From befb44ef0312f3a8d3bdbb5e7fae60eb2b932e25 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 14 May 2026 07:12:37 -0700 Subject: [PATCH] Combat: persist fight-scoped one-shots + turn-based buffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/plugin/combat_cmd.go | 134 +++++++++++++++++------- internal/plugin/combat_session.go | 95 +++++++++++++++-- internal/plugin/combat_session_build.go | 30 +++++- internal/plugin/combat_session_test.go | 112 ++++++++++++++++++++ internal/plugin/combat_turn_engine.go | 53 ++++++++-- internal/plugin/dnd_spell_combat.go | 62 +++++++++++ 6 files changed, 434 insertions(+), 52 deletions(-) diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index 5e033a4..db00dc4 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -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 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}) diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 758f515..8cfde86 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -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. diff --git a/internal/plugin/combat_session_build.go b/internal/plugin/combat_session_build.go index cfd87bf..3b29e4d 100644 --- a/internal/plugin/combat_session_build.go +++ b/internal/plugin/combat_session_build.go @@ -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 + } +} diff --git a/internal/plugin/combat_session_test.go b/internal/plugin/combat_session_test.go index cd7583d..94c02a7 100644 --- a/internal/plugin/combat_session_test.go +++ b/internal/plugin/combat_session_test.go @@ -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) diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index aa5de34..1bf92a1 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -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...) } diff --git a/internal/plugin/dnd_spell_combat.go b/internal/plugin/dnd_spell_combat.go index b26eac9..8046f5b 100644 --- a/internal/plugin/dnd_spell_combat.go +++ b/internal/plugin/dnd_spell_combat.go @@ -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 +}