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:
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal 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
5
LICENSE
Normal 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.
|
||||
94
README.md
94
README.md
@@ -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
BIN
hooks-lib-0.1.0.zip
Normal file
Binary file not shown.
36
module.json
Normal file
36
module.json
Normal 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
18
package.json
Normal 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
41
scripts/encounter.js
Normal 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;
|
||||
}
|
||||
31
scripts/events/core/combat-end.js
Normal file
31
scripts/events/core/combat-end.js
Normal 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;
|
||||
},
|
||||
};
|
||||
42
scripts/events/core/combat-inactive.js
Normal file
42
scripts/events/core/combat-inactive.js
Normal 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",
|
||||
};
|
||||
},
|
||||
};
|
||||
31
scripts/events/core/combat-round.js
Normal file
31
scripts/events/core/combat-round.js
Normal 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;
|
||||
},
|
||||
};
|
||||
35
scripts/events/core/combat-start.js
Normal file
35
scripts/events/core/combat-start.js
Normal 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;
|
||||
},
|
||||
};
|
||||
32
scripts/events/core/combat-turn.js
Normal file
32
scripts/events/core/combat-turn.js
Normal 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;
|
||||
},
|
||||
};
|
||||
78
scripts/events/core/create-active-effect.js
Normal file
78
scripts/events/core/create-active-effect.js
Normal 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;
|
||||
},
|
||||
};
|
||||
54
scripts/events/core/create-combatant.js
Normal file
54
scripts/events/core/create-combatant.js
Normal 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;
|
||||
},
|
||||
};
|
||||
67
scripts/events/core/delete-active-effect.js
Normal file
67
scripts/events/core/delete-active-effect.js
Normal 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;
|
||||
},
|
||||
};
|
||||
48
scripts/events/core/delete-combatant.js
Normal file
48
scripts/events/core/delete-combatant.js
Normal 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;
|
||||
},
|
||||
};
|
||||
48
scripts/events/core/pre-update-actor.js
Normal file
48
scripts/events/core/pre-update-actor.js
Normal 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;
|
||||
},
|
||||
};
|
||||
32
scripts/events/core/pre-update-item.js
Normal file
32
scripts/events/core/pre-update-item.js
Normal 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
|
||||
},
|
||||
};
|
||||
50
scripts/events/core/pre-update-token.js
Normal file
50
scripts/events/core/pre-update-token.js
Normal 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;
|
||||
},
|
||||
};
|
||||
71
scripts/events/core/token-avatar-change.js
Normal file
71
scripts/events/core/token-avatar-change.js
Normal 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;
|
||||
},
|
||||
};
|
||||
77
scripts/events/core/update-actor.js
Normal file
77
scripts/events/core/update-actor.js
Normal 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;
|
||||
},
|
||||
};
|
||||
71
scripts/events/core/update-item.js
Normal file
71
scripts/events/core/update-item.js
Normal 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;
|
||||
},
|
||||
};
|
||||
72
scripts/events/core/update-token.js
Normal file
72
scripts/events/core/update-token.js
Normal 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;
|
||||
},
|
||||
};
|
||||
77
scripts/events/dnd5e/roll-attack.js
Normal file
77
scripts/events/dnd5e/roll-attack.js
Normal 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;
|
||||
},
|
||||
};
|
||||
51
scripts/events/dnd5e/roll-damage.js
Normal file
51
scripts/events/dnd5e/roll-damage.js
Normal 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
111
scripts/events/registry.js
Normal 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
55
scripts/main.js
Normal 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`
|
||||
);
|
||||
});
|
||||
51
scripts/systems/core-system.js
Normal file
51
scripts/systems/core-system.js
Normal 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,
|
||||
],
|
||||
};
|
||||
20
scripts/systems/dnd5e-system.js
Normal file
20
scripts/systems/dnd5e-system.js
Normal 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
68
scripts/systems/loader.js
Normal 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
65
tests/test-helpers.mjs
Normal 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
215
tests/verify-hooks-lib.mjs
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user