Files
Its-Achievable/scripts/main.js
Kaysser Kayyali 2f6acc0da1 v0.3.0: strip combat HUD; integrate with combat-hud-hub
- Deleted scripts/hud.js, scripts/event-translation.js, styles/hud.css,
  templates/hud.html.
- Removed mod.api.getHud/openHud/closeHud/buildHudUpdatePayload exports.
- Removed hudPosition setting (moved to combat-hud-hub).
- Added hub integration: at ready, if combat-hud-hub is installed,
  register a 'pinned-achievements' section. The chatBubble listener
  now also pushes entries into that section via hub.api.pushFeedEntry.
- Soft-dep on combat-hud-hub (optional); popover still works without
  the hub.
- Tests: 57/57 passing covering sections A-G (rule engine, catalog,
  awarding, hooks wiring, hub integration, graceful degradation).
2026-06-22 12:46:57 -04:00

219 lines
7.3 KiB
JavaScript

// its-achievable — module entry point (v0.3.0).
//
// Achievements engine, custom rules, rewards, achievement wall, and
// chat-bar 🏆 popover. v0.3.0 strips the combat HUD — that's been
// moved to the combat-hud-hub module. its-achievable now registers
// a "Pinned Achievements" section on the hub (when installed) and
// pushes recent unlocks into it via the hub's feed API.
//
// v0.3.0 wiring:
// - If combat-hud-hub is installed and active, register a
// "pinned-achievements" section. Render the most-recent unlocks
// from getRecentUnlocks().
// - The chatBubble listener (existing) now also pushes into the hub
// feed when the hub is installed.
// - If the hub is missing, the popover still works — the hub is a
// soft dependency.
const MODULE_ID = "its-achievable";
const MODULE_VERSION = "0.3.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 { CustomAchievementsApp, openCustomAchievementsApp } from "./custom-achievements-app.js";
function isClient() {
return typeof ui !== "undefined" && !!ui;
}
// ── Settings registration ─────────────────────────────────────────────
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,
});
}
// ── 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;
let _hubFeedRegistered = 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. If found, pop the achievement.
const achData = message?.getFlag?.(MODULE_ID, "achievement");
if (!achData) return;
try {
renderAchievementPopover([achData], token?.name ?? null);
// Also push into the combat-hud-hub feed (if installed).
pushToHubFeed(achData, token?.name ?? null);
} catch (e) {
console.warn(`[${MODULE_ID}] popover render failed:`, e);
}
});
}
// ── Combat HUD Hub integration ──────────────────────────────────────────
// Soft-dep on combat-hud-hub. Register a "Pinned Achievements" section
// at ready. Push new unlocks into the section feed via pushFeedEntry.
const HUB_SECTION_ID = "pinned-achievements";
const HUB_FEED_MAX = 8;
function getHubApi() {
const mod = game?.modules?.get?.("combat-hud-hub");
if (!mod?.active) return null;
return mod.api ?? null;
}
function registerHubSection() {
const api = getHubApi();
if (!api?.addSection) return false;
if (_hubFeedRegistered) return true;
// Idempotent: removeSection if already present, then re-add.
try { api.removeSection?.(HUB_SECTION_ID); } catch (_) {}
api.addSection({
id: HUB_SECTION_ID,
label: "Pinned Achievements",
render: (ctx) => {
const feed = ctx.feed ?? [];
if (feed.length === 0) {
return `<p class="chh-hud-empty chh-hud-empty--inline">None yet.</p>`;
}
return `<ul class="chh-section-pinned-list">${feed.map(e => `
<li class="chh-section-pinned-item" data-achievement-id="${e.id ?? ""}">
<span class="chh-section-pinned-icon">${e.icon ?? "🏅"}</span>
<strong>${e.name ?? e.id ?? ""}</strong>
${e.description ? `<span class="chh-section-pinned-desc">${e.description}</span>` : ""}
</li>`).join("")}</ul>`;
},
});
_hubFeedRegistered = true;
return true;
}
function pushToHubFeed(achData, actorName) {
const api = getHubApi();
if (!api?.pushFeedEntry) return;
const entry = {
id: achData.id ?? null,
name: achData.name ?? achData.id ?? "Achievement",
icon: achData.icon ?? "🏅",
description: achData.description ?? "",
awardedAt: achData.awardedAt ?? Date.now(),
actorKey: actorName ?? null,
};
api.pushFeedEntry(HUB_SECTION_ID, entry);
// Note: the hub caps the feed internally. We don't trim here; the
// hub is responsible for retention.
}
// ── 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,
// Hub integration (soft-dep)
registerHubSection: () => registerHubSection(),
pushToHubFeed,
// 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;
registerChatBubblePopover();
const hubRegistered = registerHubSection();
if (hubRegistered) {
console.log(`[${MODULE_ID} v${MODULE_VERSION}] ready (registered Pinned Achievements section on combat-hud-hub)`);
} else {
console.log(`[${MODULE_ID} v${MODULE_VERSION}] ready (combat-hud-hub not installed; popover-only mode)`);
}
});
Hooks.on("unregisterModule", (moduleId) => {
if (moduleId === MODULE_ID) {
console.log(`[${MODULE_ID}] unregisterModule: cleaned up`);
}
});