How the Automation System Works¶
Back to API Reference · Feature guide: Automation Engine
For anyone writing activations, in the Activation Manager UI or through the registration API. Covers what the engine does between a game event and your activationCode running.
For trigger payload schemas, see API Reference. For API surfaces (effects, bonuses, interactive tools), see the sibling files: Effects API, Interactive API, Combat API, Items API.
Note
This started as a reaction reminder, so the internals are named after reactions: you write reactions: [...], the action path field is reactionPath, and you register with api.registerDefaultItemReactions() / api.registerDefaultGeneralReactions(). Those all mean activation. Legacy naming, not a separate concept.
Table of Contents¶
- 1. Big Picture
- 2. Lifecycle of a Trigger
- 3. Item vs General Activations
- 4. Filters, in Order
- 5. The Four Callbacks
- 6. Activation Type and Mode
- 7. Auto Mode vs Popup Mode
- 8. Clients and Sockets
- 9. Cancel and Modify
- 10. Economy and Frequency
- 11. Flow Data Injection
- 12. Paths and Extra Actions
- 13. Registration Paths
- 14. Caches and Invalidation
1. Big Picture¶
The automation system is an event-driven dispatcher. Each game event (a move, an attack, a damage roll, a status applied, a turn change, etc.) becomes a trigger with a data payload. For each trigger, the engine asks: which tokens on the scene want to react, and how?
Two kinds of definitions can react:
-
Item activations: tied to a Lancer item by its LID. Only tokens whose actor owns that item are candidate reactors.
-
General activations: not tied to any item. Any token in the scene is a candidate, filtered by your rules.
Each definition can declare:
-
One or more
triggersit cares about. -
Filters (disposition, distance, self/other, in/out of combat).
-
An
evaluatefunction that returnstrue/false(one final synchronous check). -
An
activationCodefunction (your effect) that runs when the activation fires. -
Optional
onInit(runs once when a token enters the scene/combat) andonMessage(cross-client request).
Vocabulary¶
The names used by every callback and the rest of this doc:
| Name | Meaning |
|---|---|
| trigger | a game event turned into a dispatch: a trigger type plus a data payload |
triggerData |
the payload your callbacks receive. Enriched per reactor, carries the helpers and cancel functions |
triggeringToken |
the token whose action fired the trigger |
reactor / reactorToken |
the token being checked for a reaction, the one reacting (nothing to do with the mech part) |
item |
the reactor's matched item. null for general activations |
activationName |
the name of the action at the action path, else the item's name, else the general activation's name |
| LID | Lancer ID, item.system.lid. Stable identifier, same across all copies of an item |
| flow | the Lancer system's step pipeline for one action (attack, damage, activation). Every chat card is the output of a flow |
2. Lifecycle of a Trigger¶
Flow diagram (click to expand)
flowchart TD
A["Game event<br/>(move, hit, damage, status, turn...)"] --> B["handleTrigger(triggerType, data)"]
B --> C["Iterate all tokens on the scene"]
C --> D["Compute distanceToTrigger, canTriggerReaction<br/>and merge into data"]
D --> E{"Item reactions<br/>matched by LID?"}
E -- No --> G
E -- Yes --> F["Run filters + evaluate()"]
F --> G{"General reactions<br/>matched by name<br/>or unconditional?"}
G --> H["Run filters + evaluate()"]
H --> I{"autoActivate?"}
I -- Yes --> J["activateReaction()<br/>now, or after the sweep<br/>on cancellable triggers"]
I -- No --> K["Push to reactionQueue"]
K --> L["After all tokens processed,<br/>show summary popup<br/>via socket"]
L --> M["User clicks Activate"]
M --> J
J --> N["activationType +<br/>activationMode dispatch"]
N --> O["Your activationCode runs<br/>(or flow / macro / chat card)"]
What the engine does for one trigger:
-
Trigger fan-out. A flow step or hook calls
handleTrigger(triggerType, data), or your code callsapi.dispatchCustomTrigger(name, data)(Custom triggers). The engine wires the reaction helpers (startRelatedFlow,startRelatedFlowToReactor,sendMessageToReactor,debugActivation) ontotriggerData. Signatures in section 8. -
Reactor sweep. Every token on the scene is a potential reactor. When the triggering token is hidden, only the reactors that are the mover itself or one of its targets are considered. Everyone else is skipped.
-
Distance enrichment. For each reactor, two values are computed once and merged into a per-reactor copy of the trigger data:
distanceToTrigger- distance from the reactor to the triggering token.canTriggerReaction- whether the trigger may provoke a reaction. It'sfalsewhen the mover hashidden,disengage, or theprovokeimmunity, or isintangiblewhile the reactor is not.
-
Item reactions first. For every item the reactor's actor owns whose LID matches a registered item activation, run the filter chain.
-
General reactions second. Walk the flat list of general activations that listen to this trigger, and run the filter chain.
-
Filter chain (any failure = skip). Order and details in section 4.
-
evaluate()runs synchronously (see section 5). Exceptions are caught and logged, and the activation is skipped on error. -
Branch on
autoActivate:- true:
activateReaction()runs on the local client. - false: the entry is pushed to
reactionQueue.
On the seven cancellable triggers (
onPreMove,onPreStructure,onPreStress,onPreStatusApplied,onPreStatusRemoved,onPreHpChange,onPreHeatChange) auto activations are not run inline. They are collected and run after the whole sweep, triggering token first, stopping at the first cancel. That ordering is why Engagement beats Overwatch. - true:
-
Summary popup. After every token is processed, if the queue is non-empty, the engine builds a summary popup, decides who sees it (per the
reactionNotificationModesetting), and broadcasts it via socket. -
Manual activation. When a recipient clicks Activate on a queued entry, that client runs
activateReaction()for that single entry. -
Post-activation sweep, on
onActivationandonEndActivationonly. Once the awaited auto activations have finished, the engine firesonPostActivation/onPostEndActivationwith the same payload plusresults, what each reaction returned (seeactivationCode). Popup activations resolve later and are not in it.
Filters and evaluate run for every reactor on the scene, every time a matching trigger fires. Keep them cheap.
Custom triggers¶
Any name that is not built in. api.dispatchCustomTrigger(name, data) enters at step 1. Listen via the editor's Custom field or triggers: ["myTrigger"]. Always fires, outOfCombat does not apply. data becomes triggerData, so pass triggeringToken for the token filters. Built-in names and onInit* are refused. Not cancellable, no consumption.
3. Item vs General Activations¶
Picking one¶
| If you want to... | Use |
|---|---|
| React when a specific weapon, system, or NPC feature is used | Item, with onlyOnSourceMatch: true |
| React when any hostile starts moving in your threat | General, no source match |
| Apply a passive effect at scene-load to anyone with a feature | Item, onInit only (no triggers) |
| Build a one-off rule that applies to all tokens | General |
| React on a specific deployable's action (or its deploy) | Deployable LID (actor.system.lid), with onlyOnSourceMatch: true |
| React only as one specific actor or deployable instance | Actor UUID (actor.uuid) in the LID field, with onlyOnSourceMatch: true |
| React once per event as the scene, not per token | General with sceneReactor: "add" or "only", sceneId to limit it to one scene |
| React when you deploy something yourself | Item LID that grants the deployable, triggers: ["onDeploy"], triggerSelf: true |
Item activation¶
-
Registered under a specific item LID (e.g.
"npcf_dispersal_shield_priest"). -
Engine checks: does this reactor's actor own an item whose
system.lid === <registered LID>? If yes, run filters. -
The matched item is passed to your callbacks as the
itemargument. -
onlyOnSourceMatch: truemeans: the activation only fires if the item that triggered the event has the same LID as the activation's LID. Without it, your activation would also try to fire when the user activates an unrelated item or moves. -
When a reactor owns several items sharing the triggering LID, only the exact triggering document fires (same-LID dedupe), so duplicate copies of the same item don't each react.
General activation¶
-
Registered under a name (e.g.
"Overwatch","Brace"). -
Every token on the scene is a candidate. Use filters to narrow it down (disposition, distance, your own
evaluate). -
The
itemargument to your callbacks isnull(general activations are not tied to an item). -
onlyOnSourceMatch: truemeans: the activation only fires if the triggering action's name matches the activation's registered name. Useful for general activations attached to a named action like Overwatch.
Deployable activation¶
-
Registered under a deployable LID, the value of
actor.system.lidon the deployable actor (e.g."dep_moonlight_drone"). The engine matches any LID against the deployable's actor LID - there is no required prefix. -
The reactor is the deployable actor itself: any deployable on the scene whose
actor.system.lid === <registered LID>is a candidate. -
The engine auto-resolves the source item (the item whose
system.deployables[]contains the deployable's LID - for frames, alsocore_system.deployablesandtraits[].deployables) and surfaces it astriggerData.item, so authors can read effect/tag context. -
triggerData.deployable = { actor, lid }is also set, providing the explicit deployable identity. -
triggerData.actionData.action.nameis the action's name (e.g."Move","Combine"). For the top-level deploy click it's the deployable actor's name.triggerData.actionData.action.activationis the activation type ("Protocol","Quick"). -
onlyOnSourceMatch: truemeans: only fire when the activation source is the matching deployable - i.e. one of this exact deployable's actions was the trigger. -
reactionPathselects which part of the deployable a reaction binds to:- empty string - the top-level deploy itself (
actor.system.activation). "actions.<name>"- a specific entry inactor.system.actions[](mirrors theextraActions.<name>pattern).
- empty string - the top-level deploy itself (
The deployable surrogate carries the deployable actor's system.actions[], so these resolve by name (deployable actions often have empty LIDs).
Self-deployable¶
The onDeploy trigger fires when a deployable or a thrown weapon is placed. Its payload is { triggeringToken, item, deployedTokens, deployType }:
triggeringToken- the deploying token.item- the source system item.deployedTokens- the freshly placed tokens.deployType-"deployable"or"throw".
To react when you deploy something, register an item activation on the feature or system LID that grants the deployable, and set:
-
triggers: ["onDeploy"] -
triggerSelf: true(the deploying token is the reactor, so it counts as self) -
onlyOnSourceMatch: true(only fire for this exact source item)
The Restock Drone NPC feature (startups/itemActivations.js) is the reference: on deploy it reads triggerData.deployedTokens[0] and drops an aura on it with api.createAura(...). This is a normal item reaction firing on self, not the actor-UUID path below.
Actor UUID activation¶
Alongside item LIDs and deployable LIDs, an activation can be registered against a single Actor UUID (e.g. "Actor.qe5wEevLrMN6ki44", or a "Compendium.<pack>.Actor.<id>" path). The engine synthesizes an actor_surrogate for every actor, keyed by its actor.uuid, so a reaction keyed by a UUID matches only that one actor instance.
Use this when an item or deployable LID is too broad: a LID-keyed reaction fires for every actor that owns the item or every deployable of that type, while a UUID-keyed reaction is bound to exactly one actor. Typical case: a specific named deployable or NPC that should react only as itself.
-
onlyOnSourceMatch: truealso compares the triggering token'sactor.uuidagainst the registered key (alongside item and deployable LIDs), so a UUID reaction with source-match only fires when that actor was the source of the event. -
In the Activation Manager, paste the Actor UUID into the LID field, or use the Actor Finder to fill it in.
-
A world actor's UUID (
Actor.<id>, the prototype) matches every token spawned from it, linked or not. An unlinked token's ownactor.uuid(Scene.<id>.Token.<id>.Actor.<id>) matches that one placed token only.
4. Filters, in Order¶
Filters short-circuit, in this order (rows 6 and 7 apply to item activations only):
| # | Filter | Behavior |
|---|---|---|
| 1 | onlyOnSourceMatch |
Matches the triggering item LID, deployable LID, or actor UUID against the registered key. Meaning per kind in section 3. |
| 2 | outOfCombat |
If combat is not active and outOfCombat is false, skip. Unless the trigger is inherently combat-related (onTurnStart, onTurnEnd, onRoundStart, onEnterCombat, onExitCombat) or is a custom trigger. |
| 3 | triggerSelf / triggerOther / triggerTarget |
If the reactor is the triggering token: require triggerSelf: true. If it isn't: pass with triggerOther: true, or with triggerTarget: true when the reactor is one of the event's targets. triggerOther defaults to true: it only skips when you set it to exactly false. |
| 4 | checkReaction |
Skip the reaction when the reactor has no reaction left this round. Runs only when the field is set true. Spending is separate: the world setting consumeReaction. |
| 5 | requireCanProvoke |
If true, skip if triggerData.canTriggerReaction is false. |
| 6 | item availability | A destroyed or disabled item never reacts, same for an action path that no longer resolves. |
| 7 | checkUsage |
Skip when the item is unloaded, out of uses, uncharged, or past its per-round / per-turn tag limit. Same gate as the editor's Check Usage box, which is on unless you clear it. A config registered from code that omits the field gets no gate at all. |
| 8 | dispositionFilter |
Array like ["hostile", "friendly"]. Uses Token Factions multi-team data when installed, otherwise CONST.TOKEN_DISPOSITIONS. |
| 9 | evaluate() |
Your custom predicate. Last gate. Range checks go here, compare triggerData.distanceToTrigger. |
Fail any: that activation is skipped for that reactor, with no popup. Three cases still speak up: an onActivation dropped only by outOfCombat warns once, a checkUsage on an item that has no usage tag to check warns once, and the Debug: Out of Combat Warnings setting warns on every out-of-combat drop. Debug mode (Automation Engine - Debugging) logs which filter dropped it.
The three identities. An actor does a thing, everyone may react:
| Gate | Passes when the reactor is... |
|---|---|
triggerSelf |
the one doing it |
triggerTarget |
one of the event's targets |
triggerOther |
anyone else. It still admits targets, so an older config written before triggerTarget existed keeps working. Defaults to on |
- "Target only" is Self off, Other off, Target on.
triggerTargetonly works on triggers whose payload carries targets (attacks, tech, damage,onRoll,onCheck,onInvoluntaryMove). The editor greys the rest.- Target-reactors get
isTarget: trueandtargetEntry(their own roll/crit on hit/miss triggers), and they still fire when the attacker is hidden. Being attacked is knowable. - A target-side reaction that cancels (
cancelAttack,cancelDamage) must beautoActivate: manual popups routed to another client receive serialized data without the cancel functions.
5. The Four Callbacks¶
All four receive api as the last argument. All four are wrapped in try/catch. Uncaught exceptions are logged to the console, never thrown to the user. Argument names are defined in the vocabulary.
evaluate¶
evaluate(triggerType, triggerData, reactorToken, item, activationName, api) => boolean
The final filter. Return true to allow the activation, false to skip it.
Must be synchronous, on every trigger. If evaluate returns a Promise the engine logs a console.error and treats the result as false, so the activation is dropped.
activationCode¶
activationCode(triggerType, triggerData, reactorToken, item, activationName, api) => Promise<any>
Your effect. May be async. Has full access to api. Runs on:
-
The local client (auto activations).
-
Whichever client clicks Activate in the popup (manual activations).
See section 8 for what that means for GM-only operations.
Return value. Optional, and normally nothing. On onActivation and onEndActivation, whatever the code returns is collected into results[activationName] and handed to the next trigger, onPostActivation / onPostEndActivation. That is the handoff for one automation extending what another just did, without re-registering it: the first returns what it produced, the second reads it.
Stock Bolster returns { targets, effects }, the tokens it bolstered and the effects it placed on them. A talent that lengthens Bolster reads them back:
triggers: ["onPostActivation"],
triggerSelf: true,
triggerOther: false,
evaluate: (triggerType, triggerData) => triggerData.actionName === "Bolster",
activationCode: async (triggerType, triggerData, reactorToken, item, activationName, api) => {
for (const effect of triggerData.results?.Bolster?.effects ?? [])
await api.setLAFlag(effect, 'duration', { label: 'indefinite' });
}
Only awaited auto activations are collected. Reactions with awaitActivationCompletion: false and popup activations are not, they resolve too late.
onInit¶
onInit(token, item, api) => Promise<void>
Runs once when a token (or its item) enters the scene. Used for:
-
Applying baseline constant bonuses (immunities, climber, regenerative shielding).
-
Creating auras with
api.createAura(...). -
Adding extra actions with
api.addExtraActions(...).
For a scene reactor (sceneReactor on) it also runs on scene load, see sceneReactor.
onInit is not a trigger. Do not put "onInit" in the triggers array. The engine looks for the onInit field on the reaction config and calls it directly when:
-
A token is created on the canvas (after a 100 ms delay so the canvas object exists), or
-
An item is added to an actor that already has a token on the scene.
For reactions that only have an onInit and no triggers, set triggers: [], triggerSelf: false, triggerOther: false, autoActivate: false, activationType: "none".
onMessage¶
onMessage(triggerType, data, reactorToken, item, activationName, api) => Promise<any>
Handler for cross-client requests. Invoked when another client calls triggerData.sendMessageToReactor(data, userId, opts). Runs on the target client (the user named by userId).
If the caller passed wait: true, whatever you return (or resolve(...)) from onMessage is delivered back as the caller's return value. See Interactive API for the wait card / response pattern.
6. Activation Type and Mode¶
Two independent dimensions on each reaction config:
activationType¶
| Value | Effect |
|---|---|
"code" |
Run your activationCode function. The most common choice. |
"flow" |
Launch the reaction's own flow: the reactionPath action, else the item's first Reaction action, else its first action, else the system flow, else a generic chat card (weapons get this, and it never rolls an attack). General reactions post a trigger/effect card. |
"macro" |
Execute a Foundry macro by name (activationMacro field). |
"none" |
Do nothing. Typically only used for onInit-only reactions. |
activationMode¶
Macro/code only. Ignored for "flow" and "none".
| Value | Effect |
|---|---|
"instead" |
Your code runs alone. Default for item reactions. |
"after" |
The reaction's own flow/card fires alongside your code. Default for general reactions. |
activationModenever touches the flow you are reacting to. That one runs regardless. And"after"is not ordered: both run together viaPromise.all. For strict ordering use"instead"and call the flow yourself, or inject into the flow state withinjectBonus/injectFlowExtraData(see section 11).
sceneReactor¶
General activations only. Default "off".
| Value | Effect |
|---|---|
"off" |
Per-token evaluation as usual. |
"add" |
Also evaluated once as the active scene, on top of the per-token passes. |
"only" |
Evaluated once as the active scene, never per token. onInit is the exception: it still runs per token. |
The scene pass:
- runs on the GM client only, before the token passes.
reactorTokenis a scene stand-in:isSceneReactor: true,.scene,.name,.document.texture.src,actor: null.- token gates do not apply (
triggerSelf/triggerOther/triggerTarget,checkReaction,dispositionFilter,requireCanProvoke).outOfCombatandevaluatedo. triggerDatacarriesisSceneReactor: trueandscene.- use
activationType: "code"or"none", a flow needs an actor. - with
onlyOnSourceMatchit still fires once as the scene, on the action whose name matches.
sceneId (the Scene select in the editor) limits the whole activation, token passes included, to one scene. Empty means every scene.
onInit on a scene reactor also runs when a matching scene loads (canvasReady, or once the module is ready on first load), GM only, with the scene stand-in as token and item null. Use it for "when this map opens, set up X".
7. Auto Mode vs Popup Mode¶
Auto¶
The activation runs immediately, on the local client, with no UI. Use this for things that should always happen:
-
Applying a status on hit.
-
Adding/removing a constant bonus.
-
A passive that should fire silently.
Popup¶
The activation is queued. After every reactor has been checked, all queued entries for the trigger are bundled into a single summary popup. Each entry shows the reactor's name and the activation's label. Clicking an entry expands its details, Activate runs that entry's activationCode.
Warning
Only one activation popup exists at a time. If a second trigger raises its own popup, the previous one is closed and its unclicked entries are gone. There is no queue and no pending badge. Anything that must not be missed belongs on autoActivate.
Who sees the popup¶
Controlled by Module Settings > Activation Notification Mode:
| Setting | Recipients |
|---|---|
"both" (default) |
The token's owner(s) and the active GM |
"owner" |
Only the token's owner(s) |
"gm" |
Only the active GM |
The popup is broadcast over the socket only to the recipients. Other clients see nothing.
8. Clients and Sockets¶
activationCode does not always run on the GM's client.
-
Auto activations run on whichever client called
handleTrigger: the client of the player whose action fired the trigger (e.g. who moved the token), or the GM if the triggering token has no online owner. -
Manual activations run on whichever client clicked Activate. Per the
reactionNotificationModesetting, that may be the token owner, the GM, or either: first to click wins.
If your activationCode needs GM-only permissions (creating tokens, modifying actors the local user doesn't own, deleting templates owned by someone else, etc.), you must either:
-
Use an API helper that already delegates internally (most
applyEffectsToTokens,placeDeployable,placeZone,addGlobalBonuscalls do this). -
Call
triggerData.sendMessageToReactor(data, gmUserId, { wait: true, ... })to delegate to the GM and wait for the response.
A common shortcut: api.getActiveGMId() returns the user ID of the active GM, suitable for sendMessageToReactor.
Reaction helpers¶
Wired onto triggerData for every reaction. Call from evaluate / activationCode.
startRelatedFlow() async - runs the reacting item's own default flow on the current client (weapon → WeaponAttackFlow, action/reactionPath → ActivationFlow, system → SystemFlow, else a simple activation card).
startRelatedFlowToReactor(userId = null, extraData = {}, opts = {}) async - same, but on userId's client (null → reactor's owner). extraData is merged into the flow and reads back as triggerData.extraData on its onActivation:
opts: { wait, waitTitle, waitDescription, waitItem, waitOriginToken, waitRelatedToken }. wait:true awaits remote completion. The wait* fields fill the local "waiting" card. extraData must be JSON-serializable. See the True Grit example.
sendMessageToReactor(data, userId = null, opts = {}) async → any - RPC to the reactor's onMessage (same opts). With wait:true, returns its result. Delegation primitive for GM-only work.
debugActivation(label?) - logs triggerType, triggerData, reactorToken, item, activationName to the console and returns the same as an object. Also on the api as api.debugActivation(triggerType, triggerData, reactorToken, item, activationName, label?). Debug mode and breakpoints: Automation Engine - Debugging an automation.
9. Cancel and Modify¶
A subset of triggers fire before the underlying action commits. From inside evaluate or activationCode, you can stop or modify the action.
The synchronous rule¶
The cancel/modify functions only work synchronously. The operative rule is one line: call the cancel or modify function before your first await. Everything after an await reaches the flow too late. An async activationCode is fine as long as the cancel call happens in its synchronous head, which is exactly the preConfirm / postChoice pattern below.
What awaitActivationCompletion actually does¶
It is not the escape hatch it looks like.
-
The default already awaits. The engine awaits an auto activation unless the flag is set to exactly
false. Leaving it unset awaits. -
On the seven cancellable triggers it does nothing.
onPreMove,onPreStructure,onPreStress,onPreStatusApplied,onPreStatusRemoved,onPreHpChangeandonPreHeatChangedefer their auto activations to the end of the sweep and invoke them without awaiting, so the flag is never read. This is the case the flag looks like it should fix, and it is the one case it cannot. -
Its one real effect is on activations written as code strings in the editor. An
asyncstring that mentions a cancel/modify function, or sits on a timing-sensitive trigger, raises a permanentui.notifications.warntelling you it will probably fail to block. Setting the flag suppresses that warning. A config registered from code with real functions never hits it.
Cancel functions¶
Signature: (reasonText?, title?, allowConfirm?, userIdControl?, preConfirm?, postChoice?, opts?). A Cancel/Ignore card is shown by default. cancelTriggeredMove omits the title slot.
| Trigger | Cancel function | Effect |
|---|---|---|
onPreMove |
cancel() |
Stops the move with no card and no reason text |
onPreMove |
cancelTriggeredMove |
Stops the move outright |
onPreMove |
changeTriggeredMove(newPos, extraData?, reason?, allowConfirm?, ...) |
Redirects the move to a new destination |
onInitAttack |
cancelAttack |
Aborts the attack flow |
onInitTechAttack |
cancelTechAttack |
Aborts the tech attack flow |
onInitCheck |
cancelCheck |
Aborts the stat check flow |
onInitActivation |
cancelAction |
Aborts the activation flow |
onInitEndActivation |
cancelAction |
Suppresses the end card, the item is already inactive |
onPreStatusApplied / onPreStatusRemoved |
cancelChange |
Blocks the status change |
onPreStructure |
cancelStructure |
Skips the structure roll |
onStructure |
cancelStructureOutcome |
Stops the outcome step (after the roll) |
onPreStress |
cancelStress |
Skips the overheat roll |
onStress |
cancelStressOutcome |
Stops the outcome step (after the roll) |
onPreHpChange |
cancelHpChange |
Blocks the HP change |
onPreHeatChange |
cancelHeatChange |
Blocks the heat change |
onInvoluntaryMove |
cancel(reason) |
Blocks the forced move |
Modify functions¶
They block the original update and commit the replacement value instead. modifyHpChange and modifyHeatChange take (newValue, reason?, allowConfirm?, userIdControl?, preConfirm?, postChoice?, opts?). Note the missing title: they drop that slot, unlike the cancels. Check the per-trigger signature below before passing positional arguments.
| Trigger | Function | Effect |
|---|---|---|
onPreHpChange |
modifyHpChange(newValue, ...) |
Override the HP value being applied |
onPreHeatChange |
modifyHeatChange(newValue, ...) |
Override the heat value being applied |
onStructure / onStress |
modifyRoll(newTotal) |
Override the roll total before outcome steps |
onRoll |
changeRoll(newTotal, reason?, title?, allowConfirm?, userIdControl?) |
Override any roll total. This one does keep title |
onStructure / onStress also expose triggerData.rollResult (total) and triggerData.rollDice (raw die results, useful for detecting double-1s or doubles).
Why preConfirm and postChoice exist¶
Foundry's preUpdate* hooks (move, actor update, etc.) are non-blocking: if your handler returns a Promise, Foundry does not wait for it. Anything after an await happens too late to stop the update.
So the pattern is:
1. Call the cancel/modify function synchronously (before any await). This preemptively blocks the update.
- Afterwards, do your async work (choice cards, remote player decisions, etc.) to decide what happens next.
preConfirm and postChoice are the two async hooks that let you drive that "what happens next" phase without needing to pre-build the UI yourself.
allowConfirm, preConfirm, postChoice¶
Shared across every cancel, change, modify, and reroll function.
allowConfirm: boolean (default true). Whether to show the secondary Confirm/Ignore card to userIdControl.
true: show the card, user can override.false: skip the card, auto-pick the first choice (the "no override" outcome).
Does NOT gate preConfirm. preConfirm always runs if provided.
preConfirm: () => Promise<boolean>. Async gate that runs after the sync cancel has stuck, before the secondary card.
- Returns
true: proceed to the secondary card (or auto-pick ifallowConfirm: false). - Returns
false: re-apply the original outcome.postChoicedoes NOT fire.
Typical use: the reactor's player confirms Yes/No in a choice card here. Good place to call triggerData.startRelatedFlowToReactor(...) so the reactor's own flow fires once on commit.
postChoice: (chose: boolean) => Promise<void>. Callback after the secondary card resolves (or after auto-pick).
chose === true: the replacement was committed.chose === false: the original was re-applied (user picked Ignore).
Typical use: fire a downstream effect that depends on whether the action went through.
Timing¶
sync: setFlag() -> engine blocks the original update
async: preConfirm() -> false: executeOriginal + return (no postChoice)
allowConfirm?
true -> show card
Confirm: executeNew + postChoice(true)
Ignore: executeOriginal + postChoice(false)
false -> executeNew + postChoice(true)
Example: True Grit¶
const preConfirm = async () => {
const ask = await api.askCard({
title: "TRUE GRIT",
description: `<b>${ally.name}</b> would fall to 0 HP. Keep at 1 HP?`,
owner: reactorToken
});
if (ask.confirmed)
triggerData.startRelatedFlowToReactor(ask.responderIds[0]);
return ask.confirmed;
};
triggerData.modifyHpChange(
1,
`${reactorToken.name} keeps ${ally.name} at 1 HP.`,
true,
api.getTokenOwnerUserId(ally),
preConfirm,
null
);
10. Economy and Frequency¶
The Lancer reaction economy (1 reaction per round) has two separate parts: the reaction config's checkReaction filters out a reactor with no reaction left before evaluate runs (only when set true), and the world setting consumeReaction, when enabled, spends one reaction each time a Reaction-type action fires.
Other frequency-related fields:
-
actionType: seven values,"Automation"(the default),"Reaction","Free Action","Quick Action","Full Action","Protocol","Other". It labels the popup entry, but it is not only a label: it is written into the flow data as the activation, which is whatconsumeReactionreads. Set it to"Reaction"and the activation can spend the token's reaction. -
frequency: display string ("1/Round","1/Combat") shown on the popup entry. Never enforced. -
outOfCombat: opt-in for triggers that wouldn't normally fire outside combat. Not needed for custom triggers.
Real limits come from two places. Item tags (tg_round, tg_turn, limited uses, loading, recharge) are enforced by the checkUsage filter. Everything else uses the gate API: await api.consumeOncePerRound(reactorToken, 'my_key', target) is true the first time this round per target, and consumeGate covers longer windows. See Flags API. The Triangulation Ping NPC feature is the worked example.
11. Flow Data Injection¶
Inside an activationCode whose trigger carries a flowState (most attack/damage/check/activation triggers), you can mutate the in-progress flow. Three methods are wired onto the state:
triggerData.flowState.injectBonus({
id: "my-bonus",
name: "Marker Rifle Mark",
type: "accuracy",
val: 1
});
triggerData.flowState.injectFlowExtraData({ myFlag: true });
const extra = triggerData.flowState.getFlowExtraData();
Lifetime. injectFlowExtraData values live on flowState.la_extraData for the rest of the flow. They're also serialized onto the resulting chat message, so a damage card produced later in the same flow can still read what was injected during the attack step. injectBonus is different: a bonus is dropped from the flow's bonus list the moment it is actually applied, so it lands on one roll and not the next.
Use this when you need a bonus to apply exactly to this one roll/attack/damage card without leaving a global bonus or a status effect on the actor.
12. Paths and Extra Actions¶
Three related mechanisms for binding reactions to sub-parts of an item and for tracking whether an item is currently "on".
Action path¶
An activation config can target a specific sub-action of an item instead of the item as a whole. The field is stored as reactionPath on disk (legacy name from when the system only handled reactions). In the UI and in this doc it is called action path.
Common forms:
-
"actions[0]"- the first action on a regular item -
"extraActions.Fall Prone"- an extra action stored on the item viaaddExtraActions -
"ranks[2]"- rank-3 of a talent -
"profiles[0]"- a weapon profile, fires when switched to
Warning
The path is relative to item.system, so it carries no system. prefix. "system.actions.0" resolves item.system.system, which is undefined, and the activation quietly never fires. Only the flow launcher tolerates the prefix, which is why a config can look half-working. The Find Action browser always emits the correct form.
At evaluation time the engine walks the path into item.system (or the extraActions flag), pulls the name off the action found there, and uses it as activationName. If onlyOnSourceMatch is set, the activation only fires when the triggering action's name matches the one at the path.
This is how one item can have multiple independent activations (different sub-actions, different talent ranks) without colliding.
Finding the right path. The item finder inside the Activation Manager lists every action on the selected item next to its action path - no need to guess the index or dig through the item's schema.
Extra actions¶
API: addExtraActions, getItemActions, getActorActions, removeExtraActions (all on InteractiveAPI, documented in HUD API).
Adds action objects (name, activation type, description, tags, etc.) onto an item, token, or actor via flags. Two storage locations:
- Passed an item: stored in the item's extraActions flag. Merged into the item's action list by getItemActions.
- Passed a token or actor: stored on the actor. Returned by
getActorActions.
Typical uses: Sniper's Mark adds a "Fall Prone" action, Limitless adds "Overcharge (NPC)", and Defense Net adds "Collapse the Defense Net" while it's active.
activation field must use TAH's short form ("Quick", "Full", "Protocol", "Free", "Reaction", "Quick Tech", "Full Tech"). Not "Quick Action" etc. TAH filters by strict equality.
Binding an activation to an extra action. Two ways:
-
By general name - register a general activation whose name matches the action name. Source matching is done on the activation name directly. This is the only option for an action injected onto a token or actor, and it also works for an item-stored one (Limitless binds its item-stored "Overcharge (NPC)" this way).
-
By action path - when the extra action is injected onto an item, register an item activation with
reactionPath: "extraActions.<Name>". The engine resolves the sub-action through the flag lookup the same way it walksactions[N].
Granting to someone else. An action added to another actor needs opts.grant on addExtraActions, which marks it with the token and item it came from. It is deleted when that token or item is deleted. A condition on the action gates it the rest of the time, returning hidden, locked or disabled. Both in HUD API - Granted actions. The Rotary Grenade Launcher in startups/itemActivations.js is the worked example: an aura hands adjacent allies a Quick action that reloads the owner's weapon.
Access. Extra actions are currently only surfaced through the Lancer Automations TAH. Other UIs (the native Lancer sheet, the native action bar, etc.) do not show them. If the TAH is disabled, extra actions are invisible to the user even though they still fire when triggered from code.
Activated items lifecycle¶
For items that stay "on" after being activated (auras, persistent effects, stances), the engine tracks an activated state per token.
-
setItemAsActivated(item, token, endAction, endActionDescription = "", options = {})marks the item as active. Adds an extra action (the "end action") with the given activation type and description, e.g."Protocol"and"Collapse the Defense Net". That action shows up in the TAH so the user can click to end.optionscovers the lock on the original action:blockAction(on by default),actionName,blockReason. -
getActivatedItems(token)returns the currently-active items on a token. Use inevaluateto gate other reactions behind "only while this item is on". -
endItemActivation(item, token)clears the state directly from code. Also removes the end-action.
How the three combine¶
The end action raises onEndActivation instead of onActivation (and onInitEndActivation instead of onInitActivation). List both triggers to handle setup and teardown in one reaction:
triggers: ["onActivation", "onEndActivation"],
activationCode: async function (triggerType, triggerData, reactorToken, item, activationName, api) {
if (triggerType === "onEndActivation") {
await teardownEffect(reactorToken, item, api);
return;
}
await setupEffect(reactorToken, item, api);
await api.setItemAsActivated(item, reactorToken, "Protocol", "Collapse the Defense Net");
}
Other triggers on the same item can gate themselves by checking getActivatedItems:
evaluate: function (triggerType, triggerData, reactorToken, item, activationName, api) {
if (!api.getActivatedItems(reactorToken)?.some(i => i.id === item.id))
return false;
...
}
Forced teardown from a separate trigger (e.g. the wearer gets stunned and the field collapses) calls endItemActivation directly, or calls the teardown helper and then endItemActivation to clean up the end-action row.
Defense Net in startups/itemActivations.js is the reference implementation (onActivation for setup + onEndActivation teardown, onStatusApplied with getActivatedItems to force-collapse when stunned/jammed, onHeatGain / onTechMiss reactions gated by getActivatedItems).
13. Registration Paths¶
There are four ways to register an activation. They all end up in the same dispatcher.
A. Activation Manager UI¶
Module Settings > Activation Manager. Create item or general activations through forms. Stored in customReactions / generalReactions world settings. Best for one-offs and homebrew.
B. Module-time registration¶
Hooks.on("lancer-automations.ready", (api) => {
api.registerDefaultItemReactions({
"lid_of_the_item": {
category: "Homebrew",
itemType: "npc_feature",
reactions: [{
triggers: ["onActivation"],
onlyOnSourceMatch: true,
triggerSelf: true,
triggerOther: false,
autoActivate: true,
activationType: "code",
activationMode: "instead",
activationCode: async function (triggerType, triggerData, reactorToken, item, activationName, api) {
}
}]
}
});
api.registerDefaultGeneralReactions({
"My General Reaction": { reactions: [] }
});
});
External registrations are merged with last-write-wins semantics: re-registering the same key replaces the previous entry.
C. Built-in defaults¶
The same two functions are used by startups/itemActivations.js for the bundled NPC/feature automations, with the same result as B.
D. Startup scripts¶
Module Settings > Activation Manager > Startup tab. Arbitrary code blocks run once on ready. Useful for registering helper functions on the API (api.registerUserHelper("myHelper", fn)) so your activation code can call them by name.
Override order¶
For a given key (LID or general name), the resolution order is roughly: user UI definitions override external registrations override built-in defaults. Re-registrations within the same path replace.
14. Caches and Invalidation¶
If a reaction you just changed or registered doesn't take effect, clear the caches:
That hook is the only thing that invalidates them. Nothing about an item or an actor changing does. Three caches listen to it:
-
Flat general reaction list. Built once, used for every general-reaction sweep.
-
Per-trigger non-action list. The general reactions that listen to one trigger, keyed by trigger type.
-
Per-trigger scene list. The same, for reactions with
sceneReactoron.
The UI fires the hook on every save, and so does registerDefaultItemReactions / registerDefaultGeneralReactions. You only need to call it by hand when you have edited a registry from the console.
There is no per-actor item cache: getReactionItems rebuilds an actor's list from actor.items on every trigger. If a reaction stopped firing after an item change, the cache is not why.
Separate from all three, the compiled function cache holds the functions built from UI code strings, keyed by code, argument list and source name. The hook does not clear it. The UI's own save path clears it and then fires the hook.