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.)_")
|
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 {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't set up the fight: "+err.Error())
|
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())
|
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
|
var b strings.Builder
|
||||||
if isBoss {
|
if isBoss {
|
||||||
if line := composeBossEntry(zone.ID, run.RunID, run.CurrentRoom); line != "" {
|
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())
|
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 has no effect the turn-based engine can apply yet.", spell.Name))
|
||||||
|
}
|
||||||
|
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
|
||||||
|
return p.SendDM(ctx.Sender, msg)
|
||||||
|
}
|
||||||
|
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)
|
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
|
||||||
if !supported {
|
if !supported {
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
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))
|
"%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 != "" {
|
||||||
// Material component cost (Revivify, Raise Dead — rare in a fight).
|
return p.SendDM(ctx.Sender, msg)
|
||||||
if spell.MaterialCost > 0 {
|
|
||||||
if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
||||||
"%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost))
|
|
||||||
}
|
}
|
||||||
}
|
eff = &turnActionEffect{
|
||||||
// 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 !ok {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
||||||
"No L%d slot available. %s", slotLevel, renderSlotsBrief(ctx.Sender)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eff := &turnActionEffect{
|
|
||||||
Label: out.Desc,
|
Label: out.Desc,
|
||||||
Action: "spell_cast",
|
Action: "spell_cast",
|
||||||
EnemyDamage: out.EnemyDamage,
|
EnemyDamage: out.EnemyDamage,
|
||||||
PlayerHeal: out.PlayerHeal,
|
PlayerHeal: out.PlayerHeal,
|
||||||
EnemySkip: out.EnemySkip,
|
EnemySkip: out.EnemySkip,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})
|
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if spell.Level > 0 {
|
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))
|
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 (in-combat) ────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// !consume <item> spends one combat consumable as the player's turn. Heal and
|
// !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,
|
// 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
|
// atk/def boost, spore, reflect, auto-crit) fold into the session's persisted
|
||||||
// are refused for now.
|
// fight-scoped state and carry for the rest of the fight.
|
||||||
|
|
||||||
// matchConsumable resolves a player-typed name against the player's consumable
|
// matchConsumable resolves a player-typed name against the player's consumable
|
||||||
// inventory: case-insensitive exact match first, then a unique prefix match.
|
// 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
|
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"}
|
eff := &turnActionEffect{Action: "use_consumable"}
|
||||||
switch def.Effect {
|
switch def.Effect {
|
||||||
case EffectHeal:
|
case EffectHeal:
|
||||||
@@ -591,14 +644,25 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
|
|||||||
eff.EnemyDamage = int(def.Value)
|
eff.EnemyDamage = int(def.Value)
|
||||||
eff.Label = fmt.Sprintf("%s — %d damage", def.Name, eff.EnemyDamage)
|
eff.Label = fmt.Sprintf("%s — %d damage", def.Name, eff.EnemyDamage)
|
||||||
default:
|
default:
|
||||||
|
// 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(
|
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))
|
"**%s** has no effect the turn-based engine can apply yet.", def.Name))
|
||||||
}
|
}
|
||||||
|
sess.Statuses.applyBuffDelta(d)
|
||||||
player, enemy, err := p.combatantsForSession(sess)
|
player, enemy, err = p.combatantsForSession(sess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
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})
|
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -42,13 +42,16 @@ const (
|
|||||||
// reaper sweeps it. Matches the started_at + 1h note in the schema.
|
// reaper sweeps it. Matches the started_at + 1h note in the schema.
|
||||||
const combatSessionTTL = time.Hour
|
const combatSessionTTL = time.Hour
|
||||||
|
|
||||||
// CombatStatuses is the serialized between-round effect state for a fight —
|
// CombatStatuses is the serialized between-round effect state for a fight: the
|
||||||
// the subset of combatState fields that genuinely persist round-to-round
|
// subset of combatState that genuinely persists round-to-round, plus the
|
||||||
// (monster-ability effects). Fight-scoped one-shots (rage, Lucky reroll,
|
// fight-scoped buffs a mid-fight !cast / !consume layers on. Everything here
|
||||||
// ward/heal charges) are deliberately not carried here: they reset if a fight
|
// round-trips combatState -> Statuses -> combatState across every engine step,
|
||||||
// is resumed across a bot restart. Extending coverage to those one-shots is
|
// so a fight resumes from exact mid-state after a suspend or bot restart.
|
||||||
// the command-wiring PR's job, once it reconstructs Combatants from a session.
|
//
|
||||||
|
// All fields are scalar by design — CombatStatuses must stay comparable so the
|
||||||
|
// persistence round-trip can be asserted with ==.
|
||||||
type CombatStatuses struct {
|
type CombatStatuses struct {
|
||||||
|
// Monster-ability effects — the original between-round persistence set.
|
||||||
PoisonTicks int `json:"poison_ticks,omitempty"`
|
PoisonTicks int `json:"poison_ticks,omitempty"`
|
||||||
PoisonDmg int `json:"poison_dmg,omitempty"`
|
PoisonDmg int `json:"poison_dmg,omitempty"`
|
||||||
StunPlayer bool `json:"stun_player,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
|
// — those phases resolve as separate engine steps with separate in-memory
|
||||||
// combatState, so the flag must survive the commit between them.
|
// combatState, so the flag must survive the commit between them.
|
||||||
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
|
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.
|
// 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
|
// applyPendingCast and the auto-heal consumable setup are intentionally left
|
||||||
// out of the shared builder — those are one-shot mutations that runZoneCombat
|
// out of the shared builder — those are one-shot mutations that runZoneCombat
|
||||||
// applies once before SimulateCombat. Re-applying them on every turn-based
|
// 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
|
// buildZoneCombatants derives the player/enemy Combatant pair for a zone
|
||||||
// encounter: the player's full D&D layer (stats + class/race/subclass passives
|
// 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)
|
zone := zoneOrFallback(run.ZoneID)
|
||||||
player, enemy, _, err = p.buildZoneCombatants(id.UserID(sess.UserID), monster, int(zone.Tier), run.DMMood)
|
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
|
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) {
|
func TestCombatSessionRNG_DeterministicPerPhase(t *testing.T) {
|
||||||
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhaseEnemyTurn}
|
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhaseEnemyTurn}
|
||||||
a := combatSessionRNG(sess)
|
a := combatSessionRNG(sess)
|
||||||
|
|||||||
@@ -116,6 +116,22 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
|
|||||||
armorBroken: sess.Statuses.ArmorBroken,
|
armorBroken: sess.Statuses.ArmorBroken,
|
||||||
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
|
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
|
||||||
enemySkipFirst: sess.Statuses.EnemySkipNext,
|
enemySkipFirst: sess.Statuses.EnemySkipNext,
|
||||||
|
// 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,
|
rng: rng,
|
||||||
}
|
}
|
||||||
return &turnEngine{
|
return &turnEngine{
|
||||||
@@ -265,20 +281,37 @@ func (te *turnEngine) finish(status string) {
|
|||||||
// commit folds this step's combatState back into the session struct: HP,
|
// commit folds this step's combatState back into the session struct: HP,
|
||||||
// round, the between-round status snapshot, and the appended event log.
|
// round, the between-round status snapshot, and the appended event log.
|
||||||
// Phase / Status were already set by step. saveCombatSession persists it.
|
// 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() {
|
func (te *turnEngine) commit() {
|
||||||
st := te.st
|
st := te.st
|
||||||
te.sess.Round = st.round
|
te.sess.Round = st.round
|
||||||
te.sess.PlayerHP = st.playerHP
|
te.sess.PlayerHP = st.playerHP
|
||||||
te.sess.EnemyHP = st.enemyHP
|
te.sess.EnemyHP = st.enemyHP
|
||||||
te.sess.Statuses = CombatStatuses{
|
s := &te.sess.Statuses
|
||||||
PoisonTicks: st.poisonTicks,
|
s.PoisonTicks = st.poisonTicks
|
||||||
PoisonDmg: st.poisonDmg,
|
s.PoisonDmg = st.poisonDmg
|
||||||
StunPlayer: st.stunPlayer,
|
s.StunPlayer = st.stunPlayer
|
||||||
Enraged: st.enraged,
|
s.Enraged = st.enraged
|
||||||
ArmorBroken: st.armorBroken,
|
s.ArmorBroken = st.armorBroken
|
||||||
ArmorBreakAmt: st.armorBreakAmt,
|
s.ArmorBreakAmt = st.armorBreakAmt
|
||||||
EnemySkipNext: st.enemySkipFirst,
|
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...)
|
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -406,3 +406,65 @@ func rollSpellDamageDice(spell SpellDefinition, slot, charLevel int) int {
|
|||||||
}
|
}
|
||||||
return total
|
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