package plugin import ( "fmt" "log/slog" "strings" "time" "gogobee/internal/db" "maunium.net/go/mautrix/id" ) // Phase 4 of expedition autopilot — background auto-run ticker. Walks // active expeditions on real time so the player only engages when a real // decision is needed (fork, elite, boss, low HP/SU, rare node, death). // // Design (settled 2026-05-16): // • Tick every autoRunTickInterval; per-expedition rate-limited by // autoRunCooldown via a last_autorun_at CAS. // • Skip when the player is in active combat, inside the briefing/recap // quiet window, or on a very-fresh expedition (give them a beat). // • Walk up to autoRunRoomCap rooms per tick. Smaller than foreground's // cap so background DMs stay digestible. // • DM-suppression rules (D4-a — long expedition bundling): // - Surface ONLY for: fork (player decision), death, run-complete, // boss-safety camp pitch (explicit hold), or a Night=true camp // pitch (end-of-day digest). // - Everything else — uneventful walks, preflight pauses, harvest // interrupts, mid-day rest camps — goes silent. The accumulated // day reads as one EoD digest DM when the autopilot night-camps. // - Each successful background walk logs a `walk` entry so the EoD // digest can count rooms walked without persisting raw narration. // • Idempotency: CAS-claim last_autorun_at before doing any work. A // double-fire on the same expedition is a no-op. const ( // autoRunTickInterval — how often the ticker wakes. The per-expedition // cooldown is what actually paces; the tick just has to be frequent // enough that the cooldown is enforced cleanly. autoRunTickInterval = 15 * time.Minute // autoRunCooldown — minimum gap between background walks for one // expedition. 2h is the slow cadence: the dungeon still moves on its // own while the player is away, but the auto-walk DM stays a // once-in-a-while ping instead of a steady drip. autoRunCooldown = 2 * time.Hour // autoRunMinExpeditionAge — don't auto-walk a brand-new expedition; // let the player walk the first beat manually so the opening reads // as deliberate. autoRunMinExpeditionAge = 30 * time.Minute // autoRunRoomCap — rooms per background tick. Smaller than the // foreground cap so a single background DM doesn't dump an // overwhelming wall of narration. autoRunRoomCap = 3 ) // expeditionAutoRunTicker — fires the auto-run pass every tick interval. func (p *AdventurePlugin) expeditionAutoRunTicker() { ticker := time.NewTicker(autoRunTickInterval) defer ticker.Stop() for { select { case <-p.stopCh: return case <-ticker.C: p.fireExpeditionAutoRuns(time.Now().UTC()) } } } // fireExpeditionAutoRuns walks active expeditions due for a background // step and dispatches one walk per. Cheap per-expedition gating happens // in tryAutoRun; this layer just selects candidates. func (p *AdventurePlugin) fireExpeditionAutoRuns(now time.Time) { // Reuse the ambient quiet window — same intent: don't talk over the // scheduled 06:00 briefing or 21:00 recap. if inAmbientQuietWindow(now) { return } due, err := loadExpeditionsForAutoRun( now.Add(-autoRunCooldown), now.Add(-autoRunMinExpeditionAge)) if err != nil { slog.Error("expedition: load autorun candidates", "err", err) return } for _, expID := range due { e, err := getExpedition(expID) if err != nil || e == nil { continue } if e.Status != ExpeditionStatusActive { continue } uid := id.UserID(e.UserID) if hasActiveCombatSession(uid) { continue } // Honor the rest lockout — short/long rest set RestingUntil and // foreground !expedition run checks it; background must too, or // the auto-walk ticker fires through a long rest. if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil { if restingLockoutRemaining(c) > 0 { continue } } if err := p.tryAutoRun(e, now); err != nil { slog.Warn("expedition: autorun step", "expedition", e.ID, "err", err) } } } // loadExpeditionsForAutoRun returns active expeditions whose // last_autorun_at is NULL or older than runCutoff, AND whose start_date // is older than ageCutoff so a freshly-started expedition isn't yanked // out from under the player on tick 1. func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error) { rows, err := db.Get().Query(` SELECT expedition_id FROM dnd_expedition WHERE status = 'active' AND start_date < ? AND (last_autorun_at IS NULL OR last_autorun_at < ?)`, ageCutoff, runCutoff) if err != nil { return nil, err } defer rows.Close() var ids []string for rows.Next() { var s string if err := rows.Scan(&s); err != nil { return nil, err } ids = append(ids, s) } return ids, rows.Err() } // tryAutoRun claims the slot for this expedition, runs one background // walk, and DMs the result if the suppression rules say to. The CAS- // update is the only persistent side effect on the autorun column — // supplies/threat/run-graph state are mutated by the walk itself, just // as they would be in a foreground !expedition run. func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error { // Long-expedition D2-a — camp dwell gate. A camp (player or auto) // still inside its dwell window means the party is resting; skip // the walk entirely. An auto-pitched camp past dwell gets broken // here so this tick can proceed. if shouldSkipAutoRunForCamp(e, now) { return nil } autoCampBroken := breakAutoCampIfDue(e, now) cutoff := now.Add(-autoRunCooldown) res, err := db.Get().Exec(` UPDATE dnd_expedition SET last_autorun_at = ?, last_activity = ? WHERE expedition_id = ? AND status = 'active' AND (last_autorun_at IS NULL OR last_autorun_at < ?)`, now, now, e.ID, cutoff) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return nil // someone else got there first } uid := id.UserID(e.UserID) r := p.runAutopilotWalk(MessageContext{Sender: uid}, autoRunRoomCap, true, true) if r.initErr != "" { // "no expedition" / "no run" — race with abandon/extract. Silent. return nil } // D4-a — drop a `walk` log entry per successful background walk so // the EoD digest can count rooms walked from structured logs without // persisting the raw stream narration we used to DM. if r.rooms > 0 { _ = appendExpeditionLog(e.ID, e.CurrentDay, "walk", fmt.Sprintf("auto-walk: %d room(s)", r.rooms), "") } // Long-expedition D2-a — post-walk camp scheduler. After the walk // settles, see if the autopilot should pitch a rest camp (HP low) // or a base-camp waypoint (region boss just cleared). The walk's // own preflight handles low-SU pauses; the scheduler stays out of // fork/combat/death/complete branches by checking r.reason. campBlock := "" var campDecision autoCampDecision campPitched := false if r.reason == stopBossSafety { // D3 — the boss-engage gate tripped. Force-pitch a rest camp // regardless of decideAutopilotCamp's normal HP threshold and // its RoomBoss block. Next tick past dwell retries the boss. if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil && fresh.Status == ExpeditionStatusActive { campBlock, campDecision, campPitched = p.pitchBossSafetyCamp(fresh, now) } } else if r.reason != stopEnded && r.reason != stopComplete && r.reason != stopBlocked && r.reason != stopFork { if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil && fresh.Status == ExpeditionStatusActive { campBlock, campDecision, campPitched = p.maybeAutoCamp(fresh, now) } } _ = autoCampBroken // hint reserved for downstream digest tweaks _ = campPitched // surfaced via campBlock != ""; kept for readability // D4-a DM dispatch. The old per-tick auto-walk DM is retired in compact // mode: a Night-camp pitch flushes the accumulated day as a digest; // every other quiet path stays silent until something interactive fires. if body, ok := buildAutoRunDM(e.ID, r, campBlock, campDecision); ok { if err := p.SendDM(uid, body); err != nil { slog.Warn("expedition: autorun DM", "user", uid, "err", err) } } // Emergence seam: a run-complete reached by the background ticker is // still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge. // Deferred until after the run-summary DM below so the "animal in your // house" prompt lands after the summary, not ahead of it. if r.reason == stopComplete { p.maybeRollPetArrivalOnEmerge(uid) } return nil } // buildAutoRunDM applies D4-a's surface rules and returns the DM body to // send. ok=false means the tick is silent. Inputs: // - expID: the expedition row id (for the EoD digest log fetch). // - r: walk result, including reason + accumulated stream. // - camp: rendered camp block from maybeAutoCamp / pitchBossSafetyCamp, // or "" when no camp was pitched this tick. // - dec: the camp decision; dec.Night is the trigger for the EoD // digest variant. Zero-value when no pitch happened. // // Surface rules: // - stopFork / stopEnded / stopComplete → render the walk DM. These // are the interactive / climax beats and stay their own messages. // - Night camp pitched → render the EoD digest + // camp block. Walk stream is dropped (the digest summarizes the day). // - Boss-safety camp pitched → short hold notice + camp // block; walk stream dropped (compact bail was deliberate). // - Anything else → silent. func buildAutoRunDM(expID string, r autopilotWalkResult, camp string, dec autoCampDecision) (string, bool) { switch r.reason { case stopFork, stopEnded, stopComplete: body := renderAutoRunWalkDM(r) if camp != "" { body += camp } return body, true } if camp == "" { return "", false } if dec.Night { // EoD digest. The camp pitch already bumped current_day in // nightRolloverBurn, so the day-that-just-ended is CurrentDay-1. // digest is the day rollup, then the camp block lays out the rest. fresh, ferr := getExpedition(expID) prevDay := 0 if ferr == nil && fresh != nil { prevDay = fresh.CurrentDay - 1 } digest := "" if prevDay > 0 { digest = renderEndOfDayDigest(expID, prevDay) } if digest == "" { // No structured day yet — fall back to a thin header so the // camp block isn't dropped on the player without context. digest = "🌙 *The day winds down.*\n\n" } return digest + camp, true } if dec.Reason == "boss-safety hold — resting before re-engaging" { return "⏸ *Holding before the boss — pitching a rest camp.*\n" + camp, true } // Non-night auto-camp (mid-day rest / base camp waypoint). Surface a // short notice so the player can see the dungeon's decision; full // digest waits for the night pitch. return camp, true } // renderAutoRunWalkDM is the legacy concat-the-stream renderer, kept for // the surfaces D4-a still DMs (fork / death / run-complete). func renderAutoRunWalkDM(r autopilotWalkResult) string { var b strings.Builder b.WriteString("🚶 *Auto-walk*\n\n") for _, s := range r.stream { if s == "" { continue } b.WriteString(s) b.WriteString("\n\n") } b.WriteString(r.finalMsg) return b.String() }