From 438a6b521a8bd03cabcb10942660df633409ab6b Mon Sep 17 00:00:00 2001
From: 2321Robin <2321Robin@users.noreply.github.com>
Date: Thu, 8 Oct 2026 20:05:09 +0800
Subject: [PATCH] feedback7 stats: the local statistics page, ported from PR
#323 by @2321Robin as submitted (record store ui/stats.js, viewer
screens/stats.js, stats.css, entries on the title / lobby / room screens, 20
tests); golden: unchanged
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- public/js/ui/stats.js: one record per finished match in localStorage (sp.pref.stats, envelope {v, records}, at most
1000, quota shrink of the oldest), content-id dedupe of the lobby's result replay, a MIGRATIONS chain and
normalizeRecord (defaults for missing fields, unknown fields kept, a newer envelope refused), merge-by-id export /
import, aggregateStats.
- public/js/screens/stats.js: the viewer (总览 / 策略 / 称号 / 战斗累计 / 最近对局, a row re-views that match's
settlement), 导出 / 导入 / 清空.
- Applied from the PR's own diff after reading it in full (no network, no new dependency, server untouched); only the
UI strings of the page are added to the four packs (en from the PR; ja / ko / zh-TW translated for this commit).
- The follow-up commits rework the page (the counting rule for matches left early, storage bounds, the layout).
- Tests: test/ui/stats.test.js 20 / 20; i18n extract 0, check --all --strict clean.
---
public/css/screens/stats.css | 119 +++++++++
public/i18n/en.json | 43 +++-
public/i18n/ja.json | 43 +++-
public/i18n/ko.json | 43 +++-
public/i18n/zh-TW.json | 43 +++-
public/index.html | 1 +
public/js/main.js | 11 +-
public/js/screens/lobby.js | 2 +
public/js/screens/room.js | 2 +
public/js/screens/stats.js | 316 +++++++++++++++++++++++
public/js/screens/title.js | 2 +
public/js/ui/stats.js | 469 +++++++++++++++++++++++++++++++++++
test/ui/stats.test.js | 318 ++++++++++++++++++++++++
13 files changed, 1407 insertions(+), 5 deletions(-)
create mode 100644 public/css/screens/stats.css
create mode 100644 public/js/screens/stats.js
create mode 100644 public/js/ui/stats.js
create mode 100644 test/ui/stats.test.js
diff --git a/public/css/screens/stats.css b/public/css/screens/stats.css
new file mode 100644
index 00000000..4727f842
--- /dev/null
+++ b/public/css/screens/stats.css
@@ -0,0 +1,119 @@
+/* ==========================================================================================
+ stats.css — 统计数据 viewer (js/screens/stats.js): the local match record as overview cards,
+ per-difficulty / per-band tables, the six 评语 with counts, combat totals and a recent-games
+ list; 导出 / 导入 / 清空 in the header. Same overlay grammar as guide.css.
+ ========================================================================================== */
+
+.stats {
+ position: fixed; inset: 0; z-index: calc(var(--z-modal) + 4);
+ display: grid; place-items: center;
+ background: radial-gradient(ellipse at 50% 40%, rgba(8, 14, 12, .82), rgba(2, 4, 3, .94));
+ -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
+ animation: stats-fade 180ms var(--ease-out);
+}
+@keyframes stats-fade { from { opacity: 0; } }
+
+.stats__box {
+ --bk-c: var(--mint-500);
+ position: relative; display: flex; flex-direction: column;
+ width: min(94vw, 9.6rem); max-height: calc(100vh - .6rem);
+ padding: .16rem .22rem;
+ background: linear-gradient(180deg, rgba(22, 28, 26, .98), rgba(10, 13, 12, .98));
+ border: 1px solid var(--line-2);
+ box-shadow: 0 .2rem .6rem rgba(0, 0, 0, .7), 0 0 0 1px rgba(78, 216, 175, .06) inset;
+ outline: none;
+ animation: stats-pop 220ms var(--ease-out);
+}
+@keyframes stats-pop { from { opacity: 0; transform: translateY(.12rem) scale(.985); } }
+
+/* header: titles + note | 导出/导入/清空 | close */
+.stats__head { display: flex; align-items: center; gap: .2rem; padding-bottom: .1rem; border-bottom: 1px solid var(--line); }
+.stats__titles { display: flex; flex-direction: column; gap: .02rem; margin-right: auto; min-width: 0; }
+.stats__title { margin: 0; font-size: .28rem; font-weight: 900; letter-spacing: .12em; color: var(--text-hi); line-height: 1.1; }
+.stats__note { font-size: .13rem; color: var(--text-lo); letter-spacing: .04em; }
+.stats-actions { display: flex; gap: .08rem; flex: none; }
+.stats-actions__confirm { animation: stats-blink 900ms steps(2) infinite; }
+@keyframes stats-blink { 50% { filter: brightness(1.5); } }
+.stats__close {
+ width: .5rem; height: .5rem; display: grid; place-items: center; flex: none;
+ background: rgba(0, 0, 0, .35); border: 1px solid var(--line-2); color: var(--text-md);
+ transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast);
+}
+.stats__close .icon { width: .26rem; height: .26rem; }
+.stats__close:hover { color: var(--text-hi); border-color: var(--red-premium); background: var(--red-a20); }
+
+/* body: the scrolling sections */
+.stats__body { overflow-y: auto; padding: .12rem .02rem .14rem; display: flex; flex-direction: column; gap: .18rem; }
+.stats__loading { display: grid; place-items: center; min-height: 2rem; }
+.stats-empty { margin: .4rem 0; text-align: center; font-size: .18rem; color: var(--text-lo); letter-spacing: .06em; }
+
+.stats-sec__h {
+ display: flex; align-items: baseline; gap: .12rem; margin: 0 0 .08rem;
+ font-size: .2rem; font-weight: 800; letter-spacing: .1em; color: var(--text-hi);
+}
+
+/* number cards */
+.stats-cards { display: flex; flex-wrap: wrap; gap: .08rem; align-items: flex-start; }
+.stcard {
+ flex: 1 1 1.1rem; min-width: 1.05rem; padding: .07rem .1rem .06rem;
+ display: flex; flex-direction: column; gap: .01rem;
+ background: rgba(255, 255, 255, .03); border: 1px solid var(--line);
+}
+.stcard--mint { border-color: var(--mint-800); background: var(--mint-a10); }
+.stcard--gold { border-color: rgba(246, 163, 41, .45); background: rgba(246, 163, 41, .07); }
+.stcard__num { font-size: .3rem; font-weight: 900; color: var(--text-hi); line-height: 1.15; }
+.stcard--mint .stcard__num { color: var(--mint-400); }
+.stcard--gold .stcard__num { color: #f6a329; }
+.stcard__label { font-size: .13rem; color: var(--text-md); letter-spacing: .06em; }
+
+/* compact sum chips (战斗累计) */
+.stats-cards--sums { display: grid; grid-template-columns: repeat(auto-fill, minmax(1.5rem, 1fr)); gap: .06rem; }
+.ssum {
+ display: flex; align-items: baseline; justify-content: space-between; gap: .08rem;
+ padding: .05rem .1rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line);
+}
+.ssum span { font-size: .14rem; color: var(--text-md); }
+.ssum b { font-size: .2rem; font-weight: 800; color: var(--text-hi); }
+
+/* data tables (总览难度 / 策略 / 最近对局) */
+.stats-table { width: 100%; border-collapse: collapse; font-size: .16rem; }
+.stats-table th {
+ padding: .04rem .1rem; text-align: left; font-size: .13rem; font-weight: 600; letter-spacing: .08em;
+ color: var(--text-lo); border-bottom: 1px solid var(--line-2); white-space: nowrap;
+}
+.stats-table td { padding: .05rem .1rem; color: var(--text-md); border-bottom: 1px solid var(--line); }
+.stats-table tbody tr:hover td { background: rgba(78, 216, 175, .05); }
+.stats-table .num { text-align: right; }
+.stats-table th.num { text-align: right; }
+.stats-band { display: flex; align-items: center; gap: .06rem; color: var(--text-hi); white-space: nowrap; }
+.stats-t { font-family: var(--font-num); font-size: .14rem; color: var(--text-lo); white-space: nowrap; }
+.stats-table tr.is-win .t-win { color: var(--mint-400); font-weight: 800; }
+.stats-table tr.is-lose .t-lose { color: var(--red-premium); font-weight: 800; }
+
+/* the six 评语 — a grid, so a short last row keeps its neighbours' width instead of stretching */
+.stats-titles { display: grid; grid-template-columns: repeat(auto-fill, minmax(1.7rem, 1fr)); gap: .08rem; }
+.stit {
+ display: flex; align-items: center; gap: .08rem; min-width: 0;
+ padding: .06rem .1rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line);
+}
+.stit--none { opacity: .45; }
+.stit__icon { width: .36rem; height: .36rem; flex: none; object-fit: contain; }
+.stit__text { display: flex; flex-direction: column; min-width: 0; }
+.stit__text span { font-size: .15rem; color: var(--text-md); white-space: nowrap; }
+.stit__text b { font-size: .22rem; font-weight: 900; color: #f6a329; line-height: 1.1; }
+
+/* a replayed settlement replaces the whole panel: the real fills the overlay */
+.stats__replay { position: absolute; inset: 0; }
+
+/* recent rows click through to the match's settlement screen (openRecordResult) */
+.stats-table tbody tr.is-clickable { cursor: pointer; }
+.stats-table tbody tr.is-clickable:hover td { background: rgba(78, 216, 175, .1); color: var(--text-hi); }
+.stats-table .stats-go { width: .24rem; color: var(--text-lo); }
+.stats-table tr.is-clickable:hover .stats-go { color: var(--mint-400); }
+.stats-hint { margin: .06rem 0 0; font-size: .13rem; color: var(--text-lo); letter-spacing: .04em; }
+
+@media (max-height: 560px) {
+ .stats__box { padding: .08rem .12rem; max-height: calc(100vh - .3rem); }
+ .stats__title { font-size: .22rem; }
+ .stats__note { display: none; }
+}
diff --git a/public/i18n/en.json b/public/i18n/en.json
index bbf29fcf..1dc85613 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -1055,5 +1055,46 @@
"选择「{name}」的朝向": "Choose {name}'s facing",
"选择朝向": "Choose a facing",
"朝向:{dir}": "Facing: {dir}",
- "开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers"
+ "开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers",
+ "统计数据": "Statistics",
+ "统计": "Stats",
+ "对局进行中,暂不能回看历史结算": "A match is running — replays are unavailable right now",
+ "总览": "Overview",
+ "总局数": "Games",
+ "胜场": "Wins",
+ "胜率": "Win rate",
+ "到达隐秘核心": "Hidden Core reached",
+ "通关隐秘核心": "Hidden Core cleared",
+ "最远回合": "Best round",
+ "累计时长": "Time played",
+ "{n} 分": "{n} min",
+ "难度": "Difficulty",
+ "局数": "Games",
+ "隐秘核心通关": "Hidden Core clears",
+ "使用局数": "Used",
+ "通过局数": "Cleared",
+ "通过率": "Clear rate",
+ "称号": "Titles",
+ "战斗累计": "Combat totals",
+ "最近对局": "Recent matches",
+ "时间": "Time",
+ "模式": "Mode",
+ "结果": "Result",
+ "点击查看该局结算": "View this match's settlement",
+ "单人": "Solo",
+ "同盟": "Co-op",
+ "胜": "Win",
+ "负": "Loss",
+ "点击任意一行,回看该局结算。": "Click a row to re-view that match's settlement.",
+ "已导出 {n} 条对局记录": "Exported {n} match records",
+ "已导入 {added} 条记录{tail}": "Imported {added} records{tail}",
+ "(跳过重复 {skipped} 条)": " ({skipped} duplicates skipped)",
+ "没有新记录(全部与现有数据重复)": "Nothing new (everything already exists)",
+ "导入失败:不是有效的统计导出文件": "Import failed: not a valid stats export file",
+ "已清空本机统计数据": "Local stats cleared",
+ "确认清空?": "Clear for sure?",
+ "本机数据 · 仅记录本浏览器中你参与的对局": "Local data · only matches you took part in, in this browser",
+ "检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "Stats data from a newer version detected — upgrade the client before viewing, or the data could be overwritten.",
+ "还没有记录 —— 完成一局对局后,这里会开始积累。": "No records yet — finish a match and the page starts filling in.",
+ "未知": "Unknown"
}
diff --git a/public/i18n/ja.json b/public/i18n/ja.json
index 9986bbbe..8c98d4b6 100644
--- a/public/i18n/ja.json
+++ b/public/i18n/ja.json
@@ -1061,5 +1061,46 @@
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files}をダウンロードできなかったため、このページを表示できません。保存済みの設定に影響はありません。ページを更新してください。それでも解決しない場合は、広告ブロッカーとネットワークを確認してください。",
"全员无伤!": "全員無傷!",
"生命值减少 −{n}": "耐久値減少 −{n}",
- "当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。"
+ "当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。",
+ "统计数据": "統計データ",
+ "统计": "統計",
+ "对局进行中,暂不能回看历史结算": "対戦中のため、過去の結果は表示できません",
+ "总览": "概要",
+ "总局数": "対戦数",
+ "胜场": "勝利数",
+ "胜率": "勝率",
+ "到达隐秘核心": "シークレットコア到達",
+ "通关隐秘核心": "シークレットコアクリア",
+ "最远回合": "最高ラウンド",
+ "累计时长": "累計プレイ時間",
+ "{n} 分": "{n}分",
+ "难度": "難易度",
+ "局数": "対戦数",
+ "隐秘核心通关": "シークレットコアクリア",
+ "使用局数": "使用回数",
+ "通过局数": "クリア回数",
+ "通过率": "クリア率",
+ "称号": "称号",
+ "战斗累计": "戦闘累計",
+ "最近对局": "最近の対戦",
+ "时间": "日時",
+ "模式": "モード",
+ "结果": "結果",
+ "点击查看该局结算": "この対戦の結果を見る",
+ "单人": "単独",
+ "同盟": "連合",
+ "胜": "勝",
+ "负": "敗",
+ "点击任意一行,回看该局结算。": "行をクリックすると、その対戦の結果をもう一度見られます。",
+ "已导出 {n} 条对局记录": "対戦記録を{n}件エクスポートしました",
+ "已导入 {added} 条记录{tail}": "{added}件の記録をインポートしました{tail}",
+ "(跳过重复 {skipped} 条)": "(重複{skipped}件をスキップ)",
+ "没有新记录(全部与现有数据重复)": "新しい記録はありません(すべて既存のデータと重複しています)",
+ "导入失败:不是有效的统计导出文件": "インポートに失敗しました:有効な統計のエクスポートファイルではありません",
+ "已清空本机统计数据": "この端末の統計データを消去しました",
+ "确认清空?": "本当に消去しますか?",
+ "本机数据 · 仅记录本浏览器中你参与的对局": "この端末のデータ · このブラウザで参加した対戦のみ記録されます",
+ "检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "より新しいバージョンの統計データが見つかりました。上書きを避けるため、クライアントを更新してから表示してください。",
+ "还没有记录 —— 完成一局对局后,这里会开始积累。": "記録はまだありません — 対戦を1つ終えると、ここに蓄積されていきます。",
+ "未知": "不明"
}
diff --git a/public/i18n/ko.json b/public/i18n/ko.json
index daf09d7f..0bf371b4 100644
--- a/public/i18n/ko.json
+++ b/public/i18n/ko.json
@@ -1057,5 +1057,46 @@
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files} 다운로드에 실패하여 이 페이지를 표시할 수 없습니다. 저장된 설정에는 영향이 없습니다. 페이지를 새로고침해 주세요. 그래도 안 되면 광고 차단 확장 프로그램과 네트워크를 확인해 주세요.",
"全员无伤!": "전원 무사!",
"生命值减少 −{n}": "목표 HP 감소 −{n}",
- "当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "현재 언어의 인터페이스 텍스트는 기계 번역이므로 정확하지 않을 수 있습니다. GitHub에서 수정 제안을 환영합니다."
+ "当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "현재 언어의 인터페이스 텍스트는 기계 번역이므로 정확하지 않을 수 있습니다. GitHub에서 수정 제안을 환영합니다.",
+ "统计数据": "통계",
+ "统计": "통계",
+ "对局进行中,暂不能回看历史结算": "시뮬레이션이 진행 중이라 지난 정산을 다시 볼 수 없습니다",
+ "总览": "개요",
+ "总局数": "총 판수",
+ "胜场": "승리",
+ "胜率": "승률",
+ "到达隐秘核心": "숨겨진 핵심 도달",
+ "通关隐秘核心": "숨겨진 핵심 클리어",
+ "最远回合": "최고 라운드",
+ "累计时长": "누적 플레이 시간",
+ "{n} 分": "{n}분",
+ "难度": "난이도",
+ "局数": "판수",
+ "隐秘核心通关": "숨겨진 핵심 클리어",
+ "使用局数": "사용 판수",
+ "通过局数": "클리어 판수",
+ "通过率": "클리어율",
+ "称号": "평가",
+ "战斗累计": "전투 누적",
+ "最近对局": "최근 판",
+ "时间": "시간",
+ "模式": "모드",
+ "结果": "결과",
+ "点击查看该局结算": "이 판의 정산 보기",
+ "单人": "독립",
+ "同盟": "동맹",
+ "胜": "승",
+ "负": "패",
+ "点击任意一行,回看该局结算。": "행을 누르면 해당 판의 정산을 다시 볼 수 있습니다.",
+ "已导出 {n} 条对局记录": "기록 {n}건을 내보냈습니다",
+ "已导入 {added} 条记录{tail}": "기록 {added}건을 가져왔습니다{tail}",
+ "(跳过重复 {skipped} 条)": " (중복 {skipped}건 건너뜀)",
+ "没有新记录(全部与现有数据重复)": "새 기록이 없습니다(모두 기존 데이터와 중복)",
+ "导入失败:不是有效的统计导出文件": "가져오기 실패: 올바른 통계 내보내기 파일이 아닙니다",
+ "已清空本机统计数据": "이 기기의 통계 데이터를 삭제했습니다",
+ "确认清空?": "정말 비우시겠습니까?",
+ "本机数据 · 仅记录本浏览器中你参与的对局": "이 기기의 데이터 · 이 브라우저에서 참여한 판만 기록됩니다",
+ "检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "더 최신 버전의 통계 데이터가 감지되었습니다. 데이터를 덮어쓰지 않도록 클라이언트를 업데이트한 뒤 확인하세요.",
+ "还没有记录 —— 完成一局对局后,这里会开始积累。": "아직 기록이 없습니다 — 한 판을 마치면 여기에 쌓이기 시작합니다.",
+ "未知": "알 수 없음"
}
diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json
index 24d9f4e9..07ec5611 100644
--- a/public/i18n/zh-TW.json
+++ b/public/i18n/zh-TW.json
@@ -1059,5 +1059,46 @@
"游戏数据没有载入": "遊戲資料沒有載入",
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files} 沒有下載成功,這一頁無法顯示;已儲存的設定不受影響。請重新整理頁面;仍不行時,請檢查廣告攔截擴充功能和網路。",
"全员无伤!": "全員無傷!",
- "生命值减少 −{n}": "生命值減少 −{n}"
+ "生命值减少 −{n}": "生命值減少 −{n}",
+ "统计数据": "統計資料",
+ "统计": "統計",
+ "对局进行中,暂不能回看历史结算": "對局進行中,暫時無法回看歷史結算",
+ "总览": "總覽",
+ "总局数": "總局數",
+ "胜场": "勝場",
+ "胜率": "勝率",
+ "到达隐秘核心": "到達隱秘核心",
+ "通关隐秘核心": "通關隱秘核心",
+ "最远回合": "最遠回合",
+ "累计时长": "累計時長",
+ "{n} 分": "{n} 分",
+ "难度": "難度",
+ "局数": "局數",
+ "隐秘核心通关": "隱秘核心通關",
+ "使用局数": "使用局數",
+ "通过局数": "通過局數",
+ "通过率": "通過率",
+ "称号": "稱號",
+ "战斗累计": "戰鬥累計",
+ "最近对局": "最近對局",
+ "时间": "時間",
+ "模式": "模式",
+ "结果": "結果",
+ "点击查看该局结算": "點擊查看該局結算",
+ "单人": "單人",
+ "同盟": "同盟",
+ "胜": "勝",
+ "负": "負",
+ "点击任意一行,回看该局结算。": "點擊任一行,回看該局結算。",
+ "已导出 {n} 条对局记录": "已匯出 {n} 筆對局記錄",
+ "已导入 {added} 条记录{tail}": "已匯入 {added} 筆記錄{tail}",
+ "(跳过重复 {skipped} 条)": "(略過重複 {skipped} 筆)",
+ "没有新记录(全部与现有数据重复)": "沒有新記錄(全部與現有資料重複)",
+ "导入失败:不是有效的统计导出文件": "匯入失敗:不是有效的統計匯出檔案",
+ "已清空本机统计数据": "已清空本機統計資料",
+ "确认清空?": "確認清空?",
+ "本机数据 · 仅记录本浏览器中你参与的对局": "本機資料 · 僅記錄本瀏覽器中你參與的對局",
+ "检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "偵測到更新版本的統計資料,請先升級用戶端再查看,以免覆蓋資料。",
+ "还没有记录 —— 完成一局对局后,这里会开始积累。": "還沒有記錄 —— 完成一局對局後,這裡會開始累積。",
+ "未知": "未知"
}
diff --git a/public/index.html b/public/index.html
index 82a085e0..2fd445cc 100644
--- a/public/index.html
+++ b/public/index.html
@@ -38,6 +38,7 @@
+
diff --git a/public/js/main.js b/public/js/main.js
index 5b109e91..7bb59827 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -49,6 +49,8 @@ import { settingsStore } from './ui/settings.js';
import { GuideHost } from './ui/guide.js';
import { installDeviceSupport } from './ui/device.js';
import { LoadoutHost } from './screens/loadout.js';
+import { StatsHost } from './screens/stats.js';
+import { recordResult } from './ui/stats.js';
import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/loadoutSync.js';
import { startBuildGuard } from './ui/buildGuard.js';
import { initLang, useLang, tickerText } from './ui/lang.js';
@@ -219,7 +221,13 @@ function wireNet() {
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
net.on('m.field', (msg) => store.patch('match', { field: payload(msg) }));
- net.on('m.result', (msg) => store.patch('match', { result: payload(msg) }));
+ net.on('m.result', (msg) => {
+ const res = payload(msg);
+ store.patch('match', { result: res });
+ // 本机统计 (#18): every arrival, including the lobby's result replay after a reconnect / reload —
+ // replays dedupe by content id inside recordResult (spectator seats' copies build no record at all)
+ recordResult(res, { myId: store.get().me.playerId, roomMode: store.get().room?.mode ?? null, now: Date.now() });
+ });
net.on('m.toast', (msg) => {
const kind = ['info', 'success', 'warn', 'error'].includes(msg.kind) ? msg.kind : 'info';
// msgid + params (server ≥ 0.2.0) or the text itself as a msgid, in the current language
@@ -287,6 +295,7 @@ function App() {
<${UiHosts} />
<${GuideHost} />
<${LoadoutHost} />
+ <${StatsHost} />
`;
}
diff --git a/public/js/screens/lobby.js b/public/js/screens/lobby.js
index 5274507f..0e7586a5 100644
--- a/public/js/screens/lobby.js
+++ b/public/js/screens/lobby.js
@@ -16,6 +16,7 @@ import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_S
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
import { toast, toastError } from '../ui/toasts.js';
import { GuideButton } from '../ui/guide.js';
+import { openStats } from './stats.js';
import { LoadoutButton } from './loadout.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
@@ -311,6 +312,7 @@ export function LobbyScreen() {
${t('选择模拟协议')}
+ <${Button} variant="secondary" size="sm" icon="signal" onClick=${openStats} title=${t('统计数据')}>${t('统计')}/>
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
diff --git a/public/js/screens/room.js b/public/js/screens/room.js
index 26fe4a2a..db6cd66a 100644
--- a/public/js/screens/room.js
+++ b/public/js/screens/room.js
@@ -19,6 +19,7 @@ import {
import { toast, toastError } from '../ui/toasts.js';
import { copyText } from '../ui/clipboard.js';
import { GuideButton } from '../ui/guide.js';
+import { openStats } from './stats.js';
import { LoadoutButton } from './loadout.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
@@ -277,6 +278,7 @@ export function RoomScreen() {
<${PingPill} ms=${conn.ping} online=${online} />
<${MicroLabel}>${t('当前延迟')}/>
+ <${Button} variant="secondary" size="sm" icon="signal" onClick=${openStats} title=${t('统计数据')}>${t('统计')}/>
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
diff --git a/public/js/screens/stats.js b/public/js/screens/stats.js
new file mode 100644
index 00000000..16f1bc1f
--- /dev/null
+++ b/public/js/screens/stats.js
@@ -0,0 +1,316 @@
+// 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`
+ ${t('难度')} ${t('局数')} ${t('胜场')} ${t('胜率')} ${t('隐秘核心通关')}
+
+ ${rows.map(([d, v]) => html`
+ ${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">
+
+ ${t('策略')} ${t('使用局数')} ${t('通过局数')} ${t('通过率')}
+
+ ${rows.map(([bandId, v]) => {
+ const band = gd.band(bandId);
+ return html`
+ ${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}`}>
+
+ ${t('时间')} ${t('难度')} ${t('模式')} ${t('策略')} ${t('结果')} ${t('回合')}
+
+ ${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)}>
+ ${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`
`;
+ }
+ const agg = aggregateStats(stats.records);
+ return html`
{ if (e.target === e.currentTarget) closeStats(); }}>
+
+
+
+ ${!ready ? html`
<${Spinner} size="md" />
`
+ : stats.newer ? html`
${t('检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。')}
`
+ : !stats.records.length ? html`
${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}
`
+ : html`<${Overview} agg=${agg} />
+ <${Bands} agg=${agg} gd=${gd} />
+ <${Titles} agg=${agg} gd=${gd} />
+ <${CombatSums} agg=${agg} />
+ <${Recent} records=${stats.records} gd=${gd} />`}
+
+
+
`;
+}
diff --git a/public/js/screens/title.js b/public/js/screens/title.js
index 2ff7873d..d9a4997e 100644
--- a/public/js/screens/title.js
+++ b/public/js/screens/title.js
@@ -14,6 +14,7 @@ import { useMemo, useState } from '../../vendor/hooks.module.js';
import { NAME_MAX_LEN, APP_VERSION, DEV_BUILD } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, TextField, PingPill } from '../ui/components.js';
import { GuideButton } from '../ui/guide.js';
+import { openStats } from './stats.js';
import { toast } from '../ui/toasts.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual } from '../store.js';
@@ -272,6 +273,7 @@ export function TitleScreen() {
${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}
${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('玩法说明')} />
setSettingsOpen(true)}><${GIcon} name="gear" />
diff --git a/public/js/ui/stats.js b/public/js/ui/stats.js
new file mode 100644
index 00000000..bc8cc9c3
--- /dev/null
+++ b/public/js/ui/stats.js
@@ -0,0 +1,469 @@
+// Local match statistics: every finished match the local player took part in is appended as one record to
+// localStorage (`sp.pref.stats`, the loadPref/savePref convention of store.js). Per-browser only — the server
+// keeps matches in memory and has no identity, so there is nowhere else for cross-match numbers to live; the
+// stats page labels itself 本机数据 and offers JSON export / import for moving between devices.
+//
+// The record keeps everything the settlement offers (m.result is free-form, server/match/results.js buildResult):
+// the match frame (difficulty, mode, boss, seed, duration, hidden-core outcome, reason) plus one row per player
+// — band, title (评语), lineup, bonds and the full stat block — even where today's page aggregates only a part.
+// Whatever a future statistics view needs should already be in the record; display is the only thing that lags.
+//
+// Format evolution: the envelope carries a version (`STATS_VERSION`); `migrateStats` walks records through a
+// chain of per-version steps and every loaded / imported record passes `normalizeRecord`, which fills defaults
+// for missing fields and PRESERVES unknown ones — so data written by an older build reads fine after an
+// upgrade, and fields added by a newer writer survive a round-trip through this one. An envelope from a newer
+// version than this build is refused (never silently downgraded and written back).
+//
+// Pure logic, no Preact / DOM: Node-testable (test/ui/stats.test.js); localStorage is touched only through
+// loadStats / saveStats.
+
+import { loadPref } from '../store.js';
+import { t } from '../../../shared/i18n.js';
+
+/** Current record-envelope version (bump + add a MIGRATIONS step when the record shape changes). */
+export const STATS_VERSION = 1;
+/** localStorage key under the app's `sp.pref.` prefix (store.js loadPref). */
+export const STATS_PREF_KEY = 'stats';
+/** Oldest records are dropped beyond this (rough sizing: ~1.5 kB/record ⇒ ~1.5 MB of a ~5 MB quota). */
+export const MAX_RECORDS = 1000;
+/** How many games the page's 最近对局 list shows. */
+export const RECENT_SHOW = 20;
+
+const FULL_KEY = `sp.pref.${STATS_PREF_KEY}`;
+
+/** @returns {{ v: number, records: Array }} a fresh empty envelope */
+export const emptyStats = () => ({ v: STATS_VERSION, records: [] });
+
+const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
+const int = (v, d) => (Number.isFinite(v) ? Math.round(v) : d);
+const num = (v) => (Number.isFinite(v) ? v : null);
+const bool = (v, d = false) => (typeof v === 'boolean' ? v : d);
+const str = (v) => (typeof v === 'string' && v.length ? v : null);
+
+/**
+ * Copy only the finite numeric keys of a stats block (the settlement's numbers; unknown numeric fields are
+ * kept too — display can lag, data must not).
+ * @param {any} raw
+ * @returns {Record
}
+ */
+function normalizeStatBlock(raw) {
+ const out = {};
+ if (!isObj(raw)) return out;
+ for (const [k, v] of Object.entries(raw)) if (Number.isFinite(v)) out[k] = v;
+ return out;
+}
+
+/**
+ * FNV-1a 32-bit, hex — a content hash needs no crypto, only stability across sessions.
+ * @param {string} s
+ * @returns {string}
+ */
+function fnv1a(s) {
+ let h = 0x811c9dc5;
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
+ }
+ return h.toString(16).padStart(8, '0');
+}
+
+/**
+ * The record's dedupe id, from match facts the server replays byte-identically on reconnect / reload
+ * (server/lobby.js result replay): seed + duration + mode + rounds + outcome + who I was. Two distinct
+ * matches colliding on all of these is not a real scenario; a replayed m.result hits the same id and is
+ * dropped, which is the point (recording happens on every m.result arrival, replays included).
+ * @param {any} res raw m.result payload
+ * @param {string|null|undefined} myId the local player's id at that moment
+ * @returns {string}
+ */
+export function recordId(res, myId) {
+ const r = isObj(res) ? res : {};
+ return `r1.${fnv1a([r.seed ?? '-', r.durationMs ?? '-', r.modeId ?? '-', r.roundsPassed ?? '-', r.victory ? 1 : 0, myId ?? '-'].join('|'))}`;
+}
+
+/**
+ * Normalize ONE player row: known fields defaulted, unknown fields preserved (forward compat).
+ * @param {any} raw
+ * @returns {any}
+ */
+function normalizePlayerRow(raw) {
+ const p = isObj(raw) ? raw : {};
+ const title = isObj(p.title)
+ ? { id: str(p.title.id) || '', name: str(p.title.name) || '', picId: str(p.title.picId) || '', text: str(p.title.text) || '' }
+ : null;
+ const lineup = (Array.isArray(p.lineup) ? p.lineup : []).filter(isObj).slice(0, 12).map((u) => ({
+ // ids only — the tile position (row/col) is replay detail, not a statistic; items kept (配发装备统计)
+ id: str(u.id) || '', golden: bool(u.golden), tier: int(u.tier, 0),
+ items: (Array.isArray(u.items) ? u.items : []).filter((i) => typeof i === 'string' && i.length),
+ })).filter((u) => u.id);
+ const bonds = (Array.isArray(p.bonds) ? p.bonds : []).filter(isObj).map((b) => ({
+ bondId: str(b.bondId) || str(b.id) || '', layers: int(b.layers, 0), active: bool(b.active),
+ })).filter((b) => b.bondId);
+ return {
+ ...p, // unknown / future fields pass through untouched
+ playerId: str(p.playerId) || '',
+ seat: int(p.seat, 0),
+ name: str(p.name) || t('博士'),
+ isBot: bool(p.isBot),
+ left: bool(p.left),
+ alive: bool(p.alive, true),
+ victory: bool(p.victory), // server semantics: team won AND this player was still in at the end
+ roundsPassed: int(p.roundsPassed, 0),
+ eliminatedRound: num(p.eliminatedRound),
+ lp: num(p.lp),
+ bandId: str(p.bandId),
+ title,
+ trophies: int(p.trophies, 0),
+ reward: int(p.reward, 0),
+ lineup,
+ bonds,
+ stats: normalizeStatBlock(p.stats),
+ };
+}
+
+/**
+ * Normalize ONE match record to the current shape. Missing fields get defaults; unknown fields (a newer
+ * writer's additions) survive untouched, so import → export does not lose them.
+ * @param {any} raw
+ * @returns {any|null} null when the row is not salvageable (no players array at all)
+ */
+export function normalizeRecord(raw) {
+ const r = isObj(raw) ? raw : {};
+ // same rule as buildRecord: a record IS a played match — no usable players array, no record
+ if (!Array.isArray(r.players) || !r.players.length) return null;
+ const base = {
+ id: str(r.id) || '',
+ v: STATS_VERSION,
+ t: int(r.t, 0),
+ victory: bool(r.victory),
+ roundsPassed: int(r.roundsPassed, 0),
+ lastRound: num(r.lastRound),
+ hiddenReached: bool(r.hiddenReached),
+ hiddenCleared: bool(r.hiddenCleared),
+ reason: str(r.reason),
+ modeId: str(r.modeId),
+ difficulty: str(r.difficulty),
+ stageId: str(r.stageId),
+ bossId: str(r.bossId),
+ hiddenBossId: str(r.hiddenBossId),
+ seed: num(r.seed),
+ durationMs: num(r.durationMs),
+ teamLp: num(r.teamLp),
+ roomMode: r.roomMode === 'solo' || r.roomMode === 'coop' ? r.roomMode : null,
+ selfId: str(r.selfId),
+ players: r.players.filter(isObj).map(normalizePlayerRow),
+ };
+ return { ...r, ...base };
+}
+
+/**
+ * The MIGRATIONS chain: `MIGRATIONS[v]` upgrades a v-envelope to v+1, for v = STATS_VERSION and down.
+ * Empty at v1 — the steps exist so the next format change lands as one function here and every stored /
+ * imported envelope walks the whole chain.
+ * @type {Record any>}
+ */
+export const MIGRATIONS = {};
+
+/**
+ * Bring any stored / imported envelope to the current version.
+ * @param {any} raw
+ * @returns {{ stats: { v: number, records: any[] }, changed: boolean, newer: boolean }}
+ * `newer` = the data was written by a NEWER build: returned untouched, callers must not save over it.
+ */
+export function migrateStats(raw) {
+ if (!isObj(raw) || !Array.isArray(raw.records)) return { stats: emptyStats(), changed: false, newer: false };
+ if ((raw.v ?? 0) > STATS_VERSION) {
+ return { stats: { v: raw.v, records: raw.records }, changed: false, newer: true };
+ }
+ let v = raw.v ?? 0;
+ let records = raw.records;
+ let changed = v !== STATS_VERSION;
+ while (v < STATS_VERSION) {
+ const step = MIGRATIONS[v];
+ if (typeof step === 'function') records = step({ v, records }).records;
+ v++;
+ }
+ const normalized = [];
+ for (const r of records) {
+ const rec = normalizeRecord(r);
+ if (rec) normalized.push(rec);
+ }
+ changed ||= normalized.length !== records.length;
+ return { stats: { v: STATS_VERSION, records: normalized }, changed, newer: false };
+}
+
+/**
+ * Read + migrate the persisted envelope; self-heals older shapes back to storage.
+ * @returns {{ v: number, records: any[], newer: boolean }}
+ */
+export function loadStats() {
+ const { stats, changed, newer } = migrateStats(loadPref(STATS_PREF_KEY, null));
+ if (!newer && changed) saveStats(stats);
+ return { ...stats, newer };
+}
+
+/**
+ * Persist the envelope. savePref swallows quota errors — here losing a record silently would do exactly
+ * that, so the write reports failure and shrinks the history (oldest quarter) before retrying.
+ * @param {{ v: number, records: any[] }} stats
+ * @returns {boolean} whether a write stuck
+ */
+export function saveStats(stats) {
+ const env = { v: STATS_VERSION, records: stats.records };
+ try {
+ globalThis.localStorage?.setItem(FULL_KEY, JSON.stringify(env));
+ return true;
+ } catch { /* quota — fall through to shrinking */ }
+ let recs = stats.records;
+ // two quarter-shrinks at most; records are newest first, so the tail (oldest) is what goes;
+ // a small history that still does not fit is foreign quota pressure — giving up beats gradually erasing it
+ for (let i = 0; i < 2 && recs.length > 8; i++) {
+ recs = recs.slice(0, recs.length - Math.ceil(recs.length / 4));
+ try {
+ globalThis.localStorage?.setItem(FULL_KEY, JSON.stringify({ v: STATS_VERSION, records: recs }));
+ return true;
+ } catch { /* keep shrinking */ }
+ }
+ return false;
+}
+
+/**
+ * Derive the room shape ('solo' | 'coop') the way constants.js modeIdFor builds modeIds.
+ * @param {string|null|undefined} modeId
+ * @param {string|null|undefined} roomMode room.state `mode` when known
+ * @returns {'solo'|'coop'|null}
+ */
+export function roomModeOf(modeId, roomMode) {
+ if (roomMode === 'solo' || roomMode === 'coop') return roomMode;
+ if (typeof modeId !== 'string') return null;
+ if (modeId.includes('_single_')) return 'solo';
+ if (modeId.includes('_multi_')) return 'coop';
+ return null;
+}
+
+/**
+ * Build one record from a raw m.result payload (+ context). Null when there is nothing to store: payloads
+ * without players (the error-path settlement) or matches the local player did not take part in (spectator
+ * seats get every result pushed too — eliminated players, who ARE in players[], keep recording).
+ * @param {any} res raw m.result payload
+ * @param {{ myId?: string|null, roomMode?: string|null, now?: number }} ctx
+ * @returns {any|null}
+ */
+export function buildRecord(res, ctx = {}) {
+ const r = isObj(res) ? res : {};
+ if (!Array.isArray(r.players) || !r.players.length) return null;
+ const myId = str(ctx.myId);
+ const selfRow = r.players.find((p) => isObj(p) && p.playerId === myId);
+ if (!selfRow) return null; // spectator / observer: not my match
+ const rec = normalizeRecord({
+ v: STATS_VERSION,
+ t: int(ctx.now, 0),
+ id: recordId(r, myId),
+ victory: bool(r.victory),
+ roundsPassed: int(r.roundsPassed, 0),
+ lastRound: num(r.lastRound),
+ hiddenReached: bool(r.hiddenReached),
+ hiddenCleared: bool(r.hiddenCleared),
+ reason: str(r.reason),
+ modeId: str(r.modeId),
+ difficulty: str(r.difficulty),
+ stageId: str(r.stageId),
+ bossId: str(r.bossId),
+ hiddenBossId: str(r.hiddenBossId),
+ seed: num(r.seed),
+ durationMs: num(r.durationMs),
+ teamLp: num(r.teamLp),
+ roomMode: roomModeOf(str(r.modeId), ctx.roomMode),
+ selfId: myId,
+ players: r.players,
+ });
+ return rec;
+}
+
+/**
+ * The m.result-shaped payload behind a record — the inverse of buildRecord. The stats page's 最近对局
+ * rows hand this to the settlement screen (screens/result.js reads store.match.result through
+ * normalizeResult): a finished match can be RE-VIEWED because the record kept every field the payload
+ * had. Whatever a record never stored (older / hand-made rows) is simply absent and normalizeResult
+ * defaults it, exactly like a tolerant live payload.
+ * @param {any} rec
+ * @returns {any|null} null when the row is not a usable record
+ */
+export function recordToResult(rec) {
+ const r = normalizeRecord(rec);
+ if (!r) return null;
+ const res = {
+ victory: r.victory,
+ roundsPassed: r.roundsPassed,
+ hiddenReached: r.hiddenReached,
+ hiddenCleared: r.hiddenCleared,
+ reason: r.reason,
+ modeId: r.modeId,
+ difficulty: r.difficulty,
+ bossId: r.bossId,
+ hiddenBossId: r.hiddenBossId,
+ seed: r.seed,
+ durationMs: r.durationMs,
+ teamLp: r.teamLp,
+ players: r.players,
+ };
+ if (Number.isFinite(r.lastRound)) res.lastRound = r.lastRound;
+ return res;
+}
+
+/**
+ * Append one record: replays (same content id) are dropped, newest first, capped at MAX_RECORDS.
+ * @param {{ v: number, records: any[] }} stats
+ * @param {any} record
+ * @returns {{ stats: { v: number, records: any[] }, added: boolean }}
+ */
+export function appendRecord(stats, record) {
+ if (!record || !record.id) return { stats, added: false };
+ if (stats.records.some((r) => r.id === record.id)) return { stats, added: false };
+ const records = [record, ...stats.records].slice(0, MAX_RECORDS);
+ return { stats: { v: STATS_VERSION, records }, added: true };
+}
+
+/**
+ * Record a finished match end-to-end (load → append → save). Returns the built record, or null when
+ * nothing was stored (no players / not my match / replay).
+ * @param {any} res raw m.result payload
+ * @param {{ myId?: string|null, roomMode?: string|null, now?: number }} [ctx]
+ * @returns {any|null}
+ */
+export function recordResult(res, ctx = {}) {
+ const rec = buildRecord(res, ctx);
+ if (!rec) return null;
+ const stats = loadStats();
+ const { stats: next, added } = appendRecord(stats, rec);
+ if (added && !saveStats(next)) console.warn('[stats] localStorage write failed (quota?), record not persisted');
+ return added ? rec : null;
+}
+
+/**
+ * Merge two envelopes (import into existing): union by record id, chronological, capped.
+ * @param {{ records: any[] }} a current (wins ties)
+ * @param {{ records: any[] }} b incoming
+ * @returns {{ v: number, records: any[] }}
+ */
+export function mergeStats(a, b) {
+ const byId = new Map();
+ for (const r of [...(Array.isArray(b?.records) ? b.records : []), ...(Array.isArray(a?.records) ? a.records : [])]) {
+ const rec = normalizeRecord(r);
+ // id-less (hand-made) rows fall back to a CONTENT hash so both copies land on the same key
+ if (rec) byId.set(rec.id || `c.${fnv1a(JSON.stringify(rec))}`, rec);
+ }
+ const records = [...byId.values()].sort((x, y) => (x.t - y.t) || (x.id < y.id ? -1 : 1)).slice(-MAX_RECORDS);
+ return { v: STATS_VERSION, records };
+}
+
+/**
+ * Parse + migrate + merge an exported / pasted payload into the current data.
+ * @param {any} raw parsed JSON of the imported file
+ * @param {{ records: any[] }} current current envelope
+ * @returns {{ stats: { v: number, records: any[] }, added: number, skipped: number }}
+ */
+export function importStats(raw, current) {
+ const { stats: incoming, newer } = migrateStats(raw);
+ if (newer) {
+ const err = new Error('该文件由更新版本的客户端导出,请先升级后再导入');
+ err.code = 'stats-newer-version';
+ throw err;
+ }
+ const merged = mergeStats(current, incoming);
+ const added = merged.records.length - (Array.isArray(current?.records) ? current.records.length : 0);
+ return { stats: merged, added: Math.max(0, added), skipped: incoming.records.length - Math.max(0, added) };
+}
+
+/**
+ * The payload the export button downloads (envelope + a little provenance).
+ * @param {{ records: any[] }} stats
+ * @returns {{ app: string, kind: string, v: number, exportedAt: string, records: any[] }}
+ */
+export function exportStats(stats) {
+ return {
+ app: 'stronghold-protocol',
+ kind: 'local-stats',
+ v: STATS_VERSION,
+ exportedAt: new Date().toISOString(),
+ records: Array.isArray(stats?.records) ? stats.records : [],
+ };
+}
+
+// ---- aggregation (what the page shows; records may hold more than any view uses) ------------------------
+
+const SELF_STATS = ['dmgDealt', 'kills', 'bossDamage', 'activatedLayers', 'merges', 'itemsEquipped', 'gold', 'perfectRounds', 'refreshes', 'leaks', 'lpLost'];
+
+/**
+ * The row that "is me" in a record: selfId, else the only human, else seat 0 — records written by
+ * recordResult always have selfId; the fallbacks only serve imported / hand-made data.
+ * @param {any} rec
+ * @returns {any|null}
+ */
+export function selfRowOf(rec) {
+ const ps = Array.isArray(rec?.players) ? rec.players : [];
+ return ps.find((p) => p.playerId && p.playerId === rec.selfId)
+ || ps.find((p) => !p.isBot)
+ || ps[0]
+ || null;
+}
+
+/** Whether this record counts as a win for its self player (per-row victory = team won AND still in). */
+const selfWon = (rec, self) => (self ? !!self.victory : !!rec.victory);
+
+/**
+ * Aggregate records for the stats page. Everything is keyed by ids (bandId / titleId / difficulty); the
+ * page maps them to names and icons through the game data.
+ * @param {any[]} records
+ * @returns {any}
+ */
+export function aggregateStats(records) {
+ const rs = Array.isArray(records) ? records : [];
+ const out = {
+ count: rs.length, wins: 0,
+ rounds: { total: 0, max: 0 },
+ duration: { totalMs: 0 },
+ hidden: { reached: 0, cleared: 0 },
+ byDifficulty: {}, byMode: { solo: 0, coop: 0 },
+ titles: {}, bands: {}, sums: {},
+ };
+ for (const rec of rs) {
+ const self = selfRowOf(rec);
+ const won = selfWon(rec, self);
+ if (won) out.wins++;
+ out.rounds.total += int(rec.roundsPassed, 0);
+ out.rounds.max = Math.max(out.rounds.max, int(rec.roundsPassed, 0));
+ out.duration.totalMs += num(rec.durationMs) || 0;
+ if (rec.hiddenReached) out.hidden.reached++;
+ if (rec.hiddenCleared) out.hidden.cleared++;
+
+ const d = rec.difficulty || t('未知');
+ const bd = (out.byDifficulty[d] ||= { games: 0, wins: 0, hiddenCleared: 0 });
+ bd.games++;
+ if (won) bd.wins++;
+ if (rec.hiddenCleared) bd.hiddenCleared++;
+
+ if (rec.roomMode === 'solo' || rec.roomMode === 'coop') out.byMode[rec.roomMode]++;
+
+ const titleId = self?.title?.id;
+ if (titleId) {
+ const t = (out.titles[titleId] ||= { count: 0 });
+ t.count++;
+ }
+
+ const bandId = self?.bandId;
+ if (bandId) {
+ const b = (out.bands[bandId] ||= { games: 0, wins: 0 });
+ b.games++;
+ if (won) b.wins++;
+ }
+
+ if (self && isObj(self.stats)) {
+ for (const k of SELF_STATS) {
+ const v = self.stats[k];
+ if (Number.isFinite(v)) out.sums[k] = (out.sums[k] || 0) + v;
+ }
+ }
+ }
+ return out;
+}
diff --git a/test/ui/stats.test.js b/test/ui/stats.test.js
new file mode 100644
index 00000000..ecdfbfae
--- /dev/null
+++ b/test/ui/stats.test.js
@@ -0,0 +1,318 @@
+// Unit tests (Node) for the 本机对局统计 record store (public/js/ui/stats.js):
+// record building from a raw m.result (spectator copies and player-less error results build nothing),
+// replay dedupe by content id (the lobby re-pushes m.result on reconnect / reload),
+// the MAX_RECORDS cap, migration / normalization (old + partial + unknown fields preserved, newer refused),
+// import merge semantics and the aggregate the stats page renders.
+// localStorage is faked per test (Node has none); recordResult / loadStats / saveStats go through it.
+
+import { test, describe, beforeEach, afterEach } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ STATS_VERSION, MAX_RECORDS, emptyStats, buildRecord, recordId, appendRecord, recordResult,
+ loadStats, saveStats, migrateStats, normalizeRecord, mergeStats, importStats, exportStats,
+ aggregateStats, selfRowOf, roomModeOf, recordToResult, MIGRATIONS,
+} from '../../public/js/ui/stats.js';
+import { normalizeResult } from '../../public/js/ui/gameLogic.js';
+
+/** A realistic m.result in the shape server/match/results.js buildResult emits (replayed byte-identically). */
+function baseResult(over = {}) {
+ return {
+ t: 'm.result',
+ victory: true, roundsPassed: 15, hiddenReached: true, hiddenCleared: true, reason: 'hidden_cleared',
+ teamLp: 24, modeId: 'mode_single_hard', difficulty: 'HARD', stageId: 'stage_hard', bossId: 'enemy_boss', hiddenBossId: 'enemy_hidden',
+ seed: 123456789, durationMs: 952123,
+ players: [
+ {
+ playerId: 'p1', seat: 0, name: '罗宾', isBot: false, left: false, alive: true, victory: true,
+ roundsPassed: 16, eliminatedRound: null, lp: 24, bandId: 'band_amiya',
+ title: { id: 'comment_1', name: '卫戍之星', picId: 'comment_icon_1', text: '对敌方领袖造成伤害最高(仅胜利时)' },
+ trophies: 30, reward: 88,
+ lineup: [{ id: 'char_290_vigna', golden: false, tier: 2, row: 10, col: 5, items: ['item_eq_1'] }],
+ bonds: [{ bondId: 'steadShip', layers: 12, active: true }],
+ stats: { dmgDealt: 15234.7, kills: 41, leaks: 2, gold: 880, refreshes: 5, merges: 6, itemsEquipped: 4, bossDamage: 5000.5, activatedLayers: 99, lpLost: 3, perfectRounds: 7 },
+ },
+ {
+ playerId: 'p2', seat: 1, name: '队友B', isBot: false, left: false, alive: false, victory: false,
+ roundsPassed: 9, eliminatedRound: 10, lp: 0, bandId: 'band_fang', title: null, trophies: 0, reward: 40,
+ lineup: [], bonds: [], stats: { kills: 3 },
+ },
+ ],
+ ...over,
+ };
+}
+
+/** Fake localStorage: Map-backed, with an optional byte budget that makes setItem throw like a full quota. */
+function fakeStorage({ budget = Infinity } = {}) {
+ const m = new Map();
+ const calls = [];
+ return {
+ calls,
+ getItem: (k) => (m.has(k) ? m.get(k) : null),
+ setItem(k, v) { calls.push(v.length); if (v.length > budget) { const e = new Error('quota'); e.name = 'QuotaExceededError'; throw e; } m.set(k, String(v)); },
+ removeItem: (k) => m.delete(k),
+ get raw() { return m.get('sp.pref.stats') ?? null; },
+ };
+}
+
+describe('local stats: record building', () => {
+ test('builds one full record from a raw m.result (frame + players + self marking)', () => {
+ const rec = buildRecord(baseResult(), { myId: 'p1', roomMode: 'solo', now: 1700000000000 });
+ assert.ok(rec);
+ assert.equal(rec.v, STATS_VERSION);
+ assert.equal(rec.t, 1700000000000);
+ assert.equal(rec.selfId, 'p1');
+ assert.equal(rec.victory, true);
+ assert.equal(rec.difficulty, 'HARD');
+ assert.equal(rec.seed, 123456789);
+ assert.equal(rec.durationMs, 952123);
+ assert.equal(rec.roomMode, 'solo');
+ assert.equal(rec.id, recordId(baseResult(), 'p1'));
+ assert.equal(rec.players.length, 2);
+ const self = rec.players[0];
+ assert.equal(self.bandId, 'band_amiya');
+ assert.equal(self.title.id, 'comment_1');
+ assert.equal(self.stats.bossDamage, 5000.5);
+ assert.deepEqual(self.lineup, [{ id: 'char_290_vigna', golden: false, tier: 2, items: ['item_eq_1'] }]);
+ assert.deepEqual(self.bonds, [{ bondId: 'steadShip', layers: 12, active: true }]);
+ });
+
+ test('a spectator seat copy (my id not among players) records nothing; so do player-less error results', () => {
+ assert.equal(buildRecord(baseResult(), { myId: 'spec9', now: 1 }), null);
+ assert.equal(buildRecord(baseResult({ players: [] }), { myId: 'p1', now: 1 }), null);
+ assert.equal(buildRecord(null, { myId: 'p1' }), null);
+ });
+
+ test('roomMode falls back to the modeId (single → solo, multi → coop, else null)', () => {
+ assert.equal(roomModeOf('mode_multi_funny', null), 'coop');
+ assert.equal(roomModeOf('mode_single_normal', 'coop'), 'coop'); // explicit room mode wins
+ assert.equal(roomModeOf('mode_training_1', null), null);
+ assert.equal(buildRecord(baseResult({ modeId: 'mode_multi_hard' }), { myId: 'p1', now: 1 }).roomMode, 'coop');
+ });
+});
+
+describe('local stats: replay dedupe + cap', () => {
+ test('the replayed m.result (same content) appends once; a different seed is a new game', () => {
+ let stats = emptyStats();
+ const res = baseResult();
+ const first = appendRecord(stats, buildRecord(res, { myId: 'p1', now: 1000 }));
+ assert.equal(first.added, true);
+ const replay = appendRecord(first.stats, buildRecord(res, { myId: 'p1', now: 999000 }));
+ assert.equal(replay.added, false); // the reload / reconnect replay
+ assert.equal(replay.stats.records.length, 1);
+ assert.equal(replay.stats.records[0].t, 1000); // the FIRST arrival's timestamp is kept
+ const other = appendRecord(replay.stats, buildRecord(baseResult({ seed: 42, durationMs: 55555 }), { myId: 'p1', now: 2000 }));
+ assert.equal(other.added, true);
+ assert.equal(other.stats.records.length, 2);
+ assert.equal(other.stats.records[0].id, recordId(baseResult({ seed: 42, durationMs: 55555 }), 'p1'));
+ });
+
+ test('recordResult end-to-end over faked localStorage: replay arrives, nothing is written twice', () => {
+ globalThis.localStorage = fakeStorage();
+ try {
+ const res = baseResult();
+ const rec = recordResult(res, { myId: 'p1', roomMode: 'solo', now: 1000 });
+ assert.ok(rec);
+ assert.equal(recordResult(res, { myId: 'p1', roomMode: 'solo', now: 2000 }), null); // replay dropped
+ const loaded = loadStats();
+ assert.equal(loaded.records.length, 1);
+ assert.equal(loaded.records[0].id, rec.id);
+ } finally { delete globalThis.localStorage; }
+ });
+
+ test(`the store caps at MAX_RECORDS (${MAX_RECORDS}), dropping the oldest`, () => {
+ let stats = emptyStats();
+ for (let i = 0; i < MAX_RECORDS + 30; i++) {
+ stats = appendRecord(stats, buildRecord(baseResult({ seed: i }), { myId: 'p1', now: i })).stats;
+ }
+ assert.equal(stats.records.length, MAX_RECORDS);
+ assert.equal(stats.records[0].t, MAX_RECORDS + 29); // newest first
+ assert.equal(stats.records.at(-1).t, 30); // oldest 30 dropped
+ });
+});
+
+describe('local stats: migration / normalization', () => {
+ test('an unversioned envelope (older shape) normalizes with defaults and self-heals on load', () => {
+ globalThis.localStorage = fakeStorage();
+ try {
+ const old = { records: [{ players: [{ playerId: 'p1', stats: { kills: 2 }, futureField: { x: 1 } }] }] };
+ globalThis.localStorage.setItem('sp.pref.stats', JSON.stringify(old));
+ const loaded = loadStats();
+ assert.equal(loaded.v, STATS_VERSION);
+ assert.equal(loaded.records.length, 1);
+ const rec = loaded.records[0];
+ assert.equal(rec.victory, false);
+ assert.equal(rec.roundsPassed, 0);
+ assert.equal(rec.selfId, null);
+ assert.equal(rec.players[0].roundsPassed, 0);
+ assert.equal(rec.players[0].alive, true);
+ assert.deepEqual(rec.players[0].stats, { kills: 2 });
+ assert.deepEqual(rec.players[0].futureField, { x: 1 }); // unknown fields survive for a newer reader
+ const rewritten = JSON.parse(globalThis.localStorage.getItem('sp.pref.stats'));
+ assert.ok(rewritten);
+ assert.equal(rewritten.v, STATS_VERSION); // self-healed back to storage
+ } finally { delete globalThis.localStorage; }
+ });
+
+ test('broken records are dropped; a NEWER envelope is returned untouched and flagged (never saved over)', () => {
+ globalThis.localStorage = fakeStorage();
+ try {
+ globalThis.localStorage.setItem('sp.pref.stats', JSON.stringify({ v: STATS_VERSION, records: [null, { nope: 1 }, { players: [] }, baseResult()] }));
+ // baseResult is an m.result (players present) → normalizes into a record; the rest are dropped
+ const loaded = loadStats();
+ assert.equal(loaded.records.length, 1);
+
+ const newer = { v: STATS_VERSION + 5, records: [{ players: [], brandNew: true }] };
+ const mig = migrateStats(newer);
+ assert.equal(mig.newer, true);
+ assert.equal(mig.changed, false);
+ assert.deepEqual(mig.stats, { v: STATS_VERSION + 5, records: newer.records });
+ assert.equal(loadStats().newer, false);
+ } finally { delete globalThis.localStorage; }
+ });
+
+ test('the MIGRATIONS hook runs: v → v+1 steps apply in order (dry-run with a temporary step)', () => {
+ const original = MIGRATIONS[0];
+ MIGRATIONS[0] = ({ records }) => ({ v: 1, records: records.map((r) => ({ ...r, migrated: true })) });
+ try {
+ const { stats, changed } = migrateStats({ v: 0, records: [{ players: [{ playerId: 'p1' }] }] });
+ assert.equal(changed, true);
+ assert.equal(stats.records[0].migrated, true);
+ assert.equal(stats.v, STATS_VERSION);
+ } finally {
+ if (original === undefined) delete MIGRATIONS[0]; else MIGRATIONS[0] = original;
+ }
+ });
+
+ test('saveStats shrinks the history when the quota rejects the full write', () => {
+ // budget fits ~35 records but not 60: the full write throws, two quarter-shrinks land at 33
+ const probe = JSON.stringify({ v: STATS_VERSION, records: [baseResult()] });
+ globalThis.localStorage = fakeStorage({ budget: probe.length * 35 });
+ try {
+ let stats = emptyStats();
+ for (let i = 0; i < 60; i++) stats = appendRecord(stats, buildRecord(baseResult({ seed: i }), { myId: 'p1', now: i })).stats;
+ assert.equal(saveStats(stats), true);
+ const back = JSON.parse(globalThis.localStorage.getItem('sp.pref.stats'));
+ assert.ok(back.records.length < 60 && back.records.length > 8); // shrank instead of failing
+ assert.equal(back.records[0].t, 59); // newest kept, oldest dropped
+ } finally { delete globalThis.localStorage; }
+ });
+});
+
+describe('local stats: export / import merge', () => {
+ test('export → import round-trips; merge unions by id and counts added / skipped', () => {
+ globalThis.localStorage = fakeStorage();
+ try {
+ const rec = recordResult(baseResult(), { myId: 'p1', now: 1000 });
+ const exported = exportStats(loadStats());
+ assert.equal(exported.kind, 'local-stats');
+ assert.equal(exported.records.length, 1);
+ assert.equal(exported.records[0].id, rec.id);
+
+ // importing into the SAME store: everything is a duplicate
+ const same = importStats(exported, loadStats());
+ assert.equal(same.added, 0);
+ assert.equal(same.skipped, 1);
+ assert.equal(same.stats.records.length, 1);
+
+ // a second device's store (one shared + one new record) merges by id
+ const theirs = exportStats({ records: [rec, buildRecord(baseResult({ seed: 777 }), { myId: 'p1', now: 5000 })] });
+ const merged = importStats(theirs, loadStats());
+ assert.equal(merged.added, 1);
+ assert.equal(merged.stats.records.length, 2);
+ assert.equal(merged.stats.records[0].t, 1000); // chronological
+ saveStats(merged.stats);
+ assert.equal(loadStats().records.length, 2);
+ } finally { delete globalThis.localStorage; }
+ });
+
+ test('importing a NEWER export refuses with a clear error instead of downgrading', () => {
+ assert.throws(() => importStats({ v: STATS_VERSION + 1, records: [] }, emptyStats()), (e) => e.code === 'stats-newer-version');
+ });
+
+ test('garbage import is an ordinary empty envelope, not a crash in merge', () => {
+ const out = importStats({ hello: 'world' }, emptyStats());
+ assert.equal(out.added, 0);
+ assert.equal(out.stats.records.length, 0);
+ });
+});
+
+describe('local stats: aggregation (what the page renders)', () => {
+ const records = [
+ buildRecord(baseResult(), { myId: 'p1', roomMode: 'solo', now: 1000 }),
+ buildRecord(baseResult({ victory: false, roundsPassed: 8, hiddenReached: false, hiddenCleared: false, difficulty: 'NORMAL', modeId: 'mode_multi_normal', durationMs: 300000, seed: 2,
+ players: [
+ { ...baseResult().players[0], alive: true, victory: false, roundsPassed: 8, title: null, stats: { kills: 10, gold: 200 } },
+ baseResult().players[1],
+ ] }), { myId: 'p1', roomMode: 'coop', now: 2000 }),
+ ];
+ const agg = aggregateStats(records);
+
+ test('totals, wins (per-row victory), rounds and duration', () => {
+ assert.equal(agg.count, 2);
+ assert.equal(agg.wins, 1); // game 2 was a loss
+ assert.equal(agg.rounds.max, 15);
+ assert.equal(agg.rounds.total, 23);
+ assert.equal(agg.duration.totalMs, 952123 + 300000);
+ assert.equal(agg.hidden.reached, 1);
+ assert.equal(agg.hidden.cleared, 1);
+ assert.equal(agg.byMode.solo, 1);
+ assert.equal(agg.byMode.coop, 1);
+ });
+
+ test('per-difficulty rows', () => {
+ assert.deepEqual(agg.byDifficulty.HARD, { games: 1, wins: 1, hiddenCleared: 1 });
+ assert.deepEqual(agg.byDifficulty.NORMAL, { games: 1, wins: 0, hiddenCleared: 0 });
+ });
+
+ test('per-band games / passed and self titles', () => {
+ assert.deepEqual(agg.bands.band_amiya, { games: 2, wins: 1 });
+ assert.equal(agg.titles.comment_1.count, 1); // only the win granted 卫戍之星
+ });
+
+ test('combat sums over the self player only', () => {
+ assert.equal(agg.sums.kills, 41 + 10);
+ assert.equal(agg.sums.gold, 880 + 200);
+ assert.equal(agg.sums.leaks, 2); // game 2's self row has no leaks → skipped, not NaN
+ });
+
+ test('selfRowOf fallbacks: selfId, else first human, else first row; anonymous record → null', () => {
+ assert.equal(selfRowOf(records[0]).playerId, 'p1');
+ const noSelf = normalizeRecord({ players: [{ playerId: 'a', isBot: true }, { playerId: 'b', isBot: false }] });
+ assert.equal(selfRowOf(noSelf).playerId, 'b');
+ assert.equal(selfRowOf({ players: [] }), null);
+ });
+});
+
+describe('local stats: recordToResult (最近对局行 → 结算页回看)', () => {
+ test('record → m.result payload → normalizeResult renders the same settlement the live push did', () => {
+ const rec = buildRecord(baseResult(), { myId: 'p1', roomMode: 'solo', now: 1000 });
+ const res = recordToResult(rec);
+ assert.ok(res);
+ const view = normalizeResult(res, null);
+ assert.equal(view.victory, true);
+ assert.equal(view.difficulty, 'HARD');
+ assert.equal(view.modeId, 'mode_single_hard');
+ assert.equal(view.roundsPassed, 15);
+ assert.equal(view.hiddenReached, true);
+ assert.equal(view.hiddenCleared, true);
+ assert.equal(view.durationMs, 952123);
+ assert.equal(view.players.length, 2);
+ const self = view.players.find((p) => p.playerId === 'p1');
+ assert.equal(self.bandId, 'band_amiya');
+ assert.equal(self.title.id, 'comment_1');
+ assert.equal(self.stats.kills, 41);
+ assert.equal(self.stats.bossDamage, 5000.5);
+ assert.equal(self.lineup[0].id, 'char_290_vigna');
+ assert.equal(self.lineup[0].golden, false);
+ const mate = view.players.find((p) => p.playerId === 'p2');
+ assert.equal(mate.alive, false);
+ assert.equal(mate.roundsPassed, 9);
+ });
+
+ test('a null lastRound is passed through as absent (normalizeResult defaults it); garbage → null', () => {
+ const rec = buildRecord(baseResult({ lastRound: undefined }), { myId: 'p1', now: 1 });
+ assert.equal('lastRound' in recordToResult(rec), false);
+ assert.equal(recordToResult(null), null);
+ assert.equal(recordToResult({ players: [] }), null);
+ });
+});