mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Long expeditions D1-c: T3 zones to new 22-26 room band
Both T3 zones follow the D1-a/b pattern: extend the graph so the longest entry→boss walk lands in band, keep the zone's authored topology intact. manor_blackspire (T3): 11 → 35 nodes, longest 23 (band 22-26) underforge (T3): 10 → 31 nodes, longest 23 (band 22-26) Topology preserved per zone: - Manor: two-stacked-3-way forks identity + full lock-kind coverage (LockNone, LockPerception, LockStatCheck, LockLevelMin). Adds the missing Trap anchor (Cursed Threshold). All six spokes are 3 mid-nodes so route choice is loot/encounter character, not shortcut economics. - Underforge: one-way-descent gauntlet identity preserved — 17-node linear preamble through the existing Cooling River (TRAP) and Magma Chamber (ELITE), single 3-way antechamber fork before boss. All three antechamber spokes are 4 mid-nodes. MinRooms/MaxRooms re-pitched 7-9 → 22-26 so the dice fallback also lands in band for any future graphless variant.
This commit is contained in:
@@ -423,8 +423,8 @@ func zoneManorBlackspire() ZoneDefinition {
|
||||
Faction: "Undead, Shadows, Vampiric",
|
||||
Atmosphere: "Victorian decay, impossible architecture, portraits whose eyes follow movement, cold spots, locked rooms that weren't locked before.",
|
||||
Hook: "The manor has been for sale for eleven years. Every buyer has either left immediately or not left at all. The real estate listing describes it as 'full of character.' I find this accurate.",
|
||||
MinRooms: 7,
|
||||
MaxRooms: 9,
|
||||
MinRooms: 22,
|
||||
MaxRooms: 26,
|
||||
Enemies: []ZoneEnemy{
|
||||
// Phase 4-B (outlier fix): Wraith was the dominant
|
||||
// standard-pool killer (45 hp loss/win, 85 attributed
|
||||
@@ -491,8 +491,8 @@ func zoneUnderforge() ZoneDefinition {
|
||||
Faction: "Fire Elementals, Constructs, Salamanders, Azers",
|
||||
Atmosphere: "Volcanic caverns, rivers of cooling lava, ancient dwarven stonework, the constant bass note of something very large moving below.",
|
||||
Hook: "The dwarven forge-city of Kharak Dûn was not abandoned. It was sealed from the outside. I do not have information on what they were sealing in.",
|
||||
MinRooms: 7,
|
||||
MaxRooms: 9,
|
||||
MinRooms: 22,
|
||||
MaxRooms: 26,
|
||||
Enemies: []ZoneEnemy{
|
||||
// Phase 5-C: underforge trailed at 49.5% (band 55-75)
|
||||
// under the shipped HP×1.5/+3 floor. Trace named Fire
|
||||
|
||||
@@ -1,68 +1,172 @@
|
||||
package plugin
|
||||
|
||||
// Phase G8d — Haunted Manor of Blackspire branching graph.
|
||||
// Haunted Manor of Blackspire branching graph.
|
||||
//
|
||||
// T3 zone. Shape: stacked 3-way "hub" forks. The plan calls T2+ for
|
||||
// "two forks", and a Manor of corridors and locked doors fits a wide-
|
||||
// fork hub-and-spoke pattern more naturally than a binary fork. Two
|
||||
// hub nodes (great_hall, upper_hall), each with three options:
|
||||
// Long-expedition plan D1-c: extended from the original 11-node sketch to
|
||||
// the new T3 length band (22–26 rooms traversed). Topology preserves the
|
||||
// G8d design intent — two stacked 3-way forks ("first zone where the
|
||||
// player picks twice in a row from a wide menu") with full lock-kind
|
||||
// coverage (LockNone, LockPerception, LockStatCheck, LockLevelMin) — and
|
||||
// adds the missing Trap anchor + extends every branch to 3 mid-nodes so
|
||||
// the longest entry→boss walk lands at 23.
|
||||
//
|
||||
// entry → foyer → great_hall (3-way)
|
||||
// ├─[unlocked]── portrait_gallery ─┐
|
||||
// ├─[Perception DC 14]── study ────┼── upper_hall (3-way)
|
||||
// └─[INT DC 14]── library ─────────┘ ├─[unlocked]── master_bedroom (elite) → boss
|
||||
// ├─[Perception DC 15]── hidden_oratory (secret) → boss
|
||||
// └─[LevelMin 7]── tower_observatory → boss
|
||||
// entry → grounds_walk → carriage_path → manor_gate → foyer →
|
||||
// drawing_room → gallery_corridor → cursed_threshold (TRAP) →
|
||||
// grand_staircase → great_hall (3-way fork)
|
||||
// ├─[unlocked]── portrait_gallery → cold_solarium → west_landing ─┐
|
||||
// ├─[Perception DC 14]── locked_study → study_archive → │
|
||||
// │ secretary_hall ─────────────────────── ├── second_floor_landing
|
||||
// └─[INT DC 14]── forbidden_library → library_loft → │
|
||||
// reading_rotunda ────────────────────────────── ┘
|
||||
// │
|
||||
// → portrait_landing → upper_hall (3-way fork) │
|
||||
// ├─[unlocked]── master_bedroom (ELITE) → grim_balcony → │
|
||||
// │ moonlit_passage ─────────────────────────────── ┐
|
||||
// ├─[Perception DC 15]── hidden_oratory (SECRET) → choir_balcony │
|
||||
// │ → reliquary_dust ───────────────────── ├── spire_corridor
|
||||
// └─[LevelMin 7]── tower_observatory → spiral_ascent → │
|
||||
// telescope_attic ───────────────────────────── ┘
|
||||
// → ancestral_gallery → sanctum_threshold → boss
|
||||
//
|
||||
// Two consecutive 3-way fork prompts make this the first zone where
|
||||
// the player picks twice in a row from a wide menu — distinct from the
|
||||
// binary forks of G8a/b and the parallel Y-trees of G8c. Exercises
|
||||
// LockLevelMin (first authored use) so all four non-trivial lock kinds
|
||||
// (Perception, StatCheck, LevelMin, plus implicit LockNone) appear by
|
||||
// G8d.
|
||||
// All six fork branches are 3 mid-nodes long so any route through the
|
||||
// manor lands at the same 23-node traversal length — the player's choice
|
||||
// is about loot/encounter character, not shortcut economics. The ELITE
|
||||
// hangs off the open spoke of upper_hall (cheapest gate, hardest fight);
|
||||
// the SECRET sits behind the highest-DC Perception with its loot bias
|
||||
// preserved.
|
||||
|
||||
func zoneManorBlackspireGraph() ZoneGraph {
|
||||
nodes := []ZoneNode{
|
||||
// Pre-fork preamble (path positions 1–10).
|
||||
{NodeID: "manor_blackspire.entry", Kind: NodeKindEntry, IsEntry: true,
|
||||
Label: "Manor Gate", PosX: 0, PosY: 2},
|
||||
{NodeID: "manor_blackspire.grounds_walk", Kind: NodeKindExploration,
|
||||
Label: "Overgrown Grounds", PosX: 1, PosY: 2},
|
||||
{NodeID: "manor_blackspire.carriage_path", Kind: NodeKindExploration,
|
||||
Label: "Carriage Path", PosX: 2, PosY: 2},
|
||||
{NodeID: "manor_blackspire.manor_gate", Kind: NodeKindExploration,
|
||||
Label: "Iron Gate", PosX: 3, PosY: 2},
|
||||
{NodeID: "manor_blackspire.foyer", Kind: NodeKindExploration,
|
||||
Label: "Foyer", PosX: 1, PosY: 2},
|
||||
Label: "Foyer", PosX: 4, PosY: 2},
|
||||
{NodeID: "manor_blackspire.drawing_room", Kind: NodeKindExploration,
|
||||
Label: "Drawing Room", PosX: 5, PosY: 2},
|
||||
{NodeID: "manor_blackspire.gallery_corridor", Kind: NodeKindExploration,
|
||||
Label: "Gallery Corridor", PosX: 6, PosY: 2},
|
||||
{NodeID: "manor_blackspire.cursed_threshold", Kind: NodeKindTrap,
|
||||
Label: "Cursed Threshold", PosX: 7, PosY: 2},
|
||||
{NodeID: "manor_blackspire.grand_staircase", Kind: NodeKindExploration,
|
||||
Label: "Grand Staircase", PosX: 8, PosY: 2},
|
||||
{NodeID: "manor_blackspire.great_hall", Kind: NodeKindFork,
|
||||
Label: "Great Hall", PosX: 2, PosY: 2},
|
||||
Label: "Great Hall", PosX: 9, PosY: 2},
|
||||
|
||||
// great_hall — open spoke (portrait gallery).
|
||||
{NodeID: "manor_blackspire.portrait_gallery", Kind: NodeKindExploration,
|
||||
Label: "Portrait Gallery", PosX: 3, PosY: 1},
|
||||
{NodeID: "manor_blackspire.study", Kind: NodeKindExploration,
|
||||
Label: "Locked Study", PosX: 3, PosY: 2},
|
||||
{NodeID: "manor_blackspire.library", Kind: NodeKindExploration,
|
||||
Label: "Forbidden Library", PosX: 3, PosY: 3},
|
||||
Label: "Portrait Gallery", PosX: 10, PosY: 0},
|
||||
{NodeID: "manor_blackspire.cold_solarium", Kind: NodeKindExploration,
|
||||
Label: "Cold Solarium", PosX: 11, PosY: 0},
|
||||
{NodeID: "manor_blackspire.west_landing", Kind: NodeKindExploration,
|
||||
Label: "West Landing", PosX: 12, PosY: 0},
|
||||
|
||||
// great_hall — perception spoke (locked study).
|
||||
{NodeID: "manor_blackspire.locked_study", Kind: NodeKindExploration,
|
||||
Label: "Locked Study", PosX: 10, PosY: 2},
|
||||
{NodeID: "manor_blackspire.study_archive", Kind: NodeKindExploration,
|
||||
Label: "Study Archive", PosX: 11, PosY: 2},
|
||||
{NodeID: "manor_blackspire.secretary_hall", Kind: NodeKindExploration,
|
||||
Label: "Secretary's Hall", PosX: 12, PosY: 2},
|
||||
|
||||
// great_hall — INT spoke (forbidden library).
|
||||
{NodeID: "manor_blackspire.forbidden_library", Kind: NodeKindExploration,
|
||||
Label: "Forbidden Library", PosX: 10, PosY: 4},
|
||||
{NodeID: "manor_blackspire.library_loft", Kind: NodeKindExploration,
|
||||
Label: "Library Loft", PosX: 11, PosY: 4},
|
||||
{NodeID: "manor_blackspire.reading_rotunda", Kind: NodeKindExploration,
|
||||
Label: "Reading Rotunda", PosX: 12, PosY: 4},
|
||||
|
||||
// Merge + bridge to upper_hall.
|
||||
{NodeID: "manor_blackspire.second_floor_landing", Kind: NodeKindMerge,
|
||||
Label: "Second-Floor Landing", PosX: 13, PosY: 2},
|
||||
{NodeID: "manor_blackspire.portrait_landing", Kind: NodeKindExploration,
|
||||
Label: "Portrait Landing", PosX: 14, PosY: 2},
|
||||
{NodeID: "manor_blackspire.upper_hall", Kind: NodeKindFork,
|
||||
Label: "Upper Hall", PosX: 4, PosY: 2},
|
||||
Label: "Upper Hall", PosX: 15, PosY: 2},
|
||||
|
||||
// upper_hall — open spoke (elite).
|
||||
{NodeID: "manor_blackspire.master_bedroom", Kind: NodeKindElite,
|
||||
Label: "Master Bedroom", PosX: 5, PosY: 1},
|
||||
{NodeID: "manor_blackspire.tower_observatory", Kind: NodeKindExploration,
|
||||
Label: "Tower Observatory", PosX: 5, PosY: 2},
|
||||
Label: "Master Bedroom", PosX: 16, PosY: 0},
|
||||
{NodeID: "manor_blackspire.grim_balcony", Kind: NodeKindExploration,
|
||||
Label: "Grim Balcony", PosX: 17, PosY: 0},
|
||||
{NodeID: "manor_blackspire.moonlit_passage", Kind: NodeKindExploration,
|
||||
Label: "Moonlit Passage", PosX: 18, PosY: 0},
|
||||
|
||||
// upper_hall — perception spoke (secret).
|
||||
{NodeID: "manor_blackspire.hidden_oratory", Kind: NodeKindSecret,
|
||||
Label: "Hidden Oratory", PosX: 5, PosY: 3,
|
||||
Label: "Hidden Oratory", PosX: 16, PosY: 2,
|
||||
Content: ZoneNodeContent{LootBias: 2.0}},
|
||||
{NodeID: "manor_blackspire.choir_balcony", Kind: NodeKindExploration,
|
||||
Label: "Choir Balcony", PosX: 17, PosY: 2},
|
||||
{NodeID: "manor_blackspire.reliquary_dust", Kind: NodeKindExploration,
|
||||
Label: "Reliquary Dust", PosX: 18, PosY: 2},
|
||||
|
||||
// upper_hall — level-min spoke (tower).
|
||||
{NodeID: "manor_blackspire.tower_observatory", Kind: NodeKindExploration,
|
||||
Label: "Tower Observatory", PosX: 16, PosY: 4},
|
||||
{NodeID: "manor_blackspire.spiral_ascent", Kind: NodeKindExploration,
|
||||
Label: "Spiral Ascent", PosX: 17, PosY: 4},
|
||||
{NodeID: "manor_blackspire.telescope_attic", Kind: NodeKindExploration,
|
||||
Label: "Telescope Attic", PosX: 18, PosY: 4},
|
||||
|
||||
// Merge + boss approach.
|
||||
{NodeID: "manor_blackspire.spire_corridor", Kind: NodeKindMerge,
|
||||
Label: "Spire Corridor", PosX: 19, PosY: 2},
|
||||
{NodeID: "manor_blackspire.ancestral_gallery", Kind: NodeKindExploration,
|
||||
Label: "Ancestral Gallery", PosX: 20, PosY: 2},
|
||||
{NodeID: "manor_blackspire.sanctum_threshold", Kind: NodeKindExploration,
|
||||
Label: "Sanctum Threshold", PosX: 21, PosY: 2},
|
||||
{NodeID: "manor_blackspire.boss", Kind: NodeKindBoss, IsBoss: true,
|
||||
Label: "Aldric's Sanctum", PosX: 6, PosY: 2},
|
||||
Label: "Aldric's Sanctum", PosX: 22, PosY: 2},
|
||||
}
|
||||
edges := []ZoneEdge{
|
||||
{From: "manor_blackspire.entry", To: "manor_blackspire.foyer", Lock: LockNone},
|
||||
{From: "manor_blackspire.foyer", To: "manor_blackspire.great_hall", Lock: LockNone},
|
||||
// Great Hall — 3-way: portrait (open), study (perception), library (intelligence)
|
||||
// Preamble (linear).
|
||||
{From: "manor_blackspire.entry", To: "manor_blackspire.grounds_walk", Lock: LockNone},
|
||||
{From: "manor_blackspire.grounds_walk", To: "manor_blackspire.carriage_path", Lock: LockNone},
|
||||
{From: "manor_blackspire.carriage_path", To: "manor_blackspire.manor_gate", Lock: LockNone},
|
||||
{From: "manor_blackspire.manor_gate", To: "manor_blackspire.foyer", Lock: LockNone},
|
||||
{From: "manor_blackspire.foyer", To: "manor_blackspire.drawing_room", Lock: LockNone},
|
||||
{From: "manor_blackspire.drawing_room", To: "manor_blackspire.gallery_corridor", Lock: LockNone},
|
||||
{From: "manor_blackspire.gallery_corridor", To: "manor_blackspire.cursed_threshold", Lock: LockNone},
|
||||
{From: "manor_blackspire.cursed_threshold", To: "manor_blackspire.grand_staircase", Lock: LockNone},
|
||||
{From: "manor_blackspire.grand_staircase", To: "manor_blackspire.great_hall", Lock: LockNone},
|
||||
|
||||
// great_hall fork — three spokes (open, perception 14, INT 14).
|
||||
{From: "manor_blackspire.great_hall", To: "manor_blackspire.portrait_gallery", Lock: LockNone, Weight: 1},
|
||||
{From: "manor_blackspire.great_hall", To: "manor_blackspire.study",
|
||||
{From: "manor_blackspire.great_hall", To: "manor_blackspire.locked_study",
|
||||
Lock: LockPerception, LockData: map[string]any{"dc": 14},
|
||||
Hint: "an out-of-place draft from a doorframe", Weight: 2},
|
||||
{From: "manor_blackspire.great_hall", To: "manor_blackspire.library",
|
||||
{From: "manor_blackspire.great_hall", To: "manor_blackspire.forbidden_library",
|
||||
Lock: LockStatCheck, LockData: map[string]any{"stat": "INT", "dc": 14},
|
||||
Hint: "a runed door — you'd need to read it", Weight: 2},
|
||||
// All three converge at upper_hall.
|
||||
{From: "manor_blackspire.portrait_gallery", To: "manor_blackspire.upper_hall", Lock: LockNone},
|
||||
{From: "manor_blackspire.study", To: "manor_blackspire.upper_hall", Lock: LockNone},
|
||||
{From: "manor_blackspire.library", To: "manor_blackspire.upper_hall", Lock: LockNone},
|
||||
// Upper Hall — 3-way: master_bedroom (open elite), hidden_oratory (high perception secret), tower (level-gated)
|
||||
Hint: "a runed door — you'd need to read it", Weight: 3},
|
||||
|
||||
// Portrait spoke.
|
||||
{From: "manor_blackspire.portrait_gallery", To: "manor_blackspire.cold_solarium", Lock: LockNone},
|
||||
{From: "manor_blackspire.cold_solarium", To: "manor_blackspire.west_landing", Lock: LockNone},
|
||||
{From: "manor_blackspire.west_landing", To: "manor_blackspire.second_floor_landing", Lock: LockNone},
|
||||
|
||||
// Study spoke.
|
||||
{From: "manor_blackspire.locked_study", To: "manor_blackspire.study_archive", Lock: LockNone},
|
||||
{From: "manor_blackspire.study_archive", To: "manor_blackspire.secretary_hall", Lock: LockNone},
|
||||
{From: "manor_blackspire.secretary_hall", To: "manor_blackspire.second_floor_landing", Lock: LockNone},
|
||||
|
||||
// Library spoke.
|
||||
{From: "manor_blackspire.forbidden_library", To: "manor_blackspire.library_loft", Lock: LockNone},
|
||||
{From: "manor_blackspire.library_loft", To: "manor_blackspire.reading_rotunda", Lock: LockNone},
|
||||
{From: "manor_blackspire.reading_rotunda", To: "manor_blackspire.second_floor_landing", Lock: LockNone},
|
||||
|
||||
// Bridge to upper_hall.
|
||||
{From: "manor_blackspire.second_floor_landing", To: "manor_blackspire.portrait_landing", Lock: LockNone},
|
||||
{From: "manor_blackspire.portrait_landing", To: "manor_blackspire.upper_hall", Lock: LockNone},
|
||||
|
||||
// upper_hall fork — three spokes (open elite, perception 15 secret, level-min 7).
|
||||
{From: "manor_blackspire.upper_hall", To: "manor_blackspire.master_bedroom", Lock: LockNone, Weight: 1},
|
||||
{From: "manor_blackspire.upper_hall", To: "manor_blackspire.hidden_oratory",
|
||||
Lock: LockPerception, LockData: map[string]any{"dc": 15},
|
||||
@@ -70,9 +174,26 @@ func zoneManorBlackspireGraph() ZoneGraph {
|
||||
{From: "manor_blackspire.upper_hall", To: "manor_blackspire.tower_observatory",
|
||||
Lock: LockLevelMin, LockData: map[string]any{"min_level": 7},
|
||||
Hint: "a spiral stair that creaks ominously — climb only if you trust your footing", Weight: 3},
|
||||
{From: "manor_blackspire.master_bedroom", To: "manor_blackspire.boss", Lock: LockNone},
|
||||
{From: "manor_blackspire.hidden_oratory", To: "manor_blackspire.boss", Lock: LockNone},
|
||||
{From: "manor_blackspire.tower_observatory", To: "manor_blackspire.boss", Lock: LockNone},
|
||||
|
||||
// Master Bedroom spoke (elite).
|
||||
{From: "manor_blackspire.master_bedroom", To: "manor_blackspire.grim_balcony", Lock: LockNone},
|
||||
{From: "manor_blackspire.grim_balcony", To: "manor_blackspire.moonlit_passage", Lock: LockNone},
|
||||
{From: "manor_blackspire.moonlit_passage", To: "manor_blackspire.spire_corridor", Lock: LockNone},
|
||||
|
||||
// Hidden Oratory spoke (secret).
|
||||
{From: "manor_blackspire.hidden_oratory", To: "manor_blackspire.choir_balcony", Lock: LockNone},
|
||||
{From: "manor_blackspire.choir_balcony", To: "manor_blackspire.reliquary_dust", Lock: LockNone},
|
||||
{From: "manor_blackspire.reliquary_dust", To: "manor_blackspire.spire_corridor", Lock: LockNone},
|
||||
|
||||
// Tower spoke.
|
||||
{From: "manor_blackspire.tower_observatory", To: "manor_blackspire.spiral_ascent", Lock: LockNone},
|
||||
{From: "manor_blackspire.spiral_ascent", To: "manor_blackspire.telescope_attic", Lock: LockNone},
|
||||
{From: "manor_blackspire.telescope_attic", To: "manor_blackspire.spire_corridor", Lock: LockNone},
|
||||
|
||||
// Boss approach.
|
||||
{From: "manor_blackspire.spire_corridor", To: "manor_blackspire.ancestral_gallery", Lock: LockNone},
|
||||
{From: "manor_blackspire.ancestral_gallery", To: "manor_blackspire.sanctum_threshold", Lock: LockNone},
|
||||
{From: "manor_blackspire.sanctum_threshold", To: "manor_blackspire.boss", Lock: LockNone},
|
||||
}
|
||||
return BuildGraph(ZoneManorBlackspire, nodes, edges)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ func TestManorBlackspireGraph_Registered(t *testing.T) {
|
||||
if g.Entry != "manor_blackspire.entry" {
|
||||
t.Errorf("entry node = %q", g.Entry)
|
||||
}
|
||||
if len(g.Nodes) != 11 {
|
||||
t.Errorf("nodes = %d, want 11", len(g.Nodes))
|
||||
// Long-expedition D1-c widened this zone from 11 → 35 nodes so the
|
||||
// longest entry→boss walk lands in the T3 [22,26] traversal band.
|
||||
if len(g.Nodes) != 35 {
|
||||
t.Errorf("nodes = %d, want 35", len(g.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +35,14 @@ func TestManorBlackspireGraph_TwoStackedThreeWayForks(t *testing.T) {
|
||||
func TestManorBlackspireGraph_AllSpokesReachBoss(t *testing.T) {
|
||||
g := zoneManorBlackspireGraph()
|
||||
for _, leaf := range []string{
|
||||
"manor_blackspire.master_bedroom",
|
||||
"manor_blackspire.tower_observatory",
|
||||
"manor_blackspire.hidden_oratory",
|
||||
// great_hall spokes (entry of each 3-node branch).
|
||||
"manor_blackspire.portrait_gallery",
|
||||
"manor_blackspire.study",
|
||||
"manor_blackspire.library",
|
||||
"manor_blackspire.locked_study",
|
||||
"manor_blackspire.forbidden_library",
|
||||
// upper_hall spokes.
|
||||
"manor_blackspire.master_bedroom",
|
||||
"manor_blackspire.hidden_oratory",
|
||||
"manor_blackspire.tower_observatory",
|
||||
} {
|
||||
if !reachable(g, leaf, "manor_blackspire.boss") {
|
||||
t.Errorf("%s unreachable to boss", leaf)
|
||||
@@ -46,9 +50,9 @@ func TestManorBlackspireGraph_AllSpokesReachBoss(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestManorBlackspireGraph_LockLevelMinFirstUse verifies this zone is
|
||||
// the first to author LockLevelMin, completing lock-kind coverage
|
||||
// (Perception, StatCheck, LevelMin, LockNone) by G8d.
|
||||
// TestManorBlackspireGraph_LockLevelMinFirstUse verifies the tower spoke
|
||||
// still carries the LockLevelMin gate so all four lock kinds (None,
|
||||
// Perception, StatCheck, LevelMin) remain authored within this zone.
|
||||
func TestManorBlackspireGraph_LockLevelMinFirstUse(t *testing.T) {
|
||||
g := zoneManorBlackspireGraph()
|
||||
var levelMinEdge *ZoneEdge
|
||||
@@ -69,3 +73,29 @@ func TestManorBlackspireGraph_LockLevelMinFirstUse(t *testing.T) {
|
||||
t.Error("level-gated edge missing hint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManorBlackspireGraph_SymmetricBranches locks in the D1-c design
|
||||
// intent: all six fork spokes are 3 mid-nodes long so any route walks
|
||||
// the same 23-room length — the choice is loot/encounter, not shortcut.
|
||||
func TestManorBlackspireGraph_SymmetricBranches(t *testing.T) {
|
||||
g := zoneManorBlackspireGraph()
|
||||
checks := []struct {
|
||||
from, to, via string
|
||||
want int
|
||||
}{
|
||||
// great_hall → second_floor_landing, hops via each spoke.
|
||||
{"manor_blackspire.great_hall", "manor_blackspire.second_floor_landing", "manor_blackspire.portrait_gallery", 4},
|
||||
{"manor_blackspire.great_hall", "manor_blackspire.second_floor_landing", "manor_blackspire.locked_study", 4},
|
||||
{"manor_blackspire.great_hall", "manor_blackspire.second_floor_landing", "manor_blackspire.forbidden_library", 4},
|
||||
// upper_hall → spire_corridor, hops via each spoke.
|
||||
{"manor_blackspire.upper_hall", "manor_blackspire.spire_corridor", "manor_blackspire.master_bedroom", 4},
|
||||
{"manor_blackspire.upper_hall", "manor_blackspire.spire_corridor", "manor_blackspire.hidden_oratory", 4},
|
||||
{"manor_blackspire.upper_hall", "manor_blackspire.spire_corridor", "manor_blackspire.tower_observatory", 4},
|
||||
}
|
||||
for _, c := range checks {
|
||||
got := bfsHops(g, c.from, c.to, c.via)
|
||||
if got != c.want {
|
||||
t.Errorf("hops %s → %s via %s = %d, want %d", c.from, c.to, c.via, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,155 @@
|
||||
package plugin
|
||||
|
||||
// Phase G8e — The Underforge branching graph.
|
||||
// The Underforge branching graph.
|
||||
//
|
||||
// T3 zone. Shape: pre-boss gauntlet — long linear preamble through the
|
||||
// forge-city, then a single 3-way antechamber fork right before the
|
||||
// boss. The plan §G8 calls for "two forks for T2+"; this zone bends
|
||||
// that to a single late fork with three options because the gauntlet
|
||||
// shape is the design intent (Kharak Dûn is a one-way descent — there
|
||||
// is no scenic route, only the approach to what was sealed in). The
|
||||
// triple-option antechamber preserves player agency at the
|
||||
// commitment moment.
|
||||
// Long-expedition plan D1-c: extended from the original 10-node sketch
|
||||
// to the new T3 length band (22–26 rooms traversed). Topology preserves
|
||||
// the G8e design intent — a long one-way descent into Kharak Dûn with a
|
||||
// single late 3-way antechamber fork — and adds the linear gauntlet
|
||||
// length the new band asks for. Trap (Cooling River) and Elite (Magma
|
||||
// Chamber) both sit on the descent; the 3-way fork is still the only
|
||||
// decision in the zone, deferred to the room before the boss.
|
||||
//
|
||||
// entry → sealed_gate → forge_descent → cooling_river → magma_chamber (elite) → antechamber (3-way)
|
||||
// ├─[unlocked]── direct_assault → boss
|
||||
// ├─[DEX DC 14]── catwalks → boss
|
||||
// └─[Perception DC 15]── forge_vault (secret) → boss
|
||||
// entry → sealed_threshold → wardstone_arch → forge_descent →
|
||||
// vapor_steps → slag_warrens → cooling_river (TRAP) → bellows_chamber
|
||||
// → cinder_walk → magma_chamber (ELITE) → emberforge_hall →
|
||||
// ashfall_corridor → smelting_vault → foreman_landing →
|
||||
// broken_crucible → great_anvil → resonance_passage → antechamber
|
||||
// (3-way fork)
|
||||
// ├─[unlocked]── direct_assault → forge_throat → ember_steps →
|
||||
// │ sealed_doors → boss
|
||||
// ├─[DEX DC 14]── catwalks → high_traverse → cinder_balcony →
|
||||
// │ vault_door → boss
|
||||
// └─[Perception DC 15]── forge_vault (SECRET) → secret_passage →
|
||||
// makers_walk → vault_keyhole → boss
|
||||
//
|
||||
// Distinct from prior zones in two ways:
|
||||
// 1. Five-node linear preamble (no zone yet has > 2 linear preamble nodes).
|
||||
// 2. Fork is delayed to the final node before boss; the !zone map
|
||||
// therefore renders as a long horizontal stem with a 3-leaf fan at
|
||||
// the right edge.
|
||||
// The 17-node linear preamble is the zone's identity ("Kharak Dûn is a
|
||||
// one-way descent — there is no scenic route, only the approach to what
|
||||
// was sealed in"). The autopilot pitches 3 night-camps over the T3 band,
|
||||
// breaking the descent into rest beats; the player still only picks the
|
||||
// final fork. All three antechamber spokes are 4 mid-nodes so route
|
||||
// choice is loot/encounter character, not shortcut economics.
|
||||
|
||||
func zoneUnderforgeGraph() ZoneGraph {
|
||||
nodes := []ZoneNode{
|
||||
// Linear descent (path positions 1–18).
|
||||
{NodeID: "underforge.entry", Kind: NodeKindEntry, IsEntry: true,
|
||||
Label: "Sealed Threshold", PosX: 0, PosY: 1},
|
||||
{NodeID: "underforge.sealed_gate", Kind: NodeKindExploration,
|
||||
Label: "Sealed Gate", PosX: 1, PosY: 1},
|
||||
{NodeID: "underforge.sealed_threshold", Kind: NodeKindExploration,
|
||||
Label: "Outer Wards", PosX: 1, PosY: 1},
|
||||
{NodeID: "underforge.wardstone_arch", Kind: NodeKindExploration,
|
||||
Label: "Wardstone Arch", PosX: 2, PosY: 1},
|
||||
{NodeID: "underforge.forge_descent", Kind: NodeKindExploration,
|
||||
Label: "Forge Descent", PosX: 2, PosY: 1},
|
||||
Label: "Forge Descent", PosX: 3, PosY: 1},
|
||||
{NodeID: "underforge.vapor_steps", Kind: NodeKindExploration,
|
||||
Label: "Vapor Steps", PosX: 4, PosY: 1},
|
||||
{NodeID: "underforge.slag_warrens", Kind: NodeKindExploration,
|
||||
Label: "Slag Warrens", PosX: 5, PosY: 1},
|
||||
{NodeID: "underforge.cooling_river", Kind: NodeKindTrap,
|
||||
Label: "Cooling River", PosX: 3, PosY: 1},
|
||||
Label: "Cooling River", PosX: 6, PosY: 1},
|
||||
{NodeID: "underforge.bellows_chamber", Kind: NodeKindExploration,
|
||||
Label: "Bellows Chamber", PosX: 7, PosY: 1},
|
||||
{NodeID: "underforge.cinder_walk", Kind: NodeKindExploration,
|
||||
Label: "Cinder Walk", PosX: 8, PosY: 1},
|
||||
{NodeID: "underforge.magma_chamber", Kind: NodeKindElite,
|
||||
Label: "Magma Chamber", PosX: 4, PosY: 1},
|
||||
Label: "Magma Chamber", PosX: 9, PosY: 1},
|
||||
{NodeID: "underforge.emberforge_hall", Kind: NodeKindExploration,
|
||||
Label: "Emberforge Hall", PosX: 10, PosY: 1},
|
||||
{NodeID: "underforge.ashfall_corridor", Kind: NodeKindExploration,
|
||||
Label: "Ashfall Corridor", PosX: 11, PosY: 1},
|
||||
{NodeID: "underforge.smelting_vault", Kind: NodeKindExploration,
|
||||
Label: "Smelting Vault", PosX: 12, PosY: 1},
|
||||
{NodeID: "underforge.foreman_landing", Kind: NodeKindExploration,
|
||||
Label: "Foreman's Landing", PosX: 13, PosY: 1},
|
||||
{NodeID: "underforge.broken_crucible", Kind: NodeKindExploration,
|
||||
Label: "Broken Crucible", PosX: 14, PosY: 1},
|
||||
{NodeID: "underforge.great_anvil", Kind: NodeKindExploration,
|
||||
Label: "Great Anvil", PosX: 15, PosY: 1},
|
||||
{NodeID: "underforge.resonance_passage", Kind: NodeKindExploration,
|
||||
Label: "Resonance Passage", PosX: 16, PosY: 1},
|
||||
{NodeID: "underforge.antechamber", Kind: NodeKindFork,
|
||||
Label: "Antechamber of Kharak Dûn", PosX: 5, PosY: 1},
|
||||
Label: "Antechamber of Kharak Dûn", PosX: 17, PosY: 1},
|
||||
|
||||
// Antechamber — open spoke (direct assault).
|
||||
{NodeID: "underforge.direct_assault", Kind: NodeKindExploration,
|
||||
Label: "Direct Assault", PosX: 6, PosY: 0},
|
||||
Label: "Direct Assault", PosX: 18, PosY: 0},
|
||||
{NodeID: "underforge.forge_throat", Kind: NodeKindExploration,
|
||||
Label: "Forge Throat", PosX: 19, PosY: 0},
|
||||
{NodeID: "underforge.ember_steps", Kind: NodeKindExploration,
|
||||
Label: "Ember Steps", PosX: 20, PosY: 0},
|
||||
{NodeID: "underforge.sealed_doors", Kind: NodeKindExploration,
|
||||
Label: "Sealed Doors", PosX: 21, PosY: 0},
|
||||
|
||||
// Antechamber — DEX spoke (catwalks).
|
||||
{NodeID: "underforge.catwalks", Kind: NodeKindExploration,
|
||||
Label: "Forge Catwalks", PosX: 6, PosY: 1},
|
||||
Label: "Forge Catwalks", PosX: 18, PosY: 1},
|
||||
{NodeID: "underforge.high_traverse", Kind: NodeKindExploration,
|
||||
Label: "High Traverse", PosX: 19, PosY: 1},
|
||||
{NodeID: "underforge.cinder_balcony", Kind: NodeKindExploration,
|
||||
Label: "Cinder Balcony", PosX: 20, PosY: 1},
|
||||
{NodeID: "underforge.vault_door", Kind: NodeKindExploration,
|
||||
Label: "Vault Door", PosX: 21, PosY: 1},
|
||||
|
||||
// Antechamber — secret spoke (forge vault).
|
||||
{NodeID: "underforge.forge_vault", Kind: NodeKindSecret,
|
||||
Label: "Forge Vault", PosX: 6, PosY: 2,
|
||||
Label: "Forge Vault", PosX: 18, PosY: 2,
|
||||
Content: ZoneNodeContent{LootBias: 2.0}},
|
||||
{NodeID: "underforge.secret_passage", Kind: NodeKindExploration,
|
||||
Label: "Secret Passage", PosX: 19, PosY: 2},
|
||||
{NodeID: "underforge.makers_walk", Kind: NodeKindExploration,
|
||||
Label: "Maker's Walk", PosX: 20, PosY: 2},
|
||||
{NodeID: "underforge.vault_keyhole", Kind: NodeKindExploration,
|
||||
Label: "Vault Keyhole", PosX: 21, PosY: 2},
|
||||
|
||||
{NodeID: "underforge.boss", Kind: NodeKindBoss, IsBoss: true,
|
||||
Label: "The Sealed Hall", PosX: 7, PosY: 1},
|
||||
Label: "The Sealed Hall", PosX: 22, PosY: 1},
|
||||
}
|
||||
edges := []ZoneEdge{
|
||||
{From: "underforge.entry", To: "underforge.sealed_gate", Lock: LockNone},
|
||||
{From: "underforge.sealed_gate", To: "underforge.forge_descent", Lock: LockNone},
|
||||
{From: "underforge.forge_descent", To: "underforge.cooling_river", Lock: LockNone},
|
||||
{From: "underforge.cooling_river", To: "underforge.magma_chamber", Lock: LockNone},
|
||||
{From: "underforge.magma_chamber", To: "underforge.antechamber", Lock: LockNone},
|
||||
// Linear descent.
|
||||
{From: "underforge.entry", To: "underforge.sealed_threshold", Lock: LockNone},
|
||||
{From: "underforge.sealed_threshold", To: "underforge.wardstone_arch", Lock: LockNone},
|
||||
{From: "underforge.wardstone_arch", To: "underforge.forge_descent", Lock: LockNone},
|
||||
{From: "underforge.forge_descent", To: "underforge.vapor_steps", Lock: LockNone},
|
||||
{From: "underforge.vapor_steps", To: "underforge.slag_warrens", Lock: LockNone},
|
||||
{From: "underforge.slag_warrens", To: "underforge.cooling_river", Lock: LockNone},
|
||||
{From: "underforge.cooling_river", To: "underforge.bellows_chamber", Lock: LockNone},
|
||||
{From: "underforge.bellows_chamber", To: "underforge.cinder_walk", Lock: LockNone},
|
||||
{From: "underforge.cinder_walk", To: "underforge.magma_chamber", Lock: LockNone},
|
||||
{From: "underforge.magma_chamber", To: "underforge.emberforge_hall", Lock: LockNone},
|
||||
{From: "underforge.emberforge_hall", To: "underforge.ashfall_corridor", Lock: LockNone},
|
||||
{From: "underforge.ashfall_corridor", To: "underforge.smelting_vault", Lock: LockNone},
|
||||
{From: "underforge.smelting_vault", To: "underforge.foreman_landing", Lock: LockNone},
|
||||
{From: "underforge.foreman_landing", To: "underforge.broken_crucible", Lock: LockNone},
|
||||
{From: "underforge.broken_crucible", To: "underforge.great_anvil", Lock: LockNone},
|
||||
{From: "underforge.great_anvil", To: "underforge.resonance_passage", Lock: LockNone},
|
||||
{From: "underforge.resonance_passage", To: "underforge.antechamber", Lock: LockNone},
|
||||
|
||||
// Antechamber 3-way fork.
|
||||
{From: "underforge.antechamber", To: "underforge.direct_assault", Lock: LockNone, Weight: 1},
|
||||
{From: "underforge.antechamber", To: "underforge.catwalks",
|
||||
Lock: LockStatCheck, LockData: map[string]any{"stat": "DEX", "dc": 14},
|
||||
Hint: "rusted catwalks above the magma — quick footing required", Weight: 2},
|
||||
{From: "underforge.antechamber", To: "underforge.forge_vault",
|
||||
Lock: LockPerception, LockData: map[string]any{"dc": 15},
|
||||
Hint: "a draft from a stone seam, where no draft should be", Weight: 2},
|
||||
{From: "underforge.direct_assault", To: "underforge.boss", Lock: LockNone},
|
||||
{From: "underforge.catwalks", To: "underforge.boss", Lock: LockNone},
|
||||
{From: "underforge.forge_vault", To: "underforge.boss", Lock: LockNone},
|
||||
Hint: "a draft from a stone seam, where no draft should be", Weight: 3},
|
||||
|
||||
// Direct assault spoke.
|
||||
{From: "underforge.direct_assault", To: "underforge.forge_throat", Lock: LockNone},
|
||||
{From: "underforge.forge_throat", To: "underforge.ember_steps", Lock: LockNone},
|
||||
{From: "underforge.ember_steps", To: "underforge.sealed_doors", Lock: LockNone},
|
||||
{From: "underforge.sealed_doors", To: "underforge.boss", Lock: LockNone},
|
||||
|
||||
// Catwalks spoke.
|
||||
{From: "underforge.catwalks", To: "underforge.high_traverse", Lock: LockNone},
|
||||
{From: "underforge.high_traverse", To: "underforge.cinder_balcony", Lock: LockNone},
|
||||
{From: "underforge.cinder_balcony", To: "underforge.vault_door", Lock: LockNone},
|
||||
{From: "underforge.vault_door", To: "underforge.boss", Lock: LockNone},
|
||||
|
||||
// Forge vault spoke (secret).
|
||||
{From: "underforge.forge_vault", To: "underforge.secret_passage", Lock: LockNone},
|
||||
{From: "underforge.secret_passage", To: "underforge.makers_walk", Lock: LockNone},
|
||||
{From: "underforge.makers_walk", To: "underforge.vault_keyhole", Lock: LockNone},
|
||||
{From: "underforge.vault_keyhole", To: "underforge.boss", Lock: LockNone},
|
||||
}
|
||||
return BuildGraph(ZoneUnderforge, nodes, edges)
|
||||
}
|
||||
|
||||
@@ -7,23 +7,39 @@ func TestUnderforgeGraph_Registered(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("zoneUnderforgeGraph not registered")
|
||||
}
|
||||
if len(g.Nodes) != 10 {
|
||||
t.Errorf("nodes = %d, want 10", len(g.Nodes))
|
||||
// Long-expedition D1-c widened this zone from 10 → 31 nodes so the
|
||||
// longest entry→boss walk lands in the T3 [22,26] traversal band.
|
||||
if len(g.Nodes) != 31 {
|
||||
t.Errorf("nodes = %d, want 31", len(g.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnderforgeGraph_LinearPreamble locks in the gauntlet shape:
|
||||
// the first five nodes after entry must each have exactly one outgoing
|
||||
// edge (linear chain). If a future edit splits the preamble, this test
|
||||
// catches it — that change should re-author the shape comment too.
|
||||
// TestUnderforgeGraph_LinearPreamble locks in the gauntlet identity:
|
||||
// every node from entry through resonance_passage has exactly one
|
||||
// outgoing edge — the only decision in the zone is the antechamber
|
||||
// 3-way at the very end. D1-c extends the chain but preserves the
|
||||
// "one-way descent" intent ("Kharak Dûn is a one-way descent — there
|
||||
// is no scenic route").
|
||||
func TestUnderforgeGraph_LinearPreamble(t *testing.T) {
|
||||
g := zoneUnderforgeGraph()
|
||||
for _, id := range []string{
|
||||
"underforge.entry",
|
||||
"underforge.sealed_gate",
|
||||
"underforge.sealed_threshold",
|
||||
"underforge.wardstone_arch",
|
||||
"underforge.forge_descent",
|
||||
"underforge.vapor_steps",
|
||||
"underforge.slag_warrens",
|
||||
"underforge.cooling_river",
|
||||
"underforge.bellows_chamber",
|
||||
"underforge.cinder_walk",
|
||||
"underforge.magma_chamber",
|
||||
"underforge.emberforge_hall",
|
||||
"underforge.ashfall_corridor",
|
||||
"underforge.smelting_vault",
|
||||
"underforge.foreman_landing",
|
||||
"underforge.broken_crucible",
|
||||
"underforge.great_anvil",
|
||||
"underforge.resonance_passage",
|
||||
} {
|
||||
outs := g.outgoingEdges(id)
|
||||
if len(outs) != 1 {
|
||||
|
||||
Reference in New Issue
Block a user