diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index db00dc4..a19e0d9 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -155,7 +155,7 @@ func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action Playe // close-out block (terminal status). Shared by !attack/!flee, !cast, !consume. func (p *AdventurePlugin) renderRoundResult(userID id.UserID, sess *CombatSession, events []CombatEvent, playerName string, enemy Combatant) string { var b strings.Builder - b.WriteString(renderCombatRound(events, playerName, enemy.Name)) + b.WriteString(RenderTurnRound(events, playerName, enemy.Name)) if sess.IsActive() { b.WriteString("\n\n") b.WriteString(fmt.Sprintf("You: **%d/%d** · %s: **%d/%d**\n", @@ -278,87 +278,6 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess return b.String() } -// ── rendering ─────────────────────────────────────────────────────────────── - -// renderCombatRound turns one round's events into a compact play-by-play -// block. Full TwinBee per-round narration is a later sub-phase; this is the -// functional MVP renderer. -func renderCombatRound(events []CombatEvent, playerName, enemyName string) string { - var lines []string - for _, ev := range events { - if line := renderCombatRoundEvent(ev, playerName, enemyName); line != "" { - lines = append(lines, line) - } - } - if len(lines) == 0 { - return "_The round passes without a clean blow landed._" - } - return strings.Join(lines, "\n") -} - -func renderCombatRoundEvent(ev CombatEvent, playerName, enemyName string) string { - switch ev.Actor { - case "player": - switch ev.Action { - case "hit": - return fmt.Sprintf("⚔️ %s lands a hit for **%d**.", playerName, ev.Damage) - case "crit": - return fmt.Sprintf("💥 %s **crits** for **%d**!", playerName, ev.Damage) - case "block": - return fmt.Sprintf("⚔️ %s pushes through the guard for **%d**.", playerName, ev.Damage) - case "miss": - return fmt.Sprintf("… %s swings and misses.", playerName) - case "stunned": - return fmt.Sprintf("😵 %s is stunned and loses the turn.", playerName) - case "rage": - return fmt.Sprintf("🔥 %s's blood is up — rage takes hold.", playerName) - case "death_save": - return fmt.Sprintf("✨ %s should be down — and isn't.", playerName) - case "flee": - return fmt.Sprintf("🏃 %s breaks off and runs.", playerName) - case "spell_cast": - if ev.Desc != "" { - return "✨ " + ev.Desc - } - return fmt.Sprintf("✨ %s casts a spell.", playerName) - case "use_consumable": - if ev.Desc != "" { - return "🧪 " + ev.Desc - } - return fmt.Sprintf("🧪 %s uses an item.", playerName) - } - case "enemy": - switch ev.Action { - case "hit": - return fmt.Sprintf("🩸 %s hits %s for **%d**.", enemyName, playerName, ev.Damage) - case "crit": - return fmt.Sprintf("🩸 %s **crits** %s for **%d**!", enemyName, playerName, ev.Damage) - case "miss": - return fmt.Sprintf("… %s attacks, but misses.", enemyName) - case "poison_tick": - return fmt.Sprintf("☠️ Poison saps **%d** from %s.", ev.Damage, playerName) - case "poison": - return fmt.Sprintf("☠️ %s's strike leaves a poison festering.", enemyName) - case "enrage": - return fmt.Sprintf("😡 %s flies into a rage.", enemyName) - case "armor_break": - return fmt.Sprintf("🛡️ %s cracks %s's armor.", enemyName, playerName) - case "stun": - return fmt.Sprintf("💫 %s lands a stunning blow.", enemyName) - case "lifesteal": - return fmt.Sprintf("🧛 %s drains life from the wound.", enemyName) - case "cleave": - return fmt.Sprintf("🪓 %s cleaves into %s for **%d**.", enemyName, playerName, ev.Damage) - case "spell_held": - return fmt.Sprintf("🌀 %s is held fast — no attack this round.", enemyName) - } - } - if ev.Damage > 0 { - return fmt.Sprintf("• %s — %s (%d)", ev.Actor, ev.Action, ev.Damage) - } - return "" -} - // ── !cast (in-combat) ─────────────────────────────────────────────────────── // // handleDnDCastCmd routes here when the player has an active CombatSession: diff --git a/internal/plugin/combat_cmd_test.go b/internal/plugin/combat_cmd_test.go index bfb872d..7539f42 100644 --- a/internal/plugin/combat_cmd_test.go +++ b/internal/plugin/combat_cmd_test.go @@ -235,10 +235,10 @@ func TestRunCombatRound_CastControlSkipsEnemyTurn(t *testing.T) { } } -// ── renderCombatRound ────────────────────────────────────────────────────── +// ── RenderTurnRound ──────────────────────────────────────────────────────── -func TestRenderCombatRound(t *testing.T) { - if got := renderCombatRound(nil, "Hero", "Goblin"); !strings.Contains(got, "without a clean blow") { +func TestRenderTurnRound(t *testing.T) { + if got := RenderTurnRound(nil, "Hero", "Goblin"); !strings.Contains(got, "without a clean blow") { t.Errorf("empty events render = %q", got) } events := []CombatEvent{ @@ -246,18 +246,28 @@ func TestRenderCombatRound(t *testing.T) { {Actor: "enemy", Action: "miss"}, {Actor: "enemy", Action: "poison_tick", Damage: 3}, } - got := renderCombatRound(events, "Hero", "Goblin") - if !strings.Contains(got, "Hero") || !strings.Contains(got, "9") { - t.Errorf("player hit not rendered: %q", got) + got := RenderTurnRound(events, "Hero", "Goblin") + // One line per event (damage events interpolate their amount; the random + // pool choice is what varies, so assert structure + the damage numbers + // rather than specific flavor strings). + if lines := strings.Count(got, "\n"); lines != 2 { + t.Errorf("expected 3 lines, got %d: %q", lines+1, got) } - if !strings.Contains(got, "misses") { - t.Errorf("enemy miss not rendered: %q", got) + if !strings.Contains(got, "9") { + t.Errorf("player hit damage not rendered: %q", got) } - if !strings.Contains(got, "Poison") { - t.Errorf("poison tick not rendered: %q", got) + if !strings.Contains(got, "3") { + t.Errorf("poison tick damage not rendered: %q", got) } - // Unknown actor/action with no damage renders nothing (not a blank line). - if line := renderCombatRoundEvent(CombatEvent{Actor: "system", Action: "timeout"}, "Hero", "Goblin"); line != "" { - t.Errorf("unknown zero-damage event should render empty, got %q", line) + // Turn-specific actions get their own pools. + cast := RenderTurnRound([]CombatEvent{ + {Actor: "player", Action: "spell_cast", Desc: "Fireball — 24 dmg", Damage: 24}, + }, "Hero", "Goblin") + if !strings.Contains(cast, "Fireball — 24 dmg") { + t.Errorf("spell_cast label not rendered: %q", cast) + } + flee := RenderTurnRound([]CombatEvent{{Actor: "player", Action: "flee"}}, "Hero", "Goblin") + if flee == "" || strings.Contains(flee, "without a clean blow") { + t.Errorf("flee should render a line: %q", flee) } } diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index 6bd1a3e..213b194 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -641,3 +641,84 @@ var narrativeCloseLoss = []string{ "You gave it everything. It wasn't quite sufficient. Sufficiency is the enemy's department today.", "The enemy earned that one. So did you. Only one of you gets paid.", } + +// ── Turn-based per-round narration ─────────────────────────────────────────── +// +// RenderTurnRound narrates one resolved round of a manual elite/boss fight. +// Where RenderCombatLog (auto-resolve) groups a whole fight into phased, +// multi-message blocks, a turn-based round is one message: the events from +// player_turn → enemy_turn → round_end, in order. The shared hit/crit/miss/ +// ability events the turn engine emits through the same primitives as +// auto-resolve fall through to renderEvent and reuse the full narrative pools, +// so TwinBee's voice is identical across both engines. Only the four +// turn-specific actions (flee, spell_cast, use_consumable, spell_held) get +// their own pools here. +// +// A fresh actionPicker per round is intentional: a round emits at most a +// handful of events, and across a long boss fight the picker resetting each +// round reads as variety rather than staleness. +func RenderTurnRound(events []CombatEvent, playerName, enemyName string) string { + picker := newActionPicker() + var lines []string + for _, e := range events { + line := renderTurnEvent(e, playerName, enemyName, picker) + if line == "" { + continue + } + if roll := rollAnnotation(e); roll != "" { + line += " " + roll + } + lines = append(lines, line) + } + if len(lines) == 0 { + return "_The round passes without a clean blow landed._" + } + return strings.Join(lines, "\n") +} + +func renderTurnEvent(e CombatEvent, playerName, enemyName string, picker *actionPicker) string { + switch e.Action { + case "flee": + return pickRand(narrativeTurnFlee) + case "spell_held": + return pickRand(narrativeTurnSpellHeld) + case "spell_cast": + label := e.Desc + if label == "" { + label = "a spell" + } + return fmt.Sprintf(pickRand(narrativeTurnSpellCast), label) + case "use_consumable": + label := e.Desc + if label == "" { + label = "an item" + } + return fmt.Sprintf(pickRand(narrativeTurnConsumable), label) + } + return renderEvent(e, playerName, enemyName, CombatResult{}, picker) +} + +var narrativeTurnFlee = []string{ + "🏃 You break off and run. No shame in it — the dead don't get a sequel.", + "🏃 You decide this fight isn't yours to win and make for the exit. TwinBee respects the math.", + "🏃 You disengage and bolt. The enemy doesn't chase. The enemy didn't have to.", +} + +var narrativeTurnSpellHeld = []string{ + "🌀 The enemy strains against the spell and gets nowhere. No attack this round.", + "🌀 Held fast. The enemy's turn dissolves into furious, useless effort.", + "🌀 The control holds. The enemy spends the round being a statue with opinions.", +} + +var narrativeTurnSpellCast = []string{ + "✨ You loose the spell. **%s**.", + "✨ The weave answers. **%s**.", + "✨ Words, gesture, intent — **%s**.", + "✨ You spend the magic where it counts. **%s**.", +} + +var narrativeTurnConsumable = []string{ + "🧪 You crack it open mid-fight. **%s**.", + "🧪 No time to be precious about it. **%s**.", + "🧪 You burn a consumable on the spot. **%s**.", +}