Skip to content

api stats

github-actions[bot] edited this page Sep 16, 2026 · 9 revisions

Blacksmith Stats API

Audience: Developers integrating with Blacksmith and leveraging the exposed API.

This document serves as the authoritative reference for the Coffee Pub Blacksmith statistics surface. It explains how the API is exposed, which namespaces are available, and how data flows through the round, combat, and lifetime tiers.


Overview

Blacksmith exposes its stats surface via the module API:

// Console or module integration code:
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;

The stats engine only activates on GM clients when the trackCombatStats and trackPlayerStats world settings are enabled. Consumers should wait for the Foundry ready hook before interacting with the API. The implementation requires Foundry VTT v13 or later.


Architecture & Data Tiers

The stats system splits responsibilities across multiple scopes:

  • Round scope (CombatStats.currentStats): ephemeral data for the active round, stored in memory and mirrored to the combat flag. It resets when a new round begins.
  • Combat scope (CombatStats.combatStats): aggregate-only data for the active combat. Totals live in combatStats.totals (damage, healing, attack counts) alongside per-participant summaries and top moment highlights. Raw event arrays are discarded when the summary is generated.
  • Lifetime scope (CPBPlayerStats on actor flags): permanent per-actor records covering attacks, healing, and turn metrics. Only GMs modify this data.
  • Session scope (CPBPlayerStats._sessionStats): GM-only in-memory Map keyed by actor ID to hold transient session information (combat tracking, current combat turns). Reset on world reload.
  • Bounded arrays: _boundedPush applies limits (default 1000 entries) to round and actor logs. The persisted combat history in the combatHistory world setting is not bounded — it keeps every summary. The 20 you may see is getCombatHistory(limit = 20), a read-time cap only.

Namespaces & Surface Summary

Stats (from game.modules.get('coffee-pub-blacksmith')?.api?.stats) exposes several namespaces:

  • player: asynchronous helpers for actor-based statistics, including lifetime retrieval and category lookups.
  • party: party-wide aggregates over the per-actor and per-combat sources, cached rather than recomputed per read.
  • combat: synchronous helpers that reveal current combat state, historical summaries, and subscription utilities.
  • utils: passthrough utilities (formatTime, isPlayerCharacter) shared with UI components.
  • CombatStats: direct class access for advanced integrations; use higher-level helpers whenever possible.

Player Namespace

  • getStats(actorId: string) -> Promise<object | null> returns the full stats structure stored on the actor flag, initializing missing data when necessary.
  • getLifetimeStats(actorId: string) -> Promise<object | null> extracts only the stats.lifetime segment.
  • getSessionStats(actorId: string) -> object returns the GM-only in-memory session record (combats array, combat tracking, current combat state).
  • getStatCategory(actorId: string, category: string) -> Promise<object | null> resolves an individual lifetime category such as attacks, healing, or turnStats.

Lifetime data persists on the actor flag, while session data remains transient in _sessionStats. External consumers should treat returned objects as read-only unless coordinating changes with the Blacksmith maintainers.

Player-facing aggregates such as totalHits, totalMisses, criticals, and fumbles are now updated when the module receives the blacksmith.combatSummaryReady event. Session storage keeps a bounded combats array so custom UIs can display recent combat contributions without rehydrating the entire history. Lifetime data also records cumulative MVP performance (mvp.totalScore, mvp.averageScore, mvp.highScore, mvp.combats, mvp.lastScore, mvp.lastRank) so you can present long-term rankings across server restarts.

Commands & Examples

