mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Spiritual Weapon used to ride the pet-attack channel, so a petless cleric saw "🐾 Your faithful companion" each round and couldn't tell the spell was firing. Split it to SpiritWeaponProc/Dmg with its own ✨ flavor; damage now scales with spell mod + upcast. Rest also fired mid-dungeon — only the autorun honored RestingUntil, the !rest commands themselves had no gate. Block both short and long rest when an expedition or combat session is active.
545 lines
21 KiB
Go
545 lines
21 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
|
|
}
|
|
|
|
// 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,
|
|
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)
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
// 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.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
|
|
}
|