Branching zones G8f: Feywild Crossing woven forks

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) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 16:57:48 -07:00
parent dd97a00332
commit dcef2ba734
2 changed files with 156 additions and 0 deletions

View File

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

View File

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