mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Long expeditions D1-a: graph-driven TotalRooms; goblin_warrens 7→16
Length is now sourced from the zone graph's longest entry→boss path, not the dice roll, so "Room X/Y" matches what the player walks. The dice fallback stays for graphless zones. Goblin Warrens grows to the T1 12–14 band (13-node traversal): adds the missing Trap anchor and deepens both fork branches + the post-merge approach. Pattern reference for the remaining zones in D1-b…d.
This commit is contained in:
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user