Branching zones G8i: Underdark convergent triangle (multi-region)

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) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 17:03:21 -07:00
parent 122a3d7d5d
commit 304ad27c0f
2 changed files with 182 additions and 0 deletions

View File

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

View File

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