From f4a39b46e943070490657597730e26495a02833a Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:28:05 -0700 Subject: [PATCH] Fix expedition soft-lock: extract on run idle-timeout + auto-pick stale forks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getActiveZoneRun's 24h stale-run reaper abandoned the run but left the wrapping expedition status='active' pointing at a dead run. The autopilot then read run==nil and bailed while briefing/recap tickers kept firing, so the player soft-locked at the last fork with no way to route on. The reaper now force-extracts the wrapping active expedition (run-loss seam) when it reaps that expedition's current run. Root cause was a background fork: the autopilot can't pick for the player, so the run idled all the way to the reaper. Add an 8h forkAutoPickTimeout — a background fork left unanswered that long now auto-picks the first unlocked route (same path as !zone go) and keeps walking; all-locked forks are left for the player. Foreground !expedition run still always prompts. Tests: idle-timeout extracts the expedition; stale fork takes the available route; all-locked fork stays intact. --- internal/plugin/dnd_audit_phase_R7_test.go | 139 +++++++++++++++++++++ internal/plugin/dnd_expedition_cmd.go | 68 ++++++++-- internal/plugin/dnd_zone_run.go | 11 ++ 3 files changed, 209 insertions(+), 9 deletions(-) diff --git a/internal/plugin/dnd_audit_phase_R7_test.go b/internal/plugin/dnd_audit_phase_R7_test.go index 6de9328..57ab4f9 100644 --- a/internal/plugin/dnd_audit_phase_R7_test.go +++ b/internal/plugin/dnd_audit_phase_R7_test.go @@ -42,6 +42,145 @@ func TestZoneRun_AutoAbandonsAfter24h(t *testing.T) { } } +// A run reaped by the idle timeout must also terminate the wrapping active +// expedition, or the expedition is orphaned 'active' over a dead run and the +// player soft-locks (the original feywild "stuck, can't route" bug). +func TestZoneRun_IdleTimeoutExtractsWrappingExpedition(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@orphan-run:example") + defer cleanupExpeditions(uid) + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID, + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatalf("startExpedition: %v", err) + } + if err := setExpeditionRunID(exp.ID, run.RunID); err != nil { + t.Fatalf("link run: %v", err) + } + // Backdate the run past the 24h stale threshold. + if _, err := dbExecZoneRunBackdate(run.RunID, 25*time.Hour); err != nil { + t.Fatalf("backdate: %v", err) + } + + got, err := getActiveZoneRun(uid) + if err != nil { + t.Fatalf("getActiveZoneRun: %v", err) + } + if got != nil { + t.Errorf("expected nil after timeout, got run %q", got.RunID) + } + // The wrapping expedition must no longer be active. + after, err := getExpedition(exp.ID) + if err != nil { + t.Fatalf("getExpedition: %v", err) + } + if after == nil { + t.Fatal("expedition row vanished") + } + if after.Status == ExpeditionStatusActive { + t.Errorf("expedition still active after run idle-timeout; status=%q", after.Status) + } +} + +// A stale background fork auto-picks the first unlocked route so the +// expedition keeps moving instead of idling out to the reaper. +func TestAutoPickStaleFork_TakesAvailableRoute(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@stale-fork:example") + defer cleanupExpeditions(uid) + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID, + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatalf("startExpedition: %v", err) + } + if err := setExpeditionRunID(exp.ID, run.RunID); err != nil { + t.Fatalf("link run: %v", err) + } + pf := pendingFork{ + PendingAt: "goblin_warrens.cavern_junction", + Options: []pendingChoice{ + {Index: 1, To: "goblin_warrens.guard_post", Label: "Guard Post", + Unlocked: false, Lock: "perception_check", Reason: "Perception 8 vs DC 14"}, + {Index: 2, To: "goblin_warrens.kennel_path", Label: "Kennel Path", + Unlocked: true, Lock: "none"}, + }, + } + if err := writePendingFork(run.RunID, pf); err != nil { + t.Fatalf("writePendingFork: %v", err) + } + r2, _ := getZoneRun(run.RunID) + got, _ := decodePendingFork(r2.NodeChoices) + if got == nil { + t.Fatal("fork not persisted") + } + + p := &AdventurePlugin{} + if ok := p.autoPickStaleFork(exp, r2, got); !ok { + t.Fatal("expected auto-pick to commit a route") + } + after, _ := getZoneRun(run.RunID) + if after.CurrentNode != "goblin_warrens.kennel_path" { + t.Errorf("did not advance to the unlocked route: current_node=%q", after.CurrentNode) + } + if pf2, _ := decodePendingFork(after.NodeChoices); pf2 != nil { + t.Errorf("pending fork not cleared after auto-pick") + } +} + +// When every option is locked there's nothing safe to auto-pick — leave the +// fork intact for the player (or the reaper). +func TestAutoPickStaleFork_AllLockedLeavesForkIntact(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@locked-fork:example") + defer cleanupExpeditions(uid) + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID, + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatalf("startExpedition: %v", err) + } + _ = setExpeditionRunID(exp.ID, run.RunID) + pf := pendingFork{ + PendingAt: "goblin_warrens.cavern_junction", + Options: []pendingChoice{ + {Index: 1, To: "goblin_warrens.guard_post", Label: "Guard Post", + Unlocked: false, Lock: "perception_check", Reason: "DC 14"}, + }, + } + if err := writePendingFork(run.RunID, pf); err != nil { + t.Fatalf("writePendingFork: %v", err) + } + r2, _ := getZoneRun(run.RunID) + before := r2.CurrentNode + got, _ := decodePendingFork(r2.NodeChoices) + + p := &AdventurePlugin{} + if ok := p.autoPickStaleFork(exp, r2, got); ok { + t.Fatal("expected no auto-pick when every option is locked") + } + after, _ := getZoneRun(run.RunID) + if after.CurrentNode != before { + t.Errorf("current_node moved on an all-locked fork: %q → %q", before, after.CurrentNode) + } + if pf2, _ := decodePendingFork(after.NodeChoices); pf2 == nil { + t.Errorf("pending fork was cleared on an all-locked fork") + } +} + func TestZoneRun_FreshRunNotAutoAbandoned(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@fresh-run:example") diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index b9200c1..b01ce9f 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -2,6 +2,7 @@ package plugin import ( "fmt" + "log/slog" "math" "strconv" "strings" @@ -667,6 +668,45 @@ 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 + +// 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 + } + } + if chosen == nil { + return false // nothing unlocked — leave it for the player / reaper + } + 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), "") + } + return true +} + func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult { exp, err := getActiveExpedition(ctx.Sender) if err != nil { @@ -679,18 +719,28 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."} } - // Already standing at a pending fork: autopilot can't pick for the - // player. Re-emit the prompt with rooms=0 so the background DM - // suppression keeps quiet and we don't tick the rooms counter on a - // no-op walk. + // Already standing at a pending fork. The autopilot can't pick for the + // player, so a fresh fork re-emits the prompt with rooms=0 (background + // DM suppression keeps quiet; the rooms counter doesn't tick on a no-op + // walk). But a background fork left unanswered past forkAutoPickTimeout + // would otherwise idle all the way to the 24h stale-run reaper and end + // the expedition — so once it's stale, auto-pick the first available + // (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 { - zone := zoneOrFallback(run.ZoneID) - return autopilotWalkResult{ - finalMsg: renderForkPrompt(zone, *pf), - rooms: 0, - reason: stopFork, + picked := compact && + time.Since(run.LastActionAt) > forkAutoPickTimeout && + p.autoPickStaleFork(exp, run, pf) + if !picked { + zone := zoneOrFallback(run.ZoneID) + return autopilotWalkResult{ + finalMsg: renderForkPrompt(zone, *pf), + rooms: 0, + reason: stopFork, + } } + // Auto-picked: the run now points at the chosen node. Fall + // through into the walk loop so this same tick advances it. } } diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index b42103d..6e32dee 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -290,6 +290,17 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) { } if time.Since(r.LastActionAt) > zoneRunInactivityTimeout { _ = abandonZoneRunByID(r.RunID) + // A run reaped by the §4.3 idle timeout must also terminate the + // wrapping active expedition. Without this, the expedition is left + // status='active' pointing at a now-abandoned run: the autopilot's + // runAutopilotWalk reads run==nil and bails, but the briefing/recap + // ambient tickers keep firing — the player soft-locks at the last + // fork, "stuck" with no way to route on. Mirror the run-loss seam, + // but only when this run is the active expedition's current run so + // a standalone (non-expedition) stale run still reaps cleanly. + if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID { + forceExtractExpeditionForRunLoss(userID, "run idle-timeout (§4.3 stale-run reap)") + } return nil, nil } return r, nil