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.
This commit is contained in:
sganggs committed 2026-10-08 20:36:41 +08:00
1 parent 438a6b521a
commit 51e0e60b6e
20 files changed
+1808 -530

No files matched your search

+486 -77
View File
@@ -1,18 +1,24 @@
// Unit tests (Node) for the 本机对局统计 record store (public/js/ui/stats.js):
// Unit tests (Node) for the 本机对局统计 record store (public/js/ui/stats.js; PR #323 by @2321Robin, reworked for 0.2.2):
// record building from a raw m.result (spectator copies and player-less error results build nothing),
// replay dedupe by content id (the lobby re-pushes m.result on reconnect / reload),
// the MAX_RECORDS cap, migration / normalization (old + partial + unknown fields preserved, newer refused),
// import merge semantics and the aggregate the stats page renders.
// localStorage is faked per test (Node has none); recordResult / loadStats / saveStats go through it.
// the counting rule (a match left before a round was cleared is history only — the owner's decision of 2026-10-08),
// the match left early (放弃模拟): quit records built from the page's view of the match,
// bounded storage (MAX_RECORDS, MAX_CHARS, the compacted older records, a refused write),
// corrupt / old storage (unreadable JSON, damaged rows, a pre-`end` envelope, a newer one never overwritten),
// import merge semantics (newest first) and the aggregate the stats page renders.
// localStorage is faked per test (Node has none); recordResult / recordQuit / loadStats / saveStats go through it.
import { test, describe, beforeEach, afterEach } from 'node:test';
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
STATS_VERSION, MAX_RECORDS, emptyStats, buildRecord, recordId, appendRecord, recordResult,
loadStats, saveStats, migrateStats, normalizeRecord, mergeStats, importStats, exportStats,
aggregateStats, selfRowOf, roomModeOf, recordToResult, MIGRATIONS,
STATS_VERSION, MAX_RECORDS, MAX_CHARS, FULL_KEEP, emptyStats, buildRecord, recordId, appendRecord, recordResult, recordQuit,
loadStats, saveStats, migrateStats, normalizeRecord, importStats, exportStats, aggregateStats, selfRowOf, roomModeOf,
recordToResult, compactRecord, fitToBudget, countsTowardStats, roundsOf, clearedRounds, createLiveMatch, observeMatch,
buildQuitRecord, installStatsRecorder, liveMatch, MIGRATIONS,
} from '../../public/js/ui/stats.js';
import { createStore, emptyMatch } from '../../public/js/store.js';
import { normalizeResult } from '../../public/js/ui/gameLogic.js';
import { PHASE } from '../../shared/constants.js';
/** A realistic m.result in the shape server/match/results.js buildResult emits (replayed byte-identically). */
function baseResult(over = {}) {
@@ -28,7 +34,7 @@ function baseResult(over = {}) {
title: { id: 'comment_1', name: '卫戍之星', picId: 'comment_icon_1', text: '对敌方领袖造成伤害最高(仅胜利时)' },
trophies: 30, reward: 88,
lineup: [{ id: 'char_290_vigna', golden: false, tier: 2, row: 10, col: 5, items: ['item_eq_1'] }],
bonds: [{ bondId: 'steadShip', layers: 12, active: true }],
bonds: [{ bondId: 'steadShip', count: 3, tier: 1, layers: 12, active: true }],
stats: { dmgDealt: 15234.7, kills: 41, leaks: 2, gold: 880, refreshes: 5, merges: 6, itemsEquipped: 4, bossDamage: 5000.5, activatedLayers: 99, lpLost: 3, perfectRounds: 7 },
},
{
@@ -44,21 +50,56 @@ function baseResult(over = {}) {
/** Fake localStorage: Map-backed, with an optional byte budget that makes setItem throw like a full quota. */
function fakeStorage({ budget = Infinity } = {}) {
const m = new Map();
const calls = [];
return {
calls,
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem(k, v) { calls.push(v.length); if (v.length > budget) { const e = new Error('quota'); e.name = 'QuotaExceededError'; throw e; } m.set(k, String(v)); },
removeItem: (k) => m.delete(k),
setItem(k, v) { if (String(v).length > budget) { const e = new Error('quota'); e.name = 'QuotaExceededError'; throw e; } m.set(k, String(v)); },
removeItem: (k) => { m.delete(k); },
get raw() { return m.get('sp.pref.stats') ?? null; },
get damaged() { return m.get('sp.pref.stats.damaged') ?? null; },
};
}
/** Run `fn` with a fake localStorage installed. */
function withStorage(opts, fn) {
if (typeof opts === 'function') { fn = opts; opts = {}; }
const ls = fakeStorage(opts);
globalThis.localStorage = ls;
try { return fn(ls); } finally { delete globalThis.localStorage; }
}
/** n settled records, newest first, t = n−1 … 0 */
function manyRecords(n, mk = (i) => baseResult({ seed: i })) {
let stats = emptyStats();
for (let i = 0; i < n; i++) stats = appendRecord(stats, buildRecord(mk(i), { myId: 'p1', now: i })).stats;
return stats;
}
// ---- the match on screen, as the app store holds it ---------------------------------------------------------------------
/** An app state with a running match: `phase` of `round`, the local player p1 (alive unless `alive: false`). */
function liveState({ phase = PHASE.PREP, round = 1, alive = true, spectator = false, result = null, roomMode = 'solo', difficulty = 'HARD' } = {}) {
const players = spectator ? [{ playerId: 'p9', seat: 0, name: '别人', alive: true, lp: 20, bandId: 'band_x' }]
: [{ playerId: 'p1', seat: 0, name: '罗宾', alive, lp: alive ? 22 : 0, bandId: 'band_amiya' }];
return {
me: { playerId: 'p1', name: '罗宾' },
room: { code: 'ABCD', mode: roomMode },
match: {
public: { phase, round, lastRound: 14, modeId: roomMode === 'solo' ? `mode_single_${difficulty.toLowerCase()}` : `mode_multi_${difficulty.toLowerCase()}`, difficulty, stageId: 'stage_a', bossId: 'boss_2', hiddenBossId: 'boss_10', players },
private: null, field: null, result, battle: null,
},
};
}
/** The tracker as it would stand after watching `states` (one `now` per state). */
function watched(states, t0 = 1_000_000) {
const live = createLiveMatch();
states.forEach((s, i) => observeMatch(live, s, t0 + i * 1000));
return live;
}
describe('local stats: record building', () => {
test('builds one full record from a raw m.result (frame + players + self marking)', () => {
const rec = buildRecord(baseResult(), { myId: 'p1', roomMode: 'solo', now: 1700000000000 });
assert.ok(rec);
assert.equal(rec.v, STATS_VERSION);
assert.equal(rec.end, 'settled');
assert.equal(rec.t, 1700000000000);
assert.equal(rec.selfId, 'p1');
assert.equal(rec.victory, true);
@@ -70,12 +111,25 @@ describe('local stats: record building', () => {
assert.equal(rec.players.length, 2);
const self = rec.players[0];
assert.equal(self.bandId, 'band_amiya');
assert.equal(self.title.id, 'comment_1');
assert.deepEqual(self.title, { id: 'comment_1' }, 'the title is kept by id: its texts come from the game data');
assert.equal(self.stats.bossDamage, 5000.5);
assert.deepEqual(self.lineup, [{ id: 'char_290_vigna', golden: false, tier: 2, items: ['item_eq_1'] }]);
assert.deepEqual(self.bonds, [{ bondId: 'steadShip', layers: 12, active: true }]);
});
test('a 自选 pick and a 补位 stand-in survive in the lineup, or the replayed card would draw the plain operator', () => {
const res = baseResult();
res.players[0].lineup = [
{ id: 'chess_diy_a', golden: true, tier: 5, row: 1, col: 1, items: [], diy: { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege', extra: 'x' } },
{ id: 'chess_x', golden: false, tier: 1, items: [], standInFor: 'char_002_amiya' },
{ id: 'tok_1', kind: 'token', golden: false, tier: 0, items: [] },
];
const back = buildRecord(res, { myId: 'p1', now: 1 }).players[0].lineup;
assert.deepEqual(back[0].diy, { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' });
assert.equal(back[1].standInFor, 'char_002_amiya');
assert.equal(back[2].kind, 'token');
});
test('a spectator seat copy (my id not among players) records nothing; so do player-less error results', () => {
assert.equal(buildRecord(baseResult(), { myId: 'spec9', now: 1 }), null);
assert.equal(buildRecord(baseResult({ players: [] }), { myId: 'p1', now: 1 }), null);
@@ -90,9 +144,9 @@ describe('local stats: record building', () => {
});
});
describe('local stats: replay dedupe + cap', () => {
describe('local stats: replay dedupe', () => {
test('the replayed m.result (same content) appends once; a different seed is a new game', () => {
let stats = emptyStats();
const stats = emptyStats();
const res = baseResult();
const first = appendRecord(stats, buildRecord(res, { myId: 'p1', now: 1000 }));
assert.equal(first.added, true);
@@ -107,8 +161,7 @@ describe('local stats: replay dedupe + cap', () => {
});
test('recordResult end-to-end over faked localStorage: replay arrives, nothing is written twice', () => {
globalThis.localStorage = fakeStorage();
try {
withStorage(() => {
const res = baseResult();
const rec = recordResult(res, { myId: 'p1', roomMode: 'solo', now: 1000 });
assert.ok(rec);
@@ -116,92 +169,418 @@ describe('local stats: replay dedupe + cap', () => {
const loaded = loadStats();
assert.equal(loaded.records.length, 1);
assert.equal(loaded.records[0].id, rec.id);
} finally { delete globalThis.localStorage; }
});
});
});
describe('local stats: the counting rule (a match entered and left right away does not count)', () => {
/** A quit record whose player left in `phase` of `round` (alive unless told otherwise). */
const quitAt = (phase, round, o = {}) => {
const s = liveState({ phase, round, ...o });
return buildQuitRecord(s, { live: watched([liveState({ phase: PHASE.INFO_CHECK, round: 0 }), s]), now: 5_000_000 });
};
test('a settled match always counts — even one lost in the first rounds', () => {
const rec = buildRecord(baseResult({ victory: false, roundsPassed: 0, players: [{ ...baseResult().players[0], victory: false, alive: false, roundsPassed: 0 }] }), { myId: 'p1', now: 1 });
assert.equal(rec.end, 'settled');
assert.equal(countsTowardStats(rec), true);
});
test(`the store caps at MAX_RECORDS (${MAX_RECORDS}), dropping the oldest`, () => {
let stats = emptyStats();
for (let i = 0; i < MAX_RECORDS + 30; i++) {
stats = appendRecord(stats, buildRecord(baseResult({ seed: i }), { myId: 'p1', now: i })).stats;
test('left before the first round was cleared: history only; once a round is cleared it counts', () => {
// the server's rule (platform.js _quit): leaving while round k runs has passed k − 1 rounds, leaving in its settlement k
for (const [phase, round, passed, counts] of [
[PHASE.INFO_CHECK, 0, 0, false], [PHASE.BAND_DRAFT, 0, 0, false], [PHASE.PREP, 1, 0, false], [PHASE.COMBAT, 1, 0, false],
[PHASE.SETTLE, 1, 1, true], [PHASE.PREP, 2, 1, true], [PHASE.COMBAT, 9, 8, true], [PHASE.FINAL_ASSAULT, 14, 13, true],
]) {
const rec = quitAt(phase, round);
assert.equal(rec.end, 'quit', `${phase} ${round}`);
assert.equal(rec.players[0].roundsPassed, passed, `${phase} ${round}: rounds cleared`);
assert.equal(clearedRounds(phase, round), passed);
assert.equal(countsTowardStats(rec), counts, `${phase} ${round}: counts`);
}
});
test('left after being eliminated: it counts (the player lost it), the rounds are those it was seen alive in, less one', () => {
const s = [liveState({ phase: PHASE.INFO_CHECK, round: 0 }), liveState({ round: 4 }), liveState({ round: 5 }), liveState({ phase: PHASE.SETTLE, round: 5, alive: false }), liveState({ phase: PHASE.PREP, round: 8, alive: false })];
const live = watched(s);
const rec = buildQuitRecord(s[4], { live, now: 9e6 });
assert.equal(rec.players[0].alive, false);
assert.equal(rec.players[0].eliminatedRound, 5);
assert.equal(rec.players[0].roundsPassed, 4, 'eliminated in round 5 ⇒ 4 passed (the round 8 it left in is not its)');
assert.equal(roundsOf(rec), 4);
assert.equal(countsTowardStats(rec), true);
// a page that never saw it alive (a reload after the elimination) does not know the rounds — it still counts
const unknown = buildQuitRecord(s[4], { live: watched([s[4]]), now: 9e6 });
assert.equal(unknown.players[0].roundsPassed, null);
assert.equal(roundsOf(unknown), 0);
assert.equal(countsTowardStats(unknown), true);
});
test('the aggregates take only the records that count; total / excluded say what was left out', () => {
const settled = buildRecord(baseResult(), { myId: 'p1', roomMode: 'solo', now: 3000 });
const peek = quitAt(PHASE.PREP, 1); // entered, left in the first round
const played = quitAt(PHASE.PREP, 6); // 5 rounds cleared, then gave up
const agg = aggregateStats([settled, peek, played]);
assert.equal(agg.total, 3);
assert.equal(agg.count, 2);
assert.equal(agg.excluded, 1);
assert.equal(agg.wins, 1);
assert.equal(agg.rounds.max, 16, 'the best of the player\'s own rounds');
assert.equal(agg.rounds.total, 16 + 5, 'the peek adds nothing');
assert.equal(agg.byDifficulty.HARD.games, 2);
assert.deepEqual(agg.bands.band_amiya, { games: 2, wins: 1 });
assert.equal(agg.byMode.solo, 2);
});
test('combat totals read the settled records only (a quit record has no stat block)', () => {
const agg = aggregateStats([buildRecord(baseResult(), { myId: 'p1', now: 1 }), quitAt(PHASE.PREP, 6)]);
assert.equal(agg.sums.kills, 41);
assert.equal(agg.count, 2);
});
test('the history keeps every record, counted or not: the page filters nothing out of the list', () => {
const peek = quitAt(PHASE.INFO_CHECK, 0);
assert.equal(countsTowardStats(peek), false);
withStorage(() => {
let stats = emptyStats();
stats = appendRecord(stats, peek).stats;
stats = appendRecord(stats, buildRecord(baseResult(), { myId: 'p1', now: 7e6 })).stats;
assert.equal(saveStats(stats), true);
const back = loadStats();
assert.equal(back.records.length, 2);
assert.deepEqual(back.records.map((r) => r.end).sort(), ['quit', 'settled']);
assert.equal(back.records.find((r) => r.end === 'quit').players[0].roundsPassed, 0);
});
});
});
describe('local stats: the match left early (放弃模拟)', () => {
test('builds a quit record from the page\'s view: frame, own row, no teammates, no stats, no replay', () => {
const s = liveState({ phase: PHASE.PREP, round: 3, roomMode: 'coop', difficulty: 'ABYSS' });
const rec = buildQuitRecord(s, { live: watched([liveState({ phase: PHASE.INFO_CHECK, round: 0, roomMode: 'coop', difficulty: 'ABYSS' }), s]), now: 2_000_000 });
assert.equal(rec.end, 'quit');
assert.equal(rec.reason, 'quit');
assert.equal(rec.victory, false);
assert.equal(rec.difficulty, 'ABYSS');
assert.equal(rec.roomMode, 'coop');
assert.equal(rec.modeId, 'mode_multi_abyss');
assert.equal(rec.selfId, 'p1');
assert.equal(rec.players.length, 1);
assert.equal(rec.players[0].left, true);
assert.equal(rec.players[0].bandId, 'band_amiya');
assert.deepEqual(rec.players[0].stats, {});
assert.equal(rec.durationMs, 1_000_000, 'seen from the start: now − when the page first saw it');
assert.equal(recordToResult(rec), null, 'nothing was settled: no settlement screen to show');
assert.match(rec.id, /^q\./);
});
test('no record for: no match on screen, a settlement already there, a spectator seat, a match the page never saw', () => {
const s = liveState({ round: 2 });
const live = watched([s]);
assert.equal(buildQuitRecord({ ...s, match: { ...s.match, public: null } }, { live }), null);
assert.equal(buildQuitRecord(liveState({ round: 2, result: { victory: true, players: [] } }), { live }), null);
assert.equal(buildQuitRecord(liveState({ round: 2, spectator: true }), { live: watched([liveState({ round: 2, spectator: true })]) }), null);
assert.equal(buildQuitRecord(s, { live: createLiveMatch() }), null);
});
test('a match first seen mid-way (a reload) keeps no duration; the tracker restarts for the next match', () => {
const s = liveState({ phase: PHASE.PREP, round: 6 });
const live = watched([s]);
assert.equal(live.sawStart, false);
assert.equal(buildQuitRecord(s, { live, now: 9e6 }).durationMs, null);
// the match ends (result) and a new one starts: a fresh start
observeMatch(live, liveState({ phase: PHASE.RESULT, round: 14, result: { victory: true } }), 1);
assert.equal(live.active, false);
observeMatch(live, liveState({ phase: PHASE.INFO_CHECK, round: 0 }), 2);
assert.equal(live.active, true);
assert.equal(live.sawStart, true);
});
test('recordQuit stores it through localStorage once; the settlement of the same match is a different record', () => {
withStorage(() => {
const s = liveState({ phase: PHASE.PREP, round: 4 });
const live = watched([liveState({ phase: PHASE.INFO_CHECK, round: 0 }), s]);
const rec = recordQuit(s, { live, now: 3e6 });
assert.ok(rec);
assert.equal(live.active, false, 'one record per match');
assert.equal(recordQuit(s, { live, now: 3e6 }), null);
assert.equal(loadStats().records.length, 1);
assert.equal(loadStats().records[0].players[0].roundsPassed, 3);
});
});
});
describe('local stats: following the app store', () => {
test('installStatsRecorder watches a match from its start; a quit is recorded with its duration, then the tracker rests', () => {
withStorage(() => {
const first = liveState({ phase: PHASE.INFO_CHECK, round: 0 });
const store = createStore({ me: first.me, room: first.room, match: emptyMatch() });
const off = installStatsRecorder(store);
try {
assert.equal(liveMatch.active, false);
store.patch('match', { public: first.match.public });
assert.equal(liveMatch.active, true);
assert.equal(liveMatch.sawStart, true);
store.patch('match', { public: liveState({ phase: PHASE.PREP, round: 3 }).match.public });
const rec = recordQuit(store.get());
assert.ok(rec);
assert.equal(rec.players[0].roundsPassed, 2);
assert.ok(rec.durationMs >= 0);
assert.equal(liveMatch.active, false, 'recorded once');
// the app clears the match: still at rest; a new match is a fresh start
store.set({ match: emptyMatch() });
assert.equal(liveMatch.active, false);
store.patch('match', { public: first.match.public });
assert.equal(liveMatch.active, true);
} finally { off(); }
});
});
});
describe('local stats: bounded storage', () => {
test(`the store caps at MAX_RECORDS (${MAX_RECORDS}), dropping the oldest`, () => {
const stats = manyRecords(MAX_RECORDS + 30);
assert.equal(stats.records.length, MAX_RECORDS);
assert.equal(stats.records[0].t, MAX_RECORDS + 29); // newest first
assert.equal(stats.records.at(-1).t, 30); // oldest 30 dropped
});
test(`only the newest ${FULL_KEEP} records keep their rows: the older ones are compacted to the player's own summary`, () => {
withStorage((ls) => {
assert.equal(saveStats(manyRecords(FULL_KEEP + 20)), true);
const stored = JSON.parse(ls.raw).records;
assert.equal(stored.length, FULL_KEEP + 20);
assert.equal(stored.slice(0, FULL_KEEP).every((r) => !r.compact && r.players.length === 2), true);
const old = stored.at(-1);
assert.equal(old.compact, true);
assert.equal(old.players.length, 1, 'no teammates');
assert.equal(old.players[0].name, undefined, 'no names');
assert.equal(old.players[0].lineup, undefined);
// what the aggregates read is all still there
const back = loadStats().records;
assert.deepEqual(aggregateStats(back).bands, aggregateStats(manyRecords(FULL_KEEP + 20).records).bands);
assert.equal(aggregateStats(back).sums.kills, 41 * (FULL_KEEP + 20));
// …and the settlement of a compacted record cannot be re-viewed
assert.equal(recordToResult(back.at(-1)), null);
assert.ok(recordToResult(back[0]));
});
});
test('compactRecord is idempotent and keeps the frame', () => {
const rec = buildRecord(baseResult(), { myId: 'p1', now: 5 });
const c = compactRecord(rec);
assert.equal(JSON.stringify(compactRecord(normalizeRecord(c))), JSON.stringify(c));
assert.equal(c.difficulty, 'HARD');
assert.equal(c.durationMs, 952123);
assert.equal(selfRowOf(normalizeRecord(c)).bandId, 'band_amiya');
});
test(`a store of ${MAX_RECORDS} real-size co-op matches fits MAX_CHARS (${MAX_CHARS}) — the oldest few go; solo matches all stay`, () => {
// a 4-player settlement with 10-unit lineups, 12 bonds and every stat, as server/match/results.js builds it
const row = (i) => ({
playerId: `p${i}`, seat: i, name: `博士${i}`, isBot: i > 1, left: false, alive: true, victory: true, roundsPassed: 14, eliminatedRound: null, lp: 12,
bandId: `band_${i}`, title: { id: 'comment_3', name: '坚若磐石', picId: 'comment_icon_3', text: '目标生命值损失最少' }, trophies: 5, reward: 425,
lineup: Array.from({ length: 10 }, (_, k) => ({ id: `chess_char_${k}_0${i}_a`, golden: k % 2 === 0, tier: 1 + (k % 6), row: 12, col: k, items: ['chess_item_4_08_e_a', 'chess_item_4_07_e_a'] })),
bonds: Array.from({ length: 12 }, (_, k) => ({ bondId: `bond${k}Ship`, count: 3, tier: 1, layers: 100 + k, active: true })),
stats: { dmgDealt: 6871190, kills: 393, leaks: 21, gold: 148, refreshes: 23, merges: 3, itemsEquipped: 33, bossDamage: 945575, activatedLayers: 246, lpLost: 10, perfectRounds: 7 },
});
const coop = (i) => baseResult({ seed: i, modeId: 'mode_multi_hard', players: [row(1), row(2), row(3), row(4)].map((r, k) => (k === 0 ? { ...r, playerId: 'p1' } : r)) });
const stats = manyRecords(MAX_RECORDS, coop);
const fit = fitToBudget(stats.records);
assert.ok(fit.length >= 900 && fit.length <= MAX_RECORDS, `kept ${fit.length}`);
assert.equal(fit[0].t, MAX_RECORDS - 1, 'the newest first');
assert.ok(JSON.stringify({ v: 1, records: fit }).length <= MAX_CHARS);
withStorage((ls) => { assert.equal(saveStats(stats), true); assert.ok(ls.raw.length <= MAX_CHARS); });
const solo = manyRecords(MAX_RECORDS, (i) => baseResult({ seed: i, players: [baseResult().players[0]] }));
assert.equal(fitToBudget(solo.records).length, MAX_RECORDS);
});
test('over the character budget the oldest records go first; the newest is always kept', () => {
const stats = manyRecords(200);
const fit = fitToBudget(stats.records, 60_000);
assert.ok(fit.length < 200 && fit.length > FULL_KEEP, `kept ${fit.length}`);
assert.equal(fit[0].t, 199);
assert.equal(fit.at(-1).t, 200 - fit.length);
assert.ok(JSON.stringify({ v: 1, records: fit }).length <= 60_000);
// a budget below one record still keeps the newest
assert.deepEqual(fitToBudget(stats.records, 10).map((r) => r.t), [199]);
});
test('a write the browser refuses (quota) is retried with half the history; a hopeless one reports failure instead of throwing', () => {
const probe = JSON.stringify({ v: STATS_VERSION, records: [compactRecord(buildRecord(baseResult(), { myId: 'p1', now: 1 }))] }).length;
withStorage({ budget: probe * 40 }, (ls) => {
const stats = manyRecords(300);
assert.equal(saveStats(stats), true);
const back = JSON.parse(ls.raw).records;
assert.ok(back.length < 300 && back.length >= 8, `shrank to ${back.length}`);
assert.equal(back[0].t, 299, 'the newest kept');
});
withStorage({ budget: 10 }, () => assert.equal(saveStats(manyRecords(20)), false));
// no localStorage at all (a locked-down browser): quiet failure
assert.equal(saveStats(manyRecords(2)), false);
assert.deepEqual(loadStats().records, []);
});
test('every string and list of an (imported) record is bounded', () => {
const rec = normalizeRecord({
players: Array.from({ length: 30 }, (_, i) => ({
playerId: `p${i}`, name: 'N'.repeat(500), bandId: 'b'.repeat(500),
lineup: Array.from({ length: 100 }, (_, k) => ({ id: `u${k}`, items: Array(50).fill('x'.repeat(500)) })),
bonds: Array.from({ length: 100 }, (_, k) => ({ bondId: `b${k}`, layers: 1 })),
stats: Object.fromEntries(Array.from({ length: 200 }, (_, k) => [`stat${k}`, k])),
bigUnknown: 'z'.repeat(1e5),
})),
bigUnknown: 'y'.repeat(1e6),
});
assert.ok(JSON.stringify(rec).length < 40_000, `${JSON.stringify(rec).length} chars`);
assert.equal(rec.players.length, 6);
assert.equal(rec.players[0].lineup.length, 12);
assert.equal(rec.players[0].lineup[0].items.length, 4);
assert.equal(rec.players[0].name.length, 24);
assert.equal(Object.keys(rec.players[0].stats).length, 24);
assert.equal(rec.bigUnknown, undefined);
});
});
describe('local stats: migration / normalization', () => {
test('an unversioned envelope (older shape) normalizes with defaults and self-heals on load', () => {
globalThis.localStorage = fakeStorage();
try {
const old = { records: [{ players: [{ playerId: 'p1', stats: { kills: 2 }, futureField: { x: 1 } }] }] };
globalThis.localStorage.setItem('sp.pref.stats', JSON.stringify(old));
describe('local stats: corrupt and old storage', () => {
test('an unversioned PR-era envelope (no `end`) normalizes with defaults as settled records and self-heals on load', () => {
withStorage((ls) => {
const old = { records: [{ players: [{ playerId: 'p1', stats: { kills: 2 }, futureField: { x: 1 } }], futureTop: 1 }] };
ls.setItem('sp.pref.stats', JSON.stringify(old));
const loaded = loadStats();
assert.equal(loaded.v, STATS_VERSION);
assert.equal(loaded.damaged, false);
assert.equal(loaded.records.length, 1);
const rec = loaded.records[0];
assert.equal(rec.end, 'settled');
assert.equal(countsTowardStats(rec), true);
assert.equal(rec.victory, false);
assert.equal(rec.roundsPassed, 0);
assert.equal(rec.selfId, null);
assert.equal(rec.players[0].roundsPassed, 0);
assert.equal(rec.players[0].alive, true);
assert.deepEqual(rec.players[0].stats, { kills: 2 });
assert.deepEqual(rec.players[0].futureField, { x: 1 }); // unknown fields survive for a newer reader
const rewritten = JSON.parse(globalThis.localStorage.getItem('sp.pref.stats'));
assert.ok(rewritten);
assert.equal(rec.players[0].futureField, undefined, 'a strict schema: unknown fields do not survive (a newer build bumps the version instead)');
assert.equal(rec.futureTop, undefined);
assert.match(rec.id, /^c\./, 'an id-less row gets a content id');
const rewritten = JSON.parse(ls.raw);
assert.equal(rewritten.v, STATS_VERSION); // self-healed back to storage
} finally { delete globalThis.localStorage; }
assert.equal(rewritten.records[0].id, rec.id);
});
});
test('broken records are dropped; a NEWER envelope is returned untouched and flagged (never saved over)', () => {
globalThis.localStorage = fakeStorage();
try {
globalThis.localStorage.setItem('sp.pref.stats', JSON.stringify({ v: STATS_VERSION, records: [null, { nope: 1 }, { players: [] }, baseResult()] }));
// baseResult is an m.result (players present) → normalizes into a record; the rest are dropped
test('unreadable storage (not JSON, not an envelope) is set aside as a backup and the history starts again', () => {
for (const bad of ['{"v":1,"records":[', 'null', '[1,2,3]', '"hello"', '{"v":1}', '{"records":"nope"}']) {
withStorage((ls) => {
ls.setItem('sp.pref.stats', bad);
const loaded = loadStats();
assert.equal(loaded.damaged, true, bad);
assert.deepEqual(loaded.records, [], bad);
assert.equal(ls.damaged, bad, `${bad}: kept where a person can find it`);
assert.equal(ls.raw, null);
// and recording works again straight away
assert.ok(recordResult(baseResult(), { myId: 'p1', now: 1 }));
assert.equal(loadStats().records.length, 1);
assert.equal(loadStats().damaged, false);
});
}
});
test('damaged rows are dropped and counted, the good ones kept (in order, newest first); the storage is rewritten without them', () => {
withStorage((ls) => {
const good = (seed, t) => ({ ...buildRecord(baseResult({ seed }), { myId: 'p1', now: t }) });
ls.setItem('sp.pref.stats', JSON.stringify({
v: STATS_VERSION,
records: [good(1, 100), null, 42, 'junk', { nope: 1 }, { players: [] }, { players: [null, 3] }, good(2, 300), good(3, 200)],
}));
const loaded = loadStats();
assert.deepEqual(loaded.records.map((r) => r.t), [300, 200, 100], 'sorted newest first');
assert.equal(loaded.dropped, 6);
assert.equal(JSON.parse(ls.raw).records.length, 3, 'self-healed');
assert.equal(loadStats().dropped, 0);
});
});
test('a record listed twice (hand-merged data) counts once', () => {
withStorage((ls) => {
const rec = buildRecord(baseResult(), { myId: 'p1', now: 100 });
ls.setItem('sp.pref.stats', JSON.stringify({ v: STATS_VERSION, records: [rec, { ...rec }, rec] }));
const loaded = loadStats();
assert.equal(loaded.records.length, 1);
assert.equal(loaded.dropped, 2);
assert.equal(aggregateStats(loaded.records).count, 1);
});
});
const newer = { v: STATS_VERSION + 5, records: [{ players: [], brandNew: true }] };
const mig = migrateStats(newer);
test('numbers that are not numbers, wrong types and absurd values normalize instead of crashing the page', () => {
const rec = normalizeRecord({
t: 'yesterday', victory: 'yes', roundsPassed: NaN, durationMs: '5', difficulty: 12, roomMode: 'both', end: 'whatever',
players: [{ playerId: 7, seat: 'x', name: {}, alive: 'no', stats: { kills: '3', gold: NaN, dmgDealt: 5, deep: { a: 1 } }, lineup: [1, null, { id: 5 }], bonds: 'many', title: 'comment_1' }],
});
assert.equal(rec.end, 'settled');
assert.equal(rec.t, 0);
assert.equal(rec.victory, false);
assert.equal(rec.roundsPassed, 0);
assert.equal(rec.durationMs, null);
assert.equal(rec.difficulty, null);
assert.equal(rec.roomMode, null);
const p = rec.players[0];
assert.equal(p.playerId, '');
assert.equal(p.alive, true);
assert.deepEqual(p.stats, { dmgDealt: 5 });
assert.deepEqual(p.lineup, []);
assert.deepEqual(p.bonds, []);
assert.equal(p.title, null);
// and the whole page reads it
assert.doesNotThrow(() => aggregateStats([rec]));
assert.doesNotThrow(() => recordToResult(rec));
});
test('a NEWER envelope is returned untouched and flagged — nothing records over it', () => {
withStorage((ls) => {
const newer = JSON.stringify({ v: STATS_VERSION + 5, records: [{ players: [], brandNew: true }] });
ls.setItem('sp.pref.stats', newer);
const loaded = loadStats();
assert.equal(loaded.newer, true);
assert.equal(ls.raw, newer);
assert.equal(recordResult(baseResult(), { myId: 'p1', now: 1 }), null, 'a result is not written over a newer build\'s data');
assert.equal(recordQuit(liveState({ round: 3 }), { live: watched([liveState({ round: 3 })]) }), null);
assert.equal(saveStats(loaded), false);
assert.equal(ls.raw, newer, 'still byte for byte');
const mig = migrateStats(JSON.parse(newer));
assert.equal(mig.newer, true);
assert.equal(mig.changed, false);
assert.deepEqual(mig.stats, { v: STATS_VERSION + 5, records: newer.records });
assert.equal(loadStats().newer, false);
} finally { delete globalThis.localStorage; }
});
});
test('a list longer than MAX_RECORDS (hand-edited, merged) is cut to the newest on load', () => {
withStorage((ls) => {
const recs = Array.from({ length: MAX_RECORDS + 50 }, (_, i) => buildRecord(baseResult({ seed: i }), { myId: 'p1', now: i }));
ls.setItem('sp.pref.stats', JSON.stringify({ v: STATS_VERSION, records: recs }));
const loaded = loadStats();
assert.equal(loaded.records.length, MAX_RECORDS);
assert.equal(loaded.records[0].t, MAX_RECORDS + 49);
assert.equal(loaded.dropped, 50);
});
});
test('the MIGRATIONS hook runs: v → v+1 steps apply in order (dry-run with a temporary step)', () => {
const original = MIGRATIONS[0];
MIGRATIONS[0] = ({ records }) => ({ v: 1, records: records.map((r) => ({ ...r, migrated: true })) });
MIGRATIONS[0] = ({ records }) => ({ v: 1, records: records.map((r) => ({ ...r, victory: true })) });
try {
const { stats, changed } = migrateStats({ v: 0, records: [{ players: [{ playerId: 'p1' }] }] });
assert.equal(changed, true);
assert.equal(stats.records[0].migrated, true);
assert.equal(stats.records[0].victory, true);
assert.equal(stats.v, STATS_VERSION);
} finally {
if (original === undefined) delete MIGRATIONS[0]; else MIGRATIONS[0] = original;
}
});
test('saveStats shrinks the history when the quota rejects the full write', () => {
// budget fits ~35 records but not 60: the full write throws, two quarter-shrinks land at 33
const probe = JSON.stringify({ v: STATS_VERSION, records: [baseResult()] });
globalThis.localStorage = fakeStorage({ budget: probe.length * 35 });
try {
let stats = emptyStats();
for (let i = 0; i < 60; i++) stats = appendRecord(stats, buildRecord(baseResult({ seed: i }), { myId: 'p1', now: i })).stats;
assert.equal(saveStats(stats), true);
const back = JSON.parse(globalThis.localStorage.getItem('sp.pref.stats'));
assert.ok(back.records.length < 60 && back.records.length > 8); // shrank instead of failing
assert.equal(back.records[0].t, 59); // newest kept, oldest dropped
} finally { delete globalThis.localStorage; }
});
});
describe('local stats: export / import merge', () => {
test('export → import round-trips; merge unions by id and counts added / skipped', () => {
globalThis.localStorage = fakeStorage();
try {
test('export → import round-trips; merge unions by id, newest first, and counts added / skipped', () => {
withStorage(() => {
const rec = recordResult(baseResult(), { myId: 'p1', now: 1000 });
const exported = exportStats(loadStats());
assert.equal(exported.kind, 'local-stats');
@@ -214,25 +593,43 @@ describe('local stats: export / import merge', () => {
assert.equal(same.skipped, 1);
assert.equal(same.stats.records.length, 1);
// a second device's store (one shared + one new record) merges by id
const theirs = exportStats({ records: [rec, buildRecord(baseResult({ seed: 777 }), { myId: 'p1', now: 5000 })] });
const merged = importStats(theirs, loadStats());
// a second device's store (one shared + one newer record) merges by id
const theirs = exportStats({ records: [buildRecord(baseResult({ seed: 777 }), { myId: 'p1', now: 5000 }), rec] });
const merged = importStats(JSON.parse(JSON.stringify(theirs)), loadStats());
assert.equal(merged.added, 1);
assert.equal(merged.skipped, 1);
assert.equal(merged.stats.records.length, 2);
assert.equal(merged.stats.records[0].t, 1000); // chronological
assert.equal(merged.stats.records[0].t, 5000, 'newest first: the history list shows the latest matches at the top');
saveStats(merged.stats);
assert.equal(loadStats().records.length, 2);
} finally { delete globalThis.localStorage; }
assert.deepEqual(loadStats().records.map((r) => r.t), [5000, 1000]);
// a match recorded after the import goes on top, and the cap would drop the oldest, not the newest
recordResult(baseResult({ seed: 5 }), { myId: 'p1', now: 9000 });
assert.deepEqual(loadStats().records.map((r) => r.t), [9000, 5000, 1000]);
});
});
test('importing a NEWER export refuses with a clear error instead of downgrading', () => {
test('an import over the cap keeps the newest MAX_RECORDS', () => {
const a = manyRecords(MAX_RECORDS);
const b = { v: 1, kind: 'local-stats', records: Array.from({ length: 40 }, (_, i) => buildRecord(baseResult({ seed: 5000 + i }), { myId: 'p1', now: 10_000 + i })) };
const out = importStats(JSON.parse(JSON.stringify(b)), a);
assert.equal(out.added, 40);
assert.equal(out.stats.records.length, MAX_RECORDS);
assert.equal(out.stats.records[0].t, 10_039);
});
test('importing a NEWER export, another tool\'s file or junk is refused with a code, nothing is merged', () => {
assert.throws(() => importStats({ v: STATS_VERSION + 1, records: [] }, emptyStats()), (e) => e.code === 'stats-newer-version');
assert.throws(() => importStats({ kind: 'stronghold.loadout', v: 1, records: [] }, emptyStats()), (e) => e.code === 'stats-bad-file');
for (const junk of [{ hello: 'world' }, [], 'text', 42, null]) {
assert.throws(() => importStats(junk, emptyStats()), (e) => e.code === 'stats-bad-file', JSON.stringify(junk));
}
});
test('garbage import is an ordinary empty envelope, not a crash in merge', () => {
const out = importStats({ hello: 'world' }, emptyStats());
assert.equal(out.added, 0);
assert.equal(out.stats.records.length, 0);
test('rows of an import that cannot be read are dropped and reported; the rest merge', () => {
const out = importStats({ kind: 'local-stats', v: 1, records: [buildRecord(baseResult(), { myId: 'p1', now: 1 }), null, { players: [] }, 'x'] }, emptyStats());
assert.equal(out.added, 1);
assert.equal(out.dropped, 3);
assert.equal(out.stats.records.length, 1);
});
});
@@ -249,9 +646,11 @@ describe('local stats: aggregation (what the page renders)', () => {
test('totals, wins (per-row victory), rounds and duration', () => {
assert.equal(agg.count, 2);
assert.equal(agg.total, 2);
assert.equal(agg.excluded, 0);
assert.equal(agg.wins, 1); // game 2 was a loss
assert.equal(agg.rounds.max, 15);
assert.equal(agg.rounds.total, 23);
assert.equal(agg.rounds.max, 16, 'the player\'s own rounds (the settlement prints them per player)');
assert.equal(agg.rounds.total, 16 + 8);
assert.equal(agg.duration.totalMs, 952123 + 300000);
assert.equal(agg.hidden.reached, 1);
assert.equal(agg.hidden.cleared, 1);
@@ -259,9 +658,11 @@ describe('local stats: aggregation (what the page renders)', () => {
assert.equal(agg.byMode.coop, 1);
});
test('per-difficulty rows', () => {
test('per-difficulty rows; an unknown difficulty is keyed UNKNOWN (never a translated text)', () => {
assert.deepEqual(agg.byDifficulty.HARD, { games: 1, wins: 1, hiddenCleared: 1 });
assert.deepEqual(agg.byDifficulty.NORMAL, { games: 1, wins: 0, hiddenCleared: 0 });
const odd = aggregateStats([normalizeRecord({ players: [{ playerId: 'p1' }] })]);
assert.deepEqual(Object.keys(odd.byDifficulty), ['UNKNOWN']);
});
test('per-band games / passed and self titles', () => {
@@ -281,9 +682,17 @@ describe('local stats: aggregation (what the page renders)', () => {
assert.equal(selfRowOf(noSelf).playerId, 'b');
assert.equal(selfRowOf({ players: [] }), null);
});
test('an empty list aggregates to zeros', () => {
const e = aggregateStats([]);
assert.equal(e.count, 0);
assert.equal(e.rounds.max, 0);
assert.deepEqual(e.byDifficulty, {});
assert.equal(aggregateStats(null).total, 0);
});
});
describe('local stats: recordToResult (最近对局行 → 结算页回看)', () => {
describe('local stats: recordToResult (history row → settlement re-view)', () => {
test('record → m.result payload → normalizeResult renders the same settlement the live push did', () => {
const rec = buildRecord(baseResult(), { myId: 'p1', roomMode: 'solo', now: 1000 });
const res = recordToResult(rec);