mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Long expeditions D1-e: T5 zones to new 36-44 room band
Closes D1. Both T5 zones follow the D1-a/b/c/d pattern: extend the graph so the longest entry→boss walk lands in band, keep the zone's authored topology intact. Also backfills the missing per-node RegionID authoring against dnd_expedition_region.go — the D1-d deferral note flagged this as a prerequisite for T5. dragons_lair (T5): 12 → 47 nodes, longest 38 (band 36-44) abyss_portal (T5): 13 → 51 nodes, longest 39 (band 36-44) Topology preserved per zone: - Dragon's Lair: 4-region authoring (every node carries a valid RegionID); binary-converging fork1 (ash_bridge TRAP / treasure_vault Perception 15) still converges at the R3 wyrmlings_nest elite; capstone fork2 still 3-way (LockNone / CHA 16 / Perception 17 SECRET LootBias 2.5) with each spur preserving its own named capstone node. New R4 infernax_doors MERGE consolidates the long final hall instead of triplicating it. - Abyss Portal: 4-region authoring; three sequential forks preserved (binary / binary / ternary) — fork1 Perception 16, fork2 CON 16 (full STR/DEX/CON/INT/WIS/CHA roster still covered), fork3 capstone 3-way with reality_seam SECRET LootBias 3.0. New R4 belaxath_doors MERGE consolidates the final tear-approach. MinRooms/MaxRooms re-pitched 9-10 → 36-44 so the dice fallback also lands in band for any future graphless variant.
This commit is contained in:
@@ -663,8 +663,8 @@ func zoneDragonsLair() ZoneDefinition {
|
||||
Faction: "Kobolds, Drakes, Young Dragons, Wyrm",
|
||||
Atmosphere: "Scorched stone, rivers of gold coins half-melted into the floor, kobold warrens as outer defenses, growing heat, the unmistakable smell of something ancient and enormous.",
|
||||
Hook: "The mountain has not erupted in forty years. The locals say it is dormant. The locals are wrong about what lives in mountains. I have prepared an unusually long entry description for this one.",
|
||||
MinRooms: 9,
|
||||
MaxRooms: 10,
|
||||
MinRooms: 36,
|
||||
MaxRooms: 44,
|
||||
Enemies: []ZoneEnemy{
|
||||
{BestiaryID: "kobold", SpawnWeight: 7},
|
||||
{BestiaryID: "guard_drake", SpawnWeight: 5},
|
||||
@@ -711,8 +711,8 @@ func zoneAbyssPortal() ZoneDefinition {
|
||||
Faction: "Demons, Fiends, Corrupted Celestials",
|
||||
Atmosphere: "Reality fractures, impossible geometry, constant low psychic pressure, the feeling of being watched by something that has no eyes.",
|
||||
Hook: "Someone opened a door they should not have opened. The door is still open. Things are still coming through. I am not making jokes about this one.",
|
||||
MinRooms: 9,
|
||||
MaxRooms: 10,
|
||||
MinRooms: 36,
|
||||
MaxRooms: 44,
|
||||
Enemies: []ZoneEnemy{
|
||||
// Phase 4-B (outlier fix): Nalfeshnee was mis-classified
|
||||
// as a standard at T5 — Phase 4-A measured 2.8% win rate
|
||||
|
||||
@@ -1,88 +1,280 @@
|
||||
package plugin
|
||||
|
||||
// Phase G8h — The Abyss Portal branching graph.
|
||||
// The Abyss Portal branching graph — multi-region.
|
||||
//
|
||||
// T5 zone. Shape: three sequential forks at increasing depth — binary,
|
||||
// binary, ternary capstone. The player makes more decisions in this
|
||||
// zone than in any other (3 separate fork prompts), matching the
|
||||
// "things keep getting worse" thematic descent.
|
||||
// Long-expedition plan D1-e: extended from the original 13-node sketch
|
||||
// to the new T5 length band (36–44 rooms traversed). Topology preserves
|
||||
// the G8h design intent — three sequential forks at increasing depth
|
||||
// (binary, binary, ternary capstone) and CON stat-check coverage — and
|
||||
// adds the linear depth each region now needs to feel like its own
|
||||
// sub-dungeon. D1-e also backfills the missing RegionID authoring per
|
||||
// dnd_expedition_region.go: every node carries a valid RegionID
|
||||
// matching the registry.
|
||||
//
|
||||
// entry → fractured_threshold → fork1 (binary)
|
||||
// ├─[unlocked]── burning_wastes ─┐
|
||||
// └─[Perception DC 16]── silent_chambers ─┤
|
||||
// ↓
|
||||
// fork2_a (binary)
|
||||
// ├─[unlocked]── vrock_aerie (elite marilith) ─┐
|
||||
// └─[CON DC 16]── mind_corridor ───────────────┤
|
||||
// ↓
|
||||
// fork3 (3-way capstone)
|
||||
// ├─[unlocked]── direct_assault → boss
|
||||
// ├─[CHA DC 18]── usurper_throne → boss
|
||||
// └─[Perception DC 18]── reality_seam (secret) → boss
|
||||
// The original "fork3 capstone leaves go straight to boss" shape is
|
||||
// adjusted so the three capstones now converge at a belaxath_doors
|
||||
// MERGE in R4, then walk a short tear-approach to boss. Three distinct
|
||||
// paths to boss are preserved (each via its own capstone node).
|
||||
//
|
||||
// First authored zone with three sequential forks AND first use of
|
||||
// LockStatCheck CON — completing the full ability roster (STR/DEX/
|
||||
// CON/INT/WIS/CHA all appear in shipping zones by G8h). reality_seam
|
||||
// LootBias 3.0 reflects the Abyss capstone secret.
|
||||
// R1 outer_rift preamble (10 nodes):
|
||||
// entry → shattered_path → fractured_threshold → screaming_passage
|
||||
// → ember_walk → outer_rift_descent → tear_in_stone → ruined_arch
|
||||
// → quasit_outpost → fork1
|
||||
//
|
||||
// R2 demon_assembly region:
|
||||
// fork1 — binary, both spurs converge in R2:
|
||||
// burning_wastes spur (unlocked):
|
||||
// burning_wastes → cinder_field → obsidian_plain
|
||||
// silent_chambers spur (Perception DC 16):
|
||||
// silent_chambers → hush_corridor → listening_room
|
||||
// R2 buildup to fork2 (6 nodes):
|
||||
// demon_assembly_gate → assembly_outskirts → nalfeshnee_corridor
|
||||
// → vrock_pillars → echo_atrium → assembly_descent → fork2
|
||||
//
|
||||
// R3 wardens_post region:
|
||||
// fork2 — binary, both spurs converge in R3:
|
||||
// vrock_aerie spur (unlocked, ELITE):
|
||||
// vrock_approach → vrock_aerie (ELITE) → vrock_descent
|
||||
// mind_corridor spur (CON DC 16):
|
||||
// mind_threshold → mind_corridor → echo_passage
|
||||
// R3 buildup to fork3 (6 nodes):
|
||||
// wardens_outer_post → wardens_hall → wardens_chapel →
|
||||
// wardens_descent → tear_approach → wardens_threshold → fork3
|
||||
//
|
||||
// R4 the_tear — capstone 3-way fork:
|
||||
// direct_assault spur (unlocked):
|
||||
// void_charge → direct_assault → fissure_walk
|
||||
// usurper_throne spur (CHA DC 18):
|
||||
// throne_walk → usurper_throne → claimed_path
|
||||
// reality_seam spur (Perception DC 18, SECRET LootBias 3.0):
|
||||
// seam_threshold → reality_seam (SECRET) → through_the_seam
|
||||
//
|
||||
// R4 final approach (MERGE + boss; walk 6 after capstone spur):
|
||||
// belaxath_doors (MERGE) → outer_tear → inner_tear → tear_steps →
|
||||
// final_threshold → boss
|
||||
//
|
||||
// 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.
|
||||
|
||||
func zoneAbyssPortalGraph() ZoneGraph {
|
||||
r1 := "abyss_outer_rift"
|
||||
r2 := "abyss_demon_assembly"
|
||||
r3 := "abyss_wardens_post"
|
||||
r4 := "abyss_the_tear"
|
||||
|
||||
nodes := []ZoneNode{
|
||||
{NodeID: "abyss_portal.entry", Kind: NodeKindEntry, IsEntry: true,
|
||||
// R1 outer_rift preamble.
|
||||
{NodeID: "abyss_portal.entry", Kind: NodeKindEntry, IsEntry: true, RegionID: r1,
|
||||
Label: "The Open Door", PosX: 0, PosY: 2},
|
||||
{NodeID: "abyss_portal.fractured_threshold", Kind: NodeKindExploration,
|
||||
Label: "Fractured Threshold", PosX: 1, PosY: 2},
|
||||
{NodeID: "abyss_portal.fork1", Kind: NodeKindFork,
|
||||
Label: "First Reality-Break", PosX: 2, PosY: 2},
|
||||
{NodeID: "abyss_portal.burning_wastes", Kind: NodeKindExploration,
|
||||
Label: "Burning Wastes", PosX: 3, PosY: 1},
|
||||
{NodeID: "abyss_portal.silent_chambers", Kind: NodeKindExploration,
|
||||
Label: "Silent Chambers", PosX: 3, PosY: 3},
|
||||
{NodeID: "abyss_portal.fork2", Kind: NodeKindFork,
|
||||
Label: "Second Reality-Break", PosX: 4, PosY: 2},
|
||||
{NodeID: "abyss_portal.vrock_aerie", Kind: NodeKindElite,
|
||||
Label: "Marilith's Aerie", PosX: 5, PosY: 1},
|
||||
{NodeID: "abyss_portal.mind_corridor", Kind: NodeKindExploration,
|
||||
Label: "Corridor of Whispers", PosX: 5, PosY: 3},
|
||||
{NodeID: "abyss_portal.fork3", Kind: NodeKindFork,
|
||||
Label: "Third Reality-Break", PosX: 6, PosY: 2},
|
||||
{NodeID: "abyss_portal.direct_assault", Kind: NodeKindExploration,
|
||||
Label: "Direct Assault", PosX: 7, PosY: 1},
|
||||
{NodeID: "abyss_portal.usurper_throne", Kind: NodeKindExploration,
|
||||
Label: "Usurper's Approach", PosX: 7, PosY: 2},
|
||||
{NodeID: "abyss_portal.reality_seam", Kind: NodeKindSecret,
|
||||
Label: "Reality Seam", PosX: 7, PosY: 3,
|
||||
{NodeID: "abyss_portal.shattered_path", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Shattered Path", PosX: 1, PosY: 2},
|
||||
{NodeID: "abyss_portal.fractured_threshold", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Fractured Threshold", PosX: 2, PosY: 2},
|
||||
{NodeID: "abyss_portal.screaming_passage", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Screaming Passage", PosX: 3, PosY: 2},
|
||||
{NodeID: "abyss_portal.ember_walk", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Ember Walk", PosX: 4, PosY: 2},
|
||||
{NodeID: "abyss_portal.outer_rift_descent", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Rift Descent", PosX: 5, PosY: 2},
|
||||
{NodeID: "abyss_portal.tear_in_stone", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Tear in Stone", PosX: 6, PosY: 2},
|
||||
{NodeID: "abyss_portal.ruined_arch", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Ruined Arch", PosX: 7, PosY: 2},
|
||||
{NodeID: "abyss_portal.quasit_outpost", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Quasit Outpost", PosX: 8, PosY: 2},
|
||||
{NodeID: "abyss_portal.fork1", Kind: NodeKindFork, RegionID: r1,
|
||||
Label: "First Reality-Break", PosX: 9, PosY: 2},
|
||||
|
||||
// R2 demon_assembly — fork1 spurs.
|
||||
{NodeID: "abyss_portal.burning_wastes", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Burning Wastes", PosX: 10, PosY: 1},
|
||||
{NodeID: "abyss_portal.cinder_field", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Cinder Field", PosX: 11, PosY: 1},
|
||||
{NodeID: "abyss_portal.obsidian_plain", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Obsidian Plain", PosX: 12, PosY: 1},
|
||||
|
||||
{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,
|
||||
Label: "Hush Corridor", PosX: 11, PosY: 3},
|
||||
{NodeID: "abyss_portal.listening_room", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Listening Room", PosX: 12, PosY: 3},
|
||||
|
||||
// R2 buildup to fork2.
|
||||
{NodeID: "abyss_portal.demon_assembly_gate", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Demon Assembly Gate", PosX: 13, PosY: 2},
|
||||
{NodeID: "abyss_portal.assembly_outskirts", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Assembly Outskirts", PosX: 14, PosY: 2},
|
||||
{NodeID: "abyss_portal.nalfeshnee_corridor", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Nalfeshnee Corridor", PosX: 15, PosY: 2},
|
||||
{NodeID: "abyss_portal.vrock_pillars", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Vrock Pillars", PosX: 16, PosY: 2},
|
||||
{NodeID: "abyss_portal.echo_atrium", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Echo Atrium", PosX: 17, PosY: 2},
|
||||
{NodeID: "abyss_portal.assembly_descent", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Assembly Descent", PosX: 18, PosY: 2},
|
||||
{NodeID: "abyss_portal.fork2", Kind: NodeKindFork, RegionID: r2,
|
||||
Label: "Second Reality-Break", PosX: 19, PosY: 2},
|
||||
|
||||
// R3 wardens_post — fork2 spurs.
|
||||
{NodeID: "abyss_portal.vrock_approach", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Vrock Approach", PosX: 20, PosY: 1},
|
||||
{NodeID: "abyss_portal.vrock_aerie", Kind: NodeKindElite, RegionID: r3,
|
||||
Label: "Marilith's Aerie", PosX: 21, PosY: 1},
|
||||
{NodeID: "abyss_portal.vrock_descent", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Vrock Descent", PosX: 22, PosY: 1},
|
||||
|
||||
{NodeID: "abyss_portal.mind_threshold", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Mind Threshold", PosX: 20, PosY: 3},
|
||||
{NodeID: "abyss_portal.mind_corridor", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Corridor of Whispers", PosX: 21, PosY: 3},
|
||||
{NodeID: "abyss_portal.echo_passage", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Echo Passage", PosX: 22, PosY: 3},
|
||||
|
||||
// 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,
|
||||
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},
|
||||
{NodeID: "abyss_portal.wardens_descent", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Warden's Descent", PosX: 26, PosY: 2},
|
||||
{NodeID: "abyss_portal.tear_approach", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Tear Approach", PosX: 27, PosY: 2},
|
||||
{NodeID: "abyss_portal.wardens_threshold", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Warden's Threshold", PosX: 28, PosY: 2},
|
||||
{NodeID: "abyss_portal.fork3", Kind: NodeKindFork, RegionID: r3,
|
||||
Label: "Third Reality-Break", PosX: 29, PosY: 2},
|
||||
|
||||
// R4 capstone spurs.
|
||||
{NodeID: "abyss_portal.void_charge", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Void Charge", PosX: 30, PosY: 1},
|
||||
{NodeID: "abyss_portal.direct_assault", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Direct Assault", PosX: 31, PosY: 1},
|
||||
{NodeID: "abyss_portal.fissure_walk", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Fissure Walk", PosX: 32, PosY: 1},
|
||||
|
||||
{NodeID: "abyss_portal.throne_walk", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Throne Walk", PosX: 30, PosY: 2},
|
||||
{NodeID: "abyss_portal.usurper_throne", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Usurper's Approach", PosX: 31, PosY: 2},
|
||||
{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,
|
||||
Label: "Seam Threshold", PosX: 30, PosY: 3},
|
||||
{NodeID: "abyss_portal.reality_seam", Kind: NodeKindSecret, RegionID: r4,
|
||||
Label: "Reality Seam", PosX: 31, PosY: 3,
|
||||
Content: ZoneNodeContent{LootBias: 3.0}},
|
||||
{NodeID: "abyss_portal.boss", Kind: NodeKindBoss, IsBoss: true,
|
||||
Label: "Belaxath's Throne", PosX: 8, PosY: 2},
|
||||
{NodeID: "abyss_portal.through_the_seam", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Through the Seam", PosX: 32, PosY: 3},
|
||||
|
||||
// R4 final approach.
|
||||
{NodeID: "abyss_portal.belaxath_doors", Kind: NodeKindMerge, RegionID: r4,
|
||||
Label: "The Belaxath Doors", PosX: 33, PosY: 2},
|
||||
{NodeID: "abyss_portal.outer_tear", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Outer Tear", PosX: 34, PosY: 2},
|
||||
{NodeID: "abyss_portal.inner_tear", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Inner Tear", PosX: 35, PosY: 2},
|
||||
{NodeID: "abyss_portal.tear_steps", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Tear Steps", PosX: 36, PosY: 2},
|
||||
{NodeID: "abyss_portal.final_threshold", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Final Threshold", PosX: 37, PosY: 2},
|
||||
{NodeID: "abyss_portal.boss", Kind: NodeKindBoss, IsBoss: true, RegionID: r4,
|
||||
Label: "Belaxath's Throne", PosX: 38, PosY: 2},
|
||||
}
|
||||
edges := []ZoneEdge{
|
||||
{From: "abyss_portal.entry", To: "abyss_portal.fractured_threshold", Lock: LockNone},
|
||||
{From: "abyss_portal.fractured_threshold", To: "abyss_portal.fork1", Lock: LockNone},
|
||||
// Fork1 — perception side path.
|
||||
// R1 preamble.
|
||||
{From: "abyss_portal.entry", To: "abyss_portal.shattered_path", Lock: LockNone},
|
||||
{From: "abyss_portal.shattered_path", To: "abyss_portal.fractured_threshold", Lock: LockNone},
|
||||
{From: "abyss_portal.fractured_threshold", To: "abyss_portal.screaming_passage", Lock: LockNone},
|
||||
{From: "abyss_portal.screaming_passage", To: "abyss_portal.ember_walk", Lock: LockNone},
|
||||
{From: "abyss_portal.ember_walk", To: "abyss_portal.outer_rift_descent", Lock: LockNone},
|
||||
{From: "abyss_portal.outer_rift_descent", To: "abyss_portal.tear_in_stone", Lock: LockNone},
|
||||
{From: "abyss_portal.tear_in_stone", To: "abyss_portal.ruined_arch", Lock: LockNone},
|
||||
{From: "abyss_portal.ruined_arch", To: "abyss_portal.quasit_outpost", Lock: LockNone},
|
||||
{From: "abyss_portal.quasit_outpost", To: "abyss_portal.fork1", Lock: LockNone},
|
||||
|
||||
// Fork1 — binary (R1 → R2).
|
||||
{From: "abyss_portal.fork1", To: "abyss_portal.burning_wastes", Lock: LockNone, Weight: 1},
|
||||
{From: "abyss_portal.fork1", To: "abyss_portal.silent_chambers",
|
||||
Lock: LockPerception, LockData: map[string]any{"dc": 16},
|
||||
Hint: "a silence so complete you can hear your own pulse — and something else's", Weight: 2},
|
||||
{From: "abyss_portal.burning_wastes", To: "abyss_portal.fork2", Lock: LockNone},
|
||||
{From: "abyss_portal.silent_chambers", To: "abyss_portal.fork2", Lock: LockNone},
|
||||
// Fork2 — CON wall (psychic pressure).
|
||||
{From: "abyss_portal.fork2", To: "abyss_portal.vrock_aerie", Lock: LockNone, Weight: 1},
|
||||
{From: "abyss_portal.fork2", To: "abyss_portal.mind_corridor",
|
||||
|
||||
// burning_wastes spur → demon_assembly_gate.
|
||||
{From: "abyss_portal.burning_wastes", To: "abyss_portal.cinder_field", Lock: LockNone},
|
||||
{From: "abyss_portal.cinder_field", To: "abyss_portal.obsidian_plain", Lock: LockNone},
|
||||
{From: "abyss_portal.obsidian_plain", To: "abyss_portal.demon_assembly_gate", Lock: LockNone},
|
||||
|
||||
// silent_chambers spur → demon_assembly_gate.
|
||||
{From: "abyss_portal.silent_chambers", To: "abyss_portal.hush_corridor", Lock: LockNone},
|
||||
{From: "abyss_portal.hush_corridor", To: "abyss_portal.listening_room", Lock: LockNone},
|
||||
{From: "abyss_portal.listening_room", To: "abyss_portal.demon_assembly_gate", Lock: LockNone},
|
||||
|
||||
// R2 buildup to fork2.
|
||||
{From: "abyss_portal.demon_assembly_gate", To: "abyss_portal.assembly_outskirts", Lock: LockNone},
|
||||
{From: "abyss_portal.assembly_outskirts", To: "abyss_portal.nalfeshnee_corridor", Lock: LockNone},
|
||||
{From: "abyss_portal.nalfeshnee_corridor", To: "abyss_portal.vrock_pillars", Lock: LockNone},
|
||||
{From: "abyss_portal.vrock_pillars", To: "abyss_portal.echo_atrium", Lock: LockNone},
|
||||
{From: "abyss_portal.echo_atrium", To: "abyss_portal.assembly_descent", Lock: LockNone},
|
||||
{From: "abyss_portal.assembly_descent", To: "abyss_portal.fork2", Lock: LockNone},
|
||||
|
||||
// Fork2 — binary (R2 → R3).
|
||||
{From: "abyss_portal.fork2", To: "abyss_portal.vrock_approach", Lock: LockNone, Weight: 1},
|
||||
{From: "abyss_portal.fork2", To: "abyss_portal.mind_threshold",
|
||||
Lock: LockStatCheck, LockData: map[string]any{"stat": "CON", "dc": 16},
|
||||
Hint: "the air thickens — your bones know the wrong direction is also down", Weight: 2},
|
||||
{From: "abyss_portal.vrock_aerie", To: "abyss_portal.fork3", Lock: LockNone},
|
||||
{From: "abyss_portal.mind_corridor", To: "abyss_portal.fork3", Lock: LockNone},
|
||||
// Fork3 — capstone 3-way.
|
||||
{From: "abyss_portal.fork3", To: "abyss_portal.direct_assault", Lock: LockNone, Weight: 1},
|
||||
{From: "abyss_portal.fork3", To: "abyss_portal.usurper_throne",
|
||||
|
||||
// vrock_aerie spur → wardens_outer_post.
|
||||
{From: "abyss_portal.vrock_approach", To: "abyss_portal.vrock_aerie", Lock: LockNone},
|
||||
{From: "abyss_portal.vrock_aerie", To: "abyss_portal.vrock_descent", Lock: LockNone},
|
||||
{From: "abyss_portal.vrock_descent", To: "abyss_portal.wardens_outer_post", Lock: LockNone},
|
||||
|
||||
// mind_corridor spur → wardens_outer_post.
|
||||
{From: "abyss_portal.mind_threshold", To: "abyss_portal.mind_corridor", Lock: LockNone},
|
||||
{From: "abyss_portal.mind_corridor", To: "abyss_portal.echo_passage", Lock: LockNone},
|
||||
{From: "abyss_portal.echo_passage", To: "abyss_portal.wardens_outer_post", Lock: LockNone},
|
||||
|
||||
// R3 buildup to fork3.
|
||||
{From: "abyss_portal.wardens_outer_post", To: "abyss_portal.wardens_hall", Lock: LockNone},
|
||||
{From: "abyss_portal.wardens_hall", To: "abyss_portal.wardens_chapel", Lock: LockNone},
|
||||
{From: "abyss_portal.wardens_chapel", To: "abyss_portal.wardens_descent", Lock: LockNone},
|
||||
{From: "abyss_portal.wardens_descent", To: "abyss_portal.tear_approach", Lock: LockNone},
|
||||
{From: "abyss_portal.tear_approach", To: "abyss_portal.wardens_threshold", Lock: LockNone},
|
||||
{From: "abyss_portal.wardens_threshold", To: "abyss_portal.fork3", Lock: LockNone},
|
||||
|
||||
// Fork3 — capstone 3-way (R3 → R4).
|
||||
{From: "abyss_portal.fork3", To: "abyss_portal.void_charge", Lock: LockNone, Weight: 1},
|
||||
{From: "abyss_portal.fork3", To: "abyss_portal.throne_walk",
|
||||
Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 18},
|
||||
Hint: "Belaxath has not noticed you yet — claim authority before he does", Weight: 2},
|
||||
{From: "abyss_portal.fork3", To: "abyss_portal.reality_seam",
|
||||
{From: "abyss_portal.fork3", To: "abyss_portal.seam_threshold",
|
||||
Lock: LockPerception, LockData: map[string]any{"dc": 18},
|
||||
Hint: "a hairline crack in the air itself — somewhere there is a place that is not here", Weight: 3},
|
||||
{From: "abyss_portal.direct_assault", To: "abyss_portal.boss", Lock: LockNone},
|
||||
{From: "abyss_portal.usurper_throne", To: "abyss_portal.boss", Lock: LockNone},
|
||||
{From: "abyss_portal.reality_seam", To: "abyss_portal.boss", Lock: LockNone},
|
||||
|
||||
// direct_assault spur → merge.
|
||||
{From: "abyss_portal.void_charge", To: "abyss_portal.direct_assault", Lock: LockNone},
|
||||
{From: "abyss_portal.direct_assault", To: "abyss_portal.fissure_walk", Lock: LockNone},
|
||||
{From: "abyss_portal.fissure_walk", To: "abyss_portal.belaxath_doors", Lock: LockNone},
|
||||
|
||||
// usurper_throne spur → merge.
|
||||
{From: "abyss_portal.throne_walk", To: "abyss_portal.usurper_throne", Lock: LockNone},
|
||||
{From: "abyss_portal.usurper_throne", To: "abyss_portal.claimed_path", Lock: LockNone},
|
||||
{From: "abyss_portal.claimed_path", To: "abyss_portal.belaxath_doors", Lock: LockNone},
|
||||
|
||||
// reality_seam spur → merge.
|
||||
{From: "abyss_portal.seam_threshold", To: "abyss_portal.reality_seam", Lock: LockNone},
|
||||
{From: "abyss_portal.reality_seam", To: "abyss_portal.through_the_seam", Lock: LockNone},
|
||||
{From: "abyss_portal.through_the_seam", To: "abyss_portal.belaxath_doors", Lock: LockNone},
|
||||
|
||||
// R4 final approach.
|
||||
{From: "abyss_portal.belaxath_doors", To: "abyss_portal.outer_tear", Lock: LockNone},
|
||||
{From: "abyss_portal.outer_tear", To: "abyss_portal.inner_tear", Lock: LockNone},
|
||||
{From: "abyss_portal.inner_tear", To: "abyss_portal.tear_steps", Lock: LockNone},
|
||||
{From: "abyss_portal.tear_steps", To: "abyss_portal.final_threshold", Lock: LockNone},
|
||||
{From: "abyss_portal.final_threshold", To: "abyss_portal.boss", Lock: LockNone},
|
||||
}
|
||||
return BuildGraph(ZoneAbyssPortal, nodes, edges)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,59 @@ func TestAbyssPortalGraph_Registered(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("zoneAbyssPortalGraph not registered")
|
||||
}
|
||||
if len(g.Nodes) != 13 {
|
||||
t.Errorf("nodes = %d, want 13", len(g.Nodes))
|
||||
// Long-expedition D1-e widened this zone from 13 → 51 nodes so the
|
||||
// longest entry→boss walk lands in the T5 [36,44] traversal band.
|
||||
if len(g.Nodes) != 51 {
|
||||
t.Errorf("nodes = %d, want 51", len(g.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbyssPortalGraph_LongestPathInBand(t *testing.T) {
|
||||
g := zoneAbyssPortalGraph()
|
||||
got := graphLongestPath(g)
|
||||
if got < 36 || got > 44 {
|
||||
t.Errorf("longest path = %d, want in T5 band [36,44]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAbyssPortalGraph_AllNodesHaveRegion confirms D1-e backfilled the
|
||||
// missing RegionID authoring per dnd_expedition_region.go: every node
|
||||
// carries a non-empty RegionID matching the registry.
|
||||
func TestAbyssPortalGraph_AllNodesHaveRegion(t *testing.T) {
|
||||
g := zoneAbyssPortalGraph()
|
||||
validRegions := map[string]bool{
|
||||
"abyss_outer_rift": true,
|
||||
"abyss_demon_assembly": true,
|
||||
"abyss_wardens_post": true,
|
||||
"abyss_the_tear": true,
|
||||
}
|
||||
for id, n := range g.Nodes {
|
||||
if n.RegionID == "" {
|
||||
t.Errorf("node %s has empty RegionID — D1-e requires region authoring on every node", id)
|
||||
}
|
||||
if !validRegions[n.RegionID] {
|
||||
t.Errorf("node %s RegionID = %q, not in dnd_expedition_region.go registry", id, n.RegionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAbyssPortalGraph_AllFourRegionsRepresented confirms each authored
|
||||
// region has at least one node.
|
||||
func TestAbyssPortalGraph_AllFourRegionsRepresented(t *testing.T) {
|
||||
g := zoneAbyssPortalGraph()
|
||||
regions := map[string]int{}
|
||||
for _, n := range g.Nodes {
|
||||
regions[n.RegionID]++
|
||||
}
|
||||
for _, r := range []string{
|
||||
"abyss_outer_rift",
|
||||
"abyss_demon_assembly",
|
||||
"abyss_wardens_post",
|
||||
"abyss_the_tear",
|
||||
} {
|
||||
if regions[r] == 0 {
|
||||
t.Errorf("region %q has no nodes — multi-region invariant broken", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +1,258 @@
|
||||
package plugin
|
||||
|
||||
// Phase G8g — Dragon's Lair (Infernus Peak) branching graph.
|
||||
// Dragon's Lair (Infernus Peak) branching graph — multi-region.
|
||||
//
|
||||
// T5 zone. Shape: long approach + mid-zone fork (converging at the
|
||||
// elite) + capstone 3-way fork at the boss antechamber. Combines two
|
||||
// distinct fork styles in one zone: a binary-converging fork and a
|
||||
// triple-spread capstone — escalating the player's decision-weight as
|
||||
// they descend.
|
||||
// Long-expedition plan D1-e: extended from the original 12-node sketch
|
||||
// to the new T5 length band (36–44 rooms traversed). Topology preserves
|
||||
// the G8g design intent — long approach, mid-zone binary-converging
|
||||
// fork, capstone 3-way fork — and adds the linear depth each region
|
||||
// now needs to feel like its own sub-dungeon. D1-e also backfills the
|
||||
// missing RegionID authoring per dnd_expedition_region.go: every node
|
||||
// carries a valid RegionID matching the registry.
|
||||
//
|
||||
// entry → kobold_warrens → drake_pens → fork1 (binary, converges)
|
||||
// ├─[unlocked]── ash_bridge ────┐
|
||||
// └─[Perception DC 15]── treasure_vault ─┤
|
||||
// ↓
|
||||
// wyrmlings_nest (elite young red dragon)
|
||||
// ↓
|
||||
// fork2 (capstone, 3-way)
|
||||
// ├─[unlocked]── direct_confrontation → boss
|
||||
// ├─[CHA DC 16]── dragon_bargain → boss
|
||||
// └─[Perception DC 17]── hoard_pillar (secret) → boss
|
||||
// The original "fork2 capstone leaves go straight to boss" shape is
|
||||
// adjusted so the three capstones now converge at an infernax_doors
|
||||
// MERGE in R4, then walk a short throne approach to boss. Three
|
||||
// distinct paths to boss are preserved (each via its own capstone
|
||||
// node); the merge just consolidates the long final hall instead of
|
||||
// triplicating it.
|
||||
//
|
||||
// First authored zone with two distinct fork stages where the first
|
||||
// converges (diamond-style) and the second spreads (3-way capstone).
|
||||
// Earlier zones used either-or: stacked equal-width hubs (Manor) or
|
||||
// late-only fork (Underforge). The escalation pattern ("commit, then
|
||||
// commit harder") is unique to T5.
|
||||
// R1 kobold_warrens preamble (10 nodes):
|
||||
// entry → kobold_lookout → kobold_warrens → smoke_choked_hall →
|
||||
// ash_chapel → kobold_warchief_camp → ember_corridor →
|
||||
// obsidian_steps → cinder_passage → kobold_descent
|
||||
//
|
||||
// R2 drake_pens region (13 nodes; walk length 10):
|
||||
// drake_pens → drake_grooming_pit → drake_holding_run →
|
||||
// drake_yards → drake_handlers_hall → drake_armory → fork1
|
||||
//
|
||||
// R2 fork1 — binary, converges at wyrmlings_nest (R3 boundary):
|
||||
// ash_bridge spur (unlocked, TRAP):
|
||||
// ash_bridge (TRAP) → burning_span → cinder_walk
|
||||
// treasure_vault spur (Perception DC 15, denser loot):
|
||||
// treasure_vault → coin_strewn_hall → vault_passage
|
||||
//
|
||||
// R3 the_vault region (9 nodes; walk length 9):
|
||||
// wyrmlings_nest (ELITE) → hoard_outer → coin_river → hoard_arch →
|
||||
// flame_corridor → infernax_threshold → molten_steps → inferno_walk
|
||||
// → fork2
|
||||
//
|
||||
// R4 infernax_chamber — capstone 3-way fork:
|
||||
// direct_confrontation spur (unlocked):
|
||||
// charge_walk → direct_confrontation → broken_chamber
|
||||
// dragon_bargain spur (CHA DC 16):
|
||||
// speakers_step → dragon_bargain → audience_hall
|
||||
// hoard_pillar spur (Perception DC 17, SECRET LootBias 2.5):
|
||||
// hidden_passage → hoard_pillar (SECRET) → secret_arch
|
||||
//
|
||||
// R4 final approach (MERGE + boss; walk 6 after capstone spur):
|
||||
// infernax_doors (MERGE) → throne_ascension →
|
||||
// molten_throne_approach → crown_steps → final_step → boss
|
||||
//
|
||||
// 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.
|
||||
|
||||
func zoneDragonsLairGraph() ZoneGraph {
|
||||
r1 := "dragons_lair_kobold_warrens"
|
||||
r2 := "dragons_lair_drake_pens"
|
||||
r3 := "dragons_lair_the_vault"
|
||||
r4 := "dragons_lair_infernax_chamber"
|
||||
|
||||
nodes := []ZoneNode{
|
||||
{NodeID: "dragons_lair.entry", Kind: NodeKindEntry, IsEntry: true,
|
||||
// R1 kobold_warrens preamble.
|
||||
{NodeID: "dragons_lair.entry", Kind: NodeKindEntry, IsEntry: true, RegionID: r1,
|
||||
Label: "Mountain Pass", PosX: 0, PosY: 1},
|
||||
{NodeID: "dragons_lair.kobold_warrens", Kind: NodeKindExploration,
|
||||
Label: "Kobold Warrens", PosX: 1, PosY: 1},
|
||||
{NodeID: "dragons_lair.drake_pens", Kind: NodeKindExploration,
|
||||
Label: "Drake Pens", PosX: 2, PosY: 1},
|
||||
{NodeID: "dragons_lair.fork1", Kind: NodeKindFork,
|
||||
Label: "The Cinder Crossing", PosX: 3, PosY: 1},
|
||||
{NodeID: "dragons_lair.ash_bridge", Kind: NodeKindTrap,
|
||||
Label: "Ash Bridge", PosX: 4, PosY: 0},
|
||||
{NodeID: "dragons_lair.treasure_vault", Kind: NodeKindExploration,
|
||||
Label: "Treasure Vault", PosX: 4, PosY: 2},
|
||||
{NodeID: "dragons_lair.wyrmlings_nest", Kind: NodeKindElite,
|
||||
Label: "Wyrmling's Nest", PosX: 5, PosY: 1},
|
||||
{NodeID: "dragons_lair.fork2", Kind: NodeKindFork,
|
||||
Label: "Hoard Approach", PosX: 6, PosY: 1},
|
||||
{NodeID: "dragons_lair.direct_confrontation", Kind: NodeKindExploration,
|
||||
Label: "Direct Approach", PosX: 7, PosY: 0},
|
||||
{NodeID: "dragons_lair.dragon_bargain", Kind: NodeKindExploration,
|
||||
Label: "Words With Infernax", PosX: 7, PosY: 1},
|
||||
{NodeID: "dragons_lair.hoard_pillar", Kind: NodeKindSecret,
|
||||
Label: "Hidden Hoard Pillar", PosX: 7, PosY: 2,
|
||||
{NodeID: "dragons_lair.kobold_lookout", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Kobold Lookout", PosX: 1, PosY: 1},
|
||||
{NodeID: "dragons_lair.kobold_warrens", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Kobold Warrens", PosX: 2, PosY: 1},
|
||||
{NodeID: "dragons_lair.smoke_choked_hall", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Smoke-Choked Hall", PosX: 3, PosY: 1},
|
||||
{NodeID: "dragons_lair.ash_chapel", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Ash Chapel", PosX: 4, PosY: 1},
|
||||
{NodeID: "dragons_lair.kobold_warchief_camp", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Warchief's Camp", PosX: 5, PosY: 1},
|
||||
{NodeID: "dragons_lair.ember_corridor", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Ember Corridor", PosX: 6, PosY: 1},
|
||||
{NodeID: "dragons_lair.obsidian_steps", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Obsidian Steps", PosX: 7, PosY: 1},
|
||||
{NodeID: "dragons_lair.cinder_passage", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Cinder Passage", PosX: 8, PosY: 1},
|
||||
{NodeID: "dragons_lair.kobold_descent", Kind: NodeKindExploration, RegionID: r1,
|
||||
Label: "Kobold Descent", PosX: 9, PosY: 1},
|
||||
|
||||
// R2 drake_pens region.
|
||||
{NodeID: "dragons_lair.drake_pens", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Drake Pens", PosX: 10, PosY: 1},
|
||||
{NodeID: "dragons_lair.drake_grooming_pit", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Grooming Pit", PosX: 11, PosY: 1},
|
||||
{NodeID: "dragons_lair.drake_holding_run", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Holding Run", PosX: 12, PosY: 1},
|
||||
{NodeID: "dragons_lair.drake_yards", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Drake Yards", PosX: 13, PosY: 1},
|
||||
{NodeID: "dragons_lair.drake_handlers_hall", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Handler's Hall", PosX: 14, PosY: 1},
|
||||
{NodeID: "dragons_lair.drake_armory", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Drake Armory", PosX: 15, PosY: 1},
|
||||
{NodeID: "dragons_lair.fork1", Kind: NodeKindFork, RegionID: r2,
|
||||
Label: "The Cinder Crossing", PosX: 16, PosY: 1},
|
||||
|
||||
// R2 ash_bridge spur (TRAP, unlocked).
|
||||
{NodeID: "dragons_lair.ash_bridge", Kind: NodeKindTrap, RegionID: r2,
|
||||
Label: "Ash Bridge", PosX: 17, PosY: 0},
|
||||
{NodeID: "dragons_lair.burning_span", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Burning Span", PosX: 18, PosY: 0},
|
||||
{NodeID: "dragons_lair.cinder_walk", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Cinder Walk", PosX: 19, PosY: 0},
|
||||
|
||||
// R2 treasure_vault spur (Perception 15, loot).
|
||||
{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,
|
||||
Label: "Coin-Strewn Hall", PosX: 18, PosY: 2},
|
||||
{NodeID: "dragons_lair.vault_passage", Kind: NodeKindExploration, RegionID: r2,
|
||||
Label: "Vault Passage", PosX: 19, PosY: 2},
|
||||
|
||||
// R3 the_vault region.
|
||||
{NodeID: "dragons_lair.wyrmlings_nest", Kind: NodeKindElite, RegionID: r3,
|
||||
Label: "Wyrmling's Nest", PosX: 20, PosY: 1},
|
||||
{NodeID: "dragons_lair.hoard_outer", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Outer Hoard", PosX: 21, PosY: 1},
|
||||
{NodeID: "dragons_lair.coin_river", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Coin River", PosX: 22, PosY: 1},
|
||||
{NodeID: "dragons_lair.hoard_arch", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Hoard Arch", PosX: 23, PosY: 1},
|
||||
{NodeID: "dragons_lair.flame_corridor", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Flame Corridor", PosX: 24, PosY: 1},
|
||||
{NodeID: "dragons_lair.infernax_threshold", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Infernax's Threshold", PosX: 25, PosY: 1},
|
||||
{NodeID: "dragons_lair.molten_steps", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Molten Steps", PosX: 26, PosY: 1},
|
||||
{NodeID: "dragons_lair.inferno_walk", Kind: NodeKindExploration, RegionID: r3,
|
||||
Label: "Inferno Walk", PosX: 27, PosY: 1},
|
||||
{NodeID: "dragons_lair.fork2", Kind: NodeKindFork, RegionID: r3,
|
||||
Label: "Hoard Approach", PosX: 28, PosY: 1},
|
||||
|
||||
// R4 direct_confrontation spur.
|
||||
{NodeID: "dragons_lair.charge_walk", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Charge Walk", PosX: 29, PosY: 0},
|
||||
{NodeID: "dragons_lair.direct_confrontation", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Direct Approach", PosX: 30, PosY: 0},
|
||||
{NodeID: "dragons_lair.broken_chamber", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Broken Chamber", PosX: 31, PosY: 0},
|
||||
|
||||
// R4 dragon_bargain spur (CHA 16).
|
||||
{NodeID: "dragons_lair.speakers_step", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Speaker's Step", PosX: 29, PosY: 1},
|
||||
{NodeID: "dragons_lair.dragon_bargain", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Words With Infernax", PosX: 30, PosY: 1},
|
||||
{NodeID: "dragons_lair.audience_hall", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Audience Hall", PosX: 31, PosY: 1},
|
||||
|
||||
// R4 hoard_pillar spur (Perception 17, SECRET).
|
||||
{NodeID: "dragons_lair.hidden_passage", Kind: NodeKindExploration, 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,
|
||||
Content: ZoneNodeContent{LootBias: 2.5}},
|
||||
{NodeID: "dragons_lair.boss", Kind: NodeKindBoss, IsBoss: true,
|
||||
Label: "Infernax's Crown", PosX: 8, PosY: 1},
|
||||
{NodeID: "dragons_lair.secret_arch", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Secret Arch", PosX: 31, PosY: 2},
|
||||
|
||||
// R4 final approach.
|
||||
{NodeID: "dragons_lair.infernax_doors", Kind: NodeKindMerge, RegionID: r4,
|
||||
Label: "The Infernax Doors", PosX: 32, PosY: 1},
|
||||
{NodeID: "dragons_lair.throne_ascension", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Throne Ascension", PosX: 33, PosY: 1},
|
||||
{NodeID: "dragons_lair.molten_throne_approach", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Molten Throne Approach", PosX: 34, PosY: 1},
|
||||
{NodeID: "dragons_lair.crown_steps", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Crown Steps", PosX: 35, PosY: 1},
|
||||
{NodeID: "dragons_lair.final_step", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Final Step", PosX: 36, PosY: 1},
|
||||
{NodeID: "dragons_lair.boss", Kind: NodeKindBoss, IsBoss: true, RegionID: r4,
|
||||
Label: "Infernax's Crown", PosX: 37, PosY: 1},
|
||||
}
|
||||
edges := []ZoneEdge{
|
||||
{From: "dragons_lair.entry", To: "dragons_lair.kobold_warrens", Lock: LockNone},
|
||||
{From: "dragons_lair.kobold_warrens", To: "dragons_lair.drake_pens", Lock: LockNone},
|
||||
{From: "dragons_lair.drake_pens", To: "dragons_lair.fork1", Lock: LockNone},
|
||||
// Fork1 — converges at wyrmlings_nest.
|
||||
// R1 preamble.
|
||||
{From: "dragons_lair.entry", To: "dragons_lair.kobold_lookout", Lock: LockNone},
|
||||
{From: "dragons_lair.kobold_lookout", To: "dragons_lair.kobold_warrens", Lock: LockNone},
|
||||
{From: "dragons_lair.kobold_warrens", To: "dragons_lair.smoke_choked_hall", Lock: LockNone},
|
||||
{From: "dragons_lair.smoke_choked_hall", To: "dragons_lair.ash_chapel", Lock: LockNone},
|
||||
{From: "dragons_lair.ash_chapel", To: "dragons_lair.kobold_warchief_camp", Lock: LockNone},
|
||||
{From: "dragons_lair.kobold_warchief_camp", To: "dragons_lair.ember_corridor", Lock: LockNone},
|
||||
{From: "dragons_lair.ember_corridor", To: "dragons_lair.obsidian_steps", Lock: LockNone},
|
||||
{From: "dragons_lair.obsidian_steps", To: "dragons_lair.cinder_passage", Lock: LockNone},
|
||||
{From: "dragons_lair.cinder_passage", To: "dragons_lair.kobold_descent", Lock: LockNone},
|
||||
|
||||
// R1 → R2 transition.
|
||||
{From: "dragons_lair.kobold_descent", To: "dragons_lair.drake_pens", Lock: LockNone},
|
||||
|
||||
// R2 buildup to fork1.
|
||||
{From: "dragons_lair.drake_pens", To: "dragons_lair.drake_grooming_pit", Lock: LockNone},
|
||||
{From: "dragons_lair.drake_grooming_pit", To: "dragons_lair.drake_holding_run", Lock: LockNone},
|
||||
{From: "dragons_lair.drake_holding_run", To: "dragons_lair.drake_yards", Lock: LockNone},
|
||||
{From: "dragons_lair.drake_yards", To: "dragons_lair.drake_handlers_hall", Lock: LockNone},
|
||||
{From: "dragons_lair.drake_handlers_hall", To: "dragons_lair.drake_armory", Lock: LockNone},
|
||||
{From: "dragons_lair.drake_armory", To: "dragons_lair.fork1", Lock: LockNone},
|
||||
|
||||
// Fork1 — binary, converges at wyrmlings_nest (R3 boundary).
|
||||
{From: "dragons_lair.fork1", To: "dragons_lair.ash_bridge", Lock: LockNone, Weight: 1},
|
||||
{From: "dragons_lair.fork1", To: "dragons_lair.treasure_vault",
|
||||
Lock: LockPerception, LockData: map[string]any{"dc": 15},
|
||||
Hint: "a draft from a side passage — and the dull glint of gold beyond it", Weight: 2},
|
||||
{From: "dragons_lair.ash_bridge", To: "dragons_lair.wyrmlings_nest", Lock: LockNone},
|
||||
{From: "dragons_lair.treasure_vault", To: "dragons_lair.wyrmlings_nest", Lock: LockNone},
|
||||
{From: "dragons_lair.wyrmlings_nest", To: "dragons_lair.fork2", Lock: LockNone},
|
||||
// Fork2 — capstone 3-way.
|
||||
{From: "dragons_lair.fork2", To: "dragons_lair.direct_confrontation", Lock: LockNone, Weight: 1},
|
||||
{From: "dragons_lair.fork2", To: "dragons_lair.dragon_bargain",
|
||||
|
||||
// ash_bridge spur.
|
||||
{From: "dragons_lair.ash_bridge", To: "dragons_lair.burning_span", Lock: LockNone},
|
||||
{From: "dragons_lair.burning_span", To: "dragons_lair.cinder_walk", Lock: LockNone},
|
||||
{From: "dragons_lair.cinder_walk", To: "dragons_lair.wyrmlings_nest", Lock: LockNone},
|
||||
|
||||
// treasure_vault spur.
|
||||
{From: "dragons_lair.treasure_vault", To: "dragons_lair.coin_strewn_hall", Lock: LockNone},
|
||||
{From: "dragons_lair.coin_strewn_hall", To: "dragons_lair.vault_passage", Lock: LockNone},
|
||||
{From: "dragons_lair.vault_passage", To: "dragons_lair.wyrmlings_nest", Lock: LockNone},
|
||||
|
||||
// R3 the_vault buildup to fork2.
|
||||
{From: "dragons_lair.wyrmlings_nest", To: "dragons_lair.hoard_outer", Lock: LockNone},
|
||||
{From: "dragons_lair.hoard_outer", To: "dragons_lair.coin_river", Lock: LockNone},
|
||||
{From: "dragons_lair.coin_river", To: "dragons_lair.hoard_arch", Lock: LockNone},
|
||||
{From: "dragons_lair.hoard_arch", To: "dragons_lair.flame_corridor", Lock: LockNone},
|
||||
{From: "dragons_lair.flame_corridor", To: "dragons_lair.infernax_threshold", Lock: LockNone},
|
||||
{From: "dragons_lair.infernax_threshold", To: "dragons_lair.molten_steps", Lock: LockNone},
|
||||
{From: "dragons_lair.molten_steps", To: "dragons_lair.inferno_walk", Lock: LockNone},
|
||||
{From: "dragons_lair.inferno_walk", To: "dragons_lair.fork2", Lock: LockNone},
|
||||
|
||||
// Fork2 — capstone 3-way (R3 → R4).
|
||||
{From: "dragons_lair.fork2", To: "dragons_lair.charge_walk", Lock: LockNone, Weight: 1},
|
||||
{From: "dragons_lair.fork2", To: "dragons_lair.speakers_step",
|
||||
Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 16},
|
||||
Hint: "Infernax speaks. The voice fills the chamber. You could speak back.", Weight: 2},
|
||||
{From: "dragons_lair.fork2", To: "dragons_lair.hoard_pillar",
|
||||
{From: "dragons_lair.fork2", To: "dragons_lair.hidden_passage",
|
||||
Lock: LockPerception, LockData: map[string]any{"dc": 17},
|
||||
Hint: "a single coin standing on edge — a draft, not a tremor", Weight: 3},
|
||||
{From: "dragons_lair.direct_confrontation", To: "dragons_lair.boss", Lock: LockNone},
|
||||
{From: "dragons_lair.dragon_bargain", To: "dragons_lair.boss", Lock: LockNone},
|
||||
{From: "dragons_lair.hoard_pillar", To: "dragons_lair.boss", Lock: LockNone},
|
||||
|
||||
// direct_confrontation spur → merge.
|
||||
{From: "dragons_lair.charge_walk", To: "dragons_lair.direct_confrontation", Lock: LockNone},
|
||||
{From: "dragons_lair.direct_confrontation", To: "dragons_lair.broken_chamber", Lock: LockNone},
|
||||
{From: "dragons_lair.broken_chamber", To: "dragons_lair.infernax_doors", Lock: LockNone},
|
||||
|
||||
// dragon_bargain spur → merge.
|
||||
{From: "dragons_lair.speakers_step", To: "dragons_lair.dragon_bargain", Lock: LockNone},
|
||||
{From: "dragons_lair.dragon_bargain", To: "dragons_lair.audience_hall", Lock: LockNone},
|
||||
{From: "dragons_lair.audience_hall", To: "dragons_lair.infernax_doors", Lock: LockNone},
|
||||
|
||||
// hoard_pillar spur → merge.
|
||||
{From: "dragons_lair.hidden_passage", To: "dragons_lair.hoard_pillar", Lock: LockNone},
|
||||
{From: "dragons_lair.hoard_pillar", To: "dragons_lair.secret_arch", Lock: LockNone},
|
||||
{From: "dragons_lair.secret_arch", To: "dragons_lair.infernax_doors", Lock: LockNone},
|
||||
|
||||
// R4 final approach.
|
||||
{From: "dragons_lair.infernax_doors", To: "dragons_lair.throne_ascension", Lock: LockNone},
|
||||
{From: "dragons_lair.throne_ascension", To: "dragons_lair.molten_throne_approach", Lock: LockNone},
|
||||
{From: "dragons_lair.molten_throne_approach", To: "dragons_lair.crown_steps", Lock: LockNone},
|
||||
{From: "dragons_lair.crown_steps", To: "dragons_lair.final_step", Lock: LockNone},
|
||||
{From: "dragons_lair.final_step", To: "dragons_lair.boss", Lock: LockNone},
|
||||
}
|
||||
return BuildGraph(ZoneDragonsLair, nodes, edges)
|
||||
}
|
||||
|
||||
@@ -7,19 +7,70 @@ func TestDragonsLairGraph_Registered(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("zoneDragonsLairGraph not registered")
|
||||
}
|
||||
if len(g.Nodes) != 12 {
|
||||
t.Errorf("nodes = %d, want 12", len(g.Nodes))
|
||||
// Long-expedition D1-e widened this zone from 12 → 47 nodes so the
|
||||
// longest entry→boss walk lands in the T5 [36,44] traversal band.
|
||||
if len(g.Nodes) != 47 {
|
||||
t.Errorf("nodes = %d, want 47", len(g.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDragonsLairGraph_Fork1Converges verifies the binary mid-fork
|
||||
// converges at wyrmlings_nest.
|
||||
func TestDragonsLairGraph_LongestPathInBand(t *testing.T) {
|
||||
g := zoneDragonsLairGraph()
|
||||
got := graphLongestPath(g)
|
||||
if got < 36 || got > 44 {
|
||||
t.Errorf("longest path = %d, want in T5 band [36,44]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDragonsLairGraph_AllNodesHaveRegion confirms D1-e backfilled the
|
||||
// missing RegionID authoring per dnd_expedition_region.go: every node
|
||||
// carries a non-empty RegionID matching the registry.
|
||||
func TestDragonsLairGraph_AllNodesHaveRegion(t *testing.T) {
|
||||
g := zoneDragonsLairGraph()
|
||||
validRegions := map[string]bool{
|
||||
"dragons_lair_kobold_warrens": true,
|
||||
"dragons_lair_drake_pens": true,
|
||||
"dragons_lair_the_vault": true,
|
||||
"dragons_lair_infernax_chamber": true,
|
||||
}
|
||||
for id, n := range g.Nodes {
|
||||
if n.RegionID == "" {
|
||||
t.Errorf("node %s has empty RegionID — D1-e requires region authoring on every node", id)
|
||||
}
|
||||
if !validRegions[n.RegionID] {
|
||||
t.Errorf("node %s RegionID = %q, not in dnd_expedition_region.go registry", id, n.RegionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDragonsLairGraph_AllFourRegionsRepresented confirms each authored
|
||||
// region has at least one node.
|
||||
func TestDragonsLairGraph_AllFourRegionsRepresented(t *testing.T) {
|
||||
g := zoneDragonsLairGraph()
|
||||
regions := map[string]int{}
|
||||
for _, n := range g.Nodes {
|
||||
regions[n.RegionID]++
|
||||
}
|
||||
for _, r := range []string{
|
||||
"dragons_lair_kobold_warrens",
|
||||
"dragons_lair_drake_pens",
|
||||
"dragons_lair_the_vault",
|
||||
"dragons_lair_infernax_chamber",
|
||||
} {
|
||||
if regions[r] == 0 {
|
||||
t.Errorf("region %q has no nodes — multi-region invariant broken", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDragonsLairGraph_Fork1Converges verifies the binary mid-fork's
|
||||
// two spurs both converge at wyrmlings_nest (R3 boundary).
|
||||
func TestDragonsLairGraph_Fork1Converges(t *testing.T) {
|
||||
g := zoneDragonsLairGraph()
|
||||
for _, mid := range []string{"dragons_lair.ash_bridge", "dragons_lair.treasure_vault"} {
|
||||
outs := g.outgoingEdges(mid)
|
||||
for _, spurTail := range []string{"dragons_lair.cinder_walk", "dragons_lair.vault_passage"} {
|
||||
outs := g.outgoingEdges(spurTail)
|
||||
if len(outs) != 1 || outs[0].To != "dragons_lair.wyrmlings_nest" {
|
||||
t.Errorf("%s outs = %+v, want single edge to wyrmlings_nest", mid, outs)
|
||||
t.Errorf("%s outs = %+v, want single edge to wyrmlings_nest", spurTail, outs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,9 +94,9 @@ func TestDragonsLairGraph_Fork2Capstone(t *testing.T) {
|
||||
}
|
||||
// Locks escalate: open / CHA 16 / Perception 17 (T5 secret bias).
|
||||
for _, e := range outs {
|
||||
if e.To == "dragons_lair.hoard_pillar" {
|
||||
if e.To == "dragons_lair.hidden_passage" {
|
||||
if dc := lockDataInt(e.LockData, "dc", 0); dc < 17 {
|
||||
t.Errorf("hoard_pillar DC = %d, want >= 17 (T5)", dc)
|
||||
t.Errorf("hoard_pillar spur DC = %d, want >= 17 (T5)", dc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user