mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +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:
@@ -88,6 +88,13 @@ type ActorStatuses struct {
|
||||
// and druids with no sustained DPS once their burst landed.
|
||||
ConcentrationDmg int `json:"concentration_dmg,omitempty"`
|
||||
|
||||
// ArmedAbility is the id of the active ability this character armed and
|
||||
// spent entering the fight (rage, second_wind, …). The resource is already
|
||||
// debited and the character disarmed; this is the record that lets every
|
||||
// rebuild of an in-flight fight re-apply the ability's mods, and lets the
|
||||
// close-out know a rage fired. Empty when nothing was armed.
|
||||
ArmedAbility string `json:"armed_ability,omitempty"`
|
||||
|
||||
// Once-per-fight class/race/subclass one-shots: the "already used" flags.
|
||||
// Without persistence these reset every round on resume, letting a Halfling
|
||||
// reroll a nat 1 or an Orc rage every single round of a turn-based fight.
|
||||
@@ -221,23 +228,26 @@ func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) {
|
||||
// the Abjuration Arcane Ward is normally non-zero at fight start — the
|
||||
// turn-based build deliberately omits pre-combat consumables and queued casts —
|
||||
// but the full set is seeded for robustness. Returns true if anything was set.
|
||||
func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool {
|
||||
return seedActorOneShots(&s.Statuses.ActorStatuses, playerMods)
|
||||
func seedCombatSessionOneShots(s *CombatSession, seat CombatSeatSetup) bool {
|
||||
return seedActorOneShots(&s.Statuses.ActorStatuses, seat)
|
||||
}
|
||||
|
||||
// seedActorOneShots copies one character's fight-start one-shot resources onto
|
||||
// their persisted statuses. Seat 0's live on the session row; a party member's
|
||||
// live on their participant row, and each seat reads its own combatant's mods —
|
||||
// a party Abjurer brings their own Arcane Ward, not the leader's.
|
||||
func seedActorOneShots(st *ActorStatuses, playerMods CombatModifiers) bool {
|
||||
func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool {
|
||||
playerMods := seat.Mods
|
||||
st.WardCharges = playerMods.WardCharges
|
||||
st.SporeRounds = playerMods.SporeCloud
|
||||
st.ReflectFrac = playerMods.ReflectNext
|
||||
st.AutoCritFirst = playerMods.AutoCritFirst
|
||||
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
||||
st.HealChargesLeft = playerMods.HealItemCharges
|
||||
st.ArmedAbility = seat.ArmedAbility
|
||||
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
|
||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
|
||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
|
||||
st.ArmedAbility != ""
|
||||
}
|
||||
|
||||
// CombatParticipant is one party member's seat in a fight, from seat 1 up.
|
||||
@@ -477,13 +487,13 @@ func (p *AdventurePlugin) startPartyCombatSession(
|
||||
|
||||
// Seat 0's one-shots live on the session row; seeding them is a mutation of
|
||||
// sess.Statuses that the save below flushes along with the participants.
|
||||
dirty := seedCombatSessionOneShots(sess, owner.Mods)
|
||||
dirty := seedCombatSessionOneShots(sess, owner)
|
||||
|
||||
if len(seats) > 1 {
|
||||
ps := make([]CombatParticipant, 0, len(seats)-1)
|
||||
for i, s := range seats[1:] {
|
||||
var st ActorStatuses
|
||||
seedActorOneShots(&st, s.Mods)
|
||||
seedActorOneShots(&st, s)
|
||||
ps = append(ps, CombatParticipant{
|
||||
Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st,
|
||||
})
|
||||
@@ -525,6 +535,10 @@ type CombatSeatSetup struct {
|
||||
HPMax int
|
||||
Mods CombatModifiers
|
||||
C *Combatant
|
||||
// ArmedAbility is the ability id this seat consumed entering the fight, ""
|
||||
// if they armed nothing. It is persisted onto the seat's statuses so every
|
||||
// later rebuild can re-apply the ability without re-spending it.
|
||||
ArmedAbility string
|
||||
}
|
||||
|
||||
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
|
||||
|
||||
Reference in New Issue
Block a user