Branching zones G8b: Forest of Shadows asymmetric fork + stat-check secret

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) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 16:51:19 -07:00
parent 9312ef5275
commit ea59b4e61c
2 changed files with 192 additions and 0 deletions

View File

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

View File

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