Files
gogobee/internal/plugin/combat_roster_test.go
prosolis 41f98b721a 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).
2026-07-09 20:29:05 -07:00

154 lines
4.6 KiB
Go

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