// The local stats viewer (per-browser match record, viewer + data management. Opened imperatively from the // title screen / lobby (`openStats()`); is mounted once by main.js like . // // The page aggregates over the records of ui/stats.js (one per finished match the local player took part in): // 总览 – games, wins, win rate, hidden-core reaches / clears, per-difficulty rows // 策略 – per band: games used / passed (self alive on a winning team) / pass rate (the user's ask) // 称号 – each of config.titles' six 评语 with its count // 战斗累计– the self player's summed combat bookkeeping // 最近对局– the newest RECENT_SHOW records as rows; a row click re-views that match as its settlement // screen (the record is inverted into an m.result payload for ) // Everything is 本机数据 (localStorage, `sp.pref.stats`): the note under the title says so, and 导出/导入 // (JSON file, merge-by-record-id on the way in) moves it between devices; 清空 resets after a confirm press. import { useEffect, useRef, useState } from '../../vendor/hooks.module.js'; import { DIFFICULTY_NAMES, PHASE } from '../../../shared/constants.js'; import { html, Icon, MicroLabel, Spinner, Button } from '../ui/components.js'; import { useGameData, BandIcon, Img } from '../ui/gameComponents.js'; import { data, useData } from '../data.js'; import { fmtNum } from '../ui/gameLogic.js'; import { titleIconUrl } from '../ui/assetUrls.js'; import { createStore, useStore, store, emptyMatch } from '../store.js'; import { ResultScreen } from './result.js'; import { toast, toastError } from '../ui/toasts.js'; import { loadStats, saveStats, emptyStats, importStats, exportStats, aggregateStats, recordToResult, RECENT_SHOW, } from '../ui/stats.js'; import { t, N_ } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); const statsStore = createStore({ open: false }); /** Open the stats viewer (title screen / lobby entry points). */ export function openStats() { data.load('config'); data.load('bands'); data.load('assets'); statsStore.set({ open: true }); } export const closeStats = () => { // a replayed settlement is synthetic store state — take it away with the viewer if (store.get().match.result) store.set({ match: emptyMatch() }); statsStore.set({ open: false }); }; /** * 最近对局行点击: one stored match re-viewed as its settlement screen, INSIDE the stats viewer — the * record is inverted into an m.result payload (ui/stats.js recordToResult) into store.match.result, and * StatsHost swaps its panel for the real while a result exists (the settlement's * 返回大厅 empties match again → back to the list, ready to view the next row). Refused while a match * is actually live: the overlay is global and clobbering a running match's state would be destructive. * @param {any} rec */ export function openRecordResult(rec) { const s = store.get(); if ((s.match.public && s.match.public.phase !== PHASE.LOBBY) || s.room?.inMatch) { toast(t('对局进行中,暂不能回看历史结算'), 'warn'); return; } const res = recordToResult(rec); if (!res) return; store.set({ match: { ...emptyMatch(), public: { phase: PHASE.RESULT }, result: res } }); } const pct = (n, d) => (d > 0 ? `${Math.round((n / d) * 100)}%` : '—'); const dateTime = (t) => { if (!t) return '—'; const d = new Date(t); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; }; const minsOf = (ms) => (ms > 0 ? Math.round(ms / 60000) : null); /** One labelled number card. */ function StatCard({ label, micro, value, tone }) { return html`
<${MicroLabel} tone=${tone === 'gold' ? 'gold' : 'mint'}>${micro} ${value} ${label}
`; } function Section({ title, micro, children }) { return html`

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

