Long expeditions D2-a: autopilot camp scheduler

New expedition_autocamp.go: pure decideAutopilotCamp + pitchAutopilotCamp
+ dwell-window lifecycle. Wired into tryAutoRun so the background ticker
pitches a rest camp on low HP and a base-camp waypoint on region-boss
clear; auto-pitched camps last minAutoCampDwell (4h) before the next tick
breaks them and walks. CampState.AutoPitched separates auto- vs player-
pitched camp lifetimes so a player !camp stays sticky.

Day-rollover semantics unchanged — still UTC-anchored; D2-b moves
day++/burn/threat-drift onto the camp-pitch event.
This commit is contained in:
prosolis
2026-05-27 18:23:26 -07:00
parent 42fb805ee0
commit 7115c536ef
5 changed files with 716 additions and 1 deletions

View File

@@ -57,6 +57,11 @@ type CampState struct {
// threat -5 etc.) have already been applied at pitch time. processOvernightCamp
// uses it to skip re-applying so the night cycle just breaks the camp.
RestApplied bool `json:"rest_applied,omitempty"`
// AutoPitched is set when the long-expedition autopilot pitched this
// camp. The autorun ticker breaks an auto-pitched camp itself after a
// minimum dwell so the walk can keep moving; player-pitched camps stay
// up until the player breaks them (or moves on).
AutoPitched bool `json:"auto_pitched,omitempty"`
}
// ThreatEvent — §8.4.

View File

