Files
Its-Achievable/tests/test-helpers.mjs
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

190 lines
6.1 KiB
JavaScript

// tests/test-helpers.mjs — its-achievable v0.1.0
//
// Foundry stub for the no-Foundry smoke test. Installs globalThis.Hooks,
// game, ui, FormApplication, ApplicationV2, HandlebarsApplicationMixin,
// and game.modules so the moved code can be imported without Foundry.
import { performance } from "node:perf_hooks";
const _listeners = new Map();
const _once = new WeakMap();
const _callLog = [];
// FormApplication stub: enough surface for `CustomAchievementsApp` to
// extend. The smoke test doesn't open the form, so constructor +
// defaultOptions + render are no-ops.
class StubFormApplication {
constructor(...args) {
StubFormApplication._lastInstance = this;
this._args = args;
}
render(force) { return this; }
close() { return this; }
}
StubFormApplication.defaultOptions = { id: "stub-form", template: "", width: 600 };
StubFormApplication._lastInstance = null;
// ApplicationV2 stub for hud.js.
class StubApplicationV2 {
constructor(...args) {
StubApplicationV2._lastInstance = this;
this._args = args;
}
render(opts) { return Promise.resolve(this); }
close(opts) { return Promise.resolve(this); }
}
StubApplicationV2.DEFAULT_OPTIONS = { id: "stub-appv2", classes: [] };
StubApplicationV2._lastInstance = null;
const StubHandlebarsApplicationMixin = (Base) => class extends Base {
static PARTS = {};
};
export function installStubs(opts = {}) {
resetStubs();
const { withHooksLib = true, withBattleFocus = true, systemId = "dnd5e", systemVersion = "5.2.5", foundryVersion = "13.351.0" } = opts;
globalThis.Hooks = {
on(name, fn) {
_listeners.set(name, [...(_listeners.get(name) ?? []), fn]);
},
once(name, fn) {
_listeners.set(name, [...(_listeners.get(name) ?? []), fn]);
_once.set(fn, { hookName: name });
},
off(name, fn) {
const list = _listeners.get(name);
if (!list) return;
const next = list.filter((f) => f !== fn);
if (next.length === 0) _listeners.delete(name);
else _listeners.set(name, next);
_once.delete(fn);
},
callAll(name, ...args) {
_callLog.push({ name, args, ts: performance.now() });
const list = _listeners.get(name);
if (!list) return;
const snapshot = [...list];
for (const fn of snapshot) {
if (_once.has(fn)) this.off(name, fn);
try {
fn(...args);
} catch (e) {
console.error(`[stubs] Hooks.callAll(${name}) handler threw:`, e);
}
}
},
};
// settings store (in-memory)
const _settings = new Map();
const settingsApi = {
get(moduleId, key) {
return _settings.get(`${moduleId}.${key}`);
},
set: async (moduleId, key, value) => {
_settings.set(`${moduleId}.${key}`, value);
},
register(moduleId, key, def) {
if (!_settings.has(`${moduleId}.${key}`)) {
_settings.set(`${moduleId}.${key}`, def.default);
}
},
};
// modules store
const _modules = new Map();
if (withHooksLib) {
const _hooksLibSubscribers = new Map();
const _hooksLibOnce = new WeakMap();
_modules.set("hax-hooks-lib", {
id: "hax-hooks-lib",
active: true,
api: {
MODULE_ID: "hax-hooks-lib",
version: "0.2.0",
REGISTERED_HOOKS: ["combatStart", "combatEnd", "updateActor", "createToken", "dnd5e.rollAttackV2", "dnd5e.rollDamageV2", "preUpdateActor", "updateToken", "preUpdateToken"],
subscribe(hookName, fn) {
_hooksLibSubscribers.set(hookName, [...(_hooksLibSubscribers.get(hookName) ?? []), fn]);
return () => {
const list = _hooksLibSubscribers.get(hookName);
if (!list) return;
const next = list.filter((f) => f !== fn);
if (next.length === 0) _hooksLibSubscribers.delete(hookName);
else _hooksLibSubscribers.set(hookName, next);
};
},
subscribeMany(map) {
const unsubs = [];
for (const [name, fn] of Object.entries(map)) {
unsubs.push(this.subscribe(name, fn));
}
return () => { for (const u of unsubs) u(); };
},
_fireForTest(hookName, ...args) {
const list = _hooksLibSubscribers.get(hookName);
if (!list) return;
for (const fn of list) {
try { fn({ ts: Date.now(), hook: hookName, args }); } catch (e) {
console.error(`[stubs] hooksLib ${hookName} handler threw:`, e);
}
}
},
_hasSubscribersFor: (hookName) => _hooksLibSubscribers.has(hookName),
},
});
}
if (withBattleFocus) {
const _enc = {
id: "enc-test",
startedAt: Date.now() - 30000,
round: 1,
turn: 0,
currentTurn: { name: "Bard", tokenId: "t1", portrait: "" },
combatants: new Map(),
isActive: () => true,
buildStats: () => ({ kills: 0, dmg: 0, crits: 0, hits: 0, attacks: 0 }),
};
_modules.set("battle-focus", {
id: "battle-focus",
active: true,
api: {
MODULE_ID: "battle-focus",
getActiveEncounter: () => _enc,
getEncounter: () => _enc,
},
});
}
globalThis.game = {
version: foundryVersion,
system: { id: systemId, version: systemVersion },
modules: _modules,
settings: settingsApi,
user: null,
ready: true,
};
globalThis.ui = {
notifications: { info: () => {}, warn: () => {}, error: () => {} },
chat: [],
};
globalThis.FormApplication = StubFormApplication;
globalThis.ApplicationV2 = StubApplicationV2;
globalThis.HandlebarsApplicationMixin = StubHandlebarsApplicationMixin;
globalThis.mergeObject = (a, b) => ({ ...a, ...b });
globalThis.foundry = undefined; // hud.js checks foundry?.applications?.api
globalThis.canvas = undefined; // hud.js checks canvas?.tokens?.get(...). Optional chaining doesn't catch undefined globals.
}
export function resetStubs() {
_listeners.clear();
_callLog.length = 0;
}
export function getSettingsStore() {
if (!globalThis.game?.settings) return new Map();
// Use the captured settings via game.settings — this is a thin wrapper.
// For tests that need raw access, import the internal map directly.
return null;
}
export function getCallLog() {
return [..._callLog];
}