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