mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
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:
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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 (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
|
// 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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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))]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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))]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user