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.
216 lines
7.4 KiB
JavaScript
216 lines
7.4 KiB
JavaScript
// hax-hooks-lib — verify-hooks-lib.mjs
|
|
//
|
|
// Smoke test for the registry + systems loader. Runs without Foundry.
|
|
//
|
|
// What's verified:
|
|
// 1. loadSystems({currentSystemId: "anything"}) returns the core
|
|
// adapter and nothing else (no dnd5e).
|
|
// 2. loadSystems({currentSystemId: "dnd5e"}) returns core + dnd5e.
|
|
// 3. registerAllEvents wires every event handler into Hooks.on for
|
|
// the declared hook name.
|
|
// 4. When a Foundry hook fires, the registered handler returns a
|
|
// normalized event object, and onEvent is invoked exactly once
|
|
// with that object.
|
|
// 5. registerEvent rejects event defs missing id/hook/handler.
|
|
// 6. Handlers that return null do NOT trigger onEvent.
|
|
//
|
|
// Full E2E (the real story) is battle-focus's
|
|
// tests/verify-battle-focus-v5.mjs, which loads hooks-lib as a
|
|
// Foundry module and runs 220+ assertions against the event stream.
|
|
|
|
import { installStubs, resetStubs, getRegisteredHooks, clearCallLog } from "./test-helpers.mjs";
|
|
import { loadSystems } from "../scripts/systems/loader.js";
|
|
import { registerAllEvents, registerEvent, $ctx } from "../scripts/events/registry.js";
|
|
|
|
const ASSERTIONS = [];
|
|
function assert(name, cond, extra = "") {
|
|
ASSERTIONS.push({ name, pass: !!cond, extra });
|
|
if (cond) {
|
|
console.log(` ✓ ${name}`);
|
|
} else {
|
|
console.log(` ✗ ${name} ${extra}`);
|
|
}
|
|
}
|
|
|
|
function assertThrows(name, fn, msgIncludes) {
|
|
let threw = null;
|
|
try {
|
|
fn();
|
|
} catch (e) {
|
|
threw = e;
|
|
}
|
|
const ok = !!threw && (!msgIncludes || (threw.message ?? "").includes(msgIncludes));
|
|
ASSERTIONS.push({ name, pass: ok });
|
|
if (ok) {
|
|
console.log(` ✓ ${name}`);
|
|
} else {
|
|
console.log(` ✗ ${name} expected throw containing "${msgIncludes}", got ${threw ? threw.message : "no throw"}`);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
console.log("--- hax-hooks-lib v0.1.0 smoke test ---");
|
|
|
|
// ----- Section 1: loadSystems filtering -----
|
|
installStubs();
|
|
console.log("[1] loadSystems filtering");
|
|
|
|
const noneSystem = await loadSystems({
|
|
currentSystemId: "drawings-only",
|
|
systemVersion: "0.0.0",
|
|
});
|
|
assert(
|
|
"loadSystems for unknown system returns core only",
|
|
noneSystem.length === 1 && noneSystem[0].id === "core",
|
|
`got ${noneSystem.map((s) => s.id).join(",")}`,
|
|
);
|
|
|
|
const dnd5eSystems = await loadSystems({
|
|
currentSystemId: "dnd5e",
|
|
systemVersion: "5.2.5",
|
|
});
|
|
assert(
|
|
"loadSystems for dnd5e returns core + dnd5e",
|
|
dnd5eSystems.length === 2 && dnd5eSystems.map((s) => s.id).sort().join(",") === "core,dnd5e",
|
|
`got ${dnd5eSystems.map((s) => s.id).join(",")}`,
|
|
);
|
|
assert(
|
|
"core adapter has at least 15 events",
|
|
noneSystem[0].events.length >= 15,
|
|
`got ${noneSystem[0].events.length}`,
|
|
);
|
|
assert(
|
|
"dnd5e adapter has 2 events (attack-roll, damage-roll)",
|
|
dnd5eSystems.find((s) => s.id === "dnd5e").events.length === 2,
|
|
);
|
|
|
|
// ----- Section 2: registerAllEvents wires hooks -----
|
|
resetStubs();
|
|
installStubs();
|
|
clearCallLog();
|
|
console.log("[2] registerAllEvents wiring");
|
|
|
|
const seenEvents = [];
|
|
const onEvent = async (ev) => {
|
|
seenEvents.push(ev);
|
|
};
|
|
const registered = registerAllEvents(dnd5eSystems, onEvent);
|
|
assert(
|
|
"registerAllEvents returns one def per event",
|
|
registered.length === dnd5eSystems.reduce((n, s) => n + s.events.length, 0),
|
|
`expected ${dnd5eSystems.reduce((n, s) => n + s.events.length, 0)}, got ${registered.length}`,
|
|
);
|
|
// After registration, every event's hook name should have at least
|
|
// one handler in the Hooks map.
|
|
const missingHook = registered.find((r) => (getRegisteredHooks().get(r.hook) ?? []).length === 0);
|
|
assert(
|
|
"every event def's hook has at least one Hooks.on registration",
|
|
!missingHook,
|
|
missingHook ? `missing: ${missingHook.id}` : "",
|
|
);
|
|
assert(
|
|
"every event def has _registered === true after registerAllEvents",
|
|
registered.every((r) => r._registered === true),
|
|
);
|
|
|
|
// ----- Section 3: firing a Foundry hook returns event, onEvent fires -----
|
|
console.log("[3] end-to-end handler invocation");
|
|
|
|
// Find a hook that's simple to fire and check. combatStart is the
|
|
// canonical "open journal page" event and its handler only reads
|
|
// combat, scene, and the active ctx.
|
|
const startEv = registered.find((r) => r.hook === "combatStart");
|
|
assert("combatStart event registered", !!startEv);
|
|
|
|
// Synthesize a minimal combat object.
|
|
const fakeCombat = {
|
|
id: "c-test-1",
|
|
scene: { name: "Test Scene" },
|
|
round: 1,
|
|
};
|
|
// Fire the combatStart hook. The registry's safe wrapper sets _activeCtx,
|
|
// invokes the handler, gets a result, and calls onEvent.
|
|
const handlerFns = getRegisteredHooks().get("combatStart") ?? [];
|
|
assert("combatStart has exactly one handler registered", handlerFns.length === 1, `got ${handlerFns.length}`);
|
|
// The handler is async; await its returned promise.
|
|
const returned = await handlerFns[0](fakeCombat, { active: true });
|
|
assert(
|
|
"combatStart handler returns an event with kind='combat-start'",
|
|
returned && returned.kind === "combat-start",
|
|
`got ${JSON.stringify(returned)}`,
|
|
);
|
|
assert(
|
|
"onEvent was called with the combat-start event",
|
|
seenEvents.length === 1 && seenEvents[0].kind === "combat-start",
|
|
`seenEvents=${JSON.stringify(seenEvents)}`,
|
|
);
|
|
assert(
|
|
"combat-start event has combatId from the synthetic combat",
|
|
seenEvents[0]?.combatId === "c-test-1",
|
|
`got combatId=${seenEvents[0]?.combatId}`,
|
|
);
|
|
assert(
|
|
"combat-start event has scene name as combatName",
|
|
seenEvents[0]?.combatName === "Test Scene",
|
|
`got combatName=${seenEvents[0]?.combatName}`,
|
|
);
|
|
|
|
// ----- Section 4: null return → onEvent NOT called -----
|
|
console.log("[4] null returns suppress onEvent");
|
|
|
|
// preUpdateItem is a side-effect-only handler that returns null.
|
|
const preUpdateItem = registered.find((r) => r.hook === "preUpdateItem");
|
|
assert("preUpdateItem event registered", !!preUpdateItem);
|
|
const seenBefore = seenEvents.length;
|
|
const preHandlers = getRegisteredHooks().get("preUpdateItem") ?? [];
|
|
await preHandlers[0]({ id: "i1", type: "weapon", parent: null }, {}, {}, "u1");
|
|
assert(
|
|
"preUpdateItem returning null does NOT call onEvent",
|
|
seenEvents.length === seenBefore,
|
|
`seenEvents grew from ${seenBefore} to ${seenEvents.length}`,
|
|
);
|
|
|
|
// ----- Section 5: $ctx() is null outside a handler -----
|
|
console.log("[5] $ctx outside a handler");
|
|
assert(
|
|
"$ctx() returns null when called outside any handler",
|
|
$ctx() === null,
|
|
);
|
|
|
|
// ----- Section 6: registerEvent validation -----
|
|
console.log("[6] registerEvent input validation");
|
|
assertThrows(
|
|
"registerEvent rejects missing id",
|
|
() => registerEvent({ hook: "x", handler: () => null }),
|
|
"missing id",
|
|
);
|
|
assertThrows(
|
|
"registerEvent rejects missing hook",
|
|
() => registerEvent({ id: "x", handler: () => null }),
|
|
"missing hook",
|
|
);
|
|
assertThrows(
|
|
"registerEvent rejects missing handler",
|
|
() => registerEvent({ id: "x", hook: "y" }),
|
|
"missing handler",
|
|
);
|
|
assertThrows(
|
|
"registerEvent error message names the module id",
|
|
() => registerEvent({ hook: "x", handler: () => null }),
|
|
"[hax-hooks-lib]",
|
|
);
|
|
|
|
// ----- Summary -----
|
|
const passed = ASSERTIONS.filter((a) => a.pass).length;
|
|
const total = ASSERTIONS.length;
|
|
console.log(`\n--- ${passed}/${total} assertions passed ---`);
|
|
if (passed !== total) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("[verify-hooks-lib] uncaught:", e);
|
|
process.exitCode = 1;
|
|
});
|