mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +00:00
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:
@@ -321,6 +321,42 @@ func updateSupplies(expID string, s ExpeditionSupplies) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// withExpeditionSupplies serializes one read-modify-write of the shared supply
|
||||
// pool. updateSupplies rewrites supplies_json wholesale, so a caller that folds
|
||||
// its delta onto an *Expedition it read earlier silently discards anything that
|
||||
// landed in between — a member's pooled packs, another writer's spend. Handlers
|
||||
// run one goroutine per event, so those writers genuinely interleave.
|
||||
//
|
||||
// fn is handed a freshly-read expedition under the pool's own lock and returns
|
||||
// the supplies to persist. Callers that keep using their own *Expedition
|
||||
// afterwards must copy the returned pool back onto it.
|
||||
//
|
||||
// advUserLock cannot stand in here: it is keyed by sender, so two members
|
||||
// racing the same expedition row take two different mutexes and exclude nobody.
|
||||
func (p *AdventurePlugin) withExpeditionSupplies(
|
||||
expID string, fn func(fresh *Expedition) (ExpeditionSupplies, error),
|
||||
) (ExpeditionSupplies, error) {
|
||||
mu := p.advExpeditionLock(expID)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
fresh, err := getExpedition(expID)
|
||||
if err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
if fresh == nil {
|
||||
return ExpeditionSupplies{}, fmt.Errorf("expedition %s not found", expID)
|
||||
}
|
||||
next, err := fn(fresh)
|
||||
if err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
if err := updateSupplies(expID, next); err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// updateCamp persists camp state. Pass nil to break camp.
|
||||
func updateCamp(expID string, c *CampState) error {
|
||||
var arg any
|
||||
|
||||
Reference in New Issue
Block a user