From a2992ea06c98acc168c08379cdb547c0152a837b Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Wed, 27 May 2026 20:15:54 -0700 Subject: [PATCH] Long expeditions D7-a: teach SimRunner.TickDay event-anchored rollover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deliverBriefingEventAnchored reads time.Now().UTC() for its safety-net check, so synthetic TickDay calls never advanced CurrentDay on D2-b expeditions — DaysAtEnd / SUEnd stayed at start values. Short-circuit in TickDay: when isEventAnchored, fire nightRolloverBurn → optional applyCampRest (Standard, Rough fallback, skipped on low-SU) → nightRolloverDrift(briefAt). Mirrors pitchAutopilotCamp Night=true. Production paths untouched. Unblocks D5-d retune of phase5BDailyBurnRatePct and the class corpus re-run. --- gogobee_long_expedition_plan.md | 7 +- internal/plugin/expedition_sim.go | 51 +++++++++++++- internal/plugin/expedition_sim_test.go | 96 ++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 internal/plugin/expedition_sim_test.go diff --git a/gogobee_long_expedition_plan.md b/gogobee_long_expedition_plan.md index 048cb6a..f50183f 100644 --- a/gogobee_long_expedition_plan.md +++ b/gogobee_long_expedition_plan.md @@ -136,9 +136,14 @@ Each successful background walk now logs a `walk` entry (`appendExpeditionLog(.. - No new public command surface, no schema change, no test churn (no existing test asserted on the rewritten strings); full `go test ./...` green. ### D7 — Sim + class re-baseline -- `cmd/expedition-sim` extensions: multi-day mode, autopilot camp, autopilot boss. + +**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. - 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. - New balance memory entry once corpus stabilizes. ## 4. Cross-cutting risks diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 18decd5..9d0b2ce 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -847,6 +847,15 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string { // // The clock is anchored on exp.StartDate so repeat calls advance one // 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. func (s *SimRunner) TickDay(exp *Expedition) error { if exp == nil { return fmt.Errorf("nil expedition") @@ -868,8 +877,14 @@ func (s *SimRunner) TickDay(exp *Expedition) error { } briefAt := dayBase.AddDate(0, 0, 1).Add(time.Duration(expeditionBriefingHour) * time.Hour).Add(30 * time.Second) - if err := s.P.deliverBriefing(exp, briefAt); err != nil { - return fmt.Errorf("deliverBriefing: %w", err) + if isEventAnchored(exp) { + if err := s.tickEventAnchoredRollover(exp, briefAt); err != nil { + return err + } + } else { + if err := s.P.deliverBriefing(exp, briefAt); err != nil { + return fmt.Errorf("deliverBriefing: %w", err) + } } if fresh, _ := getExpedition(exp.ID); fresh != nil { *exp = *fresh @@ -877,6 +892,38 @@ func (s *SimRunner) TickDay(exp *Expedition) error { return nil } +// tickEventAnchoredRollover mirrors pitchAutopilotCamp with Night=true +// for the sim: burn → optional Standard rest (if supplies cover it) → +// drift → stamp last_briefing_at. No DM, no camp row left active (rest +// is applied and immediately cleared the way pitchAutopilotCamp does +// via applyCampRest + the next walk-tick break). On the no-rest branch +// we still want burn/drift so supplies drain — matches a stalled +// autopilot which D2-b's safety net force-fires via processNightCamp. +func (s *SimRunner) tickEventAnchoredRollover(exp *Expedition, briefAt time.Time) error { + burn, err := s.P.nightRolloverBurn(exp) + if err != nil { + return fmt.Errorf("nightRolloverBurn: %w", err) + } + // Pitch a Standard rest if affordable, else skip (autopilot would + // also bail in low-SU; the burn already landed). Rough is the + // fallback so the sim isn't stuck at zero healing on tight budgets. + kind := CampTypeStandard + if exp.Supplies.Current < campSupplyCost[kind] { + kind = CampTypeRough + } + if exp.Supplies.Current >= campSupplyCost[kind] { + exp.Supplies.Current -= campSupplyCost[kind] + if err := updateSupplies(exp.ID, exp.Supplies); err != nil { + return fmt.Errorf("updateSupplies: %w", err) + } + applyCampRest(exp, kind) + } + drift := s.P.nightRolloverDrift(exp, briefAt) + _ = burn + _ = drift + return nil +} + // simLogEntries returns every dnd_expedition_log row for expID, oldest // first, projected into SimLogEntry. func simLogEntries(expID string) ([]SimLogEntry, error) { diff --git a/internal/plugin/expedition_sim_test.go b/internal/plugin/expedition_sim_test.go new file mode 100644 index 0000000..888eb87 --- /dev/null +++ b/internal/plugin/expedition_sim_test.go @@ -0,0 +1,96 @@ +package plugin + +import ( + "testing" + "time" + + "maunium.net/go/mautrix/id" +) + +// 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 +// Standard rest. Without this fix, CurrentDay never increments under +// D2-b and supply-burn baselining is impossible. +func TestSimRunner_TickDay_EventAnchoredAdvancesDay(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@sim-tickday-evt:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 20, Max: 20, DailyBurn: 2, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + useEventAnchored(t, exp) + + sim := &SimRunner{P: &AdventurePlugin{}} + for i := 0; i < 3; i++ { + fresh, _ := getExpedition(exp.ID) + if fresh == nil { + t.Fatalf("expedition vanished after %d ticks", i) + } + if err := sim.TickDay(fresh); err != nil { + t.Fatalf("TickDay #%d: %v", i+1, err) + } + } + + got, _ := getExpedition(exp.ID) + if got == nil { + t.Fatal("expedition missing after ticks") + } + if got.CurrentDay != 4 { + t.Errorf("CurrentDay = %d, want 4 after 3 ticks (started at 1)", got.CurrentDay) + } + if got.Supplies.Current >= 20 { + t.Errorf("Supplies.Current = %v, want < 20 (burn should have landed)", got.Supplies.Current) + } + if got.LastBriefingAt == nil { + t.Fatal("LastBriefingAt nil — drift stamp didn't fire") + } + // Each tick stamps last_briefing_at at the synthetic briefAt + // (start_date + N days at 06:00:30). After 3 ticks the stamp should + // be at least 2 days past start_date. + if got.LastBriefingAt.Sub(exp.StartDate) < 48*time.Hour { + t.Errorf("LastBriefingAt %v not advanced enough vs start %v", + got.LastBriefingAt, exp.StartDate) + } +} + +// Low-SU branch: a tight supplies budget shouldn't crash the sim — the +// rest is skipped, but burn/drift still fire so the day advances. This +// mirrors prod where a stalled autopilot in a low-SU state has its +// rollover force-fired by the safety net without a rest. +func TestSimRunner_TickDay_EventAnchoredLowSupplies(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@sim-tickday-lowsu:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + // 0.5 SU is below Rough camp cost (1 SU) but above zero, so the + // rest branch is skipped while burn still proceeds. + ExpeditionSupplies{Current: 0.5, Max: 20, DailyBurn: 0.2, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + useEventAnchored(t, exp) + // Pin supplies low after start (startExpedition may reset them). + exp.Supplies.Current = 0.5 + if err := updateSupplies(exp.ID, exp.Supplies); err != nil { + t.Fatal(err) + } + + sim := &SimRunner{P: &AdventurePlugin{}} + if err := sim.TickDay(exp); err != nil { + t.Fatalf("TickDay: %v", err) + } + got, _ := getExpedition(exp.ID) + if got == nil { + // Forced extraction from starvation is acceptable — the day still + // advanced before extraction, which is what matters for the sim. + return + } + if got.CurrentDay < 2 { + t.Errorf("CurrentDay = %d, want >= 2", got.CurrentDay) + } +}