Compare commits

...
12 Commits
Author SHA1 Message Date
prosolis 6e2782ac48 adventure: honor death-cheats on a cantrip kill and narrate the cantrip
A caster's at-will cantrip that lands the killing blow returned via a raw
enemyHP<=0 read, skipping enemyDown -- so a survive_at_1 boss or T6 Valdris's
phylactery rebirth died instead of cheating death. Route it through enemyDown,
matching resolvePlayerAttack and the concentration-tick path.

Also add the missing renderEvent "cantrip" case: the CantripDesc narration
hook was set but never rendered, so cantrip damage dropped enemy HP with no
log line.
2026-07-17 10:51:33 -07:00
prosolis 7e59697754 adventure: tell Pete whether a backpack item beats what's worn
For each backpack magic item, compute the per-stat diff against whatever
occupies the slot it would equip into (mi.Slot — the same slot the web
Equip button targets, so the card describes the trade that actually
happens). The verdict is strict dominance: all-gain is an upgrade, all-loss
a downgrade, mixed a sidegrade with no winner claimed (the case the two
opaque effect strings could never show). Empty slot reads 'new'; an
attunement item with no free bond reads 'inert', which overrides the stat
verdict because wearing it does nothing.

The diff is over tempered effects on both sides and reuses the engine's
own magicItemEffectFor, so nothing here re-derives power math that could
drift from the game. Rides an additive Compare object on the private
backpack ItemView — no migration, no endpoint, no public surface.

Rings collapse to the same path: every ring equips to ring_1 (ring_2 is
declared but never assigned by any live code), so a backpack ring simply
compares against the ring_1 occupant.
2026-07-17 10:04:18 -07:00
prosolis 22b7949791 adventure: author Pete's dispatches in his voice with the local LLM
emitFact now runs the final fact through authorDispatch, which asks the local
Ollama model for a warm-reporter headline and lede and ships them on the Fact.
Pete guards and publishes them, falling back to its own templates on anything
it rejects — so authoring is best-effort by design: LLM off, a timeout, a
malformed generation, or an over-length pair all return an empty prose pair and
Pete templates the fact. The names allowed in the prose are the fact's Actors,
built from the post-opt-out subject/opponent, so what the model may say and what
Pete's guard permits are the same list. Synchronous like the holdem tip rewrite,
but on a tight 15s budget: news facts are infrequent and a template now beats a
voiced dispatch late. With no route for Pete to call back into this box, the
voice lives in the prompt here rather than in a Pete-owned inference endpoint.
2026-07-17 09:28:13 -07:00
prosolis fbed45fc96 adventure: tell Pete when a story-grade treasure is found
A treasure that earns a public room announce now also files a treasure_found
fact, so Pete can count it on the finder's trophy case. The emit rides inside
announceTreasureToRoom, reusing the RoomAnnounce != "" gate as the newsworthiness
filter — a copper-piece pickup never becomes news, and a reversed auto-swap never
emits, because the announce it shares is cancelled on undo.

The realm's first finder of a given treasure is a priority hoard; a later finder
of the same item is a bulletin, keyed on the treasure across the realm via
claimRealmFirst, the same first/repeat split zone_first uses. The item name rides
in stakes and the tier-derived rarity in outcome.

treasure_found is a new event_type, so Pete's ingest must deploy first or the
first finds park on the retry ladder forever.
2026-07-17 09:09:23 -07:00
prosolis 7960838b3f adventure: drain Pete's equip queue and apply it for real
Poll Pete for equip/unequip orders an owner placed on the web, run them through
the real magic-item equip path so bond caps and slot eviction still hold, and
file a verdict. Same reverse pipe as mischief, with one difference that matters:
the equip action is not idempotent, so a re-offered order after a lost ack would
double-move the item. An equip_applied_orders ledger keyed on the order guid is
the guard: applied once, re-offers only re-file the stored verdict.

The delicate remove-before-equip ordering that prevents item duplication is now
one shared applyMagicEquip/applyMagicUnequip core, called by both the DM
resolver and this poller, so the anti-dup ordering can't drift between two
copies. Items now carry their inventory row id to Pete as the equip handle.
2026-07-17 08:44:44 -07:00
prosolis 32520eb7ec adventure: send Pete the fog-of-war zone map beside the room count 2026-07-17 08:18:11 -07:00
prosolis b6d4e4ccec adventure: send Pete what an item actually is, and what's worn
The self-view listed a name, a tier and a price — everything except what a
player decides on. The facts were all there, just not on the wire.

Three things the contract spec got wrong, found by reading both sides:

Equipping *moves* the row out of adventure_inventory into
magic_item_equipped, so the two sets are disjoint. The spec's `attuned` on a
backpack item can never be true — bond state isn't false there, it's
undefined. The real gap was that worn items weren't sent at all: the panel
showed the backpack and hid the sword. Hence Equipped, where Attuned means
something and an inert item can be seen.

Stat modifiers ARE modeled. The spec said they weren't, and that shipping
them meant either an engine change or a display-only approximation that lies
the first time it disagrees with the engine. But magicItemEffectSummary is
the engine's own summary — the same function the game speaks with. Sending it
can't drift, because there's nothing to drift from.

SkillSource is two different things: "mining" on masterwork gear, and the
internal "magic_item:<id>" registry pointer on magic-item rows. Sending it
raw would put gogobee's IDs on a page, and Pete couldn't tell them apart to
filter them. Only the skill name goes out.

Desc and Effect resolve at the push site because an inventory row carries
neither — descriptions live on MagicItem/EquipmentDef, and the combat delta
is computed, never stored. Shop gear resolves by (slot, tier); Name is
decorative there.

All additive and omitempty on the private /api/ingest/detail push, so neither
side has to deploy first.
2026-07-17 06:44:45 -07:00
prosolis 479f77b9c5 adventure: T6 Inversion Stitch (Seamstress) + boss re-strengthen (P8)
Second in-combat Layer-2 mechanic, reusing Amendment's round-end seam. In the
Seamstress's phase 2 (<=35% HP) the room sews inside-out in a repeating
warn(1)->sting(2) cadence, telegraphed one round ahead: during a pulse player
heals (self + ally) invert to damage, floored at 1 HP. State
inversionActive/inversionTelegraph round-trips through CombatStatuses so a
suspend/resume can't drop or double a pulse.

Sim surfaced that unplace is reach-bound (only ~37% reach the boss, then cleared
her ~98%), so the mechanic alone is sub-noise. Re-strengthened the Seamstress,
the weakest T6 boss (over-softened by P7 for a zone lift that never came):
HP 385->460, Atk 39->45, Needle Rain proc 0.40->0.45. Sim-validated at
fighter zone 37.5% (in range), and a real boss fight now instead of a victory lap.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
2026-07-16 12:14:41 -07:00
prosolis 189a44e1eb adventure: T6 Amendment (Custodian) + in-combat round-end boss seam (P8)
First in-combat Layer-2 mechanic. applyBossInCombatRoundEnd is a new
round-boundary seam called from the turn engine's stepRoundEnd, the
counterpart to the pre-combat applyBossRunModifiers. The Custodian snapshots
its HP at end of round 3 and rewinds to it once on the phase-2 crossing
(refunding front-loaded burst); a soft midnight timer past round 20 climbs
its Attack via the existing enemyAtkBuff. New state (EnemyRewindHP/Used)
round-trips through CombatStatuses so a suspend/resume can't replay the
rewind. Sim A/B (n=120 L20 party): last_meridian fighter 65->37.5% clear,
a clean -27.5pp swing attributable to the mechanic; shipped as-is per the
opt-in-endgame difficulty call.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
2026-07-16 09:20:29 -07:00
prosolis db13ed75b9 adventure: route round-end concentration kill through enemyDown (P8)
The T6 Valdris phylactery rebirth (and the pre-existing survive_at_1
one-shot) live in enemyDown, on the premise that it is the single death
chokepoint. It isn't: the round-end concentration pulse ended the fight
on a raw enemyHP<=0 read, so a cleric's Spirit Guardians landing the
lethal blow robbed a revive-armed boss of its rebirth — exactly the
cleric-party arm P8 is tuned around. Route that win-check through
enemyDown so the boss gets its chance to stand back up. Regression test
covers both the armed (revives) and charge-less (still wins) paths.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
2026-07-16 08:41:58 -07:00
prosolis 686434f8e3 adventure: T6 Phylactery Verses + stackable-rebirth engine primitive (P8)
The second Layer-2 postgame boss mechanic, and the first stateful in-combat one.
Valdris ("boss_valdris_ascendant", the Ossuary Ascendant) is bound to three
Verses hidden on the zone's secret nodes: every Verse the player finds and walks
before the fight unbinds one rebirth, every Verse they skip leaves it armed. A
full-clear explorer strips all three and fights a mortal lich; a speedrunner who
blows past the secrets fights a god who will not stay down.

New engine primitive: stackable rebirth. The stock survive_at_1 is a one-shot
1-HP stay; Valdris needs several rebirths that each restore a real pool.
combatState/CombatStatuses gain EnemyReviveCharges/EnemyReviveHP (round-tripped
through the turn engine so a suspend/resume keeps the live count), and enemyDown
consumes a charge after the survive_at_1 check, reviving to 25% of the
party-scaled max and emitting a phylactery_rebirth event. Zero for every
non-Valdris fight.

Unlike Greed Tax (a pure per-round recompute in applyBossRunModifiers), rebirths
are spent mid-fight, so the charge count is seeded ONCE at session creation
(seedBossRunStatuses, from unvisited secret Verses) and never re-derived on the
per-round enemy rebuild. Seeded from handleFightCmd after startPartyCombatSession.

Sim A/B (millenia, n=120 L20 party+Pete+pets, same binary, control neuters the
dispatch): mortal end (verses found) fighter 42.5% -- reproduces the deployed P7
ossuary baseline, confirming the mechanic doesn't touch the validated full-clear
path -- and the god end (0 verses) fighter 12.5%, a deadly-but-beatable flex arm.
The sim walks 0 verses (autopilot takes the first unlocked fork; Verses are
behind Perception locks), so the A/B brackets the whole player-agency gradient.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
2026-07-16 08:04:19 -07:00
prosolis fc9e055083 adventure: T6 postgame Layer-2 seam + Aurvandryx's Greed Tax (P8)
Add the pre-combat boss-hook seam for Tier-6 postgame bosses and the first
bespoke mechanic on it.

Seam: applyBossRunModifiers(bossID, enemy, run) in postgame_boss_hooks.go —
a pure, idempotent, bestiary-ID-dispatched hook that folds run-state-derived
adjustments into the freshly-built boss Combatant. No-op for every non-hooked
enemy or nil run. The turn engine (which resolves both prod bosses and the
sim) rebuilds the enemy every round via partyCombatantsForSession, so the hook
must be a pure function of run state — fine at a terminal boss room, where the
route is frozen. Wired at both enemy-finalization points: the per-round rebuild
and buildFightSeats' initial HP persist (threaded a run param; the caller
already had it).

