docs: HOOK_CONTRACT.md for v0.2.0 (generic Foundry facade)

Adds docs/HOOK_CONTRACT.md — the spec for hooks-lib v0.2.0. The
v0.1.0 implementation (18 hand-written event handlers, the
scripts/systems/ adapter pair, the encounter.js stub) is the wrong
shape. v0.2.0 is a generic Foundry hook facade:

- Subscribes to every Foundry hook in §6 (lifecycle, document CRUD
  generic, combat, chat/canvas/UI, dnd5e v2 roll hooks)
- Emits a uniform {ts, hook, args} envelope with no domain
  interpretation
- Provides subscribe / subscribeMany / subscribeAll / unsubscribeAll
  primitives
- Registers system adapters (separate repos) via a manifest with
  semver range matching for system + Foundry versions
- Dispatches sync for pre-* / combat* / applyActiveEffect /
  get*Context hooks; async (microtask) for everything else
- Absorbs Foundry rename churn (renderChatInput vs renderChatLog)
  and arity shifts (combatRound signature) so consumers stay stable

No code changed. The contract is the gate for v0.2.0 implementation.

Push: Gitea only (this turn). No GitHub mirror.
This commit is contained in:
2026-06-20 02:10:09 -04:00
parent d5d0b1655f
commit 8e3db117f9

508
docs/HOOK_CONTRACT.md Normal file
View File

@@ -0,0 +1,508 @@
# 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.
```js
{
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`
```js
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`
```js
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`
```js
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 `[hax-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:
```js
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):
```js
// 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`:
```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("hax-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.