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

@@ -71,7 +71,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_")
}
_, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood)
player, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't set up the fight: "+err.Error())
}
@@ -90,6 +90,14 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "Couldn't start the fight: "+err.Error())
}
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
// the session so they survive the turn engine's resume/commit cycle.
if seedCombatSessionOneShots(sess, player.Mods) {
if err := saveCombatSession(sess); err != nil {
slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err)
}
}
var b strings.Builder
if isBoss {
if line := composeBossEntry(zone.ID, run.RunID, run.CurrentRoom); line != "" {
@@ -467,38 +475,55 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
}
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
if !supported {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s is a buff/utility spell — those aren't usable in turn-based fights yet (they need cross-round tracking). Try a damage, healing, or control spell.", spell.Name))
}
// Material component cost (Revivify, Raise Dead — rare in a fight).
if spell.MaterialCost > 0 {
if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") {
var eff *turnActionEffect
if spell.Effect == EffectBuffSelf || spell.Effect == EffectBuffAlly {
// Buff path — resolve the buff against a throwaway combatant, fold the
// marginal effect into the session's persisted state, then rebuild the
// pair so the buff is live for this round's enemy turn.
as, am := player.Stats, player.Mods
applySpellBuff(spell, c, &as, &am, slotLevel)
d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost))
"%s has no effect the turn-based engine can apply yet.", spell.Name))
}
}
// Slot spend — audit pattern: debit, run the round, refund on failure.
if spell.Level > 0 {
ok, serr := consumeSpellSlot(ctx.Sender, slotLevel)
if serr != nil {
return p.SendDM(ctx.Sender, "Couldn't consume slot: "+serr.Error())
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
return p.SendDM(ctx.Sender, msg)
}
if !ok {
sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess)
if err != nil {
if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel)
}
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
}
label := spell.Name + " — active"
if d.heal > 0 {
label = fmt.Sprintf("%s — +%d HP", spell.Name, d.heal)
}
eff = &turnActionEffect{
Action: "spell_cast", Label: label,
PlayerHeal: d.heal, EnemySkip: d.enemySkip,
}
} else {
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
if !supported {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"No L%d slot available. %s", slotLevel, renderSlotsBrief(ctx.Sender)))
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name))
}
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
return p.SendDM(ctx.Sender, msg)
}
eff = &turnActionEffect{
Label: out.Desc,
Action: "spell_cast",
EnemyDamage: out.EnemyDamage,
PlayerHeal: out.PlayerHeal,
EnemySkip: out.EnemySkip,
}
}
eff := &turnActionEffect{
Label: out.Desc,
Action: "spell_cast",
EnemyDamage: out.EnemyDamage,
PlayerHeal: out.PlayerHeal,
EnemySkip: out.EnemySkip,
}
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})
if err != nil {
if spell.Level > 0 {
@@ -509,12 +534,35 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
}
// chargeSpellCost debits a spell's material component and leveled slot for a
// turn-based cast. It returns a non-empty player-facing message on failure; on
// success the caller owns the slot and must refundSpellSlot if the round itself
// errors. Material components (rare in a fight) are not refunded if the slot
// debit then fails — matching the auto-resolve cast path.
func (p *AdventurePlugin) chargeSpellCost(userID id.UserID, spell SpellDefinition, slotLevel int) string {
if spell.MaterialCost > 0 {
if p.euro == nil || !p.euro.Debit(userID, float64(spell.MaterialCost), "dnd_spell_component") {
return fmt.Sprintf("%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost)
}
}
if spell.Level > 0 {
ok, serr := consumeSpellSlot(userID, slotLevel)
if serr != nil {
return "Couldn't consume slot: " + serr.Error()
}
if !ok {
return fmt.Sprintf("No L%d slot available. %s", slotLevel, renderSlotsBrief(userID))
}
}
return ""
}
// ── !consume (in-combat) ────────────────────────────────────────────────────
//
// !consume <item> spends one combat consumable as the player's turn. Heal and
// flat-damage items resolve fully within the round; buff-type items (ward,
// atk/def boost, spore, reflect, auto-crit) need cross-round stat tracking and
// are refused for now.
// atk/def boost, spore, reflect, auto-crit) fold into the session's persisted
// fight-scoped state and carry for the rest of the fight.
// matchConsumable resolves a player-typed name against the player's consumable
// inventory: case-insensitive exact match first, then a unique prefix match.
@@ -582,6 +630,11 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
}
def := item.Def
player, enemy, err := p.combatantsForSession(sess)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
}
eff := &turnActionEffect{Action: "use_consumable"}
switch def.Effect {
case EffectHeal:
@@ -591,13 +644,24 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
eff.EnemyDamage = int(def.Value)
eff.Label = fmt.Sprintf("%s — %d damage", def.Name, eff.EnemyDamage)
default:
return p.SendDM(ctx.Sender, fmt.Sprintf(
"**%s** is a buff-type consumable — those aren't usable in turn-based fights yet. Healing and damage items (Berry Poultice, Coal Bomb, …) work.", def.Name))
}
player, enemy, err := p.combatantsForSession(sess)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
// Buff-type consumable — resolve the marginal effect against a
// throwaway combatant, fold it into the session's persisted state, and
// rebuild the pair so the buff is live for this round's enemy turn.
as, am := player.Stats, player.Mods
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"**%s** has no effect the turn-based engine can apply yet.", def.Name))
}
sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
}
eff.Label = def.Name + " — active"
eff.PlayerHeal = d.heal
eff.EnemySkip = d.enemySkip
}
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff})

