Files
gogobee/internal/plugin/combat_turn_party_test.go
prosolis ec614e84f1 N3/P3: initiative, and a turn engine that seats a party
The turn engine ran a fixed player -> enemy -> round_end phase machine over
one player and one monster. It now runs a round as a sequence of seats.

turnOrder derives that sequence per round. A solo roster short-circuits to
the historical [player, enemy] and rolls nothing -- the duel has never had
initiative, and handing the monster a coin flip on who swings first would be
a live balance change. A party rolls it with the auto-resolve engine's own
formula (speed + d10 + InitiativeBias).

Every seat in a round shares a (round, phase) pair, so the acting seat is
mixed into the RNG *seed* rather than the stream. Seat 0 and the enemy
sentinel mix to nothing, which is what keeps a solo fight drawing exactly
the pre-roster stream across a suspend/resume.

The round cursor persists as Statuses.TurnIdx, omitempty so no solo row
carries it. A fight that was in flight when the field landed decodes it as
0; turnIdxForPhase reconciles that against Phase, which is the older and
load-bearing field. Without it, a suspended enemy_turn would resume, step,
and land back on enemy_turn forever.

The enemy now picks a target uniformly among the standing roster (solo draws
nothing), a downed seat forfeits its turn silently, and the fight is lost
only when anyAlive() goes false -- not when the acting seat drops. That last
one fixes a latent solo bug on the way past: resolvePlayerSwings returns
false when a retaliate aura kills the swinger between extra attacks, and the
old code walked that corpse into the enemy's turn.

commit() reads seat 0 explicitly instead of the cursor, which the enemy turn
parks on its target and round_end walks across the roster.

TestCombatCharacterization is byte-identical: solo balance did not move.
2026-07-09 20:43:37 -07:00

278 lines
9.7 KiB
Go

