From dcef2ba7347aa98d5f96c62c7a21ca5cb8a986eb Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 9 May 2026 16:57:48 -0700 Subject: [PATCH] Branching zones G8f: Feywild Crossing woven forks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T4 zone, sixth authored graph. Shape: woven forks — two first-stage forks each lead to a second-stage fork; hag_circle is shared between both first-stage paths while time_eddy (grove-only) and illusion_garden (marsh-only, secret) are exclusive to one side. Captures the Feywild theme of "the path you walked is not the path you arrived on." First authored zone with LockStatCheck CHA, completing the stat roster (CHA joins STR/DEX/INT/WIS in shipping zones). Also the first zone where fork1 has no LockNone option — both first-stage edges are locked (CHA DC 14 vs. Perception DC 14), forcing the player to commit to whichever check they're stronger at. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plugin/zone_graph_feywild_crossing.go | 81 +++++++++++++++++++ .../zone_graph_feywild_crossing_test.go | 75 +++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 internal/plugin/zone_graph_feywild_crossing.go create mode 100644 internal/plugin/zone_graph_feywild_crossing_test.go diff --git a/internal/plugin/zone_graph_feywild_crossing.go b/internal/plugin/zone_graph_feywild_crossing.go new file mode 100644 index 0000000..f90efe5 --- /dev/null +++ b/internal/plugin/zone_graph_feywild_crossing.go @@ -0,0 +1,81 @@ +package plugin + +// Phase G8f — Feywild Crossing branching graph. +// +// T4 zone. Shape: woven forks — two first-stage forks each lead to a +// second-stage fork; the inner nodes partially overlap so the player's +// first choice does not fully determine which second-stage rooms they +// can reach. Captures the Feywild theme of "the path you walked is +// not the path you arrived on." +// +// entry → twilight_path → fork1 +// ├─[CHA DC 14]── glamoured_grove ─┬─ hag_circle (elite) ─┐ +// │ └─[DEX DC 15]── time_eddy ─┤ +// │ ├── boss +// └─[Perception DC 14]── wisp_marsh ─┬─ hag_circle ──────────┤ +// └─[Perception DC 16]── illusion_garden (secret) ─┘ +// +// hag_circle is reachable from BOTH first-stage paths — the elite +// encounter is the "you'll meet her either way" room. time_eddy is +// only reachable via the grove (CHA path); illusion_garden is only +// reachable via the marsh (Perception path). +// +// First authored zone using LockStatCheck CHA — every prior secret/ +// gate has been Perception/STR/INT/DEX. Both fork1 entries are locked +// (no LockNone option from fork1) which is the first instance of "no +// free choice"; the player must commit to the lock-check style they're +// stronger at. + +func zoneFeywildCrossingGraph() ZoneGraph { + nodes := []ZoneNode{ + {NodeID: "feywild_crossing.entry", Kind: NodeKindEntry, IsEntry: true, + Label: "Veil's Edge", PosX: 0, PosY: 2}, + {NodeID: "feywild_crossing.twilight_path", Kind: NodeKindExploration, + Label: "Twilight Path", PosX: 1, PosY: 2}, + {NodeID: "feywild_crossing.fork1", Kind: NodeKindFork, + Label: "The Bargain Crossroads", PosX: 2, PosY: 2}, + {NodeID: "feywild_crossing.glamoured_grove", Kind: NodeKindFork, + Label: "Glamoured Grove", PosX: 3, PosY: 1}, + {NodeID: "feywild_crossing.wisp_marsh", Kind: NodeKindFork, + Label: "Wisp Marsh", PosX: 3, PosY: 3}, + {NodeID: "feywild_crossing.time_eddy", Kind: NodeKindExploration, + Label: "Time Eddy", PosX: 4, PosY: 0}, + {NodeID: "feywild_crossing.hag_circle", Kind: NodeKindElite, + Label: "Hag Circle", PosX: 4, PosY: 2}, + {NodeID: "feywild_crossing.illusion_garden", Kind: NodeKindSecret, + Label: "Illusion Garden", PosX: 4, PosY: 4, + Content: ZoneNodeContent{LootBias: 2.0}}, + {NodeID: "feywild_crossing.boss", Kind: NodeKindBoss, IsBoss: true, + Label: "Court of the Antlered Queen", PosX: 5, PosY: 2}, + } + edges := []ZoneEdge{ + {From: "feywild_crossing.entry", To: "feywild_crossing.twilight_path", Lock: LockNone}, + {From: "feywild_crossing.twilight_path", To: "feywild_crossing.fork1", Lock: LockNone}, + // Fork1 — both options are locked (CHA vs. Perception). + {From: "feywild_crossing.fork1", To: "feywild_crossing.glamoured_grove", + Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 14}, + Hint: "a starlight creature offering a deal — you'd have to play along", Weight: 1}, + {From: "feywild_crossing.fork1", To: "feywild_crossing.wisp_marsh", + Lock: LockPerception, LockData: map[string]any{"dc": 14}, + Hint: "wisp-light flickering between two trees — they look like the same tree", Weight: 1}, + // Grove → hag_circle (open) or time_eddy (DEX). + {From: "feywild_crossing.glamoured_grove", To: "feywild_crossing.hag_circle", Lock: LockNone, Weight: 1}, + {From: "feywild_crossing.glamoured_grove", To: "feywild_crossing.time_eddy", + Lock: LockStatCheck, LockData: map[string]any{"stat": "DEX", "dc": 15}, + Hint: "the seconds run sideways here — sidestep at the right one", Weight: 2}, + // Marsh → hag_circle (shared) or illusion_garden (high Perception secret). + {From: "feywild_crossing.wisp_marsh", To: "feywild_crossing.hag_circle", Lock: LockNone, Weight: 1}, + {From: "feywild_crossing.wisp_marsh", To: "feywild_crossing.illusion_garden", + Lock: LockPerception, LockData: map[string]any{"dc": 16}, + Hint: "a garden visible only when you don't look directly at it", Weight: 2}, + // All three second-stage nodes terminate at boss. + {From: "feywild_crossing.hag_circle", To: "feywild_crossing.boss", Lock: LockNone}, + {From: "feywild_crossing.time_eddy", To: "feywild_crossing.boss", Lock: LockNone}, + {From: "feywild_crossing.illusion_garden", To: "feywild_crossing.boss", Lock: LockNone}, + } + return BuildGraph(ZoneFeywildCrossing, nodes, edges) +} + +func init() { + registerZoneGraph(zoneFeywildCrossingGraph()) +} diff --git a/internal/plugin/zone_graph_feywild_crossing_test.go b/internal/plugin/zone_graph_feywild_crossing_test.go new file mode 100644 index 0000000..d503fca --- /dev/null +++ b/internal/plugin/zone_graph_feywild_crossing_test.go @@ -0,0 +1,75 @@ +package plugin + +import "testing" + +func TestFeywildCrossingGraph_Registered(t *testing.T) { + g, ok := zoneGraphRegistry[ZoneFeywildCrossing] + if !ok { + t.Fatal("zoneFeywildCrossingGraph not registered") + } + if len(g.Nodes) != 9 { + t.Errorf("nodes = %d, want 9", len(g.Nodes)) + } +} + +// TestFeywildCrossingGraph_HagCircleSharedReach verifies hag_circle +// has TWO incoming edges — one from each first-stage path. This is the +// woven-fork shape's signature; if a future edit splits hag_circle so +// each path has its own copy, this fails (and the shape comment needs +// to be re-authored). +func TestFeywildCrossingGraph_HagCircleSharedReach(t *testing.T) { + g := zoneFeywildCrossingGraph() + incoming := map[string][]string{} + for from, outs := range g.Edges { + for _, e := range outs { + incoming[e.To] = append(incoming[e.To], from) + } + } + hagSources := incoming["feywild_crossing.hag_circle"] + if len(hagSources) != 2 { + t.Errorf("hag_circle incoming = %d, want 2 (woven fork)", len(hagSources)) + } +} + +// TestFeywildCrossingGraph_PartialOverlap verifies time_eddy is +// reachable only via the grove and illusion_garden only via the marsh. +// This is what makes first-stage choice meaningful even though +// hag_circle is shared. +func TestFeywildCrossingGraph_PartialOverlap(t *testing.T) { + g := zoneFeywildCrossingGraph() + if reachable(g, "feywild_crossing.wisp_marsh", "feywild_crossing.time_eddy") { + t.Error("time_eddy should NOT be reachable from wisp_marsh — grove-exclusive") + } + if reachable(g, "feywild_crossing.glamoured_grove", "feywild_crossing.illusion_garden") { + t.Error("illusion_garden should NOT be reachable from glamoured_grove — marsh-exclusive") + } +} + +// TestFeywildCrossingGraph_NoFreeChoiceAtFork1 captures the design +// intent: both fork1 outgoing edges are locked. The player must succeed +// at CHA or Perception to enter; no LockNone fallback. +func TestFeywildCrossingGraph_NoFreeChoiceAtFork1(t *testing.T) { + g := zoneFeywildCrossingGraph() + for _, e := range g.outgoingEdges("feywild_crossing.fork1") { + if e.Lock == LockNone { + t.Errorf("fork1 has unlocked edge to %s — expected all locked", e.To) + } + } +} + +// TestFeywildCrossingGraph_FirstCHALock confirms this zone uses +// LockStatCheck CHA — completing CHA in lock-kind coverage by G8f. +func TestFeywildCrossingGraph_FirstCHALock(t *testing.T) { + g := zoneFeywildCrossingGraph() + found := false + for _, outs := range g.Edges { + for _, e := range outs { + if e.Lock == LockStatCheck && lockDataString(e.LockData, "stat") == "CHA" { + found = true + } + } + } + if !found { + t.Error("expected at least one LockStatCheck CHA edge — Feywild theme") + } +}