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:
1 parent
438a6b521a
commit
51e0e60b6e
20 files changed
+1808
-530
No files matched your search
+387
-112
@@ -1,35 +1,56 @@
|
||||
// Local match statistics: every finished match the local player took part in is appended as one record to
|
||||
// localStorage (`sp.pref.stats`, the loadPref/savePref convention of store.js). Per-browser only — the server
|
||||
// keeps matches in memory and has no identity, so there is nowhere else for cross-match numbers to live; the
|
||||
// stats page labels itself 本机数据 and offers JSON export / import for moving between devices.
|
||||
// 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.)
|
||||
//
|
||||
// The record keeps everything the settlement offers (m.result is free-form, server/match/results.js buildResult):
|
||||
// the match frame (difficulty, mode, boss, seed, duration, hidden-core outcome, reason) plus one row per player
|
||||
// — band, title (评语), lineup, bonds and the full stat block — even where today's page aggregates only a part.
|
||||
// Whatever a future statistics view needs should already be in the record; display is the only thing that lags.
|
||||
// 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.
|
||||
//
|
||||
// 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`, which fills defaults
|
||||
// for missing fields and PRESERVES unknown ones — so data written by an older build reads fine after an
|
||||
// upgrade, and fields added by a newer writer survive a round-trip through this one. An envelope from a newer
|
||||
// version than this build is refused (never silently downgraded and written back).
|
||||
// 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.
|
||||
//
|
||||
// Pure logic, no Preact / DOM: Node-testable (test/ui/stats.test.js); localStorage is touched only through
|
||||
// loadStats / saveStats.
|
||||
// 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 { loadPref } from '../store.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
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 (rough sizing: ~1.5 kB/record ⇒ ~1.5 MB of a ~5 MB quota). */
|
||||
/** Oldest records are dropped beyond this many matches. */
|
||||
export const MAX_RECORDS = 1000;
|
||||
/** How many games the page's 最近对局 list shows. */
|
||||
export const RECENT_SHOW = 20;
|
||||
/** 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: [] });
|
||||
@@ -38,18 +59,32 @@ 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);
|
||||
const str = (v) => (typeof v === 'string' && v.length ? v : null);
|
||||
/** 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; unknown numeric fields are
|
||||
* kept too — display can lag, data must not).
|
||||
* 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;
|
||||
for (const [k, v] of Object.entries(raw)) if (Number.isFinite(v)) out[k] = v;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -82,33 +117,42 @@ export function recordId(res, myId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize ONE player row: known fields defaulted, unknown fields preserved (forward compat).
|
||||
* 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) {
|
||||
function normalizePlayerRow(raw, quit = false) {
|
||||
const p = isObj(raw) ? raw : {};
|
||||
const title = isObj(p.title)
|
||||
? { id: str(p.title.id) || '', name: str(p.title.name) || '', picId: str(p.title.picId) || '', text: str(p.title.text) || '' }
|
||||
: null;
|
||||
const lineup = (Array.isArray(p.lineup) ? p.lineup : []).filter(isObj).slice(0, 12).map((u) => ({
|
||||
// ids only — the tile position (row/col) is replay detail, not a statistic; items kept (配发装备统计)
|
||||
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),
|
||||
})).filter((u) => u.id);
|
||||
const bonds = (Array.isArray(p.bonds) ? p.bonds : []).filter(isObj).map((b) => ({
|
||||
// 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 {
|
||||
...p, // unknown / future fields pass through untouched
|
||||
playerId: str(p.playerId) || '',
|
||||
seat: int(p.seat, 0),
|
||||
name: str(p.name) || t('博士'),
|
||||
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: int(p.roundsPassed, 0),
|
||||
roundsPassed: quit ? num(p.roundsPassed) : int(p.roundsPassed, 0),
|
||||
eliminatedRound: num(p.eliminatedRound),
|
||||
lp: num(p.lp),
|
||||
bandId: str(p.bandId),
|
||||
@@ -122,27 +166,32 @@ function normalizePlayerRow(raw) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize ONE match record to the current shape. Missing fields get defaults; unknown fields (a newer
|
||||
* writer's additions) survive untouched, so import → export does not lose them.
|
||||
* 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 players array at all)
|
||||
* @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 : {};
|
||||
// same rule as buildRecord: a record IS a played match — no usable players array, no record
|
||||
if (!Array.isArray(r.players) || !r.players.length) return null;
|
||||
const base = {
|
||||
id: str(r.id) || '',
|
||||
// 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: int(r.roundsPassed, 0),
|
||||
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),
|
||||
reason: str(r.reason, 32),
|
||||
modeId: str(r.modeId),
|
||||
difficulty: str(r.difficulty),
|
||||
difficulty: str(r.difficulty, 24),
|
||||
stageId: str(r.stageId),
|
||||
bossId: str(r.bossId),
|
||||
hiddenBossId: str(r.hiddenBossId),
|
||||
@@ -151,9 +200,12 @@ export function normalizeRecord(raw) {
|
||||
teamLp: num(r.teamLp),
|
||||
roomMode: r.roomMode === 'solo' || r.roomMode === 'coop' ? r.roomMode : null,
|
||||
selfId: str(r.selfId),
|
||||
players: r.players.filter(isObj).map(normalizePlayerRow),
|
||||
players,
|
||||
};
|
||||
return { ...r, ...base };
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,18 +216,22 @@ export function normalizeRecord(raw) {
|
||||
*/
|
||||
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 }}
|
||||
* @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 };
|
||||
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 };
|
||||
return { stats: { v: raw.v, records: raw.records }, changed: false, newer: true, dropped: 0 };
|
||||
}
|
||||
let v = raw.v ?? 0;
|
||||
let v = Number.isFinite(raw.v) ? raw.v : 0;
|
||||
let records = raw.records;
|
||||
let changed = v !== STATS_VERSION;
|
||||
while (v < STATS_VERSION) {
|
||||
@@ -183,48 +239,108 @@ export function migrateStats(raw) {
|
||||
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) normalized.push(rec);
|
||||
if (rec && !seen.has(rec.id)) { seen.add(rec.id); normalized.push(rec); }
|
||||
}
|
||||
changed ||= normalized.length !== records.length;
|
||||
return { stats: { v: STATS_VERSION, records: normalized }, changed, newer: false };
|
||||
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.
|
||||
* @returns {{ v: number, records: any[], newer: boolean }}
|
||||
* 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 { stats, changed, newer } = migrateStats(loadPref(STATS_PREF_KEY, null));
|
||||
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 };
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the envelope. savePref swallows quota errors — here losing a record silently would do exactly
|
||||
* that, so the write reports failure and shrinks the history (oldest quarter) before retrying.
|
||||
* @param {{ v: number, records: any[] }} stats
|
||||
* 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) {
|
||||
const env = { v: STATS_VERSION, records: stats.records };
|
||||
try {
|
||||
globalThis.localStorage?.setItem(FULL_KEY, JSON.stringify(env));
|
||||
return true;
|
||||
} catch { /* quota — fall through to shrinking */ }
|
||||
let recs = stats.records;
|
||||
// two quarter-shrinks at most; records are newest first, so the tail (oldest) is what goes;
|
||||
// a small history that still does not fit is foreign quota pressure — giving up beats gradually erasing it
|
||||
for (let i = 0; i < 2 && recs.length > 8; i++) {
|
||||
recs = recs.slice(0, recs.length - Math.ceil(recs.length / 4));
|
||||
if (stats.newer) return false;
|
||||
let recs = fitToBudget(stats.records);
|
||||
for (;;) {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(FULL_KEY, JSON.stringify({ v: STATS_VERSION, records: recs }));
|
||||
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 { /* keep shrinking */ }
|
||||
} 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)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,7 +358,7 @@ export function roomModeOf(modeId, roomMode) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one record from a raw m.result payload (+ context). Null when there is nothing to store: payloads
|
||||
* 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
|
||||
@@ -255,10 +371,11 @@ export function buildRecord(res, ctx = {}) {
|
||||
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
|
||||
const rec = normalizeRecord({
|
||||
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),
|
||||
@@ -277,21 +394,120 @@ export function buildRecord(res, ctx = {}) {
|
||||
selfId: myId,
|
||||
players: r.players,
|
||||
});
|
||||
return rec;
|
||||
}
|
||||
|
||||
// ---- 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 m.result-shaped payload behind a record — the inverse of buildRecord. The stats page's 最近对局
|
||||
* rows hand this to the settlement screen (screens/result.js reads store.match.result through
|
||||
* normalizeResult): a finished match can be RE-VIEWED because the record kept every field the payload
|
||||
* had. Whatever a record never stored (older / hand-made rows) is simply absent and normalizeResult
|
||||
* defaults it, exactly like a tolerant live payload.
|
||||
* 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 record
|
||||
* @returns {any|null} null when the row is not a usable, full, settled record
|
||||
*/
|
||||
export function recordToResult(rec) {
|
||||
const r = normalizeRecord(rec);
|
||||
if (!r) return null;
|
||||
if (!r || r.end === 'quit' || r.compact) return null;
|
||||
const res = {
|
||||
victory: r.victory,
|
||||
roundsPassed: r.roundsPassed,
|
||||
@@ -324,6 +540,16 @@ export function appendRecord(stats, record) {
|
||||
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).
|
||||
@@ -332,16 +558,24 @@ export function appendRecord(stats, record) {
|
||||
* @returns {any|null}
|
||||
*/
|
||||
export function recordResult(res, ctx = {}) {
|
||||
const rec = buildRecord(res, ctx);
|
||||
if (!rec) return null;
|
||||
const stats = loadStats();
|
||||
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;
|
||||
return storeRecord(buildRecord(res, ctx));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two envelopes (import into existing): union by record id, chronological, capped.
|
||||
* 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[] }}
|
||||
@@ -350,10 +584,9 @@ 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);
|
||||
// id-less (hand-made) rows fall back to a CONTENT hash so both copies land on the same key
|
||||
if (rec) byId.set(rec.id || `c.${fnv1a(JSON.stringify(rec))}`, rec);
|
||||
if (rec) byId.set(rec.id, rec);
|
||||
}
|
||||
const records = [...byId.values()].sort((x, y) => (x.t - y.t) || (x.id < y.id ? -1 : 1)).slice(-MAX_RECORDS);
|
||||
const records = [...byId.values()].sort(newestFirst).slice(0, MAX_RECORDS);
|
||||
return { v: STATS_VERSION, records };
|
||||
}
|
||||
|
||||
@@ -361,18 +594,31 @@ export function mergeStats(a, b) {
|
||||
* 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 }}
|
||||
* @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) {
|
||||
const { stats: incoming, newer } = migrateStats(raw);
|
||||
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('该文件由更新版本的客户端导出,请先升级后再导入');
|
||||
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 = merged.records.length - (Array.isArray(current?.records) ? current.records.length : 0);
|
||||
return { stats: merged, added: Math.max(0, added), skipped: incoming.records.length - Math.max(0, added) };
|
||||
const added = incoming.records.filter((r) => !have.has(r.id)).length;
|
||||
return { stats: merged, added, skipped: incoming.records.length - added, dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -386,11 +632,11 @@ export function exportStats(stats) {
|
||||
kind: 'local-stats',
|
||||
v: STATS_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
records: Array.isArray(stats?.records) ? stats.records : [],
|
||||
records: fitToBudget(Array.isArray(stats?.records) ? stats.records : []),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- aggregation (what the page shows; records may hold more than any view uses) ------------------------
|
||||
// ---- 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'];
|
||||
|
||||
@@ -412,15 +658,43 @@ export function selfRowOf(rec) {
|
||||
const selfWon = (rec, self) => (self ? !!self.victory : !!rec.victory);
|
||||
|
||||
/**
|
||||
* Aggregate records for the stats page. Everything is keyed by ids (bandId / titleId / difficulty); the
|
||||
* page maps them to names and icons through the game data.
|
||||
* 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 rs = Array.isArray(records) ? records : [];
|
||||
const all = Array.isArray(records) ? records : [];
|
||||
const rs = all.filter(countsTowardStats);
|
||||
const out = {
|
||||
count: rs.length, wins: 0,
|
||||
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 },
|
||||
@@ -431,13 +705,14 @@ export function aggregateStats(records) {
|
||||
const self = selfRowOf(rec);
|
||||
const won = selfWon(rec, self);
|
||||
if (won) out.wins++;
|
||||
out.rounds.total += int(rec.roundsPassed, 0);
|
||||
out.rounds.max = Math.max(out.rounds.max, int(rec.roundsPassed, 0));
|
||||
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 || t('未知');
|
||||
const d = rec.difficulty || 'UNKNOWN';
|
||||
const bd = (out.byDifficulty[d] ||= { games: 0, wins: 0, hiddenCleared: 0 });
|
||||
bd.games++;
|
||||
if (won) bd.wins++;
|
||||
@@ -458,7 +733,7 @@ export function aggregateStats(records) {
|
||||
if (won) b.wins++;
|
||||
}
|
||||
|
||||
if (self && isObj(self.stats)) {
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user