feedback7 stats: a match left right away stays in the history but does not count; bounded storage, corrupt-data handling, the page reworked as 干员调配-style tabs, the settlement re-view without the app store (PR #323 by @2321Robin, owner's decision of 2026-10-08); golden: unchanged
Counting rule (ui/stats.js countsTowardStats, the owner's decision of 2026-10-08: do not count the matches entered and left right away, but keep them in the history): a settled match (m.result arrived) always counts; a match given up (放弃模拟) counts once at least one of the player's battles had finished — a round cleared (the server's own rule, platform.js _quit: leaving while round k runs has passed k - 1, in its settlement k) or already eliminated. Anything earlier is history only: marked 中途退出 and 不计入统计, dimmed, listed in 对局记录, left out of every aggregate (games, wins, win rate, strategy table, titles, rounds, time, combat totals); 总览 says how many were left out. - A leaver gets no m.result, so the page records the match itself: quitMatch (放弃模拟) → recordQuit builds an `end: 'quit'` record from the app state and a tracker (observeMatch, installed in main.js) that remembers when the page first saw the match and the last round the player was seen alive in. No record for a spectator seat, a match with its settlement already there, a match the page never saw; matches the server ended and closed tabs are not recorded. Defects of PR #323 as submitted, reproduced (scratchpad/w7-stats-notes/repro-pr-bugs.mjs, loads-check*.mjs) and fixed: - the title screen loaded all 16 game data files (5.3 MB) at boot: <StatsHost/> called useGameData() at mount; now the host renders nothing until opened, the overview reads config / bands / assets and the settlement re-view loads the rest when opened (the title screen loads the same 3 files as before); - importing a file put the list oldest first (the page shows the first 20 as the newest and the cap would drop the newest); the list is newest first everywhere, also when loaded from storage; - recordResult wrote over the envelope of a NEWER build (a downgrade, against the PR's own rule); it refuses now, as saveStats does; - 1000 four-player records are ~7.2 M characters (the PR's ~1.5 kB/record is a solo match) against a ~2.5 M-character quota: MAX_RECORDS 1000 stays, plus a 800 000-character budget (the oldest go first), and only the newest 30 records keep their rows (lineup, bonds, teammates' names; the replay needs them) — older ones are compacted to the player's own summary row (~620 characters), a refused write retries with half the history; unknown fields and oversized values are dropped (a strict schema), import files are bounded (8 MB); - the re-view clobbered the app store (match.public / match.result, the route, the music); ResultScreen is split into ResultScreen (the store) and ResultView (props: result, myId, back label), so the stats page renders a stored match itself; a 自选 pick and a 补位 stand-in were dropped from the stored lineup (the replayed card drew the plain operator); DIFFICULTY_NAMES were not translated; a band cell was a display:flex <td>; the overlay stayed open when a co-op match started; the 清空 two-step blink was replaced by the shared confirm dialog. - Corrupt storage: unreadable data is moved to sp.pref.stats.damaged and the page says so; damaged rows are dropped and counted; duplicates, an oversized or unsorted list are repaired on load; a pre-`end` (PR-era) envelope reads as settled. UI: a full-screen overlay in the grammar of 干员调配 (top bar with 返回 / four tabs 总览 · 策略 · 战斗累计 · 对局记录 / 导出 导入 清空, a note line saying the data stays in this browser and that teammates' names are stored too), text sizes with max(.xxrem, N px) for phones in landscape, a 50-row history page with 显示更多, a pass-rate bar, a per-difficulty table in order; the entries use a new `chart` icon (not the ping bars), the title screen's sits beside the language menu (the connection row has no room: it squeezed the gear and wrapped the status text), the lobby / room buttons show the icon alone below 700 px (the room's title ran under the label at 640 px). docs/PLAYING.md §12, README row; 41 + 25 UI strings in en / ja / ko / zh-TW (the PR's four unused ones removed). Tests: test/ui/stats.test.js 44 / 44 (counting rule per phase, quit records, bounds, corrupt / old / newer storage, import order, aggregation, the replay payload); test/ui/stats.e2e.test.js (opt-in SP_E2E=1, real server, headless Chrome: seeded history on 1920×1080 and 844×390, the re-view, export / import / clear, corrupt and newer storage, and the real flows: a solo match given up in the briefing is history only, one given up after round 1 counts, a settled match is recorded once and not again after a reload) 6 / 6; lint 0 errors, i18n extract 0 / check --all --strict clean, check:imports, typecheck, docs and version tests, golden unchanged.
This commit is contained in:
1 parent
438a6b521a
commit
51e0e60b6e
20 files changed
+1808
-530
No files matched your search
+253
-209
@@ -1,155 +1,182 @@
|
||||
// The local stats viewer (per-browser match record, viewer + data management. Opened imperatively from the
|
||||
// title screen / lobby (`openStats()`); <StatsHost/> is mounted once by main.js like <GuideHost/>.
|
||||
// The 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).
|
||||
//
|
||||
// The page aggregates over the records of ui/stats.js (one per finished match the local player took part in):
|
||||
// 总览 – games, wins, win rate, hidden-core reaches / clears, per-difficulty rows
|
||||
// 策略 – per band: games used / passed (self alive on a winning team) / pass rate (the user's ask)
|
||||
// 称号 – each of config.titles' six 评语 with its count
|
||||
// 战斗累计– the self player's summed combat bookkeeping
|
||||
// 最近对局– the newest RECENT_SHOW records as rows; a row click re-views that match as its settlement
|
||||
// screen (the record is inverted into an m.result payload for <ResultScreen/>)
|
||||
// Everything is 本机数据 (localStorage, `sp.pref.stats`): the note under the title says so, and 导出/导入
|
||||
// (JSON file, merge-by-record-id on the way in) moves it between devices; 清空 resets after a confirm press.
|
||||
// 总览 – 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, 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 { 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 { createStore, useStore, store, emptyMatch } from '../store.js';
|
||||
import { ResultScreen } from './result.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, recordToResult, RECENT_SHOW,
|
||||
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(' ');
|
||||
|
||||
const statsStore = createStore({ open: false });
|
||||
/** Rows of 对局记录 shown at first and added by each 显示更多. */
|
||||
export const HISTORY_PAGE = 50;
|
||||
|
||||
/** Open the stats viewer (title screen / lobby entry points). */
|
||||
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 });
|
||||
statsStore.set({ open: true, tab: 'overview' });
|
||||
}
|
||||
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 });
|
||||
};
|
||||
export const closeStats = () => statsStore.set({ open: false });
|
||||
|
||||
/**
|
||||
* 最近对局行点击: one stored match re-viewed as its settlement screen, INSIDE the stats viewer — the
|
||||
* record is inverted into an m.result payload (ui/stats.js recordToResult) into store.match.result, and
|
||||
* StatsHost swaps its panel for the real <ResultScreen/> while a result exists (the settlement's
|
||||
* 返回大厅 empties match again → back to the list, ready to view the next row). Refused while a match
|
||||
* is actually live: the overlay is global and clobbering a running match's state would be destructive.
|
||||
* @param {any} rec
|
||||
*/
|
||||
export function openRecordResult(rec) {
|
||||
const s = store.get();
|
||||
if ((s.match.public && s.match.public.phase !== PHASE.LOBBY) || s.room?.inMatch) {
|
||||
toast(t('对局进行中,暂不能回看历史结算'), 'warn');
|
||||
return;
|
||||
}
|
||||
const res = recordToResult(rec);
|
||||
if (!res) return;
|
||||
store.set({ match: { ...emptyMatch(), public: { phase: PHASE.RESULT }, result: res } });
|
||||
}
|
||||
const 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 = (t) => {
|
||||
if (!t) return '—';
|
||||
const d = new Date(t);
|
||||
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())}`;
|
||||
};
|
||||
const minsOf = (ms) => (ms > 0 ? Math.round(ms / 60000) : null);
|
||||
/** 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('stcard', tone && `stcard--${tone}`)}>
|
||||
return html`<div class=${cx('st-card', tone && `st-card--${tone}`)}>
|
||||
<${MicroLabel} tone=${tone === 'gold' ? 'gold' : 'mint'}>${micro}<//>
|
||||
<b class="stcard__num num">${value}</b>
|
||||
<span class="stcard__label">${label}</span>
|
||||
<b class="st-card__num num">${value}</b>
|
||||
<span class="st-card__label">${label}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function Section({ title, micro, children }) {
|
||||
return html`<section class="stats-sec">
|
||||
<h3 class="stats-sec__h"><span>${title}</span><${MicroLabel}>${micro}<//></h3>
|
||||
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. */
|
||||
function Overview({ agg }) {
|
||||
const rows = Object.entries(agg.byDifficulty).sort((a, b) => b[1].games - a[1].games);
|
||||
const playedMins = minsOf(agg.duration.totalMs);
|
||||
/** 总览: 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="stats-cards">
|
||||
<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('最远回合')} />
|
||||
${playedMins != null ? html`<${StatCard} micro="TIME PLAYED" value=${t('{n} 分', { n: fmtNum(playedMins) })} label=${t('累计时长')} />` : null}
|
||||
<${StatCard} micro="TIME PLAYED" value=${playedText(agg.duration.totalMs)} label=${t('累计时长')} />
|
||||
</div>
|
||||
${rows.length ? html`<table class="stats-table">
|
||||
<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, v]) => html`<tr key=${d}>
|
||||
<td>${DIFFICULTY_NAMES[d] || d}</td>
|
||||
<td class="num">${fmtNum(v.games)}</td>
|
||||
<td class="num">${fmtNum(v.wins)}</td>
|
||||
<td class="num">${pct(v.wins, v.games)}</td>
|
||||
<td class="num">${fmtNum(v.hiddenCleared)}</td>
|
||||
</tr>`)}
|
||||
${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>` : null}
|
||||
</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);
|
||||
if (!rows.length) return null;
|
||||
return html`<${Section} title=${t('策略')} micro="BANDS">
|
||||
<table class="stats-table">
|
||||
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 class="stats-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : bandId}</td>
|
||||
<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">${pct(v.wins, v.games)}</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>
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/** 称号: all six 评语 with counts (0 dimmed), official order. */
|
||||
function Titles({ agg, gd }) {
|
||||
const list = (Array.isArray(gd.config?.titles) ? gd.config.titles : [])
|
||||
.map((t) => ({ ...t, count: agg.titles[t.id]?.count || 0 }));
|
||||
if (!list.length) return null;
|
||||
return html`<${Section} title=${t('称号')} micro="TITLES">
|
||||
<div class="stats-titles">
|
||||
${list.map((t) => html`<div key=${t.id} class=${cx('stit', !t.count && 'stit--none')} title=${t.text || ''}>
|
||||
<${Img} src=${titleIconUrl(gd.m, t.picId)} class="stit__icon" fallback=${html`<${Icon} name="crown" />`} />
|
||||
<div class="stit__text"><span>${t.name}</span><b class="num">${fmtNum(t.count)}</b></div>
|
||||
</div>`)}
|
||||
</div>
|
||||
</table></div>` : html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
|
||||
<//>`;
|
||||
}
|
||||
|
||||
@@ -159,158 +186,175 @@ const SUM_ROWS = [
|
||||
['refreshes', N_('刷新次数')], ['leaks', N_('未击倒')], ['lpLost', N_('损失生命')],
|
||||
];
|
||||
|
||||
/** 战斗累计: the self player's sums. */
|
||||
/** 战斗累计: the self player's sums over the settled matches. */
|
||||
function CombatSums({ agg }) {
|
||||
const rows = SUM_ROWS.filter(([k]) => Number.isFinite(agg.sums[k]));
|
||||
if (!rows.length) return null;
|
||||
return html`<${Section} title=${t('战斗累计')} micro="COMBAT TOTALS">
|
||||
<div class="stats-cards stats-cards--sums">
|
||||
${rows.map(([k, label]) => html`<div key=${k} class="ssum"><span>${t(label)}</span><b class="num">${fmtNum(agg.sums[k])}</b></div>`)}
|
||||
</div>
|
||||
${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>`}
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/** 最近对局: newest RECENT_SHOW records (records are stored newest first). A row click re-views that
|
||||
match as its settlement screen (openRecordResult → match.result → <ResultScreen/>). */
|
||||
function Recent({ records, gd }) {
|
||||
const rows = records.slice(0, RECENT_SHOW);
|
||||
if (!rows.length) return null;
|
||||
return html`<${Section} title=${t('最近对局')} micro=${`RECENT · ${records.length}`}>
|
||||
<table class="stats-table">
|
||||
/** 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) => {
|
||||
const self = r.players.find((p) => p.playerId === r.selfId) || r.players[0] || null;
|
||||
const won = self ? !!self.victory : !!r.victory;
|
||||
const band = self?.bandId ? gd.band(self.bandId) : null;
|
||||
return html`<tr key=${r.id} class=${cx('is-clickable', won ? 'is-win' : 'is-lose')} title=${t('点击查看该局结算')}
|
||||
onClick=${() => openRecordResult(r)}>
|
||||
<td class="stats-t">${dateTime(r.t)}</td>
|
||||
<td>${DIFFICULTY_NAMES[r.difficulty] || r.difficulty || '—'}</td>
|
||||
<td>${r.roomMode === 'solo' ? t('单人') : r.roomMode === 'coop' ? t('同盟') : '—'}</td>
|
||||
<td>${band ? band.name : self?.bandId || '—'}</td>
|
||||
<td class=${won ? 't-win' : 't-lose'}>${won ? t('胜') : t('负')}</td>
|
||||
<td class="num">${fmtNum(r.roundsPassed)}</td>
|
||||
<td class="stats-go"><${Icon} name="chevronRight" /></td>
|
||||
</tr>`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="stats-hint">${t('点击任意一行,回看该局结算。')}</p>
|
||||
<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>`}
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/** 导出 / 导入 / 清空, with the two-step clear confirm. */
|
||||
function DataActions({ stats, onReload }) {
|
||||
/** 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 [confirming, setConfirming] = useState(false);
|
||||
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(() => {
|
||||
if (!confirming) return undefined;
|
||||
const t = setTimeout(() => setConfirming(false), 3000);
|
||||
return () => clearTimeout(t);
|
||||
}, [confirming]);
|
||||
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);
|
||||
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);
|
||||
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 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');
|
||||
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) {
|
||||
toastError(err.code === 'stats-newer-version' ? err : new Error(t('导入失败:不是有效的统计导出文件')));
|
||||
toast(err?.code === 'stats-newer-version' ? t('导入失败:这份文件由更新版本的客户端导出,请先升级客户端') : t('导入失败:不是有效的统计导出文件'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const doClear = () => {
|
||||
if (!confirming) { setConfirming(true); return; }
|
||||
saveStats(emptyStats());
|
||||
setConfirming(false);
|
||||
onReload();
|
||||
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');
|
||||
};
|
||||
|
||||
return html`<div class="stats-actions">
|
||||
<${Button} size="sm" icon="exit" onClick=${doExport}>${t('导出')}<//>
|
||||
<${Button} size="sm" icon="plus" onClick=${() => fileRef.current?.click()}>${t('导入')}<//>
|
||||
<input ref=${fileRef} type="file" accept="application/json,.json" hidden onChange=${doFile} />
|
||||
<${Button} size="sm" variant="danger" icon=${confirming ? 'warn' : 'close'} class=${cx(confirming && 'stats-actions__confirm')}
|
||||
onClick=${doClear}>${confirming ? t('确认清空?') : t('清空')}<//>
|
||||
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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global stats viewer host — mounted once by main.js. Waits only on the files the page reads (config for the
|
||||
* title list, bands for names/icons, assets for the picture URLs), not all of useGameData's files.
|
||||
*/
|
||||
export function StatsHost() {
|
||||
const { open } = useStore((s) => s, Object.is, statsStore);
|
||||
const replaying = useStore((s) => !!s.match.result); // a replayed settlement replaces the whole panel
|
||||
const ready = useData('config', 'bands', 'assets');
|
||||
const gd = useGameData();
|
||||
const [stats, setStats] = useState(() => emptyStats());
|
||||
const boxRef = useRef(null);
|
||||
const reload = () => setStats(loadStats());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
setStats(loadStats());
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); closeStats(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
const t = setTimeout(() => boxRef.current?.focus?.(), 30);
|
||||
return () => { window.removeEventListener('keydown', onKey, true); clearTimeout(t); };
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
if (replaying) {
|
||||
// the record re-viewed as its settlement (openRecordResult): the real <ResultScreen/>, full-bleed;
|
||||
// its 返回大厅 empties match.result and lands back on the list
|
||||
return html`<div class="stats"><div class="stats__replay"><${ResultScreen} /></div></div>`;
|
||||
}
|
||||
const agg = aggregateStats(stats.records);
|
||||
return html`<div class="stats" role="presentation" onMouseDown=${(e) => { if (e.target === e.currentTarget) closeStats(); }}>
|
||||
<div class="stats__box brackets" role="dialog" aria-modal="true" aria-label=${t('统计数据')} tabindex="-1" ref=${boxRef}>
|
||||
<header class="stats__head">
|
||||
<div class="stats__titles">
|
||||
<${MicroLabel} tone="mint">STATISTICS // LOCAL DATA<//>
|
||||
<h2 class="stats__title">${t('统计数据')}</h2>
|
||||
<span class="stats__note">${t('本机数据 · 仅记录本浏览器中你参与的对局')}</span>
|
||||
</div>
|
||||
${ready ? html`<${DataActions} stats=${stats} onReload=${reload} />` : null}
|
||||
<button type="button" class="stats__close" aria-label=${t('关闭')} title=${t('关闭 (Esc)')} onClick=${closeStats}><${Icon} name="close" /></button>
|
||||
</header>
|
||||
<div class="stats__body">
|
||||
${!ready ? html`<div class="stats__loading"><${Spinner} size="md" /></div>`
|
||||
: stats.newer ? html`<p class="stats-empty">${t('检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。')}</p>`
|
||||
: !stats.records.length ? html`<p class="stats-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`
|
||||
: html`<${Overview} agg=${agg} />
|
||||
<${Bands} agg=${agg} gd=${gd} />
|
||||
<${Titles} agg=${agg} gd=${gd} />
|
||||
<${CombatSums} agg=${agg} />
|
||||
<${Recent} records=${stats.records} gd=${gd} />`}
|
||||
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>
|
||||
<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} />`;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user