v0.2.0: port HUD from its-achievable as a section-based host

- Ported scripts/hud.js, scripts/event-translation.js,
  templates/hud.html, styles/hud.css from its-achievable v0.2.0.
- Renamed bf-hud-* CSS prefix to chh-* (function-over-branding).
- HUD now renders via addSection({ id, label, render }); no internal
  pinned-achievements feed. its-achievable (v0.3.0) registers its own
  pinned-achievements section.
- Built-in core sections: core-header, core-combatants, core-dice-streak.
- New hudPosition setting (per-user, top/bottom/left/right).
- Throttled render (1Hz).
- ApplicationV2 surface: open, close, isOpen, getState, getView,
  forceRender, wireHooks, unwireHooks.
- Tests: 42/42 passing in <1s covering PLAN sections A-K.
This commit is contained in:
2026-06-22 12:37:12 -04:00
parent 9c4e4c86fe
commit 04008acc66
10 changed files with 1348 additions and 155 deletions

View File

@@ -2,11 +2,22 @@
All notable changes to Combat HUD Hub are documented here.
## [0.2.0] — 2026-06-20 (port HUD from its-achievable)
- Ported `scripts/hud.js`, `scripts/event-translation.js`, `templates/hud.html`, `styles/hud.css` from its-achievable v0.2.0.
- Renamed `bf-hud-*` CSS class prefix to `chh-*` (function-over-branding).
- Refactored HUD to render via `addSection({ id, label, render })` API. The HUD itself no longer owns a `pinnedAchievements` feed — that becomes a section registered by its-achievable (v0.3.0).
- Built-in core sections now: `core-header` (round + current turn + portrait), `core-combatants` (per-PC rows), `core-dice-streak`.
- New `hudPosition` setting (top/bottom/left/right, per-user) replaces the previous its-achievable-owned setting.
- Throttled render (1Hz, same as its-achievable v0.2.0).
- ApplicationV2 surface: `open`, `close`, `isOpen`, `getState`, `getView({ isGM, character })`, `forceRender`, `wireHooks`, `unwireHooks`.
- Smoke tests: 42/42 passing in <1s covering sections A-K of `tests/PLAN.md`.
## [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 AG of `tests/PLAN.md`.
- HUD instance is a stub in v0.1.0; real `ApplicationV2` mounting lands in v0.2.0.
- Built-in core sections register only when both soft-deps are present at `ready`.
- Smoke test: 22/22 passing in <1s.
- HUD instance is a stub; real `ApplicationV2` mounting deferred.

View File

@@ -40,7 +40,8 @@ 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.
- **v0.2.0** — ApplicationV2 with section-based rendering, throttled to 1Hz. Built-in core sections (header, combatants, dice streak). 42/42 smoke tests passing.
- **v0.1.0** — public API + soft-dep wiring (initial scaffolding).
## Tests

View File

