Get a map of token IDs → action type for combatants with an action at resolutionTime === 0 (i.e., currently resolving this down). Defeated combatants are excluded. Type is taken from the first action found for each combatant.
Answer a delegated GM request over the shared gm-write response socket. No-ops when the request carried no id (same-client / legacy callers).
Clear the pending-resolve indicator for an action on every client.
Forward an advance request to the lead GM when this client isn't it. Shared by
advanceTracker and advanceToNextResolution, whose delegation
preambles are otherwise identical apart from the socket message type.
Socket message type to emit ("advanceTracker"
or "advanceToNextResolution").
true if the request was delegated and the caller should return immediately; false if this client is the lead GM and should proceed.
Fire bms.actionResolved for a batch of just-resolved actions, if any. Shared
by advanceTracker and advanceToNextResolution — both fire it
identically once their advance cycle's resolve() call and post-resolve merge
are complete. No-ops when resolvingActions is empty.
Live tracker action instances resolved this advance cycle.
Shared pass-aware lifecycle for every CRUD-shaped tracker mutator
(addEffectToDown, addEffectsToDowns, updateEffectInDown,
addActionToDown's plain queue branch, removeEffectFromDown,
removeCombatantEffects, removeActionFromDown's find/delete step).
This is the generalized form of the pattern updateActionInDown proved
out narrowly first — see that method's doc comment.
Acquires (or attaches to) this combat's shared ResolvePass, hands
mutatorFn the pass's own live downs array to mutate in place, then
flushes the result through _flushResolvePass with auto-diffed
swept-id sets before releasing the pass. Any of these methods called from
inside a trigger script (e.g. a bms.combatDown-hooked ActiveEffect
outcome) while this same combat's own advance cycle still has a pass open
attaches to that same pass instead of doing an independent live-document
read-modify-write — closing the same "hook-during-advance write race"
updateActionInDown was fixed for, for every other tracker mutator too.
Why this makes deletion safe by construction. ResolvePass#mergeFromLive
cannot on its own tell "this id is genuinely new, not yet flushed" apart
from "this id was just deleted by this operation and the deletion must
stick" — both look identical (absent from the pass's downs, still present
in a fresh live read) unless told which ids were deliberately removed. This
helper closes that gap automatically: it diffs the action/effect ids
present in pass.downs before and after mutatorFn runs, and any id that
disappeared is added to sweptActionIds/sweptEffectIds and forwarded to
_flushResolvePass/mergeFromLive, which then skips re-merging it in from
the live read. No caller of this helper has to remember to compute or pass
those sets itself.
Called with the
pass's own mutable downs array (same shape as system.bms.downs.toObject()
— an array of { downNumber, actions, effects } plain objects, actions/
effects keyed by each entry's own id). Mutate it in place (add/edit/
delete entries). May be async. Return false to signal "nothing changed,
skip the write entirely" (e.g. the target down/entry wasn't found) — the
pass is still released normally. Any other return value (including
undefined) proceeds to flush, and is passed back through as this
method's own return value so a caller can thread through data built
during mutation (e.g. an array of newly-added entries).
Optionaloptions: { updateOptions?: object } = {}
OptionalupdateOptions?: objectForwarded to _flushResolvePass's
updateOptions, which is passed through to combat.update() (e.g. to set
a custom _onUpdate flag). Omit for the ordinary case.
mutatorFn's return value, or false if it
signalled to skip the write.
Reentrancy (v0.7.2 follow-up — see .claude/contexts/combat.md's
_mutateDowns reentrancy subsection for the full incident writeup,
including a design revision found via regression, not by inspection).
The overwhelming majority of calls to this method happen from outside
any already-open pass for this combat — e.g. a hook script firing from
Hooks.callAll with no advance/instant-resolve currently mid-cycle. That
is the normal case handled by the paragraph above: acquire (which
creates the pass fresh), mutate, compute swept ids, flush independently,
release.
But this method can also be called reentrantly — from inside a
bms.combatDown/bms.combatTick/bms.combatRound hook callback that
fires synchronously while advanceTracker()/advanceToNextResolution()
is still mid-cycle, holding this same combat's ResolvePass open. The
confirmed trigger: those hooks fire synchronously inside those methods'
own await chains; if a hook-bound trigger calls advanceSelf/
accelerateQueuedAction/advanceEffect → _decrementResolutionTime →
this method, that's a reentrant call. _acquireResolvePass correctly
returns the SAME pass object in that case (it's keyed by combat id), but
prior to this fix, this method still went on to do its OWN independent
_enqueueDownsWrite/_flushResolvePass call — so the reentrant (inner)
call and the outer operation's own eventual flush each computed their
own baseline/swept-id sets at different, timing-dependent moments and
independently wrote pass.downs to the live document. One of those two
writes' mergeFromLive reconciliation could — and, under full test-suite
CPU-load timing, reproducibly did — drop the other's mutation.
The reentrancy signal is pass._inHookWindow, NOT bare pass-existence
— this is a correction to this fix's own first cut. The first
implementation classified a call as reentrant whenever
_resolvePasses.has(this.id) was true — i.e. whenever any pass already
existed for this combat, for any reason. That is too broad: it also
covers a call that merely happens to arrive while an UNRELATED,
long-running operation for the same combat has the pass attached — most
concretely, the instant-resolve owner/joiner span in
_resolveInstantAction(), which can be blocked on an interactive prompt
for an arbitrary length of time. Found via
quench/suite-combat-instant-queue-lock.mjs's "(h) Unrelated socket
requests process promptly during instant batch" regression test: a
concurrent addEffectToDown() call arriving while an unrelated instant
batch was mid-prompt for the same combat got wrongly classified as
reentrant, skipped its own flush, and the effect it added never landed
in the live document until the unrelated batch eventually finished —
breaking exactly the "unrelated writes aren't stalled behind someone
else's prompt" guarantee that test exists to enforce. There is no
guarantee an unrelated caller's flush is coming "soon" the way there is
for a hook nested inside an advance cycle's own synchronous
hook-processing.
The fix: _fireAdvanceHook (this file, next to _flushResolvePass) sets
pass._inHookWindow = true for the exact duration of each
bms.combatDown/bms.combatTick/bms.combatRound firing inside
advanceTracker()/advanceToNextResolution() — the only call sites
that make the "I will flush again before releasing this pass" guarantee
this optimization depends on — and false immediately after. This
method checks _resolvePasses.get(this.id)?._inHookWindow BEFORE
acquiring (checking after would always see the pass as "already there,"
having just created/attached it). Every other pass-holding span
(resolveCurrentDown(), the instant-resolve owner/joiner span, and the
bms.actionResolved firing that happens after an advance cycle's own
last flush) never sets this flag, so a _mutateDowns call made during
any of those windows takes the normal, independent-flush path — exactly
as it always did, with no regression for any of them.
When isReentrant is true:
mutatorFn still runs and still mutates pass.downs in place — the
shared-by-reference downs array means the OUTER operation is
guaranteed to see this mutation regardless of whether this call
flushes it itself._enqueueDownsWrite/
_flushResolvePass) is skipped entirely — it does not write to the
live document. It still runs through the normal finally →
_releaseResolvePass (the attach/detach refcounting is unaffected;
only whether this call performs its own flush changes).mutatorFn's return value is still returned unchanged, so callers
that inspect it (e.g. a caller threading through data built during
mutation) keep working identically in both branches.mutatorFn is recorded via
pass.notePendingReentrantChange(...) — necessary because the OUTER
operation's own baseline (captured from pass.downs at a point AFTER
this mutation already landed) cannot on its own distinguish "this id
was already live before I started" from "this id was just added in
memory by a reentrant call that never flushed it," nor does anything
else tell it "this id was just deleted by a reentrant call that never
flushed that either." See ResolvePass#notePendingReentrantChange's
doc comment for the full mechanics — this closes a second, distinct
regression found the same way as the one above (the same Quench
describe block's addEffectToDown/removeEffectFromDown tests: a
reentrantly-added effect was silently dropped, and a
reentrantly-deleted effect was silently resurrected, by the advance's
own later flush, before this bookkeeping existed).This is safe because advanceTracker()/advanceToNextResolution() are
guaranteed to flush pass.downs again — via _flushResolvePass, which
unconditionally drains and applies pass.drainPendingSweptIds() on every
call, and via ResolvePass#mergeFromLive itself consulting
pass._pendingNewActionIds/_pendingNewEffectIds directly — before
either method releases its own claim on the pass, for every hook window
_fireAdvanceHook can wrap (combatDown/combatTick/combatRound, in
both methods, at every call site each currently has).
Paint the pending-resolve (hourglass) indicator for an action on every client.
Needs id, combatantId, action, type.
The combat's shared resolve pass; mutated in place.
Number of downs to advance.
baseline is this call's own pass.beginMutationPhase() result — the
caller must thread it through to every _flushResolvePass/
pass.mergeFromLive call covering this same advance cycle (see
ResolvePass#beginMutationPhase's doc comment for why it's no
longer implicit pass state).
Emit a delegated combat request to the lead GM and await its acknowledgement. Non-lead clients (players and secondary GMs) use this instead of writing.
Socket payload; requestId is added here.
{ queued: true }, or { queued: false, reason }.
Resolve a single instant (resolutionTime 0) action that has already been committed to down 0, coalescing it with any instant resolution already in flight for this combat.
Ownership model: the first caller with no batch open becomes the batch
owner — it drains its own and every joiner's resolve against the shared
pass.downs (the same reference the commit step already mutated — no fresh
snapshot taken here), then performs the one merge+write and fires
bms.actionResolved once with the full absorbed set. Later callers join:
they resolve against the same pass.downs (safe because ItemActionData#resolve
treats downs as a pure write sink, only ever pushing follow-up swings),
write nothing, and simply await the batch's completion.
This method may block for the length of a human prompt, so it must never be
awaited from inside the socket request queue — see the deferResolve
option on addActionToDown.
The combat's shared resolve pass, already claimed
for actionId by the caller (addActionToDown/updateActionInDown) at
commit time. This method releases that claim and detaches from the pass
in its outer finally, regardless of outcome.
The committed action's id at down 0.
OptionaldedupeKey: string | null = null
In-flight dedupe key to release when done.
Resolve exactly one action against a mutable downs snapshot.
Shared by the advance cycle (resolve, via the module-level _resolveOnce
wrapper — which guards by actionId, using the combat's shared ResolvePass,
so the same action can never be resolved twice if an advance and an instant batch
race to pick it up) and the instant-action batch (_resolveInstantAction's
owner/joiner branches, which call this directly — their actionId was already
claimed at commit time in addActionToDown/updateActionInDown, so routing
through _resolveOnce would see that claim and skip itself). downs is a pure
write sink here — the action's own system.resolve only ever pushes follow-up
swing/channel entries into downs[0].actions, never reads or removes from it,
which is what makes several actions safely share one snapshot. In every current
call site downs is pass.downs — the combat's shared resolve-pass snapshot —
not a call-local one.
Prompt/resolve aborts signal distinctly from success (v0.7.2 follow-up).
BmsPromptAbort/BmsResolveAbort (a cancelled target picker, template
placement, etc.) are still caught here — they must never propagate as a
real error — but instead of returning undefined indistinguishably from
a genuine completion, this now returns { aborted: true } so callers
(_resolveOnce, and the instant-resolve owner/joiner branches in
_resolveInstantAction) can skip marking the action resolved and
leave it retryable. Any other error still propagates to the caller
unchanged. The pending-resolve indicator for this action is always
cleared, success, abort, or failure.
Live tracker action instance.
Mutable downs snapshot to write follow-ups into
(typically pass.downs).
aborted: true when resolution
was cut short by a cancelled prompt; aborted: false for a genuine
completion (including the early-return cases below — a missing
actor/system.resolve isn't a cancelled prompt, so it's not retryable
via this signal either).
Add an action to a down on the combat tracker.
The commit write is unconditional and immediate — it never waits on any
lock, so a player's action always appears on the tracker right away even
while the GM is blocked on another action's prompt. Instant actions
(resolutionTime === 0) then resolve via _resolveInstantAction,
joining whatever instant batch is already in flight.
Tracker action payload; id is assigned here.
Optionaloptions: { deferResolve?: boolean; requestId?: string | null } = {}
OptionaldeferResolve?: booleanWhen true, return as soon as the commit + ack are done and let instant resolution continue in the background. Required for the socket path so a slow prompt can't stall the GM request queue. Direct local callers leave it false and await the full resolve.
OptionalrequestId?: string | nullgm-write request id to ack when the commit lands (set by the socket handler for delegated requests).
Force-mark a single down-0 action resolved WITHOUT running its actual
resolve effects (never calls _resolveOneAction/system.resolve()).
Escape hatch for a stuck or broken action, exposed via the tracker's
"Mark Resolved" GM context action — the caller is responsible for
warning the GM first (see combat-tracker.mjs's confirmation dialog),
since this deliberately skips real resolution: any damage/triggers/
prompts the action would have fired never happen.
Claimed (mid-flight) actions — targeted force-abort (v0.7.2 GM-escape-
hatch follow-up). If actionId is currently claimed on the shared
ResolvePass — i.e. genuinely stuck inside _resolveOneAction, most
commonly blocked on an interactive prompt (target picker, template
placement, a compel check, etc.) — this used to silently return with
zero feedback, exactly the "genuinely hung prompt" case this function
exists to rescue. It now:
prompts.mjs) to find
and reject only the pending prompt request tied to this specific
actionId — never a blanket cancel of every in-flight prompt (that
would also cancel unrelated prompts for other actions; see
.claude/contexts/combat.md's "Advance-During-Pending-Prompt Race")._resolveOneAction's BmsPromptAbort catch and _resolveOnce's
finally (or the instant-resolve owner/joiner branches' equivalent)
still need their own turns of the event loop to actually release the
claim — see _awaitClaimReleased. Times out and notifies an
error (again, without marking resolved) rather than proceeding on a
claim that might not really be free.
Only once the claim is confirmed free does this proceed with the normal
mark-resolved logic below, identical to the already-unclaimed case.Mirrors the claim/flush/release shape every other resolve path uses
(acquire the combat's shared ResolvePass, claim the id, mark it
resolved, flush, release) but stops short of calling _resolveOneAction.
No internal lead-GM delegation guard — same as removeActionFromDown —
callers must gate on game.user === getLeadGM() and delegate via the
markActionResolved socket message otherwise (see combat-socket.mjs).
The down-0 action's id.
Queue an action from the character sheet into the combat tracker.
The actor object (preferred) or base actor ID string. Passing the actor object is required for correct resolution of NPC delta actors (unlinked tokens), where multiple combatants may share the same actorId.
The action item's ID
Queue an embedded item action (glyph or gear) from the actor sheet. Computes player visibility the same way queueSwing does: characters see their own actions, and applied effects with tracker.revealActions modifications can override visibility. Decrements quantity for usable non-equipable items after queueing.
The actor queuing the action
UUID or short ID of the parent item (world glyph, character item, etc.)
ID of the embedded action within the parent item
OptionalswingIndex: number = 0
Index of the swing to queue
Optionaloptions: object = {}
Additional options (consumeParentCharge, consumedResources)
Queue a single swing onto the combat tracker.
The combatant's ID
The action Item document
The swing data { duration, type }
The index of this swing in the action's swings array
Resolve one or more actions whose resolution time has reached zero.
GM-centralised: all swing triggers execute on the lead GM client.
Interactive dialogs (target pickers, templates, compel checks, etc.)
route to the appropriate user via promptFor. Multiple resolves run
in parallel — each system.resolve pushes any next-swing / channel-loop
entries into pass.downs[0].actions (atomic Array.push, so the union
is the final state even under interleaving).
The combat's shared resolve pass.
Live DataModel instances.
Optionalbaseline: { actionIds?: Set<string>; effectIds?: Set<string> } = {}
This advance cycle's pass.beginMutationPhase() result (captured by the
caller's own _processCombatSteps call). Forwarded unchanged to every
_resolveOnce call this invocation makes, so each action's individual
post-resolve flush (see _resolveOnce's doc comment) reconciles against
the correct mutation-phase baseline. Defaults to {} (nothing treated as
"original") for callers/tests that don't pass one.
Resolve only the down-0 actions that are already due (resolutionTime <= 0) and unresolved, without running any of _processCombatSteps's
decrement/sweep/tick/round bookkeeping.
This is the safe counterpart to advanceTracker()/
advanceToNextResolution() for the case where down 0 already holds one
or more due-but-unresolved actions — e.g. because a prior
advanceTracker()/advanceToNextResolution() call got interrupted (GM
browser refresh) after committing an action to resolutionTime: 0, resolved: false but before resolve() finished. Calling either advance
method again in that state would unconditionally decrement the stuck
action to -1 on the very next call and sweep it out of the persisted
downs array — silent, permanent data loss if the next attempt is also
interrupted. resolveCurrentDown() never runs that decrement/sweep loop
at all, so it can be clicked any number of times across any number of
interruptions without ever discarding an unresolved action. See "Resolve
Mode vs Advance Mode" in .claude/contexts/combat.md for the full
design and the UI gating that routes the tracker's advance button here.
Uses the same lock/pass/claim/flush/release lifecycle as
advanceTracker(): _delegateAdvanceIfNotLeadGM first, then the shared
_advanceLock (reused rather than a separate lock — resolveCurrentDown
and advanceTracker/advanceToNextResolution are mutually exclusive by
UI construction already, since the tracker only ever shows one button
mode at a time, but sharing the lock is a cheap extra guard against any
caller reaching them outside that gated UI path), then a ResolvePass
acquired/released exactly like the other two advance methods.
A no-op (after acquiring and releasing the lock/pass) if down 0 has no due-unresolved actions when called — this can happen if the UI's resolve-mode gating raced with a concurrent operation that already resolved everything due; safe to call defensively.
Fires bms.combatStarted after Foundry core commits round/turn state. hookArgs = [combat: BodyMindSoulCombat] — no extra payload, mirrors bms.combatDown.
Update an existing action in a down, instant-resolving it if the edit lands it at down 0. Same commit-then-resolve split as addActionToDown.
Pass-aware (v0.7.2 follow-up — "hook-during-advance write race" fix).
Always acquires (or attaches to) the combat's shared ResolvePass and
mutates pass.downs directly, instead of reading a one-off
this.system.toObject() snapshot and writing it straight back. This matters
when this method is called from inside a trigger script (e.g. a
bms.combatDown-hooked ActiveEffect outcome, awaited synchronously by
advanceTracker()/advanceToNextResolution() before those methods'
_processCombatSteps even runs) while that same combat's own advance cycle
still has a pass open: a raw toObject()-then-update() write in that window
is invisible to the open pass's in-memory downs, and ResolvePass#mergeFromLive
deliberately keeps the pass's own field content for entries it already
knows about (only takes bucket position from a fresh live read) — so the
advance's own next flush would silently revert this call's field changes
right back out. Mutating pass.downs here closes that window structurally.
_acquireResolvePass is safe to call unconditionally: when no advance is in
flight it just creates a fresh pass from a fresh live snapshot, equivalent to
the old raw read. (This method's instant-branch bypass and the plain-queue
branch above it manage the pass by hand because they need to hold the claim
across the instant-resolve handoff; every other CRUD-shaped tracker mutator —
addEffectToDown, addEffectsToDowns, updateEffectInDown,
addActionToDown's plain queue branch, removeEffectFromDown,
removeCombatantEffects, removeActionFromDown's find/delete step — now
routes through the shared _mutateDowns helper below, which
generalizes this exact acquire/mutate/flush/release shape and additionally
auto-diffs swept ids so deletions are safe under mergeFromLive too.)
Partial action data to merge.
Optionaloptions: { deferResolve?: boolean; requestId?: string | null } = {}
OptionaldeferResolve?: booleanSee addActionToDown.
OptionalrequestId?: string | nullgm-write request id to ack on commit.
Get the set of token IDs for combatants that currently "have initiative" (no actions queued on any down of the tracker). Defeated combatants are excluded.