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_ESTEEMED= # set to anything to enable satirical esteemed member posts
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_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.
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:**
- ~~`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).
**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.
---

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

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,
// followed by the combat-log phases, mirroring streamOrSend's
// intro+phases pattern in dnd_zone_cmd.go.

View File

@@ -1,36 +1,3 @@
package plugin
import (
"fmt"
"math/rand/v2"
)
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
// 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
// the ARENA_BOSS_FLOW flag soak (gogobee_legacy_migration.md §4 Risk).
// weakest fight and round-4 is the cap. Tuning is ongoing against
// playtest data (gogobee_legacy_migration.md §4 Risk).
var arenaBosses = map[string]DnDMonsterTemplate{}
// arenaBossID composes the canonical arena bestiary key.

View File

@@ -10,99 +10,6 @@ import (
"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.
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
if p.achievements == nil {
@@ -658,15 +565,3 @@ func (p *AdventurePlugin) resolveDungeonAction(
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
}
// RenderCombatLogArena is RenderCombatLog for arena fights.
func RenderCombatLogArena(result CombatResult, playerName, enemyName string) []string {
return RenderCombatLog(result, playerName, enemyName)
}
type phaseGroup struct {
Name string
Events []CombatEvent
@@ -297,35 +292,6 @@ func renderOutcome(result CombatResult, playerName, enemyName string, reward int
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 {
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) {
result := CombatResult{
PlayerWon: true,