mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
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:
@@ -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."
|
||||
}
|
||||
Reference in New Issue
Block a user