diff --git a/cmd/expedition-sim/main.go b/cmd/expedition-sim/main.go index 78a56e8..f5eae29 100644 --- a/cmd/expedition-sim/main.go +++ b/cmd/expedition-sim/main.go @@ -32,6 +32,7 @@ func main() { zone = flag.String("zone", "goblin_warrens", "zone id (single-run mode)") bank = flag.Float64("bank", 1000, "starting coin balance — must cover outfitting") cap = flag.Int("cap", 50, "max autopilot bursts per expedition (each = up to autopilotRoomCap rooms)") + days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)") dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)") userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)") logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)") @@ -65,14 +66,14 @@ func main() { includeLog = *logFlag } }) - runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, includeLog) + runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog) return } - runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *logFlag) + runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag) } -func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap int, includeLog bool) { +func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool) { dir := dataDir if dir == "" { var err error @@ -83,7 +84,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa defer os.RemoveAll(dir) } - res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap) + res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days) if err != nil { if res != nil { if !includeLog { @@ -99,7 +100,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa emitIndented(res) } -func runMatrix(classes, levels, zones string, runs int, bank float64, cap int, includeLog bool) { +func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool) { cs := splitNonEmpty(classes) ls := parseLevels(levels) zs := splitNonEmpty(zones) @@ -116,7 +117,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap int, i fail("mkdir temp:", err) } uid := id.UserID(fmt.Sprintf("@sim:%s-l%d-%s-%d", c, lv, z, r)) - res, runErr := runOne(dir, uid, plugin.DnDClass(c), lv, plugin.ZoneID(z), bank, cap) + res, runErr := runOne(dir, uid, plugin.DnDClass(c), lv, plugin.ZoneID(z), bank, cap, days) if res != nil && !includeLog { res.Log = nil } @@ -139,7 +140,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap int, i } } -func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap int) (*plugin.SimResult, error) { +func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int) (*plugin.SimResult, error) { runner, err := plugin.NewSimRunner(dataDir) if err != nil { return nil, fmt.Errorf("init runner: %w", err) @@ -150,7 +151,7 @@ func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zon return nil, fmt.Errorf("build character: %w", err) } runner.Euro.Credit(uid, bank, "expedition-sim bankroll") - return runner.RunExpedition(uid, zone, cap) + return runner.RunExpedition(uid, zone, cap, days) } func emitIndented(res *plugin.SimResult) { diff --git a/gogobee_long_expedition_plan.md b/gogobee_long_expedition_plan.md index 5c988ea..19b3b4b 100644 --- a/gogobee_long_expedition_plan.md +++ b/gogobee_long_expedition_plan.md @@ -141,8 +141,9 @@ Each successful background walk now logs a `walk` entry (`appendExpeditionLog(.. **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). +**D7-c (shipped 2026-05-27):** `cmd/expedition-sim` gained a `-days N` flag — when set, `RunExpedition` exits with `Outcome="day_capped"` after the Nth synthetic day rollover; 0 (default) is unbounded and only the existing `-cap` walk safety net applies. `SimResult` gained `DaySnapshots []SimDaySnapshot` ({Day, HPCurrent, HPMax, Supplies, Threat, Rooms}), captured at expedition start (Day 0), after every `applyAutoCamp`/`applyAutoCampBossSafety` Night-camp rollover, and one final end-of-run entry (reads `getExpedition(mostRecentExpeditionID(...))` when the row already extracted so closed runs still close their trajectory). `RunExpedition` signature picked up the `maxDays int` parameter; sole caller is the CLI. Coverage: `TestSimRunner_CaptureDaySnapshot_PopulatesFields` exercises the helper in isolation (HP from the live character row, SU/threat/day from the live expedition, Rooms from `res.Rooms`). Unblocks empirical D5-d retune of `phase5BDailyBurnRatePct` against per-day SU draws. + +**Remaining work (D7-d+):** - 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_sim.go b/internal/plugin/expedition_sim.go index 6f8b109..56e3e3d 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -282,7 +282,25 @@ type SimResult struct { // without re-running the matrix. Populated from combat_sessions // rows + their TurnLog at end-of-run. Combats []SimCombatSummary - Log []SimLogEntry + // DaySnapshots traces HP/SU/threat/rooms at every day rollover + // (Night camp) plus the start (Day 0) and the final state. Used by + // D7-c long-expedition baselining to see how the trajectory bends + // across multi-day runs without scrubbing the log. + DaySnapshots []SimDaySnapshot + Log []SimLogEntry +} + +// SimDaySnapshot is a point-in-time projection of the sim state at a +// day-rollover boundary. Day 0 is captured at expedition start; every +// subsequent entry lands right after a Night camp lands (CurrentDay +// already incremented). A final entry is appended at end-of-run. +type SimDaySnapshot struct { + Day int + HPCurrent int + HPMax int + Supplies float32 + Threat int + Rooms int // cumulative autopilot rooms walked at snapshot time } // SimCombatSummary is a compact per-fight trace: the entry stats, the @@ -366,9 +384,14 @@ const simWalkInterval = autoRunCooldown // walkCap bounds the number of autopilot bursts as a safety net. Each // burst walks up to autopilotRoomCap rooms. // +// maxDays, when > 0, stops the run once res.DayTicks reaches that count +// — used by D7-c long-expedition baselining to bound multi-day runs by +// synthetic day count rather than walk count. 0 leaves the run +// unbounded by days (the walkCap safety net still applies). +// // Pre-state: uid must own a synthetic character via BuildCharacter and // have a coin balance sufficient for outfitting (caller's responsibility). -func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*SimResult, error) { +func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays int) (*SimResult, error) { c, err := LoadDnDCharacter(uid) if err != nil || c == nil { return nil, fmt.Errorf("LoadDnDCharacter: %w", err) @@ -392,6 +415,9 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S return res, fmt.Errorf("expedition did not persist after start") } res.SUStart = exp.Supplies.Current + // Day-0 baseline so the snapshot stream always opens with a known + // starting state, even if the run halts before the first rollover. + s.captureDaySnapshot(res, exp, uid) // Synthetic clock — anchored on the expedition's real start_date so // nightCampWindow / nightSafetyNet comparisons against LastBriefingAt @@ -500,11 +526,20 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S } if advanced { res.DayTicks++ + if fresh2, _ := getActiveExpedition(uid); fresh2 != nil { + s.captureDaySnapshot(res, fresh2, uid) + } } if exp, _ = getActiveExpedition(uid); exp == nil { res.Outcome = "extracted" i = walkCap } + if maxDays > 0 && res.DayTicks >= maxDays { + if res.Outcome == "" { + res.Outcome = "day_capped" + } + i = walkCap + } default: // stopOK / stopPreflight / stopHarvestCombat — soft stops. // Run the same camp scheduler the production autorun fires @@ -526,12 +561,21 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S } if advanced { res.DayTicks++ + if fresh2, _ := getActiveExpedition(uid); fresh2 != nil { + s.captureDaySnapshot(res, fresh2, uid) + } } // maybeAutoCamp's drift step may have force-extracted (starvation). if exp, _ = getActiveExpedition(uid); exp == nil { res.Outcome = "extracted" i = walkCap } + if maxDays > 0 && res.DayTicks >= maxDays { + if res.Outcome == "" { + res.Outcome = "day_capped" + } + i = walkCap + } } } if res.Outcome == "" { @@ -557,9 +601,38 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S } res.YieldCount, res.YieldsByName = simMaterialYields(uid) res.Combats = simCombatSummaries(uid) + // Final snapshot. Re-read the expedition so closed-run state is + // visible (extracted runs return nil from getActiveExpedition; the + // row is still on disk via mostRecentExpeditionID). If the + // expedition row is gone we synthesize from the cached SU/threat + // already on res so the snapshot stream always closes. + if exp2, _ := getActiveExpedition(uid); exp2 != nil { + s.captureDaySnapshot(res, exp2, uid) + } else if past := mostRecentExpeditionID(uid); past != "" { + if exp3, _ := getExpedition(past); exp3 != nil { + s.captureDaySnapshot(res, exp3, uid) + } + } return res, nil } +// captureDaySnapshot appends a SimDaySnapshot reflecting current state. +// HP is read from the live character row; SU/threat/day from the live +// expedition. Rooms is the running res.Rooms counter. +func (s *SimRunner) captureDaySnapshot(res *SimResult, exp *Expedition, uid id.UserID) { + snap := SimDaySnapshot{ + Day: exp.CurrentDay, + Supplies: exp.Supplies.Current, + Threat: exp.ThreatLevel, + Rooms: res.Rooms, + } + if c, _ := LoadDnDCharacter(uid); c != nil { + snap.HPCurrent = c.HPCurrent + snap.HPMax = c.HPMax + } + res.DaySnapshots = append(res.DaySnapshots, snap) +} + // simCombatSummaries pulls every combat_sessions row for uid and folds // its TurnLog into a SimCombatSummary. AC values are inferred from the // RollAgainst column on attack events (the engine writes the defender's diff --git a/internal/plugin/expedition_sim_test.go b/internal/plugin/expedition_sim_test.go index d229207..960abc9 100644 --- a/internal/plugin/expedition_sim_test.go +++ b/internal/plugin/expedition_sim_test.go @@ -293,3 +293,51 @@ func TestSimRunner_TickDay_EventAnchoredLowSupplies(t *testing.T) { t.Errorf("CurrentDay = %d, want >= 2", got.CurrentDay) } } + +// D7-c: captureDaySnapshot must record the current expedition's day, +// supplies, and threat plus the character's live HP into res.DaySnapshots. +// Used by RunExpedition to trace state at every day rollover for +// long-expedition baselining; verified here in isolation since +// RunExpedition end-to-end isn't covered by package tests. +func TestSimRunner_CaptureDaySnapshot_PopulatesFields(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@sim-snap:example") + campTestCharacter(t, uid, 3) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 12, Max: 20, DailyBurn: 2, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + exp.CurrentDay = 3 + exp.ThreatLevel = 7 + if err := updateSupplies(exp.ID, exp.Supplies); err != nil { + t.Fatal(err) + } + c, _ := LoadDnDCharacter(uid) + + sim := &SimRunner{P: &AdventurePlugin{}} + res := &SimResult{Rooms: 9} + sim.captureDaySnapshot(res, exp, uid) + + if len(res.DaySnapshots) != 1 { + t.Fatalf("DaySnapshots len = %d, want 1", len(res.DaySnapshots)) + } + snap := res.DaySnapshots[0] + if snap.Day != 3 { + t.Errorf("Day = %d, want 3", snap.Day) + } + if snap.Supplies != 12 { + t.Errorf("Supplies = %v, want 12", snap.Supplies) + } + if snap.Threat != 7 { + t.Errorf("Threat = %d, want 7", snap.Threat) + } + if snap.Rooms != 9 { + t.Errorf("Rooms = %d, want 9", snap.Rooms) + } + if snap.HPCurrent != c.HPCurrent || snap.HPMax != c.HPMax { + t.Errorf("HP = %d/%d, want %d/%d", snap.HPCurrent, snap.HPMax, c.HPCurrent, c.HPMax) + } +}