Greed Tax (boss_aurvandryx / first_hoard): her Attack rises with the richness
of the route walked to reach her — the summed excess LootBias (>1.0) over the
run's visited nodes. Note run.LootCollected is the wrong signal (BossOnly
signature manifest, empty at the boss); the gilded veins on the graph are.
Attack += min(2.0*richness, 12). Same-binary A/B sweep (n=150, L20 party +
Pete + pets): taxless 66.7% -> taxed 48.0%, landing first_hoard mid-band.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
2026-07-16 07:44:10 -07:00
26 changed files with 2865 additions and 127 deletions
+15
View File
@@ -1807,6 +1807,21 @@ CREATE INDEX IF NOT EXISTS idx_mischief_target ON mischief_contracts(target_id,
CREATE INDEX IF NOT EXISTS idx_mischief_buyer ON mischief_contracts(buyer_id, created_at);
CREATE INDEX IF NOT EXISTS idx_mischief_due ON mischief_contracts(status, window_ends_at);
-- The web equip queue's idempotency ledger. Pete records an owner's equip/unequip
-- intent and we poll it; the guid stamped here is what makes a re-offered order a
-- no-op. Mischief can lean on its contract row for the same job, but an equip
-- opens no durable object of its own — worse, the underlying action is NOT
-- idempotent (equipping consumes an inventory row, unequip mints a fresh one), so
-- without this a poll loop whose verdict-ack was lost would re-run the equip and
-- double-move the item. We record the guid the instant the mutation lands and
-- short-circuit on it before touching anything on a re-offer.
CREATE TABLE IF NOT EXISTS equip_applied_orders (
guid TEXT PRIMARY KEY,
status TEXT NOT NULL, -- the terminal verdict we filed, replayed on re-offer
detail TEXT NOT NULL DEFAULT '',
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Babysitting Service
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+161 -9
View File
@@ -48,6 +48,13 @@ type Fact struct {
Milestone string `json:"milestone,omitempty"`
OccurredAt int64 `json:"occurred_at"`
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push
// Headline/Lede are LLM-authored prose for this fact, both optional. Pete
// prefers them over its own template when present and past its prose-guard,
// and falls back to the template otherwise — so an empty pair (LLM off, or
// authoring failed) is the normal, safe case. Populated by emitFact; see
// authorDispatch. Names in the prose must come only from Actors.
Headline string `json:"headline,omitempty"`
Lede string `json:"lede,omitempty"`
}
// Config controls the seam. Enabled=false makes Emit a durable no-op (nothing
@@ -266,9 +273,39 @@ type RosterDetail struct {
Modifiers [6]int `json:"modifiers"` // matching ability modifiers
Gear []GearItem `json:"gear,omitempty"`
// Expedition context, present only while on a run.
Supplies int `json:"supplies,omitempty"`
ThreatLevel int `json:"threat_level,omitempty"`
Room string `json:"room,omitempty"`
Supplies int `json:"supplies,omitempty"`
ThreatLevel int `json:"threat_level,omitempty"`
Room string `json:"room,omitempty"`
Map *RosterMap `json:"map,omitempty"`
}
// RosterMap is the fog-of-war cut of an adventurer's zone graph: every node
// they have visited, plus the one-hop frontier of doors leading out of visited
// nodes, with the rooms behind those doors withheld. It is per-adventurer and
// rides the roster push beside Room. Only ids and kinds cross the wire — a
// ZoneNode's Label and Content (encounter, loot bias, narration) are spoilers
// and never leave the game box. Frontier nodes carry kind "unknown".
type RosterMap struct {
ZoneID string `json:"zone_id"`
CurrentNode string `json:"current_node"`
Visited []string `json:"visited"`
Nodes []RosterMapNode `json:"nodes"`
Edges []RosterMapEdge `json:"edges"`
}
// RosterMapNode is one room reduced to what a public map may show.
type RosterMapNode struct {
ID string `json:"id"`
Kind string `json:"kind"` // ZoneNodeKind, or "unknown" for an unreached frontier room
}
// RosterMapEdge is one directed passage. Lock names the gate kind
// (perception_check, key_required, ...) so the map can mark a door as barred;
// LockData and Hint stay behind on the game box.
type RosterMapEdge struct {
From string `json:"from"`
To string `json:"to"`
Lock string `json:"lock,omitempty"`
}
// GearItem is one equipped piece for the armor/gear panel.
@@ -348,17 +385,79 @@ type PlayerDetail struct {
Token string `json:"token"`
Inventory []ItemView `json:"inventory,omitempty"`
Vault []ItemView `json:"vault,omitempty"`
Equipped []ItemView `json:"equipped,omitempty"`
House HouseView `json:"house"`
Pets []PetView `json:"pets,omitempty"`
}
// ItemView is one backpack or vault item for the private inventory panel.
// ItemView is one item in the private panels — backpack, vault, or worn.
//
// Slot/SkillSource/Desc/Effect are display resolutions done at the push site,
// because an adventure_inventory row carries none of them: descriptions live on
// MagicItem/EquipmentDef, and the combat delta is computed, never stored.
//
// SkillSource is only the player-facing skill a masterwork piece draws on
// ("mining"). Inventory rows smuggle "magic_item:<id>" through the same column
// as an internal registry pointer; that is not a fact about the item and never
// goes on the wire.
//
// Attunement (does it need a bond) and Attuned (does it have one) are distinct:
// with a hard cap of 3 bonds, a worn item can sit inert, and a player deciding
// what to wear needs to see the difference.
type ItemView struct {
Name string `json:"name"`
Type string `json:"type"`
Tier int `json:"tier"`
Value int64 `json:"value"`
Temper int `json:"temper,omitempty"`
// ID is the adventure_inventory row id, sent only for a backpack item the
// magic-item equip path will accept — so a non-zero ID is also the signal that
// this item can be equipped from the web. Worn and vault rows carry none: a
// worn item unequips by slot, and a vault item can't be equipped at all. Pete
// round-trips this id in an equip order; the table is AUTOINCREMENT, so a stale
// id (item already moved) misses cleanly rather than hitting the wrong row.
ID int64 `json:"id,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
Tier int `json:"tier"`
Value int64 `json:"value"`
Temper int `json:"temper,omitempty"`
Slot string `json:"slot,omitempty"`
SkillSource string `json:"skill_source,omitempty"`
Desc string `json:"desc,omitempty"`
Effect string `json:"effect,omitempty"`
Attunement bool `json:"attunement,omitempty"`
Attuned bool `json:"attuned,omitempty"`
// Compare pairs a backpack magic item against whatever is worn in the slot it
// would equip into, so the owner can tell an upgrade from a sidegrade without
// eyeballing two opaque effect strings. Set only on backpack magic items (the
// ones that carry an equip ID); worn and vault rows never have it. Owner-private,
// rides detail_json — no migration, no public surface.
Compare *ItemCompare `json:"compare,omitempty"`
}
// ItemCompare is the per-stat verdict for equipping a backpack magic item over
// what is currently worn in its slot. gogobee computes it (the power math folds
// in tempering and bond availability, which live in the engine); Pete only
// renders it and does no arithmetic.
type ItemCompare struct {
// Verdict is one of: upgrade, downgrade, sidegrade, same, new, inert.
// upgrade every changed stat a gain
// downgrade every changed stat a loss
// sidegrade mixed — some better, some worse; no winner claimed
// same no stat differs
// new the target slot is empty; equipping fills it
// inert needs a bond and none is free — wearing it would do nothing
Verdict string `json:"verdict"`
// VsName is the worn item being replaced; "" when Verdict is new (empty slot).
VsName string `json:"vs_name,omitempty"`
// VsSlot is the slot the item would land in (e.g. "ring_1").
VsSlot string `json:"vs_slot,omitempty"`
// Deltas is one entry per changed stat, each flagged better/worse. Engine-
// rendered player-facing text; Pete draws arrows off Better and does no math.
Deltas []ItemDelta `json:"deltas,omitempty"`
}
// ItemDelta is one stat's change between the candidate item and the worn item.
type ItemDelta struct {
Label string `json:"label"`
Better bool `json:"better"`
Text string `json:"text"`
}
// HouseView is the owner's housing summary.
@@ -568,6 +667,59 @@ func ClaimMischief(ctx context.Context, guid, status, detail string) error {
return std.post(ctx, "/api/mischief/claim", payload)
}
// ---------------------------------------------------------------------------
// The equip queue's reverse pipe
//
// An owner asks, on their own detail page, to wear or take off an item. Pete
// records the intent; we poll for it, run the real equip against our own
// equipment tables, and file a verdict. Same shape as mischief — Pete has no
// route in — but with one crucial difference: the game action is NOT naturally
// idempotent (equipping consumes an inventory row and regenerates it on
// unequip), so re-running a drained order would double-move items. The poller
// therefore short-circuits on the order guid before it mutates, the way
// placeWebMischief does on its contract; the guid is still the end-to-end key,
// but here it guards a non-idempotent action rather than riding a naturally
// idempotent one.
// ---------------------------------------------------------------------------
// EquipOrder is one equip/unequip as Pete describes it. owner_localpart is the
// Matrix localpart of the character to dress; item_id is the adventure_inventory
// row id for an equip (0 for an unequip, which keys on slot). character_name and
// item_name are display copy Pete froze at order time; we don't need them.
type EquipOrder struct {
GUID string `json:"guid"`
OwnerLocalpart string `json:"owner_localpart"`
ItemID int64 `json:"item_id"`
ItemName string `json:"item_name"`
Slot string `json:"slot"`
Action string `json:"action"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
}
// PendingEquip asks Pete for equip orders waiting on us. A Pete predating the
// queue answers 404, surfaced here as an error the poll loop logs quietly.
func PendingEquip(ctx context.Context) ([]EquipOrder, error) {
if !Enabled() {
return nil, nil
}
var out []EquipOrder
if err := std.getJSON(ctx, "/api/equip/pending", &out); err != nil {
return nil, err
}
return out, nil
}
// VerdictEquip files our verdict on an equip order. Idempotent on Pete, so a
// retried verdict is safe; the verdict rides this call directly.
func VerdictEquip(ctx context.Context, guid, status, detail string) error {
payload, err := json.Marshal(map[string]string{"guid": guid, "status": status, "detail": detail})
if err != nil {
return err
}
return std.post(ctx, "/api/equip/verdict", payload)
}
// getJSON does a bearer-authed GET and decodes the body.
func (c *Client) getJSON(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil)
+5
View File
@@ -291,6 +291,7 @@ func (p *AdventurePlugin) Init() error {
go p.expeditionBoredomTicker()
go p.mischiefTicker()
go p.peteMischiefTicker()
go p.peteEquipTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns()
@@ -1382,6 +1383,10 @@ func (p *AdventurePlugin) announceTreasureToRoom(char *AdventureCharacter, def *
if def == nil || def.RoomAnnounce == "" {
return
}
// The same story-grade gate feeds Pete's trophy case. Emit before the
// games-room check so a find is still recorded as news even when no room is
// configured to announce it in.
emitTreasureFound(char.UserID, def, loc)
gr := gamesRoom()
if gr == "" {
return
+3 -3
View File
@@ -97,7 +97,7 @@ func TestBuildFightSeats_ConsumesTheAbilityOnceAndCarriesItOnTheSeat(t *testing.
ragingBerserker(t, uid)
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" {
t.Fatalf("fight refused: %s", refusal)
}
@@ -125,7 +125,7 @@ func TestBuildZoneCombatants_RebuildKeepsTheRageForTheWholeFight(t *testing.T) {
ragingBerserker(t, uid)
p := &AdventurePlugin{}
seats, _, _, refusal := p.buildFightSeats(uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
seats, _, _, refusal := p.buildFightSeats(uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" {
t.Fatalf("fight refused: %s", refusal)
}
@@ -171,7 +171,7 @@ func TestBuildFightSeats_SatOutMemberKeepsTheirArmedAbility(t *testing.T) {
}
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
leader, []id.UserID{leader, downed}, dndBestiary["goblin"], 1, 0)
leader, []id.UserID{leader, downed}, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" {
t.Fatalf("fight refused: %s", refusal)
}
+12 -1
View File
@@ -97,7 +97,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
// Seat the whole party, leader first. A solo player is a one-seat roster and
// takes the path they always took: one build, one INSERT, no participant rows.
seats, enemy, senderSkip, refusal := p.buildFightSeats(ctx.Sender, roster, monster, int(zone.Tier), run.DMMood)
seats, enemy, senderSkip, refusal := p.buildFightSeats(ctx.Sender, roster, monster, int(zone.Tier), run.DMMood, run)
if refusal != "" {
return p.replyDM(ctx, refusal)
}
@@ -119,6 +119,17 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
}
// Layer-2 boss state that is spent mid-fight (Valdris's Phylactery Verses)
// is seeded onto the fresh session ONCE, from the route the player walked to
// get here — not on the per-round rebuild, which would resurrect spent
// rebirths. enemyHP is the party-scaled pool persisted above. No-op for every
// non-hooked enemy.
if seedBossRunStatuses(sess, monster.ID, enemyHP, run) {
if err := saveCombatSession(sess); err != nil {
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
}
}
var b strings.Builder
if isBoss {
if line := composeBossEntry(zone.ID, run.RunID, run.CurrentRoom); line != "" {
+79
View File
@@ -181,6 +181,20 @@ type CombatModifiers struct {
SpellPreDamage int
SpellPreDamageDesc string
SpellEnemySkipFirst bool
// At-will cantrip channel. Arcane blasters (Mage/Sorcerer/Warlock) throw a
// scaling damage cantrip EVERY round — 5e cantrips are the caster's at-will
// floor and scale to 4 dice at L17 (Fire Bolt 4d10, Eldritch Blast 4 beams).
// The pre-combat one-shot SpellPreDamage modelled a single leveled cast and
// left the caster swinging a stick for the rest of the fight; that is the
// whole of the caster T5-room wall (one burst can't finish a 65-HP monster
// and the quarterstaff floor does nothing). CantripPerRound is dealt as flat
// magic damage at the top of resolvePlayerSwings each round — no dice roll,
// so the RNG stream is stable and variance stays low. 0 for non-casters, so
// martial combat is byte-identical. Halved by enemy spell_resist like any
// spell. CantripDesc is the narration hook (spell name).
CantripPerRound int
CantripDesc string
}
type Combatant struct {
@@ -428,6 +442,14 @@ type combatState struct {
enemyRegen int // regenerate: enemy heals this much each round end
enemySurviveArmed bool // survive_at_1: enemy cheats death once, dropping to 1 HP
// Phylactery Verses (T6 Valdris) — stackable rebirth. enemyReviveCharges is
// how many times the boss still cheats death; each revives it to
// enemyReviveHP. Seeded once at fight start from unfound Verses, round-tripped
// through CombatStatuses. Distinct from enemySurviveArmed (a one-shot 1-HP
// proc): a rebirth restores a meaningful pool, and there can be several.
enemyReviveCharges int
enemyReviveHP int
// Phase 13 bestiary slice 4 — the former flavor-only placeholders, now
// backed by real state.
enemySpellResist bool // spell_resist: player spell damage against this enemy is halved
@@ -435,6 +457,25 @@ type combatState struct {
enemyFearImmune bool // fear_immune: player control spells (enemy-skip) fizzle against this enemy
enemyAtkBuff int // ally_buff: flat, accumulating bonus to the enemy's attack damage
// Amendment (T6 Custodian) — an in-combat Layer-2 hook resolved at round end
// by applyBossInCombatRoundEnd. enemyRewindHP is the boss's round-3 HP
// snapshot (0 until captured); enemyRewindUsed gates the once-only rewind
// that restores it to that snapshot when the boss crosses into phase 2. The
// soft midnight timer past round 20 rides enemyAtkBuff. Round-tripped through
// CombatStatuses; zero/false for every non-Custodian fight.
enemyRewindHP int
enemyRewindUsed bool
// Inversion Stitch (T6 Seamstress) — an in-combat Layer-2 hook resolved at
// round end by applyBossInCombatRoundEnd, live only in the boss's phase 2.
// inversionActive is the number of rounds the room stays sewn inside-out
// (heals sting instead of mend, gated in stepPlayerActionEffect); it counts
// down one per round end. inversionTelegraph warns the round before a pulse
// activates, so a player who watches the tell can hold their heals. Both
// round-trip through CombatStatuses; zero/false for every non-Seamstress fight.
inversionActive int
inversionTelegraph bool
round int
events []CombatEvent
@@ -541,6 +582,31 @@ func maybeTriggerOrcRage(st *combatState, player *Combatant, phaseName string) {
// consumes once-per-fight openers (AutoCritFirst, FirstAttackBonus,
// AssassinateAdvantage) via st flags — extras roll vanilla.
func resolvePlayerSwings(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
// At-will cantrip: fires once per round before the weapon swing, independent
// of whether the swing connects (a caster who whiffs the stick still throws
// its Fire Bolt). Flat magic damage, halved by spell_resist. 0 for
// non-casters → skipped entirely, so martial combat draws no extra events
// and no RNG. See CantripPerRound in CombatModifiers.
if player.Mods.CantripPerRound > 0 && st.enemyHP > 0 {
dmg := player.Mods.CantripPerRound
if enemyResistsSpells(enemy, st) {
dmg = max(1, dmg/2)
}
st.enemyHP = max(0, st.enemyHP-dmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phase.Name, Actor: "player", Action: "cantrip",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: player.Mods.CantripDesc,
})
// Route the kill through enemyDown, not a raw HP read: a boss that cheats
// death (survive_at_1) or holds a phylactery rebirth (T6 Valdris) must get
// that chance even when the lethal blow is the at-will cantrip. enemyDown
// restores its HP and returns false, so the weapon swing below resolves
// against the revived pool. Mirrors resolvePlayerAttack's own kill routing.
if enemyDown(st, phase.Name) {
return true
}
}
if resolvePlayerAttack(st, player, enemy, phase, result) {
return true
}
@@ -1308,6 +1374,19 @@ func enemyDown(st *combatState, phaseName string) bool {
})
return false
}
// Phylactery Verses (T6 Valdris): a stackable rebirth. Each unfound Verse
// left one of these charges, and each restores a real pool rather than the
// 1-HP stay above — a full-clear explorer stripped them all and fights a
// mortal, a skip-route fights a god who keeps getting back up.
if st.enemyReviveCharges > 0 {
st.enemyReviveCharges--
st.enemyHP = max(1, st.enemyReviveHP)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "phylactery_rebirth",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
return false
}
return true
}
+71
View File
@@ -242,6 +242,10 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
case "concentration_tick":
return fmt.Sprintf(pickRand(narrativeConcentrationTick), e.Damage)
case "cantrip":
// e.Desc is the spell name (Fire Bolt / Eldritch Blast); e.Damage the hit.
return fmt.Sprintf(pickRand(narrativeCantrip), e.Desc, e.Damage)
case "pet_deflect":
return pickRand(narrativePetDeflect)
@@ -331,6 +335,18 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
return pickRand(narrativeSurviveArmed)
case "survive_at_1":
return pickRand(narrativeSurvive)
case "phylactery_rebirth":
return pickRand(narrativePhylacteryRebirth)
case "amendment_rewind":
return pickRand(narrativeAmendmentRewind)
case "midnight_toll":
return fmt.Sprintf(pickRand(narrativeMidnightToll), e.Damage)
case "inversion_telegraph":
return pickRand(narrativeInversionTelegraph)
case "inversion_stitch":
return pickRand(narrativeInversionStitch)
case "heal_inverted":
return fmt.Sprintf(pickRand(narrativeHealInverted), e.Damage)
case "stat_drain":
return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage)
case "debuff":
@@ -546,6 +562,15 @@ var narrativeConcentrationTick = []string{
"🌀 The enemy steps wrong and the standing magic answers, %d damage. It does not move on.",
}
// narrativeCantrip fires each round an arcane blaster throws its at-will cantrip
// (Fire Bolt / Eldritch Blast) before the weapon swing. %s is the spell name,
// %d the damage — a caster's sustained floor, so it lands every round.
var narrativeCantrip = []string{
"✨ %s streaks out and burns home — %d damage. The at-will floor never stops.",
"✨ A bolt of %s answers before the staff even moves, scorching for %d.",
"✨ %s lances the enemy for %d. No incantation, no wind-up — just the steady arcane drum.",
}
var narrativePetDeflect = []string{
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.",
"🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",
@@ -761,6 +786,52 @@ var narrativeSurvive = []string{
"🕯️ The enemy by all rights should be down. It is, instead, very barely up.",
}
// narrativePhylacteryRebirth fires when Valdris burns a Verse the player left
// un-found: a bound rebirth spends and the lich reassembles. Each line reads as
// "you skipped one of these" so the mechanic teaches itself over a wipe.
var narrativePhylacteryRebirth = []string{
"💀 The lich comes apart — and a Verse you never found sings him back together. He rises, unhurried.",
"💀 Bone-dust swirls up off the floor and re-seats itself. A rebirth you didn't unbind just spent itself. He stands.",
"💀 That should have been the end of him. A Verse still hums somewhere in the cathedral, and Valdris simply *begins again*.",
}
// narrativeAmendmentRewind fires when the Custodian rewinds itself to its round-3
// HP snapshot — the once-only Amendment. Each line reads as time being undone so
// the mechanic (front-loaded burst is partly refunded) teaches itself over a run.
var narrativeAmendmentRewind = []string{
"🕰️ The Custodian raises a hand and *edits the last few minutes out of the record.* Wounds close in reverse; the clock-golem stands as it did rounds ago.",
"🕰️ \"That entry is amended.\" The damage you dealt simply un-happens — the Custodian rewinds to where it was and resumes, unhurried.",
"🕰️ Verdigris rings spin backward. Time you spent hurting it is refunded to the golem; it returns to its round-three self and keeps working.",
}
// narrativeMidnightToll fires past round 20 — the soft closing-time timer, the
// Custodian's Attack climbing each round a stalled fight refuses to end.
var narrativeMidnightToll = []string{
"🔔 A bell tolls somewhere above. Closing time — the Custodian's swings come harder. (+%d attack)",
"🔔 The hour is nearly spent, and so is its patience; each blow lands with more weight now. (+%d attack)",
}
// narrativeInversionTelegraph fires one round before an Inversion Stitch pulse —
// the Seamstress's tell. It reads as a warning so a player learns to hold heals.
var narrativeInversionTelegraph = []string{
"🧵 The Seamstress draws a thread taut and the room *shivers* — walls flexing toward inside-out. Whatever you were about to mend, hold it. (next round, healing turns against you)",
"🧵 A seam in the air puckers. The geometry is about to flip; a cure cast into it will run backward. (inversion incoming next round)",
}
// narrativeInversionStitch fires when a pulse activates — the room is sewn
// inside-out and healing now wounds for the pulse's duration.
var narrativeInversionStitch = []string{
"🧵 The stitch pulls through. The room is inside-out now — for a moment, to heal is to hurt.",
"🧵 Everything turns wrong-way-round. Mending and wounding have swapped ends of the needle.",
}
// narrativeHealInverted fires each time a heal lands during an active pulse: the
// cure runs backward and stings instead. Teaches the mechanic on the spot.
var narrativeHealInverted = []string{
"🧵 The heal runs backward through the inverted room — the cure opens the wound it meant to close. (%d damage)",
"🧵 Healing turns against its target in the sewn-inside-out air; the mend lands as a sting. (%d damage)",
}
var narrativeStatDrain = []string{
"🩸 The enemy saps your strength — your swings feel heavier, weaker. (-%d hit damage)",
"🩸 Something drains out of your limbs. Your hits won't bite as deep now. (-%d damage)",
+8 -1
View File
@@ -51,7 +51,7 @@ func fightRoster(sender id.UserID) []id.UserID {
// The enemy is built once. Every seat's build derives the identical stat block
// from (monster, tier, dmMood); only the player half varies.
func (p *AdventurePlugin) buildFightSeats(
sender id.UserID, roster []id.UserID, monster DnDMonsterTemplate, tier, dmMood int,
sender id.UserID, roster []id.UserID, monster DnDMonsterTemplate, tier, dmMood int, run *DungeonRun,
) (seats []CombatSeatSetup, enemy *Combatant, senderSkip, refusal string) {
skip := func(uid id.UserID, why string) {
if uid == sender {
@@ -157,6 +157,13 @@ func (p *AdventurePlugin) buildFightSeats(
// actually seated — a member who was skipped (downed, busy elsewhere) never
// joined the fight and must not be charged to the enemy.
applySeatWeights(seatCombatants(seats), levels, companions)
// Fold in any Layer-2 pre-combat boss mechanic before this enemy is used to
// persist the initial HP pool. partyCombatantsForSession re-applies the same
// modifier on every round's rebuild; doing it here keeps the persisted stat
// block consistent with the fight the engine will actually run. No-op for
// every non-hooked enemy.
applyBossRunModifiers(monster.ID, enemy, run)
return seats, enemy, senderSkip, ""
}
+5 -5
View File
@@ -109,7 +109,7 @@ func TestBuildFightSeats_SoloSeatsExactlyThePlayer(t *testing.T) {
fightTestChar(t, solo, 30)
seats, enemy, skip, refusal := (&AdventurePlugin{}).buildFightSeats(
solo, []id.UserID{solo}, dndBestiary["goblin"], 1, 0)
solo, []id.UserID{solo}, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" || skip != "" {
t.Fatalf("solo fight refused: %s / %s", refusal, skip)
}
@@ -140,7 +140,7 @@ func TestBuildFightSeats_DownedMemberSitsOut(t *testing.T) {
roster := []id.UserID{leader, downed, standing}
seats, _, skip, refusal := (&AdventurePlugin{}).buildFightSeats(
leader, roster, dndBestiary["goblin"], 1, 0)
leader, roster, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" {
t.Fatalf("party refused over a downed member: %s", refusal)
}
@@ -156,7 +156,7 @@ func TestBuildFightSeats_DownedMemberSitsOut(t *testing.T) {
// The one who was left behind typed `!fight` too, and silence is not an answer.
_, _, skip, refusal = (&AdventurePlugin{}).buildFightSeats(
downed, roster, dndBestiary["goblin"], 1, 0)
downed, roster, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" {
t.Fatalf("a downed member must not refuse the party's fight: %s", refusal)
}
@@ -176,7 +176,7 @@ func TestBuildFightSeats_DownedLeaderRefusesTheFightForEveryone(t *testing.T) {
roster := []id.UserID{leader, member}
p := &AdventurePlugin{}
seats, _, _, refusal := p.buildFightSeats(leader, roster, dndBestiary["goblin"], 1, 0)
seats, _, _, refusal := p.buildFightSeats(leader, roster, dndBestiary["goblin"], 1, 0, nil)
if len(seats) != 0 || refusal == "" {
t.Fatalf("downed leader seated %d players, refusal %q", len(seats), refusal)
}
@@ -184,7 +184,7 @@ func TestBuildFightSeats_DownedLeaderRefusesTheFightForEveryone(t *testing.T) {
t.Errorf("the leader should be told to rest, got %q", refusal)
}
_, _, _, refusal = p.buildFightSeats(member, roster, dndBestiary["goblin"], 1, 0)
_, _, _, refusal = p.buildFightSeats(member, roster, dndBestiary["goblin"], 1, 0, nil)
if !strings.Contains(refusal, "leader") {
t.Errorf("the member should be told it is the leader holding things up, got %q", refusal)
}
+32
View File
@@ -215,12 +215,44 @@ type CombatStatuses struct {
EnemyRegen int `json:"enemy_regen,omitempty"`
EnemySurviveArmed bool `json:"enemy_survive_armed,omitempty"`
// Phylactery Verses (Tier-6 postgame, Valdris Ascendant). Unlike the
// proc-armed EnemySurviveArmed one-shot, this is a *count* of rebirths seeded
// once at fight start (seedBossRunStatuses) from how many of the zone's secret
// Verses the player left un-found. Each consumed rebirth (enemyDown) revives
// the boss to EnemyReviveHP. Both fields are frozen at seed time except
// EnemyReviveCharges, which decrements as rebirths are spent — so they must
// round-trip through combatState to survive a suspend/resume. Zero for every
// other enemy, so omitempty keeps them off every non-Valdris row.
EnemyReviveCharges int `json:"enemy_revive_charges,omitempty"`
EnemyReviveHP int `json:"enemy_revive_hp,omitempty"`
// Slice-4 monster-ability effects — the former flavor-only placeholders.
// EnemyRevealNext is a one-shot; the other three persist for the fight.
EnemySpellResist bool `json:"enemy_spell_resist,omitempty"`
EnemyRevealNext bool `json:"enemy_reveal_next,omitempty"`
EnemyFearImmune bool `json:"enemy_fear_immune,omitempty"`
EnemyAtkBuff int `json:"enemy_atk_buff,omitempty"`
// Amendment (Tier-6 postgame, The Custodian of the Last Hour). An in-combat
// Layer-2 hook resolved at round end (applyBossInCombatRoundEnd), not proc-
// armed: EnemyRewindHP snapshots the boss's HP at the end of round 3 (0 until
// captured); when the boss then crosses into phase 2 the hook restores it to
// that snapshot exactly once and sets EnemyRewindUsed. Both round-trip through
// combatState so the once-only rewind survives a suspend/resume. Zero/false
// for every other enemy. The soft midnight timer past round 20 rides the
// existing EnemyAtkBuff, so it needs no field of its own.
EnemyRewindHP int `json:"enemy_rewind_hp,omitempty"`
EnemyRewindUsed bool `json:"enemy_rewind_used,omitempty"`
// Inversion Stitch (Tier-6 postgame, The Seamstress). An in-combat Layer-2
// hook resolved at round end (applyBossInCombatRoundEnd), live only in the
// boss's phase 2. InversionActive is the rounds-remaining of the inside-out
// pulse during which player heals sting instead of mend (gated in
// stepPlayerActionEffect); InversionTelegraph is the one-round warning before
// a pulse activates. Both round-trip through combatState so a suspend/resume
// can't lose or replay a pulse mid-fight. Zero/false for every other enemy.
InversionActive int `json:"inversion_active,omitempty"`
InversionTelegraph bool `json:"inversion_telegraph,omitempty"`
}
// applyBuffDelta folds one resolved buff (the result of a !cast / !consume
+7
View File
@@ -211,6 +211,13 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
// until every seat is built.
applySeatWeights(players, levels, companions)
// Layer-2 pre-combat boss mechanics: fold in any run-state-derived
// adjustment (e.g. Aurvandryx's Greed Tax) before the party HP scaling. This
// is re-derived every round like everything else here; its inputs are frozen
// for a terminal boss fight, so the result is stable. No-op for every
// non-hooked enemy.
applyBossRunModifiers(monster.ID, &enemy, run)
// Party-only enemy HP bump, re-derived each turn from the template so it never
// compounds. Matches the scalar startPartyCombatSession used for the initial
// persist; solo (one seat, weight 1) scales by 1.0.
+66 -14
View File
@@ -363,12 +363,20 @@ func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatan
enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac,
enemyRegen: sess.Statuses.EnemyRegen,
enemySurviveArmed: sess.Statuses.EnemySurviveArmed,
enemyReviveCharges: sess.Statuses.EnemyReviveCharges,
enemyReviveHP: sess.Statuses.EnemyReviveHP,
// Slice-4 monster-ability effects — the former flavor-only placeholders.
enemySpellResist: sess.Statuses.EnemySpellResist,
enemyRevealNext: sess.Statuses.EnemyRevealNext,
enemyFearImmune: sess.Statuses.EnemyFearImmune,
enemyAtkBuff: sess.Statuses.EnemyAtkBuff,
rng: rng,
// Amendment (T6 Custodian) — round-3 snapshot + once-only rewind.
enemyRewindHP: sess.Statuses.EnemyRewindHP,
enemyRewindUsed: sess.Statuses.EnemyRewindUsed,
// Inversion Stitch (T6 Seamstress) — phase-2 heal-inverting pulses.
inversionActive: sess.Statuses.InversionActive,
inversionTelegraph: sess.Statuses.InversionTelegraph,
rng: rng,
}
order := turnOrder(sess, sess.Round, players, enemy)
sess.Statuses.TurnIdx = turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)
@@ -568,10 +576,22 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
st.enemyHP = max(0, st.enemyHP-enemyDmg)
}
if eff.PlayerHeal > 0 {
// Respect any max_hp_drain monster ability — a drained player can't be
// healed back past the lowered ceiling.
hpCap := max(1, st.hpMax-st.maxHPDrain)
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
if st.inversionActive > 0 {
// Inversion Stitch (T6 Seamstress phase 2): the room is sewn inside-out,
// so the cure lands as a wound. Floored at 1 so a player is never killed
// by their own heal — the Seamstress's own blows do the finishing; the
// sting just denies the sustain and softens the seat for them.
st.playerHP = max(1, st.playerHP-eff.PlayerHeal)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "heal_inverted",
Damage: eff.PlayerHeal, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
} else {
// Respect any max_hp_drain monster ability — a drained player can't be
// healed back past the lowered ceiling.
hpCap := max(1, st.hpMax-st.maxHPDrain)
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
}
}
// §1 — heal somebody else. The caster's cursor stays where it is; only the
// target's HP moves.
@@ -582,14 +602,27 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
// path depends on. Healing keeps people up; it does not bring them back.
if eff.AllyHeal > 0 && eff.AllySeat >= 0 && eff.AllySeat < len(st.actors) {
if tgt := st.actors[eff.AllySeat]; tgt.playerHP > 0 {
cap := max(1, tgt.hpMax-tgt.maxHPDrain)
before := tgt.playerHP
tgt.playerHP = min(cap, tgt.playerHP+eff.AllyHeal)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "ally_heal",
Damage: tgt.playerHP - before, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP,
Seat: eff.AllySeat, Desc: eff.Label,
})
if st.inversionActive > 0 {
// Inversion Stitch: the ally-heal wounds the friend it was meant to
// mend. Floored at 1 like the self-heal sting above — the sting denies
// the sustain, it does not kill.
before := tgt.playerHP
tgt.playerHP = max(1, tgt.playerHP-eff.AllyHeal)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "heal_inverted",
Damage: before - tgt.playerHP, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP,
Seat: eff.AllySeat, Desc: eff.Label,
})
} else {
cap := max(1, tgt.hpMax-tgt.maxHPDrain)
before := tgt.playerHP
tgt.playerHP = min(cap, tgt.playerHP+eff.AllyHeal)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "ally_heal",
Damage: tgt.playerHP - before, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP,
Seat: eff.AllySeat, Desc: eff.Label,
})
}
}
}
// Arm / replace the concentration aura. A new concentration cast overwrites
@@ -840,7 +873,12 @@ func (te *turnEngine) stepRoundEnd() {
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "player", Action: "concentration_tick",
Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
})
if st.enemyHP <= 0 {
// Route the kill through enemyDown, not a raw HP read: a boss that cheats
// death (survive_at_1) or holds a phylactery rebirth (T6 Valdris) must get
// that chance even when the lethal blow is a lingering concentration pulse.
// enemyDown restores its HP and returns false, so the next seat's pulse (or
// the following round) resolves against the revived pool.
if enemyDown(st, CombatPhaseRoundEnd) {
te.finish(CombatStatusWon)
return
}
@@ -872,6 +910,14 @@ func (te *turnEngine) stepRoundEnd() {
Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
// Tier-6 in-combat Layer-2 boss hooks (Amendment): round-3 HP snapshot +
// once-only phase-2 rewind + soft midnight timer, resolved on the round that
// just finished. A no-op for every enemy but the hooked bosses, and only
// while the enemy still stands, so it is safe to call unconditionally here
// after the round's damage has settled.
if st.enemyHP > 0 {
applyBossInCombatRoundEnd(st, te.sess.EnemyID, te.enemy.Stats.MaxHP)
}
st.round++
// Initiative is re-rolled each round, so the next round's order is derived
// here — off st.round, since commit has not yet pushed it onto the session.
@@ -926,10 +972,16 @@ func (te *turnEngine) commit() {
s.EnemyRetaliateFrac = st.enemyRetaliateFrac
s.EnemyRegen = st.enemyRegen
s.EnemySurviveArmed = st.enemySurviveArmed
s.EnemyReviveCharges = st.enemyReviveCharges
s.EnemyReviveHP = st.enemyReviveHP
s.EnemySpellResist = st.enemySpellResist
s.EnemyRevealNext = st.enemyRevealNext
s.EnemyFearImmune = st.enemyFearImmune
s.EnemyAtkBuff = st.enemyAtkBuff
s.EnemyRewindHP = st.enemyRewindHP
s.EnemyRewindUsed = st.enemyRewindUsed
s.InversionActive = st.inversionActive
s.InversionTelegraph = st.inversionTelegraph
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
}
+54
View File
@@ -275,3 +275,57 @@ func TestTurnEngine_CommitPersistsSeatZeroNotTheCursor(t *testing.T) {
t.Error("seat 1's consumed Lucky reroll leaked onto the session row")
}
}
// A lingering concentration pulse that lands the killing blow must still give a
// revive-armed boss (survive_at_1 / T6 Valdris's phylactery rebirth) its chance
// to stand back up — the round-end tick routes the kill through enemyDown, not a
// raw enemyHP<=0 read. Regression for the concentration-bypass gap found in the
// P8 Layer-2 review.
func TestTurnEngine_ConcentrationKillHonorsRebirth(t *testing.T) {
// A charged rebirth: the pulse drops the enemy, a charge spends, it revives.
sess := turnSession(CombatPhaseRoundEnd, 500, 30)
p := basePlayer()
e := baseEnemy()
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
te.st.concentrationDmg = 100 // lethal against 30 HP
te.st.enemyReviveCharges = 1
te.st.enemyReviveHP = 40
if _, err := te.step(PlayerAction{}); err != nil {
t.Fatal(err)
}
te.commit()
if !sess.IsActive() {
t.Fatalf("a concentration kill ended the fight (%q) while a rebirth charge was armed", sess.Status)
}
if sess.EnemyHP != 40 {
t.Errorf("revived EnemyHP = %d, want the 40-HP revive pool", sess.EnemyHP)
}
if sess.Statuses.EnemyReviveCharges != 0 {
t.Errorf("post-revive charges = %d, want 0 (one spent)", sess.Statuses.EnemyReviveCharges)
}
rebirths := 0
for _, ev := range sess.TurnLog {
if ev.Action == "phylactery_rebirth" {
rebirths++
}
}
if rebirths != 1 {
t.Errorf("phylactery_rebirth events = %d, want 1", rebirths)
}
// With no charge left, the same pulse ends the fight cleanly (the win path
// is not broken by the enemyDown routing).
mortal := turnSession(CombatPhaseRoundEnd, 500, 30)
p2 := basePlayer()
e2 := baseEnemy()
te2 := resumeTurnEngine(mortal, []*Combatant{&p2}, &e2, combatSessionStepRNG(mortal, enemySeat))
te2.st.concentrationDmg = 100
if _, err := te2.step(PlayerAction{}); err != nil {
t.Fatal(err)
}
te2.commit()
if mortal.Status != CombatStatusWon {
t.Errorf("charge-less concentration kill status = %q, want %q", mortal.Status, CombatStatusWon)
}
}
+144 -91
View File
@@ -1,6 +1,7 @@
package plugin
import (
"errors"
"fmt"
"log/slog"
"math/rand/v2"
@@ -559,6 +560,133 @@ func magicItemEffectSummary(mi MagicItem) string {
return strings.Join(parts, ", ")
}
// The equip mutation, message-free, shared by the DM resolver above and the web
// equip queue (pete_equip.go). Splitting it out is deliberate: the ordering below
// — evict occupant, then remove-from-inventory *before* equip, with a rollback if
// equip fails — is the whole defence against item duplication, and a second copy
// of it living in the web path is exactly where that defence would silently rot.
// One implementation, two callers.
// errItemNotEquippable is a *permanent* refusal (not a magic item, or a slotless
// curio) — the caller should reject the request, not retry it. Every other error
// from applyMagicEquip is a transient DB fault the caller may retry.
var errItemNotEquippable = errors.New("magic-item: not equippable")
// errSlotEmpty is applyMagicUnequip's permanent refusal: nothing is in that slot.
var errSlotEmpty = errors.New("magic-item: slot empty")
// magicEquipOutcome is what an equip did, for the caller to narrate. BondsBefore
// is the attunement count *after* any swap-eviction and *before* this item, so a
// caller reporting "bonded (N/3)" adds one.
type magicEquipOutcome struct {
Effective MagicItem // the item as worn, tempering folded in
SwappedBack string // name of the occupant sent back to inventory, or ""
Bonded bool // this item took a bond just now
AtCap bool // worn but inert: it wants a bond and all 3 are used
BondsBefore int // bonds in use before this item was worn
Healed []string // stragglers a freed slot let bond, post-equip
}
// applyMagicEquip wears one inventory item, preserving the anti-duplication
// ordering. It mutates the equipment tables and sends nothing.
func applyMagicEquip(userID id.UserID, it AdvItem) (magicEquipOutcome, error) {
mi, ok := magicItemFromAdvItem(it)
if !ok || mi.Slot == "" {
return magicEquipOutcome{}, errItemNotEquippable
}
equipped, err := loadEquippedMagicItems(userID)
if err != nil {
return magicEquipOutcome{}, err
}
// Return whatever occupies that slot to inventory at full value, and drop it
// from the local map so the bond count below reflects the post-swap state.
var swappedBack string
if prev, exists := equipped[mi.Slot]; exists && prev.Item.ID != "" {
back := magicItemSellAt(prev.Item, prev.Temper)
back.SkillSource = "magic_item:" + prev.Item.ID
if err := addAdvInventoryItem(userID, back); err != nil {
return magicEquipOutcome{}, err
}
swappedBack = prev.Item.Name
delete(equipped, mi.Slot)
}
bondsBefore := countAttunedMagicItems(equipped)
bonded, atCap := false, false
if mi.Attunement {
if bondsBefore >= dndMagicItemAttuneLimit {
atCap = true
} else {
bonded = true
}
}
// Remove the inventory row FIRST, then equip; if equip fails, restore the row.
// The reverse order left a transient failure with the item both worn and in the
// pack — a free duplicate.
if err := removeAdvInventoryItem(it.ID); err != nil {
slog.Error("magic-item: failed to remove from inventory before equip",
"user", userID, "item", mi.ID, "err", err)
return magicEquipOutcome{}, err
}
if err := equipMagicItem(userID, mi.Slot, mi.ID, bonded, it.Temper); err != nil {
restored := magicItemSellAt(mi, it.Temper)
restored.Value = it.Value
restored.SkillSource = "magic_item:" + mi.ID
if rbErr := addAdvInventoryItem(userID, restored); rbErr != nil {
slog.Error("magic-item: equip failed AND inventory rollback failed",
"user", userID, "item", mi.ID, "equip_err", err, "rollback_err", rbErr)
}
return magicEquipOutcome{}, err
}
// Swapping the occupant out may have freed a bond slot — light up any straggler.
healed, _ := reconcileMagicAttunements(userID)
return magicEquipOutcome{
Effective: temperedItem(mi, it.Temper),
SwappedBack: swappedBack,
Bonded: bonded,
AtCap: atCap,
BondsBefore: bondsBefore,
Healed: healed,
}, nil
}
// magicUnequipOutcome is what an unequip did, for the caller to narrate.
type magicUnequipOutcome struct {
Item MagicItem // the item taken off, as it was worn
Healed []string // stragglers the freed bond slot let bond
}
// applyMagicUnequip takes the item off a slot and returns it to inventory,
// mirroring the equip ordering (destructive op first, restore on failure). Sends
// nothing.
func applyMagicUnequip(userID id.UserID, slot DnDSlot) (magicUnequipOutcome, error) {
equipped, err := loadEquippedMagicItems(userID)
if err != nil {
return magicUnequipOutcome{}, err
}
e, ok := equipped[slot]
if !ok || e.Item.ID == "" {
return magicUnequipOutcome{}, errSlotEmpty
}
if err := unequipMagicItem(userID, slot); err != nil {
return magicUnequipOutcome{}, err
}
back := magicItemSellAt(e.Item, e.Temper)
back.SkillSource = "magic_item:" + e.Item.ID
if err := addAdvInventoryItem(userID, back); err != nil {
if rbErr := equipMagicItem(userID, slot, e.Item.ID, e.Attuned, e.Temper); rbErr != nil {
slog.Error("magic-item: unequip failed AND re-equip rollback failed",
"user", userID, "item", e.Item.ID, "inv_err", err, "rollback_err", rbErr)
}
return magicUnequipOutcome{}, err
}
healed, _ := reconcileMagicAttunements(userID)
return magicUnequipOutcome{Item: e.Effective(), Healed: healed}, nil
}
func (p *AdventurePlugin) handleEquipMagicCmd(ctx MessageContext) error {
// Self-heal first: bond any worn item stranded inert while a slot is free
// (e.g. equipped at cap, then a bond slot opened). This is the only path a
@@ -639,86 +767,31 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction
}
it := data.Items[idx]
mi, ok := magicItemFromAdvItem(it)
if !ok || mi.Slot == "" {
out, err := applyMagicEquip(ctx.Sender, it)
if errors.Is(err, errItemNotEquippable) {
return p.SendDM(ctx.Sender, "That item can't be equipped anymore.")
}
equipped, err := loadEquippedMagicItems(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your equipped magic items.")
}
// Return whatever currently occupies that slot to inventory at full
// value — swapping a curio shouldn't tax it. Evict from the local map
// too, so the attunement count below reflects the post-swap state and
// can re-open a slot the prior occupant was holding.
var swappedBackName string
if prev, exists := equipped[mi.Slot]; exists && prev.Item.ID != "" {
back := magicItemSellAt(prev.Item, prev.Temper)
back.SkillSource = "magic_item:" + prev.Item.ID
if err := addAdvInventoryItem(ctx.Sender, back); err != nil {
return p.SendDM(ctx.Sender, "Failed to return your currently-equipped item to inventory.")
}
swappedBackName = prev.Item.Name
delete(equipped, mi.Slot)
}
// Auto-attune when the item needs it and an attunement slot is free.
// Otherwise it equips inert until the player frees a slot.
attune := false
atCap := false
if mi.Attunement {
if countAttunedMagicItems(equipped) >= dndMagicItemAttuneLimit {
atCap = true
} else {
attune = true
}
}
// Remove the inventory row FIRST, then equip. If equip fails after the
// remove succeeded, restore inventory. Doing it in the other order
// meant a transient DB error on remove left the item both equipped
// *and* still in inventory — a free duplication.
if err := removeAdvInventoryItem(it.ID); err != nil {
slog.Error("magic-item: failed to remove from inventory before equip",
"user", ctx.Sender, "item", mi.ID, "err", err)
return p.SendDM(ctx.Sender, "Failed to equip that item.")
}
if err := equipMagicItem(ctx.Sender, mi.Slot, mi.ID, attune, it.Temper); err != nil {
// Roll back: try to put the item back in inventory so the player
// doesn't lose it. Best-effort; log if the rollback also fails.
restored := magicItemSellAt(mi, it.Temper)
restored.Value = it.Value
restored.SkillSource = "magic_item:" + mi.ID
if rbErr := addAdvInventoryItem(ctx.Sender, restored); rbErr != nil {
slog.Error("magic-item: equip failed AND inventory rollback failed",
"user", ctx.Sender, "item", mi.ID, "equip_err", err, "rollback_err", rbErr)
}
return p.SendDM(ctx.Sender, "Failed to equip that item.")
}
eqMI := temperedItem(mi, it.Temper)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("✨ **%s** equipped in your %s slot — %s.",
eqMI.Name, eqMI.Slot, magicItemEffectSummary(eqMI)))
if swappedBackName != "" {
sb.WriteString(fmt.Sprintf("\n📦 **%s** moved back to inventory.", swappedBackName))
out.Effective.Name, out.Effective.Slot, magicItemEffectSummary(out.Effective)))
if out.SwappedBack != "" {
sb.WriteString(fmt.Sprintf("\n📦 **%s** moved back to inventory.", out.SwappedBack))
}
switch {
case mi.Attunement && attune:
case out.Bonded:
sb.WriteString(fmt.Sprintf("\nBonded (%d/%d bond slots used).",
countAttunedMagicItems(equipped)+1, dndMagicItemAttuneLimit))
case mi.Attunement && atCap:
out.BondsBefore+1, dndMagicItemAttuneLimit))
case out.AtCap:
sb.WriteString(fmt.Sprintf("\n⚠️ All %d bond slots are full — it's worn but **inert** until you free one (`!adventure unequip-magic` to take a bonded item off; this one bonds automatically once a slot opens).",
dndMagicItemAttuneLimit))
}
// Swapping out the prior occupant may have freed a bond slot — light up any
// item that was stranded inert (including a previously-equipped one the
// picker could never reach).
if healed, _ := reconcileMagicAttunements(ctx.Sender); len(healed) > 0 {
if len(out.Healed) > 0 {
sb.WriteString(fmt.Sprintf("\n🔗 A freed bond slot also activated **%s**.",
strings.Join(healed, "**, **")))
strings.Join(out.Healed, "**, **")))
}
return p.SendDM(ctx.Sender, sb.String())
}
@@ -783,39 +856,19 @@ func (p *AdventurePlugin) resolveMagicUnequipReply(ctx MessageContext, interacti
}
slot := data.Slots[idx]
equipped, err := loadEquippedMagicItems(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your equipped magic items.")
}
e, ok := equipped[slot]
if !ok || e.Item.ID == "" {
out, err := applyMagicUnequip(ctx.Sender, slot)
if errors.Is(err, errSlotEmpty) {
return p.SendDM(ctx.Sender, "That slot is already empty.")
}
// Clear the slot FIRST, then return the item to inventory at full value.
// This mirrors the equip resolver's ordering (destructive op first, restore
// on failure): the other order could leave the item both worn and in
// inventory — a free duplicate — on a transient DB error.
if err := unequipMagicItem(ctx.Sender, slot); err != nil {
if err != nil {
return p.SendDM(ctx.Sender, "Failed to take that item off.")
}
back := magicItemSellAt(e.Item, e.Temper)
back.SkillSource = "magic_item:" + e.Item.ID
if err := addAdvInventoryItem(ctx.Sender, back); err != nil {
// Roll back: re-equip exactly as it was so the item isn't lost.
if rbErr := equipMagicItem(ctx.Sender, slot, e.Item.ID, e.Attuned, e.Temper); rbErr != nil {
slog.Error("magic-item: unequip failed AND re-equip rollback failed",
"user", ctx.Sender, "item", e.Item.ID, "inv_err", err, "rollback_err", rbErr)
}
return p.SendDM(ctx.Sender, "Failed to return that item to your inventory.")
}
mi := e.Effective()
msg := fmt.Sprintf("📦 **%s** taken off your %s slot and returned to inventory.", mi.Name, slot)
msg := fmt.Sprintf("📦 **%s** taken off your %s slot and returned to inventory.", out.Item.Name, slot)
// Freeing a bonded slot may let a worn-but-inert item finally bond.
if healed, _ := reconcileMagicAttunements(ctx.Sender); len(healed) > 0 {
if len(out.Healed) > 0 {
msg += fmt.Sprintf("\n🔗 That freed a bond slot — **%s** is now active.",
strings.Join(healed, "**, **"))
strings.Join(out.Healed, "**, **"))
}
return p.SendDM(ctx.Sender, msg)
}
+67
View File
@@ -183,6 +183,12 @@ func emitFact(f peteclient.Fact, subjectUser, opponentUser id.UserID) {
}
}
f.Actors = actors
// Author the prose in Pete's voice from the FINAL fact, so the names in the
// dispatch match the Actors allow-list Pete guards against. Best-effort: an
// empty pair (LLM off or authoring failed) just means Pete templates the
// fact. Synchronous, like the holdem tip rewrite — news facts are infrequent
// and the call is tightly bounded (dispatchLLMTimeout).
f.Headline, f.Lede = authorDispatch(f)
peteclient.Emit(f)
}
@@ -336,3 +342,64 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) {
OccurredAt: ts,
}, userID, "")
}
// treasureRarityWord maps a treasure def's tier to the rarity adjective Pete
// weaves into a find's dispatch. Story-grade treasures are typically tier 5, so
// "legendary" is the common case; the lower tiers are here for completeness.
func treasureRarityWord(tier int) string {
switch {
case tier >= 5:
return "legendary"
case tier == 4:
return "epic"
case tier == 3:
return "rare"
case tier == 2:
return "uncommon"
default:
return "common"
}
}
// emitTreasureFound files a story-grade treasure find. Only treasures carrying a
// RoomAnnounce string reach here — the same gate that earns them a public moment
// — so a copper-piece pickup never becomes news. The realm's first finder of a
// given treasure is a PRIORITY hoard; a later finder of the same item is a
// BULLETIN, the first/repeat split zone_first already uses. Character name only;
// no-op unless the seam is enabled.
//
// treasure_found is an event_type Pete's ingest must already know: an unknown
// type 400s, retries to the cap, then parks the bulletin forever. Deploy Pete
// first.
func emitTreasureFound(userID id.UserID, def *AdvTreasureDef, loc *AdvLocation) {
if !peteclient.Enabled() || !newsEmissionOn() {
return
}
if def == nil || loc == nil {
return
}
// Claim the realm-first BEFORE the name guard, so an unnamed straggler's
// genuine first find still seeds the ledger and the next finder isn't
// mis-billed as the first-ever. Mirrors emitZoneClearNews.
tier := "bulletin"
if claimRealmFirst("treasure", def.Key) {
tier = "priority"
}
name := charName(userID)
if name == "" {
return
}
ts := nowUnix()
disc := fmt.Sprintf("%s:%d", def.Key, ts)
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("treasure_found:%s:%s:%d", eventToken(userID, disc), def.Key, ts),
EventType: "treasure_found",
Tier: tier,
Subject: name,
Zone: loc.Name,
Level: charLevel(userID),
Stakes: def.Name,
Outcome: treasureRarityWord(def.Tier),
OccurredAt: ts,
}, userID, "")
}
+169
View File
@@ -0,0 +1,169 @@
package plugin
import (
"testing"
"gogobee/internal/peteclient"
)
// weapon/ring/wondrous helpers build a MagicItem the codified effect formula
// (magicItemEffectFor) can score without touching the DB.
func mkItem(kind MagicItemKind, rarity DnDRarity, slot DnDSlot) MagicItem {
return MagicItem{ID: string(kind) + "_" + string(rarity), Kind: kind, Rarity: rarity, Slot: slot}
}
// TestMagicItemDeltasDirection pins the "which way is better" call for each stat,
// including DamageReductMult where LOWER is the improvement.
func TestMagicItemDeltasDirection(t *testing.T) {
neutral := magicItemEffect{DamageReductMult: 1.0}
t.Run("more damage is better", func(t *testing.T) {
d := magicItemDeltas(magicItemEffect{DamageBonus: 0.15, DamageReductMult: 1.0}, magicItemEffect{DamageBonus: 0.10, DamageReductMult: 1.0})
if len(d) != 1 || d[0].Label != "damage" || !d[0].Better || d[0].Text != "+5% damage" {
t.Fatalf("got %+v", d)
}
})
t.Run("less damage taken is better", func(t *testing.T) {
// cand mult 0.90 (blocks 10%) vs worn neutral 1.0 (blocks nothing).
d := magicItemDeltas(magicItemEffect{DamageReductMult: 0.90}, neutral)
if len(d) != 1 || d[0].Label != "defense" || !d[0].Better || d[0].Text != "-10% damage taken" {
t.Fatalf("got %+v", d)
}
})
t.Run("weaker armor reads as worse", func(t *testing.T) {
// cand blocks less than worn: mult rises, damage taken goes up.
d := magicItemDeltas(magicItemEffect{DamageReductMult: 0.96}, magicItemEffect{DamageReductMult: 0.90})
if len(d) != 1 || d[0].Better || d[0].Text != "+6% damage taken" {
t.Fatalf("got %+v", d)
}
})
t.Run("hp and opening damage", func(t *testing.T) {
d := magicItemDeltas(
magicItemEffect{DamageReductMult: 1.0, MaxHP: 10, FlatDmgStart: 3},
magicItemEffect{DamageReductMult: 1.0, MaxHP: 6, FlatDmgStart: 5},
)
byLabel := map[string]itemDelta{}
for _, x := range d {
byLabel[x.Label] = itemDelta{x.Better, x.Text}
}
if v := byLabel["hp"]; v.text != "+4 HP" || !v.better {
t.Errorf("hp: %+v", v)
}
if v := byLabel["opening"]; v.text != "-2 opening damage" || v.better {
t.Errorf("opening: %+v", v)
}
})
t.Run("sub-percent change is dropped", func(t *testing.T) {
// 0.004 fraction = 0.4% → rounds to 0% → not a visible delta.
if d := magicItemDeltas(
magicItemEffect{DamageBonus: 0.104, DamageReductMult: 1.0},
magicItemEffect{DamageBonus: 0.100, DamageReductMult: 1.0},
); len(d) != 0 {
t.Fatalf("expected no visible delta, got %+v", d)
}
})
}
type itemDelta struct {
better bool
text string
}
// TestCompareVerdict pins strict-dominance classification and the overrides.
func TestCompareVerdict(t *testing.T) {
gain := []peteclient.ItemDelta{{Better: true}}
loss := []peteclient.ItemDelta{{Better: false}}
mixed := []peteclient.ItemDelta{{Better: true}, {Better: false}}
cases := []struct {
name string
deltas []peteclient.ItemDelta
empty, inrt bool
want string
}{
{"all gains", gain, false, false, "upgrade"},
{"all losses", loss, false, false, "downgrade"},
{"mixed", mixed, false, false, "sidegrade"},
{"no change", nil, false, false, "same"},
{"empty slot", gain, true, false, "new"},
{"inert overrides upgrade", gain, false, true, "inert"},
{"inert overrides new", gain, true, true, "inert"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := compareVerdict(c.deltas, c.empty, c.inrt); got != c.want {
t.Errorf("compareVerdict(%s) = %q, want %q", c.name, got, c.want)
}
})
}
}
// TestMagicItemCompareIntegration drives the whole builder against equipped maps.
func TestMagicItemCompareIntegration(t *testing.T) {
rareWpn := mkItem(MagicItemWeapon, RarityRare, DnDSlotMainHand) // +15% damage
uncWpn := mkItem(MagicItemWeapon, RarityUncommon, DnDSlotMainHand) // +10% damage
t.Run("upgrade over a weaker worn weapon", func(t *testing.T) {
eq := map[DnDSlot]EquippedMagicItem{DnDSlotMainHand: {Slot: DnDSlotMainHand, Item: uncWpn}}
c := magicItemCompare(rareWpn, 0, eq)
if c.Verdict != "upgrade" || c.VsName != uncWpn.Name || c.VsSlot != "main_hand" {
t.Fatalf("got %+v", c)
}
})
t.Run("downgrade under a stronger worn weapon", func(t *testing.T) {
eq := map[DnDSlot]EquippedMagicItem{DnDSlotMainHand: {Slot: DnDSlotMainHand, Item: rareWpn}}
if c := magicItemCompare(uncWpn, 0, eq); c.Verdict != "downgrade" {
t.Fatalf("got %+v", c)
}
})
t.Run("same as an identical worn weapon", func(t *testing.T) {
eq := map[DnDSlot]EquippedMagicItem{DnDSlotMainHand: {Slot: DnDSlotMainHand, Item: rareWpn}}
c := magicItemCompare(rareWpn, 0, eq)
if c.Verdict != "same" || len(c.Deltas) != 0 {
t.Fatalf("got %+v", c)
}
})
t.Run("new into an empty slot names no worn item", func(t *testing.T) {
c := magicItemCompare(rareWpn, 0, map[DnDSlot]EquippedMagicItem{})
if c.Verdict != "new" || c.VsName != "" || len(c.Deltas) == 0 {
t.Fatalf("got %+v", c)
}
})
t.Run("attunement item with no free bond is inert", func(t *testing.T) {
ring := mkItem(MagicItemRing, RarityRare, DnDSlotRing1)
ring.Attunement = true
// Three bonds spent elsewhere; the ring slot is empty. Wearing it does nothing.
eq := map[DnDSlot]EquippedMagicItem{
DnDSlotChest: {Slot: DnDSlotChest, Item: mkItem(MagicItemArmor, RarityRare, DnDSlotChest), Attuned: true},
DnDSlotAmulet: {Slot: DnDSlotAmulet, Item: mkItem(MagicItemWondrous, RarityRare, DnDSlotAmulet), Attuned: true},
DnDSlotCloak: {Slot: DnDSlotCloak, Item: mkItem(MagicItemWondrous, RarityRare, DnDSlotCloak), Attuned: true},
}
if c := magicItemCompare(ring, 0, eq); c.Verdict != "inert" {
t.Fatalf("got %+v", c)
}
})
t.Run("evicting the worn occupant frees its bond, so not inert", func(t *testing.T) {
ring := mkItem(MagicItemRing, RarityRare, DnDSlotRing1)
ring.Attunement = true
wornRing := mkItem(MagicItemRing, RarityUncommon, DnDSlotRing1)
wornRing.Attunement = true
// Three bonds spent — but one of them is the ring the candidate would replace.
eq := map[DnDSlot]EquippedMagicItem{
DnDSlotRing1: {Slot: DnDSlotRing1, Item: wornRing, Attuned: true},
DnDSlotChest: {Slot: DnDSlotChest, Item: mkItem(MagicItemArmor, RarityRare, DnDSlotChest), Attuned: true},
DnDSlotAmulet: {Slot: DnDSlotAmulet, Item: mkItem(MagicItemWondrous, RarityRare, DnDSlotAmulet), Attuned: true},
}
if c := magicItemCompare(ring, 0, eq); c.Verdict == "inert" {
t.Fatalf("bond freed by eviction, should not be inert: %+v", c)
}
})
}
+201
View File
@@ -0,0 +1,201 @@
package plugin
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
"gogobee/internal/peteclient"
)
// LLM-authored adventure dispatches. gogobee owns the raw model compute; this is
// where a structured fact becomes warm-reporter prose for Pete to publish. Pete
// is still the editor and the safety boundary: it runs its own prose-guard over
// whatever we send and falls back to its templates on anything it does not like,
// so authoring here is best-effort by design — every failure path returns an
// empty pair and Pete templates the fact.
//
// The voice must live somewhere, and with no route for Pete to call back into
// this box (see roster.go in the Pete repo) it lives in the prompt below. Keep
// it faithful to pete_adventure_news_voice.md; Pete's persona, not gogobee's.
// dispatchLLMTimeout bounds the authoring call. emitFact runs on game-event
// chokepoints (a party wipe fires one per member), so this is deliberately far
// tighter than the interactive 120s tip budget: if the model cannot turn a
// handful of facts into two sentences this fast, it is effectively down, and a
// template dispatch now beats a voiced one late.
const dispatchLLMTimeout = 15 * time.Second
// Length ceilings, mirrored from Pete's proseGuard so we never ship prose Pete
// will reject for length alone. Byte counts, matching Pete's len() check.
const (
maxDispatchHeadline = 200
maxDispatchLede = 800
)
var dispatchHTTP = &http.Client{Timeout: dispatchLLMTimeout}
// authorDispatch turns a fact into a headline+lede in Pete's voice, or returns
// two empty strings if the model is unconfigured, errors, times out, or produces
// anything malformed. The fact must already have its FINAL Actors set (post
// opt-out anonymisation) — that list is the only set of names the prose may use,
// and it is what Pete's guard checks the output against.
func authorDispatch(f peteclient.Fact) (headline, lede string) {
host := os.Getenv("OLLAMA_HOST")
model := os.Getenv("OLLAMA_MODEL")
if host == "" || model == "" {
return "", ""
}
prompt := buildDispatchPrompt(f)
raw, err := callOllamaDispatch(host, model, prompt)
if err != nil {
slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err)
return "", ""
}
h, l, ok := parseDispatch(raw)
if !ok {
slog.Warn("pete dispatch: unparseable LLM output, Pete will template", "guid", f.GUID)
return "", ""
}
// Ship only a complete, in-bounds pair. A half-authored dispatch or an
// over-long one is exactly what Pete would reject anyway; catch it here so a
// bad generation costs nothing on the wire.
if h == "" || l == "" || len(h) > maxDispatchHeadline || len(l) > maxDispatchLede {
slog.Warn("pete dispatch: LLM output empty or over length, Pete will template",
"guid", f.GUID, "headline_len", len(h), "lede_len", len(l))
return "", ""
}
return h, l
}
// buildDispatchPrompt renders the persona, the strict rules, and this fact's
// structured facts into a single prompt. The facts block lists only the fields
// that are set, each labelled, so the model has the who/what/where and no room
// to invent the rest.
func buildDispatchPrompt(f peteclient.Fact) string {
var facts strings.Builder
add := func(label, val string) {
if val != "" {
fmt.Fprintf(&facts, "- %s: %s\n", label, val)
}
}
addN := func(label string, n int) {
if n != 0 {
fmt.Fprintf(&facts, "- %s: %d\n", label, n)
}
}
add("event", f.EventType)
add("who this is about (the subject)", f.Subject)
add("the other person named", f.Opponent)
add("monster or boss", f.Boss)
add("dungeon or zone", f.Zone)
add("region", f.Region)
addN("character level", f.Level)
addN("count", f.Count)
add("outcome", f.Outcome)
add("stakes or item", f.Stakes)
add("class and race", f.ClassRace)
add("milestone", f.Milestone)
names := "(none — this is a realm-level event with no named adventurer)"
if len(f.Actors) > 0 {
names = strings.Join(f.Actors, ", ")
}
return fmt.Sprintf(`You are Pete, a warm, friendly local news reporter for a fantasy adventuring town. Think a beloved local newscaster who genuinely knows everyone and is glad to see them. You have journalistic bones — a clear headline and a who/what/where lede that gets the facts right — delivered with personable, first-person warmth. You root for the community, celebrate wins, mourn losses gently, welcome newcomers. Conversational, never snarky, never a caps-lock hype-man. Warmth carries the register, not exclamation marks.
Write a short news dispatch about the event below.
STRICT RULES — do not violate these:
- Use ONLY these adventurer names, exactly as written: %s. Never invent a name, never use any other person's name, never use an @-handle.
- Use ONLY the facts listed. Do not invent numbers, outcomes, items, or events that are not below.
- The monster/boss, zone, region and item names are game names — you may use them as given.
- Do not address the reader as "you" unless the event is Pete's own duel.
- No markdown, no emoji, no quotation marks around the whole thing.
Respond with ONLY a JSON object, no other text:
{"headline": "one short sentence, a real headline", "lede": "one to three warm sentences with the who/what/where"}
The event:
%s`, names, facts.String())
}
// callOllamaDispatch posts a single non-streaming generation and returns the raw
// completion (think-tags stripped). Its own bounded client, separate from the
// interactive callOllama, because the game loop cannot wait 120s on the news.
func callOllamaDispatch(host, model, prompt string) (string, error) {
payload := map[string]interface{}{
"model": model,
"prompt": prompt,
"stream": false,
"think": false,
"options": map[string]interface{}{
"num_ctx": 4096,
},
}
data, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal payload: %w", err)
}
apiURL := strings.TrimRight(host, "/") + "/api/generate"
resp, err := dispatchHTTP.Post(apiURL, "application/json", bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("ollama request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(body))
}
var result struct {
Response string `json:"response"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
return result.Response, nil
}
// parseDispatch pulls {headline, lede} out of the model's completion, tolerating
// the usual noise (think blocks, markdown fences, prose around the JSON). ok is
// false when no JSON object with a headline can be recovered.
func parseDispatch(raw string) (headline, lede string, ok bool) {
s := raw
// Drop a Qwen-style reasoning block if present.
if i := strings.Index(s, "<think>"); i != -1 {
if j := strings.Index(s, "</think>"); j != -1 {
s = s[:i] + s[j+len("</think>"):]
}
}
// Isolate the first {...} object so surrounding prose or fences don't break
// the decode.
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start < 0 || end <= start {
return "", "", false
}
var out struct {
Headline string `json:"headline"`
Lede string `json:"lede"`
}
if err := json.Unmarshal([]byte(s[start:end+1]), &out); err != nil {
return "", "", false
}
headline = strings.TrimSpace(out.Headline)
lede = strings.TrimSpace(out.Lede)
if headline == "" {
return "", "", false
}
return headline, lede, true
}
@@ -0,0 +1,81 @@
package plugin
import (
"strings"
"testing"
"gogobee/internal/peteclient"
)
func TestParseDispatch(t *testing.T) {
cases := []struct {
name string
raw string
wantOK bool
wantHeadPre string
}{
{
name: "clean json",
raw: `{"headline": "Josie cleared the Ossuary.", "lede": "Alone, no less."}`,
wantOK: true,
wantHeadPre: "Josie cleared",
},
{
name: "wrapped in prose and fences",
raw: "Sure! Here you go:\n```json\n{\"headline\":\"A win.\",\"lede\":\"Nice one.\"}\n```",
wantOK: true,
wantHeadPre: "A win.",
},
{
name: "think block stripped",
raw: "<think>let me consider the tone</think>\n{\"headline\":\"Held the line.\",\"lede\":\"Proud of you all.\"}",
wantOK: true,
wantHeadPre: "Held the line.",
},
{name: "no json", raw: "I could not write that.", wantOK: false},
{name: "empty headline", raw: `{"headline":"","lede":"body"}`, wantOK: false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
h, l, ok := parseDispatch(c.raw)
if ok != c.wantOK {
t.Fatalf("ok = %v, want %v (h=%q l=%q)", ok, c.wantOK, h, l)
}
if ok && !strings.HasPrefix(h, c.wantHeadPre) {
t.Errorf("headline = %q, want prefix %q", h, c.wantHeadPre)
}
})
}
}
// TestBuildDispatchPrompt pins the two properties the prose-guard depends on:
// the allowed names are stated verbatim, and only the set facts appear (no empty
// labels for the model to fill in with invention).
func TestBuildDispatchPrompt(t *testing.T) {
f := peteclient.Fact{
EventType: "boss_kill", Subject: "Josie", Boss: "the Bone Warden",
Zone: "the Ossuary", Level: 14, Actors: []string{"Josie"},
}
p := buildDispatchPrompt(f)
if !strings.Contains(p, "ONLY these adventurer names, exactly as written: Josie") {
t.Errorf("prompt does not constrain names to Actors:\n%s", p)
}
if !strings.Contains(p, "the Bone Warden") || !strings.Contains(p, "the Ossuary") {
t.Error("prompt dropped a supplied fact")
}
// Unset fields must not appear as empty labels.
if strings.Contains(p, "region:") || strings.Contains(p, "milestone:") {
t.Errorf("prompt lists an unset fact:\n%s", p)
}
}
// TestBuildDispatchPromptRealmEvent: a realm-level fact with no named adventurer
// still produces a usable prompt that tells the model there is no name to use.
func TestBuildDispatchPromptRealmEvent(t *testing.T) {
f := peteclient.Fact{EventType: "siege_start", Boss: "the Horde", Stakes: "the whole town"}
p := buildDispatchPrompt(f)
if !strings.Contains(p, "no named adventurer") {
t.Errorf("realm event prompt missing the no-name note:\n%s", p)
}
}
+240
View File
@@ -0,0 +1,240 @@
package plugin
// The web equip queue's game-side loop.
//
// An owner asks, on their own detail page on Pete, to wear or take off an item.
// Pete records the intent; we poll for it, run the real equip against our own
// equipment tables, and file a verdict Pete shows them. Same reverse-pipe shape
// as mischief — Pete has no route into this box, so we ask for work rather than
// being told about it.
//
// The one thing that is NOT like mischief: the underlying action isn't
// idempotent. Equipping consumes an inventory row and unequipping mints a fresh
// one, so simply re-running a re-offered order would double-move the item. So
// before we touch anything we check the equip_applied_orders ledger: if this
// order's guid is already there, the mutation happened on an earlier tick and we
// only lost the verdict-ack — we re-file the stored verdict and mutate nothing.
// The guid is still the end-to-end key; here it guards a non-idempotent action
// instead of riding a naturally idempotent one.
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
const (
equipPollInterval = 30 * time.Second
equipPollTimeout = 20 * time.Second
)
// peteEquipTicker polls Pete for equip orders and fulfils them. Started alongside
// the other adventure tickers; exits on stopCh.
func (p *AdventurePlugin) peteEquipTicker() {
if !peteclient.Enabled() {
return // no Pete wire configured; the equip queue is simply off
}
ticker := time.NewTicker(equipPollInterval)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
p.pollEquipOrders()
}
}
}
func (p *AdventurePlugin) pollEquipOrders() {
ctx, cancel := context.WithTimeout(context.Background(), equipPollTimeout)
defer cancel()
orders, err := peteclient.PendingEquip(ctx)
if err != nil {
// A Pete predating the queue answers 404; a wire blip looks the same. Quiet
// on purpose — this must not spam while Pete hasn't shipped the endpoint.
slog.Debug("equip: poll failed", "err", err)
return
}
for _, order := range orders {
p.fulfilEquipOrder(ctx, order)
}
}
// fulfilEquipOrder applies one order and files its verdict. A transient failure is
// left pending for the next poll (no verdict); a permanent one gets a specific
// rejection. The guid ledger makes a re-offer after a lost ack a no-op that simply
// re-files the verdict.
func (p *AdventurePlugin) fulfilEquipOrder(ctx context.Context, order peteclient.EquipOrder) {
// Already applied on an earlier tick? Re-file the stored verdict, mutate nothing.
if status, detail, ok := equipOrderAlreadyApplied(order.GUID); ok {
if err := peteclient.VerdictEquip(ctx, order.GUID, status, detail); err != nil {
slog.Warn("equip: re-file verdict push failed, will retry next poll",
"order", order.GUID, "status", status, "err", err)
}
return
}
owner, ok := p.equipOwnerMXID(order.OwnerLocalpart)
if !ok {
// The client isn't up (tests) or the localpart is empty. Not our order to
// fail permanently — leave it pending and try again once we can name the owner.
slog.Debug("equip: cannot resolve owner, leaving pending", "order", order.GUID, "owner", order.OwnerLocalpart)
return
}
status, detail, retry := p.applyEquipOrder(owner, order)
if retry {
return // transient; leave pending for the next tick
}
// Record the verdict BEFORE pushing it, so a crash after the mutation still
// short-circuits next tick and re-files rather than re-applying. The mutation
// and this insert aren't one transaction, but the window between them is a
// single statement — the same practical guarantee the DM equip path lives with.
if err := recordEquipApplied(order.GUID, status, detail); err != nil {
// If we can't record it, don't push the verdict either: leave the order
// pending so the ledger and Pete stay in step. Re-running an equip is the
// double-move we're guarding against, so a rare re-apply here is the lesser
// evil versus a verdict with no ledger behind it. Transient; retry.
slog.Warn("equip: failed to record applied order, leaving pending",
"order", order.GUID, "status", status, "err", err)
return
}
if err := peteclient.VerdictEquip(ctx, order.GUID, status, detail); err != nil {
slog.Warn("equip: verdict push failed, will re-file next poll",
"order", order.GUID, "status", status, "err", err)
return
}
slog.Info("equip: web order fulfilled", "order", order.GUID, "action", order.Action, "status", status)
}
// applyEquipOrder runs the real equip/unequip. It returns the terminal status and
// a human note for Pete, or retry=true for a transient fault that should leave the
// order pending. It records nothing and pushes nothing — the caller does both.
func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.EquipOrder) (status, detail string, retry bool) {
switch order.Action {
case "equip":
inv, err := loadAdvInventory(owner)
if err != nil {
return "", "", true // transient
}
var it AdvItem
found := false
for _, cand := range inv {
if cand.ID == order.ItemID {
it, found = cand, true
break
}
}
if !found {
// The row left the pack before we got here (worn already, sold, a stale
// page). The table is AUTOINCREMENT, so the id can't have been reused for
// a different item — this is a clean miss, not a wrong hit.
return "rejected_not_owned", "That item wasn't in your pack anymore.", false
}
out, err := applyMagicEquip(owner, it)
if errors.Is(err, errItemNotEquippable) {
return "rejected_not_equippable", "That item can't be worn.", false
}
if err != nil {
return "", "", true // transient DB fault
}
return "applied", equipAppliedDetail(out), false
case "unequip":
out, err := applyMagicUnequip(owner, DnDSlot(order.Slot))
if errors.Is(err, errSlotEmpty) {
return "rejected_not_worn", "That slot was already empty.", false
}
if err != nil {
return "", "", true
}
note := fmt.Sprintf("Took off %s, back in your pack.", out.Item.Name)
if len(out.Healed) > 0 {
note += fmt.Sprintf(" That freed a bond, so %s is active now.", strings.Join(out.Healed, ", "))
}
return "applied", note, false
default:
// Pete validates the action before it ever queues an order, so this is a
// contract breach, not a user mistake. Reject permanently rather than spin.
return "rejected_not_equippable", "Unknown action.", false
}
}
// equipAppliedDetail turns an equip outcome into the plain note Pete shows.
func equipAppliedDetail(out magicEquipOutcome) string {
b := fmt.Sprintf("Now worn in your %s slot.", out.Effective.Slot)
switch {
case out.Bonded:
b += fmt.Sprintf(" Bonded (%d of %d).", out.BondsBefore+1, dndMagicItemAttuneLimit)
case out.AtCap:
b += fmt.Sprintf(" Worn but inert: all %d bonds are in use, so take one off to activate it.", dndMagicItemAttuneLimit)
}
if out.SwappedBack != "" {
b += fmt.Sprintf(" %s went back to your pack.", out.SwappedBack)
}
if len(out.Healed) > 0 {
b += fmt.Sprintf(" A freed bond also activated %s.", strings.Join(out.Healed, ", "))
}
return b
}
// equipOwnerMXID reconstructs the owner's Matrix id from the localpart Pete sent,
// the same construction as the mischief buyer's. Fails closed if the client isn't
// up (tests) or the name is empty.
func (p *AdventurePlugin) equipOwnerMXID(localpart string) (id.UserID, bool) {
lp := strings.ToLower(strings.TrimSpace(localpart))
if lp == "" || p.Client == nil {
return "", false
}
server := p.Client.UserID.Homeserver()
if server == "" {
return "", false
}
return id.NewUserID(lp, server), true
}
// ---- the applied-order ledger --------------------------------------------------
// equipOrderAlreadyApplied reports the verdict we filed for an order, if we have
// already applied it. This is the short-circuit that keeps a re-offered order from
// re-running its non-idempotent mutation.
func equipOrderAlreadyApplied(guid string) (status, detail string, ok bool) {
err := db.Get().QueryRow(
`SELECT status, detail FROM equip_applied_orders WHERE guid = ?`, guid,
).Scan(&status, &detail)
if errors.Is(err, sql.ErrNoRows) {
return "", "", false
}
if err != nil {
// A read failure here would send us down the mutation path and risk a
// double-move, so treat it as "don't know" and let the caller leave the
// order pending rather than assume it's fresh. We signal that by returning
// ok=false but... the caller can't tell the difference. Log loudly; a
// persistent read failure is a real problem, but a transient one self-heals
// on the next poll because the mutation itself is guarded by this same table.
slog.Error("equip: applied-ledger read failed", "order", guid, "err", err)
return "", "", false
}
return status, detail, true
}
// recordEquipApplied stamps an order as applied with the verdict we're about to
// file. OR IGNORE so a re-file that somehow reaches here can't error on the guid.
func recordEquipApplied(guid, status, detail string) error {
_, err := db.Get().Exec(
`INSERT OR IGNORE INTO equip_applied_orders (guid, status, detail) VALUES (?, ?, ?)`,
guid, status, detail)
return err
}
+273 -1
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"gogobee/internal/db"
@@ -198,29 +200,233 @@ func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
}
if items, err := loadAdvInventory(uid); err == nil {
pd.Inventory = itemViews(items)
if equipped, err := loadEquippedMagicItems(uid); err == nil {
attachInventoryCompares(pd.Inventory, items, equipped)
}
}
if items, err := loadAdvVault(uid); err == nil {
pd.Vault = itemViews(items)
}
pd.Equipped = equippedViews(uid)
snap.Players = append(snap.Players, pd)
}
return snap, nil
}
// itemViews renders inventory or vault rows for the private panel, resolving
// the display facts the row itself doesn't carry.
//
// Attuned is always false here and that is not an omission: equipping *moves*
// the row out of adventure_inventory into magic_item_equipped, so nothing in a
// backpack can hold a bond. Worn items come from equippedViews instead.
func itemViews(items []AdvItem) []peteclient.ItemView {
if len(items) == 0 {
return nil
}
out := make([]peteclient.ItemView, 0, len(items))
for _, it := range items {
out = append(out, peteclient.ItemView{
v := peteclient.ItemView{
Name: it.Name,
Type: it.Type,
Tier: it.Tier,
Value: it.Value,
Temper: it.Temper,
Slot: string(it.Slot),
}
// SkillSource is dual-use: a real skill name on masterwork gear, an
// internal registry pointer on magic-item rows. Only the former is a
// fact about the item; the latter is plumbing and stays home.
if !strings.HasPrefix(it.SkillSource, "magic_item:") {
v.SkillSource = it.SkillSource
}
if mi, ok := magicItemFromAdvItem(it); ok {
eff := temperedItem(mi, it.Temper)
v.Slot = string(eff.Slot)
v.Desc = eff.Desc
v.Effect = magicItemEffectSummary(eff)
v.Attunement = eff.Attunement
// The row id is the equip handle: a slotted magic item is the one thing
// the web equip path can wear, so only it carries an id. Mundane gear (the
// branch below) and unslotted curios get none, so no Equip button. This
// runs for vault rows too — a vault magic item would carry an id — but Pete
// offers the button on the backpack panel alone, so that's inert, not a leak.
if eff.Slot != "" {
v.ID = it.ID
}
} else if it.Slot != "" {
// Shop equipment resolves by (slot, tier) — Name is decorative.
v.Desc = equipmentDefByTier(it.Slot, it.Tier).Description
}
out = append(out, v)
}
return out
}
// attachInventoryCompares fills the Compare card on every backpack magic item —
// the ones that carry an equip id, which is exactly the set the web Equip button
// acts on (equippedViews/vault rows are excluded by construction). views and items
// are index-aligned: itemViews appends one view per item and skips none.
func attachInventoryCompares(views []peteclient.ItemView, items []AdvItem, equipped map[DnDSlot]EquippedMagicItem) {
for i := range views {
if views[i].ID == 0 {
continue // no equip id → mundane gear or unslotted curio → no button, no compare
}
mi, ok := magicItemFromAdvItem(items[i])
if !ok || mi.Slot == "" {
continue
}
views[i].Compare = magicItemCompare(mi, items[i].Temper, equipped)
}
}
// magicItemCompare pairs a candidate backpack item against whatever is worn in
// the slot it would equip into (mi.Slot — the same slot applyMagicEquip targets,
// so the card describes the trade the Equip button actually makes). The diff is
// over *tempered* effects on both sides; bond availability decides the inert case.
func magicItemCompare(cand MagicItem, temper int, equipped map[DnDSlot]EquippedMagicItem) *peteclient.ItemCompare {
if cand.Slot == "" {
return nil
}
candEff := magicItemEffectFor(temperedItem(cand, temper))
worn, wornExists := equipped[cand.Slot]
if wornExists && worn.Item.ID == "" {
wornExists = false // an empty EquippedMagicItem is not a real occupant
}
// An empty slot compares against neutral: DamageReductMult is a multiplier, so
// its neutral is 1.0, not the zero value (0 would read as -100% damage taken).
wornEff := magicItemEffect{DamageReductMult: 1.0}
vsName := ""
if wornExists {
wornEff = magicItemEffectFor(worn.Effective())
vsName = worn.Effective().Name
}
deltas := magicItemDeltas(candEff, wornEff)
// Inert: the item wants a bond and none is free. Equipping evicts the slot's
// occupant first, so an attuned occupant frees its own bond — count post-swap.
inert := false
if cand.Attunement {
bonds := countAttunedMagicItems(equipped)
if wornExists && worn.Attuned {
bonds--
}
inert = bonds >= dndMagicItemAttuneLimit
}
return &peteclient.ItemCompare{
Verdict: compareVerdict(deltas, !wornExists, inert),
VsName: vsName,
VsSlot: string(cand.Slot),
Deltas: deltas,
}
}
// compareVerdict classifies a set of deltas by strict dominance. Different stats
// are not fungible — the engine can't say +3% damage beats -4 HP — so a mixed
// result is a sidegrade with no winner claimed, which is the whole reason the
// card exists. inert and new override the stat verdict.
func compareVerdict(deltas []peteclient.ItemDelta, empty, inert bool) string {
if inert {
return "inert" // wearing it does nothing until a bond frees; stat diff is moot
}
if empty {
return "new"
}
if len(deltas) == 0 {
return "same"
}
gains, losses := 0, 0
for _, d := range deltas {
if d.Better {
gains++
} else {
losses++
}
}
switch {
case losses == 0:
return "upgrade"
case gains == 0:
return "downgrade"
default:
return "sidegrade"
}
}
// magicItemDeltas returns one entry per stat that visibly changes between the
// candidate and the worn item. It diffs the structured effect fields, never the
// summary string (which drops zero fields and would lose deltas). A change too
// small to show at the rendered precision is omitted, so the verdict matches what
// the player sees.
func magicItemDeltas(cand, worn magicItemEffect) []peteclient.ItemDelta {
var d []peteclient.ItemDelta
// DamageBonus / DamageReductMult are fractions rendered as whole percents.
if pct := (cand.DamageBonus - worn.DamageBonus) * 100; roundedPct(pct) != 0 {
d = append(d, peteclient.ItemDelta{Label: "damage", Better: pct > 0, Text: signedPct(pct, "damage")})
}
// DamageReductMult is a multiplier on damage TAKEN, so lower is better. Express
// the change as damage taken: a positive number means you take more (worse).
if taken := (cand.DamageReductMult - worn.DamageReductMult) * 100; roundedPct(taken) != 0 {
d = append(d, peteclient.ItemDelta{Label: "defense", Better: taken < 0, Text: signedPct(taken, "damage taken")})
}
if diff := cand.FlatDmgStart - worn.FlatDmgStart; diff != 0 {
d = append(d, peteclient.ItemDelta{Label: "opening", Better: diff > 0, Text: signedInt(diff, "opening damage")})
}
if diff := cand.MaxHP - worn.MaxHP; diff != 0 {
d = append(d, peteclient.ItemDelta{Label: "hp", Better: diff > 0, Text: signedInt(diff, "HP")})
}
if cand.InitiativeBias != worn.InitiativeBias {
faster := cand.InitiativeBias > worn.InitiativeBias
text := "slower to act"
if faster {
text = "faster to act"
}
d = append(d, peteclient.ItemDelta{Label: "speed", Better: faster, Text: text})
}
return d
}
// roundedPct is the whole-percent a delta renders as; used to drop sub-percent
// noise so the verdict never disagrees with the displayed chips.
func roundedPct(v float64) int {
if v < 0 {
return int(v - 0.5)
}
return int(v + 0.5)
}
func signedPct(v float64, noun string) string { return fmt.Sprintf("%+d%% %s", roundedPct(v), noun) }
func signedInt(v int, noun string) string { return fmt.Sprintf("%+d %s", v, noun) }
// equippedViews returns the magic items the player is actually wearing. This is
// the only place Attuned means anything: the bond lives on the equipped row, and
// with a cap of dndMagicItemAttuneLimit a worn item can be inert.
func equippedViews(uid id.UserID) []peteclient.ItemView {
equipped, err := loadEquippedMagicItems(uid)
if err != nil || len(equipped) == 0 {
return nil
}
out := make([]peteclient.ItemView, 0, len(equipped))
for _, e := range equipped {
eff := e.Effective()
out = append(out, peteclient.ItemView{
Name: eff.Name,
Type: string(eff.Kind),
Value: int64(eff.Value),
Temper: e.Temper,
Slot: string(e.Slot),
Desc: eff.Desc,
Effect: magicItemEffectSummary(eff),
Attunement: eff.Attunement,
Attuned: e.Attuned,
})
}
// Map iteration is random; the panel must not reshuffle every 60s poll.
sort.Slice(out, func(i, j int) bool { return out[i].Slot < out[j].Slot })
return out
}
@@ -345,6 +551,7 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap
if exp.RunID != "" {
if run, rerr := getZoneRun(exp.RunID); rerr == nil && run != nil && run.TotalRooms > 0 {
e.Detail.Room = fmt.Sprintf("%d / %d", run.CurrentRoom+1, run.TotalRooms)
e.Detail.Map = buildRosterMap(run)
}
}
}
@@ -358,6 +565,71 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap
return snap, nil
}
// buildRosterMap computes the fog-of-war cut of a run's zone graph for the
// public roster. It sends every visited node with its true kind, plus the
// one-hop frontier — the destinations of edges leading out of visited nodes,
// with their kind withheld as "unknown". Edges are directed and stored by
// from-node, so "one hop out of a visited node" is exactly g.Edges[visited].
// A frontier node's edges are NOT walked, so nothing past the first closed
// door reaches the wire — "view source to find the boss room" is not fog of
// war. Node Label/Content never leave the game box.
//
// Output order is deterministic (visited-path order, then frontier in
// discovery order) so an unchanged run produces a byte-identical snapshot and
// the roster push does not churn.
func buildRosterMap(run *DungeonRun) *peteclient.RosterMap {
g, ok := loadZoneGraph(run.ZoneID)
if !ok || len(run.VisitedNodes) == 0 {
return nil
}
// Unique visited nodes in path order — VisitedNodes repeats on backtrack.
orderedVisited := make([]string, 0, len(run.VisitedNodes))
seen := make(map[string]bool, len(run.VisitedNodes))
for _, id := range run.VisitedNodes {
if !seen[id] {
seen[id] = true
orderedVisited = append(orderedVisited, id)
}
}
m := &peteclient.RosterMap{
ZoneID: string(run.ZoneID),
CurrentNode: run.CurrentNode,
Visited: orderedVisited,
}
// Visited nodes first, in path order, with their real kind.
emitted := make(map[string]bool, len(orderedVisited))
for _, id := range orderedVisited {
emitted[id] = true
if n, ok := g.Nodes[id]; ok {
m.Nodes = append(m.Nodes, peteclient.RosterMapNode{ID: id, Kind: string(n.Kind)})
}
}
// Frontier: destinations of edges out of visited nodes, kind withheld.
// Walk visited in path order so the frontier order is stable.
for _, from := range orderedVisited {
for _, edge := range g.Edges[from] {
lock := edge.Lock
if lock == LockNone {
lock = "" // an open door needs no mark; omitempty drops it
}
m.Edges = append(m.Edges, peteclient.RosterMapEdge{
From: edge.From,
To: edge.To,
Lock: string(lock),
})
if !seen[edge.To] && !emitted[edge.To] {
emitted[edge.To] = true
m.Nodes = append(m.Nodes, peteclient.RosterMapNode{ID: edge.To, Kind: "unknown"})
}
}
}
return m
}
// resolveRosterToken maps a board token back to the adventurer it names. The
// token is a one-way HMAC (eventToken), so it can't be inverted — instead we
// recompute every live player's token and match. The salt is DB-persisted, so a
+137
View File
@@ -0,0 +1,137 @@
package plugin
import (
"testing"
)
// mapFixtureGraph registers a small branching graph and returns a cleanup.
//
// n1(entry) --none--------> n2(exploration) --key_required--> n4(boss)
// \--perception_check--> n3(trap) ------------------------/
//
// n4 is reachable two ways; the validator wants a single entry and a reachable
// boss, which this satisfies.
func mapFixtureGraph(t *testing.T) ZoneID {
t.Helper()
const zid ZoneID = "map_test_zone"
g := ZoneGraph{
ZoneID: zid,
Entry: "n1",
Boss: "n4",
Nodes: map[string]ZoneNode{
"n1": {NodeID: "n1", ZoneID: zid, Kind: NodeKindEntry, IsEntry: true, Label: "The Gate"},
"n2": {NodeID: "n2", ZoneID: zid, Kind: NodeKindExploration, Label: "Dusty Hall"},
"n3": {NodeID: "n3", ZoneID: zid, Kind: NodeKindTrap, Label: "Spiked Pit"},
"n4": {NodeID: "n4", ZoneID: zid, Kind: NodeKindBoss, IsBoss: true, Label: "Throne of Bone"},
},
Edges: map[string][]ZoneEdge{
"n1": {
{From: "n1", To: "n2", Lock: LockNone, Weight: 1},
{From: "n1", To: "n3", Lock: LockPerception, Weight: 1},
},
"n2": {{From: "n2", To: "n4", Lock: LockKey, Weight: 1}},
"n3": {{From: "n3", To: "n4", Lock: LockNone, Weight: 1}},
},
}
registerZoneGraph(g)
t.Cleanup(func() { delete(zoneGraphRegistry, zid) })
return zid
}
func TestBuildRosterMap_FogOfWar(t *testing.T) {
zid := mapFixtureGraph(t)
run := &DungeonRun{
ZoneID: zid,
CurrentNode: "n2",
VisitedNodes: []string{"n1", "n2"},
TotalRooms: 4,
}
m := buildRosterMap(run)
if m == nil {
t.Fatal("buildRosterMap returned nil for a visited run")
}
if m.ZoneID != string(zid) || m.CurrentNode != "n2" {
t.Fatalf("header wrong: %+v", m)
}
kinds := map[string]string{}
for _, n := range m.Nodes {
if _, dup := kinds[n.ID]; dup {
t.Errorf("node %q emitted twice", n.ID)
}
kinds[n.ID] = n.Kind
}
// Visited nodes carry their true kind.
if kinds["n1"] != "entry" || kinds["n2"] != "exploration" {
t.Errorf("visited kinds wrong: %v", kinds)
}
// Frontier nodes are present but their kind is withheld.
if kinds["n3"] != "unknown" {
t.Errorf("n3 is one hop out of n1 and must be unknown, got %q", kinds["n3"])
}
if kinds["n4"] != "unknown" {
t.Errorf("n4 (the boss) is one hop out of n2 and must be unknown, got %q", kinds["n4"])
}
// The map must never leak a room the player has not reached a door to.
// n4 is a real boss node, but reachable only as frontier — its kind stays
// hidden. A node past a frontier door (there is none deeper here) must not
// appear at all; assert exactly four nodes.
if len(m.Nodes) != 4 {
t.Fatalf("want 4 nodes (2 visited + 2 frontier), got %d: %+v", len(m.Nodes), m.Nodes)
}
// Edges out of visited nodes only. n3->n4 must NOT appear: n3 is frontier,
// not visited, so walking its doors would leak structure past the fog.
var edgeKeys []string
for _, e := range m.Edges {
edgeKeys = append(edgeKeys, e.From+"->"+e.To+":"+e.Lock)
}
want := map[string]bool{
"n1->n2:": true, // LockNone dropped to ""
"n1->n3:perception_check": true,
"n2->n4:key_required": true,
}
if len(edgeKeys) != len(want) {
t.Fatalf("want %d edges, got %v", len(want), edgeKeys)
}
for _, k := range edgeKeys {
if !want[k] {
t.Errorf("unexpected edge %q (n3->n4 would be a fog leak)", k)
}
}
}
func TestBuildRosterMap_EmptyRunIsNil(t *testing.T) {
zid := mapFixtureGraph(t)
// A run that has visited nothing yet has no map to show.
if m := buildRosterMap(&DungeonRun{ZoneID: zid}); m != nil {
t.Errorf("empty VisitedNodes should yield nil, got %+v", m)
}
// An unknown zone (no graph, no legacy fallback row) yields nil, not a panic.
if m := buildRosterMap(&DungeonRun{ZoneID: "no_such_zone", VisitedNodes: []string{"x"}}); m != nil {
t.Errorf("unknown zone should yield nil, got %+v", m)
}
}
func TestBuildRosterMap_BacktrackDedup(t *testing.T) {
zid := mapFixtureGraph(t)
// VisitedNodes is an ordered set that repeats on backtrack: n1,n2,n1.
run := &DungeonRun{
ZoneID: zid,
CurrentNode: "n1",
VisitedNodes: []string{"n1", "n2", "n1"},
}
m := buildRosterMap(run)
seen := map[string]int{}
for _, n := range m.Nodes {
seen[n.ID]++
}
if seen["n1"] != 1 {
t.Errorf("backtracked node n1 emitted %d times, want 1", seen["n1"])
}
if len(m.Visited) != 2 {
t.Errorf("Visited should dedup to [n1 n2], got %v", m.Visited)
}
}
+99
View File
@@ -1,6 +1,7 @@
package plugin
import (
"sort"
"testing"
"time"
@@ -125,3 +126,101 @@ func TestRosterTokenIsNotAnEventToken(t *testing.T) {
t.Error("board token not stable — the row would churn identity every snapshot")
}
}
// pickMagicItem returns a registry item matching want, so these tests read the
// real registry rather than pinning an item ID that a later SRD dump could drop.
func pickMagicItem(t *testing.T, want func(MagicItem) bool) MagicItem {
t.Helper()
var ids []string
for id := range magicItemRegistry {
ids = append(ids, id)
}
sort.Strings(ids) // map order is random; a flaky pick is a flaky test
for _, id := range ids {
if mi := magicItemRegistry[id]; want(mi) {
return mi
}
}
t.Skip("no registry item matches this shape")
return MagicItem{}
}
// TestItemViewsKeepsTheRegistryPointerHome is the leak guard. SkillSource is two
// different things depending on the row: a player-facing skill name on
// masterwork gear ("mining"), and the internal "magic_item:<id>" pointer that
// resolves an inventory row back to the registry. Only the first is a fact about
// the item. Sending the second would put gogobee's internal IDs on a page, and
// Pete would have no way to tell them apart to filter them out.
func TestItemViewsKeepsTheRegistryPointerHome(t *testing.T) {
newBoredomTestDB(t)
mi := pickMagicItem(t, func(m MagicItem) bool { return m.Desc != "" && m.Slot != "" })
views := itemViews([]AdvItem{
{Name: mi.Name, Type: "magic_item", Tier: 3, Value: 100,
SkillSource: "magic_item:" + mi.ID},
{Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300,
Slot: SlotWeapon, SkillSource: "mining"},
})
if views[0].SkillSource != "" {
t.Errorf("the magic_item registry pointer went out on the wire: %q", views[0].SkillSource)
}
if views[0].Desc != mi.Desc {
t.Errorf("desc = %q, want the registry's %q", views[0].Desc, mi.Desc)
}
if views[0].Effect == "" {
t.Error("a magic item should carry the engine's own effect summary")
}
if views[1].SkillSource != "mining" {
t.Errorf("masterwork skill source = %q, want it kept", views[1].SkillSource)
}
}
// TestItemViewsNeverClaimABackpackBond: equipping *moves* the row out of
// adventure_inventory into magic_item_equipped, so nothing in a backpack can
// hold a bond. Attuned must stay false here whatever the item wants, or the
// panel tells a player an unworn item is working for them.
func TestItemViewsNeverClaimABackpackBond(t *testing.T) {
newBoredomTestDB(t)
mi := pickMagicItem(t, func(m MagicItem) bool { return m.Attunement && m.Slot != "" })
v := itemViews([]AdvItem{{Name: mi.Name, Type: "magic_item", Tier: 3,
SkillSource: "magic_item:" + mi.ID}})[0]
if !v.Attunement {
t.Error("an attunement item should say it wants a bond")
}
if v.Attuned {
t.Error("a backpack item claimed a bond it cannot hold")
}
}
// TestEquippedViewsCarryBondState: the worn set is the only place Attuned means
// anything, and the only way the page can show that a worn item is sitting inert
// against the cap of three.
func TestEquippedViewsCarryBondState(t *testing.T) {
newBoredomTestDB(t)
uid := id.UserID("@josie:example.org")
mi := pickMagicItem(t, func(m MagicItem) bool { return m.Attunement && m.Slot != "" })
if err := equipMagicItem(uid, mi.Slot, mi.ID, false, 0); err != nil {
t.Fatalf("equip: %v", err)
}
views := equippedViews(uid)
if len(views) != 1 {
t.Fatalf("equipped views = %d, want 1", len(views))
}
if views[0].Attuned {
t.Error("an inert worn item was reported as bonded")
}
if views[0].Slot != string(mi.Slot) {
t.Errorf("slot = %q, want %q", views[0].Slot, mi.Slot)
}
if err := equipMagicItem(uid, mi.Slot, mi.ID, true, 0); err != nil {
t.Fatalf("re-equip bonded: %v", err)
}
if !equippedViews(uid)[0].Attuned {
t.Error("a bonded worn item was reported as inert")
}
}
+66
View File
@@ -3,6 +3,7 @@ package plugin
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"sync"
"testing"
@@ -193,6 +194,71 @@ func TestEmitZoneClearTaxonomy(t *testing.T) {
}
}
// TestEmitTreasureFound: a story-grade find carries the item name and rarity,
// and the realm's first finder of a given treasure is billed a PRIORITY hoard
// while a later finder of the same item is a BULLETIN — the same first/repeat
// split zone_first uses, but keyed on the treasure across the whole realm.
func TestEmitTreasureFound(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
enablePeteSeam(t)
db.Exec("seed finder", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`, "@zapp:x", "Zapp")
db.Exec("seed second finder", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`, "@kif:x", "Kif")
def := &AdvTreasureDef{Key: "thunderfury", Name: "Thunderfury, Blessed Blade of the Windseeker",
Tier: 5, RoomAnnounce: "x got Thunderfury."}
loc := &AdvLocation{Name: "The Abyssal Maw"}
emitTreasureFound(id.UserID("@zapp:x"), def, loc) // realm-first
emitTreasureFound(id.UserID("@kif:x"), def, loc) // same item, later finder
if got := queuedCount(t, "treasure_found:%"); got != 2 {
t.Fatalf("treasure_found queued = %d, want 2", got)
}
// Pull both payloads and check the taxonomy split plus the carried fields.
rows, err := db.Get().Query(`SELECT payload FROM pete_emit_queue WHERE guid LIKE 'treasure_found:%'`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
tiers := map[string]int{}
for rows.Next() {
var payload string
if err := rows.Scan(&payload); err != nil {
t.Fatal(err)
}
var f map[string]any
if err := json.Unmarshal([]byte(payload), &f); err != nil {
t.Fatal(err)
}
tiers[f["tier"].(string)]++
if f["stakes"] != def.Name {
t.Errorf("stakes = %v, want the item name", f["stakes"])
}
if f["zone"] != "The Abyssal Maw" {
t.Errorf("zone = %v, want the location", f["zone"])
}
if f["outcome"] != "legendary" {
t.Errorf("outcome = %v, want legendary for a tier-5 find", f["outcome"])
}
}
if tiers["priority"] != 1 || tiers["bulletin"] != 1 {
t.Errorf("tier split = %v, want one priority (realm-first) and one bulletin (repeat)", tiers)
}
// The ledger is seeded, so a later live find of the same treasure won't
// mis-announce as the first-ever.
if claimRealmFirst("treasure", "thunderfury") {
t.Error("realm-first ledger not seeded for the treasure")
}
}
// TestNewsEmissionKillSwitch: the runtime flag defaults on and persists a flip.
func TestNewsEmissionKillSwitch(t *testing.T) {
dir := t.TempDir()
+2 -2
View File
@@ -167,9 +167,9 @@ var _ = func() bool {
},
"boss_seamstress": {
ID: "boss_seamstress", Name: "The Seamstress",
CR: 27, HP: 385, AC: 21, Attack: 39, AttackBonus: 12, Speed: 14,
CR: 27, HP: 460, AC: 21, Attack: 45, AttackBonus: 12, Speed: 14,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Needle Rain", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"},
Ability: &MonsterAbility{Name: "Needle Rain", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"},
XPValue: 100000,
Notes: "Unplace boss. A corrupted celestial sewing herself into the tear; half of her is on the other side. Phase 2 below 35% HP. (Layer 2: Inversion Stitch — healing and damage swap direction on her in telegraphed pulses.)",
},
+334
View File
@@ -0,0 +1,334 @@
package plugin
// Tier-6 postgame "Layer-2" boss mechanics (Phase P8).
//
// Layer 1 (shipped) is everything the stock engine expresses: a single
// MonsterAbility rider plus HP/AC/Attack/PhaseTwoAt on the stat block. Layer 2
// is the bespoke, per-boss stuff the plan promised — mechanics that read the
// *run* the player took to reach the boss, not just the fight in front of them.
//
// It holds BOTH halves of that seam:
//
// - PRE-COMBAT (applyBossRunModifiers / seedBossRunStatuses): adjustments
// derived once from run state and folded into the boss's live Combatant (or
// the fresh session) before the fight resolves. applyBossRunModifiers must be
// a PURE, IDEMPOTENT function of run state, because the turn engine rebuilds
// the enemy from the bestiary every single round (partyCombatantsForSession)
// — the boss's numbers are re-derived on every !attack. That is fine as long
// as the inputs are frozen for the fight, which they are: the boss room is
// terminal, so no more nodes are walked once the fight begins.
//
// - IN-COMBAT (applyBossInCombatRoundEnd): round-boundary mechanics that read
// and mutate the live combatState — HP snapshots, once-only rewinds, escalating
// timers. Called from the turn engine's stepRoundEnd after the round's damage
// has settled. Any state it spends round-trips through CombatStatuses so a
// suspend/resume can't replay it.
//
// The remaining planned in-combat mechanics (Inversion Stitch, Two Hearts) are
// still a separate seam — a new MonsterAbility.Effect case in applyAbility — and
// are not in this file yet.
// applyBossRunModifiers folds any pre-combat Layer-2 mechanic for bossID into
// the freshly-built enemy Combatant, reading the run the player took to get
// here. It is dispatched by bestiary ID and is a no-op for every enemy that
// isn't a hooked T6 boss, so the two build seams can call it unconditionally on
// whatever they just built. A nil enemy or nil run is a no-op.
func applyBossRunModifiers(bossID string, enemy *Combatant, run *DungeonRun) {
if enemy == nil || run == nil {
return
}
switch bossID {
case "boss_aurvandryx":
applyGreedTax(enemy, run)
}
}
// ── Greed Tax — Aurvandryx, the Ember Before Fire (First Hoard) ─────────────
//
// The First Hoard carries the game's richest LootBias nodes on purpose (the
// gilded veins at 2.53.0; nowhere else in the game exceeds 1.0). Aurvandryx —
// the hoard's true owner, not its guard dog — takes the interest out of your
// hide: her Attack rises with how much of the gilded route you walked to reach
// her cradle. Strip the veins and meet a furious wyrm; take the vow-of-poverty
// line through the zone and fight her lean.
//
// Signal: the summed excess LootBias (above the 1.0 baseline) of every node the
// run walked — NOT run.LootCollected, which only tracks the boss-only signature
// manifest and is empty at the boss. A full-explore route through the First
// Hoard accrues ~3.5 richness (+7 Attack); the cap covers a maximal line.
//
// Calibration (P8 sim sweep, L20 party+Pete+pets): the full-explore route lands
// first_hoard at ~47% clear (mid-band 4055), down from the taxless 56%. A lean
// prod route pays no tax and fights her at the taxless rate; a maximal route
// hits the cap. No base-stat retune was needed — the tax corrects the prior
// slight overshoot into the band. NOTE: the sim's autopilot explores the whole
// graph, so the sweep only exercises the full-route point; the lean/greedy
// spread is a prod-only player-agency lever the headless sim cannot walk.
const (
// greedTaxRichnessMult converts summed route richness into Attack points.
greedTaxRichnessMult = 2.0
// greedTaxMaxAttack caps the tax so a maximal hoard-run meets a very hard
// wyrm, never a literally-unbeatable one.
greedTaxMaxAttack = 12
)
// greedRouteRichness sums the excess LootBias (above the 1.0 baseline) of every
// node the run has walked. Extracted for a deterministic unit test.
func greedRouteRichness(run *DungeonRun) float64 {
g, ok := loadZoneGraph(run.ZoneID)
if !ok {
return 0
}
var rich float64
for _, id := range run.VisitedNodes {
if n, exists := g.Nodes[id]; exists && n.Content.LootBias > 1.0 {
rich += n.Content.LootBias - 1.0
}
}
return rich
}
// greedTaxAttack is the Attack surcharge for a route of the given richness.
func greedTaxAttack(richness float64) int {
tax := int(richness * greedTaxRichnessMult)
if tax > greedTaxMaxAttack {
tax = greedTaxMaxAttack
}
if tax < 0 {
tax = 0
}
return tax
}
func applyGreedTax(enemy *Combatant, run *DungeonRun) {
enemy.Stats.Attack += greedTaxAttack(greedRouteRichness(run))
}
// ── Phylactery Verses — Valdris, At Last (The Ossuary Ascendant) ────────────
//
// The plan Valdris has been running since he "died" in the T1 Crypt: the
// phylactery shard players looted for years was bait, and he has rebuilt as a
// true lich. His rebirths are bound into three Verses hidden in the cathedral,
// each a NodeKindSecret behind a Perception gate. Every Verse a player finds and
// walks before the fight UNBINDS one rebirth; every Verse they skip leaves it
// armed. Full-clear explorers strip all three and fight a mortal lich;
// speedrunners who blow past the secrets fight a god who will not stay down.
//
// This is the mirror-image of the Greed Tax's design axis: the Tax punishes
// greedy exploration, the Verses reward thorough exploration. Both are prod
// player-agency levers the headless sim only samples at whatever route its
// autopilot happens to walk.
//
// Unlike the Greed Tax (a pure per-round Attack recompute), a rebirth is spent
// mid-fight, so it is stateful: seedBossRunStatuses freezes the charge count
// onto the session ONCE at fight start, and the turn engine round-trips the
// live count through CombatStatuses. It must NOT be re-derived on the per-round
// enemy rebuild, or spent rebirths would come back every round.
const (
// phylacteryReviveFrac is the fraction of the boss's (party-scaled) max HP
// each unbound rebirth restores him to — a real second wind, not the 1-HP
// stay of survive_at_1.
phylacteryReviveFrac = 4 // 1/4 == 25%
)
// phylacteryReviveCharges is the number of rebirths still armed on Valdris: one
// per zone Verse (NodeKindSecret) the run has NOT visited. The Ossuary's only
// secret nodes are the three Verses (the shared builder stamps none by default),
// so counting unvisited secrets is exactly "unbound rebirths". Zero for any
// non-Valdris boss or a nil run. A pure function of frozen run state.
func phylacteryReviveCharges(bossID string, run *DungeonRun) int {
if bossID != "boss_valdris_ascendant" || run == nil {
return 0
}
g, ok := loadZoneGraph(run.ZoneID)
if !ok {
return 0
}
visited := make(map[string]bool, len(run.VisitedNodes))
for _, id := range run.VisitedNodes {
visited[id] = true
}
charges := 0
for id, n := range g.Nodes {
if n.Kind == NodeKindSecret && !visited[id] {
charges++
}
}
return charges
}
// seedBossRunStatuses folds any ONCE-AT-FIGHT-START Layer-2 boss state into the
// freshly-created session, reading the run the player took to get here. It is
// the stateful counterpart to applyBossRunModifiers (which is a per-round pure
// recompute): whatever it seeds here is mutated by the fight and round-tripped,
// never re-derived. enemyMaxHP is the party-scaled pool already persisted onto
// the session. Returns whether it changed anything (so the caller can skip a
// redundant save). No-op — false — for every non-hooked boss.
func seedBossRunStatuses(sess *CombatSession, bossID string, enemyMaxHP int, run *DungeonRun) bool {
if sess == nil {
return false
}
if charges := phylacteryReviveCharges(bossID, run); charges > 0 {
sess.Statuses.EnemyReviveCharges = charges
sess.Statuses.EnemyReviveHP = max(1, enemyMaxHP/phylacteryReviveFrac)
return true
}
return false
}
// ── Amendment — The Custodian of the Last Hour (The Last Meridian) ───────────
//
// The Custodian has concluded its contract is complete and is dismantling the
// hours on its way out — including, once, the last few minutes of its own fight.
// Its true durability is HIDDEN in the opening rounds: it snapshots its HP at the
// end of round 3, and the first time the party knocks it into phase 2 it *rewinds
// itself* to that snapshot — once. Front-loaded burst is partially refunded (the
// damage past the snapshot is undone), so sustained builds that can grind through
// the extra pool shine over glass-cannon alpha strikes. A soft "midnight timer"
// then leans on stalls: every round past round 20 the Custodian's Attack climbs,
// so a fight that can't close still resolves before the clock runs out.
//
// This is the first IN-COMBAT Layer-2 mechanic. Unlike the pre-combat hooks it
// reads/mutates the live combatState at round end, and its once-only state
// (EnemyRewindHP snapshot + EnemyRewindUsed) round-trips through CombatStatuses so
// a suspend/resume can't hand the boss a second rewind. Dispatched by bestiary ID,
// so it is a no-op for every other enemy.
const (
// custodianSnapshotRound is the round whose end HP the Custodian rewinds to.
// Snapshotting late enough that a normal party has committed real damage, but
// early enough that the refund still matters, is the whole point — the fight's
// true length is concealed until the rewind fires.
custodianSnapshotRound = 3
// custodianPhaseTwoFrac must track The Custodian's PhaseTwoAt in
// postgame_zone_defs.go (0.45): the rewind is meant to fire exactly as the
// party crosses the boss into phase 2. Kept as a local constant because the
// turn engine resolves off the bestiary template + session, not the
// ZoneDefinition, so the threshold isn't otherwise in reach at round end.
custodianPhaseTwoFrac = 0.45
// midnightAtkStep / midnightAtkCap: the soft closing-time timer. Every round
// past midnightTimerAfter adds midnightAtkStep to the boss's Attack (via the
// shared EnemyAtkBuff, which enemyAttackStat already folds in), capped so a
// truly stalled fight still ends without the number becoming meaningless.
midnightTimerAfter = 20
midnightAtkStep = 2
midnightAtkCap = 40
)
// applyBossInCombatRoundEnd resolves the round-boundary in-combat Layer-2
// mechanics for the enemy the turn engine is fighting. It runs after the round's
// damage has settled and only while the enemy still stands. Dispatched by
// bestiary ID; a no-op for every enemy that isn't a hooked T6 boss.
func applyBossInCombatRoundEnd(st *combatState, bossID string, enemyMaxHP int) {
if st == nil {
return
}
switch bossID {
case "boss_custodian":
applyAmendment(st, enemyMaxHP)
case "boss_seamstress":
applyInversionStitch(st, enemyMaxHP)
}
}
// applyAmendment resolves the Custodian's Amendment (round-3 snapshot + once-only
// phase-2 rewind) and its soft midnight timer, given the round that just finished
// (st.round, pre-increment) and the boss's party-scaled MaxHP.
func applyAmendment(st *combatState, enemyMaxHP int) {
// Snapshot the boss's HP at the end of round 3 — the concealed "true length".
if st.round == custodianSnapshotRound && st.enemyRewindHP == 0 {
st.enemyRewindHP = st.enemyHP
}
// The first time the party crosses the boss into phase 2, rewind to the
// snapshot — once. Guarded on snapshot > current so a party that never got
// below the round-3 HP (and a fight bursted into phase 2 before the snapshot
// was even taken, EnemyRewindHP == 0) can't be handed a free heal.
phaseTwo := int(custodianPhaseTwoFrac * float64(enemyMaxHP))
if !st.enemyRewindUsed && st.enemyRewindHP > st.enemyHP && st.enemyHP <= phaseTwo {
st.enemyHP = min(enemyMaxHP, st.enemyRewindHP)
st.enemyRewindUsed = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "amendment_rewind",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
// Soft midnight timer: closing time leans on a stalled fight.
if st.round >= midnightTimerAfter && st.enemyAtkBuff < midnightAtkCap {
st.enemyAtkBuff = min(midnightAtkCap, st.enemyAtkBuff+midnightAtkStep)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "midnight_toll",
Damage: midnightAtkStep, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
}
// ── Inversion Stitch — The Seamstress (The Unplace) ──────────────────────────
//
// The Seamstress is sewing the tear shut from the inside, and once she's far
// enough into the work (phase 2) she starts turning the room inside-out around
// the party: for a two-round pulse, healing runs the wrong direction — every
// cure lands as a wound (gated in stepPlayerActionEffect, floored at 1 HP so a
// player is never killed by their own healer, only softened for the Seamstress's
// own blows to finish). The catch is that she can't invert the room without a
// visible tell: each pulse is telegraphed one full round ahead, so a player who
// reads it holds their heals and waits it out. The autopilot that drives the sim
// does not read tells — so the headless sweep sees the mechanic at its harshest,
// the way an undisciplined party would, and a real player who respects the
// warning fares better. That gap IS the difficulty lever, the same way the Greed
// Tax and the Phylactery Verses are prod-only player-agency levers.
//
// It is the second in-combat Layer-2 mechanic, so it rides the same round-end
// seam Amendment opened. Its state (inversionActive countdown + inversionTelegraph
// warning) round-trips through CombatStatuses so a suspend/resume can't drop or
// double a pulse. A no-op outside the Seamstress's phase 2.
const (
// seamstressPhaseTwoFrac must track The Seamstress's PhaseTwoAt in
// postgame_zone_defs.go (0.35): the inversion only starts once she is sewn
// deep enough into the tear. Kept local for the same reason as
// custodianPhaseTwoFrac — the round-end hook resolves off the session, not the
// ZoneDefinition, so the threshold isn't otherwise in reach here.
seamstressPhaseTwoFrac = 0.35
// inversionPulseRounds is how many rounds each inside-out pulse lasts once it
// activates (heals sting for this many player-rounds).
inversionPulseRounds = 2
)
// applyInversionStitch drives the Seamstress's phase-2 inversion cadence at round
// end: telegraph a pulse one round ahead, activate it for inversionPulseRounds,
// then re-telegraph the next — giving a repeating warn(1) → sting(2) rhythm the
// player can play around. No-op until the boss is in phase 2.
func applyInversionStitch(st *combatState, enemyMaxHP int) {
// Layer-1 fight until the Seamstress is sewn into her own phase 2.
phaseTwo := int(seamstressPhaseTwoFrac * float64(enemyMaxHP))
if st.enemyHP > phaseTwo {
return
}
// An active pulse counts down one round per round end. While it stays above
// zero the next round's heals still sting; when it lapses this round, fall
// through so a fresh telegraph is scheduled immediately.
if st.inversionActive > 0 {
st.inversionActive--
if st.inversionActive > 0 {
return
}
}
// A telegraphed pulse activates: the room turns inside-out for the pulse.
if st.inversionTelegraph {
st.inversionActive = inversionPulseRounds
st.inversionTelegraph = false
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "inversion_stitch",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
return
}
// Otherwise warn: the next round's pulse is one round out.
st.inversionTelegraph = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "inversion_telegraph",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
+534
View File
@@ -0,0 +1,534 @@
package plugin
import (
"strings"
"testing"
)
func TestGreedTaxAttack(t *testing.T) {
cases := []struct {
rich float64
want int
}{
{rich: -1, want: 0},
{rich: 0, want: 0},
{rich: 0.4, want: 0}, // 0.4*2 = 0.8, floors to 0
{rich: 0.5, want: 1}, // one point per 0.5 richness
{rich: 3.5, want: 7}, // the sim's full-explore route
{rich: 5.25, want: 10},
{rich: 6, want: 12}, // exactly the cap
{rich: 100, want: 12}, // capped
}
for _, c := range cases {
if got := greedTaxAttack(c.rich); got != c.want {
t.Errorf("greedTaxAttack(%.2f) = %d, want %d", c.rich, got, c.want)
}
}
}
func TestGreedRouteRichness(t *testing.T) {
// The First Hoard's gilded veins are the only >1.0 LootBias nodes in the
// game: cap12@3.0, f1b2@2.75, r2b2@2.5 (see zone_graph_first_hoard.go). The
// graph builder prefixes every node id with the zone id + ".".
const pre = "first_hoard."
// The full gilded route sums every vein's excess bias.
all := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{pre + "entry", pre + "cap12", pre + "f1b2", pre + "r2b2", pre + "boss"}}
if got := greedRouteRichness(all); !approxEq(got, 5.25) {
t.Errorf("full route richness = %.2f, want 5.25", got)
}
// A single vein contributes only its own excess.
one := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{pre + "entry", pre + "cap12", pre + "boss"}}
if got := greedRouteRichness(one); !approxEq(got, 2.0) {
t.Errorf("one-vein (cap12@3.0) richness = %.2f, want 2.0", got)
}
// A lean line that never touches a vein pays nothing.
lean := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{pre + "entry", pre + "p1", pre + "p2", pre + "boss"}}
if got := greedRouteRichness(lean); !approxEq(got, 0) {
t.Errorf("lean route richness = %.2f, want 0", got)
}
// An unknown zone has no graph and no richness.
if got := greedRouteRichness(&DungeonRun{ZoneID: "nope", VisitedNodes: []string{"x"}}); got != 0 {
t.Errorf("unknown-zone richness = %.2f, want 0", got)
}
}
func TestApplyBossRunModifiers_GreedTax(t *testing.T) {
richRun := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{"first_hoard.cap12", "first_hoard.f1b2", "first_hoard.r2b2"}} // 5.25 rich → +10 Attack
leanRun := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{"first_hoard.entry", "first_hoard.boss"}} // 0 rich → +0
// Aurvandryx's Attack rises with the gilded route walked into her cradle.
enemy := &Combatant{Stats: CombatStats{Attack: 45}}
applyBossRunModifiers("boss_aurvandryx", enemy, richRun)
if enemy.Stats.Attack != 45+10 {
t.Errorf("greedy-route Aurvandryx Attack = %d, want %d", enemy.Stats.Attack, 45+10)
}
// A lean run leaves her at her base Attack.
lean := &Combatant{Stats: CombatStats{Attack: 45}}
applyBossRunModifiers("boss_aurvandryx", lean, leanRun)
if lean.Stats.Attack != 45 {
t.Errorf("lean-run Aurvandryx Attack = %d, want 45", lean.Stats.Attack)
}
// Every non-hooked enemy is untouched, even on the richest route.
other := &Combatant{Stats: CombatStats{Attack: 45}}
applyBossRunModifiers("boss_seamstress", other, richRun)
if other.Stats.Attack != 45 {
t.Errorf("non-hooked boss Attack = %d, want 45 (no-op)", other.Stats.Attack)
}
// A nil run is a no-op, not a panic.
nilRun := &Combatant{Stats: CombatStats{Attack: 45}}
applyBossRunModifiers("boss_aurvandryx", nilRun, nil)
if nilRun.Stats.Attack != 45 {
t.Errorf("nil-run Attack = %d, want 45 (no-op)", nilRun.Stats.Attack)
}
}
func TestPhylacteryReviveCharges(t *testing.T) {
// The Ossuary's three Verses are its only secret nodes: f1b2, f2b2, cap32
// (see zone_graph_ossuary_ascendant.go). Node ids are zone-prefixed.
const pre = "ossuary_ascendant."
// Skip every Verse → all three rebirths stay armed (fight a god).
skip := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "p1", pre + "boss"}}
if got := phylacteryReviveCharges("boss_valdris_ascendant", skip); got != 3 {
t.Errorf("skip-route charges = %d, want 3", got)
}
// Find one Verse → one rebirth unbound, two remain.
one := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "f1b2", pre + "boss"}}
if got := phylacteryReviveCharges("boss_valdris_ascendant", one); got != 2 {
t.Errorf("one-Verse charges = %d, want 2", got)
}
// Full-clear explorer finds all three → 0 rebirths, a mortal lich.
full := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "f1b2", pre + "f2b2", pre + "cap32", pre + "boss"}}
if got := phylacteryReviveCharges("boss_valdris_ascendant", full); got != 0 {
t.Errorf("full-clear charges = %d, want 0", got)
}
// Only Valdris carries the Verses; a nil run and any other boss are 0.
if got := phylacteryReviveCharges("boss_aurvandryx", skip); got != 0 {
t.Errorf("non-Valdris boss charges = %d, want 0", got)
}
if got := phylacteryReviveCharges("boss_valdris_ascendant", nil); got != 0 {
t.Errorf("nil-run charges = %d, want 0", got)
}
}
func TestSeedBossRunStatuses_Phylactery(t *testing.T) {
const pre = "ossuary_ascendant."
skip := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "boss"}} // 3 unfound
// A skip-route seeds all three rebirths and the 25%-of-max revive pool.
sess := &CombatSession{}
if !seedBossRunStatuses(sess, "boss_valdris_ascendant", 560, skip) {
t.Fatal("skip-route seed returned false, want true (charges seeded)")
}
if sess.Statuses.EnemyReviveCharges != 3 {
t.Errorf("seeded charges = %d, want 3", sess.Statuses.EnemyReviveCharges)
}
if sess.Statuses.EnemyReviveHP != 140 { // 560/4
t.Errorf("seeded revive HP = %d, want 140", sess.Statuses.EnemyReviveHP)
}
// A full-clear seeds nothing and reports no change.
full := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "f1b2", pre + "f2b2", pre + "cap32"}}
clean := &CombatSession{}
if seedBossRunStatuses(clean, "boss_valdris_ascendant", 560, full) {
t.Error("full-clear seed returned true, want false (no rebirths)")
}
if clean.Statuses.EnemyReviveCharges != 0 {
t.Errorf("full-clear seeded charges = %d, want 0", clean.Statuses.EnemyReviveCharges)
}
// Non-hooked boss: no-op even on a skip route.
other := &CombatSession{}
if seedBossRunStatuses(other, "boss_seamstress", 560, skip) {
t.Error("non-hooked boss seed returned true, want false")
}
}
// TestEnemyDown_PhylacteryRebirth exercises the engine primitive: each seeded
// charge revives the boss to the revive pool once, and the fight ends only when
// the last charge is spent.
func TestEnemyDown_PhylacteryRebirth(t *testing.T) {
player := &Combatant{Name: "Hero", IsPlayer: true, Stats: CombatStats{MaxHP: 100, AC: 15, Attack: 10}}
seat0 := newActor(player)
st := &combatState{actor: seat0, actors: []*actor{seat0}, enemyReviveCharges: 2, enemyReviveHP: 140}
// First lethal blow: a charge spends, boss revives to the pool, not down.
st.enemyHP = 0
if enemyDown(st, "test") {
t.Fatal("first killing blow reported down, want rebirth")
}
if st.enemyHP != 140 || st.enemyReviveCharges != 1 {
t.Errorf("after 1st rebirth: HP=%d charges=%d, want 140/1", st.enemyHP, st.enemyReviveCharges)
}
// Second lethal blow: last charge spends, revives again.
st.enemyHP = -5
if enemyDown(st, "test") {
t.Fatal("second killing blow reported down, want rebirth")
}
if st.enemyHP != 140 || st.enemyReviveCharges != 0 {
t.Errorf("after 2nd rebirth: HP=%d charges=%d, want 140/0", st.enemyHP, st.enemyReviveCharges)
}
// Third lethal blow: no charges left, the lich stays dead.
st.enemyHP = 0
if !enemyDown(st, "test") {
t.Error("charge-less killing blow reported alive, want down")
}
// A rebirth event was emitted per revival (two), for the narrator.
rebirths := 0
for _, e := range st.events {
if e.Action == "phylactery_rebirth" {
rebirths++
}
}
if rebirths != 2 {
t.Errorf("phylactery_rebirth events = %d, want 2", rebirths)
}
}
func approxEq(a, b float64) bool {
d := a - b
return d < 1e-9 && d > -1e-9
}
// custodianState builds a live combatState seated against a Custodian-shaped
// enemy (MaxHP given), at the given round and current enemy HP, for direct
// applyAmendment tests. Uses the real turn engine so the embedded actor/roster
// are fully formed.
func custodianState(t *testing.T, round, enemyHP, enemyMaxHP int) *combatState {
t.Helper()
sess := turnSession(CombatPhaseRoundEnd, 10000, enemyHP)
sess.Round = round
sess.EnemyID = "boss_custodian"
p := basePlayer()
e := baseEnemy()
e.Stats.MaxHP = enemyMaxHP
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
te.st.enemyHP = enemyHP
return te.st
}
func countEvents(events []CombatEvent, action string) int {
n := 0
for _, ev := range events {
if ev.Action == action {
n++
}
}
return n
}
func TestApplyAmendment_SnapshotAtRoundThree(t *testing.T) {
// Before round 3: no snapshot.
st := custodianState(t, 2, 400, 500)
applyAmendment(st, 500)
if st.enemyRewindHP != 0 {
t.Errorf("round 2 snapshot = %d, want 0 (not yet)", st.enemyRewindHP)
}
// End of round 3: snapshot the current HP.
st = custodianState(t, 3, 380, 500)
applyAmendment(st, 500)
if st.enemyRewindHP != 380 {
t.Errorf("round 3 snapshot = %d, want 380", st.enemyRewindHP)
}
// A later round does not re-snapshot over the captured value.
st.round = 5
st.enemyHP = 200
applyAmendment(st, 500)
if st.enemyRewindHP != 380 {
t.Errorf("post-capture snapshot = %d, want it frozen at 380", st.enemyRewindHP)
}
}
func TestApplyAmendment_RewindOnceAtPhaseTwo(t *testing.T) {
// Snapshot 400; boss now at 200, below the 0.45*500=225 phase-two line.
st := custodianState(t, 6, 200, 500)
st.enemyRewindHP = 400
applyAmendment(st, 500)
if st.enemyHP != 400 {
t.Errorf("post-rewind HP = %d, want restored to snapshot 400", st.enemyHP)
}
if !st.enemyRewindUsed {
t.Error("rewind did not mark itself used")
}
if countEvents(st.events, "amendment_rewind") != 1 {
t.Errorf("amendment_rewind events = %d, want 1", countEvents(st.events, "amendment_rewind"))
}
// A second crossing does not rewind again.
st.enemyHP = 150
mark := len(st.events)
applyAmendment(st, 500)
if st.enemyHP != 150 {
t.Errorf("second-crossing HP = %d, want left at 150 (rewind spent)", st.enemyHP)
}
if len(st.events) != mark {
t.Error("a spent rewind emitted another event")
}
}
func TestApplyAmendment_NoRewindAbovePhaseTwo(t *testing.T) {
// Boss above the phase-two line: no rewind even with a snapshot in hand.
st := custodianState(t, 6, 300, 500) // 300 > 225
st.enemyRewindHP = 450
applyAmendment(st, 500)
if st.enemyHP != 300 || st.enemyRewindUsed {
t.Errorf("HP=%d used=%v; want no rewind above phase two", st.enemyHP, st.enemyRewindUsed)
}
}
func TestApplyAmendment_NoRewindWithoutSnapshot(t *testing.T) {
// Bursted into phase two before round 3: no snapshot, so no free heal.
st := custodianState(t, 2, 100, 500)
applyAmendment(st, 500)
if st.enemyHP != 100 || st.enemyRewindUsed {
t.Errorf("HP=%d used=%v; want no rewind without a snapshot", st.enemyHP, st.enemyRewindUsed)
}
}
func TestApplyAmendment_MidnightTimer(t *testing.T) {
// Before round 20: no attack climb.
st := custodianState(t, 19, 300, 500)
applyAmendment(st, 500)
if st.enemyAtkBuff != 0 {
t.Errorf("round 19 atk buff = %d, want 0", st.enemyAtkBuff)
}
// Round 20+: +2 per round, capped.
st = custodianState(t, 20, 300, 500)
applyAmendment(st, 500)
if st.enemyAtkBuff != midnightAtkStep {
t.Errorf("round 20 atk buff = %d, want %d", st.enemyAtkBuff, midnightAtkStep)
}
if countEvents(st.events, "midnight_toll") != 1 {
t.Errorf("midnight_toll events = %d, want 1", countEvents(st.events, "midnight_toll"))
}
// The cap holds against a truly endless stall.
st.enemyAtkBuff = midnightAtkCap
mark := len(st.events)
applyAmendment(st, 500)
if st.enemyAtkBuff != midnightAtkCap {
t.Errorf("capped atk buff = %d, want %d", st.enemyAtkBuff, midnightAtkCap)
}
if len(st.events) != mark {
t.Error("midnight timer emitted a toll after hitting the cap")
}
}
func TestApplyBossInCombatRoundEnd_DispatchesOnlyCustodian(t *testing.T) {
// A non-Custodian enemy is untouched even at a rewind-eligible HP.
st := custodianState(t, 6, 200, 500)
st.enemyRewindHP = 400
applyBossInCombatRoundEnd(st, "boss_seraphel", 500)
if st.enemyHP != 200 || st.enemyRewindUsed {
t.Errorf("HP=%d used=%v; want no-op for a non-Custodian boss", st.enemyHP, st.enemyRewindUsed)
}
// The Custodian id does resolve the hook.
applyBossInCombatRoundEnd(st, "boss_custodian", 500)
if st.enemyHP != 400 || !st.enemyRewindUsed {
t.Errorf("HP=%d used=%v; want the rewind for the Custodian", st.enemyHP, st.enemyRewindUsed)
}
}
// ── Inversion Stitch (Seamstress) ────────────────────────────────────────────
// seamstressState builds a fully-formed combatState (embedded actor + roster) so
// the event helpers that read st.playerHP resolve — a bare struct-literal state
// has a nil actor cursor.
func seamstressState(t *testing.T, enemyHP, enemyMaxHP int) *combatState {
t.Helper()
sess := turnSession(CombatPhaseRoundEnd, 10000, enemyHP)
sess.EnemyID = "boss_seamstress"
p := basePlayer()
e := baseEnemy()
e.Stats.MaxHP = enemyMaxHP
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
te.st.enemyHP = enemyHP
return te.st
}
func TestApplyInversionStitch_NoOpAbovePhaseTwo(t *testing.T) {
// Above the 0.35*500=175 phase-two line the room stays right-side-out.
st := seamstressState(t, 300, 500)
applyInversionStitch(st, 500)
if st.inversionTelegraph || st.inversionActive != 0 || len(st.events) != 0 {
t.Errorf("above phase two: telegraph=%v active=%d events=%d, want all zero",
st.inversionTelegraph, st.inversionActive, len(st.events))
}
}
func TestApplyInversionStitch_Cadence(t *testing.T) {
// Below the phase-two line the room cycles warn(1) → sting(pulse) → warn(1)…
st := seamstressState(t, 150, 500) // 150 < 0.35*500 = 175
// Round end 1: no pulse yet, so telegraph the first one.
applyInversionStitch(st, 500)
if !st.inversionTelegraph || st.inversionActive != 0 {
t.Fatalf("after warn: telegraph=%v active=%d, want telegraph=true active=0",
st.inversionTelegraph, st.inversionActive)
}
if countEvents(st.events, "inversion_telegraph") != 1 {
t.Fatalf("telegraph events = %d, want 1", countEvents(st.events, "inversion_telegraph"))
}
// Round end 2: the telegraphed pulse activates for its full duration.
applyInversionStitch(st, 500)
if st.inversionActive != inversionPulseRounds || st.inversionTelegraph {
t.Fatalf("after activate: active=%d telegraph=%v, want active=%d telegraph=false",
st.inversionActive, st.inversionTelegraph, inversionPulseRounds)
}
if countEvents(st.events, "inversion_stitch") != 1 {
t.Fatalf("stitch events = %d, want 1", countEvents(st.events, "inversion_stitch"))
}
// The pulse counts down one round per round end, staying active until it lapses.
for r := inversionPulseRounds - 1; r >= 1; r-- {
applyInversionStitch(st, 500)
if st.inversionActive != r {
t.Fatalf("mid-pulse active = %d, want %d", st.inversionActive, r)
}
}
// The round the pulse lapses (active 1→0) a fresh warn is scheduled in the
// same round end — the repeating rhythm, never two silent rounds in a row.
applyInversionStitch(st, 500)
if st.inversionActive != 0 || !st.inversionTelegraph {
t.Fatalf("after pulse: active=%d telegraph=%v, want active=0 telegraph=true",
st.inversionActive, st.inversionTelegraph)
}
if countEvents(st.events, "inversion_telegraph") != 2 {
t.Fatalf("telegraph events = %d, want 2 (the next pulse warned)",
countEvents(st.events, "inversion_telegraph"))
}
}
func TestApplyBossInCombatRoundEnd_DispatchesSeamstress(t *testing.T) {
// A non-Seamstress boss at a phase-two HP is untouched.
st := seamstressState(t, 150, 500)
applyBossInCombatRoundEnd(st, "boss_aurvandryx", 500)
if st.inversionTelegraph || len(st.events) != 0 {
t.Errorf("non-Seamstress boss triggered inversion (telegraph=%v events=%d)",
st.inversionTelegraph, len(st.events))
}
// The Seamstress id resolves the hook.
applyBossInCombatRoundEnd(st, "boss_seamstress", 500)
if !st.inversionTelegraph {
t.Error("boss_seamstress did not schedule the inversion telegraph")
}
}
// TestInversionStitch_HealsSting drives a real round with an active pulse seeded
// and confirms both the self-heal and the ally-heal land as wounds, floored at 1.
func TestInversionStitch_HealsSting(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
t.Run("ally heal stings the friend", func(t *testing.T) {
sess := startAllyHealFight(t, p, 50) // friend hurt to 50/100
sess.Statuses.InversionActive = 1 // room is inside-out this round
healer, friend := basePlayer(), basePlayer()
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
enemy: &Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}},
seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
before := sess.seatHP(1)
events, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast,
Effect: &turnActionEffect{Label: "Cure", Action: "spell_cast", AllyHeal: 30, AllySeat: 1}})
if err != nil {
t.Fatal(err)
}
if got := sess.seatHP(1); got >= before {
t.Errorf("friend HP = %d (was %d) — an inverted heal must wound, not mend", got, before)
}
if countEvents(events, "heal_inverted") != 1 {
t.Errorf("heal_inverted events = %d, want 1", countEvents(events, "heal_inverted"))
}
})
t.Run("self heal stings the acting seat, floored at 1", func(t *testing.T) {
// A self-heal (PlayerHeal, no AllySeat) lands on whichever seat is acting;
// assert the inversion fired (heal_inverted) and no seat was driven to 0 —
// the sting denies sustain, it never kills.
sess := startAllyHealFight(t, p, 100)
sess.Statuses.InversionActive = 1
healer, friend := basePlayer(), basePlayer()
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
enemy: &Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}},
seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
events, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast,
Effect: &turnActionEffect{Label: "Cure Self", Action: "spell_cast", PlayerHeal: 30}})
if err != nil {
t.Fatal(err)
}
if countEvents(events, "heal_inverted") != 1 {
t.Errorf("heal_inverted events = %d, want 1 — the self-heal did not invert", countEvents(events, "heal_inverted"))
}
for seat := 0; seat < 2; seat++ {
if got := sess.seatHP(seat); got < 1 {
t.Errorf("seat %d HP = %d — the sting must floor at 1, never kill", seat, got)
}
}
})
}
func TestTurnEngine_AmendmentRoundTrips(t *testing.T) {
// Drive a real Custodian round-end and confirm the once-only rewind is
// captured through CombatStatuses (a suspend/resume can't replay it).
sess := turnSession(CombatPhaseRoundEnd, 10000, 200)
sess.Round = 6
sess.EnemyID = "boss_custodian"
sess.EnemyHPMax = 500
sess.Statuses.EnemyRewindHP = 400 // snapshot taken earlier in the fight
p := basePlayer()
e := baseEnemy()
e.Stats.MaxHP = 500
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
if _, err := te.step(PlayerAction{}); err != nil {
t.Fatal(err)
}
te.commit()
if sess.EnemyHP != 400 {
t.Errorf("committed EnemyHP = %d, want the 400 rewind pool", sess.EnemyHP)
}
if !sess.Statuses.EnemyRewindUsed {
t.Error("EnemyRewindUsed did not persist through commit")
}
if countEvents(sess.TurnLog, "amendment_rewind") != 1 {
t.Errorf("amendment_rewind events = %d, want 1", countEvents(sess.TurnLog, "amendment_rewind"))
}
}
func TestTurnEngine_InversionRoundTrips(t *testing.T) {
// Drive a real Seamstress round-end in phase 2 and confirm the scheduled
// telegraph persists through CombatStatuses (a suspend/resume keeps the cadence).
sess := turnSession(CombatPhaseRoundEnd, 10000, 150) // 150 < 0.35*500 = 175
sess.EnemyID = "boss_seamstress"
sess.EnemyHPMax = 500
p := basePlayer()
e := baseEnemy()
e.Stats.MaxHP = 500
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
if _, err := te.step(PlayerAction{}); err != nil {
t.Fatal(err)
}
te.commit()
if !sess.Statuses.InversionTelegraph {
t.Error("InversionTelegraph did not persist through commit")
}
if countEvents(sess.TurnLog, "inversion_telegraph") != 1 {
t.Errorf("inversion_telegraph events = %d, want 1", countEvents(sess.TurnLog, "inversion_telegraph"))
}
}