N5/D4: secret content pass — treasure-cache secret rooms + cross-zone keys

Secret rooms were dead content: every NodeKindSecret node silently
collapsed to a normal exploration fight and its authored LootBias
(1.5-3.0) was never read at runtime. D4 makes them what they read as —
no-combat treasure caches.

resolveRoom now diverts a secret node (keyed off the graph node, since
CurrentRoomType has already lost the kind) to resolveSecretRoom before
the RoomType switch — shared by manual !zone advance, !expedition run
autopilot, and the sim. Each secret pays a guaranteed journal page
(the D1a grant hook built "for secret rooms"), a LootBias-weighted
treasure roll (floored at elite weight), and a guaranteed zone-tier
consumable cache, with bespoke in-world discovery flavor.

Underdark was the only T2+ zone with no secret; added at throne_gallery
on the universal R4 tail (Lost Reliquary, Perception DC 17), merging to
throne_steps so it's length-neutral.

Two cross-zone keys: the Sunken Temple's Coral Reliquary grants a Sunken
Sigil that opens a Sealed Reliquary in Manor Blackspire; the Underforge's
Forge Vault grants an Underforge Seal that opens a Sealed Vault in the
Underdark. Keys are persistent inventory items matched against LockKey
key_id; grants are idempotent.

Graph validator + no-soft-lock pass on both touched graphs; combat golden
byte-identical; go build/vet/test green repo-wide.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-10 16:02:15 -07:00
parent 9b6c1ff9a4
commit c37c95a3e3
8 changed files with 454 additions and 16 deletions

View File

