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