package plugin
// Initiative + N-body turn-engine tests (N3/P3).
//
// The solo path is pinned bit-for-bit by TestCombatCharacterization for
// auto-resolve, and by the fixed-order assertions below for the turn engine.
// Everything else here exercises seats the production code cannot reach until
// P4 gives a session its participant rows.
import (
"testing"
)
// partyStep drives one engine step over a roster without touching the DB.
func partyStep(sess *CombatSession, players []*Combatant, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
seat := enemySeat
if sess.Phase == CombatPhasePlayerTurn {
order := turnOrder(sess, sess.Round, players, enemy)
seat = order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)]
}
te := resumeTurnEngine(sess, players, enemy, combatSessionStepRNG(sess, seat))
events, err := te.step(action)
if err != nil {
return nil, err
}
te.commit()
return events, nil
}
func TestTurnOrder_SoloIsAlwaysPlayerThenEnemy(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
p, e := basePlayer(), baseEnemy()
// A blisteringly fast monster still swings second: the turn engine has
// never rolled initiative for a duel, and P3 must not start.
e.Stats.Speed = 999
p.Mods.InitiativeBias = -50
for round := 1; round <= 5; round++ {
got := turnOrder(sess, round, []*Combatant{&p}, &e)
if len(got) != 2 || got[0] != 0 || got[1] != enemySeat {
t.Fatalf("round %d solo order = %v, want [0 %d]", round, got, enemySeat)
}
}
}
func TestTurnOrder_PartySeatsEveryoneOnceAndIsDeterministic(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
a, b, c := basePlayer(), basePlayer(), basePlayer()
e := baseEnemy()
players := []*Combatant{&a, &b, &c}
order := turnOrder(sess, 3, players, &e)
if len(order) != 4 {
t.Fatalf("order = %v, want 4 slots (3 players + enemy)", order)
}
seen := map[int]bool{}
for _, seat := range order {
if seen[seat] {
t.Fatalf("seat %d appears twice in %v", seat, order)
}
seen[seat] = true
}
if !seen[0] || !seen[1] || !seen[2] || !seen[enemySeat] {
t.Fatalf("order %v does not seat every combatant", order)
}
// Same (session, round) must rebuild the same order — that is what lets a
// resumed fight land on the seat it left off on.
again := turnOrder(sess, 3, players, &e)
for i := range order {
if order[i] != again[i] {
t.Fatalf("order not stable across rebuilds: %v vs %v", order, again)
}
}
}
func TestTurnOrder_InitiativeBiasWins(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
slow, fast := basePlayer(), basePlayer()
fast.Mods.InitiativeBias = 100 // dwarfs the d10 spread
e := baseEnemy()
order := turnOrder(sess, 1, []*Combatant{&slow, &fast}, &e)
if order[0] != 1 {
t.Errorf("order = %v, want the biased seat 1 first", order)
}
}
func TestCombatSessionStepRNG_SeatZeroMatchesLegacyStream(t *testing.T) {
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhasePlayerTurn}
if combatSessionStepRNG(sess, 0).Uint64() != combatSessionRNG(sess).Uint64() {
t.Error("seat 0 must draw the pre-roster stream — the solo turn engine depends on it")
}
// The enemy sentinel shares seat 0's seed; its phase already separates it.
if combatSessionStepRNG(sess, enemySeat).Uint64() != combatSessionRNG(sess).Uint64() {
t.Error("the enemy sentinel must not perturb the seed")
}
// Party members share a (round, phase) pair, so only the seed keeps their
// d20s apart.
if combatSessionStepRNG(sess, 1).Uint64() == combatSessionStepRNG(sess, 0).Uint64() {
t.Error("seats 0 and 1 drew the same stream in the same phase")
}
if combatSessionStepRNG(sess, 1).Uint64() == combatSessionStepRNG(sess, 2).Uint64() {
t.Error("seats 1 and 2 drew the same stream in the same phase")
}
}
func TestTurnIdxForPhase_ReconcilesARowWrittenBeforeTheCursorExisted(t *testing.T) {
solo := []int{0, enemySeat}
// A fight suspended mid-enemy-turn decodes TurnIdx as 0. Phase is the older
// field and wins, so the cursor must snap to the enemy's slot — otherwise
// the engine re-runs the enemy turn forever.
if got := turnIdxForPhase(solo, 0, CombatPhaseEnemyTurn); got != 1 {
t.Errorf("legacy enemy_turn row reconciled to idx %d, want 1", got)
}
if got := turnIdxForPhase(solo, 0, CombatPhasePlayerTurn); got != 0 {
t.Errorf("player_turn row = idx %d, want 0", got)
}
// An out-of-range cursor (roster shrank) falls back to the phase.
if got := turnIdxForPhase(solo, 7, CombatPhaseEnemyTurn); got != 1 {
t.Errorf("out-of-range cursor = idx %d, want 1", got)
}
// A party order keeps an already-consistent cursor exactly where it is.
party := []int{1, enemySeat, 0}
if got := turnIdxForPhase(party, 2, CombatPhasePlayerTurn); got != 2 {
t.Errorf("consistent cursor moved to idx %d, want 2", got)
}
}
func TestTurnEngine_SoloRoundIsPlayerEnemyRoundEnd(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
p, e := basePlayer(), baseEnemy()
players := []*Combatant{&p}
for _, want := range []string{CombatPhaseEnemyTurn, CombatPhaseRoundEnd, CombatPhasePlayerTurn} {
if _, err := partyStep(sess, players, &e, PlayerAction{Kind: ActionAttack}); err != nil {
t.Fatal(err)
}
if sess.Phase != want {
t.Fatalf("phase = %q, want %q", sess.Phase, want)
}
}
if sess.Round != 2 {
t.Errorf("round = %d, want 2 after one full round", sess.Round)
}
if sess.Statuses.TurnIdx != 0 {
t.Errorf("solo cursor = %d, want 0 — it should never leave the first slot at a player turn", sess.Statuses.TurnIdx)
}
}
func TestTurnEngine_PartyRoundGivesEverySeatATurn(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
a, b := basePlayer(), basePlayer()
e := baseEnemy()
e.Stats.MaxHP = 100000
players := []*Combatant{&a, &b}
order := turnOrder(sess, 1, players, &e)
// Walk the round slot by slot, asserting each phase matches the order.
for i, seat := range order {
if sess.Phase != phaseForSeat(seat) {
t.Fatalf("slot %d: phase = %q, want %q for seat %d", i, sess.Phase, phaseForSeat(seat), seat)
}
if sess.Statuses.TurnIdx != i {
t.Fatalf("slot %d: cursor = %d", i, sess.Statuses.TurnIdx)
}
if _, err := partyStep(sess, players, &e, PlayerAction{Kind: ActionAttack}); err != nil {
t.Fatal(err)
}
}
if sess.Phase != CombatPhaseRoundEnd {
t.Fatalf("phase after every seat acted = %q, want round_end", sess.Phase)
}
if _, err := partyStep(sess, players, &e, PlayerAction{}); err != nil {
t.Fatal(err)
}
if sess.Round != 2 || sess.Statuses.TurnIdx != 0 {
t.Errorf("round_end left round=%d cursor=%d, want 2 and 0", sess.Round, sess.Statuses.TurnIdx)
}
}
func TestTurnEngine_DownedSeatForfeitsItsTurnSilently(t *testing.T) {
// Force a party order whose first slot is a player, then kill that player.
sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
a, b := basePlayer(), basePlayer()
e := baseEnemy()
players := []*Combatant{&a, &b}
order := turnOrder(sess, 1, players, &e)
first := order[0]
if first == enemySeat {
t.Skip("this round's initiative put the enemy first; the seat-skip path is covered by the next slot")
}
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, first))
te.st.actors[first].playerHP = 0
events, err := te.step(PlayerAction{Kind: ActionAttack})
if err != nil {
t.Fatal(err)
}
if len(events) != 0 {
t.Errorf("a downed seat emitted %d events, want none", len(events))
}
if !sess.IsActive() {
t.Error("one downed member ended the fight while another still stands")
}
if sess.Statuses.TurnIdx != 1 {
t.Errorf("cursor = %d, want the next slot", sess.Statuses.TurnIdx)
}
}
func TestTurnEngine_EnemyOnlySwingsAtStandingMembers(t *testing.T) {
sess := turnSession(CombatPhaseEnemyTurn, 10000, 10000)
a, b := basePlayer(), basePlayer()
a.Stats.MaxHP, b.Stats.MaxHP = 10000, 10000
e := baseEnemy()
players := []*Combatant{&a, &b}
// Seat 0 is down. Every enemy turn must land on seat 1, never on the corpse,
// and never end the fight.
for round := 1; round <= 25; round++ {
sess.Round = round
sess.Phase = CombatPhaseEnemyTurn
sess.Statuses.TurnIdx = 1
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
te.st.actors[0].playerHP = 0
before := te.st.actors[1].playerHP
te.step(PlayerAction{})
if te.st.actors[0].playerHP != 0 {
t.Fatal("the enemy healed a corpse")
}
if !sess.IsActive() {
t.Fatalf("round %d: fight ended with a member still standing (%d HP)", round, before)
}
}
}
func TestTurnEngine_RosterWipeEndsTheFight(t *testing.T) {
sess := turnSession(CombatPhaseEnemyTurn, 1, 10000)
a, b := basePlayer(), basePlayer()
e := baseEnemy()
players := []*Combatant{&a, &b}
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
te.st.actors[0].playerHP = 0
te.st.actors[1].playerHP = 0
if _, err := te.step(PlayerAction{}); err != nil {
t.Fatal(err)
}
if sess.Status != CombatStatusLost {
t.Errorf("status = %q, want %q once the whole roster is down", sess.Status, CombatStatusLost)
}
}
// commit persists seat 0, not whoever the cursor happens to be parked on. The
// enemy turn parks it on its target; round_end walks it across the roster.
func TestTurnEngine_CommitPersistsSeatZeroNotTheCursor(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 500, 10000)
a, b := basePlayer(), basePlayer()
e := baseEnemy()
players := []*Combatant{&a, &b}
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
te.st.actors[0].playerHP = 300
te.st.actors[1].playerHP = 77
te.st.actors[1].luckyUsed = true
if _, err := te.step(PlayerAction{}); err != nil {
t.Fatal(err)
}
te.commit()
if sess.PlayerHP != 300 {
t.Errorf("persisted PlayerHP = %d, want seat 0's 300", sess.PlayerHP)
}
if sess.Statuses.LuckyUsed {
t.Error("seat 1's consumed Lucky reroll leaked onto the session row")
}
}