mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Long expeditions D7-b: drive autopilot camp from SimRunner
maybeAutoCamp / pitchAutopilotCamp / pitchBossSafetyCamp now take a now time.Time so the sim can inject a synthetic clock; tryAutoRun still passes time.Now().UTC(). SimRunner.RunExpedition advances simNow by autoRunCooldown per walk and runs the production camp scheduler after each soft stop (and pitchBossSafetyCamp on stopBossSafety), dwelling minAutoCampDwell + breakAutoCampIfDue so the next walk can proceed. Effect: HP-low mid-day rests, base-camp waypoints, Night-camp rollovers, and boss-safety holds all fire under the sim; D7-a's tickEventAnchoredRollover shortcut is retained on TickDay for tests and the pre-cutoff legacy path.
This commit is contained in:
@@ -139,8 +139,10 @@ Each successful background walk now logs a `walk` entry (`appendExpeditionLog(..
|
|||||||
|
|
||||||
**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.
|
**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+):**
|
**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.
|
||||||
- `cmd/expedition-sim` extensions: multi-day mode CLI, autopilot camp, autopilot boss.
|
|
||||||
|
**Remaining work (D7-c+):**
|
||||||
|
- `cmd/expedition-sim` extensions: multi-day mode CLI flag (`-days` / per-day SU/HP/threat snapshots in SimResult).
|
||||||
- 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.
|
- D5-d retune of `DailyBurn` / `phase5BDailyBurnRatePct` against measured day-counts.
|
||||||
|
|||||||
@@ -157,7 +157,12 @@ func decideAutopilotCamp(in autoCampInputs) (autoCampDecision, bool) {
|
|||||||
// "" if no camp was pitched), along with the decision and an ok flag.
|
// "" if no camp was pitched), along with the decision and an ok flag.
|
||||||
// D4-a uses the Night bit on the decision to switch tryAutoRun's DM
|
// D4-a uses the Night bit on the decision to switch tryAutoRun's DM
|
||||||
// rendering into end-of-day digest mode.
|
// rendering into end-of-day digest mode.
|
||||||
func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecision, bool) {
|
//
|
||||||
|
// now drives the nightCampWindow check inside decideAutopilotCamp and
|
||||||
|
// the camp.EstablishedAt / drift timestamps inside pitchAutopilotCamp.
|
||||||
|
// Production callers pass time.Now().UTC(); the sim injects a synthetic
|
||||||
|
// clock so multi-day rollovers fire without real-time waits.
|
||||||
|
func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition, now time.Time) (string, autoCampDecision, bool) {
|
||||||
uid := id.UserID(exp.UserID)
|
uid := id.UserID(exp.UserID)
|
||||||
|
|
||||||
var run *DungeonRun
|
var run *DungeonRun
|
||||||
@@ -190,7 +195,7 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecisi
|
|||||||
HPCur: hpCur,
|
HPCur: hpCur,
|
||||||
HPMax: hpMax,
|
HPMax: hpMax,
|
||||||
Supplies: exp.Supplies,
|
Supplies: exp.Supplies,
|
||||||
Now: time.Now().UTC(),
|
Now: now,
|
||||||
EventAnchored: isEventAnchored(exp),
|
EventAnchored: isEventAnchored(exp),
|
||||||
LastBriefingAt: exp.LastBriefingAt,
|
LastBriefingAt: exp.LastBriefingAt,
|
||||||
StartDate: exp.StartDate,
|
StartDate: exp.StartDate,
|
||||||
@@ -199,7 +204,7 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecisi
|
|||||||
if !ok {
|
if !ok {
|
||||||
return "", autoCampDecision{}, false
|
return "", autoCampDecision{}, false
|
||||||
}
|
}
|
||||||
block, err := p.pitchAutopilotCamp(exp, d)
|
block, err := p.pitchAutopilotCamp(exp, d, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("autopilot camp: pitch failed", "expedition", exp.ID, "kind", d.Kind, "err", err)
|
slog.Warn("autopilot camp: pitch failed", "expedition", exp.ID, "kind", d.Kind, "err", err)
|
||||||
return "", autoCampDecision{}, false
|
return "", autoCampDecision{}, false
|
||||||
@@ -215,7 +220,7 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecisi
|
|||||||
// too: nightRolloverBurn before the camp cost (so the burn lands on
|
// too: nightRolloverBurn before the camp cost (so the burn lands on
|
||||||
// pre-pitch supplies, matching the legacy morning-burn ordering), then
|
// pre-pitch supplies, matching the legacy morning-burn ordering), then
|
||||||
// applyCampRest, then nightRolloverDrift.
|
// applyCampRest, then nightRolloverDrift.
|
||||||
func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision) (string, error) {
|
func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision, now time.Time) (string, error) {
|
||||||
var (
|
var (
|
||||||
nightBurn float32
|
nightBurn float32
|
||||||
nightTemp []string
|
nightTemp []string
|
||||||
@@ -234,7 +239,7 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision
|
|||||||
// Starvation during burn is rare (burn alone doesn't trigger
|
// Starvation during burn is rare (burn alone doesn't trigger
|
||||||
// starvation — that needs supplies <= 0 *after* burn), but
|
// starvation — that needs supplies <= 0 *after* burn), but
|
||||||
// guard anyway so we don't pitch a camp on an abandoned exp.
|
// guard anyway so we don't pitch a camp on an abandoned exp.
|
||||||
drift := p.nightRolloverDrift(exp, time.Now().UTC())
|
drift := p.nightRolloverDrift(exp, now)
|
||||||
nightTemp = drift.TemporalLines
|
nightTemp = drift.TemporalLines
|
||||||
nightMile = drift.MilestoneLines
|
nightMile = drift.MilestoneLines
|
||||||
nightForce = true
|
nightForce = true
|
||||||
@@ -253,7 +258,7 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision
|
|||||||
Active: true,
|
Active: true,
|
||||||
Type: d.Kind,
|
Type: d.Kind,
|
||||||
RoomIndex: campCurrentRoomIndex(exp),
|
RoomIndex: campCurrentRoomIndex(exp),
|
||||||
EstablishedAt: time.Now().UTC(),
|
EstablishedAt: now,
|
||||||
NightEvents: []string{},
|
NightEvents: []string{},
|
||||||
AutoPitched: true,
|
AutoPitched: true,
|
||||||
}
|
}
|
||||||
@@ -287,7 +292,7 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision
|
|||||||
}
|
}
|
||||||
|
|
||||||
if d.Night {
|
if d.Night {
|
||||||
drift := p.nightRolloverDrift(exp, time.Now().UTC())
|
drift := p.nightRolloverDrift(exp, now)
|
||||||
nightTemp = drift.TemporalLines
|
nightTemp = drift.TemporalLines
|
||||||
nightMile = drift.MilestoneLines
|
nightMile = drift.MilestoneLines
|
||||||
}
|
}
|
||||||
@@ -348,7 +353,7 @@ func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flav
|
|||||||
// time has elapsed since the last briefing on an event-anchored
|
// time has elapsed since the last briefing on an event-anchored
|
||||||
// expedition, the pitch carries Night=true and runs the burn/drift
|
// expedition, the pitch carries Night=true and runs the burn/drift
|
||||||
// alongside the rest.
|
// alongside the rest.
|
||||||
func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) (string, autoCampDecision, bool) {
|
func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition, now time.Time) (string, autoCampDecision, bool) {
|
||||||
if exp == nil || exp.Status != ExpeditionStatusActive {
|
if exp == nil || exp.Status != ExpeditionStatusActive {
|
||||||
return "", autoCampDecision{}, false
|
return "", autoCampDecision{}, false
|
||||||
}
|
}
|
||||||
@@ -371,9 +376,9 @@ func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) (string, autoCamp
|
|||||||
if isEventAnchored(exp) {
|
if isEventAnchored(exp) {
|
||||||
var since time.Duration
|
var since time.Duration
|
||||||
if exp.LastBriefingAt != nil {
|
if exp.LastBriefingAt != nil {
|
||||||
since = time.Since(*exp.LastBriefingAt)
|
since = now.Sub(*exp.LastBriefingAt)
|
||||||
} else {
|
} else {
|
||||||
since = time.Since(exp.StartDate)
|
since = now.Sub(exp.StartDate)
|
||||||
}
|
}
|
||||||
night = since >= nightCampWindow
|
night = since >= nightCampWindow
|
||||||
}
|
}
|
||||||
@@ -382,7 +387,7 @@ func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) (string, autoCamp
|
|||||||
Reason: "boss-safety hold — resting before re-engaging",
|
Reason: "boss-safety hold — resting before re-engaging",
|
||||||
Night: night,
|
Night: night,
|
||||||
}
|
}
|
||||||
block, err := p.pitchAutopilotCamp(exp, d)
|
block, err := p.pitchAutopilotCamp(exp, d, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("autopilot boss-safety camp: pitch failed", "expedition", exp.ID, "kind", kind, "err", err)
|
slog.Warn("autopilot boss-safety camp: pitch failed", "expedition", exp.ID, "kind", kind, "err", err)
|
||||||
return "", autoCampDecision{}, false
|
return "", autoCampDecision{}, false
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func TestPitchAutopilotCamp_DeductsSuppliesAndRestores(t *testing.T) {
|
|||||||
p := &AdventurePlugin{}
|
p := &AdventurePlugin{}
|
||||||
block, err := p.pitchAutopilotCamp(exp, autoCampDecision{
|
block, err := p.pitchAutopilotCamp(exp, autoCampDecision{
|
||||||
Kind: CampTypeStandard, Reason: "test pitch",
|
Kind: CampTypeStandard, Reason: "test pitch",
|
||||||
})
|
}, time.Now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -300,7 +300,7 @@ func TestPitchAutopilotCamp_NightRunsProcessNightCamp(t *testing.T) {
|
|||||||
p := &AdventurePlugin{}
|
p := &AdventurePlugin{}
|
||||||
_, err = p.pitchAutopilotCamp(exp, autoCampDecision{
|
_, err = p.pitchAutopilotCamp(exp, autoCampDecision{
|
||||||
Kind: CampTypeStandard, Reason: "night-camp test", Night: true,
|
Kind: CampTypeStandard, Reason: "night-camp test", Night: true,
|
||||||
})
|
}, time.Now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,13 +198,13 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
|
|||||||
// its RoomBoss block. Next tick past dwell retries the boss.
|
// its RoomBoss block. Next tick past dwell retries the boss.
|
||||||
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
|
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
|
||||||
fresh.Status == ExpeditionStatusActive {
|
fresh.Status == ExpeditionStatusActive {
|
||||||
campBlock, campDecision, campPitched = p.pitchBossSafetyCamp(fresh)
|
campBlock, campDecision, campPitched = p.pitchBossSafetyCamp(fresh, now)
|
||||||
}
|
}
|
||||||
} else if r.reason != stopEnded && r.reason != stopComplete &&
|
} else if r.reason != stopEnded && r.reason != stopComplete &&
|
||||||
r.reason != stopBlocked && r.reason != stopFork {
|
r.reason != stopBlocked && r.reason != stopFork {
|
||||||
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
|
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
|
||||||
fresh.Status == ExpeditionStatusActive {
|
fresh.Status == ExpeditionStatusActive {
|
||||||
campBlock, campDecision, campPitched = p.maybeAutoCamp(fresh)
|
campBlock, campDecision, campPitched = p.maybeAutoCamp(fresh, now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = autoCampBroken // hint reserved for downstream digest tweaks
|
_ = autoCampBroken // hint reserved for downstream digest tweaks
|
||||||
|
|||||||
@@ -350,10 +350,18 @@ type SimLogEntry struct {
|
|||||||
TS time.Time
|
TS time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// simWalkInterval — how much synthetic time each autopilot walk
|
||||||
|
// represents. Matches the production autorun cooldown so the
|
||||||
|
// nightCampWindow check inside decideAutopilotCamp lands on the same
|
||||||
|
// real-time cadence: ~8 walks ≈ 16h ≈ one Night-camp rollover.
|
||||||
|
const simWalkInterval = autoRunCooldown
|
||||||
|
|
||||||
// RunExpedition starts an expedition for uid in zoneID and loops the
|
// RunExpedition starts an expedition for uid in zoneID and loops the
|
||||||
// autopilot walk (compact mode, so elite rooms auto-resolve inline)
|
// autopilot walk (compact mode, so elite rooms auto-resolve inline)
|
||||||
// until a hard stop fires. Between walks it fast-forwards the day
|
// until a hard stop fires. Between walks it advances a synthetic clock
|
||||||
// cycle so multi-day expeditions complete without real-time waits.
|
// (simWalkInterval per walk) and calls into the same maybeAutoCamp /
|
||||||
|
// pitchBossSafetyCamp scheduler the production autorun ticker uses, so
|
||||||
|
// HP-low mid-day rests + Night-camp rollovers fire under the sim.
|
||||||
//
|
//
|
||||||
// walkCap bounds the number of autopilot bursts as a safety net. Each
|
// walkCap bounds the number of autopilot bursts as a safety net. Each
|
||||||
// burst walks up to autopilotRoomCap rooms.
|
// burst walks up to autopilotRoomCap rooms.
|
||||||
@@ -385,7 +393,13 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
|
|||||||
}
|
}
|
||||||
res.SUStart = exp.Supplies.Current
|
res.SUStart = exp.Supplies.Current
|
||||||
|
|
||||||
|
// Synthetic clock — anchored on the expedition's real start_date so
|
||||||
|
// nightCampWindow / nightSafetyNet comparisons against LastBriefingAt
|
||||||
|
// stay coherent with the rows the autopilot scheduler reads.
|
||||||
|
simNow := exp.StartDate
|
||||||
|
|
||||||
for i := 0; i < walkCap; i++ {
|
for i := 0; i < walkCap; i++ {
|
||||||
|
simNow = simNow.Add(simWalkInterval)
|
||||||
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true)
|
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true)
|
||||||
if walk.initErr != "" {
|
if walk.initErr != "" {
|
||||||
res.Outcome = "halted"
|
res.Outcome = "halted"
|
||||||
@@ -467,24 +481,53 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
|
|||||||
case stopBlocked:
|
case stopBlocked:
|
||||||
res.Outcome = "blocked"
|
res.Outcome = "blocked"
|
||||||
i = walkCap
|
i = walkCap
|
||||||
default:
|
case stopBossSafety:
|
||||||
// stopOK / stopPreflight / stopHarvestCombat — soft stops.
|
// Compact autopilot bailed before the boss (HP/SU gate). Mirror
|
||||||
// Fast-forward a day so the next walk has fresh supplies
|
// the production autorun: force-pitch a rest camp, dwell, then
|
||||||
// budgeted, HP that overnight camp may have healed, and
|
// break it so the next walk re-engages the boss.
|
||||||
// threat drift recorded.
|
fresh, _ := getActiveExpedition(uid)
|
||||||
if exp, _ = getActiveExpedition(uid); exp == nil {
|
if fresh == nil {
|
||||||
res.Outcome = "extracted"
|
res.Outcome = "extracted"
|
||||||
i = walkCap
|
i = walkCap
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err := s.TickDay(exp); err != nil {
|
advanced, err := s.applyAutoCampBossSafety(fresh, &simNow)
|
||||||
|
if err != nil {
|
||||||
res.Outcome = "halted"
|
res.Outcome = "halted"
|
||||||
res.StopCode = "tick:" + err.Error()
|
res.StopCode = "boss_safety_camp:" + err.Error()
|
||||||
i = walkCap
|
i = walkCap
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
res.DayTicks++
|
if advanced {
|
||||||
// TickDay may have force-extracted (starvation). Re-check.
|
res.DayTicks++
|
||||||
|
}
|
||||||
|
if exp, _ = getActiveExpedition(uid); exp == nil {
|
||||||
|
res.Outcome = "extracted"
|
||||||
|
i = walkCap
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// stopOK / stopPreflight / stopHarvestCombat — soft stops.
|
||||||
|
// Run the same camp scheduler the production autorun fires
|
||||||
|
// after every walk. With simNow past nightCampWindow the
|
||||||
|
// pitch is a Night camp and drives the day rollover; below
|
||||||
|
// that, an HP-low rest may fire mid-day.
|
||||||
|
fresh, _ := getActiveExpedition(uid)
|
||||||
|
if fresh == nil {
|
||||||
|
res.Outcome = "extracted"
|
||||||
|
i = walkCap
|
||||||
|
break
|
||||||
|
}
|
||||||
|
advanced, err := s.applyAutoCamp(fresh, &simNow)
|
||||||
|
if err != nil {
|
||||||
|
res.Outcome = "halted"
|
||||||
|
res.StopCode = "auto_camp:" + err.Error()
|
||||||
|
i = walkCap
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if advanced {
|
||||||
|
res.DayTicks++
|
||||||
|
}
|
||||||
|
// maybeAutoCamp's drift step may have force-extracted (starvation).
|
||||||
if exp, _ = getActiveExpedition(uid); exp == nil {
|
if exp, _ = getActiveExpedition(uid); exp == nil {
|
||||||
res.Outcome = "extracted"
|
res.Outcome = "extracted"
|
||||||
i = walkCap
|
i = walkCap
|
||||||
@@ -839,6 +882,47 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
|
|||||||
return cands[0].id
|
return cands[0].id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyAutoCamp drives the production camp scheduler under the sim's
|
||||||
|
// synthetic clock: call maybeAutoCamp with *simNow, then if a camp
|
||||||
|
// pitched, advance *simNow past minAutoCampDwell and break the camp so
|
||||||
|
// the next walk can proceed. Returns whether the pitch was a Night
|
||||||
|
// camp (i.e. a day rollover fired).
|
||||||
|
func (s *SimRunner) applyAutoCamp(exp *Expedition, simNow *time.Time) (bool, error) {
|
||||||
|
_, d, ok := s.P.maybeAutoCamp(exp, *simNow)
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return s.dwellAndBreakAutoCamp(exp, simNow, d.Night)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAutoCampBossSafety mirrors applyAutoCamp for the stopBossSafety
|
||||||
|
// gate — the camp is force-pitched even when the regular HP threshold
|
||||||
|
// hasn't tripped (decideAutopilotCamp also blocks pitches at boss
|
||||||
|
// rooms, which is exactly where this one belongs).
|
||||||
|
func (s *SimRunner) applyAutoCampBossSafety(exp *Expedition, simNow *time.Time) (bool, error) {
|
||||||
|
_, d, ok := s.P.pitchBossSafetyCamp(exp, *simNow)
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return s.dwellAndBreakAutoCamp(exp, simNow, d.Night)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dwellAndBreakAutoCamp advances *simNow past minAutoCampDwell and
|
||||||
|
// breaks the auto-pitched camp. Reloads exp from the DB first so the
|
||||||
|
// camp row reflects the just-applied pitch. Returns the night flag
|
||||||
|
// passed in (for the DayTicks counter).
|
||||||
|
func (s *SimRunner) dwellAndBreakAutoCamp(exp *Expedition, simNow *time.Time, night bool) (bool, error) {
|
||||||
|
*simNow = simNow.Add(minAutoCampDwell)
|
||||||
|
fresh, err := getExpedition(exp.ID)
|
||||||
|
if err != nil {
|
||||||
|
return night, err
|
||||||
|
}
|
||||||
|
if fresh != nil {
|
||||||
|
_ = breakAutoCampIfDue(fresh, *simNow)
|
||||||
|
}
|
||||||
|
return night, nil
|
||||||
|
}
|
||||||
|
|
||||||
// TickDay drives one synthetic day rollover for exp: 21:00 recap of
|
// TickDay drives one synthetic day rollover for exp: 21:00 recap of
|
||||||
// the current day, then 06:00 briefing of the next day. The briefing
|
// the current day, then 06:00 briefing of the next day. The briefing
|
||||||
// is what bumps current_day and applies supply burn / overnight camp /
|
// is what bumps current_day and applies supply burn / overnight camp /
|
||||||
@@ -849,13 +933,11 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
|
|||||||
// 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
|
// Event-anchored expeditions (D2-b) own the rollover inside the
|
||||||
// autopilot's night-camp pitch, which the sim doesn't drive on a
|
// autopilot's night-camp pitch — RunExpedition exercises that path via
|
||||||
// synthetic clock. To keep TickDay's contract ("one call = one day")
|
// applyAutoCamp; TickDay is retained for tests and the legacy
|
||||||
// we short-circuit: fire processNightCamp directly + apply a Standard
|
// non-event-anchored fallback. The event-anchored branch here short-
|
||||||
// rest if affordable (mirroring pitchAutopilotCamp with Night=true),
|
// circuits to processNightCamp so callers that DO invoke TickDay on a
|
||||||
// then stamp last_briefing_at at the synthetic threshold. deliverBriefing
|
// post-cutoff expedition still see one-call-one-day semantics.
|
||||||
// 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")
|
||||||
|
|||||||
@@ -7,6 +7,205 @@ import (
|
|||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// D7-b: applyAutoCamp drives maybeAutoCamp under the sim's synthetic
|
||||||
|
// clock. When simNow is past the nightCampWindow on an event-anchored
|
||||||
|
// expedition, the scheduler pitches a Night camp — current_day++,
|
||||||
|
// supplies burn, last_briefing_at stamped. The helper then advances
|
||||||
|
// simNow past minAutoCampDwell and breaks the camp so the next walk
|
||||||
|
// proceeds.
|
||||||
|
func TestSimRunner_ApplyAutoCamp_NightCampAdvancesDay(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@sim-applycamp-night:example")
|
||||||
|
campTestCharacter(t, uid, 3)
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, 3, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("startZoneRun: %v", err)
|
||||||
|
}
|
||||||
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
|
||||||
|
ExpeditionSupplies{Current: 20, Max: 20, DailyBurn: 2, HarshMod: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
useEventAnchored(t, exp)
|
||||||
|
|
||||||
|
sim := &SimRunner{P: &AdventurePlugin{}}
|
||||||
|
// Park simNow past the night-camp window so decideAutopilotCamp
|
||||||
|
// flags Night=true on a healthy character.
|
||||||
|
simNow := exp.StartDate.Add(nightCampWindow + time.Hour)
|
||||||
|
startDay := exp.CurrentDay
|
||||||
|
startSU := exp.Supplies.Current
|
||||||
|
before := simNow
|
||||||
|
|
||||||
|
night, err := sim.applyAutoCamp(exp, &simNow)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("applyAutoCamp: %v", err)
|
||||||
|
}
|
||||||
|
if !night {
|
||||||
|
t.Fatal("expected Night=true beyond nightCampWindow")
|
||||||
|
}
|
||||||
|
if simNow.Sub(before) < minAutoCampDwell {
|
||||||
|
t.Errorf("simNow advanced %v, want >= %v", simNow.Sub(before), minAutoCampDwell)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := getExpedition(exp.ID)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expedition missing after night camp")
|
||||||
|
}
|
||||||
|
if got.CurrentDay != startDay+1 {
|
||||||
|
t.Errorf("CurrentDay = %d, want %d", got.CurrentDay, startDay+1)
|
||||||
|
}
|
||||||
|
if got.Supplies.Current >= startSU {
|
||||||
|
t.Errorf("Supplies.Current = %v, want < %v (burn should have landed)", got.Supplies.Current, startSU)
|
||||||
|
}
|
||||||
|
if got.Camp != nil && got.Camp.Active {
|
||||||
|
t.Errorf("camp should have been broken after dwell, got %+v", got.Camp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HP-low mid-day rest: under nightCampWindow with a hurt character,
|
||||||
|
// maybeAutoCamp pitches a Standard camp (cleared room → standard) but
|
||||||
|
// doesn't advance the day. HP gets restored by applyCampRest; DayTicks
|
||||||
|
// stays untouched.
|
||||||
|
func TestSimRunner_ApplyAutoCamp_HPLowMidDayRest(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@sim-applycamp-hp:example")
|
||||||
|
campTestCharacter(t, uid, 3)
|
||||||
|
c, _ := LoadDnDCharacter(uid)
|
||||||
|
c.HPCurrent = 1 // far below autoCampHPPct (55%)
|
||||||
|
_ = SaveDnDCharacter(c)
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, 3, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("startZoneRun: %v", err)
|
||||||
|
}
|
||||||
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
|
||||||
|
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
useEventAnchored(t, exp)
|
||||||
|
|
||||||
|
sim := &SimRunner{P: &AdventurePlugin{}}
|
||||||
|
// simNow well inside the active-day window — no Night pitch.
|
||||||
|
simNow := exp.StartDate.Add(2 * time.Hour)
|
||||||
|
startDay := exp.CurrentDay
|
||||||
|
startHP := c.HPCurrent
|
||||||
|
|
||||||
|
night, err := sim.applyAutoCamp(exp, &simNow)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("applyAutoCamp: %v", err)
|
||||||
|
}
|
||||||
|
if night {
|
||||||
|
t.Error("expected non-Night pitch within active-day window")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := getExpedition(exp.ID)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expedition missing")
|
||||||
|
}
|
||||||
|
if got.CurrentDay != startDay {
|
||||||
|
t.Errorf("CurrentDay = %d, want %d (no day advance on HP rest)", got.CurrentDay, startDay)
|
||||||
|
}
|
||||||
|
c2, _ := LoadDnDCharacter(uid)
|
||||||
|
if c2 == nil || c2.HPCurrent <= startHP {
|
||||||
|
t.Errorf("HPCurrent = %v, want > %v (rest should heal)", c2.HPCurrent, startHP)
|
||||||
|
}
|
||||||
|
if got.Camp != nil && got.Camp.Active {
|
||||||
|
t.Errorf("camp should have been broken after dwell, got %+v", got.Camp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopBossSafety: pitchBossSafetyCamp force-pitches a camp regardless
|
||||||
|
// of the regular HP threshold or the boss-room block in decideAutopilotCamp.
|
||||||
|
// applyAutoCampBossSafety wraps that and runs the same dwell/break dance.
|
||||||
|
func TestSimRunner_ApplyAutoCampBossSafety_PitchesAndHeals(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@sim-bosssafety:example")
|
||||||
|
campTestCharacter(t, uid, 3)
|
||||||
|
c, _ := LoadDnDCharacter(uid)
|
||||||
|
// HP at 80% — above autoCampHPPct so maybeAutoCamp would NOT fire,
|
||||||
|
// but the boss-safety path should still pitch.
|
||||||
|
c.HPCurrent = (c.HPMax * 80) / 100
|
||||||
|
if c.HPCurrent < 1 {
|
||||||
|
c.HPCurrent = 1
|
||||||
|
}
|
||||||
|
_ = SaveDnDCharacter(c)
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, 3, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("startZoneRun: %v", err)
|
||||||
|
}
|
||||||
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
|
||||||
|
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
useEventAnchored(t, exp)
|
||||||
|
|
||||||
|
sim := &SimRunner{P: &AdventurePlugin{}}
|
||||||
|
simNow := exp.StartDate.Add(2 * time.Hour)
|
||||||
|
startSU := exp.Supplies.Current
|
||||||
|
startHP := c.HPCurrent
|
||||||
|
before := simNow
|
||||||
|
|
||||||
|
if _, err := sim.applyAutoCampBossSafety(exp, &simNow); err != nil {
|
||||||
|
t.Fatalf("applyAutoCampBossSafety: %v", err)
|
||||||
|
}
|
||||||
|
if simNow.Sub(before) < minAutoCampDwell {
|
||||||
|
t.Errorf("simNow advanced %v, want >= %v", simNow.Sub(before), minAutoCampDwell)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := getExpedition(exp.ID)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expedition missing after boss-safety pitch")
|
||||||
|
}
|
||||||
|
if got.Supplies.Current >= startSU {
|
||||||
|
t.Errorf("Supplies.Current = %v, want < %v (camp cost should have debited)", got.Supplies.Current, startSU)
|
||||||
|
}
|
||||||
|
c2, _ := LoadDnDCharacter(uid)
|
||||||
|
if c2 == nil || c2.HPCurrent <= startHP {
|
||||||
|
t.Errorf("HPCurrent = %v, want > %v (rest should heal)", c2.HPCurrent, startHP)
|
||||||
|
}
|
||||||
|
if got.Camp != nil && got.Camp.Active {
|
||||||
|
t.Errorf("camp should have been broken after dwell, got %+v", got.Camp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No trigger → no pitch: healthy HP, fresh simNow, no region cleared.
|
||||||
|
// maybeAutoCamp should bail and applyAutoCamp returns (false, nil).
|
||||||
|
func TestSimRunner_ApplyAutoCamp_NoTriggerIsNoop(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@sim-applycamp-noop:example")
|
||||||
|
campTestCharacter(t, uid, 3)
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||||
|
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
useEventAnchored(t, exp)
|
||||||
|
|
||||||
|
sim := &SimRunner{P: &AdventurePlugin{}}
|
||||||
|
simNow := exp.StartDate.Add(time.Hour)
|
||||||
|
before := simNow
|
||||||
|
|
||||||
|
night, err := sim.applyAutoCamp(exp, &simNow)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("applyAutoCamp: %v", err)
|
||||||
|
}
|
||||||
|
if night {
|
||||||
|
t.Error("expected no Night pitch from no-op call")
|
||||||
|
}
|
||||||
|
if !simNow.Equal(before) {
|
||||||
|
t.Errorf("simNow advanced %v on no-op call", simNow.Sub(before))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// D7-a: SimRunner.TickDay must advance event-anchored expeditions. The
|
// D7-a: SimRunner.TickDay must advance event-anchored expeditions. The
|
||||||
// production deliverBriefing branch reads wall-clock time for its safety-
|
// production deliverBriefing branch reads wall-clock time for its safety-
|
||||||
// net check, so the sim path short-circuits to processNightCamp + a
|
// net check, so the sim path short-circuits to processNightCamp + a
|
||||||
|
|||||||
Reference in New Issue
Block a user