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

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

+ `; +} + +/** 导出 / 导入 / 清空, with the two-step clear confirm. */ +function DataActions({ stats, onReload }) { + const fileRef = useRef(null); + const [confirming, setConfirming] = useState(false); + useEffect(() => { + if (!confirming) return undefined; + const t = setTimeout(() => setConfirming(false), 3000); + return () => clearTimeout(t); + }, [confirming]); + + const doExport = () => { + try { + const payload = exportStats(stats); + const blob = new Blob([JSON.stringify(payload, null, 1)], { type: 'application/json' }); + const a = document.createElement('a'); + const d = new Date(); + const p = (n) => String(n).padStart(2, '0'); + a.href = URL.createObjectURL(blob); + a.download = `stronghold-stats-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}.json`; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 4000); + toast(t('已导出 {n} 条对局记录', { n: payload.records.length }), 'success'); + } catch (err) { + toastError(err); + } + }; + + const doFile = async (e) => { + const file = e.currentTarget.files?.[0]; + e.currentTarget.value = ''; // allow re-picking the same file + if (!file) return; + try { + const raw = JSON.parse(await file.text()); + const { stats: merged, added, skipped } = importStats(raw, stats); + saveStats(merged); + onReload(); + toast(added ? t('已导入 {added} 条记录{tail}', { added, tail: skipped ? t('(跳过重复 {skipped} 条)', { skipped }) : '' }) : t('没有新记录(全部与现有数据重复)'), 'success'); + } catch (err) { + toastError(err.code === 'stats-newer-version' ? err : new Error(t('导入失败:不是有效的统计导出文件'))); + } + }; + + const doClear = () => { + if (!confirming) { setConfirming(true); return; } + saveStats(emptyStats()); + setConfirming(false); + onReload(); + toast(t('已清空本机统计数据'), 'success'); + }; + + return html`
+ <${Button} size="sm" icon="exit" onClick=${doExport}>${t('导出')} + <${Button} size="sm" icon="plus" onClick=${() => fileRef.current?.click()}>${t('导入')} + + <${Button} size="sm" variant="danger" icon=${confirming ? 'warn' : 'close'} class=${cx(confirming && 'stats-actions__confirm')} + onClick=${doClear}>${confirming ? t('确认清空?') : t('清空')} +
`; +} + +/** + * Global stats viewer host — mounted once by main.js. Waits only on the files the page reads (config for the + * title list, bands for names/icons, assets for the picture URLs), not all of useGameData's files. + */ +export function StatsHost() { + const { open } = useStore((s) => s, Object.is, statsStore); + const replaying = useStore((s) => !!s.match.result); // a replayed settlement replaces the whole panel + const ready = useData('config', 'bands', 'assets'); + const gd = useGameData(); + const [stats, setStats] = useState(() => emptyStats()); + const boxRef = useRef(null); + const reload = () => setStats(loadStats()); + + useEffect(() => { + if (!open) return undefined; + setStats(loadStats()); + const onKey = (e) => { + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); closeStats(); } + }; + window.addEventListener('keydown', onKey, true); + const t = setTimeout(() => boxRef.current?.focus?.(), 30); + return () => { window.removeEventListener('keydown', onKey, true); clearTimeout(t); }; + }, [open]); + + if (!open) return null; + if (replaying) { + // the record re-viewed as its settlement (openRecordResult): the real , full-bleed; + // its 返回大厅 empties match.result and lands back on the list + return html`
<${ResultScreen} />
`; + } + const agg = aggregateStats(stats.records); + return html``; +} 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('玩法说明')} /> 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); + }); +}); From 51e0e60b6e6feee18aded4726b3e70a285f9822b Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 20:36:41 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feedback7=20stats:=20a=20match=20left=20rig?= =?UTF-8?q?ht=20away=20stays=20in=20the=20history=20but=20does=20not=20cou?= =?UTF-8?q?nt;=20bounded=20storage,=20corrupt-data=20handling,=20the=20pag?= =?UTF-8?q?e=20reworked=20as=20=E5=B9=B2=E5=91=98=E8=B0=83=E9=85=8D-style?= =?UTF-8?q?=20tabs,=20the=20settlement=20re-view=20without=20the=20app=20s?= =?UTF-8?q?tore=20(PR=20#323=20by=20@2321Robin,=20owner's=20decision=20of?= =?UTF-8?q?=202026-10-08);=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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 ; 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. --- README.md | 1 + docs/PLAYING.md | 15 + public/css/screens/stats.css | 270 +++++++++------ public/css/screens/title.css | 3 + public/i18n/en.json | 37 +- public/i18n/ja.json | 31 +- public/i18n/ko.json | 31 +- public/i18n/zh-TW.json | 31 +- public/js/main.js | 5 +- public/js/screens/lobby.js | 2 +- public/js/screens/result.js | 17 +- public/js/screens/room.js | 2 +- public/js/screens/stats.js | 462 +++++++++++++------------ public/js/screens/title.js | 6 +- public/js/ui/components.js | 2 + public/js/ui/matchChrome.js | 12 +- public/js/ui/stats.js | 499 +++++++++++++++++++++------ test/fixtures/stats-results.json | 1 + test/ui/stats.e2e.test.js | 348 +++++++++++++++++++ test/ui/stats.test.js | 563 ++++++++++++++++++++++++++----- 20 files changed, 1808 insertions(+), 530 deletions(-) create mode 100644 test/fixtures/stats-results.json create mode 100644 test/ui/stats.e2e.test.js diff --git a/README.md b/README.md index d88a2217..8b341b60 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ npm start # 启动服务器:http://localhost:3000 | 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 | | 表情 | 左下角「交流」,左右滑动(或方向键)换主题,冷却 1 秒 | | 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」;不参战的朋友可以在大厅输入同盟密钥点「观战」(每个同盟最多 2 名观战者,本作新增) | +| 统计(本机) | 标题画面右上角、大厅、等待室的「统计」:胜率、策略通过率、称号、战斗累计和对局记录(点一行回看结算页);只存在本机浏览器里,可以导出 / 导入,没清完第一个回合就退出的对局不计入统计([玩法指南 §12](docs/PLAYING.md#12-统计本机)) | 完整的规则、数值和小技巧见 **[docs/PLAYING.md](docs/PLAYING.md)**(游戏内左下角也有「玩法说明」)。 diff --git a/docs/PLAYING.md b/docs/PLAYING.md index f357981a..c64f68ac 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -170,3 +170,18 @@ 刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。 **修改快捷键**:标题画面右上角或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。 + +## 12. 统计(本机) + +标题画面右上角、大厅和等待室顶栏的「统计」打开**统计数据**页:这台设备上你参与过的对局的记录。记录只存在这个浏览器里(`localStorage`),不会上传到服务器,服务器也不保存任何对局历史;换浏览器、换设备或清除网站数据后,记录不会跟过去。要搬家,用页面顶部的「导出」(一个 JSON 文件)和另一台设备上的「导入」(按记录合并,重复的跳过)。 + +| 页签 | 内容 | +|---|---| +| 总览 | 总局数、胜场、胜率、到达 / 通关隐秘核心的次数、最远回合、累计时长;各难度一行;六种结算称号各拿过几次 | +| 策略 | 每个策略用了几局、通过几局(你自己撑到最后、队伍获胜才算通过)和通过率 | +| 战斗累计 | 击倒敌人、领袖伤害、造成伤害等结算页上的数字,在打到结算的对局里累加 | +| 对局记录 | 每局一行,最新的在上面(一次显示 50 行,「显示更多」继续);点一行回看那一局的结算页(最新的 30 局可以回看,更早的只留汇总,点不开) | + +**什么算一局**:打到结算页的对局,以及中途退出(「放弃模拟」)前至少清完 1 个回合的对局,计入以上所有统计——和官方一样,中途退出按淘汰处理,通过的回合数就是退出时已经通过的回合。**进去就退、没清完第一个回合就退的对局不计入统计**(不算一局,不影响胜率和策略通过率),但会留在「对局记录」里,标着「中途退出」和「不计入统计」。已经被淘汰后再退出的对局算(你输了这一局)。下面这些不会记录:观战;被服务器结束的对局(服务器重启、同盟解散);关掉页面后没有再回来的对局。 + +**存储与隐私**:最多保留最近 1000 局,所有记录加起来不超过约 0.8 MB(超出时最旧的先丢)。只有最新的 30 局保留完整的阵容、盟约和队友信息,更早的压缩成你自己的一行汇总。同盟对局的记录里有**队友的代号**,它们保存在本机,也会出现在你导出的文件里;不会发给任何人。想删掉就点「清空」(会先确认),或清除这个网站的数据。读不出来的存档(损坏的数据)会另存为备份并重新开始记录,不会让页面出错;由更新版本导出的文件会被拒绝导入,也不会被旧版本覆盖。 diff --git a/public/css/screens/stats.css b/public/css/screens/stats.css index 4727f842..7ddda452 100644 --- a/public/css/screens/stats.css +++ b/public/css/screens/stats.css @@ -1,119 +1,193 @@ /* ========================================================================================== - 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. + screens/stats.css — 统计数据 (js/screens/stats.js): the local match record as a full-screen overlay in the grammar of + 干员调配 (screens/loadout.css) — a top bar with the back button, four tabs (总览 / 策略 / 战斗累计 / 对局记录) and + 导出 / 导入 / 清空, a note line, a scrolling body of number cards, tables, the six 评语 and the history list; a stored + match re-viewed as its settlement fills the overlay (.st--replay). + Sizes are design px ÷ 100 (theme.css root scale); text that must stay readable on phones uses max(.xxrem, N px) + (the root scale is clamped at 40 px there, so a bare .15rem would be 6 px). Ported from PR #323 by @2321Robin. ========================================================================================== */ -.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; +.st { + position: fixed; inset: 0; z-index: 55; /* like .lo: above every screen (1) and the in-match banners (40), below the connection + banner (60), modals / confirm dialogs (80), toasts and tooltips */ + display: flex; flex-direction: column; + padding: var(--sa-t, 0px) var(--sa-r, 0px) var(--sa-b, 0px) var(--sa-l, 0px); + background: var(--bg-0); + color: var(--text-hi); outline: none; - animation: stats-pop 220ms var(--ease-out); + animation: st-in 200ms var(--ease-out) backwards; + overscroll-behavior: contain; } -@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); +@keyframes st-in { from { opacity: 0; } } +.st__bg { + position: absolute; inset: 0; pointer-events: none; + background: + radial-gradient(70% 90% at 18% 10%, rgba(78, 216, 175, .07), transparent 60%), + radial-gradient(60% 70% at 100% 100%, rgba(246, 163, 41, .05), transparent 60%), + linear-gradient(rgba(78, 216, 175, .035) 1px, transparent 1px) 0 0 / .6rem .6rem, + linear-gradient(90deg, rgba(78, 216, 175, .035) 1px, transparent 1px) 0 0 / .6rem .6rem, + linear-gradient(180deg, #0f1312, #090b0a); } -.stats__close .icon { width: .26rem; height: .26rem; } -.stats__close:hover { color: var(--text-hi); border-color: var(--red-premium); background: var(--red-a20); } +.st-top, .st-note, .st-body { position: relative; } +/* the app screens behind the overlay do not need to animate */ +.sp-stats-open .app-root > .screen { visibility: hidden; } +/* a stored match re-viewed as its settlement (the result screen fills the overlay) */ +.st--replay { background: var(--bg-0); } -/* 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); +/* ---- top bar ----------------------------------------------------------------------------------------------------- */ +.st-top { + flex: none; display: grid; grid-template-columns: minmax(max-content, 1fr) minmax(0, auto) minmax(max-content, 1fr); align-items: center; gap: .2rem; + height: max(1rem, 48px); padding: 0 .36rem; + background: linear-gradient(180deg, rgba(8, 10, 9, .95), rgba(10, 13, 12, .6)); + border-bottom: 1px solid var(--line); } +.st-top::after { + content: ''; position: absolute; left: .36rem; right: .36rem; bottom: -1px; height: 1px; + background: linear-gradient(90deg, var(--mint-700), transparent 26%, transparent 74%, var(--mint-700)); +} +.st-top__left { justify-self: start; display: flex; align-items: center; gap: .14rem; } +.st-top__center { display: flex; flex-direction: column; align-items: center; gap: .02rem; min-width: 0; } +.st-top__right { justify-self: end; display: flex; align-items: center; gap: .16rem; min-width: 0; } +.st-tabs { display: flex; align-items: stretch; gap: .04rem; max-width: 100%; overflow-x: auto; scrollbar-width: none; } +.st-tab { + display: inline-flex; align-items: center; padding: .02rem .16rem; font: inherit; font-size: max(.24rem, 13px); font-weight: 900; + letter-spacing: .12em; line-height: 1.2; color: var(--text-lo); background: transparent; border: 0; border-bottom: 2px solid transparent; cursor: pointer; + touch-action: manipulation; white-space: nowrap; +} +.st-tab:hover { color: var(--text-hi); } +.st-tab.is-on { color: var(--text-hi); border-bottom-color: var(--mint-500); } + +/* the note lines under the bar: where the data lives, what went wrong with the stored value */ +.st-note { + flex: none; display: flex; align-items: center; gap: .08rem; margin: 0; padding: .07rem .36rem; + font-size: max(.15rem, 10px); color: var(--text-lo); + background: linear-gradient(90deg, rgba(78, 216, 175, .08), transparent 70%); + border-bottom: 1px solid rgba(47, 58, 53, .6); +} +.st-note .icon { width: max(.16rem, 11px); height: max(.16rem, 11px); color: var(--mint-500); flex: none; } +.st-note.is-warn { color: var(--amber); background: linear-gradient(90deg, rgba(246, 163, 41, .12), transparent 70%); } +.st-note.is-warn .icon { color: var(--amber); } + +/* ---- body ------------------------------------------------------------------------------------------------------------ */ +.st-body { + flex: 1 1 auto; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; + padding: .2rem .36rem .32rem; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; +} +.st-body > * { max-width: max(12.4rem, 760px); margin-left: auto; margin-right: auto; } +.st-loading { flex: 1; display: grid; place-items: center; min-height: 2rem; } +.st-empty { margin: .6rem auto; text-align: center; font-size: max(.2rem, 13px); color: var(--text-lo); letter-spacing: .06em; } + +.st-sec__h { display: flex; align-items: baseline; gap: .12rem; margin: 0 0 .1rem; font-size: max(.22rem, 14px); font-weight: 800; letter-spacing: .1em; color: var(--text-hi); } +.st-sub { margin: .22rem 0 .08rem; font-size: max(.17rem, 12px); font-weight: 700; letter-spacing: .08em; color: var(--text-md); } /* 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; +.st-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(max(1.5rem, 96px), 1fr)); gap: .08rem; } +.st-card { + display: flex; flex-direction: column; gap: .02rem; min-width: 0; padding: .08rem .12rem .07rem; 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; } +.st-card--mint { border-color: var(--mint-800); background: var(--mint-a10); } +.st-card--gold { border-color: rgba(246, 163, 41, .45); background: rgba(246, 163, 41, .07); } +.st-card__num { font-size: max(.3rem, 17px); font-weight: 900; line-height: 1.15; color: var(--text-hi); overflow-wrap: anywhere; } +.st-card--mint .st-card__num { color: var(--mint-400); } +.st-card--gold .st-card__num { color: var(--amber); } +.st-card__label { font-size: max(.14rem, 10px); color: var(--text-md); letter-spacing: .06em; } +.st-split { display: flex; gap: .26rem; margin: .1rem 0 .14rem; font-size: max(.15rem, 11px); color: var(--text-md); } +.st-split b { margin-left: .06rem; font-size: max(.18rem, 12px); color: var(--text-hi); } -/* 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; +/* tables (难度 / 策略 / 对局记录) */ +.st-tablewrap { overflow-x: auto; } +.st-table { width: 100%; border-collapse: collapse; font-size: max(.17rem, 11px); } +.st-table th { + padding: .05rem .1rem; text-align: left; font-size: max(.14rem, 10px); 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; } +.st-table td { padding: .06rem .1rem; color: var(--text-md); border-bottom: 1px solid var(--line); vertical-align: middle; } +/* the per-difficulty and per-strategy tables stay narrow enough to read across; the history takes the width */ +.st-table:not(.st-table--history) { max-width: max(9.4rem, 560px); } +.st-table th.num, .st-table td.num { text-align: right; } +.st-table tbody tr:hover td { background: rgba(78, 216, 175, .05); } +.st-table td.st-diff { color: var(--d-color, var(--text-md)); font-weight: 700; white-space: nowrap; } +.st-band { display: inline-flex; align-items: center; gap: .06rem; color: var(--text-hi); white-space: nowrap; vertical-align: middle; } +.st-band .bandicon { flex: none; } +.st-t { font-family: var(--font-num); font-size: max(.15rem, 10px); color: var(--text-lo); white-space: nowrap; } +.st-rate { white-space: nowrap; } +.st-rate b { color: var(--text-hi); font-weight: 700; } +.st-bar { display: inline-block; width: max(.8rem, 48px); height: max(.07rem, 4px); margin-right: .1rem; vertical-align: middle; background: var(--line); } +.st-bar i { display: block; height: 100%; background: var(--mint-500); } -/* 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); +/* 对局记录: result colours, the 不计入统计 chip, clickable rows */ +.st-res { white-space: nowrap; } +.t-win { color: var(--mint-400); font-weight: 800; } +.t-lose { color: var(--red-premium); font-weight: 800; } +.t-quit { color: var(--amber); font-weight: 800; } +.st-chip { + display: inline-block; margin-left: .08rem; padding: 0 .06rem; font-style: normal; font-size: max(.12rem, 9px); letter-spacing: .04em; + color: var(--text-lo); border: 1px solid var(--line-2); white-space: nowrap; } -.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; } +.st-table tr.is-excluded td { opacity: .6; } +.st-table tr.is-clickable { cursor: pointer; } +.st-table tr.is-clickable:hover td, .st-table tr.is-clickable:focus-visible td { background: rgba(78, 216, 175, .1); color: var(--text-hi); } +.st-table tr.is-clickable:focus-visible { outline: 2px solid var(--mint-400); outline-offset: -2px; } +.st-go { width: .24rem; color: var(--text-lo); } +.st-go .icon { width: max(.18rem, 12px); height: max(.18rem, 12px); } +.st-table tr.is-clickable:hover .st-go { color: var(--mint-400); } +.st-more { display: flex; justify-content: center; margin: .14rem 0 0; } +.st-hint { margin: .1rem 0 0; font-size: max(.13rem, 10px); line-height: 1.6; color: var(--text-lo); letter-spacing: .04em; } -/* a replayed settlement replaces the whole panel: the real fills the overlay */ -.stats__replay { position: absolute; inset: 0; } +/* the six 评语 with their counts */ +.st-titles { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(1.9rem, 120px), 1fr)); gap: .08rem; } +.st-title { display: flex; align-items: center; gap: .08rem; min-width: 0; padding: .06rem .1rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line); } +.st-title.is-none { opacity: .45; } +.st-title__icon { width: max(.38rem, 24px); height: max(.38rem, 24px); flex: none; object-fit: contain; } +.st-title__text { display: flex; flex-direction: column; min-width: 0; } +.st-title__text span { font-size: max(.15rem, 10px); color: var(--text-md); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.st-title__text b { font-size: max(.22rem, 14px); font-weight: 900; color: var(--amber); line-height: 1.1; } -/* 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; } +/* what counts (the rule of ui/stats.js countsTowardStats) */ +.st-rule { display: flex; flex-wrap: wrap; align-items: center; gap: .06rem; margin: .24rem 0 0; font-size: max(.14rem, 10px); line-height: 1.6; color: var(--text-lo); } +.st-rule .icon { width: max(.16rem, 11px); height: max(.16rem, 11px); flex: none; color: var(--mint-500); } +.st-rule b { color: var(--amber); font-weight: 700; } -@media (max-height: 560px) { - .stats__box { padding: .08rem .12rem; max-height: calc(100vh - .3rem); } - .stats__title { font-size: .22rem; } - .stats__note { display: none; } +/* 战斗累计 */ +.st-sums { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(2.4rem, 150px), 1fr)); gap: .06rem; } +.st-sum { display: flex; align-items: baseline; justify-content: space-between; gap: .08rem; padding: .08rem .12rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line); } +.st-sum span { font-size: max(.15rem, 10px); color: var(--text-md); } +.st-sum b { font-size: max(.22rem, 14px); font-weight: 800; color: var(--text-hi); } + +/* the shared Button scales with the root (40 px/rem on phones ⇒ 6–7 px text, 14–20 px tall): the overlay's own controls keep a + readable size and a tappable height */ +.st .st-back { height: max(.5rem, 32px); padding: 0 max(.2rem, 10px) 0 max(.12rem, 6px); font-size: max(.18rem, 12px); } +.st .st-top__right .btn, .st .st-more .btn { height: max(.36rem, 28px); padding: 0 max(.16rem, 8px); font-size: max(.15rem, 11px); } + +/* the entry buttons of the lobby / room top bars: on the narrowest phones (≤ 700 px) the icon alone — the room's top bar has + no room for another label beside 玩法说明 (its title ran under the label at 640 px); the name stays aria-label / title */ +@media (max-width: 700px) { + .stats-entry .btn__icon + .btn__label { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; + } } + +/* ---- narrow / short viewports ------------------------------------------------------------------------------------------ */ +@media (max-width: 1200px) { + .st-tab { padding: .02rem .08rem; letter-spacing: .04em; } +} +/* phones in landscape (up to 932 px wide): 返回 shows its icon only (the text stays its name) */ +@media (max-width: 1000px) { + .st-top .btn__icon + .btn__label { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; + } +} +@media (max-width: 760px) { + .st-top { padding: 0 .16rem; gap: .1rem; } + .st-top::after { left: .16rem; right: .16rem; } + .st-note { padding: .07rem .16rem; } + .st-body { padding: .12rem .16rem .2rem; } +} +@media (max-height: 520px) { + .st-note { display: none; } + .st-body { padding-top: .12rem; } + .st-split { margin: .06rem 0 .08rem; } +} +.sp-coarse .st-tab, .sp-coarse .st-table tr.is-clickable td { min-height: 30px; } +@media (prefers-reduced-motion: reduce) { .st { animation: none; } } diff --git a/public/css/screens/title.css b/public/css/screens/title.css index ed29a4ab..7afeaada 100644 --- a/public/css/screens/title.css +++ b/public/css/screens/title.css @@ -243,6 +243,9 @@ .title-invite .icon { width: .2rem; height: .2rem; } .title-invite b { font-size: .2rem; letter-spacing: .2em; color: #ffd08a; } .title-conn { display: flex; align-items: center; justify-content: center; gap: .1rem; min-height: .32rem; font-size: .14rem; color: var(--text-lo); } +/* 统计 (the local stats page) beside the language menu in the top-right corner */ +.title-corner__tools { display: flex; align-items: center; justify-content: flex-end; gap: .1rem; margin-bottom: .1rem; } +.title-corner__tools .title-lang { margin-bottom: 0; } /* language menu (中文 | English | …, ui/lang.js) in the top-right corner; more than four languages make it a list */ .title-lang { display: inline-flex; margin-bottom: .1rem; } .title-lang button { min-width: .74rem; height: .3rem; font-size: .14rem; cursor: pointer; } diff --git a/public/i18n/en.json b/public/i18n/en.json index 1dc85613..8b2323c8 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1058,7 +1058,6 @@ "开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers", "统计数据": "Statistics", "统计": "Stats", - "对局进行中,暂不能回看历史结算": "A match is running — replays are unavailable right now", "总览": "Overview", "总局数": "Games", "胜场": "Wins", @@ -1076,7 +1075,6 @@ "通过率": "Clear rate", "称号": "Titles", "战斗累计": "Combat totals", - "最近对局": "Recent matches", "时间": "Time", "模式": "Mode", "结果": "Result", @@ -1085,16 +1083,39 @@ "同盟": "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)", + "已导出 {n} 条对局记录": "Exported {n} match {n|record|records}", + "已导入 {added} 条记录{tail}": "Imported {added} {added|record|records}{tail}", + "(跳过重复 {skipped} 条)": " ({skipped} {skipped|duplicate|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" + "未知": "Unknown", + "对局记录": "Match history", + "{n} 小时": "{n} h", + "计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "Counted: matches played to the settlement, and matches given up after clearing at least 1 round. Matches given up earlier stay in the match history only.", + "另有 {n} 局未计入统计": "{n} more {n|match|matches} not counted", + "没有清完第一个回合就退出,不计入统计": "Left before clearing the first round — not counted", + "中途退出的对局没有结算可看": "A match given up has no settlement to view", + "较早的对局只保留汇总,不能回看结算": "Older matches keep only their summary — no settlement to view", + "中途退出": "Left early", + "不计入统计": "Not counted", + "显示更多(还有 {n} 局)": "Show more ({n} left)", + "点击一行,回看该局结算;中途退出的对局只留记录。": "Click a row to re-view that match's settlement; a match given up only leaves its entry.", + "最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "Only the latest {max} matches are kept, and only the newest {full} can show their settlement again.", + "返回统计": "Back to Stats", + "文件过大,无法导入": "File too large to import", + "无法写入本机存储,可能已满": "Could not write to local storage — it may be full", + "({n} 条无法读取)": " ({n} unreadable)", + "导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "Import failed: this file was exported by a newer client — upgrade the client first", + "清空统计数据": "Clear Statistics", + "将删除本机保存的 {n} 条对局记录,无法恢复。": "This deletes the {n} match {n|record|records} stored on this device. It cannot be undone.", + "导出为文件(可在别的设备上导入)": "Export to a file (import it on another device)", + "从导出的文件导入(与现有记录合并)": "Import from an exported file (merged with the current records)", + "清空本机的统计数据": "Clear the statistics stored on this device", + "本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "Local data: kept only in this browser, never uploaded to the server. Teammates' names from Team Simulations are stored here too (and appear in an export file).", + "本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "The stats stored on this device could not be read. They were set aside as a backup and recording starts afresh.", + "有 {n} 条记录已损坏,已忽略。": "{n} damaged {n|record was|records were} ignored." } diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 8c98d4b6..027083c4 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -1064,7 +1064,6 @@ "当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。", "统计数据": "統計データ", "统计": "統計", - "对局进行中,暂不能回看历史结算": "対戦中のため、過去の結果は表示できません", "总览": "概要", "总局数": "対戦数", "胜场": "勝利数", @@ -1082,7 +1081,6 @@ "通过率": "クリア率", "称号": "称号", "战斗累计": "戦闘累計", - "最近对局": "最近の対戦", "时间": "日時", "模式": "モード", "结果": "結果", @@ -1091,16 +1089,39 @@ "同盟": "連合", "胜": "勝", "负": "敗", - "点击任意一行,回看该局结算。": "行をクリックすると、その対戦の結果をもう一度見られます。", "已导出 {n} 条对局记录": "対戦記録を{n}件エクスポートしました", "已导入 {added} 条记录{tail}": "{added}件の記録をインポートしました{tail}", "(跳过重复 {skipped} 条)": "(重複{skipped}件をスキップ)", "没有新记录(全部与现有数据重复)": "新しい記録はありません(すべて既存のデータと重複しています)", "导入失败:不是有效的统计导出文件": "インポートに失敗しました:有効な統計のエクスポートファイルではありません", "已清空本机统计数据": "この端末の統計データを消去しました", - "确认清空?": "本当に消去しますか?", "本机数据 · 仅记录本浏览器中你参与的对局": "この端末のデータ · このブラウザで参加した対戦のみ記録されます", "检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "より新しいバージョンの統計データが見つかりました。上書きを避けるため、クライアントを更新してから表示してください。", "还没有记录 —— 完成一局对局后,这里会开始积累。": "記録はまだありません — 対戦を1つ終えると、ここに蓄積されていきます。", - "未知": "不明" + "未知": "不明", + "对局记录": "対戦履歴", + "{n} 小时": "{n}時間", + "计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "集計対象:結果画面まで進んだ対戦と、1ラウンド以上クリアしてから途中退出した対戦。それより早く退出した対戦は対戦履歴にのみ残ります。", + "另有 {n} 局未计入统计": "他に{n}件の対戦が集計対象外です", + "没有清完第一个回合就退出,不计入统计": "最初のラウンドをクリアする前に退出したため、集計対象外です", + "中途退出的对局没有结算可看": "途中退出した対戦には表示できる結果がありません", + "较早的对局只保留汇总,不能回看结算": "古い対戦は要約のみ保存されているため、結果は表示できません", + "中途退出": "途中退出", + "不计入统计": "集計対象外", + "显示更多(还有 {n} 局)": "さらに表示(残り{n}件)", + "点击一行,回看该局结算;中途退出的对局只留记录。": "行をクリックすると、その対戦の結果をもう一度見られます。途中退出した対戦は記録のみ残ります。", + "最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "保存されるのは直近{max}件のみで、結果を再表示できるのは最新の{full}件だけです。", + "返回统计": "統計に戻る", + "文件过大,无法导入": "ファイルが大きすぎるためインポートできません", + "无法写入本机存储,可能已满": "ローカルストレージに書き込めません。容量がいっぱいの可能性があります", + "({n} 条无法读取)": "(読み取れない記録{n}件)", + "导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "インポートに失敗しました:このファイルはより新しいバージョンのクライアントでエクスポートされています。先にクライアントを更新してください", + "清空统计数据": "統計データを消去", + "将删除本机保存的 {n} 条对局记录,无法恢复。": "この端末に保存されている対戦記録{n}件を削除します。元に戻せません。", + "导出为文件(可在别的设备上导入)": "ファイルにエクスポート(別の端末でインポートできます)", + "从导出的文件导入(与现有记录合并)": "エクスポートしたファイルからインポート(既存の記録と統合)", + "清空本机的统计数据": "この端末の統計データを消去", + "本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "この端末のデータ:このブラウザにのみ保存され、サーバーには送信されません。連合演算の仲間の名前もこの端末に保存されます(エクスポートファイルにも含まれます)。", + "本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "この端末に保存された統計データを読み取れませんでした。バックアップとして退避し、記録を最初からやり直します。", + "有 {n} 条记录已损坏,已忽略。": "破損した記録{n}件を無視しました。" } diff --git a/public/i18n/ko.json b/public/i18n/ko.json index 0bf371b4..6c1be692 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -1060,7 +1060,6 @@ "当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "현재 언어의 인터페이스 텍스트는 기계 번역이므로 정확하지 않을 수 있습니다. GitHub에서 수정 제안을 환영합니다.", "统计数据": "통계", "统计": "통계", - "对局进行中,暂不能回看历史结算": "시뮬레이션이 진행 중이라 지난 정산을 다시 볼 수 없습니다", "总览": "개요", "总局数": "총 판수", "胜场": "승리", @@ -1078,7 +1077,6 @@ "通过率": "클리어율", "称号": "평가", "战斗累计": "전투 누적", - "最近对局": "최근 판", "时间": "시간", "模式": "모드", "结果": "결과", @@ -1087,16 +1085,39 @@ "同盟": "동맹", "胜": "승", "负": "패", - "点击任意一行,回看该局结算。": "행을 누르면 해당 판의 정산을 다시 볼 수 있습니다.", "已导出 {n} 条对局记录": "기록 {n}건을 내보냈습니다", "已导入 {added} 条记录{tail}": "기록 {added}건을 가져왔습니다{tail}", "(跳过重复 {skipped} 条)": " (중복 {skipped}건 건너뜀)", "没有新记录(全部与现有数据重复)": "새 기록이 없습니다(모두 기존 데이터와 중복)", "导入失败:不是有效的统计导出文件": "가져오기 실패: 올바른 통계 내보내기 파일이 아닙니다", "已清空本机统计数据": "이 기기의 통계 데이터를 삭제했습니다", - "确认清空?": "정말 비우시겠습니까?", "本机数据 · 仅记录本浏览器中你参与的对局": "이 기기의 데이터 · 이 브라우저에서 참여한 판만 기록됩니다", "检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "더 최신 버전의 통계 데이터가 감지되었습니다. 데이터를 덮어쓰지 않도록 클라이언트를 업데이트한 뒤 확인하세요.", "还没有记录 —— 完成一局对局后,这里会开始积累。": "아직 기록이 없습니다 — 한 판을 마치면 여기에 쌓이기 시작합니다.", - "未知": "알 수 없음" + "未知": "알 수 없음", + "对局记录": "판 기록", + "{n} 小时": "{n}시간", + "计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "집계 대상: 정산까지 진행한 판, 그리고 1라운드 이상 클리어한 뒤 중도 포기한 판. 그보다 일찍 나간 판은 판 기록에만 남습니다.", + "另有 {n} 局未计入统计": "집계에 포함되지 않은 판이 {n}건 더 있습니다", + "没有清完第一个回合就退出,不计入统计": "첫 라운드를 클리어하기 전에 나가서 집계에 포함되지 않습니다", + "中途退出的对局没有结算可看": "중도 포기한 판은 볼 수 있는 정산이 없습니다", + "较早的对局只保留汇总,不能回看结算": "오래된 판은 요약만 저장되어 정산을 볼 수 없습니다", + "中途退出": "중도 퇴장", + "不计入统计": "집계 제외", + "显示更多(还有 {n} 局)": "더 보기 (남은 {n}건)", + "点击一行,回看该局结算;中途退出的对局只留记录。": "행을 누르면 해당 판의 정산을 다시 볼 수 있습니다. 중도 포기한 판은 기록만 남습니다.", + "最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "최근 {max}판까지만 보관하며, 정산을 다시 볼 수 있는 것은 최신 {full}판뿐입니다.", + "返回统计": "통계로 돌아가기", + "文件过大,无法导入": "파일이 너무 커서 가져올 수 없습니다", + "无法写入本机存储,可能已满": "로컬 저장소에 쓰지 못했습니다. 공간이 가득 찼을 수 있습니다", + "({n} 条无法读取)": " (읽을 수 없는 기록 {n}건)", + "导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "가져오기 실패: 더 최신 버전 클라이언트에서 내보낸 파일입니다. 먼저 클라이언트를 업데이트하세요", + "清空统计数据": "통계 데이터 비우기", + "将删除本机保存的 {n} 条对局记录,无法恢复。": "이 기기에 저장된 기록 {n}건을 삭제합니다. 되돌릴 수 없습니다.", + "导出为文件(可在别的设备上导入)": "파일로 내보내기 (다른 기기에서 가져올 수 있습니다)", + "从导出的文件导入(与现有记录合并)": "내보낸 파일에서 가져오기 (기존 기록과 병합)", + "清空本机的统计数据": "이 기기의 통계 데이터 비우기", + "本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "이 기기의 데이터: 이 브라우저에만 저장되며 서버로 전송되지 않습니다. 동맹 시뮬레이션에서 함께한 동료의 이름도 이 기기에 저장됩니다(내보낸 파일에도 포함).", + "本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "이 기기에 저장된 통계 데이터를 읽을 수 없습니다. 백업으로 따로 보관했으며 지금부터 새로 기록합니다.", + "有 {n} 条记录已损坏,已忽略。": "손상된 기록 {n}건을 무시했습니다." } diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index 07ec5611..006fe3be 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -1062,7 +1062,6 @@ "生命值减少 −{n}": "生命值減少 −{n}", "统计数据": "統計資料", "统计": "統計", - "对局进行中,暂不能回看历史结算": "對局進行中,暫時無法回看歷史結算", "总览": "總覽", "总局数": "總局數", "胜场": "勝場", @@ -1080,7 +1079,6 @@ "通过率": "通過率", "称号": "稱號", "战斗累计": "戰鬥累計", - "最近对局": "最近對局", "时间": "時間", "模式": "模式", "结果": "結果", @@ -1089,16 +1087,39 @@ "同盟": "同盟", "胜": "勝", "负": "負", - "点击任意一行,回看该局结算。": "點擊任一行,回看該局結算。", "已导出 {n} 条对局记录": "已匯出 {n} 筆對局記錄", "已导入 {added} 条记录{tail}": "已匯入 {added} 筆記錄{tail}", "(跳过重复 {skipped} 条)": "(略過重複 {skipped} 筆)", "没有新记录(全部与现有数据重复)": "沒有新記錄(全部與現有資料重複)", "导入失败:不是有效的统计导出文件": "匯入失敗:不是有效的統計匯出檔案", "已清空本机统计数据": "已清空本機統計資料", - "确认清空?": "確認清空?", "本机数据 · 仅记录本浏览器中你参与的对局": "本機資料 · 僅記錄本瀏覽器中你參與的對局", "检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "偵測到更新版本的統計資料,請先升級用戶端再查看,以免覆蓋資料。", "还没有记录 —— 完成一局对局后,这里会开始积累。": "還沒有記錄 —— 完成一局對局後,這裡會開始累積。", - "未知": "未知" + "未知": "未知", + "对局记录": "對局記錄", + "{n} 小时": "{n} 小時", + "计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "計入統計:打到結算的對局,以及中途退出前至少清完 1 個回合的對局;更早退出的對局只留在對局記錄裡。", + "另有 {n} 局未计入统计": "另有 {n} 局未計入統計", + "没有清完第一个回合就退出,不计入统计": "沒有清完第一個回合就退出,不計入統計", + "中途退出的对局没有结算可看": "中途退出的對局沒有結算可看", + "较早的对局只保留汇总,不能回看结算": "較早的對局只保留彙總,不能回看結算", + "中途退出": "中途退出", + "不计入统计": "不計入統計", + "显示更多(还有 {n} 局)": "顯示更多(還有 {n} 局)", + "点击一行,回看该局结算;中途退出的对局只留记录。": "點擊一行,回看該局結算;中途退出的對局只留記錄。", + "最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "最多保留最近 {max} 局,只有最新的 {full} 局能回看結算。", + "返回统计": "返回統計", + "文件过大,无法导入": "檔案過大,無法匯入", + "无法写入本机存储,可能已满": "無法寫入本機儲存空間,可能已滿", + "({n} 条无法读取)": "({n} 筆無法讀取)", + "导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "匯入失敗:這份檔案由更新版本的用戶端匯出,請先升級用戶端", + "清空统计数据": "清空統計資料", + "将删除本机保存的 {n} 条对局记录,无法恢复。": "將刪除本機保存的 {n} 筆對局記錄,無法復原。", + "导出为文件(可在别的设备上导入)": "匯出為檔案(可在別的裝置上匯入)", + "从导出的文件导入(与现有记录合并)": "從匯出的檔案匯入(與現有記錄合併)", + "清空本机的统计数据": "清空本機的統計資料", + "本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "本機資料:只保存在這個瀏覽器裡,不會上傳到伺服器;同盟對局裡隊友的代號也會存在本機(匯出檔案裡也有)。", + "本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "本機保存的統計資料無法讀取,已另存為備份,從現在起重新記錄。", + "有 {n} 条记录已损坏,已忽略。": "有 {n} 筆記錄已損毀,已略過。" } diff --git a/public/js/main.js b/public/js/main.js index 7bb59827..3f6188c7 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -50,7 +50,7 @@ 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 { recordResult, installStatsRecorder } 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'; @@ -224,7 +224,7 @@ function wireNet() { 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 — + // 本机统计 (PR #323): 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() }); }); @@ -348,6 +348,7 @@ async function boot() { })); wireNet(); + installStatsRecorder(store); // follows the match on screen, so a 放弃模拟 can be recorded (ui/stats.js) installLoadoutSync({ net }); installOwnershipSync({ net }); installDiySync({ net }); diff --git a/public/js/screens/lobby.js b/public/js/screens/lobby.js index 0e7586a5..33ba0478 100644 --- a/public/js/screens/lobby.js +++ b/public/js/screens/lobby.js @@ -312,7 +312,7 @@ export function LobbyScreen() {

${t('选择模拟协议')}

- <${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('干员调配')} />
diff --git a/public/js/screens/result.js b/public/js/screens/result.js index ec7572bf..dbba1907 100644 --- a/public/js/screens/result.js +++ b/public/js/screens/result.js @@ -103,12 +103,22 @@ function PlayerCard({ p, myId, titles, best, solo = false }) { `; } -/** 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() {
${mins ? html`

${tParts('本局耗时 {n} 分钟', { n: html`${mins}`, mins })}

` : null}
- <${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${back}>${hasRoom ? t('返回同盟') : t('返回大厅')} + <${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${onBack}>${backLabel}
diff --git a/public/js/screens/room.js b/public/js/screens/room.js index db6cd66a..98a9be4b 100644 --- a/public/js/screens/room.js +++ b/public/js/screens/room.js @@ -278,7 +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('统计')} + <${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('玩法说明')} />
diff --git a/public/js/screens/stats.js b/public/js/screens/stats.js index 16f1bc1f..b4be15d7 100644 --- a/public/js/screens/stats.js +++ b/public/js/screens/stats.js @@ -1,155 +1,182 @@ -// 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 local stats viewer (统计数据): this browser's record of the matches the player took part in — see ui/stats.js for +// what is recorded, what counts and how the storage is bounded. A full-screen overlay opened imperatively from the +// title screen / lobby / room (`openStats()`); is mounted once by main.js. Ported from PR #323 by +// @2321Robin; reworked for 0.2.2 in the grammar of 干员调配 (top bar with tabs, a note line, a scrolling body). // -// 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. +// 总览 – games, wins, win rate, hidden-core reaches / clears, best round, time played; the difficulties; the six +// 评语 (titles) with their counts; what counts and what was left out +// 策略 – per strategy (band): games used / passed (self alive on a winning team) / pass rate +// 战斗累计 – the player's summed combat bookkeeping of the settled matches +// 对局记录 – every stored match, newest first. A settled row re-views that match's settlement screen (the record is +// inverted into an m.result payload for ); a match given up early is marked 中途退出 and, +// when it did not count, 不计入统计 +// Everything is 本机数据 (localStorage, `sp.pref.stats`) and the page says so, teammates' names included. 导出 / 导入 (a +// JSON file, merged by record id on the way in) moves it between devices; 清空 resets it after a confirm dialog. +// The host renders nothing until opened and the game data files the replay needs load only then (the lobby alone never +// downloads them): the overview reads config / bands / assets only. -import { useEffect, 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 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`
+ return html`
<${MicroLabel} tone=${tone === 'gold' ? 'gold' : 'mint'}>${micro} - ${value} - ${label} + ${value} + ${label}
`; } -function Section({ title, micro, children }) { - return html`
-

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

+function Section({ title, micro, children, class: cls }) { + 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); +/** 总览: cards, the per-difficulty table, the six titles, and the line about what counts. */ +function Overview({ agg, gd }) { + const rows = DIFFICULTIES.filter((d) => agg.byDifficulty[d]).concat(Object.keys(agg.byDifficulty).filter((d) => !DIFFICULTIES.includes(d))); + const titles = (Array.isArray(gd.config?.titles) ? gd.config.titles : []).map((x) => ({ ...x, count: agg.titles[x.id]?.count || 0 })); return html`<${Section} title=${t('总览')} micro="OVERVIEW"> -
+
<${StatCard} micro="GAMES" value=${fmtNum(agg.count)} label=${t('总局数')} /> <${StatCard} micro="WINS" value=${fmtNum(agg.wins)} label=${t('胜场')} tone="mint" /> <${StatCard} micro="WIN RATE" value=${pct(agg.wins, agg.count)} label=${t('胜率')} tone="gold" /> <${StatCard} micro="HIDDEN CORE" value=${fmtNum(agg.hidden.reached)} label=${t('到达隐秘核心')} /> <${StatCard} micro="HIDDEN CLEAR" value=${fmtNum(agg.hidden.cleared)} label=${t('通关隐秘核心')} /> <${StatCard} micro="BEST ROUND" value=${fmtNum(agg.rounds.max)} label=${t('最远回合')} /> - ${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('累计时长')} />
- ${rows.length ? html` +

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

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

${t('称号')}

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

<${Icon} name="info" />${t('计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。')}${agg.excluded + ? html` ${t('另有 {n} 局未计入统计', { n: agg.excluded })}` : null}

`; } /** 策略: per-band used / passed / pass rate, most used first (the user's headline ask). */ function Bands({ agg, gd }) { - const rows = Object.entries(agg.bands).sort((a, b) => b[1].games - a[1].games); - if (!rows.length) return null; - return html`<${Section} title=${t('策略')} micro="BANDS"> - + const rows = Object.entries(agg.bands).sort((a, b) => (b[1].games - a[1].games) || (b[1].wins - a[1].wins)); + return html`<${Section} title=${t('策略')} micro="STRATEGIES"> + ${rows.length ? html`
${rows.map(([bandId, v]) => { const band = gd.band(bandId); + const rate = v.games > 0 ? Math.round((v.wins / v.games) * 100) : 0; return html` - + - + `; })} -
${t('策略')}${t('使用局数')}${t('通过局数')}${t('通过率')}
${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : bandId}${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : bandId} ${fmtNum(v.games)} ${fmtNum(v.wins)}${pct(v.wins, v.games)}${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)}
-
`)} -
+
` : html`

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

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

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

`} `; } -/** 最近对局: 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}`}> - +/** One 对局记录 row: what it was, who won, whether it counted and whether the settlement can be re-viewed. */ +function HistoryRow({ rec, gd, onOpen }) { + const self = selfRowOf(rec); + const quit = rec.end === 'quit'; + const counted = countsTowardStats(rec); + const won = self ? !!self.victory : !!rec.victory; + const band = self?.bandId ? gd.band(self.bandId) : null; + const rounds = roundsOf(rec); + const known = Number.isFinite(self?.roundsPassed) || Number.isFinite(rec.roundsPassed); + const view = !quit && !rec.compact; // a quit has nothing settled; a compacted record no rows left + const open = () => onOpen(rec); + return html` { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } } : undefined}> + + + + + + + + `; +} + +/** 对局记录: 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`
${dateTime(rec.t)}${difficultyName(rec.difficulty)}${rec.roomMode === 'solo' ? t('单人') : rec.roomMode === 'coop' ? t('同盟') : '—'}${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : self?.bandId || '—'}${quit ? html`${t('中途退出')}` : html`${won ? t('胜') : t('负')}`} + ${!counted ? html`${t('不计入统计')}` : null}${known ? fmtNum(rounds) : '—'}
- - ${rows.map((r) => { - const self = r.players.find((p) => p.playerId === r.selfId) || r.players[0] || null; - const won = self ? !!self.victory : !!r.victory; - const band = self?.bandId ? gd.band(self.bandId) : null; - return html` openRecordResult(r)}> - - - - - - - - `; - })} - -
${t('时间')}${t('难度')}${t('模式')}${t('策略')}${t('结果')}${t('回合')}
${dateTime(r.t)}${DIFFICULTY_NAMES[r.difficulty] || r.difficulty || '—'}${r.roomMode === 'solo' ? t('单人') : r.roomMode === 'coop' ? t('同盟') : '—'}${band ? band.name : self?.bandId || '—'}${won ? t('胜') : t('负')}${fmtNum(r.roundsPassed)}<${Icon} name="chevronRight" />
-

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

+ ${rows.map((r) => html`<${HistoryRow} key=${r.id} rec=${r} gd=${gd} onOpen=${onOpen} />`)} +
+ ${records.length > shown ? html`
<${Button} size="sm" variant="secondary" onClick=${() => setShown(shown + HISTORY_PAGE)}>${t('显示更多(还有 {n} 局)', { n: records.length - shown })}
` : null} +

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

` + : html`

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

`} `; } -/** 导出 / 导入 / 清空, 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`
<${Spinner} size="md" />
`; + return html`<${ResultView} res=${res} pub=${null} myId=${rec.selfId} backLabel=${t('返回统计')} onBack=${onBack} quiet=${true} />`; +} + +/** The overlay, rendered while open (its data hooks run only then). */ +function StatsScreen({ tab }) { + const ready = useData('config', 'bands', 'assets'); + const gd = useMemo(() => makeLookups(ready), [ready, data.locale()]); + const [stats, setStats] = useState(() => loadStats()); + const [replay, setReplay] = useState(null); + const boxRef = useRef(null); const fileRef = useRef(null); - const [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`
- <${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('清空')} + if (replay) return html``; -} -/** - * Global stats viewer host — mounted once by main.js. Waits only on the files the page reads (config for the - * title list, bands for names/icons, assets for the picture URLs), not all of useGameData's files. - */ -export function StatsHost() { - const { open } = useStore((s) => s, Object.is, statsStore); - const replaying = useStore((s) => !!s.match.result); // a replayed settlement replaces the whole panel - const ready = useData('config', 'bands', 'assets'); - const gd = useGameData(); - const [stats, setStats] = useState(() => emptyStats()); - const boxRef = useRef(null); - const reload = () => setStats(loadStats()); - - useEffect(() => { - if (!open) return undefined; - setStats(loadStats()); - const onKey = (e) => { - if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); closeStats(); } - }; - window.addEventListener('keydown', onKey, true); - const t = setTimeout(() => boxRef.current?.focus?.(), 30); - return () => { window.removeEventListener('keydown', onKey, true); clearTimeout(t); }; - }, [open]); - - if (!open) return null; - if (replaying) { - // the record re-viewed as its settlement (openRecordResult): the real , full-bleed; - // its 返回大厅 empties match.result and lands back on the list - return html`
<${ResultScreen} />
`; - } - const agg = aggregateStats(stats.records); - return html`