mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Turn the four placeholder ability effects into working mechanics: spell_resist halves player spell damage, reveal_action rolls the player's next swing at disadvantage, fear_immune fizzles control spells, and ally_buff grants an accumulating enemy attack bonus. All four are armed by applyAbility, read by the shared resolution primitives, and round-tripped through CombatStatuses for turn-based suspend/resume. New branches are guarded by zero-valued state so the auto-resolve characterization golden is untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
524 lines
20 KiB
Go
524 lines
20 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,
|
|
petProcReady: sess.Statuses.PetProcReady,
|
|
// 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. resolvePlayerAttack 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 resolvePlayerAttack(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
|
|
}
|
|
te.sess.Phase = CombatPhaseEnemyTurn
|
|
}
|
|
|
|
// petStrike resolves the player's pet attack for a turn-based fight. Whether
|
|
// the pet lands a hit was decided once at fight start (rollCombatSessionPetProc)
|
|
// and parked on the session; the pet then strikes a single time on the player's
|
|
// first acting turn — this clears the flag so it never repeats. 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.petProcReady {
|
|
return false
|
|
}
|
|
st.petProcReady = 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)
|
|
}
|
|
|
|
// rollCombatSessionPetProc makes the one-and-only per-fight pet-attack roll and
|
|
// parks the result on the session. Called once at fight start. The draw is
|
|
// deterministic — seeded off the session id on a stream distinct from the
|
|
// per-(round,phase) combat streams — so a reaper auto-play of an abandoned
|
|
// fight reproduces the same outcome. Returns true if the pet will attack (so
|
|
// the caller can decide whether the session needs persisting).
|
|
//
|
|
// Note: only the base PetAttackProc (class/race/subclass passives) is rolled
|
|
// here — a pet-proc buff cast mid-fight gets no fresh roll, consistent with the
|
|
// per-fight rule. Such a buff still raises PetAttackDmg if the pet does strike.
|
|
func rollCombatSessionPetProc(sess *CombatSession, playerMods CombatModifiers) bool {
|
|
if playerMods.PetAttackProc <= 0 {
|
|
return false
|
|
}
|
|
var seed uint64 = 1469598103934665603
|
|
for _, c := range sess.SessionID {
|
|
seed = (seed ^ uint64(c)) * 1099511628211
|
|
}
|
|
rng := rand.New(rand.NewPCG(seed, 0x9E3779B97F4A7C15))
|
|
if rngFloat(rng) < playerMods.PetAttackProc {
|
|
sess.Statuses.PetProcReady = true
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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 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 {
|
|
// 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 _, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
|
|
swing := *te.enemy
|
|
swing.Stats.Attack = atk.Damage
|
|
swing.Stats.AttackBonus = atk.AttackBonus
|
|
decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, false, false, 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.PetProcReady = st.petProcReady
|
|
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
|
|
}
|