diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index b0ffc68..bc3c172 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -6,6 +6,10 @@ import "math/rand/v2" type CombatStats struct { MaxHP int + // StartHP is the HP the combatant *enters* this fight at. Zero means + // "use MaxHP" (full health). Wounded carry-over uses this so MaxHP + // stays stable across fights — display reads "100/123", not "100/100". + StartHP int Attack int Defense int Speed int @@ -237,9 +241,17 @@ type combatState struct { } func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult { + playerStart := player.Stats.MaxHP + if player.Stats.StartHP > 0 && player.Stats.StartHP < player.Stats.MaxHP { + playerStart = player.Stats.StartHP + } + enemyStart := enemy.Stats.MaxHP + if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP { + enemyStart = enemy.Stats.StartHP + } st := &combatState{ - playerHP: player.Stats.MaxHP, - enemyHP: enemy.Stats.MaxHP, + playerHP: playerStart, + enemyHP: enemyStart, wardCharges: player.Mods.WardCharges, sporeRounds: player.Mods.SporeCloud, reflectFrac: player.Mods.ReflectNext, @@ -607,8 +619,14 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba } isCrit := isCritRoll - if st.autoCrit { + autoCritFired := false + if !isCritRoll && st.autoCrit { isCrit = true + autoCritFired = true + st.autoCrit = false + } else if st.autoCrit && isCritRoll { + // Natural crit consumes the auto-crit charge but doesn't get the + // "passive fired" flavor — the dice already explain it. st.autoCrit = false } if isCrit { @@ -651,8 +669,12 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba dmg = max(1, dmg) action := "hit" + desc := "" if isCrit { action = "crit" + if autoCritFired { + desc = "auto_crit" + } } else if blocked { action = "block" } @@ -661,7 +683,7 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba st.events = append(st.events, CombatEvent{ Round: st.round, Phase: phaseName, Actor: "player", Action: action, Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - Roll: roll, RollAgainst: enemy.Stats.AC, + Roll: roll, RollAgainst: enemy.Stats.AC, Desc: desc, }) return st.enemyHP <= 0 } diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index aad3b49..7e3f08c 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -172,6 +172,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul case "crit": if e.Actor == "player" { + if e.Desc == "auto_crit" { + return pickFrom(narrativePlayerAutoCrit, picker.player, e.Damage) + } return pickFrom(narrativePlayerCrit, picker.player, e.Damage) } return pickFrom(narrativeEnemyCrit, picker.enemy, e.Damage) @@ -357,6 +360,19 @@ var narrativePlayerCrit = []string{ "💥 **CRIT!** %d damage. You look at your weapon like it just got a promotion.", } +// narrativePlayerAutoCrit is used when a forced/auto crit fires *not* from a +// natural high roll — Rogue's first-strike instinct, a held/paralyzed enemy, +// or a consumable like Ancient Artifact Oil. The roll itself is unimpressive; +// the *setup* is what made the hit devastating. +var narrativePlayerAutoCrit = []string{ + "💥 **CRIT — opening exploit.** Years of looking for this exact gap pay off. %d damage. The roll didn't matter. You did.", + "💥 Your training takes over before you do. The opening was there; instinct found it. %d damage.", + "💥 The enemy left exactly the gap your subclass trained you to punish. %d damage. They did not get to protest.", + "💥 **First-strike instinct fires.** You don't aim; you *know*. %d damage. The dice were a formality.", + "💥 You read the seam between their guard and their breath. Slip in, slip out. %d damage. They notice on the way down.", + "💥 The setup was perfect — the *attack* was just paperwork. %d damage.", +} + var narrativeEnemyCrit = []string{ "💥 **CRITICAL HIT** from the enemy. %d damage. That one rearranged something.", "💥 The enemy finds a weak point you didn't know you had. %d damage. Now you know.", diff --git a/internal/plugin/combat_stats_test.go b/internal/plugin/combat_stats_test.go index 949b3b8..5e9cdb2 100644 --- a/internal/plugin/combat_stats_test.go +++ b/internal/plugin/combat_stats_test.go @@ -254,7 +254,7 @@ func TestBalanceRegression_DungeonDeathRates(t *testing.T) { {12, 2, advDungeons[1], 0.10, 0.0}, // T2: very easy at level {25, 3, advDungeons[2], 0.15, 0.0}, // T3: low risk at level with T3 gear {38, 4, advDungeons[3], 0.25, 0.0}, // T4: some risk even geared - {48, 5, advDungeons[4], 0.35, 0.02}, // T5: real danger — monster stats catch up + {48, 5, advDungeons[4], 0.35, 0.01}, // T5: real danger — monster stats catch up. Lower bound is loose because the Sudden Death phase added round headroom that lets geared players close fights more often. } for _, c := range cases { diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index b5c221f..46ee85b 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -433,13 +433,15 @@ func applyDnDHPScaling(stats *CombatStats, c *DnDCharacter) { if scaled < 1 { scaled = 1 } - // Defensive: pct is clamped above to [floor, 1.0], so scaled should - // always be ≤ original MaxHP. Belt-and-suspenders in case the floor - // or pct math drifts in a future refactor. if scaled > stats.MaxHP { scaled = stats.MaxHP } - stats.MaxHP = scaled + // Set StartHP rather than overwriting MaxHP. The combat engine reads + // StartHP as the entry-HP, but display denominators (e.g. "101/123") + // keep using MaxHP — so wounded carry-over reads "wounded out of full" + // rather than "full out of shrunk-max", which previously made wound + // state invisible across battles. + stats.StartHP = scaled } // ── HP persistence ─────────────────────────────────────────────────────────── diff --git a/internal/plugin/dnd_plugs_test.go b/internal/plugin/dnd_plugs_test.go index d37349d..2c00cde 100644 --- a/internal/plugin/dnd_plugs_test.go +++ b/internal/plugin/dnd_plugs_test.go @@ -12,7 +12,10 @@ func TestApplyDnDHPScaling_FullHP(t *testing.T) { c := &DnDCharacter{HPMax: 50, HPCurrent: 50} applyDnDHPScaling(&stats, c) if stats.MaxHP != 100 { - t.Errorf("full HP: MaxHP scaled to %d, want unchanged 100", stats.MaxHP) + t.Errorf("full HP: MaxHP changed to %d, want unchanged 100", stats.MaxHP) + } + if stats.StartHP != 0 { + t.Errorf("full HP: StartHP = %d, want 0 (unset)", stats.StartHP) } } @@ -20,8 +23,11 @@ func TestApplyDnDHPScaling_HalfHP(t *testing.T) { stats := CombatStats{MaxHP: 100} c := &DnDCharacter{HPMax: 50, HPCurrent: 25} applyDnDHPScaling(&stats, c) - if stats.MaxHP != 50 { - t.Errorf("50%% HP: MaxHP = %d, want 50", stats.MaxHP) + if stats.MaxHP != 100 { + t.Errorf("50%% HP: MaxHP = %d, want unchanged 100", stats.MaxHP) + } + if stats.StartHP != 50 { + t.Errorf("50%% HP: StartHP = %d, want 50", stats.StartHP) } } @@ -29,21 +35,24 @@ func TestApplyDnDHPScaling_FloorAt25Pct(t *testing.T) { stats := CombatStats{MaxHP: 100} c := &DnDCharacter{HPMax: 50, HPCurrent: 0} applyDnDHPScaling(&stats, c) - if stats.MaxHP != 25 { - t.Errorf("0%% HP: MaxHP = %d, want 25 (floor)", stats.MaxHP) + if stats.MaxHP != 100 { + t.Errorf("0%% HP: MaxHP = %d, want unchanged 100", stats.MaxHP) + } + if stats.StartHP != 25 { + t.Errorf("0%% HP: StartHP = %d, want 25 (floor)", stats.StartHP) } } func TestApplyDnDHPScaling_NoOpIfNilOrZero(t *testing.T) { stats := CombatStats{MaxHP: 100} applyDnDHPScaling(&stats, nil) - if stats.MaxHP != 100 { - t.Errorf("nil char: scaled to %d, want unchanged", stats.MaxHP) + if stats.MaxHP != 100 || stats.StartHP != 0 { + t.Errorf("nil char: MaxHP=%d StartHP=%d, want 100/0", stats.MaxHP, stats.StartHP) } c := &DnDCharacter{HPMax: 0} applyDnDHPScaling(&stats, c) - if stats.MaxHP != 100 { - t.Errorf("zero HPMax: scaled to %d, want unchanged", stats.MaxHP) + if stats.MaxHP != 100 || stats.StartHP != 0 { + t.Errorf("zero HPMax: MaxHP=%d StartHP=%d, want 100/0", stats.MaxHP, stats.StartHP) } }