Rules System
Nimble's Rules system is a set of generic, data-driven modifiers attached to items. When an actor prepares its data, the rules on every embedded item run lifecycle hooks that mutate the actor's derived data. This is how class features, magic items, ancestry traits, and homebrew options affect a character without writing per-feature code.
Design Philosophy
Rules are intentionally generic building blocks, not named after specific features. A rule like abilityBonus can be used by any item — a class feature, a magic item, a racial trait — to grant an ability score bonus. This generality enables homebrewing: game masters can compose any combination of rules on any item to create custom features without code changes.
When creating a new rule type, name it after what it does (speedBonus, grantProficiency), never after a specific feature that uses it.
Architecture
- Base class:
NimbleBaseRule(src/models/rules/base.ts) extendsfoundry.abstract.DataModel. - Registration:
src/config/registerRulesConfig.tsmaps type strings to classes inCONFIG.NIMBLE.ruleDataModelsand to i18n labels inCONFIG.NIMBLE.ruleTypes. - Storage: Plain objects in
item.system.rules(anArrayFieldofObjectField). Each entry hasid,type,disabled,priority,predicate, plus type-specific fields. - Instantiation:
RulesManager(src/managers/RulesManager.ts) is created per item inprepareBaseData(). It looks up the class fromCONFIG.NIMBLE.ruleDataModelsand instantiates it with the item as parent.
Lifecycle Hooks
The actor collects all enabled rules from all items, sorts by priority (lower runs first), then calls hooks in this order:
prePrepareData()— duringactor.prepareDerivedData(). Modify actor system data (bonuses, stats).afterPrepareData()— afteractor.prepareData()completes. Final adjustments.preCreate(args)— before item creation on actor. Can grant other items, modify pending items.preUpdate(changes)/afterUpdate(changes)— around item updates.afterDelete()— after item deletion, revert modifications.preUpdateActor(changes)— before actor update; can create/delete embedded items.
Additional event hooks (combat, save, rest, item-used, etc.) are dispatched from the corresponding system events. See NimbleBaseRule for the full surface.
Key Patterns
- Guard with
isEmbedded: Always startprePrepareData()withif (!this.item.isEmbedded) return;. Rules on un-embedded items have no actor to mutate. - Predicate testing:
this.test()checks domain tags from the predicate. An empty predicate always passes. - Formula resolution:
this.resolveFormula(formula)evaluates against the actor's roll data and returns a number. - Forward declarations: Use local interfaces for
NimbleBaseActor/NimbleBaseItemto avoid circular imports — see the pattern inbase.ts. - Mutate via
foundry.utils.setProperty(): e.g.,foundry.utils.setProperty(actor.system, 'abilities.str.bonus', newValue).
Predicates & Domain Tags
Every rule has a predicate field (a PredicateField) that gates whether the rule applies. When this.test() is called, the predicate is evaluated against the actor's domain — a Set<string> of tags describing the actor's current state.
Predicate syntax
Leaf forms
// Atomic — key:value must exist in domain
{ "armor": "unarmored" } // domain.has("armor:unarmored")
// Array OR — at least one value must match
{ "armor": ["unarmored", "light"] } // domain.has("armor:unarmored") || domain.has("armor:light")
// Binary — min / max / equal against numeric tag suffixes
{ "level": { "min": 5 } } // any "level:N" in domain where N >= 5A min/max binary op requires the keyed tag to be present in the domain: if no key:N tag matches, the op fails (it does not vacuously pass). This is what makes { "alliesAdjacent": { "min": 1 } } correctly fail when no allies are adjacent. Note this applies to max too — a count-style tag that is simply absent at zero (e.g. enemiesAdjacent, which is only emitted for counts above zero in combat) makes { "enemiesAdjacent": { "max": 2 } } fail rather than treating the absent tag as "0 enemies". Gate such rules on presence explicitly (e.g. combine with an in-combat atom) if you need the zero case to pass.
Composition with $and / $or
For tags whose value is already part of the key (e.g. self:bloodied, target:concentrating) or for combining tags across namespaces, use the $and / $or operators. Their value is an array — each element is either an atom string (presence-checked against the full tag) or a sub-predicate object for nesting.
// AND — every atom must be present
{ "$and": ["self:raging", "self:bloodied"] }
// OR — at least one atom must be present
{ "$or": ["self:bloodied", "self:dying"] }
// Berserker: raging AND (bloodied OR dying) — nest with a sub-predicate object
{
"$and": [
"self:raging",
{ "$or": ["self:bloodied", "self:dying"] }
]
}
// Mix atoms with other leaf forms inside the array
{
"$and": [
"self:bloodied",
{ "armor": "unarmored" },
{ "level": { "min": 5 } }
]
}The top-level object is an implicit AND over its entries, so you can combine a $or with other leaf forms at the top level:
{
"armor": "unarmored",
"$or": ["self:bloodied", "self:concentrating"]
}$and: [] is vacuously true; $or: [] is vacuously false.
Domain tags
Tags are populated during _populateDerivedTags() in actor data prep, before rules run.
Lifecycle timing — which tags are available when
Not every tag exists at every lifecycle phase. Three populating points, in order:
prepareBaseData()→_populateBaseTags()— emitssize:*anddisposition:*. Available everywhere downstream.prepareDerivedData()start →_populateDerivedTags()— emits the bulk of the vocabulary:self:bloodied | dying | lastStand | concentrating,self:fullHp,target:bloodied | concentrating,enemiesAdjacent:*, characterclass:* / ancestry:* / background:* / level:* / armor:* / self:shield | noShield / proficiency:*. The base actor runs_prepareEarlyDerivedData()first (characters computehp.maxthere) so HP-derived tags are fresh, then populates tags just beforeprePrepareDatahooks fire — so these tags are visible in bothprePrepareDataandafterPrepareData.- Late in
prepareDerivedData()(after ability mods are finalized) — emits the character<ability>:<mod>tags. Ability mods can't exist earlier:abilityBonusrules contribute to them duringprePrepareData, so these tags are visible only inafterPrepareDataand later hooks.
A rule whose effect runs in prePrepareData therefore cannot gate on an <ability>:<mod> tag — the predicate would never match. This is enforced by guardrails rather than left silent: the Rules Builder's predicate editor shows a warning banner (instead of the match preview) when an early-phase rule references a key in CONFIG.NIMBLE.LATE_PREDICATE_KEYS, and rule construction emits a once-per-rule console.warn for the same condition. Whether a rule class is early-phase is introspected automatically via NimbleBaseRule.appliesInPrePrepareData (true when the class implements a prePrepareData method) — never add a no-op prePrepareData for documentation purposes, as it would falsely mark the rule early.
Tags on all actors
| Tag | Source | When |
|---|---|---|
size:<category> | sizeCategory attribute | Always |
disposition:<type> | Token disposition | Always |
enemiesAdjacent:<count> | Adjacency sync | In combat |
enemiesAdjacent:most | Adjacency sync | Has most adjacent enemies |
alliesAdjacent:<count> | Adjacency sync | In combat |
alliesAdjacent:most | Adjacency sync | Has most adjacent allies |
self:bloodied | actor.statuses | Bloodied status active |
self:dying | actor.statuses (dying) | PC/Hero at 0 HP with wounds remaining |
self:lastStand | actor.statuses (lastStand) | Solo/Legendary monster phase change at 0 HP |
self:fullHp | HP value/max | HP equals max |
self:concentrating | actor.statuses | Concentration status active |
target:bloodied | actor.statuses | Bloodied status active (for targetCondition) |
target:concentrating | actor.statuses | Concentration status active (for targetCondition) |
Character-only tags
| Tag | Source | When |
|---|---|---|
level:<n> | Class data | Always |
class:<identifier> | Class items | Per class |
ancestry:<identifier> | Ancestry item | If present |
background:<identifier> | Background item | If present |
armor:equipped / armor:unarmored | Equipment scan | Has armor with armorClass rules |
self:shield / self:noShield | Equipment scan | Has shield item equipped |
proficiency:armor:<type> | Proficiencies | Per armor proficiency |
proficiency:weapon:<type> | Proficiencies | Per weapon proficiency |
proficiency:language:<type> | Proficiencies | Per language |
<ability>:<mod> | Ability scores | After ability mods computed (visible in afterPrepareData only) |
Actor type tags
| Tag | Actor type |
|---|---|
solo-monster | Solo Monster |
minion | Minion |
targetCondition on damageBonus
The damageBonus rule has an optional targetCondition field — a predicate evaluated against the target's domain at activation time (not the rule owner's domain). This enables bonuses that gate on target state:
// +@level damage when the target is bloodied
{
"type": "damageBonus",
"value": "@level",
"delivery": "any",
"source": "any",
"targetCondition": { "$and": ["target:bloodied"] }
}
// +1d6 damage when the target is bloodied OR concentrating
{
"type": "damageBonus",
"value": "1d6",
"delivery": "any",
"source": "any",
"targetCondition": { "$or": ["target:bloodied", "target:concentrating"] }
}targetCondition is evaluated via getTargetDomain(), which returns only target:* tags. This prevents self:* tags from the target actor leaking into the evaluation.
When no target is selected, bonuses with targetCondition are excluded. Bonuses without targetCondition (or with targetCondition: {}) always apply regardless of target.
TIP
targetCondition is only available on damageBonus. Other rule types use the standard predicate field which evaluates against the rule owner's domain.
toggleEffect (player-controlled tag pushes)
toggleEffect is a foundation rule that pushes one or more domain tags into the actor's domain while a backing Foundry ActiveEffect is enabled. The rule itself doesn't carry nested rules or express any modifiers; its only job is the tag push. Sibling rules elsewhere predicate on those tags via the standard predicate field.
{
"type": "toggleEffect",
"label": "Rage",
"tags": ["self:raging"],
"turnOff": ["onActorKilled", "onEncounterEnd", "onRest"]
}Lifecycle
- Toggle on: the player activates the item. The rule's
onItemActivatedhook creates a FoundryActiveEffecton the actor (or re-enables a disabled one), flagged withnimble.toggleEffectRuleIdandnimble.toggleEffectItemId. The AE shows up in the Foundry effects panel. Re-activating the item while the AE is already enabled is a no-op. Item use is "ensure on," never "flip off," so re-rolling resources mid-rage can't accidentally drop the effect. - Toggle off (manual): the player disables (or deletes) the AE via the effects panel.
- Toggle off (event): any event listed in
turnOffdeletes the AE when it fires for the rule's owning actor. - Tag push: during
prePrepareData()the rule scans the actor's effects; if a matching AE exists and is not disabled, every entry intagsis added toactor.tags. Tags drop on the next prep when the AE is gone or disabled.
turnOff triggers
| Value | Fires from |
|---|---|
onActorKilled | updateActor when HP drops to 0 with a full wound track (or no wound track, e.g. monsters) |
onActorWounded | updateActor on bloodied / lastStand transition |
onRest | nimble.rest hook |
onTurnStart | combatTurn start of owner's turn |
onTurnEnd | combatTurn end of owner's turn |
onEncounterEnd | updateCombat(started:false) or deleteCombat (dedup'd) |
onActorDying | updateActor when HP drops to 0 with the wound track below max, or nimble.conditionApplied with condition: 'dying' |
Modifying a toggle from another feature: modifyToggle
A modifyToggle rule (matched to the target toggle by toggleIdentifier, falling back to the toggle rule's id) can adjust the toggle's lifecycle from any of the actor's items:
suppressTurnOff: turn-off events the target toggle should ignore while the modifier's predicate passes.turnOn(onTurnStart|onActorDying|onCritReceived): events on which the target toggle switches on automatically. The modifier's own predicate gates the turn-on, so the granting feature carries its condition (e.g.{ "self": "dying" }for a while-Dying auto-activation). Auto-turn-on restores the toggle's state only — it creates or re-enables the backing AE (GM-side, with a chat announcement) but does not run the owning item's activation effects.onCritReceivedis driven by the defender-sideonAttackReceiveddispatch, which fires when damage from a critical hit is applied to the actor.
Worked example: Rage
The Rage item carries the toggleEffect plus its sibling modifiers. Other "while raging" features (Intensifying Fury, etc.) live on their own items and just predicate on self:raging:
[
{
"type": "toggleEffect",
"tags": ["self:raging"],
"turnOff": ["onActorKilled", "onEncounterEnd", "onRest"]
},
{
"type": "damageBonus",
"value": "@level",
"delivery": "melee",
"source": "weapon",
"predicate": { "self": "raging" }
}
]Priority note
toggleEffect.prePrepareData() pushes tags during the prePrepareData pass. The default priority is 1 (the base default). Bonus-style rules that consume the tag in afterPrepareData (the common case: damageBonus, damageReduction, etc.) always see the tags because afterPrepareData runs after every rule's prePrepareData. If a sibling rule also runs in prePrepareData and predicates on the pushed tag, set the toggleEffect rule's priority lower than the sibling's (e.g. 0) so it runs first and the tag is in place when the sibling tests its predicate.
RulesManager API
RulesManager extends Map<string, NimbleBaseRule>:
addRule(data, options?)/updateRule(id, data)/deleteRule(id)— CRUD.hasRuleOfType(type)/getRuleOfType(type)— query by type.disableAllRules()/enableAllRules()— bulk toggle.
Creating a New Rule Type
- Create
src/models/rules/yourRule.tsextendingNimbleBaseRule. - Define a local
schema()function returning type-specific Foundry data fields. - Override
defineSchema()mergingNimbleBaseRule.defineSchema()with your schema. - Implement lifecycle hooks (most commonly
prePrepareData()). - Register in
src/config/registerRulesConfig.ts— add to bothruleTypesandruleDataModels. - Add the i18n label key to
en.json(underNIMBLE.ruleTypes.<key>). - Add a description i18n key under
NIMBLE.ruleDescriptions.<key>for the builder UI. - Make the rule renderable in the Rules Builder — see below.
- Keep the rule generic — it should be reusable across any item type.
- Add a co-located test (
src/models/rules/yourRule.test.ts). Mock actor/item, instantiate the rule directly, and verify the lifecycle hook mutates actor data correctly. SeespeedBonus.test.tsfor the pattern. - The user documentation's rule reference is generated automatically from your schema (
pnpm docs:generate), so labels, hints, and choices must be user-comprehensible. Optionally add a hand-written worked example atdocs/documentation/reference/_partials/<key>.md(no headings; start with**Example — <item name>:**) — it is inlined under your rule's entry.
Minimal class skeleton
class AbilityBonusRule extends NimbleBaseRule<AbilityBonusRule.Schema> {
static override group = 'bonuses';
static override description = 'NIMBLE.ruleDescriptions.abilityBonus';
static override defineSchema(): AbilityBonusRule.Schema {
return { ...NimbleBaseRule.defineSchema(), ...schema() };
}
prePrepareData(): void {
if (!this.item.isEmbedded) return;
const actor = this.item.actor as NimbleCharacter;
const value = this.resolveFormula(this.value);
for (const ability of this.abilities) {
const baseBonus = actor.system.abilities[ability]?.bonus ?? 0;
foundry.utils.setProperty(actor.system, `abilities.${ability}.bonus`, baseBonus + value);
}
}
}Rules Builder Integration
The rules-builder UI (src/view/rulesBuilder/) auto-generates a card per rule from defineSchema(). There is no per-rule UI code — every rule is rendered by SchemaFieldRenderer.svelte reading the schema metadata. To make your rule first-class in the builder:
Class-level metadata (required)
class YourRule extends NimbleBaseRule<YourRule.Schema> {
static override group = 'bonuses';
static override description = 'NIMBLE.ruleDescriptions.yourRule';
// ...
}static group— bucket in the rule-type picker. Existing groups:'bonuses','grants','triggers','resources','flavor'. Defaulting to'unsorted'triggers a dev-mode warning.static description— i18n key shown in the card's help tooltip and the picker's grid. Add the string toen.jsonunderNIMBLE.ruleDescriptions.<key>.
Per-field metadata (required)
Every field a user edits must carry:
label:— display label. Without it,RuleCardauto-generates an English-only label from the camelCase property name and warns once per(rule.type, field)in dev. Localized labels are required for shipping.hint:— short help text shown below the input. Optional but strongly recommended for any non-obvious field.
Widget hints (when type alone isn't enough)
Foundry's data-field types map automatically to widgets, but some fields need a hint via the withWidget() helper:
import { withWidget } from './_widgetOption.js';
value: new fields.StringField(
withWidget({
required: true,
nullable: false,
initial: '@level',
label: 'Bonus',
hint: 'Flat (5), formula (@level), or dice (1d6+2).',
widget: 'formula',
}),
),The closed widget catalog is formula | diceFormula | documentUuid | predicate | templateString | richText | dicePoolPicker | chargePoolPicker | hidden. withWidget() validates the hint in dev and warns on typos.
For widget: 'documentUuid', set documentTypes: ['Item.spell'] (or ['Item'], ['Actor']) to gate accepted drops.
Conditional widgets
When the right input depends on a sibling field, pass a function instead of a hint. It receives the same data object as showWhen:
poolIdentifier: new fields.StringField(
withWidget({
required: true,
label: 'Pool',
widget: (data) => (data.poolType === 'charge' ? 'chargePoolPicker' : 'dicePoolPicker'),
}),
),The resolved value must still be in the catalog — withWidget() cannot check a resolver's return value, so <SchemaFieldRenderer> warns at render time if it isn't. 'hidden' works here too, giving conditional visibility that depends on which widget applies.
Write the fallback branch to match the sibling field's initial value: the generated reference docs resolve the function against schema initials, so that branch is what gets documented (the field's description then notes that the input varies).
Conditional fields with showWhen
Hide a field based on the current rule's data:
count: new fields.NumberField({
required: false,
nullable: true,
label: 'How many to choose',
showWhen: (data) => data.mode !== 'auto',
} as unknown as never),Type cast quirk
SchemaField/ArrayField options don't accept showWhen through withWidget()'s leaf-only generic, so use a raw as unknown as never cast at the option-object site. See applyCondition.ts and grantSpells.ts for live examples.
Default field-type rendering
| Schema construct | UI |
|---|---|
BooleanField | checkbox |
NumberField | number input (respects min/max/step/integer) |
StringField (no choices) | text input |
StringField (with choices) | <select> (string[] or Record<key, label>; functions evaluated each render) |
HTMLField | rich-text editor |
PredicateField | predicate builder |
ArrayField<StringField> (with choices) | tag group |
ArrayField<StringField> (no choices) | add/remove string list |
ArrayField<NumberField> | add/remove number list |
ArrayField<SchemaField> | fieldset list (recursive render) |
SchemaField | nested fieldset |
| anything else | inline-error block + console warn |
Adding a new ArrayField<X> element type requires extending SchemaFieldRenderer.svelte — the dispatch is intentionally closed.
Envelope fields (rendered by RuleCard, not as schema fields)
id, type, disabled, identifier, label, priority, predicate, suppressActivationCard are surfaced by the rule card itself (header, advanced section). Don't add label: / hint: / widget: to them — they're filtered out of the per-field render. The list lives in three places that must stay in sync: this section, FIXED_FIELDS in RuleCardState.svelte.ts, and BASE_RULE_FIELDS in scripts/docs/generateReference.gen.ts.
suppressActivationCard is a tri-state (auto / always / never) select in the advanced section. always and never force the resolution of suppressesActivationCard(); auto (the default) defers to the rule class's _autoSuppressesActivationCard() — false on the base class, overridden by diceConsumer for manual spend flows. Subclasses override the protected _auto hook, never the public method. Each rule resolves independently and the item suppresses the card when any enabled rule resolves true; a rule set to never only stops that rule from suppressing, it does not veto others. The item-side guard is unchanged: a card that carries rolls or effect nodes is never suppressed.
The auto branch only fires when the rule automation setting ("Auto-Apply Conditions from Rules") is enabled, because the replacement output it defers to comes from the activation dispatch that setting gates. The item passes that state to suppressesActivationCard({ automationEnabled }). always has no replacement flow to wait on, so it suppresses regardless of the setting.
Coverage test
src/view/rulesBuilder/components/RuleCard.allRules.test.ts instantiates every registered rule type with default values and fails on any inline-error block or "no widget" warning. It runs automatically — your new rule is covered as soon as it's registered.
Pool storage vs. pool consumption
Both pool kinds (dicePool, chargePool) are pure storage rules — they declare the pool (max, dieSize, initial state, refill/recovery triggers) but say nothing about how the pool is spent. Spending is the job of a paired consumer rule on the same item.
| Pool type | Storage rule | Consumer rule | Consumption modes |
|---|---|---|---|
| Charges (scalar count) | chargePool | chargeConsumer | spend on activation (cost formula) |
| Rolled dice (face array) | dicePool | diceConsumer | manual (dialog spend) / autoBonus (auto-add to qualifying attacks, no consume) |
A dicePool rule with no paired diceConsumer defaults to manual spending — the dialog prompts the player at activation time. To make a pool snowball as a damage bonus (Berserker Fury Dice), add a sibling diceConsumer with mode: 'autoBonus' and the desired bonusOnAttackDelivery filter ('melee', 'ranged', 'any', or null).
Multiple diceConsumer rules can target the same pool — e.g. an autoBonus consumer for outgoing damage and a manual consumer that a reaction effect spends from. This is how features like Berserker's "That all you got?!" reaction share the Fury Dice pool with the auto-bonus damage path.
Pool modifiers and refill gating
- Refill predicates: each
dicePool.refillsentry accepts an optionalpredicate, evaluated against the actor's live domain when the trigger fires (e.g.{ "self": "raging" }to gate a turn-start refill on an active toggle). Entries without a predicate always apply. modifyPool.addRefills: a modifier can contribute refill entries to the target pool without editing the base pool rule — the granting feature carries its own trigger. The modifier's rule-level predicate gates whether the entries are included at all; entry-level predicates are evaluated at trigger time (prefer these for state that flips mid-combat, since rule-level gating churns the stored pool definition).modifyPool.minFace: a minimum face value for dice rolled into the target pool. Rolls below the floor are raised to it at every roll point (refills, activation rolls, initial seeding); manual face edits stay unclamped. The highest floor among contributing modifiers wins.modifyConsumer: augments the effect formula ofdiceConsumerrules targeting a pool. Matching consumers'effectFormulagains+ (appendFormula), with an optionaleffectTypeFilterto restrict the change to e.g.damageReductionspends. Applied at consumer enumeration time, so both the spend panel and its preview reflect the change.poolGainMessage: posts a chat reminder whenever the targeted dice pool gains dice.formulais resolved against actor data and interpolated intomessagevia{value}.- Dice refill triggers wired to dispatchers:
onAttackedandonCritReceived(damage-application pipeline),onTurnStart/onTurnEnd(turn-boundary custom hooks, GM-side), andencounterEnd. Other declared triggers have no dispatcher yet. maximizeDiepool node action: an activation effect node that raises the lowest N faces of a dice pool to the die's maximum.
Activating an item with a manual consumer opens the pool's spend panel, opening the character sheet first if it is closed. Because the spend flow posts its own chat card, the item's default activation card is suppressed — but only while the rule automation setting ("Auto-Apply Conditions from Rules") is enabled, since that setting gates the activation dispatch that opens the panel. With automation off, activation posts the normal card instead. This automatic suppression is the consumer rule's auto behavior for the suppressActivationCard envelope field (see the envelope-fields section); set the field to never on the consumer to keep the card, or to always on any rule to silence a card-less activation outright.
A manual consumer's effectType controls what its effect roll produces. The default, generic, posts the rolled total to chat. damageReduction additionally banks the total on the actor as a one-shot incoming-damage reduction: the next time damage is applied to the actor, the banked amount is subtracted (after armor, alongside any damageReduction rule entries) and then cleared, even when it absorbs the damage entirely. This is how "That all you got?!" applies its reduction automatically: the player spends Fury Dice when attacked, and the GM's Apply Damage click consumes the banked amount.
The bank is stored as an Active Effect on the actor named for the pending amount ("Damage Reduction (8)"). Repeated spends accumulate onto the same effect. Deleting the effect drops the banked reduction; disabling it suspends it (a disabled bank neither applies nor gets consumed). Banks expire when the combat ends (src/hooks/bankedDamageReductionExpiry.ts, active-GM-gated, same end-of-combat dedup as the encounter-end dice trigger); banks created outside combat persist until consumed or removed. A bank is only consumed when the hit would otherwise deal damage — immunity or armor zeroing the hit leaves it in place.
Damage reduction: flat, half (resistance), and immunity
damageReduction rules have a mode: flat (default) subtracts the resolved value; half halves the damage instead and ignores value. Monster actors additionally carry attributes.damageResistances and attributes.damageImmunities (damage-type keys, editable in the NPC meta config dialog); a matching resistance is equivalent to an untyped-scope half entry, and a matching immunity zeroes the hit outright.
Application order in calculateAdjustedDamage (src/documents/chatMessage.ts): outcome/armor halving → immunity (zero) → resistance halving (applies once, no matter how many sources match — no quartering) → flat rule reductions + banked reduction → clamp at zero → temp HP (inside actor.applyDamage). Halving rounds up, matching the heavy-armor convention. The books don't specify resistance-vs-reduction ordering; halving first keeps flat reductions fully effective. attributes.damageVulnerabilities is stored and editable but not yet automated (the vulnerability rule interacts with armor and needs its own pass).
The system never hides Active Effects: every enabled AE on an actor renders on the token, on the canvas conditions panel, and in the sheet's effects lists, regardless of what created it. New rules that back their state with an AE get this visibility for free.
Modifying incoming attacks (modifyIncomingAttack)
modifyIncomingAttack rules modify attacks made against an actor. The modifier field picks the effect:
| Modifier | Applies | Effect |
|---|---|---|
disadvantage | Automatic, pre-roll | One disadvantage level per matching rule, pushed into the attack roll's rollModeSources (cancels 1-for-1 with advantage) |
autoMiss | Automatic, pre-roll | The attack roll is forced to a miss (forceMiss on DamageRoll), even against attacker-side "cannot miss" effects |
forceReroll | Interactive or automatic | Discard the roll and roll once more; the second result stands. See the reroll options below |
redirectToSelf | Interactive | An Interpose offer: when an ally within range spaces is targeted, the rule's owner may swap in as the target |
disadvantage, forceReroll, and autoMiss fire when the rule's owner is the attack's target; the predicate is tested against the owner's own domain at attack time (positional tags such as alliesAdjacent are fresh). redirectToSelf is protector-side: it fires when an ally within range spaces (default 2) is targeted, with the predicate tested against the protector's own domain.
These rules are not consulted during data preparation. The attacker's activation flow (ItemActivationManager, and the Zephyr unarmed strike path) reads the first target's rules through computeIncomingAttackPlan (src/utils/incomingAttackModifiers.ts) when the attack roll is built. Scope limits: only the first target is consulted (matching the targetCondition precedent), AoE attacks are exempt because their single shared roll must not absorb one target's defensive rules, and minion group attack cards are not covered.
Interactive reactions
forceReroll and redirectToSelf offers are stamped onto the attack card at creation (incomingReactions in src/models/chat/common.ts) and rendered as buttons visible to the reacting actor's owner and the GM, labeled with the granting feature and actor. Clicks route through a GM proxy socket (src/utils/incomingAttackReactions.ts, same pattern as combatTurnActions.ts); the primary active GM's client mutates the message. A forced miss makes the offers moot, so none are stamped alongside it.
Interpose offers come from two sources. Every living allied character within 2 spaces of the target gets the baseline heroic Interpose offer without needing any rule; using it spends the standard combined reaction through the combat tracker (with the usual already-spent confirmation). A redirectToSelf rule adds feature-granted offers that can bend the baseline rules (longer range, non-character protectors such as a Beastmaster companion); those do not auto-spend a reaction, since the granting feature governs the cost. When the same token qualifies both ways, the rule-granted offer wins.
Using an Interpose offer swaps the attack card's target for the protector and posts the standard Interpose reaction announcement. Damage, armor, and reductions then resolve against the new target when the GM applies damage. Token movement (the protector entering the ally's space) stays manual. A forced reroll rebuilds the damage roll from its serialized options on the GM client (dice animation happens there) and records the discarded roll on the damage node; rerolling after damage was already applied is not blocked, so apply damage last. Only the primary damage node (the first DamageRoll in the effect tree) is rerolled, and the card's hit/crit outcome mirrors that node — attacks that fan out into multiple independent damage rolls are outside this scope.
Reroll options
forceReroll rules carry three extra fields:
automatic: when true, the reroll fires at roll time with no button (for mandatory rerolls such as Mountain's Endurance rerolling an incoming crit). When false, an interactive button is offered instead.rerollTrigger:always(any attack),hit(only when the attack is not a miss), orcriticalHit(only on a crit). This gates when an automatic reroll fires and, for interactive offers, whether the button is shown for the rolled outcome.rerollWithDisadvantage: when true, the reroll is made at disadvantage rather than a straight reroll (for example Pocket Sand, whose blinded attacker rerolls at disadvantage, or FAST). One disadvantage level is appended to the reroll's roll-mode sources.