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

@@ -37,7 +37,6 @@ FEATURE_SHADE=
FEATURE_TRIVIA=true # set to "false" to disable trivia FEATURE_TRIVIA=true # set to "false" to disable trivia
FEATURE_ESTEEMED= # set to anything to enable satirical esteemed member posts FEATURE_ESTEEMED= # set to anything to enable satirical esteemed member posts
ESTEEMED_ROOM= # room ID for esteemed member posts (separate from broadcast rooms) ESTEEMED_ROOM= # room ID for esteemed member posts (separate from broadcast rooms)
ARENA_BOSS_FLOW= # Adv 2.0 L2: set to "1" to route arena rounds through the zone-boss flow (staged combat log + TwinBee mood lines). Empty / "0" / "false" keeps legacy CombatPower path. Removed when L4f lands.
# ---- Games & Economy ---- # ---- Games & Economy ----
GAMES_ROOM= # room ID where game commands work (trivia, hangman, blackjack, holdem, wordle, flip) GAMES_ROOM= # room ID where game commands work (trivia, hangman, blackjack, holdem, wordle, flip)

View File

@@ -230,7 +230,7 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events,
9. Drop AdvCharacter imports from arena files. **DEFERRED (2026-05-09):** blocked on DisplayName migration (L4f-prep, see §7) plus L4 hospital/pets/XP work. Arena code still needs `char.DisplayName` (stats DM, T5/helm announces, render), `char.PetName` (pet-recovery game-room msg), `char.DeathReprieveLast`, `char.CombatXP`, `char.Alive`/`transitionDeath`. Counter dual-writes to `char.ArenaWins/Losses/InvasionScore` could be dropped now (player_meta is source of truth post-step 6), but the surrounding `loadAdvCharacter`/`saveAdvCharacter` calls stay for the other fields, so step 9's exit grep can't pass at L2 time. Revisit after DisplayName migration ships and L4 (hospital/pets/render) lands. 9. Drop AdvCharacter imports from arena files. **DEFERRED (2026-05-09):** blocked on DisplayName migration (L4f-prep, see §7) plus L4 hospital/pets/XP work. Arena code still needs `char.DisplayName` (stats DM, T5/helm announces, render), `char.PetName` (pet-recovery game-room msg), `char.DeathReprieveLast`, `char.CombatXP`, `char.Alive`/`transitionDeath`. Counter dual-writes to `char.ArenaWins/Losses/InvasionScore` could be dropped now (player_meta is source of truth post-step 6), but the surrounding `loadAdvCharacter`/`saveAdvCharacter` calls stay for the other fields, so step 9's exit grep can't pass at L2 time. Revisit after DisplayName migration ships and L4 (hospital/pets/render) lands.
10. `go test ./... && go vet`. 10. `go test ./... && go vet`.
**Feature flag.** Ship behind `ARENA_BOSS_FLOW=1` for one week of soak. Inside the flag the new flow runs; outside it falls back to the legacy path. Flip to default-on after the soak; remove the flag in the same commit that lands L4f (twinbee). **Feature flag.** ~~Originally planned an `ARENA_BOSS_FLOW=1` soak.~~ Cancelled 2026-05-09: shipped no-flag, no legacy fallback. The legacy `runArenaCombat` / `RenderCombatLogArena` / `renderArenaCombatFinalMessage` / `renderArenaOutcome` / `arenaWin/LoseCloser` paths were deleted in the same commit. Boss-flow errors surface to the player and abort the round rather than falling back.
**Exit criteria:** **Exit criteria:**
- ~~`grep -l 'AdventureCharacter\|CombatLevel\|combat_engine' internal/plugin/adventure_arena*.go` empty.~~ **Deferred** with step 9 — see note above. The grep will only clear once DisplayName migration + L4 (hospital/pets/XP/render) ship; L2 closes without it. - ~~`grep -l 'AdventureCharacter\|CombatLevel\|combat_engine' internal/plugin/adventure_arena*.go` empty.~~ **Deferred** with step 9 — see note above. The grep will only clear once DisplayName migration + L4 (hospital/pets/XP/render) ship; L2 closes without it.
@@ -239,7 +239,7 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events,
- Existing arena DB rows readable; ArenaWins backfill from AdvCharacter into `player_meta` (§8). - Existing arena DB rows readable; ArenaWins backfill from AdvCharacter into `player_meta` (§8).
**Risk:** **Risk:**
- D&D combat is swingier than legacy CombatPower; pace by tuning HP/AC tables against legacy win-rate logs during the flag soak. - D&D combat is swingier than legacy CombatPower; tune HP/AC tables against in-flight playtest data (no flag soak — legacy path is gone).
- Refactoring `resolveBossRoom` to extract `renderBossOutcome` could regress zone bosses. Mitigate: do the extraction in step 1 alone, ship it, run a full zone-boss test pass before continuing to step 2. - Refactoring `resolveBossRoom` to extract `renderBossOutcome` could regress zone bosses. Mitigate: do the extraction in step 1 alone, ship it, run a full zone-boss test pass before continuing to step 2.
--- ---

