Files
Its-Achievable/scripts/hud.js
Kaysser Kayyali f2ef1ef4f3 v0.1.0 — initial extraction from battle-focus v0.5.0-alpha.12
Stage 2 of the Hax's Tools split. its-achievable ships as a
standalone module that subscribes to hax-hooks-lib's envelope
stream and provides achievements + custom rules + rewards +
achievement wall + combat HUD.

## What's new

scripts/ — moved from battle-focus/scripts/, MODULE_ID retagged
battle-focus → its-achievable:
- achievement-rules.js (323 lines) — rule engine: OPERATORS,
  TRIGGER_TYPES, evaluateCondition(s), testRule, evaluateRulesFor*
- achievements.js (1150 lines) — 24-entry catalog + award path,
  per-event evaluators, encounter-end + career-update evaluation
- achievement-wall.js (333 lines) — renderAchievementWall,
  getAchievementWallProgress, renderAchievementPopover
- custom-achievements-app.js (270 lines) — GM FormApplication
  for editing custom rules
- hud.js (624 lines) — combat HUD (ApplicationV2 +
  HandlebarsApplicationMixin); removed dead import of
  battle-focus's encounter.js (it was unused even in the
  original)

scripts/main.js — Foundry entry point. Registers settings at
its-achievable.* namespace; exposes the public API on mod.api;
registers chatBubble popover listener + HUD singleton on ready.

templates/ + styles/ — moved verbatim.

tests/PLAN.md — per-project test plan (sections A-F).
tests/test-helpers.mjs — Foundry stub.
tests/verify-achievable-v1.mjs — smoke test, 75 assertions
covering rule engine, catalog, awards, hooks-lib wiring, HUD
payload derivation, and wall/popover rendering. Runs in <2s.

## Architecture

