Files
Its-Achievable/scripts/custom-achievements-app.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

271 lines
9.2 KiB
JavaScript

// GM UI for managing custom achievements (slice 8).
//
// Opens a FormApplication listing all custom achievements with
// per-row edit fields (name, description, icon, tier, trigger type,
// conditions). Saves changes back to the world-scoped
// `customAchievements` setting.
//
// Design choices (slice 8 — keeping it simple for the average GM):
// - One FormApplication, opened from a chat card button or
// programmatically via `CustomAchievementsApp.open()`.
// - Each row is fully inline-editable; no drag-and-drop or
// modals. Add/Remove buttons per row.
// - Save button persists all rows at once.
// - "Test" button evaluates the rule against the active encounter
// and shows a toast with the result.
//
// Foundry v13 supports both Application (legacy) and
// ApplicationV2 (modern). We use the legacy FormApplication here
// for maximum compatibility and simplicity. v13 still ships it.
import {
OPERATORS,
TRIGGER_TYPES,
TIERS,
newRuleTemplate,
validateRule,
getCustomRules,
setCustomRules,
testRule,
} from "./achievement-rules.js";
const MODULE_ID = "its-achievable";
export class CustomAchievementsApp extends FormApplication {
constructor(...args) {
super(...args);
this._testResults = null;
}
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
id: "battle-focus-custom-achievements",
title: "Battle Focus — Custom Achievements",
template: `modules/${MODULE_ID}/templates/custom-achievements.html`,
width: 820,
height: "auto",
closeOnSubmit: false,
submitOnChange: false,
tabs: [],
});
}
async getData() {
const rules = getCustomRules();
return {
rules: rules,
operators: OPERATORS,
triggerTypes: TRIGGER_TYPES,
tiers: TIERS,
};
}
activateListeners(html) {
super.activateListeners(html);
// Add new rule
html.find(".bf-add-rule").on("click", (ev) => {
ev.preventDefault();
this._addRule();
});
// Delete rule
html.find(".bf-delete-rule").on("click", (ev) => {
ev.preventDefault();
const idx = Number(ev.currentTarget.dataset.idx);
this._deleteRule(idx);
});
// Add condition to rule
html.find(".bf-add-condition").on("click", (ev) => {
ev.preventDefault();
const ruleIdx = Number(ev.currentTarget.dataset.ruleIdx);
this._addCondition(ruleIdx);
});
// Remove condition from rule
html.find(".bf-delete-condition").on("click", (ev) => {
ev.preventDefault();
const ruleIdx = Number(ev.currentTarget.dataset.ruleIdx);
const condIdx = Number(ev.currentTarget.dataset.condIdx);
this._deleteCondition(ruleIdx, condIdx);
});
// Test rule against active encounter
html.find(".bf-test-rule").on("click", (ev) => {
ev.preventDefault();
const idx = Number(ev.currentTarget.dataset.idx);
this._testRule(idx);
});
// Live validation feedback (per row)
html.find("input, select, textarea").on("change", () => {
this._showValidation();
});
}
async _updateObject(event, formData) {
// Serialize the form back to a rules array.
const rules = this._serializeForm(formData);
const validated = [];
for (const r of rules) {
const v = validateRule(r);
if (!v.valid) {
ui.notifications?.warn(
`Battle Focus: rule "${r.name ?? r.id ?? "(unnamed)"}" has validation issues: ${v.errors.join("; ")}`
);
}
validated.push(r);
}
await setCustomRules(validated);
ui.notifications?.info(`Battle Focus: saved ${validated.length} custom achievement${validated.length === 1 ? "" : "s"}.`);
this.render();
}
/**
* Walk the FormData and produce an array of rule objects.
* Form field names use indexed paths: rules[0].id, rules[0].name,
* rules[0].conditions[0].field, etc.
*/
_serializeForm(formData) {
const out = [];
const indices = new Set();
for (const key of Object.keys(formData)) {
const m = /^rules\[(\d+)\]/.exec(key);
if (m) indices.add(Number(m[1]));
}
const sorted = [...indices].sort((a, b) => a - b);
for (const i of sorted) {
const r = {
id: formData[`rules[${i}].id`] ?? "",
name: formData[`rules[${i}].name`] ?? "",
description: formData[`rules[${i}].description`] ?? "",
icon: formData[`rules[${i}].icon`] ?? "🏅",
tier: formData[`rules[${i}].tier`] ?? "bronze",
trigger: {
type: formData[`rules[${i}].trigger.type`] ?? "event",
eventKind: formData[`rules[${i}].trigger.eventKind`] ?? "",
conditions: [],
},
};
// Collect conditions
const condIndices = new Set();
for (const key of Object.keys(formData)) {
const m = new RegExp(`^rules\\[${i}\\]\\.conditions\\[(\\d+)\\]\\.`).exec(key);
if (m) condIndices.add(Number(m[1]));
}
for (const ci of [...condIndices].sort((a, b) => a - b)) {
const field = formData[`rules[${i}].conditions[${ci}].field`] ?? "";
const operator = formData[`rules[${i}].conditions[${ci}].operator`] ?? "equals";
const valueRaw = formData[`rules[${i}].conditions[${ci}].value`] ?? "";
// Try to coerce value: numbers stay numbers, "true"/"false" become bool, else string.
let value = valueRaw;
if (valueRaw === "true") value = true;
else if (valueRaw === "false") value = false;
else if (typeof valueRaw === "string" && valueRaw !== "" && !isNaN(Number(valueRaw))) {
value = Number(valueRaw);
}
r.trigger.conditions.push({ field, operator, value });
}
// Collect rewards. Same indexed-path scheme as conditions.
r.rewards = [];
const rewardIndices = new Set();
for (const key of Object.keys(formData)) {
const m = new RegExp(`^rules\\[${i}\\]\\.rewards\\[(\\d+)\\]\\.`).exec(key);
if (m) rewardIndices.add(Number(m[1]));
}
for (const ri of [...rewardIndices].sort((a, b) => a - b)) {
const type = formData[`rules[${i}].rewards[${ri}].type`] ?? "item";
const quantityRaw = formData[`rules[${i}].rewards[${ri}].quantity`];
const quantity = Number(quantityRaw) || 1;
const reward = { type, quantity };
if (type === "item") {
reward.uuid = formData[`rules[${i}].rewards[${ri}].uuid`] ?? "";
} else {
reward.name = formData[`rules[${i}].rewards[${ri}].name`] ?? "";
}
r.rewards.push(reward);
}
out.push(r);
}
return out;
}
_addRule() {
const rules = getCustomRules();
rules.push(newRuleTemplate());
// Save immediately so the next render sees the new row.
setCustomRules(rules).then(() => this.render());
}
_deleteRule(idx) {
const rules = getCustomRules();
if (idx < 0 || idx >= rules.length) return;
const removed = rules.splice(idx, 1)[0];
setCustomRules(rules).then(() => {
ui.notifications?.info(`Removed custom achievement "${removed?.name ?? removed?.id}".`);
this.render();
});
}
_addCondition(ruleIdx) {
const rules = getCustomRules();
const rule = rules[ruleIdx];
if (!rule) return;
if (!rule.trigger) rule.trigger = { type: "event", eventKind: "hp-change", conditions: [] };
if (!Array.isArray(rule.trigger.conditions)) rule.trigger.conditions = [];
rule.trigger.conditions.push({ field: "event.isKill", operator: "equals", value: true });
setCustomRules(rules).then(() => this.render());
}
_deleteCondition(ruleIdx, condIdx) {
const rules = getCustomRules();
const rule = rules[ruleIdx];
if (!rule?.trigger?.conditions) return;
rule.trigger.conditions.splice(condIdx, 1);
setCustomRules(rules).then(() => this.render());
}
_testRule(idx) {
const rules = getCustomRules();
const rule = rules[idx];
if (!rule) return;
// Find the active encounter
const mod = game.modules.get(MODULE_ID);
const enc = mod?.api?.getActiveEncounter?.();
if (!enc) {
ui.notifications?.warn("No active encounter to test against. Start combat first.");
return;
}
const stats = enc.buildStats();
// Test against encounter-end context (most common use case).
const ctx = { stats, encounterId: stats.encounterId, career: stats };
const matches = testRule(rule, ctx);
ui.notifications?.info(
matches
? `Rule "${rule.name}" MATCHES the current encounter. ✓`
: `Rule "${rule.name}" does NOT match. Check your conditions.`
);
}
_showValidation() {
// Walk all rules in the current DOM and show inline validation.
const rules = getCustomRules();
const html = this.element;
for (let i = 0; i < rules.length; i++) {
const v = validateRule(rules[i]);
const errBox = html.find(`.bf-validation[data-idx="${i}"]`);
if (v.valid) {
errBox.html('<em style="color: green;">✓ valid</em>');
} else {
errBox.html(`<ul style="color: orange; margin: 0;">${v.errors.map((e) => `<li>${e}</li>`).join("")}</ul>`);
}
}
}
}
/**
* Open the GM UI. Safe to call from a chat macro or button.
*/
export function openCustomAchievementsApp() {
if (!game.user?.isGM) {
ui.notifications?.warn("Only GMs can manage custom achievements.");
return;
}
return new CustomAchievementsApp().render(true);
}