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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/** ===========================================================
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user