Files
gogobee/internal/plugin/combat_turn_engine.go
prosolis d1c067452e Review fixes: align party enemy-HP scaling in the !fight entry path
The P8 diff scaled the enemy's max HP ×1.15 for parties at persist and
per-turn rebuild, but the !fight command's own template stayed unscaled:
the entry banner reported the pre-scale HP, and the opening-round settle
resolved the enemy against the wrong MaxHP ceiling (regen clamp, bloodied
threshold). Mirror the scalar for the banner and align the in-memory
template before the settle. Solo scales by 1.0, so it is untouched.

Also extract enemyActionPlan() so both combat engines share the one
load-bearing action-count computation instead of duplicating it.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 13:02:28 -07:00

939 lines
36 KiB
Go

package plugin
import (
"errors"
"fmt"
"math/rand/v2"
"sort"
)
// 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
}
// 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
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 {
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:
return 0
case CombatPhaseEnemyTurn:
return 1
case CombatPhaseRoundEnd:
return 2
default: // CombatPhaseOver
return 3
}
}
// 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
}
// stepSeat is the seat that resolves the session's current phase: the acting
// player on a player_turn, the enemy sentinel on anything else.
//
// It is the *single* derivation of "whose step is this". advancePartyCombatSession
// needs it before the engine is resumed, because the seat seeds that step's RNG;
// the command layer needs it to tell a member it is not their turn. Both read
// only (sessionID, round, roster), so they agree by construction.
func stepSeat(sess *CombatSession, players []*Combatant, enemy *Combatant) int {
if sess.Phase != CombatPhasePlayerTurn {
return enemySeat
}
order := turnOrder(sess, sess.Round, players, enemy)
return order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)]
}
// actingSeat names the player seat currently on the clock. The false return
// means the fight is not waiting on anybody: it is mid enemy-turn, mid
// round-end, or over.
func actingSeat(sess *CombatSession, players []*Combatant, enemy *Combatant) (int, bool) {
if !sess.IsActive() || sess.Phase != CombatPhasePlayerTurn {
return 0, false
}
return stepSeat(sess, players, enemy), true
}
// 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 combatSessionStepRNG).
//
// restoreActor rebuilds one seat from its persisted state: live HP, the pool
// ceiling, and the per-character effect set. Depleting resources and the
// once-per-fight "already used" flags are restored rather than rearmed, so a
// charge can't silently reset across a suspend/resume or a reaper auto-play.
// snapshotActor is its exact inverse — keep the two field lists in step.
func restoreActor(c *Combatant, hp, hpMax int, s ActorStatuses) *actor {
return &actor{
c: c,
playerHP: hp,
hpMax: hpMax,
poisonTicks: s.PoisonTicks,
poisonDmg: s.PoisonDmg,
stunPlayer: s.StunPlayer,
wardCharges: s.WardCharges,
sporeRounds: s.SporeRounds,
reflectFrac: s.ReflectFrac,
autoCrit: s.AutoCritFirst,
arcaneWardHP: s.ArcaneWardHP,
healChargesLeft: s.HealChargesLeft,
concentrationDmg: s.ConcentrationDmg,
deathSaveUsed: s.DeathSaveUsed,
luckyUsed: s.LuckyUsed,
raged: s.Raged,
pendingRageAttack: s.PendingRage,
firstAttackBonusUsed: s.FirstAtkBonusUsed,
assassinateRerollUsed: s.AssassinateReroll,
assassinateBonusUsed: s.AssassinateBonus,
// Enemy debuffs stacked onto this character specifically.
playerAtkDrain: s.PlayerAtkDrain,
playerACDebuff: s.PlayerACDebuff,
maxHPDrain: s.MaxHPDrain,
}
}
// snapshotActor folds one seat's live state back into its persisted form. The
// Buff* deltas are owned by the command layer (a !cast / !consume folds them in
// via applyBuffDelta) and are not combatState fields, so they are carried over
// from the prior snapshot rather than re-derived.
func snapshotActor(a *actor, prior ActorStatuses) ActorStatuses {
s := prior
s.PoisonTicks = a.poisonTicks
s.PoisonDmg = a.poisonDmg
s.StunPlayer = a.stunPlayer
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.PlayerAtkDrain = a.playerAtkDrain
s.PlayerACDebuff = a.playerACDebuff
s.MaxHPDrain = a.maxHPDrain
return s
}
// Every seat restores its own persisted per-character statuses: seat 0 from the
// session row's embedded ActorStatuses, seats 1+ from their combat_participant
// row. Without that, a party member's once-per-fight one-shots would rearm on
// every engine step. players[i] must be the combatant for seat i — the caller
// owns that ordering, and it must match sess.Participants[i-1].UserID.
func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatant, rng *rand.Rand) *turnEngine {
seat0 := restoreActor(players[0], sess.PlayerHP, sess.PlayerHPMax, sess.Statuses.ActorStatuses)
actors := make([]*actor, len(players))
actors[0] = seat0
for i := 1; i < len(players); i++ {
// A roster longer than the persisted seats can only happen if a caller
// hands us combatants the session never seated. Open those fresh rather
// than panicking mid-fight; hydrateCombatParticipants already rejects
// the reverse (a session claiming more seats than it persisted).
if i-1 >= len(sess.Participants) {
actors[i] = newActor(players[i])
continue
}
p := sess.Participants[i-1]
actors[i] = restoreActor(players[i], p.HP, p.HPMax, p.Statuses)
}
st := &combatState{
actor: seat0,
actors: actors,
enemyHP: sess.EnemyHP,
round: sess.Round,
enraged: sess.Statuses.Enraged,
armorBroken: sess.Statuses.ArmorBroken,
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
enemySkipFirst: sess.Statuses.EnemySkipNext,
// 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,
// 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,
}
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
// 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.
// stampSeat tags every event appended since mark with the roster seat it is
// about, so a party's play-by-play can name the right character. The primitives
// in combat_primitives.go emit against the cursor and know nothing of seats, so
// the tag is applied here, once per phase step, rather than at ~20 append sites.
//
// Solo stamps seat 0 onto seat-0 events — a no-op that omitempty erases.
func (te *turnEngine) stampSeat(mark, seat int) {
for i := mark; i < len(te.st.events); i++ {
te.st.events[i].Seat = seat
}
}
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:
// The cursor was seated on the acting player by resumeTurnEngine, and
// stepPlayerTurn never moves it, so every event this phase emits — the
// swings, the pet, the spirit weapon, the retaliate that kills them —
// belongs to that seat.
acting := te.st.seatIdx
te.stepPlayerTurn(action)
te.stampSeat(0, acting)
case CombatPhaseEnemyTurn:
// stepEnemyTurn self-stamps each attack-action to the seat it targeted: a
// party's enemy re-targets across the roster within one turn, so there is
// no single seat the whole phase lands on. Solo stamps seat 0 throughout,
// exactly as the old blanket stamp did.
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) {
// 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{
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 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
}
}
te.advance()
}
// 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 st.c.Mods.PetAttackProc <= 0 || st.randFloat() >= st.c.Mods.PetAttackProc {
return false
}
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",
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 st.c.Mods.SpiritWeaponProc <= 0 || st.randFloat() >= st.c.Mods.SpiritWeaponProc {
return false
}
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",
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.advance()
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, st.hpMax-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.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) { return enemyTargetSeat(te.st) }
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 whole turn (every
// attack-action) 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
mark := len(te.st.events)
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,
})
te.stampSeat(mark, target)
} 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.stampSeat(mark, target)
te.advance()
return
}
}
// Monster ability fires once, at the top of the enemy turn, against the
// initial target. cleave / lifesteal resolve their own damage and stand in
// for the enemy's first 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) {
mark := len(te.st.events)
if applyAbility(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result) {
te.stampSeat(mark, target)
// 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
}
te.stampSeat(mark, target)
switch te.enemy.Ability.Effect {
case "cleave", "lifesteal":
abilityDealtDamage = true
}
}
// The enemy takes enemyActionsThisRound() attack-actions, each its full
// SRD multiattack, re-targeting a fresh standing seat per action so a party's
// damage is spread across the roster rather than pinning the first target.
// A solo roster is exactly one action against its one seat — no re-target is
// drawn — so its event stream and RNG draws are unchanged. When the ability
// already dealt damage it was the first action, so one fewer follows; for solo
// that collapses to the old "the ability stood in for the attack" skip.
actions, reuseFirstTarget := enemyActionPlan(te.st, abilityDealtDamage)
for a := 0; a < actions; a++ {
if !(reuseFirstTarget && a == 0) {
tgt, alive := te.enemyTarget()
if !alive {
te.finish(CombatStatusLost)
return
}
te.st.seat(tgt)
target = tgt
}
if te.enemyAttackAction(target) {
return
}
}
te.advance()
}
// enemyAttackAction resolves one enemy attack-action — the full SRD multiattack
// profile — against the seat the cursor already points at, and stamps every event
// it emits to that seat (the party's enemy re-targets across the roster within one
// turn, so the whole-turn blanket stamp in step() no longer holds). Registered
// elites/bosses swing their full profile; everyone else gets a single attack from
// the template stats.
//
// Returns true only when it decided the fight (te.finish has already been called),
// so the caller ends the enemy turn. It returns false when the target dropped but
// the roster is still alive — this action is over, and the caller re-targets the
// next one.
func (te *turnEngine) enemyAttackAction(seat int) (finished bool) {
mark := len(te.st.events)
// Pet defensive procs are a single proc per attack-action: roll once, then
// spend 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.
// (This deliberately diverges from the auto-resolve engine's apply-to-all.)
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
}
for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
swing := *te.enemy
swing.Stats.Attack = atk.Damage
swing.Stats.AttackBonus = atk.AttackBonus
swingWhiff := petWhiff && i == 0
swingDeflect := petDeflect && i == 0
decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
if te.st.playerHP <= 0 {
te.stampSeat(mark, seat)
// The target is down. The enemy stops swinging at a corpse; the fight
// ends only if the roster is empty, otherwise this action is over and
// the caller re-targets.
if !te.st.anyAlive() {
te.finish(CombatStatusLost)
return true
}
return false
}
if decided || te.st.enemyHP <= 0 {
te.stampSeat(mark, seat)
te.finish(CombatStatusWon)
return true
}
}
te.stampSeat(mark, seat)
return false
}
// 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.
// 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
for i := range st.actors {
st.seat(i)
if st.poisonTicks <= 0 {
continue
}
mark := len(st.events)
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 && !trySave(st, st.c, CombatPhaseRoundEnd) && !st.anyAlive() {
te.stampSeat(mark, i)
te.finish(CombatStatusLost)
return
}
te.stampSeat(mark, i)
}
// Concentration aura (Spirit Guardians et al.): the lingering spell bites
// 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 — and before
// Misty's crowd swings, so a caster whose aura would end the round is not
// robbed of the win by an end-of-round debuff.
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",
Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
})
if st.enemyHP <= 0 {
te.finish(CombatStatusWon)
return
}
}
// Misty's crowd, then Misty's heal — per seat, after the round's other
// damage (including the concentration pulse above) has landed so the heal
// can answer it, which is the order endOfRoundForSeat uses. Both no-op (and
// draw no RNG) for a character with no Misty history, which is every
// simulated one.
for i := range st.actors {
st.seat(i)
if st.playerHP <= 0 {
continue
}
mark := len(st.events)
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, te.result)
te.stampSeat(mark, i)
if over {
te.finish(CombatStatusLost)
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)
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++
// 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.
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 snapshotActor carries them over
// from the prior state rather than re-deriving them.
//
// Per-actor state is read off each seat by index, never off the cursor: the
// enemy turn parks the cursor on its target and round_end walks it across the
// roster, so whoever it points at when commit runs is an accident of the phase.
// Seat 0 lands on the session row; seats 1+ on their participant rows.
func (te *turnEngine) commit() {
st := te.st
te.sess.Round = st.round
te.sess.EnemyHP = st.enemyHP
seat0 := st.actors[0]
te.sess.PlayerHP = seat0.playerHP
s := &te.sess.Statuses
s.ActorStatuses = snapshotActor(seat0, s.ActorStatuses)
for i := 1; i < len(st.actors) && i-1 < len(te.sess.Participants); i++ {
p := &te.sess.Participants[i-1]
p.HP = st.actors[i].playerHP
p.Statuses = snapshotActor(st.actors[i], p.Statuses)
}
// Fight-scoped: the enemy's own stance, and the debuffs it wears.
s.Enraged = st.enraged
s.ArmorBroken = st.armorBroken
s.ArmorBreakAmt = st.armorBreakAmt
s.EnemySkipNext = st.enemySkipFirst
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.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 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
}
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.
rng := combatSessionStepRNG(sess, stepSeat(sess, players, enemy))
te := resumeTurnEngine(sess, players, 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
}