Files
combat-hud-hub/scripts/main.js
Kaysser Taylor b1121651dc v0.2.5: 1Hz tick keeps timer counting when no envelope event fires
Bug: timeSinceStart only updated when an envelope event fired
(combatStart, attack-roll, etc.). If the combat was idle — a
player thinking about their turn, between turns — the timer froze.

Fix: 1Hz self-rescheduling setTimeout in the constructor that
bumps timeSinceStart and triggers a throttled render while
_state.isActive is true. Stopped on unwireHooks (combat end).

TDD: Section O (5 assertions) added BEFORE the fix.
- O.1 initial timeSinceStart reflects _combatStartedAt
- O.2 advances without an envelope event
- O.3 tick interval is ~1s
- O.4 timer does not tick when combat is inactive
- O.5 timer resumes when combat becomes active again

Tests: 65/65 passing in ~2s. Playwright 31/31.
2026-06-22 19:21:30 -04:00

192 lines
6.3 KiB
JavaScript

// combat-hud-hub — module entry point (v0.2.5).
//
// Generic combat HUD host. Consumer modules register sections via
// the public API; the hub aggregates built-in core sections +
// consumer-registered feeds.
//
// 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.2.5";
const COMBAT_HOOK_NAMES = [
"combatStart",
"combatEnd",
"combatRound",
"combatTurn",
"createCombatant",
"deleteCombatant",
"dnd5e.rollAttackV2",
"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 ───────────────────────────────────────────────────
const _sections = new Map();
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,
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;
const wrappedRender = (ctx) => {
const feed = _feedBySection.get(id) ?? [];
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()).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();
},
getFeed(sectionId) {
return [...(_feedBySection.get(sectionId) ?? [])];
},
clearFeed(sectionId) {
if (_feedBySection.has(sectionId)) _feedBySection.set(sectionId, []);
_syncHudMirror();
},
isReady() {
return _ready;
},
getHud() {
return hudInstance;
},
openHud() {
if (!hudInstance) return;
return hudInstance.open();
},
closeHud() {
if (!hudInstance) return;
return hudInstance.close();
},
};
// ── Lifecycle ──────────────────────────────────────────────────────────
let hudInstance = null;
let _ready = false;
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", () => {
const hooksLibMod = game.modules.get("foundry-hooks-lib");
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) {
hudInstance.wireHooks(hooksLibApi);
} else {
console.warn(`[${MODULE_ID}] foundry-hooks-lib not installed or not active; the HUD will not receive live updates.`);
}
// ── Session-open: if battle-focus has an active encounter
// resumed from a prior session (or a combat tracker is sitting
// in "pending" / mid-combat), open the HUD so the user sees the
// same state they'd see if the combatStart envelope had just
// fired. Without this, refreshing the browser mid-combat leaves
// the HUD invisible until something else fires an envelope.
let sessionResumed = false;
if (battleFocusActive && battleFocusMod.api?.getActiveEncounter) {
const enc = battleFocusMod.api.getActiveEncounter();
// The encounter is "live" if it exists and hasn't ended.
if (enc && !enc.endedAt) {
// Populate the HUD's encounter + state from the live encounter
// so the first render has the right data.
hudInstance._encounter = enc;
hudInstance._state.isActive = true;
if (enc.startedAt && hudInstance._combatStartedAt == null) {
hudInstance._combatStartedAt = enc.startedAt;
}
// Open the HUD. Use .open() directly (not api.openHud which
// has the same effect but with one extra indirection).
hudInstance.open().catch((e) =>
console.warn(`[${MODULE_ID}] session-open auto-open failed:`, e)
);
sessionResumed = true;
}
}
console.log(
`[${MODULE_ID} v${MODULE_VERSION}] ready ` +
`(core sections: ${coreRegistered}, hooks-lib: ${hooksLibActive}, ` +
`session-resumed: ${sessionResumed})`,
);
_ready = true;
});
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`);
}
});
export { api };