Files
gogobee/internal/plugin/postgame_zone_graph_builder.go
prosolis d9541f07f1 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.
2026-07-15 23:17:07 -07:00

221 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}