Files
Its-Achievable/scripts/achievement-wall.js
Kaysser Kayyali f2ef1ef4f3 v0.1.0 — initial extraction from battle-focus v0.5.0-alpha.12
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.
2026-06-20 14:04:56 -04:00

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) =>
({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&#39;" })[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`;
}