diff --git a/internal/plugin/zone_graph_sunken_temple.go b/internal/plugin/zone_graph_sunken_temple.go new file mode 100644 index 0000000..f3891e2 --- /dev/null +++ b/internal/plugin/zone_graph_sunken_temple.go @@ -0,0 +1,71 @@ +package plugin + +// Phase G8c — Sunken Temple of Dar'eth branching graph. +// +// T2 zone. Shape: sequential forks (no mid-path merge). Four distinct +// paths to the boss; the player commits at fork1 (dry vs. wet) and +// commits again at fork2a/fork2b. Path is unique up to the boss room. +// +// entry → flooded_atrium → fork1 +// ├─[unlocked, w=1]── fork2a (dry) +// │ ├─[unlocked]── kuo_toa_pen (elite) → boss +// │ └─[STR DC 13]── glyph_chamber → boss +// └─[Perception DC 13, w=2]── fork2b (wet) +// ├─[unlocked]── aboleth_thralls → boss +// └─[Perception DC 15]── coral_reliquary (secret) → boss +// +// Distinct from the Crypt diamond and the Forest asymmetric-diamond: +// no two paths share a mid-node, so the !zone map paints two parallel +// "Y" subtrees instead of a converging branch. The four leaves all +// terminate at the boss (validator requires exactly one boss node). + +func zoneSunkenTempleGraph() ZoneGraph { + nodes := []ZoneNode{ + {NodeID: "sunken_temple.entry", Kind: NodeKindEntry, IsEntry: true, + Label: "Tide-Stained Threshold", PosX: 0, PosY: 2}, + {NodeID: "sunken_temple.flooded_atrium", Kind: NodeKindExploration, + Label: "Flooded Atrium", PosX: 1, PosY: 2}, + {NodeID: "sunken_temple.fork1", Kind: NodeKindFork, + Label: "Split Stair", PosX: 2, PosY: 2}, + {NodeID: "sunken_temple.fork2a", Kind: NodeKindFork, + Label: "Dry Crossing", PosX: 3, PosY: 0}, + {NodeID: "sunken_temple.fork2b", Kind: NodeKindFork, + Label: "Submerged Crossing", PosX: 3, PosY: 4}, + {NodeID: "sunken_temple.kuo_toa_pen", Kind: NodeKindElite, + Label: "Kuo-toa Pen", PosX: 4, PosY: 0}, + {NodeID: "sunken_temple.glyph_chamber", Kind: NodeKindExploration, + Label: "Glyph Chamber", PosX: 4, PosY: 1}, + {NodeID: "sunken_temple.aboleth_thralls", Kind: NodeKindExploration, + Label: "Thrall Pool", PosX: 4, PosY: 3}, + {NodeID: "sunken_temple.coral_reliquary", Kind: NodeKindSecret, + Label: "Coral Reliquary", PosX: 4, PosY: 4, + Content: ZoneNodeContent{LootBias: 1.8}}, + {NodeID: "sunken_temple.boss", Kind: NodeKindBoss, IsBoss: true, + Label: "Aboleth's Pool", PosX: 5, PosY: 2}, + } + edges := []ZoneEdge{ + {From: "sunken_temple.entry", To: "sunken_temple.flooded_atrium", Lock: LockNone}, + {From: "sunken_temple.flooded_atrium", To: "sunken_temple.fork1", Lock: LockNone}, + {From: "sunken_temple.fork1", To: "sunken_temple.fork2a", Lock: LockNone, Weight: 1}, + {From: "sunken_temple.fork1", To: "sunken_temple.fork2b", + Lock: LockPerception, LockData: map[string]any{"dc": 13}, + Hint: "wet stone glistens down a side passage", Weight: 2}, + {From: "sunken_temple.fork2a", To: "sunken_temple.kuo_toa_pen", Lock: LockNone, Weight: 1}, + {From: "sunken_temple.fork2a", To: "sunken_temple.glyph_chamber", + Lock: LockStatCheck, LockData: map[string]any{"stat": "STR", "dc": 13}, + Hint: "a stone door wedged half-shut by silt", Weight: 2}, + {From: "sunken_temple.fork2b", To: "sunken_temple.aboleth_thralls", Lock: LockNone, Weight: 1}, + {From: "sunken_temple.fork2b", To: "sunken_temple.coral_reliquary", + Lock: LockPerception, LockData: map[string]any{"dc": 15}, + Hint: "a coral arch glittering under the surface", Weight: 2}, + {From: "sunken_temple.kuo_toa_pen", To: "sunken_temple.boss", Lock: LockNone}, + {From: "sunken_temple.glyph_chamber", To: "sunken_temple.boss", Lock: LockNone}, + {From: "sunken_temple.aboleth_thralls", To: "sunken_temple.boss", Lock: LockNone}, + {From: "sunken_temple.coral_reliquary", To: "sunken_temple.boss", Lock: LockNone}, + } + return BuildGraph(ZoneSunkenTemple, nodes, edges) +} + +func init() { + registerZoneGraph(zoneSunkenTempleGraph()) +} diff --git a/internal/plugin/zone_graph_sunken_temple_test.go b/internal/plugin/zone_graph_sunken_temple_test.go new file mode 100644 index 0000000..329ecf4 --- /dev/null +++ b/internal/plugin/zone_graph_sunken_temple_test.go @@ -0,0 +1,80 @@ +package plugin + +import "testing" + +func TestSunkenTempleGraph_Registered(t *testing.T) { + g, ok := zoneGraphRegistry[ZoneSunkenTemple] + if !ok { + t.Fatal("zoneSunkenTempleGraph not registered") + } + if g.Entry != "sunken_temple.entry" { + t.Errorf("entry node = %q", g.Entry) + } + if g.Boss != "sunken_temple.boss" { + t.Errorf("boss node = %q", g.Boss) + } + if len(g.Nodes) != 10 { + t.Errorf("nodes = %d, want 10", len(g.Nodes)) + } +} + +// TestSunkenTempleGraph_FourDistinctPaths verifies the four leaves +// (kuo_toa_pen, glyph_chamber, aboleth_thralls, coral_reliquary) all +// reach the boss. Together with the no-merge invariant below, this +// confirms the design intent: four committed paths, not a diamond. +func TestSunkenTempleGraph_FourDistinctPaths(t *testing.T) { + g := zoneSunkenTempleGraph() + for _, leaf := range []string{ + "sunken_temple.kuo_toa_pen", + "sunken_temple.glyph_chamber", + "sunken_temple.aboleth_thralls", + "sunken_temple.coral_reliquary", + } { + if !reachable(g, leaf, "sunken_temple.boss") { + t.Errorf("%s unreachable to boss", leaf) + } + } +} + +// TestSunkenTempleGraph_NoMidPathMerge ensures no node other than the +// boss has more than one incoming edge. If a future edit collapses the +// two subtrees into a converging diamond, this test fails — that's the +// signal to either rename the zone's shape comment or re-author. +func TestSunkenTempleGraph_NoMidPathMerge(t *testing.T) { + g := zoneSunkenTempleGraph() + incoming := map[string]int{} + for _, outs := range g.Edges { + for _, e := range outs { + incoming[e.To]++ + } + } + for id, n := range g.Nodes { + if n.IsBoss { + continue + } + if incoming[id] > 1 { + t.Errorf("node %q has %d incoming edges, want ≤1 (no mid-path merge)", id, incoming[id]) + } + } + // Boss intentionally has 4 incoming edges (one per leaf). + if incoming["sunken_temple.boss"] != 4 { + t.Errorf("boss incoming = %d, want 4", incoming["sunken_temple.boss"]) + } +} + +func TestSunkenTempleGraph_LockKindCoverage(t *testing.T) { + g := zoneSunkenTempleGraph() + kinds := map[ZoneEdgeLockKind]bool{} + for _, outs := range g.Edges { + for _, e := range outs { + kinds[e.Lock] = true + } + } + // Sunken Temple deliberately exercises Perception + StatCheck + + // LockNone in one zone. + for _, k := range []ZoneEdgeLockKind{LockNone, LockPerception, LockStatCheck} { + if !kinds[k] { + t.Errorf("missing lock kind %s — variety check", k) + } + } +}