Files
hooks-lib/scripts/internal/registered-hooks.js
Kaysser Kayyali ba448b94c9 v0.4.0: scope to Foundry v14 only, drop v13 dual-subscription
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
2026-06-20 17:42:30 -04:00

171 lines
8.7 KiB
JavaScript

// scripts/internal/registered-hooks.js
//
// The registered hook set (HOOK_CONTRACT.md §6) and the dispatch-mode
// table (HOOK_CONTRACT.md §8). Each entry declares the Foundry hook
// name, its normalized envelope `hook` value (after anti-corruption
// mapping §9), and whether consumer callbacks dispatch synchronously
// or asynchronously.
//
// Async dispatch (microtask, default): consumer callbacks do not block
// Foundry's hook chain. Used for events where consumers only observe,
// not cancel.
//
// Sync dispatch: consumer callbacks fire inline with Foundry's hook.
// Used for hooks where the consumer's return value matters
// (pre-* cancellation) or where the consumer needs to mutate state
// before the next event in the same tick (combat*, applyActiveEffect,
// get*Context).
//
// The hook name in REGISTRY is the *normalized* envelope name. The
// ANTI_CORRUPTION map below (per §9) lists the raw Foundry names
// that produce each normalized envelope.
const MODULE_ID = "foundry-hooks-lib";
// mode: "sync" | "async"
export const HOOK_REGISTRY = [
// --- Lifecycle (always-on, sync — observable but not cancellable) ---
{ envelope: "init", mode: "sync", raw: ["init"] },
{ envelope: "setup", mode: "sync", raw: ["setup"] },
{ envelope: "ready", mode: "sync", raw: ["ready"] },
{ envelope: "pauseGame", mode: "async", raw: ["pauseGame"] },
// --- Document CRUD (async — observe only) ---
{ envelope: "createActor", mode: "async", raw: ["createActor"] },
{ envelope: "updateActor", mode: "async", raw: ["updateActor"] },
{ envelope: "deleteActor", mode: "async", raw: ["deleteActor"] },
{ envelope: "preCreateActor", mode: "sync", raw: ["preCreateActor"] },
{ envelope: "preUpdateActor", mode: "sync", raw: ["preUpdateActor"] },
{ envelope: "preDeleteActor", mode: "sync", raw: ["preDeleteActor"] },
{ envelope: "createToken", mode: "async", raw: ["createToken"] },
{ envelope: "updateToken", mode: "async", raw: ["updateToken"] },
{ envelope: "deleteToken", mode: "async", raw: ["deleteToken"] },
{ envelope: "preCreateToken", mode: "sync", raw: ["preCreateToken"] },
{ envelope: "preUpdateToken", mode: "sync", raw: ["preUpdateToken"] },
{ envelope: "preDeleteToken", mode: "sync", raw: ["preDeleteToken"] },
{ envelope: "createItem", mode: "async", raw: ["createItem"] },
{ envelope: "updateItem", mode: "async", raw: ["updateItem"] },
{ envelope: "deleteItem", mode: "async", raw: ["deleteItem"] },
{ envelope: "preCreateItem", mode: "sync", raw: ["preCreateItem"] },
{ envelope: "preUpdateItem", mode: "sync", raw: ["preUpdateItem"] },
{ envelope: "preDeleteItem", mode: "sync", raw: ["preDeleteItem"] },
{ envelope: "createScene", mode: "async", raw: ["createScene"] },
{ envelope: "updateScene", mode: "async", raw: ["updateScene"] },
{ envelope: "deleteScene", mode: "async", raw: ["deleteScene"] },
{ envelope: "createJournalEntry", mode: "async", raw: ["createJournalEntry"] },
{ envelope: "updateJournalEntry", mode: "async", raw: ["updateJournalEntry"] },
{ envelope: "deleteJournalEntry", mode: "async", raw: ["deleteJournalEntry"] },
{ envelope: "createActiveEffect", mode: "async", raw: ["createActiveEffect"] },
{ envelope: "updateActiveEffect", mode: "async", raw: ["updateActiveEffect"] },
{ envelope: "deleteActiveEffect", mode: "async", raw: ["deleteActiveEffect"] },
{ envelope: "preCreateActiveEffect", mode: "sync", raw: ["preCreateActiveEffect"] },
{ envelope: "preUpdateActiveEffect", mode: "sync", raw: ["preUpdateActiveEffect"] },
{ envelope: "preDeleteActiveEffect", mode: "sync", raw: ["preDeleteActiveEffect"] },
{ envelope: "createCombat", mode: "async", raw: ["createCombat"] },
{ envelope: "updateCombat", mode: "async", raw: ["updateCombat"] },
{ envelope: "deleteCombat", mode: "async", raw: ["deleteCombat"] },
{ envelope: "preCreateCombat", mode: "sync", raw: ["preCreateCombat"] },
{ envelope: "preUpdateCombat", mode: "sync", raw: ["preUpdateCombat"] },
{ envelope: "preDeleteCombat", mode: "sync", raw: ["preDeleteCombat"] },
{ envelope: "createCombatant", mode: "async", raw: ["createCombatant"] },
{ envelope: "updateCombatant", mode: "async", raw: ["updateCombatant"] },
{ envelope: "deleteCombatant", mode: "async", raw: ["deleteCombatant"] },
{ envelope: "preCreateCombatant", mode: "sync", raw: ["preCreateCombatant"] },
{ envelope: "preUpdateCombatant", mode: "sync", raw: ["preUpdateCombatant"] },
{ envelope: "preDeleteCombatant", mode: "sync", raw: ["preDeleteCombatant"] },
// --- Combat lifecycle (sync — consumers may need to mutate before next event) ---
{ envelope: "combatStart", mode: "sync", raw: ["combatStart"] },
{ envelope: "combatEnd", mode: "sync", raw: ["combatEnd"] },
{ envelope: "combatTurn", mode: "sync", raw: ["combatTurn"] },
{ envelope: "combatRound", mode: "sync", raw: ["combatRound"] },
// combatInactive is a synthetic event synthesized from updateCombat
// when active flips true→false (§9 anti-corruption). The raw hook
// it watches is updateCombat.
{ envelope: "combatInactive", mode: "sync", raw: ["updateCombat"], synthesized: true },
// --- Chat & rolls ---
{ envelope: "createChatMessage", mode: "async", raw: ["createChatMessage"] },
{ envelope: "renderChatMessage", mode: "sync", raw: ["renderChatMessage"] },
{ envelope: "renderChatInput", mode: "sync", raw: ["renderChatInput"] },
{ envelope: "dnd5e.rollAttackV2", mode: "async", raw: ["dnd5e.rollAttackV2"] },
{ envelope: "dnd5e.rollDamageV2", mode: "async", raw: ["dnd5e.rollDamageV2"] },
// --- Canvas / scene / UI ---
{ envelope: "canvasInit", mode: "async", raw: ["canvasInit"] },
{ envelope: "canvasReady", mode: "sync", raw: ["canvasReady"] },
{ envelope: "canvasPan", mode: "async", raw: ["canvasPan"] },
{ envelope: "controlToken", mode: "sync", raw: ["controlToken"] },
{ envelope: "hoverToken", mode: "sync", raw: ["hoverToken"] },
{ envelope: "targetToken", mode: "sync", raw: ["targetToken"] },
{ envelope: "lightingRefresh", mode: "async", raw: ["lightingRefresh"] },
{ envelope: "sightRefresh", mode: "async", raw: ["sightRefresh"] },
{ envelope: "collapseSidebar", mode: "sync", raw: ["collapseSidebar"] },
{ envelope: "changeSidebarTab", mode: "sync", raw: ["changeSidebarTab"] },
{ envelope: "getSceneControlButtons", mode: "sync", raw: ["getSceneControlButtons"] },
{ envelope: "collapseSceneNavigation", mode: "sync", raw: ["collapseSceneNavigation"] },
{ envelope: "renderJournalPageSheet", mode: "sync", raw: ["renderJournalPageSheet"] },
{ envelope: "initializePointSourceShaders", mode: "sync", raw: ["initializePointSourceShaders"] },
{ envelope: "rtcSettingsChanged", mode: "async", raw: ["rtcSettingsChanged"] },
];
// Lookup table: raw Foundry hook name -> registry entry.
// Multiple raw names can map to the same envelope (anti-corruption §9).
// When the same raw name is associated with both a regular entry and a
// synthesized entry (e.g. updateCombat → updateCombat AND updateCombat
// → combatInactive via synthesis), the regular entry wins; the
// synthesized entry is only consulted for synthesizing envelopes.
const RAW_TO_ENTRY = new Map();
const SYNTHESIZING_RAW_NAMES = new Set();
for (const entry of HOOK_REGISTRY) {
if (entry.synthesized) {
for (const rawName of entry.raw) {
SYNTHESIZING_RAW_NAMES.add(rawName);
}
continue;
}
for (const rawName of entry.raw) {
RAW_TO_ENTRY.set(rawName, entry);
}
}
export function getEntryForRawName(rawName) {
return RAW_TO_ENTRY.get(rawName) ?? null;
}
export function getEntryForEnvelope(envelopeName) {
return HOOK_REGISTRY.find((e) => e.envelope === envelopeName) ?? null;
}
export function isSynthesizingRawName(rawName) {
return SYNTHESIZING_RAW_NAMES.has(rawName);
}
// All raw Foundry names we register a Hooks.on for. The init hook
// uses this to register every listener exactly once.
export function allRawHookNames() {
const set = new Set();
for (const entry of HOOK_REGISTRY) {
for (const raw of entry.raw) set.add(raw);
}
return [...set];
}
// Names consumers can subscribe to (the normalized envelope names).
export const REGISTERED_HOOKS = HOOK_REGISTRY.map((e) => e.envelope);
// The set of synthesized envelopes (anti-corruption). combatInactive
// is the only one in v0.2.0; future syntheses add to this set.
export const SYNTHESIZED_ENVELOPES = HOOK_REGISTRY
.filter((e) => e.synthesized)
.map((e) => e.envelope);
export { MODULE_ID };