From 632e5ee315e6cfa32b386d2e40ff530f86211848 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 14 May 2026 23:17:12 -0700 Subject: [PATCH] Combat: fix Orc Rage missing fire when enemy two-shots across rounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rage threshold check sat only at the top of resolvePlayerAttack, so a player who took a threshold-crossing hit and was then killed by the very next enemy swing (before getting back to their own attack) would never see "rage" emitted — even though HP visibly crossed 50% while alive. Extracted maybeTriggerOrcRage shared by both sites: - Top of resolvePlayerAttack (unchanged UX: rage applies same-round when the player swings after the enemy hit). - End of runRound as a backstop (catches cross-round two-shots). st.raged guards against double-emit. Fixes the flaky TestOrcRageFiresOnLowHP; 10/10 repeats green. --- internal/plugin/combat_engine.go | 44 ++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 7658b19..cc740d8 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -652,9 +652,36 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase }) } + // End-of-round Orc Rage backstop. The primary trigger sits at the + // top of resolvePlayerAttack so rage fires same-round when the player + // swings after taking the threshold-crossing hit. But if the enemy + // goes first this round AND next, the player can be two-shot without + // ever getting back to that check. Re-checking here ensures the rage + // event always fires while HP > 0 and below 50%, even if the buff + // goes unused. + maybeTriggerOrcRage(st, player, phaseName) + return false } +// maybeTriggerOrcRage emits the "rage" event and arms pendingRageAttack +// the first time the player's HP crosses below 50% while alive. Idempotent +// via st.raged. Shared by the player-attack and end-of-round trigger sites. +func maybeTriggerOrcRage(st *combatState, player *Combatant, phaseName string) { + if !player.Mods.RageReady || st.raged || st.playerHP <= 0 { + return + } + if st.playerHP*2 >= player.Stats.MaxHP { + return + } + st.raged = true + st.pendingRageAttack = true + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "player", Action: "rage", + PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: "Orc Rage", + }) +} + // ── Attack Resolution ──────────────────────────────────────────────────────── // resolvePlayerAttack — d20 + AttackBonus vs enemy AC. @@ -685,17 +712,12 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba } // Orc Rage: trigger on the first attack after dropping below 50% HP. - // Use HP*2 < MaxHP rather than HP < MaxHP/2 so the threshold is exact - // regardless of MaxHP parity (avoids per-character drift on odd MaxHP). - if player.Mods.RageReady && !st.raged && st.playerHP > 0 && - st.playerHP*2 < player.Stats.MaxHP { - st.raged = true - st.pendingRageAttack = true - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: phaseName, Actor: "player", Action: "rage", - PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: "Orc Rage", - }) - } + // Threshold logic lives in maybeTriggerOrcRage; this site keeps the + // fire-on-next-swing UX so rage applies to the *same round* the player + // reacts to a threshold-crossing hit (when they swing after the enemy). + // An end-of-round backstop in runRound covers the "enemy two-shotted + // me across rounds" case so the event never silently vanishes. + maybeTriggerOrcRage(st, player, phaseName) roll := 1 + st.roll(20) // Reveal action (monster ability): the enemy read this swing coming — it's