// 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()`); 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 ); 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`
`}
/>`;
}
/** 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`
`}
/>`;
}
/** 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`
<${Spinner} size="md" />
`;
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`