N3/P2: give the combat engine an N-player roster

Splits combatState into a fight-scoped half and a per-character half.
Everything that belongs to one PC -- HP, ward/spore/reflect charges,
heal charges, poison ticks, the death save, Lucky/Rage, the
first-attack one-shots, the arcane ward, concentration, and the
debuffs an enemy stacks onto a specific character -- moves to a new
`actor`. What belongs to the fight stays: the enemy pool, the enemy's
stance (evade/block/advantage/retaliate/regen/survive), the round
counter, the event log, and the RNG stream.

combatState embeds *actor, so the promoted fields keep their names and
all ~230 existing reads (st.playerHP, st.wardCharges, ...) compile
untouched. The embedded pointer is a cursor: seat(i) points it at a
roster member. Solo seats one actor and never moves the cursor, so the
draw order off the single RNG stream is unchanged.

That is the whole point. TestCombatCharacterization -- 57 scenarios x
5 seeds, 7468 pinned golden lines -- is byte-identical before and
after. Solo combat provably did not move, so the d8prereq balance
corpus survives the parties work and only party bands need new
baselines in P7.

Hold-person is fight-scoped (holding the enemy holds it for everyone)
while stat_drain/debuff/max_hp_drain are per-character, which is why
they landed on opposite sides of the split.

No multi-actor *semantics* here: nothing yet decides who the enemy
swings at or how initiative interleaves N players. That is P3. This
commit only lands the data model, and the roster tests cover what the
solo golden structurally cannot see -- cursor isolation, shared-state
visibility across seats, and the pointer embed (a value embed would
silently copy on seat() and fail the round-trip assertion).
This commit is contained in:
prosolis
2026-07-09 20:29:05 -07:00
parent fc0dff710e
commit 41f98b721a
4 changed files with 297 additions and 62 deletions

View File

@@ -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

View File

@@ -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,

View File

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

View File

@@ -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,