Combat: persist fight-scoped one-shots + turn-based buffs

CombatStatuses now mirrors every persistent combatState one-shot —
depleting resources (ward/spore/reflect/autocrit/arcane-ward/heal-
charges), once-per-fight class/race/subclass flags, and accumulated
buff stat deltas. resumeTurnEngine restores them; commit writes them
back in place. Fixes turn-based bugs where Orc rage, Halfling Lucky
reroll, and the Assassin first-attack bonus re-fired every round and
Abjuration Arcane Ward did nothing.

Buff spells and buff-type consumables (ward/atk/def/crit/spore/reflect/
auto-crit) are now usable mid-fight: a flattened-delta model diffs the
reused applySpellBuff/ApplyConsumableMods math against a throwaway
combatant, folds the marginal effect into the session, and re-applies
the persistent stat deltas onto the rebuilt player each round. Pure-
utility spells diff to nothing and are refused before a slot is spent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-14 07:12:37 -07:00
parent 5cd343af0c
commit befb44ef03
6 changed files with 434 additions and 52 deletions

View File

@@ -116,7 +116,23 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
armorBroken: sess.Statuses.ArmorBroken,
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
enemySkipFirst: sess.Statuses.EnemySkipNext,
rng: rng,
// 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,
rng: rng,
}
return &turnEngine{
sess: sess,
@@ -265,20 +281,37 @@ func (te *turnEngine) finish(status string) {
// 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
te.sess.Statuses = CombatStatuses{
PoisonTicks: st.poisonTicks,
PoisonDmg: st.poisonDmg,
StunPlayer: st.stunPlayer,
Enraged: st.enraged,
ArmorBroken: st.armorBroken,
ArmorBreakAmt: st.armorBreakAmt,
EnemySkipNext: st.enemySkipFirst,
}
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
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
}