Long expeditions D7-c: -days flag + per-day snapshots in SimResult

cmd/expedition-sim -days N caps runs by synthetic day rollovers
(Outcome="day_capped"). SimResult.DaySnapshots traces HP/SU/threat/rooms
at start, every Night-camp rollover, and end-of-run — unblocks empirical
D5-d retune of phase5BDailyBurnRatePct against per-day SU draws.
This commit is contained in:
prosolis
2026-05-27 20:47:28 -07:00
parent 29cad7972a
commit 3b29d10461
4 changed files with 135 additions and 12 deletions

View File

@@ -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) {

View File

@@ -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.

View File

@@ -282,9 +282,27 @@ type SimResult struct {
// without re-running the matrix. Populated from combat_sessions
// rows + their TurnLog at end-of-run.
Combats []SimCombatSummary
// 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
// per-round damage dealt by each side, and the outcome. Lets J-phase
// analysis ask "did Fighter L12 hit the manor boss often enough?"
@@ -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

View File

@@ -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)
}
}