Files
gogobee/internal/plugin/combat_turn_engine.go
prosolis cbfca525f5 Lift caster trailers: concentration re-tick + Josie caster-aid bootstraps
Diagnosed a cleric "death loop" (L14 dying at T2/T3 bosses while
over-levelled): the boss isn't overtuned — caster sustained DPS is
under-delivered, compounded by a fragile healer build.

Engine fix — concentration AOE re-tick:
- Concentration damage spells (spirit_guardians, heat_metal, spike_growth,
  call_lightning, flaming_sphere) now tick the enemy every round at
  round_end instead of resolving as a one-shot, via a new
  CombatStatuses.ConcentrationDmg armed on cast and round-tripped through
  the turn engine. Closes the long-tracked turn-engine concentration gap;
  the burst still lands the casting round, then the aura lingers.
- Sim picker skips re-casting an already-active aura (models competent play
  and prevents a burst+aura double-dip). Re-baseline (n=30 sweep + n=100
  confirm): bard +47pp T3 (heat_metal), druid +3-7, cleric/mage flat,
  fighter unchanged — no regressions.

Player-data bootstraps (idempotent, run once on Init):
- bootstrapCasterSpellBackfill: ensureSpellsForCharacter only seeds an empty
  book, so defaults added after a character's roll never reach it. Backfill
  missing defaults into known+prepared for existing casters (gives the
  affected cleric inflict_wounds + a working healing_word, since her
  healing_word_spell is a dead alias).
- bootstrapGrantStarterPet: one-off L10 pet for an endgame player who never
  got the morning arrival roll; adds per-round proc damage + deflect.
- TestScenario_JosieCasterAid verifies both against a copy of the live DB,
  incl. idempotency.

Also fix a pre-existing wall-clock flake in
TestFireBriefings_EventAnchoredActivePlayerDelivers (start_date defaulted to
real now, filtering the row out when the suite runs after 06:00 UTC).
2026-06-18 06:34:16 -07:00

573 lines
22 KiB
Go

