Files
ark-stronghold-protocol/public/js/ui/stats.js
T
sganggs 51e0e60b6e feedback7 stats: a match left right away stays in the history but does not count; bounded storage, corrupt-data handling, the page reworked as 干员调配-style tabs, the settlement re-view without the app store (PR #323 by @2321Robin, owner's decision of 2026-10-08); golden: unchanged
Counting rule (ui/stats.js countsTowardStats, the owner's decision of 2026-10-08: do not count the matches entered and left
right away, but keep them in the history): a settled match (m.result arrived) always counts; a match given up (放弃模拟)
counts once at least one of the player's battles had finished — a round cleared (the server's own rule, platform.js
_quit: leaving while round k runs has passed k - 1, in its settlement k) or already eliminated. Anything earlier is
history only: marked 中途退出 and 不计入统计, dimmed, listed in 对局记录, left out of every aggregate (games, wins, win
rate, strategy table, titles, rounds, time, combat totals); 总览 says how many were left out.
- A leaver gets no m.result, so the page records the match itself: quitMatch (放弃模拟) → recordQuit builds an
  `end: 'quit'` record from the app state and a tracker (observeMatch, installed in main.js) that remembers when the page
  first saw the match and the last round the player was seen alive in. No record for a spectator seat, a match with its
  settlement already there, a match the page never saw; matches the server ended and closed tabs are not recorded.

Defects of PR #323 as submitted, reproduced (scratchpad/w7-stats-notes/repro-pr-bugs.mjs, loads-check*.mjs) and fixed:
- the title screen loaded all 16 game data files (5.3 MB) at boot: <StatsHost/> called useGameData() at mount; now the
  host renders nothing until opened, the overview reads config / bands / assets and the settlement re-view loads the rest
  when opened (the title screen loads the same 3 files as before);
- importing a file put the list oldest first (the page shows the first 20 as the newest and the cap would drop the
  newest); the list is newest first everywhere, also when loaded from storage;
