Files
gogobee/internal/plugin/expedition_autocamp.go
prosolis d76c63be0c Review follow-ups A + B: armed abilities survive the fight, supply pool serialized
A. An armed ability lasted one round of a turn-based fight.

buildZoneCombatants called applyArmedAbility, which applies an ability's mods
*and* clears ArmedAbility and saves the sheet. The turn engine calls that
builder again on every !attack / !cast / !consume, so round 1 fired the ability
and disarmed the character, and every later round rebuilt them with none of its
mods. A Berserker paid stamina for a single round of BerserkerRage /
RageMeleeDmg / PhysicalResistRage / FrenzyDmgBonus. Every entry in
dndActiveAbilities had the same shape. mods.BerserkerRage was not merely unread
at close-out — by then it no longer existed.

Split arming into its two halves:

  consumeArmedAbility(c)          mutates: disarms, saves, returns the id. Once,
                                  at fight start.
  applyAbilityByID(c, id, mods)   pure: no DB write, no disarm. Safe on every
                                  rebuild. (No ability's Apply writes to the
                                  character, so this really is pure.)
  armAbilityForFight(c, mods)     consume + apply, for the auto-resolve callers
                                  that build and fight in one breath.

buildZoneCombatants now takes the already-consumed id and re-applies it. The id
rides on ActorStatuses.ArmedAbility, seeded per seat at fight start, so
partyCombatantsForSession reproduces the ability every rebuild and the close-out
can still see that a rage fired.

The close-out itself: postCombatBookkeeping now carries grantCombatAchievements
+ persistDnDPostCombatSubclass, and all four close-outs route through it —
runDungeonCombat, runZoneCombatRoster, finishCombatSession,
finishPartyCombatSession. It fires on every terminal status, not just a win: a
Berserker who rages and loses is still exhausted, which is what auto-resolve
always did.

Also: buildFightSeats and runZoneCombatRoster consumed the ability before the
checks that could sit a seat out, so a downed member was disarmed for a fight
they never joined. The refusals now run first.

B. Six unlocked read-modify-writes against the shared supply pool.

updateSupplies rewrites supplies_json wholesale, so a caller folding its delta
onto an *Expedition it read earlier discards whatever landed in between.
Handlers run one goroutine per event, so those writers genuinely interleave.

All six now go through withExpeditionSupplies, which takes advExpeditionLock,
re-reads the row, hands the closure the fresh copy and persists what it returns:
nightRolloverBurn (forage + burn in one write), grantTwoWeeksCache,
advanceToNextRegion's transit burn, campPitch, pitchAutopilotCamp, and the
ambient pack-rat drain. expeditionCmdAccept's hand-rolled lock folds onto the
same helper. expedition_sim.go is left alone: single-threaded, takes no locks.

Known consequence, for the balance track: trySimAutoArm used to live inside the
rebuild, so a simulated Fighter (second_wind) or Cleric (healing_word) re-armed
and re-spent a resource every round of every elite/boss fight. expedition-sim
drives those through the turn engine, so every prior expedition-sim corpus
overstates those two classes. Re-baseline after this, not before.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:01:51 -07:00

