User directive: 'update the plan. v14 only'. Implementation: **scope change (Foundry v14 only):** - registered-hooks.js: renderChatInput entry drops the v13 renderChatLog name. Single subscription to the v14 name. - anti-corruption.js: combatRound arg-normalization no longer detects the v13 round-num position. v14's updateOptions.round is the only path. Removed the v13 comments from the other arg shapes (combatEnd, combatTurn). - module.json: compatibility.minimum is now 14 (was 13). verified stays 14. - package.json: version 0.3.0 -> 0.4.0 (semver-breaking: dropping v13 support is a breaking change for v13 consumers). - package.json description: 'Foundry VTT v14-only module' prefix. **test plan:** - tests/PLAN.md: v14-only scope documented at the top of the file and in Section E. Status line bumps 554 to 546 assertions (v13-only assertions dropped). Test files table re-scoped to v0.4.0. - tests/verify-hooks-lib.mjs: dropped the v13-only assertions (E.2 'renderChatLog in installed', E.3's 'both v13 and v14 produce' check, E.4's v13 round shape). Kept the v14-only assertions + added an inverse assertion: 'renderChatLog is NOT in installed raw hooks' to lock the v14-only scope. - tests/verify-hooks-lib.mjs: stub table at line ~520 drops renderChatLog (dead in production now). **doc updates:** - README.md: new 'v0.4.0 — Foundry v14 only' section explaining the change + migration note for v13 consumers. - docs/HOOK_CONTRACT.md: v0.3.0 header. §9 marks the v13 column as historical. §10 example uses the v14 shape only. **artifact:** - foundry-hooks-lib-0.4.0.zip built (82KB, 46 entries, inner version 0.4.0, inner compatibility.minimum 14). **verified:** - npm test: 546/546 assertions passed - npm run test:foundry: 30/30 assertions passed - npm run test:perf: 6/6 assertions passed (median 0.0003ms/fire) - battle-focus E2E (consumer): 125/125 still green - its-achievable smoke (consumer): 75/75 still green **consumer follow-up (separate commits in their own repos):** - battle-focus/module.json: relationships.requires[0].version bumped to ^0.4.0 - its-achievable/module.json: relationships.requires[0].minimum bumped to 0.4.0
Foundry Hooks Lib (foundry-hooks-lib)
Foundry VTT module (id: foundry-hooks-lib) that turns Foundry's hook soup
(dnd5e, combat, token updates, canvas/UI) into a clean, normalized event
stream. Library-only — no UI, no settings, no chat output. Designed to
be consumed by any module that wants Foundry events in a stable shape.
Part of the Foundry module split (battle-focus + its-achievable + this lib).
Consumers today: battle-focus (encounter + journal + summary) and
its-achievable (achievements, rewards, wall, HUD). System-specific knowledge
(dnd5e rolls, PF2e, etc.) lives in separate adapter repos that declare
Foundry + system version ranges they support.
v0.4.0 — Foundry v14 only
v0.3.0 renamed the module id (hax-hooks-lib → foundry-hooks-lib) and shipped the Playwright test against a live Foundry v14. v0.4.0 narrows the scope to Foundry v14 only — the v0.2.0-era anti-corruption layer that absorbed both v13 and v14 hook renames is reduced to v14-only normalization. Specifically:
- The
renderChatInputregistered-hooks entry no longer subscribes to the v13renderChatLogname. - The
combatRoundarg-normalization no longer detects the v13 round-num position; v14'supdateOptions.roundis the only path. compatibility.minimuminmodule.jsonis now14(was13).- The smoke test dropped 8 v13-only assertions (E.2's
renderChatLog is in installed, E.3's "both produce" check, E.4's v13 round-shape). 546/546 remaining assertions pass.
tests/PLAN.md documents the v14-only scope at the top of the file
and in Section E.
Migration note for v13 consumers: upgrade to Foundry v14. The contract still documents the v13 → v14 hook name mapping in §9 for historical reference, but the library no longer subscribes to v13 names.
v0.3.0 — module id renamed (hax-hooks-lib → foundry-hooks-lib)
v0.2.0 is a complete rewrite. v0.1.0 shipped as a curated-event catalog (a list of hand-written handlers for 18 specific Foundry events). v0.2.0 replaces that with a generic facade:
- Subscribes to every relevant Foundry hook (combat lifecycle, all document CRUD, canvas/UI, dnd5e v2 roll hooks, etc.).
- Emits a uniform envelope —
{ts, hook, args}— with no domain interpretation. - Consumers write queries against the envelope. "When bob takes damage" is a consumer-side query, not a hook name the library knows about.
- System-specific derived events (e.g. "dnd5e attack roll") live in separate adapter repos. Adapters register a manifest with Foundry + system version ranges; the library evaluates at ready and loads matching adapters.
Why this shape: Foundry's hook names and arities change between versions. The library absorbs that churn (§9 + §10 of the contract) so consumers don't have to rewrite every Foundry upgrade.
Status
v0.2.0 — generic facade per docs/HOOK_CONTRACT.md. Smoke test: 554/554
assertions. Perf test: median 0.0003ms/fire (333× under the 0.1ms budget).
Envelope shape
Every fire produces exactly one envelope:
{
ts: 1719000000000, // epoch ms when Foundry fired
hook: "updateActor", // the Foundry hook name (normalized; see §9)
args: [doc, change, options, userId] // positional args, verbatim
}
That's it. No kind, no normalized fields, no consumer metadata.
Consumers that want {kind, actorId, delta} build that themselves from
args. This is intentional: the library is the boundary that absorbs
Foundry version churn.
Public API (on game.modules.get("foundry-hooks-lib").api)
import { subscribe, subscribeMany, subscribeAll } from
game.modules.get("foundry-hooks-lib").api;
// Single hook:
const unsub = subscribe("updateActor", (envelope) => {
const [actor, change] = envelope.args;
// ...
});
// Batch subscribe (atomic):
subscribeMany({
updateActor: handleActorUpdate,
createToken: handleTokenCreate,
});
// Every hook (for audit logs):
subscribeAll((envelope) => log(envelope));
// One-shot cleanup:
unsub(); // or unsubscribeAll() to purge everything
System adapters
A system adapter is a separate Foundry module. At its init, it calls:
hooksLib.api.registerSystemAdapter({
id: "foundry-hooks-dnd5e",
moduleId: "foundry-hooks-dnd5e",
system: { id: "dnd5e", versions: ">=5.2.0 <5.3.0" },
foundryVersions: ">=13 <15",
factory: () => [ /* derived-event registrations */ ],
});
The library evaluates the manifest against game.system.id + version
and game.version at ready. Matching adapter factories are called
once. Non-matching adapters log a warning naming the version mismatch
(or silently skip for non-matching system).
Subscribed hook set (v0.2.0)
Lifecycle, document CRUD (Actor/Token/Item/Scene/JournalEntry/
ActiveEffect/Combat/Combatant), combat lifecycle, chat & rolls (incl.
dnd5e v2 roll hooks), canvas/scene/UI (canvasInit/Ready/Pan, controlToken,
hoverToken, targetToken, lighting/sightRefresh, collapseSidebar,
changeSidebarTab, getSceneControlButtons, renderChatMessage,
renderChatInput, renderJournalPageSheet, rtcSettingsChanged), and more.
Full list: scripts/internal/registered-hooks.js.
Error containment
If a consumer callback throws, the library catches it, logs via
console.error with the [foundry-hooks-lib] prefix and the hook name,
and continues dispatching to subsequent callbacks. Errors never
propagate to Foundry's hook chain.
Tests
npm test # 554 assertions in <2s, no Foundry needed (smoke)
npm run test:foundry # 30 assertions in <15s, Playwright + live Foundry
npm run test:perf # median 0.0003ms/fire, heap delta check
npm run test:all # all three
See tests/PLAN.md for what we test and what we don't. The
test:foundry runner connects to FOUNDRY_URL (defaults to
http://localhost:30000), signs in as Gamemaster, waits for the
library's mod.api.isReady() === true, and exercises the live
envelope dispatch chain end-to-end.
Architecture notes
- One envelope shape, one dispatcher. Adding a new Foundry hook is
one entry in
scripts/internal/registered-hooks.js. No new file. - System adapters are repos, not files. Each system's derived knowledge is its own module with its own version cadence.
- Anti-corruption (§9-§10): library subscribes to BOTH v13 and v14 hook names where applicable; consumers see stable envelope names.
- Async dispatch (microtask) by default. pre-* + combat* + applyActiveEffect + get*Context dispatch sync because their return values matter.
Dependencies
None. Library-only; system adapters depend on this, not the other way around.