mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Long expeditions D2-a: autopilot camp scheduler
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.
This commit is contained in:
158
gogobee_long_expedition_plan.md
Normal file
158
gogobee_long_expedition_plan.md
Normal file
@@ -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 <kind>`, `!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 <type>` 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 <type>` 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.
|
||||||
@@ -57,6 +57,11 @@ type CampState struct {
|
|||||||
// threat -5 etc.) have already been applied at pitch time. processOvernightCamp
|
// 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.
|
// uses it to skip re-applying so the night cycle just breaks the camp.
|
||||||
RestApplied bool `json:"rest_applied,omitempty"`
|
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.
|
// ThreatEvent — §8.4.
|
||||||
|
|||||||
273
internal/plugin/expedition_autocamp.go
Normal file
273
internal/plugin/expedition_autocamp.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
248
internal/plugin/expedition_autocamp_test.go
Normal file
248
internal/plugin/expedition_autocamp_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,15 @@ func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error)
|
|||||||
// supplies/threat/run-graph state are mutated by the walk itself, just
|
// supplies/threat/run-graph state are mutated by the walk itself, just
|
||||||
// as they would be in a foreground !expedition run.
|
// as they would be in a foreground !expedition run.
|
||||||
func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
|
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)
|
cutoff := now.Add(-autoRunCooldown)
|
||||||
res, err := db.Get().Exec(`
|
res, err := db.Get().Exec(`
|
||||||
UPDATE dnd_expedition
|
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.
|
// "no expedition" / "no run" — race with abandon/extract. Silent.
|
||||||
return nil
|
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
|
// Emergence seam: a run-complete reached by the background ticker is
|
||||||
// still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge.
|
// still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge.
|
||||||
// Deferred until after the run-summary DM below so the "animal in your
|
// Deferred until after the run-summary DM below so the "animal in your
|
||||||
// house" prompt lands after the summary, not ahead of it.
|
// 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)
|
body := renderAutoRunDM(r)
|
||||||
if err := p.SendDM(uid, body); err != nil {
|
if err := p.SendDM(uid, body); err != nil {
|
||||||
slog.Warn("expedition: autorun DM", "user", uid, "err", err)
|
slog.Warn("expedition: autorun DM", "user", uid, "err", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user