// Console (GM): inspect full stats for the first selected token's actor
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
const actor = canvas.tokens.controlled[0]?.actor;
if (Stats && actor) {
    const stats = await Stats.player.getStats(actor.id);
    BlacksmithUtils.postConsoleAndNotification('BLACKSMITH', 'Player Stats | Full stats', { actor: actor.name, stats }, false, false);
}
// Fetch only lifetime attack totals for the controlled actor
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
const actor = canvas.tokens.controlled[0]?.actor;
if (Stats && actor) {
    const lifetime = await Stats.player.getLifetimeStats(actor.id);
    BlacksmithUtils.postConsoleAndNotification('BLACKSMITH', 'Player Stats | Lifetime attacks', lifetime?.attacks, false, false);
}
// Session snapshot for active actor (GM only, returns synchronously)
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
const actor = canvas.tokens.controlled[0]?.actor;
if (Stats && actor) {
    const session = Stats.player.getSessionStats(actor.id);
    BlacksmithUtils.postConsoleAndNotification('BLACKSMITH', 'Player Stats | Session data', session, false, false);
}
// Show the last recorded combats for the actor (updated via combat summaries)
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
const actor = canvas.tokens.controlled[0]?.actor;
if (Stats && actor) {
    const session = Stats.player.getSessionStats(actor.id);
    BlacksmithUtils.postConsoleAndNotification('BLACKSMITH', 'Player Stats | Recent combats', session?.combats ?? [], false, false);
    // Also display as table for easier reading
    console.table(session?.combats ?? []);
}
// Pull a specific lifetime category, such as healing totals
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
const actor = canvas.tokens.controlled[0]?.actor;
if (Stats && actor) {
    const healing = await Stats.player.getStatCategory(actor.id, 'healing');
    BlacksmithUtils.postConsoleAndNotification('BLACKSMITH', 'Player Stats | Lifetime healing', healing, false, false);
}
// Inspect lifetime MVP performance for the actor
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
if (actor) {
    const stats = await Stats.player.getStats(actor.id);
    BlacksmithUtils.postConsoleAndNotification('BLACKSMITH', 'Player Stats | Lifetime MVP totals', stats?.lifetime?.mvp, false, false);
}

Party Namespace

