From 90ff7666bd91ca94b18d8cb71a13d5a2b098488b Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 9 May 2026 15:28:58 -0700 Subject: [PATCH] Branching zones G7: Crypt of Valdris POC graph Hand-authored zoneCryptValdrisGraph() (8 nodes incl. fork + Perception DC 15 secret reliquary) self-registers at init. Two surgical bridges so new runs traverse the authored graph when GOGOBEE_BRANCHING_ZONES=1: startZoneRun seeds current_node from g.Entry, and DungeonRun.CurrentRoomType resolves via the live node's kind so divergent paths (secret_chamber) dispatch through the right resolver. Gate off is bit-identical to pre-G7. Co-Authored-By: Claude Opus 4.7 (1M context) --- gogobee_branching_zones_plan.md | 15 +- internal/plugin/dnd_zone_run.go | 28 +++- internal/plugin/zone_graph_crypt_valdris.go | 61 ++++++++ .../plugin/zone_graph_crypt_valdris_test.go | 138 ++++++++++++++++++ 4 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 internal/plugin/zone_graph_crypt_valdris.go create mode 100644 internal/plugin/zone_graph_crypt_valdris_test.go diff --git a/gogobee_branching_zones_plan.md b/gogobee_branching_zones_plan.md index 3cb2abc..0faae8e 100644 --- a/gogobee_branching_zones_plan.md +++ b/gogobee_branching_zones_plan.md @@ -286,12 +286,15 @@ func compileLegacyZoneGraph(z ZoneDefinition) ZoneGraph { - **Boss flow** unchanged in essence — entering the boss node still triggers `bossEncounter`. The path to it is what changed. - **Region transitions** (`dnd_expedition_region.go`): when `nextNode.RegionID != currentNode.RegionID`, fire the existing region-transition flow. -### G7 — POC zone (Crypt of Valdris) +### G7 — POC zone (Crypt of Valdris) *(shipped)* -- Implement `zoneCryptValdrisGraph()` per §3 example. -- Register alongside the existing `zoneCryptValdris()` zone definition. -- Old linear template stays in place — only new runs use the graph form (gated by env var `GOGOBEE_BRANCHING_ZONES=1` for the POC week). -- Playtest: forks behave, secrets gate properly, boss reachable via both paths. +- `zoneCryptValdrisGraph()` lives in `internal/plugin/zone_graph_crypt_valdris.go` (graph topology per §3, including a Perception DC 15 secret reliquary off `side_chapel` with `LootBias=2.0`). Self-registers at init. +- Two surgical bridges so new runs actually traverse the authored graph when the gate is on: + - `startZoneRun` seeds `current_node = g.Entry` instead of `.r1` when `GOGOBEE_BRANCHING_ZONES=1` and the zone has a hand-authored graph in `zoneGraphRegistry`. + - `DungeonRun.CurrentRoomType()` resolves via `g.Nodes[CurrentNode].Kind` under the same gate, so divergent paths (e.g. the secret_chamber) dispatch to the right room resolver — RoomSeq can't represent off-path nodes. +- Gate off = pre-G7 behavior bit-identical. Legacy zones with the gate on still route through the linear-compiled graph (auto-advance throughout) and use the legacy `.r{N+1}` node IDs unchanged. +- Tests: registration + boss reachability via both paths, fork edge layout (weight + lock), secret-chamber DC + loot bias, `CurrentRoomType` graph-vs-RoomSeq behavior under both gate states. +- Playtest open: forks render, perception teasers fire, boss reachable via main_hall and side_chapel. ### G8 — Migrate remaining zones one at a time @@ -379,5 +382,5 @@ When the new session starts: 4. Run G1 (schema) + G2 (types) + G3 (legacy compiler) + G4 (run state). All four are infra; ship together with no behavior change. 5. Land G5 navigation behind `GOGOBEE_BRANCHING_ZONES=1` env gate. 6. **G6 (done)** — dependent surfaces: harvest re-keyed on `node_id` (with legacy `room_idx` read-fallback + drop-on-save migration), narration cadence salted on `len(visited_nodes)-1` via `narrationCadence(run)`, `!zone map` graph render gated by `GOGOBEE_BRANCHING_ZONES=1` (locked edges → `╳`, secrets hidden until visited), and a region-boundary hook in graph advance/`!zone go` that mirrors `!region travel` flavor + log without burning supplies (graph is run-state authoritative). -7. POC G7 with Crypt of Valdris. +7. **G7 (done)** — POC graph for Crypt of Valdris shipped. `zoneCryptValdrisGraph()` lives in `internal/plugin/zone_graph_crypt_valdris.go` and self-registers at init (8 nodes incl. fork + DC-15 secret reliquary). `startZoneRun` now seeds `current_node = g.Entry` whenever `GOGOBEE_BRANCHING_ZONES=1` and the zone has a hand-authored graph, so new runs traverse the authored topology instead of falling off into `.r1`. `DungeonRun.CurrentRoomType()` resolves via the live `CurrentNode`'s graph kind under the same gate (essential for divergent paths — RoomSeq can't represent secret_chamber). Gate off = pre-G7 behavior bit-identical. 8. Pause for playtest before G8 mass migration. diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index eb11f63..9fc0bf2 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -85,8 +85,24 @@ func (r *DungeonRun) IsActive() bool { } // CurrentRoomType returns the type of the room the player is currently -// standing in (CurrentRoom is 0-indexed). Returns "" if out of range. +// 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. 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.CurrentRoom < 0 || r.CurrentRoom >= len(r.RoomSeq) { return "" } @@ -212,8 +228,16 @@ 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. + // 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. entryNode := deriveLegacyNodeID(zoneID, 0) + if branchingZonesEnabled() { + if g, ok := zoneGraphRegistry[zoneID]; ok { + entryNode = g.Entry + } + } visitedJSON, _ := json.Marshal([]string{entryNode}) run.CurrentNode = entryNode run.VisitedNodes = []string{entryNode} diff --git a/internal/plugin/zone_graph_crypt_valdris.go b/internal/plugin/zone_graph_crypt_valdris.go new file mode 100644 index 0000000..2ae94c0 --- /dev/null +++ b/internal/plugin/zone_graph_crypt_valdris.go @@ -0,0 +1,61 @@ +package plugin + +// Phase G7 — Crypt of Valdris POC graph. +// +// First hand-authored branching graph. Topology per +// gogobee_branching_zones_plan.md §3: +// +// entry → corridor → fork +// ├──[unlocked]── main_hall (elite) ──┐ +// │ ├── antechamber → boss +// └──[Perception DC 12]── side_chapel ─┘ +// │ +// └──[Perception DC 15]── secret_chamber → antechamber +// +// 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{ + {NodeID: "crypt_valdris.entry", Kind: NodeKindEntry, IsEntry: true, + Label: "Crypt Threshold", PosX: 0, PosY: 1}, + {NodeID: "crypt_valdris.corridor", Kind: NodeKindExploration, + Label: "Bone Corridor", PosX: 1, PosY: 1}, + {NodeID: "crypt_valdris.fork", Kind: NodeKindFork, + Label: "Branching Passage", PosX: 2, PosY: 1}, + {NodeID: "crypt_valdris.main_hall", Kind: NodeKindElite, + Label: "Main Hall", PosX: 3, PosY: 0}, + {NodeID: "crypt_valdris.side_chapel", Kind: NodeKindExploration, + Label: "Side Chapel", PosX: 3, PosY: 2}, + {NodeID: "crypt_valdris.secret_chamber", Kind: NodeKindSecret, + Label: "Hidden Reliquary", PosX: 4, PosY: 3, + Content: ZoneNodeContent{LootBias: 2.0}}, + {NodeID: "crypt_valdris.antechamber", Kind: NodeKindMerge, + Label: "Antechamber", PosX: 5, PosY: 1}, + {NodeID: "crypt_valdris.boss", Kind: NodeKindBoss, IsBoss: true, + Label: "Valdris's Sanctum", PosX: 6, PosY: 1}, + } + edges := []ZoneEdge{ + {From: "crypt_valdris.entry", To: "crypt_valdris.corridor", Lock: LockNone}, + {From: "crypt_valdris.corridor", To: "crypt_valdris.fork", Lock: LockNone}, + {From: "crypt_valdris.fork", To: "crypt_valdris.main_hall", Lock: LockNone, Weight: 1}, + {From: "crypt_valdris.fork", To: "crypt_valdris.side_chapel", + Lock: LockPerception, LockData: map[string]any{"dc": 12}, + Hint: "a faint draft from a crack in the wall", Weight: 2}, + {From: "crypt_valdris.main_hall", To: "crypt_valdris.antechamber", Lock: LockNone}, + {From: "crypt_valdris.side_chapel", To: "crypt_valdris.antechamber", Lock: LockNone, Weight: 1}, + {From: "crypt_valdris.side_chapel", To: "crypt_valdris.secret_chamber", + Lock: LockPerception, LockData: map[string]any{"dc": 15}, + Hint: "a faint scratching behind the altar", Weight: 2}, + {From: "crypt_valdris.secret_chamber", To: "crypt_valdris.antechamber", Lock: LockNone}, + {From: "crypt_valdris.antechamber", To: "crypt_valdris.boss", Lock: LockNone}, + } + return BuildGraph(ZoneCryptValdris, nodes, edges) +} + +func init() { + registerZoneGraph(zoneCryptValdrisGraph()) +} diff --git a/internal/plugin/zone_graph_crypt_valdris_test.go b/internal/plugin/zone_graph_crypt_valdris_test.go new file mode 100644 index 0000000..d6624f0 --- /dev/null +++ b/internal/plugin/zone_graph_crypt_valdris_test.go @@ -0,0 +1,138 @@ +package plugin + +import ( + "testing" +) + +// TestCryptValdrisGraph_Registered verifies the POC graph is in the +// registry (init-time registration) so loadZoneGraph returns it instead +// of the legacy linear compile. +func TestCryptValdrisGraph_Registered(t *testing.T) { + g, ok := zoneGraphRegistry[ZoneCryptValdris] + if !ok { + t.Fatal("zoneCryptValdrisGraph not registered") + } + if g.Entry != "crypt_valdris.entry" { + t.Errorf("entry node = %q, want crypt_valdris.entry", g.Entry) + } + if g.Boss != "crypt_valdris.boss" { + t.Errorf("boss node = %q, want crypt_valdris.boss", g.Boss) + } + if len(g.Nodes) != 8 { + t.Errorf("nodes = %d, want 8", len(g.Nodes)) + } +} + +// TestCryptValdrisGraph_BothPathsReachBoss confirms that both the +// main_hall (elite) path and the side_chapel (perception-gated) path +// can reach the boss. Validates G7 design intent: no path is a +// dead-end. +func TestCryptValdrisGraph_BothPathsReachBoss(t *testing.T) { + g := zoneCryptValdrisGraph() + if !reachable(g, "crypt_valdris.main_hall", "crypt_valdris.boss") { + t.Error("main_hall path unreachable to boss") + } + if !reachable(g, "crypt_valdris.side_chapel", "crypt_valdris.boss") { + t.Error("side_chapel path unreachable to boss") + } + if !reachable(g, "crypt_valdris.secret_chamber", "crypt_valdris.boss") { + t.Error("secret_chamber path unreachable to boss") + } +} + +// TestCryptValdrisGraph_ForkLayout asserts the fork has both options +// and that side_chapel is the locked / hinted one. +func TestCryptValdrisGraph_ForkLayout(t *testing.T) { + g := zoneCryptValdrisGraph() + outs := g.outgoingEdges("crypt_valdris.fork") + if len(outs) != 2 { + t.Fatalf("fork outgoing edges = %d, want 2", len(outs)) + } + // outgoingEdges sorts by weight asc — main_hall (weight 1) first. + if outs[0].To != "crypt_valdris.main_hall" || outs[0].Lock != LockNone { + t.Errorf("first edge = %+v, want main_hall/LockNone", outs[0]) + } + if outs[1].To != "crypt_valdris.side_chapel" || outs[1].Lock != LockPerception { + t.Errorf("second edge = %+v, want side_chapel/LockPerception", outs[1]) + } + if outs[1].Hint == "" { + t.Error("side_chapel edge missing player-facing hint") + } +} + +// TestCryptValdrisGraph_SecretGated verifies secret_chamber sits behind +// a higher-DC perception lock and carries a loot bias. +func TestCryptValdrisGraph_SecretGated(t *testing.T) { + g := zoneCryptValdrisGraph() + outs := g.outgoingEdges("crypt_valdris.side_chapel") + var secretEdge *ZoneEdge + for i := range outs { + if outs[i].To == "crypt_valdris.secret_chamber" { + secretEdge = &outs[i] + break + } + } + if secretEdge == nil { + t.Fatal("no edge from side_chapel to secret_chamber") + } + if secretEdge.Lock != LockPerception { + t.Errorf("secret edge lock = %s, want perception", secretEdge.Lock) + } + if dc := lockDataInt(secretEdge.LockData, "dc", 0); dc < 15 { + t.Errorf("secret DC = %d, want >= 15", dc) + } + secret := g.Nodes["crypt_valdris.secret_chamber"] + if secret.Content.LootBias < 1.5 { + t.Errorf("secret LootBias = %v, want >= 1.5 (cruel without loot)", secret.Content.LootBias) + } +} + +// 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. +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. + run := &DungeonRun{ + ZoneID: ZoneCryptValdris, + CurrentRoom: 2, + CurrentNode: "crypt_valdris.main_hall", + RoomSeq: []RoomType{RoomEntry, RoomExploration, RoomTrap, RoomElite, RoomBoss}, + } + if got := run.CurrentRoomType(); got != RoomElite { + t.Errorf("graph mode + main_hall: got %s, want %s", got, RoomElite) + } + + run.CurrentNode = "crypt_valdris.boss" + if got := run.CurrentRoomType(); got != RoomBoss { + t.Errorf("graph mode + boss node: got %s, want %s", got, RoomBoss) + } + + run.CurrentNode = "crypt_valdris.secret_chamber" + // Secret nodes flatten to RoomExploration for legacy resolveRoom + // dispatch (per nodeKindToRoomType — no separate Secret RoomType yet). + if got := run.CurrentRoomType(); got != RoomExploration { + t.Errorf("graph mode + secret node: got %s, want %s", got, RoomExploration) + } +} + +// 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) + } +}