Combat: wire pet procs into the turn-based engine

Pet attacks were never resolved in turn-based fights. Roll the proc once
at fight start (a per-round roll would make a proc near-certain over a
long manual fight), persist it on the session so suspend/resume and
reaper auto-play honor the same outcome, and land a single pet hit on
the player's first acting turn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-14 08:08:26 -07:00
parent c84682abf7
commit c5a2634657
5 changed files with 141 additions and 2 deletions

View File

@@ -91,8 +91,11 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
} }
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto // Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
// the session so they survive the turn engine's resume/commit cycle. // the session so they survive the turn engine's resume/commit cycle, and
if seedCombatSessionOneShots(sess, player.Mods) { // make the one-and-only per-fight pet-attack roll.
seeded := seedCombatSessionOneShots(sess, player.Mods)
pet := rollCombatSessionPetProc(sess, player.Mods)
if seeded || pet {
if err := saveCombatSession(sess); err != nil { if err := saveCombatSession(sess); err != nil {
slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err) slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err)
} }

View File

@@ -267,6 +267,10 @@ type combatState struct {
// the enemy would otherwise attack). // the enemy would otherwise attack).
enemySkipFirst bool enemySkipFirst bool
// Phase 13 turn-based — pet attack decided once at fight start; the pet
// strikes once on the player's first acting turn, which clears this.
petProcReady bool
// Phase 10 SUB2a-ii first-attack one-shots. // Phase 10 SUB2a-ii first-attack one-shots.
firstAttackBonusUsed bool firstAttackBonusUsed bool
assassinateRerollUsed bool assassinateRerollUsed bool

View File

@@ -65,6 +65,14 @@ type CombatStatuses struct {
// 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"`
// PetProcReady is the per-fight pet-attack outcome. Auto-resolve rolls the
// pet proc every round; a manual fight can run many rounds, so the roll is
// decided once at fight start (rollCombatSessionPetProc) and parked here.
// The pet then lands a single hit on the player's first acting turn, which
// clears the flag — persisted so a suspend/resume or reaper auto-play sees
// the same outcome.
PetProcReady bool `json:"pet_proc_ready,omitempty"`
// Fight-scoped depleting resources — mirror the combatState charges that // Fight-scoped depleting resources — mirror the combatState charges that
// genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by // genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by
// a mid-fight !cast / !consume, restored into combatState on every resume, // a mid-fight !cast / !consume, restored into combatState on every resume,

View File

@@ -425,6 +425,72 @@ func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
} }
} }
// ── Pet proc (per-fight) ───────────────────────────────────────────────────
func TestRollCombatSessionPetProc(t *testing.T) {
// No pet proc on the player → never rolls true, never touches statuses.
none := &CombatSession{SessionID: "no-pet"}
if rollCombatSessionPetProc(none, CombatModifiers{}) || none.Statuses.PetProcReady {
t.Error("zero PetAttackProc should not arm a pet strike")
}
// A guaranteed proc arms the flag; the draw is deterministic per session id.
sure := &CombatSession{SessionID: "pet-fight"}
if !rollCombatSessionPetProc(sure, CombatModifiers{PetAttackProc: 1.0}) || !sure.Statuses.PetProcReady {
t.Error("PetAttackProc 1.0 should always arm a pet strike")
}
again := &CombatSession{SessionID: "pet-fight"}
rollCombatSessionPetProc(again, CombatModifiers{PetAttackProc: 1.0})
if again.Statuses.PetProcReady != sure.Statuses.PetProcReady {
t.Error("same session id should roll the pet proc deterministically")
}
}
// TestTurnEngine_PetStrike confirms an armed pet lands exactly one hit on the
// player's first acting turn, then never again — the per-fight roll model.
func TestTurnEngine_PetStrike(t *testing.T) {
// Pools huge so no phase can end the fight; isolate the pet behaviour.
sess := turnSession(CombatPhasePlayerTurn, 100000, 100000)
sess.Statuses.PetProcReady = true
player, enemy := basePlayer(), baseEnemy()
player.Mods.PetAttackDmg = 8
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
if err != nil {
t.Fatal(err)
}
petHits := 0
for _, e := range events {
if e.Action == "pet_attack" {
petHits++
if e.Damage <= 0 {
t.Errorf("pet_attack damage = %d, want > 0", e.Damage)
}
}
}
if petHits != 1 {
t.Fatalf("pet_attack events = %d on first player turn, want 1", petHits)
}
if sess.Statuses.PetProcReady {
t.Error("PetProcReady should clear after the pet strikes")
}
// Cycle back to the next player turn — the pet must not strike again.
for sess.Phase != CombatPhasePlayerTurn {
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
t.Fatal(err)
}
}
events, err = stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
if err != nil {
t.Fatal(err)
}
for _, e := range events {
if e.Action == "pet_attack" {
t.Error("pet struck twice — the roll is per fight, not per round")
}
}
}
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)

