Files
gogobee/internal/plugin/combat_misty_turn_test.go
prosolis 91eeee0826 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
2026-07-10 08:44:25 -07:00

179 lines
6.8 KiB
Go

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")
}
}