diff --git a/internal/plugin/dnd_zone.go b/internal/plugin/dnd_zone.go index 0462d24..ca517f5 100644 --- a/internal/plugin/dnd_zone.go +++ b/internal/plugin/dnd_zone.go @@ -258,8 +258,8 @@ func zoneCryptValdris() ZoneDefinition { Faction: "Undead", Atmosphere: "Stone corridors, dripping water, candles that shouldn't still be burning.", Hook: "The iron gate hangs open — someone left in a hurry. Carved into the stone above: \"HERE LIES VALDRIS. DO NOT.\" The rest has been chiseled away. I decline to speculate.", - MinRooms: 6, - MaxRooms: 7, + MinRooms: 12, + MaxRooms: 14, Enemies: []ZoneEnemy{ // Phase 4-B (outlier fix): the live roster was the only // dual-killer-elite zone (wight + flameskull, both CR3+ on @@ -314,8 +314,8 @@ func zoneForestShadows() ZoneDefinition { Faction: "Beasts, Fey-corrupted creatures, Bandits", Atmosphere: "Ancient forest, twisted paths, eerie silence, bioluminescent fungi, things in the canopy.", Hook: "The forest was beautiful once. Travelers still say so, usually right before they stop saying anything at all. The trees lean in when you're not looking. I have noted this is not a metaphor.", - MinRooms: 6, - MaxRooms: 8, + MinRooms: 16, + MaxRooms: 20, Enemies: []ZoneEnemy{ // Phase 4-B (outlier fix): standard pool was carrying two // real killers — Displacer Beast (38% win as a standard @@ -377,8 +377,8 @@ func zoneSunkenTemple() ZoneDefinition { Faction: "Kuo-toa, Water Elementals, Aboleth-touched", Atmosphere: "Flooded stone chambers, barnacled pillars, salt smell, alien glyphs, things that swim in the dark water.", Hook: "The tide went out thirty years ago and never fully came back. The temple stayed wet anyway. Something down there keeps it that way. I suggest waterproofing your spellbook.", - MinRooms: 6, - MaxRooms: 8, + MinRooms: 16, + MaxRooms: 20, Enemies: []ZoneEnemy{ {BestiaryID: "kuo_toa", SpawnWeight: 7}, {BestiaryID: "kuo_toa_whip", SpawnWeight: 4}, diff --git a/internal/plugin/zone_graph_crypt_valdris.go b/internal/plugin/zone_graph_crypt_valdris.go index dfa2c90..6c1076c 100644 --- a/internal/plugin/zone_graph_crypt_valdris.go +++ b/internal/plugin/zone_graph_crypt_valdris.go @@ -1,54 +1,101 @@ package plugin -// Phase G7 — Crypt of Valdris POC graph. +// Crypt of Valdris branching graph. // -// First hand-authored branching graph. Topology per -// gogobee_branching_zones_plan.md §3: +// Long-expedition plan D1-b: extended from the original 8-node POC sketch +// to the new T1 length band (12–14 rooms traversed). Topology preserves +// the original diamond — main_hall (elite) vs side_chapel (perception +// fork), with a Perception-DC-15 secret dangling off side_chapel — and +// adds the missing Trap anchor + extra exploration on both arms so the +// longest entry→boss walk lands at 13. // -// entry → corridor → fork -// ├──[unlocked]── main_hall (elite) ──┐ -// │ ├── antechamber → boss -// └──[Perception DC 12]── side_chapel ─┘ -// │ -// └──[Perception DC 15]── secret_chamber → antechamber +// entry → processional_stair → bone_corridor → +// grave_dust_trap (TRAP) → ossuary_walk → fork +// ├──[unlocked]── main_hall (ELITE) +// │ → reliquary_hall → ash_passage ──┐ +// │ ├── antechamber +// └──[Perception DC 12]── side_chapel │ → choir_landing +// │ → catacomb_passage │ → throne_steps +// │ → vault_landing ──────────┘ → boss +// └──[Perception DC 15]── secret_chamber (SECRET) → antechamber // -// Two paths to the boss (main_hall vs side_chapel); secret_chamber -// dangles off side_chapel for an extra Perception gate with loot. +// Both main and side arms walk 3 mid-nodes after the fork; secret stays a +// 1-node side dangle off side_chapel that merges back at antechamber, so +// the Perception-DC-15 path is a loot dip rather than a length detour. func zoneCryptValdrisGraph() ZoneGraph { nodes := []ZoneNode{ {NodeID: "crypt_valdris.entry", Kind: NodeKindEntry, IsEntry: true, Label: "Crypt Threshold", PosX: 0, PosY: 1}, - {NodeID: "crypt_valdris.corridor", Kind: NodeKindExploration, - Label: "Bone Corridor", PosX: 1, PosY: 1}, + {NodeID: "crypt_valdris.processional_stair", Kind: NodeKindExploration, + Label: "Processional Stair", PosX: 1, PosY: 1}, + {NodeID: "crypt_valdris.bone_corridor", Kind: NodeKindExploration, + Label: "Bone Corridor", PosX: 2, PosY: 1}, + {NodeID: "crypt_valdris.grave_dust_trap", Kind: NodeKindTrap, + Label: "Grave-Dust Cascade", PosX: 3, PosY: 1}, + {NodeID: "crypt_valdris.ossuary_walk", Kind: NodeKindExploration, + Label: "Ossuary Walk", PosX: 4, PosY: 1}, {NodeID: "crypt_valdris.fork", Kind: NodeKindFork, - Label: "Branching Passage", PosX: 2, PosY: 1}, + Label: "Branching Passage", PosX: 5, PosY: 1}, + + // Left arm — default, contains the elite. {NodeID: "crypt_valdris.main_hall", Kind: NodeKindElite, - Label: "Main Hall", PosX: 3, PosY: 0}, + Label: "Main Hall", PosX: 6, PosY: 0}, + {NodeID: "crypt_valdris.reliquary_hall", Kind: NodeKindExploration, + Label: "Reliquary Hall", PosX: 7, PosY: 0}, + {NodeID: "crypt_valdris.ash_passage", Kind: NodeKindExploration, + Label: "Ash Passage", PosX: 8, PosY: 0}, + + // Right arm — perception-gated, elite-free. {NodeID: "crypt_valdris.side_chapel", Kind: NodeKindExploration, - Label: "Side Chapel", PosX: 3, PosY: 2}, + Label: "Side Chapel", PosX: 6, PosY: 2}, + {NodeID: "crypt_valdris.catacomb_passage", Kind: NodeKindExploration, + Label: "Catacomb Passage", PosX: 7, PosY: 2}, + {NodeID: "crypt_valdris.vault_landing", Kind: NodeKindExploration, + Label: "Vault Landing", PosX: 8, PosY: 2}, + + // Secret dangle off side_chapel — high-DC Perception, loot-biased. {NodeID: "crypt_valdris.secret_chamber", Kind: NodeKindSecret, - Label: "Hidden Reliquary", PosX: 4, PosY: 3, + Label: "Hidden Reliquary", PosX: 7, PosY: 3, Content: ZoneNodeContent{LootBias: 2.0}}, + + // Merge + approach. {NodeID: "crypt_valdris.antechamber", Kind: NodeKindMerge, - Label: "Antechamber", PosX: 5, PosY: 1}, + Label: "Antechamber", PosX: 9, PosY: 1}, + {NodeID: "crypt_valdris.choir_landing", Kind: NodeKindExploration, + Label: "Choir Landing", PosX: 10, PosY: 1}, + {NodeID: "crypt_valdris.throne_steps", Kind: NodeKindExploration, + Label: "Throne Steps", PosX: 11, PosY: 1}, {NodeID: "crypt_valdris.boss", Kind: NodeKindBoss, IsBoss: true, - Label: "Valdris's Sanctum", PosX: 6, PosY: 1}, + Label: "Valdris's Sanctum", PosX: 12, PosY: 1}, } edges := []ZoneEdge{ - {From: "crypt_valdris.entry", To: "crypt_valdris.corridor", Lock: LockNone}, - {From: "crypt_valdris.corridor", To: "crypt_valdris.fork", Lock: LockNone}, + {From: "crypt_valdris.entry", To: "crypt_valdris.processional_stair", Lock: LockNone}, + {From: "crypt_valdris.processional_stair", To: "crypt_valdris.bone_corridor", Lock: LockNone}, + {From: "crypt_valdris.bone_corridor", To: "crypt_valdris.grave_dust_trap", Lock: LockNone}, + {From: "crypt_valdris.grave_dust_trap", To: "crypt_valdris.ossuary_walk", Lock: LockNone}, + {From: "crypt_valdris.ossuary_walk", To: "crypt_valdris.fork", Lock: LockNone}, + {From: "crypt_valdris.fork", To: "crypt_valdris.main_hall", Lock: LockNone, Weight: 1}, {From: "crypt_valdris.fork", To: "crypt_valdris.side_chapel", Lock: LockPerception, LockData: map[string]any{"dc": 12}, Hint: "a faint draft from a crack in the wall", Weight: 2}, - {From: "crypt_valdris.main_hall", To: "crypt_valdris.antechamber", Lock: LockNone}, - {From: "crypt_valdris.side_chapel", To: "crypt_valdris.antechamber", Lock: LockNone, Weight: 1}, + + {From: "crypt_valdris.main_hall", To: "crypt_valdris.reliquary_hall", Lock: LockNone}, + {From: "crypt_valdris.reliquary_hall", To: "crypt_valdris.ash_passage", Lock: LockNone}, + {From: "crypt_valdris.ash_passage", To: "crypt_valdris.antechamber", Lock: LockNone}, + + {From: "crypt_valdris.side_chapel", To: "crypt_valdris.catacomb_passage", Lock: LockNone, Weight: 1}, {From: "crypt_valdris.side_chapel", To: "crypt_valdris.secret_chamber", Lock: LockPerception, LockData: map[string]any{"dc": 15}, Hint: "a faint scratching behind the altar", Weight: 2}, + {From: "crypt_valdris.catacomb_passage", To: "crypt_valdris.vault_landing", Lock: LockNone}, + {From: "crypt_valdris.vault_landing", To: "crypt_valdris.antechamber", Lock: LockNone}, {From: "crypt_valdris.secret_chamber", To: "crypt_valdris.antechamber", Lock: LockNone}, - {From: "crypt_valdris.antechamber", To: "crypt_valdris.boss", Lock: LockNone}, + + {From: "crypt_valdris.antechamber", To: "crypt_valdris.choir_landing", Lock: LockNone}, + {From: "crypt_valdris.choir_landing", To: "crypt_valdris.throne_steps", Lock: LockNone}, + {From: "crypt_valdris.throne_steps", To: "crypt_valdris.boss", Lock: LockNone}, } return BuildGraph(ZoneCryptValdris, nodes, edges) } diff --git a/internal/plugin/zone_graph_crypt_valdris_test.go b/internal/plugin/zone_graph_crypt_valdris_test.go index 2cdb173..ffec2e5 100644 --- a/internal/plugin/zone_graph_crypt_valdris_test.go +++ b/internal/plugin/zone_graph_crypt_valdris_test.go @@ -18,8 +18,10 @@ func TestCryptValdrisGraph_Registered(t *testing.T) { if g.Boss != "crypt_valdris.boss" { t.Errorf("boss node = %q, want crypt_valdris.boss", g.Boss) } - if len(g.Nodes) != 8 { - t.Errorf("nodes = %d, want 8", len(g.Nodes)) + // Long-expedition D1-b widened this zone from 8 → 17 nodes so the + // longest entry→boss walk lands in the T1 [12,14] traversal band. + if len(g.Nodes) != 17 { + t.Errorf("nodes = %d, want 17", len(g.Nodes)) } } diff --git a/internal/plugin/zone_graph_forest_shadows.go b/internal/plugin/zone_graph_forest_shadows.go index 88f07c2..00a6371 100644 --- a/internal/plugin/zone_graph_forest_shadows.go +++ b/internal/plugin/zone_graph_forest_shadows.go @@ -1,64 +1,111 @@ package plugin -// Phase G8b — Forest of Shadows branching graph. +// Forest of Shadows branching graph. // -// T2 zone. Per plan §G8 ("two forks for T2+") and the G8 design -// session: deliberately a different shape from the Crypt diamond. +// Long-expedition plan D1-b: extended from the original 9-node sketch to +// the new T2 length band (16–20 rooms traversed). Topology preserves the +// G8 design intent — asymmetric main fork (long branch carries the +// elite) + WIS-gated secret hanging pre-boss — and adds the missing Trap +// anchor + extra exploration on both arms and the boss approach. // -// Shape: asymmetric main fork + WIS-gated secret pre-boss. +// entry → mossy_threshold → twisted_path → root_snare (TRAP) → +// creeping_brush → dappled_hollow → witchlight_clearing → fork1 +// ├─[unlocked, w=1]── grove_descent → +// │ dryad_circle (ELITE) → torn_meadow ──┐ +// │ ├── fork2 +// └─[Perception DC 13, w=2]── thorn_tunnel │ +// → briar_warren ────────────────────────┘ +// │ +// ┌─[unlocked, w=1]── ritual_glade → │ +// │ antler_path → hollow_steps → boss ─┘ +// │ +// └─[WIS DC 14, w=2]── sapling_shrine (SECRET) → boss // -// entry → twisted_path → fork1 -// ├─[unlocked, w=1]── grove_descent → dryad_circle (elite) ─┐ -// │ ├── fork2 -// └─[Perception DC 13, w=2]── thorn_tunnel ──────────────────┘ -// │ -// ┌─[unlocked, w=1]── boss ────────────────┘ -// │ -// └─[WIS DC 14, w=2]── sapling_shrine (secret) → boss -// -// Asymmetry: long branch is two mid-nodes (grove_descent → dryad_circle -// (elite)), short branch is one (thorn_tunnel). Map renders the long -// branch as a "+1 column" arm so the topology reads at a glance. -// Secret uses LockStatCheck WIS (not Perception) to exercise the -// stat-check lock kind — Crypt's secret is Perception, so this is the -// first authored zone using stat_check. +// Asymmetry preserved: long branch is 3 mid-nodes (grove_descent → +// dryad_circle (elite) → torn_meadow), short branch is 2 (thorn_tunnel → +// briar_warren). Longest entry→boss walk = 16 (long branch path). func zoneForestShadowsGraph() ZoneGraph { nodes := []ZoneNode{ {NodeID: "forest_shadows.entry", Kind: NodeKindEntry, IsEntry: true, Label: "Forest Edge", PosX: 0, PosY: 1}, + {NodeID: "forest_shadows.mossy_threshold", Kind: NodeKindExploration, + Label: "Mossy Threshold", PosX: 1, PosY: 1}, {NodeID: "forest_shadows.twisted_path", Kind: NodeKindExploration, - Label: "Twisted Path", PosX: 1, PosY: 1}, + Label: "Twisted Path", PosX: 2, PosY: 1}, + {NodeID: "forest_shadows.root_snare", Kind: NodeKindTrap, + Label: "Root Snare", PosX: 3, PosY: 1}, + {NodeID: "forest_shadows.creeping_brush", Kind: NodeKindExploration, + Label: "Creeping Brush", PosX: 4, PosY: 1}, + {NodeID: "forest_shadows.dappled_hollow", Kind: NodeKindExploration, + Label: "Dappled Hollow", PosX: 5, PosY: 1}, + {NodeID: "forest_shadows.witchlight_clearing", Kind: NodeKindExploration, + Label: "Witchlight Clearing", PosX: 6, PosY: 1}, {NodeID: "forest_shadows.fork1", Kind: NodeKindFork, - Label: "Splintered Trail", PosX: 2, PosY: 1}, + Label: "Splintered Trail", PosX: 7, PosY: 1}, + + // Long branch (3 mid-nodes, carries the elite). {NodeID: "forest_shadows.grove_descent", Kind: NodeKindExploration, - Label: "Grove Descent", PosX: 3, PosY: 0}, + Label: "Grove Descent", PosX: 8, PosY: 0}, {NodeID: "forest_shadows.dryad_circle", Kind: NodeKindElite, - Label: "Dryad's Circle", PosX: 4, PosY: 0}, + Label: "Dryad's Circle", PosX: 9, PosY: 0}, + {NodeID: "forest_shadows.torn_meadow", Kind: NodeKindExploration, + Label: "Torn Meadow", PosX: 10, PosY: 0}, + + // Short branch (2 mid-nodes, perception-gated). {NodeID: "forest_shadows.thorn_tunnel", Kind: NodeKindExploration, - Label: "Thorn Tunnel", PosX: 3, PosY: 2}, + Label: "Thorn Tunnel", PosX: 8, PosY: 2}, + {NodeID: "forest_shadows.briar_warren", Kind: NodeKindExploration, + Label: "Briar Warren", PosX: 9, PosY: 2}, + {NodeID: "forest_shadows.fork2", Kind: NodeKindFork, - Label: "Hollow King's Approach", PosX: 5, PosY: 1}, + Label: "Hollow King's Approach", PosX: 11, PosY: 1}, + + // Post-fork2 main approach. + {NodeID: "forest_shadows.ritual_glade", Kind: NodeKindExploration, + Label: "Ritual Glade", PosX: 12, PosY: 1}, + {NodeID: "forest_shadows.antler_path", Kind: NodeKindExploration, + Label: "Antler Path", PosX: 13, PosY: 1}, + {NodeID: "forest_shadows.hollow_steps", Kind: NodeKindExploration, + Label: "Hollow Steps", PosX: 14, PosY: 1}, + + // Secret dangle off fork2 — WIS gate, loot-biased, shortcut to boss. {NodeID: "forest_shadows.sapling_shrine", Kind: NodeKindSecret, - Label: "Sapling Shrine", PosX: 6, PosY: 2, + Label: "Sapling Shrine", PosX: 13, PosY: 2, Content: ZoneNodeContent{LootBias: 2.0}}, + {NodeID: "forest_shadows.boss", Kind: NodeKindBoss, IsBoss: true, - Label: "Throne of Antlers", PosX: 7, PosY: 1}, + Label: "Throne of Antlers", PosX: 15, PosY: 1}, } edges := []ZoneEdge{ - {From: "forest_shadows.entry", To: "forest_shadows.twisted_path", Lock: LockNone}, - {From: "forest_shadows.twisted_path", To: "forest_shadows.fork1", Lock: LockNone}, + {From: "forest_shadows.entry", To: "forest_shadows.mossy_threshold", Lock: LockNone}, + {From: "forest_shadows.mossy_threshold", To: "forest_shadows.twisted_path", Lock: LockNone}, + {From: "forest_shadows.twisted_path", To: "forest_shadows.root_snare", Lock: LockNone}, + {From: "forest_shadows.root_snare", To: "forest_shadows.creeping_brush", Lock: LockNone}, + {From: "forest_shadows.creeping_brush", To: "forest_shadows.dappled_hollow", Lock: LockNone}, + {From: "forest_shadows.dappled_hollow", To: "forest_shadows.witchlight_clearing", Lock: LockNone}, + {From: "forest_shadows.witchlight_clearing", To: "forest_shadows.fork1", Lock: LockNone}, + {From: "forest_shadows.fork1", To: "forest_shadows.grove_descent", Lock: LockNone, Weight: 1}, {From: "forest_shadows.fork1", To: "forest_shadows.thorn_tunnel", Lock: LockPerception, LockData: map[string]any{"dc": 13}, Hint: "a deer-track winding into thicker brush", Weight: 2}, + {From: "forest_shadows.grove_descent", To: "forest_shadows.dryad_circle", Lock: LockNone}, - {From: "forest_shadows.dryad_circle", To: "forest_shadows.fork2", Lock: LockNone}, - {From: "forest_shadows.thorn_tunnel", To: "forest_shadows.fork2", Lock: LockNone}, - {From: "forest_shadows.fork2", To: "forest_shadows.boss", Lock: LockNone, Weight: 1}, + {From: "forest_shadows.dryad_circle", To: "forest_shadows.torn_meadow", Lock: LockNone}, + {From: "forest_shadows.torn_meadow", To: "forest_shadows.fork2", Lock: LockNone}, + + {From: "forest_shadows.thorn_tunnel", To: "forest_shadows.briar_warren", Lock: LockNone}, + {From: "forest_shadows.briar_warren", To: "forest_shadows.fork2", Lock: LockNone}, + + {From: "forest_shadows.fork2", To: "forest_shadows.ritual_glade", Lock: LockNone, Weight: 1}, {From: "forest_shadows.fork2", To: "forest_shadows.sapling_shrine", Lock: LockStatCheck, LockData: map[string]any{"stat": "WIS", "dc": 14}, Hint: "a humming you can almost place — antlers, somewhere", Weight: 2}, + + {From: "forest_shadows.ritual_glade", To: "forest_shadows.antler_path", Lock: LockNone}, + {From: "forest_shadows.antler_path", To: "forest_shadows.hollow_steps", Lock: LockNone}, + {From: "forest_shadows.hollow_steps", To: "forest_shadows.boss", Lock: LockNone}, {From: "forest_shadows.sapling_shrine", To: "forest_shadows.boss", Lock: LockNone}, } return BuildGraph(ZoneForestShadows, nodes, edges) diff --git a/internal/plugin/zone_graph_forest_shadows_test.go b/internal/plugin/zone_graph_forest_shadows_test.go index 1fe7a4a..8cdc7a7 100644 --- a/internal/plugin/zone_graph_forest_shadows_test.go +++ b/internal/plugin/zone_graph_forest_shadows_test.go @@ -13,8 +13,10 @@ func TestForestShadowsGraph_Registered(t *testing.T) { if g.Boss != "forest_shadows.boss" { t.Errorf("boss node = %q, want forest_shadows.boss", g.Boss) } - if len(g.Nodes) != 9 { - t.Errorf("nodes = %d, want 9", len(g.Nodes)) + // Long-expedition D1-b widened this zone from 9 → 19 nodes so the + // longest entry→boss walk lands in the T2 [16,20] traversal band. + if len(g.Nodes) != 19 { + t.Errorf("nodes = %d, want 19", len(g.Nodes)) } } @@ -42,11 +44,13 @@ func TestForestShadowsGraph_AsymmetricMainFork(t *testing.T) { g := zoneForestShadowsGraph() longLen := bfsHops(g, "forest_shadows.fork1", "forest_shadows.fork2", "forest_shadows.grove_descent") shortLen := bfsHops(g, "forest_shadows.fork1", "forest_shadows.fork2", "forest_shadows.thorn_tunnel") - if longLen != 3 { - t.Errorf("long branch hops = %d, want 3 (grove_descent → dryad_circle → fork2)", longLen) + // D1-b: long branch is now 3 mid-nodes (grove_descent → dryad_circle + // → torn_meadow), short is 2 (thorn_tunnel → briar_warren). + if longLen != 4 { + t.Errorf("long branch hops = %d, want 4 (grove_descent → dryad_circle → torn_meadow → fork2)", longLen) } - if shortLen != 2 { - t.Errorf("short branch hops = %d, want 2 (thorn_tunnel → fork2)", shortLen) + if shortLen != 3 { + t.Errorf("short branch hops = %d, want 3 (thorn_tunnel → briar_warren → fork2)", shortLen) } if longLen <= shortLen { t.Errorf("expected asymmetric branches (long > short); got long=%d short=%d", longLen, shortLen) diff --git a/internal/plugin/zone_graph_sunken_temple.go b/internal/plugin/zone_graph_sunken_temple.go index f3891e2..8009b59 100644 --- a/internal/plugin/zone_graph_sunken_temple.go +++ b/internal/plugin/zone_graph_sunken_temple.go @@ -1,23 +1,30 @@ package plugin -// Phase G8c — Sunken Temple of Dar'eth branching graph. +// Sunken Temple of Dar'eth branching graph. // -// T2 zone. Shape: sequential forks (no mid-path merge). Four distinct -// paths to the boss; the player commits at fork1 (dry vs. wet) and -// commits again at fork2a/fork2b. Path is unique up to the boss room. +// Long-expedition plan D1-b: extended from the original 10-node sketch to +// the new T2 length band (16–20 rooms traversed). Topology preserves the +// G8c design intent — sequential forks with no mid-path merge — and adds +// the missing Trap anchor + deeper pre-fork and per-leaf chains. The +// four leaf chains still terminate at the single boss room (validator +// requires exactly one boss). // -// entry → flooded_atrium → fork1 -// ├─[unlocked, w=1]── fork2a (dry) -// │ ├─[unlocked]── kuo_toa_pen (elite) → boss -// │ └─[STR DC 13]── glyph_chamber → boss -// └─[Perception DC 13, w=2]── fork2b (wet) -// ├─[unlocked]── aboleth_thralls → boss -// └─[Perception DC 15]── coral_reliquary (secret) → boss +// entry → flooded_atrium → tide_passage → kelp_trap (TRAP) → +// barnacled_steps → drowned_atrium → sunken_nave → fork1 +// ├─[unlocked, w=1]── dry_corridor → silent_columns → +// │ echoing_vault → fork2a +// │ ├─[unlocked]── kuo_toa_pen (ELITE) → +// │ │ trophy_pool → idol_court → boss +// │ └─[STR DC 13]── glyph_chamber → silt_arch → boss +// └─[Perception DC 13, w=2]── submerged_passage → +// coral_lattice → drowned_nave → fork2b +// ├─[unlocked]── aboleth_thralls → +// │ thrall_basin → sodden_hall → boss +// └─[Perception DC 15]── coral_reliquary (SECRET) → boss // -// Distinct from the Crypt diamond and the Forest asymmetric-diamond: -// no two paths share a mid-node, so the !zone map paints two parallel -// "Y" subtrees instead of a converging branch. The four leaves all -// terminate at the boss (validator requires exactly one boss node). +// Distinct from the Crypt diamond and the Forest asymmetric-diamond: no +// two paths share a mid-node, so the !zone map paints two parallel "Y" +// subtrees instead of a converging branch. Longest entry→boss = 16. func zoneSunkenTempleGraph() ZoneGraph { nodes := []ZoneNode{ @@ -25,42 +32,104 @@ func zoneSunkenTempleGraph() ZoneGraph { Label: "Tide-Stained Threshold", PosX: 0, PosY: 2}, {NodeID: "sunken_temple.flooded_atrium", Kind: NodeKindExploration, Label: "Flooded Atrium", PosX: 1, PosY: 2}, + {NodeID: "sunken_temple.tide_passage", Kind: NodeKindExploration, + Label: "Tide Passage", PosX: 2, PosY: 2}, + {NodeID: "sunken_temple.kelp_trap", Kind: NodeKindTrap, + Label: "Kelp-Snare", PosX: 3, PosY: 2}, + {NodeID: "sunken_temple.barnacled_steps", Kind: NodeKindExploration, + Label: "Barnacled Steps", PosX: 4, PosY: 2}, + {NodeID: "sunken_temple.drowned_atrium", Kind: NodeKindExploration, + Label: "Drowned Atrium", PosX: 5, PosY: 2}, + {NodeID: "sunken_temple.sunken_nave", Kind: NodeKindExploration, + Label: "Sunken Nave", PosX: 6, PosY: 2}, {NodeID: "sunken_temple.fork1", Kind: NodeKindFork, - Label: "Split Stair", PosX: 2, PosY: 2}, + Label: "Split Stair", PosX: 7, PosY: 2}, + + // Dry subtree. + {NodeID: "sunken_temple.dry_corridor", Kind: NodeKindExploration, + Label: "Dry Corridor", PosX: 8, PosY: 0}, + {NodeID: "sunken_temple.silent_columns", Kind: NodeKindExploration, + Label: "Silent Columns", PosX: 9, PosY: 0}, + {NodeID: "sunken_temple.echoing_vault", Kind: NodeKindExploration, + Label: "Echoing Vault", PosX: 10, PosY: 0}, {NodeID: "sunken_temple.fork2a", Kind: NodeKindFork, - Label: "Dry Crossing", PosX: 3, PosY: 0}, - {NodeID: "sunken_temple.fork2b", Kind: NodeKindFork, - Label: "Submerged Crossing", PosX: 3, PosY: 4}, + Label: "Dry Crossing", PosX: 11, PosY: 0}, {NodeID: "sunken_temple.kuo_toa_pen", Kind: NodeKindElite, - Label: "Kuo-toa Pen", PosX: 4, PosY: 0}, + Label: "Kuo-toa Pen", PosX: 12, PosY: -1}, + {NodeID: "sunken_temple.trophy_pool", Kind: NodeKindExploration, + Label: "Trophy Pool", PosX: 13, PosY: -1}, + {NodeID: "sunken_temple.idol_court", Kind: NodeKindExploration, + Label: "Idol Court", PosX: 14, PosY: -1}, {NodeID: "sunken_temple.glyph_chamber", Kind: NodeKindExploration, - Label: "Glyph Chamber", PosX: 4, PosY: 1}, + Label: "Glyph Chamber", PosX: 12, PosY: 1}, + {NodeID: "sunken_temple.silt_arch", Kind: NodeKindExploration, + Label: "Silt Arch", PosX: 13, PosY: 1}, + + // Wet subtree. + {NodeID: "sunken_temple.submerged_passage", Kind: NodeKindExploration, + Label: "Submerged Passage", PosX: 8, PosY: 4}, + {NodeID: "sunken_temple.coral_lattice", Kind: NodeKindExploration, + Label: "Coral Lattice", PosX: 9, PosY: 4}, + {NodeID: "sunken_temple.drowned_nave", Kind: NodeKindExploration, + Label: "Drowned Nave", PosX: 10, PosY: 4}, + {NodeID: "sunken_temple.fork2b", Kind: NodeKindFork, + Label: "Submerged Crossing", PosX: 11, PosY: 4}, {NodeID: "sunken_temple.aboleth_thralls", Kind: NodeKindExploration, - Label: "Thrall Pool", PosX: 4, PosY: 3}, + Label: "Thrall Pool", PosX: 12, PosY: 3}, + {NodeID: "sunken_temple.thrall_basin", Kind: NodeKindExploration, + Label: "Thrall Basin", PosX: 13, PosY: 3}, + {NodeID: "sunken_temple.sodden_hall", Kind: NodeKindExploration, + Label: "Sodden Hall", PosX: 14, PosY: 3}, {NodeID: "sunken_temple.coral_reliquary", Kind: NodeKindSecret, - Label: "Coral Reliquary", PosX: 4, PosY: 4, + Label: "Coral Reliquary", PosX: 12, PosY: 5, Content: ZoneNodeContent{LootBias: 1.8}}, + {NodeID: "sunken_temple.boss", Kind: NodeKindBoss, IsBoss: true, - Label: "Aboleth's Pool", PosX: 5, PosY: 2}, + Label: "Aboleth's Pool", PosX: 15, PosY: 2}, } edges := []ZoneEdge{ {From: "sunken_temple.entry", To: "sunken_temple.flooded_atrium", Lock: LockNone}, - {From: "sunken_temple.flooded_atrium", To: "sunken_temple.fork1", Lock: LockNone}, - {From: "sunken_temple.fork1", To: "sunken_temple.fork2a", Lock: LockNone, Weight: 1}, - {From: "sunken_temple.fork1", To: "sunken_temple.fork2b", + {From: "sunken_temple.flooded_atrium", To: "sunken_temple.tide_passage", Lock: LockNone}, + {From: "sunken_temple.tide_passage", To: "sunken_temple.kelp_trap", Lock: LockNone}, + {From: "sunken_temple.kelp_trap", To: "sunken_temple.barnacled_steps", Lock: LockNone}, + {From: "sunken_temple.barnacled_steps", To: "sunken_temple.drowned_atrium", Lock: LockNone}, + {From: "sunken_temple.drowned_atrium", To: "sunken_temple.sunken_nave", Lock: LockNone}, + {From: "sunken_temple.sunken_nave", To: "sunken_temple.fork1", Lock: LockNone}, + + {From: "sunken_temple.fork1", To: "sunken_temple.dry_corridor", Lock: LockNone, Weight: 1}, + {From: "sunken_temple.fork1", To: "sunken_temple.submerged_passage", Lock: LockPerception, LockData: map[string]any{"dc": 13}, Hint: "wet stone glistens down a side passage", Weight: 2}, + + {From: "sunken_temple.dry_corridor", To: "sunken_temple.silent_columns", Lock: LockNone}, + {From: "sunken_temple.silent_columns", To: "sunken_temple.echoing_vault", Lock: LockNone}, + {From: "sunken_temple.echoing_vault", To: "sunken_temple.fork2a", Lock: LockNone}, + {From: "sunken_temple.fork2a", To: "sunken_temple.kuo_toa_pen", Lock: LockNone, Weight: 1}, {From: "sunken_temple.fork2a", To: "sunken_temple.glyph_chamber", Lock: LockStatCheck, LockData: map[string]any{"stat": "STR", "dc": 13}, Hint: "a stone door wedged half-shut by silt", Weight: 2}, + + {From: "sunken_temple.kuo_toa_pen", To: "sunken_temple.trophy_pool", Lock: LockNone}, + {From: "sunken_temple.trophy_pool", To: "sunken_temple.idol_court", Lock: LockNone}, + {From: "sunken_temple.idol_court", To: "sunken_temple.boss", Lock: LockNone}, + + {From: "sunken_temple.glyph_chamber", To: "sunken_temple.silt_arch", Lock: LockNone}, + {From: "sunken_temple.silt_arch", To: "sunken_temple.boss", Lock: LockNone}, + + {From: "sunken_temple.submerged_passage", To: "sunken_temple.coral_lattice", Lock: LockNone}, + {From: "sunken_temple.coral_lattice", To: "sunken_temple.drowned_nave", Lock: LockNone}, + {From: "sunken_temple.drowned_nave", To: "sunken_temple.fork2b", Lock: LockNone}, + {From: "sunken_temple.fork2b", To: "sunken_temple.aboleth_thralls", Lock: LockNone, Weight: 1}, {From: "sunken_temple.fork2b", To: "sunken_temple.coral_reliquary", Lock: LockPerception, LockData: map[string]any{"dc": 15}, Hint: "a coral arch glittering under the surface", Weight: 2}, - {From: "sunken_temple.kuo_toa_pen", To: "sunken_temple.boss", Lock: LockNone}, - {From: "sunken_temple.glyph_chamber", To: "sunken_temple.boss", Lock: LockNone}, - {From: "sunken_temple.aboleth_thralls", To: "sunken_temple.boss", Lock: LockNone}, + + {From: "sunken_temple.aboleth_thralls", To: "sunken_temple.thrall_basin", Lock: LockNone}, + {From: "sunken_temple.thrall_basin", To: "sunken_temple.sodden_hall", Lock: LockNone}, + {From: "sunken_temple.sodden_hall", To: "sunken_temple.boss", Lock: LockNone}, + {From: "sunken_temple.coral_reliquary", To: "sunken_temple.boss", Lock: LockNone}, } return BuildGraph(ZoneSunkenTemple, nodes, edges) diff --git a/internal/plugin/zone_graph_sunken_temple_test.go b/internal/plugin/zone_graph_sunken_temple_test.go index 329ecf4..4360cfd 100644 --- a/internal/plugin/zone_graph_sunken_temple_test.go +++ b/internal/plugin/zone_graph_sunken_temple_test.go @@ -13,8 +13,12 @@ func TestSunkenTempleGraph_Registered(t *testing.T) { if g.Boss != "sunken_temple.boss" { t.Errorf("boss node = %q", g.Boss) } - if len(g.Nodes) != 10 { - t.Errorf("nodes = %d, want 10", len(g.Nodes)) + // Long-expedition D1-b widened this zone from 10 → 26 nodes so the + // longest entry→boss walk lands in the T2 [16,20] traversal band. + // (Sunken Temple is wide by design — no mid-merge means each leaf + // owns its own chain to the boss.) + if len(g.Nodes) != 26 { + t.Errorf("nodes = %d, want 26", len(g.Nodes)) } }