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

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