diff --git a/internal/plugin/zone_graph_abyss_portal.go b/internal/plugin/zone_graph_abyss_portal.go new file mode 100644 index 0000000..f815db1 --- /dev/null +++ b/internal/plugin/zone_graph_abyss_portal.go @@ -0,0 +1,92 @@ +package plugin + +// Phase G8h — The Abyss Portal branching graph. +// +// T5 zone. Shape: three sequential forks at increasing depth — binary, +// binary, ternary capstone. The player makes more decisions in this +// zone than in any other (3 separate fork prompts), matching the +// "things keep getting worse" thematic descent. +// +// entry → fractured_threshold → fork1 (binary) +// ├─[unlocked]── burning_wastes ─┐ +// └─[Perception DC 16]── silent_chambers ─┤ +// ↓ +// fork2_a (binary) +// ├─[unlocked]── vrock_aerie (elite marilith) ─┐ +// └─[CON DC 16]── mind_corridor ───────────────┤ +// ↓ +// fork3 (3-way capstone) +// ├─[unlocked]── direct_assault → boss +// ├─[CHA DC 18]── usurper_throne → boss +// └─[Perception DC 18]── reality_seam (secret) → boss +// +// First authored zone with three sequential forks AND first use of +// LockStatCheck CON — completing the full ability roster (STR/DEX/ +// CON/INT/WIS/CHA all appear in shipping zones by G8h). reality_seam +// LootBias 3.0 reflects the Abyss capstone secret. + +func zoneAbyssPortalGraph() ZoneGraph { + nodes := []ZoneNode{ + {NodeID: "abyss_portal.entry", Kind: NodeKindEntry, IsEntry: true, + Label: "The Open Door", PosX: 0, PosY: 2}, + {NodeID: "abyss_portal.fractured_threshold", Kind: NodeKindExploration, + Label: "Fractured Threshold", PosX: 1, PosY: 2}, + {NodeID: "abyss_portal.fork1", Kind: NodeKindFork, + Label: "First Reality-Break", PosX: 2, PosY: 2}, + {NodeID: "abyss_portal.burning_wastes", Kind: NodeKindExploration, + Label: "Burning Wastes", PosX: 3, PosY: 1}, + {NodeID: "abyss_portal.silent_chambers", Kind: NodeKindExploration, + Label: "Silent Chambers", PosX: 3, PosY: 3}, + {NodeID: "abyss_portal.fork2", Kind: NodeKindFork, + Label: "Second Reality-Break", PosX: 4, PosY: 2}, + {NodeID: "abyss_portal.vrock_aerie", Kind: NodeKindElite, + Label: "Marilith's Aerie", PosX: 5, PosY: 1}, + {NodeID: "abyss_portal.mind_corridor", Kind: NodeKindExploration, + Label: "Corridor of Whispers", PosX: 5, PosY: 3}, + {NodeID: "abyss_portal.fork3", Kind: NodeKindFork, + Label: "Third Reality-Break", PosX: 6, PosY: 2}, + {NodeID: "abyss_portal.direct_assault", Kind: NodeKindExploration, + Label: "Direct Assault", PosX: 7, PosY: 1}, + {NodeID: "abyss_portal.usurper_throne", Kind: NodeKindExploration, + Label: "Usurper's Approach", PosX: 7, PosY: 2}, + {NodeID: "abyss_portal.reality_seam", Kind: NodeKindSecret, + Label: "Reality Seam", PosX: 7, PosY: 3, + Content: ZoneNodeContent{LootBias: 3.0}}, + {NodeID: "abyss_portal.boss", Kind: NodeKindBoss, IsBoss: true, + Label: "Belaxath's Throne", PosX: 8, PosY: 2}, + } + edges := []ZoneEdge{ + {From: "abyss_portal.entry", To: "abyss_portal.fractured_threshold", Lock: LockNone}, + {From: "abyss_portal.fractured_threshold", To: "abyss_portal.fork1", Lock: LockNone}, + // Fork1 — perception side path. + {From: "abyss_portal.fork1", To: "abyss_portal.burning_wastes", Lock: LockNone, Weight: 1}, + {From: "abyss_portal.fork1", To: "abyss_portal.silent_chambers", + Lock: LockPerception, LockData: map[string]any{"dc": 16}, + Hint: "a silence so complete you can hear your own pulse — and something else's", Weight: 2}, + {From: "abyss_portal.burning_wastes", To: "abyss_portal.fork2", Lock: LockNone}, + {From: "abyss_portal.silent_chambers", To: "abyss_portal.fork2", Lock: LockNone}, + // Fork2 — CON wall (psychic pressure). + {From: "abyss_portal.fork2", To: "abyss_portal.vrock_aerie", Lock: LockNone, Weight: 1}, + {From: "abyss_portal.fork2", To: "abyss_portal.mind_corridor", + Lock: LockStatCheck, LockData: map[string]any{"stat": "CON", "dc": 16}, + Hint: "the air thickens — your bones know the wrong direction is also down", Weight: 2}, + {From: "abyss_portal.vrock_aerie", To: "abyss_portal.fork3", Lock: LockNone}, + {From: "abyss_portal.mind_corridor", To: "abyss_portal.fork3", Lock: LockNone}, + // Fork3 — capstone 3-way. + {From: "abyss_portal.fork3", To: "abyss_portal.direct_assault", Lock: LockNone, Weight: 1}, + {From: "abyss_portal.fork3", To: "abyss_portal.usurper_throne", + Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 18}, + Hint: "Belaxath has not noticed you yet — claim authority before he does", Weight: 2}, + {From: "abyss_portal.fork3", To: "abyss_portal.reality_seam", + Lock: LockPerception, LockData: map[string]any{"dc": 18}, + Hint: "a hairline crack in the air itself — somewhere there is a place that is not here", Weight: 3}, + {From: "abyss_portal.direct_assault", To: "abyss_portal.boss", Lock: LockNone}, + {From: "abyss_portal.usurper_throne", To: "abyss_portal.boss", Lock: LockNone}, + {From: "abyss_portal.reality_seam", To: "abyss_portal.boss", Lock: LockNone}, + } + return BuildGraph(ZoneAbyssPortal, nodes, edges) +} + +func init() { + registerZoneGraph(zoneAbyssPortalGraph()) +} diff --git a/internal/plugin/zone_graph_abyss_portal_test.go b/internal/plugin/zone_graph_abyss_portal_test.go new file mode 100644 index 0000000..bcd64e0 --- /dev/null +++ b/internal/plugin/zone_graph_abyss_portal_test.go @@ -0,0 +1,70 @@ +package plugin + +import "testing" + +func TestAbyssPortalGraph_Registered(t *testing.T) { + g, ok := zoneGraphRegistry[ZoneAbyssPortal] + if !ok { + t.Fatal("zoneAbyssPortalGraph not registered") + } + if len(g.Nodes) != 13 { + t.Errorf("nodes = %d, want 13", len(g.Nodes)) + } +} + +// TestAbyssPortalGraph_ThreeSequentialForks confirms the design shape: +// three fork nodes in series, each binary or ternary. +func TestAbyssPortalGraph_ThreeSequentialForks(t *testing.T) { + g := zoneAbyssPortalGraph() + wants := map[string]int{ + "abyss_portal.fork1": 2, + "abyss_portal.fork2": 2, + "abyss_portal.fork3": 3, + } + for id, want := range wants { + if got := len(g.outgoingEdges(id)); got != want { + t.Errorf("%s outgoing = %d, want %d", id, got, want) + } + } +} + +func TestAbyssPortalGraph_AllCapstoneLeavesReachBoss(t *testing.T) { + g := zoneAbyssPortalGraph() + for _, leaf := range []string{ + "abyss_portal.direct_assault", + "abyss_portal.usurper_throne", + "abyss_portal.reality_seam", + } { + if !reachable(g, leaf, "abyss_portal.boss") { + t.Errorf("%s unreachable to boss", leaf) + } + } +} + +// TestAbyssPortalGraph_FullStatRosterCoverage confirms the +// project-wide claim: by G8h, all six abilities (STR/DEX/CON/INT/WIS/ +// CHA) appear as authored stat-check locks across shipping zones. +// CON is the missing one prior to this zone — locked here on +// mind_corridor. +func TestAbyssPortalGraph_FullStatRosterCoverage(t *testing.T) { + g := zoneAbyssPortalGraph() + conSeen := false + for _, outs := range g.Edges { + for _, e := range outs { + if e.Lock == LockStatCheck && lockDataString(e.LockData, "stat") == "CON" { + conSeen = true + } + } + } + if !conSeen { + t.Error("expected at least one CON stat-check edge — completes ability roster by G8h") + } +} + +func TestAbyssPortalGraph_RealitySeamHighestBias(t *testing.T) { + g := zoneAbyssPortalGraph() + seam := g.Nodes["abyss_portal.reality_seam"] + if seam.Content.LootBias < 3.0 { + t.Errorf("reality_seam LootBias = %v, want >= 3.0 (Abyss capstone)", seam.Content.LootBias) + } +}