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

@@ -406,3 +406,65 @@ func rollSpellDamageDice(spell SpellDefinition, slot, charLevel int) int {
}
return total
}
// ── Phase 13 — turn-based buff resolution ────────────────────────────────────
// turnBuffDelta is the marginal effect of one buff spell or consumable applied
// as a turn-based player action, diffed against the player's already-buffed
// combatant. Stat components (dAC, dDmgBonus, …) accumulate as session deltas;
// depleting resources (ward, spore, …) add to their running counters; heal and
// enemySkip land within the casting round only.
type turnBuffDelta struct {
dAC, dAtk, dSpeed, dPetDmg int
dCrit, dDmgBonus, dPetProc float64
dReductMul float64 // multiplicative; 1 = no change
ward, spore, arcaneWard int
reflect float64
autoCrit, enemySkip bool
heal int // a MaxHP-raise (Aid) collapses to an immediate heal
}
// statComponent reports whether the buff has a re-applicable persistent stat
// effect — the part applySessionBuffs folds back onto the player every round.
func (d turnBuffDelta) statComponent() bool {
return d.dAC != 0 || d.dAtk != 0 || d.dSpeed != 0 || d.dPetDmg != 0 ||
d.dCrit != 0 || d.dDmgBonus != 0 || d.dPetProc != 0 ||
(d.dReductMul > 0 && d.dReductMul != 1)
}
// any reports whether the buff produced any applicable effect at all. A buff
// the turn engine can't represent yet (pure utility) diffs to nothing, and the
// caller refuses it before a slot or item is spent.
func (d turnBuffDelta) any() bool {
return d.statComponent() || d.ward != 0 || d.spore != 0 || d.reflect != 0 ||
d.autoCrit || d.arcaneWard != 0 || d.enemySkip || d.heal != 0
}
// diffTurnBuff computes the marginal effect of a buff: (bs,bm) is the player's
// state before the buff, (as,am) the throwaway result of applying it. Reusing
// applySpellBuff / ApplyConsumableMods against a copy keeps turn-based buffs
// numerically identical to the auto-resolve engine.
func diffTurnBuff(bs, as CombatStats, bm, am CombatModifiers) turnBuffDelta {
d := turnBuffDelta{
dAC: as.AC - bs.AC,
dAtk: as.AttackBonus - bs.AttackBonus,
dSpeed: as.Speed - bs.Speed,
dPetDmg: am.PetAttackDmg - bm.PetAttackDmg,
dCrit: as.CritRate - bs.CritRate,
dDmgBonus: am.DamageBonus - bm.DamageBonus,
dPetProc: am.PetAttackProc - bm.PetAttackProc,
ward: am.WardCharges - bm.WardCharges,
spore: am.SporeCloud - bm.SporeCloud,
reflect: am.ReflectNext - bm.ReflectNext,
arcaneWard: am.ArcaneWardHP - bm.ArcaneWardHP,
autoCrit: am.AutoCritFirst && !bm.AutoCritFirst,
enemySkip: am.SpellEnemySkipFirst && !bm.SpellEnemySkipFirst,
heal: as.MaxHP - bs.MaxHP,
}
if bm.DamageReduct > 0 {
d.dReductMul = am.DamageReduct / bm.DamageReduct
} else {
d.dReductMul = 1
}
return d
}