- **Settings namespace**: its-achievable.* (was battle-focus.*).
  No migration (per Kaysser's decision); users with existing
  worlds re-create their custom rules. Documented in README.
- **HUD derives its own state from hooks-lib envelopes.** Stage 2
  keeps the legacy battle-focus:hud-update broadcast subscription
  for now (battle-focus still emits it); Stage 3 will switch the
  HUD to subscribe to hooks-lib directly and remove the
  battle-focus broadcasts.
- **Encounter singleton**: accessed via battle-focus's public
  api.getActiveEncounter() — no direct import of battle-focus's
  encounter.js.

## Dependencies

- hax-hooks-lib ^0.2.0 (declared in module.json relationships).
- battle-focus (soft, runtime) — provides the encounter singleton.

## Tests

- 75/75 smoke assertions pass in 0.07s.
- Module manifest validates: 0 errors, 1 warning (no icon —
  Stage 2+ work).

Push: Gitea only.
2026-06-20 14:04:56 -04:00

629 lines
21 KiB
JavaScript

// Active Combat HUD (slice C).
//
// A floating ApplicationV2 that shows live combat stats during an
// active combat. Subscribes to the event pipeline via Foundry's
// `Hooks.on('battle-focus:hud-update', ...)` and
// `Hooks.on('battle-focus:hud-achievement', ...)` events. Renders
// throttled to once per second to keep the DOM cheap.
//
// Layout:
// - top header: round, current turn portrait + name, time-since-start, close
// - combatants list: per-PC damage dealt / taken, hits, crits, HP%
// - dice streak: count of consecutive matching d20s
// - pinned achievements: feed of unlocks during the fight
//
// GM view shows all combatants; player view filters to the
// player's own character (per game.user.character).
//
// API exposed on `game.modules.get('battle-focus').api.hud`:
// - isOpen(): boolean
// - open(): void
// - close(): void
// - getState(): { round, turn, currentTurn, timeSinceStart, combatants,
// diceStreak, lastDiceValue, pinnedAchievements, viewMode,
// position }
// - getDiceStreak(): number
// - getPinnedAchievements(): array
// - getView(opts?): same as getState() but allows caller to specify
// a fake user for the player-view test
// - element: HTMLElement | null (the .bf-hud root)
//
// The HUD deliberately does NOT own the event pipeline — it just
// listens. main.js is responsible for broadcasting
// `battle-focus:hud-update` after each event. The HUD itself is
// passive: it stores a snapshot of state and re-renders when state
// changes.
//
// Stage 2 note: the encounter singleton is reachable via
// battle-focus.api.getActiveEncounter(). No direct ./encounter.js
// import here (encounter.js stays in battle-focus). The original
// `import { getActive as getActiveEncounter } from "./encounter.js"`
// was unused — removed to avoid a missing-module load failure.
const MODULE_ID = "its-achievable";
// Foundry v14: ApplicationV2 + HandlebarsApplicationMixin live under
// foundry.applications.api (not the global scope). Resolve them at
// import time with safe fallbacks so the module can also load on
// older Foundry versions where they may still be globals.
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. Even a busy
// combat fires <10 events/sec; 1000ms is a safe upper bound.
const RENDER_THROTTLE_MS = 1000;
// Max entries to keep in the pinned-achievements feed. Older entries
// fall off the bottom.
const PINNED_MAX = 8;
// Dice-streak dedup window. Two attack-rolls that look like they're
// "consecutive" but are 10 seconds apart probably aren't a streak
// — we reset if more than this time elapses between matching rolls.
const DICE_STREAK_MAX_GAP_MS = 8000;
const POSITION_CLASSES = {
top: "bf-hud--top",
bottom: "bf-hud--bottom",
left: "bf-hud--left",
right: "bf-hud--right",
};
/**
* Format a duration in ms as a short M:SS string for the header.
*/
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")}`;
}
/**
* Resolve the portrait URL for a token/actor. Returns null if
* neither has a texture.
*/
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;
}
/**
* Compute HP percentage for a combatant. Returns null if the
* underlying actor has no max-HP (not applicable, e.g. an object).
*/
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;
}
}
/**
* Get the player's own character ID, or null if there isn't one
* (e.g. for GMs without a character set).
*/
function getPlayerCharacterId() {
try {
return game?.user?.character?.id ?? null;
} catch (_) { return null; }
}
/**
* Read the hudPosition setting, falling back to "top".
*/
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";
}
/**
* The HUD application. ApplicationV2 with HandlebarsApplicationMixin
* so we can use a static template path.
*/
export class BattleFocusHUD extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(options = {}) {
super(options);
// The HUD's internal state. Updated on every event; rendered
// throttled. Always set to a plain object so getState() is
// safe before any events fire.
this._state = {
round: 0,
turn: 0,
currentTurn: null,
timeSinceStart: 0,
combatants: [],
diceStreak: 0,
lastDiceValue: null,
pinnedAchievements: [],
viewMode: "gm",
position: "top",
isActive: false, // tracks whether a combat is currently in progress
};
// Last rendered timestamp. The throttle uses this.
this._lastRenderedAt = 0;
// The current combat startedAt (for the timer). Set on combatStart,
// cleared on combatEnd. Resets on Foundry world reload.
this._combatStartedAt = null;
// Pendin pinned-achievement feed (so we can show toasts).
// We store the full achievement object so the template can
// render the icon + name + description.
this._pinnedQueue = [];
// Dedupe: don't re-pin the same achievement ID for the same actor
// within a single combat.
this._pinnedSeen = new Set();
// Bind so we can pass these to Hooks listeners.
this._onHudUpdate = this._onHudUpdate.bind(this);
this._onHudAchievement = this._onHudAchievement.bind(this);
this._onCombatStartHook = this._onCombatStartHook.bind(this);
this._onCombatEndHook = this._onCombatEndHook.bind(this);
// Register the listeners once. The HUD is a module-level
// singleton; main.js calls _registerHooks() after construction.
this._hooksRegistered = false;
}
/** ===========================================================
* Foundry ApplicationV2 plumbing
* =========================================================== */
static DEFAULT_OPTIONS = {
id: "battle-focus-hud",
classes: ["battle-focus", "bf-app"],
tag: "div",
window: {
title: "Battle Focus",
frame: false, // no Foundry chrome — it's a HUD overlay
positioned: false, // we control position via CSS (top/bottom/left/right)
minimizable: false,
resizable: false,
},
position: {
width: 320,
height: "auto",
},
};
static PARTS = {
body: {
template: "modules/battle-focus/templates/hud.html",
},
};
/**
* Build the context object that the template renders against.
* Pure function over `this._state` + the current encounter.
*/
_prepareContext(_options) {
// Update the position from the current setting on every render.
this._state.position = getHudPosition();
// Update the time-since-start live so the timer ticks even if no
// event has fired in the last second.
if (this._combatStartedAt != null) {
this._state.timeSinceStart = Date.now() - this._combatStartedAt;
}
// Update viewMode based on the current user.
this._state.viewMode = (() => {
try { return game?.user?.isGM ? "gm" : "player"; }
catch (_) { return "gm"; }
})();
return { ...this._state, timeSinceStart: formatDuration(this._state.timeSinceStart) };
}
/**
* Render the application. We override render() to enforce the
* throttle — call this from the event listeners and the throttle
* will coalesce.
*/
async render(force = false, options = {}) {
if (!force) {
const now = Date.now();
const elapsed = now - this._lastRenderedAt;
if (elapsed < RENDER_THROTTLE_MS) {
// Schedule a deferred render at the throttle boundary.
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}] HUD throttled render failed:`, e)
);
}, wait);
return;
}
}
this._lastRenderedAt = Date.now();
return super.render(force, options);
}
/**
* On render, wire up the close button. We don't need any other
* event handlers — the HUD is read-only.
*/
_onRender(context, options) {
// ApplicationV2 exposes `this.element` as a getter — assigning
// to it (e.g. `this.element = this.element`) throws. We just
// need the live element for our querySelector calls below; the
// getter handles that.
const root = this.element?.[0] ?? this.element;
if (!root) return;
const closeBtn = root.querySelector?.('[data-bf-action="close"]');
if (closeBtn && !closeBtn.dataset.bfWired) {
closeBtn.dataset.bfWired = "true";
closeBtn.addEventListener("click", (ev) => {
ev.preventDefault();
this.close();
});
}
}
/** ===========================================================
* Public API — used by main.js and tests
* =========================================================== */
isOpen() {
try { return !!this.rendered; }
catch (_) { return false; }
}
open() {
// render(true) shows the window without toggling the throttle.
return this.render(true, { force: true });
}
/**
* Force-render bypassing the throttle. Used by tests and by the
* close path to make sure the final state is visible.
*/
forceRender() {
this._lastRenderedAt = 0;
return this.render(true, { force: true });
}
async close(options = {}) {
this._state.isActive = false;
this._combatStartedAt = null;
// Don't kill the singleton — main.js will call open() again on
// the next combat. We just hide.
return super.close(options);
}
/**
* Schedule a close after `delay` ms. If a new combat starts before
* the timer fires, call {@link cancelPendingClose} to abort. This
* avoids the race where back-to-back combat-end / combat-start
* sequences close the new HUD that just opened.
*/
scheduleClose(delay = 300) {
this.cancelPendingClose();
this._pendingCloseTimer = setTimeout(() => {
this._pendingCloseTimer = null;
this.close().catch((e) =>
console.warn(`[${MODULE_ID}] HUD close failed:`, e)
);
}, delay);
}
cancelPendingClose() {
if (this._pendingCloseTimer) {
clearTimeout(this._pendingCloseTimer);
this._pendingCloseTimer = null;
}
}
/**
* Read-only snapshot of the current HUD state. Used by tests.
*/
getState() {
return { ...this._state };
}
/**
* Return a snapshot filtered for a given user. The default
* ({}) returns the current user's view. Tests can pass a fake
* user to assert the player view.
*/
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";
let combatants = [...this._state.combatants];
if (!isGM && playerCharId) {
combatants = combatants.filter(
(c) => !c.isPlayer || c.actorId === playerCharId
);
}
return {
...this._state,
viewMode,
combatants,
};
}
getDiceStreak() {
return this._state.diceStreak;
}
getPinnedAchievements() {
return [...this._pinnedQueue];
}
/** ===========================================================
* Hook listeners
* =========================================================== */
/**
* Register Foundry hook listeners. Called from main.js after the
* module is fully loaded. Idempotent.
*/
registerHooks() {
if (this._hooksRegistered) return;
this._hooksRegistered = true;
// The main event bus. main.js fires `battle-focus:hud-update`
// after every ingested event.
Hooks.on("battle-focus:hud-update", this._onHudUpdate);
Hooks.on("battle-focus:hud-achievement", this._onHudAchievement);
// Also listen to Foundry's combat lifecycle so the HUD knows
// when to open and close even if main.js misses a beat.
Hooks.on("combatStart", this._onCombatStartHook);
Hooks.on("combatEnd", this._onCombatEndHook);
}
/**
* Unregister hook listeners. Called from teardown if needed.
*/
unregisterHooks() {
if (!this._hooksRegistered) return;
this._hooksRegistered = false;
Hooks.off("battle-focus:hud-update", this._onHudUpdate);
Hooks.off("battle-focus:hud-achievement", this._onHudAchievement);
Hooks.off("combatStart", this._onCombatStartHook);
Hooks.off("combatEnd", this._onCombatEndHook);
}
/**
* combatStart: set the startedAt timestamp and mark active. The
* HUD itself is opened by main.js; this is a defensive fallback.
*/
_onCombatStartHook(combat) {
this._combatStartedAt = Date.now();
this._state.isActive = true;
// Reset the dice streak and pinned-achievements feed for the
// new combat.
this._state.diceStreak = 0;
this._state.lastDiceValue = null;
this._state.lastDiceAt = null;
this._pinnedQueue = [];
this._pinnedSeen = new Set();
}
/**
* combatEnd: clear isActive. Don't close here — main.js owns
* open/close. We just stop the timer.
*/
_onCombatEndHook(combat) {
this._state.isActive = false;
this._combatStartedAt = null;
}
/**
* The main event bus. main.js broadcasts the full updated state
* snapshot. We accept it as-is and re-render.
*
* Payload shape (set by main.js):
* {
* round, turn, currentTurn, combatants, event, ...
* }
*
* We also fold the dice-streak logic in here: a d20 attack-roll
* that's the same as the previous one (within the gap window)
* increments the streak; otherwise resets it.
*/
_onHudUpdate(payload) {
if (!payload) return;
// Update state from the payload.
if (typeof payload.round === "number") this._state.round = payload.round;
if (typeof payload.turn === "number") this._state.turn = payload.turn;
if (payload.currentTurn !== undefined) this._state.currentTurn = payload.currentTurn;
if (Array.isArray(payload.combatants)) this._state.combatants = payload.combatants;
// If the payload includes a startedAt, refresh our internal one.
if (typeof payload.startedAt === "number" && this._combatStartedAt == null) {
this._combatStartedAt = payload.startedAt;
}
// Update the timer live.
if (this._combatStartedAt != null) {
this._state.timeSinceStart = Date.now() - this._combatStartedAt;
}
// Dice streak logic. We look at the most recent attack-roll's d20.
if (payload.event?.kind === "attack-roll" || payload.lastAttackRoll) {
const ev = payload.lastAttackRoll ?? payload.event;
const d20 = extractD20FromEvent(ev);
if (d20 != null) {
this._updateDiceStreak(d20, ev?.ts ?? Date.now());
}
}
// Re-render (throttled).
this.render(false).catch((e) =>
console.warn(`[${MODULE_ID}] HUD render failed:`, e)
);
}
/**
* Achievement broadcast. Adds to the pinned-achievements feed
* and re-renders. The throttle applies.
*/
_onHudAchievement(payload) {
if (!payload || !payload.id) return;
const dedupeKey = `${payload.actorKey ?? "?"}::${payload.id}`;
if (this._pinnedSeen.has(dedupeKey)) return;
this._pinnedSeen.add(dedupeKey);
const entry = {
id: payload.id,
name: payload.name ?? payload.id,
icon: payload.icon ?? "🏅",
description: payload.description ?? "",
awardedAt: payload.awardedAt ?? Date.now(),
actorKey: payload.actorKey ?? null,
};
this._pinnedQueue.push(entry);
// Cap the queue. Oldest entries fall off the bottom.
if (this._pinnedQueue.length > PINNED_MAX) {
this._pinnedQueue.splice(0, this._pinnedQueue.length - PINNED_MAX);
}
// Also keep the state copy in sync so getState() / getView() see it.
this._state.pinnedAchievements = [...this._pinnedQueue];
this.render(false).catch((e) =>
console.warn(`[${MODULE_ID}] HUD render (achievement) failed:`, e)
);
}
/**
* Update the dice streak. Two consecutive matching d20s within
* the gap window increment; anything else resets to 1 (the new
* value) or 0 (if the new value is the same as old but stale).
*/
_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) {
// Consecutive matching roll — extend the streak.
nextStreak = (this._state.diceStreak ?? 0) + 1;
} else if (lastVal == null) {
// First roll of the combat — count it as a streak of 1.
nextStreak = 1;
} else {
// Non-matching d20 (or gap too long) after a prior roll —
// reset to 0; the new d20 hasn't matched anything yet, so a
// follow-up matching roll will set the streak to 1.
nextStreak = 0;
}
this._state.diceStreak = nextStreak;
this._state.lastDiceValue = d20;
this._state.lastDiceAt = ts;
}
}
/**
* Extract the d20 value from an attack-roll event. The dnd5e
* roll-attack event has the roll on event.rawRolls or in
* ev.rolls (an array of D20Rolls with terms[0].results[0].result).
* Falls back to ev.d20 for synthetic test events.
*/
function extractD20FromEvent(ev) {
if (!ev) return null;
if (typeof ev.d20 === "number") return ev.d20;
// Synthetic test events attach the d20 directly. Real Foundry
// events put the roll data in rolls (or rawRolls).
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;
}
// Module-level singleton. main.js imports `getHud()` and binds
// it to mod.api.hud.
let _singleton = null;
/**
* Get or create the module-level HUD singleton. The HUD is a
* passive observer; it doesn't own the event pipeline.
*/
export function getHud() {
if (!_singleton) {
_singleton = new BattleFocusHUD();
_singleton.registerHooks();
}
return _singleton;
}
/**
* Build a payload object for `battle-focus:hud-update` from the
* current encounter state. Used by main.js — extracted here so
* the HUD module owns the payload contract.
*/
export function buildHudUpdatePayload(encounter, event) {
if (!encounter) return null;
// Build per-PC combatant rows.
const combatants = [];
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;
// Aggregate the per-round stat block into totals for the HUD.
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;
}
}
combatants.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),
});
}
// Current turn: best-effort. The current combatant is the one
// whose turn it is on game.combat. We try to find their token
// document for the portrait.
let currentTurn = null;
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 ?? 0,
turn: encounter.currentTurn ?? 0,
currentTurn,
combatants,
startedAt: encounter.startedAt,
event: event ?? null,
};
}