Files
hooks-lib/docs/HOOK_CONTRACT.md
Kaysser Kayyali 2fabb5e98f v0.4.1: drop renderChatMessage, register renderChatMessageHTML
Foundry v13 deprecated renderChatMessage in favor of
renderChatMessageHTML (which passes an HTMLElement, not a jQuery
wrapper). Subscribing to the deprecated hook re-emits Foundry's
compatibility warning on every chat render in worlds still running
v13.351 (the foundry-hooks-lib module's tests run against such a
world).

v0.3.0 already narrowed scope to Foundry v14 only (HOOK_CONTRACT.md
section 9), but the registered hook set still included
renderChatMessage as a legacy fallback. There is no Foundry v14
hook by that name, so the entry was dead weight — and worse, any
v13.351 world running the v14-only library would still see the
deprecation warning every chat render.

Changes:
- registered-hooks.js: replace renderChatMessage entry with
  renderChatMessageHTML. Update arg shape (HTML passes HTMLElement,
  not jQuery). Add comment explaining the deprecation.
- README.md / HOOK_CONTRACT.md section 6: list renderChatMessageHTML
  instead of renderChatMessage.
- tests/verify-hooks-lib.mjs: update stub arg shape from
  [{id}, {}, {}] to [{id}, {}] (v14 signature).

Verification:
- node tests/verify-hooks-lib.mjs: 546/546 (unchanged)
- node tests/perf.mjs: 6/6, median 0.0003ms/fire (well under
  the 0.1ms budget in HOOK_CONTRACT.md section 7)
- node --check on all scripts + tests: clean

Push: Gitea only.

Note: battle-focus's own main.js line 144 still has a
Hooks.on('renderChatMessage', ...) listener for its 'Open in
Journal' button wiring. That listener fires the deprecation warning
on the user's console. Fixing it is a battle-focus change, out of
scope for this turn (hooks-lib only).
2026-06-20 22:49:32 -04:00

19 KiB

HOOK_CONTRACT.md — hooks-lib v0.3.0

Status: Implemented spec for v0.3.0. Audience: hooks-lib authors + consumer authors + system adapter authors. Stability: v0.3.0 ships as the contract; breaking changes require a major bump.

Scope: Foundry v14 only. v0.2.0's anti-corruption layer absorbed v13/v14 hook renames; v0.3.0 narrows to v14. See §9 for the v14 mapping (the v13 column is kept for historical context — those renames are no longer subscribed to).


1. Mission

hooks-lib is a generic Foundry hook facade. It subscribes to every Foundry hook that this contract defines, exposes each fire as a uniform envelope, and provides a query API for consumers (modules that want to react to gameplay events).

It is not a domain library. It does not know what a "combat," an "actor," or a "damage roll" means. It does not maintain state. It does not filter for consumers. It does not render anything.

The library is an anti-corruption layer between Foundry (whose hook names and arities change between versions) and consumers (whose business logic should not have to rewrite every Foundry upgrade).

System-specific knowledge lives in system adapter repos (e.g. hax-hooks-dnd5e), not in hooks-lib.


2. Envelope shape

Every fire produces exactly one envelope. The shape is fixed across all hooks and all consumers.

{
  ts:   1719000000000,    // epoch ms; set immediately before Hooks.on dispatch returns
  hook: "updateActor",    // the Foundry hook name that fired
  args: [doc, change, options, userId]  // positional args, verbatim from Foundry
}

Fields:

Field Type Required Notes
ts number yes Date.now() taken at the moment the wrapper invokes the Foundry handler. NOT when the consumer reads it.
hook string yes The Foundry hook name. Stable across Foundry minor versions (this contract maps semantic names where Foundry renames things; see §8).
args array yes Positional args exactly as Foundry passed them. May be empty. May contain non-serializable refs (Foundry Documents).

What the envelope does NOT contain:

  • No kind. The library doesn't classify events.
  • No normalized fields (actorId, combatId, etc). The library doesn't interpret args.
  • No consumer metadata, no subscription ids, no callbacks.

Consumers that want {kind, actorId, delta} build that themselves from the args. This is intentional: the library is the boundary that absorbs Foundry version churn.


3. Subscriber API

The library exports four subscribe primitives. All return an unsubscribe function.

3.1 subscribe(hookName, fn) — single hook, single handler

const unsub = hooksLib.subscribe("updateActor", (envelope) => {
  const [doc, change, options, userId] = envelope.args;
  // consumer logic
});
unsub(); // remove
  • hookName must be in the registered hook set (§6). Calling with an unregistered name throws TypeError synchronously. This catches typos at registration time, not at first fire.
  • fn is called with one argument (the envelope).
  • Multiple subscribe("updateActor", ...) calls are allowed and stack. Order is registration order.

