From 2ce56cf76a0fb2d181d470a0d4a06b2a0a0b4cff Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 22 May 2026 09:26:17 -0700 Subject: [PATCH] Combat narration: format magnitude for stat_drain/debuff/ally_buff These three stateful enemy-effect cases emitted their flavor pool via bare pickRand, leaking the literal %d placeholder to players ("-%d damage on every strike from here"). The magnitude is carried in CombatEvent.Damage, exactly like the already-correct max_hp_drain / spell_fizzle cases; wrap them in fmt.Sprintf. Add a regression test asserting no placeholder leaks. --- internal/plugin/combat_narrative.go | 6 ++--- .../plugin/combat_narrative_drain_test.go | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 internal/plugin/combat_narrative_drain_test.go diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index 2a7c750..5337ff3 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -326,9 +326,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul case "survive_at_1": return pickRand(narrativeSurvive) case "stat_drain": - return pickRand(narrativeStatDrain) + return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage) case "debuff": - return pickRand(narrativeDebuff) + return fmt.Sprintf(pickRand(narrativeDebuff), e.Damage) case "max_hp_drain": return fmt.Sprintf(pickRand(narrativeMaxHPDrain), e.Damage) @@ -346,7 +346,7 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul case "fear_resist": return pickRand(narrativeFearResist) case "ally_buff": - return pickRand(narrativeAllyBuff) + return fmt.Sprintf(pickRand(narrativeAllyBuff), e.Damage) case "timeout": return pickRand(narrativeTimeout) diff --git a/internal/plugin/combat_narrative_drain_test.go b/internal/plugin/combat_narrative_drain_test.go new file mode 100644 index 0000000..94c9813 --- /dev/null +++ b/internal/plugin/combat_narrative_drain_test.go @@ -0,0 +1,24 @@ +package plugin + +import ( + "strings" + "testing" +) + +// TestRenderEvent_StatefulDrainsFormatMagnitude is a regression test for the +// leaked "%d" in stateful enemy-effect narration. stat_drain, debuff and +// ally_buff carry their magnitude in CombatEvent.Damage and their flavor +// pools contain a %d placeholder; renderEvent must fmt.Sprintf them rather +// than emit the template raw (which surfaced "-%d damage" to players). +func TestRenderEvent_StatefulDrainsFormatMagnitude(t *testing.T) { + for _, action := range []string{"stat_drain", "debuff", "ally_buff"} { + e := CombatEvent{Actor: "enemy", Action: action, Damage: 4} + out := renderEvent(e, "Rurina", "Shadow", CombatResult{}, newActionPicker()) + if strings.Contains(out, "%") { + t.Errorf("%s: leaked format placeholder in output: %q", action, out) + } + if !strings.Contains(out, "4") { + t.Errorf("%s: expected magnitude 4 in output: %q", action, out) + } + } +}