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

@@ -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