package plugin
import (
"errors"
"fmt"
"math/rand/v2"
)
// Phase 13 — Turn-based combat state machine.
//
// Where SimulateCombat runs a whole fight in one call, the turn-based engine
// advances a persisted CombatSession one phase at a time:
//
// player_turn -> enemy_turn -> round_end -> player_turn (next round) -> ...
//
// each `step` resolving exactly one phase so a manual elite/boss fight can be
// suspended and resumed (or reaped) from exact mid-state. Attack resolution
// reuses the shared primitives in combat_primitives.go / combat_engine.go, so
// a hit lands identically to auto-resolve. The round_end phase is where
// between-round status effects (poison, etc.) tick — the deferred poison tick
// from the schema commit lands here, now that the round-loop shape exists.
// errCombatSessionOver is returned by step when the fight has already reached
// a terminal status — callers should not keep advancing it.
var errCombatSessionOver = errors.New("combat session already over")
// turnCombatPhase is the single neutral phase the turn-based engine resolves
// attacks under. Auto-resolve sequences named phases with weight curves; a
// manual duel has no phase clock, so weights are flat 1.0 — neutral for the
// legacy penetration formula (calcDamage) and irrelevant on the weapon-dice
// path. The name surfaces on every CombatEvent the fight emits.
var turnCombatPhase = CombatPhase{
Name: "Duel",
Rounds: 0,
AttackWeight: 1.0,
DefenseWeight: 1.0,
SpeedWeight: 1.0,
}
// Player action kinds accepted by the state machine on a player_turn step.
const (
ActionAttack = "attack"
ActionFlee = "flee"
ActionCast = "cast"
ActionConsume = "consume"
)
// PlayerAction is the player's choice for a player_turn step. It is ignored on
// enemy_turn / round_end steps (pass the zero value).
type PlayerAction struct {
Kind string
// Effect is set for ActionCast / ActionConsume: a pre-resolved outcome
// handed in by the command handler. The handler owns spell/item lookup,
// dice rolls, and the resource spend; the engine just applies the HP
// deltas and emits the event so the round flows on into the enemy turn.
Effect *turnActionEffect
}
// turnActionEffect is the resolved payload of a !cast / !consume player turn.
// Damage, heal, and the one-round enemy-skip all land within the casting round.
type turnActionEffect struct {
Label string // "Fireball — crit!", "Berry Poultice"
Action string // CombatEvent.Action: "spell_cast" or "use_consumable"
EnemyDamage int
PlayerHeal int
EnemySkip bool // control spell: enemy forfeits its attack this round
// ConcentrationDmg arms a per-round aura tick when a concentration damage
// spell is cast: EnemyDamage is the burst that lands this round, this is
// what re-ticks at every round_end after. Zero for one-shot spells; a
// non-zero value overwrites any aura already running (5e: one
// concentration at a time).
ConcentrationDmg int
}
// turnEngine wraps a combatState reconstructed from a persisted CombatSession
// so a single phase can be resolved and then written back.
type turnEngine struct {
sess *CombatSession
player *Combatant
enemy *Combatant
st *combatState
result *CombatResult
}
// 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.
func combatSessionRNG(sess *CombatSession) *rand.Rand {
var seed uint64 = 1469598103934665603 // FNV-ish offset basis
for _, c := range sess.SessionID {
seed = (seed ^ uint64(c)) * 1099511628211
}
stream := uint64(sess.Round)*4 + phaseOrdinal(sess.Phase)
return rand.New(rand.NewPCG(seed, stream))
}
func phaseOrdinal(phase string) uint64 {
switch phase {
case CombatPhasePlayerTurn:
return 0
case CombatPhaseEnemyTurn:
return 1
case CombatPhaseRoundEnd:
return 2
default: // CombatPhaseOver
return 3
}
}
// 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,
// 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.
wardCharges: sess.Statuses.WardCharges,
sporeRounds: sess.Statuses.SporeRounds,
reflectFrac: sess.Statuses.ReflectFrac,
autoCrit: sess.Statuses.AutoCritFirst,
arcaneWardHP: sess.Statuses.ArcaneWardHP,
healChargesLeft: sess.Statuses.HealChargesLeft,
concentrationDmg: sess.Statuses.ConcentrationDmg,
deathSaveUsed: sess.Statuses.DeathSaveUsed,
luckyUsed: sess.Statuses.LuckyUsed,
raged: sess.Statuses.Raged,
pendingRageAttack: sess.Statuses.PendingRage,
firstAttackBonusUsed: sess.Statuses.FirstAtkBonusUsed,
assassinateRerollUsed: sess.Statuses.AssassinateReroll,
assassinateBonusUsed: sess.Statuses.AssassinateBonus,
// 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,
enemyBlockUp: sess.Statuses.EnemyBlockUp,
enemyAdvantage: sess.Statuses.EnemyAdvantage,
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,
enemyFearImmune: sess.Statuses.EnemyFearImmune,
enemyAtkBuff: sess.Statuses.EnemyAtkBuff,
rng: rng,
}
return &turnEngine{
sess: sess,
player: player,
enemy: enemy,
st: st,
result: &CombatResult{},
}
}
// step resolves exactly one phase of the fight and advances sess.Phase. The
// events generated this step are returned (also accumulated by commit into
// sess.TurnLog). It does not persist — call commit then saveCombatSession, or
// use advanceCombatSession which does both.
func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
if !te.sess.IsActive() {
return nil, errCombatSessionOver
}
te.st.events = nil
switch te.sess.Phase {
case CombatPhasePlayerTurn:
te.stepPlayerTurn(action)
case CombatPhaseEnemyTurn:
te.stepEnemyTurn()
case CombatPhaseRoundEnd:
te.stepRoundEnd()
case CombatPhaseOver:
return nil, errCombatSessionOver
default:
return nil, fmt.Errorf("combat session %s in unknown phase %q", te.sess.SessionID, te.sess.Phase)
}
return te.st.events, nil
}
func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
switch action.Kind {
case ActionFlee:
te.st.events = append(te.st.events, CombatEvent{
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "flee",
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
})
te.finish(CombatStatusFled)
return
case ActionCast, ActionConsume:
te.stepPlayerActionEffect(action.Effect)
return
}
// Default: weapon attack. resolvePlayerSwings rolls the base swing plus
// Mods.ExtraAttacks follow-ups (5e Extra Attack at Fighter L5/L11/L20,
// Ranger/Paladin L5, Bard College of Valor L7). One !attack press in the
// turn-based engine == one 5e Attack action == all swings in sequence.
// Before this, the turn path called single-swing resolvePlayerAttack and
// 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 {
te.finish(CombatStatusWon)
}
return
}
if te.petStrike() {
te.finish(CombatStatusWon)
return
}
if te.spiritWeaponStrike() {
te.finish(CombatStatusWon)
return
}
te.sess.Phase = CombatPhaseEnemyTurn
}
// petStrike resolves the player's pet attack for a turn-based fight. The pet
// rolls fresh on every player-acting turn (PetAttackProc), mirroring the
// auto-resolve engine's per-round chance rather than a once-per-fight strike.
// The roll rides the per-(round,phase) step RNG, so a suspend/resume or reaper
// auto-play of the same turn reproduces the same outcome. Damage reuses the
// auto-resolve formula (PetAttackDmg + d5), and PetAttackDmg already carries any
// mid-fight buff delta via applySessionBuffs. Returns true if the strike dropped
// the enemy.
func (te *turnEngine) petStrike() bool {
st := te.st
if te.player.Mods.PetAttackProc <= 0 || st.randFloat() >= te.player.Mods.PetAttackProc {
return false
}
petDmg := te.player.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",
Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
return enemyDown(st, turnCombatPhase.Name)
}
// spiritWeaponStrike resolves the spell's bonus-action attack each round when
// the spiritual_weapon buff is active. Same per-turn cadence as petStrike, but
// rolls and narrates on its own channel so the spectral mace doesn't borrow
// 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 {
return false
}
dmg := te.player.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",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
return enemyDown(st, turnCombatPhase.Name)
}
// stepPlayerActionEffect resolves a !cast / !consume turn: the command handler
// has already rolled the spell / picked the item and spent the resource, so the
// engine only applies the HP deltas and emits the event before handing off to
// the enemy turn. A control-spell skip is parked in st.enemySkipFirst, which
// commit() persists as Statuses.EnemySkipNext for the enemy_turn step to read.
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
return
}
action := eff.Action
if action == "" {
action = "spell_cast"
}
// spell_resist (Spell Immunity): a spell's damage against the enemy is
// halved. Consumables that happen to deal damage are not spells, so the
// resist is gated on the spell_cast action.
enemyDmg := eff.EnemyDamage
spellFizzled := enemyDmg > 0 && action == "spell_cast" && enemyResistsSpells(te.enemy, st)
if spellFizzled {
enemyDmg = max(1, enemyDmg/2)
}
if enemyDmg > 0 {
st.enemyHP = max(0, st.enemyHP-enemyDmg)
}
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)
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
}
// Arm / replace the concentration aura. A new concentration cast overwrites
// the old one (5e: one concentration at a time); non-concentration casts
// leave any running aura alone.
if eff.ConcentrationDmg > 0 {
st.concentrationDmg = eff.ConcentrationDmg
}
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action,
Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label,
})
if spellFizzled {
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_fizzle",
Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
if enemyDown(st, turnCombatPhase.Name) {
te.finish(CombatStatusWon)
return
}
if te.petStrike() {
te.finish(CombatStatusWon)
return
}
if te.spiritWeaponStrike() {
te.finish(CombatStatusWon)
return
}
if eff.EnemySkip {
// fear_immune enemies shrug off control spells — the skip never arms.
if enemyImmuneToControl(te.enemy, st) {
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
} else {
st.enemySkipFirst = true
}
}
te.sess.Phase = CombatPhaseEnemyTurn
}
func (te *turnEngine) stepEnemyTurn() {
// 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.
if te.st.enemySkipFirst {
te.st.enemySkipFirst = false
if enemyImmuneToControl(te.enemy, te.st) {
te.st.events = append(te.st.events, CombatEvent{
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist",
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
})
} else {
te.st.events = append(te.st.events, CombatEvent{
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
})
te.sess.Phase = CombatPhaseRoundEnd
return
}
}
// Monster ability fires at the top of the enemy turn. cleave / lifesteal
// resolve their own damage and stand in for the attack action this round;
// every other effect (poison / stun / enrage / armor_break) is a rider that
// leaves the multiattack below intact. applyAbility returns true when the
// 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)
return
}
switch te.enemy.Ability.Effect {
case "cleave", "lifesteal":
abilityDealtDamage = true
}
}
if !abilityDealtDamage {
// Pet defensive procs are a single proc per enemy turn: roll once, then
// spend it on the first swing only. Whiff makes that one swing a
// guaranteed miss; deflect halves its damage. Against a multiattack the
// 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
if petDeflect {
te.result.PetDeflected = true
}
// SRD multiattack: each profile entry is one attack roll resolved
// through the shared primitive. A registered elite/boss swings its full
// profile; everyone else gets a single attack from the template stats.
// resolveEnemyAttack returns true when the fight is decided — either the
// player went down without a death save, or a reflect consumable killed
// the enemy. Disambiguate by inspecting HP.
for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
swing := *te.enemy
swing.Stats.Attack = atk.Damage
swing.Stats.AttackBonus = atk.AttackBonus
// 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)
if te.st.playerHP <= 0 {
te.finish(CombatStatusLost)
return
}
if decided || te.st.enemyHP <= 0 {
te.finish(CombatStatusWon)
return
}
}
}
te.sess.Phase = CombatPhaseRoundEnd
}
// turnAbilityFires decides whether a monster ability triggers this enemy turn.
// The auto-resolve engine gates abilities on its named phase clock (Opening /
// Clash / Decisive); the turn-based duel has no phase clock, so the phase tag
// is remapped to fight progress: "opening" is round-1 only, "decisive" unlocks
// once the enemy is bloodied (<40% HP), "clash" covers the rounds in between,
// and "any" is always eligible. ProcChance is rolled once eligible.
func turnAbilityFires(ability *MonsterAbility, st *combatState, enemy *Combatant) bool {
eligible := false
switch ability.Phase {
case "any":
eligible = true
case "opening":
eligible = st.round <= 1
case "clash":
eligible = st.round > 1
case "decisive":
eligible = st.enemyHP < int(float64(enemy.Stats.MaxHP)*0.4)
}
if !eligible {
return false
}
return st.randFloat() < ability.ProcChance
}
// stepRoundEnd applies between-round status effects, then opens the next round.
func (te *turnEngine) stepRoundEnd() {
st := te.st
if st.poisonTicks > 0 {
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
}
}
}
// 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 {
st.enemyHP = max(0, st.enemyHP-st.concentrationDmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "player", Action: "concentration_tick",
Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.enemyHP <= 0 {
te.finish(CombatStatusWon)
return
}
}
// 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)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "regen_tick",
Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
st.round++
te.sess.Phase = CombatPhasePlayerTurn
}
// finish parks the session in a terminal status + the 'over' phase.
func (te *turnEngine) finish(status string) {
te.sess.Status = status
te.sess.Phase = CombatPhaseOver
}
// commit folds this step's combatState back into the session struct: HP,
// round, the between-round status snapshot, and the appended event log.
// Phase / Status were already set by step. saveCombatSession persists it.
//
// The Buff* stat deltas on Statuses are NOT combatState fields — they're owned
// 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.
func (te *turnEngine) commit() {
st := te.st
te.sess.Round = st.round
te.sess.PlayerHP = st.playerHP
te.sess.EnemyHP = st.enemyHP
s := &te.sess.Statuses
s.PoisonTicks = st.poisonTicks
s.PoisonDmg = st.poisonDmg
s.StunPlayer = st.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.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.EnemySpellResist = st.enemySpellResist
s.EnemyRevealNext = st.enemyRevealNext
s.EnemyFearImmune = st.enemyFearImmune
s.EnemyAtkBuff = st.enemyAtkBuff
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.
func advanceCombatSession(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
if sess == nil {
return nil, ErrNoActiveCombatSession
}
rng := combatSessionRNG(sess)
te := resumeTurnEngine(sess, player, enemy, rng)
events, err := te.step(action)
if err != nil {
return nil, err
}
te.commit()
if err := saveCombatSession(sess); err != nil {
return events, fmt.Errorf("persist combat session %s: %w", sess.SessionID, err)
}
return events, nil
}