mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
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.
162 lines
6.0 KiB
Go
162 lines
6.0 KiB
Go
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."
|
||
}
|