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. // 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 { func (p *AdventurePlugin) renderRoundResult(userID id.UserID, sess *CombatSession, events []CombatEvent, playerName string, enemy Combatant) string {
var b strings.Builder var b strings.Builder
b.WriteString(renderCombatRound(events, playerName, enemy.Name)) b.WriteString(RenderTurnRound(events, playerName, enemy.Name))
if sess.IsActive() { if sess.IsActive() {
b.WriteString("\n\n") b.WriteString("\n\n")
b.WriteString(fmt.Sprintf("You: **%d/%d** · %s: **%d/%d**\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() 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) ─────────────────────────────────────────────────────── // ── !cast (in-combat) ───────────────────────────────────────────────────────
// //
// handleDnDCastCmd routes here when the player has an active CombatSession: // 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) { func TestRenderTurnRound(t *testing.T) {
if got := renderCombatRound(nil, "Hero", "Goblin"); !strings.Contains(got, "without a clean blow") { if got := RenderTurnRound(nil, "Hero", "Goblin"); !strings.Contains(got, "without a clean blow") {
t.Errorf("empty events render = %q", got) t.Errorf("empty events render = %q", got)
} }
events := []CombatEvent{ events := []CombatEvent{
@@ -246,18 +246,28 @@ func TestRenderCombatRound(t *testing.T) {
{Actor: "enemy", Action: "miss"}, {Actor: "enemy", Action: "miss"},
{Actor: "enemy", Action: "poison_tick", Damage: 3}, {Actor: "enemy", Action: "poison_tick", Damage: 3},
} }
got := renderCombatRound(events, "Hero", "Goblin") got := RenderTurnRound(events, "Hero", "Goblin")
if !strings.Contains(got, "Hero") || !strings.Contains(got, "9") { // One line per event (damage events interpolate their amount; the random
t.Errorf("player hit not rendered: %q", got) // 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") { if !strings.Contains(got, "9") {
t.Errorf("enemy miss not rendered: %q", got) t.Errorf("player hit damage not rendered: %q", got)
} }
if !strings.Contains(got, "Poison") { if !strings.Contains(got, "3") {
t.Errorf("poison tick not rendered: %q", got) t.Errorf("poison tick damage not rendered: %q", got)
} }
// Unknown actor/action with no damage renders nothing (not a blank line). // Turn-specific actions get their own pools.
if line := renderCombatRoundEvent(CombatEvent{Actor: "system", Action: "timeout"}, "Hero", "Goblin"); line != "" { cast := RenderTurnRound([]CombatEvent{
t.Errorf("unknown zero-damage event should render empty, got %q", line) {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.", "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.", "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**.",
}