From 91eeee0826b01d3b3a4d808ea41528a3f487bd3e Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:44:25 -0700 Subject: [PATCH] Review follow-up N: Misty's round-end procs fire in turn-based combat DerivePlayerStats builds MistyHealProc / CrowdRevengeProc onto every turn-based combatant, but stepRoundEnd had no counterpart to endOfRoundForSeat, so neither was ever read. The buff (Misty's heal) was silently lost. The debuff (her crowd's revenge) was an exploit: a player who declined Misty escaped it entirely by fighting with !attack instead of letting the room auto-resolve -- no discovery required, just press the button. Hoisted both procs out of endOfRoundForSeat into shared helpers (mistyCrowdRevenge, mistyHeal) and a seatEndOfRound hook that runs the pair in the auto-resolve order. endOfRoundForSeat now calls the helpers; stepRoundEnd calls seatEndOfRound per seat, after the poison tick so a heal can answer the round's damage. A one-sided debuff-only hook would have been a second parallel sibling of the kind deferred item D warns about, so the heal ships with it -- a player-favourable discovery mechanic, consistent with the lift-trailers stance. Both helpers short-circuit before st.randFloat() when their proc is unarmed, so a character with no Misty history draws no dice: the sim corpus and combat_characterization.golden do not move. seatCombatResult now reads MistyHealed back off the misty_heal event (as combat_pet_save always has), so combat_misty_clutch is reachable turn-based. combat_sniper_kill stays unreachable -- Arina's proc is a pre-combat one-shot with no round-end seam. renderAllySeatEvent renders crowd_revenge as unattributed damage: an ally sees the hit but not Misty's name, keeping the grudge the owner's own discovery. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa --- internal/plugin/combat_engine_party.go | 96 +++++++++--- internal/plugin/combat_misty_turn_test.go | 178 ++++++++++++++++++++++ internal/plugin/combat_narrative.go | 6 + internal/plugin/combat_session_build.go | 23 ++- internal/plugin/combat_turn_engine.go | 17 +++ 5 files changed, 296 insertions(+), 24 deletions(-) create mode 100644 internal/plugin/combat_misty_turn_test.go diff --git a/internal/plugin/combat_engine_party.go b/internal/plugin/combat_engine_party.go index 2196eab..46358dc 100644 --- a/internal/plugin/combat_engine_party.go +++ b/internal/plugin/combat_engine_party.go @@ -448,6 +448,78 @@ func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, s return false } +// ── Misty's pair, shared by both engines ───────────────────────────────────── +// +// These two are the only round-end effects the turn engine did not have its own +// copy of. Everything else in endOfRoundForSeat either exists there already (the +// pet, the spiritual weapon — fired after a player action rather than at round +// end), is deliberately absent (the environmental hazard: turnCombatPhase is a +// single flat "Duel" phase with EnvironmentProc at 0), or is replaced by an +// explicit player command (the consumable auto-heal, which is `!consume`). +// +// So they are hoisted rather than re-implemented. A parallel sibling is what let +// the two win close-outs drift apart (deferred item D); this pair is now one +// list of effects with two callers, and cannot. +// +// Neither draws from the RNG unless its proc is armed, so a character with no +// Misty history rolls exactly the dice it rolled before — the sim corpus and +// combat_characterization.golden do not move. + +// mistyCrowdRevenge is the debuff for declining Misty: her crowd takes a swing +// at the end of the round. Returns true when it decided the fight — that is, +// when it dropped this character, the death save failed, and nobody else is +// standing. A downed character in a still-live party returns false. +func mistyCrowdRevenge(st *combatState, player *Combatant, phaseName string) bool { + if player.Mods.CrowdRevengeProc <= 0 || st.randFloat() >= player.Mods.CrowdRevengeProc { + return false + } + dmg := player.Mods.CrowdRevengeDmg + st.playerHP = max(0, st.playerHP-dmg) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge", + Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + Desc: "Misty's crowd", + }) + return st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive() +} + +// mistyHeal is the buff's payout. It cannot decide the fight, so it returns +// nothing. result may be a scratch value the caller discards (the turn engine's +// is) — the durable record is the misty_heal event on the log, which is what +// seatCombatResult reads to award combat_misty_clutch. +func mistyHeal(st *combatState, player *Combatant, phaseName string, result *CombatResult) { + if player.Mods.MistyHealProc <= 0 || st.randFloat() >= player.Mods.MistyHealProc { + return + } + healAmt := player.Mods.MistyHealAmt + st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt) + result.MistyHealed = true + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal", + Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + Desc: "Misty", + }) +} + +// seatEndOfRound runs the round-end effects the turn engine owes one seat, in +// the order endOfRoundForSeat runs them: the crowd's swing, then — if the seat +// survived it — Misty's heal. Returns true when the fight is over. +// +// It exists because the turn engine's stepRoundEnd never ran either one. A +// player carrying Misty's buff lost it by fighting manually; worse, a player +// carrying her debuff *escaped* it by doing the same, which needed no discovery +// to exploit — just press !attack. +func seatEndOfRound(st *combatState, player *Combatant, phaseName string, result *CombatResult) bool { + if over := mistyCrowdRevenge(st, player, phaseName); over { + return true + } + if st.playerHP <= 0 { + return false + } + mistyHeal(st, player, phaseName, result) + return false +} + // endOfRoundForSeat runs the per-character close of a round against the seat the // cursor already points at: environment, Misty's crowd, the pet, the spiritual // weapon, Misty's heal, and the consumable auto-heal — in that order, which is @@ -470,17 +542,8 @@ func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult } // Misty crowd revenge (debuff for declining Misty) - if player.Mods.CrowdRevengeProc > 0 && st.randFloat() < player.Mods.CrowdRevengeProc { - dmg := player.Mods.CrowdRevengeDmg - st.playerHP = max(0, st.playerHP-dmg) - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge", - Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - Desc: "Misty's crowd", - }) - if st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive() { - return true - } + if over := mistyCrowdRevenge(st, player, phaseName); over { + return true } // A character the round just killed stops acting, but the fight goes on if @@ -517,16 +580,7 @@ func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult } // Misty heal - if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc { - healAmt := player.Mods.MistyHealAmt - st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt) - result.MistyHealed = true - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal", - Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - Desc: "Misty", - }) - } + mistyHeal(st, player, phaseName, result) // Consumable heal: triggers when the character drops below 60% HP. Fires up // to HealItemCharges times per fight (1 = legacy one-shot; bosses can diff --git a/internal/plugin/combat_misty_turn_test.go b/internal/plugin/combat_misty_turn_test.go new file mode 100644 index 0000000..3467806 --- /dev/null +++ b/internal/plugin/combat_misty_turn_test.go @@ -0,0 +1,178 @@ +package plugin + +import ( + "math/rand/v2" + "testing" +) + +// Misty's two round-end procs on the turn-based surface. +// +// Both were built onto every turn-based combatant by DerivePlayerStats and then +// never read: stepRoundEnd had no counterpart to endOfRoundForSeat. The buff was +// simply lost. The debuff was an exploit — a player who declined Misty escaped +// her crowd entirely by fighting with !attack instead of letting the room +// auto-resolve, which needed no discovery at all. +// +// seatEndOfRound is the shared hook. These pin that it fires, that it cannot be +// dodged, and that a character with no Misty history is not touched at all — +// the property that keeps the sim corpus and the golden file still. + +// mistyState seats one combatant at hp, cursor armed, with a seeded RNG. +func mistyState(t *testing.T, player *Combatant, hp int) *combatState { + t.Helper() + st := testCombatState(hp, 100, 1, rand.New(rand.NewPCG(7, 7))) + st.actor.c = player + st.actor.hpMax = player.Stats.MaxHP + return st +} + +// The two procs at certainty, so no test depends on a roll. +func mistyCursed(maxHP, dmg int) *Combatant { + return &Combatant{ + Name: "Cursed", + Stats: CombatStats{MaxHP: maxHP, AC: 10}, + Mods: CombatModifiers{CrowdRevengeProc: 1.0, CrowdRevengeDmg: dmg}, + } +} + +func mistyBuffed(maxHP, heal int) *Combatant { + return &Combatant{ + Name: "Buffed", + Stats: CombatStats{MaxHP: maxHP, AC: 10}, + Mods: CombatModifiers{MistyHealProc: 1.0, MistyHealAmt: heal}, + } +} + +func hasAction(events []CombatEvent, action string) bool { + for _, e := range events { + if e.Action == action { + return true + } + } + return false +} + +// ── the exploit ────────────────────────────────────────────────────────────── + +// The debuff must land at round end in a manual fight, exactly as it does when +// the room auto-resolves. Before seatEndOfRound, !attack was a clean escape. +func TestSeatEndOfRound_CrowdRevengeCannotBeDodgedByFightingManually(t *testing.T) { + st := mistyState(t, mistyCursed(40, 6), 40) + over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{}) + + if over { + t.Fatal("a 6-damage swing against 40 HP should not end the fight") + } + if st.playerHP != 34 { + t.Errorf("playerHP = %d, want 34 — Misty's crowd took its swing", st.playerHP) + } + if !hasAction(st.events, "crowd_revenge") { + t.Error("no crowd_revenge event on the log") + } +} + +// The debuff can be lethal, and a lethal one with nobody else standing ends the +// fight rather than leaving a corpse to take the next round's turn. +func TestSeatEndOfRound_CrowdRevengeCanEndASoloFight(t *testing.T) { + st := mistyState(t, mistyCursed(40, 30), 3) + over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{}) + + if st.playerHP != 0 { + t.Fatalf("playerHP = %d, want 0", st.playerHP) + } + if !over { + t.Error("a solo character dropped by the crowd should end the fight") + } +} + +// ── the buff ───────────────────────────────────────────────────────────────── + +// The heal fires, caps at max HP, and marks the result so the achievement can +// be granted. +func TestSeatEndOfRound_MistyHealFiresAndCaps(t *testing.T) { + st := mistyState(t, mistyBuffed(40, 12), 34) + var res CombatResult + seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &res) + + if st.playerHP != 40 { + t.Errorf("playerHP = %d, want 40 — the heal caps at max HP", st.playerHP) + } + if !res.MistyHealed { + t.Error("MistyHealed flag not set") + } + if !hasAction(st.events, "misty_heal") { + t.Error("no misty_heal event on the log") + } +} + +// A character the crowd just dropped is not then healed back up by the same +// hook. The heal is for the living. +func TestSeatEndOfRound_NoHealForACharacterTheCrowdJustDropped(t *testing.T) { + c := mistyCursed(40, 40) + c.Mods.MistyHealProc = 1.0 + c.Mods.MistyHealAmt = 20 + + st := mistyState(t, c, 10) + st.actors = append(st.actors, &actor{playerHP: 5}) // an ally keeps the fight alive + over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{}) + + if over { + t.Fatal("an ally is still standing — the fight is not over") + } + if st.playerHP != 0 { + t.Errorf("playerHP = %d, want 0 — the heal must not resurrect them", st.playerHP) + } + if hasAction(st.events, "misty_heal") { + t.Error("misty_heal fired on a downed character") + } +} + +// ── the property that protects the corpus ──────────────────────────────────── + +// A character with no Misty history is untouched: no HP change, no events, and +// — because both procs short-circuit before st.randFloat() — no dice drawn. If +// the hook drew even one float, every simulated fight would diverge and +// combat_characterization.golden would move. +func TestSeatEndOfRound_UnbuffedCharacterIsUntouchedAndDrawsNoDice(t *testing.T) { + plain := &Combatant{Name: "Plain", Stats: CombatStats{MaxHP: 40, AC: 10}} + + st := mistyState(t, plain, 40) + control := mistyState(t, plain, 40) // same seed, never passed to the hook + + if over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{}); over { + t.Fatal("an unbuffed character cannot be dropped by a hook that does nothing") + } + if st.playerHP != 40 || len(st.events) != 0 { + t.Errorf("unbuffed character was touched: hp=%d events=%d", st.playerHP, len(st.events)) + } + // The RNG streams must still be in lockstep: the hook consumed nothing. + if got, want := st.randFloat(), control.randFloat(); got != want { + t.Errorf("seatEndOfRound consumed RNG: next float %v, want %v", got, want) + } +} + +// ── the wiring ─────────────────────────────────────────────────────────────── + +// The unit tests above call seatEndOfRound directly, which proves the hook is +// correct but not that stepRoundEnd calls it. This drives the real session API +// through a round_end step. Comment the call out of stepRoundEnd and this test +// reports PlayerHP=40 — the exploit, exactly as it shipped. +func TestStepRoundEnd_WiresSeatEndOfRound(t *testing.T) { + setupEmptyTestDB(t) + player := mistyCursed(40, 6) + enemy := &Combatant{Name: "Target", Stats: CombatStats{MaxHP: 100, AC: 10}} + sess := &CombatSession{ + SessionID: "wiring", UserID: "@u:example.org", Status: CombatStatusActive, + PlayerHP: 40, PlayerHPMax: 40, EnemyHP: 100, + Phase: CombatPhaseRoundEnd, Round: 1, + } + if _, err := advanceCombatSession(sess, player, enemy, PlayerAction{}); err != nil { + t.Fatal(err) + } + if sess.PlayerHP != 34 { + t.Errorf("PlayerHP = %d, want 34 — stepRoundEnd never ran the hook", sess.PlayerHP) + } + if !hasAction(sess.TurnLog, "crowd_revenge") { + t.Error("no crowd_revenge event persisted to the session log") + } +} diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index 98b6915..ae3b8b3 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -979,6 +979,12 @@ func renderAllySeatEvent(e CombatEvent, name, enemyName string) string { return down(fmt.Sprintf("☠️ %s takes %d from poison.", who, e.Damage)) case "environmental": return down(fmt.Sprintf("%s takes %d from the room.", who, e.Damage)) + case "crowd_revenge": + // Deliberately unattributed. An ally sees the damage land — silence + // would read as HP vanishing — but Misty's grudge is the owner's own + // discovery, and naming her here would spoil it for the whole party. + // Same reason misty_heal below reads as a plain recovery. + return down(fmt.Sprintf("%s takes %d.", who, e.Damage)) case "flat_damage": return fmt.Sprintf("%s deals %d.", who, e.Damage) case "heal_item", "misty_heal": diff --git a/internal/plugin/combat_session_build.go b/internal/plugin/combat_session_build.go index 2dd65ec..55afe42 100644 --- a/internal/plugin/combat_session_build.go +++ b/internal/plugin/combat_session_build.go @@ -208,21 +208,38 @@ func seatFightStartMods(sess *CombatSession, seat int, c *DnDCharacter) CombatMo // engine never builds a CombatResult — it persists a session and a shared event // log — so the close-out has to assemble one. // -// SniperKilled and MistyHealed stay false because the turn engine has no Arina -// or Misty proc to set them: those two live only in the auto-resolve engine. +// MistyHealed is read back off the seat's event log rather than off a flag: the +// turn engine's CombatResult is a scratch value it discards between steps, and +// the log is the only thing that survives to the close-out. This is how +// combat_pet_save has always been detected. +// +// SniperKilled stays false. Arina's proc is a pre-combat one-shot, not a +// round-end effect, so seatEndOfRound does not carry it and the turn engine +// still has no place to fire it — combat_sniper_kill remains unreachable from a +// manual kill. +// // NearDeath mirrors the auto-resolve engine's win threshold (below 15% of max); // its loss-side meaning is unused here, since the only hook that reads it gates // on PlayerWon. func seatCombatResult(sess *CombatSession, seat int) CombatResult { hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat) won := sess.Status == CombatStatusWon + events := eventsForSeat(sess.TurnLog, seat) + misty := false + for _, ev := range events { + if ev.Action == "misty_heal" { + misty = true + break + } + } return CombatResult{ PlayerWon: won, - Events: eventsForSeat(sess.TurnLog, seat), + Events: events, PlayerEndHP: hp, EnemyEndHP: sess.EnemyHP, TotalRounds: sess.Round, NearDeath: won && hp > 0 && float64(hp) < float64(max(1, hpMax))*0.15, + MistyHealed: misty, } } diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 5d83719..3e88796 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -753,6 +753,23 @@ func (te *turnEngine) stepRoundEnd() { } te.stampSeat(mark, i) } + // Misty's crowd, then Misty's heal — per seat, after the round's other + // damage has landed so the heal can answer it, which is the order + // endOfRoundForSeat uses. Both no-op (and draw no RNG) for a character with + // no Misty history, which is every simulated one. + for i := range st.actors { + st.seat(i) + if st.playerHP <= 0 { + continue + } + mark := len(st.events) + over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, te.result) + te.stampSeat(mark, i) + if over { + te.finish(CombatStatusLost) + return + } + } // Concentration aura (Spirit Guardians et al.): the lingering spell bites // the enemy each round it stays up. Concentration is per-caster, so every // seat holding one pulses. Ticks before enemy regen so a lethal pulse