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 }