diff --git a/internal/plugin/zone_graph_abyss_portal.go b/internal/plugin/zone_graph_abyss_portal.go index 7d80bbd..8ce6e18 100644 --- a/internal/plugin/zone_graph_abyss_portal.go +++ b/internal/plugin/zone_graph_abyss_portal.go @@ -56,6 +56,18 @@ package plugin // Longest entry→boss walk: 10 (R1) + 10 (R2) + 10 (R3) + 10 (R4) = 40 // nodes, inside [36, 44]. All four meaningful walks (fork1 × fork2 × // capstone) hit the same node count by construction. +// +// D10 anchor variety (in-place kind swaps, no length change): the Abyss +// shipped with NO trap node and only the fork2 vrock ELITE, so D10 adds +// two traps and a region-guardian elite — +// - Hush Corridor (fork1 silent_chambers Perception-loot spur) and +// Seam Threshold (fork3 reality_seam SECRET spur) become TRAPs, so +// the two loot/secret branches each carry a hazard. +// - Warden's Hall (R3 wardens_post buildup, main path) becomes an +// ELITE — the region literally named for its wardens finally has +// one, giving every walk a mid-zone region-guardian. +// The burning_wastes / mind_corridor / void_charge / usurper_throne +// routes stay clean, preserving the safe-vs-loot fork trade-off. func zoneAbyssPortalGraph() ZoneGraph { r1 := "abyss_outer_rift" @@ -96,7 +108,7 @@ func zoneAbyssPortalGraph() ZoneGraph { {NodeID: "abyss_portal.silent_chambers", Kind: NodeKindExploration, RegionID: r2, Label: "Silent Chambers", PosX: 10, PosY: 3}, - {NodeID: "abyss_portal.hush_corridor", Kind: NodeKindExploration, RegionID: r2, + {NodeID: "abyss_portal.hush_corridor", Kind: NodeKindTrap, RegionID: r2, Label: "Hush Corridor", PosX: 11, PosY: 3}, {NodeID: "abyss_portal.listening_room", Kind: NodeKindExploration, RegionID: r2, Label: "Listening Room", PosX: 12, PosY: 3}, @@ -135,7 +147,7 @@ func zoneAbyssPortalGraph() ZoneGraph { // R3 buildup to fork3. {NodeID: "abyss_portal.wardens_outer_post", Kind: NodeKindExploration, RegionID: r3, Label: "Outer Warden Post", PosX: 23, PosY: 2}, - {NodeID: "abyss_portal.wardens_hall", Kind: NodeKindExploration, RegionID: r3, + {NodeID: "abyss_portal.wardens_hall", Kind: NodeKindElite, RegionID: r3, Label: "Warden's Hall", PosX: 24, PosY: 2}, {NodeID: "abyss_portal.wardens_chapel", Kind: NodeKindExploration, RegionID: r3, Label: "Warden's Chapel", PosX: 25, PosY: 2}, @@ -163,7 +175,7 @@ func zoneAbyssPortalGraph() ZoneGraph { {NodeID: "abyss_portal.claimed_path", Kind: NodeKindExploration, RegionID: r4, Label: "Claimed Path", PosX: 32, PosY: 2}, - {NodeID: "abyss_portal.seam_threshold", Kind: NodeKindExploration, RegionID: r4, + {NodeID: "abyss_portal.seam_threshold", Kind: NodeKindTrap, RegionID: r4, Label: "Seam Threshold", PosX: 30, PosY: 3}, {NodeID: "abyss_portal.reality_seam", Kind: NodeKindSecret, RegionID: r4, Label: "Reality Seam", PosX: 31, PosY: 3, diff --git a/internal/plugin/zone_graph_abyss_portal_test.go b/internal/plugin/zone_graph_abyss_portal_test.go index 09e6321..0d932c5 100644 --- a/internal/plugin/zone_graph_abyss_portal_test.go +++ b/internal/plugin/zone_graph_abyss_portal_test.go @@ -119,3 +119,34 @@ func TestAbyssPortalGraph_RealitySeamHighestBias(t *testing.T) { t.Errorf("reality_seam LootBias = %v, want >= 3.0 (Abyss capstone)", seam.Content.LootBias) } } + +// TestAbyssPortalGraph_D10Anchors verifies the D10 anchor-variety pass. +// The Abyss shipped with no Trap node and a single fork2 Elite, so D10 +// adds two branch Traps (Hush Corridor, Seam Threshold) and a main-path +// region-guardian Elite (Warden's Hall). Counts: 2 traps, 2 elites. +func TestAbyssPortalGraph_D10Anchors(t *testing.T) { + g := zoneAbyssPortalGraph() + var trapCount, eliteCount int + for _, n := range g.Nodes { + switch n.Kind { + case NodeKindTrap: + trapCount++ + case NodeKindElite: + eliteCount++ + } + } + if trapCount != 2 { + t.Errorf("trap nodes = %d, want 2 (D10 hush_corridor + seam_threshold)", trapCount) + } + if eliteCount != 2 { + t.Errorf("elite nodes = %d, want 2 (vrock_aerie + D10 wardens_hall)", eliteCount) + } + if g.Nodes["abyss_portal.wardens_hall"].Kind != NodeKindElite { + t.Error("D10: wardens_hall (R3 region-guardian) should be an Elite") + } + for _, id := range []string{"abyss_portal.hush_corridor", "abyss_portal.seam_threshold"} { + if g.Nodes[id].Kind != NodeKindTrap { + t.Errorf("D10: %s should be a Trap", id) + } + } +} diff --git a/internal/plugin/zone_graph_dragons_lair.go b/internal/plugin/zone_graph_dragons_lair.go index 3838b6f..8c9338d 100644 --- a/internal/plugin/zone_graph_dragons_lair.go +++ b/internal/plugin/zone_graph_dragons_lair.go @@ -52,6 +52,17 @@ package plugin // Longest entry→boss walk: 10 (R1) + 10 (R2) + 9 (R3) + 10 (R4) = 39 // nodes, inside [36, 44]. The two R2 spurs and three R4 capstones each // reach the boss in the same node count by construction. +// +// D10 anchor variety (in-place kind swaps, no length change): the two +// loot-leaning branches each gain a guard, so "the richer route costs +// more" — +// - Coin-Strewn Hall (treasure_vault spur) becomes an ELITE; the +// Perception loot route is now guarded, mirroring the ash_bridge +// spur's TRAP so both fork1 branches carry an anchor. +// - Hidden Passage (hoard_pillar SECRET capstone spur) becomes a +// TRAP guarding the densest loot in the zone. +// The direct-confrontation and dragon-bargain routes stay clean, so the +// fork choice trades safety for loot. func zoneDragonsLairGraph() ZoneGraph { r1 := "dragons_lair_kobold_warrens" @@ -110,7 +121,7 @@ func zoneDragonsLairGraph() ZoneGraph { {NodeID: "dragons_lair.treasure_vault", Kind: NodeKindExploration, RegionID: r2, Label: "Treasure Vault", PosX: 17, PosY: 2, Content: ZoneNodeContent{LootBias: 1.5}}, - {NodeID: "dragons_lair.coin_strewn_hall", Kind: NodeKindExploration, RegionID: r2, + {NodeID: "dragons_lair.coin_strewn_hall", Kind: NodeKindElite, RegionID: r2, Label: "Coin-Strewn Hall", PosX: 18, PosY: 2}, {NodeID: "dragons_lair.vault_passage", Kind: NodeKindExploration, RegionID: r2, Label: "Vault Passage", PosX: 19, PosY: 2}, @@ -152,7 +163,7 @@ func zoneDragonsLairGraph() ZoneGraph { Label: "Audience Hall", PosX: 31, PosY: 1}, // R4 hoard_pillar spur (Perception 17, SECRET). - {NodeID: "dragons_lair.hidden_passage", Kind: NodeKindExploration, RegionID: r4, + {NodeID: "dragons_lair.hidden_passage", Kind: NodeKindTrap, RegionID: r4, Label: "Hidden Passage", PosX: 29, PosY: 2}, {NodeID: "dragons_lair.hoard_pillar", Kind: NodeKindSecret, RegionID: r4, Label: "Hidden Hoard Pillar", PosX: 30, PosY: 2, diff --git a/internal/plugin/zone_graph_dragons_lair_test.go b/internal/plugin/zone_graph_dragons_lair_test.go index bebd528..8d5f5fc 100644 --- a/internal/plugin/zone_graph_dragons_lair_test.go +++ b/internal/plugin/zone_graph_dragons_lair_test.go @@ -111,3 +111,32 @@ func TestDragonsLairGraph_LootBiasEscalation(t *testing.T) { t.Errorf("hoard_pillar LootBias = %v, want >= 2.5 (T5 secret)", hoard.Content.LootBias) } } + +// TestDragonsLairGraph_D10Anchors verifies the D10 anchor-variety pass: +// the treasure_vault spur gains a guarding Elite (Coin-Strewn Hall) to +// mirror the ash_bridge spur's Trap, and the hoard_pillar SECRET capstone +// spur gains a Trap (Hidden Passage). Counts: 2 traps, 2 elites. +func TestDragonsLairGraph_D10Anchors(t *testing.T) { + g := zoneDragonsLairGraph() + var trapCount, eliteCount int + for _, n := range g.Nodes { + switch n.Kind { + case NodeKindTrap: + trapCount++ + case NodeKindElite: + eliteCount++ + } + } + if trapCount != 2 { + t.Errorf("trap nodes = %d, want 2 (ash_bridge + D10 hidden_passage)", trapCount) + } + if eliteCount != 2 { + t.Errorf("elite nodes = %d, want 2 (wyrmlings_nest + D10 coin_strewn_hall)", eliteCount) + } + if g.Nodes["dragons_lair.coin_strewn_hall"].Kind != NodeKindElite { + t.Error("D10: coin_strewn_hall (treasure_vault spur) should be an Elite") + } + if g.Nodes["dragons_lair.hidden_passage"].Kind != NodeKindTrap { + t.Error("D10: hidden_passage (hoard_pillar spur) should be a Trap") + } +} diff --git a/internal/plugin/zone_graph_feywild_crossing.go b/internal/plugin/zone_graph_feywild_crossing.go index fda27f8..f876e54 100644 --- a/internal/plugin/zone_graph_feywild_crossing.go +++ b/internal/plugin/zone_graph_feywild_crossing.go @@ -18,6 +18,16 @@ package plugin // Also new: cursed_thicket TRAP anchor in the preamble — every walk // hits it. The original G8f graph had no Trap node. // +// D10 anchor variety (in-place kind swaps, no length change): the two +// first-stage approaches each gain one distinct anchor so the fork +// choice carries a flavor, not just a skill gate — +// - Grove approach: Singing Orchard becomes an ELITE (the CHA-bonus +// route is guarded; you pay for the bargain in blood). +// - Marsh approach: Mire Steps becomes a TRAP (the free route is +// hazardous footing rather than a fight). +// The time_eddy / illusion_garden exclusive endings stay anchor-light so +// they keep their "skip the hag elite for loot" trade-off. +// // Preamble (10 nodes, ending in fork1): // entry → twilight_path → veiled_glade → faerie_lights → // cursed_thicket (TRAP) → revel_road → moonshadow_bridge → @@ -87,7 +97,7 @@ func zoneFeywildCrossingGraph() ZoneGraph { Label: "Grove Threshold", PosX: 10, PosY: 0}, {NodeID: "feywild_crossing.starlight_path", Kind: NodeKindExploration, Label: "Starlight Path", PosX: 11, PosY: 0}, - {NodeID: "feywild_crossing.singing_orchard", Kind: NodeKindExploration, + {NodeID: "feywild_crossing.singing_orchard", Kind: NodeKindElite, Label: "Singing Orchard", PosX: 12, PosY: 0}, {NodeID: "feywild_crossing.mirror_pond", Kind: NodeKindExploration, Label: "Mirror Pond", PosX: 13, PosY: 0}, @@ -109,7 +119,7 @@ func zoneFeywildCrossingGraph() ZoneGraph { Label: "Fog Basin", PosX: 12, PosY: 4}, {NodeID: "feywild_crossing.moaning_reeds", Kind: NodeKindExploration, Label: "Moaning Reeds", PosX: 13, PosY: 4}, - {NodeID: "feywild_crossing.mire_steps", Kind: NodeKindExploration, + {NodeID: "feywild_crossing.mire_steps", Kind: NodeKindTrap, Label: "Mire Steps", PosX: 14, PosY: 4}, {NodeID: "feywild_crossing.submerged_stones", Kind: NodeKindExploration, Label: "Submerged Stones", PosX: 15, PosY: 4}, diff --git a/internal/plugin/zone_graph_feywild_crossing_test.go b/internal/plugin/zone_graph_feywild_crossing_test.go index 623d92c..e321730 100644 --- a/internal/plugin/zone_graph_feywild_crossing_test.go +++ b/internal/plugin/zone_graph_feywild_crossing_test.go @@ -93,6 +93,8 @@ func TestFeywildCrossingGraph_FirstCHALock(t *testing.T) { // TestFeywildCrossingGraph_TrapAnchor verifies D1-d added the missing // Trap node. Original G8f graph had elite/secret but no trap. +// TestFeywildCrossingGraph_TrapAnchor verifies the preamble trap plus +// the D10 marsh-branch trap (Mire Steps). func TestFeywildCrossingGraph_TrapAnchor(t *testing.T) { g := zoneFeywildCrossingGraph() var trapCount int @@ -101,8 +103,30 @@ func TestFeywildCrossingGraph_TrapAnchor(t *testing.T) { trapCount++ } } - if trapCount != 1 { - t.Errorf("trap nodes = %d, want 1", trapCount) + if trapCount != 2 { + t.Errorf("trap nodes = %d, want 2 (cursed_thicket + D10 mire_steps)", trapCount) + } + if g.Nodes["feywild_crossing.mire_steps"].Kind != NodeKindTrap { + t.Error("D10: mire_steps (marsh branch) should be a Trap") + } +} + +// TestFeywildCrossingGraph_EliteAnchors verifies the shared hag_circle +// elite plus the D10 grove-branch elite (Singing Orchard), so the first +// fork carries a per-branch anchor (grove=elite, marsh=trap). +func TestFeywildCrossingGraph_EliteAnchors(t *testing.T) { + g := zoneFeywildCrossingGraph() + var eliteCount int + for _, n := range g.Nodes { + if n.Kind == NodeKindElite { + eliteCount++ + } + } + if eliteCount != 2 { + t.Errorf("elite nodes = %d, want 2 (hag_circle + D10 singing_orchard)", eliteCount) + } + if g.Nodes["feywild_crossing.singing_orchard"].Kind != NodeKindElite { + t.Error("D10: singing_orchard (grove branch) should be an Elite") } } diff --git a/internal/plugin/zone_graph_underdark.go b/internal/plugin/zone_graph_underdark.go index e75a44b..5dbb2aa 100644 --- a/internal/plugin/zone_graph_underdark.go +++ b/internal/plugin/zone_graph_underdark.go @@ -51,6 +51,16 @@ package plugin // // Trap anchor (Collapsed Arch) is new — the original G8i graph had no // Trap node. Placed in the R1 preamble so every walk hits it. +// +// D10 anchor variety (in-place kind swaps, no length change): +// - Silenced Chamber (R3 illithid arm) becomes a TRAP — the second +// trap, on a fork branch so the illithid route reads riskier than +// the drow route. +// - Drow Gate Garrison (R2 arm tail, at the R2→R4 boundary) becomes a +// region-guardian ELITE, giving the drow arm two elites (Captain + +// Garrison) and the multi-region transition a teeth-y interrupt. +// The deep_chasm spur stays anchor-light (harvest, no second trap/elite) +// so the CON-gated climb keeps its "denser loot, less fighting" identity. func zoneUnderdarkGraph() ZoneGraph { r1 := "underdark_surface_tunnels" @@ -111,15 +121,15 @@ func zoneUnderdarkGraph() ZoneGraph { Label: "Drow Descent", PosX: 17, PosY: 0}, {NodeID: "underdark.drow_passage", Kind: NodeKindExploration, RegionID: r2, Label: "Lower Passage", PosX: 18, PosY: 0}, - {NodeID: "underdark.drow_gate", Kind: NodeKindExploration, RegionID: r2, - Label: "Drow Gate", PosX: 19, PosY: 0}, + {NodeID: "underdark.drow_gate", Kind: NodeKindElite, RegionID: r2, + Label: "Drow Gate Garrison", PosX: 19, PosY: 0}, // R3 illithid_warren arm. {NodeID: "underdark.psionic_corridor", Kind: NodeKindExploration, RegionID: r3, Label: "Psionic Corridor", PosX: 8, PosY: 2}, {NodeID: "underdark.whispering_hall", Kind: NodeKindExploration, RegionID: r3, Label: "Whispering Hall", PosX: 9, PosY: 2}, - {NodeID: "underdark.silenced_chamber", Kind: NodeKindExploration, RegionID: r3, + {NodeID: "underdark.silenced_chamber", Kind: NodeKindTrap, RegionID: r3, Label: "Silenced Chamber", PosX: 10, PosY: 2}, {NodeID: "underdark.mind_tank_room", Kind: NodeKindExploration, RegionID: r3, Label: "Brine Tanks", PosX: 11, PosY: 2}, diff --git a/internal/plugin/zone_graph_underdark_test.go b/internal/plugin/zone_graph_underdark_test.go index 43f45a9..a86bc9b 100644 --- a/internal/plugin/zone_graph_underdark_test.go +++ b/internal/plugin/zone_graph_underdark_test.go @@ -106,8 +106,8 @@ func TestUnderdarkGraph_AllArmsReachBoss(t *testing.T) { } } -// TestUnderdarkGraph_TrapAnchor verifies D1-d added the missing Trap -// node. Original G8i graph had elite/boss/harvest but no trap. +// TestUnderdarkGraph_TrapAnchor verifies D1-d added the missing R1 Trap +// (collapsed_arch) and D10 added the illithid-arm Trap (silenced_chamber). func TestUnderdarkGraph_TrapAnchor(t *testing.T) { g := zoneUnderdarkGraph() var trapCount int @@ -116,7 +116,30 @@ func TestUnderdarkGraph_TrapAnchor(t *testing.T) { trapCount++ } } - if trapCount != 1 { - t.Errorf("trap nodes = %d, want 1", trapCount) + if trapCount != 2 { + t.Errorf("trap nodes = %d, want 2 (collapsed_arch + D10 silenced_chamber)", trapCount) + } + if g.Nodes["underdark.silenced_chamber"].Kind != NodeKindTrap { + t.Error("D10: silenced_chamber (illithid arm) should be a Trap") + } +} + +// TestUnderdarkGraph_EliteAnchors verifies the per-arm elites (drow +// Captain, illithid Mind Flayer) plus the D10 region-guardian elite at +// the drow→throne boundary (drow_gate), so the drow arm carries two +// elites while the chasm spur stays anchor-light. +func TestUnderdarkGraph_EliteAnchors(t *testing.T) { + g := zoneUnderdarkGraph() + var eliteCount int + for _, n := range g.Nodes { + if n.Kind == NodeKindElite { + eliteCount++ + } + } + if eliteCount != 3 { + t.Errorf("elite nodes = %d, want 3 (drow_captain + mind_flayer + D10 drow_gate)", eliteCount) + } + if g.Nodes["underdark.drow_gate"].Kind != NodeKindElite { + t.Error("D10: drow_gate (R2→R4 boundary) should be a region-guardian Elite") } }