From ec614e84f15dcc0efbb18d067e4736196f3e04d7 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:43:37 -0700 Subject: [PATCH] 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. --- internal/plugin/combat_engine.go | 6 + internal/plugin/combat_session.go | 8 + internal/plugin/combat_session_test.go | 4 +- internal/plugin/combat_turn_engine.go | 404 +++++++++++++++++----- internal/plugin/combat_turn_party_test.go | 277 +++++++++++++++ 5 files changed, 606 insertions(+), 93 deletions(-) create mode 100644 internal/plugin/combat_turn_party_test.go diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 1181d4a..48c4f79 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -309,6 +309,11 @@ type actor struct { c *Combatant playerHP int + // hpMax is this actor's HP ceiling for in-fight healing. It tracks + // c.Stats.MaxHP except in the turn-based engine, where seat 0 restores the + // session's persisted player_hp_max — that snapshot comes from + // dndHPSnapshot and can differ from the rebuilt combatant's MaxHP. + hpMax int // Consumable one-shots healChargesLeft int // remaining heal-at-<50% triggers @@ -418,6 +423,7 @@ func newActor(c *Combatant) *actor { a := &actor{ c: c, playerHP: startHP, + hpMax: c.Stats.MaxHP, wardCharges: c.Mods.WardCharges, sporeRounds: c.Mods.SporeCloud, reflectFrac: c.Mods.ReflectNext, diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 9967ace..d122027 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -65,6 +65,14 @@ type CombatStatuses struct { // combatState, so the flag must survive the commit between them. EnemySkipNext bool `json:"enemy_skip_next,omitempty"` + // TurnIdx is the position within the round's initiative order (see + // turnOrder) of whoever acts next. A solo fight's order is the fixed + // [player, enemy], so TurnIdx mirrors the phase and the field is dead + // weight — omitempty keeps it out of every solo row. Rows written before + // this field existed decode as 0 and are reconciled against Phase on + // resume, so a mid-fight upgrade lands on the right slot. + TurnIdx int `json:"turn_idx,omitempty"` + // Fight-scoped depleting resources — mirror the combatState charges that // genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by // a mid-fight !cast / !consume, restored into combatState on every resume, diff --git a/internal/plugin/combat_session_test.go b/internal/plugin/combat_session_test.go index 6f4b293..51554bf 100644 --- a/internal/plugin/combat_session_test.go +++ b/internal/plugin/combat_session_test.go @@ -181,7 +181,7 @@ func turnSession(phase string, playerHP, enemyHP int) *CombatSession { func stepEngine(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) { rng := rand.New(rand.NewPCG(42, phaseOrdinal(sess.Phase))) - te := resumeTurnEngine(sess, player, enemy, rng) + te := resumeTurnEngine(sess, []*Combatant{player}, enemy, rng) events, err := te.step(action) if err != nil { return nil, err @@ -375,7 +375,7 @@ func TestTurnEngine_StepRejectsTerminalSession(t *testing.T) { player, enemy := basePlayer(), baseEnemy() rng := rand.New(rand.NewPCG(1, 1)) - te := resumeTurnEngine(sess, &player, &enemy, rng) + te := resumeTurnEngine(sess, []*Combatant{&player}, &enemy, rng) if _, err := te.step(PlayerAction{Kind: ActionAttack}); err != errCombatSessionOver { t.Errorf("err = %v, want errCombatSessionOver", err) } diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 56f652a..6282d2e 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "math/rand/v2" + "sort" ) // Phase 13 — Turn-based combat state machine. @@ -72,29 +73,68 @@ type turnActionEffect struct { ConcentrationDmg int } +// enemySeat is the sentinel roster index the monster occupies in a turn order. +// Player seats are the roster indices 0..len(actors)-1. +const enemySeat = -1 + // turnEngine wraps a combatState reconstructed from a persisted CombatSession // so a single phase can be resolved and then written back. +// +// players is the seated roster in roster order; order is this round's seat +// sequence, which interleaves the roster with enemySeat. A solo fight's order +// is always [0, enemySeat] — the fixed player-then-enemy sequence the engine +// has always run — so no initiative is rolled and its RNG stream is untouched. type turnEngine struct { - sess *CombatSession - player *Combatant - enemy *Combatant - st *combatState - result *CombatResult + sess *CombatSession + players []*Combatant + enemy *Combatant + order []int + st *combatState + result *CombatResult +} + +// combatSessionSeed hashes the session id into the base PCG seed. +func combatSessionSeed(sess *CombatSession) uint64 { + var seed uint64 = 1469598103934665603 // FNV-ish offset basis + for _, c := range sess.SessionID { + seed = (seed ^ uint64(c)) * 1099511628211 + } + return seed } // combatSessionRNG seeds a deterministic generator from the session id and the // phase being resolved, so a fight replays reproducibly no matter how many bot // restarts occur mid-fight. Each (round, phase) gets a distinct stream — a flat // per-session seed would correlate the player's and enemy's d20s within a round. +// +// This is the solo form of combatSessionStepRNG, kept as the name the engine's +// single-seat callers and tests use. func combatSessionRNG(sess *CombatSession) *rand.Rand { - var seed uint64 = 1469598103934665603 // FNV-ish offset basis - for _, c := range sess.SessionID { - seed = (seed ^ uint64(c)) * 1099511628211 + return combatSessionStepRNG(sess, 0) +} + +// combatSessionStepRNG seeds the generator for one step resolved by one seat. +// A round holds one player_turn step per party member, and they all share a +// (round, phase) pair — so the acting seat is mixed into the *seed* to keep +// their d20s independent. Seat 0 and the enemy sentinel mix to nothing, which +// is what makes a solo fight draw exactly the pre-roster stream. +func combatSessionStepRNG(sess *CombatSession, seat int) *rand.Rand { + seed := combatSessionSeed(sess) + if seat > 0 { + seed ^= uint64(seat) * 0x9E3779B97F4A7C15 // golden-ratio odd multiplier } stream := uint64(sess.Round)*4 + phaseOrdinal(sess.Phase) return rand.New(rand.NewPCG(seed, stream)) } +// combatInitiativeRNG seeds the once-per-round initiative roll. It takes the +// round explicitly because stepRoundEnd must order the round it is opening, +// not the one it is closing, and sess.Round is only advanced by commit. +// A distinct seed mix keeps it clear of every step stream. +func combatInitiativeRNG(sess *CombatSession, round int) *rand.Rand { + return rand.New(rand.NewPCG(combatSessionSeed(sess)^0xD1B54A32D192ED03, uint64(round))) +} + func phaseOrdinal(phase string) uint64 { switch phase { case CombatPhasePlayerTurn: @@ -108,15 +148,82 @@ func phaseOrdinal(phase string) uint64 { } } +// turnOrder returns the seat sequence for one round: the player roster and the +// enemy sentinel, highest initiative first. Initiative mirrors the auto-resolve +// engine's formula (speed + d10 + InitiativeBias); ties break toward the lower +// seat, and the enemy loses every tie. +// +// A solo roster short-circuits to the engine's historical fixed order and rolls +// nothing — the turn-based engine has never had initiative, and giving one seat +// a coin-flip on who swings first would be a live balance change. +func turnOrder(sess *CombatSession, round int, players []*Combatant, enemy *Combatant) []int { + if len(players) <= 1 { + return []int{0, enemySeat} + } + type entry struct { + seat int + init float64 + } + rng := combatInitiativeRNG(sess, round) + entries := make([]entry, 0, len(players)+1) + for i, p := range players { + entries = append(entries, entry{i, float64(p.Stats.Speed) + rngFloat(rng)*10 + p.Mods.InitiativeBias}) + } + entries = append(entries, entry{enemySeat, float64(enemy.Stats.Speed) + rngFloat(rng)*10}) + sort.SliceStable(entries, func(i, j int) bool { + a, b := entries[i], entries[j] + if a.init != b.init { + return a.init > b.init + } + if (a.seat == enemySeat) != (b.seat == enemySeat) { + return b.seat == enemySeat + } + return a.seat < b.seat + }) + order := make([]int, len(entries)) + for i, e := range entries { + order[i] = e.seat + } + return order +} + +// phaseForSeat names the phase under which a seat resolves its turn. +func phaseForSeat(seat int) string { + if seat == enemySeat { + return CombatPhaseEnemyTurn + } + return CombatPhasePlayerTurn +} + +// turnIdxForPhase reconciles a persisted cursor against the persisted phase. +// They can disagree for exactly one reason: a fight that was in flight when +// TurnIdx was introduced decodes as 0 regardless of whose turn it is. Phase is +// the older, load-bearing field, so it wins; the cursor snaps to the first slot +// of the matching kind. On a solo order ([player, enemy]) that is exact. +func turnIdxForPhase(order []int, idx int, phase string) int { + if idx >= 0 && idx < len(order) && phaseForSeat(order[idx]) == phase { + return idx + } + for i, seat := range order { + if phaseForSeat(seat) == phase { + return i + } + } + return 0 +} + // 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 { - // 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. +// rng is the deterministic source for this step (see combatSessionStepRNG). +// +// Seat 0 restores the session's persisted per-character statuses. Seats 1+ open +// fresh from their combatant's modifiers: a party's per-member statuses need the +// combat_participant rows that P4 adds, and until then no persisted session ever +// carries more than one seat. +func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatant, rng *rand.Rand) *turnEngine { seat0 := &actor{ - c: player, + c: players[0], playerHP: sess.PlayerHP, + hpMax: sess.PlayerHPMax, poisonTicks: sess.Statuses.PoisonTicks, poisonDmg: sess.Statuses.PoisonDmg, stunPlayer: sess.Statuses.StunPlayer, @@ -142,9 +249,14 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R playerACDebuff: sess.Statuses.PlayerACDebuff, maxHPDrain: sess.Statuses.MaxHPDrain, } + actors := make([]*actor, len(players)) + actors[0] = seat0 + for i := 1; i < len(players); i++ { + actors[i] = newActor(players[i]) + } st := &combatState{ actor: seat0, - actors: []*actor{seat0}, + actors: actors, enemyHP: sess.EnemyHP, round: sess.Round, enraged: sess.Statuses.Enraged, @@ -166,13 +278,30 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R enemyAtkBuff: sess.Statuses.EnemyAtkBuff, rng: rng, } - return &turnEngine{ - sess: sess, - player: player, - enemy: enemy, - st: st, - result: &CombatResult{}, + order := turnOrder(sess, sess.Round, players, enemy) + sess.Statuses.TurnIdx = turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase) + if sess.Phase == CombatPhasePlayerTurn { + st.seat(order[sess.Statuses.TurnIdx]) } + return &turnEngine{ + sess: sess, + players: players, + enemy: enemy, + order: order, + st: st, + result: &CombatResult{}, + } +} + +// advance moves the round cursor to the next seat, or to round_end once every +// seat has acted. +func (te *turnEngine) advance() { + te.sess.Statuses.TurnIdx++ + if te.sess.Statuses.TurnIdx >= len(te.order) { + te.sess.Phase = CombatPhaseRoundEnd + return + } + te.sess.Phase = phaseForSeat(te.order[te.sess.Statuses.TurnIdx]) } // step resolves exactly one phase of the fight and advances sess.Phase. The @@ -200,6 +329,13 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) { } func (te *turnEngine) stepPlayerTurn(action PlayerAction) { + // A seat that went down earlier this round forfeits its turn silently. + // Unreachable while the roster is solo — a downed solo player has already + // ended the fight. + if te.st.playerHP <= 0 { + te.advance() + return + } switch action.Kind { case ActionFlee: te.st.events = append(te.st.events, CombatEvent{ @@ -220,26 +356,34 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) { // Extra Attack only fired in the auto-resolve SimulateCombat path; that // left every elite/boss !fight at L11+ short two swings/turn, which the // J1 boss-trace surfaced as Fighter's T3+ wall. - // Returns true once the fight is decided — usually the enemy is down, - // but a retaliate aura can drop the player on their own swing, so - // disambiguate the outcome by HP. - if resolvePlayerSwings(te.st, te.player, te.enemy, &turnCombatPhase, te.result) { - if te.st.playerHP <= 0 { - te.finish(CombatStatusLost) - } else { + // Returns true once the swing decided something — usually the enemy is + // down, but a retaliate aura can drop the swinger on their own attack. A + // downed swinger only ends the fight if nobody else is standing. + // The outcome is read off HP rather than the bool: resolvePlayerSwings also + // returns false when a retaliate aura kills the swinger between extra + // attacks, and the old code walked that corpse into the enemy's turn. + resolvePlayerSwings(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result) + if te.st.enemyHP <= 0 { + te.finish(CombatStatusWon) + return + } + if !te.st.anyAlive() { + te.finish(CombatStatusLost) + return + } + // A swinger who went down to retaliate gets no bonus-action follow-ups. + // Solo never reaches here dead — anyAlive above would have ended the fight. + if te.st.playerHP > 0 { + if te.petStrike() { te.finish(CombatStatusWon) + return + } + if te.spiritWeaponStrike() { + te.finish(CombatStatusWon) + return } - return } - if te.petStrike() { - te.finish(CombatStatusWon) - return - } - if te.spiritWeaponStrike() { - te.finish(CombatStatusWon) - return - } - te.sess.Phase = CombatPhaseEnemyTurn + te.advance() } // petStrike resolves the player's pet attack for a turn-based fight. The pet @@ -252,10 +396,10 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) { // the enemy. func (te *turnEngine) petStrike() bool { st := te.st - if te.player.Mods.PetAttackProc <= 0 || st.randFloat() >= te.player.Mods.PetAttackProc { + if st.c.Mods.PetAttackProc <= 0 || st.randFloat() >= st.c.Mods.PetAttackProc { return false } - petDmg := te.player.Mods.PetAttackDmg + st.roll(5) + petDmg := st.c.Mods.PetAttackDmg + st.roll(5) st.enemyHP = max(0, st.enemyHP-petDmg) st.events = append(st.events, CombatEvent{ Round: st.round, Phase: turnCombatPhase.Name, Actor: "pet", Action: "pet_attack", @@ -270,10 +414,10 @@ func (te *turnEngine) petStrike() bool { // pet flavor on a petless caster. Returns true if the strike dropped the enemy. func (te *turnEngine) spiritWeaponStrike() bool { st := te.st - if te.player.Mods.SpiritWeaponProc <= 0 || st.randFloat() >= te.player.Mods.SpiritWeaponProc { + if st.c.Mods.SpiritWeaponProc <= 0 || st.randFloat() >= st.c.Mods.SpiritWeaponProc { return false } - dmg := te.player.Mods.SpiritWeaponDmg + st.roll(5) + dmg := st.c.Mods.SpiritWeaponDmg + st.roll(5) st.enemyHP = max(0, st.enemyHP-dmg) st.events = append(st.events, CombatEvent{ Round: st.round, Phase: turnCombatPhase.Name, Actor: "spirit_weapon", Action: "spirit_weapon_strike", @@ -291,7 +435,7 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { st := te.st if eff == nil { // Defensive: a kind with no payload just passes the turn. - te.sess.Phase = CombatPhaseEnemyTurn + te.advance() return } action := eff.Action @@ -312,7 +456,7 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { if eff.PlayerHeal > 0 { // Respect any max_hp_drain monster ability — a drained player can't be // healed back past the lowered ceiling. - hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain) + hpCap := max(1, st.hpMax-st.maxHPDrain) st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal) } // Arm / replace the concentration aura. A new concentration cast overwrites @@ -354,10 +498,37 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { st.enemySkipFirst = true } } - te.sess.Phase = CombatPhaseEnemyTurn + te.advance() +} + +// enemyTarget picks the seat the monster swings at this turn: uniformly among +// the standing roster. A solo roster draws nothing, so its RNG stream keeps the +// pre-roster shape. Returns false when the whole roster is down. +func (te *turnEngine) enemyTarget() (int, bool) { + if len(te.st.actors) == 1 { + return 0, te.st.actors[0].playerHP > 0 + } + standing := make([]int, 0, len(te.st.actors)) + for i, a := range te.st.actors { + if a.playerHP > 0 { + standing = append(standing, i) + } + } + if len(standing) == 0 { + return 0, false + } + return standing[te.st.roll(len(standing))], true } func (te *turnEngine) stepEnemyTurn() { + // Seat the target before anything reads the cursor: the ability path, the + // pet procs, and the swing loop all resolve against whoever is targeted. + target, ok := te.enemyTarget() + if !ok { + te.finish(CombatStatusLost) + return + } + te.st.seat(target) // A control spell cast last phase forfeits the enemy's attack this round — // unless the enemy is fear_immune, in which case the control fizzled and it // acts as normal. @@ -373,7 +544,7 @@ func (te *turnEngine) stepEnemyTurn() { Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held", PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP, }) - te.sess.Phase = CombatPhaseRoundEnd + te.advance() return } } @@ -385,8 +556,15 @@ func (te *turnEngine) stepEnemyTurn() { // player went down without a save. abilityDealtDamage := false if te.enemy.Ability != nil && turnAbilityFires(te.enemy.Ability, te.st, te.enemy) { - if applyAbility(te.st, te.player, te.enemy, &turnCombatPhase, te.result) { - te.finish(CombatStatusLost) + if applyAbility(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result) { + // The target went down without a save. The fight only ends if it + // took the last member with it; otherwise the enemy has spent its + // turn on that kill. + if !te.st.anyAlive() { + te.finish(CombatStatusLost) + } else { + te.advance() + } return } switch te.enemy.Ability.Effect { @@ -402,8 +580,8 @@ func (te *turnEngine) stepEnemyTurn() { // remaining swings resolve normally — a single proc shouldn't nullify a // boss's whole multiattack round. (This deliberately diverges from the // auto-resolve engine's apply-to-all model.) - petWhiff := te.player.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.player.Mods.PetWhiffProc - petDeflect := te.player.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.player.Mods.PetDeflectProc + petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc + petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc if petDeflect { te.result.PetDeflected = true } @@ -421,10 +599,15 @@ func (te *turnEngine) stepEnemyTurn() { // Spend the proc on the first swing only; later swings see false. swingWhiff := petWhiff && i == 0 swingDeflect := petDeflect && i == 0 - decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false) + decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false) if te.st.playerHP <= 0 { - te.finish(CombatStatusLost) - return + // The target is down. The enemy stops swinging at a corpse; the + // fight ends only if the roster is empty. + if !te.st.anyAlive() { + te.finish(CombatStatusLost) + return + } + break } if decided || te.st.enemyHP <= 0 { te.finish(CombatStatusWon) @@ -432,7 +615,7 @@ func (te *turnEngine) stepEnemyTurn() { } } } - te.sess.Phase = CombatPhaseRoundEnd + te.advance() } // turnAbilityFires decides whether a monster ability triggers this enemy turn. @@ -460,26 +643,36 @@ func turnAbilityFires(ability *MonsterAbility, st *combatState, enemy *Combatant } // stepRoundEnd applies between-round status effects, then opens the next round. +// Per-actor effects (poison, concentration) tick once per seat, in seating +// order; the enemy's regen is fight-scoped and ticks once. A solo roster walks +// each loop exactly once, so it draws the same RNG the pre-roster engine did. func (te *turnEngine) stepRoundEnd() { st := te.st - if st.poisonTicks > 0 { + for i := range st.actors { + st.seat(i) + if st.poisonTicks <= 0 { + continue + } st.playerHP = max(0, st.playerHP-st.poisonDmg) st.poisonTicks-- st.events = append(st.events, CombatEvent{ Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "poison_tick", Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, }) - if st.playerHP <= 0 { - if !trySave(st, te.player, CombatPhaseRoundEnd) { - te.finish(CombatStatusLost) - return - } + if st.playerHP <= 0 && !trySave(st, st.c, CombatPhaseRoundEnd) && !st.anyAlive() { + te.finish(CombatStatusLost) + return } } // Concentration aura (Spirit Guardians et al.): the lingering spell bites - // the enemy each round it stays up. Ticks before enemy regen so a lethal - // pulse settles the fight before the enemy knits its wounds back. - if st.concentrationDmg > 0 && st.enemyHP > 0 { + // the enemy each round it stays up. Concentration is per-caster, so every + // seat holding one pulses. Ticks before enemy regen so a lethal pulse + // settles the fight before the enemy knits its wounds back. + for i := range st.actors { + st.seat(i) + if st.concentrationDmg <= 0 || st.enemyHP <= 0 || st.playerHP <= 0 { + continue + } st.enemyHP = max(0, st.enemyHP-st.concentrationDmg) st.events = append(st.events, CombatEvent{ Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "player", Action: "concentration_tick", @@ -490,6 +683,7 @@ func (te *turnEngine) stepRoundEnd() { return } } + st.seat(0) // Regenerate (monster ability): the enemy knits its wounds at round end. if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP { st.enemyHP = min(te.enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen) @@ -499,7 +693,11 @@ func (te *turnEngine) stepRoundEnd() { }) } st.round++ - te.sess.Phase = CombatPhasePlayerTurn + // Initiative is re-rolled each round, so the next round's order is derived + // here — off st.round, since commit has not yet pushed it onto the session. + te.order = turnOrder(te.sess, st.round, te.players, te.enemy) + te.sess.Statuses.TurnIdx = 0 + te.sess.Phase = phaseForSeat(te.order[0]) } // finish parks the session in a terminal status + the 'over' phase. @@ -516,42 +714,47 @@ func (te *turnEngine) finish(status string) { // by the command layer (a !cast / !consume folds them in) and applied to the // rebuilt combatant by applySessionBuffs — so commit mutates Statuses in place // rather than replacing it, leaving those deltas untouched. +// Per-actor state is read off seat 0 rather than off the cursor, which the +// enemy turn parks on its target and round_end walks across the roster. Seat 0 +// is the only member a session row can hold; P4's participant rows carry the +// rest. func (te *turnEngine) commit() { st := te.st + a := st.actors[0] te.sess.Round = st.round - te.sess.PlayerHP = st.playerHP + te.sess.PlayerHP = a.playerHP te.sess.EnemyHP = st.enemyHP s := &te.sess.Statuses - s.PoisonTicks = st.poisonTicks - s.PoisonDmg = st.poisonDmg - s.StunPlayer = st.stunPlayer + s.PoisonTicks = a.poisonTicks + s.PoisonDmg = a.poisonDmg + s.StunPlayer = a.stunPlayer s.Enraged = st.enraged s.ArmorBroken = st.armorBroken s.ArmorBreakAmt = st.armorBreakAmt s.EnemySkipNext = st.enemySkipFirst - s.WardCharges = st.wardCharges - s.SporeRounds = st.sporeRounds - s.ReflectFrac = st.reflectFrac - s.AutoCritFirst = st.autoCrit - s.ArcaneWardHP = st.arcaneWardHP - s.HealChargesLeft = st.healChargesLeft - s.ConcentrationDmg = st.concentrationDmg - s.DeathSaveUsed = st.deathSaveUsed - s.LuckyUsed = st.luckyUsed - s.Raged = st.raged - s.PendingRage = st.pendingRageAttack - s.FirstAtkBonusUsed = st.firstAttackBonusUsed - s.AssassinateReroll = st.assassinateRerollUsed - s.AssassinateBonus = st.assassinateBonusUsed + s.WardCharges = a.wardCharges + s.SporeRounds = a.sporeRounds + s.ReflectFrac = a.reflectFrac + s.AutoCritFirst = a.autoCrit + s.ArcaneWardHP = a.arcaneWardHP + s.HealChargesLeft = a.healChargesLeft + s.ConcentrationDmg = a.concentrationDmg + s.DeathSaveUsed = a.deathSaveUsed + s.LuckyUsed = a.luckyUsed + s.Raged = a.raged + s.PendingRage = a.pendingRageAttack + s.FirstAtkBonusUsed = a.firstAttackBonusUsed + s.AssassinateReroll = a.assassinateRerollUsed + s.AssassinateBonus = a.assassinateBonusUsed s.EnemyEvadeNext = st.enemyEvadeNext s.EnemyBlockUp = st.enemyBlockUp s.EnemyAdvantage = st.enemyAdvantage s.EnemyRetaliateFrac = st.enemyRetaliateFrac s.EnemyRegen = st.enemyRegen s.EnemySurviveArmed = st.enemySurviveArmed - s.PlayerAtkDrain = st.playerAtkDrain - s.PlayerACDebuff = st.playerACDebuff - s.MaxHPDrain = st.maxHPDrain + s.PlayerAtkDrain = a.playerAtkDrain + s.PlayerACDebuff = a.playerACDebuff + s.MaxHPDrain = a.maxHPDrain s.EnemySpellResist = st.enemySpellResist s.EnemyRevealNext = st.enemyRevealNext s.EnemyFearImmune = st.enemyFearImmune @@ -559,16 +762,35 @@ func (te *turnEngine) commit() { te.sess.TurnLog = append(te.sess.TurnLog, st.events...) } -// advanceCombatSession resolves one phase of the fight and persists the result. -// It is the single entry point callers (commands, reaper) should use: it seeds -// the deterministic RNG, resumes the engine, steps, commits, and saves. The -// passed sess is mutated in place. The events generated this step are returned. +// advanceCombatSession resolves one phase of a solo fight and persists the +// result. It is the single entry point callers (commands, reaper) should use. func advanceCombatSession(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) { + return advancePartyCombatSession(sess, []*Combatant{player}, enemy, action) +} + +// advancePartyCombatSession resolves one phase of the fight for the seated +// roster and persists the result: it derives the acting seat from this round's +// turn order, seeds that seat's deterministic RNG, resumes the engine, steps, +// commits, and saves. The passed sess is mutated in place. The events generated +// this step are returned. +func advancePartyCombatSession(sess *CombatSession, players []*Combatant, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) { if sess == nil { return nil, ErrNoActiveCombatSession } - rng := combatSessionRNG(sess) - te := resumeTurnEngine(sess, player, enemy, rng) + if len(players) == 0 { + return nil, fmt.Errorf("combat session %s: empty roster", sess.SessionID) + } + // The seat is needed before the engine is resumed, because it seeds the + // step's RNG — so the order is derived once here and once inside + // resumeTurnEngine. Both derivations read only (sessionID, round, roster), + // so they agree by construction. + seat := enemySeat + if sess.Phase == CombatPhasePlayerTurn { + order := turnOrder(sess, sess.Round, players, enemy) + seat = order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)] + } + rng := combatSessionStepRNG(sess, seat) + te := resumeTurnEngine(sess, players, enemy, rng) events, err := te.step(action) if err != nil { return nil, err diff --git a/internal/plugin/combat_turn_party_test.go b/internal/plugin/combat_turn_party_test.go new file mode 100644 index 0000000..355c39b --- /dev/null +++ b/internal/plugin/combat_turn_party_test.go @@ -0,0 +1,277 @@ +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") + } +}