v0.1.0 — initial extraction from battle-focus v0.5.0-alpha.12

Stage 1 of the Hax's Tools split (plan: .hermes/plans/2026-06-20_040000-hax-tools-split.md).

What's in this repo:
- module.json, package.json, README.md, LICENSE, .gitignore
- scripts/main.js — module entry, re-exports loadSystems / registerAllEvents / $ctx on mod.api
- scripts/events/registry.js — single-chokepoint hook wrapper (MODULE_ID retagged)
- scripts/events/core/ — 16 system-agnostic events
- scripts/events/dnd5e/ — 2 dnd5e events (roll-attack, roll-damage)
- scripts/systems/loader.js + core-system.js + dnd5e-system.js — system-adapter layer
- scripts/encounter.js — STAGE-1-STUB: returns null; real encounter wiring lands in Stage 2
- tests/verify-hooks-lib.mjs — 20-assertion no-Foundry smoke test (all green)
- hooks-lib-0.1.0.zip — release artifact for Foundry's manifest download URL

Behavior changes vs. battle-focus source:
- Module-id log strings retagged: [battle-focus] -> [hax-hooks-lib] in loader.js + registry.js
- Token/item stash flag namespace: battle-focus -> hax-hooks-lib (the stash is
  internal to the pre/update pair; consumer-agnostic so we own it)
- Hook calls in hooks-lib's copy are gated on a null encounter (the stub),
  so hooks-lib's events don't fire end-to-end. battle-focus's LOCAL copy
  of the events is untouched and still works. Stage 2 will wire the real
  encounter via registerAllEvents({getActiveEncounter}).

Verified:
- node --check passes on all 26 JS files
- npm test (verify-hooks-lib.mjs) — 20/20 assertions
- python validate-module-json.py module.json — 0 errors, 1 warning (no icon, Stage 2)

Push: Gitea only (git.homelab.local/kaykayyali/hooks-lib). No GitHub mirror.

Refs: battle-focus 8b9db20 (v0.5.0-alpha.12, 220/222 tests)
This commit is contained in:
2026-06-20 00:45:08 -04:00
parent aa5e95e44a
commit d5d0b1655f
32 changed files with 1764 additions and 14 deletions

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Foundry install mirror
# /Data/modules/hax-hooks-lib/ is generated by scripts/copy-to-foundry.mjs (if/when added).
Data/
# Dev environment
node_modules/
*.log
.env
.env.*
# OS junk
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
*.swp
snapshot.json
# Dev artifacts (regenerated on demand, never committed)
journal-snapshot.json
preview/
scripts/session.js
scripts/session-prompts.js
# Build artifacts (created by Python zip recipe or future build-zip.mjs).
# Keep this loose: hooks-lib-X.Y.Z.zip is the named release artifact;
# versioned so future rebuilds don't accidentally overwrite a released
# version. Add a new file rather than deleting old ones when bumping.
hooks-lib-*.zip
!hooks-lib-0.1.0.zip

5
LICENSE Normal file
View File

@@ -0,0 +1,5 @@
UNLICENSED
This module is part of the Hax's Tools project and is not licensed for
redistribution. Source is available at
https://git.homelab.local/kaykayyali/hooks-lib for collaborators.

View File

