Adv 2.0 L2: rip legacy arena combat path; boss flow is the only path

Cancels the planned ARENA_BOSS_FLOW soak. The boss flow is shipped
unconditionally with no legacy fallback — boss-flow errors now surface
to the player and abort the round rather than silently falling back to
the old CombatPower path.

Deleted:
- runArenaCombat (combat_bridge.go) — legacy CombatPower-vs-Lethality
  arena combat driver.
- RenderCombatLogArena, renderArenaOutcome (combat_narrative.go) — legacy
  arena renderers. RenderCombatLogArena was a thin alias for
  RenderCombatLog; renderArenaOutcome was already dead.
- renderArenaCombatFinalMessage (combat_bridge.go) — legacy "rewards +
  closer" trailing text.
- arenaWinCloser, arenaLoseCloser (adventure_arena_combat.go) — flavor
  helpers that only fed the deleted renderers. File now just holds
  arenaParticipationXP.
- arenaBossFlowEnabled + the ARENA_BOSS_FLOW env var (adventure_arena.go,
  .env.example). The env gate served only the now-removed fallback.
- sendArenaCombatMessages (adventure_arena.go) — wrapper that switched
  pacing between boss flow and legacy. Replaced with direct
  sendZoneCombatMessages calls at the three call sites.
- TestArenaBossFlowEnabled (bossflow test) and
  TestRenderCombatLogArena_ProducesPhaseMessages (narrative test).

Simplified resolveArenaRound / resolveArenaSurvival / resolveArenaDeath:
the bossNarr-vs-nil branches collapsed since narration is now always
produced by resolveArenaBoss. Equipment degradation and the death-save
reprieve hook are unchanged — both already worked off the boss-flow
CombatResult.

Migration doc (§4) updated to record the cancellation and the
remaining tuning approach (playtest-driven, no flag soak window).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 09:06:38 -07:00
parent 85a80e5c40
commit 3b4dfa44d3
9 changed files with 34 additions and 339 deletions

View File

@@ -5,7 +5,6 @@ import (
"log/slog"
"math"
"math/rand/v2"
"os"
"strconv"
"strings"
"time"
@@ -193,46 +192,30 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
}
// 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.
// resolveArenaBoss: an intro line, the per-phase combat log, and the
// outcome block (TwinBee mood + headline + dice summary).
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. 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.
// resolveArenaRound runs a single arena round through resolveArenaBoss
// (zone-boss combat + staged narration) and threads the result into the
// surrounding economic glue. There is no legacy fallback — a boss-flow
// error surfaces to the player and aborts the round.
func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, tier *ArenaTier, monster *ArenaMonster) error {
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)
intro, phases, outcome, result, err := p.resolveArenaBoss(ctx.Sender, ArenaBossEncounter{
Tier: run.Tier,
Round: run.Round,
DisplayName: char.DisplayName,
})
if err != nil {
slog.Error("arena: boss flow failed", "user", ctx.Sender, "err", err)
return p.SendDM(ctx.Sender, "Arena combat encountered an error. Try again in a moment.")
}
bossNarr := &arenaBossNarration{intro: intro, phases: phases, outcome: outcome}
// Apply event-based equipment degradation
degradation := combatDegradation(result, equip)
for slot, eq := range equip {
if d, ok := degradation[slot]; ok && d > 0 {
@@ -240,9 +223,7 @@ func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, c
}
}
deathSaved := checkDeathSaveEvent(result.Events)
if deathSaved {
if checkDeathSaveEvent(result.Events) {
now := time.Now().UTC()
char.DeathReprieveLast = &now
if err := saveAdvCharacter(char); err != nil {
@@ -253,14 +234,6 @@ func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, c
if !result.PlayerWon {
return p.resolveArenaDeath(ctx, run, char, tier, monster, result, bossNarr)
}
// Misty condition repair (post-combat) for the legacy path. Boss flow
// runs through runZoneCombat, which calls npcRepairMostDamaged
// internally — no surfaced return value needed.
if condRepair > 0 {
npcRepairMostDamaged(ctx.Sender, equip, condRepair)
}
return p.resolveArenaSurvival(ctx, run, char, tier, monster, result, bossNarr)
}
@@ -366,30 +339,9 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
}
run.XPAccumulated += battleXP
// 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
}
}
phaseMessages := bossFlowPhaseMessages(bossNarr)
phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages)
finalMessage := bossNarr.outcome + fmt.Sprintf("\n🏆 +%d XP | €%d earned", battleXP, reward)
// Suppress the "(at risk)" line on the tier-completing round — those earnings
// are about to be locked in by the tier-complete branch below, so labelling
@@ -433,7 +385,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.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMessage, bossNarr != nil)
done := p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMessage)
<-done
return p.arenaCompleteSession(ctx.Sender, run, char, "")
}
@@ -452,7 +404,7 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
p.postArenaDropAnnouncement(char.DisplayName, dropped)
}
done := p.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMessage, bossNarr != nil)
done := p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMessage)
go func() {
<-done
p.arenaCountdown(ctx.Sender, run)
@@ -474,19 +426,10 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
finalMessage += "`!arena fight` — Face this opponent"
}
p.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMessage, bossNarr != nil)
p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMessage)
return nil
}
// 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.
@@ -504,15 +447,7 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
run.LastMonster = monster.Name
lostEarnings := run.Earnings + run.TierEarnings
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]
}
}
phaseMessages := bossFlowPhaseMessages(bossNarr)
dt := transitionDeath(DeathTransitionParams{
Char: char,
@@ -545,16 +480,7 @@ 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)
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
}
}
finalMsg := bossNarr.outcome + fmt.Sprintf("\n+%d XP (participation) | Back tomorrow.", arenaParticipationXP)
if dt.PetRecovered {
finalMsg += fmt.Sprintf("\n\nYour pet dragged you out of the arena. Death timer reduced. All session earnings forfeited.")
} else {
@@ -567,7 +493,7 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
}
}
done := p.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMsg, bossNarr != nil)
done := p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMsg)
if dt.PetRecovered {
gr := gamesRoom()
@@ -1301,25 +1227,12 @@ func (p *AdventurePlugin) arenaRollGladiatorHelm(userID id.UserID, maxTierCleare
return "The Gladiator's Helm"
}
// ── Adv 2.0 boss-flow round resolver (Phase L2 step 4) ──────────────────────
// ── Arena boss-flow round resolver ──────────────────────────────────────────
//
// resolveArenaBoss is the future arena combat path: a single arena
// round routed through runZoneCombat + renderBossOutcome so the player
// sees the same staged narration that zone bosses use (Nat20/Nat1 mood
// lines, phase-two barb on T3+, BossDeath/PlayerDeath flavor, dice
// summary). The legacy CombatPower-vs-Lethality flow stays in place
// until ARENA_BOSS_FLOW soak completes — wiring lands in the next step.
// arenaBossFlowEnabled gates the new path on the ARENA_BOSS_FLOW env
// var so the legacy code path remains the default until soak passes.
// Empty string and "0" / "false" disable; anything else enables.
func arenaBossFlowEnabled() bool {
switch os.Getenv("ARENA_BOSS_FLOW") {
case "", "0", "false", "FALSE", "False":
return false
}
return true
}
// resolveArenaBoss is the arena combat path: a single arena round
// routed through runZoneCombat + renderBossOutcome so the player sees
// the same staged narration zone bosses use (Nat20/Nat1 mood lines,
// phase-two barb on T3+, BossDeath/PlayerDeath flavor, dice summary).
// ArenaBossEncounter is the single-round input for resolveArenaBoss.
// Tier and Round identify the arenaBosses entry; DisplayName is the