mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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**.",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user