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