Everything else in this API answers per actor or per combat. Anything party-wide therefore has to be reduced across the party, and this namespace is the only place that happens — so two surfaces showing the same figure cannot disagree about who counts as the party or how ties break.

  • getAggregate() -> Promise<object> returns the party aggregate. Computed only on the active GM's client — every other client reads what the GM already published rather than building its own, since game.actors is not the same collection on every client (Foundry only syncs documents a user has at least Observer permission on, and a GM is the one client guaranteed to see the whole party). A non-GM caller therefore gets the most recent value the GM has published, not a live-recomputed one; the GM republishes within a couple of seconds of anything that could change the answer (a combat ending, an actor changing). Falls back to a local build only if nothing has been published yet this session.
  • getAggregateSync() -> object | null the same value, without awaiting — returns the aggregate if one is already available (published, or locally cached on the GM's own client), otherwise null while a fetch is kicked off in the background. For callers that render synchronously and cannot await; draw what you have and pick the rest up on the next render.
  • getPartyActors() -> Actor[] the actors counted as the party: player-owned, excluding token-synthetic. Reduces game.actors on whichever client calls it directly — unlike getAggregate(), there is no published/shared version of this one, so a low-permission caller can still get an incomplete list here.
  • refresh() -> void drops the cache so the next read rebuilds. Rarely needed.

The aggregate carries:

  • Tiles, each {actorId, name, img} plus a value: topMvp, biggestHit (amount), mostCrits, mostFumbles, mostHits, mostMisses (each count). Ties break on lifetime MVP total — highest wins, except mostMisses, where lowest wins.
  • Totals across all stored combats: totalCombats, totalRounds, averageHitRate (string) and averageHitRateValue (number), totalCriticals, totalFumbles, totalKills, totalDamageGiven, totalDamageTaken, totalHealsGiven.
  • leaderboard, ranked by lifetime MVP total, each entry {rank, actorId, name, img, mvp: {totalScore, combats, averageScore, highScore}, crits, fumbles, biggestHit}.

Totals come from stored combat summaries, whose totals are player-characters-only by policy. The tiles and leaderboard come from lifetime actor flags.

The cache is the point. Building the aggregate awaits getStats for every party actor, which a window opened occasionally can afford and a menubar readout re-rendering on every combat update cannot. It rebuilds on blacksmith.combatSummaryReady and on actor create, update, and delete — not on read.

Combat Namespace

  • getCurrentStats() -> object exposes the active round snapshot, falling back to defaults if tracking has not yet started. Despite the name this is the round accumulator, not the running combat.
  • getRunningStats() -> object | null returns the running totals for the combat in progress, or null when no combat is being tracked.
  • getParticipantStats(participantId: string) -> object | null provides per-participant round information when available.
  • getNotableMoments() -> object | null surfaces current-round highlights.
  • getRoundSummary(round?: number) -> object | null returns an aggregate entry from combatStats.rounds, defaulting to the live combat round.
  • getCombatSummary() -> object | null returns the newest persisted combat summary.
  • getCombatHistory(limit = 20) -> Array<object> returns a newest-first slice of stored summaries. limit is a read-time cap only — pass null to get everything.
  • clearHistory() -> Promise<void> deletes all stored combat summaries.
  • removeCombat(combatId: string) -> Promise<void> deletes a single summary.

Combat history is unbounded. Every combat is kept, deliberately, so lifetime stats stay verifiable (see _storeCombatSummary()). It lives in a world setting, so it grows forever and syncs to every client. Use clearHistory() / removeCombat() to prune; you do not need to keep your own archive.

To observe stats, listen for the blacksmith.combatSummaryReady hook (fired at combat end with (summary, combat)). There is no subscription API; use the hook.

Summaries expose a consistent schema: totals.damage, totals.healing, and totals.attacks (attempts, hits, misses, crits, fumbles) plus participants[] entries that mirror those counts per actor. Consumers no longer receive the raw hit/miss arrays; use the aggregate fields for analytics and the notableMoments block for highlights. The notableMoments bundle now includes mvpRankings—a descending list of per-actor MVP scores (same formula used in the Party Breakdown)—alongside the top MVP entry.

Combat summary totals (current schema):

  • totals.hits, totals.misses, totals.totalAttacks, totals.hitRate
  • totals.damageDealt, totals.damageTaken, totals.healingGiven
  • totals.criticals, totals.fumbles

The running combat (getRunningStats()) returns {combatId, round, duration, durationSeconds, totals, participants, notableMoments}. The last three are shaped exactly as they are on a stored summary and are built by the same reduction, so totals.damageDealt means the same thing whether it is read mid-fight or off the summary card afterwards. The same policy guardrails below apply: totals is party-only, participants includes NPCs. It carries no scene metadata and no rounds array -- for a finished combat's per-round breakdown use getCombatSummary().

It is derived on call rather than cached, because it changes on essentially every combat event. Read it on your own update event; do not poll it.

Every client reads the same thing, the GM included. Combat tracking is GM-gated so that there is a single writer, but the GM mirrors the accumulator to a combat flag and combat documents sync everywhere, so that flag is what this returns for everyone -- the GM's read is not privileged and is not fresher. Two things follow for a consumer: the value trails the true state by up to a second, and it is null for the first moments of a combat before the first mirror lands, so handle null rather than assuming a running combat implies data. A flag write fires updateCombat on every client, which is the event to refresh on.

Three names sit close together and mean different tiers:

Call Scope
getCurrentStats() The current round
getRunningStats() The combat in progress, so far
getCombatSummary() The last combat that finished

Policy guardrails:

  • Totals include all damage/healing buckets (including other / unlinked where applicable).
  • "Top hits / Biggest hit / Weakest hit" moments are onHit-only.
  • Party-wide totals are computed from player characters only (participants may include NPCs for context/moments).

When combat ends, CombatStats._onCombatEnd emits Hooks.callAll('blacksmith.combatSummaryReady', combatSummary, combat) so external modules can react without polling the API. During each round, Hooks.callAll('blacksmith.roundMvpScore', { actorId, actorUuid, score, rank, name }) fires after the Party Breakdown is generated, which you can use to track per-round leaderboards or persist MVP progress mid-combat.

Commands & Examples

// Console: view the active round stats snapshot
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
const current = Stats?.combat.getCurrentStats();
console.log('Current round stats', current);
// Grab notable moments (largest hit/heal, fastest turn, etc.)
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
const highlights = Stats?.combat.getNotableMoments();
console.log('Notable moments', highlights);
// Inspect a specific participant by combatant ID (e.g., selected token)
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
const combatant = game.combat?.combatant;
if (Stats && combatant) {
    const participant = Stats.combat.getParticipantStats(combatant.id);
    console.log('Participant summary', participant);
}
// Pull the round summary for round 3, or default to current round when omitted
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
const roundSummary = Stats?.combat.getRoundSummary(3);
console.log('Round 3 summary', roundSummary);
// Observe combat results as they land
Hooks.on('blacksmith.combatSummaryReady', (summary, combat) => {
    console.log('Combat finished', summary.totals);
});
// View the most recent combat summary stored in history
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
const latestSummary = Stats?.combat.getCombatSummary();
console.log('Latest combat summary totals', {
    damage: latestSummary?.totals?.damageDealt,
    healing: latestSummary?.totals?.healingGiven,
    attacks: latestSummary?.totals?.attacks
});
// List the three most recent combat summaries with metadata only
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
const recent = Stats?.combat.getCombatHistory(3);
console.log('Recent combat summaries', recent.map(s => ({
    combatId: s.combatId,
    hitRate: s.totals?.hitRate,
    topCrits: s.participants?.map(p => ({ actorId: p.actorId, crits: p.criticals })),
    mvpRankings: s.notableMoments?.mvpRankings
})));
// Inspect MVP rankings for the latest combat
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
const rankings = Stats?.combat.getCombatSummary()?.notableMoments?.mvpRankings ?? [];
console.table(rankings.map((r, index) => ({ rank: index + 1, name: r.name, score: r.score })));

Utilities & Direct Access

  • Stats.utils.formatTime(ms: number) -> string formats durations for display.

  • Stats.utils.isPlayerCharacter(target) -> boolean reports whether a combatant, token, actor, ID, or name resolves to a player character, and is the definition of party membership used throughout statistics. Both conditions are required: the actor's sheet type is character, and a player owns it (by permission or by being that user's assigned character).

    NPCs are never party, whoever owns them. A summoned creature is handed to its summoner's player while it exists and so is player-owned, as is a permanent companion or an animated weapon with its own stat block; none of them are tracked. Summons are therefore absent from party totals, MVP ranking, and lifetime statistics, and damage they deal is not attributed to their summoner.

  • Stats.CombatStats exposes the full class for advanced use. Treat its static state as internal; avoid external mutation unless working directly with the core maintainers.

