mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
adventure: Tier 6 postgame "Mythic" dungeons (P1–P7 calibration)
Five post-game dungeons above the T5 ceiling, gated on both T5 bosses beaten + level 18. Opt-in endgame: deadly solo, clearable by a party with Pete + pets. - P1 gating: postgameUnlocked (T5 clears + level floor), zonesForLevel excludes T6 unconditionally; wired into startZoneRun, !zone/!expedition, party accept, boredom picker, and the list dividers. - P2 bestiary: 15 elites + 5 signature bosses (Layer-1 stat blocks). - P3 zone defs + 4-region registries; ZoneLootEntry.BossOnly. - P4 five zone graphs on a shared builder (44–52 rooms, no soft-lock; Ossuary secret Verse nodes). - P5 loot: BossOnly enforced; signature items are real registry magic items; five Thom pity recipes off the per-zone crafting anchors. - P6 narration/flavor (5 files), T6 achievements, Pete stays zone-parametric. - P7 (in progress): sim can now reach gated T6 (SimRunner.SeedPostgameUnlock + IsPostgameZone). First calibration pass on millenia — hardened ossuary + drowned_star, softened first_hoard + unplace; last_meridian in band. Fix: party members were refused from every T6 zone because expeditionCmdAccept ran the level gate (which excludes T6) before the postgame check — the intended party endgame was unreachable. Route T6 through postgameUnlocked. Regression tests added.
This commit is contained in:
220
internal/plugin/postgame_zone_graph_builder.go
Normal file
220
internal/plugin/postgame_zone_graph_builder.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package plugin
|
||||
|
||||
// Shared construction for the five Tier 6 "Mythic" post-game zone graphs
|
||||
// (Phase P4). All five share one topology so the entry→boss longest walk
|
||||
// lands deterministically in the T6 [44,52] band (46 by construction) and
|
||||
// so the four regions are evenly weighted sub-dungeons. Per-zone identity
|
||||
// lives entirely in the pgZoneSpec labels; structural set-pieces (Ossuary's
|
||||
// SECRET Verses, the First Hoard's game-max LootBias gilded route) ride the
|
||||
// Overrides map so the skeleton stays flavor-agnostic.
|
||||
//
|
||||
// Topology (counts give the longest walk = 46 nodes; every fork's first
|
||||
// spur is a LockNone edge so no run can be stranded — the graph-wide
|
||||
// TestZoneGraphs_NoSoftLockedFork invariant):
|
||||
//
|
||||
// R1 (region[0]): entry ─ p1..p12 ─ fork1 (14)
|
||||
// R2 (region[1]): fork1 ⇒ {free | locked} 3-spur ─ r2gate
|
||||
// ─ r2b1..6 ─ fork2 (+11)
|
||||
// R3 (region[2]): fork2 ⇒ {free | locked} 3-spur ─ r3gate
|
||||
// ─ r3b1..6 ─ fork3 (+11)
|
||||
// R4 (region[3]): fork3 ⇒ 3× capstone 3-spur ─ merge
|
||||
// ─ ap1..5 ─ boss (+10)
|
||||
//
|
||||
// fork1 / fork2 are binary (free + one locked spur); fork3 is the ternary
|
||||
// capstone (free + two locked spurs), each capstone leaf reaching the boss
|
||||
// through the merge. Node-id suffixes are stable and referenced by the
|
||||
// per-zone tests: entry, p1..p12, fork1, f1a1..3 (free) / f1b1..3 (locked),
|
||||
// r2gate, r2b1..6, fork2, f2a1..3 / f2b1..3, r3gate, r3b1..6, fork3,
|
||||
// cap11..13 (free) / cap21..23 (lockA) / cap31..33 (lockB), merge, ap1..5,
|
||||
// boss. Baseline anchors (a mid-run trap + two region-guardian elites) are
|
||||
// stamped by the builder; a spec Override can retype any node on top.
|
||||
|
||||
import "fmt"
|
||||
|
||||
// pgLock describes the gate on a fork's spur-entry edge. The zero value is
|
||||
// an unlocked (LockNone) edge.
|
||||
type pgLock struct {
|
||||
kind ZoneEdgeLockKind
|
||||
lockData map[string]any
|
||||
hint string
|
||||
}
|
||||
|
||||
// pgNodeOverride retypes and/or biases a single node by suffix. An empty
|
||||
// kind leaves the builder's default; a zero bias leaves LootBias unset.
|
||||
type pgNodeOverride struct {
|
||||
kind ZoneNodeKind
|
||||
bias float64
|
||||
}
|
||||
|
||||
// pgFork is a binary fork: a free (LockNone) 3-spur and a locked 3-spur,
|
||||
// both converging on the region's gate node.
|
||||
type pgFork struct {
|
||||
label string
|
||||
freeLabel [3]string
|
||||
lockLabel [3]string
|
||||
lock pgLock
|
||||
}
|
||||
|
||||
// pgCapstone is the ternary fork3: one free spur and two independently
|
||||
// locked spurs, all converging on the merge.
|
||||
type pgCapstone struct {
|
||||
label string
|
||||
freeLabel [3]string
|
||||
lockALabel [3]string
|
||||
lockA pgLock
|
||||
lockBLabel [3]string
|
||||
lockB pgLock
|
||||
}
|
||||
|
||||
type pgZoneSpec struct {
|
||||
zoneID ZoneID
|
||||
regions [4]string
|
||||
entry string
|
||||
preamble [12]string
|
||||
fork1 pgFork
|
||||
r2gate string
|
||||
r2build [6]string
|
||||
fork2 pgFork
|
||||
r3gate string
|
||||
r3build [6]string
|
||||
capstone pgCapstone // fork3
|
||||
merge string
|
||||
approach [5]string
|
||||
boss string
|
||||
overrides map[string]pgNodeOverride // keyed by node-id suffix (no zone prefix)
|
||||
}
|
||||
|
||||
// buildPostgameZoneGraph assembles and validates the canonical T6 graph.
|
||||
func buildPostgameZoneGraph(s pgZoneSpec) ZoneGraph {
|
||||
zid := string(s.zoneID)
|
||||
// Baseline anchors so every T6 zone carries a mid-run hazard and two
|
||||
// region-guardian elites without repeating them in each spec. Spec
|
||||
// overrides win (applied after).
|
||||
defaults := map[string]ZoneNodeKind{
|
||||
"p7": NodeKindTrap,
|
||||
"r2b6": NodeKindElite,
|
||||
"r3b6": NodeKindElite,
|
||||
}
|
||||
|
||||
var nodes []ZoneNode
|
||||
var edges []ZoneEdge
|
||||
id := func(suffix string) string { return zid + "." + suffix }
|
||||
|
||||
addNode := func(suffix, label, region string, kind ZoneNodeKind, x, y int) {
|
||||
if d, ok := defaults[suffix]; ok {
|
||||
kind = d
|
||||
}
|
||||
n := ZoneNode{
|
||||
NodeID: id(suffix), Kind: kind, RegionID: region,
|
||||
Label: label, PosX: x, PosY: y,
|
||||
}
|
||||
if ov, ok := s.overrides[suffix]; ok {
|
||||
if ov.kind != "" {
|
||||
n.Kind = ov.kind
|
||||
}
|
||||
if ov.bias != 0 {
|
||||
n.Content.LootBias = ov.bias
|
||||
}
|
||||
}
|
||||
if n.Kind == NodeKindEntry {
|
||||
n.IsEntry = true
|
||||
}
|
||||
if n.Kind == NodeKindBoss {
|
||||
n.IsBoss = true
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
addEdge := func(from, to string, lock pgLock, weight int) {
|
||||
e := ZoneEdge{From: id(from), To: id(to), Weight: weight, Lock: LockNone}
|
||||
if lock.kind != "" {
|
||||
e.Lock = lock.kind
|
||||
e.LockData = lock.lockData
|
||||
e.Hint = lock.hint
|
||||
}
|
||||
edges = append(edges, e)
|
||||
}
|
||||
// spur wires forkSuf → three spur nodes → gateSuf, locking only the
|
||||
// fork→spur[0] entry edge. The SECRET/LootBias node in a locked spur is
|
||||
// its middle node (index 1), reached by a LockNone edge — matching the
|
||||
// established "gated threshold, then unlocked entry into the secret"
|
||||
// pattern (see zone_graph_abyss_portal.go reality_seam).
|
||||
spur := func(forkSuf string, labels [3]string, lock pgLock, gateSuf, region, sufPrefix string, xStart, y, weight int) {
|
||||
prev := forkSuf
|
||||
for i := 0; i < 3; i++ {
|
||||
suf := fmt.Sprintf("%s%d", sufPrefix, i+1)
|
||||
addNode(suf, labels[i], region, NodeKindExploration, xStart+i, y)
|
||||
lk := pgLock{}
|
||||
if i == 0 {
|
||||
lk = lock
|
||||
}
|
||||
addEdge(prev, suf, lk, weight)
|
||||
prev = suf
|
||||
}
|
||||
addEdge(prev, gateSuf, pgLock{}, 1)
|
||||
}
|
||||
|
||||
r1, r2, r3, r4 := s.regions[0], s.regions[1], s.regions[2], s.regions[3]
|
||||
|
||||
// R1 preamble.
|
||||
addNode("entry", s.entry, r1, NodeKindEntry, 0, 2)
|
||||
prev := "entry"
|
||||
for i, lab := range s.preamble {
|
||||
suf := fmt.Sprintf("p%d", i+1)
|
||||
addNode(suf, lab, r1, NodeKindExploration, i+1, 2)
|
||||
addEdge(prev, suf, pgLock{}, 1)
|
||||
prev = suf
|
||||
}
|
||||
addNode("fork1", s.fork1.label, r1, NodeKindFork, 13, 2)
|
||||
addEdge(prev, "fork1", pgLock{}, 1)
|
||||
|
||||
// R2: fork1 spurs → r2gate → buildup → fork2.
|
||||
addNode("r2gate", s.r2gate, r2, NodeKindExploration, 17, 2)
|
||||
spur("fork1", s.fork1.freeLabel, pgLock{}, "r2gate", r2, "f1a", 14, 1, 1)
|
||||
spur("fork1", s.fork1.lockLabel, s.fork1.lock, "r2gate", r2, "f1b", 14, 3, 2)
|
||||
prev = "r2gate"
|
||||
for i, lab := range s.r2build {
|
||||
suf := fmt.Sprintf("r2b%d", i+1)
|
||||
addNode(suf, lab, r2, NodeKindExploration, 18+i, 2)
|
||||
addEdge(prev, suf, pgLock{}, 1)
|
||||
prev = suf
|
||||
}
|
||||
addNode("fork2", s.fork2.label, r2, NodeKindFork, 24, 2)
|
||||
addEdge(prev, "fork2", pgLock{}, 1)
|
||||
|
||||
// R3: fork2 spurs → r3gate → buildup → fork3.
|
||||
addNode("r3gate", s.r3gate, r3, NodeKindExploration, 28, 2)
|
||||
spur("fork2", s.fork2.freeLabel, pgLock{}, "r3gate", r3, "f2a", 25, 1, 1)
|
||||
spur("fork2", s.fork2.lockLabel, s.fork2.lock, "r3gate", r3, "f2b", 25, 3, 2)
|
||||
prev = "r3gate"
|
||||
for i, lab := range s.r3build {
|
||||
suf := fmt.Sprintf("r3b%d", i+1)
|
||||
addNode(suf, lab, r3, NodeKindExploration, 29+i, 2)
|
||||
addEdge(prev, suf, pgLock{}, 1)
|
||||
prev = suf
|
||||
}
|
||||
addNode("fork3", s.capstone.label, r3, NodeKindFork, 35, 2)
|
||||
addEdge(prev, "fork3", pgLock{}, 1)
|
||||
|
||||
// R4: fork3 ternary capstone → merge → approach → boss.
|
||||
addNode("merge", s.merge, r4, NodeKindMerge, 39, 2)
|
||||
spur("fork3", s.capstone.freeLabel, pgLock{}, "merge", r4, "cap1", 36, 1, 1)
|
||||
spur("fork3", s.capstone.lockALabel, s.capstone.lockA, "merge", r4, "cap2", 36, 2, 2)
|
||||
spur("fork3", s.capstone.lockBLabel, s.capstone.lockB, "merge", r4, "cap3", 36, 3, 3)
|
||||
prev = "merge"
|
||||
for i, lab := range s.approach {
|
||||
suf := fmt.Sprintf("ap%d", i+1)
|
||||
addNode(suf, lab, r4, NodeKindExploration, 40+i, 2)
|
||||
addEdge(prev, suf, pgLock{}, 1)
|
||||
prev = suf
|
||||
}
|
||||
addNode("boss", s.boss, r4, NodeKindBoss, 45, 2)
|
||||
addEdge(prev, "boss", pgLock{}, 1)
|
||||
|
||||
return buildPostgameGraphFrom(s.zoneID, nodes, edges)
|
||||
}
|
||||
|
||||
// buildPostgameGraphFrom is a thin seam over BuildGraph so tests can build
|
||||
// a spec's graph without re-registering it.
|
||||
func buildPostgameGraphFrom(zoneID ZoneID, nodes []ZoneNode, edges []ZoneEdge) ZoneGraph {
|
||||
return BuildGraph(zoneID, nodes, edges)
|
||||
}
|
||||
Reference in New Issue
Block a user