@@ -149,6 +149,103 @@ func (p *AdventurePlugin) grantJournalPage(userID id.UserID, rng *rand.Rand) str
journalPages[page-1].Title, page, journalTotalPages)
}
// --- N5/D4 secret content pass ---------------------------------------------
//
// Every NodeKindSecret room resolves as a no-combat treasure cache (see
// resolveSecretRoom in dnd_zone_combat.go). Finding it — via the perception /
// stat check on the fork, or a cross-zone key — is the reward: a guaranteed
// journal page, a LootBias-weighted treasure roll, and a guaranteed consumable
// cache. The flavor and the key catalog live here with the rest of the
// campaign; the resolution mechanics live next to the other room resolvers.
// secretRoomCacheCount is how many zone-tier consumables a secret room always
// hands over, so the room pays out something visible even for a player who has
// every page and misses the treasure roll.
const secretRoomCacheCount = 2
// crossZoneKey pairs a source secret room with the key item it grants. The key
// is a plain inventory item; a LockKey edge in the destination zone matches its
// lower-cased Name against lock_data.key_id (see evaluateEdgeLock). Keys persist
// in inventory, so the unlock is permanent once earned.
type crossZoneKey struct {
item AdvItem
unlocksIn ZoneID // destination zone, for the grant narration
unlockHint string // what the key opens, surfaced when it's granted
}
// secretRoomKeys — the two cross-zone keys (N5/D4). A Sunken Temple sigil opens
// a sealed reliquary in Manor Blackspire; an Underforge seal opens a vault deep
// in the Underdark throne approach. Keyed by the source secret's node ID.
var secretRoomKeys = map[string]crossZoneKey{
"sunken_temple.coral_reliquary": {
item: AdvItem{Name: "Sunken Sigil", Type: "key", Tier: 2},
unlocksIn: ZoneManorBlackspire,
unlockHint: "a drowned god's mark — Blackspire has a door that remembers it",
},
"underforge.forge_vault": {
item: AdvItem{Name: "Underforge Seal", Type: "key", Tier: 3},
unlocksIn: ZoneUnderdark,
unlockHint: "the forge's own brand — something in the deep roads was promised its work",
},
}
// grantSecretRoomKey hands over the cross-zone key a secret room carries, if it
// carries one and the player isn't already holding it. Idempotent: re-finding
// the room on a later run won't stack duplicate keys. Returns the narration line
// (empty when the room grants no key, the player already has it, or on error).
func (p *AdventurePlugin) grantSecretRoomKey(userID id.UserID, node ZoneNode) string {
key, ok := secretRoomKeys[node.NodeID]
if !ok {
return ""
}
items, err := loadAdvInventory(userID)
if err != nil {
return ""
}
want := strings.ToLower(key.item.Name)
for _, it := range items {
if strings.ToLower(it.Name) == want {
return "" // already earned — don't stack it
}
}
if err := addAdvInventoryItem(userID, key.item); err != nil {
slog.Error("secret room: grant key", "user", userID, "key", key.item.Name, "err", err)
return ""
}
return fmt.Sprintf("🗝 **%s** — %s.", key.item.Name, key.unlockHint)
}
// secretRoomDiscovery is the bespoke in-world discovery line for a secret room,
// keyed by node ID. Not TwinBee's voice — the same in-world register the journal
// pages use. Nodes without an entry fall back to a generic line built from the
// node label.
var secretRoomDiscovery = map[string]string{
"crypt_valdris.secret_chamber": "🔍 **A sealed chamber.** The other grave-robbers pried at the wrong wall. This one gives — Valdris kept something back even from his own tomb.",
"forest_shadows.sapling_shrine": "🔍 **The Sapling Shrine.** A ring of young antlers grown from the dirt, and something votive left at the centre — an offering the King's shell never came back to claim.",
"sunken_temple.coral_reliquary": "🔍 **The Coral Reliquary.** Behind a lattice of living coral, a niche the flood sealed rather than drowned. What the temple hid, it hid well.",
"manor_blackspire.hidden_oratory": "🔍 **The Hidden Oratory.** A chapel with no door in the floor plan, kept for a tenant the deeds refuse to name.",
"manor_blackspire.sealed_reliquary": "🔍 **The Sealed Reliquary.** The drowned god's sigil bites into the lock and turns. Blackspire kept a piece of the temple's secret behind a door only the temple could open.",
"underforge.forge_vault": "🔍 **The Forge Vault.** A strongroom off the unbanked heat, its ledger of commissions all addressed elsewhere and long overdue.",
"underdark.lost_reliquary": "🔍 **A lost reliquary.** A shrine the deep roads swallowed whole, still lit, still waiting on a procession that stopped coming a long age ago.",
"underdark.sealed_forge_vault": "🔍 **The Sealed Vault.** The Underforge's brand fits the seam, and the deep-road door gives up what the forge sent ahead — payment on the old account.",
"feywild_crossing.illusion_garden": "🔍 **The Illusion Garden.** A too-kind grove that isn't there when you look straight at it. He can afford beauty here; it costs him nothing to keep.",
"dragons_lair.hoard_pillar": "🔍 **A pillar of hoard.** Coin drifted to the ceiling around a single column the wyrm guards more than gold — as if something at its heart were worth more.",
"abyss_portal.reality_seam": "🔍 **A seam in the real.** The gate's arithmetic frays here, and through the frayed place a hand once reached to leave a thing behind on the way out.",
}
// secretRoomDiscoveryLine returns the discovery flavor for a secret room,
// falling back to a label-driven generic when the node has no bespoke entry.
func secretRoomDiscoveryLine(node ZoneNode) string {
if line, ok := secretRoomDiscovery[node.NodeID]; ok {
return line
}
label := node.Label
if label == "" {
label = "a hidden room"
}
return fmt.Sprintf("🔍 **%s** — a room the others walked past. Something was left here for whoever looked closer.", label)
}
// bossEpilogues ties each zone boss's death to the Hollow King arc: a 2-3
// sentence capstone appended to the boss-down moment. Forest of Shadows is the
// King himself — but what falls there is a shell he shed, which is why the arc

View File

