Body, Mind & Soul — API Reference
    Preparing search index...

    Class BodyMindSoulCombat

    Hierarchy

    • Combat
      • BodyMindSoulCombat

    Indexable

    • [key: string]: any
    Index
    • get initiativeTokenIds(): Set<string>

      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.

      Returns Set<string>

    • get resolvingTokenIds(): Map<string, string>

      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.

      Returns Map<string, string>

    • Answer a delegated GM request over the shared gm-write response socket. No-ops when the request carried no id (same-client / legacy callers).

      Parameters

      • requestId: any
      • result: any

      Returns void

    • Clear the pending-resolve indicator for an action on every client.

      Parameters

      • actionId: any

      Returns void

    • 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.

      Parameters

      • socketType: string

        Socket message type to emit ("advanceTracker" or "advanceToNextResolution").

      Returns boolean

      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.

      Parameters

      • resolvingActions: TrackerActionModel[]

        Live tracker action instances resolved this advance cycle.

      Returns Promise<void>

    • 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.

      Parameters

      • mutatorFn: (downs: object[]) => any

        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?: object

          Forwarded to _flushResolvePass's updateOptions, which is passed through to combat.update() (e.g. to set a custom _onUpdate flag). Omit for the ordinary case.

      Returns Promise<any>

      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.
      • This call's OWN independent flush (_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.
      • Any id added or removed by 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).

    • Parameters

      • parent: any
      • collection: any
      • documents: any
      • data: any
      • options: any
      • userId: any

      Returns void

    • Parameters

      • options: any
      • userId: any

      Returns void

    • Parameters

      • parent: any
      • collection: any
      • documents: any
      • ids: any
      • options: any
      • userId: any

      Returns void

    • Parameters

      • trackerEffect: any

      Returns Promise<void>

    • Parameters

      • changed: any
      • options: any
      • userId: any

      Returns void

    • Parameters

      • parent: any
      • collection: any
      • documents: any
      • changes: any
      • options: any
      • userId: any

      Returns void

    • Paint the pending-resolve (hourglass) indicator for an action on every client.

      Parameters

      • action: any

        Needs id, combatantId, action, type.

      Returns void

    • Parameters

      • changed: any
      • options: any
      • user: any

      Returns Promise<void>

    • Parameters

      • pass: ResolvePass

        The combat's shared resolve pass; mutated in place.

      • steps: number

        Number of downs to advance.

      Returns Promise<
          {
              baseline: { actionIds: Set<string>; effectIds: Set<string> };
              expiredEffects: TrackerEffectModel[];
              resolvingActions: TrackerActionModel[];
              sweptActionIds: Set<string>;
              sweptEffectIds: Set<string>;
          },
      >

      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.

      Parameters

      • payload: object

        Socket payload; requestId is added here.

      Returns Promise<object>

      { 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.

      Parameters

      • pass: ResolvePass

        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.

      • actionId: string

        The committed action's id at down 0.

      • OptionaldedupeKey: string | null = null

        In-flight dedupe key to release when done.

      Returns Promise<void>

    • 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.

      Parameters

      • action: TrackerActionModel

        Live tracker action instance.

      • downs: object[]

        Mutable downs snapshot to write follow-ups into (typically pass.downs).

      • callerTag: string = "unknown"

      Returns Promise<{ aborted: boolean }>

      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).

    • Returns void

    • 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.

      Parameters

      • downIndex: number
      • actionData: object

        Tracker action payload; id is assigned here.

      • Optionaloptions: { deferResolve?: boolean; requestId?: string | null } = {}
        • OptionaldeferResolve?: boolean

          When 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 | null

          gm-write request id to ack when the commit lands (set by the socket handler for delegated requests).

      Returns Promise<BodyMindSoulCombat | undefined>

    • Parameters

      • downIndex: any
      • effectData: any

      Returns Promise<BodyMindSoulCombat | undefined>

    • Returns Promise<void>

    • Returns Promise<void>

    • Returns Promise<any>

    • 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:

      1. Calls cancelPendingRequestForAction (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").
      2. If no matching request is found (an inconsistent state — e.g. the claim is held by a long-running synchronous trigger outcome, not a prompt), notifies the GM with an explicit error and returns without marking anything resolved, rather than no-op-ing silently.
      3. Otherwise awaits the claim actually being released — the rejected prompt promise only starts the unwind; the in-flight _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).

      Parameters

      • actionId: string

        The down-0 action's id.

      Returns Promise<void>

    • Queue an action from the character sheet into the combat tracker.

      Parameters

      • actorOrId: any

        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.

      • actionItemId: string

        The action item's ID

      • options: {} = {}

      Returns Promise<BodyMindSoulCombat | undefined>

    • 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.

      Parameters

      • actor: Actor

        The actor queuing the action

      • parentItemId: string

        UUID or short ID of the parent item (world glyph, character item, etc.)

      • embeddedActionId: string

        ID of the embedded action within the parent item

      • OptionalswingIndex: number = 0

        Index of the swing to queue

      • Optionaloptions: object = {}

        Additional options (consumeParentCharge, consumedResources)

      Returns Promise<BodyMindSoulCombat | undefined>

    • Queue a single swing onto the combat tracker.

      Parameters

      • combatantId: string

        The combatant's ID

      • actionItem: Item

        The action Item document

      • swingData: object

        The swing data { duration, type }

      • swingIndex: number

        The index of this swing in the action's swings array

      • extraData: {} = {}

      Returns Promise<BodyMindSoulCombat | undefined>

    • Parameters

      • downNumber: any
      • actionId: any

      Returns Promise<void>

    • Parameters

      • combatantId: any

      Returns Promise<any>

    • Parameters

      • downNumber: any
      • effectId: any

      Returns Promise<any>

    • 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).

      Parameters

      • pass: ResolvePass

        The combat's shared resolve pass.

      • resolvingActions: TrackerActionModel[] = []

        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.

      Returns Promise<void>

    • 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.

      Returns Promise<void>

    • Parameters

      • value: any

      Returns Promise<any>

    • Fires bms.combatStarted after Foundry core commits round/turn state. hookArgs = [combat: BodyMindSoulCombat] — no extra payload, mirrors bms.combatDown.

      Returns Promise<BodyMindSoulCombat>

    • 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.)

      Parameters

      • downIndex: number
      • actionId: string
      • actionData: object

        Partial action data to merge.

      • Optionaloptions: { deferResolve?: boolean; requestId?: string | null } = {}
        • OptionaldeferResolve?: boolean
        • OptionalrequestId?: string | null

          gm-write request id to ack on commit.

      Returns Promise<BodyMindSoulCombat | undefined>

    • Parameters

      • downNumber: any
      • effectId: any
      • effectData: any

      Returns Promise<any>