Commands & Examples

// Format 72 seconds into the display string used by Blacksmith UI
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
Stats?.utils.formatTime(72000);
// Check if the currently selected token belongs to a player
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
const token = canvas.tokens.controlled[0];
if (token) {
    const isPlayer = Stats?.utils.isPlayerCharacter(token);
    console.log(token.name, 'is a player character?', isPlayer);
}
// Advanced: access the raw CombatStats class (read-only unless coordinated)
const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;
const CombatStatsClass = Stats?.CombatStats;
console.log('Current combat totals', CombatStatsClass.combatStats?.totals);

Event Flow & Lifecycle

  1. CombatStats.initialize() runs for GMs when combat tracking is enabled. It clones defaults, registers Handlebars helpers, and hooks into combat and system events via HookManager.
  2. Player tracking initializes when lifetime tracking is enabled, adding hooks for attack rolls, damage rolls, combat lifecycle events, and actor updates.
  3. _onUpdateCombat responds to round and turn transitions. _onRoundEnd rolls up round data and resets currentStats; _onRoundStart prepares the next round and refreshes flags.
  4. _onCombatEnd guards against deleted combats, generates the summary, logs it via postConsoleAndNotification, fires the blacksmith.combatSummaryReady hook, persists the bounded history, and clears transient structures.
  5. Player turn processing (_processTurnStart, _processTurnEnd) updates session maps and actor lifetime stats, applying bounded arrays to prevent growth issues.
  6. Disabling tracking or unloading the module stops new data from being collected. No explicit teardown runs, so integrations should clear their own blacksmith.combatSummaryReady listeners when tracking toggles off.

