Combat: TwinBee per-round narration for turn-based fights

Replace the MVP turn-based round renderer with RenderTurnRound, which
reuses the full auto-resolve narrative pools for shared combat events so
TwinBee's voice is identical across both engines. Only the four
turn-specific actions (flee, spell_held, spell_cast, use_consumable) get
new pools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-14 07:53:17 -07:00
parent fd28f31de5
commit 146924818d
3 changed files with 105 additions and 95 deletions

View File

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

View File

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

View File

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