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.
197 lines
6.3 KiB
JavaScript
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`);
|
|
}
|
|
}); |