From ea59b4e61c8d06b33b6b4f5ea8deece36a8b97e9 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 9 May 2026 16:51:19 -0700 Subject: [PATCH] Branching zones G8b: Forest of Shadows asymmetric fork + stat-check secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2 zone, second authored graph. Deliberately diverges from the diamond shape so the !zone map renders distinctly: - Asymmetric main fork: long branch (grove_descent → dryad_circle elite, 2 mid-nodes) vs. short branch (thorn_tunnel, 1 mid-node, Perception DC 13 hint). Tests lock in the asymmetry. - Pre-boss fork to a sapling_shrine secret behind LockStatCheck WIS DC 14 (LootBias 2.0). First authored zone exercising stat_check — Crypt's secret was Perception, so this validates the second lock kind end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/zone_graph_forest_shadows.go | 69 ++++++++++ .../plugin/zone_graph_forest_shadows_test.go | 123 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 internal/plugin/zone_graph_forest_shadows.go create mode 100644 internal/plugin/zone_graph_forest_shadows_test.go diff --git a/internal/plugin/zone_graph_forest_shadows.go b/internal/plugin/zone_graph_forest_shadows.go new file mode 100644 index 0000000..88f07c2 --- /dev/null +++ b/internal/plugin/zone_graph_forest_shadows.go @@ -0,0 +1,69 @@ +package plugin + +// Phase G8b — Forest of Shadows branching graph. +// +// T2 zone. Per plan §G8 ("two forks for T2+") and the G8 design +// session: deliberately a different shape from the Crypt diamond. +// +// Shape: asymmetric main fork + WIS-gated secret pre-boss. +// +// entry → twisted_path → fork1 +// ├─[unlocked, w=1]── grove_descent → dryad_circle (elite) ─┐ +// │ ├── fork2 +// └─[Perception DC 13, w=2]── thorn_tunnel ──────────────────┘ +// │ +// ┌─[unlocked, w=1]── boss ────────────────┘ +// │ +// └─[WIS DC 14, w=2]── sapling_shrine (secret) → boss +// +// Asymmetry: long branch is two mid-nodes (grove_descent → dryad_circle +// (elite)), short branch is one (thorn_tunnel). Map renders the long +// branch as a "+1 column" arm so the topology reads at a glance. +// Secret uses LockStatCheck WIS (not Perception) to exercise the +// stat-check lock kind — Crypt's secret is Perception, so this is the +// first authored zone using stat_check. + +func zoneForestShadowsGraph() ZoneGraph { + nodes := []ZoneNode{ + {NodeID: "forest_shadows.entry", Kind: NodeKindEntry, IsEntry: true, + Label: "Forest Edge", PosX: 0, PosY: 1}, + {NodeID: "forest_shadows.twisted_path", Kind: NodeKindExploration, + Label: "Twisted Path", PosX: 1, PosY: 1}, + {NodeID: "forest_shadows.fork1", Kind: NodeKindFork, + Label: "Splintered Trail", PosX: 2, PosY: 1}, + {NodeID: "forest_shadows.grove_descent", Kind: NodeKindExploration, + Label: "Grove Descent", PosX: 3, PosY: 0}, + {NodeID: "forest_shadows.dryad_circle", Kind: NodeKindElite, + Label: "Dryad's Circle", PosX: 4, PosY: 0}, + {NodeID: "forest_shadows.thorn_tunnel", Kind: NodeKindExploration, + Label: "Thorn Tunnel", PosX: 3, PosY: 2}, + {NodeID: "forest_shadows.fork2", Kind: NodeKindFork, + Label: "Hollow King's Approach", PosX: 5, PosY: 1}, + {NodeID: "forest_shadows.sapling_shrine", Kind: NodeKindSecret, + Label: "Sapling Shrine", PosX: 6, PosY: 2, + Content: ZoneNodeContent{LootBias: 2.0}}, + {NodeID: "forest_shadows.boss", Kind: NodeKindBoss, IsBoss: true, + Label: "Throne of Antlers", PosX: 7, PosY: 1}, + } + edges := []ZoneEdge{ + {From: "forest_shadows.entry", To: "forest_shadows.twisted_path", Lock: LockNone}, + {From: "forest_shadows.twisted_path", To: "forest_shadows.fork1", Lock: LockNone}, + {From: "forest_shadows.fork1", To: "forest_shadows.grove_descent", Lock: LockNone, Weight: 1}, + {From: "forest_shadows.fork1", To: "forest_shadows.thorn_tunnel", + Lock: LockPerception, LockData: map[string]any{"dc": 13}, + Hint: "a deer-track winding into thicker brush", Weight: 2}, + {From: "forest_shadows.grove_descent", To: "forest_shadows.dryad_circle", Lock: LockNone}, + {From: "forest_shadows.dryad_circle", To: "forest_shadows.fork2", Lock: LockNone}, + {From: "forest_shadows.thorn_tunnel", To: "forest_shadows.fork2", Lock: LockNone}, + {From: "forest_shadows.fork2", To: "forest_shadows.boss", Lock: LockNone, Weight: 1}, + {From: "forest_shadows.fork2", To: "forest_shadows.sapling_shrine", + Lock: LockStatCheck, LockData: map[string]any{"stat": "WIS", "dc": 14}, + Hint: "a humming you can almost place — antlers, somewhere", Weight: 2}, + {From: "forest_shadows.sapling_shrine", To: "forest_shadows.boss", Lock: LockNone}, + } + return BuildGraph(ZoneForestShadows, nodes, edges) +} + +func init() { + registerZoneGraph(zoneForestShadowsGraph()) +} diff --git a/internal/plugin/zone_graph_forest_shadows_test.go b/internal/plugin/zone_graph_forest_shadows_test.go new file mode 100644 index 0000000..1fe7a4a --- /dev/null +++ b/internal/plugin/zone_graph_forest_shadows_test.go @@ -0,0 +1,123 @@ +package plugin + +import "testing" + +func TestForestShadowsGraph_Registered(t *testing.T) { + g, ok := zoneGraphRegistry[ZoneForestShadows] + if !ok { + t.Fatal("zoneForestShadowsGraph not registered") + } + if g.Entry != "forest_shadows.entry" { + t.Errorf("entry node = %q, want forest_shadows.entry", g.Entry) + } + if g.Boss != "forest_shadows.boss" { + t.Errorf("boss node = %q, want forest_shadows.boss", g.Boss) + } + if len(g.Nodes) != 9 { + t.Errorf("nodes = %d, want 9", len(g.Nodes)) + } +} + +func TestForestShadowsGraph_AllPathsReachBoss(t *testing.T) { + g := zoneForestShadowsGraph() + for _, mid := range []string{ + "forest_shadows.grove_descent", + "forest_shadows.dryad_circle", + "forest_shadows.thorn_tunnel", + "forest_shadows.fork2", + "forest_shadows.sapling_shrine", + } { + if !reachable(g, mid, "forest_shadows.boss") { + t.Errorf("%s unreachable to boss", mid) + } + } +} + +// TestForestShadowsGraph_AsymmetricMainFork captures the design intent: +// the long branch is grove_descent → dryad_circle (2 mid-nodes), the +// short branch is thorn_tunnel (1 mid-node). If a future edit +// accidentally makes the branches the same length, this test fails so +// the divergence from the diamond shape is preserved. +func TestForestShadowsGraph_AsymmetricMainFork(t *testing.T) { + g := zoneForestShadowsGraph() + longLen := bfsHops(g, "forest_shadows.fork1", "forest_shadows.fork2", "forest_shadows.grove_descent") + shortLen := bfsHops(g, "forest_shadows.fork1", "forest_shadows.fork2", "forest_shadows.thorn_tunnel") + if longLen != 3 { + t.Errorf("long branch hops = %d, want 3 (grove_descent → dryad_circle → fork2)", longLen) + } + if shortLen != 2 { + t.Errorf("short branch hops = %d, want 2 (thorn_tunnel → fork2)", shortLen) + } + if longLen <= shortLen { + t.Errorf("expected asymmetric branches (long > short); got long=%d short=%d", longLen, shortLen) + } +} + +// TestForestShadowsGraph_SecretIsStatCheck verifies the secret uses +// LockStatCheck (WIS) rather than LockPerception — Forest of Shadows +// is the first authored zone exercising stat_check, so a regression +// here would silently degrade to "another perception zone". +func TestForestShadowsGraph_SecretIsStatCheck(t *testing.T) { + g := zoneForestShadowsGraph() + outs := g.outgoingEdges("forest_shadows.fork2") + var secretEdge *ZoneEdge + for i := range outs { + if outs[i].To == "forest_shadows.sapling_shrine" { + secretEdge = &outs[i] + break + } + } + if secretEdge == nil { + t.Fatal("no edge from fork2 to sapling_shrine") + } + if secretEdge.Lock != LockStatCheck { + t.Errorf("secret edge lock = %s, want stat_check", secretEdge.Lock) + } + if stat := lockDataString(secretEdge.LockData, "stat"); stat != "WIS" { + t.Errorf("stat = %q, want WIS", stat) + } + if dc := lockDataInt(secretEdge.LockData, "dc", 0); dc < 14 { + t.Errorf("DC = %d, want >= 14", dc) + } + if secretEdge.Hint == "" { + t.Error("secret edge missing hint") + } + secret := g.Nodes["forest_shadows.sapling_shrine"] + if secret.Content.LootBias < 1.5 { + t.Errorf("secret LootBias = %v, want >= 1.5", secret.Content.LootBias) + } +} + +// bfsHops returns the BFS shortest-path length from `from` to `to`, +// constrained to start by going through `via`. Returns 0 if unreachable. +func bfsHops(g ZoneGraph, from, to, via string) int { + type frame struct { + node string + hops int + } + if from == to { + return 0 + } + seen := map[string]bool{from: true} + queue := []frame{} + for _, e := range g.Edges[from] { + if e.To == via { + queue = append(queue, frame{e.To, 1}) + seen[e.To] = true + } + } + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + if cur.node == to { + return cur.hops + } + for _, e := range g.Edges[cur.node] { + if !seen[e.To] { + seen[e.To] = true + queue = append(queue, frame{e.To, cur.hops + 1}) + } + } + } + return 0 +}