Adv 2.0 Phase L2 step 4b: wire ARENA_BOSS_FLOW into round resolver

resolveArenaRound now branches on arenaBossFlowEnabled(): when set, the
round runs through resolveArenaBoss (zone-boss combat + staged narration)
and the resulting intro/phases/outcome are threaded into resolveArenaSurvival
and resolveArenaDeath via a new arenaBossNarration carrier. Both handlers
swap their RenderCombatLogArena + dnd opening/closing/roll-summary stack
for the pre-rendered boss-flow narration; arena economic glue (rewards,
tier-clear, helmet drops, hospital ad) is preserved untouched.

A new sendArenaCombatMessages helper picks 2–3s zone pacing under boss
flow and 5–8s arena pacing under the legacy path. bossFlowPhaseMessages
prepends the intro line ahead of phases, mirroring streamOrSend's
intro+phases pattern from dnd_zone_cmd.go.

Tests: TestArenaBossFlowEnabled covers the env-gate parsing;
TestBossFlowPhaseMessages asserts the staged-narration assembly. Full
suite green with the flag both off (default, legacy path) and on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 08:02:15 -07:00
parent 315909e6fe
commit 6862a94c8f
2 changed files with 160 additions and 30 deletions

View File

@@ -188,10 +188,45 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
return p.resolveArenaRound(ctx, run, char, equip, tier, monster)
}
// arenaBossNarration carries the staged-narration trio produced by
// resolveArenaBoss (Phase L2 step 4b). When non-nil it tells the
// survival/death handlers to skip the legacy RenderCombatLogArena +
// dnd opening/closing/roll-summary stack and instead use the
// pre-rendered boss-flow intro/phases/outcome.
type arenaBossNarration struct {
intro string
phases []string
outcome string
}
// resolveArenaRound runs a single arena round through the combat engine and
// dispatches to survival or death handlers.
// dispatches to survival or death handlers. When ARENA_BOSS_FLOW is set, the
// round is routed through resolveArenaBoss (zone-boss combat + staged
// narration) and the resulting CombatResult / narration are threaded into
// the existing economic glue.
func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, tier *ArenaTier, monster *ArenaMonster) error {
result, condRepair := p.runArenaCombat(ctx.Sender, char, equip, monster, run.Round, run.Tier)
var (
result CombatResult
condRepair int
bossNarr *arenaBossNarration
)
if arenaBossFlowEnabled() {
intro, phases, outcome, res, err := p.resolveArenaBoss(ctx.Sender, ArenaBossEncounter{
Tier: run.Tier,
Round: run.Round,
DisplayName: char.DisplayName,
})
if err != nil {
slog.Error("arena: boss flow failed, falling back to legacy", "user", ctx.Sender, "err", err)
} else {
result = res
bossNarr = &arenaBossNarration{intro: intro, phases: phases, outcome: outcome}
}
}
if bossNarr == nil {
result, condRepair = p.runArenaCombat(ctx.Sender, char, equip, monster, run.Round, run.Tier)
}
// Apply event-based equipment degradation
degradation := combatDegradation(result, equip)
@@ -212,15 +247,16 @@ func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, c
}
if !result.PlayerWon {
return p.resolveArenaDeath(ctx, run, char, tier, monster, result)
return p.resolveArenaDeath(ctx, run, char, tier, monster, result, bossNarr)
}
// Misty condition repair (post-combat)
// Misty condition repair (post-combat). Boss-flow path runs through
// runZoneCombat which doesn't surface condRepair; skip it there.
if condRepair > 0 {
npcRepairMostDamaged(ctx.Sender, equip, condRepair)
}
return p.resolveArenaSurvival(ctx, run, char, tier, monster, result)
return p.resolveArenaSurvival(ctx, run, char, tier, monster, result, bossNarr)
}
func (p *AdventurePlugin) handleArenaCancel(ctx MessageContext) error {
@@ -299,7 +335,7 @@ func (p *AdventurePlugin) handleArenaLeaderboard(ctx MessageContext) error {
var arenaStreakEuroMultiplier = [6]float64{0, 1.0, 1.5, 2.0, 2.75, 4.0} // index = tier
var arenaStreakXPMultiplier = [6]float64{0, 1.0, 1.2, 1.5, 1.85, 2.5} // index = tiers won
func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, result CombatResult) error {
func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, result CombatResult, bossNarr *arenaBossNarration) error {
// Calculate reward — accumulate in tier earnings, not credited yet
reward := arenaRoundReward(tier, run.Round, char.CombatLevel)
run.TierEarnings += reward
@@ -317,18 +353,29 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
}
run.XPAccumulated += battleXP
// Render combat log as phased messages + final outcome
phaseMessages := RenderCombatLogArena(result, char.DisplayName, monster.Name)
phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages)
if open := dndCombatOpeningLine(); open != "" && len(phaseMessages) > 0 {
phaseMessages[0] = "_" + open + "_\n\n" + phaseMessages[0]
}
finalMessage := renderArenaCombatFinalMessage(result, monster, reward, battleXP, run.Round)
// Boss = T5 final round. Use BossDeath flavor for that fight's win.
isBoss := run.Tier == 5
finalMessage += dndItalicize(dndCombatClosingLine(true, isBoss))
if rollLine := dndRollSummaryLine(result); rollLine != "" {
finalMessage += "\n" + rollLine
// Render combat log as phased messages + final outcome. Boss-flow path
// uses pre-rendered intro/phases/outcome; legacy path builds them here.
var (
phaseMessages []string
finalMessage string
)
if bossNarr != nil {
phaseMessages = bossFlowPhaseMessages(bossNarr)
phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages)
finalMessage = bossNarr.outcome + fmt.Sprintf("\n🏆 +%d XP | €%d earned", battleXP, reward)
} else {
phaseMessages = RenderCombatLogArena(result, char.DisplayName, monster.Name)
phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages)
if open := dndCombatOpeningLine(); open != "" && len(phaseMessages) > 0 {
phaseMessages[0] = "_" + open + "_\n\n" + phaseMessages[0]
}
finalMessage = renderArenaCombatFinalMessage(result, monster, reward, battleXP, run.Round)
// Boss = T5 final round. Use BossDeath flavor for that fight's win.
isBoss := run.Tier == 5
finalMessage += dndItalicize(dndCombatClosingLine(true, isBoss))
if rollLine := dndRollSummaryLine(result); rollLine != "" {
finalMessage += "\n" + rollLine
}
}
// Suppress the "(at risk)" line on the tier-completing round — those earnings
@@ -373,7 +420,7 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
finalMessage += fmt.Sprintf("\n🏆 **Tier %d cleared!** Round earnings: €%d + completion bonus: €%d (×%.1f streak)\n",
tier.Number, tierRaw, tier.CompletionBonus, multiplier)
done := p.sendCombatMessages(ctx.Sender, phaseMessages, finalMessage)
done := p.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMessage, bossNarr != nil)
<-done
return p.arenaCompleteSession(ctx.Sender, run, char, "")
}
@@ -392,7 +439,7 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
p.postArenaDropAnnouncement(char.DisplayName, dropped)
}
done := p.sendCombatMessages(ctx.Sender, phaseMessages, finalMessage)
done := p.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMessage, bossNarr != nil)
go func() {
<-done
p.arenaCountdown(ctx.Sender, run)
@@ -414,17 +461,44 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
finalMessage += "`!arena fight` — Face this opponent"
}
p.sendCombatMessages(ctx.Sender, phaseMessages, finalMessage)
p.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMessage, bossNarr != nil)
return nil
}
func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, result CombatResult) error {
// sendArenaCombatMessages dispatches arena phase narration. Boss-flow uses
// the tighter zone-combat pacing (23s); legacy uses arena pacing (58s).
func (p *AdventurePlugin) sendArenaCombatMessages(userID id.UserID, phases []string, final string, bossFlow bool) <-chan struct{} {
if bossFlow {
return p.sendZoneCombatMessages(userID, phases, final)
}
return p.sendCombatMessages(userID, phases, final)
}
// bossFlowPhaseMessages prepends the resolveArenaBoss intro line to the
// staged combat phases, mirroring streamOrSend's intro+phases pattern in
// dnd_zone_cmd.go.
func bossFlowPhaseMessages(n *arenaBossNarration) []string {
if n.intro == "" {
return append([]string{}, n.phases...)
}
out := make([]string, 0, len(n.phases)+1)
out = append(out, n.intro)
out = append(out, n.phases...)
return out
}
func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, result CombatResult, bossNarr *arenaBossNarration) error {
run.LastMonster = monster.Name
lostEarnings := run.Earnings + run.TierEarnings
phaseMessages := RenderCombatLogArena(result, char.DisplayName, monster.Name)
if open := dndCombatOpeningLine(); open != "" && len(phaseMessages) > 0 {
phaseMessages[0] = "_" + open + "_\n\n" + phaseMessages[0]
var phaseMessages []string
if bossNarr != nil {
phaseMessages = bossFlowPhaseMessages(bossNarr)
} else {
phaseMessages = RenderCombatLogArena(result, char.DisplayName, monster.Name)
if open := dndCombatOpeningLine(); open != "" && len(phaseMessages) > 0 {
phaseMessages[0] = "_" + open + "_\n\n" + phaseMessages[0]
}
}
dt := transitionDeath(DeathTransitionParams{
@@ -454,10 +528,15 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
insertArenaHistory(run.UserID, run.StartTier, run.Tier, run.RoundsSurvived, 0, "dead", monster.Name)
upsertArenaStats(run.UserID, 0, true, run.Tier)
finalMsg := renderArenaCombatFinalMessage(result, monster, 0, arenaParticipationXP, run.Round)
finalMsg += dndItalicize(dndCombatClosingLine(false, false))
if rollLine := dndRollSummaryLine(result); rollLine != "" {
finalMsg += "\n" + rollLine
var finalMsg string
if bossNarr != nil {
finalMsg = bossNarr.outcome + fmt.Sprintf("\n+%d XP (participation) | Back tomorrow.", arenaParticipationXP)
} else {
finalMsg = renderArenaCombatFinalMessage(result, monster, 0, arenaParticipationXP, run.Round)
finalMsg += dndItalicize(dndCombatClosingLine(false, false))
if rollLine := dndRollSummaryLine(result); rollLine != "" {
finalMsg += "\n" + rollLine
}
}
if dt.PetRecovered {
finalMsg += fmt.Sprintf("\n\nYour pet dragged you out of the arena. Death timer reduced. All session earnings forfeited.")
@@ -471,7 +550,7 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
}
}
done := p.sendCombatMessages(ctx.Sender, phaseMessages, finalMsg)
done := p.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMsg, bossNarr != nil)
if dt.PetRecovered {
gr := gamesRoom()

View File

@@ -84,6 +84,57 @@ func TestArenaBossPhaseTwoAt(t *testing.T) {
}
}
// Phase L2 step 4b — flag gate. Empty / "0" / "false" disable the new
// boss-flow path; anything else (including "1") enables it.
func TestArenaBossFlowEnabled(t *testing.T) {
cases := []struct {
val string
want bool
}{
{"", false},
{"0", false},
{"false", false},
{"FALSE", false},
{"False", false},
{"1", true},
{"true", true},
{"on", true},
}
for _, c := range cases {
t.Setenv("ARENA_BOSS_FLOW", c.val)
if got := arenaBossFlowEnabled(); got != c.want {
t.Errorf("arenaBossFlowEnabled with %q = %v want %v", c.val, got, c.want)
}
}
}
// Phase L2 step 4b — staged-narration assembly. The intro line leads,
// followed by the combat-log phases, mirroring streamOrSend's
// intro+phases pattern in dnd_zone_cmd.go.
func TestBossFlowPhaseMessages(t *testing.T) {
n := &arenaBossNarration{
intro: "🏟️ **Arena T1 R1 — Slug** (HP 12, AC 10)",
phases: []string{"phase A", "phase B"},
}
got := bossFlowPhaseMessages(n)
if len(got) != 3 {
t.Fatalf("len = %d, want 3 (intro + 2 phases): %v", len(got), got)
}
if got[0] != n.intro {
t.Errorf("got[0] = %q, want intro %q", got[0], n.intro)
}
if got[1] != "phase A" || got[2] != "phase B" {
t.Errorf("phases out of order: %v", got)
}
// No intro → just the phases, unchanged.
n2 := &arenaBossNarration{phases: []string{"only"}}
got2 := bossFlowPhaseMessages(n2)
if len(got2) != 1 || got2[0] != "only" {
t.Errorf("no-intro case: got %v want [only]", got2)
}
}
func TestArenaBosses_AllTiersPopulated(t *testing.T) {
for tier := 1; tier <= 5; tier++ {
for round := 1; round <= 4; round++ {