Data Retention & Safeguards

  • Combat aggregates reset as soon as the summary is generated: combatStats.totals and combatStats.participantStats are reinitialized for the next combat, so no combat-level flags remain in the world data.
  • Guard checks (game.combats.has(combat.id)) prevent operations on deleted combats.
  • _boundedPush limits hit, miss, and turn arrays to safeguard against unbounded growth.
  • The combatHistory world setting keeps every combat summary — deliberately unpruned, so lifetime stats stay verifiable. It grows without bound and syncs to every client; prune with clearHistory() / removeCombat(). Modules do not need their own archive.
  • Attack/damage correlation is now handled via chat message resolution (createChatMessage hook) using stable keying (attacker actor, item UUID, activity UUID, sorted target UUIDs). Lifetime counters update when blacksmith.combatSummaryReady fires.

Debugging & Instrumentation

Stats logging relies on postConsoleAndNotification(MODULE.NAME, message, payload, /*debug=*/true, /*notify=*/false) so messages respect the module's debug flag. Critical errors, such as storage failures, log with debug = false to surface regardless of debug settings. Combat summaries log with the COMBAT SUMMARY prefix when combats conclude; filter the console by that phrase to inspect payloads quickly.


Integration Patterns

// Wait for Blacksmith to be ready, then access stats
Hooks.once('ready', async () => {
    const Stats = game.modules.get('coffee-pub-blacksmith')?.api?.stats;

    // Listen for end-of-combat summaries without polling
    Hooks.on('blacksmith.combatSummaryReady', (summary, combat) => {
        const mvp = summary?.notableMoments?.mvp;
        if (mvp) {
            console.log('MVP recorded:', { combatId: summary.combatId, mvp });
        }
    });

    // Track round-by-round MVP progress (e.g., update a custom UI badge)
    Hooks.on('blacksmith.roundMvpScore', ({ actorId, score, rank, name }) => {
        console.log('Round MVP update:', { actorId, name, score, rank });
        // Example: store in your module's state to show a live leaderboard
    });

    // Pull lifetime actor stats (requires GM)
    const actorId = 'ABC123';
    const lifetime = await Stats?.player.getLifetimeStats(actorId);
    BlacksmithUtils.postConsoleAndNotification('BLACKSMITH', 'Player Stats | Lifetime attack totals', lifetime?.attacks?.totalHits, false, false);

    // Inspect recent combat summary
    const recentSummary = Stats?.combat.getCombatSummary();
    console.log('Most recent combat hit rate:', recentSummary?.totals?.hitRate);
});

---

## Version & Compatibility Notes

- `trackCombatStats` enables combat tracking; `trackPlayerStats` enables lifetime tracking. If a setting is disabled, methods return defaults or `null` rather than throwing errors.
- Only GMs mutate statistics; player clients receive read-only snapshots when available.
- This module requires Foundry VTT v13 or later. Hook usage and flag management follow the Foundry v13 API.

---

## Updating `api-core.md`

The Stats API section in `api-core.md` should remain a short summary that links directly to this document. This keeps `api-core.md` focused on high-level integration while `api-stats.md` provides the detailed reference for maintainers and external consumers.

Clone this wiki locally