diff --git a/internal/plugin/dnd_expedition_map.go b/internal/plugin/dnd_expedition_map.go index 7ca652f..a5c4e75 100644 --- a/internal/plugin/dnd_expedition_map.go +++ b/internal/plugin/dnd_expedition_map.go @@ -113,19 +113,24 @@ func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) e // Per-run room map. The expedition's zone run is the per-region // dungeon instance; for single-region zones it's the only run. run, _ := getActiveZoneRun(ctx.Sender) - if run != nil && len(run.RoomSeq) > 0 { - cleared := map[int]bool{} - for _, r := range run.RoomsCleared { - cleared[r] = true - } - b.WriteString("```\n") - b.WriteString(renderZoneMap(run.RoomSeq, run.CurrentRoom, cleared)) - b.WriteString("\n```\n") - b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss · ✓ cleared ▶ here · pending_") - if chain != "" { - b.WriteString("\n_Regions: ") - b.WriteString(renderRegionLegend(exp)) - b.WriteString(" · ⛺ base camp pitched_") + if run != nil { + if g, ok := loadZoneGraph(run.ZoneID); ok { + b.WriteString("```\n") + b.WriteString(renderZoneGraphMap(g, run)) + b.WriteString("\n```\n") + b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending ╳ locked_") + if chain != "" { + b.WriteString("\n_Regions: ") + b.WriteString(renderRegionLegend(exp)) + b.WriteString(" · ⛺ base camp pitched_") + } + } else { + b.WriteString("_(no room layout — between rooms or pre-entry)_") + if chain != "" { + b.WriteString("\n_Regions: ") + b.WriteString(renderRegionLegend(exp)) + b.WriteString(" · ⛺ base camp pitched_") + } } } else { b.WriteString("_(no room layout — between rooms or pre-entry)_") diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 3b2b7ff..5816a20 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -288,14 +288,13 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error { zone := zoneOrFallback(run.ZoneID) var b strings.Builder b.WriteString(fmt.Sprintf("**%s — Map**\n```\n", zone.Display)) - if branchingZonesEnabled() { - if g, ok := loadZoneGraph(run.ZoneID); ok { - b.WriteString(renderZoneGraphMap(g, run)) - b.WriteString("\n```\n") - b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending ╳ locked_") - return p.SendDM(ctx.Sender, b.String()) - } + if g, ok := loadZoneGraph(run.ZoneID); ok { + b.WriteString(renderZoneGraphMap(g, run)) + b.WriteString("\n```\n") + b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending ╳ locked_") + return p.SendDM(ctx.Sender, b.String()) } + // No registered graph (defensive — every zone has one post-G8). cleared := map[int]bool{} for _, r := range run.RoomsCleared { cleared[r] = true @@ -415,60 +414,18 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error { return p.streamOrSend(ctx.Sender, preStream, intro, phases, outcome) } - // Graph-mode (POC gate): clear the current room, then either auto-advance - // down a single edge, surface a fork prompt for 2+ edges, or complete the - // run when there are 0 outgoing edges. Legacy linear path stays on - // markRoomCleared so non-gated deployments are bit-identical to G4. - if branchingZonesEnabled() { - c, cerr := LoadDnDCharacter(ctx.Sender) - if cerr != nil { - return p.SendDM(ctx.Sender, "Couldn't load character: "+cerr.Error()) - } - next, forkMsg, complete, gerr := p.advanceTransitionGraph(run, zone, c) - if gerr != nil { - return p.SendDM(ctx.Sender, "Couldn't advance: "+gerr.Error()) - } - if complete { - _, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete) - var b strings.Builder - if outcome != "" { - b.WriteString(outcome) - b.WriteString("\n\n") - } - b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display)) - if line := twinBeeLine(zone.ID, DMZoneComplete, run.RunID, prevIdx); line != "" { - b.WriteString(line) - b.WriteString("\n\n") - } - if granted := p.rollZoneLoot(ctx.Sender, run, zone); len(granted) > 0 { - b.WriteString("**Loot:**\n") - for _, id := range granted { - b.WriteString("• " + id + "\n") - } - } - return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String()) - } - if forkMsg != "" { - var b strings.Builder - if outcome != "" { - b.WriteString(outcome) - b.WriteString("\n\n") - } - b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev))) - b.WriteString(forkMsg) - return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String()) - } - // Single-edge auto-advance — fall through to the shared next-room - // teaser using the resolved next room type. - return p.streamOrSend(ctx.Sender, preStream, intro, phases, - p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next)) + // Graph-mode advance (G9a — only mode now): clear the current room, + // then either auto-advance down a single edge, surface a fork prompt + // for 2+ edges, or complete the run when there are 0 outgoing edges. + c, cerr := LoadDnDCharacter(ctx.Sender) + if cerr != nil { + return p.SendDM(ctx.Sender, "Couldn't load character: "+cerr.Error()) } - - next, err := markRoomCleared(run.RunID) - if err != nil { - return p.SendDM(ctx.Sender, "Couldn't advance: "+err.Error()) + next, forkMsg, complete, gerr := p.advanceTransitionGraph(run, zone, c) + if gerr != nil { + return p.SendDM(ctx.Sender, "Couldn't advance: "+gerr.Error()) } - if next == "" { + if complete { _, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete) var b strings.Builder if outcome != "" { @@ -488,7 +445,16 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error { } return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String()) } - + if forkMsg != "" { + var b strings.Builder + if outcome != "" { + b.WriteString(outcome) + b.WriteString("\n\n") + } + b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev))) + b.WriteString(forkMsg) + return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String()) + } return p.streamOrSend(ctx.Sender, preStream, intro, phases, p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next)) } diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index 9fc0bf2..6d7f840 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -85,21 +85,17 @@ func (r *DungeonRun) IsActive() bool { } // CurrentRoomType returns the type of the room the player is currently -// standing in. In gated graph mode (GOGOBEE_BRANCHING_ZONES=1) with a -// hand-authored ZoneGraph registered for the run's zone, the live -// CurrentNode's kind is authoritative — that's the only way side paths -// (e.g. the Crypt of Valdris secret chamber) resolve to the right -// room-type when the player diverges from the canonical RoomSeq. The -// legacy RoomSeq fallback covers gate-off runs and runs in zones whose -// graph is still the linear-compiled one. Returns "" if no resolution -// is possible. +// standing in. The live CurrentNode's kind is authoritative — that's +// the only way side paths (e.g. the Crypt of Valdris secret chamber) +// resolve to the right room-type when the player diverges from the +// canonical RoomSeq. The legacy RoomSeq fallback covers in-flight runs +// from before the G4 dual-write deploy that lack a CurrentNode entry. +// Returns "" if no resolution is possible. func (r *DungeonRun) CurrentRoomType() RoomType { - if branchingZonesEnabled() && r.CurrentNode != "" { - if _, authored := zoneGraphRegistry[r.ZoneID]; authored { - if g, ok := loadZoneGraph(r.ZoneID); ok { - if n, exists := g.Nodes[r.CurrentNode]; exists { - return nodeKindToRoomType(n.Kind) - } + if r.CurrentNode != "" { + if g, ok := loadZoneGraph(r.ZoneID); ok { + if n, exists := g.Nodes[r.CurrentNode]; exists { + return nodeKindToRoomType(n.Kind) } } } @@ -228,15 +224,12 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand) } // G4 dual-write: persist the entry node id and seed visited_nodes // with it, so navigation surfaces in G5 can read graph state without - // further migration. G7: when graph mode is on AND the zone has a - // hand-authored graph, start at that graph's Entry node so the player - // actually traverses the authored topology rather than falling off - // into the legacy `.r1` namespace. + // further migration. New runs always start at the registered graph's + // Entry node; only zones that lack a registered graph (none, post-G8) + // would fall back to the legacy linear `.r1` namespace. entryNode := deriveLegacyNodeID(zoneID, 0) - if branchingZonesEnabled() { - if g, ok := zoneGraphRegistry[zoneID]; ok { - entryNode = g.Entry - } + if g, ok := zoneGraphRegistry[zoneID]; ok { + entryNode = g.Entry } visitedJSON, _ := json.Marshal([]string{entryNode}) run.CurrentNode = entryNode diff --git a/internal/plugin/zone_graph_crypt_valdris.go b/internal/plugin/zone_graph_crypt_valdris.go index 2ae94c0..dfa2c90 100644 --- a/internal/plugin/zone_graph_crypt_valdris.go +++ b/internal/plugin/zone_graph_crypt_valdris.go @@ -14,9 +14,6 @@ package plugin // // Two paths to the boss (main_hall vs side_chapel); secret_chamber // dangles off side_chapel for an extra Perception gate with loot. -// Registration is unconditional — the runtime gate -// (GOGOBEE_BRANCHING_ZONES) decides whether new runs adopt this graph -// or stay on the legacy linear template. func zoneCryptValdrisGraph() ZoneGraph { nodes := []ZoneNode{ diff --git a/internal/plugin/zone_graph_crypt_valdris_test.go b/internal/plugin/zone_graph_crypt_valdris_test.go index d6624f0..2cdb173 100644 --- a/internal/plugin/zone_graph_crypt_valdris_test.go +++ b/internal/plugin/zone_graph_crypt_valdris_test.go @@ -87,14 +87,12 @@ func TestCryptValdrisGraph_SecretGated(t *testing.T) { } } -// TestCurrentRoomType_GraphAuthored verifies that in gated graph mode, -// CurrentRoomType resolves via the registered graph's node kind rather -// than the legacy RoomSeq lookup. This is what makes side_path nodes -// (e.g. the secret_chamber) resolve to the right RoomType when the -// player diverges from the canonical RoomSeq layout. +// TestCurrentRoomType_GraphAuthored verifies that CurrentRoomType +// resolves via the registered graph's node kind rather than the legacy +// RoomSeq lookup. This is what makes side_path nodes (e.g. the +// secret_chamber) resolve to the right RoomType when the player +// diverges from the canonical RoomSeq layout. func TestCurrentRoomType_GraphAuthored(t *testing.T) { - t.Setenv("GOGOBEE_BRANCHING_ZONES", "1") - // Mismatched RoomSeq vs. live CurrentNode: CurrentRoom=2 would land // on RoomTrap in the legacy fallback, but the graph node kind is // Elite (main_hall). Graph wins. @@ -121,18 +119,3 @@ func TestCurrentRoomType_GraphAuthored(t *testing.T) { } } -// TestCurrentRoomType_GateOffUsesRoomSeq asserts the legacy behavior is -// untouched when the gate is off: RoomSeq[CurrentRoom] is authoritative -// even though Crypt of Valdris has a registered graph. -func TestCurrentRoomType_GateOffUsesRoomSeq(t *testing.T) { - t.Setenv("GOGOBEE_BRANCHING_ZONES", "") - run := &DungeonRun{ - ZoneID: ZoneCryptValdris, - CurrentRoom: 2, - CurrentNode: "crypt_valdris.main_hall", - RoomSeq: []RoomType{RoomEntry, RoomExploration, RoomTrap, RoomElite, RoomBoss}, - } - if got := run.CurrentRoomType(); got != RoomTrap { - t.Errorf("gate off: got %s, want %s (RoomSeq lookup)", got, RoomTrap) - } -} diff --git a/internal/plugin/zone_graph_map.go b/internal/plugin/zone_graph_map.go index 7630aef..8edbd80 100644 --- a/internal/plugin/zone_graph_map.go +++ b/internal/plugin/zone_graph_map.go @@ -2,12 +2,12 @@ package plugin // Phase G6 — graph-aware !zone map renderer. // -// Replaces the linear renderZoneMap() output when GOGOBEE_BRANCHING_ZONES -// is on. Walks the registered (or legacy-compiled) ZoneGraph in BFS -// order rooted at Entry, lays nodes out by PosX/PosY, and prints a -// horizontal glyph row per layer with a status row underneath. Locked -// outgoing edges show as `╳`; secret nodes stay hidden until the -// player has visited them (prevents spoilers from `!zone map`). +// Replaces the linear renderZoneMap() output. Walks the registered +// (or legacy-compiled) ZoneGraph in BFS order rooted at Entry, lays +// nodes out by PosX/PosY, and prints a horizontal glyph row per layer +// with a status row underneath. Locked outgoing edges show as `╳`; +// secret nodes stay hidden until the player has visited them +// (prevents spoilers from `!zone map`). import ( "fmt" diff --git a/internal/plugin/zone_graph_nav.go b/internal/plugin/zone_graph_nav.go index d64ccb8..06685e7 100644 --- a/internal/plugin/zone_graph_nav.go +++ b/internal/plugin/zone_graph_nav.go @@ -8,28 +8,19 @@ package plugin // consumes the choice, validates the chosen edge is unlocked, and // advances the run state to the chosen node. // -// Behavior is gated by GOGOBEE_BRANCHING_ZONES=1 during the POC week -// (plan §G5 / §9.5). With the gate off, !zone advance still uses the -// linear markRoomCleared path and forks never fire — even on zones -// that have a hand-authored graph registered. +// G9a retired the GOGOBEE_BRANCHING_ZONES POC gate: graph mode is the +// only runtime path now that all 9 zones have hand-authored graphs. import ( "crypto/sha1" "encoding/binary" "encoding/json" "fmt" - "os" "strings" "gogobee/internal/db" ) -// branchingZonesEnabled — POC gate. Default off. Operator flips it for -// the POC soak week, then it's removed in G9. -func branchingZonesEnabled() bool { - return os.Getenv("GOGOBEE_BRANCHING_ZONES") == "1" -} - // pendingFork is the typed shape of dnd_zone_run.node_choices when the // player is paused at a fork. Persisted as JSON inside the // map[string]any NodeChoices column; helpers below round-trip via JSON diff --git a/internal/plugin/zone_graph_nav_test.go b/internal/plugin/zone_graph_nav_test.go index a25a357..2bf5fd6 100644 --- a/internal/plugin/zone_graph_nav_test.go +++ b/internal/plugin/zone_graph_nav_test.go @@ -241,17 +241,3 @@ func TestNodeKindToRoomType(t *testing.T) { } } -func TestBranchingZonesGate(t *testing.T) { - t.Setenv("GOGOBEE_BRANCHING_ZONES", "") - if branchingZonesEnabled() { - t.Error("gate should be off when env empty") - } - t.Setenv("GOGOBEE_BRANCHING_ZONES", "1") - if !branchingZonesEnabled() { - t.Error("gate should be on when env=1") - } - t.Setenv("GOGOBEE_BRANCHING_ZONES", "0") - if branchingZonesEnabled() { - t.Error("gate should be off when env=0") - } -}