View File

@@ -42,13 +42,16 @@ const (
// reaper sweeps it. Matches the started_at + 1h note in the schema.
const combatSessionTTL = time.Hour
// CombatStatuses is the serialized between-round effect state for a fight
// the subset of combatState fields that genuinely persist round-to-round
// (monster-ability effects). Fight-scoped one-shots (rage, Lucky reroll,
// ward/heal charges) are deliberately not carried here: they reset if a fight
// is resumed across a bot restart. Extending coverage to those one-shots is
// the command-wiring PR's job, once it reconstructs Combatants from a session.
// CombatStatuses is the serialized between-round effect state for a fight: the
// subset of combatState that genuinely persists round-to-round, plus the
// fight-scoped buffs a mid-fight !cast / !consume layers on. Everything here
// round-trips combatState -> Statuses -> combatState across every engine step,
// so a fight resumes from exact mid-state after a suspend or bot restart.
//
// All fields are scalar by design — CombatStatuses must stay comparable so the
// persistence round-trip can be asserted with ==.
type CombatStatuses struct {
// Monster-ability effects — the original between-round persistence set.
PoisonTicks int `json:"poison_ticks,omitempty"`
PoisonDmg int `json:"poison_dmg,omitempty"`
StunPlayer bool `json:"stun_player,omitempty"`
@@ -60,6 +63,86 @@ type CombatStatuses struct {
// — those phases resolve as separate engine steps with separate in-memory
// combatState, so the flag must survive the commit between them.
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
// Fight-scoped depleting resources — mirror the combatState charges that
// genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by
// a mid-fight !cast / !consume, restored into combatState on every resume,
// written back on every commit so a depleting charge can't silently reset
// when a fight is suspended and resumed.
WardCharges int `json:"ward_charges,omitempty"`
SporeRounds int `json:"spore_rounds,omitempty"`
ReflectFrac float64 `json:"reflect_frac,omitempty"`
AutoCritFirst bool `json:"auto_crit_first,omitempty"`
ArcaneWardHP int `json:"arcane_ward_hp,omitempty"`
HealChargesLeft int `json:"heal_charges_left,omitempty"`
// Once-per-fight class/race/subclass one-shots: the "already used" flags.
// Without persistence these reset every round on resume, letting a Halfling
// reroll a nat 1 or an Orc rage every single round of a turn-based fight.
DeathSaveUsed bool `json:"death_save_used,omitempty"`
LuckyUsed bool `json:"lucky_used,omitempty"`
Raged bool `json:"raged,omitempty"`
PendingRage bool `json:"pending_rage,omitempty"`
FirstAtkBonusUsed bool `json:"first_atk_bonus_used,omitempty"`
AssassinateReroll bool `json:"assassinate_reroll_used,omitempty"`
AssassinateBonus bool `json:"assassinate_bonus_used,omitempty"`
// Persistent stat buffs from mid-fight !cast / !consume, accumulated as
// deltas against the freshly-rebuilt combatant. applySessionBuffs folds
// these back onto the player every round; diffTurnBuff produces them.
// BuffDamageReductMul is multiplicative (0 = none / 1.0 neutral).
BuffACBonus int `json:"buff_ac_bonus,omitempty"`
BuffAtkBonus int `json:"buff_atk_bonus,omitempty"`
BuffSpeedBonus int `json:"buff_speed_bonus,omitempty"`
BuffPetDmg int `json:"buff_pet_dmg,omitempty"`
BuffCritRate float64 `json:"buff_crit_rate,omitempty"`
BuffDamageBonus float64 `json:"buff_damage_bonus,omitempty"`
BuffPetProc float64 `json:"buff_pet_proc,omitempty"`
BuffDamageReductMul float64 `json:"buff_damage_reduct_mul,omitempty"`
}
// applyBuffDelta folds one resolved buff (the result of a !cast / !consume
// player turn) into the persisted fight-scoped state. Stat components
// accumulate as deltas; depleting resources add to their running counters.
func (s *CombatStatuses) applyBuffDelta(d turnBuffDelta) {
s.BuffACBonus += d.dAC
s.BuffAtkBonus += d.dAtk
s.BuffSpeedBonus += d.dSpeed
s.BuffPetDmg += d.dPetDmg
s.BuffCritRate += d.dCrit
s.BuffDamageBonus += d.dDmgBonus
s.BuffPetProc += d.dPetProc
if d.dReductMul > 0 && d.dReductMul != 1 {
if s.BuffDamageReductMul == 0 {
s.BuffDamageReductMul = d.dReductMul
} else {
s.BuffDamageReductMul *= d.dReductMul
}
}
s.WardCharges += d.ward
s.SporeRounds += d.spore
s.ReflectFrac += d.reflect
if d.autoCrit {
s.AutoCritFirst = true
}
s.ArcaneWardHP += d.arcaneWard
}
// seedCombatSessionOneShots copies the fight-start one-shot resources off the
// freshly-built player combatant into the session's persisted statuses. Only
// the Abjuration Arcane Ward is normally non-zero at fight start — the
// turn-based build deliberately omits pre-combat consumables and queued casts —
// but the full set is seeded for robustness. Returns true if anything was set.
func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool {
st := &s.Statuses
st.WardCharges = playerMods.WardCharges
st.SporeRounds = playerMods.SporeCloud
st.ReflectFrac = playerMods.ReflectNext
st.AutoCritFirst = playerMods.AutoCritFirst
st.ArcaneWardHP = playerMods.ArcaneWardHP
st.HealChargesLeft = playerMods.HealItemCharges
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
}
// CombatSession is the in-memory shape of a combat_session row.

View File

@@ -24,7 +24,9 @@ import (
// applyPendingCast and the auto-heal consumable setup are intentionally left
// out of the shared builder — those are one-shot mutations that runZoneCombat
// applies once before SimulateCombat. Re-applying them on every turn-based
// round would double-consume. Per-round !cast / !consume is a later sub-phase.
// round would double-consume. Mid-fight !cast / !consume buffs are instead
// persisted on the CombatSession and folded back in by combatantsForSession
// via applySessionBuffs.
// buildZoneCombatants derives the player/enemy Combatant pair for a zone
// encounter: the player's full D&D layer (stats + class/race/subclass passives
@@ -121,5 +123,31 @@ func (p *AdventurePlugin) combatantsForSession(sess *CombatSession) (player Comb
}
zone := zoneOrFallback(run.ZoneID)
player, enemy, _, err = p.buildZoneCombatants(id.UserID(sess.UserID), monster, int(zone.Tier), run.DMMood)
if err != nil {
return player, enemy, err
}
// Fold any fight-scoped buffs a mid-fight !cast / !consume layered on back
// onto the freshly-rebuilt player. The depleting one-shots (ward/spore/…)
// live on the session's Statuses and flow through the turn engine's
// resume/commit cycle, so only the persistent stat deltas are applied here.
applySessionBuffs(&player, sess.Statuses)
return player, enemy, err
}
// applySessionBuffs re-derives the persistent stat effect of every mid-fight
// buff onto the rebuilt player. The buffs are stored as accumulated deltas
// (diffTurnBuff produced them against the player's state at cast time), so
// re-applying them to a deterministic rebuild reproduces the same totals every
// round without double-counting.
func applySessionBuffs(player *Combatant, s CombatStatuses) {
player.Stats.AC += s.BuffACBonus
player.Stats.AttackBonus += s.BuffAtkBonus
player.Stats.Speed += s.BuffSpeedBonus
player.Stats.CritRate += s.BuffCritRate
player.Mods.DamageBonus += s.BuffDamageBonus
player.Mods.PetAttackProc += s.BuffPetProc
player.Mods.PetAttackDmg += s.BuffPetDmg
if s.BuffDamageReductMul > 0 {
player.Mods.DamageReduct *= s.BuffDamageReductMul
}
}

View File

@@ -285,6 +285,118 @@ func TestTurnEngine_StepRejectsTerminalSession(t *testing.T) {
}
}
// ── Fight-scoped buff persistence ──────────────────────────────────────────
func TestDiffTurnBuff(t *testing.T) {
bs := CombatStats{AC: 14, AttackBonus: 3, MaxHP: 50, Speed: 10, CritRate: 0.05}
bm := CombatModifiers{DamageBonus: 0.1, DamageReduct: 1.0}
// A buff that bumps AC, grants ward charges, reduces damage taken, and
// raises max HP (Aid-style → collapses to a heal).
as, am := bs, bm
as.AC = 16
as.MaxHP = 55
am.WardCharges = 2
am.DamageReduct = 0.8
d := diffTurnBuff(bs, as, bm, am)
if d.dAC != 2 || d.ward != 2 || d.heal != 5 {
t.Errorf("delta = %+v, want dAC 2 / ward 2 / heal 5", d)
}
if d.dReductMul < 0.79 || d.dReductMul > 0.81 {
t.Errorf("dReductMul = %v, want ~0.8", d.dReductMul)
}
if !d.statComponent() || !d.any() {
t.Errorf("buff with AC + reduct should report statComponent and any")
}
// A no-op buff (utility spell with no combat hook) diffs to nothing.
none := diffTurnBuff(bs, bs, bm, bm)
if none.statComponent() || none.any() {
t.Errorf("no-op buff should report neither statComponent nor any: %+v", none)
}
}
func TestApplyBuffDelta(t *testing.T) {
var s CombatStatuses
s.applyBuffDelta(turnBuffDelta{dAC: 2, ward: 1, dReductMul: 0.8, autoCrit: true})
if s.BuffACBonus != 2 || s.WardCharges != 1 || !s.AutoCritFirst {
t.Errorf("first delta not folded in: %+v", s)
}
if s.BuffDamageReductMul < 0.79 || s.BuffDamageReductMul > 0.81 {
t.Errorf("BuffDamageReductMul = %v, want ~0.8", s.BuffDamageReductMul)
}
// A second buff stacks: deltas accumulate, reduction multipliers compound.
s.applyBuffDelta(turnBuffDelta{dAC: 1, dReductMul: 0.5})
if s.BuffACBonus != 3 {
t.Errorf("BuffACBonus = %d, want 3 after stacking", s.BuffACBonus)
}
if s.BuffDamageReductMul < 0.39 || s.BuffDamageReductMul > 0.41 {
t.Errorf("BuffDamageReductMul = %v, want ~0.4 (0.8 * 0.5)", s.BuffDamageReductMul)
}
}
func TestApplySessionBuffs(t *testing.T) {
player := basePlayer() // AC 0, AttackBonus 0, CritRate 0.05, Speed 10, DamageReduct 1.0
applySessionBuffs(&player, CombatStatuses{
BuffACBonus: 3, BuffAtkBonus: 2, BuffSpeedBonus: 5, BuffCritRate: 0.15,
BuffDamageBonus: 0.25, BuffDamageReductMul: 0.8, BuffPetProc: 0.5, BuffPetDmg: 6,
})
if player.Stats.AC != 3 || player.Stats.AttackBonus != 2 || player.Stats.Speed != 15 {
t.Errorf("stat deltas wrong: %+v", player.Stats)
}
if player.Stats.CritRate < 0.19 || player.Stats.CritRate > 0.21 {
t.Errorf("CritRate = %v, want ~0.20", player.Stats.CritRate)
}
if player.Mods.DamageBonus != 0.25 || player.Mods.PetAttackProc != 0.5 || player.Mods.PetAttackDmg != 6 {
t.Errorf("mod deltas wrong: %+v", player.Mods)
}
if player.Mods.DamageReduct < 0.79 || player.Mods.DamageReduct > 0.81 {
t.Errorf("DamageReduct = %v, want ~0.8 (1.0 * 0.8)", player.Mods.DamageReduct)
}
}
func TestSeedCombatSessionOneShots(t *testing.T) {
// Arcane Ward is the one resource normally live at fight start.
s := &CombatSession{}
if !seedCombatSessionOneShots(s, CombatModifiers{ArcaneWardHP: 15}) {
t.Error("seed should report true when Arcane Ward is present")
}
if s.Statuses.ArcaneWardHP != 15 {
t.Errorf("ArcaneWardHP = %d, want 15", s.Statuses.ArcaneWardHP)
}
// Nothing to seed → false, statuses untouched.
empty := &CombatSession{}
if seedCombatSessionOneShots(empty, CombatModifiers{}) {
t.Error("seed should report false with no fight-start resources")
}
}
// TestTurnEngine_OneShotsRoundTrip drives a no-op round_end step and confirms
// every fight-scoped one-shot survives the resume -> combatState -> commit
// cycle. Without that round-trip a Halfling could reroll a nat 1 — or a player
// re-bank a depleted ward charge — on every resumed round.
func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 50)
sess.Statuses = CombatStatuses{
WardCharges: 3, SporeRounds: 2, ReflectFrac: 0.5, AutoCritFirst: true,
ArcaneWardHP: 10, HealChargesLeft: 1,
Raged: true, LuckyUsed: true, DeathSaveUsed: true, PendingRage: true,
FirstAtkBonusUsed: true, AssassinateReroll: true, AssassinateBonus: true,
// Buff stat deltas are owned by the command layer — commit must leave
// them untouched rather than zeroing them on every step.
BuffACBonus: 2, BuffDamageBonus: 0.15,
}
want := sess.Statuses // round_end with no poison mutates none of these
player, enemy := basePlayer(), baseEnemy()
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
t.Fatal(err)
}
if sess.Statuses != want {
t.Errorf("one-shots not round-tripped:\n got %+v\n want %+v", sess.Statuses, want)
}
}
func TestCombatSessionRNG_DeterministicPerPhase(t *testing.T) {
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhaseEnemyTurn}
a := combatSessionRNG(sess)

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...)
}

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
}