zones: make locked doors openable and stop autopilot stalling on them

Locks were fully implemented as pass/fail gates and nothing else. A
Perception or stat check rolls once per (run, edge), seeded so it can't
be reload-scummed — that half shipped in G5, the counterweight never did.
A bad roll simply deleted a branch of the graph for the rest of the run,
worst for a solo low-WIS character who quietly loses routes they never
learn existed. Three changes, one theme: a die roll should not be able to
permanently wall a player.

Party's best stat answers the check. evaluateEdgeLock read only the
acting character's mods, which made a party's rogue and its hired scout
decorative at every lock. Fold the whole roster — Pete included, since
excluding him would make hiring a scout worth less than the coins it
costs — and credit whoever got it open in the fork menu.

Thieves' tools as the escape hatch. A utility item (not a ConsumableDef,
or the fight engine would spend them for you) sold on Luigi's supplies
shelf, consumed by `!zone unlock <n>`. Deliberately not a skeleton key:
tools answer the two dice-driven locks only. A key lock is a quest token,
a level-min lock is progression, a region-clear lock is structure — none
of those are "you rolled badly", so none of them are pickable.

Autopilot picks a route instead of parking. The fork timeout was 8h,
which reads as "the player gets first say" and behaves as "the expedition
stops for a third of a day, at every fork" — a multi-day expedition
crosses a lot of forks. 30m keeps a genuine first say for anyone at the
keyboard. It now ranks by unvisited-then-edge-weight rather than taking
whatever the graph author happened to list first, spends tools when every
route is locked, and backtracks a room when it can't do even that, rather
than idling into the 24h reaper and losing the player days of progress to
a roll they never saw.

Sim A/B, same seeds, 90 runs/arm: 43.3% -> 42.2% clear, a single run
flipping and well inside the documented noise floor. A party+companion+pet
arm runs 31/32 clean through the new roster-folding path.
This commit is contained in:
prosolis
2026-07-20 23:52:20 -07:00
parent 71b97763ce
commit 6bcac41aa2
7 changed files with 542 additions and 32 deletions
+75 -6
View File
@@ -19,6 +19,8 @@ import (
"strings"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// pendingFork is the typed shape of dnd_zone_run.node_choices when the
@@ -79,14 +81,42 @@ func decodePendingFork(m map[string]any) (*pendingFork, error) {
// test them without going through the live DB. Filled in by
// evaluateForkEdges from the live run + character.
type edgeUnlockCtx struct {
RunID string
FromNode string
CharLevel int
AbilityMods [6]int // STR, DEX, CON, INT, WIS, CHA — matches DnDCharacter.Modifiers()
RunID string
FromNode string
CharLevel int
// AbilityMods is the *party's best* modifier per ability — STR, DEX, CON,
// INT, WIS, CHA, matching DnDCharacter.Modifiers(). A door doesn't care
// which set of eyes spotted the seam, and reading only the leader's sheet
// meant a party's rogue and its hired scout were decorative at every lock.
// AbilityWho names whoever supplied each best, empty when it's the leader,
// so the fork prompt can say who got it open.
AbilityMods [6]int
AbilityWho [6]string
InventoryNames map[string]bool
Expedition *Expedition
}
// creditFor names the party member whose ability carried a check, phrased for
// the fork prompt. Empty when the acting character managed it alone — there is
// nobody to credit and the menu stays quiet.
func (c edgeUnlockCtx) creditFor(ability int) string {
if who := c.AbilityWho[ability]; who != "" {
return who + " got it open"
}
return ""
}
// bestAbility folds one body's modifiers into the running party-best, recording
// the contributor's name for any ability it improves on.
func (c *edgeUnlockCtx) bestAbility(mods [6]int, who string) {
for i, m := range mods {
if m > c.AbilityMods[i] {
c.AbilityMods[i] = m
c.AbilityWho[i] = who
}
}
}
// evaluateEdgeLock returns whether the player can take this edge right
// now, with a player-facing reason on failure. Per plan §G5: Perception
// rolls fire once at fork-arrival (deterministic seed) and the result
@@ -101,7 +131,7 @@ func evaluateEdgeLock(e ZoneEdge, ctx edgeUnlockCtx) (unlocked bool, reason stri
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
total := roll + ctx.AbilityMods[4]
if total >= dc {
return true, ""
return true, ctx.creditFor(4)
}
return false, fmt.Sprintf("Perception %d vs DC %d", total, dc)
case LockKey:
@@ -138,7 +168,7 @@ func evaluateEdgeLock(e ZoneEdge, ctx edgeUnlockCtx) (unlocked bool, reason stri
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
total := roll + ctx.AbilityMods[idx]
if total >= dc {
return true, ""
return true, ctx.creditFor(idx)
}
return false, fmt.Sprintf("%s %d vs DC %d", stat, total, dc)
}
@@ -219,10 +249,45 @@ func buildUnlockCtx(c *DnDCharacter, runID, fromNode string) edgeUnlockCtx {
}
if exp, err := getActiveExpedition(c.UserID); err == nil && exp != nil {
ctx.Expedition = exp
foldPartyAbilities(&ctx, exp, c.UserID)
}
return ctx
}
// foldPartyAbilities raises ctx.AbilityMods to the best any body on the
// expedition can offer. The companion counts: he is a seat that walks the same
// corridor, and excluding him would make hiring a scout worth less than the
// coins it costs.
//
// Errors are swallowed rather than propagated — a roster read that fails leaves
// the leader's own mods standing, which is exactly the pre-party behaviour and
// never harder than it was.
func foldPartyAbilities(ctx *edgeUnlockCtx, exp *Expedition, acting id.UserID) {
seats, err := expeditionParty(exp.ID, string(exp.UserID))
if err != nil {
return
}
for _, s := range seats {
if s.Kind == SeatCompanion {
class, level := companionLoadout(exp.ID)
ctx.bestAbility(companionSheet(class, level).Modifiers(), companionDisplayName)
continue
}
if s.UserID == acting {
continue // whoever we built the ctx from is already the baseline
}
mate, err := LoadDnDCharacter(s.UserID)
if err != nil || mate == nil {
continue
}
name, _ := loadDisplayName(s.UserID)
if name == "" {
name = string(s.UserID)
}
ctx.bestAbility(mate.Modifiers(), name)
}
}
// evaluateForkEdges walks all outgoing edges of fromNode in the graph
// and produces a pending-choice list ready to be persisted. Locked
// edges that have a Hint stay in the menu (the player needs the
@@ -288,6 +353,10 @@ func renderForkPrompt(zone ZoneDefinition, pf pendingFork) string {
b.WriteString(fmt.Sprintf("**%s — Path divides.** Choose with `!zone go <n>`.\n\n", zone.Display))
for _, c := range pf.Options {
switch {
case c.Unlocked && c.Reason != "":
// A party-mate's ability beat the check — say so, so the player can
// see what the roster bought them.
b.WriteString(fmt.Sprintf("**%d.** %s _(%s)_\n", c.Index, c.Label, c.Reason))
case c.Unlocked:
b.WriteString(fmt.Sprintf("**%d.** %s\n", c.Index, c.Label))
case c.Hint != "":