Files
Its-Achievable/scripts/main.js
Kaysser Kayyali 888790e5ff v0.1.1: track the hax-hooks-lib -> foundry-hooks-lib rename
User callout: 'Hax' is Kaysser's nickname, drop it from module
names. hooks-lib v0.3.0 renamed its module id; this commit
follows:

- relationships.modules[0].id: hax-hooks-lib -> foundry-hooks-lib
- relationships.modules[0].compatibility.minimum: 0.2.0 -> 0.3.0
  (we now require the renamed version)
- module.json: version 0.1.0 -> 0.1.1, download URL updated
- README.md, scripts/main.js, tests/PLAN.md, tests/test-helpers.mjs:
  branding text updates
- .gitignore: un-ignore the new 0.1.1 zip

Verification: 75/75 smoke assertions pass, no logic change.

Module title is now 'It's Achievable' (dropped the
'Hax's Tools' umbrella prefix per the same callout). 2 module
text changes are non-functional; pure branding.
2026-06-20 16:55:29 -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 Foundry module 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`);
}
});