@@ -0,0 +1,216 @@
package plugin
import (
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
// registeredSecretNodes collects every NodeKindSecret node across all
// registered zone graphs, keyed by node ID.
func registeredSecretNodes(t *testing.T) map[string]ZoneNode {
t.Helper()
out := map[string]ZoneNode{}
for _, z := range allZones() {
g, ok := loadZoneGraph(z.ID)
if !ok {
continue
}
for id, n := range g.Nodes {
if n.Kind == NodeKindSecret {
out[id] = n
}
}
}
return out
}
// TestSecretRoomDiscovery_EverySecretHasBespokeFlavor guards that no secret
// node ships on the generic fallback — every one gets an authored line. Catches
// a future secret room added without a discovery entry.
func TestSecretRoomDiscovery_EverySecretHasBespokeFlavor(t *testing.T) {
for id, n := range registeredSecretNodes(t) {
if _, ok := secretRoomDiscovery[id]; !ok {
t.Errorf("secret node %q (%q) has no bespoke discovery line", id, n.Label)
}
if line := secretRoomDiscoveryLine(n); strings.TrimSpace(line) == "" {
t.Errorf("secret node %q produced an empty discovery line", id)
}
}
}
func TestSecretRoomDiscoveryLine_FallsBackOnUnknownNode(t *testing.T) {
n := ZoneNode{NodeID: "made_up.node", Kind: NodeKindSecret, Label: "Nowhere Nook"}
line := secretRoomDiscoveryLine(n)
if !strings.Contains(line, "Nowhere Nook") {
t.Errorf("fallback should name the node label, got: %s", line)
}
}
// TestSecretRoomKeys_UnlockRealDestinationEdges is the cross-zone-key
// consistency check: every key's item Name must match a LockKey edge's key_id
// (lower-cased) in its destination zone, and every source must be a real secret
// room. A typo between the granted item name and the authored key_id would
// silently make a vault permanently unreachable; this catches it.
func TestSecretRoomKeys_UnlockRealDestinationEdges(t *testing.T) {
secrets := registeredSecretNodes(t)
for srcNode, key := range secretRoomKeys {
if _, ok := secrets[srcNode]; !ok {
t.Errorf("key source %q is not a registered secret room", srcNode)
}
g, ok := loadZoneGraph(key.unlocksIn)
if !ok {
t.Errorf("%s: destination zone %q has no graph", srcNode, key.unlocksIn)
continue
}
wantKeyID := strings.ToLower(key.item.Name)
found := false
for _, outs := range g.Edges {
for _, e := range outs {
if e.Lock == LockKey && strings.ToLower(lockDataString(e.LockData, "key_id")) == wantKeyID {
found = true
}
}
}
if !found {
t.Errorf("key %q (from %s) has no LockKey edge with key_id=%q in zone %q — vault unreachable",
key.item.Name, srcNode, wantKeyID, key.unlocksIn)
}
}
}
// TestCrossZoneKey_UnlocksWithItemNotWithout exercises the runtime lock
// evaluation end-to-end on a real authored edge.
func TestCrossZoneKey_UnlocksWithItemNotWithout(t *testing.T) {
g, ok := loadZoneGraph(ZoneManorBlackspire)
if !ok {
t.Fatal("manor graph missing")
}
var keyEdge ZoneEdge
for _, e := range g.outgoingEdges("manor_blackspire.upper_hall") {
if e.Lock == LockKey {
keyEdge = e
}
}
if keyEdge.To == "" {
t.Fatal("no LockKey edge off manor upper_hall")
}
base := edgeUnlockCtx{RunID: "r", FromNode: "manor_blackspire.upper_hall"}
if ok, _ := evaluateEdgeLock(keyEdge, base); ok {
t.Error("edge should be locked without the key in inventory")
}
withKey := base
withKey.InventoryNames = map[string]bool{"sunken sigil": true}
if ok, reason := evaluateEdgeLock(keyEdge, withKey); !ok {
t.Errorf("edge should unlock with the sigil in inventory, got locked: %s", reason)
}
}
// TestGrantSecretRoomKey_GrantsOnceIdempotent verifies a key-bearing secret
// hands its key over exactly once.
func TestGrantSecretRoomKey_GrantsOnceIdempotent(t *testing.T) {
townTestDB(t)
p := &AdventurePlugin{}
u := id.UserID("@keyholder:test.invalid")
node := ZoneNode{NodeID: "sunken_temple.coral_reliquary", Kind: NodeKindSecret}
line := p.grantSecretRoomKey(u, node)
if !strings.Contains(line, "Sunken Sigil") {
t.Fatalf("first grant should name the key, got: %q", line)
}
items, err := loadAdvInventory(u)
if err != nil {
t.Fatalf("load inventory: %v", err)
}
sigils := 0
for _, it := range items {
if strings.EqualFold(it.Name, "Sunken Sigil") {
sigils++
}
}
if sigils != 1 {
t.Fatalf("want exactly 1 Sunken Sigil, got %d", sigils)
}
// Second find on a later run must not stack a duplicate.
if again := p.grantSecretRoomKey(u, node); again != "" {
t.Errorf("re-finding the room should not re-grant the key, got: %q", again)
}
items, _ = loadAdvInventory(u)
sigils = 0
for _, it := range items {
if strings.EqualFold(it.Name, "Sunken Sigil") {
sigils++
}
}
if sigils != 1 {
t.Fatalf("still want exactly 1 Sunken Sigil after re-find, got %d", sigils)
}
}
// TestGrantSecretRoomKey_NoKeyForPlainSecret confirms a secret room that carries
// no cross-zone key stays silent.
func TestGrantSecretRoomKey_NoKeyForPlainSecret(t *testing.T) {
townTestDB(t)
p := &AdventurePlugin{}
u := id.UserID("@plain:test.invalid")
node := ZoneNode{NodeID: "forest_shadows.sapling_shrine", Kind: NodeKindSecret}
if line := p.grantSecretRoomKey(u, node); line != "" {
t.Errorf("plain secret should grant no key, got: %q", line)
}
}
// TestResolveSecretRoom_GrantsPageCacheAndKey checks the full treasure-cache
// payout: a journal page, the consumable cache, and (for a key-bearing secret)
// the cross-zone key — with no combat touching HP.
func TestResolveSecretRoom_GrantsPageCacheAndKey(t *testing.T) {
townTestDB(t)
p := &AdventurePlugin{}
u := id.UserID("@secret:test.invalid")
zone, ok := getZone(ZoneSunkenTemple)
if !ok {
t.Fatal("sunken temple zone missing")
}
node := ZoneNode{
NodeID: "sunken_temple.coral_reliquary", Kind: NodeKindSecret,
Label: "Coral Reliquary",
Content: ZoneNodeContent{LootBias: 1.8},
}
run := &DungeonRun{UserID: string(u), ZoneID: ZoneSunkenTemple, CurrentNode: node.NodeID}
out := p.resolveSecretRoom(u, run, zone, node)
if !strings.Contains(out, "Coral Reliquary") {
t.Errorf("outcome should carry the discovery line:\n%s", out)
}
// A journal page was granted (fresh player, nothing found yet).
if mask, err := loadJournalPages(u); err != nil || journalPageCount(mask) != 1 {
t.Fatalf("want exactly 1 journal page granted, got count=%d err=%v", journalPageCount(mask), err)
}
// The guaranteed cache + the cross-zone key are in inventory.
items, err := loadAdvInventory(u)
if err != nil {
t.Fatalf("load inventory: %v", err)
}
var caches, keys int
for _, it := range items {
switch {
case it.Type == "key":
keys++
case it.Type == "consumable":
caches++
}
}
if caches != secretRoomCacheCount {
t.Errorf("want %d cache consumables, got %d", secretRoomCacheCount, caches)
}
if keys != 1 {
t.Errorf("want the Sunken Sigil key granted, got %d keys", keys)
}
}

View File

@@ -988,6 +988,25 @@ func (p *AdventurePlugin) streamFlowThen(userID id.UserID, phaseMessages []strin
// inter-phase delays — see resolveCombatRoom for the contract. For
// non-combat rooms (entry, trap), phases is nil and outcome carries the
// resolution narration.
// currentSecretNode returns the run's current graph node when it is a secret
// room, so resolveRoom can divert it to the treasure-cache resolver. Returns
// false for a non-secret node, an unknown zone, or a legacy row that never got
// a CurrentNode written (those pre-date branching graphs and have no secrets).
func currentSecretNode(run *DungeonRun) (ZoneNode, bool) {
if run.CurrentNode == "" {
return ZoneNode{}, false
}
g, ok := loadZoneGraph(run.ZoneID)
if !ok {
return ZoneNode{}, false
}
n, ok := g.Nodes[run.CurrentNode]
if !ok || n.Kind != NodeKindSecret {
return ZoneNode{}, false
}
return n, true
}
func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, compact bool) (intro string, phases []string, outcome string, ended bool, err error) {
// Revisit R2 — a cleared room stays cleared. Advancing out of a room the
// player backtracked into must not re-roll its combat or re-arm its trap.
@@ -1004,6 +1023,14 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
if run.RoomIsCleared(run.CurrentRoom) {
return
}
// N5/D4 — a secret room is a no-combat treasure cache, not the exploration
// fight its RoomType collapses to. Keyed off the graph node (which still
// carries NodeKindSecret) rather than CurrentRoomType (which does not), so
// it must be checked before the RoomType switch below.
if node, ok := currentSecretNode(run); ok {
outcome = p.resolveSecretRoom(userID, run, zone, node)
return
}
switch run.CurrentRoomType() {
case RoomEntry:
return

View File

@@ -202,6 +202,52 @@ func scanMoodEventsFromEvents(runID string, events []CombatEvent) (nat20s, nat1s
//
// Higher-tier zones currently fall through to the pre-D2a flat-percent
// nick — D3a/D4a will add Tier 2+ trap catalogs.
// resolveSecretRoom resolves a NodeKindSecret room as a no-combat treasure
// cache (N5/D4). Finding the secret — via the perception/stat check on the fork
// that hangs it, or a cross-zone key — is the reward: a guaranteed journal page
// (the Hollow King's fragments gather in hidden rooms), a LootBias-weighted
// treasure roll, a guaranteed zone-tier consumable cache, and, for the two
// key-bearing secrets, a cross-zone key. No enemy, no threat.
//
// LootBias has been authored on every secret node since the branching-zones
// phase and was dead code until now; it finally scales the treasure weight here.
func (p *AdventurePlugin) resolveSecretRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, node ZoneNode) string {
var b strings.Builder
b.WriteString(secretRoomDiscoveryLine(node))
var rewards []string
if line := p.grantJournalPage(userID, nil); line != "" {
rewards = append(rewards, line)
}
if line := p.grantSecretRoomKey(userID, node); line != "" {
rewards = append(rewards, line)
}
// LootBias-weighted treasure roll. Floors at the elite weight so even the
// leanest secret out-rolls a standard kill; the richest (abyss 3.0) still
// rolls below a boss. checkTreasureDrop DMs the discovery itself on a hit,
// exactly as a combat-room drop does, so it isn't folded into the outcome.
weight := node.Content.LootBias
if weight < advTreasureWeightElite {
weight = advTreasureWeightElite
}
p.rollZoneTreasure(userID, zone.ID, weight)
// Guaranteed cache so the room always pays out something visible.
for _, item := range consumableCache(int(zone.Tier), secretRoomCacheCount) {
it := item
if line := p.grantZoneItem(userID, &it, "🧪"); line != "" {
rewards = append(rewards, line)
}
}
if len(rewards) > 0 {
b.WriteString("\n\n")
b.WriteString(strings.Join(rewards, "\n"))
}
return b.String()
}
func (p *AdventurePlugin) resolveTrapRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (damage int, narration string) {
dndChar, _ := LoadDnDCharacter(userID)
if dndChar == nil {

View File

@@ -34,6 +34,12 @@ package plugin
// 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.
//
// N5/D4 adds a fourth upper_hall spoke: the Sealed Reliquary, a cross-zone
// key vault (LockKey "sunken sigil", earned in the Sunken Temple's Coral
// Reliquary). A one-node loot shortcut back to spire_corridor — reachable
// only by a player who found the sigil, so it never shows on the longest
// path and the 23-node band is unchanged.
func zoneManorBlackspireGraph() ZoneGraph {
nodes := []ZoneNode{
@@ -108,6 +114,12 @@ func zoneManorBlackspireGraph() ZoneGraph {
{NodeID: "manor_blackspire.reliquary_dust", Kind: NodeKindExploration,
Label: "Reliquary Dust", PosX: 18, PosY: 2},
// upper_hall — cross-zone key spoke (N5/D4). A Sunken Temple sigil
// opens this vault; a loot-rich shortcut back to the spire corridor.
{NodeID: "manor_blackspire.sealed_reliquary", Kind: NodeKindSecret,
Label: "Sealed Reliquary", PosX: 16, PosY: -2,
Content: ZoneNodeContent{LootBias: 2.2}},
// upper_hall — level-min spoke (tower).
{NodeID: "manor_blackspire.tower_observatory", Kind: NodeKindExploration,
Label: "Tower Observatory", PosX: 16, PosY: 4},
@@ -174,6 +186,9 @@ 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.upper_hall", To: "manor_blackspire.sealed_reliquary",
Lock: LockKey, LockData: map[string]any{"key_id": "sunken sigil"},
Hint: "a panel scored with a drowned god's mark — you'd need its twin to open it", Weight: 4},
// Master Bedroom spoke (elite).
{From: "manor_blackspire.master_bedroom", To: "manor_blackspire.grim_balcony", Lock: LockNone},
@@ -185,6 +200,9 @@ func zoneManorBlackspireGraph() ZoneGraph {
{From: "manor_blackspire.choir_balcony", To: "manor_blackspire.reliquary_dust", Lock: LockNone},
{From: "manor_blackspire.reliquary_dust", To: "manor_blackspire.spire_corridor", Lock: LockNone},
// Sealed Reliquary spoke (cross-zone key — Sunken Sigil).
{From: "manor_blackspire.sealed_reliquary", 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},

View File

@@ -12,22 +12,24 @@ func TestManorBlackspireGraph_Registered(t *testing.T) {
}
// 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))
// N5/D4 added the Sealed Reliquary cross-zone vault → 36.
if len(g.Nodes) != 36 {
t.Errorf("nodes = %d, want 36", len(g.Nodes))
}
}
// TestManorBlackspireGraph_TwoStackedThreeWayForks captures the design
// shape: both great_hall and upper_hall expose three options.
func TestManorBlackspireGraph_TwoStackedThreeWayForks(t *testing.T) {
// TestManorBlackspireGraph_StackedForks captures the design shape: great_hall
// exposes three options; upper_hall exposes four since N5/D4 added the Sealed
// Reliquary key spoke (the other three remain the elite/secret/tower gates).
func TestManorBlackspireGraph_StackedForks(t *testing.T) {
g := zoneManorBlackspireGraph()
for _, hub := range []string{
"manor_blackspire.great_hall",
"manor_blackspire.upper_hall",
} {
outs := g.outgoingEdges(hub)
if len(outs) != 3 {
t.Errorf("%s outgoing = %d, want 3", hub, len(outs))
want := map[string]int{
"manor_blackspire.great_hall": 3,
"manor_blackspire.upper_hall": 4,
}
for hub, n := range want {
if outs := g.outgoingEdges(hub); len(outs) != n {
t.Errorf("%s outgoing = %d, want %d", hub, len(outs), n)
}
}
}
@@ -43,6 +45,7 @@ func TestManorBlackspireGraph_AllSpokesReachBoss(t *testing.T) {
"manor_blackspire.master_bedroom",
"manor_blackspire.hidden_oratory",
"manor_blackspire.tower_observatory",
"manor_blackspire.sealed_reliquary",
} {
if !reachable(g, leaf, "manor_blackspire.boss") {
t.Errorf("%s unreachable to boss", leaf)

View File

@@ -61,6 +61,15 @@ package plugin
// Garrison) and the multi-region transition a teeth-y interrupt.
// The deep_chasm spur stays anchor-light (harvest, no second trap/elite)
// so the CON-gated climb keeps its "denser loot, less fighting" identity.
//
// N5/D4 turns throne_gallery (R4 tail, which every arm passes through) into
// a fork with two secret spokes, both merging to throne_steps so either is
// length-neutral vs the inner-hall path:
// - Lost Reliquary — the Underdark's own perception-gated secret (DC 17).
// - Sealed Vault — the cross-zone key vault opened by an Underforge Seal
// (earned in the Underforge's Forge Vault). LockKey "underforge seal".
// Placed on the universal R4 tail, not in an arm, so the secrets are
// reachable no matter which region the walk took.
func zoneUnderdarkGraph() ZoneGraph {
r1 := "underdark_surface_tunnels"
@@ -163,10 +172,22 @@ func zoneUnderdarkGraph() ZoneGraph {
Label: "Guard Post", PosX: 24, PosY: 2},
{NodeID: "underdark.throne_doors", Kind: NodeKindExploration, RegionID: r4,
Label: "Riven Doors", PosX: 25, PosY: 2},
{NodeID: "underdark.throne_gallery", Kind: NodeKindExploration, RegionID: r4,
{NodeID: "underdark.throne_gallery", Kind: NodeKindFork, RegionID: r4,
Label: "Throne Gallery", PosX: 26, PosY: 2},
{NodeID: "underdark.throne_inner_hall", Kind: NodeKindExploration, RegionID: r4,
Label: "Inner Hall", PosX: 27, PosY: 2},
// N5/D4 secret spokes off the throne gallery (both merge to
// throne_steps, so either detour is length-neutral vs the inner-hall
// path). lost_reliquary is the Underdark's own perception-gated secret;
// sealed_forge_vault is the cross-zone key vault opened by an Underforge
// Seal (earned in the Underforge's Forge Vault).
{NodeID: "underdark.lost_reliquary", Kind: NodeKindSecret, RegionID: r4,
Label: "Lost Reliquary", PosX: 27, PosY: 0,
Content: ZoneNodeContent{LootBias: 2.0}},
{NodeID: "underdark.sealed_forge_vault", Kind: NodeKindSecret, RegionID: r4,
Label: "Sealed Vault", PosX: 27, PosY: 4,
Content: ZoneNodeContent{LootBias: 2.2}},
{NodeID: "underdark.throne_steps", Kind: NodeKindExploration, RegionID: r4,
Label: "Throne Steps", PosX: 28, PosY: 2},
{NodeID: "underdark.boss", Kind: NodeKindBoss, IsBoss: true, RegionID: r4,
@@ -232,7 +253,15 @@ func zoneUnderdarkGraph() ZoneGraph {
{From: "underdark.throne_antechamber", To: "underdark.throne_guard_post", Lock: LockNone},
{From: "underdark.throne_guard_post", To: "underdark.throne_doors", Lock: LockNone},
{From: "underdark.throne_doors", To: "underdark.throne_gallery", Lock: LockNone},
{From: "underdark.throne_gallery", To: "underdark.throne_inner_hall", Lock: LockNone},
{From: "underdark.throne_gallery", To: "underdark.throne_inner_hall", Lock: LockNone, Weight: 1},
{From: "underdark.throne_gallery", To: "underdark.lost_reliquary",
Lock: LockPerception, LockData: map[string]any{"dc": 17},
Hint: "a side-shrine still lit, off the gallery's blind end", Weight: 2},
{From: "underdark.throne_gallery", To: "underdark.sealed_forge_vault",
Lock: LockKey, LockData: map[string]any{"key_id": "underforge seal"},
Hint: "a deep-road door branded by a forge you may have passed", Weight: 3},
{From: "underdark.lost_reliquary", To: "underdark.throne_steps", Lock: LockNone},
{From: "underdark.sealed_forge_vault", To: "underdark.throne_steps", Lock: LockNone},
{From: "underdark.throne_inner_hall", To: "underdark.throne_steps", Lock: LockNone},
{From: "underdark.throne_steps", To: "underdark.boss", Lock: LockNone},
}

View File

@@ -9,8 +9,10 @@ func TestUnderdarkGraph_Registered(t *testing.T) {
}
// Long-expedition D1-d widened this zone from 10 → 46 nodes so the
// longest entry→boss walk lands in the T4 [28,34] traversal band.
if len(g.Nodes) != 46 {
t.Errorf("nodes = %d, want 46", len(g.Nodes))
// N5/D4 added two throne-gallery secret spokes (Lost Reliquary +
// Sealed Vault) → 48.
if len(g.Nodes) != 48 {
t.Errorf("nodes = %d, want 48", len(g.Nodes))
}
}