3.2 subscribeMany({hookName: fn, ...}) — batch subscribe

const unsub = hooksLib.subscribeMany({
  updateActor: (env) => { ... },
  updateToken: (env) => { ... },
  createItem:  (env) => { ... },
});
  • Returns a single unsubscribe that removes all handlers.
  • Equivalent to N subscribe calls but atomic: if any name is unregistered, the whole batch throws and no handlers are registered. This prevents partial-registration bugs.

3.3 subscribeAll(fn) — every hook, one handler

const unsub = hooksLib.subscribeAll((envelope) => {
  // consumer logic that dispatches on envelope.hook
});
  • For consumers that want to filter at one point (e.g. an audit log).
  • The envelope.hook field is the dispatch key.

3.4 unsubscribeAll() — purge everything

Intended for test teardown and module-disable cleanup. Removes all callbacks registered through any of the three primitives above.

3.5 Error containment

If a consumer callback throws, the library:

  1. Catches the error.
  2. Logs it via console.error with the prefix [foundry-hooks-lib] and the hook name.
  3. Continues dispatching to subsequent callbacks (registration order).

This matches Hooks.on semantics. A misbehaving consumer cannot break the dispatch chain or other consumers.


4. Lifecycle

4.1 init — registration only

The library's init hook does only registration:

  • Declares the registered hook set (§6) by calling Hooks.on for each one with the wrapper function.
  • Does NOT scan for system adapters yet.
  • Does NOT read game.system (it's not safe in init for some hooks).

4.2 ready — system adapter evaluation

The library's ready hook:

  1. Reads game.system.id and game.system.version.
  2. Iterates the registered system adapters (registered by adapter modules' own init hooks — see §5).
  3. For each adapter whose system.id matches and versions range covers game.system.version AND foundryVersions covers game.version:
    • Calls the adapter's factory once.
    • The factory returns derived-event registrations; the library applies them.
  4. For each non-matching adapter: log a warning at debug level if the world WOULD have matched if Foundry were a different version (so users can tell why an adapter didn't load).
  5. Idempotent: calling ready twice on the same world produces the same adapter set. Re-registration replaces previous matches.

4.3 unregisterModule — cleanup

When the library's own module is unregistered (Foundry's unregisterModule hook with the library's id):

  • Removes all Hooks.on registrations the library added.
  • Calls unsubscribeAll() to drop every consumer callback.
  • Clears the adapter registry.

Adapter modules that registered with the library should clean up their own Hooks.on in their own unregisterModule.

4.4 World change

Foundry's ready fires per-client when a world is loaded. The library's ready handler is the re-evaluation point. If a user switches worlds with a different game.system.id, the new ready call re-runs §4.2 with the new system version.

Adapter modules should make their own init re-register their factory with the library. The library deduplicates by system.id

  • adapter manifest id.

5. System adapter protocol

A system adapter is a Foundry module that contributes system-specific derived-event APIs to the library. The adapter is itself a module with its own module.json, its own init hook, its own release cadence.

5.1 Adapter registration

The adapter module's init hook calls:

hooksLib.registerSystemAdapter({
  id: "hax-hooks-dnd5e",         // unique adapter id
  moduleId: "hax-hooks-dnd5e",   // the adapter's own Foundry module id
  system: {
    id: "dnd5e",                 // game.system.id this adapter knows about
    versions: ">=5.2.0 <5.3.0",  // semver range
  },
  foundryVersions: ">=13 <15",  // semver range, applied to game.version
  factory: () => {
    // called once on match. Returns nothing; side-effect is
    // calling hooksLib.registerDerivedEvent for each derived event.
    return [
      {
        name: "dnd5e.rollAttack",
        register: (fn) => hooksLib.subscribe("dnd5e.rollAttackV2", (env) => {
          const [rolls, ctx] = env.args;
          fn({ actor: ctx?.subject?.actor, item: ctx?.subject?.item, rolls });
        }),
      },
      // ...
    ];
  },
});

The library stores the manifest. At ready, it evaluates the matching conditions (§4.2) and calls factory() for matches.

5.2 Adapter non-match

If the world's game.system.id doesn't match the adapter's system.id, the adapter is skipped. The library does NOT load the factory.

If the system's id matches but the version doesn't, the library logs a single warning identifying the adapter and the version it needed vs. the version it found. The adapter does not load.

5.3 Adapter version upgrades

When the adapter module is updated to support a new dnd5e version, the adapter author bumps the adapter's system.versions range. The library re-evaluates on next ready.

The adapter module is not versioned against hooks-lib's version in the manifest's relationships.requires. The library's runtime adapter API (§5.1) is the contract. If the library's adapter API changes, the library bumps its own major version and adapter authors update.

5.4 Adapter failures

If an adapter's factory throws, the library:

  • Logs the error with adapter id + stack trace.
  • Continues loading other adapters.
  • Marks this adapter as failed for the duration of the session.

Failed adapters don't crash the library.


6. Registered hook set (v0.2.0)

The library registers a Hooks.on for each of the following Foundry hooks. Each registration uses the wrapper from §2 to produce envelopes.

Lifecycle (always-on):

  • init, setup, ready
  • pauseGame

Document CRUD — generic:

  • createActor, updateActor, deleteActor
  • preCreateActor, preUpdateActor, preDeleteActor
  • createToken, updateToken, deleteToken
  • preCreateToken, preUpdateToken, preDeleteToken
  • createItem, updateItem, deleteItem
  • preCreateItem, preUpdateItem, preDeleteItem
  • createScene, updateScene, deleteScene
  • createJournalEntry, updateJournalEntry, deleteJournalEntry
  • createActiveEffect, updateActiveEffect, deleteActiveEffect
  • preCreateActiveEffect, preUpdateActiveEffect, preDeleteActiveEffect
  • createCombat, updateCombat, deleteCombat
  • preCreateCombat, preUpdateCombat, preDeleteCombat
  • createCombatant, updateCombatant, deleteCombatant
  • preCreateCombatant, preUpdateCombatant, preDeleteCombatant

Combat-specific (Foundry core):

  • combatStart, combatEnd, combatTurn, combatRound
  • preUpdateCombat (for the active→inactive transition detection)

Chat & rolls:

  • createChatMessage
  • dnd5e.rollAttackV2, dnd5e.rollDamageV2 (registered so the envelope captures them; system-specific interpretation is the adapter's job, but the envelope shape is the library's)

Canvas / scene / UI:

  • canvasInit, canvasReady, canvasPan
  • controlToken, hoverToken
  • targetToken
  • lightingRefresh, sightRefresh
  • collapseSidebar, changeSidebarTab, getSceneControlButtons
  • collapseSceneNavigation
  • renderChatMessageHTML, renderChatInput, renderJournalPageSheet
  • initializePointSourceShaders
  • rtcSettingsChanged

Embedded documents:

  • For each registered document type's embedded collections, the library also subscribes to create*, update*, delete*, preCreate*, preUpdate*, preDelete* for embedded names. Implementation: a single for loop over Foundry's CONFIG[type].collection.instance shapes (or equivalent in v14). Exact set TBD during implementation; covered by tests.

NOT registered by hooks-lib:

  • Hooks that fire only server-side with no useful consumer scenario (e.g. applyActiveEffect — the dnd5e adapter can register it if it has consumers).
  • Hooks that are Foundry internal (e.g. get*Context).
  • System-specific hooks beyond dnd5e 5.2.x roll hooks. Each system's adapter registers its own hooks. (Example: a PF2e adapter would register pf2e.roll* hooks in its own factory.)

The full list above is the v0.2.0 contract. Adding a hook is a minor version bump. Removing a hook is a major version bump.


7. Performance budget

The library adds overhead to every registered Foundry hook. The budget is:

  • Per-fire overhead: median <0.1ms on a 2020-era x86 (ThinkPad X1 Carbon Gen 8 or equivalent). Measured in tests/perf.mjs with a synthetic 10k-fire loop.
  • Memory: no per-fire allocation beyond the envelope object (one object + the args array, which is the same reference Foundry already allocated).
  • Synchronous work: zero. The wrapper is sync, but it does NOT call consumer callbacks inline with Foundry's hook dispatch chain — the wrapper dispatches to a microtask queue (Promise.resolve().then()). This decouples consumer latency from Foundry's hook chain.

Why microtask dispatch? Foundry's hooks can fire in tight loops during combat (hundreds of updateToken events per round). If a consumer is slow, synchronous dispatch would block Foundry's hook chain and stall the entire game. Microtask dispatch means each consumer gets called on the next microtask, in registration order, and Foundry's hook returns immediately.

Trade-off: consumer callbacks see the envelope AFTER Foundry has already moved on. This is fine for updateActor/updateToken because the args are immutable snapshots. It would NOT be fine for pre* hooks where consumers want to cancel the change — for those, the library dispatches SYNCHRONOUSLY, returning the first false from the consumer chain. (See §8.)


8. Sync vs. async dispatch

Synchronous dispatch (consumer callbacks fire inline with Foundry's hook):

  • All pre* hooks (so consumers can return false to cancel).
  • All combat* hooks (so consumers can react before the next hook in the same tick).
  • get*Context hooks (so consumers can mutate the menu/buttons).
  • applyActiveEffect (so consumers can adjust the change).

Async dispatch (microtask, consumer callbacks fire on next tick):

  • Everything else.

The library makes this decision per-hook based on whether synchronous return matters. The dispatch mode is part of the hook's entry in §6.


9. Anti-corruption: hook rename mapping

Foundry renames hooks between versions. The library absorbs the churn so consumers don't have to. v0.3.0 is Foundry v14 only.

v0.2.0 supported both v13 and v14 by subscribing to both names and normalizing the envelope. The current v0.3.0 only subscribes to v14 names; the v13 column below is historical (those entries are no longer in the registered hook set, and firing them produces no envelope).

Foundry v14 hook Foundry v13 hook (historical) Library envelope.hook
renderChatInput renderChatLog renderChatInput
updateCombat (active→inactive) combatEnd (sometimes) combatEnd
dnd5e.rollAttackV2 dnd5e.rollAttack (v1) dnd5e.rollAttackV2 (only v2 supported)

For v14, the library subscribes to the v14 name and emits it verbatim. The envelope.hook name IS the v14 name. Consumers see one stable name.

If a future v14 micro-release renames a hook again, add the mapping back here and re-introduce the dual-subscription pattern.


10. Anti-corruption: arg normalization

Foundry's hook arities shift between versions. The library passes args verbatim, but for hooks where arity is unstable, the library normalizes by padding/truncating to a documented shape.

Example (Foundry v14; v0.3.0 is v14 only):

// Foundry v14: combatRound(combat, updateData, updateOptions)

// Library normalizes to:
envelope.args = [combat, updateData, updateOptions?.round ?? 1, updateOptions]

This normalization is the only consumer-facing change vs raw Foundry. Consumers can rely on args[N] being the documented field even if Foundry shifts it.

The exact normalized shapes are listed in docs/HOOK_ARG_SHAPES.md (separate file because it's long). v0.3.0 documents the shape for every hook in §6.


11. Public API surface

The library exports the following from scripts/main.js:

export const VERSION = "0.2.0";
export const REGISTERED_HOOKS = [/* array of hook name strings */];

export function subscribe(hookName, fn) { ... }
export function subscribeMany(map) { ... }
export function subscribeAll(fn) { ... }
export function unsubscribeAll() { ... }

export function registerSystemAdapter(manifest) { ... }

export function listRegisteredHooks() { ... }
export function listActiveAdapters() { ... }

mod.api mirrors these so consumers can also access via game.modules.get("foundry-hooks-lib").api.subscribe(...).


12. What this contract explicitly does NOT promise

  1. No domain interpretation. If you want "HP changed," you subscribe to preUpdateActor + updateActor + preUpdateToken
    • updateToken and write the diff yourself (or use a system adapter that exposes it).
  2. No state. The library doesn't track "the current combatant" or "what changed since the last hook." Consumers track state.
  3. No persistence. The library doesn't write to settings, scene flags, or anywhere else.
  4. No Foundry compatibility shims beyond §9 and §10. If Foundry adds a new hook, it's a library version bump to surface it.
  5. No system-specific hooks beyond dnd5e. Each system's adapter is responsible for its own hooks.

13. Versioning

  • hooks-lib follows semver. The library's adapter API (§5) is the load-bearing surface for adapter authors; breaking it is a major bump.
  • The hook set in §6 grows additively in minor versions.
  • The arg-shape normalization in §10 is allowed to add optional fields in minor versions; removing or changing types is major.
  • Adapter repos version independently. Adapter manifests declare the Foundry + system version ranges they support, not the hooks-lib version.

14. Test plan

See tests/PLAN.md. Summary:

  • Envelope shape: every registered hook in §6 produces an envelope with the exact field set from §2, no extras.
  • Subscriber API: all four primitives work; unsubscribe removes callbacks; subscribeMany is atomic.
  • Error containment: throwing consumer doesn't break the chain.
  • Lifecycle: hooks registered in init, removed in unregisterModule, re-evaluated in ready.
  • System adapter loading: matching adapter loads; non-matching adapter logs + skips; version-range mismatches log + skip; throwing factory is contained.
  • Anti-corruption: v13 vs v14 hook renames produce the same envelope hook value; arg shapes from §10 match documented.
  • Performance: 10k fires complete under the budget in §7.

15. Migration from v0.1.0

v0.1.0 is removed. The 18 hand-written event handlers, the scripts/systems/ adapters, and the scripts/encounter.js stub all go away. The v0.2.0 contract is incompatible with the curated-event catalog shape.

No migration tooling. The library was 2 days old with no consumers; the only downstream is battle-focus, which still uses its own local copy of the events and is unaffected by hooks-lib's contents.

If a future consumer needs a curated-event catalog API, that's a separate layer on top of the v0.2.0 facade, not a reversion.