diff --git a/internal/plugin/dnd_zone.go b/internal/plugin/dnd_zone.go index e7d9026..a816203 100644 --- a/internal/plugin/dnd_zone.go +++ b/internal/plugin/dnd_zone.go @@ -548,8 +548,8 @@ func zoneUnderdark() ZoneDefinition { Faction: "Drow, Mind Flayers, Beholders (far), Ropers, Hook Horrors", Atmosphere: "Absolute darkness, phosphorescent mushroom groves, vast underground seas, carved drow cities in the distance, things older than the surface world.", Hook: "There is a world below the world. It has its own cities, its own wars, its own sky — which is stone, and has never once been kind. I speak more quietly here. Something might be listening.", - MinRooms: 8, - MaxRooms: 10, + MinRooms: 28, + MaxRooms: 34, Enemies: []ZoneEnemy{ // Phase 5-C: underdark ran 88% (band 45-65, way over) // at the T4 centerline — its sibling feywild sat at @@ -605,8 +605,8 @@ func zoneFeywildCrossing() ZoneDefinition { Faction: "Hags, Redcaps, Will-o-Wisps, Fomorians, Unseelie Fey", Atmosphere: "Impossible beauty, treacherous whimsy, time distortion, rules that change without notice, bargains with terrible fine print.", Hook: "The veil between worlds is thin here. Colors are too saturated. The mushrooms are too large. A small creature made of starlight just offered you a deal. I advise extreme caution regarding deals.", - MinRooms: 8, - MaxRooms: 10, + MinRooms: 28, + MaxRooms: 34, Enemies: []ZoneEnemy{ // Phase 5-C: feywild trailed at 54% (band 45-65, but // the design goal was to close the 30pp gap with its diff --git a/internal/plugin/zone_graph_feywild_crossing.go b/internal/plugin/zone_graph_feywild_crossing.go index f90efe5..a67491f 100644 --- a/internal/plugin/zone_graph_feywild_crossing.go +++ b/internal/plugin/zone_graph_feywild_crossing.go @@ -1,77 +1,275 @@ package plugin -// Phase G8f — Feywild Crossing branching graph. +// 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." +// Long-expedition plan D1-d: extended from the original 9-node sketch +// to the new T4 length band (28–34 rooms traversed). Topology preserves +// the G8f design intent — woven first-stage forks (CHA vs Perception, +// no LockNone), hag_circle shared between the two first-stage paths, +// time_eddy grove-exclusive, illusion_garden marsh-exclusive — and +// adds the linear depth each arm now needs. // -// 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) ─┘ +// New in D1-d: a single fae_court MERGE node gathers all three second- +// stage endings (hag_circle / time_eddy / illusion_garden) before the +// final walk to boss. The Court of the Antlered Queen is one venue, so +// the convergence reads thematically; this also avoids triplicating the +// long pre-boss approach. // -// 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). +// Also new: cursed_thicket TRAP anchor in the preamble — every walk +// hits it. The original G8f graph had no Trap node. // -// 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. +// Preamble (10 nodes, ending in fork1): +// entry → twilight_path → veiled_glade → faerie_lights → +// cursed_thicket (TRAP) → revel_road → moonshadow_bridge → +// bargain_walk → fey_market → fork1 +// +// Fork1 → both options locked (CHA vs Perception, no free choice): +// +// Grove approach (8 nodes, CHA DC 14): +// grove_threshold → starlight_path → singing_orchard → mirror_pond +// → prismatic_arbor → moonpetal_clearing → dawnglow_walk → +// glamoured_grove (fork2a) +// +// Marsh approach (8 nodes, Perception DC 14): +// marsh_threshold → wisp_lights → fog_basin → moaning_reeds → +// mire_steps → submerged_stones → drowned_grove → wisp_marsh +// (fork2b) +// +// Fork2a (glamoured_grove) → +// LockNone: hag_corridor_a → dryad_promenade → moonbridge_a → hag_circle +// LockStatCheck DEX 15: eddy_corridor → splintered_minute → unwoven_step → time_eddy +// +// Fork2b (wisp_marsh) → +// LockNone: hag_corridor_b → fen_walk → moonbridge_b → hag_circle (SHARED) +// LockPerception 16: garden_approach → mirror_walk → unseen_path → illusion_garden +// +// Post-elite/secret tails to fae_court MERGE (2 mid-nodes each): +// hag_circle → courtly_walk → hawthorn_arch → fae_court +// time_eddy → backward_corridor → reverberation_hall → fae_court +// illusion_garden → false_arbor → unseen_atrium → fae_court +// +// Final approach (4 mid-nodes + boss): +// fae_court → antlered_approach → queens_promenade → throne_steps → +// glamoured_dais → boss +// +// Longest entry→boss walk lands at 30 nodes (e.g. preamble 10 + grove +// approach 8 + eddy spur 3 + time_eddy 1 + eddy tail 2 + fae_court 1 + +// final approach 4 + boss 1 = 30), inside [28,34]. All four meaningful +// walks (grove/marsh × hag/exclusive) hit the same 30-node target by +// construction. func zoneFeywildCrossingGraph() ZoneGraph { nodes := []ZoneNode{ + // Preamble. {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.veiled_glade", Kind: NodeKindExploration, + Label: "Veiled Glade", PosX: 2, PosY: 2}, + {NodeID: "feywild_crossing.faerie_lights", Kind: NodeKindExploration, + Label: "Faerie Lights", PosX: 3, PosY: 2}, + {NodeID: "feywild_crossing.cursed_thicket", Kind: NodeKindTrap, + Label: "Cursed Thicket", PosX: 4, PosY: 2}, + {NodeID: "feywild_crossing.revel_road", Kind: NodeKindExploration, + Label: "The Revel Road", PosX: 5, PosY: 2}, + {NodeID: "feywild_crossing.moonshadow_bridge", Kind: NodeKindExploration, + Label: "Moonshadow Bridge", PosX: 6, PosY: 2}, + {NodeID: "feywild_crossing.bargain_walk", Kind: NodeKindExploration, + Label: "The Bargain Walk", PosX: 7, PosY: 2}, + {NodeID: "feywild_crossing.fey_market", Kind: NodeKindExploration, + Label: "Hush Market", PosX: 8, PosY: 2}, {NodeID: "feywild_crossing.fork1", Kind: NodeKindFork, - Label: "The Bargain Crossroads", PosX: 2, PosY: 2}, + Label: "The Bargain Crossroads", PosX: 9, PosY: 2}, + + // Grove approach (CHA route to glamoured_grove). + {NodeID: "feywild_crossing.grove_threshold", Kind: NodeKindExploration, + 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, + Label: "Singing Orchard", PosX: 12, PosY: 0}, + {NodeID: "feywild_crossing.mirror_pond", Kind: NodeKindExploration, + Label: "Mirror Pond", PosX: 13, PosY: 0}, + {NodeID: "feywild_crossing.prismatic_arbor", Kind: NodeKindExploration, + Label: "Prismatic Arbor", PosX: 14, PosY: 0}, + {NodeID: "feywild_crossing.moonpetal_clearing", Kind: NodeKindExploration, + Label: "Moonpetal Clearing", PosX: 15, PosY: 0}, + {NodeID: "feywild_crossing.dawnglow_walk", Kind: NodeKindExploration, + Label: "Dawnglow Walk", PosX: 16, PosY: 0}, {NodeID: "feywild_crossing.glamoured_grove", Kind: NodeKindFork, - Label: "Glamoured Grove", PosX: 3, PosY: 1}, + Label: "Glamoured Grove", PosX: 17, PosY: 0}, + + // Marsh approach (Perception route to wisp_marsh). + {NodeID: "feywild_crossing.marsh_threshold", Kind: NodeKindExploration, + Label: "Marsh Threshold", PosX: 10, PosY: 4}, + {NodeID: "feywild_crossing.wisp_lights", Kind: NodeKindExploration, + Label: "Wisp Lights", PosX: 11, PosY: 4}, + {NodeID: "feywild_crossing.fog_basin", Kind: NodeKindExploration, + 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, + Label: "Mire Steps", PosX: 14, PosY: 4}, + {NodeID: "feywild_crossing.submerged_stones", Kind: NodeKindExploration, + Label: "Submerged Stones", PosX: 15, PosY: 4}, + {NodeID: "feywild_crossing.drowned_grove", Kind: NodeKindExploration, + Label: "Drowned Grove", PosX: 16, PosY: 4}, {NodeID: "feywild_crossing.wisp_marsh", Kind: NodeKindFork, - Label: "Wisp Marsh", PosX: 3, PosY: 3}, + Label: "Wisp Marsh", PosX: 17, PosY: 4}, + + // Grove → hag_circle spur. + {NodeID: "feywild_crossing.hag_corridor_a", Kind: NodeKindExploration, + Label: "Whispering Hedge", PosX: 18, PosY: 1}, + {NodeID: "feywild_crossing.dryad_promenade", Kind: NodeKindExploration, + Label: "Dryad Promenade", PosX: 19, PosY: 1}, + {NodeID: "feywild_crossing.moonbridge_a", Kind: NodeKindExploration, + Label: "Moonbridge", PosX: 20, PosY: 1}, + + // Grove → time_eddy spur. + {NodeID: "feywild_crossing.eddy_corridor", Kind: NodeKindExploration, + Label: "Eddy Corridor", PosX: 18, PosY: 0}, + {NodeID: "feywild_crossing.splintered_minute", Kind: NodeKindExploration, + Label: "Splintered Minute", PosX: 19, PosY: 0}, + {NodeID: "feywild_crossing.unwoven_step", Kind: NodeKindExploration, + Label: "Unwoven Step", PosX: 20, PosY: 0}, {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}, + Label: "Time Eddy", PosX: 21, PosY: 0}, + + // Marsh → hag_circle spur. + {NodeID: "feywild_crossing.hag_corridor_b", Kind: NodeKindExploration, + Label: "Sunken Causeway", PosX: 18, PosY: 3}, + {NodeID: "feywild_crossing.fen_walk", Kind: NodeKindExploration, + Label: "Fen Walk", PosX: 19, PosY: 3}, + {NodeID: "feywild_crossing.moonbridge_b", Kind: NodeKindExploration, + Label: "Drowned Moonbridge", PosX: 20, PosY: 3}, + + // Marsh → illusion_garden spur. + {NodeID: "feywild_crossing.garden_approach", Kind: NodeKindExploration, + Label: "Garden Approach", PosX: 18, PosY: 4}, + {NodeID: "feywild_crossing.mirror_walk", Kind: NodeKindExploration, + Label: "Mirror Walk", PosX: 19, PosY: 4}, + {NodeID: "feywild_crossing.unseen_path", Kind: NodeKindExploration, + Label: "Unseen Path", PosX: 20, PosY: 4}, {NodeID: "feywild_crossing.illusion_garden", Kind: NodeKindSecret, - Label: "Illusion Garden", PosX: 4, PosY: 4, + Label: "Illusion Garden", PosX: 21, PosY: 4, Content: ZoneNodeContent{LootBias: 2.0}}, + + // hag_circle (shared elite). + {NodeID: "feywild_crossing.hag_circle", Kind: NodeKindElite, + Label: "Hag Circle", PosX: 21, PosY: 2}, + + // Hag tail. + {NodeID: "feywild_crossing.courtly_walk", Kind: NodeKindExploration, + Label: "Courtly Walk", PosX: 22, PosY: 2}, + {NodeID: "feywild_crossing.hawthorn_arch", Kind: NodeKindExploration, + Label: "Hawthorn Arch", PosX: 23, PosY: 2}, + + // Eddy tail. + {NodeID: "feywild_crossing.backward_corridor", Kind: NodeKindExploration, + Label: "Backward Corridor", PosX: 22, PosY: 0}, + {NodeID: "feywild_crossing.reverberation_hall", Kind: NodeKindExploration, + Label: "Reverberation Hall", PosX: 23, PosY: 0}, + + // Garden tail. + {NodeID: "feywild_crossing.false_arbor", Kind: NodeKindExploration, + Label: "False Arbor", PosX: 22, PosY: 4}, + {NodeID: "feywild_crossing.unseen_atrium", Kind: NodeKindExploration, + Label: "Unseen Atrium", PosX: 23, PosY: 4}, + + // Final approach. + {NodeID: "feywild_crossing.fae_court", Kind: NodeKindMerge, + Label: "Fae Court", PosX: 24, PosY: 2}, + {NodeID: "feywild_crossing.antlered_approach", Kind: NodeKindExploration, + Label: "Antlered Approach", PosX: 25, PosY: 2}, + {NodeID: "feywild_crossing.queens_promenade", Kind: NodeKindExploration, + Label: "Queen's Promenade", PosX: 26, PosY: 2}, + {NodeID: "feywild_crossing.throne_steps", Kind: NodeKindExploration, + Label: "Throne Steps", PosX: 27, PosY: 2}, + {NodeID: "feywild_crossing.glamoured_dais", Kind: NodeKindExploration, + Label: "Glamoured Dais", PosX: 28, PosY: 2}, {NodeID: "feywild_crossing.boss", Kind: NodeKindBoss, IsBoss: true, - Label: "Court of the Antlered Queen", PosX: 5, PosY: 2}, + Label: "Court of the Antlered Queen", PosX: 29, PosY: 2}, } edges := []ZoneEdge{ + // Preamble. {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", + {From: "feywild_crossing.twilight_path", To: "feywild_crossing.veiled_glade", Lock: LockNone}, + {From: "feywild_crossing.veiled_glade", To: "feywild_crossing.faerie_lights", Lock: LockNone}, + {From: "feywild_crossing.faerie_lights", To: "feywild_crossing.cursed_thicket", Lock: LockNone}, + {From: "feywild_crossing.cursed_thicket", To: "feywild_crossing.revel_road", Lock: LockNone}, + {From: "feywild_crossing.revel_road", To: "feywild_crossing.moonshadow_bridge", Lock: LockNone}, + {From: "feywild_crossing.moonshadow_bridge", To: "feywild_crossing.bargain_walk", Lock: LockNone}, + {From: "feywild_crossing.bargain_walk", To: "feywild_crossing.fey_market", Lock: LockNone}, + {From: "feywild_crossing.fey_market", To: "feywild_crossing.fork1", Lock: LockNone}, + + // Fork1 — both options locked (CHA vs. Perception, no LockNone). + {From: "feywild_crossing.fork1", To: "feywild_crossing.grove_threshold", 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", + {From: "feywild_crossing.fork1", To: "feywild_crossing.marsh_threshold", 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", + + // Grove approach. + {From: "feywild_crossing.grove_threshold", To: "feywild_crossing.starlight_path", Lock: LockNone}, + {From: "feywild_crossing.starlight_path", To: "feywild_crossing.singing_orchard", Lock: LockNone}, + {From: "feywild_crossing.singing_orchard", To: "feywild_crossing.mirror_pond", Lock: LockNone}, + {From: "feywild_crossing.mirror_pond", To: "feywild_crossing.prismatic_arbor", Lock: LockNone}, + {From: "feywild_crossing.prismatic_arbor", To: "feywild_crossing.moonpetal_clearing", Lock: LockNone}, + {From: "feywild_crossing.moonpetal_clearing", To: "feywild_crossing.dawnglow_walk", Lock: LockNone}, + {From: "feywild_crossing.dawnglow_walk", To: "feywild_crossing.glamoured_grove", Lock: LockNone}, + + // Marsh approach. + {From: "feywild_crossing.marsh_threshold", To: "feywild_crossing.wisp_lights", Lock: LockNone}, + {From: "feywild_crossing.wisp_lights", To: "feywild_crossing.fog_basin", Lock: LockNone}, + {From: "feywild_crossing.fog_basin", To: "feywild_crossing.moaning_reeds", Lock: LockNone}, + {From: "feywild_crossing.moaning_reeds", To: "feywild_crossing.mire_steps", Lock: LockNone}, + {From: "feywild_crossing.mire_steps", To: "feywild_crossing.submerged_stones", Lock: LockNone}, + {From: "feywild_crossing.submerged_stones", To: "feywild_crossing.drowned_grove", Lock: LockNone}, + {From: "feywild_crossing.drowned_grove", To: "feywild_crossing.wisp_marsh", Lock: LockNone}, + + // glamoured_grove (fork2a) → hag spur (open) | eddy spur (DEX). + {From: "feywild_crossing.glamoured_grove", To: "feywild_crossing.hag_corridor_a", Lock: LockNone, Weight: 1}, + {From: "feywild_crossing.glamoured_grove", To: "feywild_crossing.eddy_corridor", 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", + {From: "feywild_crossing.hag_corridor_a", To: "feywild_crossing.dryad_promenade", Lock: LockNone}, + {From: "feywild_crossing.dryad_promenade", To: "feywild_crossing.moonbridge_a", Lock: LockNone}, + {From: "feywild_crossing.moonbridge_a", To: "feywild_crossing.hag_circle", Lock: LockNone}, + {From: "feywild_crossing.eddy_corridor", To: "feywild_crossing.splintered_minute", Lock: LockNone}, + {From: "feywild_crossing.splintered_minute", To: "feywild_crossing.unwoven_step", Lock: LockNone}, + {From: "feywild_crossing.unwoven_step", To: "feywild_crossing.time_eddy", Lock: LockNone}, + + // wisp_marsh (fork2b) → hag spur (open) | garden spur (Perception secret). + {From: "feywild_crossing.wisp_marsh", To: "feywild_crossing.hag_corridor_b", Lock: LockNone, Weight: 1}, + {From: "feywild_crossing.wisp_marsh", To: "feywild_crossing.garden_approach", 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}, + {From: "feywild_crossing.hag_corridor_b", To: "feywild_crossing.fen_walk", Lock: LockNone}, + {From: "feywild_crossing.fen_walk", To: "feywild_crossing.moonbridge_b", Lock: LockNone}, + {From: "feywild_crossing.moonbridge_b", To: "feywild_crossing.hag_circle", Lock: LockNone}, + {From: "feywild_crossing.garden_approach", To: "feywild_crossing.mirror_walk", Lock: LockNone}, + {From: "feywild_crossing.mirror_walk", To: "feywild_crossing.unseen_path", Lock: LockNone}, + {From: "feywild_crossing.unseen_path", To: "feywild_crossing.illusion_garden", Lock: LockNone}, + + // Post-second-stage tails into fae_court merge. + {From: "feywild_crossing.hag_circle", To: "feywild_crossing.courtly_walk", Lock: LockNone}, + {From: "feywild_crossing.courtly_walk", To: "feywild_crossing.hawthorn_arch", Lock: LockNone}, + {From: "feywild_crossing.hawthorn_arch", To: "feywild_crossing.fae_court", Lock: LockNone}, + {From: "feywild_crossing.time_eddy", To: "feywild_crossing.backward_corridor", Lock: LockNone}, + {From: "feywild_crossing.backward_corridor", To: "feywild_crossing.reverberation_hall", Lock: LockNone}, + {From: "feywild_crossing.reverberation_hall", To: "feywild_crossing.fae_court", Lock: LockNone}, + {From: "feywild_crossing.illusion_garden", To: "feywild_crossing.false_arbor", Lock: LockNone}, + {From: "feywild_crossing.false_arbor", To: "feywild_crossing.unseen_atrium", Lock: LockNone}, + {From: "feywild_crossing.unseen_atrium", To: "feywild_crossing.fae_court", Lock: LockNone}, + + // Final approach. + {From: "feywild_crossing.fae_court", To: "feywild_crossing.antlered_approach", Lock: LockNone}, + {From: "feywild_crossing.antlered_approach", To: "feywild_crossing.queens_promenade", Lock: LockNone}, + {From: "feywild_crossing.queens_promenade", To: "feywild_crossing.throne_steps", Lock: LockNone}, + {From: "feywild_crossing.throne_steps", To: "feywild_crossing.glamoured_dais", Lock: LockNone}, + {From: "feywild_crossing.glamoured_dais", To: "feywild_crossing.boss", Lock: LockNone}, } return BuildGraph(ZoneFeywildCrossing, nodes, edges) } diff --git a/internal/plugin/zone_graph_feywild_crossing_test.go b/internal/plugin/zone_graph_feywild_crossing_test.go index d503fca..57297a9 100644 --- a/internal/plugin/zone_graph_feywild_crossing_test.go +++ b/internal/plugin/zone_graph_feywild_crossing_test.go @@ -7,8 +7,18 @@ func TestFeywildCrossingGraph_Registered(t *testing.T) { if !ok { t.Fatal("zoneFeywildCrossingGraph not registered") } - if len(g.Nodes) != 9 { - t.Errorf("nodes = %d, want 9", len(g.Nodes)) + // Long-expedition D1-d widened this zone from 9 → 53 nodes so the + // longest entry→boss walk lands in the T4 [28,34] traversal band. + if len(g.Nodes) != 53 { + t.Errorf("nodes = %d, want 53", len(g.Nodes)) + } +} + +func TestFeywildCrossingGraph_LongestPathInBand(t *testing.T) { + g := zoneFeywildCrossingGraph() + got := graphLongestPath(g) + if got < 28 || got > 34 { + t.Errorf("longest path = %d, want in T4 band [28,34]", got) } } @@ -73,3 +83,38 @@ func TestFeywildCrossingGraph_FirstCHALock(t *testing.T) { t.Error("expected at least one LockStatCheck CHA edge — Feywild theme") } } + +// TestFeywildCrossingGraph_TrapAnchor verifies D1-d added the missing +// Trap node. Original G8f graph had elite/secret but no trap. +func TestFeywildCrossingGraph_TrapAnchor(t *testing.T) { + g := zoneFeywildCrossingGraph() + var trapCount int + for _, n := range g.Nodes { + if n.Kind == NodeKindTrap { + trapCount++ + } + } + if trapCount != 1 { + t.Errorf("trap nodes = %d, want 1", trapCount) + } +} + +// TestFeywildCrossingGraph_FaeCourtMerge verifies all three second- +// stage endings (hag_circle, time_eddy, illusion_garden) converge at +// the fae_court merge before the final boss approach. D1-d added this +// merge to avoid triplicating the long pre-boss walk. +func TestFeywildCrossingGraph_FaeCourtMerge(t *testing.T) { + g := zoneFeywildCrossingGraph() + for _, ending := range []string{ + "feywild_crossing.hag_circle", + "feywild_crossing.time_eddy", + "feywild_crossing.illusion_garden", + } { + if !reachable(g, ending, "feywild_crossing.fae_court") { + t.Errorf("%s does not reach fae_court merge", ending) + } + } + if !reachable(g, "feywild_crossing.fae_court", "feywild_crossing.boss") { + t.Error("fae_court → boss path missing") + } +} diff --git a/internal/plugin/zone_graph_underdark.go b/internal/plugin/zone_graph_underdark.go index 9422198..e75a44b 100644 --- a/internal/plugin/zone_graph_underdark.go +++ b/internal/plugin/zone_graph_underdark.go @@ -1,31 +1,56 @@ package plugin -// Phase G8i — The Underdark branching graph (multi-region). +// The Underdark branching graph (multi-region). // -// T4 zone. Shape: 3-way regional fork — fork1 routes the player into -// one of three distinct regions (R1 deep, R2 drow, R3 illithid), each -// with its own internal mid-node, all converging at R4 (deep_throne). +// Long-expedition plan D1-d: extended from the original 10-node sketch +// to the new T4 length band (28–34 rooms traversed). Topology preserves +// the G8i design intent — three regional arms converge at the deep +// throne — and adds the linear depth each arm now needs to feel like a +// region in its own right rather than a single side-room. // -// R1 surface_tunnels: entry → tunnel_descent → fork1 -// ┌─[CON DC 15]── deep_chasm (R1, rich harvest) ──────────┐ -// │ │ -// ├─[unlocked]── drow_patrol (R2) → drow_captain (R2 elite)─┤ -// │ ├── throne_approach (R4) → boss (R4) -// └─[Perception DC 16]── psionic_corridor (R3) → mind_flayer (R3 elite)─┘ +// R1 surface_tunnels preamble (8 nodes): +// entry → tunnel_descent → moss_corridor → fungal_grove → +// collapsed_arch (TRAP) → ledge_walk → shrine_to_lolth → fork1 // -// Each colored arm crosses a region boundary, exercising the G6 -// fireGraphRegionTransition hook end-to-end. The four authored regions -// match dnd_expedition_region.go: -// R1 = underdark_surface_tunnels -// R2 = underdark_drow_outpost -// R3 = underdark_illithid_warren -// R4 = underdark_deep_throne +// Fork1 → three regional arms (each crosses a region boundary except +// the deep_chasm arm, which intentionally stays in R1 — same as G8i): // -// Distinct from prior zones in two ways: -// 1. First (and only) authored zone with non-empty RegionID on every -// node. Region transitions fire on fork1→{drow_patrol|psionic_corridor} -// and on the elite→throne_approach edges. -// 2. Convergent triangle: three colored arms, one merge. +// R2 drow_outpost arm (12 nodes): +// drow_patrol → drow_picket → corridor_of_eyes → spider_warren → +// web_passage → drow_chapel → drow_armory → drow_captain (ELITE) → +// captain_quarters → drow_descent → drow_passage → drow_gate +// +// R3 illithid_warren arm (12 nodes): +// psionic_corridor → whispering_hall → silenced_chamber → +// mind_tank_room → thrall_pens → broodling_chamber → +// mind_flayer (ELITE) → flayer_sanctum → illithid_descent → +// illithid_passage → illithid_gate → illithid_threshold +// +// R1 deep_chasm spur (4 nodes — short arm, the “rich-harvest if you +// can take the CON-15 climb” route): +// deep_chasm (HARVEST) → chasm_floor → chasm_bridge → chasm_ascent +// +// R4 deep_throne tail (10 nodes including boss): +// throne_approach (MERGE) → throne_stairs → throne_corridor → +// throne_antechamber → throne_guard_post → throne_doors → +// throne_gallery → throne_inner_hall → throne_steps → boss +// +// Longest entry→boss walk is via either R2 or R3 arm + R4 tail: +// 8 (preamble) + 12 (arm) + 10 (tail) = 30 nodes, inside [28,34]. +// The deep_chasm spur reaches the boss in 8 + 4 + 10 = 22 nodes — a +// faster route that trades the elite encounter for the harvest spur, +// preserving the original “high-CON cost, no elite, denser loot” +// trade-off. +// +// Per-region totals (single-traverse): R1 surface_tunnels = 8 preamble, +// R1 chasm spur = 4 (only one is on any given walk), R2 = 12, R3 = 12, +// R4 = 10. Boundary transitions remain at fork1 → arm-entry and +// arm-tail → throne_approach; the G6 fireGraphRegionTransition hook +// still fires exactly once per fork choice (or twice if the player +// later !region-travels into a non-walked arm). +// +// Trap anchor (Collapsed Arch) is new — the original G8i graph had no +// Trap node. Placed in the R1 preamble so every walk hits it. func zoneUnderdarkGraph() ZoneGraph { r1 := "underdark_surface_tunnels" @@ -34,32 +59,120 @@ func zoneUnderdarkGraph() ZoneGraph { r4 := "underdark_deep_throne" nodes := []ZoneNode{ + // R1 preamble. {NodeID: "underdark.entry", Kind: NodeKindEntry, IsEntry: true, RegionID: r1, Label: "Cave Mouth", PosX: 0, PosY: 2}, {NodeID: "underdark.tunnel_descent", Kind: NodeKindExploration, RegionID: r1, Label: "Tunnel Descent", PosX: 1, PosY: 2}, + {NodeID: "underdark.moss_corridor", Kind: NodeKindExploration, RegionID: r1, + Label: "Glowmoss Corridor", PosX: 2, PosY: 2}, + {NodeID: "underdark.fungal_grove", Kind: NodeKindExploration, RegionID: r1, + Label: "Fungal Grove", PosX: 3, PosY: 2}, + {NodeID: "underdark.collapsed_arch", Kind: NodeKindTrap, RegionID: r1, + Label: "Collapsed Arch", PosX: 4, PosY: 2}, + {NodeID: "underdark.ledge_walk", Kind: NodeKindExploration, RegionID: r1, + Label: "Ledge Walk", PosX: 5, PosY: 2}, + {NodeID: "underdark.shrine_to_lolth", Kind: NodeKindExploration, RegionID: r1, + Label: "Defaced Shrine", PosX: 6, PosY: 2}, {NodeID: "underdark.fork1", Kind: NodeKindFork, RegionID: r1, - Label: "Three-Way Pass", PosX: 2, PosY: 2}, + Label: "Three-Way Pass", PosX: 7, PosY: 2}, + + // R1 deep_chasm spur (HARVEST, stays in surface_tunnels). {NodeID: "underdark.deep_chasm", Kind: NodeKindHarvest, RegionID: r1, - Label: "Deep Chasm", PosX: 3, PosY: 2, + Label: "Deep Chasm", PosX: 8, PosY: 4, Content: ZoneNodeContent{LootBias: 1.8}}, + {NodeID: "underdark.chasm_floor", Kind: NodeKindExploration, RegionID: r1, + Label: "Chasm Floor", PosX: 9, PosY: 4}, + {NodeID: "underdark.chasm_bridge", Kind: NodeKindExploration, RegionID: r1, + Label: "Stone-Web Bridge", PosX: 10, PosY: 4}, + {NodeID: "underdark.chasm_ascent", Kind: NodeKindExploration, RegionID: r1, + Label: "Chasm Ascent", PosX: 11, PosY: 4}, + + // R2 drow_outpost arm. {NodeID: "underdark.drow_patrol", Kind: NodeKindExploration, RegionID: r2, - Label: "Drow Patrol", PosX: 3, PosY: 1}, + Label: "Drow Patrol", PosX: 8, PosY: 0}, + {NodeID: "underdark.drow_picket", Kind: NodeKindExploration, RegionID: r2, + Label: "Sentry Picket", PosX: 9, PosY: 0}, + {NodeID: "underdark.corridor_of_eyes", Kind: NodeKindExploration, RegionID: r2, + Label: "Corridor of Eyes", PosX: 10, PosY: 0}, + {NodeID: "underdark.spider_warren", Kind: NodeKindExploration, RegionID: r2, + Label: "Spider Warren", PosX: 11, PosY: 0}, + {NodeID: "underdark.web_passage", Kind: NodeKindExploration, RegionID: r2, + Label: "Web Passage", PosX: 12, PosY: 0}, + {NodeID: "underdark.drow_chapel", Kind: NodeKindExploration, RegionID: r2, + Label: "Lolth's Chapel", PosX: 13, PosY: 0}, + {NodeID: "underdark.drow_armory", Kind: NodeKindExploration, RegionID: r2, + Label: "Drow Armory", PosX: 14, PosY: 0}, {NodeID: "underdark.drow_captain", Kind: NodeKindElite, RegionID: r2, - Label: "Drow Captain's Camp", PosX: 4, PosY: 1}, + Label: "Drow Captain's Camp", PosX: 15, PosY: 0}, + {NodeID: "underdark.captain_quarters", Kind: NodeKindExploration, RegionID: r2, + Label: "Captain's Quarters", PosX: 16, PosY: 0}, + {NodeID: "underdark.drow_descent", Kind: NodeKindExploration, RegionID: r2, + 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}, + + // R3 illithid_warren arm. {NodeID: "underdark.psionic_corridor", Kind: NodeKindExploration, RegionID: r3, - Label: "Psionic Corridor", PosX: 3, PosY: 3}, + 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, + Label: "Silenced Chamber", PosX: 10, PosY: 2}, + {NodeID: "underdark.mind_tank_room", Kind: NodeKindExploration, RegionID: r3, + Label: "Brine Tanks", PosX: 11, PosY: 2}, + {NodeID: "underdark.thrall_pens", Kind: NodeKindExploration, RegionID: r3, + Label: "Thrall Pens", PosX: 12, PosY: 2}, + {NodeID: "underdark.broodling_chamber", Kind: NodeKindExploration, RegionID: r3, + Label: "Broodling Chamber", PosX: 13, PosY: 2}, {NodeID: "underdark.mind_flayer", Kind: NodeKindElite, RegionID: r3, - Label: "Mind Flayer Elder", PosX: 4, PosY: 3}, + Label: "Mind Flayer Elder", PosX: 14, PosY: 2}, + {NodeID: "underdark.flayer_sanctum", Kind: NodeKindExploration, RegionID: r3, + Label: "Flayer Sanctum", PosX: 15, PosY: 2}, + {NodeID: "underdark.illithid_descent", Kind: NodeKindExploration, RegionID: r3, + Label: "Illithid Descent", PosX: 16, PosY: 2}, + {NodeID: "underdark.illithid_passage", Kind: NodeKindExploration, RegionID: r3, + Label: "Slime Corridor", PosX: 17, PosY: 2}, + {NodeID: "underdark.illithid_gate", Kind: NodeKindExploration, RegionID: r3, + Label: "Illithid Gate", PosX: 18, PosY: 2}, + {NodeID: "underdark.illithid_threshold", Kind: NodeKindExploration, RegionID: r3, + Label: "Threshold of Thought", PosX: 19, PosY: 2}, + + // R4 deep_throne tail. {NodeID: "underdark.throne_approach", Kind: NodeKindMerge, RegionID: r4, - Label: "Throne Approach", PosX: 5, PosY: 2}, + Label: "Throne Approach", PosX: 20, PosY: 2}, + {NodeID: "underdark.throne_stairs", Kind: NodeKindExploration, RegionID: r4, + Label: "Obsidian Stairs", PosX: 21, PosY: 2}, + {NodeID: "underdark.throne_corridor", Kind: NodeKindExploration, RegionID: r4, + Label: "Throne Corridor", PosX: 22, PosY: 2}, + {NodeID: "underdark.throne_antechamber", Kind: NodeKindExploration, RegionID: r4, + Label: "Antechamber", PosX: 23, PosY: 2}, + {NodeID: "underdark.throne_guard_post", Kind: NodeKindExploration, RegionID: r4, + Label: "Guard Post", PosX: 24, PosY: 2}, + {NodeID: "underdark.throne_doors", Kind: NodeKindExploration, RegionID: r4, + Label: "Riven Doors", PosX: 25, PosY: 2}, + {NodeID: "underdark.throne_gallery", Kind: NodeKindExploration, RegionID: r4, + Label: "Throne Gallery", PosX: 26, PosY: 2}, + {NodeID: "underdark.throne_inner_hall", Kind: NodeKindExploration, RegionID: r4, + Label: "Inner Hall", PosX: 27, PosY: 2}, + {NodeID: "underdark.throne_steps", Kind: NodeKindExploration, RegionID: r4, + Label: "Throne Steps", PosX: 28, PosY: 2}, {NodeID: "underdark.boss", Kind: NodeKindBoss, IsBoss: true, RegionID: r4, - Label: "Deep Throne", PosX: 6, PosY: 2}, + Label: "Deep Throne", PosX: 29, PosY: 2}, } edges := []ZoneEdge{ + // R1 preamble. {From: "underdark.entry", To: "underdark.tunnel_descent", Lock: LockNone}, - {From: "underdark.tunnel_descent", To: "underdark.fork1", Lock: LockNone}, - // Fork1 — three regional arms. + {From: "underdark.tunnel_descent", To: "underdark.moss_corridor", Lock: LockNone}, + {From: "underdark.moss_corridor", To: "underdark.fungal_grove", Lock: LockNone}, + {From: "underdark.fungal_grove", To: "underdark.collapsed_arch", Lock: LockNone}, + {From: "underdark.collapsed_arch", To: "underdark.ledge_walk", Lock: LockNone}, + {From: "underdark.ledge_walk", To: "underdark.shrine_to_lolth", Lock: LockNone}, + {From: "underdark.shrine_to_lolth", To: "underdark.fork1", Lock: LockNone}, + + // Fork1 — three regional arms (unchanged lock/hint identity). {From: "underdark.fork1", To: "underdark.drow_patrol", Lock: LockNone, Weight: 1}, {From: "underdark.fork1", To: "underdark.psionic_corridor", Lock: LockPerception, LockData: map[string]any{"dc": 16}, @@ -67,15 +180,51 @@ func zoneUnderdarkGraph() ZoneGraph { {From: "underdark.fork1", To: "underdark.deep_chasm", Lock: LockStatCheck, LockData: map[string]any{"stat": "CON", "dc": 15}, Hint: "a vertical shaft humming with cold air — the climb will hurt", Weight: 3}, + + // R1 spur. + {From: "underdark.deep_chasm", To: "underdark.chasm_floor", Lock: LockNone}, + {From: "underdark.chasm_floor", To: "underdark.chasm_bridge", Lock: LockNone}, + {From: "underdark.chasm_bridge", To: "underdark.chasm_ascent", Lock: LockNone}, + {From: "underdark.chasm_ascent", To: "underdark.throne_approach", Lock: LockNone}, + // R2 arm. - {From: "underdark.drow_patrol", To: "underdark.drow_captain", Lock: LockNone}, - {From: "underdark.drow_captain", To: "underdark.throne_approach", Lock: LockNone}, + {From: "underdark.drow_patrol", To: "underdark.drow_picket", Lock: LockNone}, + {From: "underdark.drow_picket", To: "underdark.corridor_of_eyes", Lock: LockNone}, + {From: "underdark.corridor_of_eyes", To: "underdark.spider_warren", Lock: LockNone}, + {From: "underdark.spider_warren", To: "underdark.web_passage", Lock: LockNone}, + {From: "underdark.web_passage", To: "underdark.drow_chapel", Lock: LockNone}, + {From: "underdark.drow_chapel", To: "underdark.drow_armory", Lock: LockNone}, + {From: "underdark.drow_armory", To: "underdark.drow_captain", Lock: LockNone}, + {From: "underdark.drow_captain", To: "underdark.captain_quarters", Lock: LockNone}, + {From: "underdark.captain_quarters", To: "underdark.drow_descent", Lock: LockNone}, + {From: "underdark.drow_descent", To: "underdark.drow_passage", Lock: LockNone}, + {From: "underdark.drow_passage", To: "underdark.drow_gate", Lock: LockNone}, + {From: "underdark.drow_gate", To: "underdark.throne_approach", Lock: LockNone}, + // R3 arm. - {From: "underdark.psionic_corridor", To: "underdark.mind_flayer", Lock: LockNone}, - {From: "underdark.mind_flayer", To: "underdark.throne_approach", Lock: LockNone}, - // R1 deep arm — single node back to merge. - {From: "underdark.deep_chasm", To: "underdark.throne_approach", Lock: LockNone}, - {From: "underdark.throne_approach", To: "underdark.boss", Lock: LockNone}, + {From: "underdark.psionic_corridor", To: "underdark.whispering_hall", Lock: LockNone}, + {From: "underdark.whispering_hall", To: "underdark.silenced_chamber", Lock: LockNone}, + {From: "underdark.silenced_chamber", To: "underdark.mind_tank_room", Lock: LockNone}, + {From: "underdark.mind_tank_room", To: "underdark.thrall_pens", Lock: LockNone}, + {From: "underdark.thrall_pens", To: "underdark.broodling_chamber", Lock: LockNone}, + {From: "underdark.broodling_chamber", To: "underdark.mind_flayer", Lock: LockNone}, + {From: "underdark.mind_flayer", To: "underdark.flayer_sanctum", Lock: LockNone}, + {From: "underdark.flayer_sanctum", To: "underdark.illithid_descent", Lock: LockNone}, + {From: "underdark.illithid_descent", To: "underdark.illithid_passage", Lock: LockNone}, + {From: "underdark.illithid_passage", To: "underdark.illithid_gate", Lock: LockNone}, + {From: "underdark.illithid_gate", To: "underdark.illithid_threshold", Lock: LockNone}, + {From: "underdark.illithid_threshold", To: "underdark.throne_approach", Lock: LockNone}, + + // R4 tail. + {From: "underdark.throne_approach", To: "underdark.throne_stairs", Lock: LockNone}, + {From: "underdark.throne_stairs", To: "underdark.throne_corridor", Lock: LockNone}, + {From: "underdark.throne_corridor", To: "underdark.throne_antechamber", Lock: LockNone}, + {From: "underdark.throne_antechamber", To: "underdark.throne_guard_post", Lock: LockNone}, + {From: "underdark.throne_guard_post", To: "underdark.throne_doors", Lock: LockNone}, + {From: "underdark.throne_doors", To: "underdark.throne_gallery", Lock: LockNone}, + {From: "underdark.throne_gallery", To: "underdark.throne_inner_hall", Lock: LockNone}, + {From: "underdark.throne_inner_hall", To: "underdark.throne_steps", Lock: LockNone}, + {From: "underdark.throne_steps", To: "underdark.boss", Lock: LockNone}, } return BuildGraph(ZoneUnderdark, nodes, edges) } diff --git a/internal/plugin/zone_graph_underdark_test.go b/internal/plugin/zone_graph_underdark_test.go index 0ce61b4..43f45a9 100644 --- a/internal/plugin/zone_graph_underdark_test.go +++ b/internal/plugin/zone_graph_underdark_test.go @@ -7,8 +7,18 @@ func TestUnderdarkGraph_Registered(t *testing.T) { if !ok { t.Fatal("zoneUnderdarkGraph not registered") } - if len(g.Nodes) != 10 { - t.Errorf("nodes = %d, want 10", len(g.Nodes)) + // Long-expedition D1-d widened this zone from 10 → 46 nodes so the + // longest entry→boss walk lands in the T4 [28,34] traversal band. + if len(g.Nodes) != 46 { + t.Errorf("nodes = %d, want 46", len(g.Nodes)) + } +} + +func TestUnderdarkGraph_LongestPathInBand(t *testing.T) { + g := zoneUnderdarkGraph() + got := graphLongestPath(g) + if got < 28 || got > 34 { + t.Errorf("longest path = %d, want in T4 band [28,34]", got) } } @@ -18,10 +28,10 @@ func TestUnderdarkGraph_Registered(t *testing.T) { func TestUnderdarkGraph_AllNodesHaveRegion(t *testing.T) { g := zoneUnderdarkGraph() validRegions := map[string]bool{ - "underdark_surface_tunnels": true, - "underdark_drow_outpost": true, - "underdark_illithid_warren": true, - "underdark_deep_throne": true, + "underdark_surface_tunnels": true, + "underdark_drow_outpost": true, + "underdark_illithid_warren": true, + "underdark_deep_throne": true, } for id, n := range g.Nodes { if n.RegionID == "" { @@ -95,3 +105,18 @@ 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. +func TestUnderdarkGraph_TrapAnchor(t *testing.T) { + g := zoneUnderdarkGraph() + var trapCount int + for _, n := range g.Nodes { + if n.Kind == NodeKindTrap { + trapCount++ + } + } + if trapCount != 1 { + t.Errorf("trap nodes = %d, want 1", trapCount) + } +}