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.
1151 lines
41 KiB
JavaScript
1151 lines
41 KiB
JavaScript
// 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;
|
||
} |