diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 4a5705c..c16898c 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -5,6 +5,7 @@ import ( "log/slog" "math" "math/rand/v2" + "os" "strconv" "strings" "time" @@ -1226,3 +1227,103 @@ func (p *AdventurePlugin) arenaRollGladiatorHelm(userID id.UserID, maxTierCleare return "The Gladiator's Helm" } + +// ── Adv 2.0 boss-flow round resolver (Phase L2 step 4) ────────────────────── +// +// 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 +} + +// ArenaBossEncounter is the single-round input for resolveArenaBoss. +// Tier and Round identify the arenaBosses entry; DisplayName is the +// player-facing combatant label, falling back to "You" when empty. +type ArenaBossEncounter struct { + Tier int + Round int + DisplayName string +} + +// resolveArenaBoss runs one arena round through the zone-boss combat + +// narration stack. Returns the staged-narration trio that the caller +// streams via sendZoneCombatMessages, plus the underlying CombatResult +// so the surrounding economic glue (rewards, achievements, helmet +// drops, death flag) can branch on PlayerWon and inspect events. +// +// Side effects belong to the caller: this helper does not touch +// ArenaRun state, payout, or arena history rows. +func (p *AdventurePlugin) resolveArenaBoss(userID id.UserID, enc ArenaBossEncounter) (intro string, phases []string, outcome string, result CombatResult, err error) { + monster, ok := arenaBosses[arenaBossID(enc.Tier, enc.Round)] + if !ok { + err = fmt.Errorf("arena: no bestiary entry for tier %d round %d", enc.Tier, enc.Round) + return + } + + preHP, _ := dndHPSnapshot(userID) + result, err = p.runZoneCombat(userID, monster, enc.Tier) + if err != nil { + return + } + postHP, maxHP := dndHPSnapshot(userID) + + // Synthetic run/room IDs so twinBeeLine seeds deterministically per + // fight without colliding with zone runs. Arena fights aren't tied + // to a DungeonRun so MoodEvents don't apply — the Nat20/Nat1 counts + // drive narration directly. + runID := fmt.Sprintf("arena-%s-t%d-r%d", string(userID), enc.Tier, enc.Round) + roomIdx := enc.Tier*10 + enc.Round + nat20s, nat1s := countNat20sAnd1s(result) + + playerName := enc.DisplayName + if playerName == "" { + playerName = "You" + } + + intro = fmt.Sprintf("🏟️ **Arena T%d R%d — %s** (HP %d, AC %d)", + enc.Tier, enc.Round, monster.Name, monster.HP, monster.AC) + phases = RenderCombatLog(result, playerName, monster.Name) + + outcome = renderBossOutcome(BossOutcomeInputs{ + ZoneID: ZoneArena, + RunID: runID, + RoomIdx: roomIdx, + Monster: monster, + Result: result, + PreHP: preHP, + PostHP: postHP, + MaxHP: maxHP, + PhaseTwoAt: arenaBossPhaseTwoAt(enc.Tier), + Nat20s: nat20s, + Nat1s: nat1s, + DefeatHeadline: fmt.Sprintf("💀 **%s** stands over your body. The arena collects its fee.", monster.Name), + VictoryHeadline: fmt.Sprintf("🏆 **%s** falls (HP %d→%d / %d).", monster.Name, preHP, postHP, maxHP), + }) + return +} + +// countNat20sAnd1s scans a CombatResult for d20 rolls and returns the +// nat-20 / nat-1 counts. Mirrors scanMoodEventsFromCombat's tally but +// without writing run-scoped mood events (arena has no DungeonRun). +func countNat20sAnd1s(result CombatResult) (nat20s, nat1s int) { + for _, e := range result.Events { + if e.Roll == 20 { + nat20s++ + } else if e.Roll == 1 { + nat1s++ + } + } + return +} diff --git a/internal/plugin/adventure_arena_bossflow_test.go b/internal/plugin/adventure_arena_bossflow_test.go new file mode 100644 index 0000000..315d0f2 --- /dev/null +++ b/internal/plugin/adventure_arena_bossflow_test.go @@ -0,0 +1,101 @@ +package plugin + +import ( + "strings" + "testing" + + "maunium.net/go/mautrix/id" +) + +// Phase L2 step 4 — smoke test for resolveArenaBoss. Asserts the helper +// returns staged narration (intro + phases + outcome) for a representative +// arena round and that the outcome carries the boss-flow signatures: the +// arena-styled headline and a dice-roll summary. + +func TestResolveArenaBoss_T1Round1_Smoke(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@arena-bossflow:example") + t.Cleanup(func() { cleanupZoneRuns(uid) }) + + if err := createAdvCharacter(uid, "bossflow"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5, + STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10, + HPMax: 60, HPCurrent: 60, ArmorClass: 18, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatalf("SaveDnDCharacter: %v", err) + } + + p := &AdventurePlugin{} + intro, phases, outcome, result, err := p.resolveArenaBoss(uid, ArenaBossEncounter{ + Tier: 1, Round: 1, DisplayName: "Smoke", + }) + if err != nil { + t.Fatalf("resolveArenaBoss: %v", err) + } + if intro == "" { + t.Error("intro empty") + } + if !strings.Contains(intro, "Arena T1 R1") { + t.Errorf("intro missing tier/round label: %q", intro) + } + if len(phases) == 0 { + t.Error("phases empty — combat log did not render") + } + if outcome == "" { + t.Error("outcome empty") + } + if result.PlayerWon { + // On a win the headline mentions "falls"; on a loss "stands over". + if !strings.Contains(outcome, "falls") { + t.Errorf("win outcome missing 'falls' headline: %q", outcome) + } + } else { + if !strings.Contains(outcome, "stands over") { + t.Errorf("loss outcome missing defeat headline: %q", outcome) + } + } +} + +func TestResolveArenaBoss_BadTierRound(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@arena-bossflow-bad:example") + if err := createAdvCharacter(uid, "bad"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + p := &AdventurePlugin{} + if _, _, _, _, err := p.resolveArenaBoss(uid, ArenaBossEncounter{Tier: 9, Round: 9}); err == nil { + t.Error("expected error for unknown tier/round") + } +} + +func TestArenaBossPhaseTwoAt(t *testing.T) { + cases := []struct { + tier int + want float64 + }{{1, 0}, {2, 0}, {3, 0.5}, {4, 0.5}, {5, 0.5}} + for _, c := range cases { + if got := arenaBossPhaseTwoAt(c.tier); got != c.want { + t.Errorf("arenaBossPhaseTwoAt(%d)=%v want %v", c.tier, got, c.want) + } + } +} + +func TestArenaBosses_AllTiersPopulated(t *testing.T) { + for tier := 1; tier <= 5; tier++ { + for round := 1; round <= 4; round++ { + id := arenaBossID(tier, round) + m, ok := arenaBosses[id] + if !ok { + t.Errorf("arenaBosses missing %s", id) + continue + } + if m.HP <= 0 || m.AC <= 0 || m.Attack <= 0 { + t.Errorf("%s has bad stats: HP=%d AC=%d ATK=%d", id, m.HP, m.AC, m.Attack) + } + } + } +}