v0.1.0 shipped as a curated-event catalog (18 hand-written handlers
+ system adapters + an encounter.js stub). Wrong shape. v0.2.0 is a
RIP-AND-REPLACE per HOOK_CONTRACT.md (docs/HOOK_CONTRACT.md):
generic Foundry hook facade with NO domain interpretation.
## What's new
scripts/internal/ — new modular structure:
- registered-hooks.js: the §6 hook set (~60 raw Foundry hooks,
envelope name, sync/async mode)
- envelope.js: buildEnvelope + sync/async dispatch (§8)
- subscribers.js: subscribe/subscribeMany/subscribeAll/unsubscribeAll
primitives (§3) + error containment (§3.5)
- adapter-registry.js: registerSystemAdapter + semver-range matching
(§5) + ready-time evaluation
- semver.js: inline semver matcher (no external dep)
- anti-corruption.js: hook rename normalization (§9) + arg shape
fixes (§10) + combatInactive synthesis from updateCombat
- lifecycle.js: init/ready/unregisterModule hooks (§4)
scripts/main.js — Foundry entry point. Registers the public API on
mod.api; init installs Foundry hooks; ready evaluates adapters;
unregisterModule cleans up.
## Tests
- tests/verify-hooks-lib.mjs — 554 assertions in 0.34s (under the
2s budget). Sections A-G of tests/PLAN.md:
- A: envelope shape (every registered hook produces exactly
{ts, hook, args})
- B: subscriber API (single, batch, all, atomic, error path)
- C: error containment (throwing consumer doesn't break chain)
- D: lifecycle (install/uninstall, adapter eval, world change)
- E: anti-corruption (renderChatLog→renderChatInput,
combatInactive synthesis, combatRound arg normalization)
- F: semver matcher
- G: adapter loading (validation, dedup, factory failure)
- tests/perf.mjs — 6 assertions. Median 0.0003ms/fire
(333x under the 0.1ms budget). Heap delta 2.8MB across 10k fires.
- tests/test-helpers.mjs — Foundry stub (Hooks, game, ui).
## Archive
scripts/_archive/v0.1.0/ — v0.1.0 catalog moved here for git
history. The 18 handlers, system adapters, and encounter.js stub
all live there but are not part of the v0.2.0 module.
tests/_archive_v0.1.0_*.mjs — v0.1.0 test files renamed with
prefix to avoid colliding with v0.2.0 files.
## Manifest
- module.json: bumped to 0.2.0; download URL points at the new zip.
- package.json: bumped to 0.2.0; added test:perf script.
- README.md: rewritten for v0.2.0.
Push: Gitea only.
Hax's Tools — Hooks Lib (hax-hooks-lib)
Foundry VTT module (id: hax-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 Hax's Tools umbrella. 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.2.0 — generic Foundry hook facade
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("hax-hooks-lib").api)
import { subscribe, subscribeMany, subscribeAll } from
game.modules.get("hax-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: "hax-hooks-dnd5e",
moduleId: "hax-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 [hax-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 ~0.4s, no Foundry needed
npm run test:perf # median 0.0003ms/fire, heap delta check
See tests/PLAN.md for what we test and what we don't. The Foundry-load
test (Playwright against a live Foundry) is deferred to when a real
consumer (battle-focus) migrates and exercises it.
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.