@@ -0,0 +1,273 @@
package plugin
import (
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/flavor"
"maunium.net/go/mautrix/id"
)
// Long-expedition D2-a — autopilot camp scheduler.
//
// The autorun ticker (expedition_autorun.go) calls into here after the
// walk loop returns so the dungeon can pitch its own camp when the party
// is low on HP, or to drop a base-camp waypoint after a region clear.
// Day-rollover semantics are still UTC-anchored in D2-a; D2-b moves
// day++/burn into a night-camp helper this scheduler will eventually
// trigger.
//
// An auto-pitched camp is treated as a transient rest stop: the next
// autorun tick past minAutoCampDwell breaks it itself so the walk can
// continue. Player-pitched camps (`!camp`) never get broken by the
// scheduler — those are an explicit player intent and stay up until
// the player moves on or breaks them.
const (
// minAutoCampDwell — how long an auto-pitched camp stays up before
// the autorun ticker breaks it on its own. The autorun cooldown is
// 2h, so a dwell > 2h guarantees the camp spans at least one full
// tick of "the player is resting" before the walk resumes.
minAutoCampDwell = 4 * time.Hour
// autoCampHPPct — pitch a rest camp when current HP is at or below
// this fraction of max. Set above autopilotLowHPPct (0.30) so the
// scheduler camps *before* the walk-preflight gives up.
autoCampHPPct = 0.55
)
// autoCampInputs is the minimal snapshot decideAutopilotCamp needs.
// Pulled out so the decision is pure / cheap to test without DB setup.
type autoCampInputs struct {
Camp *CampState
Run *DungeonRun
Multi bool
RegionCleared bool
BaseSite bool
BaseAlready bool
HPCur, HPMax int
Supplies ExpeditionSupplies
}
// autoCampDecision — what to pitch and why. Reason is a short log-line
// fragment ("region boss cleared", "HP low").
type autoCampDecision struct {
Kind string
Reason string
}
// decideAutopilotCamp returns the camp to pitch (or ok=false). Pure;
// the executor does the DB work.
func decideAutopilotCamp(in autoCampInputs) (autoCampDecision, bool) {
if in.Camp != nil && in.Camp.Active {
return autoCampDecision{}, false
}
if in.Run == nil {
return autoCampDecision{}, false
}
rt := in.Run.CurrentRoomType()
if rt == RoomBoss || rt == RoomTrap {
return autoCampDecision{}, false
}
cleared := false
for _, idx := range in.Run.RoomsCleared {
if idx == in.Run.CurrentRoom {
cleared = true
break
}
}
// Heuristic — region base-camp waypoint. Eager: pitch once per
// eligible region after its boss is down. BaseAlready stops the
// next tick from re-pitching the same waypoint.
if in.Multi && in.RegionCleared && in.BaseSite && !in.BaseAlready && cleared {
if in.Supplies.Current >= campSupplyCost[CampTypeBase] {
return autoCampDecision{Kind: CampTypeBase, Reason: "region boss cleared — pitching base camp waypoint"}, true
}
}
// Heuristic — HP-low rest. Standard if cleared, rough otherwise.
// LowSU as a *trigger* would just dig the hole deeper (camps burn
// SU); the walk's preflight already pauses on low-SU so the
// player can extract or buy more time.
lowHP := in.HPMax > 0 && float64(in.HPCur) <= float64(in.HPMax)*autoCampHPPct
if !lowHP {
return autoCampDecision{}, false
}
kind := CampTypeRough
if cleared {
kind = CampTypeStandard
}
cost := campSupplyCost[kind]
if in.Supplies.Current < cost {
// Try the cheaper rough tier if standard doesn't fit.
if kind == CampTypeStandard && in.Supplies.Current >= campSupplyCost[CampTypeRough] {
kind = CampTypeRough
} else {
return autoCampDecision{}, false
}
}
return autoCampDecision{Kind: kind, Reason: "HP low — pitching rest camp"}, true
}
// maybeAutoCamp gathers DB state, calls decideAutopilotCamp, and pitches
// when the decision says to. Returns the player-facing camp block (or
// "" if no camp was pitched) so the autorun DM can include it.
func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) string {
uid := id.UserID(exp.UserID)
var run *DungeonRun
if exp.RunID != "" {
if r, err := getZoneRun(exp.RunID); err == nil {
run = r
}
}
multi := IsMultiRegionZone(exp.ZoneID)
regionCleared := false
baseSite := false
baseAlready := false
if multi {
if region, ok := CurrentRegion(exp); ok {
regionCleared = IsRegionCleared(exp, region.ID)
baseSite = region.BaseCampSite
baseAlready = HasBaseCampAt(exp, region.ID)
}
}
hpCur, hpMax := dndHPSnapshot(uid)
in := autoCampInputs{
Camp: exp.Camp,
Run: run,
Multi: multi,
RegionCleared: regionCleared,
BaseSite: baseSite,
BaseAlready: baseAlready,
HPCur: hpCur,
HPMax: hpMax,
Supplies: exp.Supplies,
}
d, ok := decideAutopilotCamp(in)
if !ok {
return ""
}
block, err := p.pitchAutopilotCamp(exp, d)
if err != nil {
slog.Warn("autopilot camp: pitch failed", "expedition", exp.ID, "kind", d.Kind, "err", err)
return ""
}
return block
}
// pitchAutopilotCamp performs the same state mutations as the player
// !camp path (supply debit, camp row, applyCampRest, log entry, base-
// camp waypoint persist) without DMing — the autorun ticker bundles
// the returned block into the single auto-walk DM.
func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision) (string, error) {
cost := campSupplyCost[d.Kind]
if exp.Supplies.Current < cost {
return "", fmt.Errorf("insufficient supplies (have %.1f, need %.1f)", exp.Supplies.Current, cost)
}
exp.Supplies.Current -= cost
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
return "", err
}
camp := &CampState{
Active: true,
Type: d.Kind,
RoomIndex: campCurrentRoomIndex(exp),
EstablishedAt: time.Now().UTC(),
NightEvents: []string{},
AutoPitched: true,
}
if err := updateCamp(exp.ID, camp); err != nil {
return "", err
}
exp.Camp = camp
restSummary := applyCampRest(exp, d.Kind)
camp.RestApplied = true
if err := updateCamp(exp.ID, camp); err != nil {
slog.Warn("autopilot camp: mark rest applied", "expedition", exp.ID, "err", err)
}
var line string
if d.Kind == CampTypeBase {
line = flavor.Pick(flavor.BaseCampEstablished)
line = strings.ReplaceAll(line, "[N]", fmt.Sprintf("%d", exp.CurrentDay))
} else {
line = flavor.Pick(flavor.CampEstablished)
}
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "rest",
fmt.Sprintf("autopilot camp pitched (%s) — %s — %.1f SU consumed", d.Kind, d.Reason, cost), line)
if d.Kind == CampTypeBase {
if region, ok := CurrentRegion(exp); ok {
if _, err := addRegionListEntry(exp, regionStateBaseCampKey, region.ID); err != nil {
slog.Warn("autopilot camp: persist base camp waypoint", "expedition", exp.ID, "err", err)
}
}
}
return renderAutoCampBlock(exp, d, cost, line, restSummary), nil
}
// renderAutoCampBlock formats the autopilot-camp section appended to
// the auto-walk DM. Kept short — the autorun DM already opens with the
// walk narration.
func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flavorLine, restSummary string) string {
out := fmt.Sprintf("\n\n⛺ **Autopilot camp — %s.** _%s_\n", d.Kind, d.Reason)
out += fmt.Sprintf("Supplies: %.1f / %.1f SU (%.1f).\n",
exp.Supplies.Current, exp.Supplies.Max, cost)
if flavorLine != "" {
out += "\n" + flavorLine + "\n"
}
if restSummary != "" {
out += "\n" + restSummary + "\n"
}
if d.Kind == CampTypeBase {
out += "\n_Base camp — **waypoint persisted**._"
}
return out
}
// breakAutoCampIfDue tears down an auto-pitched camp once it has dwelled
// for minAutoCampDwell. Returns true when it broke a camp (so the
// caller knows the walk should proceed this tick). Player-pitched camps
// are left untouched.
func breakAutoCampIfDue(exp *Expedition, now time.Time) bool {
if exp.Camp == nil || !exp.Camp.Active || !exp.Camp.AutoPitched {
return false
}
if now.Sub(exp.Camp.EstablishedAt) < minAutoCampDwell {
return false
}
if err := updateCamp(exp.ID, nil); err != nil {
slog.Warn("autopilot camp: break failed", "expedition", exp.ID, "err", err)
return false
}
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
"autopilot broke camp (dwell elapsed)", "")
exp.Camp = nil
return true
}
// shouldSkipAutoRunForCamp reports whether the autorun ticker should
// skip this expedition entirely because an auto- or player-pitched
// camp is still within its dwell window. Returns true when the ticker
// should bail before walking.
func shouldSkipAutoRunForCamp(exp *Expedition, now time.Time) bool {
if exp.Camp == nil || !exp.Camp.Active {
return false
}
// Player camps are sticky — never walked through by autopilot until
// the player explicitly breaks or moves.
if !exp.Camp.AutoPitched {
return true
}
// Auto-pitched camps: skip while still in dwell; the next tick past
// the window will break + walk in breakAutoCampIfDue.
return now.Sub(exp.Camp.EstablishedAt) < minAutoCampDwell
}

