From 304ad27c0f3d05e44ea86710e79ab57c99efffd8 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 9 May 2026 17:03:21 -0700 Subject: [PATCH] Branching zones G8i: Underdark convergent triangle (multi-region) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T4 zone, ninth (final) authored graph. Shape: 3-way regional fork where each arm crosses a different region boundary, all converging at R4 (deep_throne). First and only authored zone where every node carries a non-empty RegionID matching dnd_expedition_region.go. Three arms from fork1: - R1→R2 drow_patrol → drow_captain (elite) - R1→R3 psionic_corridor → mind_flayer (elite) - R1→R1 deep_chasm (CON-gated harvest, stays in surface_tunnels) …all converging at R4 throne_approach → boss. Exercises G6 fireGraphRegionTransition end-to-end: edges crossing region boundaries fire the transition hook (updates Expedition .CurrentRegion, marks region visited, writes a transit log entry) without burning supplies or advancing the day, since the graph is the run state. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/zone_graph_underdark.go | 85 +++++++++++++++++ internal/plugin/zone_graph_underdark_test.go | 97 ++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 internal/plugin/zone_graph_underdark.go create mode 100644 internal/plugin/zone_graph_underdark_test.go diff --git a/internal/plugin/zone_graph_underdark.go b/internal/plugin/zone_graph_underdark.go new file mode 100644 index 0000000..9422198 --- /dev/null +++ b/internal/plugin/zone_graph_underdark.go @@ -0,0 +1,85 @@ +package plugin + +// Phase G8i — The Underdark branching graph (multi-region). +// +// T4 zone. Shape: 3-way regional fork — fork1 routes the player into +// one of three distinct regions (R1 deep, R2 drow, R3 illithid), each +// with its own internal mid-node, all converging at R4 (deep_throne). +// +// R1 surface_tunnels: entry → tunnel_descent → fork1 +// ┌─[CON DC 15]── deep_chasm (R1, rich harvest) ──────────┐ +// │ │ +// ├─[unlocked]── drow_patrol (R2) → drow_captain (R2 elite)─┤ +// │ ├── throne_approach (R4) → boss (R4) +// └─[Perception DC 16]── psionic_corridor (R3) → mind_flayer (R3 elite)─┘ +// +// Each colored arm crosses a region boundary, exercising the G6 +// fireGraphRegionTransition hook end-to-end. The four authored regions +// match dnd_expedition_region.go: +// R1 = underdark_surface_tunnels +// R2 = underdark_drow_outpost +// R3 = underdark_illithid_warren +// R4 = underdark_deep_throne +// +// Distinct from prior zones in two ways: +// 1. First (and only) authored zone with non-empty RegionID on every +// node. Region transitions fire on fork1→{drow_patrol|psionic_corridor} +// and on the elite→throne_approach edges. +// 2. Convergent triangle: three colored arms, one merge. + +func zoneUnderdarkGraph() ZoneGraph { + r1 := "underdark_surface_tunnels" + r2 := "underdark_drow_outpost" + r3 := "underdark_illithid_warren" + r4 := "underdark_deep_throne" + + nodes := []ZoneNode{ + {NodeID: "underdark.entry", Kind: NodeKindEntry, IsEntry: true, RegionID: r1, + Label: "Cave Mouth", PosX: 0, PosY: 2}, + {NodeID: "underdark.tunnel_descent", Kind: NodeKindExploration, RegionID: r1, + Label: "Tunnel Descent", PosX: 1, PosY: 2}, + {NodeID: "underdark.fork1", Kind: NodeKindFork, RegionID: r1, + Label: "Three-Way Pass", PosX: 2, PosY: 2}, + {NodeID: "underdark.deep_chasm", Kind: NodeKindHarvest, RegionID: r1, + Label: "Deep Chasm", PosX: 3, PosY: 2, + Content: ZoneNodeContent{LootBias: 1.8}}, + {NodeID: "underdark.drow_patrol", Kind: NodeKindExploration, RegionID: r2, + Label: "Drow Patrol", PosX: 3, PosY: 1}, + {NodeID: "underdark.drow_captain", Kind: NodeKindElite, RegionID: r2, + Label: "Drow Captain's Camp", PosX: 4, PosY: 1}, + {NodeID: "underdark.psionic_corridor", Kind: NodeKindExploration, RegionID: r3, + Label: "Psionic Corridor", PosX: 3, PosY: 3}, + {NodeID: "underdark.mind_flayer", Kind: NodeKindElite, RegionID: r3, + Label: "Mind Flayer Elder", PosX: 4, PosY: 3}, + {NodeID: "underdark.throne_approach", Kind: NodeKindMerge, RegionID: r4, + Label: "Throne Approach", PosX: 5, PosY: 2}, + {NodeID: "underdark.boss", Kind: NodeKindBoss, IsBoss: true, RegionID: r4, + Label: "Deep Throne", PosX: 6, PosY: 2}, + } + edges := []ZoneEdge{ + {From: "underdark.entry", To: "underdark.tunnel_descent", Lock: LockNone}, + {From: "underdark.tunnel_descent", To: "underdark.fork1", Lock: LockNone}, + // Fork1 — three regional arms. + {From: "underdark.fork1", To: "underdark.drow_patrol", Lock: LockNone, Weight: 1}, + {From: "underdark.fork1", To: "underdark.psionic_corridor", + Lock: LockPerception, LockData: map[string]any{"dc": 16}, + Hint: "a faint psionic pulse — somewhere a mind is broadcasting", Weight: 2}, + {From: "underdark.fork1", To: "underdark.deep_chasm", + Lock: LockStatCheck, LockData: map[string]any{"stat": "CON", "dc": 15}, + Hint: "a vertical shaft humming with cold air — the climb will hurt", Weight: 3}, + // R2 arm. + {From: "underdark.drow_patrol", To: "underdark.drow_captain", Lock: LockNone}, + {From: "underdark.drow_captain", To: "underdark.throne_approach", Lock: LockNone}, + // R3 arm. + {From: "underdark.psionic_corridor", To: "underdark.mind_flayer", Lock: LockNone}, + {From: "underdark.mind_flayer", To: "underdark.throne_approach", Lock: LockNone}, + // R1 deep arm — single node back to merge. + {From: "underdark.deep_chasm", To: "underdark.throne_approach", Lock: LockNone}, + {From: "underdark.throne_approach", To: "underdark.boss", Lock: LockNone}, + } + return BuildGraph(ZoneUnderdark, nodes, edges) +} + +func init() { + registerZoneGraph(zoneUnderdarkGraph()) +} diff --git a/internal/plugin/zone_graph_underdark_test.go b/internal/plugin/zone_graph_underdark_test.go new file mode 100644 index 0000000..0ce61b4 --- /dev/null +++ b/internal/plugin/zone_graph_underdark_test.go @@ -0,0 +1,97 @@ +package plugin + +import "testing" + +func TestUnderdarkGraph_Registered(t *testing.T) { + g, ok := zoneGraphRegistry[ZoneUnderdark] + if !ok { + t.Fatal("zoneUnderdarkGraph not registered") + } + if len(g.Nodes) != 10 { + t.Errorf("nodes = %d, want 10", len(g.Nodes)) + } +} + +// TestUnderdarkGraph_AllNodesHaveRegion confirms Underdark is the +// canonical multi-region zone — every node carries a non-empty RegionID +// matching the dnd_expedition_region.go registry. +func TestUnderdarkGraph_AllNodesHaveRegion(t *testing.T) { + g := zoneUnderdarkGraph() + validRegions := map[string]bool{ + "underdark_surface_tunnels": true, + "underdark_drow_outpost": true, + "underdark_illithid_warren": true, + "underdark_deep_throne": true, + } + for id, n := range g.Nodes { + if n.RegionID == "" { + t.Errorf("node %s has empty RegionID — Underdark requires region authoring on every node", id) + } + if !validRegions[n.RegionID] { + t.Errorf("node %s RegionID = %q, not in dnd_expedition_region.go registry", id, n.RegionID) + } + } +} + +// TestUnderdarkGraph_AllFourRegionsRepresented confirms each authored +// region has at least one node on the canonical entry→boss path. +func TestUnderdarkGraph_AllFourRegionsRepresented(t *testing.T) { + g := zoneUnderdarkGraph() + regions := map[string]int{} + for _, n := range g.Nodes { + regions[n.RegionID]++ + } + for _, r := range []string{ + "underdark_surface_tunnels", + "underdark_drow_outpost", + "underdark_illithid_warren", + "underdark_deep_throne", + } { + if regions[r] == 0 { + t.Errorf("region %q has no nodes — multi-region invariant broken", r) + } + } +} + +// TestUnderdarkGraph_ForkCrossesRegionBoundaries verifies the fork +// edges actually traverse region boundaries (this is what makes the +// G6 fireGraphRegionTransition hook fire end-to-end on this zone). +func TestUnderdarkGraph_ForkCrossesRegionBoundaries(t *testing.T) { + g := zoneUnderdarkGraph() + fork := g.Nodes["underdark.fork1"] + if fork.RegionID != "underdark_surface_tunnels" { + t.Fatalf("fork1 region = %q, want surface_tunnels", fork.RegionID) + } + for _, e := range g.outgoingEdges("underdark.fork1") { + toNode := g.Nodes[e.To] + switch e.To { + case "underdark.drow_patrol": + if toNode.RegionID != "underdark_drow_outpost" { + t.Errorf("drow_patrol region = %q", toNode.RegionID) + } + case "underdark.psionic_corridor": + if toNode.RegionID != "underdark_illithid_warren" { + t.Errorf("psionic_corridor region = %q", toNode.RegionID) + } + case "underdark.deep_chasm": + // Deep_chasm intentionally stays in R1 (surface_tunnels) — the + // vertical-shaft path explores R1 deeper before joining R4. + if toNode.RegionID != "underdark_surface_tunnels" { + t.Errorf("deep_chasm region = %q, want surface_tunnels (intentional R1 stay)", toNode.RegionID) + } + } + } +} + +func TestUnderdarkGraph_AllArmsReachBoss(t *testing.T) { + g := zoneUnderdarkGraph() + for _, leaf := range []string{ + "underdark.drow_captain", + "underdark.mind_flayer", + "underdark.deep_chasm", + } { + if !reachable(g, leaf, "underdark.boss") { + t.Errorf("%s unreachable to boss", leaf) + } + } +}