Files
gogobee/internal/plugin/combat_turn_engine.go
prosolis d538f91cf7 Combat engine: pay off the N-body debt, and hire Pete
N3 widened the combat *roster* from 1 to N but never widened the action model,
the scaling model, or the test net to match. Building the hireable companion
walked into all three. Only one of the four defects found was the companion's;
the rest have been live in prod for every human party since N3.

The party golden did not exist (§5)
  Solo combat is pinned exhaustively (7468 lines); the entire N-body layer had
  nothing. That is why a healer class that cannot heal shipped without a test
  going red. Adds party_characterization.golden (9 scenarios x 5 seeds, incl.
  weak and dying seats) and TestPartyCharacterization_OneSeatIsStillSolo, so the
  N-body path can never quietly stop being a superset of the balance corpus.
  Regenerate only on purpose: -update-party.

No action could target another seat (§1)
  Every heal in the engine was self-scoped. A party cleric could not put one hit
  point on a friend. Adds turnActionEffect.AllyHeal/AllySeat, `!cast <spell>
  @user` and `--target @user` -- the latter has been advertised in !help and
  silently swallowed by parseCombatCast since SP2 ("reserved for SP3"). The auto
  picker uses it too (simPickAllyHeal), so away-players and engine-driven healers
  behave like competent ones. It will not raise the dead.

Corpses kept buffing the boss (§2b)
  enemyActionsThisRound counted len(st.actors), dead included -- so a party that
  lost a member kept paying for them, and the survivor faced a boss still swinging
  at two-player cadence, alone. A death spiral with the arrow pointing the wrong
  way. Now counts livingActors(). Party golden moved deliberately for this.

An engine-driven seat was a bool any command could clear (§3)
  autoDriveCombat drives a party by dispatching each seat's turn AS that seat, so
  a companion's own auto-played move arrived at beginCombatTurn looking like a
  player returning to the keyboard and cleared the latch that was moving him. He
  then stood in the fight doing nothing while the boss he had inflated killed
  everyone. ActorStatuses.EngineDriven is now a persisted seat property that no
  command clears, and the driver calls driveEngineSeat instead of impersonating.

"The party" could be empty (§4)
  A solo expedition has no expedition_party rows, so asking the roster who was in
  the party answered "nobody" -- and every caller fell back to something plausible.
  That is how the companion got hired at level 1 for exactly the player the feature
  exists for. expeditionParty()/partyHumans() always include the owner.

The companion himself (!expedition hire [class] / !expedition dismiss)
  Day 1, leader only, costs coins, role-fills the gap, globally exclusive. He is an
  NPC seat and must never become a player: no player_meta, no dnd_character, no
  inventory, no DM room -- mint him a player_meta row and
  ensureDnDCharacterForCombat will auto-build the news bot a real character, and
  he starts appearing in the graveyard and filing death notices about himself.
  Mail and seats are different sets: he fights, he does not get written to.

Measured on millenia, n=750/arm. Before these fixes he was -28pp -- worse than no
companion at all. After: solo 48.5% -> 63.9% clear (+15.3pp), with +28.0pp for
trailing players and +2.0pp for leaders. Help, never a carry.

The solo golden is byte-identical throughout: solo combat provably did not move,
and the balance corpus is intact.

Known gap: the companion cannot cast (castActionForSeat loads a sheet from the DB
and he has none by design), so a hired Cleric is still just a bad fighter.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 12:39:01 -07:00

971 lines
38 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
// AllyHeal heals ANOTHER seat instead of the caster — the thing the engine
// could not do until §1, and the reason a party cleric was a cleric in name
// only. Every heal in the engine wrote to the acting seat: MistyHealProc,
// HealItem, PlayerHeal above. Nothing could put a hit point on a friend, so
// the class whose entire identity is keeping other people alive could not.
// N3 shipped that way and no test noticed, because there was no party golden.
//
// Zero means no ally heal, which keeps every existing construction of this
// struct meaning exactly what it meant before. AllySeat is only read when
// AllyHeal > 0, so its zero value is never mistaken for "seat 0".
AllyHeal int
AllySeat int
// 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)
}
// §1 — heal somebody else. The caster's cursor stays where it is; only the
// target's HP moves.
//
// A downed seat is NOT raised. Death in this engine is terminal for the fight
// (the close-out marks them, the hospital takes them), and a heal that
// resurrected a corpse would quietly rewrite the loss rules every close-out
// path depends on. Healing keeps people up; it does not bring them back.
if eff.AllyHeal > 0 && eff.AllySeat >= 0 && eff.AllySeat < len(st.actors) {
if tgt := st.actors[eff.AllySeat]; tgt.playerHP > 0 {
cap := max(1, tgt.hpMax-tgt.maxHPDrain)
before := tgt.playerHP
tgt.playerHP = min(cap, tgt.playerHP+eff.AllyHeal)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "ally_heal",
Damage: tgt.playerHP - before, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP,
Seat: eff.AllySeat, Desc: eff.Label,
})
}
}
// 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
}