Combat: fix wounded-carry HP display + auto-crit narration

Two bugs reported back-to-back from a Rogue's run:

1. HP display reset between battles. End of fight 1: 101/123 → start of
   fight 2: 100/100. applyDnDHPScaling was overwriting Stats.MaxHP with
   the scaled wound value, so the display denominator dropped along with
   the numerator. Wounded carry-over became invisible.

   Fix: introduce CombatStats.StartHP. Combat engine reads it as the
   entry-HP when set; MaxHP stays put. Display now reads "100/123" as
   intended.

2. Auto-crit fired on a roll of 11 with no narrative tell. Rogue passive
   AutoCritFirst was triggering correctly, but the renderer used the
   generic crit pool, so the player saw "🎲 11 vs AC 10 → CRIT" with no
   indication their *class* caused it.

   Fix: tag the crit event with Desc="auto_crit" when the passive (not
   the dice) caused it; new narrativePlayerAutoCrit pool calls out the
   training/instinct/exploit theme.

Test bound for T5 dungeon death rate loosened from 0.02 to 0.01 — the
new Sudden Death phase from the previous commit shifted geared-T5
fights slightly toward player wins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 22:17:14 -07:00
parent de02907e69
commit c5217e9ecf
5 changed files with 67 additions and 18 deletions

View File

@@ -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
}

View File

@@ -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.",

View File

@@ -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 {

View File

@@ -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 ───────────────────────────────────────────────────────────

View File

@@ -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)
}
}