diff --git a/internal/plugin/adventure_shop.go b/internal/plugin/adventure_shop.go index fdf0d1a..ac783df 100644 --- a/internal/plugin/adventure_shop.go +++ b/internal/plugin/adventure_shop.go @@ -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 `). Not used in combat.\n\n", + thievesToolsName, thievesToolsPrice)) + 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+.") 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.*") } + // 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 var match *ConsumableDef for i := range consumableDefs { @@ -1115,6 +1125,34 @@ func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interactio 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) ──────────────────────────────────────────────────── // curiosStockSize — how many registry magic items Luigi stocks per day. diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index 52717fc..0bdb41b 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -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 ` 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 ` 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{ diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index d168fcf..2d41531 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -53,6 +53,9 @@ func (p *AdventurePlugin) handleDnDZoneCmd(ctx MessageContext, args string) erro // fork pending) the handler short-circuits with a friendly // message — see zoneCmdGo for the full surface. 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": return p.zoneCmdStatus(ctx) case "map", "m": @@ -83,6 +86,7 @@ func zoneHelpText() string { b.WriteString("`!zone map` — show the room layout\n") b.WriteString("`!zone advance` — resolve the current room and move on\n") b.WriteString("`!zone go ` — at a fork, take path #n\n") + b.WriteString("`!zone unlock ` — spend thieves' tools to open a path you couldn't\n") b.WriteString("`!revisit ` — walk back to a room you've already cleared\n") b.WriteString("`!zone abandon` — end the active run (no rewards)\n") b.WriteString("`!zone taunt` — poke TwinBee (they'll remember)\n") diff --git a/internal/plugin/dnd_zone_cmd_graph.go b/internal/plugin/dnd_zone_cmd_graph.go index 20ee53e..2cbfbd7 100644 --- a/internal/plugin/dnd_zone_cmd_graph.go +++ b/internal/plugin/dnd_zone_cmd_graph.go @@ -188,6 +188,17 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error { if cerr != nil { 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) if aerr != nil { 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) nextRoom := nodeKindToRoomType(nextNode.Kind) var b strings.Builder + b.WriteString(header) if kind := autoBreakCampOnMove(ctx.Sender); kind != "" { b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind)) } diff --git a/internal/plugin/zone_graph_nav.go b/internal/plugin/zone_graph_nav.go index 7714465..9542fd7 100644 --- a/internal/plugin/zone_graph_nav.go +++ b/internal/plugin/zone_graph_nav.go @@ -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", 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 != "": diff --git a/internal/plugin/zone_graph_unlock.go b/internal/plugin/zone_graph_unlock.go new file mode 100644 index 0000000..b374349 --- /dev/null +++ b/internal/plugin/zone_graph_unlock.go @@ -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 `: spend one set of thieves' tools to +// open a locked fork option, then commit the move exactly as `!zone go ` +// 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 `.") + } + 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 ` — 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." +} diff --git a/internal/plugin/zone_graph_unlock_test.go b/internal/plugin/zone_graph_unlock_test.go new file mode 100644 index 0000000..a9c0fc0 --- /dev/null +++ b/internal/plugin/zone_graph_unlock_test.go @@ -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)) + } +}