From 29cad7972a89c6d6bea8e62b935030ffb4ebf75c Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Wed, 27 May 2026 20:40:04 -0700 Subject: [PATCH] Long expeditions D7-b: drive autopilot camp from SimRunner maybeAutoCamp / pitchAutopilotCamp / pitchBossSafetyCamp now take a now time.Time so the sim can inject a synthetic clock; tryAutoRun still passes time.Now().UTC(). SimRunner.RunExpedition advances simNow by autoRunCooldown per walk and runs the production camp scheduler after each soft stop (and pitchBossSafetyCamp on stopBossSafety), dwelling minAutoCampDwell + breakAutoCampIfDue so the next walk can proceed. Effect: HP-low mid-day rests, base-camp waypoints, Night-camp rollovers, and boss-safety holds all fire under the sim; D7-a's tickEventAnchoredRollover shortcut is retained on TickDay for tests and the pre-cutoff legacy path. --- gogobee_long_expedition_plan.md | 6 +- internal/plugin/expedition_autocamp.go | 27 +-- internal/plugin/expedition_autocamp_test.go | 4 +- internal/plugin/expedition_autorun.go | 4 +- internal/plugin/expedition_sim.go | 120 ++++++++++-- internal/plugin/expedition_sim_test.go | 199 ++++++++++++++++++++ 6 files changed, 324 insertions(+), 36 deletions(-) diff --git a/gogobee_long_expedition_plan.md b/gogobee_long_expedition_plan.md index f50183f..5c988ea 100644 --- a/gogobee_long_expedition_plan.md +++ b/gogobee_long_expedition_plan.md @@ -139,8 +139,10 @@ Each successful background walk now logs a `walk` entry (`appendExpeditionLog(.. **D7-a (shipped 2026-05-27):** `SimRunner.TickDay` now drives event-anchored expeditions. `expedition_sim.go` short-circuits when `isEventAnchored(exp)` is true: instead of calling `deliverBriefing` (whose `deliverBriefingEventAnchored` branch reads `time.Now().UTC()` and would never trip the safety-net under synthetic ticks), it runs `nightRolloverBurn` → optional `applyCampRest(Standard)` if affordable (Rough fallback, skipped on low-SU) → `nightRolloverDrift(briefAt)`. Mirrors `pitchAutopilotCamp` with `Night=true`. Production paths untouched. New `expedition_sim_test.go` covers (i) 3 ticks advancing day 1→4 with supply burn + stamped `LastBriefingAt`, and (ii) the low-SU branch where the rest is skipped but burn/drift still fire. Unblocks D5-d empirical re-baseline of `phase5BDailyBurnRatePct` and the class corpus re-run. -**Remaining work (D7-b+):** -- `cmd/expedition-sim` extensions: multi-day mode CLI, autopilot camp, autopilot boss. +**D7-b (shipped 2026-05-27):** `SimRunner.RunExpedition` now drives the production camp scheduler under a synthetic clock (`simWalkInterval = autoRunCooldown`, 2h per walk). After every soft-stop walk it calls `maybeAutoCamp(exp, simNow)`; `stopBossSafety` routes to `pitchBossSafetyCamp(exp, simNow)`. The helpers (`applyAutoCamp` / `applyAutoCampBossSafety`) advance `simNow` past `minAutoCampDwell` (4h) and break the auto-pitched camp via `breakAutoCampIfDue` so the next walk proceeds. `maybeAutoCamp`, `pitchAutopilotCamp`, and `pitchBossSafetyCamp` gained a `now time.Time` parameter (the only prod caller, `tryAutoRun`, still passes `time.Now().UTC()`). Effect: HP-low mid-day rests, multi-region base-camp waypoints, Night-camp rollovers, and boss-safety holds all fire under the sim — the D7-a `tickEventAnchoredRollover` shortcut is no longer used by RunExpedition (kept on TickDay for tests + the pre-cutoff legacy path). Coverage in `expedition_sim_test.go`: Night-camp day advance, HP-low mid-day rest, boss-safety force-pitch, no-op when neither trigger fires. + +**Remaining work (D7-c+):** +- `cmd/expedition-sim` extensions: multi-day mode CLI flag (`-days` / per-day SU/HP/threat snapshots in SimResult). - 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. - D5-d retune of `DailyBurn` / `phase5BDailyBurnRatePct` against measured day-counts. diff --git a/internal/plugin/expedition_autocamp.go b/internal/plugin/expedition_autocamp.go index a0abfc8..55c6f67 100644 --- a/internal/plugin/expedition_autocamp.go +++ b/internal/plugin/expedition_autocamp.go @@ -157,7 +157,12 @@ func decideAutopilotCamp(in autoCampInputs) (autoCampDecision, bool) { // "" if no camp was pitched), along with the decision and an ok flag. // D4-a uses the Night bit on the decision to switch tryAutoRun's DM // rendering into end-of-day digest mode. -func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecision, bool) { +// +// now drives the nightCampWindow check inside decideAutopilotCamp and +// the camp.EstablishedAt / drift timestamps inside pitchAutopilotCamp. +// Production callers pass time.Now().UTC(); the sim injects a synthetic +// clock so multi-day rollovers fire without real-time waits. +func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition, now time.Time) (string, autoCampDecision, bool) { uid := id.UserID(exp.UserID) var run *DungeonRun @@ -190,7 +195,7 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecisi HPCur: hpCur, HPMax: hpMax, Supplies: exp.Supplies, - Now: time.Now().UTC(), + Now: now, EventAnchored: isEventAnchored(exp), LastBriefingAt: exp.LastBriefingAt, StartDate: exp.StartDate, @@ -199,7 +204,7 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecisi if !ok { return "", autoCampDecision{}, false } - block, err := p.pitchAutopilotCamp(exp, d) + block, err := p.pitchAutopilotCamp(exp, d, now) if err != nil { slog.Warn("autopilot camp: pitch failed", "expedition", exp.ID, "kind", d.Kind, "err", err) return "", autoCampDecision{}, false @@ -215,7 +220,7 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecisi // too: nightRolloverBurn before the camp cost (so the burn lands on // pre-pitch supplies, matching the legacy morning-burn ordering), then // applyCampRest, then nightRolloverDrift. -func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision) (string, error) { +func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision, now time.Time) (string, error) { var ( nightBurn float32 nightTemp []string @@ -234,7 +239,7 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision // Starvation during burn is rare (burn alone doesn't trigger // starvation — that needs supplies <= 0 *after* burn), but // guard anyway so we don't pitch a camp on an abandoned exp. - drift := p.nightRolloverDrift(exp, time.Now().UTC()) + drift := p.nightRolloverDrift(exp, now) nightTemp = drift.TemporalLines nightMile = drift.MilestoneLines nightForce = true @@ -253,7 +258,7 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision Active: true, Type: d.Kind, RoomIndex: campCurrentRoomIndex(exp), - EstablishedAt: time.Now().UTC(), + EstablishedAt: now, NightEvents: []string{}, AutoPitched: true, } @@ -287,7 +292,7 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision } if d.Night { - drift := p.nightRolloverDrift(exp, time.Now().UTC()) + drift := p.nightRolloverDrift(exp, now) nightTemp = drift.TemporalLines nightMile = drift.MilestoneLines } @@ -348,7 +353,7 @@ func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flav // time has elapsed since the last briefing on an event-anchored // expedition, the pitch carries Night=true and runs the burn/drift // alongside the rest. -func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) (string, autoCampDecision, bool) { +func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition, now time.Time) (string, autoCampDecision, bool) { if exp == nil || exp.Status != ExpeditionStatusActive { return "", autoCampDecision{}, false } @@ -371,9 +376,9 @@ func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) (string, autoCamp if isEventAnchored(exp) { var since time.Duration if exp.LastBriefingAt != nil { - since = time.Since(*exp.LastBriefingAt) + since = now.Sub(*exp.LastBriefingAt) } else { - since = time.Since(exp.StartDate) + since = now.Sub(exp.StartDate) } night = since >= nightCampWindow } @@ -382,7 +387,7 @@ func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) (string, autoCamp Reason: "boss-safety hold — resting before re-engaging", Night: night, } - block, err := p.pitchAutopilotCamp(exp, d) + block, err := p.pitchAutopilotCamp(exp, d, now) if err != nil { slog.Warn("autopilot boss-safety camp: pitch failed", "expedition", exp.ID, "kind", kind, "err", err) return "", autoCampDecision{}, false diff --git a/internal/plugin/expedition_autocamp_test.go b/internal/plugin/expedition_autocamp_test.go index 6951062..5299b0d 100644 --- a/internal/plugin/expedition_autocamp_test.go +++ b/internal/plugin/expedition_autocamp_test.go @@ -160,7 +160,7 @@ func TestPitchAutopilotCamp_DeductsSuppliesAndRestores(t *testing.T) { p := &AdventurePlugin{} block, err := p.pitchAutopilotCamp(exp, autoCampDecision{ Kind: CampTypeStandard, Reason: "test pitch", - }) + }, time.Now().UTC()) if err != nil { t.Fatal(err) } @@ -300,7 +300,7 @@ func TestPitchAutopilotCamp_NightRunsProcessNightCamp(t *testing.T) { p := &AdventurePlugin{} _, err = p.pitchAutopilotCamp(exp, autoCampDecision{ Kind: CampTypeStandard, Reason: "night-camp test", Night: true, - }) + }, time.Now().UTC()) if err != nil { t.Fatal(err) } diff --git a/internal/plugin/expedition_autorun.go b/internal/plugin/expedition_autorun.go index fc165de..ac98695 100644 --- a/internal/plugin/expedition_autorun.go +++ b/internal/plugin/expedition_autorun.go @@ -198,13 +198,13 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error { // 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) + 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) + campBlock, campDecision, campPitched = p.maybeAutoCamp(fresh, now) } } _ = autoCampBroken // hint reserved for downstream digest tweaks diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 9d0b2ce..6f8b109 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -350,10 +350,18 @@ type SimLogEntry struct { TS time.Time } +// simWalkInterval — how much synthetic time each autopilot walk +// represents. Matches the production autorun cooldown so the +// nightCampWindow check inside decideAutopilotCamp lands on the same +// real-time cadence: ~8 walks ≈ 16h ≈ one Night-camp rollover. +const simWalkInterval = autoRunCooldown + // RunExpedition starts an expedition for uid in zoneID and loops the // autopilot walk (compact mode, so elite rooms auto-resolve inline) -// until a hard stop fires. Between walks it fast-forwards the day -// cycle so multi-day expeditions complete without real-time waits. +// until a hard stop fires. Between walks it advances a synthetic clock +// (simWalkInterval per walk) and calls into the same maybeAutoCamp / +// pitchBossSafetyCamp scheduler the production autorun ticker uses, so +// HP-low mid-day rests + Night-camp rollovers fire under the sim. // // walkCap bounds the number of autopilot bursts as a safety net. Each // burst walks up to autopilotRoomCap rooms. @@ -385,7 +393,13 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S } res.SUStart = exp.Supplies.Current + // Synthetic clock — anchored on the expedition's real start_date so + // nightCampWindow / nightSafetyNet comparisons against LastBriefingAt + // stay coherent with the rows the autopilot scheduler reads. + simNow := exp.StartDate + for i := 0; i < walkCap; i++ { + simNow = simNow.Add(simWalkInterval) walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true) if walk.initErr != "" { res.Outcome = "halted" @@ -467,24 +481,53 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S case stopBlocked: res.Outcome = "blocked" i = walkCap - default: - // stopOK / stopPreflight / stopHarvestCombat — soft stops. - // Fast-forward a day so the next walk has fresh supplies - // budgeted, HP that overnight camp may have healed, and - // threat drift recorded. - if exp, _ = getActiveExpedition(uid); exp == nil { + case stopBossSafety: + // Compact autopilot bailed before the boss (HP/SU gate). Mirror + // the production autorun: force-pitch a rest camp, dwell, then + // break it so the next walk re-engages the boss. + fresh, _ := getActiveExpedition(uid) + if fresh == nil { res.Outcome = "extracted" i = walkCap break } - if err := s.TickDay(exp); err != nil { + advanced, err := s.applyAutoCampBossSafety(fresh, &simNow) + if err != nil { res.Outcome = "halted" - res.StopCode = "tick:" + err.Error() + res.StopCode = "boss_safety_camp:" + err.Error() i = walkCap break } - res.DayTicks++ - // TickDay may have force-extracted (starvation). Re-check. + if advanced { + res.DayTicks++ + } + if exp, _ = getActiveExpedition(uid); exp == nil { + res.Outcome = "extracted" + i = walkCap + } + default: + // stopOK / stopPreflight / stopHarvestCombat — soft stops. + // Run the same camp scheduler the production autorun fires + // after every walk. With simNow past nightCampWindow the + // pitch is a Night camp and drives the day rollover; below + // that, an HP-low rest may fire mid-day. + fresh, _ := getActiveExpedition(uid) + if fresh == nil { + res.Outcome = "extracted" + i = walkCap + break + } + advanced, err := s.applyAutoCamp(fresh, &simNow) + if err != nil { + res.Outcome = "halted" + res.StopCode = "auto_camp:" + err.Error() + i = walkCap + break + } + if advanced { + res.DayTicks++ + } + // maybeAutoCamp's drift step may have force-extracted (starvation). if exp, _ = getActiveExpedition(uid); exp == nil { res.Outcome = "extracted" i = walkCap @@ -839,6 +882,47 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string { return cands[0].id } +// applyAutoCamp drives the production camp scheduler under the sim's +// synthetic clock: call maybeAutoCamp with *simNow, then if a camp +// pitched, advance *simNow past minAutoCampDwell and break the camp so +// the next walk can proceed. Returns whether the pitch was a Night +// camp (i.e. a day rollover fired). +func (s *SimRunner) applyAutoCamp(exp *Expedition, simNow *time.Time) (bool, error) { + _, d, ok := s.P.maybeAutoCamp(exp, *simNow) + if !ok { + return false, nil + } + return s.dwellAndBreakAutoCamp(exp, simNow, d.Night) +} + +// applyAutoCampBossSafety mirrors applyAutoCamp for the stopBossSafety +// gate — the camp is force-pitched even when the regular HP threshold +// hasn't tripped (decideAutopilotCamp also blocks pitches at boss +// rooms, which is exactly where this one belongs). +func (s *SimRunner) applyAutoCampBossSafety(exp *Expedition, simNow *time.Time) (bool, error) { + _, d, ok := s.P.pitchBossSafetyCamp(exp, *simNow) + if !ok { + return false, nil + } + return s.dwellAndBreakAutoCamp(exp, simNow, d.Night) +} + +// dwellAndBreakAutoCamp advances *simNow past minAutoCampDwell and +// breaks the auto-pitched camp. Reloads exp from the DB first so the +// camp row reflects the just-applied pitch. Returns the night flag +// passed in (for the DayTicks counter). +func (s *SimRunner) dwellAndBreakAutoCamp(exp *Expedition, simNow *time.Time, night bool) (bool, error) { + *simNow = simNow.Add(minAutoCampDwell) + fresh, err := getExpedition(exp.ID) + if err != nil { + return night, err + } + if fresh != nil { + _ = breakAutoCampIfDue(fresh, *simNow) + } + return night, nil +} + // TickDay drives one synthetic day rollover for exp: 21:00 recap of // the current day, then 06:00 briefing of the next day. The briefing // is what bumps current_day and applies supply burn / overnight camp / @@ -849,13 +933,11 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string { // real-day per call regardless of wall-clock time when the sim runs. // // Event-anchored expeditions (D2-b) own the rollover inside the -// autopilot's night-camp pitch, which the sim doesn't drive on a -// synthetic clock. To keep TickDay's contract ("one call = one day") -// we short-circuit: fire processNightCamp directly + apply a Standard -// rest if affordable (mirroring pitchAutopilotCamp with Night=true), -// then stamp last_briefing_at at the synthetic threshold. deliverBriefing -// is skipped — its event-anchored branch reads wall-clock time and -// would never see "enough" elapsed time to force-fire under the sim. +// autopilot's night-camp pitch — RunExpedition exercises that path via +// applyAutoCamp; TickDay is retained for tests and the legacy +// non-event-anchored fallback. The event-anchored branch here short- +// circuits to processNightCamp so callers that DO invoke TickDay on a +// post-cutoff expedition still see one-call-one-day semantics. func (s *SimRunner) TickDay(exp *Expedition) error { if exp == nil { return fmt.Errorf("nil expedition") diff --git a/internal/plugin/expedition_sim_test.go b/internal/plugin/expedition_sim_test.go index 888eb87..d229207 100644 --- a/internal/plugin/expedition_sim_test.go +++ b/internal/plugin/expedition_sim_test.go @@ -7,6 +7,205 @@ import ( "maunium.net/go/mautrix/id" ) +// D7-b: applyAutoCamp drives maybeAutoCamp under the sim's synthetic +// clock. When simNow is past the nightCampWindow on an event-anchored +// expedition, the scheduler pitches a Night camp — current_day++, +// supplies burn, last_briefing_at stamped. The helper then advances +// simNow past minAutoCampDwell and breaks the camp so the next walk +// proceeds. +func TestSimRunner_ApplyAutoCamp_NightCampAdvancesDay(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@sim-applycamp-night:example") + campTestCharacter(t, uid, 3) + defer cleanupExpeditions(uid) + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 3, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID, + ExpeditionSupplies{Current: 20, Max: 20, DailyBurn: 2, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + useEventAnchored(t, exp) + + sim := &SimRunner{P: &AdventurePlugin{}} + // Park simNow past the night-camp window so decideAutopilotCamp + // flags Night=true on a healthy character. + simNow := exp.StartDate.Add(nightCampWindow + time.Hour) + startDay := exp.CurrentDay + startSU := exp.Supplies.Current + before := simNow + + night, err := sim.applyAutoCamp(exp, &simNow) + if err != nil { + t.Fatalf("applyAutoCamp: %v", err) + } + if !night { + t.Fatal("expected Night=true beyond nightCampWindow") + } + if simNow.Sub(before) < minAutoCampDwell { + t.Errorf("simNow advanced %v, want >= %v", simNow.Sub(before), minAutoCampDwell) + } + + got, _ := getExpedition(exp.ID) + if got == nil { + t.Fatal("expedition missing after night camp") + } + if got.CurrentDay != startDay+1 { + t.Errorf("CurrentDay = %d, want %d", got.CurrentDay, startDay+1) + } + if got.Supplies.Current >= startSU { + t.Errorf("Supplies.Current = %v, want < %v (burn should have landed)", got.Supplies.Current, startSU) + } + if got.Camp != nil && got.Camp.Active { + t.Errorf("camp should have been broken after dwell, got %+v", got.Camp) + } +} + +// HP-low mid-day rest: under nightCampWindow with a hurt character, +// maybeAutoCamp pitches a Standard camp (cleared room → standard) but +// doesn't advance the day. HP gets restored by applyCampRest; DayTicks +// stays untouched. +func TestSimRunner_ApplyAutoCamp_HPLowMidDayRest(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@sim-applycamp-hp:example") + campTestCharacter(t, uid, 3) + c, _ := LoadDnDCharacter(uid) + c.HPCurrent = 1 // far below autoCampHPPct (55%) + _ = SaveDnDCharacter(c) + defer cleanupExpeditions(uid) + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 3, 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.Fatal(err) + } + useEventAnchored(t, exp) + + sim := &SimRunner{P: &AdventurePlugin{}} + // simNow well inside the active-day window — no Night pitch. + simNow := exp.StartDate.Add(2 * time.Hour) + startDay := exp.CurrentDay + startHP := c.HPCurrent + + night, err := sim.applyAutoCamp(exp, &simNow) + if err != nil { + t.Fatalf("applyAutoCamp: %v", err) + } + if night { + t.Error("expected non-Night pitch within active-day window") + } + + got, _ := getExpedition(exp.ID) + if got == nil { + t.Fatal("expedition missing") + } + if got.CurrentDay != startDay { + t.Errorf("CurrentDay = %d, want %d (no day advance on HP rest)", got.CurrentDay, startDay) + } + c2, _ := LoadDnDCharacter(uid) + if c2 == nil || c2.HPCurrent <= startHP { + t.Errorf("HPCurrent = %v, want > %v (rest should heal)", c2.HPCurrent, startHP) + } + if got.Camp != nil && got.Camp.Active { + t.Errorf("camp should have been broken after dwell, got %+v", got.Camp) + } +} + +// stopBossSafety: pitchBossSafetyCamp force-pitches a camp regardless +// of the regular HP threshold or the boss-room block in decideAutopilotCamp. +// applyAutoCampBossSafety wraps that and runs the same dwell/break dance. +func TestSimRunner_ApplyAutoCampBossSafety_PitchesAndHeals(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@sim-bosssafety:example") + campTestCharacter(t, uid, 3) + c, _ := LoadDnDCharacter(uid) + // HP at 80% — above autoCampHPPct so maybeAutoCamp would NOT fire, + // but the boss-safety path should still pitch. + c.HPCurrent = (c.HPMax * 80) / 100 + if c.HPCurrent < 1 { + c.HPCurrent = 1 + } + _ = SaveDnDCharacter(c) + defer cleanupExpeditions(uid) + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 3, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID, + ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + useEventAnchored(t, exp) + + sim := &SimRunner{P: &AdventurePlugin{}} + simNow := exp.StartDate.Add(2 * time.Hour) + startSU := exp.Supplies.Current + startHP := c.HPCurrent + before := simNow + + if _, err := sim.applyAutoCampBossSafety(exp, &simNow); err != nil { + t.Fatalf("applyAutoCampBossSafety: %v", err) + } + if simNow.Sub(before) < minAutoCampDwell { + t.Errorf("simNow advanced %v, want >= %v", simNow.Sub(before), minAutoCampDwell) + } + + got, _ := getExpedition(exp.ID) + if got == nil { + t.Fatal("expedition missing after boss-safety pitch") + } + if got.Supplies.Current >= startSU { + t.Errorf("Supplies.Current = %v, want < %v (camp cost should have debited)", got.Supplies.Current, startSU) + } + c2, _ := LoadDnDCharacter(uid) + if c2 == nil || c2.HPCurrent <= startHP { + t.Errorf("HPCurrent = %v, want > %v (rest should heal)", c2.HPCurrent, startHP) + } + if got.Camp != nil && got.Camp.Active { + t.Errorf("camp should have been broken after dwell, got %+v", got.Camp) + } +} + +// No trigger → no pitch: healthy HP, fresh simNow, no region cleared. +// maybeAutoCamp should bail and applyAutoCamp returns (false, nil). +func TestSimRunner_ApplyAutoCamp_NoTriggerIsNoop(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@sim-applycamp-noop:example") + campTestCharacter(t, uid, 3) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + useEventAnchored(t, exp) + + sim := &SimRunner{P: &AdventurePlugin{}} + simNow := exp.StartDate.Add(time.Hour) + before := simNow + + night, err := sim.applyAutoCamp(exp, &simNow) + if err != nil { + t.Fatalf("applyAutoCamp: %v", err) + } + if night { + t.Error("expected no Night pitch from no-op call") + } + if !simNow.Equal(before) { + t.Errorf("simNow advanced %v on no-op call", simNow.Sub(before)) + } +} + // D7-a: SimRunner.TickDay must advance event-anchored expeditions. The // production deliverBriefing branch reads wall-clock time for its safety- // net check, so the sim path short-circuits to processNightCamp + a