v0.2.1: current-turn indicator + encounter-as-source-of-truth

- Combatants row whose tokenId matches encounter's current combatant
  gets data-chh-current-turn=true, an accent-color border + tint, an
  outlined portrait, and a '▶' marker before the name.
- Header section prepends '▶' before the turn name.
- buildRenderContext now resolves currentTurn from the encounter
  singleton (encounter.currentTurn / encounter.combatantId) instead
  of reading game.combat directly. battle-focus owns the encounter.
- Tests: 46/46 in <1s, new section L covers the indicator.
This commit is contained in:
2026-06-22 16:05:14 -04:00
parent 04008acc66
commit f304db49cc
6 changed files with 120 additions and 14 deletions

View File

@@ -2,6 +2,12 @@
All notable changes to Combat HUD Hub are documented here.
## [0.2.1] — 2026-06-22 (current-turn indicator)
- **Current-turn indicator.** The combatants row whose `tokenId` matches the encounter's current combatant gets `data-chh-current-turn="true"`, a `.chh-section-combatants-row--current` class, an accent-color left border + subtle background tint, an outlined portrait, and a "▶" marker before the name. The header section also prepends "▶" before the turn name.
- **Encounter-as-source-of-truth for currentTurn.** `buildRenderContext` now resolves the current turn from the encounter singleton (`encounter.currentTurn` or `encounter.combatantId → encounter.combatants.get(...)`) instead of reading `game.combat` directly. battle-focus owns the encounter; the hub reads it.
- Tests: 46/46 passing in <1s covering sections A-L.
## [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.

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.0",
"version": "0.2.1",
"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.0.zip",
"download": "https://git.homelab.local/kaykayyali/combat-hud-hub/raw/branch/main/combat-hud-hub-0.2.1.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.0",
"version": "0.2.1",
"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

@@ -154,18 +154,43 @@ function buildRenderContext(state, encounter, event) {
? 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.
let currentTurn = null;
try {
const cc = game.combat?.combatant;
const tokDoc = cc?.token ?? (cc?.tokenId ? canvas?.tokens?.get(cc.tokenId)?.document : null);
if (encounter?.currentTurn && typeof encounter.currentTurn === "object") {
const ec = encounter.currentTurn;
currentTurn = { name: ec.name, tokenId: ec.tokenId ?? null, portrait: ec.portrait ?? null };
} else if (encounter?.combatantId && encounter.combatants?.get) {
// Some encounter shapes store the current combatant id and a
// combatants map; resolve through the map.
const cc = encounter.combatants.get(encounter.combatantId);
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(tokDoc, cc.actor),
portrait: resolvePortrait(tok, actor),
};
}
} catch (_) { /* no combat */ }
} 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,
@@ -193,19 +218,24 @@ function renderCoreHeader(ctx) {
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 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 ?? ""}" />` : ""}
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>`
@@ -223,7 +253,8 @@ function renderCoreCombatants(ctx) {
: ""}
</div>
</div>
</li>`).join("");
</li>`;
}).join("");
return rows
? `<ul class="chh-section-combatants-list">${rows}</ul>`
: `<p class="chh-hud-empty">No combatants yet.</p>`;

View File

@@ -172,6 +172,34 @@
border-left-color: var(--chh-hud-foe);
}
/* Current-turn indicator (v0.2.1). The row whose data-token-id
* matches ctx.currentTurn.tokenId gets an accent-color border +
* subtle tint + outlined portrait + ▶ marker. The intent is "this
* is whose turn it is, at a glance" without redesigning the row.
*/
.chh-section-combatants-row--current {
border-left-color: var(--chh-hud-accent);
background: rgba(201, 122, 74, 0.08);
box-shadow: inset 0 0 0 1px rgba(201, 122, 74, 0.25);
}
.chh-section-combatants-row--current.chh-section-combatants-row--party,
.chh-section-combatants-row--current.chh-section-combatants-row--foe {
/* Override the party/foe border for the current row. */
border-left-color: var(--chh-hud-accent);
}
.chh-section-combatants-portrait--current {
outline: 2px solid var(--chh-hud-accent);
outline-offset: -1px;
}
.chh-section-combatants-current-marker {
color: var(--chh-hud-accent);
font-weight: 700;
margin-right: 2px;
}
.chh-section-combatants-portrait {
width: 20px;
height: 20px;

View File

@@ -200,6 +200,47 @@ mod.api.addSection({
mod.api.getHud().forceRender();
assert("F.1 forceRender triggers section render", renderCount >= 1);
// ── Section L — current-turn indicator (v0.2.1) ───────────────────────
console.log("[L] current-turn indicator");
// Install battle-focus with an encounter that has a current combatant
// tokenId, then verify the combatants row renders
// data-chh-current-turn="true" on the matching row.
game.modules.set("battle-focus", {
id: "battle-focus",
active: true,
api: {
version: "0.7.0",
getActiveEncounter: () => ({
id: "enc1",
currentRound: 1,
currentTurn: 0,
combatantId: "t-bard", // <-- current combatant key for buildRenderContext fallback
startedAt: Date.now() - 10000,
combatants: new Map([
["t-bard", { tokenId: "t-bard", actorId: "a-bard", name: "Bard", isPlayer: true, status: "active" }],
["t-goblin", { tokenId: "t-goblin", actorId: "a-goblin", name: "Goblin", isPlayer: false, status: "active" }],
]),
statsByRound: new Map(),
}),
},
});
// Force the encounter into the HUD's render ctx.
const hud2 = mod.api.getHud();
hud2._encounter = game.modules.get("battle-focus").api.getActiveEncounter();
await hud2.forceRender();
// Inspect the last render HTML (the stub stores it).
const html = hud2._lastHtml ?? "";
const currentRow = html.match(/<li[^>]*data-chh-current-turn="true"[^>]*>([\s\S]*?)<\/li>/);
assert("L.1 combatants row has data-chh-current-turn=\"true\"", !!currentRow);
assert("L.2 current-turn row references the current combatant tokenId",
currentRow && currentRow[0].includes('data-token-id="t-bard"'));
// Verify the header section also shows the "▶" indicator before the turn name.
const headerSection = html.match(/<section[^>]*data-section-id="core-header"[^>]*>([\s\S]*?)<\/section>/);
assert("L.3 header section rendered", !!headerSection);
assert("L.4 header section has ▶ indicator before turn name",
headerSection && /▶\s*[^<]*<\/span>/.test(headerSection[1] ?? ""));
// ── Section G — public API integrity ───────────────────────────────────
console.log("[G] public API integrity");
const apiKeys = Object.keys(mod.api).sort();