feedback7: merge fb7-stats — the local statistics page (PR #323 by @2321Robin, reworked): a match counts when it reached its result or the player cleared at least one round before quitting; earlier quits stay in the history marked 中途退出 / 不计入统计 (the owner's rule of 2026-10-08)
- Conflict: docs/PLAYING.md — #321's §12 反馈问题 and the stats §12 both added; the stats section is §13 (README anchor updated). - Golden unchanged; broad node --test 5382 (5381 pass, 0 fail, 1 skipped); lint 0 errors.
This commit is contained in:
commit
e183f2fabd
21 files changed
+2691
-11
No files matched your search
+11
-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, installStatsRecorder } 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';
|
||||
@@ -220,7 +222,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 });
|
||||
// 本机统计 (PR #323): 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
|
||||
@@ -288,6 +296,7 @@ function App() {
|
||||
<${UiHosts} />
|
||||
<${GuideHost} />
|
||||
<${LoadoutHost} />
|
||||
<${StatsHost} />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -342,6 +351,7 @@ async function boot() {
|
||||
}));
|
||||
|
||||
wireNet();
|
||||
installStatsRecorder(store); // follows the match on screen, so a 放弃模拟 can be recorded (ui/stats.js)
|
||||
installLoadoutSync({ net });
|
||||
installOwnershipSync({ net });
|
||||
installDiySync({ net });
|
||||
|
||||
@@ -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="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
|
||||
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
|
||||
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
|
||||
<div class="me-chip">
|
||||
|
||||
@@ -103,12 +103,22 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
|
||||
</article>`;
|
||||
}
|
||||
|
||||
/** RESULT screen. */
|
||||
/** RESULT screen of the match on screen: reads the store; 返回同盟 / 返回大厅 drops the match. */
|
||||
export function ResultScreen() {
|
||||
const res = useStore((s) => s.match.result);
|
||||
const pub = useStore((s) => s.match.public);
|
||||
const myId = useStore((s) => s.me.playerId);
|
||||
const hasRoom = useStore((s) => !!s.room);
|
||||
const back = () => store.set({ match: emptyMatch() });
|
||||
return html`<${ResultView} res=${res} pub=${pub} myId=${myId} backLabel=${hasRoom ? t('返回同盟') : t('返回大厅')} onBack=${back} />`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settlement view of one m.result payload — the live screen above, and the stats page's re-view of a stored match
|
||||
* (screens/stats.js, which hands it the payload recordToResult rebuilt; `quiet`: no settlement jingle).
|
||||
* @param {{ res: any, pub?: any, myId?: string|null, backLabel: string, onBack: () => void, quiet?: boolean }} props
|
||||
*/
|
||||
export function ResultView({ res, pub = null, myId = null, backLabel, onBack, quiet = false }) {
|
||||
const gd = useGameData();
|
||||
const r = normalizeResult(res, pub);
|
||||
const titles = Array.isArray(gd.config?.titles) ? gd.config.titles : [];
|
||||
@@ -118,8 +128,7 @@ export function ResultScreen() {
|
||||
for (const p of r.players) if (Number.isFinite(p.stats[k]) && p.stats[k] > 0 && (top == null || p.stats[k] > top.v)) top = { v: p.stats[k], id: p.playerId };
|
||||
if (top && r.players.length > 1) best[k] = top.id;
|
||||
}
|
||||
useEffect(() => { audio.sfx(r.victory ? 'settlementSucceed' : 'settlementFail'); }, []);
|
||||
const back = () => store.set({ match: emptyMatch() });
|
||||
useEffect(() => { if (!quiet) audio.sfx(r.victory ? 'settlementSucceed' : 'settlementFail'); }, []);
|
||||
const boss = r.bossId ? gd.boss(r.bossId) : null;
|
||||
// the Hidden Core medal (and its corrupted leader) only once R15 was actually fought
|
||||
const hidden = r.hiddenBossId && r.hiddenReached ? gd.boss(r.hiddenBossId) : null;
|
||||
@@ -150,7 +159,7 @@ export function ResultScreen() {
|
||||
</div>
|
||||
${mins ? html`<p class="result__time t-lo">${tParts('本局耗时 {n} 分钟', { n: html`<b class="num">${mins}</b>`, mins })}</p>` : null}
|
||||
<footer class="result__foot">
|
||||
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${back}>${hasRoom ? t('返回同盟') : t('返回大厅')}<//>
|
||||
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${onBack}>${backLabel}<//>
|
||||
</footer>
|
||||
</section>
|
||||
<section class="result__players">
|
||||
|
||||
@@ -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';
|
||||
@@ -306,6 +307,7 @@ export function RoomScreen() {
|
||||
<${PingPill} ms=${conn.ping} online=${online} />
|
||||
<${MicroLabel}>${t('当前延迟')}<//>
|
||||
</div>
|
||||
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
|
||||
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
|
||||
</div>
|
||||
<div class="topbar__center">
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// The local stats viewer (统计数据): this browser's record of the matches the player took part in — see ui/stats.js for
|
||||
// what is recorded, what counts and how the storage is bounded. A full-screen overlay opened imperatively from the
|
||||
// title screen / lobby / room (`openStats()`); <StatsHost/> is mounted once by main.js. Ported from PR #323 by
|
||||
// @2321Robin; reworked for 0.2.2 in the grammar of 干员调配 (top bar with tabs, a note line, a scrolling body).
|
||||
//
|
||||
// 总览 – games, wins, win rate, hidden-core reaches / clears, best round, time played; the difficulties; the six
|
||||
// 评语 (titles) with their counts; what counts and what was left out
|
||||
// 策略 – per strategy (band): games used / passed (self alive on a winning team) / pass rate
|
||||
// 战斗累计 – the player's summed combat bookkeeping of the settled matches
|
||||
// 对局记录 – every stored match, newest first. A settled row re-views that match's settlement screen (the record is
|
||||
// inverted into an m.result payload for <ResultView/>); a match given up early is marked 中途退出 and,
|
||||
// when it did not count, 不计入统计
|
||||
// Everything is 本机数据 (localStorage, `sp.pref.stats`) and the page says so, teammates' names included. 导出 / 导入 (a
|
||||
// JSON file, merged by record id on the way in) moves it between devices; 清空 resets it after a confirm dialog.
|
||||
// The host renders nothing until opened and the game data files the replay needs load only then (the lobby alone never
|
||||
// downloads them): the overview reads config / bands / assets only.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS } from '../../../shared/constants.js';
|
||||
import { html, Icon, MicroLabel, Spinner, Button, confirmDialog } from '../ui/components.js';
|
||||
import { useGameData, makeLookups, 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 { useDocClass } from '../ui/device.js';
|
||||
import { createStore, useStore, selectRoute } from '../store.js';
|
||||
import { ResultView } from './result.js';
|
||||
import { toast, toastError } from '../ui/toasts.js';
|
||||
import {
|
||||
loadStats, saveStats, emptyStats, importStats, exportStats, aggregateStats, countsTowardStats, selfRowOf, roundsOf,
|
||||
recordToResult, MAX_RECORDS, FULL_KEEP, IMPORT_MAX_CHARS,
|
||||
} from '../ui/stats.js';
|
||||
import { t, N_ } from '../../../shared/i18n.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
/** Rows of 对局记录 shown at first and added by each 显示更多. */
|
||||
export const HISTORY_PAGE = 50;
|
||||
|
||||
const statsStore = createStore({ open: false, tab: 'overview' });
|
||||
|
||||
/** Open the stats viewer (title screen / lobby / room entry points). */
|
||||
export function openStats() {
|
||||
data.load('config');
|
||||
data.load('bands');
|
||||
data.load('assets');
|
||||
statsStore.set({ open: true, tab: 'overview' });
|
||||
}
|
||||
export const closeStats = () => statsStore.set({ open: false });
|
||||
|
||||
const TABS = [
|
||||
{ id: 'overview', name: N_('总览'), micro: 'OVERVIEW' },
|
||||
{ id: 'bands', name: N_('策略'), micro: 'STRATEGIES' },
|
||||
{ id: 'totals', name: N_('战斗累计'), micro: 'COMBAT TOTALS' },
|
||||
{ id: 'history', name: N_('对局记录'), micro: 'HISTORY' },
|
||||
];
|
||||
|
||||
const pct = (n, d) => (d > 0 ? `${Math.round((n / d) * 100)}%` : '—');
|
||||
const dateTime = (ms) => {
|
||||
if (!ms) return '—';
|
||||
const d = new Date(ms);
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
};
|
||||
/** Time played: minutes up to 99, then hours with one decimal. */
|
||||
function playedText(ms) {
|
||||
const mins = Math.round((ms || 0) / 60000);
|
||||
return mins < 100 ? t('{n} 分', { n: fmtNum(mins) }) : t('{n} 小时', { n: (mins / 60).toFixed(1) });
|
||||
}
|
||||
const difficultyName = (d) => (DIFFICULTY_NAMES[d] ? t(DIFFICULTY_NAMES[d]) : t('未知'));
|
||||
|
||||
/** Save `text` as a download. Silent no-op when the browser refuses downloads. */
|
||||
function downloadText(filename, text) {
|
||||
try {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Read a picked file as text (`File.text()`, with a FileReader fallback for older Safari). */
|
||||
function readFileText(file) {
|
||||
if (typeof file?.text === 'function') return file.text();
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result ?? ''));
|
||||
fr.onerror = () => reject(fr.error || new Error('read failed'));
|
||||
fr.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** `stronghold-stats-20261008-2130.json` */
|
||||
function exportFilename(now = new Date()) {
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `stronghold-stats-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
|
||||
}
|
||||
|
||||
/** One labelled number card. */
|
||||
function StatCard({ label, micro, value, tone }) {
|
||||
return html`<div class=${cx('st-card', tone && `st-card--${tone}`)}>
|
||||
<${MicroLabel} tone=${tone === 'gold' ? 'gold' : 'mint'}>${micro}<//>
|
||||
<b class="st-card__num num">${value}</b>
|
||||
<span class="st-card__label">${label}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function Section({ title, micro, children, class: cls }) {
|
||||
return html`<section class=${cx('st-sec', cls)}>
|
||||
<h3 class="st-sec__h"><span>${title}</span><${MicroLabel}>${micro}<//></h3>
|
||||
${children}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/** 总览: cards, the per-difficulty table, the six titles, and the line about what counts. */
|
||||
function Overview({ agg, gd }) {
|
||||
const rows = DIFFICULTIES.filter((d) => agg.byDifficulty[d]).concat(Object.keys(agg.byDifficulty).filter((d) => !DIFFICULTIES.includes(d)));
|
||||
const titles = (Array.isArray(gd.config?.titles) ? gd.config.titles : []).map((x) => ({ ...x, count: agg.titles[x.id]?.count || 0 }));
|
||||
return html`<${Section} title=${t('总览')} micro="OVERVIEW">
|
||||
<div class="st-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('最远回合')} />
|
||||
<${StatCard} micro="TIME PLAYED" value=${playedText(agg.duration.totalMs)} label=${t('累计时长')} />
|
||||
</div>
|
||||
<p class="st-split"><span>${t('单人')} <b class="num">${fmtNum(agg.byMode.solo)}</b></span><span>${t('同盟')} <b class="num">${fmtNum(agg.byMode.coop)}</b></span></p>
|
||||
${rows.length ? html`<div class="st-tablewrap"><table class="st-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) => {
|
||||
const v = agg.byDifficulty[d];
|
||||
return html`<tr key=${d}>
|
||||
<td class="st-diff" style=${`--d-color:${DIFFICULTY_COLORS[d] || 'var(--text-lo)'}`}>${difficultyName(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></div>` : null}
|
||||
${titles.length ? html`<h4 class="st-sub">${t('称号')}</h4>
|
||||
<div class="st-titles">
|
||||
${titles.map((x) => html`<div key=${x.id} class=${cx('st-title', !x.count && 'is-none')} title=${x.text || ''}>
|
||||
<${Img} src=${titleIconUrl(gd.m, x.picId)} class="st-title__icon" fallback=${html`<${Icon} name="crown" />`} />
|
||||
<div class="st-title__text"><span>${x.name}</span><b class="num">${fmtNum(x.count)}</b></div>
|
||||
</div>`)}
|
||||
</div>` : null}
|
||||
<p class="st-rule"><${Icon} name="info" />${t('计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。')}${agg.excluded
|
||||
? html` <b>${t('另有 {n} 局未计入统计', { n: agg.excluded })}</b>` : null}</p>
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/** 策略: 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) || (b[1].wins - a[1].wins));
|
||||
return html`<${Section} title=${t('策略')} micro="STRATEGIES">
|
||||
${rows.length ? html`<div class="st-tablewrap"><table class="st-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);
|
||||
const rate = v.games > 0 ? Math.round((v.wins / v.games) * 100) : 0;
|
||||
return html`<tr key=${bandId}>
|
||||
<td><span class="st-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : bandId}</span></td>
|
||||
<td class="num">${fmtNum(v.games)}</td>
|
||||
<td class="num">${fmtNum(v.wins)}</td>
|
||||
<td class="num st-rate"><span class="st-bar" aria-hidden="true"><i style=${`width:${rate}%`}></i></span><b>${pct(v.wins, v.games)}</b></td>
|
||||
</tr>`;
|
||||
})}
|
||||
</tbody>
|
||||
</table></div>` : html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
|
||||
<//>`;
|
||||
}
|
||||
|
||||
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 over the settled matches. */
|
||||
function CombatSums({ agg }) {
|
||||
const rows = SUM_ROWS.filter(([k]) => Number.isFinite(agg.sums[k]));
|
||||
return html`<${Section} title=${t('战斗累计')} micro="COMBAT TOTALS">
|
||||
${rows.length ? html`<div class="st-sums">
|
||||
${rows.map(([k, label]) => html`<div key=${k} class="st-sum"><span>${t(label)}</span><b class="num">${fmtNum(agg.sums[k])}</b></div>`)}
|
||||
</div>` : html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/** One 对局记录 row: what it was, who won, whether it counted and whether the settlement can be re-viewed. */
|
||||
function HistoryRow({ rec, gd, onOpen }) {
|
||||
const self = selfRowOf(rec);
|
||||
const quit = rec.end === 'quit';
|
||||
const counted = countsTowardStats(rec);
|
||||
const won = self ? !!self.victory : !!rec.victory;
|
||||
const band = self?.bandId ? gd.band(self.bandId) : null;
|
||||
const rounds = roundsOf(rec);
|
||||
const known = Number.isFinite(self?.roundsPassed) || Number.isFinite(rec.roundsPassed);
|
||||
const view = !quit && !rec.compact; // a quit has nothing settled; a compacted record no rows left
|
||||
const open = () => onOpen(rec);
|
||||
return html`<tr class=${cx(quit ? 'is-quit' : won ? 'is-win' : 'is-lose', !counted && 'is-excluded', view && 'is-clickable')}
|
||||
title=${view ? t('点击查看该局结算') : !counted ? t('没有清完第一个回合就退出,不计入统计') : quit ? t('中途退出的对局没有结算可看') : t('较早的对局只保留汇总,不能回看结算')}
|
||||
tabIndex=${view ? 0 : undefined}
|
||||
onClick=${view ? open : undefined} onKeyDown=${view ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } } : undefined}>
|
||||
<td class="st-t">${dateTime(rec.t)}</td>
|
||||
<td>${difficultyName(rec.difficulty)}</td>
|
||||
<td>${rec.roomMode === 'solo' ? t('单人') : rec.roomMode === 'coop' ? t('同盟') : '—'}</td>
|
||||
<td><span class="st-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : self?.bandId || '—'}</span></td>
|
||||
<td class="st-res">${quit ? html`<span class="t-quit">${t('中途退出')}</span>` : html`<span class=${won ? 't-win' : 't-lose'}>${won ? t('胜') : t('负')}</span>`}
|
||||
${!counted ? html`<em class="st-chip">${t('不计入统计')}</em>` : null}</td>
|
||||
<td class="num">${known ? fmtNum(rounds) : '—'}</td>
|
||||
<td class="st-go" aria-hidden="true">${view ? html`<${Icon} name="chevronRight" />` : null}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
/** 对局记录: every stored match, newest first, HISTORY_PAGE rows at a time. */
|
||||
function History({ records, gd, onOpen }) {
|
||||
const [shown, setShown] = useState(HISTORY_PAGE);
|
||||
const rows = records.slice(0, shown);
|
||||
return html`<${Section} title=${t('对局记录')} micro=${`HISTORY · ${records.length}`}>
|
||||
${rows.length ? html`<div class="st-tablewrap"><table class="st-table st-table--history">
|
||||
<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) => html`<${HistoryRow} key=${r.id} rec=${r} gd=${gd} onOpen=${onOpen} />`)}</tbody>
|
||||
</table></div>
|
||||
${records.length > shown ? html`<div class="st-more"><${Button} size="sm" variant="secondary" onClick=${() => setShown(shown + HISTORY_PAGE)}>${t('显示更多(还有 {n} 局)', { n: records.length - shown })}<//></div>` : null}
|
||||
<p class="st-hint">${t('点击一行,回看该局结算;中途退出的对局只留记录。')} ${t('最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。', { max: MAX_RECORDS, full: FULL_KEEP })}</p>`
|
||||
: html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/** A stored match re-viewed as its settlement screen: the full game data is loaded now (the lobby alone never loads it). */
|
||||
function Replay({ rec, onBack }) {
|
||||
const gd = useGameData();
|
||||
const res = useMemo(() => recordToResult(rec), [rec]);
|
||||
if (!gd.ready) return html`<div class="st-loading"><${Spinner} size="md" /></div>`;
|
||||
return html`<${ResultView} res=${res} pub=${null} myId=${rec.selfId} backLabel=${t('返回统计')} onBack=${onBack} quiet=${true} />`;
|
||||
}
|
||||
|
||||
/** The overlay, rendered while open (its data hooks run only then). */
|
||||
function StatsScreen({ tab }) {
|
||||
const ready = useData('config', 'bands', 'assets');
|
||||
const gd = useMemo(() => makeLookups(ready), [ready, data.locale()]);
|
||||
const [stats, setStats] = useState(() => loadStats());
|
||||
const [replay, setReplay] = useState(null);
|
||||
const boxRef = useRef(null);
|
||||
const fileRef = useRef(null);
|
||||
const replayRef = useRef(null);
|
||||
replayRef.current = replay;
|
||||
const inMatch = useStore((s) => selectRoute(s) === 'game');
|
||||
useDocClass('sp-stats-open');
|
||||
|
||||
// a match starting (the host pressed 开始) takes the player to its briefing: the overlay must not sit on top of it
|
||||
useEffect(() => { if (inMatch) closeStats(); }, [inMatch]);
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape' || document.querySelector('.modal')) return; // a confirm dialog on top takes the Esc
|
||||
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
|
||||
if (replayRef.current) setReplay(null); else closeStats();
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
const timer = setTimeout(() => boxRef.current?.focus?.(), 30);
|
||||
return () => { window.removeEventListener('keydown', onKey, true); clearTimeout(timer); };
|
||||
}, []);
|
||||
|
||||
const agg = useMemo(() => aggregateStats(stats.records), [stats]);
|
||||
const reload = () => setStats(loadStats());
|
||||
|
||||
const doExport = () => {
|
||||
try {
|
||||
const payload = exportStats(stats);
|
||||
downloadText(exportFilename(), JSON.stringify(payload));
|
||||
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;
|
||||
if (file.size > IMPORT_MAX_CHARS) { toast(t('文件过大,无法导入'), 'error'); return; }
|
||||
try {
|
||||
const raw = JSON.parse(await readFileText(file));
|
||||
const { stats: merged, added, skipped, dropped } = importStats(raw, loadStats());
|
||||
if (added && !saveStats(merged)) { toast(t('无法写入本机存储,可能已满'), 'error'); return; }
|
||||
reload();
|
||||
const tail = (skipped ? t('(跳过重复 {skipped} 条)', { skipped }) : '') + (dropped ? t('({n} 条无法读取)', { n: dropped }) : '');
|
||||
toast(added ? t('已导入 {added} 条记录{tail}', { added, tail }) : t('没有新记录(全部与现有数据重复)'), 'success');
|
||||
} catch (err) {
|
||||
toast(err?.code === 'stats-newer-version' ? t('导入失败:这份文件由更新版本的客户端导出,请先升级客户端') : t('导入失败:不是有效的统计导出文件'), 'error');
|
||||
}
|
||||
};
|
||||
const doClear = async () => {
|
||||
const ok = await confirmDialog({
|
||||
title: t('清空统计数据'), micro: 'CLEAR STATISTICS', danger: true, okText: t('清空'),
|
||||
text: t('将删除本机保存的 {n} 条对局记录,无法恢复。', { n: stats.records.length }),
|
||||
});
|
||||
if (!ok) return;
|
||||
if (!saveStats(emptyStats())) { try { globalThis.localStorage?.removeItem('sp.pref.stats'); } catch { /* ignore */ } }
|
||||
reload();
|
||||
toast(t('已清空本机统计数据'), 'success');
|
||||
};
|
||||
|
||||
if (replay) return html`<div class="st st--replay" ref=${boxRef} tabindex="-1" role="dialog" aria-modal="true" aria-label=${t('统计数据')}>
|
||||
<${Replay} rec=${replay} onBack=${() => setReplay(null)} />
|
||||
</div>`;
|
||||
|
||||
const tabInfo = TABS.find((x) => x.id === tab) || TABS[0];
|
||||
const empty = !stats.records.length;
|
||||
return html`<div class="st" role="dialog" aria-modal="true" aria-label=${t('统计数据')} tabindex="-1" ref=${boxRef}>
|
||||
<div class="st__bg" aria-hidden="true"></div>
|
||||
<header class="st-top">
|
||||
<div class="st-top__left">
|
||||
<${Button} variant="ghost" size="md" icon="chevronLeft" class="st-back" onClick=${closeStats} aria-label=${t('返回')} title=${t('返回 (Esc)')}>${t('返回')}<//>
|
||||
</div>
|
||||
<div class="st-top__center">
|
||||
<${MicroLabel} tone="mint">STATISTICS // ${tabInfo.micro}<//>
|
||||
<div class="st-tabs" role="tablist" aria-label=${t('统计数据')}>
|
||||
${TABS.map((x) => html`<button key=${x.id} type="button" role="tab" aria-selected=${x.id === tab ? 'true' : 'false'} data-tab=${x.id}
|
||||
class=${cx('st-tab', x.id === tab && 'is-on')} onClick=${() => statsStore.set({ tab: x.id })}>${t(x.name)}</button>`)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-top__right">
|
||||
<${Button} variant="ghost" size="sm" data-testid="stats-export" disabled=${empty || stats.newer} onClick=${doExport} title=${t('导出为文件(可在别的设备上导入)')}>${t('导出')}<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="stats-import" disabled=${stats.newer} onClick=${() => fileRef.current?.click()} title=${t('从导出的文件导入(与现有记录合并)')}>${t('导入')}<//>
|
||||
<input ref=${fileRef} type="file" accept="application/json,.json" hidden onChange=${doFile} />
|
||||
<${Button} variant="secondary" size="sm" data-testid="stats-clear" disabled=${empty || stats.newer} onClick=${doClear} title=${t('清空本机的统计数据')}>${t('清空')}<//>
|
||||
</div>
|
||||
</header>
|
||||
<p class="st-note"><${Icon} name="info" />${t('本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。')}</p>
|
||||
${stats.damaged ? html`<p class="st-note is-warn"><${Icon} name="warn" />${t('本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。')}</p>` : null}
|
||||
${stats.dropped ? html`<p class="st-note is-warn"><${Icon} name="warn" />${t('有 {n} 条记录已损坏,已忽略。', { n: stats.dropped })}</p>` : null}
|
||||
<main class="st-body" data-tab=${tab}>
|
||||
${!ready ? html`<div class="st-loading"><${Spinner} size="md" /></div>`
|
||||
: stats.newer ? html`<p class="st-empty">${t('检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。')}</p>`
|
||||
: empty ? html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`
|
||||
: tab === 'bands' ? html`<${Bands} agg=${agg} gd=${gd} />`
|
||||
: tab === 'totals' ? html`<${CombatSums} agg=${agg} />`
|
||||
: tab === 'history' ? html`<${History} records=${stats.records} gd=${gd} onOpen=${setReplay} />`
|
||||
: html`<${Overview} agg=${agg} gd=${gd} />`}
|
||||
</main>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Global stats viewer host — mounted once by main.js; renders nothing (and loads nothing) until opened. */
|
||||
export function StatsHost() {
|
||||
const { open, tab } = useStore((s) => s, Object.is, statsStore);
|
||||
if (!open) return null;
|
||||
return html`<${StatsScreen} tab=${tab} />`;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -245,7 +246,10 @@ export function TitleScreen() {
|
||||
</div>
|
||||
<div class="title-corner title-corner--tr">
|
||||
<div>
|
||||
<${LangToggle} class="title-lang" />
|
||||
<div class="title-corner__tools">
|
||||
<${Button} variant="ghost" size="sm" icon="chart" class="title-stats" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
|
||||
<${LangToggle} class="title-lang" />
|
||||
</div>
|
||||
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,8 @@ export const ICONS = {
|
||||
users: { d: 'M9 4a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7zM2 20a7 6.5 0 0 1 14 0zM16.5 5a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm.9 8.1A6.5 6 0 0 1 22.5 20H18a8.6 8 0 0 0-2.4-6.1 6 6 0 0 1 1.8-.8z' },
|
||||
rook: { d: 'M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z' },
|
||||
signal: { d: 'M2 17h3v4H2zm6-4h3v8H8zm6-4h3v12h-3zm6-4h3v16h-3z' },
|
||||
// 统计 (the stats page's entry): three columns on a baseline — apart from `signal`, the ping bars
|
||||
chart: { d: 'M4 12h4v7H4zm6-6h4v13h-4zm6 3h4v10h-4zM3 20h18v2H3z' },
|
||||
refresh: { d: 'M12 4a8 8 0 0 1 7.4 5H17v2h6V5h-2v2.3A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8zm0 16a8 8 0 0 1-7.4-5H7v-2H1v6h2v-2.3A10 10 0 0 0 22 12h-2a8 8 0 0 1-8 8z' },
|
||||
snow: { d: 'M11 2h2v4.2l2.3-2.3 1.4 1.4-3.7 3.7v2h2l3.7-3.7 1.4 1.4-2.3 2.3H22v2h-4.2l2.3 2.3-1.4 1.4-3.7-3.7h-2v2l3.7 3.7-1.4 1.4-2.3-2.3V22h-2v-4.2l-2.3 2.3-1.4-1.4 3.7-3.7v-2H9l-3.7 3.7-1.4-1.4L6.2 13H2v-2h4.2L3.9 8.7l1.4-1.4L9 11h2V9L7.3 5.3l1.4-1.4L11 6.2z' },
|
||||
chevronRight: { d: 'M8.6 5 7.2 6.4 12.8 12l-5.6 5.6L8.6 19l7-7z' },
|
||||
|
||||
@@ -12,6 +12,7 @@ import { net } from '../net.js';
|
||||
import { store, useStore, shallowEqual, emptyMatch, createStore, isSpectating } from '../store.js';
|
||||
import { GIcon } from './gameComponents.js';
|
||||
import { GuideButton } from './guide.js';
|
||||
import { recordQuit } from './stats.js';
|
||||
import { t, tParts, N_ } from '../../../shared/i18n.js';
|
||||
|
||||
/**
|
||||
@@ -37,18 +38,27 @@ export const awayEnds = (s, prev) => (!!prev?.match?.public && !s?.match?.public
|
||||
store.subscribe((s, prev) => { if (awayStore.get().away && awayEnds(s, prev)) awayStore.set({ away: false }); });
|
||||
|
||||
/**
|
||||
* Leave the match for good (g.leave → room.leave), then drop local room/match state.
|
||||
* Leave the match for good (g.leave → room.leave), then drop local room/match state. The server sends a leaver no
|
||||
* settlement, so the page records the match itself before it is cleared (本机统计, ui/stats.js recordQuit: a 中途退出
|
||||
* entry of the history; it counts only when a round had been cleared) — once the leave went through.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function quitMatch() {
|
||||
const before = store.get();
|
||||
let left = false;
|
||||
try {
|
||||
try { await net.request('g.leave', {}); } catch (err) {
|
||||
if (err?.code !== 'NOT_IN_ROOM' && err?.code !== 'WRONG_PHASE' && err?.code !== 'OFFLINE') throw err;
|
||||
}
|
||||
try { await net.request('room.leave', {}); } catch (err) { if (err?.code !== 'NOT_IN_ROOM' && err?.code !== 'OFFLINE') throw err; }
|
||||
left = true;
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
// (a settlement that arrived while the leave was in flight is the match's record already)
|
||||
if (left && !store.get().match.result) {
|
||||
try { recordQuit(before); } catch (err) { console.warn('[stats] quit record failed', err); }
|
||||
}
|
||||
store.set({ room: null, match: emptyMatch() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
// Local match statistics: every match the local player took part in is appended as one record to localStorage
|
||||
// (`sp.pref.stats`). Per-browser only — the server keeps matches in memory and has no identity, so there is nowhere
|
||||
// else for cross-match numbers to live; nothing here is ever sent anywhere. The stats page labels itself 本机数据 and
|
||||
// offers JSON export / import for moving between devices. (Ported from PR #323 by @2321Robin, reworked for 0.2.2.)
|
||||
//
|
||||
// Two kinds of record (`end`):
|
||||
// 'settled' the match reached its settlement: m.result arrived (server/match/results.js buildResult). The record
|
||||
// keeps what the settlement offers — the match frame (difficulty, mode, boss, seed, duration, hidden-core
|
||||
// outcome, reason) and one row per player (band, title, lineup, bonds, the stat block) — so the page can
|
||||
// re-view the settlement screen (recordToResult).
|
||||
// 'quit' the player gave up (放弃模拟) before the settlement; the server sends a leaver nothing, so the record is
|
||||
// built from what the client knew at that moment (buildQuitRecord): the frame, the player's own row, the
|
||||
// rounds cleared. It is listed in the history, marked 中途退出, and cannot be re-viewed.
|
||||
//
|
||||
// What counts (countsTowardStats, the owner's decision of 2026-10-08: a match entered and left right away must not
|
||||
// count): every aggregate — games, wins, win rate, hidden core, best round, time played, the strategy table, titles
|
||||
// and the combat totals — is taken over the records that count. A settled record always counts. A quit record counts
|
||||
// once at least one of the player's own battles had finished — a round cleared (the server's rule: leaving while
|
||||
// round k runs has passed k − 1 rounds, leaving in its settlement k) or already eliminated. A match left earlier is
|
||||
// history only.
|
||||
//
|
||||
// Bounded storage: at most MAX_RECORDS matches and MAX_CHARS characters of JSON. Only the newest FULL_KEEP records
|
||||
// keep their full rows (lineups, bonds, teammates' names — what the settlement replay needs); older ones are compacted
|
||||
// to the player's own summary row, which is all the aggregates read. The oldest records go first when a budget is hit,
|
||||
// and a write the browser refuses shrinks the list further instead of failing. Teammates' display names are part of
|
||||
// the newest records and of an export file: they stay on this device unless the player exports them.
|
||||
//
|
||||
// Format evolution: the envelope carries a version (`STATS_VERSION`); `migrateStats` walks records through a chain of
|
||||
// per-version steps and every loaded / imported record passes `normalizeRecord`, a strict schema: missing fields get
|
||||
// defaults, unknown fields and oversized values are dropped, a row it cannot salvage is dropped. An envelope from a
|
||||
// NEWER version than this build is refused (never downgraded and written back); an unreadable one is set aside
|
||||
// (`sp.pref.stats.damaged`) and the history starts again.
|
||||
//
|
||||
// Pure logic, no Preact / DOM: Node-testable (test/ui/stats.test.js); localStorage is touched only by loadStats /
|
||||
// saveStats / their helpers.
|
||||
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
|
||||
/** Current record-envelope version (bump + add a MIGRATIONS step when the record shape changes). */
|
||||
export const STATS_VERSION = 1;
|
||||
/** localStorage key under the app's `sp.pref.` prefix (store.js loadPref). */
|
||||
export const STATS_PREF_KEY = 'stats';
|
||||
/** Oldest records are dropped beyond this many matches. */
|
||||
export const MAX_RECORDS = 1000;
|
||||
/** The newest records that keep their full rows (lineups, bonds, teammates) — what the settlement replay reads. */
|
||||
export const FULL_KEEP = 30;
|
||||
/** Budget of the stored JSON in characters (a character costs up to 2 bytes of the browser's ~5 MB quota): the oldest records go first. */
|
||||
export const MAX_CHARS = 800_000;
|
||||
/** Largest export file the page reads. */
|
||||
export const IMPORT_MAX_CHARS = 8_000_000;
|
||||
|
||||
const FULL_KEY = `sp.pref.${STATS_PREF_KEY}`;
|
||||
const DAMAGED_KEY = `${FULL_KEY}.damaged`;
|
||||
|
||||
/** @returns {{ v: number, records: Array }} a fresh empty envelope */
|
||||
export const emptyStats = () => ({ v: STATS_VERSION, records: [] });
|
||||
|
||||
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
const int = (v, d) => (Number.isFinite(v) ? Math.round(v) : d);
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const bool = (v, d = false) => (typeof v === 'boolean' ? v : d);
|
||||
/** A non-empty string, cut to `max` characters. */
|
||||
const str = (v, max = 64) => (typeof v === 'string' && v.length ? v.slice(0, max) : null);
|
||||
|
||||
// per-record bounds (an imported file is foreign data: nothing a row holds may grow without limit)
|
||||
const MAX_PLAYERS = 6; // the game seats 4
|
||||
const MAX_LINEUP = 12;
|
||||
const MAX_ITEMS = 4;
|
||||
const MAX_BONDS = 24;
|
||||
const MAX_STAT_KEYS = 24;
|
||||
const MAX_NAME = 24;
|
||||
|
||||
/**
|
||||
* Copy only the finite numeric keys of a stats block (the settlement's numbers; a numeric field this build does not
|
||||
* know is kept too, up to MAX_STAT_KEYS — display can lag, data should not).
|
||||
* @param {any} raw
|
||||
* @returns {Record<string, number>}
|
||||
*/
|
||||
function normalizeStatBlock(raw) {
|
||||
const out = {};
|
||||
if (!isObj(raw)) return out;
|
||||
let n = 0;
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (!Number.isFinite(v) || k.length > 32) continue;
|
||||
out[k] = v;
|
||||
if (++n >= MAX_STAT_KEYS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNV-1a 32-bit, hex — a content hash needs no crypto, only stability across sessions.
|
||||
* @param {string} s
|
||||
* @returns {string}
|
||||
*/
|
||||
function fnv1a(s) {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
||||
}
|
||||
return h.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* The record's dedupe id, from match facts the server replays byte-identically on reconnect / reload
|
||||
* (server/lobby.js result replay): seed + duration + mode + rounds + outcome + who I was. Two distinct
|
||||
* matches colliding on all of these is not a real scenario; a replayed m.result hits the same id and is
|
||||
* dropped, which is the point (recording happens on every m.result arrival, replays included).
|
||||
* @param {any} res raw m.result payload
|
||||
* @param {string|null|undefined} myId the local player's id at that moment
|
||||
* @returns {string}
|
||||
*/
|
||||
export function recordId(res, myId) {
|
||||
const r = isObj(res) ? res : {};
|
||||
return `r1.${fnv1a([r.seed ?? '-', r.durationMs ?? '-', r.modeId ?? '-', r.roundsPassed ?? '-', r.victory ? 1 : 0, myId ?? '-'].join('|'))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize ONE player row: known fields defaulted and bounded, unknown fields dropped. `quit`: the record is a quit
|
||||
* record, whose row may not know its rounds (`roundsPassed: null`).
|
||||
* @param {any} raw
|
||||
* @param {boolean} [quit]
|
||||
* @returns {any}
|
||||
*/
|
||||
function normalizePlayerRow(raw, quit = false) {
|
||||
const p = isObj(raw) ? raw : {};
|
||||
// the page and the replay read the title record from the game data by id: the sent texts are not kept
|
||||
const title = isObj(p.title) && str(p.title.id) ? { id: str(p.title.id) } : null;
|
||||
const lineup = (Array.isArray(p.lineup) ? p.lineup : []).filter(isObj).slice(0, MAX_LINEUP).map((u) => {
|
||||
// ids only — the tile position (row/col) is replay detail, not a statistic; items kept (配发装备统计); a 自选
|
||||
// pick and a 补位 stand-in kept too, or the replayed card would draw the plain operator
|
||||
const e = {
|
||||
id: str(u.id) || '', golden: bool(u.golden), tier: int(u.tier, 0),
|
||||
items: (Array.isArray(u.items) ? u.items : []).filter((i) => typeof i === 'string' && i.length).slice(0, MAX_ITEMS).map((i) => i.slice(0, 64)),
|
||||
};
|
||||
if (u.kind === 'token') e.kind = 'token';
|
||||
if (isObj(u.diy) && str(u.diy.charId)) {
|
||||
e.diy = { charId: str(u.diy.charId), skillIndex: Number.isInteger(u.diy.skillIndex) ? u.diy.skillIndex : 0, uniEquipId: str(u.diy.uniEquipId) };
|
||||
}
|
||||
if (str(u.standInFor)) e.standInFor = str(u.standInFor);
|
||||
return e;
|
||||
}).filter((u) => u.id);
|
||||
const bonds = (Array.isArray(p.bonds) ? p.bonds : []).filter(isObj).slice(0, MAX_BONDS).map((b) => ({
|
||||
bondId: str(b.bondId) || str(b.id) || '', layers: int(b.layers, 0), active: bool(b.active),
|
||||
})).filter((b) => b.bondId);
|
||||
return {
|
||||
playerId: str(p.playerId) || '',
|
||||
seat: int(p.seat, 0),
|
||||
name: str(p.name, MAX_NAME) || '',
|
||||
isBot: bool(p.isBot),
|
||||
left: bool(p.left),
|
||||
alive: bool(p.alive, true),
|
||||
victory: bool(p.victory), // server semantics: team won AND this player was still in at the end
|
||||
roundsPassed: quit ? num(p.roundsPassed) : int(p.roundsPassed, 0),
|
||||
eliminatedRound: num(p.eliminatedRound),
|
||||
lp: num(p.lp),
|
||||
bandId: str(p.bandId),
|
||||
title,
|
||||
trophies: int(p.trophies, 0),
|
||||
reward: int(p.reward, 0),
|
||||
lineup,
|
||||
bonds,
|
||||
stats: normalizeStatBlock(p.stats),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize ONE match record to the current shape (a strict schema: missing fields get defaults, unknown ones are
|
||||
* dropped, every string / list is bounded). A record without an `end` is a settled one — the PR #323 build, which
|
||||
* never recorded anything else, wrote none.
|
||||
* @param {any} raw
|
||||
* @returns {any|null} null when the row is not salvageable (no usable player row at all)
|
||||
*/
|
||||
export function normalizeRecord(raw) {
|
||||
const r = isObj(raw) ? raw : {};
|
||||
// a record IS a played match — no usable players array, no record
|
||||
if (!Array.isArray(r.players)) return null;
|
||||
const quit = r.end === 'quit';
|
||||
const players = r.players.filter(isObj).slice(0, MAX_PLAYERS).map((p) => normalizePlayerRow(p, quit));
|
||||
if (!players.length) return null;
|
||||
const rec = {
|
||||
id: str(r.id),
|
||||
v: STATS_VERSION,
|
||||
t: int(r.t, 0),
|
||||
end: quit ? 'quit' : 'settled',
|
||||
victory: bool(r.victory),
|
||||
roundsPassed: quit ? num(r.roundsPassed) : int(r.roundsPassed, 0),
|
||||
lastRound: num(r.lastRound),
|
||||
hiddenReached: bool(r.hiddenReached),
|
||||
hiddenCleared: bool(r.hiddenCleared),
|
||||
reason: str(r.reason, 32),
|
||||
modeId: str(r.modeId),
|
||||
difficulty: str(r.difficulty, 24),
|
||||
stageId: str(r.stageId),
|
||||
bossId: str(r.bossId),
|
||||
hiddenBossId: str(r.hiddenBossId),
|
||||
seed: num(r.seed),
|
||||
durationMs: num(r.durationMs),
|
||||
teamLp: num(r.teamLp),
|
||||
roomMode: r.roomMode === 'solo' || r.roomMode === 'coop' ? r.roomMode : null,
|
||||
selfId: str(r.selfId),
|
||||
players,
|
||||
};
|
||||
if (r.compact === true) rec.compact = true;
|
||||
// an id-less (hand-made) record gets a content id, so both copies of it land on the same key
|
||||
if (!rec.id) rec.id = `c.${fnv1a(JSON.stringify(rec))}`;
|
||||
return rec;
|
||||
}
|
||||
|
||||
/**
|
||||
* The MIGRATIONS chain: `MIGRATIONS[v]` upgrades a v-envelope to v+1, for v = STATS_VERSION and down.
|
||||
* Empty at v1 — the steps exist so the next format change lands as one function here and every stored /
|
||||
* imported envelope walks the whole chain.
|
||||
* @type {Record<number, (stats: any) => any>}
|
||||
*/
|
||||
export const MIGRATIONS = {};
|
||||
|
||||
/** Newest first (the stored order): by time, then id. */
|
||||
const newestFirst = (a, b) => (b.t - a.t) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
||||
|
||||
/**
|
||||
* Bring any stored / imported envelope to the current version.
|
||||
* @param {any} raw
|
||||
* @returns {{ stats: { v: number, records: any[] }, changed: boolean, newer: boolean, dropped: number }}
|
||||
* `newer` = the data was written by a NEWER build: returned untouched, callers must not save over it.
|
||||
* `dropped` = rows that were not salvageable, repeated a record or went beyond MAX_RECORDS: gone from `stats`.
|
||||
*/
|
||||
export function migrateStats(raw) {
|
||||
if (!isObj(raw) || !Array.isArray(raw.records)) return { stats: emptyStats(), changed: false, newer: false, dropped: 0 };
|
||||
if ((raw.v ?? 0) > STATS_VERSION) {
|
||||
return { stats: { v: raw.v, records: raw.records }, changed: false, newer: true, dropped: 0 };
|
||||
}
|
||||
let v = Number.isFinite(raw.v) ? raw.v : 0;
|
||||
let records = raw.records;
|
||||
let changed = v !== STATS_VERSION;
|
||||
while (v < STATS_VERSION) {
|
||||
const step = MIGRATIONS[v];
|
||||
if (typeof step === 'function') records = step({ v, records }).records;
|
||||
v++;
|
||||
}
|
||||
// a hand-edited / merged list can be oversized, unsorted or repeat a record: only the newest MAX_RECORDS survive, newest first
|
||||
const normalized = [];
|
||||
const seen = new Set(); // a record listed twice (hand-merged data) counts once: the first copy
|
||||
for (const r of records) {
|
||||
const rec = normalizeRecord(r);
|
||||
if (rec && !seen.has(rec.id)) { seen.add(rec.id); normalized.push(rec); }
|
||||
}
|
||||
normalized.sort(newestFirst);
|
||||
const kept = normalized.slice(0, MAX_RECORDS);
|
||||
const dropped = records.length - kept.length;
|
||||
// storage is rewritten (self-healed) when a row went, the order changed or an id-less row got its content id
|
||||
changed ||= dropped > 0 || kept.some((rec, i) => !isObj(records[i]) || rec.id !== records[i].id);
|
||||
return { stats: { v: STATS_VERSION, records: kept }, changed, newer: false, dropped };
|
||||
}
|
||||
|
||||
/** Read the raw stored string (null when absent or unreadable). */
|
||||
function readRaw() {
|
||||
try { return globalThis.localStorage?.getItem(FULL_KEY) ?? null; } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read + migrate the persisted envelope; self-heals older shapes back to storage. An unreadable value (not JSON, or
|
||||
* not an envelope) is moved to `sp.pref.stats.damaged` — kept for a manual recovery — and the history starts again.
|
||||
* @returns {{ v: number, records: any[], newer: boolean, dropped: number, damaged: boolean }}
|
||||
*/
|
||||
export function loadStats() {
|
||||
const raw = readRaw();
|
||||
if (raw == null) return { ...emptyStats(), newer: false, dropped: 0, damaged: false };
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(raw); } catch { parsed = undefined; }
|
||||
if (!isObj(parsed) || !Array.isArray(parsed.records)) {
|
||||
// keep the bad value where a person can find it; if even that write fails the next save simply replaces it
|
||||
try { globalThis.localStorage?.setItem(DAMAGED_KEY, raw); globalThis.localStorage?.removeItem(FULL_KEY); } catch { /* keep it in place */ }
|
||||
return { ...emptyStats(), newer: false, dropped: 0, damaged: true };
|
||||
}
|
||||
const { stats, changed, newer, dropped } = migrateStats(parsed);
|
||||
if (!newer && changed) saveStats(stats);
|
||||
return { ...stats, newer, dropped, damaged: false };
|
||||
}
|
||||
|
||||
// ---- compaction + the storage budget ---------------------------------------------------------------------------
|
||||
|
||||
const COMPACT_FRAME = ['id', 'v', 't', 'end', 'victory', 'roundsPassed', 'lastRound', 'hiddenReached', 'hiddenCleared', 'reason', 'modeId',
|
||||
'difficulty', 'roomMode', 'durationMs', 'selfId'];
|
||||
|
||||
/**
|
||||
* The compact form of a record: the frame and the player's OWN row without lineup, bonds and name — everything the
|
||||
* aggregates and the history list read, nothing a replay needs. Teammates are gone. Idempotent.
|
||||
* @param {any} rec a normalized record
|
||||
* @returns {any}
|
||||
*/
|
||||
export function compactRecord(rec) {
|
||||
const self = selfRowOf(rec);
|
||||
const out = {};
|
||||
for (const k of COMPACT_FRAME) if (rec[k] != null) out[k] = rec[k];
|
||||
out.compact = true;
|
||||
out.players = self ? [{
|
||||
playerId: self.playerId, seat: self.seat, alive: self.alive, victory: self.victory, roundsPassed: self.roundsPassed,
|
||||
eliminatedRound: self.eliminatedRound, bandId: self.bandId, title: self.title, left: self.left, stats: self.stats,
|
||||
}] : [];
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* What goes to storage: the newest FULL_KEEP records as they are, older ones compacted, then the oldest dropped until
|
||||
* the JSON fits `maxChars` (the newest record is always kept).
|
||||
* @param {any[]} records newest first
|
||||
* @param {number} [maxChars]
|
||||
* @returns {any[]}
|
||||
*/
|
||||
export function fitToBudget(records, maxChars = MAX_CHARS) {
|
||||
const kept = [];
|
||||
let used = 40; // the envelope itself
|
||||
records.slice(0, MAX_RECORDS).forEach((r, i) => {
|
||||
const rec = i < FULL_KEEP ? r : compactRecord(r);
|
||||
const n = JSON.stringify(rec).length + 1;
|
||||
if (kept.length && used + n > maxChars) return;
|
||||
used += n;
|
||||
kept.push(rec);
|
||||
});
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the envelope. Never overwrites an envelope of a newer build. Compacts and trims to the budget first; a write
|
||||
* the browser refuses (quota — other keys share it) is retried with half the history, down to a handful of records.
|
||||
* @param {{ v: number, records: any[], newer?: boolean }} stats
|
||||
* @returns {boolean} whether a write stuck
|
||||
*/
|
||||
export function saveStats(stats) {
|
||||
if (stats.newer) return false;
|
||||
let recs = fitToBudget(stats.records);
|
||||
for (;;) {
|
||||
try {
|
||||
const ls = globalThis.localStorage;
|
||||
if (!ls) return false; // no storage at all (disabled by the browser)
|
||||
ls.setItem(FULL_KEY, JSON.stringify({ v: STATS_VERSION, records: recs }));
|
||||
return true;
|
||||
} catch { /* quota or storage disabled — fall through to shrinking */ }
|
||||
if (recs.length <= 8) return false; // a small history that still does not fit is foreign quota pressure
|
||||
recs = recs.slice(0, Math.max(8, Math.ceil(recs.length / 2)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the room shape ('solo' | 'coop') the way constants.js modeIdFor builds modeIds.
|
||||
* @param {string|null|undefined} modeId
|
||||
* @param {string|null|undefined} roomMode room.state `mode` when known
|
||||
* @returns {'solo'|'coop'|null}
|
||||
*/
|
||||
export function roomModeOf(modeId, roomMode) {
|
||||
if (roomMode === 'solo' || roomMode === 'coop') return roomMode;
|
||||
if (typeof modeId !== 'string') return null;
|
||||
if (modeId.includes('_single_')) return 'solo';
|
||||
if (modeId.includes('_multi_')) return 'coop';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one settled record from a raw m.result payload (+ context). Null when there is nothing to store: payloads
|
||||
* without players (the error-path settlement) or matches the local player did not take part in (spectator
|
||||
* seats get every result pushed too — eliminated players, who ARE in players[], keep recording).
|
||||
* @param {any} res raw m.result payload
|
||||
* @param {{ myId?: string|null, roomMode?: string|null, now?: number }} ctx
|
||||
* @returns {any|null}
|
||||
*/
|
||||
export function buildRecord(res, ctx = {}) {
|
||||
const r = isObj(res) ? res : {};
|
||||
if (!Array.isArray(r.players) || !r.players.length) return null;
|
||||
const myId = str(ctx.myId);
|
||||
const selfRow = r.players.find((p) => isObj(p) && p.playerId === myId);
|
||||
if (!selfRow) return null; // spectator / observer: not my match
|
||||
return normalizeRecord({
|
||||
v: STATS_VERSION,
|
||||
t: int(ctx.now, 0),
|
||||
id: recordId(r, myId),
|
||||
end: 'settled',
|
||||
victory: bool(r.victory),
|
||||
roundsPassed: int(r.roundsPassed, 0),
|
||||
lastRound: num(r.lastRound),
|
||||
hiddenReached: bool(r.hiddenReached),
|
||||
hiddenCleared: bool(r.hiddenCleared),
|
||||
reason: str(r.reason),
|
||||
modeId: str(r.modeId),
|
||||
difficulty: str(r.difficulty),
|
||||
stageId: str(r.stageId),
|
||||
bossId: str(r.bossId),
|
||||
hiddenBossId: str(r.hiddenBossId),
|
||||
seed: num(r.seed),
|
||||
durationMs: num(r.durationMs),
|
||||
teamLp: num(r.teamLp),
|
||||
roomMode: roomModeOf(str(r.modeId), ctx.roomMode),
|
||||
selfId: myId,
|
||||
players: r.players,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the match left early ----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* What the page remembers of the match on screen, so that giving it up can still be recorded: when the page first saw
|
||||
* it, whether that was at the start (a duration is only kept then), and the last round the player was seen alive in
|
||||
* (the server tells the others at the settlement, a leaver nothing).
|
||||
* @returns {{ active: boolean, startedAt: number, sawStart: boolean, aliveRound: number|null }}
|
||||
*/
|
||||
export const createLiveMatch = () => ({ active: false, startedAt: 0, sawStart: false, aliveRound: null });
|
||||
|
||||
/** The module's own tracker (fed by installStatsRecorder, read by recordQuit). */
|
||||
export const liveMatch = createLiveMatch();
|
||||
|
||||
/**
|
||||
* Feed the tracker one app state (cheap: called on every store change).
|
||||
* @param {ReturnType<typeof createLiveMatch>} live
|
||||
* @param {any} state the app store state
|
||||
* @param {number} [now]
|
||||
*/
|
||||
export function observeMatch(live, state, now = Date.now()) {
|
||||
const pub = state?.match?.public;
|
||||
const inMatch = !!pub && !!pub.phase && pub.phase !== PHASE.LOBBY && pub.phase !== PHASE.RESULT && !state.match.result;
|
||||
if (!inMatch) { live.active = false; return live; }
|
||||
const round = Number(pub.round) || 0;
|
||||
if (!live.active) {
|
||||
live.active = true;
|
||||
live.startedAt = now;
|
||||
live.sawStart = round <= 1;
|
||||
live.aliveRound = null;
|
||||
}
|
||||
const me = Array.isArray(pub.players) ? pub.players.find((p) => isObj(p) && p.playerId === state.me?.playerId) : null;
|
||||
if (me && me.alive !== false) live.aliveRound = Math.max(live.aliveRound ?? 0, round);
|
||||
return live;
|
||||
}
|
||||
|
||||
/** Start following the store (main.js). Returns the unsubscribe function. */
|
||||
export function installStatsRecorder(store) {
|
||||
observeMatch(liveMatch, store.get());
|
||||
return store.subscribe((s) => observeMatch(liveMatch, s));
|
||||
}
|
||||
|
||||
/**
|
||||
* The rounds a player who leaves in `phase` of `round` has cleared — the server's rule for a quit (platform.js _quit:
|
||||
* eliminated in round k ⇒ k − 1 passed; leaving in the settlement of round k counts k).
|
||||
* @param {string} phase @param {number} round
|
||||
*/
|
||||
export function clearedRounds(phase, round) {
|
||||
const r = Number.isFinite(round) ? round : 0;
|
||||
return Math.max(0, phase === PHASE.SETTLE ? r : r - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the quit record of a match the local player is leaving (放弃模拟), from the app state and the tracker. Null when
|
||||
* there is nothing of mine to record: no match on screen, its settlement already here (that one is recorded by
|
||||
* recordResult), a spectator seat, or a match the page never saw.
|
||||
* @param {any} state the app store state BEFORE the match is cleared
|
||||
* @param {{ live?: ReturnType<typeof createLiveMatch>, now?: number }} [ctx]
|
||||
* @returns {any|null}
|
||||
*/
|
||||
export function buildQuitRecord(state, ctx = {}) {
|
||||
const live = ctx.live ?? liveMatch;
|
||||
const now = int(ctx.now, Date.now());
|
||||
const pub = state?.match?.public;
|
||||
const myId = str(state?.me?.playerId);
|
||||
if (!pub || !myId || state.match.result || !live.active) return null;
|
||||
const row = Array.isArray(pub.players) ? pub.players.find((p) => isObj(p) && p.playerId === myId) : null;
|
||||
if (!row) return null; // a spectator seat is no player of the match
|
||||
const alive = row.alive !== false;
|
||||
const round = Number(pub.round) || 0;
|
||||
// alive: the rounds cleared so far; already eliminated: the round it was seen alive in, less one (unknown when the
|
||||
// page never saw it alive — a reload after the elimination)
|
||||
const roundsPassed = alive ? clearedRounds(pub.phase, round) : (live.aliveRound != null ? Math.max(0, live.aliveRound - 1) : null);
|
||||
return normalizeRecord({
|
||||
v: STATS_VERSION,
|
||||
t: now,
|
||||
id: `q.${fnv1a([myId, live.startedAt, pub.modeId ?? '-', state.room?.code ?? '-'].join('|'))}`,
|
||||
end: 'quit',
|
||||
victory: false,
|
||||
roundsPassed,
|
||||
lastRound: num(pub.lastRound),
|
||||
hiddenReached: pub.phase === PHASE.HIDDEN_CORE,
|
||||
hiddenCleared: false,
|
||||
reason: 'quit',
|
||||
modeId: str(pub.modeId),
|
||||
difficulty: str(pub.difficulty),
|
||||
stageId: str(pub.stageId),
|
||||
bossId: str(pub.bossId),
|
||||
hiddenBossId: str(pub.hiddenBossId),
|
||||
seed: null,
|
||||
durationMs: live.sawStart ? Math.max(0, now - live.startedAt) : null,
|
||||
teamLp: num(pub.teamLp),
|
||||
roomMode: roomModeOf(str(pub.modeId), state.room?.mode),
|
||||
selfId: myId,
|
||||
players: [{
|
||||
playerId: myId, seat: row.seat, name: row.name, isBot: false, left: true, alive, victory: false, roundsPassed,
|
||||
eliminatedRound: alive ? null : live.aliveRound, lp: row.lp, bandId: row.bandId,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The m.result-shaped payload behind a settled record — the inverse of buildRecord. The stats page's history
|
||||
* rows hand this to the settlement view (screens/result.js reads it through normalizeResult): a finished match can be
|
||||
* RE-VIEWED because the record kept every field the payload had. Whatever a record never stored is simply absent and
|
||||
* normalizeResult defaults it, exactly like a tolerant live payload. A quit record (nothing was settled) and a
|
||||
* compacted one (its rows are gone) have no settlement to show.
|
||||
* @param {any} rec
|
||||
* @returns {any|null} null when the row is not a usable, full, settled record
|
||||
*/
|
||||
export function recordToResult(rec) {
|
||||
const r = normalizeRecord(rec);
|
||||
if (!r || r.end === 'quit' || r.compact) return null;
|
||||
const res = {
|
||||
victory: r.victory,
|
||||
roundsPassed: r.roundsPassed,
|
||||
hiddenReached: r.hiddenReached,
|
||||
hiddenCleared: r.hiddenCleared,
|
||||
reason: r.reason,
|
||||
modeId: r.modeId,
|
||||
difficulty: r.difficulty,
|
||||
bossId: r.bossId,
|
||||
hiddenBossId: r.hiddenBossId,
|
||||
seed: r.seed,
|
||||
durationMs: r.durationMs,
|
||||
teamLp: r.teamLp,
|
||||
players: r.players,
|
||||
};
|
||||
if (Number.isFinite(r.lastRound)) res.lastRound = r.lastRound;
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one record: replays (same content id) are dropped, newest first, capped at MAX_RECORDS.
|
||||
* @param {{ v: number, records: any[] }} stats
|
||||
* @param {any} record
|
||||
* @returns {{ stats: { v: number, records: any[] }, added: boolean }}
|
||||
*/
|
||||
export function appendRecord(stats, record) {
|
||||
if (!record || !record.id) return { stats, added: false };
|
||||
if (stats.records.some((r) => r.id === record.id)) return { stats, added: false };
|
||||
const records = [record, ...stats.records].slice(0, MAX_RECORDS);
|
||||
return { stats: { v: STATS_VERSION, records }, added: true };
|
||||
}
|
||||
|
||||
/** load → append → save of one built record (never over an envelope of a newer build). @returns {any|null} the record when stored */
|
||||
function storeRecord(rec) {
|
||||
if (!rec) return null;
|
||||
const stats = loadStats();
|
||||
if (stats.newer) return null;
|
||||
const { stats: next, added } = appendRecord(stats, rec);
|
||||
if (added && !saveStats(next)) console.warn('[stats] localStorage write failed (quota?), record not persisted');
|
||||
return added ? rec : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a finished match end-to-end (load → append → save). Returns the built record, or null when
|
||||
* nothing was stored (no players / not my match / replay).
|
||||
* @param {any} res raw m.result payload
|
||||
* @param {{ myId?: string|null, roomMode?: string|null, now?: number }} [ctx]
|
||||
* @returns {any|null}
|
||||
*/
|
||||
export function recordResult(res, ctx = {}) {
|
||||
return storeRecord(buildRecord(res, ctx));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the match the player is giving up (放弃模拟), before the app clears its state. Returns the record, or null.
|
||||
* @param {any} state the app store state before the match is cleared
|
||||
* @param {{ live?: ReturnType<typeof createLiveMatch>, now?: number }} [ctx]
|
||||
* @returns {any|null}
|
||||
*/
|
||||
export function recordQuit(state, ctx = {}) {
|
||||
const live = ctx.live ?? liveMatch;
|
||||
const rec = storeRecord(buildQuitRecord(state, ctx));
|
||||
live.active = false; // one record per match
|
||||
return rec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two envelopes (import into existing): union by record id, newest first, capped.
|
||||
* @param {{ records: any[] }} a current (wins ties)
|
||||
* @param {{ records: any[] }} b incoming
|
||||
* @returns {{ v: number, records: any[] }}
|
||||
*/
|
||||
export function mergeStats(a, b) {
|
||||
const byId = new Map();
|
||||
for (const r of [...(Array.isArray(b?.records) ? b.records : []), ...(Array.isArray(a?.records) ? a.records : [])]) {
|
||||
const rec = normalizeRecord(r);
|
||||
if (rec) byId.set(rec.id, rec);
|
||||
}
|
||||
const records = [...byId.values()].sort(newestFirst).slice(0, MAX_RECORDS);
|
||||
return { v: STATS_VERSION, records };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse + migrate + merge an exported / pasted payload into the current data.
|
||||
* @param {any} raw parsed JSON of the imported file
|
||||
* @param {{ records: any[] }} current current envelope
|
||||
* @returns {{ stats: { v: number, records: any[] }, added: number, skipped: number, dropped: number }}
|
||||
* added = records new to this device; skipped = duplicates of records it has; dropped = rows of the file that could
|
||||
* not be read
|
||||
*/
|
||||
export function importStats(raw, current) {
|
||||
if (isObj(raw) && raw.kind != null && raw.kind !== 'local-stats') {
|
||||
const err = new Error('not a stats export'); // the page shows its own text
|
||||
err.code = 'stats-bad-file';
|
||||
throw err;
|
||||
}
|
||||
if (!isObj(raw) || !Array.isArray(raw.records)) {
|
||||
const err = new Error('not a stats export');
|
||||
err.code = 'stats-bad-file';
|
||||
throw err;
|
||||
}
|
||||
const { stats: incoming, newer, dropped } = migrateStats(raw);
|
||||
if (newer) {
|
||||
const err = new Error('exported by a newer client'); // the page shows its own text
|
||||
err.code = 'stats-newer-version';
|
||||
throw err;
|
||||
}
|
||||
const have = new Set((Array.isArray(current?.records) ? current.records : []).map((r) => r.id));
|
||||
const merged = mergeStats(current, incoming);
|
||||
const added = incoming.records.filter((r) => !have.has(r.id)).length;
|
||||
return { stats: merged, added, skipped: incoming.records.length - added, dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload the export button downloads (envelope + a little provenance).
|
||||
* @param {{ records: any[] }} stats
|
||||
* @returns {{ app: string, kind: string, v: number, exportedAt: string, records: any[] }}
|
||||
*/
|
||||
export function exportStats(stats) {
|
||||
return {
|
||||
app: 'stronghold-protocol',
|
||||
kind: 'local-stats',
|
||||
v: STATS_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
records: fitToBudget(Array.isArray(stats?.records) ? stats.records : []),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- aggregation (what the page shows; records may hold more than any view uses) ------------------------------------
|
||||
|
||||
const SELF_STATS = ['dmgDealt', 'kills', 'bossDamage', 'activatedLayers', 'merges', 'itemsEquipped', 'gold', 'perfectRounds', 'refreshes', 'leaks', 'lpLost'];
|
||||
|
||||
/**
|
||||
* The row that "is me" in a record: selfId, else the only human, else seat 0 — records written by
|
||||
* recordResult always have selfId; the fallbacks only serve imported / hand-made data.
|
||||
* @param {any} rec
|
||||
* @returns {any|null}
|
||||
*/
|
||||
export function selfRowOf(rec) {
|
||||
const ps = Array.isArray(rec?.players) ? rec.players : [];
|
||||
return ps.find((p) => p.playerId && p.playerId === rec.selfId)
|
||||
|| ps.find((p) => !p.isBot)
|
||||
|| ps[0]
|
||||
|| null;
|
||||
}
|
||||
|
||||
/** Whether this record counts as a win for its self player (per-row victory = team won AND still in). */
|
||||
const selfWon = (rec, self) => (self ? !!self.victory : !!rec.victory);
|
||||
|
||||
/**
|
||||
* The rounds the player passed in a record: its own row's (the table the settlement prints per player), else the
|
||||
* team's; 0 when the record does not know.
|
||||
* @param {any} rec
|
||||
*/
|
||||
export function roundsOf(rec) {
|
||||
const self = selfRowOf(rec);
|
||||
const v = self && Number.isFinite(self.roundsPassed) ? self.roundsPassed : rec?.roundsPassed;
|
||||
return Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a record enters the aggregates (see the header). A settled record always does. A quit record does once the
|
||||
* player had finished at least one battle: cleared a round, or was already eliminated; a match given up before that —
|
||||
* entered and left right away — is history only.
|
||||
* @param {any} rec a normalized record
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function countsTowardStats(rec) {
|
||||
if (!isObj(rec)) return false;
|
||||
if (rec.end !== 'quit') return true;
|
||||
const self = selfRowOf(rec);
|
||||
if (!self) return false;
|
||||
return self.alive === false || (Number.isFinite(self.roundsPassed) && self.roundsPassed >= 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate records for the stats page — over the records that count; `total` / `excluded` say how many did not.
|
||||
* Everything is keyed by ids (bandId / titleId / difficulty); the page maps them to names and icons through the game
|
||||
* data. The combat totals read the settled records' stat blocks (a quit record has none).
|
||||
* @param {any[]} records
|
||||
* @returns {any}
|
||||
*/
|
||||
export function aggregateStats(records) {
|
||||
const all = Array.isArray(records) ? records : [];
|
||||
const rs = all.filter(countsTowardStats);
|
||||
const out = {
|
||||
total: all.length, count: rs.length, excluded: all.length - rs.length, wins: 0,
|
||||
rounds: { total: 0, max: 0 },
|
||||
duration: { totalMs: 0 },
|
||||
hidden: { reached: 0, cleared: 0 },
|
||||
byDifficulty: {}, byMode: { solo: 0, coop: 0 },
|
||||
titles: {}, bands: {}, sums: {},
|
||||
};
|
||||
for (const rec of rs) {
|
||||
const self = selfRowOf(rec);
|
||||
const won = selfWon(rec, self);
|
||||
if (won) out.wins++;
|
||||
const rounds = roundsOf(rec);
|
||||
out.rounds.total += rounds;
|
||||
out.rounds.max = Math.max(out.rounds.max, rounds);
|
||||
out.duration.totalMs += num(rec.durationMs) || 0;
|
||||
if (rec.hiddenReached) out.hidden.reached++;
|
||||
if (rec.hiddenCleared) out.hidden.cleared++;
|
||||
|
||||
const d = rec.difficulty || 'UNKNOWN';
|
||||
const bd = (out.byDifficulty[d] ||= { games: 0, wins: 0, hiddenCleared: 0 });
|
||||
bd.games++;
|
||||
if (won) bd.wins++;
|
||||
if (rec.hiddenCleared) bd.hiddenCleared++;
|
||||
|
||||
if (rec.roomMode === 'solo' || rec.roomMode === 'coop') out.byMode[rec.roomMode]++;
|
||||
|
||||
const titleId = self?.title?.id;
|
||||
if (titleId) {
|
||||
const t = (out.titles[titleId] ||= { count: 0 });
|
||||
t.count++;
|
||||
}
|
||||
|
||||
const bandId = self?.bandId;
|
||||
if (bandId) {
|
||||
const b = (out.bands[bandId] ||= { games: 0, wins: 0 });
|
||||
b.games++;
|
||||
if (won) b.wins++;
|
||||
}
|
||||
|
||||
if (rec.end !== 'quit' && self && isObj(self.stats)) {
|
||||
for (const k of SELF_STATS) {
|
||||
const v = self.stats[k];
|
||||
if (Number.isFinite(v)) out.sums[k] = (out.sums[k] || 0) + v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in new issue
Block a user