Branching zones G9a: remove GOGOBEE_BRANCHING_ZONES gate

All 9 zones have hand-authored graphs (G8a–G8i), so the POC gate has
served its purpose. Make graph mode the only runtime path:

- Drop branchingZonesEnabled() and the os import in zone_graph_nav.go.
- Inline the gate-on branches in zoneCmdMap, zoneCmdAdvance,
  CurrentRoomType, startZoneRun, and the expedition map renderer.
- Keep the legacy linear functions (markRoomCleared, renderZoneMap,
  generateRoomSequence) standing — their tests still exercise them and
  they'll retire alongside current_room / room_seq_json in G9b.
- Strip gate test (TestBranchingZonesGate, TestCurrentRoomType_GateOff)
  and t.Setenv calls from the surviving graph tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 17:17:19 -07:00
parent 304ad27c0f
commit 103cf30987
8 changed files with 72 additions and 151 deletions

View File

@@ -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)_")

View File

@@ -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))
}

View File

@@ -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 `<zone>.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 `<zone>.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

View File

@@ -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{

View File

@@ -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)
}
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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")
}
}