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