View File

@@ -116,6 +116,7 @@ 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,
petProcReady: sess.Statuses.PetProcReady,
// Fight-scoped depleting resources + once-per-fight one-shots: restored // Fight-scoped depleting resources + once-per-fight one-shots: restored
// from the persisted statuses so a charge or "already used" flag can't // from the persisted statuses so a charge or "already used" flag can't
// reset across a suspend/resume. commit writes the updated values back. // reset across a suspend/resume. commit writes the updated values back.
@@ -186,9 +187,61 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
te.finish(CombatStatusWon) te.finish(CombatStatusWon)
return return
} }
if te.petStrike() {
te.finish(CombatStatusWon)
return
}
te.sess.Phase = CombatPhaseEnemyTurn te.sess.Phase = CombatPhaseEnemyTurn
} }
// petStrike resolves the player's pet attack for a turn-based fight. Whether
// the pet lands a hit was decided once at fight start (rollCombatSessionPetProc)
// and parked on the session; the pet then strikes a single time on the player's
// first acting turn — this clears the flag so it never repeats. Damage reuses
// the auto-resolve formula (PetAttackDmg + d5), and PetAttackDmg already carries
// any mid-fight buff delta via applySessionBuffs. Returns true if the strike
// dropped the enemy.
func (te *turnEngine) petStrike() bool {
st := te.st
if !st.petProcReady {
return false
}
st.petProcReady = false
petDmg := te.player.Mods.PetAttackDmg + st.roll(5)
st.enemyHP = max(0, st.enemyHP-petDmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "pet", Action: "pet_attack",
Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
return st.enemyHP <= 0
}
// rollCombatSessionPetProc makes the one-and-only per-fight pet-attack roll and
// parks the result on the session. Called once at fight start. The draw is
// deterministic — seeded off the session id on a stream distinct from the
// per-(round,phase) combat streams — so a reaper auto-play of an abandoned
// fight reproduces the same outcome. Returns true if the pet will attack (so
// the caller can decide whether the session needs persisting).
//
// Note: only the base PetAttackProc (class/race/subclass passives) is rolled
// here — a pet-proc buff cast mid-fight gets no fresh roll, consistent with the
// per-fight rule. Such a buff still raises PetAttackDmg if the pet does strike.
func rollCombatSessionPetProc(sess *CombatSession, playerMods CombatModifiers) bool {
if playerMods.PetAttackProc <= 0 {
return false
}
var seed uint64 = 1469598103934665603
for _, c := range sess.SessionID {
seed = (seed ^ uint64(c)) * 1099511628211
}
rng := rand.New(rand.NewPCG(seed, 0x9E3779B97F4A7C15))
if rngFloat(rng) < playerMods.PetAttackProc {
sess.Statuses.PetProcReady = true
return true
}
return false
}
// stepPlayerActionEffect resolves a !cast / !consume turn: the command handler // stepPlayerActionEffect resolves a !cast / !consume turn: the command handler
// has already rolled the spell / picked the item and spent the resource, so the // has already rolled the spell / picked the item and spent the resource, so the
// engine only applies the HP deltas and emits the event before handing off to // engine only applies the HP deltas and emits the event before handing off to
@@ -219,6 +272,10 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
te.finish(CombatStatusWon) te.finish(CombatStatusWon)
return return
} }
if te.petStrike() {
te.finish(CombatStatusWon)
return
}
if eff.EnemySkip { if eff.EnemySkip {
st.enemySkipFirst = true st.enemySkipFirst = true
} }
@@ -351,6 +408,7 @@ func (te *turnEngine) commit() {
s.ArmorBroken = st.armorBroken s.ArmorBroken = st.armorBroken
s.ArmorBreakAmt = st.armorBreakAmt s.ArmorBreakAmt = st.armorBreakAmt
s.EnemySkipNext = st.enemySkipFirst s.EnemySkipNext = st.enemySkipFirst
s.PetProcReady = st.petProcReady
s.WardCharges = st.wardCharges s.WardCharges = st.wardCharges
s.SporeRounds = st.sporeRounds s.SporeRounds = st.sporeRounds
s.ReflectFrac = st.reflectFrac s.ReflectFrac = st.reflectFrac