// 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`
<${MicroLabel} tone=${tone === 'gold' ? 'gold' : 'mint'}>${micro} ${value} ${label}
`; } function Section({ title, micro, children, class: cls }) { return html`

${title}<${MicroLabel}>${micro}

${children}
`; } /** 总览: 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">
<${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('累计时长')} />

${t('单人')} ${fmtNum(agg.byMode.solo)}${t('同盟')} ${fmtNum(agg.byMode.coop)}

${rows.length ? html`
${rows.map((d) => { const v = agg.byDifficulty[d]; return html``; })}
${t('难度')}${t('局数')}${t('胜场')}${t('胜率')}${t('隐秘核心通关')}
${difficultyName(d)} ${fmtNum(v.games)} ${fmtNum(v.wins)} ${pct(v.wins, v.games)} ${fmtNum(v.hiddenCleared)}
` : null} ${titles.length ? html`

${t('称号')}

${titles.map((x) => html`
<${Img} src=${titleIconUrl(gd.m, x.picId)} class="st-title__icon" fallback=${html`<${Icon} name="crown" />`} />
${x.name}${fmtNum(x.count)}
`)}
` : null}

<${Icon} name="info" />${t('计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。')}${agg.excluded ? html` ${t('另有 {n} 局未计入统计', { n: agg.excluded })}` : 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) || (b[1].wins - a[1].wins)); return html`<${Section} title=${t('策略')} micro="STRATEGIES"> ${rows.length ? html`
${rows.map(([bandId, v]) => { const band = gd.band(bandId); const rate = v.games > 0 ? Math.round((v.wins / v.games) * 100) : 0; return html``; })}
${t('策略')}${t('使用局数')}${t('通过局数')}${t('通过率')}
${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : bandId} ${fmtNum(v.games)} ${fmtNum(v.wins)} ${pct(v.wins, v.games)}
` : html`

${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}

`} `; } 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`
${rows.map(([k, label]) => html`
${t(label)}${fmtNum(agg.sums[k])}
`)}
` : html`

${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}

`} `; } /** 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` { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } } : undefined}> ${dateTime(rec.t)} ${difficultyName(rec.difficulty)} ${rec.roomMode === 'solo' ? t('单人') : rec.roomMode === 'coop' ? t('同盟') : '—'} ${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : self?.bandId || '—'} ${quit ? html`${t('中途退出')}` : html`${won ? t('胜') : t('负')}`} ${!counted ? html`${t('不计入统计')}` : null} ${known ? fmtNum(rounds) : '—'} ${view ? html`<${Icon} name="chevronRight" />` : null} `; } /** 对局记录: 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`
${rows.map((r) => html`<${HistoryRow} key=${r.id} rec=${r} gd=${gd} onOpen=${onOpen} />`)}
${t('时间')}${t('难度')}${t('模式')}${t('策略')}${t('结果')}${t('回合')}
${records.length > shown ? html`
<${Button} size="sm" variant="secondary" onClick=${() => setShown(shown + HISTORY_PAGE)}>${t('显示更多(还有 {n} 局)', { n: records.length - shown })}
` : null}

${t('点击一行,回看该局结算;中途退出的对局只留记录。')} ${t('最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。', { max: MAX_RECORDS, full: FULL_KEEP })}

` : html`

${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}

`} `; } /** 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``; const tabInfo = TABS.find((x) => x.id === tab) || TABS[0]; const empty = !stats.records.length; return html``; } /** 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} />`; }