View File

@@ -0,0 +1,248 @@
package plugin
import (
"testing"
"time"
"maunium.net/go/mautrix/id"
)
func TestDecideAutopilotCamp_SkipsWhenCamped(t *testing.T) {
in := autoCampInputs{
Camp: &CampState{Active: true, Type: CampTypeRough},
Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}},
HPCur: 1, HPMax: 20, // very low — would otherwise camp
Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1},
}
if _, ok := decideAutopilotCamp(in); ok {
t.Errorf("expected no camp when already camped")
}
}
func TestDecideAutopilotCamp_SkipsHealthyHP(t *testing.T) {
in := autoCampInputs{
Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}},
HPCur: 20, HPMax: 20,
Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1},
}
if _, ok := decideAutopilotCamp(in); ok {
t.Errorf("expected no camp when HP full")
}
}
func TestDecideAutopilotCamp_LowHPClearedPitchesStandard(t *testing.T) {
in := autoCampInputs{
Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}},
HPCur: 5, HPMax: 20, // 25% — below 55% threshold
Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1},
}
d, ok := decideAutopilotCamp(in)
if !ok || d.Kind != CampTypeStandard {
t.Errorf("expected standard camp, got %+v ok=%v", d, ok)
}
}
func TestDecideAutopilotCamp_LowHPUnclearedPitchesRough(t *testing.T) {
in := autoCampInputs{
Run: &DungeonRun{CurrentRoom: 2, RoomsCleared: []int{0, 1}},
HPCur: 5, HPMax: 20,
Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1},
}
d, ok := decideAutopilotCamp(in)
if !ok || d.Kind != CampTypeRough {
t.Errorf("expected rough camp, got %+v ok=%v", d, ok)
}
}
func TestDecideAutopilotCamp_DowngradesWhenSuppliesTight(t *testing.T) {
in := autoCampInputs{
Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}},
HPCur: 5, HPMax: 20,
Supplies: ExpeditionSupplies{Current: 0.6, Max: 5, DailyBurn: 1}, // can't afford 1.0
}
d, ok := decideAutopilotCamp(in)
if !ok || d.Kind != CampTypeRough {
t.Errorf("expected downgrade to rough, got %+v ok=%v", d, ok)
}
}
func TestDecideAutopilotCamp_SkipsBossRoom(t *testing.T) {
in := autoCampInputs{
Run: &DungeonRun{
CurrentRoom: 0, RoomsCleared: []int{},
RoomSeq: []RoomType{RoomBoss},
},
HPCur: 1, HPMax: 20,
}
if _, ok := decideAutopilotCamp(in); ok {
t.Errorf("expected no camp in boss room")
}
}
func TestDecideAutopilotCamp_BaseCampWhenRegionCleared(t *testing.T) {
in := autoCampInputs{
Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}},
Multi: true,
RegionCleared: true,
BaseSite: true,
BaseAlready: false,
HPCur: 20, HPMax: 20, // healthy — still pitch base waypoint
Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1},
}
d, ok := decideAutopilotCamp(in)
if !ok || d.Kind != CampTypeBase {
t.Errorf("expected base camp, got %+v ok=%v", d, ok)
}
}
func TestDecideAutopilotCamp_BaseCampSkippedIfAlreadyPersisted(t *testing.T) {
in := autoCampInputs{
Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}},
Multi: true,
RegionCleared: true,
BaseSite: true,
BaseAlready: true,
HPCur: 20, HPMax: 20,
Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1},
}
if _, ok := decideAutopilotCamp(in); ok {
t.Errorf("expected no second base-camp pitch")
}
}
func TestShouldSkipAutoRunForCamp(t *testing.T) {
now := time.Now().UTC()
stickyPlayerCamp := &Expedition{Camp: &CampState{
Active: true, Type: CampTypeStandard, AutoPitched: false,
EstablishedAt: now.Add(-10 * time.Hour),
}}
if !shouldSkipAutoRunForCamp(stickyPlayerCamp, now) {
t.Error("player camp should always block autorun")
}
freshAuto := &Expedition{Camp: &CampState{
Active: true, Type: CampTypeStandard, AutoPitched: true,
EstablishedAt: now.Add(-1 * time.Hour),
}}
if !shouldSkipAutoRunForCamp(freshAuto, now) {
t.Error("auto camp within dwell window should skip")
}
staleAuto := &Expedition{Camp: &CampState{
Active: true, Type: CampTypeStandard, AutoPitched: true,
EstablishedAt: now.Add(-(minAutoCampDwell + time.Minute)),
}}
if shouldSkipAutoRunForCamp(staleAuto, now) {
t.Error("auto camp past dwell should let walk proceed")
}
none := &Expedition{}
if shouldSkipAutoRunForCamp(none, now) {
t.Error("no camp should not skip")
}
}
func TestPitchAutopilotCamp_DeductsSuppliesAndRestores(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@autocamp-pitch:example")
campTestCharacter(t, uid, 1)
// Damage the character so the standard rest can heal them.
c, _ := LoadDnDCharacter(uid)
c.HPCurrent = 4
_ = SaveDnDCharacter(c)
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
block, err := p.pitchAutopilotCamp(exp, autoCampDecision{
Kind: CampTypeStandard, Reason: "test pitch",
})
if err != nil {
t.Fatal(err)
}
if block == "" {
t.Error("expected non-empty camp block")
}
fresh, _ := getActiveExpedition(uid)
if fresh.Camp == nil || !fresh.Camp.Active {
t.Fatal("expected camp pitched")
}
if !fresh.Camp.AutoPitched {
t.Error("expected AutoPitched flag set")
}
if !fresh.Camp.RestApplied {
t.Error("expected RestApplied flag set")
}
if fresh.Supplies.Current != 4.0 {
t.Errorf("supplies = %v, want 4.0 after standard pitch", fresh.Supplies.Current)
}
cc, _ := LoadDnDCharacter(uid)
if cc.HPCurrent != cc.HPMax {
t.Errorf("HP = %d/%d, want full after standard rest", cc.HPCurrent, cc.HPMax)
}
}
func TestBreakAutoCampIfDue_RespectsDwellWindow(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@autocamp-break:example")
campTestCharacter(t, uid, 1)
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
exp.Camp = &CampState{
Active: true, Type: CampTypeRough, AutoPitched: true,
EstablishedAt: now.Add(-1 * time.Hour),
}
if err := updateCamp(exp.ID, exp.Camp); err != nil {
t.Fatal(err)
}
if breakAutoCampIfDue(exp, now) {
t.Error("camp inside dwell window should not be broken")
}
exp.Camp.EstablishedAt = now.Add(-(minAutoCampDwell + time.Minute))
if err := updateCamp(exp.ID, exp.Camp); err != nil {
t.Fatal(err)
}
if !breakAutoCampIfDue(exp, now) {
t.Error("camp past dwell window should be broken")
}
fresh, _ := getActiveExpedition(uid)
if fresh.Camp != nil && fresh.Camp.Active {
t.Errorf("expected camp cleared, got %+v", fresh.Camp)
}
}
func TestBreakAutoCampIfDue_LeavesPlayerCampAlone(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@autocamp-playercamp:example")
campTestCharacter(t, uid, 1)
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
exp.Camp = &CampState{
Active: true, Type: CampTypeStandard, AutoPitched: false,
EstablishedAt: now.Add(-10 * time.Hour),
}
if err := updateCamp(exp.ID, exp.Camp); err != nil {
t.Fatal(err)
}
if breakAutoCampIfDue(exp, now) {
t.Error("player camp must not be auto-broken")
}
}

