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
+161
View File
@@ -0,0 +1,161 @@
package plugin
import (
"fmt"
"strings"
"maunium.net/go/mautrix/id"
)
// Thieves' tools — the counterweight to a failed skill check.
//
// A Perception / stat-check lock rolls once per (run, edge) and the roll is
// seeded, deliberately, so re-reading the fork can't reroll it (plan §G5). That
// half shipped; the other half never did, so a bad roll simply deleted a branch
// of the graph for the rest of the run with no recourse at all — worst for a
// solo low-WIS character, who quietly loses routes they never learn existed.
//
// Tools are that recourse: a consumable that answers the check instead of the
// character. They are deliberately NOT a skeleton key. A key lock is a quest
// token, a level-min lock is progression, and a region-clear lock is structure —
// none of those are "you rolled badly", so none of them are pickable. Tools open
// exactly the locks that luck closed.
// thievesToolsName is the inventory item name. Matching is case-folded so a
// player typing it back at the shop doesn't have to find the apostrophe.
const thievesToolsName = "Thieves' Tools"
// thievesToolsItemType keeps them out of the combat consumable scan. They are a
// utility item like the medical-debt card, not something the fight engine may
// spend on the player's behalf.
const thievesToolsItemType = "tool"
// thievesToolsPrice is what Luigi charges. Priced against a T1 consumable so
// carrying a couple is a routine purchase rather than a considered one — the
// point is that no run is ever hard-walled, not to open a money sink.
const thievesToolsPrice int64 = 600
// pickableLock reports whether tools can answer this lock. Only the two
// dice-driven kinds qualify; see the file comment for why the rest don't.
func pickableLock(kind string) bool {
return kind == string(LockPerception) || kind == string(LockStatCheck)
}
// findThievesTools returns the inventory row ID of one set of tools, or ok=false
// if the player is carrying none.
func findThievesTools(userID id.UserID) (int64, bool) {
inv, err := loadAdvInventory(userID)
if err != nil {
return 0, false
}
for _, it := range inv {
if strings.EqualFold(it.Name, thievesToolsName) {
return it.ID, true
}
}
return 0, false
}
// countThievesTools reports how many sets the player carries, for the "N left"
// line after a use.
func countThievesTools(userID id.UserID) int {
inv, err := loadAdvInventory(userID)
if err != nil {
return 0
}
n := 0
for _, it := range inv {
if strings.EqualFold(it.Name, thievesToolsName) {
n++
}
}
return n
}
// zoneCmdUnlock handles `!zone unlock <n>`: spend one set of thieves' tools to
// open a locked fork option, then commit the move exactly as `!zone go <n>`
// would. Every guard zoneCmdGo applies applies here too — same run, same leader
// rule, same mid-fight refusal — because this is that command with a different
// admission price.
func (p *AdventurePlugin) zoneCmdUnlock(ctx MessageContext, rest string) error {
run, isLeader, err := activeZoneRunFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
}
if run == nil {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
}
if !isLeader {
return p.SendDM(ctx.Sender, msgLeaderPicksPath)
}
if cs, _ := activeCombatSessionFor(ctx.Sender); cs != nil {
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.")
}
pf, derr := decodePendingFork(run.NodeChoices)
if derr != nil {
return p.SendDM(ctx.Sender, "Couldn't decode pending fork: "+derr.Error())
}
if pf == nil {
return p.SendDM(ctx.Sender, "No fork pending. Use "+continueHint(ctx.Sender))
}
rest = strings.TrimSpace(rest)
if rest == "" {
zone := zoneOrFallback(run.ZoneID)
return p.SendDM(ctx.Sender, "**Which one?**\n\n"+renderForkPrompt(zone, *pf)+
fmt.Sprintf("\n\n_`!zone unlock <n>` — spends one set of %s. You carry %d._",
thievesToolsName, countThievesTools(ctx.Sender)))
}
choice := atoiSafe(rest)
if choice < 1 || choice > len(pf.Options) {
return p.SendDM(ctx.Sender, fmt.Sprintf("Choice must be a number from the menu (1%d).", len(pf.Options)))
}
chosen := pf.Options[choice-1]
if chosen.Unlocked {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"**%s** is already open — no need to spend tools. `!zone go %d`.", chosen.Label, choice))
}
if !pickableLock(chosen.Lock) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"🔒 Tools won't help here. %s\n\n_Thieves' tools answer a failed check, not a locked gate._",
lockRefusalFor(chosen)))
}
toolID, ok := findThievesTools(ctx.Sender)
if !ok {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"🔒 **%s** needs %s and you're carrying none.\n\nLuigi stocks them under `!shop` → Supplies.",
chosen.Label, thievesToolsName))
}
if rerr := removeAdvInventoryItem(toolID); rerr != nil {
return p.SendDM(ctx.Sender, "Couldn't spend the tools: "+rerr.Error())
}
// The tools answered the check, so the option is open from here on. Commit
// it back to the pending fork before advancing: if the advance fails, the
// player has paid and must not be told the door is shut again.
pf.Options[choice-1].Unlocked = true
pf.Options[choice-1].Reason = "opened with " + thievesToolsName
_ = writePendingFork(run.RunID, *pf)
left := countThievesTools(ctx.Sender)
header := fmt.Sprintf("🔓 **%s** — picked. _(%s used, %d left)_\n\n", chosen.Label, thievesToolsName, left)
return p.commitForkChoice(ctx, run, pf.Options[choice-1], header)
}
// lockRefusalFor phrases why a non-pickable lock stays shut, preferring the
// evaluator's own reason so the player sees the same wording the menu gave.
func lockRefusalFor(c pendingChoice) string {
if c.Reason != "" {
return c.Reason + "."
}
switch c.Lock {
case string(LockKey):
return "That door wants a key, and a key is a thing you find, not a thing you force."
case string(LockLevelMin):
return "That way is beyond you yet. Come back stronger."
case string(LockRegionClear):
return "Somewhere else has to fall first."
}
return "That one isn't going to open."
}