mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Long expeditions D7-a: teach SimRunner.TickDay event-anchored rollover
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.
This commit is contained in:
@@ -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.
|
- 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
|
### 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-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.
|
- 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.
|
- New balance memory entry once corpus stabilizes.
|
||||||
|
|
||||||
## 4. Cross-cutting risks
|
## 4. Cross-cutting risks
|
||||||
|
|||||||
@@ -847,6 +847,15 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
|
|||||||
//
|
//
|
||||||
// The clock is anchored on exp.StartDate so repeat calls advance one
|
// 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.
|
// 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 {
|
func (s *SimRunner) TickDay(exp *Expedition) error {
|
||||||
if exp == nil {
|
if exp == nil {
|
||||||
return fmt.Errorf("nil expedition")
|
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)
|
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 {
|
if isEventAnchored(exp) {
|
||||||
return fmt.Errorf("deliverBriefing: %w", err)
|
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 {
|
if fresh, _ := getExpedition(exp.ID); fresh != nil {
|
||||||
*exp = *fresh
|
*exp = *fresh
|
||||||
@@ -877,6 +892,38 @@ func (s *SimRunner) TickDay(exp *Expedition) error {
|
|||||||
return nil
|
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
|
// simLogEntries returns every dnd_expedition_log row for expID, oldest
|
||||||
// first, projected into SimLogEntry.
|
// first, projected into SimLogEntry.
|
||||||
func simLogEntries(expID string) ([]SimLogEntry, error) {
|
func simLogEntries(expID string) ([]SimLogEntry, error) {
|
||||||
|
|||||||
96
internal/plugin/expedition_sim_test.go
Normal file
96
internal/plugin/expedition_sim_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user