mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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
179 lines
6.8 KiB
Go
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")
|
|
}
|
|
}
|