mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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:
@@ -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.
|
||||
|
||||
273
internal/plugin/expedition_autocamp.go
Normal file
273
internal/plugin/expedition_autocamp.go
Normal 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
|
||||
}
|
||||
248
internal/plugin/expedition_autocamp_test.go
Normal file
248
internal/plugin/expedition_autocamp_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user