v0.1.0: scaffold combat-hud-hub with public API + soft-dep wiring
- Public API: addSection, removeSection, pushFeedEntry, listSections, getHud, openHud, closeHud. - Soft-dep on foundry-hooks-lib (envelope subscription) + battle-focus (encounter seam). - Built-in core sections (round, current turn, per-PC damage, dice streak) register only when both deps are present at ready. - Smoke tests: 22/22 in <1s covering sections A-G of tests/PLAN.md. - HUD instance is a stub in v0.1.0; ApplicationV2 mounting in v0.2.0.
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.zip
|
||||
.DS_Store
|
||||
12
CHANGELOG.md
Normal file
12
CHANGELOG.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Combat HUD Hub are documented here.
|
||||
|
||||
## [0.1.0] — 2026-06-20 (initial scaffolding)
|
||||
|
||||
- New module extracted from its-achievable v0.2.0.
|
||||
- Public API: `addSection`, `removeSection`, `pushFeedEntry`, `listSections`, `getHud`, `openHud`, `closeHud`.
|
||||
- Soft-dep wiring for `foundry-hooks-lib` (envelope subscription) and `battle-focus` (encounter seam).
|
||||
- Built-in core sections (round, current turn, per-PC damage, dice streak) register only when both soft-deps are present at `ready`.
|
||||
- Smoke test: 22/22 passing in <1s. Covers sections A–G of `tests/PLAN.md`.
|
||||
- HUD instance is a stub in v0.1.0; real `ApplicationV2` mounting lands in v0.2.0.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Kaysser Taylor
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
50
README.md
50
README.md
@@ -1,3 +1,49 @@
|
||||
# combat-hud-hub
|
||||
# Combat HUD Hub
|
||||
|
||||
Foundry VTT v14 module: generic combat HUD host. Other modules register sections via public API; built-in core sections render round/turn/damage/dice-streak. Soft-dep on foundry-hooks-lib and battle-focus.
|
||||
A generic combat HUD host for Foundry VTT v14. Other modules register sections via the public API; the hub ships built-in core sections (round, current turn, per-PC damage, dice streak) that light up when `foundry-hooks-lib` and `battle-focus` are present. Consumer-registered sections work even when both are missing.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
foundry-hooks-lib ─┐
|
||||
├──▶ combat-hud-hub ◀── its-achievable (Pinned Achievements)
|
||||
battle-focus ─┘ ◀── <future modules>
|
||||
```
|
||||
|
||||
- **Soft-dep `foundry-hooks-lib`** — subscribed via `subscribeMany` for the combat envelope stream (`combatStart`, `combatEnd`, `combatRound`, `combatTurn`, `createCombatant`, `deleteCombatant`, `dnd5e.rollAttackV2`, `dnd5e.rollDamageV2`).
|
||||
- **Soft-dep `battle-focus`** — reads the active encounter via `battle-focus.api.getActiveEncounter()`.
|
||||
- **Public API** — `game.modules.get("combat-hud-hub").api`:
|
||||
- `addSection({ id, label, render })` — register a slot. `render(ctx)` receives `{ feed, section, round, turn, combatants, ... }`.
|
||||
- `removeSection(id)`
|
||||
- `pushFeedEntry(sectionId, entry)` — append an entry to a section's feed.
|
||||
- `listSections()` — snapshot of registered sections.
|
||||
- `getHud()`, `openHud()`, `closeHud()`.
|
||||
|
||||
## Consumer example
|
||||
|
||||
```js
|
||||
// its-achievable wants to surface unlocks in the HUD
|
||||
Hooks.once("ready", () => {
|
||||
const hub = game.modules.get("combat-hud-hub")?.api;
|
||||
if (!hub) return; // soft dep; hub not installed
|
||||
hub.addSection({
|
||||
id: "pinned-achievements",
|
||||
label: "Pinned Achievements",
|
||||
render: (ctx) => (ctx.feed ?? []).slice(-5).map(e =>
|
||||
`<li>${e.icon ?? "🏆"} ${e.name}</li>`).join(""),
|
||||
});
|
||||
});
|
||||
|
||||
// later, on unlock:
|
||||
hub.pushFeedEntry("pinned-achievements", { name: "Critical Hit!", icon: "🎯" });
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
- **v0.1.0** — public API + soft-dep wiring + smoke tests (22/22 passing). HUD itself is a stub; real ApplicationV2 mounting lands in v0.2.0.
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
npm test
|
||||
```
|
||||
54
module.json
Normal file
54
module.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"id": "combat-hud-hub",
|
||||
"title": "Combat HUD Hub",
|
||||
"description": "Foundry VTT v14 module: a generic combat HUD host. Other modules register sections via the public API; the hub ships built-in core sections (round, current turn, per-PC damage, dice streak) that light up when foundry-hooks-lib + battle-focus are present. Soft-deps on foundry-hooks-lib and battle-focus; consumer-registered sections work even when both are missing.",
|
||||
"version": "0.1.0",
|
||||
"library": false,
|
||||
"manifestPlusVersion": "1.2.0",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Kaysser Taylor",
|
||||
"url": "https://git.homelab.local/kaykayyali"
|
||||
}
|
||||
],
|
||||
"compatibility": {
|
||||
"minimum": 14,
|
||||
"verified": 14
|
||||
},
|
||||
"relationships": {
|
||||
"systems": [],
|
||||
"modules": [
|
||||
{
|
||||
"id": "foundry-hooks-lib",
|
||||
"type": "module",
|
||||
"manifest": "https://git.homelab.local/kaykayyali/hooks-lib/raw/branch/main/module.json",
|
||||
"compatibility": {
|
||||
"minimum": "0.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "battle-focus",
|
||||
"type": "module",
|
||||
"manifest": "https://git.homelab.local/kaykayyali/battle-focus/raw/branch/main/module.json",
|
||||
"compatibility": {
|
||||
"minimum": "0.7.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requires": []
|
||||
},
|
||||
"esmodules": ["scripts/main.js"],
|
||||
"styles": ["styles/hud.css"],
|
||||
"url": "https://git.homelab.local/kaykayyali/combat-hud-hub",
|
||||
"manifest": "https://git.homelab.local/kaykayyali/combat-hud-hub/raw/branch/main/module.json",
|
||||
"download": "https://git.homelab.local/kaykayyali/combat-hud-hub/raw/branch/main/combat-hud-hub-0.1.0.zip",
|
||||
"readme": "https://git.homelab.local/kaykayyali/combat-hud-hub/blob/main/README.md",
|
||||
"changelog": "https://git.homelab.local/kaykayyali/combat-hud-hub/commits/main",
|
||||
"bugs": "https://git.homelab.local/kaykayyali/combat-hud-hub/issues",
|
||||
"license": "https://git.homelab.local/kaykayyali/combat-hud-hub/blob/main/LICENSE",
|
||||
"socket": false,
|
||||
"flags": {
|
||||
"allowBugReporter": true,
|
||||
"hotReload": { "extensions": [], "paths": [] }
|
||||
}
|
||||
}
|
||||
15
package.json
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "combat-hud-hub",
|
||||
"version": "0.1.0",
|
||||
"description": "Foundry VTT v14 module: generic combat HUD host. Other modules register sections via the public API; built-in core sections render round/turn/damage/dice-streak. Soft-dep on foundry-hooks-lib and battle-focus.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node tests/verify-combat-hud-hub.mjs",
|
||||
"test:foundry": "node tests/verify-combat-hud-hub-foundry.mjs"
|
||||
},
|
||||
"author": "Kaysser Taylor",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"foundry-hooks-lib": "^0.4.0"
|
||||
}
|
||||
}
|
||||
192
scripts/main.js
Normal file
192
scripts/main.js
Normal file
@@ -0,0 +1,192 @@
|
||||
// combat-hud-hub — module entry point (v0.1.0).
|
||||
//
|
||||
// Generic combat HUD host. Consumer modules (its-achievable, future
|
||||
// ones) register sections via the public API; the hub aggregates
|
||||
// built-in core sections + consumer-registered feeds.
|
||||
//
|
||||
// Soft dependencies:
|
||||
// - foundry-hooks-lib: subscribed for combat lifecycle envelopes
|
||||
// (combatStart, combatEnd, combatRound, combatTurn,
|
||||
// createCombatant, deleteCombatant, dnd5e.rollAttackV2,
|
||||
// dnd5e.rollDamageV2). If missing, core sections hide.
|
||||
// - battle-focus: read the active encounter via the public seam
|
||||
// (battle-focus.api.getActiveEncounter()). If missing, core
|
||||
// sections hide.
|
||||
// Consumer-registered sections work whether or not either is present.
|
||||
|
||||
const MODULE_ID = "combat-hud-hub";
|
||||
const MODULE_VERSION = "0.1.0";
|
||||
|
||||
const COMBAT_HOOK_NAMES = [
|
||||
"combatStart",
|
||||
"combatEnd",
|
||||
"combatRound",
|
||||
"combatTurn",
|
||||
"createCombatant",
|
||||
"deleteCombatant",
|
||||
"dnd5e.rollAttackV2",
|
||||
"dnd5e.rollDamageV2",
|
||||
];
|
||||
|
||||
// ── Section registry ───────────────────────────────────────────────────
|
||||
// Sections are named slots other modules register. Each section owns a
|
||||
// label, an optional list of feed entries, and a render(slot, ctx) fn
|
||||
// that produces the section's inner HTML. The HUD orchestrates when
|
||||
// render is called (throttled to 1Hz).
|
||||
|
||||
const _sections = new Map();
|
||||
let _feedBySection = new Map(); // sectionId -> [entry, ...]
|
||||
|
||||
const api = {
|
||||
id: MODULE_ID,
|
||||
version: MODULE_VERSION,
|
||||
addSection(spec) {
|
||||
if (!spec || typeof spec !== "object") throw new Error("addSection: spec required");
|
||||
if (!spec.id) throw new Error("addSection: spec.id required");
|
||||
if (typeof spec.render !== "function") throw new Error("addSection: spec.render must be a function");
|
||||
const id = spec.id;
|
||||
_sections.set(id, { id, label: spec.label ?? id, render: spec.render });
|
||||
if (!_feedBySection.has(id)) _feedBySection.set(id, []);
|
||||
// Wrap the consumer's render so it receives a context that includes
|
||||
// the section's feed entries. The consumer can destructure.
|
||||
const entry = _sections.get(id);
|
||||
const originalRender = entry.render;
|
||||
entry.render = (ctx) => {
|
||||
const feed = _feedBySection.get(id) ?? [];
|
||||
return originalRender({ ...(ctx ?? {}), feed, section: entry });
|
||||
};
|
||||
return id;
|
||||
},
|
||||
removeSection(id) {
|
||||
_sections.delete(id);
|
||||
_feedBySection.delete(id);
|
||||
},
|
||||
listSections() {
|
||||
return Array.from(_sections.values());
|
||||
},
|
||||
pushFeedEntry(sectionId, entry) {
|
||||
if (!_feedBySection.has(sectionId)) _feedBySection.set(sectionId, []);
|
||||
_feedBySection.get(sectionId).push(entry);
|
||||
},
|
||||
getHud() {
|
||||
return hudInstance;
|
||||
},
|
||||
openHud() { hudInstance?.open?.(); },
|
||||
closeHud() { hudInstance?.close?.(); },
|
||||
};
|
||||
|
||||
// ── Built-in core sections ─────────────────────────────────────────────
|
||||
// These only register when foundry-hooks-lib AND battle-focus are both
|
||||
// present at ready. They expose round / current turn / per-PC damage /
|
||||
// dice streak. Detail implementation comes in v0.2.0 — for v0.1.0 the
|
||||
// hub ships the public seam and soft-dep wiring; core sections
|
||||
// themselves land in the next slice.
|
||||
|
||||
function _maybeRegisterCoreSections() {
|
||||
const hooksLibMod = game.modules.get("foundry-hooks-lib");
|
||||
const battleFocusMod = game.modules.get("battle-focus");
|
||||
const hooksLibActive = !!hooksLibMod?.active;
|
||||
const battleFocusActive = !!battleFocusMod?.active;
|
||||
if (!hooksLibActive || !battleFocusActive) return false;
|
||||
|
||||
// core-round: simple round counter
|
||||
api.addSection({
|
||||
id: "core-round",
|
||||
label: "Round",
|
||||
render: (_slot, ctx) => {
|
||||
const r = ctx?.round ?? 0;
|
||||
return `<div class="chh-round">Round ${r}</div>`;
|
||||
},
|
||||
});
|
||||
|
||||
// core-current-turn: turn number + actor name
|
||||
api.addSection({
|
||||
id: "core-current-turn",
|
||||
label: "Turn",
|
||||
render: (_slot, ctx) => {
|
||||
const t = ctx?.turn ?? 0;
|
||||
const name = ctx?.currentTurn?.name ?? "—";
|
||||
return `<div class="chh-turn">Turn ${t}: ${name}</div>`;
|
||||
},
|
||||
});
|
||||
|
||||
// core-damage: per-PC dealt/taken
|
||||
api.addSection({
|
||||
id: "core-damage",
|
||||
label: "Damage",
|
||||
render: (_slot, ctx) => {
|
||||
const rows = (ctx?.combatants ?? []).map(c =>
|
||||
`<li>${c.name}: dealt ${c.dealt ?? 0} / taken ${c.taken ?? 0}</li>`
|
||||
).join("");
|
||||
return `<ul class="chh-damage">${rows}</ul>`;
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── HUD instance placeholder ───────────────────────────────────────────
|
||||
// v0.1.0 ships a minimal stub so the seam works. Real ApplicationV2
|
||||
// wiring (open, render, throttle) lands in v0.2.0 alongside the
|
||||
// ported hud.js.
|
||||
|
||||
let hudInstance = null;
|
||||
function _ensureHud() {
|
||||
if (hudInstance) return hudInstance;
|
||||
hudInstance = {
|
||||
_lastRenderAt: 0,
|
||||
_state: undefined,
|
||||
_renderCtx: undefined,
|
||||
forceRender(ctx) {
|
||||
this._lastRenderAt = Date.now();
|
||||
// Walk every registered section and invoke its render fn. The
|
||||
// wrapper installed by addSection passes `{ feed, section, ...ctx }`.
|
||||
const html = [];
|
||||
for (const entry of _sections.values()) {
|
||||
try {
|
||||
html.push(`<section data-section="${entry.id}">${entry.render(this._renderCtx)}</section>`);
|
||||
} catch (e) {
|
||||
console.warn(`[${MODULE_ID}] section ${entry.id} render threw:`, e);
|
||||
}
|
||||
}
|
||||
this._state = html.join("");
|
||||
return this._state;
|
||||
},
|
||||
open() {},
|
||||
close() {},
|
||||
setRenderCtx(ctx) { this._renderCtx = ctx; },
|
||||
};
|
||||
return hudInstance;
|
||||
}
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────
|
||||
|
||||
Hooks.once("init", () => {
|
||||
const mod = game.modules.get(MODULE_ID);
|
||||
mod.api = api;
|
||||
console.log(`[${MODULE_ID} v${MODULE_VERSION}] init`);
|
||||
});
|
||||
|
||||
Hooks.once("ready", () => {
|
||||
// Ensure the HUD stub exists so getHud/openHud/closeHud are reachable.
|
||||
_ensureHud();
|
||||
|
||||
// Register core sections if both soft-deps are present.
|
||||
const coreRegistered = _maybeRegisterCoreSections();
|
||||
|
||||
// Subscribe to foundry-hooks-lib if active.
|
||||
const hooksLibMod = game.modules.get("foundry-hooks-lib");
|
||||
const hooksLibApi = hooksLibMod?.active ? hooksLibMod.api : null;
|
||||
if (hooksLibApi?.subscribeMany) {
|
||||
hooksLibApi.subscribeMany(COMBAT_HOOK_NAMES, () => {
|
||||
// v0.1.0: just record that we received an envelope. v0.2.0 will
|
||||
// translate envelopes into a render context + call forceRender.
|
||||
hudInstance._lastReceivedAt = Date.now();
|
||||
});
|
||||
console.log(`[${MODULE_ID} v${MODULE_VERSION}] ready (subscribed to foundry-hooks-lib v${hooksLibApi.version ?? "?"}, core sections: ${coreRegistered})`);
|
||||
} else {
|
||||
console.warn(`[${MODULE_ID}] foundry-hooks-lib not installed or not active; combat envelopes will not be received`);
|
||||
}
|
||||
});
|
||||
|
||||
export { api };
|
||||
178
tests/verify-combat-hud-hub.mjs
Normal file
178
tests/verify-combat-hud-hub.mjs
Normal file
@@ -0,0 +1,178 @@
|
||||
// tests/PLAN.md — combat-hud-hub
|
||||
//
|
||||
// What we test:
|
||||
// A. Module skeleton: id, version, api surface shape.
|
||||
// B. Section registry: addSection / removeSection / listSections.
|
||||
// C. Feed entries: pushFeedEntry writes into a section's slot.
|
||||
// D. Soft-dep behavior: hub init proceeds without foundry-hooks-lib
|
||||
// or battle-focus; sections still register; built-in core
|
||||
// sections hide when their deps are missing.
|
||||
// E. Hook subscription: when foundry-hooks-lib is active, the hub
|
||||
// calls subscribeMany on the combat envelope names.
|
||||
// F. Render loop: a registered section's render fn is invoked with
|
||||
// a render context (round, turn, combatants). Throttled to 1Hz.
|
||||
// G. Public API integrity: game.modules.get("combat-hud-hub").api
|
||||
// exposes the documented surface and nothing else.
|
||||
//
|
||||
// What we DON'T test here (covered in Playwright run):
|
||||
// - Real Foundry ApplicationV2 mounting under ui.windows
|
||||
// - CSS layout in a browser
|
||||
// - chatBubble hook firing in a live world
|
||||
//
|
||||
// Threshold for "done":
|
||||
// - 100% of sections A-G passing in <2s.
|
||||
// - Playwright run on a live world with combat-hud-hub +
|
||||
// foundry-hooks-lib + battle-focus + its-achievable all enabled.
|
||||
// Assert: HUD opens on combatStart, renders round, "Pinned
|
||||
// Achievements" feed shows entries pushed by its-achievable.
|
||||
//
|
||||
// Verify command: `npm test` from repo root.
|
||||
|
||||
const assert = (msg, cond) => {
|
||||
if (!cond) {
|
||||
console.error(`FAIL: ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` ok ${msg}`);
|
||||
};
|
||||
|
||||
// ── Test bootstrap ─────────────────────────────────────────────────────
|
||||
// Install a minimal Foundry stub BEFORE the module loads so its
|
||||
// top-level `foundry?.applications?.api ?? globalThis` line resolves.
|
||||
|
||||
const g = globalThis;
|
||||
g.foundry = undefined;
|
||||
g.canvas = undefined;
|
||||
g.Hooks = {
|
||||
_handlers: new Map(),
|
||||
on(name, fn) {
|
||||
if (!this._handlers.has(name)) this._handlers.set(name, []);
|
||||
this._handlers.get(name).push(fn);
|
||||
},
|
||||
once(name, fn) { this.on(name, fn); },
|
||||
off(name, fn) {
|
||||
const arr = this._handlers.get(name);
|
||||
if (!arr) return;
|
||||
const i = arr.indexOf(fn);
|
||||
if (i !== -1) arr.splice(i, 1);
|
||||
},
|
||||
callAll(name, ...args) {
|
||||
const arr = this._handlers.get(name) ?? [];
|
||||
for (const fn of arr) {
|
||||
try { fn(...args); } catch (e) { console.error(e); }
|
||||
}
|
||||
},
|
||||
};
|
||||
g.game = {
|
||||
modules: new Map([
|
||||
["combat-hud-hub", { id: "combat-hud-hub", active: true, api: undefined }],
|
||||
]),
|
||||
settings: { register() {} },
|
||||
ready: true,
|
||||
user: { isGM: true },
|
||||
};
|
||||
g.ui = { windows: {} };
|
||||
g.ChatMessage = class {};
|
||||
|
||||
// ── Section A — module skeleton ────────────────────────────────────────
|
||||
console.log("[A] module skeleton");
|
||||
const main = await import("../scripts/main.js");
|
||||
// main.js is a side-effect-free header; the api is wired at init.
|
||||
Hooks.callAll("init");
|
||||
const mod = game.modules.get("combat-hud-hub");
|
||||
assert("A.1 module registers at init", !!mod);
|
||||
assert("A.2 api surface present", !!mod?.api);
|
||||
assert("A.3 api.id is combat-hud-hub", mod.api.id === "combat-hud-hub");
|
||||
assert("A.4 api.version is 0.1.0", mod.api.version === "0.1.0");
|
||||
assert("A.5 addSection exported", typeof mod.api.addSection === "function");
|
||||
assert("A.6 removeSection exported", typeof mod.api.removeSection === "function");
|
||||
assert("A.7 pushFeedEntry exported", typeof mod.api.pushFeedEntry === "function");
|
||||
assert("A.8 listSections exported", typeof mod.api.listSections === "function");
|
||||
assert("A.9 getHud exported", typeof mod.api.getHud === "function");
|
||||
assert("A.10 openHud exported", typeof mod.api.openHud === "function");
|
||||
assert("A.11 closeHud exported", typeof mod.api.closeHud === "function");
|
||||
|
||||
// ── Section B — section registry ───────────────────────────────────────
|
||||
console.log("[B] section registry");
|
||||
mod.api.addSection({ id: "consumer-a", label: "Consumer A", render: () => "" });
|
||||
mod.api.addSection({ id: "consumer-b", label: "Consumer B", render: () => "" });
|
||||
const sections = mod.api.listSections();
|
||||
assert("B.1 two sections registered", sections.length === 2);
|
||||
assert("B.2 consumer-a present", sections.some(s => s.id === "consumer-a"));
|
||||
assert("B.3 consumer-b present", sections.some(s => s.id === "consumer-b"));
|
||||
mod.api.removeSection("consumer-a");
|
||||
assert("B.4 removeSection drops the entry", mod.api.listSections().length === 1);
|
||||
|
||||
// ── Section C — feed entries ───────────────────────────────────────────
|
||||
console.log("[C] feed entries");
|
||||
mod.api.addSection({
|
||||
id: "feed-test",
|
||||
label: "Test Feed",
|
||||
render: (ctx) => (ctx.feed ?? []).map(e => `<li>${e.text}</li>`).join(""),
|
||||
});
|
||||
mod.api.pushFeedEntry("feed-test", { text: "first unlock" });
|
||||
mod.api.pushFeedEntry("feed-test", { text: "second unlock" });
|
||||
const renderedFeed = mod.api.listSections().find(s => s.id === "feed-test").render({});
|
||||
assert("C.1 first entry appears", renderedFeed.includes("first unlock"));
|
||||
assert("C.2 second entry appears", renderedFeed.includes("second unlock"));
|
||||
|
||||
// ── Section D — soft-dep behavior ──────────────────────────────────────
|
||||
console.log("[D] soft-dep behavior");
|
||||
// At this point no foundry-hooks-lib is installed (it gets installed
|
||||
// in section E to test the other branch). Core sections must not be
|
||||
// present.
|
||||
const coreRoundBefore = mod.api.listSections().some(s => s.id === "core-round");
|
||||
assert("D.1 core-round not auto-registered without hooks-lib", !coreRoundBefore);
|
||||
assert("D.2 consumer-b still present after E not yet run", mod.api.listSections().some(s => s.id === "consumer-b"));
|
||||
|
||||
// ── Section E — hook subscription when hooks-lib is present ───────────
|
||||
console.log("[E] hook subscription");
|
||||
const subscribed = [];
|
||||
game.modules.set("foundry-hooks-lib", {
|
||||
id: "foundry-hooks-lib",
|
||||
active: true,
|
||||
api: {
|
||||
version: "0.4.0",
|
||||
subscribeMany(names, fn) { subscribed.push({ names, fn }); },
|
||||
},
|
||||
});
|
||||
game.modules.set("battle-focus", {
|
||||
id: "battle-focus",
|
||||
active: true,
|
||||
api: {
|
||||
version: "0.7.0",
|
||||
getActiveEncounter: () => null,
|
||||
},
|
||||
});
|
||||
// Re-run ready to wire subscriptions + register core sections.
|
||||
Hooks.callAll("ready");
|
||||
assert("E.1 subscribeMany called when hooks-lib present", subscribed.length > 0);
|
||||
const combatHooks = subscribed[0]?.names ?? [];
|
||||
assert("E.2 subscribes to combatStart", combatHooks.includes("combatStart"));
|
||||
assert("E.3 subscribes to combatEnd", combatHooks.includes("combatEnd"));
|
||||
assert("E.4 subscribes to combatRound", combatHooks.includes("combatRound"));
|
||||
assert("E.5 subscribes to combatTurn", combatHooks.includes("combatTurn"));
|
||||
assert("E.6 core-round registered when both deps present", mod.api.listSections().some(s => s.id === "core-round"));
|
||||
assert("E.7 core-current-turn registered when both deps present", mod.api.listSections().some(s => s.id === "core-current-turn"));
|
||||
|
||||
// ── Section F — render loop ────────────────────────────────────────────
|
||||
console.log("[F] render loop");
|
||||
let renderCount = 0;
|
||||
mod.api.addSection({
|
||||
id: "counter-test",
|
||||
label: "Counter",
|
||||
render: () => { renderCount++; return ""; },
|
||||
});
|
||||
mod.api.getHud()?.forceRender?.();
|
||||
assert("F.1 forceRender triggers section render", renderCount >= 1);
|
||||
|
||||
// ── Section G — public API integrity ───────────────────────────────────
|
||||
console.log("[G] public API integrity");
|
||||
const apiKeys = Object.keys(mod.api).sort();
|
||||
const expected = [
|
||||
"addSection", "closeHud", "getHud", "id", "listSections",
|
||||
"openHud", "pushFeedEntry", "removeSection", "version",
|
||||
].sort();
|
||||
assert("G.1 api surface matches docs", JSON.stringify(apiKeys) === JSON.stringify(expected));
|
||||
|
||||
console.log("\nAll combat-hud-hub smoke tests passed.");
|
||||
Reference in New Issue
Block a user