@@ -1,26 +1,92 @@
# Hax's Tools — Hooks Lib
Foundry VTT module: turns Foundry's hook soup (dnd5e, combat, token updates) into a clean normalized event stream. Works independently of any specific consumer (Battle Focus uses it; other modules can too).
Foundry VTT module (id: `hax-hooks-lib`) that turns Foundry's hook soup
(dnd5e, combat, token updates) 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
- `Its-Achievable` — achievements, rewards, wall, HUD
## Status
Not yet implemented. Sourced from battle-focus `scripts/events/core/` and `scripts/events/registry.js`. Coming soon to a separate repo.
v0.1.0 — initial extraction from `battle-focus` v0.5.0-alpha.12. The
`scripts/events/` and `scripts/systems/` content is a verbatim copy
of the battle-focus versions, with module-id log strings retagged
from `battle-focus` to `hax-hooks-lib`.
## Planned Event Shape
Stage 1 of the split plan: ship this repo without changing
battle-focus's behavior. Stage 2 will flip battle-focus to import
from here and delete the local copy.
```json
## Event Shape
Every event is a plain object with at minimum:
```js
{
"kind": "combat-start | combat-end | combat-turn | combat-round | ...",
"ts": <epoch_ms>,
...
kind: "combat-start", // stable string id
ts: 1719000000000, // epoch ms when handler fired
// ... kind-specific fields
}
```
Includes:
- combat lifecycle (combat-start, combat-end, combat-turn, combat-round, combatant-add, combatant-remove)
- token/actor updates (pre-update-actor, update-actor, pre-update-token, update-token, pre-update-item, update-item)
- effects (create-active-effect, delete-active-effect)
- dnd5e rolls (roll-attack, roll-damage)
- token avatar change
Handler functions in `scripts/events/core/` and `scripts/events/dnd5e/`
return the event object (or `null` to drop it). The registry calls the
consumer's `onEvent(event)` callback for any non-null return.
## Maintained by Kaysser Taylor + Hermes
## Event catalog
Core (system-agnostic, always loaded):
- `combat-start`, `combat-end`, `combat-inactive`, `combat-turn`, `combat-round`
- `combatant-add`, `combatant-remove`
- `pre-update-actor`, `update-actor`, `pre-update-token`, `update-token`, `pre-update-item`, `update-item`
- `create-active-effect`, `delete-active-effect`
- `token-avatar-change`
D&D 5e (loaded when `game.system.id === "dnd5e"`):
- `attack-roll`, `damage-roll`
## Public API (on `game.modules.get("hax-hooks-lib").api`)
- `loadSystems({ currentSystemId, systemVersion })` → array of active
system adapters (`[{ id, label, match, events }]`). The `core`
adapter is always present; other adapters are filtered by `match()`.
- `registerAllEvents(systems, onEvent)` → registers every event from
every active system via `Hooks.on(...)`. The `onEvent` callback is
invoked once per non-null handler return, awaited. Returns the array
of registered event defs (for introspection).
Consumers should call `registerAllEvents` from their `ready` hook and
provide their own `onEvent(event)` to drive their downstream pipeline.
## Dependencies
None. This is a leaf library.
## Architecture notes
- **Single chokepoint:** `registerEvent()` is the only place that
wraps a Foundry hook into the normalized shape. Adding a new event
type = add one file + one line in the relevant system adapter.
- **System loader:** `scripts/systems/loader.js` filters system
adapters by `game.system.id`. Adding a new system = add one file
in `scripts/systems/` + one import line.
- **Context helper:** `$ctx()` returns the active event's metadata
for the duration of the handler call. Use it inside a handler to
log with the event id, or to read which system emitted it.
## Tests
`tests/verify-hooks-lib.mjs` is a minimal smoke test that exercises
the registry + a single event without booting Foundry. Foundry-load
verification happens in battle-focus's E2E suite (the test driver
registers hooks-lib's events through battle-focus's consumer path
and confirms the event stream flows end-to-end).
```bash
npm test
```
## Maintained by Kaysser Taylor + Hermes

BIN
hooks-lib-0.1.0.zip Normal file

Binary file not shown.

36
module.json Normal file
View File

@@ -0,0 +1,36 @@
{
"id": "hax-hooks-lib",
"title": "Hax's Tools — Hooks Lib",
"description": "Foundry VTT module: turns Foundry's hook soup (dnd5e, combat, token updates) into a clean normalized event stream. Works independently of any specific consumer (Battle Focus uses it; other modules can too).",
"version": "0.1.0",
"library": true,
"manifestPlusVersion": "1.2.0",
"authors": [
{
"name": "Kaysser Taylor",
"url": "https://git.homelab.local/kaykayyali"
}
],
"compatibility": {
"minimum": 13,
"verified": 14
},
"relationships": {
"systems": [],
"modules": [],
"requires": []
},
"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/hooks-lib-0.1.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",
"license": "https://git.homelab.local/kaykayyali/hooks-lib/blob/main/LICENSE",
"socket": false,
"flags": {
"allowBugReporter": true,
"hotReload": { "extensions": [], "paths": [] }
}
}

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "hax-hooks-lib",
"version": "0.1.0",
"private": true,
"description": "Foundry VTT module: normalized event stream from Foundry hooks. Library-only — no UI, no settings.",
"main": "scripts/main.js",
"type": "module",
"scripts": {
"test": "node tests/verify-hooks-lib.mjs",
"test:verbose": "TEST_VERBOSE=1 node tests/verify-hooks-lib.mjs"
},
"engines": {
"node": ">=18"
},
"author": "kaykayyali",
"license": "UNLICENSED",
"comment": "This package.json is for test/CI tooling only — Foundry VTT modules are loaded by Foundry, not by npm."
}

41
scripts/encounter.js Normal file
View File

@@ -0,0 +1,41 @@
// STAGE-1-STUB: encounter.js
//
// hooks-lib is a leaf library. It must NOT depend on battle-focus
// (that would create a circular dep and defeat the point of the
// split). But several event handlers do depend on "is there an
// active encounter right now, and what's its state?" for two
// reasons:
//
// 1. Gate events to active encounters only. The events fire
// unconditionally on every Foundry hook, but we only want
// to emit normalized events when there's an encounter in
// progress. Example: `if (!enc) return null;` in roll-attack.
//
// 2. Enrich events with encounter-resolved fields. The handlers
// stash HP "before" snapshots on the encounter, look up
// combatant state, etc. Example:
// enc.lastHp.set(tokenId, { value, max });
//
// STAGE 1 (no behavior change): this file exports `getActive`
// returning `null`. Every event handler therefore sees no active
// encounter, returns `null`, and emits no events. battle-focus's
// LOCAL copy of scripts/events/ is unchanged and still uses the
// real battle-focus Encounter, so its event flow is unaffected.
//
// STAGE 2 (real split): this whole file is deleted. hooks-lib
// exposes a consumer-supplied encounter provider through its
// public API (e.g. `registerAllEvents(systems, onEvent, {
// getActiveEncounter: () => myEncounter })` or a module-level
// setter). Battle-focus wires its `Encounter` getter into the
// API at startup. The event files lose the `getActive` import
// and gain a `lib.getActiveEncounter()` call (passed via the
// handler context or a module-level binding set at registration
// time).
//
// Until then, the existence of this stub is intentional and is
// the only seam between hooks-lib and any specific consumer's
// encounter model.
export function getActive() {
return null;
}

View File

@@ -0,0 +1,31 @@
// Event: combatEnd
// Source: core (Foundry v13+)
//
// Fires when combat ends via the system's End Combat flow. (Combat.delete()
// does NOT fire this; we catch that via core.combatInactive on the
// updateCombat hook.)
// Args: (combat, updateData) — arity 2.
import { $ctx } from '../registry.js';
export default {
id: "core.combatEnd",
hook: "combatEnd",
source: "core",
label: "Combat ended",
description:
"Fires when combat ends via the system's End Combat flow. " +
"Triggers the post-combat summary.",
handler: (combat, updateData) => {
const ctx = $ctx();
const result = {
kind: "combat-end",
ts: Date.now(),
combatId: combat?.id,
combatName: combat?.name,
finalRound: combat?.round,
reason: "end-combat",
};
if (ctx) ctx.log(`combatEnd id=${result.combatId} name="${result.combatName}" round=${result.finalRound}`);
return result;
},
};

View File

@@ -0,0 +1,42 @@
// Event: combatInactive
// Source: core
//
// Fires when a Combat's `active` flag transitions from true to false. This
// is a fallback for combatEnd, which Foundry v13 only fires through the
// dnd5e system's End Combat flow — Combat.delete() and other paths that
// just set active=false don't fire combatEnd.
//
// We hook updateCombat (the broader update event) and filter to the
// active=true→false transition. Note: this is a synthetic event; the
// actual Foundry hook is updateCombat.
//
// Args from updateCombat: (combat, updateData, options, userId) — arity 4.
import { $ctx } from '../registry.js';
export default {
id: "core.combatInactive",
hook: "updateCombat",
source: "core",
label: "Combat went inactive",
description:
"Fires when a Combat's `active` flag transitions from true to false. " +
"Catches Combat.delete() and other paths that don't trigger combatEnd.",
handler: (combat, updateData, options, userId) => {
const ctx = $ctx();
// Only the active: true → false transition. Skip if the update
// doesn't touch `active`, or if the transition is false→true.
if (!updateData || !("active" in updateData)) return null;
if (updateData.active !== false) return null;
// The combat is in the process of deactivating; combat.active may
// still be true at this point (preUpdate). We emit the event anyway;
// the encounter state machine will check combat.active at end-time.
return {
kind: "combat-end",
ts: Date.now(),
combatId: combat?.id,
combatName: combat?.name,
finalRound: combat?.round,
reason: "inactive",
};
},
};

View File

@@ -0,0 +1,31 @@
// Event: combatRound
// Source: core
//
// Fires when the round counter advances. Foundry v14 signature:
// (combat, updateData, updateOptions) — the v13 docs said roundNum
// but v14 passes the update options object as the third arg. The
// actual round is on `combat.round` after the hook fires; we read
// it from there with a fallback to updateData.round for older v13
// callers (tests use the synthetic Hooks.callAll path sometimes).
import { $ctx } from '../registry.js';
export default {
id: "core.combatRound",
hook: "combatRound",
source: "core",
label: "Combat round advanced",
description:
"Fires when the round counter advances.",
handler: (combat, updateData, updateOptions) => {
const ctx = $ctx();
const round = updateData?.round ?? combat?.round ?? 1;
const result = {
kind: "round",
ts: Date.now(),
combatId: combat?.id,
round,
};
if (ctx) ctx.log(`combatRound round=${result.round}`);
return result;
},
};

View File

@@ -0,0 +1,35 @@
// Event: combatStart
// Source: core (Foundry v13+)
//
// Fires on every client when a Combat becomes active (either via the
// "Start Combat" button, or by toggling active=true on a Combat doc).
// Args: (combat, updateData) — arity 2.
//
// This is the "open the journal page" signal.
//
// dnd5e note: dnd5e's Combat5e document has no `name` field; the tracker
// uses the scene name. We fall back to scene.name, then to "Encounter".
import { $ctx } from '../registry.js';
export default {
id: "core.combatStart",
hook: "combatStart",
source: "core",
label: "Combat started",
description:
"Fires when a Combat document becomes active. Used to open a new " +
"journal page for the encounter.",
handler: (combat, updateData) => {
const ctx = $ctx();
const sceneName = combat?.scene?.name ?? null;
const result = {
kind: "combat-start",
ts: Date.now(),
combatId: combat?.id,
combatName: sceneName ?? "Encounter",
round: combat?.round || 1, // 0 is a sentinel for "not started" — treat as round 1
};
if (ctx) ctx.log(`combatStart id=${result.combatId} name="${result.combatName}" round=${result.round}`);
return result;
},
};

View File

@@ -0,0 +1,32 @@
// Event: combatTurn
// Source: core
//
// Fires when the active combatant changes. Foundry v13+ arity 3:
// (combat, updateData, combatantId).
//
// The 3rd arg is a combatant id (string). We use combat.combatant for
// the resolved combatant object.
import { $ctx } from '../registry.js';
export default {
id: "core.combatTurn",
hook: "combatTurn",
source: "core",
label: "Combat turn changed",
description: "Fires when the active combatant changes during combat.",
handler: (combat, updateData, combatantId) => {
const ctx = $ctx();
const combatant = combat?.combatant;
const result = {
kind: "turn",
ts: Date.now(),
combatId: combat?.id,
round: combat?.round,
turn: combat?.turn ?? 0,
combatantId: combatant?.id ?? combatantId ?? null,
combatantName: combatant?.name ?? "(unknown)",
};
if (ctx) ctx.log(`combatTurn R${result.round} T${result.turn} combatant="${result.combatantName}"`);
return result;
},
};

View File

@@ -0,0 +1,78 @@
// Event: createActiveEffect
// Source: core
//
// Fires when an Active Effect is created. In dnd5e (and most systems),
// conditions are stored as Active Effects on an actor or item.
//
// We only track effects on combatants in the active combat. Effects on
// non-combatant actors are dropped.
//
// Args: (effect, options, userId) — arity 3.
import { $ctx } from '../registry.js';
import { getActive } from '../../encounter.js';
// Helper: is the effect's parent a combatant in the active combat?
// Returns the matching combatant, or null.
function combatantForEffect(effect) {
const combat = game.combat;
if (!combat?.active) return null;
const parent = effect?.parent;
if (!parent) return null;
// parent can be an Actor (effect on actor) or an Item (effect on item).
// In dnd5e, conditions on items are unusual; we focus on actor parents.
let actor = parent;
if (parent.documentName === "Item") actor = parent.actor;
if (!actor) return null;
// Resolve to a token id. For an unlinked token, getActiveTokens() returns
// the placed tokens. For a linked token (the actor is a token), use the
// token id directly.
let tokenId = null;
if (actor.isToken) {
tokenId = actor.token?.id;
} else {
const tokens = actor.getActiveTokens?.() ?? [];
tokenId = tokens[0]?.id;
}
return (
combat.getCombatantByToken(tokenId) ??
combat.getCombatantByActor?.(actor.id) ??
null
);
}
export default {
id: "core.createActiveEffect",
hook: "createActiveEffect",
source: "core",
label: "Active effect (condition) created",
description:
"Fires when an Active Effect is created. Tracked only for " +
"combatants in the active combat.",
handler: (effect, options, userId) => {
const ctx = $ctx();
if (!getActive()) return null;
const combatant = combatantForEffect(effect);
if (!combatant) return null;
const parent = effect.parent;
const actor = parent.documentName === "Item" ? parent.actor : parent;
let targetId;
if (actor.isToken) targetId = actor.token?.id ?? actor.id;
else {
const tokens = actor.getActiveTokens?.() ?? [];
targetId = tokens[0]?.id ?? actor.id;
}
const result = {
kind: "condition-add",
ts: Date.now(),
targetId,
targetName: actor?.name ?? "(unnamed)",
condition: effect?.label ?? effect?.name ?? "(unknown)",
};
if (ctx) ctx.log(`condition-add ${result.targetName} +${result.condition}`);
return result;
},
};

View File

@@ -0,0 +1,54 @@
// Event: createCombatant
// Source: core
//
// Fires when a Combatant document is created. Used to track
// mid-fight reinforcements — NPCs that join an active encounter.
//
// Args: (combatant, options, userId) — arity 3.
//
// Returns: a { kind, ts, ... } event if this is a reinforcement
// (i.e. combatant added to an active encounter after it started),
// or null otherwise. The main.js handleEvent ingests the event
// into the active Encounter and adds the combatant to its roster.
import { $ctx } from '../registry.js';
import { getActive } from '../../encounter.js';
export default {
id: "core.createCombatant",
hook: "createCombatant",
source: "core",
label: "Combatant created",
description:
"Fires when a Combatant document is created. Used to track " +
"mid-fight reinforcements.",
handler: (combatant, options, userId) => {
const ctx = $ctx();
const enc = getActive();
if (!enc) return null; // no active encounter — drop
// Only fire for combatants in the ACTIVE combat. We don't filter
// on timing because: (a) initial combatants are often created just
// before combat-start and (b) reinforcements can happen at any
// moment. The Encounter class deduplicates via _ensureCombatant.
// The handler returns null if the encounter is no longer active
// (end() was called).
const isInActiveCombat =
combatant?.parent?.id === enc.combatId &&
enc.isActive?.();
if (!isInActiveCombat) return null;
const result = {
kind: "combatant-add",
ts: Date.now(),
combatantId: combatant?.id,
tokenId: combatant?.tokenId ?? combatant?.id,
combatantName: combatant?.name ?? combatant?.actor?.name ?? "(unnamed)",
actorId: combatant?.actor?.id,
actorName: combatant?.actor?.name,
initiative: combatant?.initiative,
};
if (ctx) ctx.log(`combatant-add ${result.combatantName} (init=${result.initiative}) tokenId=${result.tokenId}`);
return result;
},
};

View File

@@ -0,0 +1,67 @@
// Event: deleteActiveEffect
// Source: core
//
// Fires when an Active Effect is deleted. Tracks condition removal on
// combatants in the active combat; drops non-combatants.
//
// Args: (effect, options, userId) — arity 3.
import { $ctx } from '../registry.js';
import { getActive } from '../../encounter.js';
function combatantForEffect(effect) {
const combat = game.combat;
if (!combat?.active) return null;
const parent = effect?.parent;
if (!parent) return null;
let actor = parent;
if (parent.documentName === "Item") actor = parent.actor;
if (!actor) return null;
let tokenId = null;
if (actor.isToken) {
tokenId = actor.token?.id;
} else {
const tokens = actor.getActiveTokens?.() ?? [];
tokenId = tokens[0]?.id;
}
return (
combat.getCombatantByToken(tokenId) ??
combat.getCombatantByActor?.(actor.id) ??
null
);
}
export default {
id: "core.deleteActiveEffect",
hook: "deleteActiveEffect",
source: "core",
label: "Active effect (condition) removed",
description:
"Fires when an Active Effect is deleted. Tracked only for " +
"combatants in the active combat.",
handler: (effect, options, userId) => {
const ctx = $ctx();
if (!getActive()) return null;
const combatant = combatantForEffect(effect);
if (!combatant) return null;
const parent = effect.parent;
const actor = parent.documentName === "Item" ? parent.actor : parent;
let targetId;
if (actor.isToken) targetId = actor.token?.id ?? actor.id;
else {
const tokens = actor.getActiveTokens?.() ?? [];
targetId = tokens[0]?.id ?? actor.id;
}
const result = {
kind: "condition-remove",
ts: Date.now(),
targetId,
targetName: actor?.name ?? "(unnamed)",
condition: effect?.label ?? effect?.name ?? "(unknown)",
};
if (ctx) ctx.log(`condition-remove ${result.targetName} -${result.condition}`);
return result;
},
};

View File

@@ -0,0 +1,48 @@
// Event: deleteCombatant
// Source: core
//
// Fires when a Combatant document is deleted. Used to track
// mid-fight departures — combatants killed, dismissed, or
// removed from the active encounter.
//
// Args: (combatant, options, userId) — arity 3.
//
// Returns: a { kind, ts, ... } event if this is a mid-fight
// departure, or null otherwise.
import { $ctx } from '../registry.js';
import { getActive } from '../../encounter.js';
export default {
id: "core.deleteCombatant",
hook: "deleteCombatant",
source: "core",
label: "Combatant deleted",
description:
"Fires when a Combatant document is deleted. Used to track " +
"mid-fight departures.",
handler: (combatant, options, userId) => {
const ctx = $ctx();
const enc = getActive();
if (!enc) return null; // no active encounter — drop
// Fire for any combatant removed from the ACTIVE combat while
// the encounter is still active. We don't filter on timing because
// the handler is a no-op for combatants that aren't tracked.
const isMidFightDeparture =
combatant?.parent?.id === enc.combatId &&
enc.isActive?.();
if (!isMidFightDeparture) return null;
const result = {
kind: "combatant-remove",
ts: Date.now(),
combatantId: combatant?.id,
tokenId: combatant?.tokenId ?? combatant?.id,
combatantName: combatant?.name ?? combatant?.actor?.name ?? "(unnamed)",
actorId: combatant?.actor?.id,
};
if (ctx) ctx.log(`combatant-remove ${result.combatantName} tokenId=${result.tokenId}`);
return result;
},
};

View File

@@ -0,0 +1,48 @@
// Event: preUpdateActor
// Source: core
//
// Fires BEFORE an Actor document is updated. We use this as a state-
// capture helper for updateActor: we stash the "before" HP value in
// the active encounter's `lastHp` map, so updateActor can compute
// the delta.
//
// In Foundry v13+ with dnd5e's ActorDelta model, HP changes typically
// go through `actor.update(...)`. The `updateToken` hook doesn't
// always fire for these (verified in v0.3.3 — see probe-update-token
// diagnostic in the test harness). So we ALSO listen for preUpdateActor.
//
// This handler does NOT return a stored event. It returns null.
// Args: (actor, updateData, options, userId) — arity 4.
import { $ctx } from '../registry.js';
import { getActive } from '../../encounter.js';
export default {
id: "core.preUpdateActor",
hook: "preUpdateActor",
source: "core",
label: "Actor about to update",
description:
"Fires before an Actor is updated. Stashes 'before' HP for the " +
"active encounter so updateActor can compute the delta.",
handler: (actor, updateData, options, userId) => {
const ctx = $ctx();
const enc = getActive();
if (!enc) return null;
// Only track actors who have a combatant token in the active combat.
const combat = game.combat;
if (!combat?.active) return null;
const combatant = combat.getCombatantByActor?.(actor);
if (!combatant) return null;
// Stash the "before" HP. We key by tokenId so updateActor (which
// sees the actor) can find it.
const currentHp = actor?.system?.attributes?.hp?.value;
const currentMax = actor?.system?.attributes?.hp?.max;
if (currentHp == null) return null;
enc.lastHp.set(combatant.tokenId, { value: currentHp, max: currentMax });
if (ctx) ctx.log(`preUpdateActor stashed before-HP=${currentHp} for ${actor?.name}`);
return null;
},
};

View File

@@ -0,0 +1,32 @@
// Event: preUpdateItem (state-only, no event emitted)
//
// Fires BEFORE an Item is updated on an Actor. Used to stash the
// OLD equipped state so the updateItem handler can diff and emit
// an equipment-swap event.
//
// Args: (item, updateData, options, userId) — arity 4.
//
// Returns: null (this is a side-effect-only handler; the actual
// equipment-swap event is emitted by updateItem).
export default {
id: "core.preUpdateItem",
hook: "preUpdateItem",
source: "core",
label: "Item pre-update",
description:
"Stashes the previous equipped state of an item so the " +
"updateItem handler can detect swaps.",
handler: (item, updateData, options, userId) => {
if (!item?.parent) return null;
// Only stash for equippable types.
const equippableTypes = new Set(["weapon", "equipment", "armor", "shield"]);
if (!equippableTypes.has(item.type ?? "")) return null;
const stash = item.parent.getFlag("hax-hooks-lib", "_itemStash") ?? {};
stash[item.id] = item.system?.equipped;
// Best-effort write; flag writes during preUpdate may be no-ops
// depending on Foundry version, but the updateItem handler also
// accepts a no-stash fallthrough.
item.parent.setFlag("hax-hooks-lib", "_itemStash", stash).catch?.(() => {});
return null; // no event emitted
},
};

View File

@@ -0,0 +1,50 @@
// Event: preUpdateToken
// Source: core
//
// Fires BEFORE a Token document is updated. We use this as a state-capture
// helper for updateToken: we stash the "before" HP value in the active
// encounter's `lastHp` map, so updateToken can compute the delta.
//
// This handler does NOT return a stored event. It returns null.
// Args: (token, updateData, options, userId) — arity 4.
import { $ctx } from '../registry.js';
import { getActive } from '../../encounter.js';
export default {
id: "core.preUpdateToken",
hook: "preUpdateToken",
source: "core",
label: "Token about to update",
description:
"Fires before a Token is updated. Stashes 'before' HP for the " +
"active encounter so updateToken can compute the delta.",
handler: (token, updateData, options, userId) => {
const ctx = $ctx();
const enc = getActive();
if (!enc) return null;
// Only track combatants. Drop non-combatant token updates.
const combat = game.combat;
if (!combat?.active) return null;
const combatant = combat.getCombatantByToken(token?.id);
if (!combatant) return null;
// Stash the "before" HP. Foundry v13+ reads HP from the linked
// actor (`token.actor.system.attributes.hp.value`). The v9-v11
// `token.actorData` property is gone; reading it returns undefined
// and the handler silently fails.
const currentHp = token?.actor?.system?.attributes?.hp?.value;
const currentMax = token?.actor?.system?.attributes?.hp?.max;
if (currentHp == null) return null;
enc.lastHp.set(token.id, { value: currentHp, max: currentMax });
if (ctx) ctx.log(`preUpdateToken stashed before-HP=${currentHp} for ${token?.name}`);
// Slice 8: also stash texture/ring so tokenAvatarChange handler
// can diff for the equipment/cosmetic-change event.
const oldTexture = token?.texture?.src ?? null;
const oldRing = token?.ring?.color ?? null;
token.setFlag("hax-hooks-lib", "_tokenStash", { texture: oldTexture, ring: oldRing })
.catch?.(() => { /* flag write during preUpdate may be a no-op in some Foundry versions */ });
return null;
},
};

View File

@@ -0,0 +1,71 @@
// Event: token-avatar-change
// Source: core
//
// Fires AFTER a Token document is updated. Used to detect when a PC's
// token image (texture.src), ring color, or other visual properties
// change. Useful for custom achievements like "Disguised: change
// appearance mid-combat" or "Royalty: equip a crown (specific ring
// color)".
//
// Args: (token, updateData, options, userId) — arity 4.
//
// We diff old vs new on the relevant fields via the preUpdateToken
// stash (already populated by the existing pre-update-token handler
// for HP). Here we stash the texture/ring state.
import { $ctx } from '../registry.js';
export default {
id: "core.tokenAvatarChange",
hook: "updateToken",
source: "core",
label: "Token avatar changed",
description:
"Fires after a Token document is updated. Detects changes to " +
"the texture (avatar image) and ring (color) so custom " +
"achievements can react to cosmetic changes.",
handler: (token, updateData, options, userId) => {
const ctx = $ctx();
if (!token) return null;
// We only care about changes to the texture or ring.
const changedTexture = updateData?.texture;
const changedRing = updateData?.ring;
if (!changedTexture && !changedRing) return null;
// Read the pre-update state from a stash populated by
// preUpdateToken (extended). If we don't have a stash, skip.
const stash = token.getFlag("hax-hooks-lib", "_tokenStash") ?? {};
const oldTexture = stash.texture;
const oldRing = stash.ring;
// Clear the stash entry.
token.setFlag("hax-hooks-lib", "_tokenStash", {}).catch?.(() => {});
// Normalize the diff fields. We only emit if either the texture
// src changed or the ring color changed.
const newTexture = updateData?.texture?.src
?? token.texture?.src
?? null;
const newRingColor = updateData?.ring?.color
?? token.ring?.color
?? null;
const textureChanged = oldTexture !== undefined
&& oldTexture !== newTexture;
const ringChanged = oldRing !== undefined
&& oldRing !== newRingColor;
if (!textureChanged && !ringChanged) return null;
const result = {
kind: "token-avatar-change",
ts: Date.now(),
tokenId: token.id,
tokenName: token.name,
actorId: token.actorId ?? token.actor?.id,
fromTexture: oldTexture ?? null,
toTexture: newTexture,
fromRingColor: oldRing ?? null,
toRingColor: newRingColor,
};
if (ctx) ctx.log(`token-avatar-change ${result.tokenName}: texture=${textureChanged} ring=${ringChanged}`);
return result;
},
};

View File

@@ -0,0 +1,77 @@
// Event: updateActor
// Source: core
//
// Fires AFTER an Actor document is updated. Used to detect HP changes
// on actors who are also tokens in the active combat.
//
// In Foundry v13+ with dnd5e's ActorDelta model, HP changes typically
// happen via `actor.update(...)` on the linked actor (which then
// propagates to the token). The `updateToken` hook doesn't always
// fire for these (verified in v0.3.3 — see verify-battle-focus-v4
// section [8] diagnostic). So we ALSO listen for updateActor.
//
// Args: (actor, updateData, options, userId) — arity 4.
//
// Returns: an hp-change event if the actor has a combatant token
// in the active combat, or null otherwise.
import { $ctx } from '../registry.js';
import { getActive } from '../../encounter.js';
export default {
id: "core.updateActor",
hook: "updateActor",
source: "core",
label: "Actor updated",
description:
"Fires after an Actor is updated. Tracks HP changes for combatant " +
"actors; complements updateToken which doesn't always fire for " +
"dnd5e ActorDelta HP edits.",
handler: (actor, updateData, options, userId) => {
const ctx = $ctx();
const enc = getActive();
if (!enc) return null;
const combat = game.combat;
if (!combat?.active) return null;
const combatant = combat.getCombatantByActor?.(actor);
if (!combatant) return null;
// Read the new HP from the update.
const newHp =
updateData?.system?.attributes?.hp?.value ??
updateData?.delta?.system?.attributes?.hp?.value ??
null;
if (newHp == null) return null;
// Read "before" HP from the stash populated by preUpdateActor.
// If missing, we can't compute the delta — drop the event.
const tokenId = combatant.tokenId;
const before = enc.lastHp.get(tokenId);
if (!before) return null;
if (before.value === newHp) {
enc.lastHp.delete(tokenId);
return null; // no change
}
// Clear the stash so we don't double-count.
enc.lastHp.delete(tokenId);
const result = {
kind: "hp-change",
ts: Date.now(),
tokenId,
tokenName: combatant.name ?? actor.name ?? "(unnamed)",
before: before.value,
after: newHp,
delta: newHp - before.value,
// Derive isKill: target's HP just dropped to 0 or below.
// The achievement engine (PER_EVENT_EVALUATORS["hp-change"])
// reads this flag to fire first-blood, death-blow, overkill.
isKill: newHp <= 0 && before.value > 0,
// maxHp at the time of the change — useful for overkill
// (>2x max HP in one shot) and other ratio-based checks.
maxHp: before.max ?? null,
};
if (ctx) ctx.log(`updateActor ${result.tokenName} HP ${result.before} -> ${result.after} (${result.delta})${result.isKill ? ' [KILL]' : ''}`);
return result;
},
};

View File

@@ -0,0 +1,71 @@
// Event: equipment-swap
// Source: core
//
// Fires AFTER an Item is updated on an Actor. Used to detect when a PC
// equips or unequips a weapon/armor (slice 8 — feeds the achievements
// pipeline so GMs can write custom achievements like "Weapon Master:
// use 5 different weapons in one combat" or "Armor Up: swap armor
// during combat").
//
// Args: (item, updateData, options, userId) — arity 4.
// (updateItem hook signature in Foundry v13: hook callback receives the
// updated Item, the updateData object, options, and the userId.)
//
// Returns: an equipment-swap event if the equipped state changed, or
// null otherwise. We diff the old vs new equipped state via
// preUpdateItem (which stashes the old state in the actor's flag).
import { $ctx } from '../registry.js';
export default {
id: "core.updateItem",
hook: "updateItem",
source: "core",
label: "Item updated",
description:
"Fires after an Item on an Actor is updated. Detects equipment " +
"swaps (equipped state changes) for combatant actors so the " +
"achievement pipeline can react to weapon/armor changes.",
handler: (item, updateData, options, userId) => {
const ctx = $ctx();
if (!item?.parent) return null;
// Only fire for items that can be equipped (weapons, armor,
// equipment). Filter by item type to avoid noise from spell
// slot updates, class feature changes, etc.
const equippableTypes = new Set(["weapon", "equipment", "armor", "shield"]);
const itemType = item.type ?? "";
if (!equippableTypes.has(itemType)) return null;
// Diff equipped state. dnd5e v5 stores this at item.system.equipped
// (boolean) and item.system.attunement (number 0=none, 1=req,
// 2=attuned). For the basic "equipment swap" event we just
// detect the equipped boolean flip.
const newEquipped = updateData?.system?.equipped ?? item.system?.equipped;
if (newEquipped === undefined) return null;
// Read the pre-update value from a stash populated by
// preUpdateItem. If we don't have a stash, skip (we can't diff).
const stash = item.parent.getFlag("hax-hooks-lib", "_itemStash") ?? {};
const oldEquipped = stash[item.id];
if (oldEquipped === undefined) return null;
// Clear the stash entry; we don't want to double-fire.
const nextStash = { ...stash };
delete nextStash[item.id];
item.parent.setFlag("hax-hooks-lib", "_itemStash", nextStash);
if (oldEquipped === newEquipped) return null;
const result = {
kind: "equipment-swap",
ts: Date.now(),
actorId: item.parent.id,
actorName: item.parent.name,
itemId: item.id,
itemName: item.name,
itemType,
fromEquipped: oldEquipped,
toEquipped: newEquipped,
};
if (ctx) ctx.log(`equipment-swap ${result.actorName} ${result.itemName}: ${oldEquipped} -> ${newEquipped}`);
return result;
},
};

View File

@@ -0,0 +1,72 @@
// Event: updateToken
// Source: core
//
// Fires AFTER a Token document is updated. We pair this with preUpdateToken
// to compute the HP delta.
//
// Args: (token, updateData, options, userId) — arity 4.
//
// Returns: an hp-change event if the token is a combatant and the HP
// changed, or null otherwise.
import { $ctx } from '../registry.js';
import { getActive } from '../../encounter.js';
export default {
id: "core.updateToken",
hook: "updateToken",
source: "core",
label: "Token updated",
description:
"Fires after a Token is updated. Pairs with preUpdateToken to " +
"compute HP deltas on combatants.",
handler: (token, updateData, options, userId) => {
const ctx = $ctx();
const enc = getActive();
if (!enc) return null;
// Only track combatants.
const combat = game.combat;
if (!combat?.active) return null;
const combatant = combat.getCombatantByToken(token?.id);
if (!combatant) return null;
// Read the new HP from the update. Foundry v13+:
// - For actor updates: updateData.actor.system.attributes.hp.value
// - For token.actorData (v9-v11 legacy): updateData.actorData...
// - For delta updates: updateData.delta.system.attributes.hp.value
// We try each shape so direct HP edits (GM sheet), attack flows
// (dnd5e), and legacy test paths all work.
const newHp =
updateData?.actor?.system?.attributes?.hp?.value ??
updateData?.delta?.system?.attributes?.hp?.value ??
updateData?.actorData?.system?.attributes?.hp?.value ??
null;
if (newHp == null) return null;
if (!before) return null;
const beforeValue = before.value;
if (beforeValue === newHp) return null; // no change
// Clear the stash so we don't double-count if updateToken fires
// multiple times without a preUpdate in between.
enc.lastHp.delete(token.id);
const result = {
kind: "hp-change",
ts: Date.now(),
tokenId: token?.id,
tokenName: token?.name ?? combatant?.name ?? "(unnamed)",
before: beforeValue,
after: newHp,
delta: newHp - beforeValue,
// Derive isKill: target's HP just dropped to 0 or below. The
// achievement engine (PER_EVENT_EVALUATORS["hp-change"]) reads
// this flag to fire first-blood, death-blow, overkill. Without
// it, those achievements never fire from the live pipeline.
isKill: newHp <= 0 && beforeValue > 0,
// maxHp at the time of the change — used by ratio-based checks.
maxHp: before?.max ?? null,
};
if (ctx) ctx.log(`updateToken ${result.tokenName} HP ${result.before} -> ${result.after} (${result.delta})${result.isKill ? ' [KILL]' : ''}`);
return result;
},
};

View File

@@ -0,0 +1,77 @@
// Event: dnd5e.rollAttackV2
// Source: dnd5e
//
// Fires when an attack roll is made through the dnd5e system's modern
// attack flow. (dnd5e.rollAttack is the legacy version; V2 is the
// current API.)
//
// Args: (rolls, { subject, ammoUpdate }) — arity 2.
// - rolls: array of D20Roll objects (one per attack, e.g. multiattack)
// - subject: the AttackActivity that performed the roll
// - ammoUpdate: { id, quantity, destroy? } if ammunition was consumed
import { $ctx } from '../registry.js';
import { getActive } from '../../encounter.js';
export default {
id: "dnd5e.rollAttackV2",
hook: "dnd5e.rollAttackV2",
source: "dnd5e",
label: "Attack roll (dnd5e)",
description:
"Fires when a dnd5e AttackActivity rolls an attack. Provides raw " +
"roll data, the item used, and any ammo consumption.",
handler: (rolls, { subject, ammoUpdate }) => {
const ctx = $ctx();
if (!getActive()) return null;
const actor = subject?.actor;
const item = subject?.item;
const target = subject?.target;
const total = rolls?.[0]?.total;
const formula = rolls?.[0]?.formula;
// Crit detection: d20 === 20. We look at the first Die term's first
// result. Falls back to false if the roll is malformed.
let isCrit = false;
const die = rolls?.[0]?.terms?.find?.(t => t.constructor?.name === "Die");
const d20Result = die?.results?.[0]?.result;
if (d20Result === 20) isCrit = true;
// Compute hit/miss if we have both the roll total and the target's AC.
// dnd5e fires this hook for the attack roll regardless of whether it
// hits; the damage hook only fires on a hit. So an `attack-roll` with
// outcome="miss" is the canonical signal of a miss. A crit is a hit.
let outcome = "unknown";
if (typeof total === "number") {
const targetAC = target?.actor?.system?.attributes?.ac?.value;
if (typeof targetAC === "number") {
if (isCrit) outcome = "crit";
else outcome = total >= targetAC ? "hit" : "miss";
}
}
const result = {
kind: "attack-roll",
ts: Date.now(),
attackerId: actor?.id,
attackerTokenId: actor?.token?.id ?? null,
attackerName: actor?.name ?? "(unknown)",
itemName: item?.name ?? "(unknown item)",
targetId: target?.id ?? null,
targetName: target?.actor?.name ?? null,
targetAC: target?.actor?.system?.attributes?.ac?.value ?? null,
total: total ?? 0,
formula: formula ?? "",
outcome,
isCrit,
// Surface the d20 itself for the HUD's dice-streak tracker.
// The HUD's extractD20FromEvent falls back to ev.d20 first,
// then ev.rolls[0].terms (real Foundry roll data). For events
// we generated, we put it directly on the event so the HUD
// doesn't have to reverse-engineer it from the formula.
d20: typeof d20Result === "number" ? d20Result : null,
rolls: rolls,
};
if (ctx) ctx.log(`attack-roll ${result.attackerName} ${result.itemName}: ${result.total} vs AC ${result.targetAC ?? "?"}${result.outcome}${isCrit ? " (CRIT)" : ""}`);
return result;
},
};

View File

@@ -0,0 +1,51 @@
// Event: dnd5e.rollDamageV2
// Source: dnd5e
//
// Fires when a damage roll is made. Args: (rolls, { subject }).
// rolls is an array (one per damage part) on a weapon/spell.
//
// Pairs with rollAttackV2: an attack → damage is one logical event but
// the system gives us two hooks so we can stream them separately.
import { $ctx } from '../registry.js';
import { getActive } from '../../encounter.js';
export default {
id: "dnd5e.rollDamageV2",
hook: "dnd5e.rollDamageV2",
source: "dnd5e",
label: "Damage roll (dnd5e)",
description:
"Fires when a dnd5e Activity rolls damage. Provides the raw damage " +
"rolls, formulas, and the source item.",
handler: (rolls, { subject }) => {
const ctx = $ctx();
if (!getActive()) return null;
const actor = subject?.actor;
const item = subject?.item;
const total = Array.isArray(rolls)
? rolls.reduce((sum, r) => sum + (r?.total ?? 0), 0)
: (rolls?.total ?? 0);
// The dnd5e activity model doesn't always include the target in the
// roll event payload directly. We try a few common paths.
const target = subject?.target ?? null;
const targetName = target?.name ?? null;
const targetId = target?.id ?? null;
const result = {
kind: "damage-roll",
ts: Date.now(),
attackerId: actor?.id,
attackerTokenId: actor?.token?.id ?? null,
attackerName: actor?.name ?? "(unknown)",
targetId,
targetName: targetName ?? "(unknown target)",
itemName: item?.name ?? "(unknown item)",
total,
formula: Array.isArray(rolls) ? rolls.map((r) => r?.formula).join(" + ") : (rolls?.formula ?? ""),
};
if (ctx) ctx.log(`damage-roll ${result.attackerName} ${result.itemName} -> ${result.targetName}: ${result.total}`);
return result;
},
};

111
scripts/events/registry.js Normal file
View File

@@ -0,0 +1,111 @@
// Event registry.
//
// registerEvent() is the single chokepoint where a Foundry hook gets
// converted into a hax-hooks-lib event. Every event file in events/ ends
// up calling this.
//
// Foundry hook arity varies per hook — combatStart is 2 args, combatTurn
// is 3, preUpdateToken is 4, dnd5e.rollAttackV2 is 2, etc. We can't just
// append ctx as a positional arg without each handler knowing Foundry's
// arity and declaring the right signature.
//
// So we use a different approach: the registry sets an "active" context
// before invoking the Foundry hook and clears it after. Handlers retrieve
// the context via the `$ctx()` helper (imported from this file).
//
// In slice 2, handlers ALSO return a normalized event object (or null to
// drop). The registry calls `onEvent(result)` for non-null results. main.js
// passes a callback that ingests the event into the active encounter and
// appends it to the journal page.
//
// `onEvent` is a module-scoped callback set by registerAllEvents. It is
// shared by all events. (If we ever need per-event callbacks, we'd extend
// the contract — not needed in slice 2.)
const MODULE_ID = "hax-hooks-lib";
let _activeCtx = null;
let _onEvent = null;
/**
* Get the current event's context. Must be called synchronously from
* within a registered event handler. Returns null if called outside a
* handler (e.g. from a setTimeout).
*/
export function $ctx() {
return _activeCtx;
}
export function registerEvent(eventDef) {
if (!eventDef?.id) {
throw new Error(`[${MODULE_ID}] event def missing id: ${JSON.stringify(eventDef)}`);
}
if (!eventDef?.hook) {
throw new Error(`[${MODULE_ID}] event def ${eventDef.id} missing hook name`);
}
if (typeof eventDef.handler !== "function") {
throw new Error(`[${MODULE_ID}] event def ${eventDef.id} missing handler fn`);
}
const ctx = Object.freeze({
source: eventDef.source ?? "unknown",
eventId: eventDef.id,
log: (...args) => {
console.log(`[${MODULE_ID}] [event=${eventDef.id}]`, ...args);
},
});
// Wrap the handler. We set/clear _activeCtx around the call so $ctx()
// works regardless of Foundry's arity. We also call _onEvent for any
// truthy return value. The onEvent callback may be async (e.g. it
// awaits appendEventToPage) — we await it so events write in order
// and the page text is visible by the time handleEvent returns.
const safe = async (...args) => {
const prevCtx = _activeCtx;
const prevOnEvent = _onEvent;
_activeCtx = ctx;
let result;
try {
result = eventDef.handler(...args);
} catch (e) {
console.error(`[${MODULE_ID}] handler for ${eventDef.id} threw:`, e);
_activeCtx = prevCtx;
_onEvent = prevOnEvent;
return undefined;
}
_activeCtx = prevCtx;
// Call the onEvent callback for non-null results. We do this outside
// the try/catch so a single misbehaving event doesn't break the chain.
if (result != null && typeof _onEvent === "function") {
try {
await _onEvent(result);
} catch (e) {
console.error(`[${MODULE_ID}] onEvent for ${eventDef.id} threw:`, e);
}
}
_onEvent = prevOnEvent;
return result;
};
Hooks.on(eventDef.hook, safe);
return { ...eventDef, _registered: true };
}
export function registerAllEvents(systems, onEvent) {
_onEvent = typeof onEvent === "function" ? onEvent : null;
const registered = [];
for (const sys of systems) {
for (const ev of sys.events ?? []) {
try {
registered.push(registerEvent(ev));
} catch (e) {
console.error(
`[${MODULE_ID}] failed to register event ${ev?.id} ` +
`from system ${sys.id}:`,
e
);
}
}
}
return registered;
}

55
scripts/main.js Normal file
View File

@@ -0,0 +1,55 @@
// hax-hooks-lib — module entry point.
//
// This module is a leaf library: it provides a normalized event stream
// derived from Foundry hooks. It does not render UI, does not register
// settings, does not post chat. Consumers (battle-focus, Its-Achievable,
// …) import the public API from this file and call `registerAllEvents`
// from their own `ready` hook with their own onEvent callback.
//
// Lifecycle:
// init: nothing (we have no settings, no patches, no UI to register).
// ready: consumers call loadSystems() then registerAllEvents(systems, onEvent).
// We do not auto-register from here — that would couple this
// library to a specific consumer's lifecycle and force
// `Hooks.on(...)` calls even when no consumer wants them.
//
// The MODULE_ID exposed on `game.modules.get(MODULE_ID).api` is just
// this entry point's exports. Consumers can inspect it for smoke
// testing (e.g. "is hooks-lib loaded and ready?").
const MODULE_ID = "hax-hooks-lib";
const MODULE_VERSION = "0.1.0";
// Public API re-exports. Consumers do:
// import { loadSystems, registerAllEvents, $ctx } from
// "../../../modules/hax-hooks-lib/scripts/main.js";
// or read `game.modules.get("hax-hooks-lib").api.{loadSystems, registerAllEvents, $ctx}`.
export { loadSystems } from "./systems/loader.js";
export {
registerEvent,
registerAllEvents,
$ctx,
} from "./events/registry.js";
function isClient() {
return typeof ui !== "undefined" && !!ui;
}
Hooks.once("init", () => {
const mod = game.modules.get(MODULE_ID);
mod.api = {
MODULE_ID,
version: MODULE_VERSION,
isReady: () => isClient() && !!game.ready,
// Re-export the public API on mod.api so consumers can also do
// const api = game.modules.get("hax-hooks-lib").api;
// api.loadSystems(...);
// api.registerAllEvents(systems, onEvent);
loadSystems,
registerAllEvents,
$ctx,
};
console.log(
`[${MODULE_ID} v${MODULE_VERSION}] init (client=${isClient()}) — library mode, awaiting consumer registration`
);
});

View File

@@ -0,0 +1,51 @@
// The "core" system adapter. Always loaded.
//
// Defines system-agnostic events — hooks that fire on any Foundry system
// regardless of game.system.id. Each event is a single file under
// events/core/ so they can be added/removed in isolation.
import combatStart from "../events/core/combat-start.js";
import combatEnd from "../events/core/combat-end.js";
import combatInactive from "../events/core/combat-inactive.js";
import combatTurn from "../events/core/combat-turn.js";
import combatRound from "../events/core/combat-round.js";
import preUpdateToken from "../events/core/pre-update-token.js";
import updateToken from "../events/core/update-token.js";
import preUpdateActor from "../events/core/pre-update-actor.js";
import updateActor from "../events/core/update-actor.js";
import preUpdateItem from "../events/core/pre-update-item.js";
import updateItem from "../events/core/update-item.js";
import tokenAvatarChange from "../events/core/token-avatar-change.js";
import createActiveEffect from "../events/core/create-active-effect.js";
import deleteActiveEffect from "../events/core/delete-active-effect.js";
import createCombatant from "../events/core/create-combatant.js";
import deleteCombatant from "../events/core/delete-combatant.js";
export const coreSystem = {
id: "core",
label: "Core (system-agnostic)",
// match() is only meaningful for non-core systems; core is always loaded
// by the loader, so this is never called. We still define it to keep the
// shape consistent.
match: () => true,
events: [
combatStart,
combatEnd,
combatInactive,
combatTurn,
combatRound,
preUpdateToken,
updateToken,
preUpdateActor,
updateActor,
preUpdateItem,
updateItem,
tokenAvatarChange,
createActiveEffect,
deleteActiveEffect,
createCombatant,
deleteCombatant,
],
};

View File

@@ -0,0 +1,20 @@
// dnd5e system adapter. Loaded when game.system.id === "dnd5e".
//
// System-specific events layered on top of core. Each event is a single
// file under events/dnd5e/.
//
// Verified against installed dnd5e 5.2.5 in Foundry 13.351:
// Hooks.callAll("dnd5e.rollAttackV2", rolls, { subject, ammoUpdate })
// Hooks.callAll("dnd5e.rollDamageV2", rolls, { subject })
import rollAttack from "../events/dnd5e/roll-attack.js";
import rollDamage from "../events/dnd5e/roll-damage.js";
export const dnd5eSystem = {
id: "dnd5e",
label: "D&D 5e",
match: (sysId) => sysId === "dnd5e",
events: [rollAttack, rollDamage],
};

68
scripts/systems/loader.js Normal file
View File

@@ -0,0 +1,68 @@
// System loader.
//
// The world has exactly one active Foundry system (game.system.id). For
// battle-focus, a "system" means: a slice of events + helpers that's only
// relevant when that specific game system is running. We define one adapter
// per supported system, plus a "core" adapter that's always loaded.
//
// How a system file is discovered:
// - We import each file in systems/*.js explicitly (no glob — Foundry's
// module loader doesn't support Vite-style dynamic imports of arbitrary
// files at runtime). Adding a new system = add one import line here.
// - The system's `match(systemId, systemVersion)` decides whether to
// activate. If false, the system is skipped and a warning is logged.
//
// What a system file exports:
// {
// id: string, // matches game.system.id (or "core")
// label: string, // human-readable
// match: (sysId, sysVer) => bool,
// events: EventDef[] // array of event definitions
// }
import { coreSystem } from "./core-system.js";
import { dnd5eSystem } from "./dnd5e-system.js";
// Add new systems here. The order matters only for log output; core is
// always loaded first by convention.
const SYSTEM_FILES = [coreSystem, dnd5eSystem];
export async function loadSystems({ currentSystemId, systemVersion }) {
const active = [];
for (const sys of SYSTEM_FILES) {
if (sys.id === "core") {
// core is always loaded.
active.push(sys);
continue;
}
try {
if (sys.match(currentSystemId, systemVersion)) {
active.push(sys);
console.log(
`[hax-hooks-lib] system matched: ${sys.id} (${sys.label}) ` +
`for ${currentSystemId} v${systemVersion}`
);
} else {
console.debug?.(
`[hax-hooks-lib] system skipped: ${sys.id} ` +
`(needs ${currentSystemId} v${systemVersion})`
);
}
} catch (e) {
console.error(`[hax-hooks-lib] system ${sys.id} failed to match:`, e);
}
}
if (active.length === 1) {
// Only core loaded. That's fine for worlds that don't use a supported
// system (e.g. a "drawings-only" test world). We still get the core
// events; just no system-specific ones.
console.log(
`[hax-hooks-lib] no system adapter for "${currentSystemId}" — ` +
`loading core events only.`
);
}
return active;
}

65
tests/test-helpers.mjs Normal file
View File

@@ -0,0 +1,65 @@
// hax-hooks-lib — test-helpers.mjs
//
// No-Foundry stubs so the smoke test can exercise the registry and
// systems loader in a plain Node process. We expose a single
// `installStubs()` that wires `globalThis.Hooks`, `globalThis.game`,
// and `globalThis.ui` so that the production code's `import.meta` /
// global lookups resolve.
const _hooks = new Map(); // hookName -> [fn, fn, ...]
const _calls = []; // log of every Hooks.on(name, fn)
export function installStubs() {
globalThis.Hooks = {
on(name, fn) {
_hooks.set(name, [...(_hooks.get(name) ?? []), fn]);
_calls.push({ kind: "on", name, fn });
},
once(name, fn) {
_hooks.set(name, [...(_hooks.get(name) ?? []), fn]);
_calls.push({ kind: "once", name, fn });
},
callAll(name, ...args) {
const fns = _hooks.get(name) ?? [];
let lastResult;
for (const fn of fns) {
try {
lastResult = fn(...args);
} catch (e) {
console.error(`[stubs] Hooks.callAll(${name}) handler threw:`, e);
}
}
return lastResult;
},
off(name, fn) {
const list = _hooks.get(name) ?? [];
const next = list.filter((f) => f !== fn);
if (next.length === 0) _hooks.delete(name);
else _hooks.set(name, next);
},
};
globalThis.game = {
system: { id: "test-system", version: "0.0.0" },
modules: new Map(),
user: null,
ready: true,
};
globalThis.ui = { notifications: { info: () => {}, warn: () => {}, error: () => {} } };
}
export function resetStubs() {
_hooks.clear();
_calls.length = 0;
}
export function getRegisteredHooks() {
return new Map(_hooks);
}
export function getCallLog() {
return [..._calls];
}
export function clearCallLog() {
_calls.length = 0;
}

215
tests/verify-hooks-lib.mjs Normal file
View File

@@ -0,0 +1,215 @@
// 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;
});