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:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool {
|
// order — meaningful to whoever wrote the graph, arbitrary to the player. It
|
||||||
var chosen *pendingChoice
|
// walked past unvisited branches to loop through cleared ones often enough to
|
||||||
for i := range pf.Options {
|
// look broken.
|
||||||
if pf.Options[i].Unlocked {
|
func rankForkOptions(g ZoneGraph, run *DungeonRun, pf *pendingFork) []pendingChoice {
|
||||||
chosen = &pf.Options[i]
|
weights := map[string]int{}
|
||||||
break
|
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 {
|
sort.SliceStable(open, func(i, j int) bool {
|
||||||
return false // nothing unlocked — leave it for the player / reaper
|
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 {
|
||||||
|
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 {
|
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{
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -79,14 +81,42 @@ func decodePendingFork(m map[string]any) (*pendingFork, error) {
|
|||||||
// test them without going through the live DB. Filled in by
|
// test them without going through the live DB. Filled in by
|
||||||
// evaluateForkEdges from the live run + character.
|
// evaluateForkEdges from the live run + character.
|
||||||
type edgeUnlockCtx struct {
|
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 != "":
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user