diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index de99373..d00f115 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -91,11 +91,9 @@ 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, and - // make the one-and-only per-fight pet-attack roll. - seeded := seedCombatSessionOneShots(sess, player.Mods) - pet := rollCombatSessionPetProc(sess, player.Mods) - if seeded || pet { + // the session so they survive the turn engine's resume/commit cycle. The + // pet now rolls per-turn inside the engine, so there's no fight-start roll. + if seedCombatSessionOneShots(sess, player.Mods) { 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 e79f4f7..98b1fd1 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -319,10 +319,6 @@ 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 77618f0..16f58c4 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -65,14 +65,6 @@ 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 d882014..3b1e34c 100644 --- a/internal/plugin/combat_session_test.go +++ b/internal/plugin/combat_session_test.go @@ -425,72 +425,120 @@ func TestTurnEngine_OneShotsRoundTrip(t *testing.T) { } } -// ── Pet proc (per-fight) ─────────────────────────────────────────────────── +// ── Pet proc (per-round) ─────────────────────────────────────────────────── -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. +// TestTurnEngine_PetStrike confirms a pet with a guaranteed proc strikes on +// every player-acting turn — the per-round roll model, matching auto-resolve. 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.PetAttackProc = 1.0 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) + petHitsOnPlayerTurn := func() int { + events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}) + if err != nil { + t.Fatal(err) + } + hits := 0 + for _, e := range events { + if e.Action == "pet_attack" { + hits++ + 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") + return hits } - // Cycle back to the next player turn — the pet must not strike again. + if got := petHitsOnPlayerTurn(); got != 1 { + t.Fatalf("pet_attack events = %d on first player turn, want 1", got) + } + + // Cycle back to the next player turn — the pet must 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 got := petHitsOnPlayerTurn(); got != 1 { + t.Errorf("pet_attack events = %d on second player turn, want 1 (per-round)", got) + } +} + +// TestTurnEngine_PetNoProc confirms a player without the pet proc never sees a +// pet strike. +func TestTurnEngine_PetNoProc(t *testing.T) { + sess := turnSession(CombatPhasePlayerTurn, 100000, 100000) + player, enemy := basePlayer(), baseEnemy() + player.Mods.PetAttackProc = 0 + + 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") + t.Error("pet struck with zero PetAttackProc") } } } +// TestTurnEngine_PetWhiff confirms a guaranteed whiff makes the enemy's turn a +// total miss — a pet_whiff event fires and the player takes no damage. +func TestTurnEngine_PetWhiff(t *testing.T) { + sess := turnSession(CombatPhaseEnemyTurn, 100000, 100000) + player, enemy := basePlayer(), baseEnemy() + player.Mods.PetWhiffProc = 1.0 + enemy.Stats.AttackBonus = 50 // would connect easily if not whiffed + startHP := sess.PlayerHP + + events, err := stepEngine(sess, &player, &enemy, PlayerAction{}) + if err != nil { + t.Fatal(err) + } + whiffs := 0 + for _, e := range events { + if e.Action == "pet_whiff" { + whiffs++ + } + if e.Actor == "enemy" && e.Action == "hit" { + t.Error("enemy landed a hit despite a guaranteed pet whiff") + } + } + if whiffs == 0 { + t.Error("expected a pet_whiff event with PetWhiffProc 1.0") + } + if sess.PlayerHP != startHP { + t.Errorf("player took %d damage on a whiffed turn, want 0", startHP-sess.PlayerHP) + } +} + +// TestTurnEngine_PetDeflect confirms a guaranteed deflect emits a pet_deflect +// event on a connecting enemy attack. +func TestTurnEngine_PetDeflect(t *testing.T) { + sess := turnSession(CombatPhaseEnemyTurn, 100000, 100000) + player, enemy := basePlayer(), baseEnemy() + player.Mods.PetDeflectProc = 1.0 + enemy.Stats.AttackBonus = 50 // guarantee the swing connects + + events, err := stepEngine(sess, &player, &enemy, PlayerAction{}) + if err != nil { + t.Fatal(err) + } + deflects := 0 + for _, e := range events { + if e.Action == "pet_deflect" { + deflects++ + } + } + if deflects == 0 { + t.Error("expected a pet_deflect event with PetDeflectProc 1.0 on a connecting hit") + } +} + 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 c83e334..8b71a86 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -116,7 +116,6 @@ 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. @@ -223,19 +222,19 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) { 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. +// petStrike resolves the player's pet attack for a turn-based fight. The pet +// rolls fresh on every player-acting turn (PetAttackProc), mirroring the +// auto-resolve engine's per-round chance rather than a once-per-fight strike. +// The roll rides the per-(round,phase) step RNG, so a suspend/resume or reaper +// auto-play of the same turn reproduces the same outcome. 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 { + if te.player.Mods.PetAttackProc <= 0 || st.randFloat() >= te.player.Mods.PetAttackProc { 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{ @@ -245,32 +244,6 @@ func (te *turnEngine) petStrike() bool { return enemyDown(st, turnCombatPhase.Name) } -// 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 @@ -375,6 +348,17 @@ func (te *turnEngine) stepEnemyTurn() { } if !abilityDealtDamage { + // Pet defensive procs are a per-round concept (mirrors the auto-resolve + // engine): roll once for the whole enemy turn, then apply to every swing + // in a multiattack profile. Whiff makes the enemy's attack this round a + // guaranteed miss; deflect halves the incoming damage. The shared + // resolveEnemyAttack primitive consumes both flags. + petWhiff := te.player.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.player.Mods.PetWhiffProc + petDeflect := te.player.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.player.Mods.PetDeflectProc + if petDeflect { + te.result.PetDeflected = true + } + // SRD multiattack: each profile entry is one attack roll resolved // through the shared primitive. A registered elite/boss swings its full // profile; everyone else gets a single attack from the template stats. @@ -385,7 +369,7 @@ func (te *turnEngine) stepEnemyTurn() { swing := *te.enemy swing.Stats.Attack = atk.Damage swing.Stats.AttackBonus = atk.AttackBonus - decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, false, false, false) + decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, petWhiff, petDeflect, false) if te.st.playerHP <= 0 { te.finish(CombatStatusLost) return @@ -479,7 +463,6 @@ 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