mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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:
85
internal/plugin/zone_graph_underdark.go
Normal file
85
internal/plugin/zone_graph_underdark.go
Normal 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())
|
||||
}
|
||||
97
internal/plugin/zone_graph_underdark_test.go
Normal file
97
internal/plugin/zone_graph_underdark_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user