Files
Its-Achievable/scripts/achievement-rules.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

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 ?? []);
}