diff --git a/internal/plugin/dnd_zone.go b/internal/plugin/dnd_zone.go index eac1af3..0462d24 100644 --- a/internal/plugin/dnd_zone.go +++ b/internal/plugin/dnd_zone.go @@ -214,8 +214,8 @@ func zoneGoblinWarrens() ZoneDefinition { Faction: "Goblins, Hobgoblins", Atmosphere: "Low ceilings, torchlight, crude traps, cackling in the dark.", Hook: "A network of fetid tunnels burrowed beneath the Merchant's Road. The smell arrives before the sounds — smoke, rot, and something worse. I advise keeping one hand on your blade.", - MinRooms: 6, - MaxRooms: 7, + MinRooms: 12, + MaxRooms: 14, Enemies: []ZoneEnemy{ {BestiaryID: "goblin_sneak", SpawnWeight: 7}, {BestiaryID: "goblin_archer", SpawnWeight: 6}, diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index 55aa869..b42103d 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -108,11 +108,21 @@ func (r *DungeonRun) CurrentRoomType() RoomType { // generateRoomSequence builds the deterministic-but-seeded room layout // for a run of the given zone. The boss room is always last; one Entry // is always first; one Trap and one Elite room sit between explorations. -// Total length is sampled in [zone.MinRooms, zone.MaxRooms]. +// +// Total length tracks the zone's *graph* longest entry→boss path so the +// "Room X/Y" display lines up with what the player actually walks. If +// the graph hasn't been authored / can't be loaded, we fall back to a +// dice roll within [zone.MinRooms, zone.MaxRooms] (the pre-graph shape). func generateRoomSequence(zone ZoneDefinition, rng *rand.Rand) []RoomType { - total := zone.MinRooms - if zone.MaxRooms > zone.MinRooms { - total += rng.IntN(zone.MaxRooms - zone.MinRooms + 1) + total := 0 + if g, ok := loadZoneGraph(zone.ID); ok { + total = graphLongestPath(g) + } + if total == 0 { + total = zone.MinRooms + if zone.MaxRooms > zone.MinRooms { + total += rng.IntN(zone.MaxRooms - zone.MinRooms + 1) + } } // Fixed slots: Entry + Trap + Elite + Boss = 4. Remaining = explorations. const fixed = 4 diff --git a/internal/plugin/dnd_zone_test.go b/internal/plugin/dnd_zone_test.go index 877d0a0..5508a5c 100644 --- a/internal/plugin/dnd_zone_test.go +++ b/internal/plugin/dnd_zone_test.go @@ -126,9 +126,12 @@ func TestZoneRegistry_LootDropChances(t *testing.T) { } func TestZoneRegistry_RoomCountSane(t *testing.T) { + // Long-expedition plan §2 widens the bands per tier: T1 12–14 up to + // T5 ~36–44. The guard floor stays at 5 (no zone should ever drop + // below that) and the ceiling tracks the T5 target. for _, z := range allZones() { - if z.MinRooms < 5 || z.MaxRooms > 10 || z.MinRooms > z.MaxRooms { - t.Errorf("zone %s rooms %d-%d outside design (5-10, min<=max)", + if z.MinRooms < 5 || z.MaxRooms > 44 || z.MinRooms > z.MaxRooms { + t.Errorf("zone %s rooms %d-%d outside design (5-44, min<=max)", z.ID, z.MinRooms, z.MaxRooms) } } diff --git a/internal/plugin/zone_graph.go b/internal/plugin/zone_graph.go index ce78b08..dedd49f 100644 --- a/internal/plugin/zone_graph.go +++ b/internal/plugin/zone_graph.go @@ -360,6 +360,45 @@ func roomTypeToNodeKind(rt RoomType) ZoneNodeKind { return NodeKindExploration } +// graphLongestPath returns the number of nodes on the longest simple +// path from Entry to Boss. Used by generateRoomSequence so the +// "Room X/Y" display tracks the actual graph traversal length rather +// than a dice-rolled stub. Returns 0 if the graph has no entry/boss. +func graphLongestPath(g ZoneGraph) int { + if g.Entry == "" || g.Boss == "" { + return 0 + } + memo := map[string]int{} + var dfs func(node string, onPath map[string]bool) int + dfs = func(node string, onPath map[string]bool) int { + if node == g.Boss { + return 1 + } + if v, ok := memo[node]; ok && !onPath[node] { + // memo is safe only when the cached subpath doesn't + // revisit a node currently on the active path. With DAG + // zones (the norm) this is always safe; the onPath guard + // keeps us correct if a future zone authors a cycle. + return v + } + best := 0 + for _, e := range g.Edges[node] { + if onPath[e.To] { + continue + } + onPath[e.To] = true + sub := dfs(e.To, onPath) + delete(onPath, e.To) + if sub > 0 && sub+1 > best { + best = sub + 1 + } + } + memo[node] = best + return best + } + return dfs(g.Entry, map[string]bool{g.Entry: true}) +} + // loadZoneGraph returns the graph for a zone. Registered (hand-authored) // graphs take precedence; otherwise the legacy linear compiler is used. // Returns ok=false only for unknown zone IDs. diff --git a/internal/plugin/zone_graph_goblin_warrens.go b/internal/plugin/zone_graph_goblin_warrens.go index b810b0b..4b2d83d 100644 --- a/internal/plugin/zone_graph_goblin_warrens.go +++ b/internal/plugin/zone_graph_goblin_warrens.go @@ -1,49 +1,91 @@ package plugin -// Phase G8a — Goblin Warrens branching graph. +// Goblin Warrens branching graph. // -// First non-POC zone. T1 design constraint per -// gogobee_branching_zones_plan.md §G8: one fork per zone for T1, no -// secret (the secret pattern is exercised by Crypt of Valdris; Goblin -// Warrens deliberately validates the bare single-fork shape on a -// non-POC zone so we can confirm legacy compileLegacyZoneGraph fully -// hands off to the authored graph end-to-end). +// Long-expedition plan D1: extended from the original 7-node sketch to +// the new T1 length band (12–14 rooms traversed). Topology preserves the +// original single-fork "Perception skips the elite" shape — the warband +// branch is the default path and houses the zone's only Elite; the +// collapsed-shaft branch is hint-gated, elite-free, and roughly equal in +// node count so the two routes feel balanced. // -// entry → guard_post → fork -// ├──[unlocked]── warband_pit (elite) ──┐ -// │ ├── warchief_hall → boss -// └──[Perception DC 12]── collapsed_shaft ┘ +// entry → tunnel_descent → guard_post → snare_trap (TRAP) → +// patrol_route → cavern_junction (FORK) +// ├──[unlocked]── warband_pit (ELITE) +// │ → trophy_chamber → kennel_path ──┐ +// │ ├── warchief_hall +// └──[Perception DC 12]── collapsed_shaft │ → ritual_dais +// → fungus_grotto → service_kitchens ──────────┘ → throne_approach +// → boss // -// Both branches reach the boss; Perception side is hinted (per plan: -// "Locked paths should always have a hint — cruel design otherwise"). +// Per-tier room budget: 16 graph nodes total; longest-path traversal is +// 13 (entry-side 5 + fork 1 + branch 3 + post-merge 4), which lands in +// the §2 T1 band [12,14] for both branches. func zoneGoblinWarrensGraph() ZoneGraph { nodes := []ZoneNode{ {NodeID: "goblin_warrens.entry", Kind: NodeKindEntry, IsEntry: true, Label: "Tunnel Mouth", PosX: 0, PosY: 1}, + {NodeID: "goblin_warrens.tunnel_descent", Kind: NodeKindExploration, + Label: "Slick Descent", PosX: 1, PosY: 1}, {NodeID: "goblin_warrens.guard_post", Kind: NodeKindExploration, - Label: "Goblin Guard Post", PosX: 1, PosY: 1}, - {NodeID: "goblin_warrens.fork", Kind: NodeKindFork, - Label: "Cavern Junction", PosX: 2, PosY: 1}, + Label: "Goblin Guard Post", PosX: 2, PosY: 1}, + {NodeID: "goblin_warrens.snare_trap", Kind: NodeKindTrap, + Label: "Tripwire Snare", PosX: 3, PosY: 1}, + {NodeID: "goblin_warrens.patrol_route", Kind: NodeKindExploration, + Label: "Patrol Route", PosX: 4, PosY: 1}, + {NodeID: "goblin_warrens.cavern_junction", Kind: NodeKindFork, + Label: "Cavern Junction", PosX: 5, PosY: 1}, + + // Left branch — default, contains the elite. {NodeID: "goblin_warrens.warband_pit", Kind: NodeKindElite, - Label: "Warband Pit", PosX: 3, PosY: 0}, + Label: "Warband Pit", PosX: 6, PosY: 0}, + {NodeID: "goblin_warrens.trophy_chamber", Kind: NodeKindExploration, + Label: "Trophy Chamber", PosX: 7, PosY: 0}, + {NodeID: "goblin_warrens.kennel_path", Kind: NodeKindExploration, + Label: "Wolf Kennel Path", PosX: 8, PosY: 0}, + + // Right branch — perception-gated, elite-free. {NodeID: "goblin_warrens.collapsed_shaft", Kind: NodeKindExploration, - Label: "Collapsed Shaft", PosX: 3, PosY: 2}, + Label: "Collapsed Shaft", PosX: 6, PosY: 2}, + {NodeID: "goblin_warrens.fungus_grotto", Kind: NodeKindExploration, + Label: "Fungus Grotto", PosX: 7, PosY: 2}, + {NodeID: "goblin_warrens.service_kitchens", Kind: NodeKindExploration, + Label: "Goblin Kitchens", PosX: 8, PosY: 2}, + + // Post-merge approach. {NodeID: "goblin_warrens.warchief_hall", Kind: NodeKindMerge, - Label: "Warchief's Hall", PosX: 4, PosY: 1}, + Label: "Warchief's Antechamber", PosX: 9, PosY: 1}, + {NodeID: "goblin_warrens.ritual_dais", Kind: NodeKindExploration, + Label: "Ritual Dais", PosX: 10, PosY: 1}, + {NodeID: "goblin_warrens.throne_approach", Kind: NodeKindExploration, + Label: "Throne Approach", PosX: 11, PosY: 1}, {NodeID: "goblin_warrens.boss", Kind: NodeKindBoss, IsBoss: true, - Label: "Grol's Throne", PosX: 5, PosY: 1}, + Label: "Grol's Throne", PosX: 12, PosY: 1}, } edges := []ZoneEdge{ - {From: "goblin_warrens.entry", To: "goblin_warrens.guard_post", Lock: LockNone}, - {From: "goblin_warrens.guard_post", To: "goblin_warrens.fork", Lock: LockNone}, - {From: "goblin_warrens.fork", To: "goblin_warrens.warband_pit", Lock: LockNone, Weight: 1}, - {From: "goblin_warrens.fork", To: "goblin_warrens.collapsed_shaft", + {From: "goblin_warrens.entry", To: "goblin_warrens.tunnel_descent", Lock: LockNone}, + {From: "goblin_warrens.tunnel_descent", To: "goblin_warrens.guard_post", Lock: LockNone}, + {From: "goblin_warrens.guard_post", To: "goblin_warrens.snare_trap", Lock: LockNone}, + {From: "goblin_warrens.snare_trap", To: "goblin_warrens.patrol_route", Lock: LockNone}, + {From: "goblin_warrens.patrol_route", To: "goblin_warrens.cavern_junction", Lock: LockNone}, + + {From: "goblin_warrens.cavern_junction", To: "goblin_warrens.warband_pit", Lock: LockNone, Weight: 1}, + {From: "goblin_warrens.cavern_junction", To: "goblin_warrens.collapsed_shaft", Lock: LockPerception, LockData: map[string]any{"dc": 12}, Hint: "a muffled scrape behind a heap of fallen timbers", Weight: 2}, - {From: "goblin_warrens.warband_pit", To: "goblin_warrens.warchief_hall", Lock: LockNone}, - {From: "goblin_warrens.collapsed_shaft", To: "goblin_warrens.warchief_hall", Lock: LockNone}, - {From: "goblin_warrens.warchief_hall", To: "goblin_warrens.boss", Lock: LockNone}, + + {From: "goblin_warrens.warband_pit", To: "goblin_warrens.trophy_chamber", Lock: LockNone}, + {From: "goblin_warrens.trophy_chamber", To: "goblin_warrens.kennel_path", Lock: LockNone}, + {From: "goblin_warrens.kennel_path", To: "goblin_warrens.warchief_hall", Lock: LockNone}, + + {From: "goblin_warrens.collapsed_shaft", To: "goblin_warrens.fungus_grotto", Lock: LockNone}, + {From: "goblin_warrens.fungus_grotto", To: "goblin_warrens.service_kitchens", Lock: LockNone}, + {From: "goblin_warrens.service_kitchens", To: "goblin_warrens.warchief_hall", Lock: LockNone}, + + {From: "goblin_warrens.warchief_hall", To: "goblin_warrens.ritual_dais", Lock: LockNone}, + {From: "goblin_warrens.ritual_dais", To: "goblin_warrens.throne_approach", Lock: LockNone}, + {From: "goblin_warrens.throne_approach", To: "goblin_warrens.boss", Lock: LockNone}, } return BuildGraph(ZoneGoblinWarrens, nodes, edges) } diff --git a/internal/plugin/zone_graph_goblin_warrens_test.go b/internal/plugin/zone_graph_goblin_warrens_test.go index fde79cd..7e8a8fa 100644 --- a/internal/plugin/zone_graph_goblin_warrens_test.go +++ b/internal/plugin/zone_graph_goblin_warrens_test.go @@ -13,8 +13,10 @@ func TestGoblinWarrensGraph_Registered(t *testing.T) { if g.Boss != "goblin_warrens.boss" { t.Errorf("boss node = %q, want goblin_warrens.boss", g.Boss) } - if len(g.Nodes) != 7 { - t.Errorf("nodes = %d, want 7", len(g.Nodes)) + // Long-expedition D1 widened this zone from 7 → 16 nodes so both + // branches land in the T1 [12,14] traversal band. + if len(g.Nodes) != 16 { + t.Errorf("nodes = %d, want 16", len(g.Nodes)) } } @@ -30,7 +32,7 @@ func TestGoblinWarrensGraph_BothPathsReachBoss(t *testing.T) { func TestGoblinWarrensGraph_ForkLayout(t *testing.T) { g := zoneGoblinWarrensGraph() - outs := g.outgoingEdges("goblin_warrens.fork") + outs := g.outgoingEdges("goblin_warrens.cavern_junction") if len(outs) != 2 { t.Fatalf("fork outgoing edges = %d, want 2", len(outs)) }