mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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.
344 lines
11 KiB
Go
344 lines
11 KiB
Go
package plugin
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"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
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|