mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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.
274 lines
8.8 KiB
Go
274 lines
8.8 KiB
Go
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
|
||
}
|