feedback7 stats: the local statistics page, ported from PR #323 by @2321Robin as submitted (record store ui/stats.js, viewer screens/stats.js, stats.css, entries on the title / lobby / room screens, 20 tests); golden: unchanged
- public/js/ui/stats.js: one record per finished match in localStorage (sp.pref.stats, envelope {v, records}, at most
1000, quota shrink of the oldest), content-id dedupe of the lobby's result replay, a MIGRATIONS chain and
normalizeRecord (defaults for missing fields, unknown fields kept, a newer envelope refused), merge-by-id export /
import, aggregateStats.
- public/js/screens/stats.js: the viewer (总览 / 策略 / 称号 / 战斗累计 / 最近对局, a row re-views that match's
settlement), 导出 / 导入 / 清空.
- Applied from the PR's own diff after reading it in full (no network, no new dependency, server untouched); only the
UI strings of the page are added to the four packs (en from the PR; ja / ko / zh-TW translated for this commit).
- The follow-up commits rework the page (the counting rule for matches left early, storage bounds, the layout).
- Tests: test/ui/stats.test.js 20 / 20; i18n extract 0, check --all --strict clean.
This commit is contained in:
1 parent
ae22335cfe
commit
438a6b521a
13 files changed
+1407
-5
No files matched your search
+10
-1
@@ -49,6 +49,8 @@ import { settingsStore } from './ui/settings.js';
|
||||
import { GuideHost } from './ui/guide.js';
|
||||
import { installDeviceSupport } from './ui/device.js';
|
||||
import { LoadoutHost } from './screens/loadout.js';
|
||||
import { StatsHost } from './screens/stats.js';
|
||||
import { recordResult } from './ui/stats.js';
|
||||
import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/loadoutSync.js';
|
||||
import { startBuildGuard } from './ui/buildGuard.js';
|
||||
import { initLang, useLang, tickerText } from './ui/lang.js';
|
||||
@@ -219,7 +221,13 @@ function wireNet() {
|
||||
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
|
||||
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
|
||||
net.on('m.field', (msg) => store.patch('match', { field: payload(msg) }));
|
||||
net.on('m.result', (msg) => store.patch('match', { result: payload(msg) }));
|
||||
net.on('m.result', (msg) => {
|
||||
const res = payload(msg);
|
||||
store.patch('match', { result: res });
|
||||
// 本机统计 (#18): every arrival, including the lobby's result replay after a reconnect / reload —
|
||||
// replays dedupe by content id inside recordResult (spectator seats' copies build no record at all)
|
||||
recordResult(res, { myId: store.get().me.playerId, roomMode: store.get().room?.mode ?? null, now: Date.now() });
|
||||
});
|
||||
net.on('m.toast', (msg) => {
|
||||
const kind = ['info', 'success', 'warn', 'error'].includes(msg.kind) ? msg.kind : 'info';
|
||||
// msgid + params (server ≥ 0.2.0) or the text itself as a msgid, in the current language
|
||||
@@ -287,6 +295,7 @@ function App() {
|
||||
<${UiHosts} />
|
||||
<${GuideHost} />
|
||||
<${LoadoutHost} />
|
||||
<${StatsHost} />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_S
|
||||
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
|
||||
import { toast, toastError } from '../ui/toasts.js';
|
||||
import { GuideButton } from '../ui/guide.js';
|
||||
import { openStats } from './stats.js';
|
||||
import { LoadoutButton } from './loadout.js';
|
||||
import { net, identity } from '../net.js';
|
||||
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
|
||||
@@ -311,6 +312,7 @@ export function LobbyScreen() {
|
||||
<h1 class="topbar__title">${t('选择模拟协议')}</h1>
|
||||
</div>
|
||||
<div class="topbar__right">
|
||||
<${Button} variant="secondary" size="sm" icon="signal" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
|
||||
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
|
||||
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
|
||||
<div class="me-chip">
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { toast, toastError } from '../ui/toasts.js';
|
||||
import { copyText } from '../ui/clipboard.js';
|
||||
import { GuideButton } from '../ui/guide.js';
|
||||
import { openStats } from './stats.js';
|
||||
import { LoadoutButton } from './loadout.js';
|
||||
import { net } from '../net.js';
|
||||
import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
|
||||
@@ -277,6 +278,7 @@ export function RoomScreen() {
|
||||
<${PingPill} ms=${conn.ping} online=${online} />
|
||||
<${MicroLabel}>${t('当前延迟')}<//>
|
||||
</div>
|
||||
<${Button} variant="secondary" size="sm" icon="signal" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
|
||||
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
|
||||
</div>
|
||||
<div class="topbar__center">
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// The local stats viewer (per-browser match record, viewer + data management. Opened imperatively from the
|
||||
// title screen / lobby (`openStats()`); <StatsHost/> is mounted once by main.js like <GuideHost/>.
|
||||
//
|
||||
// The page aggregates over the records of ui/stats.js (one per finished match the local player took part in):
|
||||
// 总览 – games, wins, win rate, hidden-core reaches / clears, per-difficulty rows
|
||||
// 策略 – per band: games used / passed (self alive on a winning team) / pass rate (the user's ask)
|
||||
// 称号 – each of config.titles' six 评语 with its count
|
||||
// 战斗累计– the self player's summed combat bookkeeping
|
||||
// 最近对局– the newest RECENT_SHOW records as rows; a row click re-views that match as its settlement
|
||||
// screen (the record is inverted into an m.result payload for <ResultScreen/>)
|
||||
// Everything is 本机数据 (localStorage, `sp.pref.stats`): the note under the title says so, and 导出/导入
|
||||
// (JSON file, merge-by-record-id on the way in) moves it between devices; 清空 resets after a confirm press.
|
||||
|
||||
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { DIFFICULTY_NAMES, PHASE } from '../../../shared/constants.js';
|
||||
import { html, Icon, MicroLabel, Spinner, Button } from '../ui/components.js';
|
||||
import { useGameData, BandIcon, Img } from '../ui/gameComponents.js';
|
||||
import { data, useData } from '../data.js';
|
||||
import { fmtNum } from '../ui/gameLogic.js';
|
||||
import { titleIconUrl } from '../ui/assetUrls.js';
|
||||
import { createStore, useStore, store, emptyMatch } from '../store.js';
|
||||
import { ResultScreen } from './result.js';
|
||||
import { toast, toastError } from '../ui/toasts.js';
|
||||
import {
|
||||
loadStats, saveStats, emptyStats, importStats, exportStats, aggregateStats, recordToResult, RECENT_SHOW,
|
||||
} from '../ui/stats.js';
|
||||
import { t, N_ } from '../../../shared/i18n.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
const statsStore = createStore({ open: false });
|
||||
|
||||
/** Open the stats viewer (title screen / lobby entry points). */
|
||||
export function openStats() {
|
||||
data.load('config');
|
||||
data.load('bands');
|
||||
data.load('assets');
|
||||
statsStore.set({ open: true });
|
||||
}
|
||||
export const closeStats = () => {
|
||||
// a replayed settlement is synthetic store state — take it away with the viewer
|
||||
if (store.get().match.result) store.set({ match: emptyMatch() });
|
||||
statsStore.set({ open: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 最近对局行点击: one stored match re-viewed as its settlement screen, INSIDE the stats viewer — the
|
||||
* record is inverted into an m.result payload (ui/stats.js recordToResult) into store.match.result, and
|
||||
* StatsHost swaps its panel for the real <ResultScreen/> while a result exists (the settlement's
|
||||
* 返回大厅 empties match again → back to the list, ready to view the next row). Refused while a match
|
||||
* is actually live: the overlay is global and clobbering a running match's state would be destructive.
|
||||
* @param {any} rec
|
||||
*/
|
||||
export function openRecordResult(rec) {
|
||||
const s = store.get();
|
||||
if ((s.match.public && s.match.public.phase !== PHASE.LOBBY) || s.room?.inMatch) {
|
||||
toast(t('对局进行中,暂不能回看历史结算'), 'warn');
|
||||
return;
|
||||
}
|
||||
const res = recordToResult(rec);
|
||||
if (!res) return;
|
||||
store.set({ match: { ...emptyMatch(), public: { phase: PHASE.RESULT }, result: res } });
|
||||
}
|
||||
|
||||
const pct = (n, d) => (d > 0 ? `${Math.round((n / d) * 100)}%` : '—');
|
||||
const dateTime = (t) => {
|
||||
if (!t) return '—';
|
||||
const d = new Date(t);
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
};
|
||||
const minsOf = (ms) => (ms > 0 ? Math.round(ms / 60000) : null);
|
||||
|
||||
/** One labelled number card. */
|
||||
function StatCard({ label, micro, value, tone }) {
|
||||
return html`<div class=${cx('stcard', tone && `stcard--${tone}`)}>
|
||||
<${MicroLabel} tone=${tone === 'gold' ? 'gold' : 'mint'}>${micro}<//>
|
||||
<b class="stcard__num num">${value}</b>
|
||||
<span class="stcard__label">${label}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function Section({ title, micro, children }) {
|
||||
return html`<section class="stats-sec">
|
||||
<h3 class="stats-sec__h"><span>${title}</span><${MicroLabel}>${micro}<//></h3>
|
||||
${children}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/** 总览: cards + the per-difficulty table. */
|
||||
function Overview({ agg }) {
|
||||
const rows = Object.entries(agg.byDifficulty).sort((a, b) => b[1].games - a[1].games);
|
||||
const playedMins = minsOf(agg.duration.totalMs);
|
||||
return html`<${Section} title=${t('总览')} micro="OVERVIEW">
|
||||
<div class="stats-cards">
|
||||
<${StatCard} micro="GAMES" value=${fmtNum(agg.count)} label=${t('总局数')} />
|
||||
<${StatCard} micro="WINS" value=${fmtNum(agg.wins)} label=${t('胜场')} tone="mint" />
|
||||
<${StatCard} micro="WIN RATE" value=${pct(agg.wins, agg.count)} label=${t('胜率')} tone="gold" />
|
||||
<${StatCard} micro="HIDDEN CORE" value=${fmtNum(agg.hidden.reached)} label=${t('到达隐秘核心')} />
|
||||
<${StatCard} micro="HIDDEN CLEAR" value=${fmtNum(agg.hidden.cleared)} label=${t('通关隐秘核心')} />
|
||||
<${StatCard} micro="BEST ROUND" value=${fmtNum(agg.rounds.max)} label=${t('最远回合')} />
|
||||
${playedMins != null ? html`<${StatCard} micro="TIME PLAYED" value=${t('{n} 分', { n: fmtNum(playedMins) })} label=${t('累计时长')} />` : null}
|
||||
</div>
|
||||
${rows.length ? html`<table class="stats-table">
|
||||
<thead><tr><th>${t('难度')}</th><th class="num">${t('局数')}</th><th class="num">${t('胜场')}</th><th class="num">${t('胜率')}</th><th class="num">${t('隐秘核心通关')}</th></tr></thead>
|
||||
<tbody>
|
||||
${rows.map(([d, v]) => html`<tr key=${d}>
|
||||
<td>${DIFFICULTY_NAMES[d] || d}</td>
|
||||
<td class="num">${fmtNum(v.games)}</td>
|
||||
<td class="num">${fmtNum(v.wins)}</td>
|
||||
<td class="num">${pct(v.wins, v.games)}</td>
|
||||
<td class="num">${fmtNum(v.hiddenCleared)}</td>
|
||||
</tr>`)}
|
||||
</tbody>
|
||||
</table>` : null}
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/** 策略: per-band used / passed / pass rate, most used first (the user's headline ask). */
|
||||
function Bands({ agg, gd }) {
|
||||
const rows = Object.entries(agg.bands).sort((a, b) => b[1].games - a[1].games);
|
||||
if (!rows.length) return null;
|
||||
return html`<${Section} title=${t('策略')} micro="BANDS">
|
||||
<table class="stats-table">
|
||||
<thead><tr><th>${t('策略')}</th><th class="num">${t('使用局数')}</th><th class="num">${t('通过局数')}</th><th class="num">${t('通过率')}</th></tr></thead>
|
||||
<tbody>
|
||||
${rows.map(([bandId, v]) => {
|
||||
const band = gd.band(bandId);
|
||||
return html`<tr key=${bandId}>
|
||||
<td class="stats-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : bandId}</td>
|
||||
<td class="num">${fmtNum(v.games)}</td>
|
||||
<td class="num">${fmtNum(v.wins)}</td>
|
||||
<td class="num">${pct(v.wins, v.games)}</td>
|
||||
</tr>`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/** 称号: all six 评语 with counts (0 dimmed), official order. */
|
||||
function Titles({ agg, gd }) {
|
||||
const list = (Array.isArray(gd.config?.titles) ? gd.config.titles : [])
|
||||
.map((t) => ({ ...t, count: agg.titles[t.id]?.count || 0 }));
|
||||
if (!list.length) return null;
|
||||
return html`<${Section} title=${t('称号')} micro="TITLES">
|
||||
<div class="stats-titles">
|
||||
${list.map((t) => html`<div key=${t.id} class=${cx('stit', !t.count && 'stit--none')} title=${t.text || ''}>
|
||||
<${Img} src=${titleIconUrl(gd.m, t.picId)} class="stit__icon" fallback=${html`<${Icon} name="crown" />`} />
|
||||
<div class="stit__text"><span>${t.name}</span><b class="num">${fmtNum(t.count)}</b></div>
|
||||
</div>`)}
|
||||
</div>
|
||||
<//>`;
|
||||
}
|
||||
|
||||
const SUM_ROWS = [
|
||||
['kills', N_('击倒敌人')], ['bossDamage', N_('领袖伤害')], ['dmgDealt', N_('造成伤害')], ['activatedLayers', N_('盟约层数')],
|
||||
['merges', N_('晋升次数')], ['itemsEquipped', N_('配发装备')], ['gold', N_('消耗资金')], ['perfectRounds', N_('完美作战')],
|
||||
['refreshes', N_('刷新次数')], ['leaks', N_('未击倒')], ['lpLost', N_('损失生命')],
|
||||
];
|
||||
|
||||
/** 战斗累计: the self player's sums. */
|
||||
function CombatSums({ agg }) {
|
||||
const rows = SUM_ROWS.filter(([k]) => Number.isFinite(agg.sums[k]));
|
||||
if (!rows.length) return null;
|
||||
return html`<${Section} title=${t('战斗累计')} micro="COMBAT TOTALS">
|
||||
<div class="stats-cards stats-cards--sums">
|
||||
${rows.map(([k, label]) => html`<div key=${k} class="ssum"><span>${t(label)}</span><b class="num">${fmtNum(agg.sums[k])}</b></div>`)}
|
||||
</div>
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/** 最近对局: newest RECENT_SHOW records (records are stored newest first). A row click re-views that
|
||||
match as its settlement screen (openRecordResult → match.result → <ResultScreen/>). */
|
||||
function Recent({ records, gd }) {
|
||||
const rows = records.slice(0, RECENT_SHOW);
|
||||
if (!rows.length) return null;
|
||||
return html`<${Section} title=${t('最近对局')} micro=${`RECENT · ${records.length}`}>
|
||||
<table class="stats-table">
|
||||
<thead><tr><th>${t('时间')}</th><th>${t('难度')}</th><th>${t('模式')}</th><th>${t('策略')}</th><th>${t('结果')}</th><th class="num">${t('回合')}</th><th aria-hidden="true"></th></tr></thead>
|
||||
<tbody>
|
||||
${rows.map((r) => {
|
||||
const self = r.players.find((p) => p.playerId === r.selfId) || r.players[0] || null;
|
||||
const won = self ? !!self.victory : !!r.victory;
|
||||
const band = self?.bandId ? gd.band(self.bandId) : null;
|
||||
return html`<tr key=${r.id} class=${cx('is-clickable', won ? 'is-win' : 'is-lose')} title=${t('点击查看该局结算')}
|
||||
onClick=${() => openRecordResult(r)}>
|
||||
<td class="stats-t">${dateTime(r.t)}</td>
|
||||
<td>${DIFFICULTY_NAMES[r.difficulty] || r.difficulty || '—'}</td>
|
||||
<td>${r.roomMode === 'solo' ? t('单人') : r.roomMode === 'coop' ? t('同盟') : '—'}</td>
|
||||
<td>${band ? band.name : self?.bandId || '—'}</td>
|
||||
<td class=${won ? 't-win' : 't-lose'}>${won ? t('胜') : t('负')}</td>
|
||||
<td class="num">${fmtNum(r.roundsPassed)}</td>
|
||||
<td class="stats-go"><${Icon} name="chevronRight" /></td>
|
||||
</tr>`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="stats-hint">${t('点击任意一行,回看该局结算。')}</p>
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/** 导出 / 导入 / 清空, with the two-step clear confirm. */
|
||||
function DataActions({ stats, onReload }) {
|
||||
const fileRef = useRef(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!confirming) return undefined;
|
||||
const t = setTimeout(() => setConfirming(false), 3000);
|
||||
return () => clearTimeout(t);
|
||||
}, [confirming]);
|
||||
|
||||
const doExport = () => {
|
||||
try {
|
||||
const payload = exportStats(stats);
|
||||
const blob = new Blob([JSON.stringify(payload, null, 1)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `stronghold-stats-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}.json`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
|
||||
toast(t('已导出 {n} 条对局记录', { n: payload.records.length }), 'success');
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const doFile = async (e) => {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
e.currentTarget.value = ''; // allow re-picking the same file
|
||||
if (!file) return;
|
||||
try {
|
||||
const raw = JSON.parse(await file.text());
|
||||
const { stats: merged, added, skipped } = importStats(raw, stats);
|
||||
saveStats(merged);
|
||||
onReload();
|
||||
toast(added ? t('已导入 {added} 条记录{tail}', { added, tail: skipped ? t('(跳过重复 {skipped} 条)', { skipped }) : '' }) : t('没有新记录(全部与现有数据重复)'), 'success');
|
||||
} catch (err) {
|
||||
toastError(err.code === 'stats-newer-version' ? err : new Error(t('导入失败:不是有效的统计导出文件')));
|
||||
}
|
||||
};
|
||||
|
||||
const doClear = () => {
|
||||
if (!confirming) { setConfirming(true); return; }
|
||||
saveStats(emptyStats());
|
||||
setConfirming(false);
|
||||
onReload();
|
||||
toast(t('已清空本机统计数据'), 'success');
|
||||
};
|
||||
|
||||
return html`<div class="stats-actions">
|
||||
<${Button} size="sm" icon="exit" onClick=${doExport}>${t('导出')}<//>
|
||||
<${Button} size="sm" icon="plus" onClick=${() => fileRef.current?.click()}>${t('导入')}<//>
|
||||
<input ref=${fileRef} type="file" accept="application/json,.json" hidden onChange=${doFile} />
|
||||
<${Button} size="sm" variant="danger" icon=${confirming ? 'warn' : 'close'} class=${cx(confirming && 'stats-actions__confirm')}
|
||||
onClick=${doClear}>${confirming ? t('确认清空?') : t('清空')}<//>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global stats viewer host — mounted once by main.js. Waits only on the files the page reads (config for the
|
||||
* title list, bands for names/icons, assets for the picture URLs), not all of useGameData's files.
|
||||
*/
|
||||
export function StatsHost() {
|
||||
const { open } = useStore((s) => s, Object.is, statsStore);
|
||||
const replaying = useStore((s) => !!s.match.result); // a replayed settlement replaces the whole panel
|
||||
const ready = useData('config', 'bands', 'assets');
|
||||
const gd = useGameData();
|
||||
const [stats, setStats] = useState(() => emptyStats());
|
||||
const boxRef = useRef(null);
|
||||
const reload = () => setStats(loadStats());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
setStats(loadStats());
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); closeStats(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
const t = setTimeout(() => boxRef.current?.focus?.(), 30);
|
||||
return () => { window.removeEventListener('keydown', onKey, true); clearTimeout(t); };
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
if (replaying) {
|
||||
// the record re-viewed as its settlement (openRecordResult): the real <ResultScreen/>, full-bleed;
|
||||
// its 返回大厅 empties match.result and lands back on the list
|
||||
return html`<div class="stats"><div class="stats__replay"><${ResultScreen} /></div></div>`;
|
||||
}
|
||||
const agg = aggregateStats(stats.records);
|
||||
return html`<div class="stats" role="presentation" onMouseDown=${(e) => { if (e.target === e.currentTarget) closeStats(); }}>
|
||||
<div class="stats__box brackets" role="dialog" aria-modal="true" aria-label=${t('统计数据')} tabindex="-1" ref=${boxRef}>
|
||||
<header class="stats__head">
|
||||
<div class="stats__titles">
|
||||
<${MicroLabel} tone="mint">STATISTICS // LOCAL DATA<//>
|
||||
<h2 class="stats__title">${t('统计数据')}</h2>
|
||||
<span class="stats__note">${t('本机数据 · 仅记录本浏览器中你参与的对局')}</span>
|
||||
</div>
|
||||
${ready ? html`<${DataActions} stats=${stats} onReload=${reload} />` : null}
|
||||
<button type="button" class="stats__close" aria-label=${t('关闭')} title=${t('关闭 (Esc)')} onClick=${closeStats}><${Icon} name="close" /></button>
|
||||
</header>
|
||||
<div class="stats__body">
|
||||
${!ready ? html`<div class="stats__loading"><${Spinner} size="md" /></div>`
|
||||
: stats.newer ? html`<p class="stats-empty">${t('检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。')}</p>`
|
||||
: !stats.records.length ? html`<p class="stats-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`
|
||||
: html`<${Overview} agg=${agg} />
|
||||
<${Bands} agg=${agg} gd=${gd} />
|
||||
<${Titles} agg=${agg} gd=${gd} />
|
||||
<${CombatSums} agg=${agg} />
|
||||
<${Recent} records=${stats.records} gd=${gd} />`}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { useMemo, useState } from '../../vendor/hooks.module.js';
|
||||
import { NAME_MAX_LEN, APP_VERSION, DEV_BUILD } from '../../../shared/constants.js';
|
||||
import { html, Button, Icon, MicroLabel, TextField, PingPill } from '../ui/components.js';
|
||||
import { GuideButton } from '../ui/guide.js';
|
||||
import { openStats } from './stats.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
import { net, identity } from '../net.js';
|
||||
import { store, useStore, shallowEqual } from '../store.js';
|
||||
@@ -272,6 +273,7 @@ export function TitleScreen() {
|
||||
<span class=${`status-dot ${dotClass}`}></span>
|
||||
<span>${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}</span>
|
||||
${conn.status === 'online' ? html`<${PingPill} ms=${conn.ping} />` : null}
|
||||
<${Button} variant="ghost" size="sm" icon="signal" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
|
||||
<${GuideButton} class="title-guide" label=${t('玩法说明')} />
|
||||
<button type="button" class="title-settings fsbtn tapx" aria-label=${t('设置')} title=${t('设置')}
|
||||
onClick=${() => setSettingsOpen(true)}><${GIcon} name="gear" /></button>
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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).
|
||||
//
|
||||
// Pure logic, no Preact / DOM: Node-testable (test/ui/stats.test.js); localStorage is touched only through
|
||||
// loadStats / saveStats.
|
||||
|
||||
import { loadPref } from '../store.js';
|
||||
import { t } from '../../../shared/i18n.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). */
|
||||
export const MAX_RECORDS = 1000;
|
||||
/** How many games the page's 最近对局 list shows. */
|
||||
export const RECENT_SHOW = 20;
|
||||
|
||||
const FULL_KEY = `sp.pref.${STATS_PREF_KEY}`;
|
||||
|
||||
/** @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);
|
||||
const str = (v) => (typeof v === 'string' && v.length ? v : null);
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* @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;
|
||||
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, unknown fields preserved (forward compat).
|
||||
* @param {any} raw
|
||||
* @returns {any}
|
||||
*/
|
||||
function normalizePlayerRow(raw) {
|
||||
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) => ({
|
||||
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('博士'),
|
||||
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),
|
||||
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. Missing fields get defaults; unknown fields (a newer
|
||||
* writer's additions) survive untouched, so import → export does not lose them.
|
||||
* @param {any} raw
|
||||
* @returns {any|null} null when the row is not salvageable (no players array 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) || '',
|
||||
v: STATS_VERSION,
|
||||
t: int(r.t, 0),
|
||||
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: r.roomMode === 'solo' || r.roomMode === 'coop' ? r.roomMode : null,
|
||||
selfId: str(r.selfId),
|
||||
players: r.players.filter(isObj).map(normalizePlayerRow),
|
||||
};
|
||||
return { ...r, ...base };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {};
|
||||
|
||||
/**
|
||||
* Bring any stored / imported envelope to the current version.
|
||||
* @param {any} raw
|
||||
* @returns {{ stats: { v: number, records: any[] }, changed: boolean, newer: boolean }}
|
||||
* `newer` = the data was written by a NEWER build: returned untouched, callers must not save over it.
|
||||
*/
|
||||
export function migrateStats(raw) {
|
||||
if (!isObj(raw) || !Array.isArray(raw.records)) return { stats: emptyStats(), changed: false, newer: false };
|
||||
if ((raw.v ?? 0) > STATS_VERSION) {
|
||||
return { stats: { v: raw.v, records: raw.records }, changed: false, newer: true };
|
||||
}
|
||||
let 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++;
|
||||
}
|
||||
const normalized = [];
|
||||
for (const r of records) {
|
||||
const rec = normalizeRecord(r);
|
||||
if (rec) normalized.push(rec);
|
||||
}
|
||||
changed ||= normalized.length !== records.length;
|
||||
return { stats: { v: STATS_VERSION, records: normalized }, changed, newer: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read + migrate the persisted envelope; self-heals older shapes back to storage.
|
||||
* @returns {{ v: number, records: any[], newer: boolean }}
|
||||
*/
|
||||
export function loadStats() {
|
||||
const { stats, changed, newer } = migrateStats(loadPref(STATS_PREF_KEY, null));
|
||||
if (!newer && changed) saveStats(stats);
|
||||
return { ...stats, newer };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @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));
|
||||
try {
|
||||
globalThis.localStorage?.setItem(FULL_KEY, JSON.stringify({ v: STATS_VERSION, records: recs }));
|
||||
return true;
|
||||
} catch { /* keep shrinking */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
const rec = normalizeRecord({
|
||||
v: STATS_VERSION,
|
||||
t: int(ctx.now, 0),
|
||||
id: recordId(r, myId),
|
||||
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,
|
||||
});
|
||||
return rec;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param {any} rec
|
||||
* @returns {any|null} null when the row is not a usable record
|
||||
*/
|
||||
export function recordToResult(rec) {
|
||||
const r = normalizeRecord(rec);
|
||||
if (!r) 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {}) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two envelopes (import into existing): union by record id, chronological, 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);
|
||||
// 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);
|
||||
}
|
||||
const records = [...byId.values()].sort((x, y) => (x.t - y.t) || (x.id < y.id ? -1 : 1)).slice(-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 }}
|
||||
*/
|
||||
export function importStats(raw, current) {
|
||||
const { stats: incoming, newer } = migrateStats(raw);
|
||||
if (newer) {
|
||||
const err = new Error('该文件由更新版本的客户端导出,请先升级后再导入');
|
||||
err.code = 'stats-newer-version';
|
||||
throw err;
|
||||
}
|
||||
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) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: 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);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param {any[]} records
|
||||
* @returns {any}
|
||||
*/
|
||||
export function aggregateStats(records) {
|
||||
const rs = Array.isArray(records) ? records : [];
|
||||
const out = {
|
||||
count: 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++;
|
||||
out.rounds.total += int(rec.roundsPassed, 0);
|
||||
out.rounds.max = Math.max(out.rounds.max, int(rec.roundsPassed, 0));
|
||||
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 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 (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;
|
||||
}
|
||||
Reference in new issue
Block a user