mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user