feedback7 stats: a match left right away stays in the history but does not count; bounded storage, corrupt-data handling, the page reworked as 干员调配-style tabs, the settlement re-view without the app store (PR #323 by @2321Robin, owner's decision of 2026-10-08); golden: unchanged
Counting rule (ui/stats.js countsTowardStats, the owner's decision of 2026-10-08: do not count the matches entered and left right away, but keep them in the history): a settled match (m.result arrived) always counts; a match given up (放弃模拟) counts once at least one of the player's battles had finished — a round cleared (the server's own rule, platform.js _quit: leaving while round k runs has passed k - 1, in its settlement k) or already eliminated. Anything earlier is history only: marked 中途退出 and 不计入统计, dimmed, listed in 对局记录, left out of every aggregate (games, wins, win rate, strategy table, titles, rounds, time, combat totals); 总览 says how many were left out. - A leaver gets no m.result, so the page records the match itself: quitMatch (放弃模拟) → recordQuit builds an `end: 'quit'` record from the app state and a tracker (observeMatch, installed in main.js) that remembers when the page first saw the match and the last round the player was seen alive in. No record for a spectator seat, a match with its settlement already there, a match the page never saw; matches the server ended and closed tabs are not recorded. Defects of PR #323 as submitted, reproduced (scratchpad/w7-stats-notes/repro-pr-bugs.mjs, loads-check*.mjs) and fixed: - the title screen loaded all 16 game data files (5.3 MB) at boot: <StatsHost/> called useGameData() at mount; now the host renders nothing until opened, the overview reads config / bands / assets and the settlement re-view loads the rest when opened (the title screen loads the same 3 files as before); - importing a file put the list oldest first (the page shows the first 20 as the newest and the cap would drop the newest); the list is newest first everywhere, also when loaded from storage; - recordResult wrote over the envelope of a NEWER build (a downgrade, against the PR's own rule); it refuses now, as saveStats does; - 1000 four-player records are ~7.2 M characters (the PR's ~1.5 kB/record is a solo match) against a ~2.5 M-character quota: MAX_RECORDS 1000 stays, plus a 800 000-character budget (the oldest go first), and only the newest 30 records keep their rows (lineup, bonds, teammates' names; the replay needs them) — older ones are compacted to the player's own summary row (~620 characters), a refused write retries with half the history; unknown fields and oversized values are dropped (a strict schema), import files are bounded (8 MB); - the re-view clobbered the app store (match.public / match.result, the route, the music); ResultScreen is split into ResultScreen (the store) and ResultView (props: result, myId, back label), so the stats page renders a stored match itself; a 自选 pick and a 补位 stand-in were dropped from the stored lineup (the replayed card drew the plain operator); DIFFICULTY_NAMES were not translated; a band cell was a display:flex <td>; the overlay stayed open when a co-op match started; the 清空 two-step blink was replaced by the shared confirm dialog. - Corrupt storage: unreadable data is moved to sp.pref.stats.damaged and the page says so; damaged rows are dropped and counted; duplicates, an oversized or unsorted list are repaired on load; a pre-`end` (PR-era) envelope reads as settled. UI: a full-screen overlay in the grammar of 干员调配 (top bar with 返回 / four tabs 总览 · 策略 · 战斗累计 · 对局记录 / 导出 导入 清空, a note line saying the data stays in this browser and that teammates' names are stored too), text sizes with max(.xxrem, N px) for phones in landscape, a 50-row history page with 显示更多, a pass-rate bar, a per-difficulty table in order; the entries use a new `chart` icon (not the ping bars), the title screen's sits beside the language menu (the connection row has no room: it squeezed the gear and wrapped the status text), the lobby / room buttons show the icon alone below 700 px (the room's title ran under the label at 640 px). docs/PLAYING.md §12, README row; 41 + 25 UI strings in en / ja / ko / zh-TW (the PR's four unused ones removed). Tests: test/ui/stats.test.js 44 / 44 (counting rule per phase, quit records, bounds, corrupt / old / newer storage, import order, aggregation, the replay payload); test/ui/stats.e2e.test.js (opt-in SP_E2E=1, real server, headless Chrome: seeded history on 1920×1080 and 844×390, the re-view, export / import / clear, corrupt and newer storage, and the real flows: a solo match given up in the briefing is history only, one given up after round 1 counts, a settled match is recorded once and not again after a reload) 6 / 6; lint 0 errors, i18n extract 0 / check --all --strict clean, check:imports, typecheck, docs and version tests, golden unchanged.
This commit is contained in:
1 parent
438a6b521a
commit
51e0e60b6e
20 files changed
+1808
-530
No files matched your search
@@ -168,6 +168,7 @@ npm start # 启动服务器:http://localhost:3000
|
|||||||
| 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 |
|
| 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 |
|
||||||
| 表情 | 左下角「交流」,左右滑动(或方向键)换主题,冷却 1 秒 |
|
| 表情 | 左下角「交流」,左右滑动(或方向键)换主题,冷却 1 秒 |
|
||||||
| 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」;不参战的朋友可以在大厅输入同盟密钥点「观战」(每个同盟最多 2 名观战者,本作新增) |
|
| 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」;不参战的朋友可以在大厅输入同盟密钥点「观战」(每个同盟最多 2 名观战者,本作新增) |
|
||||||
|
| 统计(本机) | 标题画面右上角、大厅、等待室的「统计」:胜率、策略通过率、称号、战斗累计和对局记录(点一行回看结算页);只存在本机浏览器里,可以导出 / 导入,没清完第一个回合就退出的对局不计入统计([玩法指南 §12](docs/PLAYING.md#12-统计本机)) |
|
||||||
|
|
||||||
完整的规则、数值和小技巧见 **[docs/PLAYING.md](docs/PLAYING.md)**(游戏内左下角也有「玩法说明」)。
|
完整的规则、数值和小技巧见 **[docs/PLAYING.md](docs/PLAYING.md)**(游戏内左下角也有「玩法说明」)。
|
||||||
|
|
||||||
|
|||||||
@@ -170,3 +170,18 @@
|
|||||||
刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。
|
刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。
|
||||||
|
|
||||||
**修改快捷键**:标题画面右上角或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。
|
**修改快捷键**:标题画面右上角或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 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 局保留完整的阵容、盟约和队友信息,更早的压缩成你自己的一行汇总。同盟对局的记录里有**队友的代号**,它们保存在本机,也会出现在你导出的文件里;不会发给任何人。想删掉就点「清空」(会先确认),或清除这个网站的数据。读不出来的存档(损坏的数据)会另存为备份并重新开始记录,不会让页面出错;由更新版本导出的文件会被拒绝导入,也不会被旧版本覆盖。
|
||||||
+172
-98
@@ -1,119 +1,193 @@
|
|||||||
/* ==========================================================================================
|
/* ==========================================================================================
|
||||||
stats.css — 统计数据 viewer (js/screens/stats.js): the local match record as overview cards,
|
screens/stats.css — 统计数据 (js/screens/stats.js): the local match record as a full-screen overlay in the grammar of
|
||||||
per-difficulty / per-band tables, the six 评语 with counts, combat totals and a recent-games
|
干员调配 (screens/loadout.css) — a top bar with the back button, four tabs (总览 / 策略 / 战斗累计 / 对局记录) and
|
||||||
list; 导出 / 导入 / 清空 in the header. Same overlay grammar as guide.css.
|
导出 / 导入 / 清空, 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 {
|
.st {
|
||||||
position: fixed; inset: 0; z-index: calc(var(--z-modal) + 4);
|
position: fixed; inset: 0; z-index: 55; /* like .lo: above every screen (1) and the in-match banners (40), below the connection
|
||||||
display: grid; place-items: center;
|
banner (60), modals / confirm dialogs (80), toasts and tooltips */
|
||||||
background: radial-gradient(ellipse at 50% 40%, rgba(8, 14, 12, .82), rgba(2, 4, 3, .94));
|
display: flex; flex-direction: column;
|
||||||
-webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
|
padding: var(--sa-t, 0px) var(--sa-r, 0px) var(--sa-b, 0px) var(--sa-l, 0px);
|
||||||
animation: stats-fade 180ms var(--ease-out);
|
background: var(--bg-0);
|
||||||
}
|
color: var(--text-hi);
|
||||||
@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;
|
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); } }
|
@keyframes st-in { from { opacity: 0; } }
|
||||||
|
.st__bg {
|
||||||
/* header: titles + note | 导出/导入/清空 | close */
|
position: absolute; inset: 0; pointer-events: none;
|
||||||
.stats__head { display: flex; align-items: center; gap: .2rem; padding-bottom: .1rem; border-bottom: 1px solid var(--line); }
|
background:
|
||||||
.stats__titles { display: flex; flex-direction: column; gap: .02rem; margin-right: auto; min-width: 0; }
|
radial-gradient(70% 90% at 18% 10%, rgba(78, 216, 175, .07), transparent 60%),
|
||||||
.stats__title { margin: 0; font-size: .28rem; font-weight: 900; letter-spacing: .12em; color: var(--text-hi); line-height: 1.1; }
|
radial-gradient(60% 70% at 100% 100%, rgba(246, 163, 41, .05), transparent 60%),
|
||||||
.stats__note { font-size: .13rem; color: var(--text-lo); letter-spacing: .04em; }
|
linear-gradient(rgba(78, 216, 175, .035) 1px, transparent 1px) 0 0 / .6rem .6rem,
|
||||||
.stats-actions { display: flex; gap: .08rem; flex: none; }
|
linear-gradient(90deg, rgba(78, 216, 175, .035) 1px, transparent 1px) 0 0 / .6rem .6rem,
|
||||||
.stats-actions__confirm { animation: stats-blink 900ms steps(2) infinite; }
|
linear-gradient(180deg, #0f1312, #090b0a);
|
||||||
@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; }
|
.st-top, .st-note, .st-body { position: relative; }
|
||||||
.stats__close:hover { color: var(--text-hi); border-color: var(--red-premium); background: var(--red-a20); }
|
/* 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 */
|
/* ---- top bar ----------------------------------------------------------------------------------------------------- */
|
||||||
.stats__body { overflow-y: auto; padding: .12rem .02rem .14rem; display: flex; flex-direction: column; gap: .18rem; }
|
.st-top {
|
||||||
.stats__loading { display: grid; place-items: center; min-height: 2rem; }
|
flex: none; display: grid; grid-template-columns: minmax(max-content, 1fr) minmax(0, auto) minmax(max-content, 1fr); align-items: center; gap: .2rem;
|
||||||
.stats-empty { margin: .4rem 0; text-align: center; font-size: .18rem; color: var(--text-lo); letter-spacing: .06em; }
|
height: max(1rem, 48px); padding: 0 .36rem;
|
||||||
|
background: linear-gradient(180deg, rgba(8, 10, 9, .95), rgba(10, 13, 12, .6));
|
||||||
.stats-sec__h {
|
border-bottom: 1px solid var(--line);
|
||||||
display: flex; align-items: baseline; gap: .12rem; margin: 0 0 .08rem;
|
|
||||||
font-size: .2rem; font-weight: 800; letter-spacing: .1em; color: var(--text-hi);
|
|
||||||
}
|
}
|
||||||
|
.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 */
|
/* number cards */
|
||||||
.stats-cards { display: flex; flex-wrap: wrap; gap: .08rem; align-items: flex-start; }
|
.st-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(max(1.5rem, 96px), 1fr)); gap: .08rem; }
|
||||||
.stcard {
|
.st-card {
|
||||||
flex: 1 1 1.1rem; min-width: 1.05rem; padding: .07rem .1rem .06rem;
|
display: flex; flex-direction: column; gap: .02rem; min-width: 0; padding: .08rem .12rem .07rem;
|
||||||
display: flex; flex-direction: column; gap: .01rem;
|
|
||||||
background: rgba(255, 255, 255, .03); border: 1px solid var(--line);
|
background: rgba(255, 255, 255, .03); border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.stcard--mint { border-color: var(--mint-800); background: var(--mint-a10); }
|
.st-card--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); }
|
.st-card--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; }
|
.st-card__num { font-size: max(.3rem, 17px); font-weight: 900; line-height: 1.15; color: var(--text-hi); overflow-wrap: anywhere; }
|
||||||
.stcard--mint .stcard__num { color: var(--mint-400); }
|
.st-card--mint .st-card__num { color: var(--mint-400); }
|
||||||
.stcard--gold .stcard__num { color: #f6a329; }
|
.st-card--gold .st-card__num { color: var(--amber); }
|
||||||
.stcard__label { font-size: .13rem; color: var(--text-md); letter-spacing: .06em; }
|
.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 (战斗累计) */
|
/* tables (难度 / 策略 / 对局记录) */
|
||||||
.stats-cards--sums { display: grid; grid-template-columns: repeat(auto-fill, minmax(1.5rem, 1fr)); gap: .06rem; }
|
.st-tablewrap { overflow-x: auto; }
|
||||||
.ssum {
|
.st-table { width: 100%; border-collapse: collapse; font-size: max(.17rem, 11px); }
|
||||||
display: flex; align-items: baseline; justify-content: space-between; gap: .08rem;
|
.st-table th {
|
||||||
padding: .05rem .1rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line);
|
padding: .05rem .1rem; text-align: left; font-size: max(.14rem, 10px); font-weight: 600; letter-spacing: .08em;
|
||||||
}
|
|
||||||
.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;
|
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); }
|
.st-table td { padding: .06rem .1rem; color: var(--text-md); border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||||
.stats-table tbody tr:hover td { background: rgba(78, 216, 175, .05); }
|
/* the per-difficulty and per-strategy tables stay narrow enough to read across; the history takes the width */
|
||||||
.stats-table .num { text-align: right; }
|
.st-table:not(.st-table--history) { max-width: max(9.4rem, 560px); }
|
||||||
.stats-table th.num { text-align: right; }
|
.st-table th.num, .st-table td.num { text-align: right; }
|
||||||
.stats-band { display: flex; align-items: center; gap: .06rem; color: var(--text-hi); white-space: nowrap; }
|
.st-table tbody tr:hover td { background: rgba(78, 216, 175, .05); }
|
||||||
.stats-t { font-family: var(--font-num); font-size: .14rem; color: var(--text-lo); white-space: nowrap; }
|
.st-table td.st-diff { color: var(--d-color, var(--text-md)); font-weight: 700; white-space: nowrap; }
|
||||||
.stats-table tr.is-win .t-win { color: var(--mint-400); font-weight: 800; }
|
.st-band { display: inline-flex; align-items: center; gap: .06rem; color: var(--text-hi); white-space: nowrap; vertical-align: middle; }
|
||||||
.stats-table tr.is-lose .t-lose { color: var(--red-premium); font-weight: 800; }
|
.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 */
|
/* 对局记录: result colours, the 不计入统计 chip, clickable rows */
|
||||||
.stats-titles { display: grid; grid-template-columns: repeat(auto-fill, minmax(1.7rem, 1fr)); gap: .08rem; }
|
.st-res { white-space: nowrap; }
|
||||||
.stit {
|
.t-win { color: var(--mint-400); font-weight: 800; }
|
||||||
display: flex; align-items: center; gap: .08rem; min-width: 0;
|
.t-lose { color: var(--red-premium); font-weight: 800; }
|
||||||
padding: .06rem .1rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line);
|
.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; }
|
.st-table tr.is-excluded td { opacity: .6; }
|
||||||
.stit__icon { width: .36rem; height: .36rem; flex: none; object-fit: contain; }
|
.st-table tr.is-clickable { cursor: pointer; }
|
||||||
.stit__text { display: flex; flex-direction: column; min-width: 0; }
|
.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); }
|
||||||
.stit__text span { font-size: .15rem; color: var(--text-md); white-space: nowrap; }
|
.st-table tr.is-clickable:focus-visible { outline: 2px solid var(--mint-400); outline-offset: -2px; }
|
||||||
.stit__text b { font-size: .22rem; font-weight: 900; color: #f6a329; line-height: 1.1; }
|
.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 <ResultScreen/> fills the overlay */
|
/* the six 评语 with their counts */
|
||||||
.stats__replay { position: absolute; inset: 0; }
|
.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) */
|
/* what counts (the rule of ui/stats.js countsTowardStats) */
|
||||||
.stats-table tbody tr.is-clickable { cursor: pointer; }
|
.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); }
|
||||||
.stats-table tbody tr.is-clickable:hover td { background: rgba(78, 216, 175, .1); color: var(--text-hi); }
|
.st-rule .icon { width: max(.16rem, 11px); height: max(.16rem, 11px); flex: none; color: var(--mint-500); }
|
||||||
.stats-table .stats-go { width: .24rem; color: var(--text-lo); }
|
.st-rule b { color: var(--amber); font-weight: 700; }
|
||||||
.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); }
|
.st-sums { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(2.4rem, 150px), 1fr)); gap: .06rem; }
|
||||||
.stats__title { font-size: .22rem; }
|
.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); }
|
||||||
.stats__note { display: none; }
|
.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; } }
|
||||||
@@ -243,6 +243,9 @@
|
|||||||
.title-invite .icon { width: .2rem; height: .2rem; }
|
.title-invite .icon { width: .2rem; height: .2rem; }
|
||||||
.title-invite b { font-size: .2rem; letter-spacing: .2em; color: #ffd08a; }
|
.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); }
|
.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 */
|
/* 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 { display: inline-flex; margin-bottom: .1rem; }
|
||||||
.title-lang button { min-width: .74rem; height: .3rem; font-size: .14rem; cursor: pointer; }
|
.title-lang button { min-width: .74rem; height: .3rem; font-size: .14rem; cursor: pointer; }
|
||||||
|
|||||||
+29
-8
@@ -1058,7 +1058,6 @@
|
|||||||
"开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers",
|
"开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers",
|
||||||
"统计数据": "Statistics",
|
"统计数据": "Statistics",
|
||||||
"统计": "Stats",
|
"统计": "Stats",
|
||||||
"对局进行中,暂不能回看历史结算": "A match is running — replays are unavailable right now",
|
|
||||||
"总览": "Overview",
|
"总览": "Overview",
|
||||||
"总局数": "Games",
|
"总局数": "Games",
|
||||||
"胜场": "Wins",
|
"胜场": "Wins",
|
||||||
@@ -1076,7 +1075,6 @@
|
|||||||
"通过率": "Clear rate",
|
"通过率": "Clear rate",
|
||||||
"称号": "Titles",
|
"称号": "Titles",
|
||||||
"战斗累计": "Combat totals",
|
"战斗累计": "Combat totals",
|
||||||
"最近对局": "Recent matches",
|
|
||||||
"时间": "Time",
|
"时间": "Time",
|
||||||
"模式": "Mode",
|
"模式": "Mode",
|
||||||
"结果": "Result",
|
"结果": "Result",
|
||||||
@@ -1085,16 +1083,39 @@
|
|||||||
"同盟": "Co-op",
|
"同盟": "Co-op",
|
||||||
"胜": "Win",
|
"胜": "Win",
|
||||||
"负": "Loss",
|
"负": "Loss",
|
||||||
"点击任意一行,回看该局结算。": "Click a row to re-view that match's settlement.",
|
"已导出 {n} 条对局记录": "Exported {n} match {n|record|records}",
|
||||||
"已导出 {n} 条对局记录": "Exported {n} match records",
|
"已导入 {added} 条记录{tail}": "Imported {added} {added|record|records}{tail}",
|
||||||
"已导入 {added} 条记录{tail}": "Imported {added} records{tail}",
|
"(跳过重复 {skipped} 条)": " ({skipped} {skipped|duplicate|duplicates} skipped)",
|
||||||
"(跳过重复 {skipped} 条)": " ({skipped} duplicates skipped)",
|
|
||||||
"没有新记录(全部与现有数据重复)": "Nothing new (everything already exists)",
|
"没有新记录(全部与现有数据重复)": "Nothing new (everything already exists)",
|
||||||
"导入失败:不是有效的统计导出文件": "Import failed: not a valid stats export file",
|
"导入失败:不是有效的统计导出文件": "Import failed: not a valid stats export file",
|
||||||
"已清空本机统计数据": "Local stats cleared",
|
"已清空本机统计数据": "Local stats cleared",
|
||||||
"确认清空?": "Clear for sure?",
|
|
||||||
"本机数据 · 仅记录本浏览器中你参与的对局": "Local data · only matches you took part in, in this browser",
|
"本机数据 · 仅记录本浏览器中你参与的对局": "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.",
|
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "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.",
|
"还没有记录 —— 完成一局对局后,这里会开始积累。": "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."
|
||||||
}
|
}
|
||||||
+26
-5
@@ -1064,7 +1064,6 @@
|
|||||||
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。",
|
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。",
|
||||||
"统计数据": "統計データ",
|
"统计数据": "統計データ",
|
||||||
"统计": "統計",
|
"统计": "統計",
|
||||||
"对局进行中,暂不能回看历史结算": "対戦中のため、過去の結果は表示できません",
|
|
||||||
"总览": "概要",
|
"总览": "概要",
|
||||||
"总局数": "対戦数",
|
"总局数": "対戦数",
|
||||||
"胜场": "勝利数",
|
"胜场": "勝利数",
|
||||||
@@ -1082,7 +1081,6 @@
|
|||||||
"通过率": "クリア率",
|
"通过率": "クリア率",
|
||||||
"称号": "称号",
|
"称号": "称号",
|
||||||
"战斗累计": "戦闘累計",
|
"战斗累计": "戦闘累計",
|
||||||
"最近对局": "最近の対戦",
|
|
||||||
"时间": "日時",
|
"时间": "日時",
|
||||||
"模式": "モード",
|
"模式": "モード",
|
||||||
"结果": "結果",
|
"结果": "結果",
|
||||||
@@ -1091,16 +1089,39 @@
|
|||||||
"同盟": "連合",
|
"同盟": "連合",
|
||||||
"胜": "勝",
|
"胜": "勝",
|
||||||
"负": "敗",
|
"负": "敗",
|
||||||
"点击任意一行,回看该局结算。": "行をクリックすると、その対戦の結果をもう一度見られます。",
|
|
||||||
"已导出 {n} 条对局记录": "対戦記録を{n}件エクスポートしました",
|
"已导出 {n} 条对局记录": "対戦記録を{n}件エクスポートしました",
|
||||||
"已导入 {added} 条记录{tail}": "{added}件の記録をインポートしました{tail}",
|
"已导入 {added} 条记录{tail}": "{added}件の記録をインポートしました{tail}",
|
||||||
"(跳过重复 {skipped} 条)": "(重複{skipped}件をスキップ)",
|
"(跳过重复 {skipped} 条)": "(重複{skipped}件をスキップ)",
|
||||||
"没有新记录(全部与现有数据重复)": "新しい記録はありません(すべて既存のデータと重複しています)",
|
"没有新记录(全部与现有数据重复)": "新しい記録はありません(すべて既存のデータと重複しています)",
|
||||||
"导入失败:不是有效的统计导出文件": "インポートに失敗しました:有効な統計のエクスポートファイルではありません",
|
"导入失败:不是有效的统计导出文件": "インポートに失敗しました:有効な統計のエクスポートファイルではありません",
|
||||||
"已清空本机统计数据": "この端末の統計データを消去しました",
|
"已清空本机统计数据": "この端末の統計データを消去しました",
|
||||||
"确认清空?": "本当に消去しますか?",
|
|
||||||
"本机数据 · 仅记录本浏览器中你参与的对局": "この端末のデータ · このブラウザで参加した対戦のみ記録されます",
|
"本机数据 · 仅记录本浏览器中你参与的对局": "この端末のデータ · このブラウザで参加した対戦のみ記録されます",
|
||||||
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "より新しいバージョンの統計データが見つかりました。上書きを避けるため、クライアントを更新してから表示してください。",
|
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "より新しいバージョンの統計データが見つかりました。上書きを避けるため、クライアントを更新してから表示してください。",
|
||||||
"还没有记录 —— 完成一局对局后,这里会开始积累。": "記録はまだありません — 対戦を1つ終えると、ここに蓄積されていきます。",
|
"还没有记录 —— 完成一局对局后,这里会开始积累。": "記録はまだありません — 対戦を1つ終えると、ここに蓄積されていきます。",
|
||||||
"未知": "不明"
|
"未知": "不明",
|
||||||
|
"对局记录": "対戦履歴",
|
||||||
|
"{n} 小时": "{n}時間",
|
||||||
|
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "集計対象:結果画面まで進んだ対戦と、1ラウンド以上クリアしてから途中退出した対戦。それより早く退出した対戦は対戦履歴にのみ残ります。",
|
||||||
|
"另有 {n} 局未计入统计": "他に{n}件の対戦が集計対象外です",
|
||||||
|
"没有清完第一个回合就退出,不计入统计": "最初のラウンドをクリアする前に退出したため、集計対象外です",
|
||||||
|
"中途退出的对局没有结算可看": "途中退出した対戦には表示できる結果がありません",
|
||||||
|
"较早的对局只保留汇总,不能回看结算": "古い対戦は要約のみ保存されているため、結果は表示できません",
|
||||||
|
"中途退出": "途中退出",
|
||||||
|
"不计入统计": "集計対象外",
|
||||||
|
"显示更多(还有 {n} 局)": "さらに表示(残り{n}件)",
|
||||||
|
"点击一行,回看该局结算;中途退出的对局只留记录。": "行をクリックすると、その対戦の結果をもう一度見られます。途中退出した対戦は記録のみ残ります。",
|
||||||
|
"最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "保存されるのは直近{max}件のみで、結果を再表示できるのは最新の{full}件だけです。",
|
||||||
|
"返回统计": "統計に戻る",
|
||||||
|
"文件过大,无法导入": "ファイルが大きすぎるためインポートできません",
|
||||||
|
"无法写入本机存储,可能已满": "ローカルストレージに書き込めません。容量がいっぱいの可能性があります",
|
||||||
|
"({n} 条无法读取)": "(読み取れない記録{n}件)",
|
||||||
|
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "インポートに失敗しました:このファイルはより新しいバージョンのクライアントでエクスポートされています。先にクライアントを更新してください",
|
||||||
|
"清空统计数据": "統計データを消去",
|
||||||
|
"将删除本机保存的 {n} 条对局记录,无法恢复。": "この端末に保存されている対戦記録{n}件を削除します。元に戻せません。",
|
||||||
|
"导出为文件(可在别的设备上导入)": "ファイルにエクスポート(別の端末でインポートできます)",
|
||||||
|
"从导出的文件导入(与现有记录合并)": "エクスポートしたファイルからインポート(既存の記録と統合)",
|
||||||
|
"清空本机的统计数据": "この端末の統計データを消去",
|
||||||
|
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "この端末のデータ:このブラウザにのみ保存され、サーバーには送信されません。連合演算の仲間の名前もこの端末に保存されます(エクスポートファイルにも含まれます)。",
|
||||||
|
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "この端末に保存された統計データを読み取れませんでした。バックアップとして退避し、記録を最初からやり直します。",
|
||||||
|
"有 {n} 条记录已损坏,已忽略。": "破損した記録{n}件を無視しました。"
|
||||||
}
|
}
|
||||||
+26
-5
@@ -1060,7 +1060,6 @@
|
|||||||
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "현재 언어의 인터페이스 텍스트는 기계 번역이므로 정확하지 않을 수 있습니다. GitHub에서 수정 제안을 환영합니다.",
|
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "현재 언어의 인터페이스 텍스트는 기계 번역이므로 정확하지 않을 수 있습니다. GitHub에서 수정 제안을 환영합니다.",
|
||||||
"统计数据": "통계",
|
"统计数据": "통계",
|
||||||
"统计": "통계",
|
"统计": "통계",
|
||||||
"对局进行中,暂不能回看历史结算": "시뮬레이션이 진행 중이라 지난 정산을 다시 볼 수 없습니다",
|
|
||||||
"总览": "개요",
|
"总览": "개요",
|
||||||
"总局数": "총 판수",
|
"总局数": "총 판수",
|
||||||
"胜场": "승리",
|
"胜场": "승리",
|
||||||
@@ -1078,7 +1077,6 @@
|
|||||||
"通过率": "클리어율",
|
"通过率": "클리어율",
|
||||||
"称号": "평가",
|
"称号": "평가",
|
||||||
"战斗累计": "전투 누적",
|
"战斗累计": "전투 누적",
|
||||||
"最近对局": "최근 판",
|
|
||||||
"时间": "시간",
|
"时间": "시간",
|
||||||
"模式": "모드",
|
"模式": "모드",
|
||||||
"结果": "결과",
|
"结果": "결과",
|
||||||
@@ -1087,16 +1085,39 @@
|
|||||||
"同盟": "동맹",
|
"同盟": "동맹",
|
||||||
"胜": "승",
|
"胜": "승",
|
||||||
"负": "패",
|
"负": "패",
|
||||||
"点击任意一行,回看该局结算。": "행을 누르면 해당 판의 정산을 다시 볼 수 있습니다.",
|
|
||||||
"已导出 {n} 条对局记录": "기록 {n}건을 내보냈습니다",
|
"已导出 {n} 条对局记录": "기록 {n}건을 내보냈습니다",
|
||||||
"已导入 {added} 条记录{tail}": "기록 {added}건을 가져왔습니다{tail}",
|
"已导入 {added} 条记录{tail}": "기록 {added}건을 가져왔습니다{tail}",
|
||||||
"(跳过重复 {skipped} 条)": " (중복 {skipped}건 건너뜀)",
|
"(跳过重复 {skipped} 条)": " (중복 {skipped}건 건너뜀)",
|
||||||
"没有新记录(全部与现有数据重复)": "새 기록이 없습니다(모두 기존 데이터와 중복)",
|
"没有新记录(全部与现有数据重复)": "새 기록이 없습니다(모두 기존 데이터와 중복)",
|
||||||
"导入失败:不是有效的统计导出文件": "가져오기 실패: 올바른 통계 내보내기 파일이 아닙니다",
|
"导入失败:不是有效的统计导出文件": "가져오기 실패: 올바른 통계 내보내기 파일이 아닙니다",
|
||||||
"已清空本机统计数据": "이 기기의 통계 데이터를 삭제했습니다",
|
"已清空本机统计数据": "이 기기의 통계 데이터를 삭제했습니다",
|
||||||
"确认清空?": "정말 비우시겠습니까?",
|
|
||||||
"本机数据 · 仅记录本浏览器中你参与的对局": "이 기기의 데이터 · 이 브라우저에서 참여한 판만 기록됩니다",
|
"本机数据 · 仅记录本浏览器中你参与的对局": "이 기기의 데이터 · 이 브라우저에서 참여한 판만 기록됩니다",
|
||||||
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "더 최신 버전의 통계 데이터가 감지되었습니다. 데이터를 덮어쓰지 않도록 클라이언트를 업데이트한 뒤 확인하세요.",
|
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "더 최신 버전의 통계 데이터가 감지되었습니다. 데이터를 덮어쓰지 않도록 클라이언트를 업데이트한 뒤 확인하세요.",
|
||||||
"还没有记录 —— 完成一局对局后,这里会开始积累。": "아직 기록이 없습니다 — 한 판을 마치면 여기에 쌓이기 시작합니다.",
|
"还没有记录 —— 完成一局对局后,这里会开始积累。": "아직 기록이 없습니다 — 한 판을 마치면 여기에 쌓이기 시작합니다.",
|
||||||
"未知": "알 수 없음"
|
"未知": "알 수 없음",
|
||||||
|
"对局记录": "판 기록",
|
||||||
|
"{n} 小时": "{n}시간",
|
||||||
|
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "집계 대상: 정산까지 진행한 판, 그리고 1라운드 이상 클리어한 뒤 중도 포기한 판. 그보다 일찍 나간 판은 판 기록에만 남습니다.",
|
||||||
|
"另有 {n} 局未计入统计": "집계에 포함되지 않은 판이 {n}건 더 있습니다",
|
||||||
|
"没有清完第一个回合就退出,不计入统计": "첫 라운드를 클리어하기 전에 나가서 집계에 포함되지 않습니다",
|
||||||
|
"中途退出的对局没有结算可看": "중도 포기한 판은 볼 수 있는 정산이 없습니다",
|
||||||
|
"较早的对局只保留汇总,不能回看结算": "오래된 판은 요약만 저장되어 정산을 볼 수 없습니다",
|
||||||
|
"中途退出": "중도 퇴장",
|
||||||
|
"不计入统计": "집계 제외",
|
||||||
|
"显示更多(还有 {n} 局)": "더 보기 (남은 {n}건)",
|
||||||
|
"点击一行,回看该局结算;中途退出的对局只留记录。": "행을 누르면 해당 판의 정산을 다시 볼 수 있습니다. 중도 포기한 판은 기록만 남습니다.",
|
||||||
|
"最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "최근 {max}판까지만 보관하며, 정산을 다시 볼 수 있는 것은 최신 {full}판뿐입니다.",
|
||||||
|
"返回统计": "통계로 돌아가기",
|
||||||
|
"文件过大,无法导入": "파일이 너무 커서 가져올 수 없습니다",
|
||||||
|
"无法写入本机存储,可能已满": "로컬 저장소에 쓰지 못했습니다. 공간이 가득 찼을 수 있습니다",
|
||||||
|
"({n} 条无法读取)": " (읽을 수 없는 기록 {n}건)",
|
||||||
|
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "가져오기 실패: 더 최신 버전 클라이언트에서 내보낸 파일입니다. 먼저 클라이언트를 업데이트하세요",
|
||||||
|
"清空统计数据": "통계 데이터 비우기",
|
||||||
|
"将删除本机保存的 {n} 条对局记录,无法恢复。": "이 기기에 저장된 기록 {n}건을 삭제합니다. 되돌릴 수 없습니다.",
|
||||||
|
"导出为文件(可在别的设备上导入)": "파일로 내보내기 (다른 기기에서 가져올 수 있습니다)",
|
||||||
|
"从导出的文件导入(与现有记录合并)": "내보낸 파일에서 가져오기 (기존 기록과 병합)",
|
||||||
|
"清空本机的统计数据": "이 기기의 통계 데이터 비우기",
|
||||||
|
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "이 기기의 데이터: 이 브라우저에만 저장되며 서버로 전송되지 않습니다. 동맹 시뮬레이션에서 함께한 동료의 이름도 이 기기에 저장됩니다(내보낸 파일에도 포함).",
|
||||||
|
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "이 기기에 저장된 통계 데이터를 읽을 수 없습니다. 백업으로 따로 보관했으며 지금부터 새로 기록합니다.",
|
||||||
|
"有 {n} 条记录已损坏,已忽略。": "손상된 기록 {n}건을 무시했습니다."
|
||||||
}
|
}
|
||||||
+26
-5
@@ -1062,7 +1062,6 @@
|
|||||||
"生命值减少 −{n}": "生命值減少 −{n}",
|
"生命值减少 −{n}": "生命值減少 −{n}",
|
||||||
"统计数据": "統計資料",
|
"统计数据": "統計資料",
|
||||||
"统计": "統計",
|
"统计": "統計",
|
||||||
"对局进行中,暂不能回看历史结算": "對局進行中,暫時無法回看歷史結算",
|
|
||||||
"总览": "總覽",
|
"总览": "總覽",
|
||||||
"总局数": "總局數",
|
"总局数": "總局數",
|
||||||
"胜场": "勝場",
|
"胜场": "勝場",
|
||||||
@@ -1080,7 +1079,6 @@
|
|||||||
"通过率": "通過率",
|
"通过率": "通過率",
|
||||||
"称号": "稱號",
|
"称号": "稱號",
|
||||||
"战斗累计": "戰鬥累計",
|
"战斗累计": "戰鬥累計",
|
||||||
"最近对局": "最近對局",
|
|
||||||
"时间": "時間",
|
"时间": "時間",
|
||||||
"模式": "模式",
|
"模式": "模式",
|
||||||
"结果": "結果",
|
"结果": "結果",
|
||||||
@@ -1089,16 +1087,39 @@
|
|||||||
"同盟": "同盟",
|
"同盟": "同盟",
|
||||||
"胜": "勝",
|
"胜": "勝",
|
||||||
"负": "負",
|
"负": "負",
|
||||||
"点击任意一行,回看该局结算。": "點擊任一行,回看該局結算。",
|
|
||||||
"已导出 {n} 条对局记录": "已匯出 {n} 筆對局記錄",
|
"已导出 {n} 条对局记录": "已匯出 {n} 筆對局記錄",
|
||||||
"已导入 {added} 条记录{tail}": "已匯入 {added} 筆記錄{tail}",
|
"已导入 {added} 条记录{tail}": "已匯入 {added} 筆記錄{tail}",
|
||||||
"(跳过重复 {skipped} 条)": "(略過重複 {skipped} 筆)",
|
"(跳过重复 {skipped} 条)": "(略過重複 {skipped} 筆)",
|
||||||
"没有新记录(全部与现有数据重复)": "沒有新記錄(全部與現有資料重複)",
|
"没有新记录(全部与现有数据重复)": "沒有新記錄(全部與現有資料重複)",
|
||||||
"导入失败:不是有效的统计导出文件": "匯入失敗:不是有效的統計匯出檔案",
|
"导入失败:不是有效的统计导出文件": "匯入失敗:不是有效的統計匯出檔案",
|
||||||
"已清空本机统计数据": "已清空本機統計資料",
|
"已清空本机统计数据": "已清空本機統計資料",
|
||||||
"确认清空?": "確認清空?",
|
|
||||||
"本机数据 · 仅记录本浏览器中你参与的对局": "本機資料 · 僅記錄本瀏覽器中你參與的對局",
|
"本机数据 · 仅记录本浏览器中你参与的对局": "本機資料 · 僅記錄本瀏覽器中你參與的對局",
|
||||||
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "偵測到更新版本的統計資料,請先升級用戶端再查看,以免覆蓋資料。",
|
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "偵測到更新版本的統計資料,請先升級用戶端再查看,以免覆蓋資料。",
|
||||||
"还没有记录 —— 完成一局对局后,这里会开始积累。": "還沒有記錄 —— 完成一局對局後,這裡會開始累積。",
|
"还没有记录 —— 完成一局对局后,这里会开始积累。": "還沒有記錄 —— 完成一局對局後,這裡會開始累積。",
|
||||||
"未知": "未知"
|
"未知": "未知",
|
||||||
|
"对局记录": "對局記錄",
|
||||||
|
"{n} 小时": "{n} 小時",
|
||||||
|
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "計入統計:打到結算的對局,以及中途退出前至少清完 1 個回合的對局;更早退出的對局只留在對局記錄裡。",
|
||||||
|
"另有 {n} 局未计入统计": "另有 {n} 局未計入統計",
|
||||||
|
"没有清完第一个回合就退出,不计入统计": "沒有清完第一個回合就退出,不計入統計",
|
||||||
|
"中途退出的对局没有结算可看": "中途退出的對局沒有結算可看",
|
||||||
|
"较早的对局只保留汇总,不能回看结算": "較早的對局只保留彙總,不能回看結算",
|
||||||
|
"中途退出": "中途退出",
|
||||||
|
"不计入统计": "不計入統計",
|
||||||
|
"显示更多(还有 {n} 局)": "顯示更多(還有 {n} 局)",
|
||||||
|
"点击一行,回看该局结算;中途退出的对局只留记录。": "點擊一行,回看該局結算;中途退出的對局只留記錄。",
|
||||||
|
"最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "最多保留最近 {max} 局,只有最新的 {full} 局能回看結算。",
|
||||||
|
"返回统计": "返回統計",
|
||||||
|
"文件过大,无法导入": "檔案過大,無法匯入",
|
||||||
|
"无法写入本机存储,可能已满": "無法寫入本機儲存空間,可能已滿",
|
||||||
|
"({n} 条无法读取)": "({n} 筆無法讀取)",
|
||||||
|
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "匯入失敗:這份檔案由更新版本的用戶端匯出,請先升級用戶端",
|
||||||
|
"清空统计数据": "清空統計資料",
|
||||||
|
"将删除本机保存的 {n} 条对局记录,无法恢复。": "將刪除本機保存的 {n} 筆對局記錄,無法復原。",
|
||||||
|
"导出为文件(可在别的设备上导入)": "匯出為檔案(可在別的裝置上匯入)",
|
||||||
|
"从导出的文件导入(与现有记录合并)": "從匯出的檔案匯入(與現有記錄合併)",
|
||||||
|
"清空本机的统计数据": "清空本機的統計資料",
|
||||||
|
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "本機資料:只保存在這個瀏覽器裡,不會上傳到伺服器;同盟對局裡隊友的代號也會存在本機(匯出檔案裡也有)。",
|
||||||
|
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "本機保存的統計資料無法讀取,已另存為備份,從現在起重新記錄。",
|
||||||
|
"有 {n} 条记录已损坏,已忽略。": "有 {n} 筆記錄已損毀,已略過。"
|
||||||
}
|
}
|
||||||
+3
-2
@@ -50,7 +50,7 @@ import { GuideHost } from './ui/guide.js';
|
|||||||
import { installDeviceSupport } from './ui/device.js';
|
import { installDeviceSupport } from './ui/device.js';
|
||||||
import { LoadoutHost } from './screens/loadout.js';
|
import { LoadoutHost } from './screens/loadout.js';
|
||||||
import { StatsHost } from './screens/stats.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 { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/loadoutSync.js';
|
||||||
import { startBuildGuard } from './ui/buildGuard.js';
|
import { startBuildGuard } from './ui/buildGuard.js';
|
||||||
import { initLang, useLang, tickerText } from './ui/lang.js';
|
import { initLang, useLang, tickerText } from './ui/lang.js';
|
||||||
@@ -224,7 +224,7 @@ function wireNet() {
|
|||||||
net.on('m.result', (msg) => {
|
net.on('m.result', (msg) => {
|
||||||
const res = payload(msg);
|
const res = payload(msg);
|
||||||
store.patch('match', { result: res });
|
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)
|
// 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() });
|
recordResult(res, { myId: store.get().me.playerId, roomMode: store.get().room?.mode ?? null, now: Date.now() });
|
||||||
});
|
});
|
||||||
@@ -348,6 +348,7 @@ async function boot() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
wireNet();
|
wireNet();
|
||||||
|
installStatsRecorder(store); // follows the match on screen, so a 放弃模拟 can be recorded (ui/stats.js)
|
||||||
installLoadoutSync({ net });
|
installLoadoutSync({ net });
|
||||||
installOwnershipSync({ net });
|
installOwnershipSync({ net });
|
||||||
installDiySync({ net });
|
installDiySync({ net });
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ export function LobbyScreen() {
|
|||||||
<h1 class="topbar__title">${t('选择模拟协议')}</h1>
|
<h1 class="topbar__title">${t('选择模拟协议')}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar__right">
|
<div class="topbar__right">
|
||||||
<${Button} variant="secondary" size="sm" icon="signal" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
|
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
|
||||||
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
|
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
|
||||||
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
|
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
|
||||||
<div class="me-chip">
|
<div class="me-chip">
|
||||||
|
|||||||
@@ -103,12 +103,22 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
|
|||||||
</article>`;
|
</article>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RESULT screen. */
|
/** RESULT screen of the match on screen: reads the store; 返回同盟 / 返回大厅 drops the match. */
|
||||||
export function ResultScreen() {
|
export function ResultScreen() {
|
||||||
const res = useStore((s) => s.match.result);
|
const res = useStore((s) => s.match.result);
|
||||||
const pub = useStore((s) => s.match.public);
|
const pub = useStore((s) => s.match.public);
|
||||||
const myId = useStore((s) => s.me.playerId);
|
const myId = useStore((s) => s.me.playerId);
|
||||||
const hasRoom = useStore((s) => !!s.room);
|
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 gd = useGameData();
|
||||||
const r = normalizeResult(res, pub);
|
const r = normalizeResult(res, pub);
|
||||||
const titles = Array.isArray(gd.config?.titles) ? gd.config.titles : [];
|
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 };
|
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;
|
if (top && r.players.length > 1) best[k] = top.id;
|
||||||
}
|
}
|
||||||
useEffect(() => { audio.sfx(r.victory ? 'settlementSucceed' : 'settlementFail'); }, []);
|
useEffect(() => { if (!quiet) audio.sfx(r.victory ? 'settlementSucceed' : 'settlementFail'); }, []);
|
||||||
const back = () => store.set({ match: emptyMatch() });
|
|
||||||
const boss = r.bossId ? gd.boss(r.bossId) : null;
|
const boss = r.bossId ? gd.boss(r.bossId) : null;
|
||||||
// the Hidden Core medal (and its corrupted leader) only once R15 was actually fought
|
// 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;
|
const hidden = r.hiddenBossId && r.hiddenReached ? gd.boss(r.hiddenBossId) : null;
|
||||||
@@ -150,7 +159,7 @@ export function ResultScreen() {
|
|||||||
</div>
|
</div>
|
||||||
${mins ? html`<p class="result__time t-lo">${tParts('本局耗时 {n} 分钟', { n: html`<b class="num">${mins}</b>`, mins })}</p>` : null}
|
${mins ? html`<p class="result__time t-lo">${tParts('本局耗时 {n} 分钟', { n: html`<b class="num">${mins}</b>`, mins })}</p>` : null}
|
||||||
<footer class="result__foot">
|
<footer class="result__foot">
|
||||||
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${back}>${hasRoom ? t('返回同盟') : t('返回大厅')}<//>
|
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${onBack}>${backLabel}<//>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
<section class="result__players">
|
<section class="result__players">
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ export function RoomScreen() {
|
|||||||
<${PingPill} ms=${conn.ping} online=${online} />
|
<${PingPill} ms=${conn.ping} online=${online} />
|
||||||
<${MicroLabel}>${t('当前延迟')}<//>
|
<${MicroLabel}>${t('当前延迟')}<//>
|
||||||
</div>
|
</div>
|
||||||
<${Button} variant="secondary" size="sm" icon="signal" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
|
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
|
||||||
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
|
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar__center">
|
<div class="topbar__center">
|
||||||
|
|||||||
+253
-209
@@ -1,155 +1,182 @@
|
|||||||
// The local stats viewer (per-browser match record, viewer + data management. Opened imperatively from the
|
// The local stats viewer (统计数据): this browser's record of the matches the player took part in — see ui/stats.js for
|
||||||
// title screen / lobby (`openStats()`); <StatsHost/> is mounted once by main.js like <GuideHost/>.
|
// what is recorded, what counts and how the storage is bounded. A full-screen overlay opened imperatively from the
|
||||||
|
// title screen / lobby / room (`openStats()`); <StatsHost/> is mounted once by main.js. Ported from PR #323 by
|
||||||
|
// @2321Robin; reworked for 0.2.2 in the grammar of 干员调配 (top bar with tabs, a note line, a scrolling body).
|
||||||
//
|
//
|
||||||
// The page aggregates over the records of ui/stats.js (one per finished match the local player took part in):
|
// 总览 – games, wins, win rate, hidden-core reaches / clears, best round, time played; the difficulties; the six
|
||||||
// 总览 – games, wins, win rate, hidden-core reaches / clears, per-difficulty rows
|
// 评语 (titles) with their counts; what counts and what was left out
|
||||||
// 策略 – per band: games used / passed (self alive on a winning team) / pass rate (the user's ask)
|
// 策略 – per strategy (band): games used / passed (self alive on a winning team) / pass rate
|
||||||
// 称号 – each of config.titles' six 评语 with its count
|
// 战斗累计 – the player's summed combat bookkeeping of the settled matches
|
||||||
// 战斗累计– the self player's summed combat bookkeeping
|
// 对局记录 – every stored match, newest first. A settled row re-views that match's settlement screen (the record is
|
||||||
// 最近对局– the newest RECENT_SHOW records as rows; a row click re-views that match as its settlement
|
// inverted into an m.result payload for <ResultView/>); a match given up early is marked 中途退出 and,
|
||||||
// screen (the record is inverted into an m.result payload for <ResultScreen/>)
|
// when it did not count, 不计入统计
|
||||||
// Everything is 本机数据 (localStorage, `sp.pref.stats`): the note under the title says so, and 导出/导入
|
// Everything is 本机数据 (localStorage, `sp.pref.stats`) and the page says so, teammates' names included. 导出 / 导入 (a
|
||||||
// (JSON file, merge-by-record-id on the way in) moves it between devices; 清空 resets after a confirm press.
|
// 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 { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||||
import { DIFFICULTY_NAMES, PHASE } from '../../../shared/constants.js';
|
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS } from '../../../shared/constants.js';
|
||||||
import { html, Icon, MicroLabel, Spinner, Button } from '../ui/components.js';
|
import { html, Icon, MicroLabel, Spinner, Button, confirmDialog } from '../ui/components.js';
|
||||||
import { useGameData, BandIcon, Img } from '../ui/gameComponents.js';
|
import { useGameData, makeLookups, BandIcon, Img } from '../ui/gameComponents.js';
|
||||||
import { data, useData } from '../data.js';
|
import { data, useData } from '../data.js';
|
||||||
import { fmtNum } from '../ui/gameLogic.js';
|
import { fmtNum } from '../ui/gameLogic.js';
|
||||||
import { titleIconUrl } from '../ui/assetUrls.js';
|
import { titleIconUrl } from '../ui/assetUrls.js';
|
||||||
import { createStore, useStore, store, emptyMatch } from '../store.js';
|
import { useDocClass } from '../ui/device.js';
|
||||||
import { ResultScreen } from './result.js';
|
import { createStore, useStore, selectRoute } from '../store.js';
|
||||||
|
import { ResultView } from './result.js';
|
||||||
import { toast, toastError } from '../ui/toasts.js';
|
import { toast, toastError } from '../ui/toasts.js';
|
||||||
import {
|
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';
|
} from '../ui/stats.js';
|
||||||
import { t, N_ } from '../../../shared/i18n.js';
|
import { t, N_ } from '../../../shared/i18n.js';
|
||||||
|
|
||||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
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() {
|
export function openStats() {
|
||||||
data.load('config');
|
data.load('config');
|
||||||
data.load('bands');
|
data.load('bands');
|
||||||
data.load('assets');
|
data.load('assets');
|
||||||
statsStore.set({ open: true });
|
statsStore.set({ open: true, tab: 'overview' });
|
||||||
}
|
}
|
||||||
export const closeStats = () => {
|
export const closeStats = () => statsStore.set({ open: false });
|
||||||
// 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 });
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
const TABS = [
|
||||||
* 最近对局行点击: one stored match re-viewed as its settlement screen, INSIDE the stats viewer — the
|
{ id: 'overview', name: N_('总览'), micro: 'OVERVIEW' },
|
||||||
* record is inverted into an m.result payload (ui/stats.js recordToResult) into store.match.result, and
|
{ id: 'bands', name: N_('策略'), micro: 'STRATEGIES' },
|
||||||
* StatsHost swaps its panel for the real <ResultScreen/> while a result exists (the settlement's
|
{ id: 'totals', name: N_('战斗累计'), micro: 'COMBAT TOTALS' },
|
||||||
* 返回大厅 empties match again → back to the list, ready to view the next row). Refused while a match
|
{ id: 'history', name: N_('对局记录'), micro: 'HISTORY' },
|
||||||
* 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 pct = (n, d) => (d > 0 ? `${Math.round((n / d) * 100)}%` : '—');
|
||||||
const dateTime = (t) => {
|
const dateTime = (ms) => {
|
||||||
if (!t) return '—';
|
if (!ms) return '—';
|
||||||
const d = new Date(t);
|
const d = new Date(ms);
|
||||||
const p = (n) => String(n).padStart(2, '0');
|
const p = (n) => String(n).padStart(2, '0');
|
||||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
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. */
|
/** One labelled number card. */
|
||||||
function StatCard({ label, micro, value, tone }) {
|
function StatCard({ label, micro, value, tone }) {
|
||||||
return html`<div class=${cx('stcard', tone && `stcard--${tone}`)}>
|
return html`<div class=${cx('st-card', tone && `st-card--${tone}`)}>
|
||||||
<${MicroLabel} tone=${tone === 'gold' ? 'gold' : 'mint'}>${micro}<//>
|
<${MicroLabel} tone=${tone === 'gold' ? 'gold' : 'mint'}>${micro}<//>
|
||||||
<b class="stcard__num num">${value}</b>
|
<b class="st-card__num num">${value}</b>
|
||||||
<span class="stcard__label">${label}</span>
|
<span class="st-card__label">${label}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ title, micro, children }) {
|
function Section({ title, micro, children, class: cls }) {
|
||||||
return html`<section class="stats-sec">
|
return html`<section class=${cx('st-sec', cls)}>
|
||||||
<h3 class="stats-sec__h"><span>${title}</span><${MicroLabel}>${micro}<//></h3>
|
<h3 class="st-sec__h"><span>${title}</span><${MicroLabel}>${micro}<//></h3>
|
||||||
${children}
|
${children}
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 总览: cards + the per-difficulty table. */
|
/** 总览: cards, the per-difficulty table, the six titles, and the line about what counts. */
|
||||||
function Overview({ agg }) {
|
function Overview({ agg, gd }) {
|
||||||
const rows = Object.entries(agg.byDifficulty).sort((a, b) => b[1].games - a[1].games);
|
const rows = DIFFICULTIES.filter((d) => agg.byDifficulty[d]).concat(Object.keys(agg.byDifficulty).filter((d) => !DIFFICULTIES.includes(d)));
|
||||||
const playedMins = minsOf(agg.duration.totalMs);
|
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">
|
return html`<${Section} title=${t('总览')} micro="OVERVIEW">
|
||||||
<div class="stats-cards">
|
<div class="st-cards">
|
||||||
<${StatCard} micro="GAMES" value=${fmtNum(agg.count)} label=${t('总局数')} />
|
<${StatCard} micro="GAMES" value=${fmtNum(agg.count)} label=${t('总局数')} />
|
||||||
<${StatCard} micro="WINS" value=${fmtNum(agg.wins)} label=${t('胜场')} tone="mint" />
|
<${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="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 CORE" value=${fmtNum(agg.hidden.reached)} label=${t('到达隐秘核心')} />
|
||||||
<${StatCard} micro="HIDDEN CLEAR" value=${fmtNum(agg.hidden.cleared)} label=${t('通关隐秘核心')} />
|
<${StatCard} micro="HIDDEN CLEAR" value=${fmtNum(agg.hidden.cleared)} label=${t('通关隐秘核心')} />
|
||||||
<${StatCard} micro="BEST ROUND" value=${fmtNum(agg.rounds.max)} label=${t('最远回合')} />
|
<${StatCard} micro="BEST ROUND" value=${fmtNum(agg.rounds.max)} label=${t('最远回合')} />
|
||||||
${playedMins != null ? html`<${StatCard} micro="TIME PLAYED" value=${t('{n} 分', { n: fmtNum(playedMins) })} label=${t('累计时长')} />` : null}
|
<${StatCard} micro="TIME PLAYED" value=${playedText(agg.duration.totalMs)} label=${t('累计时长')} />
|
||||||
</div>
|
</div>
|
||||||
${rows.length ? html`<table class="stats-table">
|
<p class="st-split"><span>${t('单人')} <b class="num">${fmtNum(agg.byMode.solo)}</b></span><span>${t('同盟')} <b class="num">${fmtNum(agg.byMode.coop)}</b></span></p>
|
||||||
|
${rows.length ? html`<div class="st-tablewrap"><table class="st-table">
|
||||||
<thead><tr><th>${t('难度')}</th><th class="num">${t('局数')}</th><th class="num">${t('胜场')}</th><th class="num">${t('胜率')}</th><th class="num">${t('隐秘核心通关')}</th></tr></thead>
|
<thead><tr><th>${t('难度')}</th><th class="num">${t('局数')}</th><th class="num">${t('胜场')}</th><th class="num">${t('胜率')}</th><th class="num">${t('隐秘核心通关')}</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${rows.map(([d, v]) => html`<tr key=${d}>
|
${rows.map((d) => {
|
||||||
<td>${DIFFICULTY_NAMES[d] || d}</td>
|
const v = agg.byDifficulty[d];
|
||||||
<td class="num">${fmtNum(v.games)}</td>
|
return html`<tr key=${d}>
|
||||||
<td class="num">${fmtNum(v.wins)}</td>
|
<td class="st-diff" style=${`--d-color:${DIFFICULTY_COLORS[d] || 'var(--text-lo)'}`}>${difficultyName(d)}</td>
|
||||||
<td class="num">${pct(v.wins, v.games)}</td>
|
<td class="num">${fmtNum(v.games)}</td>
|
||||||
<td class="num">${fmtNum(v.hiddenCleared)}</td>
|
<td class="num">${fmtNum(v.wins)}</td>
|
||||||
</tr>`)}
|
<td class="num">${pct(v.wins, v.games)}</td>
|
||||||
|
<td class="num">${fmtNum(v.hiddenCleared)}</td>
|
||||||
|
</tr>`;
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>` : null}
|
</table></div>` : null}
|
||||||
|
${titles.length ? html`<h4 class="st-sub">${t('称号')}</h4>
|
||||||
|
<div class="st-titles">
|
||||||
|
${titles.map((x) => html`<div key=${x.id} class=${cx('st-title', !x.count && 'is-none')} title=${x.text || ''}>
|
||||||
|
<${Img} src=${titleIconUrl(gd.m, x.picId)} class="st-title__icon" fallback=${html`<${Icon} name="crown" />`} />
|
||||||
|
<div class="st-title__text"><span>${x.name}</span><b class="num">${fmtNum(x.count)}</b></div>
|
||||||
|
</div>`)}
|
||||||
|
</div>` : null}
|
||||||
|
<p class="st-rule"><${Icon} name="info" />${t('计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。')}${agg.excluded
|
||||||
|
? html` <b>${t('另有 {n} 局未计入统计', { n: agg.excluded })}</b>` : null}</p>
|
||||||
<//>`;
|
<//>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 策略: per-band used / passed / pass rate, most used first (the user's headline ask). */
|
/** 策略: per-band used / passed / pass rate, most used first (the user's headline ask). */
|
||||||
function Bands({ agg, gd }) {
|
function Bands({ agg, gd }) {
|
||||||
const rows = Object.entries(agg.bands).sort((a, b) => b[1].games - a[1].games);
|
const rows = Object.entries(agg.bands).sort((a, b) => (b[1].games - a[1].games) || (b[1].wins - a[1].wins));
|
||||||
if (!rows.length) return null;
|
return html`<${Section} title=${t('策略')} micro="STRATEGIES">
|
||||||
return html`<${Section} title=${t('策略')} micro="BANDS">
|
${rows.length ? html`<div class="st-tablewrap"><table class="st-table">
|
||||||
<table class="stats-table">
|
|
||||||
<thead><tr><th>${t('策略')}</th><th class="num">${t('使用局数')}</th><th class="num">${t('通过局数')}</th><th class="num">${t('通过率')}</th></tr></thead>
|
<thead><tr><th>${t('策略')}</th><th class="num">${t('使用局数')}</th><th class="num">${t('通过局数')}</th><th class="num">${t('通过率')}</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${rows.map(([bandId, v]) => {
|
${rows.map(([bandId, v]) => {
|
||||||
const band = gd.band(bandId);
|
const band = gd.band(bandId);
|
||||||
|
const rate = v.games > 0 ? Math.round((v.wins / v.games) * 100) : 0;
|
||||||
return html`<tr key=${bandId}>
|
return html`<tr key=${bandId}>
|
||||||
<td class="stats-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : bandId}</td>
|
<td><span class="st-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : bandId}</span></td>
|
||||||
<td class="num">${fmtNum(v.games)}</td>
|
<td class="num">${fmtNum(v.games)}</td>
|
||||||
<td class="num">${fmtNum(v.wins)}</td>
|
<td class="num">${fmtNum(v.wins)}</td>
|
||||||
<td class="num">${pct(v.wins, v.games)}</td>
|
<td class="num st-rate"><span class="st-bar" aria-hidden="true"><i style=${`width:${rate}%`}></i></span><b>${pct(v.wins, v.games)}</b></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table></div>` : html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
|
||||||
<//>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 称号: all six 评语 with counts (0 dimmed), official order. */
|
|
||||||
function Titles({ agg, gd }) {
|
|
||||||
const list = (Array.isArray(gd.config?.titles) ? gd.config.titles : [])
|
|
||||||
.map((t) => ({ ...t, count: agg.titles[t.id]?.count || 0 }));
|
|
||||||
if (!list.length) return null;
|
|
||||||
return html`<${Section} title=${t('称号')} micro="TITLES">
|
|
||||||
<div class="stats-titles">
|
|
||||||
${list.map((t) => html`<div key=${t.id} class=${cx('stit', !t.count && 'stit--none')} title=${t.text || ''}>
|
|
||||||
<${Img} src=${titleIconUrl(gd.m, t.picId)} class="stit__icon" fallback=${html`<${Icon} name="crown" />`} />
|
|
||||||
<div class="stit__text"><span>${t.name}</span><b class="num">${fmtNum(t.count)}</b></div>
|
|
||||||
</div>`)}
|
|
||||||
</div>
|
|
||||||
<//>`;
|
<//>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,158 +186,175 @@ const SUM_ROWS = [
|
|||||||
['refreshes', N_('刷新次数')], ['leaks', N_('未击倒')], ['lpLost', N_('损失生命')],
|
['refreshes', N_('刷新次数')], ['leaks', N_('未击倒')], ['lpLost', N_('损失生命')],
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 战斗累计: the self player's sums. */
|
/** 战斗累计: the self player's sums over the settled matches. */
|
||||||
function CombatSums({ agg }) {
|
function CombatSums({ agg }) {
|
||||||
const rows = SUM_ROWS.filter(([k]) => Number.isFinite(agg.sums[k]));
|
const rows = SUM_ROWS.filter(([k]) => Number.isFinite(agg.sums[k]));
|
||||||
if (!rows.length) return null;
|
|
||||||
return html`<${Section} title=${t('战斗累计')} micro="COMBAT TOTALS">
|
return html`<${Section} title=${t('战斗累计')} micro="COMBAT TOTALS">
|
||||||
<div class="stats-cards stats-cards--sums">
|
${rows.length ? html`<div class="st-sums">
|
||||||
${rows.map(([k, label]) => html`<div key=${k} class="ssum"><span>${t(label)}</span><b class="num">${fmtNum(agg.sums[k])}</b></div>`)}
|
${rows.map(([k, label]) => html`<div key=${k} class="st-sum"><span>${t(label)}</span><b class="num">${fmtNum(agg.sums[k])}</b></div>`)}
|
||||||
</div>
|
</div>` : html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
|
||||||
<//>`;
|
<//>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 最近对局: newest RECENT_SHOW records (records are stored newest first). A row click re-views that
|
/** One 对局记录 row: what it was, who won, whether it counted and whether the settlement can be re-viewed. */
|
||||||
match as its settlement screen (openRecordResult → match.result → <ResultScreen/>). */
|
function HistoryRow({ rec, gd, onOpen }) {
|
||||||
function Recent({ records, gd }) {
|
const self = selfRowOf(rec);
|
||||||
const rows = records.slice(0, RECENT_SHOW);
|
const quit = rec.end === 'quit';
|
||||||
if (!rows.length) return null;
|
const counted = countsTowardStats(rec);
|
||||||
return html`<${Section} title=${t('最近对局')} micro=${`RECENT · ${records.length}`}>
|
const won = self ? !!self.victory : !!rec.victory;
|
||||||
<table class="stats-table">
|
const band = self?.bandId ? gd.band(self.bandId) : null;
|
||||||
|
const rounds = roundsOf(rec);
|
||||||
|
const known = Number.isFinite(self?.roundsPassed) || Number.isFinite(rec.roundsPassed);
|
||||||
|
const view = !quit && !rec.compact; // a quit has nothing settled; a compacted record no rows left
|
||||||
|
const open = () => onOpen(rec);
|
||||||
|
return html`<tr class=${cx(quit ? 'is-quit' : won ? 'is-win' : 'is-lose', !counted && 'is-excluded', view && 'is-clickable')}
|
||||||
|
title=${view ? t('点击查看该局结算') : !counted ? t('没有清完第一个回合就退出,不计入统计') : quit ? t('中途退出的对局没有结算可看') : t('较早的对局只保留汇总,不能回看结算')}
|
||||||
|
tabIndex=${view ? 0 : undefined}
|
||||||
|
onClick=${view ? open : undefined} onKeyDown=${view ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } } : undefined}>
|
||||||
|
<td class="st-t">${dateTime(rec.t)}</td>
|
||||||
|
<td>${difficultyName(rec.difficulty)}</td>
|
||||||
|
<td>${rec.roomMode === 'solo' ? t('单人') : rec.roomMode === 'coop' ? t('同盟') : '—'}</td>
|
||||||
|
<td><span class="st-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : self?.bandId || '—'}</span></td>
|
||||||
|
<td class="st-res">${quit ? html`<span class="t-quit">${t('中途退出')}</span>` : html`<span class=${won ? 't-win' : 't-lose'}>${won ? t('胜') : t('负')}</span>`}
|
||||||
|
${!counted ? html`<em class="st-chip">${t('不计入统计')}</em>` : null}</td>
|
||||||
|
<td class="num">${known ? fmtNum(rounds) : '—'}</td>
|
||||||
|
<td class="st-go" aria-hidden="true">${view ? html`<${Icon} name="chevronRight" />` : null}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对局记录: every stored match, newest first, HISTORY_PAGE rows at a time. */
|
||||||
|
function History({ records, gd, onOpen }) {
|
||||||
|
const [shown, setShown] = useState(HISTORY_PAGE);
|
||||||
|
const rows = records.slice(0, shown);
|
||||||
|
return html`<${Section} title=${t('对局记录')} micro=${`HISTORY · ${records.length}`}>
|
||||||
|
${rows.length ? html`<div class="st-tablewrap"><table class="st-table st-table--history">
|
||||||
<thead><tr><th>${t('时间')}</th><th>${t('难度')}</th><th>${t('模式')}</th><th>${t('策略')}</th><th>${t('结果')}</th><th class="num">${t('回合')}</th><th aria-hidden="true"></th></tr></thead>
|
<thead><tr><th>${t('时间')}</th><th>${t('难度')}</th><th>${t('模式')}</th><th>${t('策略')}</th><th>${t('结果')}</th><th class="num">${t('回合')}</th><th aria-hidden="true"></th></tr></thead>
|
||||||
<tbody>
|
<tbody>${rows.map((r) => html`<${HistoryRow} key=${r.id} rec=${r} gd=${gd} onOpen=${onOpen} />`)}</tbody>
|
||||||
${rows.map((r) => {
|
</table></div>
|
||||||
const self = r.players.find((p) => p.playerId === r.selfId) || r.players[0] || null;
|
${records.length > shown ? html`<div class="st-more"><${Button} size="sm" variant="secondary" onClick=${() => setShown(shown + HISTORY_PAGE)}>${t('显示更多(还有 {n} 局)', { n: records.length - shown })}<//></div>` : null}
|
||||||
const won = self ? !!self.victory : !!r.victory;
|
<p class="st-hint">${t('点击一行,回看该局结算;中途退出的对局只留记录。')} ${t('最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。', { max: MAX_RECORDS, full: FULL_KEEP })}</p>`
|
||||||
const band = self?.bandId ? gd.band(self.bandId) : null;
|
: html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
|
||||||
return html`<tr key=${r.id} class=${cx('is-clickable', won ? 'is-win' : 'is-lose')} title=${t('点击查看该局结算')}
|
|
||||||
onClick=${() => openRecordResult(r)}>
|
|
||||||
<td class="stats-t">${dateTime(r.t)}</td>
|
|
||||||
<td>${DIFFICULTY_NAMES[r.difficulty] || r.difficulty || '—'}</td>
|
|
||||||
<td>${r.roomMode === 'solo' ? t('单人') : r.roomMode === 'coop' ? t('同盟') : '—'}</td>
|
|
||||||
<td>${band ? band.name : self?.bandId || '—'}</td>
|
|
||||||
<td class=${won ? 't-win' : 't-lose'}>${won ? t('胜') : t('负')}</td>
|
|
||||||
<td class="num">${fmtNum(r.roundsPassed)}</td>
|
|
||||||
<td class="stats-go"><${Icon} name="chevronRight" /></td>
|
|
||||||
</tr>`;
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="stats-hint">${t('点击任意一行,回看该局结算。')}</p>
|
|
||||||
<//>`;
|
<//>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导出 / 导入 / 清空, with the two-step clear confirm. */
|
/** A stored match re-viewed as its settlement screen: the full game data is loaded now (the lobby alone never loads it). */
|
||||||
function DataActions({ stats, onReload }) {
|
function Replay({ rec, onBack }) {
|
||||||
|
const gd = useGameData();
|
||||||
|
const res = useMemo(() => recordToResult(rec), [rec]);
|
||||||
|
if (!gd.ready) return html`<div class="st-loading"><${Spinner} size="md" /></div>`;
|
||||||
|
return html`<${ResultView} res=${res} pub=${null} myId=${rec.selfId} backLabel=${t('返回统计')} onBack=${onBack} quiet=${true} />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The overlay, rendered while open (its data hooks run only then). */
|
||||||
|
function StatsScreen({ tab }) {
|
||||||
|
const ready = useData('config', 'bands', 'assets');
|
||||||
|
const gd = useMemo(() => makeLookups(ready), [ready, data.locale()]);
|
||||||
|
const [stats, setStats] = useState(() => loadStats());
|
||||||
|
const [replay, setReplay] = useState(null);
|
||||||
|
const boxRef = useRef(null);
|
||||||
const fileRef = useRef(null);
|
const 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(() => {
|
useEffect(() => {
|
||||||
if (!confirming) return undefined;
|
const onKey = (e) => {
|
||||||
const t = setTimeout(() => setConfirming(false), 3000);
|
if (e.key !== 'Escape' || document.querySelector('.modal')) return; // a confirm dialog on top takes the Esc
|
||||||
return () => clearTimeout(t);
|
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
|
||||||
}, [confirming]);
|
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 = () => {
|
const doExport = () => {
|
||||||
try {
|
try {
|
||||||
const payload = exportStats(stats);
|
const payload = exportStats(stats);
|
||||||
const blob = new Blob([JSON.stringify(payload, null, 1)], { type: 'application/json' });
|
downloadText(exportFilename(), JSON.stringify(payload));
|
||||||
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');
|
toast(t('已导出 {n} 条对局记录', { n: payload.records.length }), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err);
|
toastError(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const doFile = async (e) => {
|
const doFile = async (e) => {
|
||||||
const file = e.currentTarget.files?.[0];
|
const file = e.currentTarget.files?.[0];
|
||||||
e.currentTarget.value = ''; // allow re-picking the same file
|
e.currentTarget.value = ''; // allow re-picking the same file
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
if (file.size > IMPORT_MAX_CHARS) { toast(t('文件过大,无法导入'), 'error'); return; }
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(await file.text());
|
const raw = JSON.parse(await readFileText(file));
|
||||||
const { stats: merged, added, skipped } = importStats(raw, stats);
|
const { stats: merged, added, skipped, dropped } = importStats(raw, loadStats());
|
||||||
saveStats(merged);
|
if (added && !saveStats(merged)) { toast(t('无法写入本机存储,可能已满'), 'error'); return; }
|
||||||
onReload();
|
reload();
|
||||||
toast(added ? t('已导入 {added} 条记录{tail}', { added, tail: skipped ? t('(跳过重复 {skipped} 条)', { skipped }) : '' }) : t('没有新记录(全部与现有数据重复)'), 'success');
|
const tail = (skipped ? t('(跳过重复 {skipped} 条)', { skipped }) : '') + (dropped ? t('({n} 条无法读取)', { n: dropped }) : '');
|
||||||
|
toast(added ? t('已导入 {added} 条记录{tail}', { added, tail }) : t('没有新记录(全部与现有数据重复)'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err.code === 'stats-newer-version' ? err : new Error(t('导入失败:不是有效的统计导出文件')));
|
toast(err?.code === 'stats-newer-version' ? t('导入失败:这份文件由更新版本的客户端导出,请先升级客户端') : t('导入失败:不是有效的统计导出文件'), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const doClear = async () => {
|
||||||
const doClear = () => {
|
const ok = await confirmDialog({
|
||||||
if (!confirming) { setConfirming(true); return; }
|
title: t('清空统计数据'), micro: 'CLEAR STATISTICS', danger: true, okText: t('清空'),
|
||||||
saveStats(emptyStats());
|
text: t('将删除本机保存的 {n} 条对局记录,无法恢复。', { n: stats.records.length }),
|
||||||
setConfirming(false);
|
});
|
||||||
onReload();
|
if (!ok) return;
|
||||||
|
if (!saveStats(emptyStats())) { try { globalThis.localStorage?.removeItem('sp.pref.stats'); } catch { /* ignore */ } }
|
||||||
|
reload();
|
||||||
toast(t('已清空本机统计数据'), 'success');
|
toast(t('已清空本机统计数据'), 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
return html`<div class="stats-actions">
|
if (replay) return html`<div class="st st--replay" ref=${boxRef} tabindex="-1" role="dialog" aria-modal="true" aria-label=${t('统计数据')}>
|
||||||
<${Button} size="sm" icon="exit" onClick=${doExport}>${t('导出')}<//>
|
<${Replay} rec=${replay} onBack=${() => setReplay(null)} />
|
||||||
<${Button} size="sm" icon="plus" onClick=${() => fileRef.current?.click()}>${t('导入')}<//>
|
|
||||||
<input ref=${fileRef} type="file" accept="application/json,.json" hidden onChange=${doFile} />
|
|
||||||
<${Button} size="sm" variant="danger" icon=${confirming ? 'warn' : 'close'} class=${cx(confirming && 'stats-actions__confirm')}
|
|
||||||
onClick=${doClear}>${confirming ? t('确认清空?') : t('清空')}<//>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const tabInfo = TABS.find((x) => x.id === tab) || TABS[0];
|
||||||
* Global stats viewer host — mounted once by main.js. Waits only on the files the page reads (config for the
|
const empty = !stats.records.length;
|
||||||
* title list, bands for names/icons, assets for the picture URLs), not all of useGameData's files.
|
return html`<div class="st" role="dialog" aria-modal="true" aria-label=${t('统计数据')} tabindex="-1" ref=${boxRef}>
|
||||||
*/
|
<div class="st__bg" aria-hidden="true"></div>
|
||||||
export function StatsHost() {
|
<header class="st-top">
|
||||||
const { open } = useStore((s) => s, Object.is, statsStore);
|
<div class="st-top__left">
|
||||||
const replaying = useStore((s) => !!s.match.result); // a replayed settlement replaces the whole panel
|
<${Button} variant="ghost" size="md" icon="chevronLeft" class="st-back" onClick=${closeStats} aria-label=${t('返回')} title=${t('返回 (Esc)')}>${t('返回')}<//>
|
||||||
const ready = useData('config', 'bands', 'assets');
|
|
||||||
const gd = useGameData();
|
|
||||||
const [stats, setStats] = useState(() => emptyStats());
|
|
||||||
const boxRef = useRef(null);
|
|
||||||
const reload = () => setStats(loadStats());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return undefined;
|
|
||||||
setStats(loadStats());
|
|
||||||
const onKey = (e) => {
|
|
||||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); closeStats(); }
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', onKey, true);
|
|
||||||
const t = setTimeout(() => boxRef.current?.focus?.(), 30);
|
|
||||||
return () => { window.removeEventListener('keydown', onKey, true); clearTimeout(t); };
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
if (replaying) {
|
|
||||||
// the record re-viewed as its settlement (openRecordResult): the real <ResultScreen/>, full-bleed;
|
|
||||||
// its 返回大厅 empties match.result and lands back on the list
|
|
||||||
return html`<div class="stats"><div class="stats__replay"><${ResultScreen} /></div></div>`;
|
|
||||||
}
|
|
||||||
const agg = aggregateStats(stats.records);
|
|
||||||
return html`<div class="stats" role="presentation" onMouseDown=${(e) => { if (e.target === e.currentTarget) closeStats(); }}>
|
|
||||||
<div class="stats__box brackets" role="dialog" aria-modal="true" aria-label=${t('统计数据')} tabindex="-1" ref=${boxRef}>
|
|
||||||
<header class="stats__head">
|
|
||||||
<div class="stats__titles">
|
|
||||||
<${MicroLabel} tone="mint">STATISTICS // LOCAL DATA<//>
|
|
||||||
<h2 class="stats__title">${t('统计数据')}</h2>
|
|
||||||
<span class="stats__note">${t('本机数据 · 仅记录本浏览器中你参与的对局')}</span>
|
|
||||||
</div>
|
|
||||||
${ready ? html`<${DataActions} stats=${stats} onReload=${reload} />` : null}
|
|
||||||
<button type="button" class="stats__close" aria-label=${t('关闭')} title=${t('关闭 (Esc)')} onClick=${closeStats}><${Icon} name="close" /></button>
|
|
||||||
</header>
|
|
||||||
<div class="stats__body">
|
|
||||||
${!ready ? html`<div class="stats__loading"><${Spinner} size="md" /></div>`
|
|
||||||
: stats.newer ? html`<p class="stats-empty">${t('检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。')}</p>`
|
|
||||||
: !stats.records.length ? html`<p class="stats-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`
|
|
||||||
: html`<${Overview} agg=${agg} />
|
|
||||||
<${Bands} agg=${agg} gd=${gd} />
|
|
||||||
<${Titles} agg=${agg} gd=${gd} />
|
|
||||||
<${CombatSums} agg=${agg} />
|
|
||||||
<${Recent} records=${stats.records} gd=${gd} />`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="st-top__center">
|
||||||
|
<${MicroLabel} tone="mint">STATISTICS // ${tabInfo.micro}<//>
|
||||||
|
<div class="st-tabs" role="tablist" aria-label=${t('统计数据')}>
|
||||||
|
${TABS.map((x) => html`<button key=${x.id} type="button" role="tab" aria-selected=${x.id === tab ? 'true' : 'false'} data-tab=${x.id}
|
||||||
|
class=${cx('st-tab', x.id === tab && 'is-on')} onClick=${() => statsStore.set({ tab: x.id })}>${t(x.name)}</button>`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-top__right">
|
||||||
|
<${Button} variant="ghost" size="sm" data-testid="stats-export" disabled=${empty || stats.newer} onClick=${doExport} title=${t('导出为文件(可在别的设备上导入)')}>${t('导出')}<//>
|
||||||
|
<${Button} variant="ghost" size="sm" data-testid="stats-import" disabled=${stats.newer} onClick=${() => fileRef.current?.click()} title=${t('从导出的文件导入(与现有记录合并)')}>${t('导入')}<//>
|
||||||
|
<input ref=${fileRef} type="file" accept="application/json,.json" hidden onChange=${doFile} />
|
||||||
|
<${Button} variant="secondary" size="sm" data-testid="stats-clear" disabled=${empty || stats.newer} onClick=${doClear} title=${t('清空本机的统计数据')}>${t('清空')}<//>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<p class="st-note"><${Icon} name="info" />${t('本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。')}</p>
|
||||||
|
${stats.damaged ? html`<p class="st-note is-warn"><${Icon} name="warn" />${t('本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。')}</p>` : null}
|
||||||
|
${stats.dropped ? html`<p class="st-note is-warn"><${Icon} name="warn" />${t('有 {n} 条记录已损坏,已忽略。', { n: stats.dropped })}</p>` : null}
|
||||||
|
<main class="st-body" data-tab=${tab}>
|
||||||
|
${!ready ? html`<div class="st-loading"><${Spinner} size="md" /></div>`
|
||||||
|
: stats.newer ? html`<p class="st-empty">${t('检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。')}</p>`
|
||||||
|
: empty ? html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`
|
||||||
|
: tab === 'bands' ? html`<${Bands} agg=${agg} gd=${gd} />`
|
||||||
|
: tab === 'totals' ? html`<${CombatSums} agg=${agg} />`
|
||||||
|
: tab === 'history' ? html`<${History} records=${stats.records} gd=${gd} onOpen=${setReplay} />`
|
||||||
|
: html`<${Overview} agg=${agg} gd=${gd} />`}
|
||||||
|
</main>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Global stats viewer host — mounted once by main.js; renders nothing (and loads nothing) until opened. */
|
||||||
|
export function StatsHost() {
|
||||||
|
const { open, tab } = useStore((s) => s, Object.is, statsStore);
|
||||||
|
if (!open) return null;
|
||||||
|
return html`<${StatsScreen} tab=${tab} />`;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -246,7 +246,10 @@ export function TitleScreen() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="title-corner title-corner--tr">
|
<div class="title-corner title-corner--tr">
|
||||||
<div>
|
<div>
|
||||||
<${LangToggle} class="title-lang" />
|
<div class="title-corner__tools">
|
||||||
|
<${Button} variant="ghost" size="sm" icon="chart" class="title-stats" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
|
||||||
|
<${LangToggle} class="title-lang" />
|
||||||
|
</div>
|
||||||
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
|
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,7 +276,6 @@ export function TitleScreen() {
|
|||||||
<span class=${`status-dot ${dotClass}`}></span>
|
<span class=${`status-dot ${dotClass}`}></span>
|
||||||
<span>${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}</span>
|
<span>${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}</span>
|
||||||
${conn.status === 'online' ? html`<${PingPill} ms=${conn.ping} />` : null}
|
${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('玩法说明')} />
|
<${GuideButton} class="title-guide" label=${t('玩法说明')} />
|
||||||
<button type="button" class="title-settings fsbtn tapx" aria-label=${t('设置')} title=${t('设置')}
|
<button type="button" class="title-settings fsbtn tapx" aria-label=${t('设置')} title=${t('设置')}
|
||||||
onClick=${() => setSettingsOpen(true)}><${GIcon} name="gear" /></button>
|
onClick=${() => setSettingsOpen(true)}><${GIcon} name="gear" /></button>
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export const ICONS = {
|
|||||||
users: { d: 'M9 4a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7zM2 20a7 6.5 0 0 1 14 0zM16.5 5a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm.9 8.1A6.5 6 0 0 1 22.5 20H18a8.6 8 0 0 0-2.4-6.1 6 6 0 0 1 1.8-.8z' },
|
users: { d: 'M9 4a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7zM2 20a7 6.5 0 0 1 14 0zM16.5 5a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm.9 8.1A6.5 6 0 0 1 22.5 20H18a8.6 8 0 0 0-2.4-6.1 6 6 0 0 1 1.8-.8z' },
|
||||||
rook: { d: 'M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z' },
|
rook: { d: 'M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z' },
|
||||||
signal: { d: 'M2 17h3v4H2zm6-4h3v8H8zm6-4h3v12h-3zm6-4h3v16h-3z' },
|
signal: { d: 'M2 17h3v4H2zm6-4h3v8H8zm6-4h3v12h-3zm6-4h3v16h-3z' },
|
||||||
|
// 统计 (the stats page's entry): three columns on a baseline — apart from `signal`, the ping bars
|
||||||
|
chart: { d: 'M4 12h4v7H4zm6-6h4v13h-4zm6 3h4v10h-4zM3 20h18v2H3z' },
|
||||||
refresh: { d: 'M12 4a8 8 0 0 1 7.4 5H17v2h6V5h-2v2.3A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8zm0 16a8 8 0 0 1-7.4-5H7v-2H1v6h2v-2.3A10 10 0 0 0 22 12h-2a8 8 0 0 1-8 8z' },
|
refresh: { d: 'M12 4a8 8 0 0 1 7.4 5H17v2h6V5h-2v2.3A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8zm0 16a8 8 0 0 1-7.4-5H7v-2H1v6h2v-2.3A10 10 0 0 0 22 12h-2a8 8 0 0 1-8 8z' },
|
||||||
snow: { d: 'M11 2h2v4.2l2.3-2.3 1.4 1.4-3.7 3.7v2h2l3.7-3.7 1.4 1.4-2.3 2.3H22v2h-4.2l2.3 2.3-1.4 1.4-3.7-3.7h-2v2l3.7 3.7-1.4 1.4-2.3-2.3V22h-2v-4.2l-2.3 2.3-1.4-1.4 3.7-3.7v-2H9l-3.7 3.7-1.4-1.4L6.2 13H2v-2h4.2L3.9 8.7l1.4-1.4L9 11h2V9L7.3 5.3l1.4-1.4L11 6.2z' },
|
snow: { d: 'M11 2h2v4.2l2.3-2.3 1.4 1.4-3.7 3.7v2h2l3.7-3.7 1.4 1.4-2.3 2.3H22v2h-4.2l2.3 2.3-1.4 1.4-3.7-3.7h-2v2l3.7 3.7-1.4 1.4-2.3-2.3V22h-2v-4.2l-2.3 2.3-1.4-1.4 3.7-3.7v-2H9l-3.7 3.7-1.4-1.4L6.2 13H2v-2h4.2L3.9 8.7l1.4-1.4L9 11h2V9L7.3 5.3l1.4-1.4L11 6.2z' },
|
||||||
chevronRight: { d: 'M8.6 5 7.2 6.4 12.8 12l-5.6 5.6L8.6 19l7-7z' },
|
chevronRight: { d: 'M8.6 5 7.2 6.4 12.8 12l-5.6 5.6L8.6 19l7-7z' },
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { net } from '../net.js';
|
|||||||
import { store, useStore, shallowEqual, emptyMatch, createStore, isSpectating } from '../store.js';
|
import { store, useStore, shallowEqual, emptyMatch, createStore, isSpectating } from '../store.js';
|
||||||
import { GIcon } from './gameComponents.js';
|
import { GIcon } from './gameComponents.js';
|
||||||
import { GuideButton } from './guide.js';
|
import { GuideButton } from './guide.js';
|
||||||
|
import { recordQuit } from './stats.js';
|
||||||
import { t, tParts, N_ } from '../../../shared/i18n.js';
|
import { t, tParts, N_ } from '../../../shared/i18n.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,18 +38,27 @@ export const awayEnds = (s, prev) => (!!prev?.match?.public && !s?.match?.public
|
|||||||
store.subscribe((s, prev) => { if (awayStore.get().away && awayEnds(s, prev)) awayStore.set({ away: false }); });
|
store.subscribe((s, prev) => { if (awayStore.get().away && awayEnds(s, prev)) awayStore.set({ away: false }); });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leave the match for good (g.leave → room.leave), then drop local room/match state.
|
* Leave the match for good (g.leave → room.leave), then drop local room/match state. The server sends a leaver no
|
||||||
|
* settlement, so the page records the match itself before it is cleared (本机统计, ui/stats.js recordQuit: a 中途退出
|
||||||
|
* entry of the history; it counts only when a round had been cleared) — once the leave went through.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export async function quitMatch() {
|
export async function quitMatch() {
|
||||||
|
const before = store.get();
|
||||||
|
let left = false;
|
||||||
try {
|
try {
|
||||||
try { await net.request('g.leave', {}); } catch (err) {
|
try { await net.request('g.leave', {}); } catch (err) {
|
||||||
if (err?.code !== 'NOT_IN_ROOM' && err?.code !== 'WRONG_PHASE' && err?.code !== 'OFFLINE') throw err;
|
if (err?.code !== 'NOT_IN_ROOM' && err?.code !== 'WRONG_PHASE' && err?.code !== 'OFFLINE') throw err;
|
||||||
}
|
}
|
||||||
try { await net.request('room.leave', {}); } catch (err) { if (err?.code !== 'NOT_IN_ROOM' && err?.code !== 'OFFLINE') throw err; }
|
try { await net.request('room.leave', {}); } catch (err) { if (err?.code !== 'NOT_IN_ROOM' && err?.code !== 'OFFLINE') throw err; }
|
||||||
|
left = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err);
|
toastError(err);
|
||||||
} finally {
|
} finally {
|
||||||
|
// (a settlement that arrived while the leave was in flight is the match's record already)
|
||||||
|
if (left && !store.get().match.result) {
|
||||||
|
try { recordQuit(before); } catch (err) { console.warn('[stats] quit record failed', err); }
|
||||||
|
}
|
||||||
store.set({ room: null, match: emptyMatch() });
|
store.set({ room: null, match: emptyMatch() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+387
-112
@@ -1,35 +1,56 @@
|
|||||||
// Local match statistics: every finished match the local player took part in is appended as one record to
|
// Local match statistics: every match the local player took part in is appended as one record to localStorage
|
||||||
// localStorage (`sp.pref.stats`, the loadPref/savePref convention of store.js). Per-browser only — the server
|
// (`sp.pref.stats`). Per-browser only — the server keeps matches in memory and has no identity, so there is nowhere
|
||||||
// keeps matches in memory and has no identity, so there is nowhere else for cross-match numbers to live; the
|
// else for cross-match numbers to live; nothing here is ever sent anywhere. The stats page labels itself 本机数据 and
|
||||||
// stats page labels itself 本机数据 and offers JSON export / import for moving between devices.
|
// offers JSON export / import for moving between devices. (Ported from PR #323 by @2321Robin, reworked for 0.2.2.)
|
||||||
//
|
//
|
||||||
// The record keeps everything the settlement offers (m.result is free-form, server/match/results.js buildResult):
|
// Two kinds of record (`end`):
|
||||||
// the match frame (difficulty, mode, boss, seed, duration, hidden-core outcome, reason) plus one row per player
|
// 'settled' the match reached its settlement: m.result arrived (server/match/results.js buildResult). The record
|
||||||
// — band, title (评语), lineup, bonds and the full stat block — even where today's page aggregates only a part.
|
// keeps what the settlement offers — the match frame (difficulty, mode, boss, seed, duration, hidden-core
|
||||||
// Whatever a future statistics view needs should already be in the record; display is the only thing that lags.
|
// outcome, reason) and one row per player (band, title, lineup, bonds, the stat block) — so the page can
|
||||||
|
// re-view the settlement screen (recordToResult).
|
||||||
|
// 'quit' the player gave up (放弃模拟) before the settlement; the server sends a leaver nothing, so the record is
|
||||||
|
// built from what the client knew at that moment (buildQuitRecord): the frame, the player's own row, the
|
||||||
|
// rounds cleared. It is listed in the history, marked 中途退出, and cannot be re-viewed.
|
||||||
//
|
//
|
||||||
// Format evolution: the envelope carries a version (`STATS_VERSION`); `migrateStats` walks records through a
|
// What counts (countsTowardStats, the owner's decision of 2026-10-08: a match entered and left right away must not
|
||||||
// chain of per-version steps and every loaded / imported record passes `normalizeRecord`, which fills defaults
|
// count): every aggregate — games, wins, win rate, hidden core, best round, time played, the strategy table, titles
|
||||||
// for missing fields and PRESERVES unknown ones — so data written by an older build reads fine after an
|
// and the combat totals — is taken over the records that count. A settled record always counts. A quit record counts
|
||||||
// upgrade, and fields added by a newer writer survive a round-trip through this one. An envelope from a newer
|
// once at least one of the player's own battles had finished — a round cleared (the server's rule: leaving while
|
||||||
// version than this build is refused (never silently downgraded and written back).
|
// round k runs has passed k − 1 rounds, leaving in its settlement k) or already eliminated. A match left earlier is
|
||||||
|
// history only.
|
||||||
//
|
//
|
||||||
// Pure logic, no Preact / DOM: Node-testable (test/ui/stats.test.js); localStorage is touched only through
|
// Bounded storage: at most MAX_RECORDS matches and MAX_CHARS characters of JSON. Only the newest FULL_KEEP records
|
||||||
// loadStats / saveStats.
|
// keep their full rows (lineups, bonds, teammates' names — what the settlement replay needs); older ones are compacted
|
||||||
|
// to the player's own summary row, which is all the aggregates read. The oldest records go first when a budget is hit,
|
||||||
|
// and a write the browser refuses shrinks the list further instead of failing. Teammates' display names are part of
|
||||||
|
// the newest records and of an export file: they stay on this device unless the player exports them.
|
||||||
|
//
|
||||||
|
// 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`, a strict schema: missing fields get
|
||||||
|
// defaults, unknown fields and oversized values are dropped, a row it cannot salvage is dropped. An envelope from a
|
||||||
|
// NEWER version than this build is refused (never downgraded and written back); an unreadable one is set aside
|
||||||
|
// (`sp.pref.stats.damaged`) and the history starts again.
|
||||||
|
//
|
||||||
|
// Pure logic, no Preact / DOM: Node-testable (test/ui/stats.test.js); localStorage is touched only by loadStats /
|
||||||
|
// saveStats / their helpers.
|
||||||
|
|
||||||
import { loadPref } from '../store.js';
|
import { PHASE } from '../../../shared/constants.js';
|
||||||
import { t } from '../../../shared/i18n.js';
|
|
||||||
|
|
||||||
/** Current record-envelope version (bump + add a MIGRATIONS step when the record shape changes). */
|
/** Current record-envelope version (bump + add a MIGRATIONS step when the record shape changes). */
|
||||||
export const STATS_VERSION = 1;
|
export const STATS_VERSION = 1;
|
||||||
/** localStorage key under the app's `sp.pref.` prefix (store.js loadPref). */
|
/** localStorage key under the app's `sp.pref.` prefix (store.js loadPref). */
|
||||||
export const STATS_PREF_KEY = 'stats';
|
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). */
|
/** Oldest records are dropped beyond this many matches. */
|
||||||
export const MAX_RECORDS = 1000;
|
export const MAX_RECORDS = 1000;
|
||||||
/** How many games the page's 最近对局 list shows. */
|
/** The newest records that keep their full rows (lineups, bonds, teammates) — what the settlement replay reads. */
|
||||||
export const RECENT_SHOW = 20;
|
export const FULL_KEEP = 30;
|
||||||
|
/** Budget of the stored JSON in characters (a character costs up to 2 bytes of the browser's ~5 MB quota): the oldest records go first. */
|
||||||
|
export const MAX_CHARS = 800_000;
|
||||||
|
/** Largest export file the page reads. */
|
||||||
|
export const IMPORT_MAX_CHARS = 8_000_000;
|
||||||
|
|
||||||
const FULL_KEY = `sp.pref.${STATS_PREF_KEY}`;
|
const FULL_KEY = `sp.pref.${STATS_PREF_KEY}`;
|
||||||
|
const DAMAGED_KEY = `${FULL_KEY}.damaged`;
|
||||||
|
|
||||||
/** @returns {{ v: number, records: Array }} a fresh empty envelope */
|
/** @returns {{ v: number, records: Array }} a fresh empty envelope */
|
||||||
export const emptyStats = () => ({ v: STATS_VERSION, records: [] });
|
export const emptyStats = () => ({ v: STATS_VERSION, records: [] });
|
||||||
@@ -38,18 +59,32 @@ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|||||||
const int = (v, d) => (Number.isFinite(v) ? Math.round(v) : d);
|
const int = (v, d) => (Number.isFinite(v) ? Math.round(v) : d);
|
||||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||||
const bool = (v, d = false) => (typeof v === 'boolean' ? v : d);
|
const bool = (v, d = false) => (typeof v === 'boolean' ? v : d);
|
||||||
const str = (v) => (typeof v === 'string' && v.length ? v : null);
|
/** A non-empty string, cut to `max` characters. */
|
||||||
|
const str = (v, max = 64) => (typeof v === 'string' && v.length ? v.slice(0, max) : null);
|
||||||
|
|
||||||
|
// per-record bounds (an imported file is foreign data: nothing a row holds may grow without limit)
|
||||||
|
const MAX_PLAYERS = 6; // the game seats 4
|
||||||
|
const MAX_LINEUP = 12;
|
||||||
|
const MAX_ITEMS = 4;
|
||||||
|
const MAX_BONDS = 24;
|
||||||
|
const MAX_STAT_KEYS = 24;
|
||||||
|
const MAX_NAME = 24;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy only the finite numeric keys of a stats block (the settlement's numbers; unknown numeric fields are
|
* Copy only the finite numeric keys of a stats block (the settlement's numbers; a numeric field this build does not
|
||||||
* kept too — display can lag, data must not).
|
* know is kept too, up to MAX_STAT_KEYS — display can lag, data should not).
|
||||||
* @param {any} raw
|
* @param {any} raw
|
||||||
* @returns {Record<string, number>}
|
* @returns {Record<string, number>}
|
||||||
*/
|
*/
|
||||||
function normalizeStatBlock(raw) {
|
function normalizeStatBlock(raw) {
|
||||||
const out = {};
|
const out = {};
|
||||||
if (!isObj(raw)) return out;
|
if (!isObj(raw)) return out;
|
||||||
for (const [k, v] of Object.entries(raw)) if (Number.isFinite(v)) out[k] = v;
|
let n = 0;
|
||||||
|
for (const [k, v] of Object.entries(raw)) {
|
||||||
|
if (!Number.isFinite(v) || k.length > 32) continue;
|
||||||
|
out[k] = v;
|
||||||
|
if (++n >= MAX_STAT_KEYS) break;
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,33 +117,42 @@ export function recordId(res, myId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize ONE player row: known fields defaulted, unknown fields preserved (forward compat).
|
* Normalize ONE player row: known fields defaulted and bounded, unknown fields dropped. `quit`: the record is a quit
|
||||||
|
* record, whose row may not know its rounds (`roundsPassed: null`).
|
||||||
* @param {any} raw
|
* @param {any} raw
|
||||||
|
* @param {boolean} [quit]
|
||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
function normalizePlayerRow(raw) {
|
function normalizePlayerRow(raw, quit = false) {
|
||||||
const p = isObj(raw) ? raw : {};
|
const p = isObj(raw) ? raw : {};
|
||||||
const title = isObj(p.title)
|
// the page and the replay read the title record from the game data by id: the sent texts are not kept
|
||||||
? { id: str(p.title.id) || '', name: str(p.title.name) || '', picId: str(p.title.picId) || '', text: str(p.title.text) || '' }
|
const title = isObj(p.title) && str(p.title.id) ? { id: str(p.title.id) } : null;
|
||||||
: null;
|
const lineup = (Array.isArray(p.lineup) ? p.lineup : []).filter(isObj).slice(0, MAX_LINEUP).map((u) => {
|
||||||
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 (配发装备统计); a 自选
|
||||||
// ids only — the tile position (row/col) is replay detail, not a statistic; items kept (配发装备统计)
|
// pick and a 补位 stand-in kept too, or the replayed card would draw the plain operator
|
||||||
id: str(u.id) || '', golden: bool(u.golden), tier: int(u.tier, 0),
|
const e = {
|
||||||
items: (Array.isArray(u.items) ? u.items : []).filter((i) => typeof i === 'string' && i.length),
|
id: str(u.id) || '', golden: bool(u.golden), tier: int(u.tier, 0),
|
||||||
})).filter((u) => u.id);
|
items: (Array.isArray(u.items) ? u.items : []).filter((i) => typeof i === 'string' && i.length).slice(0, MAX_ITEMS).map((i) => i.slice(0, 64)),
|
||||||
const bonds = (Array.isArray(p.bonds) ? p.bonds : []).filter(isObj).map((b) => ({
|
};
|
||||||
|
if (u.kind === 'token') e.kind = 'token';
|
||||||
|
if (isObj(u.diy) && str(u.diy.charId)) {
|
||||||
|
e.diy = { charId: str(u.diy.charId), skillIndex: Number.isInteger(u.diy.skillIndex) ? u.diy.skillIndex : 0, uniEquipId: str(u.diy.uniEquipId) };
|
||||||
|
}
|
||||||
|
if (str(u.standInFor)) e.standInFor = str(u.standInFor);
|
||||||
|
return e;
|
||||||
|
}).filter((u) => u.id);
|
||||||
|
const bonds = (Array.isArray(p.bonds) ? p.bonds : []).filter(isObj).slice(0, MAX_BONDS).map((b) => ({
|
||||||
bondId: str(b.bondId) || str(b.id) || '', layers: int(b.layers, 0), active: bool(b.active),
|
bondId: str(b.bondId) || str(b.id) || '', layers: int(b.layers, 0), active: bool(b.active),
|
||||||
})).filter((b) => b.bondId);
|
})).filter((b) => b.bondId);
|
||||||
return {
|
return {
|
||||||
...p, // unknown / future fields pass through untouched
|
|
||||||
playerId: str(p.playerId) || '',
|
playerId: str(p.playerId) || '',
|
||||||
seat: int(p.seat, 0),
|
seat: int(p.seat, 0),
|
||||||
name: str(p.name) || t('博士'),
|
name: str(p.name, MAX_NAME) || '',
|
||||||
isBot: bool(p.isBot),
|
isBot: bool(p.isBot),
|
||||||
left: bool(p.left),
|
left: bool(p.left),
|
||||||
alive: bool(p.alive, true),
|
alive: bool(p.alive, true),
|
||||||
victory: bool(p.victory), // server semantics: team won AND this player was still in at the end
|
victory: bool(p.victory), // server semantics: team won AND this player was still in at the end
|
||||||
roundsPassed: int(p.roundsPassed, 0),
|
roundsPassed: quit ? num(p.roundsPassed) : int(p.roundsPassed, 0),
|
||||||
eliminatedRound: num(p.eliminatedRound),
|
eliminatedRound: num(p.eliminatedRound),
|
||||||
lp: num(p.lp),
|
lp: num(p.lp),
|
||||||
bandId: str(p.bandId),
|
bandId: str(p.bandId),
|
||||||
@@ -122,27 +166,32 @@ function normalizePlayerRow(raw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize ONE match record to the current shape. Missing fields get defaults; unknown fields (a newer
|
* Normalize ONE match record to the current shape (a strict schema: missing fields get defaults, unknown ones are
|
||||||
* writer's additions) survive untouched, so import → export does not lose them.
|
* dropped, every string / list is bounded). A record without an `end` is a settled one — the PR #323 build, which
|
||||||
|
* never recorded anything else, wrote none.
|
||||||
* @param {any} raw
|
* @param {any} raw
|
||||||
* @returns {any|null} null when the row is not salvageable (no players array at all)
|
* @returns {any|null} null when the row is not salvageable (no usable player row at all)
|
||||||
*/
|
*/
|
||||||
export function normalizeRecord(raw) {
|
export function normalizeRecord(raw) {
|
||||||
const r = isObj(raw) ? raw : {};
|
const r = isObj(raw) ? raw : {};
|
||||||
// same rule as buildRecord: a record IS a played match — no usable players array, no record
|
// a record IS a played match — no usable players array, no record
|
||||||
if (!Array.isArray(r.players) || !r.players.length) return null;
|
if (!Array.isArray(r.players)) return null;
|
||||||
const base = {
|
const quit = r.end === 'quit';
|
||||||
id: str(r.id) || '',
|
const players = r.players.filter(isObj).slice(0, MAX_PLAYERS).map((p) => normalizePlayerRow(p, quit));
|
||||||
|
if (!players.length) return null;
|
||||||
|
const rec = {
|
||||||
|
id: str(r.id),
|
||||||
v: STATS_VERSION,
|
v: STATS_VERSION,
|
||||||
t: int(r.t, 0),
|
t: int(r.t, 0),
|
||||||
|
end: quit ? 'quit' : 'settled',
|
||||||
victory: bool(r.victory),
|
victory: bool(r.victory),
|
||||||
roundsPassed: int(r.roundsPassed, 0),
|
roundsPassed: quit ? num(r.roundsPassed) : int(r.roundsPassed, 0),
|
||||||
lastRound: num(r.lastRound),
|
lastRound: num(r.lastRound),
|
||||||
hiddenReached: bool(r.hiddenReached),
|
hiddenReached: bool(r.hiddenReached),
|
||||||
hiddenCleared: bool(r.hiddenCleared),
|
hiddenCleared: bool(r.hiddenCleared),
|
||||||
reason: str(r.reason),
|
reason: str(r.reason, 32),
|
||||||
modeId: str(r.modeId),
|
modeId: str(r.modeId),
|
||||||
difficulty: str(r.difficulty),
|
difficulty: str(r.difficulty, 24),
|
||||||
stageId: str(r.stageId),
|
stageId: str(r.stageId),
|
||||||
bossId: str(r.bossId),
|
bossId: str(r.bossId),
|
||||||
hiddenBossId: str(r.hiddenBossId),
|
hiddenBossId: str(r.hiddenBossId),
|
||||||
@@ -151,9 +200,12 @@ export function normalizeRecord(raw) {
|
|||||||
teamLp: num(r.teamLp),
|
teamLp: num(r.teamLp),
|
||||||
roomMode: r.roomMode === 'solo' || r.roomMode === 'coop' ? r.roomMode : null,
|
roomMode: r.roomMode === 'solo' || r.roomMode === 'coop' ? r.roomMode : null,
|
||||||
selfId: str(r.selfId),
|
selfId: str(r.selfId),
|
||||||
players: r.players.filter(isObj).map(normalizePlayerRow),
|
players,
|
||||||
};
|
};
|
||||||
return { ...r, ...base };
|
if (r.compact === true) rec.compact = true;
|
||||||
|
// an id-less (hand-made) record gets a content id, so both copies of it land on the same key
|
||||||
|
if (!rec.id) rec.id = `c.${fnv1a(JSON.stringify(rec))}`;
|
||||||
|
return rec;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,18 +216,22 @@ export function normalizeRecord(raw) {
|
|||||||
*/
|
*/
|
||||||
export const MIGRATIONS = {};
|
export const MIGRATIONS = {};
|
||||||
|
|
||||||
|
/** Newest first (the stored order): by time, then id. */
|
||||||
|
const newestFirst = (a, b) => (b.t - a.t) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bring any stored / imported envelope to the current version.
|
* Bring any stored / imported envelope to the current version.
|
||||||
* @param {any} raw
|
* @param {any} raw
|
||||||
* @returns {{ stats: { v: number, records: any[] }, changed: boolean, newer: boolean }}
|
* @returns {{ stats: { v: number, records: any[] }, changed: boolean, newer: boolean, dropped: number }}
|
||||||
* `newer` = the data was written by a NEWER build: returned untouched, callers must not save over it.
|
* `newer` = the data was written by a NEWER build: returned untouched, callers must not save over it.
|
||||||
|
* `dropped` = rows that were not salvageable, repeated a record or went beyond MAX_RECORDS: gone from `stats`.
|
||||||
*/
|
*/
|
||||||
export function migrateStats(raw) {
|
export function migrateStats(raw) {
|
||||||
if (!isObj(raw) || !Array.isArray(raw.records)) return { stats: emptyStats(), changed: false, newer: false };
|
if (!isObj(raw) || !Array.isArray(raw.records)) return { stats: emptyStats(), changed: false, newer: false, dropped: 0 };
|
||||||
if ((raw.v ?? 0) > STATS_VERSION) {
|
if ((raw.v ?? 0) > STATS_VERSION) {
|
||||||
return { stats: { v: raw.v, records: raw.records }, changed: false, newer: true };
|
return { stats: { v: raw.v, records: raw.records }, changed: false, newer: true, dropped: 0 };
|
||||||
}
|
}
|
||||||
let v = raw.v ?? 0;
|
let v = Number.isFinite(raw.v) ? raw.v : 0;
|
||||||
let records = raw.records;
|
let records = raw.records;
|
||||||
let changed = v !== STATS_VERSION;
|
let changed = v !== STATS_VERSION;
|
||||||
while (v < STATS_VERSION) {
|
while (v < STATS_VERSION) {
|
||||||
@@ -183,48 +239,108 @@ export function migrateStats(raw) {
|
|||||||
if (typeof step === 'function') records = step({ v, records }).records;
|
if (typeof step === 'function') records = step({ v, records }).records;
|
||||||
v++;
|
v++;
|
||||||
}
|
}
|
||||||
|
// a hand-edited / merged list can be oversized, unsorted or repeat a record: only the newest MAX_RECORDS survive, newest first
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
|
const seen = new Set(); // a record listed twice (hand-merged data) counts once: the first copy
|
||||||
for (const r of records) {
|
for (const r of records) {
|
||||||
const rec = normalizeRecord(r);
|
const rec = normalizeRecord(r);
|
||||||
if (rec) normalized.push(rec);
|
if (rec && !seen.has(rec.id)) { seen.add(rec.id); normalized.push(rec); }
|
||||||
}
|
}
|
||||||
changed ||= normalized.length !== records.length;
|
normalized.sort(newestFirst);
|
||||||
return { stats: { v: STATS_VERSION, records: normalized }, changed, newer: false };
|
const kept = normalized.slice(0, MAX_RECORDS);
|
||||||
|
const dropped = records.length - kept.length;
|
||||||
|
// storage is rewritten (self-healed) when a row went, the order changed or an id-less row got its content id
|
||||||
|
changed ||= dropped > 0 || kept.some((rec, i) => !isObj(records[i]) || rec.id !== records[i].id);
|
||||||
|
return { stats: { v: STATS_VERSION, records: kept }, changed, newer: false, dropped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the raw stored string (null when absent or unreadable). */
|
||||||
|
function readRaw() {
|
||||||
|
try { return globalThis.localStorage?.getItem(FULL_KEY) ?? null; } catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read + migrate the persisted envelope; self-heals older shapes back to storage.
|
* Read + migrate the persisted envelope; self-heals older shapes back to storage. An unreadable value (not JSON, or
|
||||||
* @returns {{ v: number, records: any[], newer: boolean }}
|
* not an envelope) is moved to `sp.pref.stats.damaged` — kept for a manual recovery — and the history starts again.
|
||||||
|
* @returns {{ v: number, records: any[], newer: boolean, dropped: number, damaged: boolean }}
|
||||||
*/
|
*/
|
||||||
export function loadStats() {
|
export function loadStats() {
|
||||||
const { stats, changed, newer } = migrateStats(loadPref(STATS_PREF_KEY, null));
|
const raw = readRaw();
|
||||||
|
if (raw == null) return { ...emptyStats(), newer: false, dropped: 0, damaged: false };
|
||||||
|
let parsed;
|
||||||
|
try { parsed = JSON.parse(raw); } catch { parsed = undefined; }
|
||||||
|
if (!isObj(parsed) || !Array.isArray(parsed.records)) {
|
||||||
|
// keep the bad value where a person can find it; if even that write fails the next save simply replaces it
|
||||||
|
try { globalThis.localStorage?.setItem(DAMAGED_KEY, raw); globalThis.localStorage?.removeItem(FULL_KEY); } catch { /* keep it in place */ }
|
||||||
|
return { ...emptyStats(), newer: false, dropped: 0, damaged: true };
|
||||||
|
}
|
||||||
|
const { stats, changed, newer, dropped } = migrateStats(parsed);
|
||||||
if (!newer && changed) saveStats(stats);
|
if (!newer && changed) saveStats(stats);
|
||||||
return { ...stats, newer };
|
return { ...stats, newer, dropped, damaged: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- compaction + the storage budget ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const COMPACT_FRAME = ['id', 'v', 't', 'end', 'victory', 'roundsPassed', 'lastRound', 'hiddenReached', 'hiddenCleared', 'reason', 'modeId',
|
||||||
|
'difficulty', 'roomMode', 'durationMs', 'selfId'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The compact form of a record: the frame and the player's OWN row without lineup, bonds and name — everything the
|
||||||
|
* aggregates and the history list read, nothing a replay needs. Teammates are gone. Idempotent.
|
||||||
|
* @param {any} rec a normalized record
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
export function compactRecord(rec) {
|
||||||
|
const self = selfRowOf(rec);
|
||||||
|
const out = {};
|
||||||
|
for (const k of COMPACT_FRAME) if (rec[k] != null) out[k] = rec[k];
|
||||||
|
out.compact = true;
|
||||||
|
out.players = self ? [{
|
||||||
|
playerId: self.playerId, seat: self.seat, alive: self.alive, victory: self.victory, roundsPassed: self.roundsPassed,
|
||||||
|
eliminatedRound: self.eliminatedRound, bandId: self.bandId, title: self.title, left: self.left, stats: self.stats,
|
||||||
|
}] : [];
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist the envelope. savePref swallows quota errors — here losing a record silently would do exactly
|
* What goes to storage: the newest FULL_KEEP records as they are, older ones compacted, then the oldest dropped until
|
||||||
* that, so the write reports failure and shrinks the history (oldest quarter) before retrying.
|
* the JSON fits `maxChars` (the newest record is always kept).
|
||||||
* @param {{ v: number, records: any[] }} stats
|
* @param {any[]} records newest first
|
||||||
|
* @param {number} [maxChars]
|
||||||
|
* @returns {any[]}
|
||||||
|
*/
|
||||||
|
export function fitToBudget(records, maxChars = MAX_CHARS) {
|
||||||
|
const kept = [];
|
||||||
|
let used = 40; // the envelope itself
|
||||||
|
records.slice(0, MAX_RECORDS).forEach((r, i) => {
|
||||||
|
const rec = i < FULL_KEEP ? r : compactRecord(r);
|
||||||
|
const n = JSON.stringify(rec).length + 1;
|
||||||
|
if (kept.length && used + n > maxChars) return;
|
||||||
|
used += n;
|
||||||
|
kept.push(rec);
|
||||||
|
});
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the envelope. Never overwrites an envelope of a newer build. Compacts and trims to the budget first; a write
|
||||||
|
* the browser refuses (quota — other keys share it) is retried with half the history, down to a handful of records.
|
||||||
|
* @param {{ v: number, records: any[], newer?: boolean }} stats
|
||||||
* @returns {boolean} whether a write stuck
|
* @returns {boolean} whether a write stuck
|
||||||
*/
|
*/
|
||||||
export function saveStats(stats) {
|
export function saveStats(stats) {
|
||||||
const env = { v: STATS_VERSION, records: stats.records };
|
if (stats.newer) return false;
|
||||||
try {
|
let recs = fitToBudget(stats.records);
|
||||||
globalThis.localStorage?.setItem(FULL_KEY, JSON.stringify(env));
|
for (;;) {
|
||||||
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 {
|
try {
|
||||||
globalThis.localStorage?.setItem(FULL_KEY, JSON.stringify({ v: STATS_VERSION, records: recs }));
|
const ls = globalThis.localStorage;
|
||||||
|
if (!ls) return false; // no storage at all (disabled by the browser)
|
||||||
|
ls.setItem(FULL_KEY, JSON.stringify({ v: STATS_VERSION, records: recs }));
|
||||||
return true;
|
return true;
|
||||||
} catch { /* keep shrinking */ }
|
} catch { /* quota or storage disabled — fall through to shrinking */ }
|
||||||
|
if (recs.length <= 8) return false; // a small history that still does not fit is foreign quota pressure
|
||||||
|
recs = recs.slice(0, Math.max(8, Math.ceil(recs.length / 2)));
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -242,7 +358,7 @@ export function roomModeOf(modeId, roomMode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build one record from a raw m.result payload (+ context). Null when there is nothing to store: payloads
|
* Build one settled 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
|
* 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).
|
* seats get every result pushed too — eliminated players, who ARE in players[], keep recording).
|
||||||
* @param {any} res raw m.result payload
|
* @param {any} res raw m.result payload
|
||||||
@@ -255,10 +371,11 @@ export function buildRecord(res, ctx = {}) {
|
|||||||
const myId = str(ctx.myId);
|
const myId = str(ctx.myId);
|
||||||
const selfRow = r.players.find((p) => isObj(p) && p.playerId === myId);
|
const selfRow = r.players.find((p) => isObj(p) && p.playerId === myId);
|
||||||
if (!selfRow) return null; // spectator / observer: not my match
|
if (!selfRow) return null; // spectator / observer: not my match
|
||||||
const rec = normalizeRecord({
|
return normalizeRecord({
|
||||||
v: STATS_VERSION,
|
v: STATS_VERSION,
|
||||||
t: int(ctx.now, 0),
|
t: int(ctx.now, 0),
|
||||||
id: recordId(r, myId),
|
id: recordId(r, myId),
|
||||||
|
end: 'settled',
|
||||||
victory: bool(r.victory),
|
victory: bool(r.victory),
|
||||||
roundsPassed: int(r.roundsPassed, 0),
|
roundsPassed: int(r.roundsPassed, 0),
|
||||||
lastRound: num(r.lastRound),
|
lastRound: num(r.lastRound),
|
||||||
@@ -277,21 +394,120 @@ export function buildRecord(res, ctx = {}) {
|
|||||||
selfId: myId,
|
selfId: myId,
|
||||||
players: r.players,
|
players: r.players,
|
||||||
});
|
});
|
||||||
return rec;
|
}
|
||||||
|
|
||||||
|
// ---- the match left early ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the page remembers of the match on screen, so that giving it up can still be recorded: when the page first saw
|
||||||
|
* it, whether that was at the start (a duration is only kept then), and the last round the player was seen alive in
|
||||||
|
* (the server tells the others at the settlement, a leaver nothing).
|
||||||
|
* @returns {{ active: boolean, startedAt: number, sawStart: boolean, aliveRound: number|null }}
|
||||||
|
*/
|
||||||
|
export const createLiveMatch = () => ({ active: false, startedAt: 0, sawStart: false, aliveRound: null });
|
||||||
|
|
||||||
|
/** The module's own tracker (fed by installStatsRecorder, read by recordQuit). */
|
||||||
|
export const liveMatch = createLiveMatch();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed the tracker one app state (cheap: called on every store change).
|
||||||
|
* @param {ReturnType<typeof createLiveMatch>} live
|
||||||
|
* @param {any} state the app store state
|
||||||
|
* @param {number} [now]
|
||||||
|
*/
|
||||||
|
export function observeMatch(live, state, now = Date.now()) {
|
||||||
|
const pub = state?.match?.public;
|
||||||
|
const inMatch = !!pub && !!pub.phase && pub.phase !== PHASE.LOBBY && pub.phase !== PHASE.RESULT && !state.match.result;
|
||||||
|
if (!inMatch) { live.active = false; return live; }
|
||||||
|
const round = Number(pub.round) || 0;
|
||||||
|
if (!live.active) {
|
||||||
|
live.active = true;
|
||||||
|
live.startedAt = now;
|
||||||
|
live.sawStart = round <= 1;
|
||||||
|
live.aliveRound = null;
|
||||||
|
}
|
||||||
|
const me = Array.isArray(pub.players) ? pub.players.find((p) => isObj(p) && p.playerId === state.me?.playerId) : null;
|
||||||
|
if (me && me.alive !== false) live.aliveRound = Math.max(live.aliveRound ?? 0, round);
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start following the store (main.js). Returns the unsubscribe function. */
|
||||||
|
export function installStatsRecorder(store) {
|
||||||
|
observeMatch(liveMatch, store.get());
|
||||||
|
return store.subscribe((s) => observeMatch(liveMatch, s));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The m.result-shaped payload behind a record — the inverse of buildRecord. The stats page's 最近对局
|
* The rounds a player who leaves in `phase` of `round` has cleared — the server's rule for a quit (platform.js _quit:
|
||||||
* rows hand this to the settlement screen (screens/result.js reads store.match.result through
|
* eliminated in round k ⇒ k − 1 passed; leaving in the settlement of round k counts k).
|
||||||
* normalizeResult): a finished match can be RE-VIEWED because the record kept every field the payload
|
* @param {string} phase @param {number} round
|
||||||
* had. Whatever a record never stored (older / hand-made rows) is simply absent and normalizeResult
|
*/
|
||||||
* defaults it, exactly like a tolerant live payload.
|
export function clearedRounds(phase, round) {
|
||||||
|
const r = Number.isFinite(round) ? round : 0;
|
||||||
|
return Math.max(0, phase === PHASE.SETTLE ? r : r - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the quit record of a match the local player is leaving (放弃模拟), from the app state and the tracker. Null when
|
||||||
|
* there is nothing of mine to record: no match on screen, its settlement already here (that one is recorded by
|
||||||
|
* recordResult), a spectator seat, or a match the page never saw.
|
||||||
|
* @param {any} state the app store state BEFORE the match is cleared
|
||||||
|
* @param {{ live?: ReturnType<typeof createLiveMatch>, now?: number }} [ctx]
|
||||||
|
* @returns {any|null}
|
||||||
|
*/
|
||||||
|
export function buildQuitRecord(state, ctx = {}) {
|
||||||
|
const live = ctx.live ?? liveMatch;
|
||||||
|
const now = int(ctx.now, Date.now());
|
||||||
|
const pub = state?.match?.public;
|
||||||
|
const myId = str(state?.me?.playerId);
|
||||||
|
if (!pub || !myId || state.match.result || !live.active) return null;
|
||||||
|
const row = Array.isArray(pub.players) ? pub.players.find((p) => isObj(p) && p.playerId === myId) : null;
|
||||||
|
if (!row) return null; // a spectator seat is no player of the match
|
||||||
|
const alive = row.alive !== false;
|
||||||
|
const round = Number(pub.round) || 0;
|
||||||
|
// alive: the rounds cleared so far; already eliminated: the round it was seen alive in, less one (unknown when the
|
||||||
|
// page never saw it alive — a reload after the elimination)
|
||||||
|
const roundsPassed = alive ? clearedRounds(pub.phase, round) : (live.aliveRound != null ? Math.max(0, live.aliveRound - 1) : null);
|
||||||
|
return normalizeRecord({
|
||||||
|
v: STATS_VERSION,
|
||||||
|
t: now,
|
||||||
|
id: `q.${fnv1a([myId, live.startedAt, pub.modeId ?? '-', state.room?.code ?? '-'].join('|'))}`,
|
||||||
|
end: 'quit',
|
||||||
|
victory: false,
|
||||||
|
roundsPassed,
|
||||||
|
lastRound: num(pub.lastRound),
|
||||||
|
hiddenReached: pub.phase === PHASE.HIDDEN_CORE,
|
||||||
|
hiddenCleared: false,
|
||||||
|
reason: 'quit',
|
||||||
|
modeId: str(pub.modeId),
|
||||||
|
difficulty: str(pub.difficulty),
|
||||||
|
stageId: str(pub.stageId),
|
||||||
|
bossId: str(pub.bossId),
|
||||||
|
hiddenBossId: str(pub.hiddenBossId),
|
||||||
|
seed: null,
|
||||||
|
durationMs: live.sawStart ? Math.max(0, now - live.startedAt) : null,
|
||||||
|
teamLp: num(pub.teamLp),
|
||||||
|
roomMode: roomModeOf(str(pub.modeId), state.room?.mode),
|
||||||
|
selfId: myId,
|
||||||
|
players: [{
|
||||||
|
playerId: myId, seat: row.seat, name: row.name, isBot: false, left: true, alive, victory: false, roundsPassed,
|
||||||
|
eliminatedRound: alive ? null : live.aliveRound, lp: row.lp, bandId: row.bandId,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The m.result-shaped payload behind a settled record — the inverse of buildRecord. The stats page's history
|
||||||
|
* rows hand this to the settlement view (screens/result.js reads it through normalizeResult): a finished match can be
|
||||||
|
* RE-VIEWED because the record kept every field the payload had. Whatever a record never stored is simply absent and
|
||||||
|
* normalizeResult defaults it, exactly like a tolerant live payload. A quit record (nothing was settled) and a
|
||||||
|
* compacted one (its rows are gone) have no settlement to show.
|
||||||
* @param {any} rec
|
* @param {any} rec
|
||||||
* @returns {any|null} null when the row is not a usable record
|
* @returns {any|null} null when the row is not a usable, full, settled record
|
||||||
*/
|
*/
|
||||||
export function recordToResult(rec) {
|
export function recordToResult(rec) {
|
||||||
const r = normalizeRecord(rec);
|
const r = normalizeRecord(rec);
|
||||||
if (!r) return null;
|
if (!r || r.end === 'quit' || r.compact) return null;
|
||||||
const res = {
|
const res = {
|
||||||
victory: r.victory,
|
victory: r.victory,
|
||||||
roundsPassed: r.roundsPassed,
|
roundsPassed: r.roundsPassed,
|
||||||
@@ -324,6 +540,16 @@ export function appendRecord(stats, record) {
|
|||||||
return { stats: { v: STATS_VERSION, records }, added: true };
|
return { stats: { v: STATS_VERSION, records }, added: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** load → append → save of one built record (never over an envelope of a newer build). @returns {any|null} the record when stored */
|
||||||
|
function storeRecord(rec) {
|
||||||
|
if (!rec) return null;
|
||||||
|
const stats = loadStats();
|
||||||
|
if (stats.newer) return null;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a finished match end-to-end (load → append → save). Returns the built record, or null when
|
* 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).
|
* nothing was stored (no players / not my match / replay).
|
||||||
@@ -332,16 +558,24 @@ export function appendRecord(stats, record) {
|
|||||||
* @returns {any|null}
|
* @returns {any|null}
|
||||||
*/
|
*/
|
||||||
export function recordResult(res, ctx = {}) {
|
export function recordResult(res, ctx = {}) {
|
||||||
const rec = buildRecord(res, ctx);
|
return storeRecord(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.
|
* Record the match the player is giving up (放弃模拟), before the app clears its state. Returns the record, or null.
|
||||||
|
* @param {any} state the app store state before the match is cleared
|
||||||
|
* @param {{ live?: ReturnType<typeof createLiveMatch>, now?: number }} [ctx]
|
||||||
|
* @returns {any|null}
|
||||||
|
*/
|
||||||
|
export function recordQuit(state, ctx = {}) {
|
||||||
|
const live = ctx.live ?? liveMatch;
|
||||||
|
const rec = storeRecord(buildQuitRecord(state, ctx));
|
||||||
|
live.active = false; // one record per match
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge two envelopes (import into existing): union by record id, newest first, capped.
|
||||||
* @param {{ records: any[] }} a current (wins ties)
|
* @param {{ records: any[] }} a current (wins ties)
|
||||||
* @param {{ records: any[] }} b incoming
|
* @param {{ records: any[] }} b incoming
|
||||||
* @returns {{ v: number, records: any[] }}
|
* @returns {{ v: number, records: any[] }}
|
||||||
@@ -350,10 +584,9 @@ export function mergeStats(a, b) {
|
|||||||
const byId = new Map();
|
const byId = new Map();
|
||||||
for (const r of [...(Array.isArray(b?.records) ? b.records : []), ...(Array.isArray(a?.records) ? a.records : [])]) {
|
for (const r of [...(Array.isArray(b?.records) ? b.records : []), ...(Array.isArray(a?.records) ? a.records : [])]) {
|
||||||
const rec = normalizeRecord(r);
|
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, rec);
|
||||||
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);
|
const records = [...byId.values()].sort(newestFirst).slice(0, MAX_RECORDS);
|
||||||
return { v: STATS_VERSION, records };
|
return { v: STATS_VERSION, records };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,18 +594,31 @@ export function mergeStats(a, b) {
|
|||||||
* Parse + migrate + merge an exported / pasted payload into the current data.
|
* Parse + migrate + merge an exported / pasted payload into the current data.
|
||||||
* @param {any} raw parsed JSON of the imported file
|
* @param {any} raw parsed JSON of the imported file
|
||||||
* @param {{ records: any[] }} current current envelope
|
* @param {{ records: any[] }} current current envelope
|
||||||
* @returns {{ stats: { v: number, records: any[] }, added: number, skipped: number }}
|
* @returns {{ stats: { v: number, records: any[] }, added: number, skipped: number, dropped: number }}
|
||||||
|
* added = records new to this device; skipped = duplicates of records it has; dropped = rows of the file that could
|
||||||
|
* not be read
|
||||||
*/
|
*/
|
||||||
export function importStats(raw, current) {
|
export function importStats(raw, current) {
|
||||||
const { stats: incoming, newer } = migrateStats(raw);
|
if (isObj(raw) && raw.kind != null && raw.kind !== 'local-stats') {
|
||||||
|
const err = new Error('not a stats export'); // the page shows its own text
|
||||||
|
err.code = 'stats-bad-file';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!isObj(raw) || !Array.isArray(raw.records)) {
|
||||||
|
const err = new Error('not a stats export');
|
||||||
|
err.code = 'stats-bad-file';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { stats: incoming, newer, dropped } = migrateStats(raw);
|
||||||
if (newer) {
|
if (newer) {
|
||||||
const err = new Error('该文件由更新版本的客户端导出,请先升级后再导入');
|
const err = new Error('exported by a newer client'); // the page shows its own text
|
||||||
err.code = 'stats-newer-version';
|
err.code = 'stats-newer-version';
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
const have = new Set((Array.isArray(current?.records) ? current.records : []).map((r) => r.id));
|
||||||
const merged = mergeStats(current, incoming);
|
const merged = mergeStats(current, incoming);
|
||||||
const added = merged.records.length - (Array.isArray(current?.records) ? current.records.length : 0);
|
const added = incoming.records.filter((r) => !have.has(r.id)).length;
|
||||||
return { stats: merged, added: Math.max(0, added), skipped: incoming.records.length - Math.max(0, added) };
|
return { stats: merged, added, skipped: incoming.records.length - added, dropped };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -386,11 +632,11 @@ export function exportStats(stats) {
|
|||||||
kind: 'local-stats',
|
kind: 'local-stats',
|
||||||
v: STATS_VERSION,
|
v: STATS_VERSION,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
records: Array.isArray(stats?.records) ? stats.records : [],
|
records: fitToBudget(Array.isArray(stats?.records) ? stats.records : []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- aggregation (what the page shows; records may hold more than any view uses) ------------------------
|
// ---- 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'];
|
const SELF_STATS = ['dmgDealt', 'kills', 'bossDamage', 'activatedLayers', 'merges', 'itemsEquipped', 'gold', 'perfectRounds', 'refreshes', 'leaks', 'lpLost'];
|
||||||
|
|
||||||
@@ -412,15 +658,43 @@ export function selfRowOf(rec) {
|
|||||||
const selfWon = (rec, self) => (self ? !!self.victory : !!rec.victory);
|
const selfWon = (rec, self) => (self ? !!self.victory : !!rec.victory);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregate records for the stats page. Everything is keyed by ids (bandId / titleId / difficulty); the
|
* The rounds the player passed in a record: its own row's (the table the settlement prints per player), else the
|
||||||
* page maps them to names and icons through the game data.
|
* team's; 0 when the record does not know.
|
||||||
|
* @param {any} rec
|
||||||
|
*/
|
||||||
|
export function roundsOf(rec) {
|
||||||
|
const self = selfRowOf(rec);
|
||||||
|
const v = self && Number.isFinite(self.roundsPassed) ? self.roundsPassed : rec?.roundsPassed;
|
||||||
|
return Number.isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a record enters the aggregates (see the header). A settled record always does. A quit record does once the
|
||||||
|
* player had finished at least one battle: cleared a round, or was already eliminated; a match given up before that —
|
||||||
|
* entered and left right away — is history only.
|
||||||
|
* @param {any} rec a normalized record
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function countsTowardStats(rec) {
|
||||||
|
if (!isObj(rec)) return false;
|
||||||
|
if (rec.end !== 'quit') return true;
|
||||||
|
const self = selfRowOf(rec);
|
||||||
|
if (!self) return false;
|
||||||
|
return self.alive === false || (Number.isFinite(self.roundsPassed) && self.roundsPassed >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate records for the stats page — over the records that count; `total` / `excluded` say how many did not.
|
||||||
|
* Everything is keyed by ids (bandId / titleId / difficulty); the page maps them to names and icons through the game
|
||||||
|
* data. The combat totals read the settled records' stat blocks (a quit record has none).
|
||||||
* @param {any[]} records
|
* @param {any[]} records
|
||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
export function aggregateStats(records) {
|
export function aggregateStats(records) {
|
||||||
const rs = Array.isArray(records) ? records : [];
|
const all = Array.isArray(records) ? records : [];
|
||||||
|
const rs = all.filter(countsTowardStats);
|
||||||
const out = {
|
const out = {
|
||||||
count: rs.length, wins: 0,
|
total: all.length, count: rs.length, excluded: all.length - rs.length, wins: 0,
|
||||||
rounds: { total: 0, max: 0 },
|
rounds: { total: 0, max: 0 },
|
||||||
duration: { totalMs: 0 },
|
duration: { totalMs: 0 },
|
||||||
hidden: { reached: 0, cleared: 0 },
|
hidden: { reached: 0, cleared: 0 },
|
||||||
@@ -431,13 +705,14 @@ export function aggregateStats(records) {
|
|||||||
const self = selfRowOf(rec);
|
const self = selfRowOf(rec);
|
||||||
const won = selfWon(rec, self);
|
const won = selfWon(rec, self);
|
||||||
if (won) out.wins++;
|
if (won) out.wins++;
|
||||||
out.rounds.total += int(rec.roundsPassed, 0);
|
const rounds = roundsOf(rec);
|
||||||
out.rounds.max = Math.max(out.rounds.max, int(rec.roundsPassed, 0));
|
out.rounds.total += rounds;
|
||||||
|
out.rounds.max = Math.max(out.rounds.max, rounds);
|
||||||
out.duration.totalMs += num(rec.durationMs) || 0;
|
out.duration.totalMs += num(rec.durationMs) || 0;
|
||||||
if (rec.hiddenReached) out.hidden.reached++;
|
if (rec.hiddenReached) out.hidden.reached++;
|
||||||
if (rec.hiddenCleared) out.hidden.cleared++;
|
if (rec.hiddenCleared) out.hidden.cleared++;
|
||||||
|
|
||||||
const d = rec.difficulty || t('未知');
|
const d = rec.difficulty || 'UNKNOWN';
|
||||||
const bd = (out.byDifficulty[d] ||= { games: 0, wins: 0, hiddenCleared: 0 });
|
const bd = (out.byDifficulty[d] ||= { games: 0, wins: 0, hiddenCleared: 0 });
|
||||||
bd.games++;
|
bd.games++;
|
||||||
if (won) bd.wins++;
|
if (won) bd.wins++;
|
||||||
@@ -458,7 +733,7 @@ export function aggregateStats(records) {
|
|||||||
if (won) b.wins++;
|
if (won) b.wins++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self && isObj(self.stats)) {
|
if (rec.end !== 'quit' && self && isObj(self.stats)) {
|
||||||
for (const k of SELF_STATS) {
|
for (const k of SELF_STATS) {
|
||||||
const v = self.stats[k];
|
const v = self.stats[k];
|
||||||
if (Number.isFinite(v)) out.sums[k] = (out.sums[k] || 0) + v;
|
if (Number.isFinite(v)) out.sums[k] = (out.sums[k] || 0) + v;
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"solo-normal":{"victory":false,"roundsPassed":13,"hiddenReached":false,"hiddenCleared":false,"reason":"defeat","teamLp":0,"modeId":"mode_single_normal","difficulty":"NORMAL","stageId":"act2autochess_m04","bossId":"boss_1","hiddenBossId":"boss_8","seed":101,"durationMs":291900,"players":[{"playerId":"p_0","seat":0,"name":"P0","isBot":false,"left":false,"alive":true,"victory":false,"roundsPassed":13,"eliminatedRound":null,"lp":0,"bandId":"band_sarkazb","lineup":[{"id":"chess_char_1_14_b","golden":true,"tier":1,"row":11,"col":7,"items":[]},{"id":"chess_char_3_13_b","golden":true,"tier":3,"row":11,"col":8,"items":["chess_item_3_06_e_a","chess_item_4_08_e_a"]},{"id":"chess_char_3_14_a","golden":false,"tier":3,"row":10,"col":5,"items":["chess_item_3_03_e_a","chess_item_5_02_e_a"]},{"id":"chess_char_5_14_a","golden":false,"tier":5,"row":10,"col":8,"items":["chess_item_3_01_e_a","chess_item_1_01_e_b"]},{"id":"chess_char_1_02_b","golden":true,"tier":1,"row":9,"col":6,"items":["chess_item_3_11_e_a","chess_item_2_01_e_b"]},{"id":"chess_char_1_20_b","golden":true,"tier":1,"row":9,"col":7,"items":[]},{"id":"chess_char_5_06_a","golden":false,"tier":5,"row":9,"col":8,"items":["chess_item_3_08_e_a"]},{"id":"chess_char_4_07_b","golden":true,"tier":4,"row":9,"col":9,"items":["chess_item_6_05_e_a","chess_item_3_09_e_a"]}],"bonds":[{"bondId":"indomShip","count":3,"active":true,"tier":2,"layers":30},{"bondId":"visiShip","count":2,"active":true,"tier":1,"layers":19},{"bondId":"kjeragShip","count":3,"active":true,"tier":1,"layers":12},{"bondId":"skillfulShip","count":3,"active":true,"tier":1,"layers":8},{"bondId":"suntShip","count":5,"active":true,"tier":2,"layers":0},{"bondId":"steadShip","count":1,"active":false,"tier":0,"layers":8},{"bondId":"swiftShip","count":1,"active":false,"tier":0,"layers":6},{"bondId":"preciShip","count":0,"active":false,"tier":0,"layers":2},{"bondId":"arcaneShip","count":0,"active":false,"tier":0,"layers":2}],"stats":{"dmgDealt":2724948,"kills":327,"leaks":19,"gold":133,"refreshes":14,"merges":5,"itemsEquipped":21,"bossDamage":509756,"activatedLayers":69,"lpLost":10,"perfectRounds":12},"title":{"id":"comment_2","name":"不朽盟约","picId":"comment_icon_2","text":"激活的盟约层数总和最高"},"trophies":0,"reward":320}]},"coop4-hard":{"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":37,"modeId":"mode_multi_hard","difficulty":"HARD","stageId":"act2autochess_m04","bossId":"boss_2","hiddenBossId":"boss_10","seed":102,"durationMs":308166.2697753906,"players":[{"playerId":"p_0","seat":0,"name":"P0","isBot":false,"left":false,"alive":true,"victory":true,"roundsPassed":14,"eliminatedRound":null,"lp":16,"bandId":"band_quintus","lineup":[{"id":"chess_char_3_09_b","golden":true,"tier":3,"row":11,"col":8,"items":["chess_item_4_08_e_a","chess_item_4_07_e_a"]},{"id":"chess_char_3_17_a","golden":false,"tier":3,"row":10,"col":3,"items":["chess_item_4_03_e_a","chess_item_3_05_e_b"]},{"id":"chess_char_6_19_a","golden":false,"tier":6,"row":10,"col":4,"items":["chess_item_3_01_e_a","chess_item_2_04_e_a"]},{"id":"chess_char_1_10_b","golden":true,"tier":1,"row":10,"col":5,"items":[]},{"id":"chess_char_4_04_a","golden":false,"tier":4,"row":10,"col":7,"items":[]},{"id":"chess_char_6_07_a","golden":false,"tier":6,"row":10,"col":8,"items":["chess_item_4_04_e_a"]},{"id":"chess_char_2_18_a","golden":false,"tier":2,"row":9,"col":8,"items":["chess_item_1_01_e_a","chess_item_1_05_e_a"]},{"id":"chess_char_5_07_a","golden":false,"tier":5,"row":9,"col":9,"items":["chess_item_4_06_e_a","chess_item_3_03_e_a"]}],"bonds":[{"bondId":"raidShip","count":2,"active":true,"tier":1,"layers":147},{"bondId":"kazimierzShip","count":3,"active":true,"tier":1,"layers":78},{"bondId":"arcaneShip","count":2,"active":true,"tier":1,"layers":18},{"bondId":"steadShip","count":2,"active":true,"tier":1,"layers":3},{"bondId":"soloShip","count":1,"active":true,"tier":1,"layers":0},{"bondId":"suntShip","count":2,"active":true,"tier":1,"layers":0},{"bondId":"visiShip","count":1,"active":false,"tier":0,"layers":21},{"bondId":"swiftShip","count":1,"active":false,"tier":0,"layers":18},{"bondId":"kjeragShip","count":1,"active":false,"tier":0,"layers":16}],"stats":{"dmgDealt":6871190,"kills":393,"leaks":21,"gold":148,"refreshes":23,"merges":3,"itemsEquipped":33,"bossDamage":945575,"activatedLayers":246,"lpLost":10,"perfectRounds":7},"title":{"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"},"trophies":5,"reward":425},{"playerId":"p_1","seat":1,"name":"P1","isBot":false,"left":false,"alive":true,"victory":true,"roundsPassed":14,"eliminatedRound":null,"lp":6,"bandId":"band_ducklord","lineup":[{"id":"chess_char_4_13_a","golden":false,"tier":4,"row":12,"col":5,"items":[]},{"id":"chess_char_1_02_b","golden":true,"tier":1,"row":12,"col":8,"items":["chess_item_4_06_e_b","chess_item_4_08_e_a"]},{"id":"chess_char_3_14_b","golden":true,"tier":3,"row":11,"col":8,"Line truncated
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
// Browser E2E of the 统计数据 page (public/js/screens/stats.js, ui/stats.js; PR #323 by @2321Robin, reworked for 0.2.2)
|
||||||
|
// against the real server, headless Chrome (puppeteer-core + system Chrome). Opt-in: SP_E2E=1 node --test test/ui/stats.e2e.test.js
|
||||||
|
//
|
||||||
|
// 1. A seeded history (records built from three REAL m.result payloads, test/fixtures/stats-results.json) on desktop
|
||||||
|
// 1920×1080 and a phone in landscape 844×390: the four tabs, the counting rule on screen (the match left in the first
|
||||||
|
// round is listed as 中途退出 / 不计入统计 and left out of the totals), the row click that re-views a settlement and
|
||||||
|
// its 返回统计, Esc, the title screen's entry; zero console errors. Screenshots: test/e2e/out/stats-*.png
|
||||||
|
// 2. The data actions: 导出 hands out a file the page itself imports again (nothing new), a corrupt file and a newer
|
||||||
|
// one are refused with a message and change nothing, 清空 asks first (the confirm dialog sits above the overlay and
|
||||||
|
// Esc closes only it), unreadable storage is set aside with a notice, a newer build's data is never overwritten.
|
||||||
|
// 3. The real flows: a solo match given up in the briefing is history only; one given up after round 1 counts (1 round
|
||||||
|
// cleared, the server's own rule); a match played to its settlement is recorded once — also after a reload (the
|
||||||
|
// lobby replays m.result).
|
||||||
|
|
||||||
|
import { test, describe, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { Client, sleep, startRealServer, hasChrome, OUT } from '../e2e/client.mjs';
|
||||||
|
import { buildRecord, buildQuitRecord, createLiveMatch, observeMatch, STATS_VERSION } from '../../public/js/ui/stats.js';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const ENABLED = process.env.SP_E2E === '1' && hasChrome();
|
||||||
|
const FIX = JSON.parse(readFileSync(path.join(ROOT, 'test/fixtures/stats-results.json'), 'utf8'));
|
||||||
|
const BANDS = Object.keys(JSON.parse(readFileSync(path.join(ROOT, 'data/bands.json'), 'utf8')));
|
||||||
|
const NOW = Date.parse('2026-10-08T21:30:00');
|
||||||
|
const DAY = 86400000;
|
||||||
|
|
||||||
|
/** n settled records from the real payloads (cycling solo / 4-player co-op, difficulties, outcomes, bands) + two quit records. */
|
||||||
|
function seed(n = 24) {
|
||||||
|
const recs = [];
|
||||||
|
const names = ['solo-normal', 'coop4-hard', 'coop4-abyss'];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const res = JSON.parse(JSON.stringify(FIX[names[i % 3]]));
|
||||||
|
const solo = i % 3 === 0;
|
||||||
|
const diff = ['FUNNY', 'NORMAL', 'HARD', 'ABYSS'][i % 4];
|
||||||
|
const win = i % 5 < 2;
|
||||||
|
Object.assign(res, { seed: 1000 + i, difficulty: diff, modeId: `mode_${solo ? 'single' : 'multi'}_${diff.toLowerCase()}`, victory: win, reason: win ? 'victory' : 'defeat', roundsPassed: win ? 14 : 4 + (i % 8), durationMs: (8 + (i % 13)) * 60000 });
|
||||||
|
const me = res.players.find((p) => p.playerId === 'p_0');
|
||||||
|
Object.assign(me, { bandId: BANDS[i % 7], victory: win, alive: win, roundsPassed: res.roundsPassed, title: i % 3 ? { id: `comment_${1 + (i % 6)}` } : null });
|
||||||
|
recs.unshift(buildRecord(res, { myId: 'p_0', roomMode: solo ? 'solo' : 'coop', now: NOW - i * 0.6 * DAY }));
|
||||||
|
}
|
||||||
|
const state = (phase, round) => ({
|
||||||
|
me: { playerId: 'p_0' }, room: { code: 'ABCD', mode: 'solo' },
|
||||||
|
match: { public: { phase, round, lastRound: 14, modeId: 'mode_single_hard', difficulty: 'HARD', players: [{ playerId: 'p_0', seat: 0, name: 'Doctor', alive: true, lp: 30, bandId: BANDS[2] }] }, result: null },
|
||||||
|
});
|
||||||
|
const quit = (phase, round, t) => { const live = createLiveMatch(); observeMatch(live, state('INFO_CHECK', 0), t - 600000); return buildQuitRecord(state(phase, round), { live, now: t }); };
|
||||||
|
recs.push(quit('PREP', 1, NOW - 2 * 3600e3), quit('PREP', 7, NOW - 1.3 * DAY));
|
||||||
|
return { v: STATS_VERSION, records: recs.sort((a, b) => b.t - a.t) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('统计数据 page (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||||
|
let srv;
|
||||||
|
let puppeteer;
|
||||||
|
const clients = [];
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
puppeteer = (await import('puppeteer-core')).default;
|
||||||
|
srv = await startRealServer();
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
});
|
||||||
|
after(async () => { for (const c of clients) await c.close(); await srv?.stop(); });
|
||||||
|
|
||||||
|
/** A fresh browser on `base` with `sp.pref.stats` (a string) stored before the page loads. */
|
||||||
|
async function open({ w = 1920, h = 1080, stored = null, entered = true, base = srv.base, lang = null } = {}) {
|
||||||
|
const c = new Client(puppeteer, base, 'stats', { prefix: 'stats', w, h });
|
||||||
|
clients.push(c);
|
||||||
|
c.browser = await puppeteer.launch({
|
||||||
|
executablePath: process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', headless: true,
|
||||||
|
args: ['--no-sandbox', '--mute-audio', '--force-device-scale-factor=1'],
|
||||||
|
});
|
||||||
|
const [first] = await c.browser.pages();
|
||||||
|
c.page = first || await c.browser.newPage();
|
||||||
|
await c.page.setViewport({ width: w, height: h, hasTouch: w < 1000, isMobile: w < 1000 });
|
||||||
|
c.page.on('console', (m) => { if (m.type() === 'error') c.problems.push(`console: ${m.text()}`); });
|
||||||
|
c.page.on('pageerror', (e) => c.problems.push(`pageerror: ${e.message}`));
|
||||||
|
await c.page.evaluateOnNewDocument((stored, entered, lang) => {
|
||||||
|
localStorage.setItem('sp.name', 'Doctor');
|
||||||
|
if (entered) sessionStorage.setItem('sp.entered', '1');
|
||||||
|
if (lang) localStorage.setItem('sp.pref.lang', JSON.stringify(lang));
|
||||||
|
if (stored != null && !sessionStorage.getItem('sp.e2e.seeded')) { localStorage.setItem('sp.pref.stats', stored); sessionStorage.setItem('sp.e2e.seeded', '1'); }
|
||||||
|
}, stored, entered, lang);
|
||||||
|
await c.page.goto(`${base}/`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await c.page.waitForFunction((needOnline) => (!needOnline || globalThis.__SP__?.store.get().connection.status === 'online') && !!document.querySelector('.screen'), { timeout: 30000 }, entered);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
const texts = (c, sel) => c.page.$$eval(sel, (els) => els.map((e) => e.innerText.replace(/\s+/g, ' ').trim()));
|
||||||
|
const stored = (c) => c.page.evaluate(() => { const r = localStorage.getItem('sp.pref.stats'); return r ? JSON.parse(r) : null; });
|
||||||
|
const openStats = async (c, from = '.lobby-screen .topbar__right .btn') => {
|
||||||
|
await c.click(from, '统计');
|
||||||
|
await c.page.waitForSelector('.st .st-tab', { visible: true, timeout: 15000 });
|
||||||
|
};
|
||||||
|
const toast = (c) => c.page.waitForFunction(() => document.querySelector('.toast')?.textContent || false, { timeout: 8000 }).then((h) => h.jsonValue());
|
||||||
|
const noProblems = (c) => assert.deepEqual(c.problems, [], 'no console errors / failed requests');
|
||||||
|
|
||||||
|
test('desktop: tabs, the counting rule on screen, a settlement re-viewed, Esc, the title entry', async () => {
|
||||||
|
const env = seed();
|
||||||
|
const c = await open({ stored: JSON.stringify(env) });
|
||||||
|
await c.page.waitForSelector('.lobby-screen');
|
||||||
|
await openStats(c);
|
||||||
|
// 总览: 24 settled + the quit after 6 rounds count, the first-round one does not
|
||||||
|
const cards = await texts(c, '.st-card__num');
|
||||||
|
assert.equal(cards[0], '25', 'games');
|
||||||
|
assert.match(await c.page.$eval('.st-rule', (e) => e.textContent), /另有 1 局未计入统计/);
|
||||||
|
assert.equal(await c.page.$$eval('.st-title', (els) => els.length), 6, 'the six 评语');
|
||||||
|
await c.page.screenshot({ path: path.join(OUT, 'stats-overview-desktop.png') });
|
||||||
|
// 策略 / 战斗累计
|
||||||
|
await c.click('.st-tab[data-tab="bands"]');
|
||||||
|
assert.ok((await c.page.$$eval('.st-table tbody tr', (r) => r.length)) >= 5, 'strategy rows');
|
||||||
|
await c.page.screenshot({ path: path.join(OUT, 'stats-bands-desktop.png') });
|
||||||
|
await c.click('.st-tab[data-tab="totals"]');
|
||||||
|
assert.ok((await c.page.$$eval('.st-sum', (r) => r.length)) >= 8, 'combat totals');
|
||||||
|
// 对局记录: every record, the left-early ones marked, the first-round one dimmed, only settled rows open
|
||||||
|
await c.click('.st-tab[data-tab="history"]');
|
||||||
|
const rows = await texts(c, '.st-table--history tbody tr');
|
||||||
|
assert.equal(rows.length, 26);
|
||||||
|
assert.equal(rows.filter((r) => r.includes('中途退出')).length, 2);
|
||||||
|
assert.equal(rows.filter((r) => r.includes('不计入统计')).length, 1);
|
||||||
|
assert.equal(await c.page.$$eval('.st-table--history tr.is-excluded', (r) => r.length), 1);
|
||||||
|
assert.equal(await c.page.$$eval('.st-table--history tr.is-clickable', (r) => r.length), 24);
|
||||||
|
await c.page.screenshot({ path: path.join(OUT, 'stats-history-desktop.png') });
|
||||||
|
// a row re-views the settlement (the real result screen), 返回统计 comes back to the list, Esc closes
|
||||||
|
await c.click('.st-table--history tr.is-clickable');
|
||||||
|
await c.page.waitForSelector('.st--replay .result', { visible: true, timeout: 20000 });
|
||||||
|
await sleep(900);
|
||||||
|
await c.page.screenshot({ path: path.join(OUT, 'stats-replay-desktop.png') });
|
||||||
|
assert.ok(await c.page.$('.st--replay .rcard.is-self'), 'the player\'s own card is marked 你 (the record\'s own id)');
|
||||||
|
await c.page.keyboard.press('Escape');
|
||||||
|
await c.page.waitForSelector('.st .st-tab', { timeout: 5000 });
|
||||||
|
assert.ok(!(await c.exists('.st--replay')), 'Esc in the re-view goes back to the list');
|
||||||
|
await c.page.keyboard.press('Escape');
|
||||||
|
await c.page.waitForFunction(() => !document.querySelector('.st'), { timeout: 5000 });
|
||||||
|
assert.ok(await c.exists('.lobby-screen'));
|
||||||
|
noProblems(c);
|
||||||
|
await c.close();
|
||||||
|
// the title screen's entry (not entered yet: no connection needed), in English
|
||||||
|
const t = await open({ stored: JSON.stringify(env), entered: false, lang: 'en' });
|
||||||
|
await t.click('.title-corner .btn', 'Stats');
|
||||||
|
await t.page.waitForSelector('.st .st-tab', { visible: true, timeout: 15000 });
|
||||||
|
assert.match(await t.page.$eval('.st-top', (e) => e.textContent), /Overview/);
|
||||||
|
await t.page.screenshot({ path: path.join(OUT, 'stats-overview-title-en.png') });
|
||||||
|
noProblems(t);
|
||||||
|
await t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phone in landscape (844×390): the page and the re-view fit; no console errors', async () => {
|
||||||
|
const c = await open({ w: 844, h: 390, stored: JSON.stringify(seed()) });
|
||||||
|
await c.page.waitForSelector('.lobby-screen');
|
||||||
|
await openStats(c);
|
||||||
|
const fits = () => c.page.evaluate(() => {
|
||||||
|
const st = document.querySelector('.st').getBoundingClientRect();
|
||||||
|
const top = document.querySelector('.st-top').getBoundingClientRect();
|
||||||
|
const right = document.querySelector('.st-top__right').getBoundingClientRect();
|
||||||
|
const tabs = document.querySelector('.st-tabs').getBoundingClientRect();
|
||||||
|
return { w: st.width, topH: top.height, rightEdge: right.right, tabsRight: tabs.right, rightLeft: right.left, scrollW: document.documentElement.scrollWidth };
|
||||||
|
});
|
||||||
|
const f = await fits();
|
||||||
|
assert.ok(f.rightEdge <= f.w + 1 && f.tabsRight <= f.rightLeft + 1, `the top bar fits: ${JSON.stringify(f)}`);
|
||||||
|
assert.ok(f.scrollW <= 845, 'no horizontal page scroll');
|
||||||
|
await c.page.screenshot({ path: path.join(OUT, 'stats-overview-phone.png') });
|
||||||
|
await c.click('.st-tab[data-tab="history"]');
|
||||||
|
await c.page.screenshot({ path: path.join(OUT, 'stats-history-phone.png') });
|
||||||
|
await c.click('.st-table--history tr.is-clickable');
|
||||||
|
await c.page.waitForSelector('.st--replay .result', { visible: true, timeout: 20000 });
|
||||||
|
await sleep(900);
|
||||||
|
await c.page.screenshot({ path: path.join(OUT, 'stats-replay-phone.png') });
|
||||||
|
await c.click('.st--replay .btn--primary', '返回统计');
|
||||||
|
await c.page.waitForSelector('.st .st-tab', { timeout: 5000 });
|
||||||
|
noProblems(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the data actions: export → import (nothing new), a corrupt file, a newer file, 清空 with its confirm dialog', async () => {
|
||||||
|
const c = await open({ stored: JSON.stringify(seed(6)) });
|
||||||
|
await c.page.waitForSelector('.lobby-screen');
|
||||||
|
await openStats(c);
|
||||||
|
// 导出: capture the blob the page hands to the browser
|
||||||
|
await c.page.evaluate(() => {
|
||||||
|
HTMLAnchorElement.prototype.click = function click() { globalThis.__dl = { name: this.download, href: this.href }; };
|
||||||
|
});
|
||||||
|
await c.click('[data-testid="stats-export"]');
|
||||||
|
await c.page.waitForFunction(() => !!globalThis.__dl, { timeout: 5000 });
|
||||||
|
const exported = await c.page.evaluate(async () => { const r = await fetch(globalThis.__dl.href); return { name: globalThis.__dl.name, text: await r.text() }; });
|
||||||
|
assert.match(exported.name, /^stronghold-stats-\d{8}-\d{4}\.json$/);
|
||||||
|
const payload = JSON.parse(exported.text);
|
||||||
|
assert.equal(payload.kind, 'local-stats');
|
||||||
|
assert.equal(payload.records.length, 8);
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), 'sp-stats-'));
|
||||||
|
const upload = async (name, text) => {
|
||||||
|
const file = path.join(dir, name);
|
||||||
|
writeFileSync(file, text);
|
||||||
|
const input = await c.page.$('.st input[type="file"]');
|
||||||
|
await input.uploadFile(file);
|
||||||
|
};
|
||||||
|
await c.page.evaluate(() => document.querySelectorAll('.toast').forEach((e) => e.remove()));
|
||||||
|
// 导入 the same file: nothing new
|
||||||
|
await upload('same.json', exported.text);
|
||||||
|
assert.match(await toast(c), /没有新记录/);
|
||||||
|
assert.equal((await stored(c)).records.length, 8);
|
||||||
|
await c.page.evaluate(() => document.querySelectorAll('.toast').forEach((e) => e.remove()));
|
||||||
|
// a corrupt file and a newer one change nothing
|
||||||
|
await upload('bad.json', '{"records": [1, 2');
|
||||||
|
assert.match(await toast(c), /不是有效的统计导出文件/);
|
||||||
|
await c.page.evaluate(() => document.querySelectorAll('.toast').forEach((e) => e.remove()));
|
||||||
|
await upload('newer.json', JSON.stringify({ kind: 'local-stats', v: 99, records: [] }));
|
||||||
|
assert.match(await toast(c), /更新版本/);
|
||||||
|
assert.equal((await stored(c)).records.length, 8);
|
||||||
|
await c.page.evaluate(() => document.querySelectorAll('.toast').forEach((e) => e.remove()));
|
||||||
|
// 导入 a file with one new record: merged, newest first
|
||||||
|
const extra = { ...payload.records[0], id: 'r1.cafe0001', t: NOW + DAY };
|
||||||
|
await upload('extra.json', JSON.stringify({ ...payload, records: [extra] }));
|
||||||
|
assert.match(await toast(c), /已导入 1 条记录/);
|
||||||
|
assert.equal((await stored(c)).records[0].id, 'r1.cafe0001');
|
||||||
|
// 清空: the confirm dialog is above the overlay, Esc closes only it, the second time confirms
|
||||||
|
await c.click('[data-testid="stats-clear"]');
|
||||||
|
await c.page.waitForSelector('.modal', { visible: true });
|
||||||
|
assert.match(await c.page.$eval('.modal', (e) => e.textContent), /将删除本机保存的 9 条对局记录/);
|
||||||
|
await sleep(250); // the dialog's key handler is attached by its effect, a moment after it paints
|
||||||
|
await c.page.keyboard.press('Escape');
|
||||||
|
await c.page.waitForFunction(() => !document.querySelector('.modal'), { timeout: 3000 });
|
||||||
|
assert.ok(await c.exists('.st'), 'Esc closed the dialog only');
|
||||||
|
assert.equal((await stored(c)).records.length, 9, 'nothing deleted');
|
||||||
|
await c.click('[data-testid="stats-clear"]');
|
||||||
|
await c.page.waitForSelector('.modal', { visible: true });
|
||||||
|
await c.click('.modal__actions button.btn--danger');
|
||||||
|
await c.page.waitForFunction(() => /还没有记录/.test(document.querySelector('.st-body')?.textContent || ''), { timeout: 5000 });
|
||||||
|
assert.deepEqual((await stored(c)).records, []);
|
||||||
|
noProblems(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('corrupt and newer storage: a notice, no crash; the newer build\'s data is never overwritten', async () => {
|
||||||
|
const bad = '{"v":1,"records":[{"players":[';
|
||||||
|
const c = await open({ stored: bad });
|
||||||
|
await c.page.waitForSelector('.lobby-screen');
|
||||||
|
await openStats(c);
|
||||||
|
assert.match(await c.page.$eval('.st-note.is-warn', (e) => e.textContent), /无法读取,已另存为备份/);
|
||||||
|
assert.equal(await c.page.evaluate(() => localStorage.getItem('sp.pref.stats.damaged')), bad, 'kept as a backup');
|
||||||
|
noProblems(c);
|
||||||
|
await c.close();
|
||||||
|
const newer = JSON.stringify({ v: STATS_VERSION + 3, records: [{ players: [{ playerId: 'x' }], future: true }] });
|
||||||
|
const n = await open({ stored: newer });
|
||||||
|
await n.page.waitForSelector('.lobby-screen');
|
||||||
|
await openStats(n);
|
||||||
|
assert.match(await n.page.$eval('.st-body', (e) => e.textContent), /更新版本的统计数据/);
|
||||||
|
assert.ok(await n.page.$eval('[data-testid="stats-clear"]', (b) => b.disabled), 'no 清空 over data this build cannot read');
|
||||||
|
assert.equal(await n.page.evaluate(() => localStorage.getItem('sp.pref.stats')), newer);
|
||||||
|
noProblems(n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('统计数据: the real flows (a fast real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||||
|
let puppeteer;
|
||||||
|
const servers = [];
|
||||||
|
before(async () => { puppeteer = (await import('puppeteer-core')).default; });
|
||||||
|
after(async () => { for (const s of servers) await s.stop(); });
|
||||||
|
|
||||||
|
const stored = (c) => c.page.evaluate(() => { const r = localStorage.getItem('sp.pref.stats'); return r ? JSON.parse(r) : null; });
|
||||||
|
async function toSoloMatch(c) {
|
||||||
|
await c.click('.mode-card', '独立模拟');
|
||||||
|
await sleep(300);
|
||||||
|
await c.click('.diff-card', '标准模拟');
|
||||||
|
await c.click('.create-box button', '开始独立模拟');
|
||||||
|
await c.waitFor((s) => !!s.room, 'solo room');
|
||||||
|
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||||
|
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||||
|
}
|
||||||
|
async function toPrep(c) {
|
||||||
|
await c.click('.brief__foot .btn--primary', '准备就绪');
|
||||||
|
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
|
||||||
|
await c.click('.dband', null, { nth: 1 });
|
||||||
|
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||||
|
await c.waitFor((s) => s.phase === 'PREP' && s.round === 1, 'prep round 1', 60000);
|
||||||
|
await sleep(1500);
|
||||||
|
}
|
||||||
|
async function giveUp(c) {
|
||||||
|
await c.click('.stephead .btn--danger, .gtop__exit', null, { timeout: 8000 });
|
||||||
|
await c.click('.modal__actions button', '放弃模拟');
|
||||||
|
await c.waitFor((s) => !s.room && !s.phase, 'back in the lobby', 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('given up in the briefing: history only; given up after round 1: it counts', async () => {
|
||||||
|
const srv = await startRealServer({ fast: { timerScale: 0.25, combatSpeed: 8 } });
|
||||||
|
servers.push(srv);
|
||||||
|
const c = new Client(puppeteer, srv.base, 'stats', { prefix: 'stats', w: 1600, h: 900 });
|
||||||
|
try {
|
||||||
|
await c.open();
|
||||||
|
await c.enter('Doc');
|
||||||
|
// entered and left right away
|
||||||
|
await toSoloMatch(c);
|
||||||
|
await giveUp(c);
|
||||||
|
let env = await stored(c);
|
||||||
|
assert.equal(env.records.length, 1);
|
||||||
|
assert.equal(env.records[0].end, 'quit');
|
||||||
|
assert.equal(env.records[0].players[0].roundsPassed, 0);
|
||||||
|
// through round 1, then given up in round 2
|
||||||
|
await toSoloMatch(c);
|
||||||
|
await toPrep(c);
|
||||||
|
await c.click('.readybtn');
|
||||||
|
await c.waitFor((s) => s.round >= 2 && ['PREP', 'ROUND_START', 'SP_DRAFT'].includes(s.phase), 'round 2 (round 1 cleared)', 120000);
|
||||||
|
await giveUp(c);
|
||||||
|
env = await stored(c);
|
||||||
|
assert.equal(env.records.length, 2);
|
||||||
|
assert.equal(env.records[0].end, 'quit');
|
||||||
|
assert.equal(env.records[0].players[0].roundsPassed, 1, 'one round cleared — the server\'s own count for a quit in round 2');
|
||||||
|
assert.ok(env.records[0].players[0].bandId, 'its strategy');
|
||||||
|
// the page: one game counted, one left out, both in the history
|
||||||
|
await c.click('.lobby-screen .topbar__right .btn', '统计');
|
||||||
|
await c.page.waitForSelector('.st .st-card', { visible: true });
|
||||||
|
assert.equal((await c.page.$$eval('.st-card__num', (els) => els.map((e) => e.textContent)))[0], '1');
|
||||||
|
assert.match(await c.page.$eval('.st-rule', (e) => e.textContent), /另有 1 局未计入统计/);
|
||||||
|
await c.click('.st-tab[data-tab="history"]');
|
||||||
|
const rows = await c.page.$$eval('.st-table--history tbody tr', (trs) => trs.map((tr) => tr.innerText.replace(/\s+/g, ' ').trim()));
|
||||||
|
assert.equal(rows.length, 2);
|
||||||
|
assert.ok(rows[0].includes('中途退出') && !rows[0].includes('不计入统计'), rows[0]);
|
||||||
|
assert.ok(rows[1].includes('中途退出') && rows[1].includes('不计入统计'), rows[1]);
|
||||||
|
await c.shot('history-real');
|
||||||
|
assert.deepEqual(c.problems, []);
|
||||||
|
} finally { await c.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('played to its settlement: one record, and a reload (the lobby replays m.result) adds no second one', async () => {
|
||||||
|
const srv = await startRealServer({ fast: { timerScale: 0.25, combatSpeed: 8, finishAfter: 1 } });
|
||||||
|
servers.push(srv);
|
||||||
|
const c = new Client(puppeteer, srv.base, 'stats', { prefix: 'stats', w: 1600, h: 900 });
|
||||||
|
try {
|
||||||
|
await c.open();
|
||||||
|
await c.enter('Doc');
|
||||||
|
await toSoloMatch(c);
|
||||||
|
await toPrep(c);
|
||||||
|
await c.click('.readybtn');
|
||||||
|
await c.waitFor((s) => s.result, 'the settlement', 120000);
|
||||||
|
await sleep(500);
|
||||||
|
let env = await stored(c);
|
||||||
|
assert.equal(env.records.length, 1);
|
||||||
|
assert.equal(env.records[0].end, 'settled');
|
||||||
|
assert.equal(env.records[0].roomMode, 'solo');
|
||||||
|
assert.equal(env.records[0].selfId, (await c.st()).me);
|
||||||
|
await c.page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
await c.page.waitForFunction(() => !!globalThis.__SP__ && !!document.querySelector('.screen'), { timeout: 30000 });
|
||||||
|
await c.waitFor((s) => s.result, 'the replayed settlement after a reload', 30000);
|
||||||
|
await sleep(500);
|
||||||
|
env = await stored(c);
|
||||||
|
assert.equal(env.records.length, 1, 'the replay is the same record');
|
||||||
|
assert.deepEqual(c.problems, []);
|
||||||
|
} finally { await c.close(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
+486
-77
@@ -1,18 +1,24 @@
|
|||||||
// Unit tests (Node) for the 本机对局统计 record store (public/js/ui/stats.js):
|
// Unit tests (Node) for the 本机对局统计 record store (public/js/ui/stats.js; PR #323 by @2321Robin, reworked for 0.2.2):
|
||||||
// record building from a raw m.result (spectator copies and player-less error results build nothing),
|
// 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),
|
// 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),
|
// the counting rule (a match left before a round was cleared is history only — the owner's decision of 2026-10-08),
|
||||||
// import merge semantics and the aggregate the stats page renders.
|
// the match left early (放弃模拟): quit records built from the page's view of the match,
|
||||||
// localStorage is faked per test (Node has none); recordResult / loadStats / saveStats go through it.
|
// bounded storage (MAX_RECORDS, MAX_CHARS, the compacted older records, a refused write),
|
||||||
|
// corrupt / old storage (unreadable JSON, damaged rows, a pre-`end` envelope, a newer one never overwritten),
|
||||||
|
// import merge semantics (newest first) and the aggregate the stats page renders.
|
||||||
|
// localStorage is faked per test (Node has none); recordResult / recordQuit / loadStats / saveStats go through it.
|
||||||
|
|
||||||
import { test, describe, beforeEach, afterEach } from 'node:test';
|
import { test, describe } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
STATS_VERSION, MAX_RECORDS, emptyStats, buildRecord, recordId, appendRecord, recordResult,
|
STATS_VERSION, MAX_RECORDS, MAX_CHARS, FULL_KEEP, emptyStats, buildRecord, recordId, appendRecord, recordResult, recordQuit,
|
||||||
loadStats, saveStats, migrateStats, normalizeRecord, mergeStats, importStats, exportStats,
|
loadStats, saveStats, migrateStats, normalizeRecord, importStats, exportStats, aggregateStats, selfRowOf, roomModeOf,
|
||||||
aggregateStats, selfRowOf, roomModeOf, recordToResult, MIGRATIONS,
|
recordToResult, compactRecord, fitToBudget, countsTowardStats, roundsOf, clearedRounds, createLiveMatch, observeMatch,
|
||||||
|
buildQuitRecord, installStatsRecorder, liveMatch, MIGRATIONS,
|
||||||
} from '../../public/js/ui/stats.js';
|
} from '../../public/js/ui/stats.js';
|
||||||
|
import { createStore, emptyMatch } from '../../public/js/store.js';
|
||||||
import { normalizeResult } from '../../public/js/ui/gameLogic.js';
|
import { normalizeResult } from '../../public/js/ui/gameLogic.js';
|
||||||
|
import { PHASE } from '../../shared/constants.js';
|
||||||
|
|
||||||
/** A realistic m.result in the shape server/match/results.js buildResult emits (replayed byte-identically). */
|
/** A realistic m.result in the shape server/match/results.js buildResult emits (replayed byte-identically). */
|
||||||
function baseResult(over = {}) {
|
function baseResult(over = {}) {
|
||||||
@@ -28,7 +34,7 @@ function baseResult(over = {}) {
|
|||||||
title: { id: 'comment_1', name: '卫戍之星', picId: 'comment_icon_1', text: '对敌方领袖造成伤害最高(仅胜利时)' },
|
title: { id: 'comment_1', name: '卫戍之星', picId: 'comment_icon_1', text: '对敌方领袖造成伤害最高(仅胜利时)' },
|
||||||
trophies: 30, reward: 88,
|
trophies: 30, reward: 88,
|
||||||
lineup: [{ id: 'char_290_vigna', golden: false, tier: 2, row: 10, col: 5, items: ['item_eq_1'] }],
|
lineup: [{ id: 'char_290_vigna', golden: false, tier: 2, row: 10, col: 5, items: ['item_eq_1'] }],
|
||||||
bonds: [{ bondId: 'steadShip', layers: 12, active: true }],
|
bonds: [{ bondId: 'steadShip', count: 3, tier: 1, 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 },
|
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 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -44,21 +50,56 @@ function baseResult(over = {}) {
|
|||||||
/** Fake localStorage: Map-backed, with an optional byte budget that makes setItem throw like a full quota. */
|
/** Fake localStorage: Map-backed, with an optional byte budget that makes setItem throw like a full quota. */
|
||||||
function fakeStorage({ budget = Infinity } = {}) {
|
function fakeStorage({ budget = Infinity } = {}) {
|
||||||
const m = new Map();
|
const m = new Map();
|
||||||
const calls = [];
|
|
||||||
return {
|
return {
|
||||||
calls,
|
|
||||||
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
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)); },
|
setItem(k, v) { if (String(v).length > budget) { const e = new Error('quota'); e.name = 'QuotaExceededError'; throw e; } m.set(k, String(v)); },
|
||||||
removeItem: (k) => m.delete(k),
|
removeItem: (k) => { m.delete(k); },
|
||||||
get raw() { return m.get('sp.pref.stats') ?? null; },
|
get raw() { return m.get('sp.pref.stats') ?? null; },
|
||||||
|
get damaged() { return m.get('sp.pref.stats.damaged') ?? null; },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
/** Run `fn` with a fake localStorage installed. */
|
||||||
|
function withStorage(opts, fn) {
|
||||||
|
if (typeof opts === 'function') { fn = opts; opts = {}; }
|
||||||
|
const ls = fakeStorage(opts);
|
||||||
|
globalThis.localStorage = ls;
|
||||||
|
try { return fn(ls); } finally { delete globalThis.localStorage; }
|
||||||
|
}
|
||||||
|
/** n settled records, newest first, t = n−1 … 0 */
|
||||||
|
function manyRecords(n, mk = (i) => baseResult({ seed: i })) {
|
||||||
|
let stats = emptyStats();
|
||||||
|
for (let i = 0; i < n; i++) stats = appendRecord(stats, buildRecord(mk(i), { myId: 'p1', now: i })).stats;
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the match on screen, as the app store holds it ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** An app state with a running match: `phase` of `round`, the local player p1 (alive unless `alive: false`). */
|
||||||
|
function liveState({ phase = PHASE.PREP, round = 1, alive = true, spectator = false, result = null, roomMode = 'solo', difficulty = 'HARD' } = {}) {
|
||||||
|
const players = spectator ? [{ playerId: 'p9', seat: 0, name: '别人', alive: true, lp: 20, bandId: 'band_x' }]
|
||||||
|
: [{ playerId: 'p1', seat: 0, name: '罗宾', alive, lp: alive ? 22 : 0, bandId: 'band_amiya' }];
|
||||||
|
return {
|
||||||
|
me: { playerId: 'p1', name: '罗宾' },
|
||||||
|
room: { code: 'ABCD', mode: roomMode },
|
||||||
|
match: {
|
||||||
|
public: { phase, round, lastRound: 14, modeId: roomMode === 'solo' ? `mode_single_${difficulty.toLowerCase()}` : `mode_multi_${difficulty.toLowerCase()}`, difficulty, stageId: 'stage_a', bossId: 'boss_2', hiddenBossId: 'boss_10', players },
|
||||||
|
private: null, field: null, result, battle: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** The tracker as it would stand after watching `states` (one `now` per state). */
|
||||||
|
function watched(states, t0 = 1_000_000) {
|
||||||
|
const live = createLiveMatch();
|
||||||
|
states.forEach((s, i) => observeMatch(live, s, t0 + i * 1000));
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
|
||||||
describe('local stats: record building', () => {
|
describe('local stats: record building', () => {
|
||||||
test('builds one full record from a raw m.result (frame + players + self marking)', () => {
|
test('builds one full record from a raw m.result (frame + players + self marking)', () => {
|
||||||
const rec = buildRecord(baseResult(), { myId: 'p1', roomMode: 'solo', now: 1700000000000 });
|
const rec = buildRecord(baseResult(), { myId: 'p1', roomMode: 'solo', now: 1700000000000 });
|
||||||
assert.ok(rec);
|
assert.ok(rec);
|
||||||
assert.equal(rec.v, STATS_VERSION);
|
assert.equal(rec.v, STATS_VERSION);
|
||||||
|
assert.equal(rec.end, 'settled');
|
||||||
assert.equal(rec.t, 1700000000000);
|
assert.equal(rec.t, 1700000000000);
|
||||||
assert.equal(rec.selfId, 'p1');
|
assert.equal(rec.selfId, 'p1');
|
||||||
assert.equal(rec.victory, true);
|
assert.equal(rec.victory, true);
|
||||||
@@ -70,12 +111,25 @@ describe('local stats: record building', () => {
|
|||||||
assert.equal(rec.players.length, 2);
|
assert.equal(rec.players.length, 2);
|
||||||
const self = rec.players[0];
|
const self = rec.players[0];
|
||||||
assert.equal(self.bandId, 'band_amiya');
|
assert.equal(self.bandId, 'band_amiya');
|
||||||
assert.equal(self.title.id, 'comment_1');
|
assert.deepEqual(self.title, { id: 'comment_1' }, 'the title is kept by id: its texts come from the game data');
|
||||||
assert.equal(self.stats.bossDamage, 5000.5);
|
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.lineup, [{ id: 'char_290_vigna', golden: false, tier: 2, items: ['item_eq_1'] }]);
|
||||||
assert.deepEqual(self.bonds, [{ bondId: 'steadShip', layers: 12, active: true }]);
|
assert.deepEqual(self.bonds, [{ bondId: 'steadShip', layers: 12, active: true }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a 自选 pick and a 补位 stand-in survive in the lineup, or the replayed card would draw the plain operator', () => {
|
||||||
|
const res = baseResult();
|
||||||
|
res.players[0].lineup = [
|
||||||
|
{ id: 'chess_diy_a', golden: true, tier: 5, row: 1, col: 1, items: [], diy: { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege', extra: 'x' } },
|
||||||
|
{ id: 'chess_x', golden: false, tier: 1, items: [], standInFor: 'char_002_amiya' },
|
||||||
|
{ id: 'tok_1', kind: 'token', golden: false, tier: 0, items: [] },
|
||||||
|
];
|
||||||
|
const back = buildRecord(res, { myId: 'p1', now: 1 }).players[0].lineup;
|
||||||
|
assert.deepEqual(back[0].diy, { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' });
|
||||||
|
assert.equal(back[1].standInFor, 'char_002_amiya');
|
||||||
|
assert.equal(back[2].kind, 'token');
|
||||||
|
});
|
||||||
|
|
||||||
test('a spectator seat copy (my id not among players) records nothing; so do player-less error results', () => {
|
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(), { myId: 'spec9', now: 1 }), null);
|
||||||
assert.equal(buildRecord(baseResult({ players: [] }), { myId: 'p1', now: 1 }), null);
|
assert.equal(buildRecord(baseResult({ players: [] }), { myId: 'p1', now: 1 }), null);
|
||||||
@@ -90,9 +144,9 @@ describe('local stats: record building', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('local stats: replay dedupe + cap', () => {
|
describe('local stats: replay dedupe', () => {
|
||||||
test('the replayed m.result (same content) appends once; a different seed is a new game', () => {
|
test('the replayed m.result (same content) appends once; a different seed is a new game', () => {
|
||||||
let stats = emptyStats();
|
const stats = emptyStats();
|
||||||
const res = baseResult();
|
const res = baseResult();
|
||||||
const first = appendRecord(stats, buildRecord(res, { myId: 'p1', now: 1000 }));
|
const first = appendRecord(stats, buildRecord(res, { myId: 'p1', now: 1000 }));
|
||||||
assert.equal(first.added, true);
|
assert.equal(first.added, true);
|
||||||
@@ -107,8 +161,7 @@ describe('local stats: replay dedupe + cap', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('recordResult end-to-end over faked localStorage: replay arrives, nothing is written twice', () => {
|
test('recordResult end-to-end over faked localStorage: replay arrives, nothing is written twice', () => {
|
||||||
globalThis.localStorage = fakeStorage();
|
withStorage(() => {
|
||||||
try {
|
|
||||||
const res = baseResult();
|
const res = baseResult();
|
||||||
const rec = recordResult(res, { myId: 'p1', roomMode: 'solo', now: 1000 });
|
const rec = recordResult(res, { myId: 'p1', roomMode: 'solo', now: 1000 });
|
||||||
assert.ok(rec);
|
assert.ok(rec);
|
||||||
@@ -116,92 +169,418 @@ describe('local stats: replay dedupe + cap', () => {
|
|||||||
const loaded = loadStats();
|
const loaded = loadStats();
|
||||||
assert.equal(loaded.records.length, 1);
|
assert.equal(loaded.records.length, 1);
|
||||||
assert.equal(loaded.records[0].id, rec.id);
|
assert.equal(loaded.records[0].id, rec.id);
|
||||||
} finally { delete globalThis.localStorage; }
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('local stats: the counting rule (a match entered and left right away does not count)', () => {
|
||||||
|
/** A quit record whose player left in `phase` of `round` (alive unless told otherwise). */
|
||||||
|
const quitAt = (phase, round, o = {}) => {
|
||||||
|
const s = liveState({ phase, round, ...o });
|
||||||
|
return buildQuitRecord(s, { live: watched([liveState({ phase: PHASE.INFO_CHECK, round: 0 }), s]), now: 5_000_000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
test('a settled match always counts — even one lost in the first rounds', () => {
|
||||||
|
const rec = buildRecord(baseResult({ victory: false, roundsPassed: 0, players: [{ ...baseResult().players[0], victory: false, alive: false, roundsPassed: 0 }] }), { myId: 'p1', now: 1 });
|
||||||
|
assert.equal(rec.end, 'settled');
|
||||||
|
assert.equal(countsTowardStats(rec), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`the store caps at MAX_RECORDS (${MAX_RECORDS}), dropping the oldest`, () => {
|
test('left before the first round was cleared: history only; once a round is cleared it counts', () => {
|
||||||
let stats = emptyStats();
|
// the server's rule (platform.js _quit): leaving while round k runs has passed k − 1 rounds, leaving in its settlement k
|
||||||
for (let i = 0; i < MAX_RECORDS + 30; i++) {
|
for (const [phase, round, passed, counts] of [
|
||||||
stats = appendRecord(stats, buildRecord(baseResult({ seed: i }), { myId: 'p1', now: i })).stats;
|
[PHASE.INFO_CHECK, 0, 0, false], [PHASE.BAND_DRAFT, 0, 0, false], [PHASE.PREP, 1, 0, false], [PHASE.COMBAT, 1, 0, false],
|
||||||
|
[PHASE.SETTLE, 1, 1, true], [PHASE.PREP, 2, 1, true], [PHASE.COMBAT, 9, 8, true], [PHASE.FINAL_ASSAULT, 14, 13, true],
|
||||||
|
]) {
|
||||||
|
const rec = quitAt(phase, round);
|
||||||
|
assert.equal(rec.end, 'quit', `${phase} ${round}`);
|
||||||
|
assert.equal(rec.players[0].roundsPassed, passed, `${phase} ${round}: rounds cleared`);
|
||||||
|
assert.equal(clearedRounds(phase, round), passed);
|
||||||
|
assert.equal(countsTowardStats(rec), counts, `${phase} ${round}: counts`);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('left after being eliminated: it counts (the player lost it), the rounds are those it was seen alive in, less one', () => {
|
||||||
|
const s = [liveState({ phase: PHASE.INFO_CHECK, round: 0 }), liveState({ round: 4 }), liveState({ round: 5 }), liveState({ phase: PHASE.SETTLE, round: 5, alive: false }), liveState({ phase: PHASE.PREP, round: 8, alive: false })];
|
||||||
|
const live = watched(s);
|
||||||
|
const rec = buildQuitRecord(s[4], { live, now: 9e6 });
|
||||||
|
assert.equal(rec.players[0].alive, false);
|
||||||
|
assert.equal(rec.players[0].eliminatedRound, 5);
|
||||||
|
assert.equal(rec.players[0].roundsPassed, 4, 'eliminated in round 5 ⇒ 4 passed (the round 8 it left in is not its)');
|
||||||
|
assert.equal(roundsOf(rec), 4);
|
||||||
|
assert.equal(countsTowardStats(rec), true);
|
||||||
|
// a page that never saw it alive (a reload after the elimination) does not know the rounds — it still counts
|
||||||
|
const unknown = buildQuitRecord(s[4], { live: watched([s[4]]), now: 9e6 });
|
||||||
|
assert.equal(unknown.players[0].roundsPassed, null);
|
||||||
|
assert.equal(roundsOf(unknown), 0);
|
||||||
|
assert.equal(countsTowardStats(unknown), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the aggregates take only the records that count; total / excluded say what was left out', () => {
|
||||||
|
const settled = buildRecord(baseResult(), { myId: 'p1', roomMode: 'solo', now: 3000 });
|
||||||
|
const peek = quitAt(PHASE.PREP, 1); // entered, left in the first round
|
||||||
|
const played = quitAt(PHASE.PREP, 6); // 5 rounds cleared, then gave up
|
||||||
|
const agg = aggregateStats([settled, peek, played]);
|
||||||
|
assert.equal(agg.total, 3);
|
||||||
|
assert.equal(agg.count, 2);
|
||||||
|
assert.equal(agg.excluded, 1);
|
||||||
|
assert.equal(agg.wins, 1);
|
||||||
|
assert.equal(agg.rounds.max, 16, 'the best of the player\'s own rounds');
|
||||||
|
assert.equal(agg.rounds.total, 16 + 5, 'the peek adds nothing');
|
||||||
|
assert.equal(agg.byDifficulty.HARD.games, 2);
|
||||||
|
assert.deepEqual(agg.bands.band_amiya, { games: 2, wins: 1 });
|
||||||
|
assert.equal(agg.byMode.solo, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('combat totals read the settled records only (a quit record has no stat block)', () => {
|
||||||
|
const agg = aggregateStats([buildRecord(baseResult(), { myId: 'p1', now: 1 }), quitAt(PHASE.PREP, 6)]);
|
||||||
|
assert.equal(agg.sums.kills, 41);
|
||||||
|
assert.equal(agg.count, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the history keeps every record, counted or not: the page filters nothing out of the list', () => {
|
||||||
|
const peek = quitAt(PHASE.INFO_CHECK, 0);
|
||||||
|
assert.equal(countsTowardStats(peek), false);
|
||||||
|
withStorage(() => {
|
||||||
|
let stats = emptyStats();
|
||||||
|
stats = appendRecord(stats, peek).stats;
|
||||||
|
stats = appendRecord(stats, buildRecord(baseResult(), { myId: 'p1', now: 7e6 })).stats;
|
||||||
|
assert.equal(saveStats(stats), true);
|
||||||
|
const back = loadStats();
|
||||||
|
assert.equal(back.records.length, 2);
|
||||||
|
assert.deepEqual(back.records.map((r) => r.end).sort(), ['quit', 'settled']);
|
||||||
|
assert.equal(back.records.find((r) => r.end === 'quit').players[0].roundsPassed, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('local stats: the match left early (放弃模拟)', () => {
|
||||||
|
test('builds a quit record from the page\'s view: frame, own row, no teammates, no stats, no replay', () => {
|
||||||
|
const s = liveState({ phase: PHASE.PREP, round: 3, roomMode: 'coop', difficulty: 'ABYSS' });
|
||||||
|
const rec = buildQuitRecord(s, { live: watched([liveState({ phase: PHASE.INFO_CHECK, round: 0, roomMode: 'coop', difficulty: 'ABYSS' }), s]), now: 2_000_000 });
|
||||||
|
assert.equal(rec.end, 'quit');
|
||||||
|
assert.equal(rec.reason, 'quit');
|
||||||
|
assert.equal(rec.victory, false);
|
||||||
|
assert.equal(rec.difficulty, 'ABYSS');
|
||||||
|
assert.equal(rec.roomMode, 'coop');
|
||||||
|
assert.equal(rec.modeId, 'mode_multi_abyss');
|
||||||
|
assert.equal(rec.selfId, 'p1');
|
||||||
|
assert.equal(rec.players.length, 1);
|
||||||
|
assert.equal(rec.players[0].left, true);
|
||||||
|
assert.equal(rec.players[0].bandId, 'band_amiya');
|
||||||
|
assert.deepEqual(rec.players[0].stats, {});
|
||||||
|
assert.equal(rec.durationMs, 1_000_000, 'seen from the start: now − when the page first saw it');
|
||||||
|
assert.equal(recordToResult(rec), null, 'nothing was settled: no settlement screen to show');
|
||||||
|
assert.match(rec.id, /^q\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no record for: no match on screen, a settlement already there, a spectator seat, a match the page never saw', () => {
|
||||||
|
const s = liveState({ round: 2 });
|
||||||
|
const live = watched([s]);
|
||||||
|
assert.equal(buildQuitRecord({ ...s, match: { ...s.match, public: null } }, { live }), null);
|
||||||
|
assert.equal(buildQuitRecord(liveState({ round: 2, result: { victory: true, players: [] } }), { live }), null);
|
||||||
|
assert.equal(buildQuitRecord(liveState({ round: 2, spectator: true }), { live: watched([liveState({ round: 2, spectator: true })]) }), null);
|
||||||
|
assert.equal(buildQuitRecord(s, { live: createLiveMatch() }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a match first seen mid-way (a reload) keeps no duration; the tracker restarts for the next match', () => {
|
||||||
|
const s = liveState({ phase: PHASE.PREP, round: 6 });
|
||||||
|
const live = watched([s]);
|
||||||
|
assert.equal(live.sawStart, false);
|
||||||
|
assert.equal(buildQuitRecord(s, { live, now: 9e6 }).durationMs, null);
|
||||||
|
// the match ends (result) and a new one starts: a fresh start
|
||||||
|
observeMatch(live, liveState({ phase: PHASE.RESULT, round: 14, result: { victory: true } }), 1);
|
||||||
|
assert.equal(live.active, false);
|
||||||
|
observeMatch(live, liveState({ phase: PHASE.INFO_CHECK, round: 0 }), 2);
|
||||||
|
assert.equal(live.active, true);
|
||||||
|
assert.equal(live.sawStart, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordQuit stores it through localStorage once; the settlement of the same match is a different record', () => {
|
||||||
|
withStorage(() => {
|
||||||
|
const s = liveState({ phase: PHASE.PREP, round: 4 });
|
||||||
|
const live = watched([liveState({ phase: PHASE.INFO_CHECK, round: 0 }), s]);
|
||||||
|
const rec = recordQuit(s, { live, now: 3e6 });
|
||||||
|
assert.ok(rec);
|
||||||
|
assert.equal(live.active, false, 'one record per match');
|
||||||
|
assert.equal(recordQuit(s, { live, now: 3e6 }), null);
|
||||||
|
assert.equal(loadStats().records.length, 1);
|
||||||
|
assert.equal(loadStats().records[0].players[0].roundsPassed, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('local stats: following the app store', () => {
|
||||||
|
test('installStatsRecorder watches a match from its start; a quit is recorded with its duration, then the tracker rests', () => {
|
||||||
|
withStorage(() => {
|
||||||
|
const first = liveState({ phase: PHASE.INFO_CHECK, round: 0 });
|
||||||
|
const store = createStore({ me: first.me, room: first.room, match: emptyMatch() });
|
||||||
|
const off = installStatsRecorder(store);
|
||||||
|
try {
|
||||||
|
assert.equal(liveMatch.active, false);
|
||||||
|
store.patch('match', { public: first.match.public });
|
||||||
|
assert.equal(liveMatch.active, true);
|
||||||
|
assert.equal(liveMatch.sawStart, true);
|
||||||
|
store.patch('match', { public: liveState({ phase: PHASE.PREP, round: 3 }).match.public });
|
||||||
|
const rec = recordQuit(store.get());
|
||||||
|
assert.ok(rec);
|
||||||
|
assert.equal(rec.players[0].roundsPassed, 2);
|
||||||
|
assert.ok(rec.durationMs >= 0);
|
||||||
|
assert.equal(liveMatch.active, false, 'recorded once');
|
||||||
|
// the app clears the match: still at rest; a new match is a fresh start
|
||||||
|
store.set({ match: emptyMatch() });
|
||||||
|
assert.equal(liveMatch.active, false);
|
||||||
|
store.patch('match', { public: first.match.public });
|
||||||
|
assert.equal(liveMatch.active, true);
|
||||||
|
} finally { off(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('local stats: bounded storage', () => {
|
||||||
|
test(`the store caps at MAX_RECORDS (${MAX_RECORDS}), dropping the oldest`, () => {
|
||||||
|
const stats = manyRecords(MAX_RECORDS + 30);
|
||||||
assert.equal(stats.records.length, MAX_RECORDS);
|
assert.equal(stats.records.length, MAX_RECORDS);
|
||||||
assert.equal(stats.records[0].t, MAX_RECORDS + 29); // newest first
|
assert.equal(stats.records[0].t, MAX_RECORDS + 29); // newest first
|
||||||
assert.equal(stats.records.at(-1).t, 30); // oldest 30 dropped
|
assert.equal(stats.records.at(-1).t, 30); // oldest 30 dropped
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(`only the newest ${FULL_KEEP} records keep their rows: the older ones are compacted to the player's own summary`, () => {
|
||||||
|
withStorage((ls) => {
|
||||||
|
assert.equal(saveStats(manyRecords(FULL_KEEP + 20)), true);
|
||||||
|
const stored = JSON.parse(ls.raw).records;
|
||||||
|
assert.equal(stored.length, FULL_KEEP + 20);
|
||||||
|
assert.equal(stored.slice(0, FULL_KEEP).every((r) => !r.compact && r.players.length === 2), true);
|
||||||
|
const old = stored.at(-1);
|
||||||
|
assert.equal(old.compact, true);
|
||||||
|
assert.equal(old.players.length, 1, 'no teammates');
|
||||||
|
assert.equal(old.players[0].name, undefined, 'no names');
|
||||||
|
assert.equal(old.players[0].lineup, undefined);
|
||||||
|
// what the aggregates read is all still there
|
||||||
|
const back = loadStats().records;
|
||||||
|
assert.deepEqual(aggregateStats(back).bands, aggregateStats(manyRecords(FULL_KEEP + 20).records).bands);
|
||||||
|
assert.equal(aggregateStats(back).sums.kills, 41 * (FULL_KEEP + 20));
|
||||||
|
// …and the settlement of a compacted record cannot be re-viewed
|
||||||
|
assert.equal(recordToResult(back.at(-1)), null);
|
||||||
|
assert.ok(recordToResult(back[0]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compactRecord is idempotent and keeps the frame', () => {
|
||||||
|
const rec = buildRecord(baseResult(), { myId: 'p1', now: 5 });
|
||||||
|
const c = compactRecord(rec);
|
||||||
|
assert.equal(JSON.stringify(compactRecord(normalizeRecord(c))), JSON.stringify(c));
|
||||||
|
assert.equal(c.difficulty, 'HARD');
|
||||||
|
assert.equal(c.durationMs, 952123);
|
||||||
|
assert.equal(selfRowOf(normalizeRecord(c)).bandId, 'band_amiya');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`a store of ${MAX_RECORDS} real-size co-op matches fits MAX_CHARS (${MAX_CHARS}) — the oldest few go; solo matches all stay`, () => {
|
||||||
|
// a 4-player settlement with 10-unit lineups, 12 bonds and every stat, as server/match/results.js builds it
|
||||||
|
const row = (i) => ({
|
||||||
|
playerId: `p${i}`, seat: i, name: `博士${i}`, isBot: i > 1, left: false, alive: true, victory: true, roundsPassed: 14, eliminatedRound: null, lp: 12,
|
||||||
|
bandId: `band_${i}`, title: { id: 'comment_3', name: '坚若磐石', picId: 'comment_icon_3', text: '目标生命值损失最少' }, trophies: 5, reward: 425,
|
||||||
|
lineup: Array.from({ length: 10 }, (_, k) => ({ id: `chess_char_${k}_0${i}_a`, golden: k % 2 === 0, tier: 1 + (k % 6), row: 12, col: k, items: ['chess_item_4_08_e_a', 'chess_item_4_07_e_a'] })),
|
||||||
|
bonds: Array.from({ length: 12 }, (_, k) => ({ bondId: `bond${k}Ship`, count: 3, tier: 1, layers: 100 + k, active: true })),
|
||||||
|
stats: { dmgDealt: 6871190, kills: 393, leaks: 21, gold: 148, refreshes: 23, merges: 3, itemsEquipped: 33, bossDamage: 945575, activatedLayers: 246, lpLost: 10, perfectRounds: 7 },
|
||||||
|
});
|
||||||
|
const coop = (i) => baseResult({ seed: i, modeId: 'mode_multi_hard', players: [row(1), row(2), row(3), row(4)].map((r, k) => (k === 0 ? { ...r, playerId: 'p1' } : r)) });
|
||||||
|
const stats = manyRecords(MAX_RECORDS, coop);
|
||||||
|
const fit = fitToBudget(stats.records);
|
||||||
|
assert.ok(fit.length >= 900 && fit.length <= MAX_RECORDS, `kept ${fit.length}`);
|
||||||
|
assert.equal(fit[0].t, MAX_RECORDS - 1, 'the newest first');
|
||||||
|
assert.ok(JSON.stringify({ v: 1, records: fit }).length <= MAX_CHARS);
|
||||||
|
withStorage((ls) => { assert.equal(saveStats(stats), true); assert.ok(ls.raw.length <= MAX_CHARS); });
|
||||||
|
const solo = manyRecords(MAX_RECORDS, (i) => baseResult({ seed: i, players: [baseResult().players[0]] }));
|
||||||
|
assert.equal(fitToBudget(solo.records).length, MAX_RECORDS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('over the character budget the oldest records go first; the newest is always kept', () => {
|
||||||
|
const stats = manyRecords(200);
|
||||||
|
const fit = fitToBudget(stats.records, 60_000);
|
||||||
|
assert.ok(fit.length < 200 && fit.length > FULL_KEEP, `kept ${fit.length}`);
|
||||||
|
assert.equal(fit[0].t, 199);
|
||||||
|
assert.equal(fit.at(-1).t, 200 - fit.length);
|
||||||
|
assert.ok(JSON.stringify({ v: 1, records: fit }).length <= 60_000);
|
||||||
|
// a budget below one record still keeps the newest
|
||||||
|
assert.deepEqual(fitToBudget(stats.records, 10).map((r) => r.t), [199]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a write the browser refuses (quota) is retried with half the history; a hopeless one reports failure instead of throwing', () => {
|
||||||
|
const probe = JSON.stringify({ v: STATS_VERSION, records: [compactRecord(buildRecord(baseResult(), { myId: 'p1', now: 1 }))] }).length;
|
||||||
|
withStorage({ budget: probe * 40 }, (ls) => {
|
||||||
|
const stats = manyRecords(300);
|
||||||
|
assert.equal(saveStats(stats), true);
|
||||||
|
const back = JSON.parse(ls.raw).records;
|
||||||
|
assert.ok(back.length < 300 && back.length >= 8, `shrank to ${back.length}`);
|
||||||
|
assert.equal(back[0].t, 299, 'the newest kept');
|
||||||
|
});
|
||||||
|
withStorage({ budget: 10 }, () => assert.equal(saveStats(manyRecords(20)), false));
|
||||||
|
// no localStorage at all (a locked-down browser): quiet failure
|
||||||
|
assert.equal(saveStats(manyRecords(2)), false);
|
||||||
|
assert.deepEqual(loadStats().records, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every string and list of an (imported) record is bounded', () => {
|
||||||
|
const rec = normalizeRecord({
|
||||||
|
players: Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
playerId: `p${i}`, name: 'N'.repeat(500), bandId: 'b'.repeat(500),
|
||||||
|
lineup: Array.from({ length: 100 }, (_, k) => ({ id: `u${k}`, items: Array(50).fill('x'.repeat(500)) })),
|
||||||
|
bonds: Array.from({ length: 100 }, (_, k) => ({ bondId: `b${k}`, layers: 1 })),
|
||||||
|
stats: Object.fromEntries(Array.from({ length: 200 }, (_, k) => [`stat${k}`, k])),
|
||||||
|
bigUnknown: 'z'.repeat(1e5),
|
||||||
|
})),
|
||||||
|
bigUnknown: 'y'.repeat(1e6),
|
||||||
|
});
|
||||||
|
assert.ok(JSON.stringify(rec).length < 40_000, `${JSON.stringify(rec).length} chars`);
|
||||||
|
assert.equal(rec.players.length, 6);
|
||||||
|
assert.equal(rec.players[0].lineup.length, 12);
|
||||||
|
assert.equal(rec.players[0].lineup[0].items.length, 4);
|
||||||
|
assert.equal(rec.players[0].name.length, 24);
|
||||||
|
assert.equal(Object.keys(rec.players[0].stats).length, 24);
|
||||||
|
assert.equal(rec.bigUnknown, undefined);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('local stats: migration / normalization', () => {
|
describe('local stats: corrupt and old storage', () => {
|
||||||
test('an unversioned envelope (older shape) normalizes with defaults and self-heals on load', () => {
|
test('an unversioned PR-era envelope (no `end`) normalizes with defaults as settled records and self-heals on load', () => {
|
||||||
globalThis.localStorage = fakeStorage();
|
withStorage((ls) => {
|
||||||
try {
|
const old = { records: [{ players: [{ playerId: 'p1', stats: { kills: 2 }, futureField: { x: 1 } }], futureTop: 1 }] };
|
||||||
const old = { records: [{ players: [{ playerId: 'p1', stats: { kills: 2 }, futureField: { x: 1 } }] }] };
|
ls.setItem('sp.pref.stats', JSON.stringify(old));
|
||||||
globalThis.localStorage.setItem('sp.pref.stats', JSON.stringify(old));
|
|
||||||
const loaded = loadStats();
|
const loaded = loadStats();
|
||||||
assert.equal(loaded.v, STATS_VERSION);
|
assert.equal(loaded.v, STATS_VERSION);
|
||||||
|
assert.equal(loaded.damaged, false);
|
||||||
assert.equal(loaded.records.length, 1);
|
assert.equal(loaded.records.length, 1);
|
||||||
const rec = loaded.records[0];
|
const rec = loaded.records[0];
|
||||||
|
assert.equal(rec.end, 'settled');
|
||||||
|
assert.equal(countsTowardStats(rec), true);
|
||||||
assert.equal(rec.victory, false);
|
assert.equal(rec.victory, false);
|
||||||
assert.equal(rec.roundsPassed, 0);
|
assert.equal(rec.roundsPassed, 0);
|
||||||
assert.equal(rec.selfId, null);
|
assert.equal(rec.selfId, null);
|
||||||
assert.equal(rec.players[0].roundsPassed, 0);
|
assert.equal(rec.players[0].roundsPassed, 0);
|
||||||
assert.equal(rec.players[0].alive, true);
|
assert.equal(rec.players[0].alive, true);
|
||||||
assert.deepEqual(rec.players[0].stats, { kills: 2 });
|
assert.deepEqual(rec.players[0].stats, { kills: 2 });
|
||||||
assert.deepEqual(rec.players[0].futureField, { x: 1 }); // unknown fields survive for a newer reader
|
assert.equal(rec.players[0].futureField, undefined, 'a strict schema: unknown fields do not survive (a newer build bumps the version instead)');
|
||||||
const rewritten = JSON.parse(globalThis.localStorage.getItem('sp.pref.stats'));
|
assert.equal(rec.futureTop, undefined);
|
||||||
assert.ok(rewritten);
|
assert.match(rec.id, /^c\./, 'an id-less row gets a content id');
|
||||||
|
const rewritten = JSON.parse(ls.raw);
|
||||||
assert.equal(rewritten.v, STATS_VERSION); // self-healed back to storage
|
assert.equal(rewritten.v, STATS_VERSION); // self-healed back to storage
|
||||||
} finally { delete globalThis.localStorage; }
|
assert.equal(rewritten.records[0].id, rec.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('broken records are dropped; a NEWER envelope is returned untouched and flagged (never saved over)', () => {
|
test('unreadable storage (not JSON, not an envelope) is set aside as a backup and the history starts again', () => {
|
||||||
globalThis.localStorage = fakeStorage();
|
for (const bad of ['{"v":1,"records":[', 'null', '[1,2,3]', '"hello"', '{"v":1}', '{"records":"nope"}']) {
|
||||||
try {
|
withStorage((ls) => {
|
||||||
globalThis.localStorage.setItem('sp.pref.stats', JSON.stringify({ v: STATS_VERSION, records: [null, { nope: 1 }, { players: [] }, baseResult()] }));
|
ls.setItem('sp.pref.stats', bad);
|
||||||
// baseResult is an m.result (players present) → normalizes into a record; the rest are dropped
|
const loaded = loadStats();
|
||||||
|
assert.equal(loaded.damaged, true, bad);
|
||||||
|
assert.deepEqual(loaded.records, [], bad);
|
||||||
|
assert.equal(ls.damaged, bad, `${bad}: kept where a person can find it`);
|
||||||
|
assert.equal(ls.raw, null);
|
||||||
|
// and recording works again straight away
|
||||||
|
assert.ok(recordResult(baseResult(), { myId: 'p1', now: 1 }));
|
||||||
|
assert.equal(loadStats().records.length, 1);
|
||||||
|
assert.equal(loadStats().damaged, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damaged rows are dropped and counted, the good ones kept (in order, newest first); the storage is rewritten without them', () => {
|
||||||
|
withStorage((ls) => {
|
||||||
|
const good = (seed, t) => ({ ...buildRecord(baseResult({ seed }), { myId: 'p1', now: t }) });
|
||||||
|
ls.setItem('sp.pref.stats', JSON.stringify({
|
||||||
|
v: STATS_VERSION,
|
||||||
|
records: [good(1, 100), null, 42, 'junk', { nope: 1 }, { players: [] }, { players: [null, 3] }, good(2, 300), good(3, 200)],
|
||||||
|
}));
|
||||||
|
const loaded = loadStats();
|
||||||
|
assert.deepEqual(loaded.records.map((r) => r.t), [300, 200, 100], 'sorted newest first');
|
||||||
|
assert.equal(loaded.dropped, 6);
|
||||||
|
assert.equal(JSON.parse(ls.raw).records.length, 3, 'self-healed');
|
||||||
|
assert.equal(loadStats().dropped, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a record listed twice (hand-merged data) counts once', () => {
|
||||||
|
withStorage((ls) => {
|
||||||
|
const rec = buildRecord(baseResult(), { myId: 'p1', now: 100 });
|
||||||
|
ls.setItem('sp.pref.stats', JSON.stringify({ v: STATS_VERSION, records: [rec, { ...rec }, rec] }));
|
||||||
const loaded = loadStats();
|
const loaded = loadStats();
|
||||||
assert.equal(loaded.records.length, 1);
|
assert.equal(loaded.records.length, 1);
|
||||||
|
assert.equal(loaded.dropped, 2);
|
||||||
|
assert.equal(aggregateStats(loaded.records).count, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const newer = { v: STATS_VERSION + 5, records: [{ players: [], brandNew: true }] };
|
test('numbers that are not numbers, wrong types and absurd values normalize instead of crashing the page', () => {
|
||||||
const mig = migrateStats(newer);
|
const rec = normalizeRecord({
|
||||||
|
t: 'yesterday', victory: 'yes', roundsPassed: NaN, durationMs: '5', difficulty: 12, roomMode: 'both', end: 'whatever',
|
||||||
|
players: [{ playerId: 7, seat: 'x', name: {}, alive: 'no', stats: { kills: '3', gold: NaN, dmgDealt: 5, deep: { a: 1 } }, lineup: [1, null, { id: 5 }], bonds: 'many', title: 'comment_1' }],
|
||||||
|
});
|
||||||
|
assert.equal(rec.end, 'settled');
|
||||||
|
assert.equal(rec.t, 0);
|
||||||
|
assert.equal(rec.victory, false);
|
||||||
|
assert.equal(rec.roundsPassed, 0);
|
||||||
|
assert.equal(rec.durationMs, null);
|
||||||
|
assert.equal(rec.difficulty, null);
|
||||||
|
assert.equal(rec.roomMode, null);
|
||||||
|
const p = rec.players[0];
|
||||||
|
assert.equal(p.playerId, '');
|
||||||
|
assert.equal(p.alive, true);
|
||||||
|
assert.deepEqual(p.stats, { dmgDealt: 5 });
|
||||||
|
assert.deepEqual(p.lineup, []);
|
||||||
|
assert.deepEqual(p.bonds, []);
|
||||||
|
assert.equal(p.title, null);
|
||||||
|
// and the whole page reads it
|
||||||
|
assert.doesNotThrow(() => aggregateStats([rec]));
|
||||||
|
assert.doesNotThrow(() => recordToResult(rec));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a NEWER envelope is returned untouched and flagged — nothing records over it', () => {
|
||||||
|
withStorage((ls) => {
|
||||||
|
const newer = JSON.stringify({ v: STATS_VERSION + 5, records: [{ players: [], brandNew: true }] });
|
||||||
|
ls.setItem('sp.pref.stats', newer);
|
||||||
|
const loaded = loadStats();
|
||||||
|
assert.equal(loaded.newer, true);
|
||||||
|
assert.equal(ls.raw, newer);
|
||||||
|
assert.equal(recordResult(baseResult(), { myId: 'p1', now: 1 }), null, 'a result is not written over a newer build\'s data');
|
||||||
|
assert.equal(recordQuit(liveState({ round: 3 }), { live: watched([liveState({ round: 3 })]) }), null);
|
||||||
|
assert.equal(saveStats(loaded), false);
|
||||||
|
assert.equal(ls.raw, newer, 'still byte for byte');
|
||||||
|
const mig = migrateStats(JSON.parse(newer));
|
||||||
assert.equal(mig.newer, true);
|
assert.equal(mig.newer, true);
|
||||||
assert.equal(mig.changed, false);
|
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('a list longer than MAX_RECORDS (hand-edited, merged) is cut to the newest on load', () => {
|
||||||
|
withStorage((ls) => {
|
||||||
|
const recs = Array.from({ length: MAX_RECORDS + 50 }, (_, i) => buildRecord(baseResult({ seed: i }), { myId: 'p1', now: i }));
|
||||||
|
ls.setItem('sp.pref.stats', JSON.stringify({ v: STATS_VERSION, records: recs }));
|
||||||
|
const loaded = loadStats();
|
||||||
|
assert.equal(loaded.records.length, MAX_RECORDS);
|
||||||
|
assert.equal(loaded.records[0].t, MAX_RECORDS + 49);
|
||||||
|
assert.equal(loaded.dropped, 50);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the MIGRATIONS hook runs: v → v+1 steps apply in order (dry-run with a temporary step)', () => {
|
test('the MIGRATIONS hook runs: v → v+1 steps apply in order (dry-run with a temporary step)', () => {
|
||||||
const original = MIGRATIONS[0];
|
const original = MIGRATIONS[0];
|
||||||
MIGRATIONS[0] = ({ records }) => ({ v: 1, records: records.map((r) => ({ ...r, migrated: true })) });
|
MIGRATIONS[0] = ({ records }) => ({ v: 1, records: records.map((r) => ({ ...r, victory: true })) });
|
||||||
try {
|
try {
|
||||||
const { stats, changed } = migrateStats({ v: 0, records: [{ players: [{ playerId: 'p1' }] }] });
|
const { stats, changed } = migrateStats({ v: 0, records: [{ players: [{ playerId: 'p1' }] }] });
|
||||||
assert.equal(changed, true);
|
assert.equal(changed, true);
|
||||||
assert.equal(stats.records[0].migrated, true);
|
assert.equal(stats.records[0].victory, true);
|
||||||
assert.equal(stats.v, STATS_VERSION);
|
assert.equal(stats.v, STATS_VERSION);
|
||||||
} finally {
|
} finally {
|
||||||
if (original === undefined) delete MIGRATIONS[0]; else MIGRATIONS[0] = original;
|
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', () => {
|
describe('local stats: export / import merge', () => {
|
||||||
test('export → import round-trips; merge unions by id and counts added / skipped', () => {
|
test('export → import round-trips; merge unions by id, newest first, and counts added / skipped', () => {
|
||||||
globalThis.localStorage = fakeStorage();
|
withStorage(() => {
|
||||||
try {
|
|
||||||
const rec = recordResult(baseResult(), { myId: 'p1', now: 1000 });
|
const rec = recordResult(baseResult(), { myId: 'p1', now: 1000 });
|
||||||
const exported = exportStats(loadStats());
|
const exported = exportStats(loadStats());
|
||||||
assert.equal(exported.kind, 'local-stats');
|
assert.equal(exported.kind, 'local-stats');
|
||||||
@@ -214,25 +593,43 @@ describe('local stats: export / import merge', () => {
|
|||||||
assert.equal(same.skipped, 1);
|
assert.equal(same.skipped, 1);
|
||||||
assert.equal(same.stats.records.length, 1);
|
assert.equal(same.stats.records.length, 1);
|
||||||
|
|
||||||
// a second device's store (one shared + one new record) merges by id
|
// a second device's store (one shared + one newer record) merges by id
|
||||||
const theirs = exportStats({ records: [rec, buildRecord(baseResult({ seed: 777 }), { myId: 'p1', now: 5000 })] });
|
const theirs = exportStats({ records: [buildRecord(baseResult({ seed: 777 }), { myId: 'p1', now: 5000 }), rec] });
|
||||||
const merged = importStats(theirs, loadStats());
|
const merged = importStats(JSON.parse(JSON.stringify(theirs)), loadStats());
|
||||||
assert.equal(merged.added, 1);
|
assert.equal(merged.added, 1);
|
||||||
|
assert.equal(merged.skipped, 1);
|
||||||
assert.equal(merged.stats.records.length, 2);
|
assert.equal(merged.stats.records.length, 2);
|
||||||
assert.equal(merged.stats.records[0].t, 1000); // chronological
|
assert.equal(merged.stats.records[0].t, 5000, 'newest first: the history list shows the latest matches at the top');
|
||||||
saveStats(merged.stats);
|
saveStats(merged.stats);
|
||||||
assert.equal(loadStats().records.length, 2);
|
assert.deepEqual(loadStats().records.map((r) => r.t), [5000, 1000]);
|
||||||
} finally { delete globalThis.localStorage; }
|
// a match recorded after the import goes on top, and the cap would drop the oldest, not the newest
|
||||||
|
recordResult(baseResult({ seed: 5 }), { myId: 'p1', now: 9000 });
|
||||||
|
assert.deepEqual(loadStats().records.map((r) => r.t), [9000, 5000, 1000]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('importing a NEWER export refuses with a clear error instead of downgrading', () => {
|
test('an import over the cap keeps the newest MAX_RECORDS', () => {
|
||||||
|
const a = manyRecords(MAX_RECORDS);
|
||||||
|
const b = { v: 1, kind: 'local-stats', records: Array.from({ length: 40 }, (_, i) => buildRecord(baseResult({ seed: 5000 + i }), { myId: 'p1', now: 10_000 + i })) };
|
||||||
|
const out = importStats(JSON.parse(JSON.stringify(b)), a);
|
||||||
|
assert.equal(out.added, 40);
|
||||||
|
assert.equal(out.stats.records.length, MAX_RECORDS);
|
||||||
|
assert.equal(out.stats.records[0].t, 10_039);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('importing a NEWER export, another tool\'s file or junk is refused with a code, nothing is merged', () => {
|
||||||
assert.throws(() => importStats({ v: STATS_VERSION + 1, records: [] }, emptyStats()), (e) => e.code === 'stats-newer-version');
|
assert.throws(() => importStats({ v: STATS_VERSION + 1, records: [] }, emptyStats()), (e) => e.code === 'stats-newer-version');
|
||||||
|
assert.throws(() => importStats({ kind: 'stronghold.loadout', v: 1, records: [] }, emptyStats()), (e) => e.code === 'stats-bad-file');
|
||||||
|
for (const junk of [{ hello: 'world' }, [], 'text', 42, null]) {
|
||||||
|
assert.throws(() => importStats(junk, emptyStats()), (e) => e.code === 'stats-bad-file', JSON.stringify(junk));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('garbage import is an ordinary empty envelope, not a crash in merge', () => {
|
test('rows of an import that cannot be read are dropped and reported; the rest merge', () => {
|
||||||
const out = importStats({ hello: 'world' }, emptyStats());
|
const out = importStats({ kind: 'local-stats', v: 1, records: [buildRecord(baseResult(), { myId: 'p1', now: 1 }), null, { players: [] }, 'x'] }, emptyStats());
|
||||||
assert.equal(out.added, 0);
|
assert.equal(out.added, 1);
|
||||||
assert.equal(out.stats.records.length, 0);
|
assert.equal(out.dropped, 3);
|
||||||
|
assert.equal(out.stats.records.length, 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -249,9 +646,11 @@ describe('local stats: aggregation (what the page renders)', () => {
|
|||||||
|
|
||||||
test('totals, wins (per-row victory), rounds and duration', () => {
|
test('totals, wins (per-row victory), rounds and duration', () => {
|
||||||
assert.equal(agg.count, 2);
|
assert.equal(agg.count, 2);
|
||||||
|
assert.equal(agg.total, 2);
|
||||||
|
assert.equal(agg.excluded, 0);
|
||||||
assert.equal(agg.wins, 1); // game 2 was a loss
|
assert.equal(agg.wins, 1); // game 2 was a loss
|
||||||
assert.equal(agg.rounds.max, 15);
|
assert.equal(agg.rounds.max, 16, 'the player\'s own rounds (the settlement prints them per player)');
|
||||||
assert.equal(agg.rounds.total, 23);
|
assert.equal(agg.rounds.total, 16 + 8);
|
||||||
assert.equal(agg.duration.totalMs, 952123 + 300000);
|
assert.equal(agg.duration.totalMs, 952123 + 300000);
|
||||||
assert.equal(agg.hidden.reached, 1);
|
assert.equal(agg.hidden.reached, 1);
|
||||||
assert.equal(agg.hidden.cleared, 1);
|
assert.equal(agg.hidden.cleared, 1);
|
||||||
@@ -259,9 +658,11 @@ describe('local stats: aggregation (what the page renders)', () => {
|
|||||||
assert.equal(agg.byMode.coop, 1);
|
assert.equal(agg.byMode.coop, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('per-difficulty rows', () => {
|
test('per-difficulty rows; an unknown difficulty is keyed UNKNOWN (never a translated text)', () => {
|
||||||
assert.deepEqual(agg.byDifficulty.HARD, { games: 1, wins: 1, hiddenCleared: 1 });
|
assert.deepEqual(agg.byDifficulty.HARD, { games: 1, wins: 1, hiddenCleared: 1 });
|
||||||
assert.deepEqual(agg.byDifficulty.NORMAL, { games: 1, wins: 0, hiddenCleared: 0 });
|
assert.deepEqual(agg.byDifficulty.NORMAL, { games: 1, wins: 0, hiddenCleared: 0 });
|
||||||
|
const odd = aggregateStats([normalizeRecord({ players: [{ playerId: 'p1' }] })]);
|
||||||
|
assert.deepEqual(Object.keys(odd.byDifficulty), ['UNKNOWN']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('per-band games / passed and self titles', () => {
|
test('per-band games / passed and self titles', () => {
|
||||||
@@ -281,9 +682,17 @@ describe('local stats: aggregation (what the page renders)', () => {
|
|||||||
assert.equal(selfRowOf(noSelf).playerId, 'b');
|
assert.equal(selfRowOf(noSelf).playerId, 'b');
|
||||||
assert.equal(selfRowOf({ players: [] }), null);
|
assert.equal(selfRowOf({ players: [] }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an empty list aggregates to zeros', () => {
|
||||||
|
const e = aggregateStats([]);
|
||||||
|
assert.equal(e.count, 0);
|
||||||
|
assert.equal(e.rounds.max, 0);
|
||||||
|
assert.deepEqual(e.byDifficulty, {});
|
||||||
|
assert.equal(aggregateStats(null).total, 0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('local stats: recordToResult (最近对局行 → 结算页回看)', () => {
|
describe('local stats: recordToResult (history row → settlement re-view)', () => {
|
||||||
test('record → m.result payload → normalizeResult renders the same settlement the live push did', () => {
|
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 rec = buildRecord(baseResult(), { myId: 'p1', roomMode: 'solo', now: 1000 });
|
||||||
const res = recordToResult(rec);
|
const res = recordToResult(rec);
|
||||||
|
|||||||
Reference in new issue
Block a user