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.
324 lines
11 KiB
JavaScript
324 lines
11 KiB
JavaScript
// Custom achievement rules engine (slice 8).
|
|
//
|
|
// The GM can define custom achievements via the module config UI.
|
|
// Each custom achievement is a data object (no code). This module
|
|
// evaluates those data objects against combat events and career
|
|
// stats.
|
|
//
|
|
// ── Rule shape ──────────────────────────────────────────────────────
|
|
//
|
|
// {
|
|
// id: "boss-killer", // unique slug
|
|
// name: "Boss Killer", // display name
|
|
// description: "Defeat an enemy with 100+ HP.",
|
|
// icon: "💀",
|
|
// tier: "silver", // bronze/silver/gold/platinum
|
|
// trigger: {
|
|
// type: "event", // "event" | "encounter-end" | "career-update"
|
|
// eventKind: "hp-change", // required when type=event
|
|
// // All conditions must be true for the achievement to fire.
|
|
// conditions: [
|
|
// { field: "isKill", equals: true },
|
|
// { field: "targetActor.system.attributes.hp.max", gte: 100 }
|
|
// ]
|
|
// }
|
|
// }
|
|
//
|
|
// ── Operators ───────────────────────────────────────────────────────
|
|
//
|
|
// equals, notEquals — strict equality (===/!==)
|
|
// gt, gte, lt, lte — numeric comparison
|
|
// in, notIn — value must be in array
|
|
// contains — substring (for strings) OR has-property (for objects)
|
|
// exists, notExists — field present (or not) in the object
|
|
//
|
|
// ── Field paths ─────────────────────────────────────────────────────
|
|
//
|
|
// Dot-notation into the context object. For event triggers the
|
|
// context is {event, encounter, actor, targetActor}. For
|
|
// encounter-end and career-update the context is the relevant data.
|
|
//
|
|
// Examples:
|
|
// "event.isKill" (boolean)
|
|
// "targetActor.system.attributes.hp.max" (number)
|
|
// "event.weapon" (string)
|
|
// "actor.name" (string)
|
|
// "career.totalDamage" (number)
|
|
|
|
const MODULE_ID = "its-achievable";
|
|
|
|
export const OPERATORS = [
|
|
"equals", "notEquals",
|
|
"gt", "gte", "lt", "lte",
|
|
"in", "notIn",
|
|
"contains",
|
|
"exists", "notExists",
|
|
];
|
|
|
|
export const TRIGGER_TYPES = ["event", "encounter-end", "career-update"];
|
|
|
|
export const TIERS = ["bronze", "silver", "gold", "platinum"];
|
|
|
|
/**
|
|
* Resolve a dot-path field against an object. Returns the value at
|
|
* that path, or undefined if any segment is missing.
|
|
*
|
|
* Examples:
|
|
* getAtPath({a:{b:{c:42}}}, "a.b.c") → 42
|
|
* getAtPath({a:{b:null}}, "a.b.c") → undefined (null short-circuits)
|
|
* getAtPath(null, "a.b") → undefined
|
|
*/
|
|
export function getAtPath(obj, path) {
|
|
if (obj == null || typeof path !== "string") return undefined;
|
|
const parts = path.split(".");
|
|
let cur = obj;
|
|
for (const part of parts) {
|
|
if (cur == null) return undefined;
|
|
cur = cur[part];
|
|
}
|
|
return cur;
|
|
}
|
|
|
|
/**
|
|
* Evaluate a single condition against a context. Returns true if
|
|
* the condition matches.
|
|
*
|
|
* Operator semantics:
|
|
* equals/notEquals → strict ===/!==
|
|
* gt/gte/lt/lte → numeric; coerces both sides
|
|
* in/notIn → value must be (or not be) in an array
|
|
* contains → string: substring; array: includes; object: hasOwnProperty
|
|
* exists/notExists → getAtPath() !== undefined / === undefined
|
|
*/
|
|
export function evaluateCondition(condition, context) {
|
|
if (!condition || typeof condition !== "object") return false;
|
|
const { field, operator, value } = condition;
|
|
if (!field || !OPERATORS.includes(operator)) return false;
|
|
const actual = getAtPath(context, field);
|
|
switch (operator) {
|
|
case "equals":
|
|
return actual === value;
|
|
case "notEquals":
|
|
return actual !== value;
|
|
case "gt":
|
|
return Number(actual) > Number(value);
|
|
case "gte":
|
|
return Number(actual) >= Number(value);
|
|
case "lt":
|
|
return Number(actual) < Number(value);
|
|
case "lte":
|
|
return Number(actual) <= Number(value);
|
|
case "in":
|
|
return Array.isArray(value) && value.includes(actual);
|
|
case "notIn":
|
|
return Array.isArray(value) && !value.includes(actual);
|
|
case "contains":
|
|
if (typeof actual === "string") return actual.includes(String(value));
|
|
if (Array.isArray(actual)) return actual.includes(value);
|
|
if (actual && typeof actual === "object") return Object.prototype.hasOwnProperty.call(actual, value);
|
|
return false;
|
|
case "exists":
|
|
return actual !== undefined;
|
|
case "notExists":
|
|
return actual === undefined;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Evaluate a list of conditions against a context. ALL conditions
|
|
* must match (AND semantics). An empty conditions array means
|
|
* "always match" (so the trigger fires on every relevant event).
|
|
*/
|
|
export function evaluateConditions(conditions, context) {
|
|
if (!Array.isArray(conditions) || conditions.length === 0) return true;
|
|
return conditions.every((c) => evaluateCondition(c, context));
|
|
}
|
|
|
|
/**
|
|
* Build the context object for an event-based rule. Resolves the
|
|
* event's tokens (attacker/target) to live actor documents so that
|
|
* field paths like `targetActor.system.attributes.hp.max` work.
|
|
*/
|
|
export function buildEventContext(event, encounter) {
|
|
const ctx = { event };
|
|
if (!event) return ctx;
|
|
if (encounter) ctx.encounter = encounter;
|
|
// Resolve attacker actor
|
|
if (event.attackerId) {
|
|
ctx.actor = game.actors.get(event.attackerId);
|
|
ctx.actorId = event.attackerId;
|
|
} else if (event.attackerName) {
|
|
ctx.actor = game.actors.getName(event.attackerName);
|
|
ctx.actorName = event.attackerName;
|
|
}
|
|
// Resolve target actor
|
|
if (event.targetId) {
|
|
ctx.targetActor = game.actors.get(event.targetId);
|
|
ctx.targetId = event.targetId;
|
|
} else if (event.targetName) {
|
|
ctx.targetActor = game.actors.getName(event.targetName);
|
|
ctx.targetName = event.targetName;
|
|
} else if (event.targetActor) {
|
|
// Already passed in
|
|
ctx.targetActor = event.targetActor;
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
/**
|
|
* Build the context object for an encounter-end rule. The stats
|
|
* object contains combatants + encounter metadata.
|
|
*/
|
|
export function buildEncounterEndContext(stats) {
|
|
return {
|
|
career: stats,
|
|
stats,
|
|
encounterId: stats?.encounterId,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the context object for a career-update rule. The career
|
|
* object is the running per-PC career row.
|
|
*/
|
|
export function buildCareerUpdateContext(pcCareer) {
|
|
return {
|
|
career: pcCareer,
|
|
actorName: pcCareer?.actorName,
|
|
encounters: pcCareer?.encounters,
|
|
totalDamage: pcCareer?.totalDamage,
|
|
kills: pcCareer?.kills,
|
|
weapons: pcCareer?.weapons ?? {},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Test whether a custom achievement rule fires against a given
|
|
* context. Returns true if all conditions match.
|
|
*/
|
|
export function testRule(rule, context) {
|
|
if (!rule?.trigger) return false;
|
|
const { conditions = [] } = rule.trigger;
|
|
return evaluateConditions(conditions, context);
|
|
}
|
|
|
|
/**
|
|
* Validate a custom achievement rule. Returns {valid: bool, errors: []}.
|
|
* Used by the GM UI before save and by tests.
|
|
*/
|
|
export function validateRule(rule) {
|
|
const errors = [];
|
|
if (!rule || typeof rule !== "object") {
|
|
return { valid: false, errors: ["Rule must be an object"] };
|
|
}
|
|
if (!rule.id || typeof rule.id !== "string") errors.push("id is required (slug)");
|
|
if (!rule.name || typeof rule.name !== "string") errors.push("name is required");
|
|
if (!rule.trigger || typeof rule.trigger !== "object") {
|
|
errors.push("trigger is required");
|
|
return { valid: false, errors };
|
|
}
|
|
if (!TRIGGER_TYPES.includes(rule.trigger.type)) {
|
|
errors.push(`trigger.type must be one of: ${TRIGGER_TYPES.join(", ")}`);
|
|
}
|
|
if (rule.trigger.type === "event" && !rule.trigger.eventKind) {
|
|
errors.push("trigger.eventKind is required when trigger.type=event");
|
|
}
|
|
if (rule.tier && !TIERS.includes(rule.tier)) {
|
|
errors.push(`tier must be one of: ${TIERS.join(", ")}`);
|
|
}
|
|
if (rule.trigger.conditions && !Array.isArray(rule.trigger.conditions)) {
|
|
errors.push("trigger.conditions must be an array");
|
|
} else if (Array.isArray(rule.trigger.conditions)) {
|
|
rule.trigger.conditions.forEach((c, i) => {
|
|
if (!c.field) errors.push(`condition[${i}].field is required`);
|
|
if (!OPERATORS.includes(c.operator)) {
|
|
errors.push(`condition[${i}].operator must be one of: ${OPERATORS.join(", ")}`);
|
|
}
|
|
});
|
|
}
|
|
return { valid: errors.length === 0, errors };
|
|
}
|
|
|
|
/**
|
|
* Build a starter template for a new custom achievement.
|
|
* Provides sensible defaults so the GM only has to fill in what
|
|
* they care about.
|
|
*/
|
|
export function newRuleTemplate() {
|
|
return {
|
|
id: `custom-${Date.now().toString(36)}`,
|
|
name: "New Achievement",
|
|
description: "Describe when this should fire.",
|
|
icon: "🏅",
|
|
tier: "bronze",
|
|
trigger: {
|
|
type: "event",
|
|
eventKind: "hp-change",
|
|
conditions: [
|
|
{ field: "event.isKill", operator: "equals", value: true },
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Evaluate all custom achievement rules against an event. Returns
|
|
* an array of rule objects that fired.
|
|
*/
|
|
export function evaluateRulesForEvent(event, encounter, customRules = []) {
|
|
const ctx = buildEventContext(event, encounter);
|
|
return customRules.filter((r) => {
|
|
if (r.trigger?.type !== "event") return false;
|
|
if (r.trigger.eventKind !== event?.kind) return false;
|
|
return testRule(r, ctx);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Evaluate all custom achievement rules against encounter-end stats.
|
|
* One rule may fire for multiple PCs; returns array of {rule, actorKey}.
|
|
*/
|
|
export function evaluateRulesForEncounterEnd(stats, customRules = []) {
|
|
const out = [];
|
|
const ctx = buildEncounterEndContext(stats);
|
|
for (const rule of customRules) {
|
|
if (rule.trigger?.type !== "encounter-end") continue;
|
|
// First match wins (no per-actor targeting for custom rules yet)
|
|
if (testRule(rule, ctx)) out.push({ rule, actorKey: stats?.actorKey ?? null });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Evaluate all custom achievement rules against a PC career update.
|
|
* Returns array of rule objects that fired.
|
|
*/
|
|
export function evaluateRulesForCareerUpdate(pcCareer, customRules = []) {
|
|
const ctx = buildCareerUpdateContext(pcCareer);
|
|
return customRules.filter((r) => {
|
|
if (r.trigger?.type !== "career-update") return false;
|
|
return testRule(r, ctx);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get all custom rules from the world-scoped setting. Returns [] if
|
|
* the setting is missing or invalid.
|
|
*/
|
|
export function getCustomRules() {
|
|
try {
|
|
const v = game.settings.get(MODULE_ID, "customAchievements");
|
|
if (Array.isArray(v)) return v;
|
|
} catch (_) { /* setting not registered yet */ }
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Save the custom rules array to the setting.
|
|
*/
|
|
export async function setCustomRules(rules) {
|
|
await game.settings.set(MODULE_ID, "customAchievements", rules ?? []);
|
|
}
|