From c5a26346572183768b9473b38de49102738f1d96 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 14 May 2026 08:08:26 -0700 Subject: [PATCH] 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) --- internal/plugin/combat_cmd.go | 7 ++- internal/plugin/combat_engine.go | 4 ++ internal/plugin/combat_session.go | 8 ++++ internal/plugin/combat_session_test.go | 66 ++++++++++++++++++++++++++ internal/plugin/combat_turn_engine.go | 58 ++++++++++++++++++++++ 5 files changed, 141 insertions(+), 2 deletions(-) diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index a19e0d9..c4f2f14 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -91,8 +91,11 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) 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) { + // the session so they survive the turn engine's resume/commit cycle, and + // 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 { slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err) } diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index e5c931c..e98cc50 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -267,6 +267,10 @@ type combatState struct { // the enemy would otherwise attack). 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. firstAttackBonusUsed bool assassinateRerollUsed bool diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 3f21963..1f1c6ef 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -65,6 +65,14 @@ type CombatStatuses struct { // combatState, so the flag must survive the commit between them. 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 // genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by // a mid-fight !cast / !consume, restored into combatState on every resume, diff --git a/internal/plugin/combat_session_test.go b/internal/plugin/combat_session_test.go index 33a96e4..d882014 100644 --- a/internal/plugin/combat_session_test.go +++ b/internal/plugin/combat_session_test.go @@ -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) { 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 bb570c8..1e078a3 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -116,6 +116,7 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R armorBroken: sess.Statuses.ArmorBroken, armorBreakAmt: sess.Statuses.ArmorBreakAmt, enemySkipFirst: sess.Statuses.EnemySkipNext, + petProcReady: sess.Statuses.PetProcReady, // 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. @@ -186,9 +187,61 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) { te.finish(CombatStatusWon) return } + if te.petStrike() { + te.finish(CombatStatusWon) + return + } 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 // 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 @@ -219,6 +272,10 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { te.finish(CombatStatusWon) return } + if te.petStrike() { + te.finish(CombatStatusWon) + return + } if eff.EnemySkip { st.enemySkipFirst = true } @@ -351,6 +408,7 @@ func (te *turnEngine) commit() { s.ArmorBroken = st.armorBroken s.ArmorBreakAmt = st.armorBreakAmt s.EnemySkipNext = st.enemySkipFirst + s.PetProcReady = st.petProcReady s.WardCharges = st.wardCharges s.SporeRounds = st.sporeRounds s.ReflectFrac = st.reflectFrac