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
+143 -26
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"log/slog"
"math"
"sort"
"strconv"
"strings"
"time"
@@ -843,41 +844,147 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
// run graph / harvest tally / supplies / threat — same as before, just
// no streamFlow here. compact==true switches the underlying combat
// narration into terse mode and auto-resolves elite (not boss) rooms.
// forkAutoPickTimeout — how long a background fork may sit unanswered
// before the autopilot picks an available route itself. Short enough that
// the expedition keeps moving rather than idling out to the 24h stale-run
// reaper; long enough that a player away for the evening still gets first
// say on a genuine fork.
const forkAutoPickTimeout = 8 * time.Hour
// forkAutoPickTimeout — how long a background fork may sit unanswered before
// the autopilot picks a route itself.
//
// This was 8h, which reads as "the player gets first say" and behaves as "the
// expedition stops for a third of a day, every fork." A multi-day expedition
// crosses a lot of forks; at 8h apiece the autopilot spends more of its life
// parked than walking, and a player who is simply asleep loses a night per
// branch. 30m keeps a genuine first say for anyone actually at the keyboard and
// costs an absent player almost nothing.
const forkAutoPickTimeout = 30 * time.Minute
// autoPickStaleFork commits the first unlocked option of a stale background
// fork, advancing the run to that node exactly as `!zone go <n>` would
// (advanceZoneRunNode + region-transition hook). Returns false — no pick
// when every option is locked, so the caller re-emits the prompt and the
// run idles on toward the reaper. The choice is logged as a narrative entry
// so the end-of-day digest can surface the decision the player missed.
func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool {
var chosen *pendingChoice
for i := range pf.Options {
if pf.Options[i].Unlocked {
chosen = &pf.Options[i]
break
// rankForkOptions orders a fork's options by how much walking them is worth:
// somewhere new first, then the fatter edge (Weight is the author's own "this
// is the main line" signal), then menu order as the tiebreak so the pick is
// deterministic. Only unlocked options are returned.
//
// The old rule was "first unlocked option in the menu", which is edge-authoring
// order — meaningful to whoever wrote the graph, arbitrary to the player. It
// walked past unvisited branches to loop through cleared ones often enough to
// look broken.
func rankForkOptions(g ZoneGraph, run *DungeonRun, pf *pendingFork) []pendingChoice {
weights := map[string]int{}
for _, e := range g.outgoingEdges(run.CurrentNode) {
weights[e.To] = e.Weight
}
visited := map[string]bool{}
for _, n := range run.VisitedNodes {
visited[n] = true
}
open := make([]pendingChoice, 0, len(pf.Options))
for _, o := range pf.Options {
if o.Unlocked {
open = append(open, o)
}
}
if chosen == nil {
return false // nothing unlocked — leave it for the player / reaper
sort.SliceStable(open, func(i, j int) bool {
vi, vj := visited[open[i].To], visited[open[j].To]
if vi != vj {
return !vi // unvisited first
}
if wi, wj := weights[open[i].To], weights[open[j].To]; wi != wj {
return wi > wj
}
return open[i].Index < open[j].Index
})
return open
}
// autoPickStaleFork commits a stale background fork, advancing the run exactly
// as `!zone go <n>` would (advanceZoneRunNode + region-transition hook). The
// choice is logged as a narrative entry so the end-of-day digest can surface
// the decision the player missed.
//
// When every route is locked it does not give up: it spends a set of thieves'
// tools if the party is carrying any and one of the locks is the pickable kind.
// Returns false only when there is genuinely nothing it can do — the caller
// then backtracks rather than idling the expedition into the 24h reaper.
func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool {
g, _ := loadZoneGraph(run.ZoneID)
ranked := rankForkOptions(g, run, pf)
note := "autopilot took the most promising path"
if len(ranked) == 0 {
picked, ok := p.autoPickWithTools(run, pf)
if !ok {
return false
}
ranked = []pendingChoice{picked}
note = "autopilot spent " + thievesToolsName + " on the only way forward"
}
chosen := ranked[0]
if _, err := advanceZoneRunNode(run.RunID, chosen.To); err != nil {
slog.Warn("expedition: auto-pick stale fork",
"user", run.UserID, "run", run.RunID, "err", err)
return false
}
g, _ := loadZoneGraph(run.ZoneID)
fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To])
if exp != nil {
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
fmt.Sprintf("autopilot took an available path after %dh idle at the fork: %s",
int(forkAutoPickTimeout.Hours()), chosen.Label), "")
fmt.Sprintf("%s: %s", note, chosen.Label), "")
}
return true
}
// autoPickWithTools spends one set of thieves' tools to open a fork where every
// route is locked, so a bad Perception roll can't quietly end an expedition the
// player paid days into. It only ever fires when there is no free route left —
// the player's tools are their own, and the autopilot does not get to burn them
// for convenience.
func (p *AdventurePlugin) autoPickWithTools(run *DungeonRun, pf *pendingFork) (pendingChoice, bool) {
owner := id.UserID(run.UserID)
toolID, ok := findThievesTools(owner)
if !ok {
return pendingChoice{}, false
}
for i := range pf.Options {
if pf.Options[i].Unlocked || !pickableLock(pf.Options[i].Lock) {
continue
}
if err := removeAdvInventoryItem(toolID); err != nil {
slog.Warn("expedition: autopilot tools spend", "user", run.UserID, "err", err)
return pendingChoice{}, false
}
pf.Options[i].Unlocked = true
pf.Options[i].Reason = "opened with " + thievesToolsName
_ = writePendingFork(run.RunID, *pf)
return pf.Options[i], true
}
return pendingChoice{}, false
}
// backtrackFromDeadFork walks the run back one room when a fork has no route
// the autopilot can take and no tools to buy one with. Without this the run
// simply sits there until the 24h stale reaper ends the expedition — a player
// losing days of progress to a die roll they never saw and could not answer.
// Backtracking at least returns them to a room with other exits.
//
// Returns false at the entry node, where there is nowhere behind to go.
func (p *AdventurePlugin) backtrackFromDeadFork(exp *Expedition, run *DungeonRun) bool {
idx := pathIndexOf(run.VisitedNodes, run.CurrentNode)
if idx <= 0 {
return false
}
target := run.VisitedNodes[idx-1]
// Clear the fork first: it belongs to the node being left, and both
// `!zone advance` and `!zone go` would otherwise resolve a prompt pointing
// at a room the party is no longer standing in.
if err := clearPendingFork(run.RunID); err != nil {
slog.Warn("expedition: backtrack clear fork", "run", run.RunID, "err", err)
return false
}
if _, err := revisitZoneRun(run.RunID, target, run.VisitedNodes); err != nil {
slog.Warn("expedition: backtrack from dead fork", "run", run.RunID, "err", err)
return false
}
if exp != nil {
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
"every way on was sealed — the party doubled back", "")
}
return true
}
@@ -903,9 +1010,19 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
// (unlocked) route and keep walking instead of stalling out.
if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil {
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
picked := compact &&
time.Since(run.LastActionAt) > forkAutoPickTimeout &&
p.autoPickStaleFork(exp, run, pf)
stale := compact && time.Since(run.LastActionAt) > forkAutoPickTimeout
picked := stale && p.autoPickStaleFork(exp, run, pf)
// Stale and nothing takeable: every route locked, no tools. Back out
// one room rather than sitting here until the 24h reaper ends an
// expedition the player may be days into. The backtrack clears the
// fork, so the next tick walks from the previous room normally.
if stale && !picked && p.backtrackFromDeadFork(exp, run) {
return autopilotWalkResult{
finalMsg: "🔒 Every way on was sealed. The party doubled back to look for another line.",
rooms: 0,
reason: stopFork,
}
}
if !picked {
zone := zoneOrFallback(run.ZoneID)
return autopilotWalkResult{