diff --git a/internal/plugin/zone_graph_goblin_warrens.go b/internal/plugin/zone_graph_goblin_warrens.go new file mode 100644 index 0000000..b810b0b --- /dev/null +++ b/internal/plugin/zone_graph_goblin_warrens.go @@ -0,0 +1,53 @@ +package plugin + +// Phase G8a — Goblin Warrens branching graph. +// +// First non-POC zone. T1 design constraint per +// gogobee_branching_zones_plan.md §G8: one fork per zone for T1, no +// secret (the secret pattern is exercised by Crypt of Valdris; Goblin +// Warrens deliberately validates the bare single-fork shape on a +// non-POC zone so we can confirm legacy compileLegacyZoneGraph fully +// hands off to the authored graph end-to-end). +// +// entry → guard_post → fork +// ├──[unlocked]── warband_pit (elite) ──┐ +// │ ├── warchief_hall → boss +// └──[Perception DC 12]── collapsed_shaft ┘ +// +// Both branches reach the boss; Perception side is hinted (per plan: +// "Locked paths should always have a hint — cruel design otherwise"). + +func zoneGoblinWarrensGraph() ZoneGraph { + nodes := []ZoneNode{ + {NodeID: "goblin_warrens.entry", Kind: NodeKindEntry, IsEntry: true, + Label: "Tunnel Mouth", PosX: 0, PosY: 1}, + {NodeID: "goblin_warrens.guard_post", Kind: NodeKindExploration, + Label: "Goblin Guard Post", PosX: 1, PosY: 1}, + {NodeID: "goblin_warrens.fork", Kind: NodeKindFork, + Label: "Cavern Junction", PosX: 2, PosY: 1}, + {NodeID: "goblin_warrens.warband_pit", Kind: NodeKindElite, + Label: "Warband Pit", PosX: 3, PosY: 0}, + {NodeID: "goblin_warrens.collapsed_shaft", Kind: NodeKindExploration, + Label: "Collapsed Shaft", PosX: 3, PosY: 2}, + {NodeID: "goblin_warrens.warchief_hall", Kind: NodeKindMerge, + Label: "Warchief's Hall", PosX: 4, PosY: 1}, + {NodeID: "goblin_warrens.boss", Kind: NodeKindBoss, IsBoss: true, + Label: "Grol's Throne", PosX: 5, PosY: 1}, + } + edges := []ZoneEdge{ + {From: "goblin_warrens.entry", To: "goblin_warrens.guard_post", Lock: LockNone}, + {From: "goblin_warrens.guard_post", To: "goblin_warrens.fork", Lock: LockNone}, + {From: "goblin_warrens.fork", To: "goblin_warrens.warband_pit", Lock: LockNone, Weight: 1}, + {From: "goblin_warrens.fork", To: "goblin_warrens.collapsed_shaft", + Lock: LockPerception, LockData: map[string]any{"dc": 12}, + Hint: "a muffled scrape behind a heap of fallen timbers", Weight: 2}, + {From: "goblin_warrens.warband_pit", To: "goblin_warrens.warchief_hall", Lock: LockNone}, + {From: "goblin_warrens.collapsed_shaft", To: "goblin_warrens.warchief_hall", Lock: LockNone}, + {From: "goblin_warrens.warchief_hall", To: "goblin_warrens.boss", Lock: LockNone}, + } + return BuildGraph(ZoneGoblinWarrens, nodes, edges) +} + +func init() { + registerZoneGraph(zoneGoblinWarrensGraph()) +} diff --git a/internal/plugin/zone_graph_goblin_warrens_test.go b/internal/plugin/zone_graph_goblin_warrens_test.go new file mode 100644 index 0000000..fde79cd --- /dev/null +++ b/internal/plugin/zone_graph_goblin_warrens_test.go @@ -0,0 +1,63 @@ +package plugin + +import "testing" + +func TestGoblinWarrensGraph_Registered(t *testing.T) { + g, ok := zoneGraphRegistry[ZoneGoblinWarrens] + if !ok { + t.Fatal("zoneGoblinWarrensGraph not registered") + } + if g.Entry != "goblin_warrens.entry" { + t.Errorf("entry node = %q, want goblin_warrens.entry", g.Entry) + } + if g.Boss != "goblin_warrens.boss" { + t.Errorf("boss node = %q, want goblin_warrens.boss", g.Boss) + } + if len(g.Nodes) != 7 { + t.Errorf("nodes = %d, want 7", len(g.Nodes)) + } +} + +func TestGoblinWarrensGraph_BothPathsReachBoss(t *testing.T) { + g := zoneGoblinWarrensGraph() + if !reachable(g, "goblin_warrens.warband_pit", "goblin_warrens.boss") { + t.Error("warband_pit path unreachable to boss") + } + if !reachable(g, "goblin_warrens.collapsed_shaft", "goblin_warrens.boss") { + t.Error("collapsed_shaft path unreachable to boss") + } +} + +func TestGoblinWarrensGraph_ForkLayout(t *testing.T) { + g := zoneGoblinWarrensGraph() + outs := g.outgoingEdges("goblin_warrens.fork") + if len(outs) != 2 { + t.Fatalf("fork outgoing edges = %d, want 2", len(outs)) + } + // outgoingEdges sorts by weight asc — warband_pit (weight 1) first. + if outs[0].To != "goblin_warrens.warband_pit" || outs[0].Lock != LockNone { + t.Errorf("first edge = %+v, want warband_pit/LockNone", outs[0]) + } + if outs[1].To != "goblin_warrens.collapsed_shaft" || outs[1].Lock != LockPerception { + t.Errorf("second edge = %+v, want collapsed_shaft/LockPerception", outs[1]) + } + if outs[1].Hint == "" { + t.Error("collapsed_shaft edge missing player-facing hint (plan §G8: cruel design)") + } + if dc := lockDataInt(outs[1].LockData, "dc", 0); dc != 12 { + t.Errorf("perception DC = %d, want 12", dc) + } +} + +// TestGoblinWarrensGraph_NoSecretByDesign locks in the T1 "single fork, +// no secret" decision recorded in project_phase_G_progress memo. If a +// later session adds a secret to this zone, this test should be removed +// or updated deliberately — not silently. +func TestGoblinWarrensGraph_NoSecretByDesign(t *testing.T) { + g := zoneGoblinWarrensGraph() + for _, n := range g.Nodes { + if n.Kind == NodeKindSecret { + t.Errorf("unexpected secret node %q — Goblin Warrens is T1 single-fork by design", n.NodeID) + } + } +}