441 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// nightCampWindow — D2-b. Event-anchored expeditions roll the day on
// autopilot night-camp pitch. After this many hours since the last
// rollover, the next eligible camp is treated as the night camp
// (Night=true) and triggers processNightCamp. 16h gives the player
// most of an active day before the engine starts wanting to bed
// down; a healthy party with no HP pressure still camps at the end
// of the day rather than walking forever.
nightCampWindow = 16 * time.Hour
)
// 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
// D2-b: event-anchored rollover inputs.
Now time.Time
EventAnchored bool
LastBriefingAt *time.Time
StartDate time.Time
}
// autoCampDecision — what to pitch and why. Reason is a short log-line
// fragment ("region boss cleared", "HP low"). Night=true means the
// caller should run processNightCamp as part of the pitch so the day++/
// supply burn / threat drift happen alongside the rest.
type autoCampDecision struct {
Kind string
Reason string
Night bool
}
// 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
}
}
// D2-b: night-camp window — for event-anchored expeditions, the
// autopilot pitches the day's rollover camp when enough real time
// has elapsed since the last rollover. A flag, not a separate
// branch, so each pitch below can become a night camp.
night := false
if in.EventAnchored {
var since time.Duration
if in.LastBriefingAt != nil {
since = in.Now.Sub(*in.LastBriefingAt)
} else {
since = in.Now.Sub(in.StartDate)
}
night = since >= nightCampWindow
}
// 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, Night: night,
Reason: "region boss cleared — pitching base camp waypoint",
}, true
}
}
// Heuristic — HP-low rest OR end-of-day night camp. Standard if
// cleared, rough otherwise. LowSU as a *trigger* would just dig
// the hole deeper; the walk's preflight pauses on low-SU instead.
lowHP := in.HPMax > 0 && float64(in.HPCur) <= float64(in.HPMax)*autoCampHPPct
if !lowHP && !night {
return autoCampDecision{}, false
}
kind := CampTypeRough
if cleared {
kind = CampTypeStandard
}
cost := campSupplyCost[kind]
if in.Supplies.Current < cost {
if kind == CampTypeStandard && in.Supplies.Current >= campSupplyCost[CampTypeRough] {
kind = CampTypeRough
} else {
return autoCampDecision{}, false
}
}
reason := "HP low — pitching rest camp"
switch {
case night && lowHP:
reason = "HP low + end of day — pitching night camp"
case night:
reason = "end of day — pitching night camp"
}
return autoCampDecision{Kind: kind, Reason: reason, Night: night}, 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), along with the decision and an ok flag.
// D4-a uses the Night bit on the decision to switch tryAutoRun's DM
// rendering into end-of-day digest mode.
//
// now drives the nightCampWindow check inside decideAutopilotCamp and
// the camp.EstablishedAt / drift timestamps inside pitchAutopilotCamp.
// Production callers pass time.Now().UTC(); the sim injects a synthetic
// clock so multi-day rollovers fire without real-time waits.
func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition, now time.Time) (string, autoCampDecision, bool) {
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,
Now: now,
EventAnchored: isEventAnchored(exp),
LastBriefingAt: exp.LastBriefingAt,
StartDate: exp.StartDate,
}
d, ok := decideAutopilotCamp(in)
if !ok {
return "", autoCampDecision{}, false
}
block, err := p.pitchAutopilotCamp(exp, d, now)
if err != nil {
slog.Warn("autopilot camp: pitch failed", "expedition", exp.ID, "kind", d.Kind, "err", err)
return "", autoCampDecision{}, false
}
return block, d, true
}
// 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. When d.Night is true
// (D2-b event-anchored rollover), the day++/burn/threat-drift fire here
// too: nightRolloverBurn before the camp cost (so the burn lands on
// pre-pitch supplies, matching the legacy morning-burn ordering), then
// applyCampRest, then nightRolloverDrift.
func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision, now time.Time) (string, error) {
var (
nightBurn float32
nightTemp []string
nightMile []string
nightDid bool
nightForce bool
)
if d.Night {
burn, err := p.nightRolloverBurn(exp)
if err != nil {
return "", err
}
nightBurn = burn
nightDid = true
if exp.Status != ExpeditionStatusActive {
// Starvation during burn is rare (burn alone doesn't trigger
// starvation — that needs supplies <= 0 *after* burn), but
// guard anyway so we don't pitch a camp on an abandoned exp.
drift := p.nightRolloverDrift(exp, now)
nightTemp = drift.TemporalLines
nightMile = drift.MilestoneLines
nightForce = true
return renderAutoCampBlock(exp, d, 0, "", "", nightBurn, nightTemp, nightMile, nightDid, nightForce), nil
}
}
cost := campSupplyCost[d.Kind]
if exp.Supplies.Current < cost {
return "", fmt.Errorf("insufficient supplies (have %.1f, need %.1f)", exp.Supplies.Current, cost)
}
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
next := fresh.Supplies
next.Current -= cost
return next, nil
})
if err != nil {
return "", err
}
exp.Supplies = pooled
camp := &CampState{
Active: true,
Type: d.Kind,
RoomIndex: campCurrentRoomIndex(exp),
EstablishedAt: now,
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)
}
}
}
if d.Night {
drift := p.nightRolloverDrift(exp, now)
nightTemp = drift.TemporalLines
nightMile = drift.MilestoneLines
}
return renderAutoCampBlock(exp, d, cost, line, restSummary,
nightBurn, nightTemp, nightMile, nightDid, nightForce), nil
}
// renderAutoCampBlock formats the autopilot-camp section appended to
// the auto-walk DM. Kept short — the autorun DM already opens with the
// walk narration. nightBurn/nightTemp/nightMile carry the D2-b rollover
// side effects when the pitch was a night camp.
func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flavorLine, restSummary string,
nightBurn float32, nightTemp []string, nightMile []string, nightDid bool, nightForce bool) string {
if nightForce {
out := fmt.Sprintf("\n\n🌙 **Day %d.** _Supplies burned (%.1f SU); the rollover fired but the camp couldn't pitch._\n", exp.CurrentDay, nightBurn)
for _, tl := range nightTemp {
out += "\n🌀 " + tl + "\n"
}
for _, ml := range nightMile {
out += "\n" + ml
}
return out
}
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**._"
}
if nightDid {
out += fmt.Sprintf("\n\n🌙 **Day %d.** _Overnight burn: %.1f SU._\n", exp.CurrentDay, nightBurn)
for _, tl := range nightTemp {
out += "\n🌀 " + tl + "\n"
}
for _, ml := range nightMile {
out += "\n" + ml
}
}
return out
}
// pitchBossSafetyCamp force-pitches a rest camp after the compact
// autopilot bailed out before the boss (stopBossSafety). Bypasses the
// decideAutopilotCamp HP threshold and its RoomBoss room-type block —
// the boss doorway is *exactly* where this camp belongs. Picks the best
// kind the supplies will pay for (Standard > Rough); returns "" if even
// Rough is too expensive (extract-soon territory). The returned block is
// appended to the autorun DM by the caller.
//
// Day-rollover handling mirrors decideAutopilotCamp: when enough real
// time has elapsed since the last briefing on an event-anchored
// expedition, the pitch carries Night=true and runs the burn/drift
// alongside the rest.
func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition, now time.Time) (string, autoCampDecision, bool) {
if exp == nil || exp.Status != ExpeditionStatusActive {
return "", autoCampDecision{}, false
}
if exp.Camp != nil && exp.Camp.Active {
// Already resting — nothing to pitch. The dwell window will pass
// and the next autorun tick will retry the boss engagement.
return "", autoCampDecision{}, false
}
kind := CampTypeStandard
if exp.Supplies.Current < campSupplyCost[kind] {
kind = CampTypeRough
}
if exp.Supplies.Current < campSupplyCost[kind] {
// No SU even for Rough — autopilot can't help here. The walk's
// preflight will surface low-SU on the next tick if the player
// doesn't extract.
return "", autoCampDecision{}, false
}
night := false
if isEventAnchored(exp) {
var since time.Duration
if exp.LastBriefingAt != nil {
since = now.Sub(*exp.LastBriefingAt)
} else {
since = now.Sub(exp.StartDate)
}
night = since >= nightCampWindow
}
d := autoCampDecision{
Kind: kind,
Reason: "boss-safety hold — resting before re-engaging",
Night: night,
}
block, err := p.pitchAutopilotCamp(exp, d, now)
if err != nil {
slog.Warn("autopilot boss-safety camp: pitch failed", "expedition", exp.ID, "kind", kind, "err", err)
return "", autoCampDecision{}, false
}
return block, d, true
}
// 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
}