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
+38
View File
@@ -1019,6 +1019,9 @@ func luigiSuppliesView(_ id.UserID, balance float64) string {
} }
} }
sb.WriteString(fmt.Sprintf("**%s** — €%d\n Opens one dungeon path a failed check closed (`!zone unlock <n>`). Not used in combat.\n\n",
thievesToolsName, thievesToolsPrice))
sb.WriteString("Reply with an item name to buy, or `back` to return.\n") sb.WriteString("Reply with an item name to buy, or `back` to return.\n")
sb.WriteString("Stronger consumables drop from foraging, mining, fishing, and dungeons at T2+.") sb.WriteString("Stronger consumables drop from foraging, mining, fishing, and dungeons at T2+.")
return sb.String() return sb.String()
@@ -1073,6 +1076,13 @@ func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interactio
return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*") return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*")
} }
// Thieves' tools sit on the supplies shelf but are not a ConsumableDef:
// the combat engine scans inventory against that table and would happily
// spend them mid-fight. They get their own branch and their own item type.
if containsFold(thievesToolsName, reply) || containsFold("thieves tools", reply) {
return p.buyThievesTools(ctx, interaction)
}
// Find matching consumable // Find matching consumable
var match *ConsumableDef var match *ConsumableDef
for i := range consumableDefs { for i := range consumableDefs {
@@ -1115,6 +1125,34 @@ func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interactio
match.Name, consumablePrice, newBalance)) match.Name, consumablePrice, newBalance))
} }
// buyThievesTools sells one set off the supplies shelf. Mirrors the consumable
// purchase beside it — same session price factor, same 5% pot cut — and leaves
// the player in the supplies view so they can buy a second.
func (p *AdventurePlugin) buyThievesTools(ctx MessageContext, interaction *advPendingInteraction) error {
price := float64(thievesToolsPrice) * p.shopSessionPriceFactor(ctx.Sender)
balance := p.euro.GetBalance(ctx.Sender)
if balance < price {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%.0f for %s but only have €%.0f.",
price, thievesToolsName, balance))
}
p.euro.Debit(ctx.Sender, price, "shop_thieves_tools")
if potCut := int(math.Round(price * 0.05)); potCut > 0 {
communityPotAdd(potCut)
trackTaxPaid(ctx.Sender, potCut)
}
_ = addAdvInventoryItem(ctx.Sender, AdvItem{
Name: thievesToolsName,
Type: thievesToolsItemType,
Tier: 1,
Value: thievesToolsPrice / 2,
})
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Purchased **%s** for €%.0f. You carry %d.\n💰 Balance: €%.0f\n\nReply with another item name or `back` to return.",
thievesToolsName, price, countThievesTools(ctx.Sender), p.euro.GetBalance(ctx.Sender)))
}
// ── Curios (Magic Items) ──────────────────────────────────────────────────── // ── Curios (Magic Items) ────────────────────────────────────────────────────
// curiosStockSize — how many registry magic items Luigi stocks per day. // curiosStockSize — how many registry magic items Luigi stocks per day.
+143 -26
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"math" "math"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -843,41 +844,147 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
// run graph / harvest tally / supplies / threat — same as before, just // run graph / harvest tally / supplies / threat — same as before, just
// no streamFlow here. compact==true switches the underlying combat // no streamFlow here. compact==true switches the underlying combat
// narration into terse mode and auto-resolves elite (not boss) rooms. // narration into terse mode and auto-resolves elite (not boss) rooms.
// forkAutoPickTimeout — how long a background fork may sit unanswered // forkAutoPickTimeout — how long a background fork may sit unanswered before
// before the autopilot picks an available route itself. Short enough that // the autopilot picks a route itself.
// 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 // This was 8h, which reads as "the player gets first say" and behaves as "the
// say on a genuine fork. // expedition stops for a third of a day, every fork." A multi-day expedition
const forkAutoPickTimeout = 8 * time.Hour // 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 // rankForkOptions orders a fork's options by how much walking them is worth:
// fork, advancing the run to that node exactly as `!zone go <n>` would // somewhere new first, then the fatter edge (Weight is the author's own "this
// (advanceZoneRunNode + region-transition hook). Returns false — no pick // is the main line" signal), then menu order as the tiebreak so the pick is
// when every option is locked, so the caller re-emits the prompt and the // deterministic. Only unlocked options are returned.
// 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. // 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)
}
}
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 { func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool {
var chosen *pendingChoice g, _ := loadZoneGraph(run.ZoneID)
for i := range pf.Options {
if pf.Options[i].Unlocked { ranked := rankForkOptions(g, run, pf)
chosen = &pf.Options[i] note := "autopilot took the most promising path"
break 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"
} }
if chosen == nil { chosen := ranked[0]
return false // nothing unlocked — leave it for the player / reaper
}
if _, err := advanceZoneRunNode(run.RunID, chosen.To); err != nil { if _, err := advanceZoneRunNode(run.RunID, chosen.To); err != nil {
slog.Warn("expedition: auto-pick stale fork", slog.Warn("expedition: auto-pick stale fork",
"user", run.UserID, "run", run.RunID, "err", err) "user", run.UserID, "run", run.RunID, "err", err)
return false return false
} }
g, _ := loadZoneGraph(run.ZoneID)
fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To]) fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To])
if exp != nil { if exp != nil {
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
fmt.Sprintf("autopilot took an available path after %dh idle at the fork: %s", fmt.Sprintf("%s: %s", note, chosen.Label), "")
int(forkAutoPickTimeout.Hours()), 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 return true
} }
@@ -903,9 +1010,19 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
// (unlocked) route and keep walking instead of stalling out. // (unlocked) route and keep walking instead of stalling out.
if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil { if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil {
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil { if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
picked := compact && stale := compact && time.Since(run.LastActionAt) > forkAutoPickTimeout
time.Since(run.LastActionAt) > forkAutoPickTimeout && picked := stale && p.autoPickStaleFork(exp, run, pf)
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 { if !picked {
zone := zoneOrFallback(run.ZoneID) zone := zoneOrFallback(run.ZoneID)
return autopilotWalkResult{ return autopilotWalkResult{
+4
View File
@@ -53,6 +53,9 @@ func (p *AdventurePlugin) handleDnDZoneCmd(ctx MessageContext, args string) erro
// fork pending) the handler short-circuits with a friendly // fork pending) the handler short-circuits with a friendly
// message — see zoneCmdGo for the full surface. // message — see zoneCmdGo for the full surface.
return p.zoneCmdGo(ctx, rest) return p.zoneCmdGo(ctx, rest)
case "unlock", "pick", "force":
// Spend thieves' tools on a fork option a failed check closed.
return p.zoneCmdUnlock(ctx, rest)
case "status", "info": case "status", "info":
return p.zoneCmdStatus(ctx) return p.zoneCmdStatus(ctx)
case "map", "m": case "map", "m":
@@ -83,6 +86,7 @@ func zoneHelpText() string {
b.WriteString("`!zone map` — show the room layout\n") b.WriteString("`!zone map` — show the room layout\n")
b.WriteString("`!zone advance` — resolve the current room and move on\n") b.WriteString("`!zone advance` — resolve the current room and move on\n")
b.WriteString("`!zone go <n>` — at a fork, take path #n\n") b.WriteString("`!zone go <n>` — at a fork, take path #n\n")
b.WriteString("`!zone unlock <n>` — spend thieves' tools to open a path you couldn't\n")
b.WriteString("`!revisit <n>` — walk back to a room you've already cleared\n") b.WriteString("`!revisit <n>` — walk back to a room you've already cleared\n")
b.WriteString("`!zone abandon` — end the active run (no rewards)\n") b.WriteString("`!zone abandon` — end the active run (no rewards)\n")
b.WriteString("`!zone taunt` — poke TwinBee (they'll remember)\n") b.WriteString("`!zone taunt` — poke TwinBee (they'll remember)\n")
+12
View File
@@ -188,6 +188,17 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
if cerr != nil { if cerr != nil {
return p.SendDM(ctx.Sender, cerr.Error()) return p.SendDM(ctx.Sender, cerr.Error())
} }
return p.commitForkChoice(ctx, run, chosen, "")
}
// commitForkChoice advances the run onto an already-validated fork option and
// emits the arrival teaser. Split out of zoneCmdGo so `!zone unlock` — which
// reaches the same place by paying for it — cannot drift from the plain
// `!zone go` arrival: same region-transition hook, same camp strike, same
// boss/elite prompt. header, if set, is printed above the move.
func (p *AdventurePlugin) commitForkChoice(
ctx MessageContext, run *DungeonRun, chosen pendingChoice, header string,
) error {
nextIdx, aerr := advanceZoneRunNode(run.RunID, chosen.To) nextIdx, aerr := advanceZoneRunNode(run.RunID, chosen.To)
if aerr != nil { if aerr != nil {
return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error()) return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error())
@@ -200,6 +211,7 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
fireGraphRegionTransition(run.UserID, fromNode, nextNode) fireGraphRegionTransition(run.UserID, fromNode, nextNode)
nextRoom := nodeKindToRoomType(nextNode.Kind) nextRoom := nodeKindToRoomType(nextNode.Kind)
var b strings.Builder var b strings.Builder
b.WriteString(header)
if kind := autoBreakCampOnMove(ctx.Sender); kind != "" { if kind := autoBreakCampOnMove(ctx.Sender); kind != "" {
b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind)) b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind))
} }
+72 -3
View File
@@ -19,6 +19,8 @@ import (
"strings" "strings"
"gogobee/internal/db" "gogobee/internal/db"
"maunium.net/go/mautrix/id"
) )
// pendingFork is the typed shape of dnd_zone_run.node_choices when the // pendingFork is the typed shape of dnd_zone_run.node_choices when the
@@ -82,11 +84,39 @@ type edgeUnlockCtx struct {
RunID string RunID string
FromNode string FromNode string
CharLevel int CharLevel int
AbilityMods [6]int // STR, DEX, CON, INT, WIS, CHA — matches DnDCharacter.Modifiers() // 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 InventoryNames map[string]bool
Expedition *Expedition 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 // evaluateEdgeLock returns whether the player can take this edge right
// now, with a player-facing reason on failure. Per plan §G5: Perception // now, with a player-facing reason on failure. Per plan §G5: Perception
// rolls fire once at fork-arrival (deterministic seed) and the result // 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) roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
total := roll + ctx.AbilityMods[4] total := roll + ctx.AbilityMods[4]
if total >= dc { if total >= dc {
return true, "" return true, ctx.creditFor(4)
} }
return false, fmt.Sprintf("Perception %d vs DC %d", total, dc) return false, fmt.Sprintf("Perception %d vs DC %d", total, dc)
case LockKey: case LockKey:
@@ -138,7 +168,7 @@ func evaluateEdgeLock(e ZoneEdge, ctx edgeUnlockCtx) (unlocked bool, reason stri
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To) roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
total := roll + ctx.AbilityMods[idx] total := roll + ctx.AbilityMods[idx]
if total >= dc { if total >= dc {
return true, "" return true, ctx.creditFor(idx)
} }
return false, fmt.Sprintf("%s %d vs DC %d", stat, total, dc) 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 { if exp, err := getActiveExpedition(c.UserID); err == nil && exp != nil {
ctx.Expedition = exp ctx.Expedition = exp
foldPartyAbilities(&ctx, exp, c.UserID)
} }
return ctx 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 // evaluateForkEdges walks all outgoing edges of fromNode in the graph
// and produces a pending-choice list ready to be persisted. Locked // and produces a pending-choice list ready to be persisted. Locked
// edges that have a Hint stay in the menu (the player needs the // 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)) b.WriteString(fmt.Sprintf("**%s — Path divides.** Choose with `!zone go <n>`.\n\n", zone.Display))
for _, c := range pf.Options { for _, c := range pf.Options {
switch { 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: case c.Unlocked:
b.WriteString(fmt.Sprintf("**%d.** %s\n", c.Index, c.Label)) b.WriteString(fmt.Sprintf("**%d.** %s\n", c.Index, c.Label))
case c.Hint != "": case c.Hint != "":
+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."
}
+109
View File
@@ -0,0 +1,109 @@
package plugin
import "testing"
// TestPickableLock pins which locks thieves' tools may answer. Tools substitute
// for a failed die roll, never for progression: a key is a quest token, a level
// gate is progression, a region-clear gate is structure.
func TestPickableLock(t *testing.T) {
pickable := []ZoneEdgeLockKind{LockPerception, LockStatCheck}
for _, k := range pickable {
if !pickableLock(string(k)) {
t.Errorf("%s should be pickable", k)
}
}
sealed := []ZoneEdgeLockKind{LockKey, LockLevelMin, LockRegionClear, LockNone, ""}
for _, k := range sealed {
if pickableLock(string(k)) {
t.Errorf("%s must not be pickable", k)
}
}
}
// TestBestAbilityTakesPartyMax is the core of the party-check fix: a door reads
// the best eyes present, not the leader's.
func TestBestAbilityTakesPartyMax(t *testing.T) {
ctx := edgeUnlockCtx{AbilityMods: [6]int{0, 1, 0, 0, 1, 0}}
ctx.bestAbility([6]int{0, 5, 0, 0, -1, 0}, "Josie")
ctx.bestAbility([6]int{0, 2, 0, 0, 6, 0}, "Pete")
if ctx.AbilityMods[1] != 5 || ctx.AbilityWho[1] != "Josie" {
t.Errorf("DEX = %d by %q, want 5 by Josie", ctx.AbilityMods[1], ctx.AbilityWho[1])
}
if ctx.AbilityMods[4] != 6 || ctx.AbilityWho[4] != "Pete" {
t.Errorf("WIS = %d by %q, want 6 by Pete", ctx.AbilityMods[4], ctx.AbilityWho[4])
}
// Nobody beat the leader's STR, so nobody is credited for it.
if ctx.AbilityWho[0] != "" {
t.Errorf("STR credited to %q, want nobody", ctx.AbilityWho[0])
}
}
// TestEvaluateEdgeLockUsesPartyBest — a check the leader fails and a party-mate
// passes must open, and must say who opened it.
func TestEvaluateEdgeLockUsesPartyBest(t *testing.T) {
e := ZoneEdge{To: "z.secret", Lock: LockPerception, LockData: map[string]any{"dc": 30}}
ctx := edgeUnlockCtx{RunID: "run1", FromNode: "z.fork"}
if ok, _ := evaluateEdgeLock(e, ctx); ok {
t.Fatal("DC 30 should be unreachable with no modifiers")
}
// perceptionRollForEdge is seeded, so a mod big enough to clear DC 30 from
// any roll makes this deterministic without pinning the roll itself.
ctx.bestAbility([6]int{0, 0, 0, 0, 29, 0}, "Pete")
ok, reason := evaluateEdgeLock(e, ctx)
if !ok {
t.Fatalf("party best should open the door, got %q", reason)
}
if reason != "Pete got it open" {
t.Errorf("reason = %q, want credit to Pete", reason)
}
}
// TestRankForkOptionsPrefersUnvisitedThenWeight guards the autopilot's route
// choice. Menu order is edge-authoring order and means nothing to a player.
func TestRankForkOptionsPrefersUnvisitedThenWeight(t *testing.T) {
g := ZoneGraph{
Nodes: map[string]ZoneNode{},
Edges: map[string][]ZoneEdge{
"z.fork": {
{From: "z.fork", To: "z.seen", Weight: 9},
{From: "z.fork", To: "z.thin", Weight: 1},
{From: "z.fork", To: "z.fat", Weight: 5},
},
},
}
run := &DungeonRun{CurrentNode: "z.fork", VisitedNodes: []string{"z.fork", "z.seen"}}
pf := &pendingFork{Options: []pendingChoice{
{Index: 1, To: "z.seen", Label: "Seen", Unlocked: true},
{Index: 2, To: "z.thin", Label: "Thin", Unlocked: true},
{Index: 3, To: "z.fat", Label: "Fat", Unlocked: true},
{Index: 4, To: "z.shut", Label: "Shut", Unlocked: false},
}}
got := rankForkOptions(g, run, pf)
want := []string{"z.fat", "z.thin", "z.seen"}
if len(got) != len(want) {
t.Fatalf("got %d options, want %d (locked routes must be dropped)", len(got), len(want))
}
for i, w := range want {
if got[i].To != w {
t.Errorf("rank[%d] = %s, want %s", i, got[i].To, w)
}
}
}
// TestRankForkOptionsAllLocked — nothing takeable means nothing returned, which
// is what sends the autopilot to the tools/backtrack path instead of the reaper.
func TestRankForkOptionsAllLocked(t *testing.T) {
g := ZoneGraph{Nodes: map[string]ZoneNode{}, Edges: map[string][]ZoneEdge{}}
run := &DungeonRun{CurrentNode: "z.fork", VisitedNodes: []string{"z.fork"}}
pf := &pendingFork{Options: []pendingChoice{
{Index: 1, To: "z.a", Unlocked: false},
{Index: 2, To: "z.b", Unlocked: false},
}}
if got := rankForkOptions(g, run, pf); len(got) != 0 {
t.Errorf("got %d takeable options, want 0", len(got))
}
}