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.
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
hookNamemust be in the registered hook set (§6). Calling with an unregistered name throwsTypeErrorsynchronously. This catches typos at registration time, not at first fire.fnis 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
subscribecalls 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.hookfield 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:
- Catches the error.
- Logs it via
console.errorwith the prefix[foundry-hooks-lib]and the hook name. - 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.onfor 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:
- Reads
game.system.idandgame.system.version. - Iterates the registered system adapters (registered by adapter
modules' own
inithooks — see §5). - For each adapter whose
system.idmatches andversionsrange coversgame.system.versionANDfoundryVersionscoversgame.version:- Calls the adapter's factory once.
- The factory returns derived-event registrations; the library applies them.
- 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).
- Idempotent: calling
readytwice 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.onregistrations 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,readypauseGame
Document CRUD — generic:
createActor,updateActor,deleteActorpreCreateActor,preUpdateActor,preDeleteActorcreateToken,updateToken,deleteTokenpreCreateToken,preUpdateToken,preDeleteTokencreateItem,updateItem,deleteItempreCreateItem,preUpdateItem,preDeleteItemcreateScene,updateScene,deleteScenecreateJournalEntry,updateJournalEntry,deleteJournalEntrycreateActiveEffect,updateActiveEffect,deleteActiveEffectpreCreateActiveEffect,preUpdateActiveEffect,preDeleteActiveEffectcreateCombat,updateCombat,deleteCombatpreCreateCombat,preUpdateCombat,preDeleteCombatcreateCombatant,updateCombatant,deleteCombatantpreCreateCombatant,preUpdateCombatant,preDeleteCombatant
Combat-specific (Foundry core):
combatStart,combatEnd,combatTurn,combatRoundpreUpdateCombat(for the active→inactive transition detection)
Chat & rolls:
createChatMessagednd5e.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,canvasPancontrolToken,hoverTokentargetTokenlightingRefresh,sightRefreshcollapseSidebar,changeSidebarTab,getSceneControlButtonscollapseSceneNavigationrenderChatMessage,renderChatInput,renderJournalPageSheetinitializePointSourceShadersrtcSettingsChanged
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 singleforloop over Foundry'sCONFIG[type].collection.instanceshapes (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.mjswith 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 returnfalseto cancel). - All
combat*hooks (so consumers can react before the next hook in the same tick). get*Contexthooks (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
- No domain interpretation. If you want "HP changed," you
subscribe to
preUpdateActor+updateActor+preUpdateTokenupdateTokenand write the diff yourself (or use a system adapter that exposes it).
- No state. The library doesn't track "the current combatant" or "what changed since the last hook." Consumers track state.
- No persistence. The library doesn't write to settings, scene flags, or anywhere else.
- No Foundry compatibility shims beyond §9 and §10. If Foundry adds a new hook, it's a library version bump to surface it.
- No system-specific hooks beyond dnd5e. Each system's adapter is responsible for its own hooks.
13. Versioning
hooks-libfollows 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;
subscribeManyis 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
hookvalue; 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.