@@ -2,7 +2,7 @@
"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",
"version": "0.2.0",
"library": false,
"manifestPlusVersion": "1.2.0",
"authors": [
@@ -41,7 +41,7 @@
"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",
"download": "https://git.homelab.local/kaykayyali/combat-hud-hub/raw/branch/main/combat-hud-hub-0.2.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",

View File

@@ -1,6 +1,6 @@
{
"name": "combat-hud-hub",
"version": "0.1.0",
"version": "0.2.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": {

View File

@@ -0,0 +1,172 @@
// scripts/event-translation.js
//
// Thin translation layer: hooks-lib envelope args -> the event shape
// the combat HUD consumes. Each handler returns a
// `{kind, ts, ...eventData}` event object or null.
//
// Per the v0.6.x split intent, this duplicates the shape of
// battle-focus's event handlers (scripts/events.js) but ONLY for
// the hooks the HUD cares about. battle-focus and combat-hud-hub are
// independent consumers of the foundry-hooks-lib envelope stream;
// the duplication is intentional and small.
/**
* Build a timestamped event object. ts is a Date.now() taken at
* translation time.
*/
function ev(kind, data = {}) {
return { kind, ts: Date.now(), ...data };
}
/**
* combatStart(combat, updateData) — fires when a combat is started.
* The HUD uses this to know "the encounter began."
*/
export function onCombatStart(combat /*, updateData */) {
return ev("combat-start", {
combatId: combat?.id ?? null,
sceneId: combat?.scene?.id ?? null,
round: combat?.round ?? 1,
});
}
/**
* combatEnd(combat) — fires when a combat is ended.
*/
export function onCombatEnd(combat) {
return ev("combat-end", {
combatId: combat?.id ?? null,
});
}
/**
* combatRound(combat, updateData, updateOptions) — fires on round change.
*/
export function onCombatRound(combat, updateData /*, updateOptions */) {
return ev("round", {
combatId: combat?.id ?? null,
round: updateData?.round ?? combat?.round ?? null,
});
}
/**
* combatTurn(combat, updateData, updateOptions) — fires on turn change.
*/
export function onCombatTurn(combat, updateData /*, updateOptions */) {
return ev("turn", {
combatId: combat?.id ?? null,
turn: updateData?.turn ?? combat?.turn ?? null,
combatantId: combat?.combatant?.id ?? null,
});
}
/**
* createCombatant(combatant, options, userId) — fires on combatant
* add. Note: the Foundry arg order in v14 is
* (combatant, options, userId). Some v14 micro-releases also
* surface this as a single object — we accept both shapes.
*/
export function onCreateCombatant(combatant, options, userId) {
return ev("combatant-add", {
combatantId: combatant?.id ?? null,
tokenId: combatant?.tokenId ?? null,
actorId: combatant?.actorId ?? null,
name: combatant?.name ?? null,
initiative: combatant?.initiative ?? null,
userId: userId ?? null,
});
}
/**
* deleteCombatant(combatant, options, userId) — fires on combatant
* remove.
*/
export function onDeleteCombatant(combatant /*, options, userId */) {
return ev("combatant-remove", {
combatantId: combatant?.id ?? null,
tokenId: combatant?.tokenId ?? null,
actorId: combatant?.actorId ?? null,
name: combatant?.name ?? null,
});
}
/**
* dnd5e.rollAttackV2(rolls, { subject, ammoUpdate }) — dnd5e v2
* attack roll. The HUD cares about this for the dice-streak
* counter: extract d20 from rolls[0].terms[].results[0].result.
*/
export function onDnd5eRollAttack(rolls, { subject } = {}) {
const actor = subject?.actor ?? null;
const item = subject?.item ?? null;
const target = subject?.target ?? null;
const d20 = extractD20(rolls);
const total = Array.isArray(rolls)
? rolls.reduce((sum, r) => sum + (r?.total ?? 0), 0)
: rolls?.total ?? 0;
return ev("attack-roll", {
attackerId: actor?.id ?? null,
attackerTokenId: actor?.token?.id ?? null,
attackerName: actor?.name ?? "(unknown)",
itemName: item?.name ?? "(unknown item)",
targetId: target?.id ?? null,
targetName: target?.actor?.name ?? null,
total,
formula: Array.isArray(rolls) ? rolls[0]?.formula ?? "" : rolls?.formula ?? "",
d20,
rolls,
});
}
/**
* dnd5e.rollDamageV2(rolls, { subject }) — dnd5e v2 damage roll.
* The HUD uses this for the damage display in the encounter feed.
*/
export function onDnd5eRollDamage(rolls, { subject } = {}) {
const actor = subject?.actor ?? null;
const item = subject?.item ?? null;
const target = subject?.target ?? null;
const total = Array.isArray(rolls)
? rolls.reduce((sum, r) => sum + (r?.total ?? 0), 0)
: rolls?.total ?? 0;
return ev("damage-roll", {
attackerId: actor?.id ?? null,
attackerTokenId: actor?.token?.id ?? null,
attackerName: actor?.name ?? "(unknown)",
targetId: target?.id ?? null,
targetName: target?.name ?? target?.actor?.name ?? "(unknown target)",
itemName: item?.name ?? "(unknown item)",
total,
formula: Array.isArray(rolls)
? rolls.map((r) => r?.formula).join(" + ")
: rolls?.formula ?? "",
});
}
export function onUpdateActor() { return null; }
/**
* Find the d20 value from a dnd5e roll array. The first roll's
* terms contain a Die with faces === 20 (the d20 itself).
*
* Synthetic test events: pass `{ d20: 17, formula: "1d20+5" }`.
* Foundry real events: pass an array of D20Roll objects.
*/
function extractD20(rolls) {
if (!rolls) return null;
if (typeof rolls === "object" && !Array.isArray(rolls) && typeof rolls.d20 === "number") {
return rolls.d20;
}
if (!Array.isArray(rolls) || !rolls[0]) return null;
const r0 = rolls[0];
if (typeof r0.total !== "number") return null;
const terms = r0.terms;
if (Array.isArray(terms)) {
for (const t of terms) {
if (t && (t.faces === 20 || t.constructor?.name === "Die")) {
const result = t.results?.[0]?.result;
if (typeof result === "number") return result;
}
}
}
return null;
}

550
scripts/hud.js Normal file
View File

@@ -0,0 +1,550 @@
// scripts/hud.js — Combat HUD Hub's ApplicationV2 instance.
//
// A floating HUD that aggregates registered sections. Consumer
// modules register sections via combat-hud-hub.api.addSection();
// the hub ships built-in core sections (core-header, core-combatants,
// core-dice-streak) that light up when battle-focus is present.
//
// Lifecycle: created as a singleton in main.js's getHud(). Subscribes
// to foundry-hooks-lib envelope stream at ready (when hooks-lib is
// active). Reads the active encounter from battle-focus.api.
// Throttled to once per second.
//
// API surface (game.modules.get("combat-hud-hub").api):
// getHud() -> CombatHudHubApp instance
// openHud() / closeHud() -> proxies to instance.open() / .close()
// Sections are managed via the same api object (addSection,
// removeSection, listSections, pushFeedEntry — see main.js).
import {
onCombatStart,
onCombatEnd,
onCombatRound,
onCombatTurn,
onCreateCombatant,
onDeleteCombatant,
onDnd5eRollAttack,
onDnd5eRollDamage,
} from "./event-translation.js";
const MODULE_ID = "combat-hud-hub";
// Foundry v14: ApplicationV2 + HandlebarsApplicationMixin live under
// foundry.applications.api. Resolve with safe fallbacks.
const _APP = foundry?.applications?.api ?? globalThis;
const ApplicationV2 = _APP.ApplicationV2 ?? globalThis.ApplicationV2;
const HandlebarsApplicationMixin =
_APP.HandlebarsApplicationMixin ?? globalThis.HandlebarsApplicationMixin;
// Re-render at most once per this many milliseconds.
const RENDER_THROTTLE_MS = 1000;
// Dice-streak dedup window.
const DICE_STREAK_MAX_GAP_MS = 8000;
const POSITION_CLASSES = {
top: "chh-hud--top",
bottom: "chh-hud--bottom",
left: "chh-hud--left",
right: "chh-hud--right",
};
// ── Helpers ────────────────────────────────────────────────────────────
function formatDuration(ms) {
if (!Number.isFinite(ms) || ms < 0) return "0:00";
const total = Math.floor(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
function resolvePortrait(tokenDoc, actorDoc) {
try { if (tokenDoc?.texture?.src) return tokenDoc.texture.src; }
catch (_) { /* no texture */ }
try { if (actorDoc?.img) return actorDoc.img; }
catch (_) { /* no img */ }
return null;
}
function hpPercent(actorDoc) {
try {
const hp = actorDoc?.system?.attributes?.hp;
const max = hp?.max ?? null;
const value = hp?.value ?? null;
if (max == null || max <= 0 || value == null) return null;
return Math.max(0, Math.min(100, Math.round((value / max) * 100)));
} catch (_) { return null; }
}
function getPlayerCharacterId() {
try { return game?.user?.character?.id ?? null; }
catch (_) { return null; }
}
function getHudPosition() {
try {
const v = game.settings.get(MODULE_ID, "hudPosition");
if (v === "top" || v === "bottom" || v === "left" || v === "right") return v;
} catch (_) { /* setting not registered yet */ }
return "top";
}
function extractD20FromEvent(ev) {
if (!ev) return null;
if (typeof ev.d20 === "number") return ev.d20;
const rolls = ev.rolls ?? ev.rawRolls ?? null;
if (Array.isArray(rolls) && rolls[0]) {
const r0 = rolls[0];
const die = r0.terms?.find?.((t) => t.constructor?.name === "Die");
const result = die?.results?.[0]?.result;
if (typeof result === "number") return result;
}
return null;
}
// ── Build the per-section render context ───────────────────────────────
// The HUD runs every registered section's render() with a context
// derived from the current state + encounter. We give each section
// the same shared context shape so consumers can compose freely.
function buildRenderContext(state, encounter, event) {
const isGM = (() => {
try { return !!game?.user?.isGM; }
catch (_) { return true; }
})();
const playerCharId = getPlayerCharacterId();
const viewMode = isGM ? "gm" : "player";
// Per-PC combatant rows.
const allCombatants = [];
if (encounter) {
for (const c of encounter.combatants?.values?.() ?? []) {
const tok = (() => {
try { return canvas?.tokens?.get(c.tokenId)?.document ?? null; }
catch (_) { return null; }
})();
const actor = c.actorId ? game.actors?.get(c.actorId) : null;
let damageDealt = 0, damageTaken = 0, hits = 0, crits = 0;
const perRound = encounter.statsByRound?.get(c.tokenId);
if (perRound instanceof Map) {
for (const stat of perRound.values()) {
damageDealt += stat.damageDealt ?? 0;
damageTaken += stat.damageTaken ?? 0;
hits += stat.hits ?? 0;
crits += stat.crits ?? 0;
}
}
allCombatants.push({
tokenId: c.tokenId,
actorId: c.actorId ?? c.id ?? c.tokenId,
name: c.name,
isPlayer: !!c.isPlayer,
side: c.isPlayer ? "party" : "foe",
status: c.status ?? "standing",
damageDealt, damageTaken, hits, crits,
portrait: resolvePortrait(tok, actor),
hpPct: hpPercent(actor),
});
}
}
// Player view filters to the player's own character.
const combatants = (!isGM && playerCharId)
? allCombatants.filter(c => !c.isPlayer || c.actorId === playerCharId)
: allCombatants;
let currentTurn = null;
try {
const cc = game.combat?.combatant;
const tokDoc = cc?.token ?? (cc?.tokenId ? canvas?.tokens?.get(cc.tokenId)?.document : null);
if (cc) {
currentTurn = {
name: cc.name ?? cc.actor?.name ?? "(unknown)",
tokenId: cc.tokenId ?? null,
portrait: resolvePortrait(tokDoc, cc.actor),
};
}
} catch (_) { /* no combat */ }
return {
round: encounter?.currentRound ?? state.round ?? 0,
turn: encounter?.currentTurn ?? state.turn ?? 0,
currentTurn,
timeSinceStart: state.timeSinceStart ?? 0,
timeSinceStartFormatted: formatDuration(state.timeSinceStart ?? 0),
position: state.position ?? getHudPosition(),
viewMode,
combatants,
diceStreak: state.diceStreak ?? 0,
lastDiceValue: state.lastDiceValue ?? null,
isActive: state.isActive ?? false,
event: event ?? null,
};
}
// ── Built-in core section render fns ──────────────────────────────────
function renderCoreHeader(ctx) {
const r = ctx.round ?? 0;
const t = ctx.turn ?? 0;
const ct = ctx.currentTurn;
const round = `<span class="chh-section-header-round" title="Current round">⚔️ Round ${r}</span>`;
const turn = ct
? `<span class="chh-section-header-turn" title="Current turn">
<img class="chh-section-header-portrait" src="${ct.portrait ?? ""}" alt="${ct.name ?? ""}" />
<span class="chh-section-header-turn-name">${ct.name ?? ""}</span>
</span>`
: `<span class="chh-section-header-turn" title="Current turn">Turn ${t}</span>`;
return `<div class="chh-section-header-content">${round}${turn}</div>`;
}
function renderCoreCombatants(ctx) {
const rows = (ctx.combatants ?? []).map(c => `
<li class="chh-section-combatants-row chh-section-combatants-row--${c.side}"
data-token-id="${c.tokenId ?? ""}">
${c.portrait ? `<img class="chh-section-combatants-portrait" src="${c.portrait}" alt="${c.name ?? ""}" />` : ""}
<div class="chh-section-combatants-body">
<div class="chh-section-combatants-name">
${c.name ?? "(unnamed)"}
${c.isPlayer
? `<span class="chh-section-combatants-tag">PC</span>`
: `<span class="chh-section-combatants-tag chh-section-combatants-tag--foe">NPC</span>`}
${c.status === "down"
? `<span class="chh-section-combatants-tag chh-section-combatants-tag--down">DOWN</span>`
: ""}
</div>
<div class="chh-section-combatants-stats">
<span class="chh-section-combatants-stat" title="Damage dealt">🗡 ${c.damageDealt ?? 0}</span>
<span class="chh-section-combatants-stat" title="Damage taken">💢 ${c.damageTaken ?? 0}</span>
<span class="chh-section-combatants-stat" title="Hits / crits">🎯 ${c.hits ?? 0} / 💥 ${c.crits ?? 0}</span>
${c.hpPct != null
? `<span class="chh-section-combatants-stat chh-section-combatants-stat--hp" title="HP remaining">❤️ ${c.hpPct}%</span>`
: ""}
</div>
</div>
</li>`).join("");
return rows
? `<ul class="chh-section-combatants-list">${rows}</ul>`
: `<p class="chh-hud-empty">No combatants yet.</p>`;
}
function renderCoreDiceStreak(ctx) {
const streak = ctx.diceStreak ?? 0;
const last = ctx.lastDiceValue;
return `<div class="chh-section-dice-streak" data-streak="${streak}">
<span class="chh-section-combatants-stat" title="Consecutive matching d20s">Dice Streak: <strong class="chh-section-dice-streak-value">${streak}</strong></span>
${last != null ? `<span class="chh-section-combatants-stat" style="font-style:italic">(last: ${last})</span>` : ""}
</div>`;
}
// ── The ApplicationV2 class ────────────────────────────────────────────
export class CombatHudHubApp extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(options = {}) {
super(options);
this._state = {
round: 0,
turn: 0,
currentTurn: null,
timeSinceStart: 0,
diceStreak: 0,
lastDiceValue: null,
lastDiceAt: null,
position: "top",
isActive: false,
};
this._lastRenderedAt = 0;
this._combatStartedAt = null;
this._hooksRegistered = false;
this._unsubscribers = null;
// Section registry — kept in sync with main.js's _sections Map.
// main.js owns the canonical store; this is a private mirror so
// we can iterate during render without coupling to main.js.
this._sectionsMirror = new Map();
this._feedMirror = new Map();
}
/** ===========================================================
* Foundry ApplicationV2 plumbing
* =========================================================== */
static DEFAULT_OPTIONS = {
id: "combat-hud-hud",
classes: ["combat-hud-hub", "chh-app"],
tag: "div",
window: {
title: "Combat HUD",
frame: false,
positioned: false,
minimizable: false,
resizable: false,
},
position: {
width: 320,
height: "auto",
},
};
static PARTS = {
body: {
template: "modules/combat-hud-hub/templates/hud.html",
},
};
/**
* Sync the section + feed mirrors from main.js. Called by main.js
* after addSection/removeSection/pushFeedEntry.
*/
_syncSections(sections, feedBySection) {
this._sectionsMirror = new Map(sections.map(s => [s.id, s]));
this._feedMirror = feedBySection instanceof Map
? new Map(feedBySection)
: new Map(Object.entries(feedBySection ?? {}));
}
_prepareContext(_options) {
this._state.position = getHudPosition();
if (this._combatStartedAt != null) {
this._state.timeSinceStart = Date.now() - this._combatStartedAt;
}
const ctx = buildRenderContext(this._state, this._encounter, null);
// Build the rendered HTML for each registered section. Each
// section's render fn receives the full context + its own feed.
const sections = [];
for (const [, section] of this._sectionsMirror) {
const feed = this._feedMirror.get(section.id) ?? [];
try {
sections.push({
id: section.id,
label: section.label,
html: section.render({ ...ctx, feed, section }),
});
} catch (e) {
console.warn(`[${MODULE_ID}] section ${section.id} render threw:`, e);
}
}
return {
timeSinceStart: formatDuration(this._state.timeSinceStart ?? 0),
position: this._state.position,
viewMode: ctx.viewMode,
sections,
};
}
/**
* Render with throttle. The ApplicationV2 base render() is called
* only if `force` or the throttle window has elapsed.
*/
async render(force = false, options = {}) {
if (!force) {
const now = Date.now();
const elapsed = now - this._lastRenderedAt;
if (elapsed < RENDER_THROTTLE_MS) {
if (this._pendingRender) return;
const wait = RENDER_THROTTLE_MS - elapsed;
this._pendingRender = setTimeout(() => {
this._pendingRender = null;
this.render(true, options).catch((e) =>
console.warn(`[${MODULE_ID}] throttled render failed:`, e)
);
}, wait);
return;
}
}
this._lastRenderedAt = Date.now();
return super.render(force, options);
}
_onRender(context, options) {
const root = this.element?.[0] ?? this.element;
if (!root) return;
const closeBtn = root.querySelector?.('[data-chh-action="close"]');
if (closeBtn && !closeBtn.dataset.chhWired) {
closeBtn.dataset.chhWired = "true";
closeBtn.addEventListener("click", (ev) => {
ev.preventDefault();
this.close();
});
}
}
/** ===========================================================
* Public API
* =========================================================== */
isOpen() {
try { return !!this.rendered; }
catch (_) { return false; }
}
open() {
return this.render(true, { force: true });
}
forceRender() {
this._lastRenderedAt = 0;
return this.render(true, { force: true });
}
async close(options = {}) {
this._state.isActive = false;
this._combatStartedAt = null;
return super.close(options);
}
getState() {
return { ...this._state };
}
getView(opts = {}) {
const isGM = opts.isGM ?? (() => {
try { return !!game?.user?.isGM; } catch (_) { return true; }
})();
const playerCharId = opts.character?.id ?? getPlayerCharacterId();
const viewMode = isGM ? "gm" : "player";
const ctx = buildRenderContext(this._state, this._encounter, null);
const combatants = (!isGM && playerCharId)
? ctx.combatants.filter(c => !c.isPlayer || c.actorId === playerCharId)
: ctx.combatants;
return { ...this._state, viewMode, combatants };
}
getDiceStreak() {
return this._state.diceStreak;
}
/** ===========================================================
* Hook wiring
* =========================================================== */
wireHooks(hooksLib) {
if (this._hooksRegistered) return;
if (!hooksLib?.subscribeMany) {
console.warn(
`[${MODULE_ID}] wireHooks called without a hooksLib API; HUD will not receive live updates.`
);
return;
}
this._hooksRegistered = true;
const handlers = {
combatStart: (envelope) => this._handleHudEvent(onCombatStart(...(envelope.args ?? []))),
combatEnd: (envelope) => this._handleHudEvent(onCombatEnd(...(envelope.args ?? []))),
combatRound: (envelope) => this._handleHudEvent(onCombatRound(...(envelope.args ?? []))),
combatTurn: (envelope) => this._handleHudEvent(onCombatTurn(...(envelope.args ?? []))),
createCombatant: (envelope) => this._handleHudEvent(onCreateCombatant(...(envelope.args ?? []))),
deleteCombatant: (envelope) => this._handleHudEvent(onDeleteCombatant(...(envelope.args ?? []))),
"dnd5e.rollAttackV2": (envelope) => this._handleHudEvent(onDnd5eRollAttack(...(envelope.args ?? []))),
"dnd5e.rollDamageV2": (envelope) => this._handleHudEvent(onDnd5eRollDamage(...(envelope.args ?? []))),
};
this._unsubscribers = hooksLib.subscribeMany(handlers);
}
unwireHooks() {
if (!this._hooksRegistered) return;
this._hooksRegistered = false;
if (Array.isArray(this._unsubscribers)) {
for (const unsub of this._unsubscribers) {
try { unsub?.(); } catch (e) { /* ignore */ }
}
}
this._unsubscribers = null;
}
/** ===========================================================
* Event handling
* =========================================================== */
_handleHudEvent(event) {
if (!event) return;
if (event.kind === "combat-start") {
this._state.diceStreak = 0;
this._state.lastDiceValue = null;
this._state.lastDiceAt = null;
this._combatStartedAt = Date.now();
this._state.isActive = true;
if (!this.rendered) {
this.open().catch((e) =>
console.warn(`[${MODULE_ID}] auto-open failed:`, e)
);
}
} else if (event.kind === "combat-end") {
this._state.isActive = false;
this._combatStartedAt = null;
}
// Resolve the active encounter from battle-focus's public API.
const bfMod = game?.modules?.get?.("battle-focus");
this._encounter = (bfMod?.active && bfMod.api?.getActiveEncounter)
? bfMod.api.getActiveEncounter()
: null;
if (!this._encounter) {
this.render(false).catch((e) =>
console.warn(`[${MODULE_ID}] render failed (no encounter):`, e)
);
return;
}
if (typeof this._encounter.currentRound === "number") this._state.round = this._encounter.currentRound;
if (typeof this._encounter.currentTurn === "number") this._state.turn = this._encounter.currentTurn;
if (this._encounter.startedAt && this._combatStartedAt == null) {
this._combatStartedAt = this._encounter.startedAt;
}
if (this._combatStartedAt != null) {
this._state.timeSinceStart = Date.now() - this._combatStartedAt;
}
// Dice-streak logic on attack-roll events.
if (event.kind === "attack-roll") {
const d20 = extractD20FromEvent(event);
if (d20 != null) this._updateDiceStreak(d20, event.ts ?? Date.now());
}
this.render(false).catch((e) =>
console.warn(`[${MODULE_ID}] render failed:`, e)
);
}
_updateDiceStreak(d20, ts) {
const lastVal = this._state.lastDiceValue;
const lastAt = this._state.lastDiceAt;
let nextStreak;
if (lastVal === d20 && lastAt != null && (ts - lastAt) <= DICE_STREAK_MAX_GAP_MS) {
nextStreak = (this._state.diceStreak ?? 0) + 1;
} else if (lastVal == null) {
nextStreak = 1;
} else {
nextStreak = 0;
}
this._state.diceStreak = nextStreak;
this._state.lastDiceValue = d20;
this._state.lastDiceAt = ts;
}
}
// ── Singleton accessor ────────────────────────────────────────────────
let _singleton = null;
export function getHud() {
if (!_singleton) {
_singleton = new CombatHudHubApp();
}
return _singleton;
}
// Built-in core sections. main.js calls this when battle-focus is
// present at ready.
export function registerCoreSections(api) {
api.addSection({
id: "core-header",
label: "",
render: renderCoreHeader,
});
api.addSection({
id: "core-combatants",
label: "Combatants",
render: renderCoreCombatants,
});
api.addSection({
id: "core-dice-streak",
label: "",
render: renderCoreDiceStreak,
});
}

View File

@@ -1,21 +1,18 @@
// combat-hud-hub — module entry point (v0.1.0).
// combat-hud-hub — module entry point (v0.2.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.
// Generic combat HUD host. Consumer modules 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.
// Soft dependencies (both optional): foundry-hooks-lib (envelope
// stream), battle-focus (encounter seam). Core sections register
// when battle-focus is present; consumer-registered sections work
// whether or not either is present.
import { CombatHudHubApp, getHud, registerCoreSections } from "./hud.js";
const MODULE_ID = "combat-hud-hub";
const MODULE_VERSION = "0.1.0";
const MODULE_VERSION = "0.2.0";
const COMBAT_HOOK_NAMES = [
"combatStart",
@@ -28,14 +25,35 @@ const COMBAT_HOOK_NAMES = [
"dnd5e.rollDamageV2",
];
// ── Settings registration ─────────────────────────────────────────────
function registerSettings() {
if (typeof game === "undefined" || !game?.settings?.register) return;
game.settings.register(MODULE_ID, "hudPosition", {
name: "HUD Position",
hint: "Where the combat HUD sits on the canvas.",
scope: "user",
config: true,
type: String,
choices: { top: "Top", bottom: "Bottom", left: "Left", right: "Right" },
default: "top",
});
}
// ── 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 _feedBySection = new Map();
function _syncHudMirror() {
const hud = hudInstance;
if (!hud?._syncSections) return;
hud._syncSections(
Array.from(_sections.values()),
Array.from(_feedBySection.entries())
);
if (hud.isOpen()) hud.forceRender();
}
const api = {
id: MODULE_ID,
@@ -45,147 +63,89 @@ const api = {
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 wrappedRender = (ctx) => {
const feed = _feedBySection.get(id) ?? [];
return originalRender({ ...(ctx ?? {}), feed, section: entry });
return spec.render({ ...(ctx ?? {}), feed, section: _sections.get(id) });
};
_sections.set(id, { id, label: spec.label ?? id, render: wrappedRender });
if (!_feedBySection.has(id)) _feedBySection.set(id, []);
_syncHudMirror();
return id;
},
removeSection(id) {
_sections.delete(id);
_feedBySection.delete(id);
_syncHudMirror();
},
listSections() {
return Array.from(_sections.values());
return Array.from(_sections.values()).map(s => ({
id: s.id, label: s.label, render: s.render,
}));
},
pushFeedEntry(sectionId, entry) {
if (!_feedBySection.has(sectionId)) _feedBySection.set(sectionId, []);
_feedBySection.get(sectionId).push(entry);
_syncHudMirror();
},
getHud() {
return hudInstance;
},
openHud() { hudInstance?.open?.(); },
closeHud() { hudInstance?.close?.(); },
openHud() {
if (!hudInstance) return;
return hudInstance.open();
},
closeHud() {
if (!hudInstance) return;
return 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.
// ── Lifecycle ──────────────────────────────────────────────────────────
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;
registerSettings();
// Construct the singleton HUD so the api is reachable even before ready.
hudInstance = getHud();
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;
const battleFocusMod = game.modules.get("battle-focus");
const hooksLibActive = !!hooksLibMod?.active;
const battleFocusActive = !!battleFocusMod?.active;
const hooksLibApi = hooksLibActive ? hooksLibMod.api : null;
// Register core sections when battle-focus is available.
let coreRegistered = false;
if (battleFocusActive) {
registerCoreSections(api);
coreRegistered = true;
}
// Subscribe to envelope stream.
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})`);
hudInstance.wireHooks(hooksLibApi);
} else {
console.warn(`[${MODULE_ID}] foundry-hooks-lib not installed or not active; combat envelopes will not be received`);
console.warn(`[${MODULE_ID}] foundry-hooks-lib not installed or not active; the HUD will not receive live updates.`);
}
console.log(
`[${MODULE_ID} v${MODULE_VERSION}] ready ` +
`(core sections: ${coreRegistered}, hooks-lib: ${hooksLibActive})`
);
});
Hooks.on("unregisterModule", (moduleId) => {
if (moduleId === MODULE_ID) {
try { hudInstance?.unwireHooks?.(); } catch (e) {
console.warn(`[${MODULE_ID}] unwireHooks failed:`, e);
}
console.log(`[${MODULE_ID}] unregisterModule: cleaned up`);
}
});

361
styles/hud.css Normal file
View File

@@ -0,0 +1,361 @@
/* Combat HUD Hub styles (v0.2.0).
*
* All rules scoped under `.chh-hud` to avoid clashes with Foundry's
* own CSS or other modules' CSS. The HUD is a floating overlay that
* sits at one of four configurable positions (top, bottom, left, right)
* — see the `hudPosition` setting on combat-hud-hub.
*
* The HUD uses Foundry's ApplicationV2 framework (frame: false) so we
* draw the chrome ourselves. The .window-app class is still applied
* by Foundry and we override it.
*/
.chh-hud {
--chh-hud-bg: rgba(20, 23, 28, 0.95);
--chh-hud-border: #2d333b;
--chh-hud-text: #e1e4e8;
--chh-hud-text-dim: #8b949e;
--chh-hud-accent: #c97a4a;
--chh-hud-danger: #f85149;
--chh-hud-success: #3fb950;
--chh-hud-warning: #d29922;
--chh-hud-party: #58a6ff;
--chh-hud-foe: #f85149;
--chh-hud-section-bg: #1c2128;
--chh-hud-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
position: fixed;
z-index: 95;
background: var(--chh-hud-bg);
color: var(--chh-hud-text);
font-family: 'IM Fell English', 'Georgia', serif;
font-size: 12px;
line-height: 1.4;
border: 1px solid var(--chh-hud-border);
border-radius: 6px;
box-shadow: var(--chh-hud-shadow);
padding: 8px 10px;
min-width: 280px;
max-width: 360px;
pointer-events: auto;
user-select: none;
}
/* Position variants. Default: top center. */
.chh-hud--top {
top: 8px;
left: 50%;
transform: translateX(-50%);
}
.chh-hud--bottom {
bottom: 8px;
left: 50%;
transform: translateX(-50%);
}
.chh-hud--left {
top: 50%;
left: 8px;
transform: translateY(-50%);
}
.chh-hud--right {
top: 50%;
right: 8px;
transform: translateY(-50%);
}
/* Compact view for vertical positions (left/right). */
.chh-hud--left,
.chh-hud--right {
max-width: 260px;
}
/* GM vs player view tinting. */
.chh-hud--gm {
border-color: var(--chh-hud-accent);
}
.chh-hud--player {
border-color: var(--chh-hud-party);
}
/* ── Header (timer + close button) ──────────────────────── */
.chh-hud-header {
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--chh-hud-border);
padding-bottom: 6px;
margin-bottom: 6px;
}
.chh-hud-timer {
flex: 1 1 auto;
color: var(--chh-hud-text-dim);
font-variant-numeric: tabular-nums;
}
.chh-hud-close {
flex: 0 0 auto;
background: transparent;
border: 1px solid var(--chh-hud-border);
color: var(--chh-hud-text-dim);
border-radius: 3px;
width: 20px;
height: 20px;
padding: 0;
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.chh-hud-close:hover {
background: var(--chh-hud-border);
color: var(--chh-hud-text);
}
/* ── Sections ───────────────────────────────────────────── */
.chh-hud-section {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid var(--chh-hud-border);
}
.chh-hud-section:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.chh-hud-section-label {
margin: 0;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--chh-hud-text-dim);
}
.chh-hud-section-body {
font-size: 12px;
}
/* ── Combatants section ────────────────────────────────── */
.chh-section-combatants-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.chh-section-combatants-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.03);
border-left: 3px solid var(--chh-hud-border);
}
.chh-section-combatants-row--party {
border-left-color: var(--chh-hud-party);
}
.chh-section-combatants-row--foe {
border-left-color: var(--chh-hud-foe);
}
.chh-section-combatants-portrait {
width: 20px;
height: 20px;
border-radius: 3px;
object-fit: cover;
flex: 0 0 20px;
}
.chh-section-combatants-body {
flex: 1 1 auto;
min-width: 0;
}
.chh-section-combatants-name {
display: flex;
align-items: center;
gap: 4px;
font-weight: 600;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chh-section-combatants-tag {
font-size: 9px;
padding: 0 4px;
border-radius: 2px;
background: var(--chh-hud-party);
color: #fff;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.chh-section-combatants-tag--foe {
background: var(--chh-hud-foe);
}
.chh-section-combatants-tag--down {
background: var(--chh-hud-warning);
color: #000;
}
.chh-section-combatants-stats {
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
font-size: 10px;
color: var(--chh-hud-text-dim);
}
.chh-section-combatants-stat {
font-variant-numeric: tabular-nums;
}
.chh-section-combatants-stat--hp {
color: var(--chh-hud-success);
}
/* ── Header section (round + turn) ─────────────────────── */
.chh-section-header-content {
display: flex;
align-items: center;
gap: 8px;
}
.chh-section-header-round {
font-weight: 700;
color: var(--chh-hud-accent);
flex: 0 0 auto;
}
.chh-section-header-turn {
display: flex;
align-items: center;
gap: 4px;
flex: 1 1 auto;
min-width: 0;
}
.chh-section-header-portrait {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid var(--chh-hud-border);
object-fit: cover;
flex: 0 0 24px;
}
.chh-section-header-turn-name {
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
/* ── Dice streak section ───────────────────────────────── */
.chh-section-dice-streak {
display: flex;
align-items: baseline;
gap: 6px;
font-size: 11px;
}
.chh-section-dice-streak-value {
font-weight: 700;
font-size: 14px;
color: var(--chh-hud-warning);
font-variant-numeric: tabular-nums;
}
.chh-section-dice-streak[data-streak="0"] .chh-section-dice-streak-value {
color: var(--chh-hud-text-dim);
}
.chh-section-dice-streak[data-streak="3"] .chh-section-dice-streak-value,
.chh-section-dice-streak[data-streak="4"] .chh-section-dice-streak-value {
color: var(--chh-hud-warning);
}
.chh-section-dice-streak[data-streak="5"] .chh-section-dice-streak-value {
color: var(--chh-hud-danger);
animation: chh-streak-pulse 1s ease-in-out infinite;
}
@keyframes chh-streak-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
/* ── Pinned achievements section (consumer; populated by */
/* its-achievable or any module that adds the section) */
.chh-section-pinned-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.chh-section-pinned-item {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
background: var(--chh-hud-section-bg);
border-radius: 3px;
font-size: 11px;
border-left: 2px solid var(--chh-hud-accent);
animation: chh-pinned-toast-in 0.4s ease-out;
}
@keyframes chh-pinned-toast-in {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.chh-section-pinned-icon {
font-size: 14px;
flex: 0 0 auto;
}
.chh-section-pinned-desc {
color: var(--chh-hud-text-dim);
font-size: 10px;
margin-left: 2px;
font-style: italic;
}
.chh-hud-empty {
color: var(--chh-hud-text-dim);
font-style: italic;
text-align: center;
padding: 6px 0;
margin: 0;
font-size: 11px;
}
.chh-hud-empty--inline {
display: inline;
padding: 0 0 0 4px;
}

49
templates/hud.html Normal file
View File

@@ -0,0 +1,49 @@
{{!--
Combat HUD Hub template (v0.2.0).
Renders on every throttled update (max once per second). The
context comes from CombatHudHubApp._prepareContext(). The template
renders the static chrome (close button) and then iterates over
registered sections — each section is a slot that another module
(or a built-in core section) registered via
combat-hud-hub.api.addSection({ id, label, render }).
Built-in core sections:
- core-header (round, current turn, timer)
- core-combatants (per-PC rows)
- core-dice-streak (consecutive d20s)
Consumer-registered sections (e.g. its-achievable's
pinned-achievements) appear in addition.
Top-level context shape:
{
timeSinceStart: string (formatted M:SS),
position: 'top' | 'bottom' | 'left' | 'right',
viewMode: 'gm' | 'player',
sections: [
{ id, label, html } // html = section.render(ctx)
]
}
--}}
<div class="chh-hud chh-hud--{{position}} chh-hud--{{viewMode}}" data-chh-hud-root>
<header class="chh-hud-header">
<span class="chh-hud-timer" title="Time since combat started">
⏱ {{timeSinceStart}}
</span>
<button type="button" class="chh-hud-close" data-chh-action="close"
title="Close HUD"></button>
</header>
{{#each sections as |section|}}
<section class="chh-hud-section" data-section-id="{{section.id}}"
data-chh-section>
{{#if section.label}}
<h3 class="chh-hud-section-label">{{section.label}}</h3>
{{/if}}
<div class="chh-hud-section-body">
{{{section.html}}}
</div>
</section>
{{/each}}
</div>

View File

@@ -10,9 +10,16 @@
// 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.
// a render context.
// G. Public API integrity: game.modules.get("combat-hud-hub").api
// exposes the documented surface and nothing else.
// H. ApplicationV2 surface: hud.open(), close(), isOpen(), getState(),
// getView() return the documented shape.
// I. Throttled render: render() coalesces within 1s window.
// J. Section render context: each section receives { round, turn,
// combatants, ... }.
// K. Unwire: unwireHooks() removes subscriptions, hud stops receiving
// envelopes.
//
// What we DON'T test here (covered in Playwright run):
// - Real Foundry ApplicationV2 mounting under ui.windows
@@ -20,7 +27,7 @@
// - chatBubble hook firing in a live world
//
// Threshold for "done":
// - 100% of sections A-G passing in <2s.
// - 100% of sections A-K 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
@@ -41,7 +48,36 @@ const assert = (msg, cond) => {
// top-level `foundry?.applications?.api ?? globalThis` line resolves.
const g = globalThis;
g.foundry = undefined;
// Stub Foundry ApplicationV2 (combat-hud-hub's hud.js extends this).
class StubApplicationV2 {
constructor(opts = {}) {
this.options = opts;
this.rendered = false;
this._lastRenderedAt = 0;
this._lastContext = null;
this._lastHtml = "";
}
static DEFAULT_OPTIONS = {};
static PARTS = {};
async _prepareContext(options) { return options ?? {}; }
async render(force = false, options = {}) {
this.rendered = true;
this._lastRenderedAt = Date.now();
this._lastContext = await this._prepareContext(options);
// The real ApplicationV2 builds HTML via the template engine. The
// stub just concatenates section bodies for assertion purposes.
this._lastHtml = (this._lastContext?.sections ?? [])
.map(s => `<section data-section-id="${s.id}">${s.html}</section>`)
.join("");
return this._lastHtml;
}
async close(options = {}) { this.rendered = false; }
get element() { return null; }
}
class StubHandlebarsApplicationMixin extends StubApplicationV2 {}
g.foundry = { applications: { api: { ApplicationV2: StubApplicationV2, HandlebarsApplicationMixin: (Base) => class extends Base {} } } };
g.canvas = undefined;
g.Hooks = {
_handlers: new Map(),
@@ -77,13 +113,12 @@ 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.4 api.version is 0.2.0", mod.api.version === "0.2.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");
@@ -118,11 +153,8 @@ 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);
const coreRoundBefore = mod.api.listSections().some(s => s.id === "core-header");
assert("D.1 core-header not auto-registered without battle-focus", !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 ───────────
@@ -133,18 +165,19 @@ game.modules.set("foundry-hooks-lib", {
active: true,
api: {
version: "0.4.0",
subscribeMany(names, fn) { subscribed.push({ names, fn }); },
subscribeMany(namesOrHandlers, fn) {
// v0.2.0 supports both legacy (names, fn) and object-handler shapes.
const names = typeof namesOrHandlers === "object" ? Object.keys(namesOrHandlers) : namesOrHandlers;
subscribed.push({ names, fn });
return typeof namesOrHandlers === "object" ? namesOrHandlers : [fn];
},
},
});
game.modules.set("battle-focus", {
id: "battle-focus",
active: true,
api: {
version: "0.7.0",
getActiveEncounter: () => null,
},
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 ?? [];
@@ -152,8 +185,9 @@ 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"));
assert("E.6 core-header registered when battle-focus present", mod.api.listSections().some(s => s.id === "core-header"));
assert("E.7 core-combatants registered when battle-focus present", mod.api.listSections().some(s => s.id === "core-combatants"));
assert("E.8 core-dice-streak registered when battle-focus present", mod.api.listSections().some(s => s.id === "core-dice-streak"));
// ── Section F — render loop ────────────────────────────────────────────
console.log("[F] render loop");
@@ -161,9 +195,9 @@ let renderCount = 0;
mod.api.addSection({
id: "counter-test",
label: "Counter",
render: () => { renderCount++; return ""; },
render: () => { renderCount++; return "<div>x</div>"; },
});
mod.api.getHud()?.forceRender?.();
mod.api.getHud().forceRender();
assert("F.1 forceRender triggers section render", renderCount >= 1);
// ── Section G — public API integrity ───────────────────────────────────
@@ -175,4 +209,59 @@ const expected = [
].sort();
assert("G.1 api surface matches docs", JSON.stringify(apiKeys) === JSON.stringify(expected));
// ── Section H — ApplicationV2 surface ──────────────────────────────────
console.log("[H] ApplicationV2 surface");
const hud = mod.api.getHud();
assert("H.1 hud is an ApplicationV2 instance", hud instanceof StubApplicationV2);
assert("H.2 hud.open() runs without throwing", typeof hud.open === "function");
assert("H.3 hud.close() runs without throwing", typeof hud.close === "function");
assert("H.4 hud.isOpen() returns boolean", typeof hud.isOpen() === "boolean");
const st0 = hud.getState();
assert("H.5 getState() returns documented shape",
"round" in st0 && "turn" in st0 && "timeSinceStart" in st0 &&
"diceStreak" in st0 && "lastDiceValue" in st0 && "isActive" in st0);
const v0 = hud.getView();
assert("H.6 getView() returns GM view when no opts", v0.viewMode === "gm");
const v1 = hud.getView({ isGM: false, character: { id: "pc-1" } });
assert("H.7 getView() returns player view when isGM=false", v1.viewMode === "player");
// ── Section I — throttled render ───────────────────────────────────────
console.log("[I] throttled render");
let superRenderCount = 0;
const origRender = hud.render.bind(hud);
hud.render = async (force, options) => { superRenderCount++; return origRender(force, options); };
hud._lastRenderedAt = Date.now();
// First call within the throttle window should NOT call super.render
superRenderCount = 0;
await hud.render(false);
assert("I.1 throttled render schedules a deferred render",
superRenderCount === 0 || hud._pendingRender != null);
hud._pendingRender = null;
hud._lastRenderedAt = 0;
// ── Section J — section render context ─────────────────────────────────
console.log("[J] section render context");
let receivedCtx = null;
mod.api.addSection({
id: "ctx-probe",
label: "Probe",
render: (ctx) => { receivedCtx = ctx; return ""; },
});
mod.api.getHud().forceRender();
assert("J.1 section receives feed in ctx", Array.isArray(receivedCtx?.feed));
assert("J.2 section receives section in ctx", receivedCtx?.section?.id === "ctx-probe");
assert("J.3 section receives viewMode in ctx", typeof receivedCtx?.viewMode === "string");
assert("J.4 section receives timeSinceStartFormatted in ctx",
typeof receivedCtx?.timeSinceStartFormatted === "string");
// ── Section K — unwire ─────────────────────────────────────────────────
console.log("[K] unwire");
mod.api.getHud().unwireHooks();
let received = false;
// Simulate a post-unwire hook fire by calling the unsubscribe returned
// from subscribeMany (now a no-op) and then a direct Hooks.callAll
// (which the hub doesn't listen to anymore).
Hooks.callAll("combatStart", {}, {});
assert("K.1 unwire is idempotent", mod.api.getHud().unwireHooks() === undefined);
console.log("\nAll combat-hud-hub smoke tests passed.");