- recordResult wrote over the envelope of a NEWER build (a downgrade, against the PR's own rule); it refuses now, as
  saveStats does;
- 1000 four-player records are ~7.2 M characters (the PR's ~1.5 kB/record is a solo match) against a ~2.5 M-character
  quota: MAX_RECORDS 1000 stays, plus a 800 000-character budget (the oldest go first), and only the newest 30 records
  keep their rows (lineup, bonds, teammates' names; the replay needs them) — older ones are compacted to the player's own
  summary row (~620 characters), a refused write retries with half the history; unknown fields and oversized values are
  dropped (a strict schema), import files are bounded (8 MB);
- the re-view clobbered the app store (match.public / match.result, the route, the music); ResultScreen is split into
  ResultScreen (the store) and ResultView (props: result, myId, back label), so the stats page renders a stored match
  itself; a 自选 pick and a 补位 stand-in were dropped from the stored lineup (the replayed card drew the plain
  operator); DIFFICULTY_NAMES were not translated; a band cell was a display:flex <td>; the overlay stayed open when a
  co-op match started; the 清空 two-step blink was replaced by the shared confirm dialog.
- Corrupt storage: unreadable data is moved to sp.pref.stats.damaged and the page says so; damaged rows are dropped and
  counted; duplicates, an oversized or unsorted list are repaired on load; a pre-`end` (PR-era) envelope reads as settled.

UI: a full-screen overlay in the grammar of 干员调配 (top bar with 返回 / four tabs 总览 · 策略 · 战斗累计 · 对局记录 /
导出 导入 清空, a note line saying the data stays in this browser and that teammates' names are stored too), text sizes
with max(.xxrem, N px) for phones in landscape, a 50-row history page with 显示更多, a pass-rate bar, a per-difficulty
table in order; the entries use a new `chart` icon (not the ping bars), the title screen's sits beside the language menu
(the connection row has no room: it squeezed the gear and wrapped the status text), the lobby / room buttons show the
icon alone below 700 px (the room's title ran under the label at 640 px). docs/PLAYING.md §12, README row; 41 + 25 UI
strings in en / ja / ko / zh-TW (the PR's four unused ones removed).

Tests: test/ui/stats.test.js 44 / 44 (counting rule per phase, quit records, bounds, corrupt / old / newer storage,
import order, aggregation, the replay payload); test/ui/stats.e2e.test.js (opt-in SP_E2E=1, real server, headless Chrome:
seeded history on 1920×1080 and 844×390, the re-view, export / import / clear, corrupt and newer storage, and the real
flows: a solo match given up in the briefing is history only, one given up after round 1 counts, a settled match is
recorded once and not again after a reload) 6 / 6; lint 0 errors, i18n extract 0 / check --all --strict clean,
check:imports, typecheck, docs and version tests, golden unchanged.
2026-10-08 20:36:41 +08:00

745 lines
32 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.
// Local match statistics: every match the local player took part in is appended as one record to localStorage
// (`sp.pref.stats`). Per-browser only — the server keeps matches in memory and has no identity, so there is nowhere
// else for cross-match numbers to live; nothing here is ever sent anywhere. The stats page labels itself 本机数据 and
// offers JSON export / import for moving between devices. (Ported from PR #323 by @2321Robin, reworked for 0.2.2.)
//
// Two kinds of record (`end`):
// 'settled' the match reached its settlement: m.result arrived (server/match/results.js buildResult). The record
// keeps what the settlement offers — the match frame (difficulty, mode, boss, seed, duration, hidden-core
// outcome, reason) and one row per player (band, title, lineup, bonds, the stat block) — so the page can
// re-view the settlement screen (recordToResult).
// 'quit' the player gave up (放弃模拟) before the settlement; the server sends a leaver nothing, so the record is
// built from what the client knew at that moment (buildQuitRecord): the frame, the player's own row, the
// rounds cleared. It is listed in the history, marked 中途退出, and cannot be re-viewed.
//
// What counts (countsTowardStats, the owner's decision of 2026-10-08: a match entered and left right away must not
// count): every aggregate — games, wins, win rate, hidden core, best round, time played, the strategy table, titles
// and the combat totals — is taken over the records that count. A settled record always counts. A quit record counts
// once at least one of the player's own battles had finished — a round cleared (the server's rule: leaving while
// round k runs has passed k − 1 rounds, leaving in its settlement k) or already eliminated. A match left earlier is
// history only.
//
// Bounded storage: at most MAX_RECORDS matches and MAX_CHARS characters of JSON. Only the newest FULL_KEEP records
// keep their full rows (lineups, bonds, teammates' names — what the settlement replay needs); older ones are compacted
// to the player's own summary row, which is all the aggregates read. The oldest records go first when a budget is hit,
// and a write the browser refuses shrinks the list further instead of failing. Teammates' display names are part of
// the newest records and of an export file: they stay on this device unless the player exports them.
//
// Format evolution: the envelope carries a version (`STATS_VERSION`); `migrateStats` walks records through a chain of
// per-version steps and every loaded / imported record passes `normalizeRecord`, a strict schema: missing fields get
// defaults, unknown fields and oversized values are dropped, a row it cannot salvage is dropped. An envelope from a
// NEWER version than this build is refused (never downgraded and written back); an unreadable one is set aside
// (`sp.pref.stats.damaged`) and the history starts again.
//
// Pure logic, no Preact / DOM: Node-testable (test/ui/stats.test.js); localStorage is touched only by loadStats /
// saveStats / their helpers.
import { PHASE } from '../../../shared/constants.js';
/** Current record-envelope version (bump + add a MIGRATIONS step when the record shape changes). */
export const STATS_VERSION = 1;
/** localStorage key under the app's `sp.pref.` prefix (store.js loadPref). */
export const STATS_PREF_KEY = 'stats';
/** Oldest records are dropped beyond this many matches. */
export const MAX_RECORDS = 1000;
/** The newest records that keep their full rows (lineups, bonds, teammates) — what the settlement replay reads. */
export const FULL_KEEP = 30;
/** Budget of the stored JSON in characters (a character costs up to 2 bytes of the browser's ~5 MB quota): the oldest records go first. */
export const MAX_CHARS = 800_000;
/** Largest export file the page reads. */
export const IMPORT_MAX_CHARS = 8_000_000;
const FULL_KEY = `sp.pref.${STATS_PREF_KEY}`;
const DAMAGED_KEY = `${FULL_KEY}.damaged`;
/** @returns {{ v: number, records: Array }} a fresh empty envelope */
export const emptyStats = () => ({ v: STATS_VERSION, records: [] });
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const int = (v, d) => (Number.isFinite(v) ? Math.round(v) : d);
const num = (v) => (Number.isFinite(v) ? v : null);
const bool = (v, d = false) => (typeof v === 'boolean' ? v : d);
/** A non-empty string, cut to `max` characters. */
const str = (v, max = 64) => (typeof v === 'string' && v.length ? v.slice(0, max) : null);
// per-record bounds (an imported file is foreign data: nothing a row holds may grow without limit)
const MAX_PLAYERS = 6; // the game seats 4
const MAX_LINEUP = 12;
const MAX_ITEMS = 4;
const MAX_BONDS = 24;
const MAX_STAT_KEYS = 24;
const MAX_NAME = 24;
/**
* Copy only the finite numeric keys of a stats block (the settlement's numbers; a numeric field this build does not
* know is kept too, up to MAX_STAT_KEYS — display can lag, data should not).
* @param {any} raw
* @returns {Record<string, number>}
*/
function normalizeStatBlock(raw) {
const out = {};
if (!isObj(raw)) return out;
let n = 0;
for (const [k, v] of Object.entries(raw)) {
if (!Number.isFinite(v) || k.length > 32) continue;
out[k] = v;
if (++n >= MAX_STAT_KEYS) break;
}
return out;
}
/**
* FNV-1a 32-bit, hex — a content hash needs no crypto, only stability across sessions.
* @param {string} s
* @returns {string}
*/
function fnv1a(s) {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
}
return h.toString(16).padStart(8, '0');
}
/**
* The record's dedupe id, from match facts the server replays byte-identically on reconnect / reload
* (server/lobby.js result replay): seed + duration + mode + rounds + outcome + who I was. Two distinct
* matches colliding on all of these is not a real scenario; a replayed m.result hits the same id and is
* dropped, which is the point (recording happens on every m.result arrival, replays included).
* @param {any} res raw m.result payload
* @param {string|null|undefined} myId the local player's id at that moment
* @returns {string}
*/
export function recordId(res, myId) {
const r = isObj(res) ? res : {};
return `r1.${fnv1a([r.seed ?? '-', r.durationMs ?? '-', r.modeId ?? '-', r.roundsPassed ?? '-', r.victory ? 1 : 0, myId ?? '-'].join('|'))}`;
}
/**
* Normalize ONE player row: known fields defaulted and bounded, unknown fields dropped. `quit`: the record is a quit
* record, whose row may not know its rounds (`roundsPassed: null`).
* @param {any} raw
* @param {boolean} [quit]
* @returns {any}
*/
function normalizePlayerRow(raw, quit = false) {
const p = isObj(raw) ? raw : {};
// the page and the replay read the title record from the game data by id: the sent texts are not kept
const title = isObj(p.title) && str(p.title.id) ? { id: str(p.title.id) } : null;
const lineup = (Array.isArray(p.lineup) ? p.lineup : []).filter(isObj).slice(0, MAX_LINEUP).map((u) => {
// ids only — the tile position (row/col) is replay detail, not a statistic; items kept (配发装备统计); a 自选
// pick and a 补位 stand-in kept too, or the replayed card would draw the plain operator
const e = {
id: str(u.id) || '', golden: bool(u.golden), tier: int(u.tier, 0),
items: (Array.isArray(u.items) ? u.items : []).filter((i) => typeof i === 'string' && i.length).slice(0, MAX_ITEMS).map((i) => i.slice(0, 64)),
};
if (u.kind === 'token') e.kind = 'token';
if (isObj(u.diy) && str(u.diy.charId)) {
e.diy = { charId: str(u.diy.charId), skillIndex: Number.isInteger(u.diy.skillIndex) ? u.diy.skillIndex : 0, uniEquipId: str(u.diy.uniEquipId) };
}
if (str(u.standInFor)) e.standInFor = str(u.standInFor);
return e;
}).filter((u) => u.id);
const bonds = (Array.isArray(p.bonds) ? p.bonds : []).filter(isObj).slice(0, MAX_BONDS).map((b) => ({
bondId: str(b.bondId) || str(b.id) || '', layers: int(b.layers, 0), active: bool(b.active),
})).filter((b) => b.bondId);
return {
playerId: str(p.playerId) || '',
seat: int(p.seat, 0),
name: str(p.name, MAX_NAME) || '',
isBot: bool(p.isBot),
left: bool(p.left),
alive: bool(p.alive, true),
victory: bool(p.victory), // server semantics: team won AND this player was still in at the end
roundsPassed: quit ? num(p.roundsPassed) : int(p.roundsPassed, 0),
eliminatedRound: num(p.eliminatedRound),
lp: num(p.lp),
bandId: str(p.bandId),
title,
trophies: int(p.trophies, 0),
reward: int(p.reward, 0),
lineup,
bonds,
stats: normalizeStatBlock(p.stats),
};
}
/**
* Normalize ONE match record to the current shape (a strict schema: missing fields get defaults, unknown ones are
* dropped, every string / list is bounded). A record without an `end` is a settled one — the PR #323 build, which
* never recorded anything else, wrote none.
* @param {any} raw
* @returns {any|null} null when the row is not salvageable (no usable player row at all)
*/
export function normalizeRecord(raw) {
const r = isObj(raw) ? raw : {};
// a record IS a played match — no usable players array, no record
if (!Array.isArray(r.players)) return null;
const quit = r.end === 'quit';
const players = r.players.filter(isObj).slice(0, MAX_PLAYERS).map((p) => normalizePlayerRow(p, quit));
if (!players.length) return null;
const rec = {
id: str(r.id),
v: STATS_VERSION,
t: int(r.t, 0),
end: quit ? 'quit' : 'settled',
victory: bool(r.victory),
roundsPassed: quit ? num(r.roundsPassed) : int(r.roundsPassed, 0),
lastRound: num(r.lastRound),
hiddenReached: bool(r.hiddenReached),
hiddenCleared: bool(r.hiddenCleared),
reason: str(r.reason, 32),
modeId: str(r.modeId),
difficulty: str(r.difficulty, 24),
stageId: str(r.stageId),
bossId: str(r.bossId),
hiddenBossId: str(r.hiddenBossId),
seed: num(r.seed),
durationMs: num(r.durationMs),
teamLp: num(r.teamLp),
roomMode: r.roomMode === 'solo' || r.roomMode === 'coop' ? r.roomMode : null,
selfId: str(r.selfId),
players,
};
if (r.compact === true) rec.compact = true;
// an id-less (hand-made) record gets a content id, so both copies of it land on the same key
if (!rec.id) rec.id = `c.${fnv1a(JSON.stringify(rec))}`;
return rec;
}
/**
* The MIGRATIONS chain: `MIGRATIONS[v]` upgrades a v-envelope to v+1, for v = STATS_VERSION and down.
* Empty at v1 — the steps exist so the next format change lands as one function here and every stored /
* imported envelope walks the whole chain.
* @type {Record<number, (stats: any) => any>}
*/
export const MIGRATIONS = {};
/** Newest first (the stored order): by time, then id. */
const newestFirst = (a, b) => (b.t - a.t) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
/**
* Bring any stored / imported envelope to the current version.
* @param {any} raw
* @returns {{ stats: { v: number, records: any[] }, changed: boolean, newer: boolean, dropped: number }}
* `newer` = the data was written by a NEWER build: returned untouched, callers must not save over it.
* `dropped` = rows that were not salvageable, repeated a record or went beyond MAX_RECORDS: gone from `stats`.
*/
export function migrateStats(raw) {
if (!isObj(raw) || !Array.isArray(raw.records)) return { stats: emptyStats(), changed: false, newer: false, dropped: 0 };
if ((raw.v ?? 0) > STATS_VERSION) {
return { stats: { v: raw.v, records: raw.records }, changed: false, newer: true, dropped: 0 };
}
let v = Number.isFinite(raw.v) ? raw.v : 0;
let records = raw.records;
let changed = v !== STATS_VERSION;
while (v < STATS_VERSION) {
const step = MIGRATIONS[v];
if (typeof step === 'function') records = step({ v, records }).records;
v++;
}
// a hand-edited / merged list can be oversized, unsorted or repeat a record: only the newest MAX_RECORDS survive, newest first
const normalized = [];
const seen = new Set(); // a record listed twice (hand-merged data) counts once: the first copy
for (const r of records) {
const rec = normalizeRecord(r);
if (rec && !seen.has(rec.id)) { seen.add(rec.id); normalized.push(rec); }
}
normalized.sort(newestFirst);
const kept = normalized.slice(0, MAX_RECORDS);
const dropped = records.length - kept.length;
// storage is rewritten (self-healed) when a row went, the order changed or an id-less row got its content id
changed ||= dropped > 0 || kept.some((rec, i) => !isObj(records[i]) || rec.id !== records[i].id);
return { stats: { v: STATS_VERSION, records: kept }, changed, newer: false, dropped };
}
/** Read the raw stored string (null when absent or unreadable). */
function readRaw() {
try { return globalThis.localStorage?.getItem(FULL_KEY) ?? null; } catch { return null; }
}
/**
* Read + migrate the persisted envelope; self-heals older shapes back to storage. An unreadable value (not JSON, or
* not an envelope) is moved to `sp.pref.stats.damaged` — kept for a manual recovery — and the history starts again.
* @returns {{ v: number, records: any[], newer: boolean, dropped: number, damaged: boolean }}
*/
export function loadStats() {
const raw = readRaw();
if (raw == null) return { ...emptyStats(), newer: false, dropped: 0, damaged: false };
let parsed;
try { parsed = JSON.parse(raw); } catch { parsed = undefined; }
if (!isObj(parsed) || !Array.isArray(parsed.records)) {
// keep the bad value where a person can find it; if even that write fails the next save simply replaces it
try { globalThis.localStorage?.setItem(DAMAGED_KEY, raw); globalThis.localStorage?.removeItem(FULL_KEY); } catch { /* keep it in place */ }
return { ...emptyStats(), newer: false, dropped: 0, damaged: true };
}
const { stats, changed, newer, dropped } = migrateStats(parsed);
if (!newer && changed) saveStats(stats);
return { ...stats, newer, dropped, damaged: false };
}
// ---- compaction + the storage budget ---------------------------------------------------------------------------
const COMPACT_FRAME = ['id', 'v', 't', 'end', 'victory', 'roundsPassed', 'lastRound', 'hiddenReached', 'hiddenCleared', 'reason', 'modeId',
'difficulty', 'roomMode', 'durationMs', 'selfId'];
/**
* The compact form of a record: the frame and the player's OWN row without lineup, bonds and name — everything the
* aggregates and the history list read, nothing a replay needs. Teammates are gone. Idempotent.
* @param {any} rec a normalized record
* @returns {any}
*/
export function compactRecord(rec) {
const self = selfRowOf(rec);
const out = {};
for (const k of COMPACT_FRAME) if (rec[k] != null) out[k] = rec[k];
out.compact = true;
out.players = self ? [{
playerId: self.playerId, seat: self.seat, alive: self.alive, victory: self.victory, roundsPassed: self.roundsPassed,
eliminatedRound: self.eliminatedRound, bandId: self.bandId, title: self.title, left: self.left, stats: self.stats,
}] : [];
return out;
}
/**
* What goes to storage: the newest FULL_KEEP records as they are, older ones compacted, then the oldest dropped until
* the JSON fits `maxChars` (the newest record is always kept).
* @param {any[]} records newest first
* @param {number} [maxChars]
* @returns {any[]}
*/
export function fitToBudget(records, maxChars = MAX_CHARS) {
const kept = [];
let used = 40; // the envelope itself
records.slice(0, MAX_RECORDS).forEach((r, i) => {
const rec = i < FULL_KEEP ? r : compactRecord(r);
const n = JSON.stringify(rec).length + 1;
if (kept.length && used + n > maxChars) return;
used += n;
kept.push(rec);
});
return kept;
}
/**
* Persist the envelope. Never overwrites an envelope of a newer build. Compacts and trims to the budget first; a write
* the browser refuses (quota — other keys share it) is retried with half the history, down to a handful of records.
* @param {{ v: number, records: any[], newer?: boolean }} stats
* @returns {boolean} whether a write stuck
*/
export function saveStats(stats) {
if (stats.newer) return false;
let recs = fitToBudget(stats.records);
for (;;) {
try {
const ls = globalThis.localStorage;
if (!ls) return false; // no storage at all (disabled by the browser)
ls.setItem(FULL_KEY, JSON.stringify({ v: STATS_VERSION, records: recs }));
return true;
} catch { /* quota or storage disabled — fall through to shrinking */ }
if (recs.length <= 8) return false; // a small history that still does not fit is foreign quota pressure
recs = recs.slice(0, Math.max(8, Math.ceil(recs.length / 2)));
}
}
/**
* Derive the room shape ('solo' | 'coop') the way constants.js modeIdFor builds modeIds.
* @param {string|null|undefined} modeId
* @param {string|null|undefined} roomMode room.state `mode` when known
* @returns {'solo'|'coop'|null}
*/
export function roomModeOf(modeId, roomMode) {
if (roomMode === 'solo' || roomMode === 'coop') return roomMode;
if (typeof modeId !== 'string') return null;
if (modeId.includes('_single_')) return 'solo';
if (modeId.includes('_multi_')) return 'coop';
return null;
}
/**
* Build one settled record from a raw m.result payload (+ context). Null when there is nothing to store: payloads
* without players (the error-path settlement) or matches the local player did not take part in (spectator
* seats get every result pushed too — eliminated players, who ARE in players[], keep recording).
* @param {any} res raw m.result payload
* @param {{ myId?: string|null, roomMode?: string|null, now?: number }} ctx
* @returns {any|null}
*/
export function buildRecord(res, ctx = {}) {
const r = isObj(res) ? res : {};
if (!Array.isArray(r.players) || !r.players.length) return null;
const myId = str(ctx.myId);
const selfRow = r.players.find((p) => isObj(p) && p.playerId === myId);
if (!selfRow) return null; // spectator / observer: not my match
return normalizeRecord({
v: STATS_VERSION,
t: int(ctx.now, 0),
id: recordId(r, myId),
end: 'settled',
victory: bool(r.victory),
roundsPassed: int(r.roundsPassed, 0),
lastRound: num(r.lastRound),
hiddenReached: bool(r.hiddenReached),
hiddenCleared: bool(r.hiddenCleared),
reason: str(r.reason),
modeId: str(r.modeId),
difficulty: str(r.difficulty),
stageId: str(r.stageId),
bossId: str(r.bossId),
hiddenBossId: str(r.hiddenBossId),
seed: num(r.seed),
durationMs: num(r.durationMs),
teamLp: num(r.teamLp),
roomMode: roomModeOf(str(r.modeId), ctx.roomMode),
selfId: myId,
players: r.players,
});
}
// ---- the match left early ----------------------------------------------------------------------------------------
/**
* What the page remembers of the match on screen, so that giving it up can still be recorded: when the page first saw
* it, whether that was at the start (a duration is only kept then), and the last round the player was seen alive in
* (the server tells the others at the settlement, a leaver nothing).
* @returns {{ active: boolean, startedAt: number, sawStart: boolean, aliveRound: number|null }}
*/
export const createLiveMatch = () => ({ active: false, startedAt: 0, sawStart: false, aliveRound: null });
/** The module's own tracker (fed by installStatsRecorder, read by recordQuit). */
export const liveMatch = createLiveMatch();
/**
* Feed the tracker one app state (cheap: called on every store change).
* @param {ReturnType<typeof createLiveMatch>} live
* @param {any} state the app store state
* @param {number} [now]
*/
export function observeMatch(live, state, now = Date.now()) {
const pub = state?.match?.public;
const inMatch = !!pub && !!pub.phase && pub.phase !== PHASE.LOBBY && pub.phase !== PHASE.RESULT && !state.match.result;
if (!inMatch) { live.active = false; return live; }
const round = Number(pub.round) || 0;
if (!live.active) {
live.active = true;
live.startedAt = now;
live.sawStart = round <= 1;
live.aliveRound = null;
}
const me = Array.isArray(pub.players) ? pub.players.find((p) => isObj(p) && p.playerId === state.me?.playerId) : null;
if (me && me.alive !== false) live.aliveRound = Math.max(live.aliveRound ?? 0, round);
return live;
}
/** Start following the store (main.js). Returns the unsubscribe function. */
export function installStatsRecorder(store) {
observeMatch(liveMatch, store.get());
return store.subscribe((s) => observeMatch(liveMatch, s));
}
/**
* The rounds a player who leaves in `phase` of `round` has cleared — the server's rule for a quit (platform.js _quit:
* eliminated in round k ⇒ k − 1 passed; leaving in the settlement of round k counts k).
* @param {string} phase @param {number} round
*/
export function clearedRounds(phase, round) {
const r = Number.isFinite(round) ? round : 0;
return Math.max(0, phase === PHASE.SETTLE ? r : r - 1);
}
/**
* Build the quit record of a match the local player is leaving (放弃模拟), from the app state and the tracker. Null when
* there is nothing of mine to record: no match on screen, its settlement already here (that one is recorded by
* recordResult), a spectator seat, or a match the page never saw.
* @param {any} state the app store state BEFORE the match is cleared
* @param {{ live?: ReturnType<typeof createLiveMatch>, now?: number }} [ctx]
* @returns {any|null}
*/
export function buildQuitRecord(state, ctx = {}) {
const live = ctx.live ?? liveMatch;
const now = int(ctx.now, Date.now());
const pub = state?.match?.public;
const myId = str(state?.me?.playerId);
if (!pub || !myId || state.match.result || !live.active) return null;
const row = Array.isArray(pub.players) ? pub.players.find((p) => isObj(p) && p.playerId === myId) : null;
if (!row) return null; // a spectator seat is no player of the match
const alive = row.alive !== false;
const round = Number(pub.round) || 0;
// alive: the rounds cleared so far; already eliminated: the round it was seen alive in, less one (unknown when the
// page never saw it alive — a reload after the elimination)
const roundsPassed = alive ? clearedRounds(pub.phase, round) : (live.aliveRound != null ? Math.max(0, live.aliveRound - 1) : null);
return normalizeRecord({
v: STATS_VERSION,
t: now,
id: `q.${fnv1a([myId, live.startedAt, pub.modeId ?? '-', state.room?.code ?? '-'].join('|'))}`,
end: 'quit',
victory: false,
roundsPassed,
lastRound: num(pub.lastRound),
hiddenReached: pub.phase === PHASE.HIDDEN_CORE,
hiddenCleared: false,
reason: 'quit',
modeId: str(pub.modeId),
difficulty: str(pub.difficulty),
stageId: str(pub.stageId),
bossId: str(pub.bossId),
hiddenBossId: str(pub.hiddenBossId),
seed: null,
durationMs: live.sawStart ? Math.max(0, now - live.startedAt) : null,
teamLp: num(pub.teamLp),
roomMode: roomModeOf(str(pub.modeId), state.room?.mode),
selfId: myId,
players: [{
playerId: myId, seat: row.seat, name: row.name, isBot: false, left: true, alive, victory: false, roundsPassed,
eliminatedRound: alive ? null : live.aliveRound, lp: row.lp, bandId: row.bandId,
}],
});
}
/**
* The m.result-shaped payload behind a settled record — the inverse of buildRecord. The stats page's history
* rows hand this to the settlement view (screens/result.js reads it through normalizeResult): a finished match can be
* RE-VIEWED because the record kept every field the payload had. Whatever a record never stored is simply absent and
* normalizeResult defaults it, exactly like a tolerant live payload. A quit record (nothing was settled) and a
* compacted one (its rows are gone) have no settlement to show.
* @param {any} rec
* @returns {any|null} null when the row is not a usable, full, settled record
*/
export function recordToResult(rec) {
const r = normalizeRecord(rec);
if (!r || r.end === 'quit' || r.compact) return null;
const res = {
victory: r.victory,
roundsPassed: r.roundsPassed,
hiddenReached: r.hiddenReached,
hiddenCleared: r.hiddenCleared,
reason: r.reason,
modeId: r.modeId,
difficulty: r.difficulty,
bossId: r.bossId,
hiddenBossId: r.hiddenBossId,
seed: r.seed,
durationMs: r.durationMs,
teamLp: r.teamLp,
players: r.players,
};
if (Number.isFinite(r.lastRound)) res.lastRound = r.lastRound;
return res;
}
/**
* Append one record: replays (same content id) are dropped, newest first, capped at MAX_RECORDS.
* @param {{ v: number, records: any[] }} stats
* @param {any} record
* @returns {{ stats: { v: number, records: any[] }, added: boolean }}
*/
export function appendRecord(stats, record) {
if (!record || !record.id) return { stats, added: false };
if (stats.records.some((r) => r.id === record.id)) return { stats, added: false };
const records = [record, ...stats.records].slice(0, MAX_RECORDS);
return { stats: { v: STATS_VERSION, records }, added: true };
}
/** load → append → save of one built record (never over an envelope of a newer build). @returns {any|null} the record when stored */
function storeRecord(rec) {
if (!rec) return null;
const stats = loadStats();
if (stats.newer) return null;
const { stats: next, added } = appendRecord(stats, rec);
if (added && !saveStats(next)) console.warn('[stats] localStorage write failed (quota?), record not persisted');
return added ? rec : null;
}
/**
* Record a finished match end-to-end (load → append → save). Returns the built record, or null when
* nothing was stored (no players / not my match / replay).
* @param {any} res raw m.result payload
* @param {{ myId?: string|null, roomMode?: string|null, now?: number }} [ctx]
* @returns {any|null}
*/
export function recordResult(res, ctx = {}) {
return storeRecord(buildRecord(res, ctx));
}
/**
* Record the match the player is giving up (放弃模拟), before the app clears its state. Returns the record, or null.
* @param {any} state the app store state before the match is cleared
* @param {{ live?: ReturnType<typeof createLiveMatch>, now?: number }} [ctx]
* @returns {any|null}
*/
export function recordQuit(state, ctx = {}) {
const live = ctx.live ?? liveMatch;
const rec = storeRecord(buildQuitRecord(state, ctx));
live.active = false; // one record per match
return rec;
}
/**
* Merge two envelopes (import into existing): union by record id, newest first, capped.
* @param {{ records: any[] }} a current (wins ties)
* @param {{ records: any[] }} b incoming
* @returns {{ v: number, records: any[] }}
*/
export function mergeStats(a, b) {
const byId = new Map();
for (const r of [...(Array.isArray(b?.records) ? b.records : []), ...(Array.isArray(a?.records) ? a.records : [])]) {
const rec = normalizeRecord(r);
if (rec) byId.set(rec.id, rec);
}
const records = [...byId.values()].sort(newestFirst).slice(0, MAX_RECORDS);
return { v: STATS_VERSION, records };
}
/**
* Parse + migrate + merge an exported / pasted payload into the current data.
* @param {any} raw parsed JSON of the imported file
* @param {{ records: any[] }} current current envelope
* @returns {{ stats: { v: number, records: any[] }, added: number, skipped: number, dropped: number }}
* added = records new to this device; skipped = duplicates of records it has; dropped = rows of the file that could
* not be read
*/
export function importStats(raw, current) {
if (isObj(raw) && raw.kind != null && raw.kind !== 'local-stats') {
const err = new Error('not a stats export'); // the page shows its own text
err.code = 'stats-bad-file';
throw err;
}
if (!isObj(raw) || !Array.isArray(raw.records)) {
const err = new Error('not a stats export');
err.code = 'stats-bad-file';
throw err;
}
const { stats: incoming, newer, dropped } = migrateStats(raw);
if (newer) {
const err = new Error('exported by a newer client'); // the page shows its own text
err.code = 'stats-newer-version';
throw err;
}
const have = new Set((Array.isArray(current?.records) ? current.records : []).map((r) => r.id));
const merged = mergeStats(current, incoming);
const added = incoming.records.filter((r) => !have.has(r.id)).length;
return { stats: merged, added, skipped: incoming.records.length - added, dropped };
}
/**
* The payload the export button downloads (envelope + a little provenance).
* @param {{ records: any[] }} stats
* @returns {{ app: string, kind: string, v: number, exportedAt: string, records: any[] }}
*/
export function exportStats(stats) {
return {
app: 'stronghold-protocol',
kind: 'local-stats',
v: STATS_VERSION,
exportedAt: new Date().toISOString(),
records: fitToBudget(Array.isArray(stats?.records) ? stats.records : []),
};
}
// ---- aggregation (what the page shows; records may hold more than any view uses) ------------------------------------
const SELF_STATS = ['dmgDealt', 'kills', 'bossDamage', 'activatedLayers', 'merges', 'itemsEquipped', 'gold', 'perfectRounds', 'refreshes', 'leaks', 'lpLost'];
/**
* The row that "is me" in a record: selfId, else the only human, else seat 0 — records written by
* recordResult always have selfId; the fallbacks only serve imported / hand-made data.
* @param {any} rec
* @returns {any|null}
*/
export function selfRowOf(rec) {
const ps = Array.isArray(rec?.players) ? rec.players : [];
return ps.find((p) => p.playerId && p.playerId === rec.selfId)
|| ps.find((p) => !p.isBot)
|| ps[0]
|| null;
}
/** Whether this record counts as a win for its self player (per-row victory = team won AND still in). */
const selfWon = (rec, self) => (self ? !!self.victory : !!rec.victory);
/**
* The rounds the player passed in a record: its own row's (the table the settlement prints per player), else the
* team's; 0 when the record does not know.
* @param {any} rec
*/
export function roundsOf(rec) {
const self = selfRowOf(rec);
const v = self && Number.isFinite(self.roundsPassed) ? self.roundsPassed : rec?.roundsPassed;
return Number.isFinite(v) ? v : 0;
}
/**
* Whether a record enters the aggregates (see the header). A settled record always does. A quit record does once the
* player had finished at least one battle: cleared a round, or was already eliminated; a match given up before that —
* entered and left right away — is history only.
* @param {any} rec a normalized record
* @returns {boolean}
*/
export function countsTowardStats(rec) {
if (!isObj(rec)) return false;
if (rec.end !== 'quit') return true;
const self = selfRowOf(rec);
if (!self) return false;
return self.alive === false || (Number.isFinite(self.roundsPassed) && self.roundsPassed >= 1);
}
/**
* Aggregate records for the stats page — over the records that count; `total` / `excluded` say how many did not.
* Everything is keyed by ids (bandId / titleId / difficulty); the page maps them to names and icons through the game
* data. The combat totals read the settled records' stat blocks (a quit record has none).
* @param {any[]} records
* @returns {any}
*/
export function aggregateStats(records) {
const all = Array.isArray(records) ? records : [];
const rs = all.filter(countsTowardStats);
const out = {
total: all.length, count: rs.length, excluded: all.length - rs.length, wins: 0,
rounds: { total: 0, max: 0 },
duration: { totalMs: 0 },
hidden: { reached: 0, cleared: 0 },
byDifficulty: {}, byMode: { solo: 0, coop: 0 },
titles: {}, bands: {}, sums: {},
};
for (const rec of rs) {
const self = selfRowOf(rec);
const won = selfWon(rec, self);
if (won) out.wins++;
const rounds = roundsOf(rec);
out.rounds.total += rounds;
out.rounds.max = Math.max(out.rounds.max, rounds);
out.duration.totalMs += num(rec.durationMs) || 0;
if (rec.hiddenReached) out.hidden.reached++;
if (rec.hiddenCleared) out.hidden.cleared++;
const d = rec.difficulty || 'UNKNOWN';
const bd = (out.byDifficulty[d] ||= { games: 0, wins: 0, hiddenCleared: 0 });
bd.games++;
if (won) bd.wins++;
if (rec.hiddenCleared) bd.hiddenCleared++;
if (rec.roomMode === 'solo' || rec.roomMode === 'coop') out.byMode[rec.roomMode]++;
const titleId = self?.title?.id;
if (titleId) {
const t = (out.titles[titleId] ||= { count: 0 });
t.count++;
}
const bandId = self?.bandId;
if (bandId) {
const b = (out.bands[bandId] ||= { games: 0, wins: 0 });
b.games++;
if (won) b.wins++;
}
if (rec.end !== 'quit' && self && isObj(self.stats)) {
for (const k of SELF_STATS) {
const v = self.stats[k];
if (Number.isFinite(v)) out.sums[k] = (out.sums[k] || 0) + v;
}
}
}
return out;
}