View File

@@ -141,6 +141,15 @@ func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error)
// supplies/threat/run-graph state are mutated by the walk itself, just
// as they would be in a foreground !expedition run.
func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
// Long-expedition D2-a — camp dwell gate. A camp (player or auto)
// still inside its dwell window means the party is resting; skip
// the walk entirely. An auto-pitched camp past dwell gets broken
// here so this tick can proceed.
if shouldSkipAutoRunForCamp(e, now) {
return nil
}
autoCampBroken := breakAutoCampIfDue(e, now)
cutoff := now.Add(-autoRunCooldown)
res, err := db.Get().Exec(`
UPDATE dnd_expedition
@@ -163,11 +172,33 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
// "no expedition" / "no run" — race with abandon/extract. Silent.
return nil
}
// Long-expedition D2-a — post-walk camp scheduler. After the walk
// settles, see if the autopilot should pitch a rest camp (HP low)
// or a base-camp waypoint (region boss just cleared). The walk's
// own preflight handles low-SU pauses; the scheduler stays out of
// fork/combat/death/complete branches by checking r.reason.
campBlock := ""
if r.reason != stopEnded && r.reason != stopComplete &&
r.reason != stopBlocked && r.reason != stopFork {
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
fresh.Status == ExpeditionStatusActive {
campBlock = p.maybeAutoCamp(fresh)
}
}
if campBlock != "" {
r.finalMsg += campBlock
}
_ = autoCampBroken // reserved for D2-b end-of-day DM bundling
// Emergence seam: a run-complete reached by the background ticker is
// still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge.
// Deferred until after the run-summary DM below so the "animal in your
// house" prompt lands after the summary, not ahead of it.
if shouldDMAutoRun(r) {
//
// DM rule: surface anytime the regular suppression says to, OR
// whenever the autopilot pitched a camp this tick — a camp event
// is always worth a DM, even if the walk itself was quiet.
if shouldDMAutoRun(r) || campBlock != "" {
body := renderAutoRunDM(r)
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: autorun DM", "user", uid, "err", err)