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.
190 lines
6.1 KiB
JavaScript
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];
|
|
}
|