mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 Phase L2 step 4a: resolveArenaBoss helper
Adds resolveArenaBoss(userID, ArenaBossEncounter) — a single-round combat path that routes through runZoneCombat + renderBossOutcome so arena fights surface the same staged narration zone bosses use: intro line, RenderCombatLog phases, Nat20/Nat1 mood lines, phase-two barb on T3+ (ZoneArena routing), BossDeath/PlayerDeath flavor, dice summary, arena-styled headline. Behind ARENA_BOSS_FLOW env var (arenaBossFlowEnabled). Side-effect free — no ArenaRun mutation, no payout, no history rows. The dispatch into resolveArenaRound lands in step 4b once the surrounding economic glue (rewards, achievements, helmet drops, death flag) is plumbed for the new combat result shape. Smoke tests cover (a) end-to-end arena round produces non-empty intro/phases/outcome with the right headline, (b) bad tier/round returns an error, (c) phase-two thresholds match the design doc, (d) every tier×round arenaBosses entry has positive stats. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
101
internal/plugin/adventure_arena_bossflow_test.go
Normal file
101
internal/plugin/adventure_arena_bossflow_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user