From c37c95a3e32e669c00688b90dffc161d2393863f Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:02:15 -0700 Subject: [PATCH] =?UTF-8?q?N5/D4:=20secret=20content=20pass=20=E2=80=94=20?= =?UTF-8?q?treasure-cache=20secret=20rooms=20+=20cross-zone=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/plugin/adventure_flavor_campaign.go | 97 ++++++++ internal/plugin/adventure_secret_room_test.go | 216 ++++++++++++++++++ internal/plugin/dnd_zone_cmd.go | 27 +++ internal/plugin/dnd_zone_combat.go | 46 ++++ .../plugin/zone_graph_manor_blackspire.go | 18 ++ .../zone_graph_manor_blackspire_test.go | 27 ++- internal/plugin/zone_graph_underdark.go | 33 ++- internal/plugin/zone_graph_underdark_test.go | 6 +- 8 files changed, 454 insertions(+), 16 deletions(-) create mode 100644 internal/plugin/adventure_secret_room_test.go diff --git a/internal/plugin/adventure_flavor_campaign.go b/internal/plugin/adventure_flavor_campaign.go index 7449527..740666c 100644 --- a/internal/plugin/adventure_flavor_campaign.go +++ b/internal/plugin/adventure_flavor_campaign.go @@ -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 diff --git a/internal/plugin/adventure_secret_room_test.go b/internal/plugin/adventure_secret_room_test.go new file mode 100644 index 0000000..486892a --- /dev/null +++ b/internal/plugin/adventure_secret_room_test.go @@ -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) + } +} diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index b104422..0b07733 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -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 diff --git a/internal/plugin/dnd_zone_combat.go b/internal/plugin/dnd_zone_combat.go index ea3831c..067213e 100644 --- a/internal/plugin/dnd_zone_combat.go +++ b/internal/plugin/dnd_zone_combat.go @@ -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 { diff --git a/internal/plugin/zone_graph_manor_blackspire.go b/internal/plugin/zone_graph_manor_blackspire.go index e711d72..981fe1f 100644 --- a/internal/plugin/zone_graph_manor_blackspire.go +++ b/internal/plugin/zone_graph_manor_blackspire.go @@ -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}, diff --git a/internal/plugin/zone_graph_manor_blackspire_test.go b/internal/plugin/zone_graph_manor_blackspire_test.go index ba55308..ce6a0b9 100644 --- a/internal/plugin/zone_graph_manor_blackspire_test.go +++ b/internal/plugin/zone_graph_manor_blackspire_test.go @@ -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) diff --git a/internal/plugin/zone_graph_underdark.go b/internal/plugin/zone_graph_underdark.go index 5dbb2aa..4fe321e 100644 --- a/internal/plugin/zone_graph_underdark.go +++ b/internal/plugin/zone_graph_underdark.go @@ -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}, } diff --git a/internal/plugin/zone_graph_underdark_test.go b/internal/plugin/zone_graph_underdark_test.go index a86bc9b..366c429 100644 --- a/internal/plugin/zone_graph_underdark_test.go +++ b/internal/plugin/zone_graph_underdark_test.go @@ -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)) } }