diff --git a/internal/plugin/zone_graph_manor_blackspire.go b/internal/plugin/zone_graph_manor_blackspire.go new file mode 100644 index 0000000..2087a82 --- /dev/null +++ b/internal/plugin/zone_graph_manor_blackspire.go @@ -0,0 +1,82 @@ +package plugin + +// Phase G8d — Haunted Manor of Blackspire branching graph. +// +// T3 zone. Shape: stacked 3-way "hub" forks. The plan calls T2+ for +// "two forks", and a Manor of corridors and locked doors fits a wide- +// fork hub-and-spoke pattern more naturally than a binary fork. Two +// hub nodes (great_hall, upper_hall), each with three options: +// +// entry → foyer → great_hall (3-way) +// ├─[unlocked]── portrait_gallery ─┐ +// ├─[Perception DC 14]── study ────┼── upper_hall (3-way) +// └─[INT DC 14]── library ─────────┘ ├─[unlocked]── master_bedroom (elite) → boss +// ├─[Perception DC 15]── hidden_oratory (secret) → boss +// └─[LevelMin 7]── tower_observatory → boss +// +// Two consecutive 3-way fork prompts make this the first zone where +// the player picks twice in a row from a wide menu — distinct from the +// binary forks of G8a/b and the parallel Y-trees of G8c. Exercises +// LockLevelMin (first authored use) so all four non-trivial lock kinds +// (Perception, StatCheck, LevelMin, plus implicit LockNone) appear by +// G8d. + +func zoneManorBlackspireGraph() ZoneGraph { + nodes := []ZoneNode{ + {NodeID: "manor_blackspire.entry", Kind: NodeKindEntry, IsEntry: true, + Label: "Manor Gate", PosX: 0, PosY: 2}, + {NodeID: "manor_blackspire.foyer", Kind: NodeKindExploration, + Label: "Foyer", PosX: 1, PosY: 2}, + {NodeID: "manor_blackspire.great_hall", Kind: NodeKindFork, + Label: "Great Hall", PosX: 2, PosY: 2}, + {NodeID: "manor_blackspire.portrait_gallery", Kind: NodeKindExploration, + Label: "Portrait Gallery", PosX: 3, PosY: 1}, + {NodeID: "manor_blackspire.study", Kind: NodeKindExploration, + Label: "Locked Study", PosX: 3, PosY: 2}, + {NodeID: "manor_blackspire.library", Kind: NodeKindExploration, + Label: "Forbidden Library", PosX: 3, PosY: 3}, + {NodeID: "manor_blackspire.upper_hall", Kind: NodeKindFork, + Label: "Upper Hall", PosX: 4, PosY: 2}, + {NodeID: "manor_blackspire.master_bedroom", Kind: NodeKindElite, + Label: "Master Bedroom", PosX: 5, PosY: 1}, + {NodeID: "manor_blackspire.tower_observatory", Kind: NodeKindExploration, + Label: "Tower Observatory", PosX: 5, PosY: 2}, + {NodeID: "manor_blackspire.hidden_oratory", Kind: NodeKindSecret, + Label: "Hidden Oratory", PosX: 5, PosY: 3, + Content: ZoneNodeContent{LootBias: 2.0}}, + {NodeID: "manor_blackspire.boss", Kind: NodeKindBoss, IsBoss: true, + Label: "Aldric's Sanctum", PosX: 6, PosY: 2}, + } + edges := []ZoneEdge{ + {From: "manor_blackspire.entry", To: "manor_blackspire.foyer", Lock: LockNone}, + {From: "manor_blackspire.foyer", To: "manor_blackspire.great_hall", Lock: LockNone}, + // Great Hall — 3-way: portrait (open), study (perception), library (intelligence) + {From: "manor_blackspire.great_hall", To: "manor_blackspire.portrait_gallery", Lock: LockNone, Weight: 1}, + {From: "manor_blackspire.great_hall", To: "manor_blackspire.study", + Lock: LockPerception, LockData: map[string]any{"dc": 14}, + Hint: "an out-of-place draft from a doorframe", Weight: 2}, + {From: "manor_blackspire.great_hall", To: "manor_blackspire.library", + Lock: LockStatCheck, LockData: map[string]any{"stat": "INT", "dc": 14}, + Hint: "a runed door — you'd need to read it", Weight: 2}, + // All three converge at upper_hall. + {From: "manor_blackspire.portrait_gallery", To: "manor_blackspire.upper_hall", Lock: LockNone}, + {From: "manor_blackspire.study", To: "manor_blackspire.upper_hall", Lock: LockNone}, + {From: "manor_blackspire.library", To: "manor_blackspire.upper_hall", Lock: LockNone}, + // Upper Hall — 3-way: master_bedroom (open elite), hidden_oratory (high perception secret), tower (level-gated) + {From: "manor_blackspire.upper_hall", To: "manor_blackspire.master_bedroom", Lock: LockNone, Weight: 1}, + {From: "manor_blackspire.upper_hall", To: "manor_blackspire.hidden_oratory", + Lock: LockPerception, LockData: map[string]any{"dc": 15}, + Hint: "a candle behind a panel that shouldn't have a back", Weight: 2}, + {From: "manor_blackspire.upper_hall", To: "manor_blackspire.tower_observatory", + Lock: LockLevelMin, LockData: map[string]any{"min_level": 7}, + Hint: "a spiral stair that creaks ominously — climb only if you trust your footing", Weight: 3}, + {From: "manor_blackspire.master_bedroom", To: "manor_blackspire.boss", Lock: LockNone}, + {From: "manor_blackspire.hidden_oratory", To: "manor_blackspire.boss", Lock: LockNone}, + {From: "manor_blackspire.tower_observatory", To: "manor_blackspire.boss", Lock: LockNone}, + } + return BuildGraph(ZoneManorBlackspire, nodes, edges) +} + +func init() { + registerZoneGraph(zoneManorBlackspireGraph()) +} diff --git a/internal/plugin/zone_graph_manor_blackspire_test.go b/internal/plugin/zone_graph_manor_blackspire_test.go new file mode 100644 index 0000000..6c1c686 --- /dev/null +++ b/internal/plugin/zone_graph_manor_blackspire_test.go @@ -0,0 +1,71 @@ +package plugin + +import "testing" + +func TestManorBlackspireGraph_Registered(t *testing.T) { + g, ok := zoneGraphRegistry[ZoneManorBlackspire] + if !ok { + t.Fatal("zoneManorBlackspireGraph not registered") + } + if g.Entry != "manor_blackspire.entry" { + t.Errorf("entry node = %q", g.Entry) + } + if len(g.Nodes) != 11 { + t.Errorf("nodes = %d, want 11", len(g.Nodes)) + } +} + +// TestManorBlackspireGraph_TwoStackedThreeWayForks captures the design +// shape: both great_hall and upper_hall expose three options. +func TestManorBlackspireGraph_TwoStackedThreeWayForks(t *testing.T) { + g := zoneManorBlackspireGraph() + for _, hub := range []string{ + "manor_blackspire.great_hall", + "manor_blackspire.upper_hall", + } { + outs := g.outgoingEdges(hub) + if len(outs) != 3 { + t.Errorf("%s outgoing = %d, want 3", hub, len(outs)) + } + } +} + +func TestManorBlackspireGraph_AllSpokesReachBoss(t *testing.T) { + g := zoneManorBlackspireGraph() + for _, leaf := range []string{ + "manor_blackspire.master_bedroom", + "manor_blackspire.tower_observatory", + "manor_blackspire.hidden_oratory", + "manor_blackspire.portrait_gallery", + "manor_blackspire.study", + "manor_blackspire.library", + } { + if !reachable(g, leaf, "manor_blackspire.boss") { + t.Errorf("%s unreachable to boss", leaf) + } + } +} + +// TestManorBlackspireGraph_LockLevelMinFirstUse verifies this zone is +// the first to author LockLevelMin, completing lock-kind coverage +// (Perception, StatCheck, LevelMin, LockNone) by G8d. +func TestManorBlackspireGraph_LockLevelMinFirstUse(t *testing.T) { + g := zoneManorBlackspireGraph() + var levelMinEdge *ZoneEdge + for _, outs := range g.Edges { + for i := range outs { + if outs[i].Lock == LockLevelMin { + levelMinEdge = &outs[i] + } + } + } + if levelMinEdge == nil { + t.Fatal("expected at least one LockLevelMin edge") + } + if min := lockDataInt(levelMinEdge.LockData, "min_level", 0); min < 7 { + t.Errorf("min_level = %d, want >= 7 (T3 zone)", min) + } + if levelMinEdge.Hint == "" { + t.Error("level-gated edge missing hint") + } +}