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.
271 lines
9.2 KiB
JavaScript
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);
|
|
}
|