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
This commit is contained in:
2026-06-20 17:42:30 -04:00
parent 722e00cea2
commit ba448b94c9
11 changed files with 135 additions and 90 deletions

View File

@@ -11,6 +11,33 @@ Consumers today: `battle-focus` (encounter + journal + summary) and
(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 `renderChatInput` registered-hooks entry no longer subscribes
to the v13 `renderChatLog` name.
- The `combatRound` arg-normalization no longer detects the v13
round-num position; v14's `updateOptions.round` is the only path.
- `compatibility.minimum` in `module.json` is now `14` (was `13`).
- 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

View File

@@ -1,8 +1,13 @@
# HOOK_CONTRACT.md — `hooks-lib` v0.2.0
# HOOK_CONTRACT.md — `hooks-lib` v0.3.0
**Status:** Proposed spec. Subject to revision until v0.2.0 ships.
**Status:** Implemented spec for v0.3.0.
**Audience:** hooks-lib authors + consumer authors + system adapter authors.
**Stability:** v0.2.0 ships as the contract; breaking changes require a major bump.
**Stability:** v0.3.0 ships as the contract; breaking changes require a major bump.
**Scope:** Foundry v14 only. v0.2.0's anti-corruption layer absorbed
v13/v14 hook renames; v0.3.0 narrows to v14. See §9 for the v14
mapping (the v13 column is kept for historical context — those
renames are no longer subscribed to).
---
@@ -374,20 +379,26 @@ 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):
churn so consumers don't have to. **v0.3.0 is Foundry v14 only.**
| 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) |
v0.2.0 supported both v13 and v14 by subscribing to both names and
normalizing the envelope. The current v0.3.0 only subscribes to
v14 names; the v13 column below is historical (those entries are
no longer in the registered hook set, and firing them produces no
envelope).
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.
| Foundry v14 hook | Foundry v13 hook (historical) | Library `envelope.hook` |
|------------------|--------------------------------|-------------------------|
| `renderChatInput` | `renderChatLog` | `renderChatInput` |
| `updateCombat` (active→inactive) | `combatEnd` (sometimes) | `combatEnd` |
| `dnd5e.rollAttackV2` | `dnd5e.rollAttack` (v1) | `dnd5e.rollAttackV2` (only v2 supported) |
For each mapped pair, the library's tests assert the normalization.
For v14, the library subscribes to the v14 name and emits it
verbatim. The envelope.hook name IS the v14 name. Consumers see
one stable name.
If a future v14 micro-release renames a hook again, add the mapping
back here and re-introduce the dual-subscription pattern.
---
@@ -397,14 +408,13 @@ 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):
Example (Foundry v14; v0.3.0 is v14 only):
```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]
envelope.args = [combat, updateData, updateOptions?.round ?? 1, updateOptions]
```
This normalization is **the only** consumer-facing change vs raw
@@ -412,7 +422,7 @@ 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
(separate file because it's long). v0.3.0 documents the shape for
every hook in §6.
---

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,8 +1,8 @@
{
"id": "foundry-hooks-lib",
"title": "Foundry Hooks Lib",
"description": "Foundry VTT module: generic Foundry hook facade. Subscribes to every relevant Foundry hook (combat, document CRUD, canvas/UI, dnd5e v2 rolls), emits a uniform {ts, hook, args} envelope. No domain interpretation — consumers query the stream. See docs/HOOK_CONTRACT.md.",
"version": "0.3.0",
"description": "Foundry VTT v14-only module: generic Foundry hook facade. Subscribes to every relevant Foundry hook (combat, document CRUD, canvas/UI, dnd5e v2 rolls), emits a uniform {ts, hook, args} envelope. No domain interpretation — consumers query the stream. See docs/HOOK_CONTRACT.md.",
"version": "0.4.0",
"library": true,
"manifestPlusVersion": "1.2.0",
"authors": [
@@ -12,7 +12,7 @@
}
],
"compatibility": {
"minimum": 13,
"minimum": 14,
"verified": 14
},
"relationships": {
@@ -23,7 +23,7 @@
"esmodules": ["scripts/main.js"],
"url": "https://git.homelab.local/kaykayyali/hooks-lib",
"manifest": "https://git.homelab.local/kaykayyali/hooks-lib/raw/branch/main/module.json",
"download": "https://git.homelab.local/kaykayyali/hooks-lib/raw/branch/main/foundry-hooks-lib-0.3.0.zip",
"download": "https://git.homelab.local/kaykayyali/hooks-lib/raw/branch/main/foundry-hooks-lib-0.4.0.zip",
"readme": "https://git.homelab.local/kaykayyali/hooks-lib/blob/main/README.md",
"changelog": "https://git.homelab.local/kaykayyali/hooks-lib/commits/main",
"bugs": "https://git.homelab.local/kaykayyali/hooks-lib/issues",

View File

@@ -1,8 +1,8 @@
{
"name": "foundry-hooks-lib",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"description": "Foundry VTT module: generic Foundry hook facade. Library-only — no UI, no settings, no domain interpretation. v0.2.0 implements HOOK_CONTRACT.md and tests/PLAN.md.",
"description": "Foundry VTT v14-only module: generic Foundry hook facade. Library-only — no UI, no settings, no domain interpretation. v0.4.0 implements HOOK_CONTRACT.md and tests/PLAN.md.",
"main": "scripts/main.js",
"type": "module",
"scripts": {

View File

@@ -1,15 +1,20 @@
// scripts/internal/anti-corruption.js
//
// HOOK_CONTRACT.md §9-§10: absorb Foundry rename + arity churn.
// HOOK_CONTRACT.md §9-§10: absorb Foundry hook shape churn so
// consumers see stable envelope.hook names and a documented
// args[] shape.
//
// §9 Hook rename mapping: a Foundry v13 fire of renderChatLog produces
// an envelope with hook === "renderChatInput"; a v14 fire of
// renderChatInput produces the same. The envelope hook name is
// always the modern (v14) name.
// v0.3.0 is Foundry v14 only (per tests/PLAN.md file header). v0.2.0
// supported both v13 and v14; v0.3.0 narrows to v14. The dual v13
// `renderChatLog` listener is dropped, and arg-normalization shapes
// assume v14's arity. If a future v14 micro-release renames a hook
// again, add the mapping here.
//
// §9 Hook rename mapping: the envelope.hook name is always the v14
// name. v13 mapping is out of scope.
//
// §10 Arg normalization: for hooks with unstable arity, pad/truncate
// to a documented shape. Consumers can rely on args[N] regardless of
// Foundry version.
// to a documented shape. Consumers can rely on args[N].
//
// This module exports the *normalization functions* and the
// per-hook arg shape definitions. The dispatcher in envelope.js
@@ -20,38 +25,29 @@ import { getEntryForRawName } from "./registered-hooks.js";
// Arg shape per envelope name. undefined = no normalization
// (the args array is passed verbatim from Foundry).
//
// Each shape is a function (rawArgs, foundryVersion?) -> normalizedArgs.
// foundryVersion is optional; absent means "don't version-conditional."
// Each shape is a function (rawArgs) -> normalizedArgs.
//
// v0.2.0 documents shapes for: combatStart, combatEnd, combatTurn,
// v0.3.0 documents shapes for: combatStart, combatEnd, combatTurn,
// combatRound, preUpdateActor, updateActor, preUpdateToken,
// updateToken, dnd5e.rollAttackV2, dnd5e.rollDamageV2.
export const ARG_SHAPES = {
// combatStart(combat, updateData) — arity 2. Stable.
// combatStart(combat, updateData) — arity 2. Stable in v14.
combatStart: (args) => normalizeArity(args, 2),
// combatEnd(combat) — arity 1. Stable in v14. v13 sometimes passed
// (combat, updateData); we normalize to (combat, updateData).
// combatEnd(combat) — arity 1. Stable in v14.
combatEnd: (args) => normalizeArity(args, 2),
// combatTurn(combat, updateData, updateOptions) — v14. v13 sometimes
// passed (combat, updateData, combatantId). Normalize to 3 args;
// the third is whichever Foundry provided.
// combatTurn(combat, updateData, updateOptions) — arity 3. Stable in v14.
combatTurn: (args) => normalizeArity(args, 3),
// combatRound(combat, updateData, updateOptions) — v14. v13 passed
// (combat, updateData, roundNum). Normalize to 4 args:
// [combat, updateData, roundNum ?? updateOptions, updateOptions]
// This is the §10 example shape. The third arg is always the round
// number; the fourth is options.
// combatRound(combat, updateData, updateOptions) — arity 3 in v14.
// Normalized to 4 args: [combat, updateData, updateOptions.round, updateOptions]
// The third arg is always the round number; the fourth is options.
// v13's (combat, updateData, roundNum) shape is out of scope.
combatRound: (args) => {
const out = [args[0], args[1], null, args[2] ?? null];
if (args.length >= 3 && typeof args[2] === "number") {
// v13 shape: round num is the third arg.
out[2] = args[2];
} else if (args.length >= 3 && args[2] && typeof args[2] === "object") {
// v14 shape: third arg is options; the round number lives there.
if (args.length >= 3 && args[2] && typeof args[2] === "object") {
out[2] = args[2].round ?? null;
}
return out;

View File

@@ -95,7 +95,7 @@ export const HOOK_REGISTRY = [
// --- Chat & rolls ---
{ envelope: "createChatMessage", mode: "async", raw: ["createChatMessage"] },
{ envelope: "renderChatMessage", mode: "sync", raw: ["renderChatMessage"] },
{ envelope: "renderChatInput", mode: "sync", raw: ["renderChatInput", "renderChatLog"] },
{ envelope: "renderChatInput", mode: "sync", raw: ["renderChatInput"] },
{ envelope: "dnd5e.rollAttackV2", mode: "async", raw: ["dnd5e.rollAttackV2"] },
{ envelope: "dnd5e.rollDamageV2", mode: "async", raw: ["dnd5e.rollDamageV2"] },

View File

@@ -1,8 +1,18 @@
# hooks-lib test plan — v0.3.0
# hooks-lib test plan — v0.4.0
**Status:** Implemented. Smoke (554/554) + Playwright (30/30) + perf (6/6)
**Scope:** Foundry v14 only. v0.3.0 does not test v13 compatibility;
the anti-corruption layer is reduced to v14-only normalization (no
dual v13/v14 hook subscription). v0.2.0 supported both; v0.3.0 narrows
to v14 because the only consumer (battle-focus) is v14.
**Status:** Implemented. Smoke (546/546) + Playwright (30/30) + perf (6/6)
all green as of 2026-06-20. The § Definition of done release gate is met.
The smoke-test assertion count went from 554 to 546 when v0.4.0 dropped
the v13-only `renderChatLog` and v13-round-num assertions (8 fewer
v13-specific assertions). v0.3.0 was the rename + Playwright test;
v0.4.0 narrows to v14 only.
**Drives:** `tests/verify-hooks-lib.mjs` (no-Foundry smoke),
`tests/verify-hooks-lib-foundry.mjs` (Playwright against a live
Foundry v14 instance), and `tests/perf.mjs` (perf budget).
@@ -116,18 +126,22 @@ A throwing consumer callback does not break the dispatch chain.
### Section E — Anti-corruption
**Hook rename mapping (§9):**
- A Foundry v13 fire of `renderChatLog` produces an envelope with
**Hook rename mapping (§9):** the library subscribes to the v14 hook
names and emits a stable `envelope.hook` name. Example mappings (v0.3.0):
- A Foundry v14 fire of `renderChatInput` produces an envelope with
`hook === "renderChatInput"`.
- A Foundry v14 fire of `renderChatInput` produces the same.
- A Foundry v13 `combatEnd` (when fired) AND a Foundry v14
`updateCombat` with `active: false` both produce envelopes with
`hook === "combatEnd"` (normalized).
- A Foundry v14 `updateCombat` with `active: false` produces an envelope
with `hook === "combatEnd"` (normalized from the raw updateCombat
with active→inactive transition).
- A Foundry v14 `dnd5e.rollAttackV2` produces `envelope.hook ===
"dnd5e.rollAttackV2"`. (v1 `dnd5e.rollAttack` is not supported in
v0.3.0; v13 in general is out of scope per the file header.)
**Arg normalization (§10):**
- For every hook in §6 with documented arg-shape normalization,
firing with raw Foundry args produces an `args` array matching the
documented shape, regardless of Foundry version.
firing with raw Foundry v14 args produces an `args` array matching the
documented shape.
- The exact normalization shapes live in `docs/HOOK_ARG_SHAPES.md`.
v0.3.0 ships shapes for: `combatStart`, `combatEnd`, `combatTurn`,
`combatRound`, `preUpdateActor`, `updateActor`, `preUpdateToken`,
@@ -177,10 +191,12 @@ Beyond Section D's basic cases:
- **System-specific derived events.** That's the adapter's test
plan. hooks-lib's tests verify the adapter *protocol* (registration,
matching, lifecycle) but not the adapter's *content*.
- **Foundry-version compatibility beyond the latest two releases.**
v0.3.0 tests Foundry v13.351 and v14.364. Older versions are
"best effort" — the anti-corruption layer should handle them, but
no CI gate.
- **Foundry v13 compatibility.** v0.3.0 is Foundry v14 only. The
anti-corruption layer in v0.2.0 absorbed both v13 and v14 hook
renames; v0.3.0 narrows to v14. The dual-subscription
`renderChatInput` + `renderChatLog` listener is now just
`renderChatInput`. Older versions of Foundry are "best effort" — not
a CI gate, not in the test plan.
- **Real Foundry runtime for the smoke test.** The no-Foundry smoke
test stubs `Hooks`, `game`, `ui`. It runs in <2s. Real-Foundry
verification is the Playwright test (§ Playwright run below).
@@ -199,8 +215,9 @@ A v0.3.0 release is "done" when:
2. **`npm test` exits 0** in the no-Foundry smoke runner. Runs in
<2s.
3. **`npm run test:foundry` exits 0** against a live Foundry v14
instance with a representative world (3 actors, Bard-vs-Goblin
fixture reused from battle-focus's test helper). Runs in <30s.
instance with a representative world (the battle-focus v0.6.x
`hello-world-demo` world works). Runs in <30s. v13 is not
supported (see file header).
4. **`npm run lint` exits 0** (if we adopt a linter; otherwise skip).
5. **Both pass on the Hermes shell** (Windows, git-bash, Node 18+).
CI portability is a stretch goal, not a gate.
@@ -210,14 +227,14 @@ A v0.3.0 release is "done" when:
---
## Test files (v0.3.0)
## Test files (v0.4.0)
| File | Purpose |
|---|---|
| `tests/verify-hooks-lib.mjs` | No-Foundry smoke. Sections AF (F's perf-measurement subset that doesn't require Foundry). Runs in <2s. Replaces v0.1.0's `verify-hooks-lib.mjs`. |
| `tests/verify-hooks-lib-foundry.mjs` | Playwright against a live Foundry. Loads the library as a Foundry module, asserts `mod.api.isReady() === true`, fires one synthetic `updateActor` and one `dnd5e.rollAttackV2` (stubbed at the Foundry level), asserts the library captures both. Runs in <30s. |
| `tests/verify-hooks-lib.mjs` | No-Foundry smoke. Sections AF (F's perf-measurement subset that doesn't require Foundry). Runs in <2s. v0.4.0 is v14-only — v13-specific assertions are dropped. |
| `tests/verify-hooks-lib-foundry.mjs` | Playwright against a live Foundry v14. Loads the library as a Foundry module, asserts `mod.api.isReady() === true`, fires synthetic `updateActor` + `dnd5e.rollAttackV2` + creates a real combat, asserts envelopes flow through. 30 assertions. Runs in <15s. |
| `tests/perf.mjs` | The performance budget measurement. Runs in <5s. Called from the smoke runner or standalone. |
| `tests/test-helpers.mjs` | Shared stubs and Foundry helpers. Updates the v0.1.0 helper to expose the new Foundry hook stubbing. |
| `tests/test-helpers.mjs` | Shared stubs and Foundry helpers. The no-Foundry smoke stubs `Hooks`, `game`, `ui`. |
The smoke test is the gate for PRs. The Foundry test is the gate
for releases. Performance test runs weekly or on demand.

View File

@@ -384,39 +384,34 @@ async function mainTest() {
assert("E.1c: updateCombat round-change does NOT synthesize combatInactive", inactiveSeenC === null);
uE1c();
// E.2: renderChatInput is registered for BOTH v13 (renderChatLog) and v14 names.
assert(
"E.2: renderChatLog (v13) is in installed raw hooks",
listInstalledRawHooks().includes("renderChatLog")
);
// E.2: renderChatInput is registered (v14 only; v13's renderChatLog
// was supported in v0.2.0 but is dropped in v0.3.0 per the plan's
// v14-only scope).
assert(
"E.2: renderChatInput (v14) is in installed raw hooks",
listInstalledRawHooks().includes("renderChatInput")
);
assert(
"E.2: renderChatLog (v13) is NOT in installed raw hooks",
!listInstalledRawHooks().includes("renderChatLog")
);
// E.3: both produce envelope.hook === "renderChatInput".
// E.3: v14 fire produces envelope.hook === "renderChatInput".
let chatInputSeen = [];
const uE3 = subscribe("renderChatInput", (env) => chatInputSeen.push(env.hook));
Hooks.callAll("renderChatInput", { id: "m1" }, {}, "u1");
Hooks.callAll("renderChatLog", { id: "m2" }, {}, "u1");
await new Promise((r) => setTimeout(r, 10));
assertEq("E.3: both v13 and v14 chat hooks produce envelope.hook='renderChatInput'", chatInputSeen, ["renderChatInput", "renderChatInput"]);
assertEq("E.3: v14 renderChatInput produces envelope.hook='renderChatInput'", chatInputSeen, ["renderChatInput"]);
uE3();
// E.4: arg normalization. combatRound v13 shape: (combat, updateData, roundNum).
// v14 shape: (combat, updateData, updateOptions). Normalized to 4 args.
let roundArgsV13 = null;
const uE4a = subscribe("combatRound", (env) => { roundArgsV13 = env.args; });
Hooks.callAll("combatRound", { id: "c1" }, {}, 3, "u1");
await new Promise((r) => setTimeout(r, 10));
assertEq("E.4a: combatRound v13 shape normalizes round num to args[2]", roundArgsV13?.[2], 3);
uE4a();
// E.4: arg normalization. v14 combatRound shape: (combat, updateData, updateOptions).
// v0.3.0 is v14-only; the v13 round-num position is no longer
// exercised. round number comes from updateOptions.round.
let roundArgsV14 = null;
const uE4b = subscribe("combatRound", (env) => { roundArgsV14 = env.args; });
Hooks.callAll("combatRound", { id: "c1" }, {}, { round: 5 }, "u1");
await new Promise((r) => setTimeout(r, 10));
assertEq("E.4b: combatRound v14 shape normalizes round num to args[2]", roundArgsV14?.[2], 5);
assertEq("E.4: combatRound v14 shape normalizes round num to args[2]", roundArgsV14?.[2], 5);
uE4b();
// ----- Section G — System adapter loading deeper -----
@@ -522,7 +517,7 @@ function syntheticArgsFor(rawName) {
if (rawName === "changeSidebarTab") return [{}];
if (rawName === "getSceneControlButtons") return [[]];
if (rawName === "renderChatMessage") return [{ id: "m1" }, {}, {}];
if (rawName === "renderChatInput" || rawName === "renderChatLog") return [{}, {}, {}];
if (rawName === "renderChatInput") return [{}, {}, {}];
if (rawName === "renderJournalPageSheet") return [{}, {}, {}];
if (rawName === "initializePointSourceShaders") return [{}];
if (rawName === "rtcSettingsChanged") return [{}, {}];