diff --git a/.env.example b/.env.example index 8133681..2fab177 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index 0e0027a..f533bf4 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -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. --- diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 6e8f0d0..f24c05d 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -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 (2–3s); legacy uses arena pacing (5–8s). -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 diff --git a/internal/plugin/adventure_arena_bossflow_test.go b/internal/plugin/adventure_arena_bossflow_test.go index bb94b15..fe3dfa2 100644 --- a/internal/plugin/adventure_arena_bossflow_test.go +++ b/internal/plugin/adventure_arena_bossflow_test.go @@ -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. diff --git a/internal/plugin/adventure_arena_combat.go b/internal/plugin/adventure_arena_combat.go index bc8436a..a533ef8 100644 --- a/internal/plugin/adventure_arena_combat.go +++ b/internal/plugin/adventure_arena_combat.go @@ -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))] -} diff --git a/internal/plugin/adventure_arena_monsters.go b/internal/plugin/adventure_arena_monsters.go index 2cec8c2..7febfee 100644 --- a/internal/plugin/adventure_arena_monsters.go +++ b/internal/plugin/adventure_arena_monsters.go @@ -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. diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go index 2d1c08b..9b88707 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -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) -} diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index 70053f0..34619dc 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -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))] } diff --git a/internal/plugin/combat_narrative_test.go b/internal/plugin/combat_narrative_test.go index d83229e..8b36b2c 100644 --- a/internal/plugin/combat_narrative_test.go +++ b/internal/plugin/combat_narrative_test.go @@ -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,