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.
This commit is contained in:
2026-06-22 19:21:30 -04:00
parent 86687e4b7a
commit b1121651dc
7 changed files with 153 additions and 7 deletions

View File

@@ -2,6 +2,11 @@
All notable changes to Combat HUD Hub are documented here.
## [0.2.5] — 2026-06-22 (timer auto-tick)
- **1Hz tick keeps the timer counting.** The HUD's `timeSinceStart` field 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. Added a 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).
- Tests: 65/65 passing in ~2s. Section O covers initial value, no-event advance, tick interval, idle pause, and resume.
## [0.2.4] — 2026-06-22 (current-turn updates on combatTurn + session-open auto-open)
- **Current-turn updates instantly on combatant switch.** The event-translation layer now resolves the new combatant from `combat.turns[newTurn]` (because Foundry fires `combatTurn` BEFORE the new state is committed, so `game.combat.combatant` is still the OLD combatant at that moment). The HUD stashes the new turn info on `_state._latestTurn` and uses it as a tiebreaker when the encounter's `combatantId` is stale.

View File

@@ -40,6 +40,7 @@ hub.pushFeedEntry("pinned-achievements", { name: "Critical Hit!", icon: "🎯" }
## Status
- **v0.2.5** — 1Hz tick keeps the timer counting when no envelope event fires. 65/65 smoke tests.
- **v0.2.4** — current-turn updates on combatTurn + session-open auto-open. 60/60 smoke tests.
- **v0.2.3** — added `isReady()` and corrected 3-place version drift. 50/50 smoke tests.
- **v0.2.2** — added `getFeed`/`clearFeed` read accessors. 50/50 smoke 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.2.4",
"version": "0.2.5",
"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.2.4.zip",
"download": "https://git.homelab.local/kaykayyali/combat-hud-hub/raw/branch/main/combat-hud-hub-0.2.5.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.2.4",
"version": "0.2.5",
"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

@@ -343,6 +343,42 @@ export class CombatHudHubApp extends HandlebarsApplicationMixin(ApplicationV2) {
// 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;
}
}
/** ===========================================================
@@ -529,6 +565,8 @@ export class CombatHudHubApp extends HandlebarsApplicationMixin(ApplicationV2) {
}
}
this._unsubscribers = null;
// Stop the 1Hz tick — combat is over, no need to keep counting.
this._stopTick();
}
/** ===========================================================

View File

@@ -1,4 +1,4 @@
// combat-hud-hub — module entry point (v0.2.4).
// 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 +
@@ -12,7 +12,7 @@
import { CombatHudHubApp, getHud, registerCoreSections } from "./hud.js";
const MODULE_ID = "combat-hud-hub";
const MODULE_VERSION = "0.2.4";
const MODULE_VERSION = "0.2.5";
const COMBAT_HOOK_NAMES = [
"combatStart",

View File

@@ -122,7 +122,7 @@ 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.2.4", mod.api.version === "0.2.4",
assert("A.4 api.version is 0.2.5", mod.api.version === "0.2.5",
`got=${mod.api.version}`);
assert("A.5 addSection exported", typeof mod.api.addSection === "function");
assert("A.6 removeSection exported", typeof mod.api.removeSection === "function");
@@ -354,7 +354,11 @@ superRenderCount = 0;
await hud.render(false);
assert("I.1 throttled render schedules a deferred render",
superRenderCount === 0 || hud._pendingRender != null);
hud._pendingRender = null;
// Clear the pending render AND its setTimeout (id stored in _pendingRender).
if (hud._pendingRender) {
clearTimeout(hud._pendingRender);
hud._pendingRender = null;
}
hud._lastRenderedAt = 0;
// ── Section J — section render context ─────────────────────────────────
@@ -382,6 +386,104 @@ let received = false;
Hooks.callAll("combatStart", {}, {});
assert("K.1 unwire is idempotent", mod.api.getHud().unwireHooks() === undefined);
// ── Section O — timer auto-tick (v0.2.5) ────────────────────────────────
// Bug: the HUD's time-since-combat-start only updates when an event
// fires (combatStart, attack-roll, etc.). If the combat is idle, the
// timer doesn't tick. The fix: schedule a 1Hz interval that bumps
// timeSinceStart and re-renders the HUD while the combat is active.
console.log("[O] timer auto-tick every second");
game.modules.set("battle-focus", {
id: "battle-focus",
active: true,
api: {
version: "0.7.0",
getActiveEncounter: () => ({
id: "enc-o",
currentRound: 1,
currentTurn: 0,
combatantId: "c-bard",
startedAt: Date.now() - 5_000, // 5s ago
endedAt: null,
combatants: new Map([
["t-bard", { tokenId: "t-bard", combatantId: "c-bard", actorId: "a-bard", name: "Bard", isPlayer: true }],
]),
statsByRound: new Map(),
}),
},
});
const hudO = mod.api.getHud();
hudO._encounter = game.modules.get("battle-focus").api.getActiveEncounter();
hudO._state.isActive = true;
// Section K (run before us) called unwireHooks which stopped the
// 1Hz tick. Restart it for the timer tests.
hudO._startTick();
// Set _combatStartedAt to 5s ago so the timer starts at ~5s.
hudO._combatStartedAt = Date.now() - 5_000;
await hudO.forceRender();
// O.1 — initial timeSinceStart is ~5000ms (5s ago).
assert("O.1 initial timeSinceStart reflects _combatStartedAt",
hudO._state.timeSinceStart >= 4_500 && hudO._state.timeSinceStart <= 6_000,
`timeSinceStart=${hudO._state.timeSinceStart}`);
// O.2 — after ~1.5s with no event fired, timeSinceStart must
// have advanced. (This is the bug: it currently does NOT advance
// unless an event triggers _handleHudEvent.)
// Clean any pending throttled render from prior sections.
const hudO2 = hudO;
if (hudO2._pendingRender) {
clearTimeout(hudO2._pendingRender);
hudO2._pendingRender = null;
}
hudO2._lastRenderedAt = Date.now();
// Reset baseline. forceRender triggers _prepareContext which sets
// timeSinceStart to Date.now() - _combatStartedAt. Capture that.
await hudO2.forceRender();
const ts1 = hudO2._state.timeSinceStart;
const wallStart = Date.now();
await new Promise(r => setTimeout(r, 1500));
const ts2 = hudO2._state.timeSinceStart;
const wallElapsed = Date.now() - wallStart;
assert("O.2 timeSinceStart advances without an envelope event",
ts2 > ts1 + 500,
`ts1=${ts1} ts2=${ts2} delta=${ts2 - ts1}ms wall=${wallElapsed}ms`);
// O.3 — the tick interval must be ~1s. Measure by observing the
// _state.timeSinceStart over a 2.5s window. The tick runs at 1Hz,
// so timeSinceStart should grow by ~2500ms (within ±300ms tolerance
// of wall clock). We can't reset timeSinceStart directly without
// racing the tick, so we use a fresh _combatStartedAt and read
// after the wall-clock window.
hudO._combatStartedAt = Date.now();
// Drain any in-flight render first so the next read is clean.
await hudO.forceRender();
const tickStart = Date.now();
const tsStart = hudO._state.timeSinceStart;
await new Promise(r => setTimeout(r, 2500));
const tsEnd = hudO._state.timeSinceStart;
const wallDelta = Date.now() - tickStart;
const tickDelta = tsEnd - tsStart;
assert("O.3 tick interval is ~1s (delta matches wall clock)",
Math.abs(tickDelta - wallDelta) < 500,
`tickDelta=${tickDelta}ms wallDelta=${wallDelta}ms`);
// O.4 — when state.isActive is false, the tick stops (no
// re-render, no time advance). Saves CPU when there's no combat.
hudO._state.isActive = false;
hudO._combatStartedAt = null;
hudO._state.timeSinceStart = 0;
const tsBeforeIdle = hudO._state.timeSinceStart;
await new Promise(r => setTimeout(r, 1200));
assert("O.4 timer does not tick when combat is inactive",
hudO._state.timeSinceStart === tsBeforeIdle,
`before=${tsBeforeIdle} after=${hudO._state.timeSinceStart}`);
// O.5 — re-activating resumes ticking.
hudO._state.isActive = true;
hudO._combatStartedAt = Date.now() - 2_000;
const tsBeforeResume = hudO._state.timeSinceStart;
await new Promise(r => setTimeout(r, 1500));
assert("O.5 timer resumes when combat becomes active again",
hudO._state.timeSinceStart > tsBeforeResume + 500,
`before=${tsBeforeResume} after=${hudO._state.timeSinceStart}`);
// Stop the tick so the test process can exit cleanly.
hudO._stopTick();
// ── Section N — session-open auto-opens HUD (v0.2.4) ──────────────────
// When the user reloads the browser mid-combat, the combatStart
// envelope has already fired (in the previous session) and won't