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
This commit is contained in:
prosolis
2026-07-10 08:01:51 -07:00
parent 1f211564d9
commit d76c63be0c
20 changed files with 657 additions and 129 deletions

View File

@@ -178,21 +178,13 @@ func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacte
if isHol, _ := isHolidayToday(); isHol {
suppliesPurchase.StandardPacks++
}
// updateSupplies overwrites supplies_json wholesale, so the pool has to be
// re-read under the expedition's own lock: `exp` was fetched before the coin
// debit, and a second invitee accepting — or the leader's day-burn tick —
// may have rewritten the row since. Folding onto that stale snapshot would
// silently drop their packs or resurrect spent SU.
expMu := p.advExpeditionLock(exp.ID)
expMu.Lock()
fresh, err := getExpedition(exp.ID)
if err != nil || fresh == nil {
expMu.Unlock()
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool.")
}
pooled := addSupplyPurchase(fresh.Supplies, suppliesPurchase)
err = updateSupplies(exp.ID, pooled)
expMu.Unlock()
// `exp` was fetched before the coin debit, and a second invitee accepting —
// or the leader's day-burn tick — may have rewritten the row since. Folding
// onto that stale snapshot would silently drop their packs or resurrect
// spent SU, so the fold happens against a fresh read under the pool's lock.
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
return addSupplyPurchase(fresh.Supplies, suppliesPurchase), nil
})
if err != nil {
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool: "+err.Error())
}