${children}
`; } /** 总览: cards + the per-difficulty table. */ function Overview({ agg }) { const rows = Object.entries(agg.byDifficulty).sort((a, b) => b[1].games - a[1].games); const playedMins = minsOf(agg.duration.totalMs); return html`<${Section} title=${t('总览')} micro="OVERVIEW">
<${StatCard} micro="GAMES" value=${fmtNum(agg.count)} label=${t('总局数')} /> <${StatCard} micro="WINS" value=${fmtNum(agg.wins)} label=${t('胜场')} tone="mint" /> <${StatCard} micro="WIN RATE" value=${pct(agg.wins, agg.count)} label=${t('胜率')} tone="gold" /> <${StatCard} micro="HIDDEN CORE" value=${fmtNum(agg.hidden.reached)} label=${t('到达隐秘核心')} /> <${StatCard} micro="HIDDEN CLEAR" value=${fmtNum(agg.hidden.cleared)} label=${t('通关隐秘核心')} /> <${StatCard} micro="BEST ROUND" value=${fmtNum(agg.rounds.max)} label=${t('最远回合')} /> ${playedMins != null ? html`<${StatCard} micro="TIME PLAYED" value=${t('{n} 分', { n: fmtNum(playedMins) })} label=${t('累计时长')} />` : null}
${rows.length ? html` ${rows.map(([d, v]) => html``)}
${t('难度')}${t('局数')}${t('胜场')}${t('胜率')}${t('隐秘核心通关')}
${DIFFICULTY_NAMES[d] || d} ${fmtNum(v.games)} ${fmtNum(v.wins)} ${pct(v.wins, v.games)} ${fmtNum(v.hiddenCleared)}
` : null} `; } /** 策略: per-band used / passed / pass rate, most used first (the user's headline ask). */ function Bands({ agg, gd }) { const rows = Object.entries(agg.bands).sort((a, b) => b[1].games - a[1].games); if (!rows.length) return null; return html`<${Section} title=${t('策略')} micro="BANDS"> ${rows.map(([bandId, v]) => { const band = gd.band(bandId); 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)}
`; } /** 称号: all six 评语 with counts (0 dimmed), official order. */ function Titles({ agg, gd }) { const list = (Array.isArray(gd.config?.titles) ? gd.config.titles : []) .map((t) => ({ ...t, count: agg.titles[t.id]?.count || 0 })); if (!list.length) return null; return html`<${Section} title=${t('称号')} micro="TITLES">
${list.map((t) => html`
<${Img} src=${titleIconUrl(gd.m, t.picId)} class="stit__icon" fallback=${html`<${Icon} name="crown" />`} />
${t.name}${fmtNum(t.count)}
`)}
`; } const SUM_ROWS = [ ['kills', N_('击倒敌人')], ['bossDamage', N_('领袖伤害')], ['dmgDealt', N_('造成伤害')], ['activatedLayers', N_('盟约层数')], ['merges', N_('晋升次数')], ['itemsEquipped', N_('配发装备')], ['gold', N_('消耗资金')], ['perfectRounds', N_('完美作战')], ['refreshes', N_('刷新次数')], ['leaks', N_('未击倒')], ['lpLost', N_('损失生命')], ]; /** 战斗累计: the self player's sums. */ function CombatSums({ agg }) { const rows = SUM_ROWS.filter(([k]) => Number.isFinite(agg.sums[k])); if (!rows.length) return null; return html`<${Section} title=${t('战斗累计')} micro="COMBAT TOTALS">
${rows.map(([k, label]) => html`
${t(label)}${fmtNum(agg.sums[k])}
`)}
`; } /** 最近对局: newest RECENT_SHOW records (records are stored newest first). A row click re-views that match as its settlement screen (openRecordResult → match.result → ). */ function Recent({ records, gd }) { const rows = records.slice(0, RECENT_SHOW); if (!rows.length) return null; return html`<${Section} title=${t('最近对局')} micro=${`RECENT · ${records.length}`}> ${rows.map((r) => { const self = r.players.find((p) => p.playerId === r.selfId) || r.players[0] || null; const won = self ? !!self.victory : !!r.victory; const band = self?.bandId ? gd.band(self.bandId) : null; return html` openRecordResult(r)}> `; })}
${t('时间')}${t('难度')}${t('模式')}${t('策略')}${t('结果')}${t('回合')}
${dateTime(r.t)} ${DIFFICULTY_NAMES[r.difficulty] || r.difficulty || '—'} ${r.roomMode === 'solo' ? t('单人') : r.roomMode === 'coop' ? t('同盟') : '—'} ${band ? band.name : self?.bandId || '—'} ${won ? t('胜') : t('负')} ${fmtNum(r.roundsPassed)} <${Icon} name="chevronRight" />

${t('点击任意一行,回看该局结算。')}

`; } /** 导出 / 导入 / 清空, with the two-step clear confirm. */ function DataActions({ stats, onReload }) { const fileRef = useRef(null); const [confirming, setConfirming] = useState(false); useEffect(() => { if (!confirming) return undefined; const t = setTimeout(() => setConfirming(false), 3000); return () => clearTimeout(t); }, [confirming]); const doExport = () => { try { const payload = exportStats(stats); const blob = new Blob([JSON.stringify(payload, null, 1)], { type: 'application/json' }); const a = document.createElement('a'); const d = new Date(); const p = (n) => String(n).padStart(2, '0'); a.href = URL.createObjectURL(blob); a.download = `stronghold-stats-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); toast(t('已导出 {n} 条对局记录', { n: payload.records.length }), 'success'); } catch (err) { toastError(err); } }; const doFile = async (e) => { const file = e.currentTarget.files?.[0]; e.currentTarget.value = ''; // allow re-picking the same file if (!file) return; try { const raw = JSON.parse(await file.text()); const { stats: merged, added, skipped } = importStats(raw, stats); saveStats(merged); onReload(); toast(added ? t('已导入 {added} 条记录{tail}', { added, tail: skipped ? t('(跳过重复 {skipped} 条)', { skipped }) : '' }) : t('没有新记录(全部与现有数据重复)'), 'success'); } catch (err) { toastError(err.code === 'stats-newer-version' ? err : new Error(t('导入失败:不是有效的统计导出文件'))); } }; const doClear = () => { if (!confirming) { setConfirming(true); return; } saveStats(emptyStats()); setConfirming(false); onReload(); toast(t('已清空本机统计数据'), 'success'); }; return html`
<${Button} size="sm" icon="exit" onClick=${doExport}>${t('导出')} <${Button} size="sm" icon="plus" onClick=${() => fileRef.current?.click()}>${t('导入')} <${Button} size="sm" variant="danger" icon=${confirming ? 'warn' : 'close'} class=${cx(confirming && 'stats-actions__confirm')} onClick=${doClear}>${confirming ? t('确认清空?') : t('清空')}
`; } /** * Global stats viewer host — mounted once by main.js. Waits only on the files the page reads (config for the * title list, bands for names/icons, assets for the picture URLs), not all of useGameData's files. */ export function StatsHost() { const { open } = useStore((s) => s, Object.is, statsStore); const replaying = useStore((s) => !!s.match.result); // a replayed settlement replaces the whole panel const ready = useData('config', 'bands', 'assets'); const gd = useGameData(); const [stats, setStats] = useState(() => emptyStats()); const boxRef = useRef(null); const reload = () => setStats(loadStats()); useEffect(() => { if (!open) return undefined; setStats(loadStats()); const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); closeStats(); } }; window.addEventListener('keydown', onKey, true); const t = setTimeout(() => boxRef.current?.focus?.(), 30); return () => { window.removeEventListener('keydown', onKey, true); clearTimeout(t); }; }, [open]); if (!open) return null; if (replaying) { // the record re-viewed as its settlement (openRecordResult): the real , full-bleed; // its 返回大厅 empties match.result and lands back on the list return html`
<${ResultScreen} />
`; } const agg = aggregateStats(stats.records); return html``; }