Files
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

689 lines
25 KiB
JavaScript

// 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;
// Current turn: prefer the encounter's current combatant (if it has
// one), fall back to game.combat for legacy callers. The encounter's
// authoritative source — battle-focus owns the encounter singleton.
// The HUD also tracks _latestTurn (set on combatTurn events) as a
// tiebreaker when the encounter's currentTurn is a stale number.
// If we have a _latestTurn that doesn't match the encounter's
// current combatant, the encounter is stale — use _latestTurn.
let currentTurn = null;
const _lt = state?._latestTurn;
const _encStale = _lt?.combatantId
&& encounter?.combatantId
&& _lt.combatantId !== encounter.combatantId;
if (encounter?.currentTurn && typeof encounter.currentTurn === "object" && !_encStale) {
const ec = encounter.currentTurn;
currentTurn = { name: ec.name, tokenId: ec.tokenId ?? null, portrait: ec.portrait ?? null };
} else if (encounter?.combatantId && encounter.combatants?.get && !_encStale) {
// Some encounter shapes store the current combatant id and a
// combatants map. The map may be keyed by tokenId, actorId,
// or combatantId — try each in turn.
const cid = encounter.combatantId;
let cc = encounter.combatants.get(cid);
if (!cc) {
for (const c of encounter.combatants.values()) {
if (c.combatantId === cid || c.tokenId === cid || c.actorId === cid) {
cc = c; break;
}
}
}
if (cc) {
const tok = (() => {
try { return canvas?.tokens?.get(cc.tokenId)?.document ?? null; }
catch (_) { return null; }
})();
const actor = cc.actorId ? game.actors?.get(cc.actorId) : null;
currentTurn = {
name: cc.name ?? cc.actor?.name ?? "(unknown)",
tokenId: cc.tokenId ?? null,
portrait: resolvePortrait(tok, actor),
};
}
} else if (_lt?.combatantId) {
// HUD's cached latest turn (from the most recent combatTurn
// event). Use it to resolve the new combatant via the turn
// order or the encounter's combatants map.
const cached = state._latestTurn;
let cc = encounter?.combatants?.get?.(cached.combatantTokenId ?? cached.combatantId);
if (!cc && encounter?.combatants) {
// Look up by combatantId (combatant document id).
for (const c of encounter.combatants.values()) {
if (c.combatantId === cached.combatantId) { cc = c; break; }
}
}
if (cc) {
const tok = (() => {
try { return canvas?.tokens?.get(cc.tokenId)?.document ?? null; }
catch (_) { return null; }
})();
const actor = cc.actorId ? game.actors?.get(cc.actorId) : null;
currentTurn = {
name: cc.name ?? cc.actor?.name ?? cached.combatantName ?? "(unknown)",
tokenId: cc.tokenId ?? null,
portrait: resolvePortrait(tok, actor),
};
} else {
// No combatant resolution — emit a minimal stub from the
// cached fields so the header at least shows the right name.
currentTurn = {
name: cached.combatantName ?? "(unknown)",
tokenId: cached.combatantTokenId ?? null,
portrait: null,
};
}
} else {
// Legacy fallback: game.combat global.
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 currentTokenId = ctx.currentTurn?.tokenId ?? null;
const rows = (ctx.combatants ?? []).map(c => {
const isCurrent = currentTokenId != null && c.tokenId === currentTokenId;
return `
<li class="chh-section-combatants-row chh-section-combatants-row--${c.side}${isCurrent ? " chh-section-combatants-row--current" : ""}"
data-token-id="${c.tokenId ?? ""}"
${isCurrent ? 'data-chh-current-turn="true"' : ""}>
${c.portrait ? `<img class="chh-section-combatants-portrait${isCurrent ? " chh-section-combatants-portrait--current" : ""}" src="${c.portrait}" alt="${c.name ?? ""}" />` : ""}
<div class="chh-section-combatants-body">
<div class="chh-section-combatants-name">
${isCurrent ? '<span class="chh-section-combatants-current-marker" title="Current turn">▶</span>' : ""}
${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();
// 1Hz tick that bumps _state.timeSinceStart and re-renders the
// HUD while combat is active. Without this, the timer freezes
// when no envelope event fires (e.g., a player is just thinking
// about their turn). The interval is owned by the singleton and
// set up at construction; it does work only when
// _state.isActive is true. The interval is NOT a Foundry Hook
// and does not depend on a Foundry lifecycle event.
this._tickHandle = null;
this._startTick();
}
_startTick() {
if (this._tickHandle != null) return;
// Use a self-rescheduling setTimeout rather than setInterval so
// we can stop it on close and restart on open without leaking.
const TICK_MS = 1000;
const tick = () => {
if (this._state.isActive && this._combatStartedAt != null) {
this._state.timeSinceStart = Date.now() - this._combatStartedAt;
// Re-render to update the displayed timer. Throttled render
// will coalesce if multiple ticks fire in quick succession
// (they won't, but the safety is there).
this.render(false).catch((e) =>
console.warn(`[${MODULE_ID}] tick render failed:`, e)
);
}
this._tickHandle = setTimeout(tick, TICK_MS);
};
this._tickHandle = setTimeout(tick, TICK_MS);
}
_stopTick() {
if (this._tickHandle != null) {
clearTimeout(this._tickHandle);
this._tickHandle = null;
}
}
/** ===========================================================
* 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;
// Stop the 1Hz tick — combat is over, no need to keep counting.
this._stopTick();
}
/** ===========================================================
* 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;
} else if (event.kind === "turn") {
// Foundry's combatTurn hook fires BEFORE the new state is
// committed, so game.combat.combatant is still the OLD
// combatant at this point. The event-translation layer
// resolves the new combatant from combat.turns[newTurn] and
// passes it through. Stash the latest turn info on the state
// so buildRenderContext can use it as a tiebreaker when the
// encounter's currentTurn is a stale number.
if (typeof event.turn === "number") this._state.turn = event.turn;
if (typeof event.round === "number") this._state.round = event.round;
this._state._latestTurn = {
combatantId: event.combatantId ?? null,
combatantName: event.combatantName ?? null,
combatantTokenId: event.combatantTokenId ?? null,
turn: event.turn ?? null,
round: event.round ?? 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" && event?.kind !== "turn") {
this._state.round = this._encounter.currentRound;
}
if (typeof this._encounter.currentTurn === "number" && event?.kind !== "turn") {
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,
});
}