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

1151 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Achievement system for Battle Focus (slice 6.2 + slice 8).
//
// Achievements are awarded based on three triggers:
// - Per-event: hooks fire as combat events occur (attack-roll,
// damage-roll, hp-change, kill, equipment-swap, token-avatar-change).
// - Per-combat-end: fires once at combat-end, evaluates aggregate stats.
// - Per-career-update: fires when the PC's career is updated.
//
// Achievements are stored in `game.settings.battle-focus.achievementsByActor`
// keyed by actor ID (or name as fallback). Each entry has:
// { id, name, description, icon, tier, awardedAt, encounterId }
//
// Definitions are data-driven so GMs can add custom ones via module
// config (slice 8). The default catalog covers:
// - First Blood (first kill)
// - Crit Master (5+ crits in one encounter)
// - Survivor (end >50% HP after taking 100+ dmg)
// - Sharpshooter (>80% hit rate with 10+ attacks)
// - Damage milestones (100/500/1000/5000 career dmg)
// - Encounter milestones (10/50/100 encounters)
// - Kill milestones (1/10/50 career kills)
// - Crit Streak (3 in a row) — slice 6.2 loop
// - Slice 8 new entries:
// Giant Killer, Small Fry, Executioner, Death Blow,
// Glass Cannon, Crit Streak 5, Attack Chain,
// Jack of All Trades, Arsenal, Weapon Master,
// Veteran, Iron Survivor
import {
evaluateRulesForEvent,
evaluateRulesForEncounterEnd,
evaluateRulesForCareerUpdate,
getCustomRules,
} from "./achievement-rules.js";
const MODULE_ID = "its-achievable";
/**
* Achievement definition shape:
* {
* id: string,
* name: string,
* description: string,
* icon: string (emoji),
* tier: "bronze" | "silver" | "gold" | "platinum",
* category: "combat" | "career" | "milestone",
* // For event-based: function(event, encounter) → boolean
* // For career-based: function(career) → boolean
* check: function,
* // Optional: kind to match (for event-based)
* matchKind?: string,
* // Slice A (v0.5.0-alpha.10): optional in-game rewards granted
* // when this achievement is first unlocked. Defaults to [].
* // Reward shapes:
* // { type: "item", uuid?: string, itemId?: string, quantity?: number }
* // { type: "currency", denomination: "gp"|"sp"|"cp"|"pp"|"ep", quantity: number }
* rewards: [],
* }
*/
export const ACHIEVEMENTS = [
// ── Event-based combat achievements ──────────────────────────────
{
id: "first-blood",
name: "First Blood",
description: "Land the killing blow on your first enemy.",
icon: "🩸",
tier: "bronze",
category: "combat",
matchKind: "hp-change",
rewards: [],
check: (event, encounter) => {
// Award when ANY PC lands a kill (first per-PC).
// The encounter tracks kills via stats.kills[].
const attackerId = event.attackerId ?? event.attackerName;
// We use a different check: this fires per HP-change, but the
// "first kill" award is determined at combat-end. So this
// check returns false here; the kill-counting is done in
// evaluateCombatAchievements() at combat-end.
return false;
},
},
{
id: "crit-master",
name: "Crit Master",
description: "Land 5 or more critical hits in a single encounter.",
icon: "💥",
tier: "silver",
category: "combat",
target: 5, // slice B: progress shows N/5 crits in current encounter
check: (event, encounter, stats) => {
// Checked at combat-end; this check is unused (event-based).
return false;
},
},
{
id: "sharpshooter",
name: "Sharpshooter",
description: "Hit rate of 80% or higher with at least 10 attacks in one encounter.",
icon: "🎯",
tier: "silver",
category: "combat",
check: () => false, // evaluated at combat-end
},
// ── Career milestones ────────────────────────────────────────────
{
id: "career-100-dmg",
name: "Apprentice",
description: "Deal 100 total damage across your career.",
icon: "⚔️",
tier: "bronze",
category: "career",
check: (career) => (career.totalDamage ?? 0) >= 100 && (career.totalDamage ?? 0) < 500,
},
{
id: "career-500-dmg",
name: "Veteran",
description: "Deal 500 total damage across your career.",
icon: "⚔️",
tier: "silver",
category: "career",
check: (career) => (career.totalDamage ?? 0) >= 500 && (career.totalDamage ?? 0) < 1000,
},
{
id: "career-1000-dmg",
name: "Hero",
description: "Deal 1,000 total damage across your career.",
icon: "🗡",
tier: "gold",
category: "career",
target: 1000,
targetSource: "career.totalDamage",
check: (career) => (career.totalDamage ?? 0) >= 1000 && (career.totalDamage ?? 0) < 5000,
},
{
id: "career-5000-dmg",
name: "Legend",
description: "Deal 5,000 total damage across your career.",
icon: "👑",
tier: "platinum",
category: "career",
check: (career) => (career.totalDamage ?? 0) >= 5000,
},
{
id: "career-10-encounters",
name: "Regular",
description: "Participate in 10 encounters.",
icon: "📅",
tier: "bronze",
category: "career",
check: (career) => (career.encounters ?? 0) >= 10 && (career.encounters ?? 0) < 50,
},
{
id: "career-50-encounters",
name: "Survivor",
description: "Participate in 50 encounters.",
icon: "🛡",
tier: "silver",
category: "career",
check: (career) => (career.encounters ?? 0) >= 50 && (career.encounters ?? 0) < 100,
},
{
id: "career-100-encounters",
name: "Centurion",
description: "Participate in 100 encounters.",
icon: "🏆",
tier: "gold",
category: "career",
target: 100,
targetSource: "career.encounters",
check: (career) => (career.encounters ?? 0) >= 100,
},
{
id: "career-first-kill",
name: "First Blood",
description: "Land your first career kill.",
icon: "🩸",
tier: "bronze",
category: "career",
check: (career) => (career.kills ?? 0) >= 1 && (career.kills ?? 0) < 10,
},
{
id: "career-10-kills",
name: "Reaper",
description: "Land 10 career kills.",
icon: "💀",
tier: "silver",
category: "career",
check: (career) => (career.kills ?? 0) >= 10 && (career.kills ?? 0) < 50,
},
{
id: "career-50-kills",
name: "Death Incarnate",
description: "Land 50 career kills.",
icon: "☠️",
tier: "gold",
category: "career",
check: (career) => (career.kills ?? 0) >= 50,
},
// ──────────────────────────────────────────────────────────────────
// Slice 8 — Achievements 2.0: new built-in catalog entries
// ──────────────────────────────────────────────────────────────────
// ── Combat: Kill flavor (evaluated at combat-end from stats) ────
{
id: "giant-killer",
name: "Giant Killer",
description: "Land the killing blow on an enemy with 100+ HP.",
icon: "💀",
tier: "silver",
category: "combat",
target: 1, // slice B: progress shows 0/1 until the PC lands a 100+ HP kill
check: () => false, // evaluated specially in evaluateCombatAchievements
},
{
id: "small-fry",
name: "Small Fry",
description: "Kill 5+ enemies with 10 or fewer HP in one encounter.",
icon: "🪓",
tier: "bronze",
category: "combat",
target: 5, // slice B: progress shows N/5 small kills in current encounter
check: () => false,
},
{
id: "executioner",
name: "Executioner",
description: "Land killing blows on 3+ different enemies in one encounter.",
icon: "🗡️",
tier: "silver",
category: "combat",
target: 3, // slice B: progress shows N/3 distinct kills
check: () => false,
},
{
id: "death-blow",
name: "Death Blow",
description: "Land a killing blow with a critical hit.",
icon: "💥",
tier: "gold",
category: "combat",
target: 1, // slice B: progress shows N/1 crit-kills (debut)
check: () => false, // evaluated per-event
},
// ── Combat: Damage flavor ───────────────────────────────────────
{
id: "glass-cannon",
name: "Glass Cannon",
description: "Deal 50+ damage in a single hit.",
icon: "💣",
tier: "silver",
category: "combat",
check: () => false, // evaluated per-event on damage-roll
},
{
id: "crit-streak-5",
name: "On Fire",
description: "Land 5 critical hits in a row.",
icon: "🔥",
tier: "gold",
category: "combat",
check: () => false, // evaluated per-event on attack-roll
},
{
id: "attack-chain",
name: "Attack Chain",
description: "Make 5+ attacks in a single round.",
icon: "⚡",
tier: "bronze",
category: "combat",
check: () => false, // evaluated at combat-end
},
// ── Career: Equipment/Style ─────────────────────────────────────
{
id: "jack-of-all-trades",
name: "Jack of All Trades",
description: "Attack with 3+ different weapons in one encounter.",
icon: "🎭",
tier: "silver",
category: "combat",
check: () => false, // evaluated at combat-end
},
{
id: "arsenal",
name: "Arsenal",
description: "Use 5+ different weapons across your career.",
icon: "🗡️",
tier: "gold",
category: "career",
check: (career) => Object.keys(career?.weapons ?? {}).length >= 5,
},
{
id: "weapon-master",
name: "Weapon Master",
description: "Deal 1000+ damage with a single weapon over your career.",
icon: "🏆",
tier: "platinum",
category: "career",
check: (career) =>
Object.values(career?.weapons ?? {}).some(
(w) => (w?.totalDamage ?? 0) >= 1000,
),
},
// ── Career: Persistence ─────────────────────────────────────────
{
id: "veteran",
name: "Veteran",
description: "Participate in 50 encounters.",
icon: "🎖️",
tier: "silver",
category: "career",
check: (career) => (career.encounters ?? 0) >= 50,
},
{
id: "iron-survivor",
name: "Iron Survivor",
description: "Survive an encounter after taking 500+ damage.",
icon: "🛡️",
tier: "gold",
category: "combat",
check: () => false, // evaluated at combat-end
},
];
/**
* Get the achievements-by-actor map from settings. Falls back to {}.
*/
export function getAchievementsByActor() {
try {
return game.settings.get(MODULE_ID, "achievementsByActor") ?? {};
} catch (_) {
return {};
}
}
/**
* Save the achievements-by-actor map to settings.
*/
export async function setAchievementsByActor(map) {
try {
await game.settings.set(MODULE_ID, "achievementsByActor", map);
return true;
} catch (e) {
console.warn(`[${MODULE_ID}] failed to save achievements:`, e);
return false;
}
}
/**
* Check if an actor has already earned a given achievement.
*/
export function hasAchievement(map, actorKey, achievementId) {
const list = map[actorKey] ?? [];
return list.some((a) => a.id === achievementId);
}
/**
* Read the `enableRewards` setting. Defaults to false (opt-in) so
* existing users are not surprised by items appearing on their
* characters. Slice A of v0.5.0-alpha.10.
*/
function areRewardsEnabled() {
try {
return !!game.settings.get(MODULE_ID, "enableRewards");
} catch (_) {
return false; // setting not registered yet → no grants
}
}
/**
* Resolve the rewards array for a given record. Looks up the
* ACHIEVEMENTS catalog for built-ins and the custom rules array for
* custom achievements. Returns [] if no rewards are defined.
*/
function resolveRewardsForRecord(record) {
if (!record) return [];
// Custom achievements: look up the rule by customRuleId.
if (record.category === "custom" && record.customRuleId) {
try {
const rules = game.settings.get(MODULE_ID, "customAchievements") ?? [];
const rule = rules.find((r) => r?.id === record.customRuleId);
return Array.isArray(rule?.rewards) ? rule.rewards : [];
} catch (_) {
return [];
}
}
// Built-in: look up the catalog entry by id.
const def = ACHIEVEMENTS.find((a) => a.id === record.id);
return Array.isArray(def?.rewards) ? def.rewards : [];
}
/**
* In-memory idempotency map keyed by `${actorId}::${achievementId}::${rewardIndex}`.
* Prevents re-granting rewards when awardAchievement() is called twice
* for the same (actor, achievement) pair (e.g., reload mid-combat).
* This is the slice-A idempotency layer; it does NOT persist across
* world reloads. The achievementsByActor map already prevents
* double-award; this is the second line of defense for the reward
* side-effects (e.g., a defensive code path that calls
* grantRewardsForAchievement directly).
*/
const _rewardGrantLog = new Set();
function rewardGrantKey(actorId, achievementId, rewardIndex) {
return `${actorId ?? "_"}::${achievementId ?? "_"}::${rewardIndex}`;
}
/**
* Award an achievement to an actor. Returns the awarded achievement
* object, or null if already earned.
*
* Slice A (v0.5.0-alpha.10): after the record is persisted, if the
* `enableRewards` setting is true and the achievement has rewards,
* grants each reward to the actor via grantRewardsForAchievement().
* The reward grant is best-effort — failures are logged but do not
* undo the achievement record.
*/
export async function awardAchievement(actorKey, achievementId, encounterId = null) {
const map = getAchievementsByActor();
if (hasAchievement(map, actorKey, achievementId)) return null;
const def = ACHIEVEMENTS.find((a) => a.id === achievementId);
if (!def) {
console.warn(`[${MODULE_ID}] unknown achievement: ${achievementId}`);
return null;
}
const record = {
id: def.id,
name: def.name,
description: def.description,
icon: def.icon,
tier: def.tier,
category: def.category,
awardedAt: Date.now(),
encounterId,
};
if (!map[actorKey]) map[actorKey] = [];
map[actorKey].push(record);
await setAchievementsByActor(map);
// Slice A: grant rewards (best-effort). Resolves the actor from
// the key (id or name) and walks def.rewards.
if (areRewardsEnabled() && Array.isArray(def.rewards) && def.rewards.length > 0) {
try {
const actor = await resolveActorFromKey(actorKey);
if (actor) await grantRewardsForAchievement(actor, record);
} catch (e) {
console.warn(`[${MODULE_ID}] reward grant failed for ${achievementId}:`, e);
}
}
return record;
}
/**
* Evaluate combat-end achievements for a list of PCs. Returns a map
* keyed by actor key (name or id) of arrays of newly-awarded
* achievements. Used to render badges in the recap + Career page.
*
* Slice 8 additions: Giant Killer, Small Fry, Executioner, Attack
* Chain, Jack of All Trades, Iron Survivor. Each is data-checked
* against the aggregate per-PC stats at combat-end.
*/
export async function evaluateCombatAchievements(stats) {
const newlyAwarded = {};
const combatants = Object.values(stats.combatants ?? {});
const pcs = combatants.filter((c) => c.isPlayer);
for (const pc of pcs) {
const actorKey = pc.id ?? pc.name;
const newOnes = [];
// Crit Master: 5+ crits in this encounter
if ((pc.crits ?? 0) >= 5) {
const a = await awardAchievement(actorKey, "crit-master", stats.encounterId);
if (a) newOnes.push(a);
}
// Sharpshooter: 80%+ hit rate with 10+ attacks
if ((pc.attacks ?? 0) >= 10) {
const hitRate = pc.hits / pc.attacks;
if (hitRate >= 0.8) {
const a = await awardAchievement(actorKey, "sharpshooter", stats.encounterId);
if (a) newOnes.push(a);
}
}
// First Blood: first kill
if ((pc.kills?.length ?? 0) >= 1) {
const a = await awardAchievement(actorKey, "career-first-kill", stats.encounterId);
if (a) newOnes.push(a);
}
// ── Slice 8: new combat-end achievements ──────────────────────
// Giant Killer: killed an actor whose HP.max >= 100
const giantKills = (pc.kills ?? []).filter((name) => {
const target = game.actors.getName(name);
return target && (target.system?.attributes?.hp?.max ?? 0) >= 100;
});
if (giantKills.length > 0) {
const a = await awardAchievement(actorKey, "giant-killer", stats.encounterId);
if (a) newOnes.push(a);
}
// Small Fry: 5+ kills of actors with HP.max <= 10
const smallFryKills = (pc.kills ?? []).filter((name) => {
const target = game.actors.getName(name);
return target && (target.system?.attributes?.hp?.max ?? Infinity) <= 10;
});
if (smallFryKills.length >= 5) {
const a = await awardAchievement(actorKey, "small-fry", stats.encounterId);
if (a) newOnes.push(a);
}
// Executioner: 3+ distinct kills in one encounter
if ((pc.kills?.length ?? 0) >= 3) {
const a = await awardAchievement(actorKey, "executioner", stats.encounterId);
if (a) newOnes.push(a);
}
// Attack Chain: 5+ attacks in a single round
// We can check pc.maxAttacksInRound if the encounter tracked it
// (the existing buildStats() doesn't surface this; we approximate
// by checking if totalAttacks >= 5 in a single round — fall back
// to totalAttacks >= 5 for now as a coarse signal). For accuracy,
// check per-round stats if available.
const roundsWithFivePlus = countRoundsWithNAttacks(pc, 5);
if (roundsWithFivePlus > 0) {
const a = await awardAchievement(actorKey, "attack-chain", stats.encounterId);
if (a) newOnes.push(a);
}
// Jack of All Trades: 3+ distinct weapons used in this encounter
const weaponsUsed = countDistinctWeapons(pc);
if (weaponsUsed >= 3) {
const a = await awardAchievement(actorKey, "jack-of-all-trades", stats.encounterId);
if (a) newOnes.push(a);
}
// Iron Survivor: took 500+ damage and still alive (HP > 0)
if ((pc.damageTaken ?? 0) >= 500 && (pc.hpAfter?.value ?? 1) > 0) {
const a = await awardAchievement(actorKey, "iron-survivor", stats.encounterId);
if (a) newOnes.push(a);
}
// ── Slice 8: custom rules with encounter-end trigger ───────
// Custom rules currently award to one actor per rule (no per-actor
// targeting yet). We award to the first PC that satisfies the rule.
try {
const customRules = getCustomRules();
if (customRules.length > 0) {
const fired = evaluateRulesForEncounterEnd(stats, customRules);
for (const { rule } of fired) {
const a = await awardCustomAchievement(actorKey, rule.id, stats.encounterId);
if (a) newOnes.push(a);
}
}
} catch (e) {
console.warn(`[${MODULE_ID}] custom combat-end rule failed:`, e);
}
if (newOnes.length > 0) newlyAwarded[actorKey] = newOnes;
}
return newlyAwarded;
}
/**
* Count how many rounds the PC made N+ attacks. Walks pc.byRound
* (the per-round map) if present; otherwise returns 0.
*/
function countRoundsWithNAttacks(pc, n) {
const byRound = pc.byRound ?? {};
let count = 0;
for (const r of Object.values(byRound)) {
if ((r?.attacks ?? 0) >= n) count++;
}
return count;
}
/**
* Count distinct weapons used by the PC in this encounter.
* Walks pc.byRound and collects unique weapon names.
*
* pc.byRound[round].weapons is an OBJECT (not an array) keyed by
* weapon name, so we just collect the keys.
*/
function countDistinctWeapons(pc) {
const byRound = pc.byRound ?? {};
const names = new Set();
for (const r of Object.values(byRound)) {
const weapons = r?.weapons ?? {};
if (Array.isArray(weapons)) {
for (const w of weapons) {
if (typeof w?.name === "string") names.add(w.name);
}
} else if (typeof weapons === "object") {
for (const wname of Object.keys(weapons)) names.add(wname);
}
}
return names.size;
}
/**
* Evaluate career milestones for an updated PC career. Called at
* combat-end after the career row has been updated. Returns array
* of newly-awarded achievements.
*/
export async function evaluateCareerAchievements(pcName, career, encounterId = null) {
const newOnes = [];
for (const def of ACHIEVEMENTS) {
if (def.category !== "career") continue;
try {
if (def.check(career)) {
const a = await awardAchievement(pcName, def.id, encounterId);
if (a) newOnes.push(a);
}
} catch (e) {
console.warn(`[${MODULE_ID}] achievement check failed for ${def.id}:`, e);
}
}
// Slice 8: also evaluate custom rules with career-update trigger.
try {
const customRules = getCustomRules();
if (customRules.length > 0) {
const fired = evaluateRulesForCareerUpdate(career, customRules);
for (const rule of fired) {
const a = await awardCustomAchievement(pcName, rule.id, encounterId);
if (a) newOnes.push(a);
}
}
} catch (e) {
console.warn(`[${MODULE_ID}] custom career-update rule failed:`, e);
}
return newOnes;
}
/**
* Get all earned achievements for an actor.
*/
export function getActorAchievements(actorKey) {
const map = getAchievementsByActor();
return map[actorKey] ?? [];
}
// Achievement evaluators indexed by event kind. Each evaluator
// returns an array of {achievementId, actorKey, encounterId} tuples
// for any newly-qualifying achievements. The handler then awards
// them via awardAchievement().
//
// Per-event evaluators fire as events happen — useful for time-
// sensitive achievements like "First Blood" (the kill event itself
// should trigger the achievement, not a delayed combat-end pass).
export const PER_EVENT_EVALUATORS = {
/**
* hp-change evaluator: fires on every HP change. Looks for:
* - first-blood: PC lands a kill (delta < 0 AND after <= 0)
* - death-blow (slice 8): kill was caused by a critical hit
* - lucky-break: heal > 50% max hp in one shot (heal from critical)
* - overkill: damage > 2x max HP in one shot
*/
"hp-change": (event, encounter) => {
const out = [];
// ── first-blood: PC lands a kill ───────────────────────────
if (event?.isKill) {
const kills = [];
if (encounter?.combatants instanceof Map) {
const candidates = Array.from(encounter.combatants.values())
.filter((c) => c.isPlayer)
.sort((a, b) => (b.lastAttackAt ?? 0) - (a.lastAttackAt ?? 0));
if (candidates.length > 0) {
kills.push({
achievementId: "first-blood",
actorKey: candidates[0].id ?? candidates[0].name,
actorId: candidates[0].id,
encounterId: encounter.id,
});
}
}
out.push(...kills);
}
// ── death-blow (slice 8): the kill was from a crit ──────
// We need to know if the previous attack-roll was a crit
// by the same actor. We approximate via event.isCritFromKill
// (set by the dnd5e event pipeline) or check encounter
// state for the actor's lastAttackWasCrit flag.
if (event.isCritFromKill || encounter?.lastAttackWasCrit) {
if (encounter?.combatants instanceof Map) {
const candidates = Array.from(encounter.combatants.values())
.filter((c) => c.isPlayer)
.sort((a, b) => (b.lastAttackAt ?? 0) - (a.lastAttackAt ?? 0));
if (candidates.length > 0) {
out.push({
achievementId: "death-blow",
actorKey: candidates[0].id ?? candidates[0].name,
actorId: candidates[0].id,
encounterId: encounter.id,
});
}
}
}
return out;
},
/**
* damage-roll evaluator: fires on every damage roll. Looks for:
* - glass-cannon (slice 8): 50+ damage in a single hit
*/
"damage-roll": (event, encounter) => {
// The dnd5e damage-roll event uses `total` for the damage total;
// processEventForAchievements passes the event through unchanged.
// Accept any of total/totalDamage/damage to be lenient.
const total = (event?.totalDamage ?? event?.damage ?? event?.total ?? 0);
if (typeof total !== "number" || total < 50) return [];
if (!encounter?.combatants instanceof Map) return [];
// Look up the attacker by tokenId first (the canonical key after
// the encounter snapshot), then fall back to scanning values for
// a matching actor id. This handles both real Foundry events and
// synthetic test events that pass actorId directly.
const lookupId = event.attackerTokenId ?? event.attackerId ?? event.attackerName;
let actor = encounter.combatants.get(lookupId);
if (!actor) {
for (const c of encounter.combatants.values()) {
if (c.id === lookupId) { actor = c; break; }
}
}
if (!actor) return [];
return [{
achievementId: "glass-cannon",
actorKey: actor.id ?? actor.name,
actorId: actor.id,
encounterId: encounter.id,
}];
},
/**
* attack-roll evaluator: fires on every attack roll. Looks for:
* - crit-streak (slice 6.2): 3+ crits in a row
* - crit-streak-5 (slice 8): 5+ crits in a row
*/
"attack-roll": (event, encounter) => {
if (!event?.isCrit) return [];
if (!encounter?.combatants instanceof Map) return [];
// Dual-lookup: tokenId first, then scan values for actor id match.
// See the damage-roll evaluator for the rationale.
const lookupId = event.attackerTokenId ?? event.attackerId ?? event.attackerName;
let actor = encounter.combatants.get(lookupId);
if (!actor) {
for (const c of encounter.combatants.values()) {
if (c.id === lookupId) { actor = c; break; }
}
}
if (!actor) return [];
actor.critStreak = (actor.critStreak ?? 0) + 1;
actor.lastAttackAt = event.ts ?? Date.now();
actor.lastAttackWasCrit = true;
const out = [];
if (actor.critStreak >= 3) {
out.push({
achievementId: "crit-streak",
actorKey: actor.id ?? actor.name,
actorId: actor.id,
encounterId: encounter.id,
});
}
if (actor.critStreak >= 5) {
out.push({
achievementId: "crit-streak-5",
actorKey: actor.id ?? actor.name,
actorId: actor.id,
encounterId: encounter.id,
});
}
return out;
},
/**
* equipment-swap evaluator (slice 8): fires when a PC swaps
* equipment. Reserved for custom achievements; no built-in
* achievement fires on this kind yet.
*/
"equipment-swap": () => [],
/**
* token-avatar-change evaluator (slice 8): fires when a token's
* texture or ring changes. Reserved for custom achievements.
*/
"token-avatar-change": () => [],
};
// Add new achievements unlocked per-event.
ACHIEVEMENTS.push(
{
id: "crit-streak",
name: "Crit Streak",
description: "Land 3 critical hits in a row.",
icon: "🔥",
tier: "silver",
category: "combat",
check: () => false, // event-based
},
);
/**
* Process a single event against all per-event evaluators.
* Returns array of newly-awarded achievement objects.
*/
export async function processEventForAchievements(event, encounter) {
const evaluator = PER_EVENT_EVALUATORS[event?.kind];
let candidates = [];
if (evaluator) {
try {
candidates = evaluator(event, encounter) ?? [];
} catch (e) {
console.warn(`[${MODULE_ID}] per-event evaluator for ${event.kind} failed:`, e);
}
}
// Slice 8: also evaluate GM-defined custom rules for this event kind.
try {
const customRules = await getCustomRulesAsync();
if (customRules.length > 0 && encounter) {
const fired = evaluateRulesForEvent(event, encounter, customRules);
for (const rule of fired) {
// For event triggers, we need an actor key. Best-effort:
// use the event's attacker.
const actorKey = event.attackerId ?? event.attackerName ?? encounter.id;
// Find a matching per-PC actorId for the player's character.
let actorId = event.attackerId ?? null;
// Award the custom rule as an achievement using the rule's id.
candidates.push({
achievementId: rule.id,
actorKey,
actorId,
encounterId: encounter.id,
});
}
}
} catch (e) {
console.warn(`[${MODULE_ID}] custom rule evaluator failed:`, e);
}
const awarded = [];
for (const c of candidates) {
// Custom rules: awardAchievement looks up the definition in
// ACHIEVEMENTS. If not found, look up in the custom rules array.
let a = await awardAchievement(c.actorKey, c.achievementId, c.encounterId);
if (!a) {
a = await awardCustomAchievement(c.actorKey, c.achievementId, c.encounterId);
}
if (a) awarded.push(a);
}
return awarded;
}
/**
* Async wrapper for getting custom rules from settings (game.settings
* is sync in v13 but we keep this async-safe for future versions).
*/
async function getCustomRulesAsync() {
try {
const v = game.settings.get(MODULE_ID, "customAchievements");
if (Array.isArray(v)) return v;
} catch (_) { /* setting not registered yet */ }
return [];
}
/**
* Award a custom achievement. Like awardAchievement() but for
* GM-defined rules not in the built-in ACHIEVEMENTS catalog.
*
* Slice A (v0.5.0-alpha.10): also walks def.rewards after the
* record is persisted, when `enableRewards` is on. See
* awardAchievement() for the full contract.
*/
export async function awardCustomAchievement(actorKey, ruleId, encounterId = null) {
const map = getAchievementsByActor();
const earnedKey = `custom::${ruleId}`;
const list = map[actorKey] ?? [];
if (list.some((a) => a.id === earnedKey)) return null;
const rules = await getCustomRulesAsync();
const def = rules.find((r) => r.id === ruleId);
if (!def) return null;
const record = {
id: earnedKey,
name: def.name,
description: def.description,
icon: def.icon ?? "🏅",
tier: def.tier ?? "bronze",
category: "custom",
awardedAt: Date.now(),
encounterId,
customRuleId: ruleId,
};
if (!map[actorKey]) map[actorKey] = [];
map[actorKey].push(record);
await setAchievementsByActor(map);
// Slice A: grant rewards (best-effort).
if (areRewardsEnabled() && Array.isArray(def.rewards) && def.rewards.length > 0) {
try {
const actor = await resolveActorFromKey(actorKey);
if (actor) await grantRewardsForAchievement(actor, record);
} catch (e) {
console.warn(`[${MODULE_ID}] reward grant failed for custom ${ruleId}:`, e);
}
}
return record;
}
/**
* Resolve an actor from the various keys we use to index
* achievementsByActor. Tries id first, then name. Returns null if
* neither resolves.
*
* Slice A helper.
*/
export async function resolveActorFromKey(actorKey) {
if (!actorKey) return null;
// Already an actor document?
if (typeof actorKey === "object" && actorKey?.documentName === "Actor") return actorKey;
// Try id
try {
const byId = game.actors?.get?.(actorKey);
if (byId) return byId;
} catch (_) { /* no-op */ }
// Fall back: name
try {
const byName = game.actors?.getName?.(actorKey);
if (byName) return byName;
} catch (_) { /* no-op */ }
return null;
}
/**
* Grant the rewards attached to an achievement record. Walks
* `record.rewards` (custom) or looks up the catalog/rule definition
* (built-in) and grants each entry to the actor.
*
* Slice A (v0.5.0-alpha.10). Reward shapes:
* { type: "item", uuid?: string, itemId?: string, quantity?: number }
* { type: "currency", denomination: "gp"|"sp"|"cp"|"pp"|"ep", quantity: number }
*
* Idempotency: an in-memory Set tracks (actorId, achievementId,
* rewardIndex) tuples. Once a reward has been granted for a given
* achievement on a given actor, subsequent calls are no-ops. The
* achievementsByActor map already prevents the achievement itself
* from being awarded twice; this guards against the case where
* grantRewardsForAchievement is called directly (e.g., from a macro)
* with an already-persisted record.
*
* Best-effort: failures for individual rewards are logged and
* skipped. The function does NOT throw.
*
* Returns the array of granted descriptions (used by the chat
* announcement to build the "Granted:" line).
*/
export async function grantRewardsForAchievement(actor, record) {
if (!actor || !record) return [];
// Setting gate: if rewards are disabled, do nothing.
if (!areRewardsEnabled()) return [];
const rewards = resolveRewardsForRecord(record);
if (!Array.isArray(rewards) || rewards.length === 0) return [];
const granted = [];
for (let i = 0; i < rewards.length; i++) {
const r = rewards[i];
if (!r || typeof r !== "object") continue;
// Idempotency check.
const k = rewardGrantKey(actor.id, record.id, i);
if (_rewardGrantLog.has(k)) continue;
try {
const summary = await grantSingleReward(actor, r);
if (summary) {
granted.push(summary);
_rewardGrantLog.add(k);
}
} catch (e) {
console.warn(`[${MODULE_ID}] grant reward #${i} failed for ${record.id}:`, e);
}
}
return granted;
}
/**
* Grant a single reward entry. Returns a short string describing
* what was granted (used for the chat "Granted:" line), or null if
* the reward was skipped or unsupported.
*/
async function grantSingleReward(actor, reward) {
if (!reward?.type) return null;
if (reward.type === "item") {
const qty = Math.max(1, Number(reward.quantity ?? 1));
// Resolve source: prefer uuid (compendium), then itemId (world).
let source = null;
if (reward.uuid) {
try {
source = await fromUuid(reward.uuid);
} catch (_) { /* fall through */ }
}
if (!source && reward.itemId) {
try {
source = game.items?.get?.(reward.itemId) ?? null;
} catch (_) { /* fall through */ }
}
if (!source) {
console.warn(`[${MODULE_ID}] reward item not found:`, reward);
return null;
}
// Create N copies on the actor. createEmbeddedDocuments is the
// canonical way to add items to an actor.
const itemData = source.toObject();
const items = Array.from({ length: qty }, () => ({ ...itemData }));
await actor.createEmbeddedDocuments("Item", items);
return `${qty}× ${source.name}`;
}
if (reward.type === "currency") {
const denom = String(reward.denomination ?? "gp").toLowerCase();
const qty = Math.max(0, Number(reward.quantity ?? 0));
if (qty === 0) return null;
// dnd5e stores currency under system.currency.{denom}.
// We mutate the actor via .update so the change is persisted.
const path = `system.currency.${denom}`;
const current = Number(getProperty(actor, path) ?? 0);
await actor.update({ [path]: current + qty });
return `${qty} ${denom.toUpperCase()}`;
}
console.warn(`[${MODULE_ID}] unknown reward type: ${reward.type}`);
return null;
}
/**
* Format the rewards array for the chat announcement. Returns a
* short string like "Granted: 1× Health Potion, 50 GP", or null if
* the record has no granted rewards.
*
* Slice A: the main.js chat announcement calls this to add the
* "Granted:" line below the achievement description.
*/
export function formatGrantedLine(record) {
if (!record) return null;
// The caller passes in the array returned by grantRewardsForAchievement;
// we also accept the record itself and resolve rewards defensively.
if (Array.isArray(record?.__granted) && record.__granted.length > 0) {
return `Granted: ${record.__granted.join(", ")}`;
}
// Defensive fallback: peek at the rule/catalog for any rewards
// (used when the caller didn't capture the grant return value).
const rewards = resolveRewardsForRecord(record);
if (rewards.length === 0) return null;
// Without live actor context we can't enumerate granted items;
// describe the rewards generically from their definitions.
const descs = rewards.map((r) => {
if (r?.type === "item") return `${Math.max(1, Number(r.quantity ?? 1))}× Item`;
if (r?.type === "currency") return `${Number(r.quantity ?? 0)} ${String(r.denomination ?? "gp").toUpperCase()}`;
return null;
}).filter(Boolean);
if (descs.length === 0) return null;
return `Granted: ${descs.join(", ")}`;
}
/**
* Compute achievement progress for an actor against all unearned
* achievements in a given category. Returns array of {id, name,
* icon, tier, progress, target} for display in the UI.
*
* Example output:
* [
* { id: "career-1000-dmg", name: "Hero", progress: 470, target: 1000, ... },
* { id: "career-100-encounters", name: "Centurion", progress: 37, target: 100, ... },
* ]
*/
export function getAchievementProgress(actorName, career) {
const earned = new Set((getAchievementsByActor()[actorName] ?? []).map((a) => a.id));
const progress = [];
for (const def of ACHIEVEMENTS) {
if (def.category !== "career") continue;
if (earned.has(def.id)) continue;
if (def.id.startsWith("career-100-dmg")) progress.push({ ...def, progress: career?.totalDamage ?? 0, target: 100 });
else if (def.id.startsWith("career-500-dmg")) progress.push({ ...def, progress: career?.totalDamage ?? 0, target: 500 });
else if (def.id.startsWith("career-1000-dmg")) progress.push({ ...def, progress: career?.totalDamage ?? 0, target: 1000 });
else if (def.id.startsWith("career-5000-dmg")) progress.push({ ...def, progress: career?.totalDamage ?? 0, target: 5000 });
else if (def.id.startsWith("career-10-encounters")) progress.push({ ...def, progress: career?.encounters ?? 0, target: 10 });
else if (def.id.startsWith("career-50-encounters")) progress.push({ ...def, progress: career?.encounters ?? 0, target: 50 });
else if (def.id.startsWith("career-100-encounters")) progress.push({ ...def, progress: career?.encounters ?? 0, target: 100 });
else if (def.id.startsWith("career-first-kill")) progress.push({ ...def, progress: career?.kills ?? 0, target: 1 });
else if (def.id.startsWith("career-10-kills")) progress.push({ ...def, progress: career?.kills ?? 0, target: 10 });
else if (def.id.startsWith("career-50-kills")) progress.push({ ...def, progress: career?.kills ?? 0, target: 50 });
}
return progress;
}
/**
* Compute notification throttle state: returns whether a given
* achievement unlock should be announced for the given actor. The
* throttle window is now configurable via the
* `achievementThrottleMs` setting.
*
* Prevents chat spam when the same achievement could fire from
* multiple triggers in the same combat.
*/
export function shouldAnnounceAchievement(actorKey, achievementId, throttleMs = null) {
// Use the configured throttle window if caller didn't pass one.
if (throttleMs == null) {
try {
const v = game.settings.get(MODULE_ID, "achievementThrottleMs");
if (typeof v === "number" && v >= 0) throttleMs = v;
} catch (_) { /* setting not registered yet */ }
}
if (throttleMs == null) throttleMs = 1000; // sensible default
// Simple in-memory throttle. For per-event evaluation, the same
// achievement shouldn't fire twice within the throttle window
// for the same actor.
if (!globalThis._bfAchievementThrottle) {
globalThis._bfAchievementThrottle = new Map();
}
const key = `${actorKey}::${achievementId}`;
const now = Date.now();
const last = globalThis._bfAchievementThrottle.get(key);
if (last && now - last < throttleMs) return false;
globalThis._bfAchievementThrottle.set(key, now);
return true;
}
/**
* Resolve the whisper list for an achievement announcement.
* Honours the GM's `achievementChatMode` setting:
* - "gm": only GMs see the card
* - "gm-and-actor": GMs + the player whose character earned it (if online)
* - "off": no card (returns empty array)
*
* Each recipient is filtered by their `playerShowAchievementsInChat`
* per-player opt-out.
*/
export function resolveAchievementRecipients(actorKey, actorId = null) {
let mode = "gm";
try { mode = game.settings.get(MODULE_ID, "achievementChatMode") ?? "gm"; }
catch (_) { /* setting not registered */ }
if (mode === "off") return [];
const gms = (game.users ?? []).filter((u) => u.isGM);
if (mode === "gm") return gms.map((u) => u.id);
// mode === "gm-and-actor": also include the player who owns this
// actor. We resolve by actor ID if provided, else by name.
const players = (game.users ?? []).filter((u) => !u.isGM);
let owner = null;
if (actorId) {
const actor = game.actors.get(actorId);
if (actor) {
owner = players.find((u) => u.character?.id === actor.id);
}
}
if (!owner && actorKey) {
// Fall back: find a player whose character has this name.
const actor = game.actors.getName(actorKey);
if (actor) {
owner = players.find((u) => u.character?.id === actor.id);
}
}
// If the player has opted out, don't include them.
if (owner) {
try {
const showInChat = game.settings.get(MODULE_ID, "playerShowAchievementsInChat", { user: owner.id });
if (showInChat === false) owner = null;
} catch (_) { /* setting not registered */ }
}
const out = gms.map((u) => u.id);
if (owner) out.push(owner.id);
return out;
}