mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -149,6 +149,103 @@ func (p *AdventurePlugin) grantJournalPage(userID id.UserID, rng *rand.Rand) str
|
|||||||
journalPages[page-1].Title, page, journalTotalPages)
|
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
|
// 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
|
// 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
|
// King himself — but what falls there is a shell he shed, which is why the arc
|
||||||
|
|||||||
216
internal/plugin/adventure_secret_room_test.go
Normal file
216
internal/plugin/adventure_secret_room_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -988,6 +988,25 @@ func (p *AdventurePlugin) streamFlowThen(userID id.UserID, phaseMessages []strin
|
|||||||
// inter-phase delays — see resolveCombatRoom for the contract. For
|
// inter-phase delays — see resolveCombatRoom for the contract. For
|
||||||
// non-combat rooms (entry, trap), phases is nil and outcome carries the
|
// non-combat rooms (entry, trap), phases is nil and outcome carries the
|
||||||
// resolution narration.
|
// 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) {
|
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
|
// 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.
|
// 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) {
|
if run.RoomIsCleared(run.CurrentRoom) {
|
||||||
return
|
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() {
|
switch run.CurrentRoomType() {
|
||||||
case RoomEntry:
|
case RoomEntry:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -202,6 +202,52 @@ func scanMoodEventsFromEvents(runID string, events []CombatEvent) (nat20s, nat1s
|
|||||||
//
|
//
|
||||||
// Higher-tier zones currently fall through to the pre-D2a flat-percent
|
// Higher-tier zones currently fall through to the pre-D2a flat-percent
|
||||||
// nick — D3a/D4a will add Tier 2+ trap catalogs.
|
// 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) {
|
func (p *AdventurePlugin) resolveTrapRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (damage int, narration string) {
|
||||||
dndChar, _ := LoadDnDCharacter(userID)
|
dndChar, _ := LoadDnDCharacter(userID)
|
||||||
if dndChar == nil {
|
if dndChar == nil {
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ package plugin
|
|||||||
// hangs off the open spoke of upper_hall (cheapest gate, hardest fight);
|
// hangs off the open spoke of upper_hall (cheapest gate, hardest fight);
|
||||||
// the SECRET sits behind the highest-DC Perception with its loot bias
|
// the SECRET sits behind the highest-DC Perception with its loot bias
|
||||||
// preserved.
|
// 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 {
|
func zoneManorBlackspireGraph() ZoneGraph {
|
||||||
nodes := []ZoneNode{
|
nodes := []ZoneNode{
|
||||||
@@ -108,6 +114,12 @@ func zoneManorBlackspireGraph() ZoneGraph {
|
|||||||
{NodeID: "manor_blackspire.reliquary_dust", Kind: NodeKindExploration,
|
{NodeID: "manor_blackspire.reliquary_dust", Kind: NodeKindExploration,
|
||||||
Label: "Reliquary Dust", PosX: 18, PosY: 2},
|
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).
|
// upper_hall — level-min spoke (tower).
|
||||||
{NodeID: "manor_blackspire.tower_observatory", Kind: NodeKindExploration,
|
{NodeID: "manor_blackspire.tower_observatory", Kind: NodeKindExploration,
|
||||||
Label: "Tower Observatory", PosX: 16, PosY: 4},
|
Label: "Tower Observatory", PosX: 16, PosY: 4},
|
||||||
@@ -174,6 +186,9 @@ func zoneManorBlackspireGraph() ZoneGraph {
|
|||||||
{From: "manor_blackspire.upper_hall", To: "manor_blackspire.tower_observatory",
|
{From: "manor_blackspire.upper_hall", To: "manor_blackspire.tower_observatory",
|
||||||
Lock: LockLevelMin, LockData: map[string]any{"min_level": 7},
|
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},
|
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).
|
// Master Bedroom spoke (elite).
|
||||||
{From: "manor_blackspire.master_bedroom", To: "manor_blackspire.grim_balcony", Lock: LockNone},
|
{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.choir_balcony", To: "manor_blackspire.reliquary_dust", Lock: LockNone},
|
||||||
{From: "manor_blackspire.reliquary_dust", To: "manor_blackspire.spire_corridor", 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.
|
// Tower spoke.
|
||||||
{From: "manor_blackspire.tower_observatory", To: "manor_blackspire.spiral_ascent", Lock: LockNone},
|
{From: "manor_blackspire.tower_observatory", To: "manor_blackspire.spiral_ascent", Lock: LockNone},
|
||||||
{From: "manor_blackspire.spiral_ascent", To: "manor_blackspire.telescope_attic", Lock: LockNone},
|
{From: "manor_blackspire.spiral_ascent", To: "manor_blackspire.telescope_attic", Lock: LockNone},
|
||||||
|
|||||||
@@ -12,22 +12,24 @@ func TestManorBlackspireGraph_Registered(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Long-expedition D1-c widened this zone from 11 → 35 nodes so the
|
// 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.
|
// longest entry→boss walk lands in the T3 [22,26] traversal band.
|
||||||
if len(g.Nodes) != 35 {
|
// N5/D4 added the Sealed Reliquary cross-zone vault → 36.
|
||||||
t.Errorf("nodes = %d, want 35", len(g.Nodes))
|
if len(g.Nodes) != 36 {
|
||||||
|
t.Errorf("nodes = %d, want 36", len(g.Nodes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestManorBlackspireGraph_TwoStackedThreeWayForks captures the design
|
// TestManorBlackspireGraph_StackedForks captures the design shape: great_hall
|
||||||
// shape: both great_hall and upper_hall expose three options.
|
// exposes three options; upper_hall exposes four since N5/D4 added the Sealed
|
||||||
func TestManorBlackspireGraph_TwoStackedThreeWayForks(t *testing.T) {
|
// Reliquary key spoke (the other three remain the elite/secret/tower gates).
|
||||||
|
func TestManorBlackspireGraph_StackedForks(t *testing.T) {
|
||||||
g := zoneManorBlackspireGraph()
|
g := zoneManorBlackspireGraph()
|
||||||
for _, hub := range []string{
|
want := map[string]int{
|
||||||
"manor_blackspire.great_hall",
|
"manor_blackspire.great_hall": 3,
|
||||||
"manor_blackspire.upper_hall",
|
"manor_blackspire.upper_hall": 4,
|
||||||
} {
|
}
|
||||||
outs := g.outgoingEdges(hub)
|
for hub, n := range want {
|
||||||
if len(outs) != 3 {
|
if outs := g.outgoingEdges(hub); len(outs) != n {
|
||||||
t.Errorf("%s outgoing = %d, want 3", hub, len(outs))
|
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.master_bedroom",
|
||||||
"manor_blackspire.hidden_oratory",
|
"manor_blackspire.hidden_oratory",
|
||||||
"manor_blackspire.tower_observatory",
|
"manor_blackspire.tower_observatory",
|
||||||
|
"manor_blackspire.sealed_reliquary",
|
||||||
} {
|
} {
|
||||||
if !reachable(g, leaf, "manor_blackspire.boss") {
|
if !reachable(g, leaf, "manor_blackspire.boss") {
|
||||||
t.Errorf("%s unreachable to boss", leaf)
|
t.Errorf("%s unreachable to boss", leaf)
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ package plugin
|
|||||||
// Garrison) and the multi-region transition a teeth-y interrupt.
|
// Garrison) and the multi-region transition a teeth-y interrupt.
|
||||||
// The deep_chasm spur stays anchor-light (harvest, no second trap/elite)
|
// The deep_chasm spur stays anchor-light (harvest, no second trap/elite)
|
||||||
// so the CON-gated climb keeps its "denser loot, less fighting" identity.
|
// 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 {
|
func zoneUnderdarkGraph() ZoneGraph {
|
||||||
r1 := "underdark_surface_tunnels"
|
r1 := "underdark_surface_tunnels"
|
||||||
@@ -163,10 +172,22 @@ func zoneUnderdarkGraph() ZoneGraph {
|
|||||||
Label: "Guard Post", PosX: 24, PosY: 2},
|
Label: "Guard Post", PosX: 24, PosY: 2},
|
||||||
{NodeID: "underdark.throne_doors", Kind: NodeKindExploration, RegionID: r4,
|
{NodeID: "underdark.throne_doors", Kind: NodeKindExploration, RegionID: r4,
|
||||||
Label: "Riven Doors", PosX: 25, PosY: 2},
|
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},
|
Label: "Throne Gallery", PosX: 26, PosY: 2},
|
||||||
{NodeID: "underdark.throne_inner_hall", Kind: NodeKindExploration, RegionID: r4,
|
{NodeID: "underdark.throne_inner_hall", Kind: NodeKindExploration, RegionID: r4,
|
||||||
Label: "Inner Hall", PosX: 27, PosY: 2},
|
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,
|
{NodeID: "underdark.throne_steps", Kind: NodeKindExploration, RegionID: r4,
|
||||||
Label: "Throne Steps", PosX: 28, PosY: 2},
|
Label: "Throne Steps", PosX: 28, PosY: 2},
|
||||||
{NodeID: "underdark.boss", Kind: NodeKindBoss, IsBoss: true, RegionID: r4,
|
{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_antechamber", To: "underdark.throne_guard_post", Lock: LockNone},
|
||||||
{From: "underdark.throne_guard_post", To: "underdark.throne_doors", 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_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_inner_hall", To: "underdark.throne_steps", Lock: LockNone},
|
||||||
{From: "underdark.throne_steps", To: "underdark.boss", Lock: LockNone},
|
{From: "underdark.throne_steps", To: "underdark.boss", Lock: LockNone},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ func TestUnderdarkGraph_Registered(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Long-expedition D1-d widened this zone from 10 → 46 nodes so the
|
// 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.
|
// longest entry→boss walk lands in the T4 [28,34] traversal band.
|
||||||
if len(g.Nodes) != 46 {
|
// N5/D4 added two throne-gallery secret spokes (Lost Reliquary +
|
||||||
t.Errorf("nodes = %d, want 46", len(g.Nodes))
|
// Sealed Vault) → 48.
|
||||||
|
if len(g.Nodes) != 48 {
|
||||||
|
t.Errorf("nodes = %d, want 48", len(g.Nodes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user