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:
sganggs committed 2026-10-08 20:36:41 +08:00
1 parent 438a6b521a
commit 51e0e60b6e
20 files changed
+1808 -530

No files matched your search

+1 -1
View File
@@ -312,7 +312,7 @@ export function LobbyScreen() {
<h1 class="topbar__title">${t('选择模拟协议')}</h1>
</div>
<div class="topbar__right">
<${Button} variant="secondary" size="sm" icon="signal" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
<${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">
+13 -4
View File
@@ -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">
+1 -1
View File
@@ -278,7 +278,7 @@ export function RoomScreen() {
<${PingPill} ms=${conn.ping} online=${online} />
<${MicroLabel}>${t('当前延迟')}<//>
</div>
<${Button} variant="secondary" size="sm" icon="signal" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
<${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">
+253 -209
View File
@@ -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} />`;
}
+4 -2
View File
@@ -246,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>
@@ -273,7 +276,6 @@ export function TitleScreen() {
<span class=${`status-dot ${dotClass}`}></span>
<span>${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}</span>
${conn.status === 'online' ? html`<${PingPill} ms=${conn.ping} />` : null}
<${Button} variant="ghost" size="sm" icon="signal" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
<${GuideButton} class="title-guide" label=${t('玩法说明')} />
<button type="button" class="title-settings fsbtn tapx" aria-label=${t('设置')} title=${t('设置')}
onClick=${() => setSettingsOpen(true)}><${GIcon} name="gear" /></button>