Files
hooks-lib/docs/HOOK_CONTRACT.md
Kaysser Kayyali d038eb8c67 v0.3.0: rename module id hax-hooks-lib -> foundry-hooks-lib
User callout: 'Hax' is Kaysser's nickname. The module id should
not use it. Rename the Foundry module id from 'hax-hooks-lib' to
'foundry-hooks-lib'. Gitea repo name stays as 'hooks-lib' (kept
for the user-facing URL); the Gitea manifest URL is unchanged.

**Scope of rename:**
- module.json: id, title, version (0.2.0 -> 0.3.0), download URL
- package.json: name
- README.md, HOOK_CONTRACT.md, LICENSE: branding text
- All 6 production JS files: MODULE_ID constant + comments
- 4 active test files: console.log strings + test descriptions
- Rename of release zips in git: hooks-lib-X.Y.Z.zip ->
  foundry-hooks-lib-X.Y.Z.zip (preserves the v0.1.0 and v0.2.0
  zips as historical artifacts; the v0.3.0 zip is the new
  release artifact)
- .gitignore: glob + un-ignore lines updated to match

**Out of scope (deliberate):**
- Gitea repo name 'kaykayyali/hooks-lib' stays. Per the user's
  direction, only the module id is renamed; the Gitea URL path
  is preserved for the existing 'url', 'manifest', 'download'
  fields.
- scripts/_archive/v0.1.0/*: historical v0.1.0 code is left
  as-is. Those files tested 'hax-hooks-lib v0.1.0'; rewriting
  the history would be misleading.
- tests/_archive_v0.1.0_*.mjs: same reason, left untouched.
- .hermes/plans/* session-historian plans that reference
  'Hax's Tools split': session artifact, not a release asset.

**Verification:** 554/554 smoke assertions pass, 6/6 perf
assertions pass, median 0.0004ms/fire (well under 0.1ms
budget). No logic change; rename is string-only.

**Consumer action required:** battle-focus and its-achievable
both declare 'relationships.requires' pointing to
'hax-hooks-lib'. The next commits on those repos will update
their relationships to 'foundry-hooks-lib' + bump their
versions. Foundry instances with v0.2.0 of the old id
installed will need to be reinstalled as v0.3.0 of the new
id.
2026-06-20 16:53:37 -04:00

18 KiB

HOOK_CONTRACT.md — hooks-lib v0.2.0

Status: Proposed spec. Subject to revision until v0.2.0 ships. Audience: hooks-lib authors + consumer authors + system adapter authors. Stability: v0.2.0 ships as the contract; breaking changes require a major bump.


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
  • renderChatMessage, 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. Example mappings (v0.2.0):

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

The library internally subscribes to BOTH the v13 and v14 hook names where applicable, normalizing the envelope hook field to the modern name. Consumers see one stable name regardless of Foundry version.

For each mapped pair, the library's tests assert the normalization.


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 (illustrative — exact normalization is implementation-defined):

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

// Library normalizes to:
envelope.args = [combat, updateData, roundNum ?? 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.2.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.