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.
334 lines
12 KiB
JavaScript
334 lines
12 KiB
JavaScript
// Player Achievement Wall — slice B (v0.5.0-alpha.11).
|
|
//
|
|
// Renders a tier-colored badge grid for a PC, showing every
|
|
// achievement they've earned. Includes a progress section for
|
|
// un-earned achievements that have a `target` field (e.g.,
|
|
// "470/1000 dmg toward Hero").
|
|
//
|
|
// The wall lives in three places:
|
|
// 1. The Career journal page (per-PC, GM-rendered).
|
|
// 2. The StableRecap document (per-PC, locked).
|
|
// 3. A chat-bar popover (per-player, lists recent unlocks).
|
|
//
|
|
// All three call into renderAchievementWall() with the same
|
|
// input shape (actorId, name, opts).
|
|
//
|
|
// The "target" field convention: when a catalog entry has a
|
|
// numeric `target`, getAchievementWallProgress() emits a
|
|
// {progress, target} tuple so the wall can show "N/target" next
|
|
// to the locked badge. This is a strict subset of the existing
|
|
// getAchievementProgress() helper (which is career-only); the
|
|
// wall version covers both career and combat achievements.
|
|
|
|
import {
|
|
ACHIEVEMENTS,
|
|
getAchievementsByActor,
|
|
getActorAchievements,
|
|
} from "./achievements.js";
|
|
|
|
const MODULE_ID = "its-achievable";
|
|
|
|
function esc(s) {
|
|
if (s == null) return "";
|
|
return String(s).replace(/[<>&"']/g, (c) =>
|
|
({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Render the achievement wall HTML for a given PC.
|
|
*
|
|
* @param {string} actorId - The actor's ID. Used as the primary
|
|
* lookup key in achievementsByActor.
|
|
* @param {string} actorName - The actor's name. Fallback key
|
|
* (slice 6 stored awards by name when the actor ID wasn't
|
|
* resolvable).
|
|
* @param {object} [opts]
|
|
* @param {object|null} [opts.career] - The PC's career map. When
|
|
* provided, un-earned career achievements get a progress
|
|
* display. When null, the wall only shows the badge grid.
|
|
* @param {string} [opts.title] - Optional override for the wall's
|
|
* heading text. Defaults to "Achievements".
|
|
* @returns {string} HTML.
|
|
*/
|
|
export function renderAchievementWall(actorId, actorName, opts = {}) {
|
|
const { career = null, title = "Achievements" } = opts;
|
|
// Look up earned records. Try the actor ID first, then name.
|
|
const earned =
|
|
(actorId && getActorAchievements(actorId)) ||
|
|
(actorName && getActorAchievements(actorName)) ||
|
|
[];
|
|
// Sort by awardedAt DESC (most recent first).
|
|
const sorted = [...earned].sort((a, b) => (b.awardedAt ?? 0) - (a.awardedAt ?? 0));
|
|
const progress = getAchievementWallProgress(actorId, actorName, career);
|
|
|
|
const badges = sorted
|
|
.map((a) => renderBadge(a))
|
|
.join("");
|
|
const progressHtml = progress.length > 0
|
|
? `<div class="bf-award-wall-progress">
|
|
<h4>Locked — in progress</h4>
|
|
<ul class="bf-award-wall-progress-list">
|
|
${progress.map(renderProgress).join("")}
|
|
</ul>
|
|
</div>`
|
|
: "";
|
|
|
|
const heading = `<h3 class="bf-award-wall-title">${esc(title)}</h3>`;
|
|
const grid = earned.length > 0
|
|
? `<div class="bf-achievement-grid" data-actor-id="${esc(actorId ?? "")}">${badges}</div>`
|
|
: `<p class="bf-award-wall-empty"><em>No achievements yet — keep fighting!</em></p>`;
|
|
return `<section class="bf-award-wall" data-actor-id="${esc(actorId ?? "")}">
|
|
${heading}
|
|
${grid}
|
|
${progressHtml}
|
|
</section>`;
|
|
}
|
|
|
|
/**
|
|
* Render a single badge <span> with the tier-specific class.
|
|
*/
|
|
function renderBadge(record) {
|
|
const tier = record.tier ?? "bronze";
|
|
return `<span class="bf-achievement bf-achievement-${esc(tier)}"
|
|
data-achievement-id="${esc(record.id ?? "")}"
|
|
title="${esc(record.description ?? "")}">
|
|
<span class="bf-achievement-icon">${esc(record.icon ?? "🏅")}</span>
|
|
<span class="bf-achievement-name">${esc(record.name ?? "")}</span>
|
|
</span>`;
|
|
}
|
|
|
|
/**
|
|
* Render a single progress <li> with the N/target label.
|
|
*/
|
|
function renderProgress(entry) {
|
|
const tier = entry.tier ?? "bronze";
|
|
const pct = entry.target > 0
|
|
? Math.min(100, Math.round((entry.progress / entry.target) * 100))
|
|
: 0;
|
|
return `<li class="bf-award-wall-progress-item bf-achievement-locked bf-achievement-${esc(tier)}"
|
|
data-achievement-id="${esc(entry.id ?? "")}"
|
|
title="${esc(entry.description ?? "")}">
|
|
<span class="bf-achievement-icon">${esc(entry.icon ?? "🏅")}</span>
|
|
<span class="bf-achievement-name">${esc(entry.name ?? "")}</span>
|
|
<span class="bf-award-wall-progress-bar"
|
|
aria-label="${entry.progress}/${entry.target}">
|
|
<span class="bf-award-wall-progress-fill"
|
|
style="width: ${pct}%;"></span>
|
|
</span>
|
|
<span class="bf-award-wall-progress-label">${entry.progress}/${entry.target}</span>
|
|
</li>`;
|
|
}
|
|
|
|
/**
|
|
* Compute the progress entries for un-earned achievements that
|
|
* have a `target` field. Covers both career and combat
|
|
* achievements. Returns array of
|
|
* { id, name, description, icon, tier, progress, target, category }.
|
|
*
|
|
* Career achievements use the existing per-PC career map
|
|
* (career.totalDamage, career.kills, career.encounters). Combat
|
|
* achievements use encounter-level stats when present
|
|
* (passed via opts.career or via the most-recent encounter's
|
|
* stats). When no live stats are available, combat-achievement
|
|
* progress is omitted (the wall only shows what it can measure).
|
|
*
|
|
* @param {string} actorId
|
|
* @param {string} actorName
|
|
* @param {object|null} [career]
|
|
* @returns {Array}
|
|
*/
|
|
export function getAchievementWallProgress(actorId, actorName, career = null) {
|
|
// If the caller didn't pass a career map, look it up from the
|
|
// saved settings. Keys may be either actor id (alphanumeric) or
|
|
// name (for legacy / synthetic-test compat).
|
|
if (!career && (actorId || actorName)) {
|
|
try {
|
|
const all = game?.settings?.get?.(MODULE_ID, "careerByActor") ?? {};
|
|
if (actorId && all[actorId]) career = all[actorId];
|
|
else if (actorName && all[actorName]) career = all[actorName];
|
|
else {
|
|
// Try to find by name match.
|
|
const wantName = String(actorName ?? "").toLowerCase();
|
|
if (wantName) {
|
|
for (const [k, v] of Object.entries(all)) {
|
|
if (typeof k === "string" && k.toLowerCase() === wantName) {
|
|
career = v;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// game.settings not available (e.g. node-side import) — leave
|
|
// career null; resolveProgressForDef will skip.
|
|
}
|
|
}
|
|
const earnedIds = new Set(
|
|
((actorId && getActorAchievements(actorId)) ||
|
|
(actorName && getActorAchievements(actorName)) ||
|
|
[]).map((a) => a.id)
|
|
);
|
|
const out = [];
|
|
for (const def of ACHIEVEMENTS) {
|
|
if (earnedIds.has(def.id)) continue;
|
|
if (typeof def.target !== "number" || def.target <= 0) continue;
|
|
// Resolve the progress value based on the def's category
|
|
// and the achievement's metric path (encoded in def.id by
|
|
// convention; see below).
|
|
const progress = resolveProgressForDef(def, career);
|
|
if (progress == null) continue;
|
|
out.push({
|
|
id: def.id,
|
|
name: def.name,
|
|
description: def.description,
|
|
icon: def.icon,
|
|
tier: def.tier,
|
|
category: def.category,
|
|
target: def.target,
|
|
progress,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Resolve the progress value for a given achievement def using
|
|
* the career map (and any per-encounter stats passed through).
|
|
*
|
|
* The convention: the achievement's `target` field is the
|
|
* numeric goal (e.g., 1000 for Hero). The progress source is
|
|
* determined by a `targetSource` field on the def (e.g.,
|
|
* "career.totalDamage"). For career-only achievements, the
|
|
* source is always a path on the career map.
|
|
*
|
|
* Returns null if the source isn't available — the wall then
|
|
* silently drops the progress entry (no N/A clutter).
|
|
*/
|
|
function resolveProgressForDef(def, career) {
|
|
// Combat achievements: pull from per-encounter stats when
|
|
// present. The career map may also include per-combat-end
|
|
// snapshots of these stats (e.g., career.killsFromCombat).
|
|
// For built-in combat achievements, we use the most-recent
|
|
// encounter's stats when passed through.
|
|
const source = def.targetSource;
|
|
if (!source) {
|
|
// Built-in career achievements have implicit sources by id.
|
|
if (def.id.startsWith("career-100-dmg") || def.id === "career-100-dmg") {
|
|
return career?.totalDamage ?? 0;
|
|
}
|
|
if (def.id.startsWith("career-500-dmg")) return career?.totalDamage ?? 0;
|
|
if (def.id.startsWith("career-1000-dmg")) return career?.totalDamage ?? 0;
|
|
if (def.id.startsWith("career-5000-dmg")) return career?.totalDamage ?? 0;
|
|
if (def.id.startsWith("career-10-encounters")) return career?.encounters ?? 0;
|
|
if (def.id.startsWith("career-50-encounters")) return career?.encounters ?? 0;
|
|
if (def.id.startsWith("career-100-encounters")) return career?.encounters ?? 0;
|
|
if (def.id.startsWith("career-first-kill")) return career?.kills ?? 0;
|
|
if (def.id.startsWith("career-10-kills")) return career?.kills ?? 0;
|
|
if (def.id.startsWith("career-50-kills")) return career?.kills ?? 0;
|
|
// Career equipment/style
|
|
if (def.id === "arsenal") {
|
|
return Object.keys(career?.weapons ?? {}).length;
|
|
}
|
|
if (def.id === "weapon-master") {
|
|
return Math.max(0,
|
|
...Object.values(career?.weapons ?? {}).map((w) => w?.totalDamage ?? 0));
|
|
}
|
|
return null;
|
|
}
|
|
// Explicit targetSource — walk the path on the career object.
|
|
// e.g. targetSource = "career.totalDamage" reads career.career.totalDamage.
|
|
// Most paths are relative to the career object, so we strip a
|
|
// leading "career." if present.
|
|
const path = source.replace(/^career\./, "");
|
|
return getPathValue(career, path);
|
|
}
|
|
|
|
function getPathValue(obj, path) {
|
|
if (!obj || !path) return null;
|
|
const parts = path.split(".");
|
|
let cur = obj;
|
|
for (const p of parts) {
|
|
if (cur == null) return null;
|
|
cur = cur[p];
|
|
}
|
|
return typeof cur === "number" ? cur : null;
|
|
}
|
|
|
|
/**
|
|
* Get the recent unlocks for a given actor key (id or name).
|
|
* Sorted by awardedAt DESC. The chat-bar popover uses this to
|
|
* show the player's latest achievements.
|
|
*
|
|
* @param {string|null} actorKey - Actor ID or name. When null,
|
|
* returns the most recent unlocks across all actors (used
|
|
* when the current user has no character assigned).
|
|
* @param {number} [limit=10]
|
|
* @returns {Array}
|
|
*/
|
|
export function getRecentUnlocks(actorKey, limit = 10) {
|
|
const map = getAchievementsByActor();
|
|
let pool = [];
|
|
if (actorKey && map[actorKey]) {
|
|
pool = map[actorKey];
|
|
} else if (!actorKey) {
|
|
// All actors: flatten.
|
|
for (const list of Object.values(map)) {
|
|
if (Array.isArray(list)) pool.push(...list);
|
|
}
|
|
} else {
|
|
// Try the alternate key (id <-> name).
|
|
const altKey = Object.keys(map).find(
|
|
(k) => k.toLowerCase() === String(actorKey).toLowerCase()
|
|
);
|
|
if (altKey) pool = map[altKey];
|
|
}
|
|
return pool
|
|
.slice()
|
|
.sort((a, b) => (b.awardedAt ?? 0) - (a.awardedAt ?? 0))
|
|
.slice(0, limit);
|
|
}
|
|
|
|
/**
|
|
* Render the popover content for the chat-bar 🏆 button. Used
|
|
* by main.js's chat-log hook to populate the popover when the
|
|
* user clicks the button.
|
|
*
|
|
* @param {Array} unlocks - Output of getRecentUnlocks().
|
|
* @param {string|null} viewerName - The viewer's character name
|
|
* (for the empty-state message).
|
|
* @returns {string} HTML.
|
|
*/
|
|
export function renderAchievementPopover(unlocks, viewerName = null) {
|
|
if (!unlocks || unlocks.length === 0) {
|
|
return `<div class="battle-focus-achievements-popover">
|
|
<header><strong>🏆 Your Achievements</strong></header>
|
|
<p class="bf-popover-empty"><em>${
|
|
viewerName
|
|
? `${esc(viewerName)} hasn't earned any achievements yet.`
|
|
: "No achievements yet — keep fighting!"
|
|
}</em></p>
|
|
</div>`;
|
|
}
|
|
const items = unlocks
|
|
.map((a) => `<li class="bf-popover-item bf-achievement-${esc(a.tier ?? "bronze")}">
|
|
<span class="bf-achievement-icon">${esc(a.icon ?? "🏅")}</span>
|
|
<span class="bf-achievement-name">${esc(a.name ?? "")}</span>
|
|
<span class="bf-popover-when">${formatRelative(a.awardedAt)}</span>
|
|
</li>`)
|
|
.join("");
|
|
return `<div class="battle-focus-achievements-popover">
|
|
<header><strong>🏆 Your Achievements</strong> <span class="bf-popover-count">(${unlocks.length})</span></header>
|
|
<ul class="bf-popover-list">${items}</ul>
|
|
</div>`;
|
|
}
|
|
|
|
function formatRelative(ts) {
|
|
if (!ts) return "";
|
|
const diff = Date.now() - ts;
|
|
if (diff < 60_000) return "just now";
|
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
}
|