From 7115c536ef4a7b028cd102d68ee4b366bdc66993 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Wed, 27 May 2026 18:23:26 -0700 Subject: [PATCH] Long expeditions D2-a: autopilot camp scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New expedition_autocamp.go: pure decideAutopilotCamp + pitchAutopilotCamp + dwell-window lifecycle. Wired into tryAutoRun so the background ticker pitches a rest camp on low HP and a base-camp waypoint on region-boss clear; auto-pitched camps last minAutoCampDwell (4h) before the next tick breaks them and walks. CampState.AutoPitched separates auto- vs player- pitched camp lifetimes so a player !camp stays sticky. Day-rollover semantics unchanged — still UTC-anchored; D2-b moves day++/burn/threat-drift onto the camp-pitch event. --- gogobee_long_expedition_plan.md | 158 +++++++++++ internal/plugin/dnd_expedition.go | 5 + internal/plugin/expedition_autocamp.go | 273 ++++++++++++++++++++ internal/plugin/expedition_autocamp_test.go | 248 ++++++++++++++++++ internal/plugin/expedition_autorun.go | 33 ++- 5 files changed, 716 insertions(+), 1 deletion(-) create mode 100644 gogobee_long_expedition_plan.md create mode 100644 internal/plugin/expedition_autocamp.go create mode 100644 internal/plugin/expedition_autocamp_test.go diff --git a/gogobee_long_expedition_plan.md b/gogobee_long_expedition_plan.md new file mode 100644 index 0000000..c68ee6e --- /dev/null +++ b/gogobee_long_expedition_plan.md @@ -0,0 +1,158 @@ +# Long Expeditions — multi-session plan + +> Goal: dungeon expeditions become real multi-day journeys (T1 ≈ 2 days → T5 ≈ 7 days). Player picks **camp supplies** at launch, then watches it play out. Camp pitch/break, elite engagement, and **boss engagement** all become autonomous. Foreground `!camp` / `!fight` survive as overrides, not the expected path. + +## 1. Why the current shape is "too short" + +| Tier | Zones | Rooms | Real-time exit | +|------|-------|------|----------------| +| T1 | Goblin Warrens, Crypt Valdris | 6–7 | usually <1 calendar day | +| T2 | Forest Shadows, Sunken Temple | 7–8 | usually 1 day | +| T3 | Manor Blackspire, Underforge | 7–9 | 1–2 days | +| T4 | Underdark, Feywild Crossing | 8–10 (×regions) | 2–3 days | +| T5 | Dragon's Lair, Abyss Portal | 9–10 (×regions) | 2–4 days | + +(Room counts from `internal/plugin/dnd_zone.go:70-134`; tier zones from memory `project_expedition_difficulty`.) + +Because the autopilot walks ~3 rooms / 2h (`expedition_autorun.go:43,53`) and boss/elite stops are manual, a player who pays attention clears T1–T3 in an evening. Briefing/recap UTC ticks fire but rarely matter — the expedition is over before "day 2" ever lands. + +## 2. Target shape + +| Tier | Target duration | New room budget (single-region) | Notes | +|------|-----------------|---------------------------------|-------| +| T1 | 2 days | 12–14 | one autopilot-camp | +| T2 | 3 days | 16–20 | two camps | +| T3 | 4 days | 22–26 | three camps; threat starts to bite | +| T4 | 5–6 days | 28–34 (split across 3–4 regions) | base camp emerges | +| T5 | 7 days | 36–44 (split across 3–4 regions) | full siege/temporal pressure | + +(Numbers are first-draft anchors; class-balance sim drives the actual dial in D7.) + +Duration is measured in **expedition-days advanced by autopilot camp**, not real-time UTC days. Each autopilot night-camp = day++. Real-time UTC still drives the morning briefing DM cadence, but day-count and supply burn become event-driven (camp = sleep = day++ = burn). + +The autopilot decides: +- when to camp (and which camp type) +- whether to engage elites and bosses, or to camp first and retry +- whether to extract early on starvation/HP collapse + +The player decides: +- supply pack purchases at launch (only meaningful pre-expedition choice) +- whether to override (`!camp `, `!fight`, `!expedition extract`) + +## 3. Phasing + +Built across multiple sessions; each phase ships independently with tests and a small DM-side feedback loop. + +### D1 — Length + room-count rework +**Files:** `dnd_zone.go` (per-zone Min/MaxRooms), `dnd_expedition_region.go` (multi-region region sizes), `dnd_zone_run.go:generateRoomSequence` (still terminates Entry → … → Elite → Boss, but with more Explorations). +**Work:** +- Re-pitch Min/MaxRooms per zone to the table in §2. +- Multi-region zones: extend per-region room budgets so the total stays in band when summed. +- Keep the fixed Entry / Trap / Elite / Boss anchors; only Exploration count scales. +- Consider 2 traps + 2 elites for T4–T5 to break up the long middle (deferred decision — see §6). +- Update existing zone graphs in `zone_graph_*.go` for the new lengths. *Auditing whether each zone has a hand-tuned graph or relies on the linear compiler is part of D1 scoping.* + +**Exit criteria:** `expedition-sim` shows mean-room-cleared per zone in the new band; no test fixtures hard-code old counts. + +### D2 — Autopilot camp scheduler + +**D2-a (shipped 2026-05-27):** new `expedition_autocamp.go` with pure `decideAutopilotCamp` + `pitchAutopilotCamp` + dwell-window lifecycle (`shouldSkipAutoRunForCamp`, `breakAutoCampIfDue`). Wired into `tryAutoRun`: skip while inside `minAutoCampDwell` (4h), break + walk past it, post-walk scheduler pitches HP-low rest camps and region-boss base-camp waypoints. CampState gained `AutoPitched` so auto- vs player-pitched lifetimes diverge. Day rollover is still UTC-anchored; D2-b moves it event-anchored. + + +**Files:** new `expedition_autocamp.go`; hooks in `expedition_autorun.go:tryAutoRun`; reuse `dnd_expedition_camp.go:campPitch` / `applyCampRest`. +**Heuristics (in priority order):** +1. **Day-budget pacing** — pitch when rooms-walked-this-day ≥ tier-specific day-room target. Standard if room cleared, rough if not. +2. **Resource gates** — pitch if HP < 35% of max, or supplies projected to cover <1 more day's burn (`exp.Supplies.Current < exp.Supplies.DailyBurn * harshMod`). +3. **Post-elite breath** — auto-pitch a standard camp in the cleared elite room. +4. **Region boss cleared in multi-region zone** — auto-pitch base camp at first eligible site. +5. **Don't pitch when** mid-fight, in a trap/boss room, in a fork-pending state, or already camped. + +Auto-break already happens on move (`autoBreakCampOnMove` in `dnd_expedition_camp.go:401`). Autopilot just needs to *not* break a freshly-pitched camp by immediately walking on — gate by `time.Since(camp.EstablishedAt) > minCampDwell` (15min real-time? or simply: don't auto-walk while camped). + +**Day-rollover semantics change (decided event-anchored):** today, day++ happens at the 06:00 UTC briefing (`dnd_expedition_cycle.go:236`). After D2, the autopilot **night-camp pitch** is the event that fires day++, supply burn, overnight rest, and threat drift — in one atomic rollover the camp scheduler triggers. The 06:00 UTC briefing tick survives only as a re-engagement DM anchor (and a safety net: if for some reason no camp pitched in N hours, force one). Concretely: +- Move the body of `deliverBriefing` (`dnd_expedition_cycle.go:186-`) into a `processNightCamp(exp)` helper called from the autopilot camp scheduler when it pitches a *night* camp (rough/standard/fortified — not mid-day breath stops). +- Distinguish "night camp" (advances day) from "rest stop" (doesn't). Heuristic: a camp pitched at the end of the day-budget room count is night; one pitched purely for HP/SU recovery mid-day is a rest stop. Both apply `applyCampRest`; only night also runs supply burn + day++ + threat drift. +- The existing UTC briefing ticker becomes: if `last_briefing_at` is older than ~20h *and* the user is active, post a "Day N — here's where you stand" re-engagement DM. It calls **no** mutators. +- Manual `!camp ` from the player still does what it does today (apply rest immediately) but **also** counts as a night camp if invoked with no other camp today — keep parity with the autopilot behavior so the override doesn't accidentally stall day advancement. + +**Migration:** in-flight expeditions at deploy time keep their existing 06:00 UTC day++ until they end (fence by `start_date`). New expeditions use the event-anchored model. + +**Exit criteria:** sim shows autopilot pitches the right kind of camp at the right room with no player input; supply economy still survives a 7-day T5. + +### D3 — Autonomous elite + boss engagement +**Files:** `dnd_zone_cmd.go:497-535` (the prev==RoomElite / RoomBoss branch). +**Work:** +- Background autopilot (compact==true): drop the `prev == RoomBoss` carve-out. Boss auto-resolves through the same forward-sim path as elites. +- **Safety gate before boss**: if HP < 80% of max OR supplies < 1 day's burn OR active "low" status, autopilot pitches a camp instead of engaging. Retry next tick. +- **Loss handling**: a death on auto-resolved boss still kicks the player-death narration and ends the run. No special treatment. +- Foreground `!fight` still works (manual override). Foreground `!expedition run` still stops at boss for the player who wants to watch. + +**Open question:** do we want a "set autopilot off" toggle for players who actively want to play the boss themselves? My take: default-on, single-flag per expedition (`autopilot_engage`) settable at start or via `!expedition autopilot off`. Defer until D3 lands and we see how it reads. + +**Exit criteria:** sim runs a full T5 expedition launch → boss kill with zero player commands besides `!expedition start`. + +### D4 — DM volume + day surfaces +The big risk: a 7-day T5 with autopilot walking, camping, fighting elites, and killing bosses will spam DMs unless we bundle. The user has already pushed back on recap chatter (`feedback_skip_recaps`). +**Work:** +- One **end-of-day DM** per autopilot night-camp, bundling: rooms walked, fights resolved, loot, camp pitched, day++ event. Replaces today's per-room auto-walk DM in compact mode. +- One **morning re-engagement DM** when the player first opens the channel after a day-rollover (anchored to user activity, not UTC clock — drops the 06:00 wakeup if the player isn't around). +- Boss kill + run-complete still get their own DM (the climax beat, not bundled). +- TwinBee voice: first-person only (`feedback_twinbee_voice`). +- No trailing recaps (`feedback_skip_recaps`). + +**Exit criteria:** a 7-day T5 produces ≤ 10 DMs across the whole expedition (1 launch + 6 end-of-day + 1 boss + 1 run-complete + 1 emergency). + +### D5 — Supplies economics retune +**Files:** `dnd_expedition.go:38-47` (ExpeditionSupplies), pack-purchase surface (E1b), `applyDailyBurn`. +**Work:** +- New pack tiers sized for 2-day → 7-day. Max packs scale with intended duration. Today's caps (PacksStandard ≤ 3, PacksDeluxe ≤ 1) are a 2-day shape. +- DailyBurn: keep zone-tier scaling, but the autopilot's camp choices now consume meaningful SU per day — so `Max` headroom needs to roughly equal `DailyBurn × intendedDays × 1.3` (slop). +- Surface supply purchase at launch as a single "Pick your loadout" prompt with 3–4 presets ("Lean: 2-day shape", "Balanced", "Heavy: full T5"), with raw pack counts as an advanced override. +- Forage and Ranger forage tuning re-baseline. + +**Exit criteria:** balanced loadout completes intended-duration expedition in ≥ 85% of sim runs without starvation extraction. + +### D6 — Player-facing surface cleanup +- Help text rewrite — frame expedition as "supply pack + watch it play out". +- Hide / soft-deprecate `!camp ` from primary help, keep it documented as an override. +- `!fight` still in help, framed as override. +- Status DM (`!expedition status`) shows: current day / expected days, rooms walked / total, supplies, last 3 events. + +### D7 — Sim + class re-baseline +- `cmd/expedition-sim` extensions: multi-day mode, autopilot camp, autopilot boss. +- Re-run the class win-rate corpus (cross-link `project_class_balance`, `project_j2_sim_artifact`). +- Re-check trailers (bard/cleric per `project_j1_post_sweep_findings`) — autopilot camp pacing may relieve their HP-cliff issue. +- New balance memory entry once corpus stabilizes. + +## 4. Cross-cutting risks + +- **Existing in-flight expeditions** at deploy time. Either fence by `start_date` (old expeditions finish under old rules) or write a one-shot migration that retunes their room counts. Lean: fence — these are typically <2 days. +- **The 24h zone-run inactivity timeout** (`dnd_zone_run.go:254`) — must be raised, or the autopilot must self-poke `last_action_at` per tick. With multi-day expeditions, a quiet stretch overnight is now expected. +- **Idle reaper** (commit `0f72484`, see `expedition_autorun.go` references) — already holds streak on autopilot; verify it tolerates 5-day expeditions. +- **Babysit perk** — `BabysitSafeRest` already upgrades Standard → Fortified at rest (`dnd_expedition_camp.go:241-245`); confirm interaction with autopilot picking Standard (no change needed; the upgrade fires inside `applyCampRest`). +- **Multi-region travel still unwired in autopilot** (memory `project_multiregion_travel_unwired`). For T4/T5 to actually take 5–7 days, autopilot **must** auto-advance regions on region-clear. D2/D3 covers this implicitly but call it out — likely a small dedicated commit. +- **Pet arrival** (memory `project_pet_event_reachability`) — emergence seam is at run-complete; longer expedition just delays the roll, no code change needed. +- **Twinbee voice + secret NPC buffs** (memories `feedback_twinbee_voice`, `feedback_npc_buffs_are_secret`) — none of the new copy can break these. + +## 5. Schema impact + +Likely none. Existing columns cover it: +- `current_day` → autopilot increments via `advanceExpeditionDay`. +- `camp_json`, `supplies_json`, `region_state`, `threat_*` already in place. +- One **possible** new column: `expected_days` int (set at launch from zone tier + loadout) so status/end-of-day DMs can render "Day 3/5". Cheaper to derive than store — defer. + +## 6. Open questions for next session + +1. ~~Day-advance semantics~~ — **decided 2026-05-27: event-anchored.** day++ fires on autopilot camp-pitch (autopilot night-camp = sleep = day burn + briefing). UTC clock becomes a re-engagement-DM anchor only, not a state mutator. See §3-D2 for implementation impact. +2. **Boss autopilot default**: always-on, opt-out, or opt-in? My lean: always-on with a per-expedition opt-out flag; revisit after D3 reads. +3. **Per-tier room counts**: the table in §2 is a starting point. Worth a sim pass before D1 commits — or fine to ship a guess and refine in D7? +4. **Extra anchor rooms**: T4/T5 with one elite + one trap may feel thin spread over 30+ rooms. Add a second elite mid-zone, or rely on multi-region + patrols + threat for variety? +5. **Mobile/AFK friendliness**: should the autopilot-DM cadence aim for "1 DM per real-time evening" regardless of room count, or "1 DM per in-game day" (which could be hours apart if the player set the pacing fast)? + +## 7. Sequence + +D1 → D2 → D3 (independent of D4/D5) → D5 → D4 → D6 → D7. + +D2 and D3 can land in either order; D4 needs the volume problem to be real (so after D2/D3 at minimum). D5 wants D1's new lengths but can run in parallel with D2/D3. D7 always last. + +Estimated sessions: 5–7. D2 and D5 are the biggest single-session chunks. diff --git a/internal/plugin/dnd_expedition.go b/internal/plugin/dnd_expedition.go index b39c33b..e335018 100644 --- a/internal/plugin/dnd_expedition.go +++ b/internal/plugin/dnd_expedition.go @@ -57,6 +57,11 @@ type CampState struct { // threat -5 etc.) have already been applied at pitch time. processOvernightCamp // uses it to skip re-applying so the night cycle just breaks the camp. RestApplied bool `json:"rest_applied,omitempty"` + // AutoPitched is set when the long-expedition autopilot pitched this + // camp. The autorun ticker breaks an auto-pitched camp itself after a + // minimum dwell so the walk can keep moving; player-pitched camps stay + // up until the player breaks them (or moves on). + AutoPitched bool `json:"auto_pitched,omitempty"` } // ThreatEvent — §8.4. diff --git a/internal/plugin/expedition_autocamp.go b/internal/plugin/expedition_autocamp.go new file mode 100644 index 0000000..77d669e --- /dev/null +++ b/internal/plugin/expedition_autocamp.go @@ -0,0 +1,273 @@ +package plugin + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "gogobee/internal/flavor" + "maunium.net/go/mautrix/id" +) + +// Long-expedition D2-a — autopilot camp scheduler. +// +// The autorun ticker (expedition_autorun.go) calls into here after the +// walk loop returns so the dungeon can pitch its own camp when the party +// is low on HP, or to drop a base-camp waypoint after a region clear. +// Day-rollover semantics are still UTC-anchored in D2-a; D2-b moves +// day++/burn into a night-camp helper this scheduler will eventually +// trigger. +// +// An auto-pitched camp is treated as a transient rest stop: the next +// autorun tick past minAutoCampDwell breaks it itself so the walk can +// continue. Player-pitched camps (`!camp`) never get broken by the +// scheduler — those are an explicit player intent and stay up until +// the player moves on or breaks them. + +const ( + // minAutoCampDwell — how long an auto-pitched camp stays up before + // the autorun ticker breaks it on its own. The autorun cooldown is + // 2h, so a dwell > 2h guarantees the camp spans at least one full + // tick of "the player is resting" before the walk resumes. + minAutoCampDwell = 4 * time.Hour + + // autoCampHPPct — pitch a rest camp when current HP is at or below + // this fraction of max. Set above autopilotLowHPPct (0.30) so the + // scheduler camps *before* the walk-preflight gives up. + autoCampHPPct = 0.55 +) + +// autoCampInputs is the minimal snapshot decideAutopilotCamp needs. +// Pulled out so the decision is pure / cheap to test without DB setup. +type autoCampInputs struct { + Camp *CampState + Run *DungeonRun + Multi bool + RegionCleared bool + BaseSite bool + BaseAlready bool + HPCur, HPMax int + Supplies ExpeditionSupplies +} + +// autoCampDecision — what to pitch and why. Reason is a short log-line +// fragment ("region boss cleared", "HP low"). +type autoCampDecision struct { + Kind string + Reason string +} + +// decideAutopilotCamp returns the camp to pitch (or ok=false). Pure; +// the executor does the DB work. +func decideAutopilotCamp(in autoCampInputs) (autoCampDecision, bool) { + if in.Camp != nil && in.Camp.Active { + return autoCampDecision{}, false + } + if in.Run == nil { + return autoCampDecision{}, false + } + rt := in.Run.CurrentRoomType() + if rt == RoomBoss || rt == RoomTrap { + return autoCampDecision{}, false + } + cleared := false + for _, idx := range in.Run.RoomsCleared { + if idx == in.Run.CurrentRoom { + cleared = true + break + } + } + + // Heuristic — region base-camp waypoint. Eager: pitch once per + // eligible region after its boss is down. BaseAlready stops the + // next tick from re-pitching the same waypoint. + if in.Multi && in.RegionCleared && in.BaseSite && !in.BaseAlready && cleared { + if in.Supplies.Current >= campSupplyCost[CampTypeBase] { + return autoCampDecision{Kind: CampTypeBase, Reason: "region boss cleared — pitching base camp waypoint"}, true + } + } + + // Heuristic — HP-low rest. Standard if cleared, rough otherwise. + // LowSU as a *trigger* would just dig the hole deeper (camps burn + // SU); the walk's preflight already pauses on low-SU so the + // player can extract or buy more time. + lowHP := in.HPMax > 0 && float64(in.HPCur) <= float64(in.HPMax)*autoCampHPPct + if !lowHP { + return autoCampDecision{}, false + } + kind := CampTypeRough + if cleared { + kind = CampTypeStandard + } + cost := campSupplyCost[kind] + if in.Supplies.Current < cost { + // Try the cheaper rough tier if standard doesn't fit. + if kind == CampTypeStandard && in.Supplies.Current >= campSupplyCost[CampTypeRough] { + kind = CampTypeRough + } else { + return autoCampDecision{}, false + } + } + return autoCampDecision{Kind: kind, Reason: "HP low — pitching rest camp"}, true +} + +// maybeAutoCamp gathers DB state, calls decideAutopilotCamp, and pitches +// when the decision says to. Returns the player-facing camp block (or +// "" if no camp was pitched) so the autorun DM can include it. +func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) string { + uid := id.UserID(exp.UserID) + + var run *DungeonRun + if exp.RunID != "" { + if r, err := getZoneRun(exp.RunID); err == nil { + run = r + } + } + + multi := IsMultiRegionZone(exp.ZoneID) + regionCleared := false + baseSite := false + baseAlready := false + if multi { + if region, ok := CurrentRegion(exp); ok { + regionCleared = IsRegionCleared(exp, region.ID) + baseSite = region.BaseCampSite + baseAlready = HasBaseCampAt(exp, region.ID) + } + } + + hpCur, hpMax := dndHPSnapshot(uid) + in := autoCampInputs{ + Camp: exp.Camp, + Run: run, + Multi: multi, + RegionCleared: regionCleared, + BaseSite: baseSite, + BaseAlready: baseAlready, + HPCur: hpCur, + HPMax: hpMax, + Supplies: exp.Supplies, + } + d, ok := decideAutopilotCamp(in) + if !ok { + return "" + } + block, err := p.pitchAutopilotCamp(exp, d) + if err != nil { + slog.Warn("autopilot camp: pitch failed", "expedition", exp.ID, "kind", d.Kind, "err", err) + return "" + } + return block +} + +// pitchAutopilotCamp performs the same state mutations as the player +// !camp path (supply debit, camp row, applyCampRest, log entry, base- +// camp waypoint persist) without DMing — the autorun ticker bundles +// the returned block into the single auto-walk DM. +func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision) (string, error) { + cost := campSupplyCost[d.Kind] + if exp.Supplies.Current < cost { + return "", fmt.Errorf("insufficient supplies (have %.1f, need %.1f)", exp.Supplies.Current, cost) + } + exp.Supplies.Current -= cost + if err := updateSupplies(exp.ID, exp.Supplies); err != nil { + return "", err + } + camp := &CampState{ + Active: true, + Type: d.Kind, + RoomIndex: campCurrentRoomIndex(exp), + EstablishedAt: time.Now().UTC(), + NightEvents: []string{}, + AutoPitched: true, + } + if err := updateCamp(exp.ID, camp); err != nil { + return "", err + } + exp.Camp = camp + + restSummary := applyCampRest(exp, d.Kind) + camp.RestApplied = true + if err := updateCamp(exp.ID, camp); err != nil { + slog.Warn("autopilot camp: mark rest applied", "expedition", exp.ID, "err", err) + } + + var line string + if d.Kind == CampTypeBase { + line = flavor.Pick(flavor.BaseCampEstablished) + line = strings.ReplaceAll(line, "[N]", fmt.Sprintf("%d", exp.CurrentDay)) + } else { + line = flavor.Pick(flavor.CampEstablished) + } + _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "rest", + fmt.Sprintf("autopilot camp pitched (%s) — %s — %.1f SU consumed", d.Kind, d.Reason, cost), line) + + if d.Kind == CampTypeBase { + if region, ok := CurrentRegion(exp); ok { + if _, err := addRegionListEntry(exp, regionStateBaseCampKey, region.ID); err != nil { + slog.Warn("autopilot camp: persist base camp waypoint", "expedition", exp.ID, "err", err) + } + } + } + + return renderAutoCampBlock(exp, d, cost, line, restSummary), nil +} + +// renderAutoCampBlock formats the autopilot-camp section appended to +// the auto-walk DM. Kept short — the autorun DM already opens with the +// walk narration. +func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flavorLine, restSummary string) string { + out := fmt.Sprintf("\n\n⛺ **Autopilot camp — %s.** _%s_\n", d.Kind, d.Reason) + out += fmt.Sprintf("Supplies: %.1f / %.1f SU (−%.1f).\n", + exp.Supplies.Current, exp.Supplies.Max, cost) + if flavorLine != "" { + out += "\n" + flavorLine + "\n" + } + if restSummary != "" { + out += "\n" + restSummary + "\n" + } + if d.Kind == CampTypeBase { + out += "\n_Base camp — **waypoint persisted**._" + } + return out +} + +// breakAutoCampIfDue tears down an auto-pitched camp once it has dwelled +// for minAutoCampDwell. Returns true when it broke a camp (so the +// caller knows the walk should proceed this tick). Player-pitched camps +// are left untouched. +func breakAutoCampIfDue(exp *Expedition, now time.Time) bool { + if exp.Camp == nil || !exp.Camp.Active || !exp.Camp.AutoPitched { + return false + } + if now.Sub(exp.Camp.EstablishedAt) < minAutoCampDwell { + return false + } + if err := updateCamp(exp.ID, nil); err != nil { + slog.Warn("autopilot camp: break failed", "expedition", exp.ID, "err", err) + return false + } + _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", + "autopilot broke camp (dwell elapsed)", "") + exp.Camp = nil + return true +} + +// shouldSkipAutoRunForCamp reports whether the autorun ticker should +// skip this expedition entirely because an auto- or player-pitched +// camp is still within its dwell window. Returns true when the ticker +// should bail before walking. +func shouldSkipAutoRunForCamp(exp *Expedition, now time.Time) bool { + if exp.Camp == nil || !exp.Camp.Active { + return false + } + // Player camps are sticky — never walked through by autopilot until + // the player explicitly breaks or moves. + if !exp.Camp.AutoPitched { + return true + } + // Auto-pitched camps: skip while still in dwell; the next tick past + // the window will break + walk in breakAutoCampIfDue. + return now.Sub(exp.Camp.EstablishedAt) < minAutoCampDwell +} diff --git a/internal/plugin/expedition_autocamp_test.go b/internal/plugin/expedition_autocamp_test.go new file mode 100644 index 0000000..6fec7b6 --- /dev/null +++ b/internal/plugin/expedition_autocamp_test.go @@ -0,0 +1,248 @@ +package plugin + +import ( + "testing" + "time" + + "maunium.net/go/mautrix/id" +) + +func TestDecideAutopilotCamp_SkipsWhenCamped(t *testing.T) { + in := autoCampInputs{ + Camp: &CampState{Active: true, Type: CampTypeRough}, + Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}}, + HPCur: 1, HPMax: 20, // very low — would otherwise camp + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + } + if _, ok := decideAutopilotCamp(in); ok { + t.Errorf("expected no camp when already camped") + } +} + +func TestDecideAutopilotCamp_SkipsHealthyHP(t *testing.T) { + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}}, + HPCur: 20, HPMax: 20, + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + } + if _, ok := decideAutopilotCamp(in); ok { + t.Errorf("expected no camp when HP full") + } +} + +func TestDecideAutopilotCamp_LowHPClearedPitchesStandard(t *testing.T) { + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, + HPCur: 5, HPMax: 20, // 25% — below 55% threshold + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + } + d, ok := decideAutopilotCamp(in) + if !ok || d.Kind != CampTypeStandard { + t.Errorf("expected standard camp, got %+v ok=%v", d, ok) + } +} + +func TestDecideAutopilotCamp_LowHPUnclearedPitchesRough(t *testing.T) { + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 2, RoomsCleared: []int{0, 1}}, + HPCur: 5, HPMax: 20, + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + } + d, ok := decideAutopilotCamp(in) + if !ok || d.Kind != CampTypeRough { + t.Errorf("expected rough camp, got %+v ok=%v", d, ok) + } +} + +func TestDecideAutopilotCamp_DowngradesWhenSuppliesTight(t *testing.T) { + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, + HPCur: 5, HPMax: 20, + Supplies: ExpeditionSupplies{Current: 0.6, Max: 5, DailyBurn: 1}, // can't afford 1.0 + } + d, ok := decideAutopilotCamp(in) + if !ok || d.Kind != CampTypeRough { + t.Errorf("expected downgrade to rough, got %+v ok=%v", d, ok) + } +} + +func TestDecideAutopilotCamp_SkipsBossRoom(t *testing.T) { + in := autoCampInputs{ + Run: &DungeonRun{ + CurrentRoom: 0, RoomsCleared: []int{}, + RoomSeq: []RoomType{RoomBoss}, + }, + HPCur: 1, HPMax: 20, + } + if _, ok := decideAutopilotCamp(in); ok { + t.Errorf("expected no camp in boss room") + } +} + +func TestDecideAutopilotCamp_BaseCampWhenRegionCleared(t *testing.T) { + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}}, + Multi: true, + RegionCleared: true, + BaseSite: true, + BaseAlready: false, + HPCur: 20, HPMax: 20, // healthy — still pitch base waypoint + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + } + d, ok := decideAutopilotCamp(in) + if !ok || d.Kind != CampTypeBase { + t.Errorf("expected base camp, got %+v ok=%v", d, ok) + } +} + +func TestDecideAutopilotCamp_BaseCampSkippedIfAlreadyPersisted(t *testing.T) { + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}}, + Multi: true, + RegionCleared: true, + BaseSite: true, + BaseAlready: true, + HPCur: 20, HPMax: 20, + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + } + if _, ok := decideAutopilotCamp(in); ok { + t.Errorf("expected no second base-camp pitch") + } +} + +func TestShouldSkipAutoRunForCamp(t *testing.T) { + now := time.Now().UTC() + stickyPlayerCamp := &Expedition{Camp: &CampState{ + Active: true, Type: CampTypeStandard, AutoPitched: false, + EstablishedAt: now.Add(-10 * time.Hour), + }} + if !shouldSkipAutoRunForCamp(stickyPlayerCamp, now) { + t.Error("player camp should always block autorun") + } + + freshAuto := &Expedition{Camp: &CampState{ + Active: true, Type: CampTypeStandard, AutoPitched: true, + EstablishedAt: now.Add(-1 * time.Hour), + }} + if !shouldSkipAutoRunForCamp(freshAuto, now) { + t.Error("auto camp within dwell window should skip") + } + + staleAuto := &Expedition{Camp: &CampState{ + Active: true, Type: CampTypeStandard, AutoPitched: true, + EstablishedAt: now.Add(-(minAutoCampDwell + time.Minute)), + }} + if shouldSkipAutoRunForCamp(staleAuto, now) { + t.Error("auto camp past dwell should let walk proceed") + } + + none := &Expedition{} + if shouldSkipAutoRunForCamp(none, now) { + t.Error("no camp should not skip") + } +} + +func TestPitchAutopilotCamp_DeductsSuppliesAndRestores(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@autocamp-pitch:example") + campTestCharacter(t, uid, 1) + // Damage the character so the standard rest can heal them. + c, _ := LoadDnDCharacter(uid) + c.HPCurrent = 4 + _ = SaveDnDCharacter(c) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + p := &AdventurePlugin{} + block, err := p.pitchAutopilotCamp(exp, autoCampDecision{ + Kind: CampTypeStandard, Reason: "test pitch", + }) + if err != nil { + t.Fatal(err) + } + if block == "" { + t.Error("expected non-empty camp block") + } + + fresh, _ := getActiveExpedition(uid) + if fresh.Camp == nil || !fresh.Camp.Active { + t.Fatal("expected camp pitched") + } + if !fresh.Camp.AutoPitched { + t.Error("expected AutoPitched flag set") + } + if !fresh.Camp.RestApplied { + t.Error("expected RestApplied flag set") + } + if fresh.Supplies.Current != 4.0 { + t.Errorf("supplies = %v, want 4.0 after standard pitch", fresh.Supplies.Current) + } + cc, _ := LoadDnDCharacter(uid) + if cc.HPCurrent != cc.HPMax { + t.Errorf("HP = %d/%d, want full after standard rest", cc.HPCurrent, cc.HPMax) + } +} + +func TestBreakAutoCampIfDue_RespectsDwellWindow(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@autocamp-break:example") + campTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + exp.Camp = &CampState{ + Active: true, Type: CampTypeRough, AutoPitched: true, + EstablishedAt: now.Add(-1 * time.Hour), + } + if err := updateCamp(exp.ID, exp.Camp); err != nil { + t.Fatal(err) + } + if breakAutoCampIfDue(exp, now) { + t.Error("camp inside dwell window should not be broken") + } + + exp.Camp.EstablishedAt = now.Add(-(minAutoCampDwell + time.Minute)) + if err := updateCamp(exp.ID, exp.Camp); err != nil { + t.Fatal(err) + } + if !breakAutoCampIfDue(exp, now) { + t.Error("camp past dwell window should be broken") + } + fresh, _ := getActiveExpedition(uid) + if fresh.Camp != nil && fresh.Camp.Active { + t.Errorf("expected camp cleared, got %+v", fresh.Camp) + } +} + +func TestBreakAutoCampIfDue_LeavesPlayerCampAlone(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@autocamp-playercamp:example") + campTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + exp.Camp = &CampState{ + Active: true, Type: CampTypeStandard, AutoPitched: false, + EstablishedAt: now.Add(-10 * time.Hour), + } + if err := updateCamp(exp.ID, exp.Camp); err != nil { + t.Fatal(err) + } + if breakAutoCampIfDue(exp, now) { + t.Error("player camp must not be auto-broken") + } +} diff --git a/internal/plugin/expedition_autorun.go b/internal/plugin/expedition_autorun.go index 60cbc97..347178a 100644 --- a/internal/plugin/expedition_autorun.go +++ b/internal/plugin/expedition_autorun.go @@ -141,6 +141,15 @@ func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error) // 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 @@ -163,11 +172,33 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error { // "no expedition" / "no run" — race with abandon/extract. Silent. return nil } + // 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 := "" + 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 = p.maybeAutoCamp(fresh) + } + } + if campBlock != "" { + r.finalMsg += campBlock + } + _ = autoCampBroken // reserved for D2-b end-of-day DM bundling + // 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 shouldDMAutoRun(r) { + // + // DM rule: surface anytime the regular suppression says to, OR + // whenever the autopilot pitched a camp this tick — a camp event + // is always worth a DM, even if the walk itself was quiet. + if shouldDMAutoRun(r) || campBlock != "" { body := renderAutoRunDM(r) if err := p.SendDM(uid, body); err != nil { slog.Warn("expedition: autorun DM", "user", uid, "err", err)