Files
Its-Achievable/scripts/main.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

197 lines
6.3 KiB
JavaScript

// its-achievable — module entry point (v0.1.0).
//
// Achievements engine, custom rules, rewards, achievement wall, combat
// HUD. Stage 2 of the Hax's Tools split.
//
// Stage 2 wiring:
// - The HUD listens to battle-focus's `battle-focus:hud-update` and
// `battle-focus:hud-achievement` broadcasts (battle-focus still
// emits these — Stage 3 removes them).
// - The HUD also listens to Foundry's `combatStart`/`combatEnd` as
// defensive fallbacks.
// - its-achievable's own `chatBubble` listener draws the achievement
// popover near the chat input.
//
// Stage 3 will:
// - Remove the `battle-focus:hud-update` and
// `battle-focus:hud-achievement` broadcasts from battle-focus's
// main.js.
// - Convert the HUD's subscription pattern to hooks-lib's envelope
// stream (per the v0.2.0 contract).
//
// For Stage 2, battle-focus is a runtime dependency for HUD update
// events but NOT for achievement evaluation (achievement code reads
// the encounter via battle-focus.api.getActiveEncounter(), which is
// the public seam).
const MODULE_ID = "its-achievable";
const MODULE_VERSION = "0.1.0";
import {
ACHIEVEMENTS,
awardAchievement,
evaluateCareerAchievements,
evaluateCombatAchievements,
getAchievementsByActor,
getActorAchievements,
processEventForAchievements,
} from "./achievements.js";
import {
getAchievementWallProgress,
getRecentUnlocks,
renderAchievementPopover,
renderAchievementWall,
} from "./achievement-wall.js";
import {
evaluateRulesForCareerUpdate,
evaluateRulesForEncounterEnd,
evaluateRulesForEvent,
getCustomRules,
setCustomRules,
} from "./achievement-rules.js";
import {
buildHudUpdatePayload,
getHud,
} from "./hud.js";
import { CustomAchievementsApp, openCustomAchievementsApp } from "./custom-achievements-app.js";
function isClient() {
return typeof ui !== "undefined" && !!ui;
}
// ── Settings registration ───────────────────────────────────────────────
// Register at its-achievable.* namespace. Battle-focus retains its
// own registrations until Stage 3 of the split (which will remove
// them).
function registerSettings() {
if (typeof game === "undefined" || !game?.settings?.register) return;
game.settings.register(MODULE_ID, "achievementsByActor", {
name: "Achievements By Actor",
scope: "world",
config: false,
type: Object,
default: {},
});
game.settings.register(MODULE_ID, "customAchievementRules", {
name: "Custom Achievement Rules",
hint: "GM-authored custom achievement rules. See Custom Achievements form.",
scope: "world",
config: false,
type: Array,
default: [],
});
game.settings.register(MODULE_ID, "enableRewards", {
name: "Enable Rewards",
hint: "When true, earning an achievement grants items/currency/features.",
scope: "world",
config: true,
type: Boolean,
default: false,
});
game.settings.register(MODULE_ID, "hudPosition", {
name: "HUD Position",
hint: "Where the combat HUD sits on the canvas.",
scope: "user",
config: true,
type: String,
choices: { top: "Top", bottom: "Bottom", left: "Left", right: "Right" },
default: "bottom",
});
}
// ── Chat-bar popover ────────────────────────────────────────────────────
// Render a small popover near the chat input when an achievement is
// awarded. Subscribes to chatBubble because that's when the chat
// card actually renders.
let _popoverHookRegistered = false;
function registerChatBubblePopover() {
if (_popoverHookRegistered) return;
_popoverHookRegistered = true;
Hooks.on("chatBubble", (token, html, message, { emote }) => {
if (emote) return;
// Look for an achievement flag on the message. battle-focus sets
// it via `message.setFlag(MODULE_ID, "achievement", {...})` when
// awarding; battle-focus's broadcasts do this. If found, pop the
// achievement.
const achData = message?.getFlag?.(MODULE_ID, "achievement");
if (!achData) return;
try {
renderAchievementPopover([achData], token?.name ?? null);
} catch (e) {
console.warn(`[${MODULE_ID}] popover render failed:`, e);
}
});
}
// ── Lifecycle ───────────────────────────────────────────────────────────
Hooks.once("init", () => {
const mod = game.modules.get(MODULE_ID);
mod.api = {
MODULE_ID,
version: MODULE_VERSION,
// Catalog
ACHIEVEMENTS,
getAchievementCatalog: () => ACHIEVEMENTS,
getAchievementsByActor,
getActorAchievements,
// Rule engine
evaluateRulesForEvent,
evaluateRulesForEncounterEnd,
evaluateRulesForCareerUpdate,
getCustomRules,
setCustomRules,
// Awarding
awardAchievement,
evaluateCombatAchievements,
evaluateCareerAchievements,
processEventForAchievements,
// Wall + popover
renderAchievementWall,
getAchievementWallProgress,
getRecentUnlocks,
renderAchievementPopover,
// HUD
buildHudUpdatePayload,
getHud,
openHud: () => getHud().open(),
closeHud: () => getHud().close(),
// Form
openCustomAchievementsApp,
CustomAchievementsApp,
// Helpers
isReady: () => isClient() && !!game.ready,
};
registerSettings();
console.log(
`[${MODULE_ID} v${MODULE_VERSION}] init (client=${isClient()})`
);
});
Hooks.once("ready", () => {
if (!isClient()) return;
// Register the chat-bar popover listener.
registerChatBubblePopover();
// Construct + register the HUD singleton. This triggers
// `registerHooks()` inside hud.js — the HUD will start listening
// to `battle-focus:hud-update`, `battle-focus:hud-achievement`,
// `combatStart`, `combatEnd`.
getHud();
console.log(
`[${MODULE_ID} v${MODULE_VERSION}] ready (hud registered, popover registered)`
);
});
// Cleanup on module disable.
Hooks.on("unregisterModule", (moduleId) => {
if (moduleId === MODULE_ID) {
const hud = getHud();
try { hud.unregisterHooks(); } catch (e) {
console.warn(`[${MODULE_ID}] HUD unregisterHooks failed:`, e);
}
console.log(`[${MODULE_ID}] unregisterModule: cleaned up`);
}
});