View File

@@ -5,7 +5,6 @@ import (
"log/slog" "log/slog"
"math" "math"
"math/rand/v2" "math/rand/v2"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -193,46 +192,30 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
} }
// arenaBossNarration carries the staged-narration trio produced by // arenaBossNarration carries the staged-narration trio produced by
// resolveArenaBoss (Phase L2 step 4b). When non-nil it tells the // resolveArenaBoss: an intro line, the per-phase combat log, and the
// survival/death handlers to skip the legacy RenderCombatLogArena + // outcome block (TwinBee mood + headline + dice summary).
// dnd opening/closing/roll-summary stack and instead use the
// pre-rendered boss-flow intro/phases/outcome.
type arenaBossNarration struct { type arenaBossNarration struct {
intro string intro string
phases []string phases []string
outcome string outcome string
} }
// resolveArenaRound runs a single arena round through the combat engine and // resolveArenaRound runs a single arena round through resolveArenaBoss
// dispatches to survival or death handlers. When ARENA_BOSS_FLOW is set, the // (zone-boss combat + staged narration) and threads the result into the
// round is routed through resolveArenaBoss (zone-boss combat + staged // surrounding economic glue. There is no legacy fallback — a boss-flow
// narration) and the resulting CombatResult / narration are threaded into // error surfaces to the player and aborts the round.
// the existing economic glue.
func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, tier *ArenaTier, monster *ArenaMonster) error { func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, tier *ArenaTier, monster *ArenaMonster) error {
var ( intro, phases, outcome, result, err := p.resolveArenaBoss(ctx.Sender, ArenaBossEncounter{
result CombatResult Tier: run.Tier,
condRepair int Round: run.Round,
bossNarr *arenaBossNarration DisplayName: char.DisplayName,
) })
if err != nil {
if arenaBossFlowEnabled() { slog.Error("arena: boss flow failed", "user", ctx.Sender, "err", err)
intro, phases, outcome, res, err := p.resolveArenaBoss(ctx.Sender, ArenaBossEncounter{ return p.SendDM(ctx.Sender, "Arena combat encountered an error. Try again in a moment.")
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)
} }
bossNarr := &arenaBossNarration{intro: intro, phases: phases, outcome: outcome}
// Apply event-based equipment degradation
degradation := combatDegradation(result, equip) degradation := combatDegradation(result, equip)
for slot, eq := range equip { for slot, eq := range equip {
if d, ok := degradation[slot]; ok && d > 0 { 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 checkDeathSaveEvent(result.Events) {
if deathSaved {
now := time.Now().UTC() now := time.Now().UTC()
char.DeathReprieveLast = &now char.DeathReprieveLast = &now
if err := saveAdvCharacter(char); err != nil { if err := saveAdvCharacter(char); err != nil {
@@ -253,14 +234,6 @@ func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, c
if !result.PlayerWon { if !result.PlayerWon {
return p.resolveArenaDeath(ctx, run, char, tier, monster, result, bossNarr) 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) 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 run.XPAccumulated += battleXP
// Render combat log as phased messages + final outcome. Boss-flow path phaseMessages := bossFlowPhaseMessages(bossNarr)
// uses pre-rendered intro/phases/outcome; legacy path builds them here. phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages)
var ( finalMessage := bossNarr.outcome + fmt.Sprintf("\n🏆 +%d XP | €%d earned", battleXP, reward)
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 // 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 // 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", finalMessage += fmt.Sprintf("\n🏆 **Tier %d cleared!** Round earnings: €%d + completion bonus: €%d (×%.1f streak)\n",
tier.Number, tierRaw, tier.CompletionBonus, multiplier) tier.Number, tierRaw, tier.CompletionBonus, multiplier)
done := p.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMessage, bossNarr != nil) done := p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMessage)
<-done <-done
return p.arenaCompleteSession(ctx.Sender, run, char, "") return p.arenaCompleteSession(ctx.Sender, run, char, "")
} }
@@ -452,7 +404,7 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
p.postArenaDropAnnouncement(char.DisplayName, dropped) p.postArenaDropAnnouncement(char.DisplayName, dropped)
} }
done := p.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMessage, bossNarr != nil) done := p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMessage)
go func() { go func() {
<-done <-done
p.arenaCountdown(ctx.Sender, run) p.arenaCountdown(ctx.Sender, run)
@@ -474,19 +426,10 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
finalMessage += "`!arena fight` — Face this opponent" finalMessage += "`!arena fight` — Face this opponent"
} }
p.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMessage, bossNarr != nil) p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMessage)
return nil 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 // bossFlowPhaseMessages prepends the resolveArenaBoss intro line to the
// staged combat phases, mirroring streamOrSend's intro+phases pattern in // staged combat phases, mirroring streamOrSend's intro+phases pattern in
// dnd_zone_cmd.go. // dnd_zone_cmd.go.
@@ -504,15 +447,7 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
run.LastMonster = monster.Name run.LastMonster = monster.Name
lostEarnings := run.Earnings + run.TierEarnings lostEarnings := run.Earnings + run.TierEarnings
var phaseMessages []string phaseMessages := bossFlowPhaseMessages(bossNarr)
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{ dt := transitionDeath(DeathTransitionParams{
Char: char, 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) insertArenaHistory(run.UserID, run.StartTier, run.Tier, run.RoundsSurvived, 0, "dead", monster.Name)
upsertArenaStats(run.UserID, 0, true, run.Tier) upsertArenaStats(run.UserID, 0, true, run.Tier)
var finalMsg string finalMsg := bossNarr.outcome + fmt.Sprintf("\n+%d XP (participation) | Back tomorrow.", arenaParticipationXP)
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 { if dt.PetRecovered {
finalMsg += fmt.Sprintf("\n\nYour pet dragged you out of the arena. Death timer reduced. All session earnings forfeited.") finalMsg += fmt.Sprintf("\n\nYour pet dragged you out of the arena. Death timer reduced. All session earnings forfeited.")
} else { } 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 { if dt.PetRecovered {
gr := gamesRoom() gr := gamesRoom()
@@ -1301,25 +1227,12 @@ func (p *AdventurePlugin) arenaRollGladiatorHelm(userID id.UserID, maxTierCleare
return "The Gladiator's Helm" 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 // resolveArenaBoss is the arena combat path: a single arena round
// round routed through runZoneCombat + renderBossOutcome so the player // routed through runZoneCombat + renderBossOutcome so the player sees
// sees the same staged narration that zone bosses use (Nat20/Nat1 mood // the same staged narration zone bosses use (Nat20/Nat1 mood lines,
// lines, phase-two barb on T3+, BossDeath/PlayerDeath flavor, dice // phase-two barb on T3+, BossDeath/PlayerDeath flavor, dice summary).
// 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
}
// ArenaBossEncounter is the single-round input for resolveArenaBoss. // ArenaBossEncounter is the single-round input for resolveArenaBoss.
// Tier and Round identify the arenaBosses entry; DisplayName is the // Tier and Round identify the arenaBosses entry; DisplayName is the

View File

@@ -84,30 +84,6 @@ 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, // Phase L2 step 4b — staged-narration assembly. The intro line leads,
// followed by the combat-log phases, mirroring streamOrSend's // followed by the combat-log phases, mirroring streamOrSend's
// intro+phases pattern in dnd_zone_cmd.go. // intro+phases pattern in dnd_zone_cmd.go.

View File

@@ -1,36 +1,3 @@
package plugin package plugin
import (
"fmt"
"math/rand/v2"
)
const arenaParticipationXP = 60 const arenaParticipationXP = 60
// ── Closer Lines ───────────────────────────────────────────────────────────
func arenaWinCloser(loserName string, lastRound int) string {
closers := []string{
"%s fought. It counts.",
"%s will be back. The arena keeps score.",
fmt.Sprintf("%%s has until tomorrow to think about round %d.", lastRound),
"%s gave you more trouble than you'd like to admit. They don't need to know that.",
"%s loses this one. The next one is an open question.",
"%s came here to fight and did. The result is a separate matter.",
"%s is already planning the rematch. You can feel it.",
}
return fmt.Sprintf(closers[rand.IntN(len(closers))], loserName)
}
func arenaLoseCloser(winnerName string, lastRound int) string {
closers := []string{
"You fought. It counts.",
"You'll be back. The arena keeps score.",
fmt.Sprintf("You have until tomorrow to think about round %d.", lastRound),
fmt.Sprintf("You gave %s more trouble than they'd like to admit. Small comfort. Still comfort.", winnerName),
"You lose this one. The next one is an open question.",
"You came here to fight and did. The result is a separate matter.",
fmt.Sprintf("%s won this one. You're already planning the rematch.", winnerName),
}
return closers[rand.IntN(len(closers))]
}

View File

@@ -203,8 +203,8 @@ func arenaGetMonster(tier, round int) *ArenaMonster {
// //
// HP/AC/Attack are first-pass tier-banded values; BaseLethality biases // HP/AC/Attack are first-pass tier-banded values; BaseLethality biases
// each monster up or down within its tier band so round-1 is the // each monster up or down within its tier band so round-1 is the
// weakest fight and round-4 is the cap. Final tuning happens during // weakest fight and round-4 is the cap. Tuning is ongoing against
// the ARENA_BOSS_FLOW flag soak (gogobee_legacy_migration.md §4 Risk). // playtest data (gogobee_legacy_migration.md §4 Risk).
var arenaBosses = map[string]DnDMonsterTemplate{} var arenaBosses = map[string]DnDMonsterTemplate{}
// arenaBossID composes the canonical arena bestiary key. // arenaBossID composes the canonical arena bestiary key.

View File

@@ -10,99 +10,6 @@ import (
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
) )
// runArenaCombat executes arena combat using the new simulation engine.
// Returns the CombatResult and the post-combat Misty condition repair amount (if any).
func (p *AdventurePlugin) runArenaCombat(
userID id.UserID,
char *AdventureCharacter,
equip map[EquipmentSlot]*AdvEquipment,
monster *ArenaMonster,
arenaRound int,
arenaTier int,
) (CombatResult, int) {
bonuses := p.loadCombatBonuses(userID, char)
chatLvl := p.chatLevel(userID)
hasGrudge := char.GrudgeLocation != ""
playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, hasGrudge)
// Load consumables from inventory and auto-select
consumables := p.loadConsumableInventory(userID)
enemyStats, _ := DeriveArenaMonsterStats(monster)
// All combat is D&D. If the player has no sheet yet (or only a draft),
// auto-migrate them to a sensible inferred character before fighting.
dndChar, freshMigrate, err := ensureDnDCharacterForCombat(userID, char)
if err != nil {
slog.Error("dnd: ensureDnDCharacterForCombat (arena) failed", "user", userID, "err", err)
return CombatResult{}, 0
}
if freshMigrate {
p.maybeSendDnDOnboarding(userID, char, dndChar)
}
applyDnDPlayerLayer(&playerStats, dndChar)
applyDnDEquipmentLayer(&playerStats, dndChar, equip)
applyDnDHPScaling(&playerStats, dndChar)
applyClassPassives(&playerStats, &playerMods, dndChar)
applyRacePassives(&playerStats, &playerMods, dndChar)
applySubclassPassives(&playerStats, &playerMods, dndChar)
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
}
applyDnDArenaMonsterLayer(&enemyStats, monster.ThreatLevel)
applyPendingCast(userID, dndChar, &playerStats, &playerMods, &enemyStats)
selected := SelectConsumables(consumables, playerStats, enemyStats, arenaRound, arenaTier)
ApplyConsumableMods(&playerStats, &playerMods, selected)
// Misty condition repair (post-combat, not part of engine)
condRepair := 0
now := time.Now().UTC()
if char.MistyBuffExpires != nil && now.Before(*char.MistyBuffExpires) {
if rand.Float64() < 0.20 {
condRepair = 5
}
}
player := Combatant{
Name: char.DisplayName,
Stats: playerStats,
Mods: playerMods,
IsPlayer: true,
}
enemy := Combatant{
Name: monster.Name,
Stats: enemyStats,
Mods: CombatModifiers{DamageReduct: 1.0},
Ability: monster.Ability,
}
result := SimulateCombat(player, enemy, defaultCombatPhases)
result = injectConsumableEvents(result, selected, len(consumables))
// Consume used items from inventory
for _, c := range selected {
if err := removeAdvInventoryItem(c.InventoryID); err != nil {
slog.Error("combat: failed to consume item", "id", c.InventoryID, "name", c.Def.Name, "err", err)
}
}
p.grantCombatAchievements(userID, result)
persistDnDHPAfterCombat(userID, result.PlayerStartHP, result.PlayerEndHP)
if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil {
slog.Error("dnd: post-combat subclass persist (arena)", "user", userID, "err", err)
}
if xp := arenaCombatXP(result, monster.ThreatLevel); xp > 0 {
if _, err := p.grantDnDXP(userID, xp); err != nil {
slog.Error("dnd: grantDnDXP arena", "user", userID, "err", err)
}
}
return result, condRepair
}
// grantCombatAchievements checks combat results for achievement-worthy moments. // grantCombatAchievements checks combat results for achievement-worthy moments.
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) { func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
if p.achievements == nil { if p.achievements == nil {
@@ -658,15 +565,3 @@ func (p *AdventurePlugin) resolveDungeonAction(
return result return result
} }
// renderArenaCombatFinalMessage builds the post-combat text (rewards, tier progress, etc.)
// This is sent as the last message after the phase-by-phase combat messages.
func renderArenaCombatFinalMessage(result CombatResult, monster *ArenaMonster, reward int64, battleXP int, round int) string {
if result.PlayerWon {
closer := arenaWinCloser(monster.Name, round)
return fmt.Sprintf("💀 **%s** has been defeated.\n%s\n🏆 +%d XP | €%d earned",
monster.Name, closer, battleXP, reward)
}
closer := arenaLoseCloser(monster.Name, round)
return fmt.Sprintf("The healers are already moving.\n💀 **Defeated.**\n%s\n+%d XP (participation) | Back tomorrow.",
closer, arenaParticipationXP)
}

View File

@@ -70,11 +70,6 @@ func RenderCombatLog(result CombatResult, playerName, enemyName string) []string
return msgs return msgs
} }
// RenderCombatLogArena is RenderCombatLog for arena fights.
func RenderCombatLogArena(result CombatResult, playerName, enemyName string) []string {
return RenderCombatLog(result, playerName, enemyName)
}
type phaseGroup struct { type phaseGroup struct {
Name string Name string
Events []CombatEvent Events []CombatEvent
@@ -297,35 +292,6 @@ func renderOutcome(result CombatResult, playerName, enemyName string, reward int
return sb.String() return sb.String()
} }
func renderArenaOutcome(result CombatResult, playerName, enemyName string, reward int64, xp int, tier, round int) string {
var sb strings.Builder
if result.PlayerWon {
sb.WriteString(fmt.Sprintf("💀 **%s** has been defeated.\n", enemyName))
closer := arenaWinCloser(enemyName, round)
sb.WriteString(closer + "\n")
sb.WriteString(fmt.Sprintf("🏆 +%d XP | €%d earned", xp, reward))
} else {
sb.WriteString("The healers are already moving.\n")
sb.WriteString("💀 **Defeated.**\n")
closer := arenaLoseCloser(enemyName, round)
sb.WriteString(closer + "\n")
sb.WriteString(fmt.Sprintf("+%d XP (participation) | Back tomorrow.", arenaParticipationXP))
}
if result.SniperKilled {
sb.WriteString("\n" + pickRand(narrativeSniperKill))
}
if result.MistyHealed {
sb.WriteString("\n🌿 Misty's healing made the difference.")
}
if result.PetAttacked {
sb.WriteString("\n🐾 Your pet contributed to the fight.")
}
return sb.String()
}
func pickRand(pool []string) string { func pickRand(pool []string) string {
return pool[rand.IntN(len(pool))] return pool[rand.IntN(len(pool))]
} }

View File

@@ -106,27 +106,6 @@ func TestRenderCombatLog_LossPhaseMessages(t *testing.T) {
} }
} }
func TestRenderCombatLogArena_ProducesPhaseMessages(t *testing.T) {
result := CombatResult{
PlayerWon: true,
PlayerStartHP: 100,
EnemyStartHP: 60,
PlayerEndHP: 70,
EnemyEndHP: 0,
TotalRounds: 3,
Closeness: 0.3,
Events: []CombatEvent{
{Round: 1, Phase: "opening", Actor: "player", Action: "hit", Damage: 30, EnemyHP: 30, PlayerHP: 100},
{Round: 2, Phase: "clash", Actor: "player", Action: "hit", Damage: 30, EnemyHP: 0, PlayerHP: 70},
},
}
msgs := RenderCombatLogArena(result, "Hero", "Ratticus")
if len(msgs) < 1 {
t.Fatalf("expected at least 1 phase message, got %d", len(msgs))
}
}
func TestRenderCombatLog_ConsumableEvents(t *testing.T) { func TestRenderCombatLog_ConsumableEvents(t *testing.T) {
result := CombatResult{ result := CombatResult{
PlayerWon: true, PlayerWon: true,