Files
gogobee/internal/plugin/zone_graph_unlock_test.go
prosolis 6bcac41aa2 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.
2026-07-20 23:52:20 -07:00

110 lines
4.0 KiB
Go

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))
}
}