diff --git a/internal/plugin/bestiary_srd_test.go b/internal/plugin/bestiary_srd_test.go index deaea5d..fed287c 100644 --- a/internal/plugin/bestiary_srd_test.go +++ b/internal/plugin/bestiary_srd_test.go @@ -39,9 +39,23 @@ func TestEnemyAttackProfile_Registered(t *testing.T) { } } +// testCombatState seats a single actor, the shape every direct-primitive test +// wants. The engine reads per-actor state through the embedded cursor, so a +// combatState with a nil actor panics on the first st.playerHP touch. +func testCombatState(playerHP, enemyHP, round int, rng *rand.Rand) *combatState { + a := &actor{playerHP: playerHP} + return &combatState{ + actor: a, + actors: []*actor{a}, + enemyHP: enemyHP, + round: round, + rng: rng, + } +} + func TestTurnAbilityFires(t *testing.T) { enemy := baseEnemy() // MaxHP 60 - st := &combatState{rng: rand.New(rand.NewPCG(1, 1))} + st := testCombatState(0, 0, 0, rand.New(rand.NewPCG(1, 1))) cases := []struct { name string @@ -78,10 +92,7 @@ func TestTurnAbilityFires(t *testing.T) { // within applyAbility with no persistent state. func TestApplyAbility_Slice2Effects(t *testing.T) { newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) { - st := &combatState{ - playerHP: playerHP, enemyHP: enemyHP, round: 1, - rng: rand.New(rand.NewPCG(7, 7)), - } + st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(7, 7))) return st, basePlayer(), baseEnemy() } phase := &turnCombatPhase @@ -136,10 +147,7 @@ func TestApplyAbility_Slice2Effects(t *testing.T) { // all but evade), and the shared resolution primitives read that state. func TestApplyAbility_Slice3Effects(t *testing.T) { newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) { - st := &combatState{ - playerHP: playerHP, enemyHP: enemyHP, round: 1, - rng: rand.New(rand.NewPCG(9, 9)), - } + st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(9, 9))) return st, basePlayer(), baseEnemy() } phase := &turnCombatPhase @@ -188,7 +196,8 @@ func TestApplyAbility_Slice3Effects(t *testing.T) { } // enemyDown lets survive_at_1 cheat death exactly once. - stS := &combatState{enemyHP: 0, enemySurviveArmed: true} + stS := testCombatState(100, 0, 1, rand.New(rand.NewPCG(13, 13))) + stS.enemySurviveArmed = true if enemyDown(stS, "Duel") { t.Error("survive_at_1: armed enemy at 0 HP should not be down") } @@ -224,10 +233,7 @@ func TestApplyAbility_Slice3Effects(t *testing.T) { // itself, and the shared resolution primitives / helpers read that state. func TestApplyAbility_Slice4Effects(t *testing.T) { newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) { - st := &combatState{ - playerHP: playerHP, enemyHP: enemyHP, round: 1, - rng: rand.New(rand.NewPCG(11, 11)), - } + st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(11, 11))) return st, basePlayer(), baseEnemy() } phase := &turnCombatPhase diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 09ed397..1181d4a 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -292,10 +292,23 @@ var bossCombatPhases = []CombatPhase{ // ── Simulation ─────────────────────────────────────────────────────────────── -// combatState tracks mutable state during the simulation. -type combatState struct { +// actor is the per-combatant half of the simulation state — everything that +// belongs to one player character rather than to the fight as a whole. +// +// It is embedded into combatState as a *pointer*, which promotes its fields: +// `st.playerHP` still resolves, now to `st.actor.playerHP`. The embedded +// pointer is a cursor naming whoever is currently resolving. Solo combat has a +// one-element roster and never moves the cursor, so every read and every RNG +// draw is bit-identical to the pre-roster engine — which is what keeps the +// solo balance corpus (sim_results/d8prereq_corpus.jsonl lineage) valid. +// +// Field names are deliberately unchanged from when they lived on combatState. +type actor struct { + // c is the Combatant this state belongs to. Nil only in tests that drive + // a primitive directly without a roster. + c *Combatant + playerHP int - enemyHP int // Consumable one-shots healChargesLeft int // remaining heal-at-<50% triggers @@ -304,13 +317,10 @@ type combatState struct { reflectFrac float64 autoCrit bool - // Monster ability effects - poisonTicks int - poisonDmg int - stunPlayer bool - enraged bool - armorBroken bool - armorBreakAmt float64 + // Monster ability effects landing on this actor + poisonTicks int + poisonDmg int + stunPlayer bool // Sovereign reprieve deathSaveUsed bool @@ -320,10 +330,6 @@ type combatState struct { raged bool // Orc Rage already triggered this fight pendingRageAttack bool // next player attack gets +50% damage - // Phase 9 spell — enemy skip-first-attack (consumed on the first round - // the enemy would otherwise attack). - enemySkipFirst bool - // Phase 10 SUB2a-ii first-attack one-shots. firstAttackBonusUsed bool assassinateRerollUsed bool @@ -332,27 +338,52 @@ type combatState struct { // Phase 10 SUB2b — Abjuration Arcane Ward HP buffer. arcaneWardHP int + // Debuffs the enemy has stacked onto this actor specifically. + playerAtkDrain int // stat_drain: flat reduction to this actor's hit damage + playerACDebuff int // debuff: flat reduction to this actor's effective AC + maxHPDrain int // max_hp_drain: reduction to this actor's effective MaxHP + // concentrationDmg — per-round damage of an active concentration AOE - // (Spirit Guardians et al.). Armed by a !cast of a concentration damage - // spell, ticked against the enemy every round_end until the fight ends - // or another concentration spell overwrites it. Only the turn engine - // reads it; SimulateCombat resolves whole fights in one pass and folds - // the aura's value into the picker's concentration multiplier instead. + // (Spirit Guardians et al.). Concentration is per-caster, so it lives + // here rather than on the fight. concentrationDmg int +} + +// combatState tracks mutable state during the simulation. The embedded *actor +// is the cursor: the player character currently resolving. Everything declared +// directly on combatState is shared by the whole fight — the enemy, the round +// counter, the event log, and the RNG stream. +type combatState struct { + *actor // cursor into actors; promotes the per-actor fields + actors []*actor // the player roster, in seating order. len == 1 for solo. + + enemyHP int + + // Monster ability effects (enemy-side stance — shared across the roster) + enraged bool + armorBroken bool + armorBreakAmt float64 + + // Phase 9 spell — enemy skip-first-attack (consumed on the first round + // the enemy would otherwise attack). Holding the enemy holds it for + // everyone, so this is fight-scoped, not per-caster. + enemySkipFirst bool // Phase 13 bestiary slice 3 — stateful monster-ability effects. Each is // armed by applyAbility and read by the shared resolution primitives, so // both engines honour them; the turn-based engine additionally round-trips // them through CombatStatuses so they survive a suspend/resume. + // + // These describe the *enemy's* stance, so they are fight-scoped: an enemy + // holding a parry stance parries the next swing from anyone. The debuffs + // it stacks onto a specific character (stat_drain / debuff / max_hp_drain) + // live on actor instead. enemyEvadeNext bool // evade: next player weapon attack auto-misses enemyBlockUp bool // block: enemy holds a parry stance (~50% block on player hits) enemyAdvantage bool // advantage: enemy rolls its attacks with advantage enemyRetaliateFrac float64 // retaliate: fraction of a player hit reflected back enemyRegen int // regenerate: enemy heals this much each round end enemySurviveArmed bool // survive_at_1: enemy cheats death once, dropping to 1 HP - playerAtkDrain int // stat_drain: flat reduction to the player's hit damage - playerACDebuff int // debuff: flat reduction to the player's effective AC - maxHPDrain int // max_hp_drain: reduction to the player's effective MaxHP // Phase 13 bestiary slice 4 — the former flavor-only placeholders, now // backed by real state. @@ -377,6 +408,47 @@ type combatState struct { // Auto-resolve leaves st.rng nil — behaviorally identical to the // pre-injection code; the turn-based engine and the timeout reaper seed // it per session so a fight can be resumed and replayed reproducibly. +// newActor seats one player character, deriving its opening per-fight state +// from that character's modifiers. +func newActor(c *Combatant) *actor { + startHP := c.Stats.MaxHP + if c.Stats.StartHP > 0 && c.Stats.StartHP < c.Stats.MaxHP { + startHP = c.Stats.StartHP + } + a := &actor{ + c: c, + playerHP: startHP, + wardCharges: c.Mods.WardCharges, + sporeRounds: c.Mods.SporeCloud, + reflectFrac: c.Mods.ReflectNext, + autoCrit: c.Mods.AutoCritFirst, + arcaneWardHP: c.Mods.ArcaneWardHP, + } + // HealItemCharges: explicit count overrides legacy one-shot. Backfill + // to 1 charge if the caller set a HealItem amount but no count. + a.healChargesLeft = c.Mods.HealItemCharges + if a.healChargesLeft == 0 && c.Mods.HealItem > 0 { + a.healChargesLeft = 1 + } + return a +} + +// seat points the cursor at roster index i. Every per-actor read in the +// resolution primitives (st.playerHP, st.wardCharges, …) follows the cursor. +// Solo combat seats index 0 once and never moves it. +func (st *combatState) seat(i int) { st.actor = st.actors[i] } + +// anyAlive reports whether at least one seated character is still standing. +// Solo fights read this as "the player is alive". +func (st *combatState) anyAlive() bool { + for _, a := range st.actors { + if a.playerHP > 0 { + return true + } + } + return false +} + func (st *combatState) roll(n int) int { return rngIntN(st.rng, n) } func (st *combatState) randFloat() float64 { return rngFloat(st.rng) } @@ -398,23 +470,18 @@ func simulateCombatWithRNG(player, enemy Combatant, phases []CombatPhase, rng *r if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP { enemyStart = enemy.Stats.StartHP } + // Solo roster: one seat, cursor parked on it for the whole fight. The + // resolution primitives read the cursor, so this is the degenerate case + // of the N-player model and draws RNG in exactly the pre-roster order. + seat0 := newActor(&player) + seat0.playerHP = playerStart st := &combatState{ - playerHP: playerStart, + actor: seat0, + actors: []*actor{seat0}, enemyHP: enemyStart, - wardCharges: player.Mods.WardCharges, - sporeRounds: player.Mods.SporeCloud, - reflectFrac: player.Mods.ReflectNext, - autoCrit: player.Mods.AutoCritFirst, enemySkipFirst: player.Mods.SpellEnemySkipFirst, - arcaneWardHP: player.Mods.ArcaneWardHP, rng: rng, } - // HealItemCharges: explicit count overrides legacy one-shot. Backfill - // to 1 charge if the caller set a HealItem amount but no count. - st.healChargesLeft = player.Mods.HealItemCharges - if st.healChargesLeft == 0 && player.Mods.HealItem > 0 { - st.healChargesLeft = 1 - } result := CombatResult{ PlayerStartHP: player.Stats.MaxHP, diff --git a/internal/plugin/combat_roster_test.go b/internal/plugin/combat_roster_test.go new file mode 100644 index 0000000..a5edf91 --- /dev/null +++ b/internal/plugin/combat_roster_test.go @@ -0,0 +1,153 @@ +package plugin + +// Roster/cursor tests for the N-player combat state (N3/P2). +// +// The solo path is pinned bit-for-bit by TestCombatCharacterization; these +// cover the machinery that pin cannot see, because solo never moves the +// cursor off seat 0. + +import ( + "math/rand/v2" + "testing" +) + +func TestNewActor_DerivesPerFightStateFromMods(t *testing.T) { + c := basePlayer() + c.Mods.WardCharges = 2 + c.Mods.SporeCloud = 3 + c.Mods.ReflectNext = 0.5 + c.Mods.AutoCritFirst = true + c.Mods.ArcaneWardHP = 25 + + a := newActor(&c) + + if a.c != &c { + t.Error("actor should point back at its Combatant") + } + if a.playerHP != c.Stats.MaxHP { + t.Errorf("playerHP = %d, want MaxHP %d", a.playerHP, c.Stats.MaxHP) + } + if a.wardCharges != 2 || a.sporeRounds != 3 || a.reflectFrac != 0.5 || !a.autoCrit || a.arcaneWardHP != 25 { + t.Errorf("consumable one-shots not carried from Mods: %+v", a) + } +} + +func TestNewActor_StartHPAndHealChargeBackfill(t *testing.T) { + // StartHP below MaxHP means the character walks in wounded. + wounded := basePlayer() + wounded.Stats.StartHP = 40 + if got := newActor(&wounded).playerHP; got != 40 { + t.Errorf("wounded entry HP = %d, want 40", got) + } + + // StartHP at or above MaxHP is ignored (guards a stale snapshot). + full := basePlayer() + full.Stats.StartHP = 999 + if got := newActor(&full).playerHP; got != full.Stats.MaxHP { + t.Errorf("StartHP above MaxHP should not raise entry HP, got %d", got) + } + + // Legacy one-shot: a HealItem amount with no explicit count backfills to 1. + legacy := basePlayer() + legacy.Mods.HealItem = 30 + if got := newActor(&legacy).healChargesLeft; got != 1 { + t.Errorf("legacy heal backfill = %d charges, want 1", got) + } + + // An explicit count wins over the backfill. + stocked := basePlayer() + stocked.Mods.HealItem = 30 + stocked.Mods.HealItemCharges = 4 + if got := newActor(&stocked).healChargesLeft; got != 4 { + t.Errorf("explicit heal charges = %d, want 4", got) + } + + // No HealItem at all means no charges, even if a count leaked through. + none := basePlayer() + if got := newActor(&none).healChargesLeft; got != 0 { + t.Errorf("no heal item should mean 0 charges, got %d", got) + } +} + +// The cursor is the whole point of the embed: per-actor fields must follow +// seat(), and fight-scoped fields must not. +func TestCombatState_SeatSwitchesPerActorStateOnly(t *testing.T) { + alice, bob := basePlayer(), basePlayer() + alice.Name, bob.Name = "Alice", "Bob" + a0, a1 := newActor(&alice), newActor(&bob) + + st := &combatState{ + actor: a0, + actors: []*actor{a0, a1}, + enemyHP: 100, + rng: rand.New(rand.NewPCG(1, 1)), + } + + // Wound seat 0 and burn one of its once-per-fight one-shots. + st.seat(0) + st.playerHP = 10 + st.luckyUsed = true + + // Seat 1 must be untouched — a promoted write goes to the cursor, not the + // struct. This is the assertion that would fail if actor were embedded by + // value instead of by pointer. + st.seat(1) + if st.playerHP != bob.Stats.MaxHP { + t.Errorf("seat 1 HP = %d, want a full pool %d — seat 0's wound leaked", st.playerHP, bob.Stats.MaxHP) + } + if st.luckyUsed { + t.Error("seat 1 saw seat 0's consumed Lucky reroll") + } + if st.c.Name != "Bob" { + t.Errorf("cursor Combatant = %q, want Bob", st.c.Name) + } + + // Fight-scoped state is shared: writing it under one seat is visible + // under the other. + st.enemyHP = 42 + st.enemyBlockUp = true + st.seat(0) + if st.playerHP != 10 || !st.luckyUsed { + t.Error("seat 0 lost its own state across a cursor round-trip") + } + if st.enemyHP != 42 || !st.enemyBlockUp { + t.Error("enemy stance should be fight-scoped, not per-actor") + } + if st.c.Name != "Alice" { + t.Errorf("cursor Combatant = %q, want Alice", st.c.Name) + } +} + +func TestCombatState_AnyAlive(t *testing.T) { + alice, bob := basePlayer(), basePlayer() + a0, a1 := newActor(&alice), newActor(&bob) + st := &combatState{actor: a0, actors: []*actor{a0, a1}} + + if !st.anyAlive() { + t.Error("a fresh roster should be alive") + } + a0.playerHP = 0 + if !st.anyAlive() { + t.Error("one downed member should not end the fight while another stands") + } + a1.playerHP = 0 + if st.anyAlive() { + t.Error("a fully downed roster should not be alive") + } +} + +// Solo fights seat exactly one actor and park the cursor on it. If this ever +// regresses, the characterization golden stops proving anything about the +// production auto-resolve path. +func TestSimulateCombat_SeatsExactlyOneActor(t *testing.T) { + p, e := basePlayer(), baseEnemy() + seat0 := newActor(&p) + st := &combatState{actor: seat0, actors: []*actor{seat0}, enemyHP: e.Stats.MaxHP} + + if len(st.actors) != 1 { + t.Fatalf("solo roster length = %d, want 1", len(st.actors)) + } + if st.actor != st.actors[0] { + t.Error("solo cursor must point at seat 0") + } +} diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 448202c..56f652a 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -111,17 +111,15 @@ func phaseOrdinal(phase string) uint64 { // resumeTurnEngine rebuilds the in-memory combatState from a persisted session. // rng is the deterministic source for this step (see combatSessionRNG). func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.Rand) *turnEngine { - st := &combatState{ - playerHP: sess.PlayerHP, - enemyHP: sess.EnemyHP, - round: sess.Round, - poisonTicks: sess.Statuses.PoisonTicks, - poisonDmg: sess.Statuses.PoisonDmg, - stunPlayer: sess.Statuses.StunPlayer, - enraged: sess.Statuses.Enraged, - armorBroken: sess.Statuses.ArmorBroken, - armorBreakAmt: sess.Statuses.ArmorBreakAmt, - enemySkipFirst: sess.Statuses.EnemySkipNext, + // The seated character's half of the persisted statuses. A single-seat + // roster today; P4 gives combat_session per-participant rows and this + // becomes one actor per member. + seat0 := &actor{ + c: player, + playerHP: sess.PlayerHP, + poisonTicks: sess.Statuses.PoisonTicks, + poisonDmg: sess.Statuses.PoisonDmg, + stunPlayer: sess.Statuses.StunPlayer, // Fight-scoped depleting resources + once-per-fight one-shots: restored // from the persisted statuses so a charge or "already used" flag can't // reset across a suspend/resume. commit writes the updated values back. @@ -139,6 +137,20 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R firstAttackBonusUsed: sess.Statuses.FirstAtkBonusUsed, assassinateRerollUsed: sess.Statuses.AssassinateReroll, assassinateBonusUsed: sess.Statuses.AssassinateBonus, + // Enemy debuffs stacked onto this character specifically. + playerAtkDrain: sess.Statuses.PlayerAtkDrain, + playerACDebuff: sess.Statuses.PlayerACDebuff, + maxHPDrain: sess.Statuses.MaxHPDrain, + } + st := &combatState{ + actor: seat0, + actors: []*actor{seat0}, + enemyHP: sess.EnemyHP, + round: sess.Round, + enraged: sess.Statuses.Enraged, + armorBroken: sess.Statuses.ArmorBroken, + armorBreakAmt: sess.Statuses.ArmorBreakAmt, + enemySkipFirst: sess.Statuses.EnemySkipNext, // Slice-3 stateful monster-ability effects — armed by applyAbility, // round-tripped here so they survive a suspend/resume or reaper auto-play. enemyEvadeNext: sess.Statuses.EnemyEvadeNext, @@ -147,9 +159,6 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac, enemyRegen: sess.Statuses.EnemyRegen, enemySurviveArmed: sess.Statuses.EnemySurviveArmed, - playerAtkDrain: sess.Statuses.PlayerAtkDrain, - playerACDebuff: sess.Statuses.PlayerACDebuff, - maxHPDrain: sess.Statuses.MaxHPDrain, // Slice-4 monster-ability effects — the former flavor-only placeholders. enemySpellResist: sess.Statuses.EnemySpellResist, enemyRevealNext: sess.Statuses.EnemyRevealNext,