mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user