diff --git a/docs/PLAYING.md b/docs/PLAYING.md index b1122da7..b631b08b 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -170,3 +170,7 @@ 刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。 **修改快捷键**:标题画面右上角或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。 + +## 12. 反馈问题 + +遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合,以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 ``、``、`` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。 diff --git a/public/css/screens/game.css b/public/css/screens/game.css index be580299..d46c9089 100644 --- a/public/css/screens/game.css +++ b/public/css/screens/game.css @@ -543,6 +543,26 @@ .set-keys__more > summary { padding: .06rem 0; font-size: .15rem; color: var(--mint-400); cursor: pointer; } .set-keys__more[open] > summary { margin-bottom: .08rem; } @keyframes set-key-wait { to { box-shadow: 0 0 .12rem rgba(246, 163, 41, .45); } } +/* 问题反馈 (ui/settings.js DiagSection): laid out like 快捷键 — a head with its button, a hint, the result line; the + recorded-error count in the figure face of the slider values (amber once there is one); the report box only when the + clipboard refused it, styled like 干员调配's 导出 box (screens/loadout.css .lo-io__text) */ +.set-diag { display: flex; flex-direction: column; gap: .08rem; padding-top: .1rem; border-top: 1px solid var(--line); } +.set-diag__meta { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .04rem .2rem; } +.set-diag__stat { display: inline-flex; align-items: baseline; gap: .1rem; font-size: .14rem; color: var(--text-lo); } +.set-diag__count { font-family: var(--font-num); font-size: .2rem; line-height: 1; color: var(--mint-400); } +.set-diag__count.is-warn { color: var(--amber); } +.set-diag__url { min-width: 0; font-size: .13rem; color: var(--text-dim); overflow-wrap: anywhere; -webkit-user-select: all; user-select: all; } +.set-diag__battle-hint { margin-top: -.06rem; } +.set-diag__text { + box-sizing: border-box; width: 100%; height: max(2rem, 140px); min-height: 120px; resize: vertical; + padding: max(.1rem, 8px) max(.12rem, 10px); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: max(.13rem, 11px); line-height: 1.5; + color: var(--text-hi); background: rgba(4, 10, 8, .55); border: 1px solid var(--line-2); border-radius: 2px; outline: none; + white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; +} +.set-diag__text:focus { border-color: var(--mint-700); } +/* touch: 16 px, below which iOS zooms into the focused box (as 干员调配's 导出 box and ui/clipboard.js) */ +.sp-coarse .set-diag__text { font-size: 16px; } @media (max-width: 767px) { .set-keys__list { grid-template-columns: minmax(0, 1fr); } } @media (max-height: 600px) and (pointer: coarse), (max-height: 600px) and (any-pointer: coarse) { .set-key { height: max(.38rem, 30px); } diff --git a/public/i18n/en.json b/public/i18n/en.json index e011327d..ab7f0e25 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -811,6 +811,15 @@ "已恢复默认快捷键": "Default hotkeys restored", "快捷键需要实体键盘;触屏设备连接键盘后可用": "Hotkeys need a physical keyboard; connect one to use them on a touch device", "查看 / 更改快捷键": "Show / change hotkeys", + "问题反馈": "Report a problem", + "复制诊断信息": "Copy diagnostics", + "遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "If something goes wrong, copy the diagnostics and paste them into a GitHub issue so the developers can see what happened. They are generated on this device and never uploaded; player names and the room code are replaced.", + "已记录的错误": "Errors recorded", + "附上本场战斗": "Attach this battle", + "开发者可以用附上的战斗数据重现这场战斗。": "Developers can replay this battle from the attached data.", + "已复制诊断信息,可以粘贴到 GitHub issue 中": "Diagnostics copied. Paste them into a GitHub issue.", + "无法写入剪贴板,请手动复制下面的内容": "Couldn't write to the clipboard. Copy the text below by hand.", + "诊断信息": "Diagnostics", "观战中无法进行该操作": "Spectators cannot do that", "标准": "Standard", "险境": "Perilous", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 58938229..1beff69c 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -811,6 +811,15 @@ "已恢复默认快捷键": "ショートカットキーをデフォルトに戻しました", "快捷键需要实体键盘;触屏设备连接键盘后可用": "ショートカットキーには物理キーボードが必要です。タッチ端末でもキーボードを接続すれば使えます", "查看 / 更改快捷键": "ショートカットキーの確認 / 変更", + "问题反馈": "問題の報告", + "复制诊断信息": "診断情報をコピー", + "遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "問題が起きたときは、診断情報をコピーして GitHub の issue に貼り付けてください。開発者がエラー発生時の状況を確認できます。診断情報はこの端末内で作成され、自動でアップロードされることはありません。プレイヤー名とルーム番号は置き換えられます。", + "已记录的错误": "記録されたエラー", + "附上本场战斗": "この戦闘を添付", + "开发者可以用附上的战斗数据重现这场战斗。": "添付した戦闘データから、開発者がこの戦闘を再現できます。", + "已复制诊断信息,可以粘贴到 GitHub issue 中": "診断情報をコピーしました。GitHub の issue に貼り付けてください", + "无法写入剪贴板,请手动复制下面的内容": "クリップボードに書き込めませんでした。下の内容を手動でコピーしてください", + "诊断信息": "診断情報", "观战中无法进行该操作": "観戦中はこの操作はできません", "标准": "標準", "险境": "逆境", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index c5b380d9..cb4fac70 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -807,6 +807,15 @@ "已恢复默认快捷键": "기본 단축키를 복원했습니다", "快捷键需要实体键盘;触屏设备连接键盘后可用": "단축키는 실제 키보드가 필요합니다. 터치 기기에서는 키보드를 연결하면 사용할 수 있습니다.", "查看 / 更改快捷键": "단축키 보기 / 변경", + "问题反馈": "문제 신고", + "复制诊断信息": "진단 정보 복사", + "遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "문제가 생기면 진단 정보를 복사해 GitHub issue에 붙여 넣어 주세요. 개발자가 오류가 났을 때의 상황을 확인할 수 있습니다. 진단 정보는 이 기기에서만 만들어지며 자동으로 업로드되지 않습니다. 플레이어 이름과 방 번호는 다른 값으로 바뀝니다.", + "已记录的错误": "기록된 오류", + "附上本场战斗": "이 전투 첨부", + "开发者可以用附上的战斗数据重现这场战斗。": "첨부한 전투 데이터로 개발자가 이 전투를 재현할 수 있습니다.", + "已复制诊断信息,可以粘贴到 GitHub issue 中": "진단 정보를 복사했습니다. GitHub issue에 붙여 넣어 주세요", + "无法写入剪贴板,请手动复制下面的内容": "클립보드에 쓸 수 없습니다. 아래 내용을 직접 복사해 주세요", + "诊断信息": "진단 정보", "观战中无法进行该操作": "관전 중에는 할 수 없습니다", "标准": "표준", "险境": "험지", diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index b4843497..36246ee3 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -810,6 +810,15 @@ "已恢复默认快捷键": "已恢復預設快捷鍵", "快捷键需要实体键盘;触屏设备连接键盘后可用": "快捷鍵需要實體鍵盤;觸控裝置連接鍵盤後可用", "查看 / 更改快捷键": "查看 / 更改快捷鍵", + "问题反馈": "問題回報", + "复制诊断信息": "複製診斷資訊", + "遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "遇到問題時,複製診斷資訊並貼到 GitHub issue 中,開發者就能看到出錯時的情況。診斷資訊只在本機產生,不會自動上傳;玩家名稱和房間號碼會被替換。", + "已记录的错误": "已記錄的錯誤", + "附上本场战斗": "附上本場戰鬥", + "开发者可以用附上的战斗数据重现这场战斗。": "開發者可以用附上的戰鬥資料重現這場戰鬥。", + "已复制诊断信息,可以粘贴到 GitHub issue 中": "已複製診斷資訊,可以貼到 GitHub issue 中", + "无法写入剪贴板,请手动复制下面的内容": "無法寫入剪貼簿,請手動複製下面的內容", + "诊断信息": "診斷資訊", "观战中无法进行该操作": "觀戰中無法進行該操作", "标准": "標準", "险境": "險境", diff --git a/public/js/battle/runner.js b/public/js/battle/runner.js index e8d25fbe..79ffbc1e 100644 --- a/public/js/battle/runner.js +++ b/public/js/battle/runner.js @@ -72,6 +72,7 @@ import { net as appNet } from '../net.js'; import { store as appStore } from '../store.js'; import { unitStatsEntry, fxForm } from '../../../shared/protocol.js'; import { spectateEffects } from './observe.js'; +import { recordError, setBattleSource } from '../diag.js'; const TICK = 1 / 30; /** Fast-forward budget per frame (ticks) when far behind. */ @@ -156,9 +157,12 @@ export async function loadBrowserSim({ base = '/sim/', dataBase = '/data/', fetc return { spec, ds: new simdata.DataSource(raw, null) }; } -/** Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own). */ +/** + * Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own) and keep + * them for the diagnostics a player copies (diag.js). + */ const SIM_LOGGER = Object.freeze({ - error: (...a) => console.warn('[sim]', ...a), + error: (...a) => { console.warn('[sim]', ...a); recordError('sim', a.length === 1 ? a[0] : a.map(String).join(' ')); }, warn: (...a) => console.warn('[sim]', ...a), info() {}, debug() {}, @@ -351,6 +355,7 @@ export function createBattleRunner(deps) { } catch (err) { stats.errors++; console.warn('[runner] battle step failed', err); + recordError('runner', err, 'battle step failed'); try { b.forceEnd('timeout'); } catch { /* ignore */ } } const dt = now() - t; @@ -393,7 +398,7 @@ export function createBattleRunner(deps) { const list = catchingUp ? s.ev.filter(keepsState) : s.ev; if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt: s.gt, ev: list }); } - try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); } + try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); recordError('runner', err, 'snapshot failed'); } } /** @@ -457,7 +462,7 @@ export function createBattleRunner(deps) { result = e.sim.spec.compactResult(e.battle.result()); // an oversized frame would close the socket at the very end of the battle (64 KB inbound limit) if (typeof e.sim.spec.fitResult === 'function') result = e.sim.spec.fitResult(result, { bossLike: bossLike(e), battleId: e.battleId }); - } catch (err) { console.warn('[runner] result failed', err); } + } catch (err) { console.warn('[runner] result failed', err); recordError('runner', err, 'result failed'); } if (result) { e.result = result; // the view answers with the settlement voice of this battle (screens/game.js → audio.voice result*): the @@ -491,6 +496,7 @@ export function createBattleRunner(deps) { } else { e.delivery = 'delivered'; console.warn('[runner] b.result refused', code); + recordError('runner', code, 'b.result refused'); } return null; }); @@ -670,6 +676,7 @@ export function createBattleRunner(deps) { let sim; try { sim = await ensureSim(); } catch (err) { console.warn('[runner] simulation unavailable', err); + recordError('runner', err, 'simulation unavailable'); return; } if (!wanted()) return; @@ -678,6 +685,7 @@ export function createBattleRunner(deps) { battle = sim.spec.createBattleFromSpec(e.spec, sim.ds, { logger }); } catch (err) { console.warn('[runner] battle construction failed', err); + recordError('runner', err, 'battle construction failed'); return; } stats.battles++; @@ -806,6 +814,16 @@ export function createBattleRunner(deps) { return () => listeners.get(type)?.delete(fn); }, state, + /** + * The battle on screen as a report attaches it (diag.js): its b.start fields and its game time; null without one. + * @returns {{ battleId: string, fieldId: string, kind: string, spec: object, time: number|null }|null} + */ + currentBattle() { + const e = cur; + if (!e || !e.spec) return null; + const time = e.battle && Number.isFinite(e.battle.time) ? e.battle.time : null; + return { battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, spec: e.spec, time }; + }, stats() { return { ...stats, avgTickMs: stats.ticks ? stats.stepMs / stats.ticks : 0, entries: entries.size, loadingSim: !!simP }; }, @@ -869,3 +887,4 @@ export const battleRunner = typeof window !== 'undefined' && typeof document !== ? createBattleRunner({ net: appNet, store: appStore }) : null; if (battleRunner) globalThis.__SP_RUNNER__ = battleRunner; // dev / E2E introspection +if (battleRunner) setBattleSource(() => battleRunner.currentBattle()); diff --git a/public/js/diag.js b/public/js/diag.js new file mode 100644 index 00000000..4a0c526c --- /dev/null +++ b/public/js/diag.js @@ -0,0 +1,269 @@ +// public/js/diag.js — the client's error log and the diagnostics a player copies into a bug report (设置 → 问题反馈). +// +// Nothing leaves the browser. The log is kept in memory (the last LOG_MAX entries, an identical error in a row counted +// on its entry); the report is plain text the player copies and pastes into a GitHub issue, with the player names, +// the room code and the reconnect token replaced and the page's host cut from every URL. A secret is a whole token in +// the prose and an exact string value in the attached battle JSON, so a name never rewrites a longer id. The battle +// on screen can be attached as its BattleSpec (the b.start a browser receives: player ids, no names), which the dev +// tools replay. +// +// recordError(source, err, detail?) source: 'error' (uncaught) | 'rejection' (unhandled promise) | 'sim' (a content +// error the battle isolated) | 'runner' (the local battle runner) | 'request' +// (a failed request or error push the player was shown) +// errorLog() / errorCount() / clearErrorLog() +// setBattleSource(fn) the battle runner registers how to read the battle on screen +// buildDiagnostics(input) the report text (pure: the state, the environment and the battle are passed in) +// diagnosticsText({ state, settings, attachBattle }) the report of this page (this browser, the battle on screen) +// +// Report keys are English on purpose: the text is read by developers and searched in issues, whatever the UI language. + +import { APP_VERSION } from '../../shared/constants.js'; + +export const LOG_MAX = 40; +export const STACK_LINES = 6; +const MESSAGE_MAX = 300; + +/** @type {{ source: string, message: string, stack: string|null, detail: string|null, at: number, lastAt: number, count: number }[]} */ +const log = []; +let total = 0; +let dropped = 0; +const pageStart = Date.now(); + +/** `name: message (code)` of an Error, a NetError, a {code, msg} push, a string or anything else. */ +export function messageOf(err) { + let s; + if (err == null) s = String(err); + else if (typeof err === 'string') s = err; + else if (typeof err === 'object') { + const name = typeof err.name === 'string' && err.name && err.name !== 'Error' ? `${err.name}: ` : ''; + const text = typeof err.message === 'string' && err.message ? err.message : typeof err.msg === 'string' ? err.msg : ''; + const code = typeof err.code === 'string' || typeof err.code === 'number' ? String(err.code) : ''; + if (text) s = `${name}${text}${code && !text.includes(code) ? ` (${code})` : ''}`; + else if (code) s = `${name}${code}`; + else { try { s = JSON.stringify(err); } catch { s = Object.prototype.toString.call(err); } } + } else s = String(err); + s = s.replace(/\s+/g, ' ').trim(); + return s.length > MESSAGE_MAX ? `${s.slice(0, MESSAGE_MAX - 1)}…` : s; +} + +/** The first STACK_LINES frames of `err.stack`, each without the page's scheme and host (`/js/ui/x.js:12:5`). */ +export function stackOf(err) { + const raw = err && typeof err === 'object' && typeof err.stack === 'string' ? err.stack : ''; + if (!raw) return null; + const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean); + // V8 starts with "Name: message"; Firefox / Safari list frames only + const frames = lines.filter((l, i) => !(i === 0 && !/^at |@|:\d+:\d+\)?$/.test(l))); + const out = frames.slice(0, STACK_LINES).map(stripHosts); + return out.length ? out.join('\n') : null; +} + +/** Cut `scheme://host[:port]` from every URL (a LAN server's address, a domain): the path is what locates the code. */ +export const stripHosts = (s) => String(s).replace(/\b[a-z][a-z0-9+.-]*:\/\/[^/\s)]+/gi, ''); + +/** + * Record an error. An entry identical to the newest one (same source, message and stack) raises its count instead. + * @param {string} source @param {any} err @param {string|null} [detail] @param {number} [at] epoch ms + */ +export function recordError(source, err, detail = null, at = Date.now()) { + const message = stripHosts(messageOf(err)); + const stack = stackOf(err); + const det = detail == null ? null : stripHosts(String(detail)).slice(0, MESSAGE_MAX); + total++; + const last = log[log.length - 1]; + if (last && last.source === source && last.message === message && last.stack === stack && last.detail === det) { + last.count++; + last.lastAt = at; + return last; + } + const e = { source: String(source), message, stack, detail: det, at, lastAt: at, count: 1 }; + log.push(e); + while (log.length > LOG_MAX) { log.shift(); dropped++; } + return e; +} + +/** The log, oldest first (copies). */ +export const errorLog = () => log.map((e) => ({ ...e })); +/** Errors recorded on this page (repeats included). */ +export const errorCount = () => total; +export function clearErrorLog() { log.length = 0; total = 0; dropped = 0; } + +let battleSource = null; +/** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */ +export function setBattleSource(fn) { battleSource = typeof fn === 'function' ? fn : null; } +/** The battle on screen for a report, or null. */ +export function currentBattle() { + try { return battleSource ? battleSource() || null : null; } catch { return null; } +} + +/** Secrets worth replacing: non-empty strings, later pair winning when the same value is listed twice. */ +function secretMap(pairs) { + const map = new Map(); + for (const [v, ph] of pairs) { + if (typeof v === 'string' && v.trim()) map.set(v, ph); + } + return map; +} + +/** + * Replace each secret only as a whole token, longest first, in one pass (a placeholder is never scanned again). + * A token is bounded by the start or end of the text, or by anything that is not a letter, a digit or `_`, so + * "char" does not eat `chess_char_…`, "player" does not eat "players", and "W" does not eat "Windows". "W-W" is + * two tokens. The left boundary is a consumed character (or the start): a lookbehind is a SyntaxError in Safari + * before 16.4. + * @param {string} text @param {[string, string][]} pairs [value, placeholder] + */ +export function redact(text, pairs) { + const map = secretMap(pairs); + const list = [...map.keys()].sort((a, b) => b.length - a.length); + if (!list.length) return String(text); + const esc = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const word = '[\\p{L}\\p{N}_]'; + const re = new RegExp(`(?
^|(?!${word})[\\s\\S])(?${list.map(esc).join('|')})(?!${word})`, 'gu');
+  return String(text).replace(re, (...args) => {
+    const g = args[args.length - 1];
+    return `${g.pre}${map.get(g.hit)}`;
+  });
+}
+
+/**
+ * Copy `value`, replacing a string only when it is exactly a secret (object keys included). A longer string that
+ * merely contains the secret, such as a chess id or a note, is kept. This is what the attached battle JSON uses;
+ * token replacement would still rewrite `"charge the char"` when the name is `char`.
+ * @param {any} value @param {[string, string][]} pairs
+ */
+export function redactValues(value, pairs) {
+  const map = secretMap(pairs);
+  const walk = (v) => {
+    if (typeof v === 'string') return map.has(v) ? map.get(v) : v;
+    if (Array.isArray(v)) return v.map(walk);
+    if (v && typeof v === 'object') {
+      const out = {};
+      for (const [k, val] of Object.entries(v)) out[map.has(k) ? map.get(k) : k] = walk(val);
+      return out;
+    }
+    return v;
+  };
+  return walk(value);
+}
+
+/** [value, placeholder] pairs of what a report must not show: player names, the room code, the reconnect token. */
+export function secretsOf(state) {
+  const pairs = [];
+  const names = new Map();
+  const addName = (n) => {
+    if (typeof n !== 'string' || !n.trim() || names.has(n)) return;
+    names.set(n, n === state?.me?.name ? '' : ``);
+  };
+  addName(state?.me?.name);
+  for (const s of state?.room?.seats || []) addName(s?.name);
+  for (const s of state?.room?.spectators || []) addName(s?.name);
+  for (const p of state?.match?.public?.players || []) addName(p?.name);
+  for (const [n, ph] of names) pairs.push([n, ph]);
+  if (state?.room?.code) pairs.push([String(state.room.code), '']);
+  if (state?.me?.token) pairs.push([String(state.me.token), '']);
+  return pairs;
+}
+
+const pad2 = (n) => String(n).padStart(2, '0');
+const clockOf = (ms) => { const d = new Date(ms); return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`; };
+const spanOf = (ms) => {
+  const s = Math.max(0, Math.round(ms / 1000));
+  return s >= 3600 ? `${Math.floor(s / 3600)}h ${pad2(Math.floor(s / 60) % 60)}m` : `${Math.floor(s / 60)}m ${pad2(s % 60)}s`;
+};
+const join = (...parts) => parts.filter((p) => p != null && p !== '' && p !== false).join(' | ');
+
+/**
+ * The report text: a ```text block (version, time, browser, screen, connection, where the player is, settings, the
+ * error log newest first) and, with `battle`, a ```json block of its spec. Secrets of `state` are whole tokens in the
+ * prose and exact string values in the JSON.
+ * @param {{ state: any, env?: any, settings?: any, battle?: any, entries?: any[], dropped?: number, total?: number,
+ *   now?: number, pageStart?: number, version?: string }} input
+ */
+export function buildDiagnostics(input) {
+  const { state = {}, env = {}, settings = null, battle = null } = input;
+  const entries = input.entries ?? errorLog();
+  const now = input.now ?? Date.now();
+  const lines = [];
+  lines.push('Stronghold-Protocol diagnostics');
+  lines.push(`version: ${input.version ?? APP_VERSION}`);
+  lines.push(`time: ${new Date(now).toISOString()} | page open ${spanOf(now - (input.pageStart ?? pageStart))}`);
+  if (env.ua) lines.push(`browser: ${env.ua}`);
+  if (env.gpu) lines.push(`gpu: ${env.gpu}`);
+  lines.push(`device: ${join(env.viewport, env.dpr ? `@${env.dpr}x` : null, env.touch ? 'touch' : null, env.cores ? `${env.cores} threads` : null,
+    env.memoryGB ? `${env.memoryGB} GB` : null, env.lang ? `lang ${env.lang}` : null, env.uiLang ? `ui ${env.uiLang}` : null) || 'unknown'}`);
+  const c = state.connection || {};
+  lines.push(`connection: ${join(c.status || 'unknown', Number.isFinite(c.ping) ? `ping ${Math.round(c.ping)} ms` : null,
+    c.attempt ? `retry ${c.attempt}` : null, c.lastError ? `last error ${messageOf(c.lastError)}` : null)}`);
+  const pub = state.match?.public || null;
+  const room = state.room || null;
+  lines.push(`where: ${join(pub?.phase ? `phase ${pub.phase}` : room ? 'room' : state.session?.entered ? 'lobby' : 'title',
+    Number.isFinite(pub?.round) ? `round ${pub.round}` : null, room?.mode ? `mode ${room.mode}` : null,
+    room?.difficulty ? `difficulty ${room.difficulty}` : null,
+    Array.isArray(pub?.players) ? `players ${pub.players.length}` : Array.isArray(room?.seats) ? `seats ${room.seats.filter(Boolean).length}` : null)}`);
+  const b = state.match?.battle || null;
+  if (b) {
+    lines.push(`battle: ${join(b.kind, b.fieldId ? `field ${b.fieldId}` : null, b.authoritative ? 'authoritative' : b.watch ? 'watching' : null,
+      b.done ? 'done' : b.loading ? 'loading' : 'running', b.paused ? 'paused' : null, Number.isFinite(b.speed) ? `${b.speed}x` : null)}`);
+  }
+  if (settings) {
+    lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.muted ? 'muted' : null)}`);
+  }
+  const shown = entries.slice().reverse();
+  const tot = input.total ?? total;
+  const drop = input.dropped ?? dropped;
+  lines.push('');
+  lines.push(`errors: ${tot ? `${tot} recorded${drop ? `, ${drop} oldest entries dropped` : ''}` : 'none recorded'}`);
+  shown.forEach((e, i) => {
+    lines.push(`#${i + 1} ${clockOf(e.at)}${e.count > 1 ? ` (x${e.count}, last ${clockOf(e.lastAt)})` : ''} ${e.source}: ${e.message}`);
+    if (e.detail) lines.push(`   ${e.detail}`);
+    if (e.stack) for (const l of e.stack.split('\n')) lines.push(`   ${l}`);
+  });
+  const pairs = secretsOf(state);
+  let text = redact(`\`\`\`text\n${lines.join('\n')}\n\`\`\``, pairs);
+  if (battle && battle.spec) {
+    const head = join(`battle ${battle.battleId}`, battle.kind, Number.isFinite(battle.spec.round) ? `round ${battle.spec.round}` : null,
+      Number.isFinite(battle.time) ? `copied at ${battle.time.toFixed(2)} s` : null);
+    const payload = redactValues({
+      t: 'b.start', battleId: battle.battleId, fieldId: battle.fieldId, kind: battle.kind, spec: battle.spec,
+    }, pairs);
+    text += `\n\n${redact(head, pairs)}\n\`\`\`json\n${JSON.stringify(payload)}\n\`\`\``;
+  }
+  return text;
+}
+
+let gpuName;
+/** What the report says about this browser and device (null fields stay out). */
+export function browserEnv() {
+  const g = globalThis;
+  if (gpuName === undefined) {
+    gpuName = null;
+    try {
+      const cv = g.document?.createElement('canvas');
+      const gl = cv && (cv.getContext('webgl2') || cv.getContext('webgl'));
+      const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
+      gpuName = gl ? String(ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL';
+    } catch { gpuName = null; }
+  }
+  let uiLang = null;
+  try { uiLang = g.document?.documentElement?.lang || null; } catch { /* ignore */ }
+  return {
+    ua: g.navigator?.userAgent ?? null,
+    gpu: gpuName,
+    viewport: Number.isFinite(g.innerWidth) ? `${g.innerWidth}x${g.innerHeight}` : null,
+    dpr: g.devicePixelRatio ?? null,
+    touch: !!(g.matchMedia && g.matchMedia('(pointer: coarse)').matches),
+    cores: g.navigator?.hardwareConcurrency ?? null,
+    memoryGB: g.navigator?.deviceMemory ?? null,
+    lang: g.navigator?.language ?? null,
+    uiLang,
+  };
+}
+
+/**
+ * The report of this page: the given store state and settings, this browser, the log and, with `attachBattle`, the
+ * battle on screen.
+ * @param {{ state: any, settings?: any, attachBattle?: boolean }} opts
+ */
+export function diagnosticsText({ state, settings = null, attachBattle = false }) {
+  return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null });
+}
diff --git a/public/js/main.js b/public/js/main.js
index 5b109e91..4e3a52f1 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -53,6 +53,7 @@ import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/l
 import { startBuildGuard } from './ui/buildGuard.js';
 import { initLang, useLang, tickerText } from './ui/lang.js';
 import { t, N_, translateWire } from '../../shared/i18n.js';
+import { recordError } from './diag.js';
 
 const RESTORE_GRACE_MS = 1500;
 const JOIN_DELAY_MS = 350;
@@ -311,12 +312,14 @@ function installGlobalErrorHandlers() {
     // expected browser behaviour, not app errors: log quietly, never toast.
     if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) { console.warn('[app] ignored rejection', err.name); return; }
     console.error('[app] unhandled rejection', err);
+    recordError('rejection', err);
     if (err instanceof NetError) toastError(err);
     else toast(t('发生意外错误:{error}', { error: describeError(err) }).slice(0, 120), 'error');
   });
   window.addEventListener('error', (ev) => {
     if (!(ev instanceof ErrorEvent)) return; // resource load errors are not script errors
     console.error('[app] uncaught error', ev.error || ev.message);
+    recordError('error', ev.error || ev.message, ev.error ? null : `${ev.filename || '?'}:${ev.lineno || 0}:${ev.colno || 0}`);
   });
 }
 
diff --git a/public/js/ui/settings.js b/public/js/ui/settings.js
index d7db406d..bc1049f9 100644
--- a/public/js/ui/settings.js
+++ b/public/js/ui/settings.js
@@ -3,17 +3,20 @@
 // the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`; under it a note
 // while the current language's pack is a machine translation, `_meta.machineTranslated`) and the 快捷键
 // section that rebinds the in-match shortcuts (the key map: ui/gameLogic/shortcuts.js; the community request
-// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07).
+// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07) and 问题反馈, which copies the diagnostics of this page
+// for a bug report (diag.js: the error log, this browser, optionally the battle on screen; nothing is uploaded).
 
-import { useLayoutEffect, useState } from '../../vendor/hooks.module.js';
+import { useLayoutEffect, useRef, useState } from '../../vendor/hooks.module.js';
 import { html, Modal, Button, Icon, MicroLabel } from './components.js';
-import { createStore, useStore, loadPref, savePref } from '../store.js';
+import { createStore, useStore, loadPref, savePref, store } from '../store.js';
 import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey } from './gameLogic.js';
 import { audio } from '../audio.js';
 import { openGuide } from './guide.js';
 import { detectFeatures } from './device.js';
 import { LangToggle, machineTranslationNote } from './lang.js';
 import { t, tc, N_ } from '../../../shared/i18n.js';
+import { errorCount, currentBattle, diagnosticsText } from '../diag.js';
+import { copyText } from './clipboard.js';
 
 const cx = (...p) => p.flat().filter(Boolean).join(' ');
 
@@ -144,6 +147,55 @@ function HotkeySection({ keys, touchUi }) {
   `;
 }
 
+/** Where a report goes: the upstream issue tracker (shown as text the player can select; a link may open nothing). */
+export const ISSUES_URL = 'https://github.com/sganggs/Stronghold-Protocol/issues';
+
+/**
+ * 问题反馈: copy the diagnostics of this page (diag.js) for a GitHub issue — the errors recorded since the page opened,
+ * this browser and device, where the player is, and, switched on by default while a battle is on screen, that battle's
+ * spec (the dev tools replay it). Player names, the room code and the token are replaced; nothing is uploaded. Laid out
+ * like 快捷键 above it (a head with its button, a hint, the result line); the battle switch is the settings' Toggle, and
+ * when the clipboard refuses (some in-app browsers), the report is shown selected in a box styled like 干员调配's 导出.
+ */
+/** The result line of 复制诊断信息, by outcome (msgids: translated when shown, so a language switch reaches it). */
+const DIAG_NOTES = { copied: N_('已复制诊断信息,可以粘贴到 GitHub issue 中'), refused: N_('无法写入剪贴板,请手动复制下面的内容') };
+
+function DiagSection() {
+  const [attach, setAttach] = useState(true);
+  const [note, setNote] = useState(null);       // 'copied' | 'refused' | null: the result line
+  const [manual, setManual] = useState(null);   // the report, when the clipboard refused it
+  const boxRef = useRef(null);
+  // the box opens selected for a manual copy, scrolled to its first line (select() leaves it at the end)
+  useLayoutEffect(() => {
+    const el = boxRef.current;
+    if (manual && el) { el.focus(); el.select(); el.scrollTop = 0; }
+  }, [manual]);
+  const n = errorCount();
+  const battle = currentBattle();
+  const copy = async () => {
+    const text = diagnosticsText({ state: store.get(), settings: settingsStore.get(), attachBattle: attach && !!battle });
+    const ok = await copyText(text);
+    setManual(ok ? null : text);
+    setNote(ok ? 'copied' : 'refused');
+  };
+  return html`
+
+ ${t('问题反馈')}<${MicroLabel}>DIAGNOSTICS + <${Button} variant="ghost" size="sm" icon="copy" class="set-diag__copy" data-testid="diag-copy" onClick=${copy}>${t('复制诊断信息')} +
+

${t('遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。')}

+
+ ${t('已记录的错误')} 0 && 'is-warn')} data-testid="diag-count">${n} + ${ISSUES_URL.replace(/^https:\/\//, '')} +
+ ${battle ? html`<${Toggle} label=${t('附上本场战斗')} micro="BATTLE DATA" value=${attach} onChange=${setAttach} /> +

${t('开发者可以用附上的战斗数据重现这场战斗。')}

` : null} + ${note ? html`

${t(DIAG_NOTES[note])}

` : null} + ${manual ? html`` : null} +
`; +} + /** * Settings modal. * @param {{ open: boolean, onClose: Function }} props @@ -176,6 +228,7 @@ export function SettingsModal({ open, onClose }) { <${HotkeySection} keys=${s.keys} touchUi=${touchUi} /> + <${DiagSection} />

${touchUi ? t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向') : t('右键查看详情')}

`; diff --git a/public/js/ui/toasts.js b/public/js/ui/toasts.js index 3bd67048..bdd1060d 100644 --- a/public/js/ui/toasts.js +++ b/public/js/ui/toasts.js @@ -9,6 +9,7 @@ import { useEffect, useReducer } from '../../vendor/hooks.module.js'; import htm from '../../vendor/htm.module.js'; import { ERR_TEXT } from '../../../shared/constants.js'; import { t } from '../../../shared/i18n.js'; +import { recordError } from '../diag.js'; // no shared static vnodes (see components.js hFresh: htm's static cache would retain unmounted DOM) function hFresh(type, props, ...children) { @@ -108,6 +109,7 @@ export function describeError(err) { * @returns {number} */ export function toastError(err) { + recordError('request', err); return toast(describeError(err), 'error'); } diff --git a/test/diag.test.js b/test/diag.test.js new file mode 100644 index 00000000..0a16ebbf --- /dev/null +++ b/test/diag.test.js @@ -0,0 +1,186 @@ +// test/diag.test.js — the client's error log and bug-report diagnostics (public/js/diag.js; 设置 → 问题反馈): what an +// entry keeps (message, a short stack without the page's host), repeats counted on one entry, the cap, the report's +// lines, the battle attached as a replayable b.start, and the player names / room code / token never in the text. +import { test, describe, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { + recordError, errorLog, errorCount, clearErrorLog, messageOf, stackOf, stripHosts, redact, redactValues, secretsOf, buildDiagnostics, + setBattleSource, currentBattle, LOG_MAX, STACK_LINES, +} from '../public/js/diag.js'; +import { APP_VERSION } from '../shared/constants.js'; + +const v8Error = (msg = 'boom') => { + const e = new TypeError(msg); + e.stack = [`TypeError: ${msg}`, ...Array.from({ length: 9 }, (_, i) => ` at f${i} (http://192.168.1.5:3000/js/ui/a.js:${i + 1}:7)`)].join('\n'); + return e; +}; + +describe('the error log', () => { + beforeEach(() => clearErrorLog()); + + test('an entry keeps the message and the first frames of the stack, without the page\'s scheme and host', () => { + const e = recordError('error', v8Error('x is undefined'), null, 1000); + assert.equal(e.message, 'TypeError: x is undefined'); + const frames = e.stack.split('\n'); + assert.equal(frames.length, STACK_LINES); + assert.equal(frames[0], 'at f0 (/js/ui/a.js:1:7)'); + assert.ok(!e.stack.includes('192.168.1.5')); + // Firefox / Safari stacks list frames only: the first line is a frame and stays + assert.equal(stackOf({ stack: 'g@https://sp.example/sim/x.js:3:9\nh@https://sp.example/sim/y.js:1:1' }), 'g@/sim/x.js:3:9\nh@/sim/y.js:1:1'); + assert.equal(stackOf('a string'), null); + }); + + test('messages of a NetError, a {code, msg} push, a string and a plain object', () => { + assert.equal(messageOf(Object.assign(new Error('请求超时'), { name: 'NetError', code: 'TIMEOUT' })), 'NetError: 请求超时 (TIMEOUT)'); + assert.equal(messageOf({ code: 'ROOM_FULL', msg: '房间已满' }), '房间已满 (ROOM_FULL)'); + assert.equal(messageOf({ code: 'OFFLINE' }), 'OFFLINE'); + assert.equal(messageOf(' plain text '), 'plain text'); + assert.equal(messageOf({ a: 1 }), '{"a":1}'); + assert.equal(messageOf(null), 'null'); + assert.ok(messageOf('x'.repeat(500)).length <= 300); + assert.equal(stripHosts('see http://10.0.0.2:3000/x.js and https://a.b/c'), 'see /x.js and /c'); + }); + + test('the same error again raises the count of its entry; another source or message is a new entry', () => { + recordError('sim', '[sim] skill failed', null, 1000); + recordError('sim', '[sim] skill failed', null, 2000); + recordError('sim', '[sim] skill failed', null, 3000); + recordError('runner', '[sim] skill failed', null, 4000); + recordError('runner', 'other', null, 5000); + const log = errorLog(); + assert.deepEqual(log.map((e) => [e.source, e.count]), [['sim', 3], ['runner', 1], ['runner', 1]]); + assert.equal(log[0].lastAt, 3000); + assert.equal(errorCount(), 5); + }); + + test('the log keeps the newest LOG_MAX entries and the report says how many were dropped', () => { + for (let i = 0; i < LOG_MAX + 5; i++) recordError('error', `e${i}`, null, i * 1000); + const log = errorLog(); + assert.equal(log.length, LOG_MAX); + assert.equal(log[0].message, 'e5'); + const text = buildDiagnostics({ state: {}, now: 100000, pageStart: 0 }); + assert.match(text, /errors: 45 recorded, 5 oldest entries dropped/); + assert.match(text, /#1 00:00:44 error: e44/, 'newest first'); + clearErrorLog(); + assert.equal(errorCount(), 0); + assert.equal(errorLog().length, 0); + }); +}); + +describe('the report', () => { + beforeEach(() => clearErrorLog()); + + const state = { + connection: { status: 'online', ping: 41.6, attempt: 0, lastError: null }, + me: { playerId: 'p_1', name: '阿米娅', token: 'tok-secret-123' }, + session: { entered: true }, + room: { code: 'K7Q2', mode: 'coop', difficulty: 'HARD', seats: [{ name: '阿米娅' }, { name: 'Kal' }, { name: 'Kaltsit' }, null], spectators: [{ name: 'W' }] }, + match: { + public: { phase: 'COMBAT', round: 9, players: [{ playerId: 'p_1', name: '阿米娅' }, { playerId: 'p_2', name: 'Kal' }] }, + battle: { kind: 'unite', fieldId: 'u', authoritative: true, done: false, speed: 2 }, + }, + }; + const env = { ua: 'Mozilla/5.0 Test', gpu: 'ANGLE (Test GPU)', viewport: '844x390', dpr: 2, touch: true, cores: 8, lang: 'zh-CN', uiLang: 'zh' }; + + test('version, time, browser, device, connection, where the player is, the battle, the settings and the errors', () => { + recordError('request', { code: 'ROOM_FULL', msg: '房间已满' }, null, Date.UTC(2026, 9, 8, 12, 0, 5)); + recordError('sim', '[sim] onAttack (char_391_rosmon) failed: TypeError: x', null, Date.UTC(2026, 9, 8, 12, 0, 9)); + recordError('sim', '[sim] onAttack (char_391_rosmon) failed: TypeError: x', null, Date.UTC(2026, 9, 8, 12, 0, 12)); + const text = buildDiagnostics({ state, env, settings: { quality: 'low', damageNumbers: true, muted: false }, + now: Date.UTC(2026, 9, 8, 12, 1, 0), pageStart: Date.UTC(2026, 9, 8, 11, 48, 30) }); + const lines = text.split('\n'); + assert.equal(lines[0], '```text'); + assert.equal(lines[1], 'Stronghold-Protocol diagnostics'); + assert.equal(lines[2], `version: ${APP_VERSION}`); + assert.equal(lines[3], 'time: 2026-10-08T12:01:00.000Z | page open 12m 30s'); + assert.ok(lines.includes('browser: Mozilla/5.0 Test')); + assert.ok(lines.includes('gpu: ANGLE (Test GPU)')); + assert.ok(lines.includes('device: 844x390 | @2x | touch | 8 threads | lang zh-CN | ui zh')); + assert.ok(lines.includes('connection: online | ping 42 ms')); + assert.ok(lines.includes('where: phase COMBAT | round 9 | mode coop | difficulty HARD | players 2')); + assert.ok(lines.includes('battle: unite | field u | authoritative | running | 2x')); + assert.ok(lines.includes('settings: quality low | damage numbers')); + assert.ok(lines.includes('errors: 3 recorded')); + assert.ok(lines.includes('#1 12:00:09 (x2, last 12:00:12) sim: [sim] onAttack (char_391_rosmon) failed: TypeError: x')); + assert.ok(lines.includes('#2 12:00:05 request: 房间已满 (ROOM_FULL)')); + assert.equal(lines.at(-1), '```'); + assert.ok(!text.includes('```json'), 'no battle attached'); + }); + + test('player names, the room code and the reconnect token never reach the text, wherever they appear', () => { + recordError('request', '阿米娅 left room K7Q2 (token tok-secret-123)', 'Kaltsit and Kal and W'); + const text = buildDiagnostics({ state, env }); + for (const secret of ['阿米娅', 'Kaltsit', 'Kal ', 'K7Q2', 'tok-secret-123']) assert.ok(!text.includes(secret), secret); + assert.match(text, /request: left room \(token \)/); + // the longer name is replaced first, so 'Kaltsit' does not become 'tsit'; the one-letter 'W' as a word + assert.ok(text.includes('\n and and \n')); + assert.deepEqual(secretsOf(state).map(([, p]) => p), ['', '', '', '', '', '']); + assert.equal(redact('ab a cab W-W Windows', [['a', 'X'], ['ab', 'Y'], ['W', 'V'], ['', 'Z']]), 'Y X cab V-V Windows', + 'every value, short or long, is a whole token'); + assert.equal(redact('player and players', [['player', ''], ['Kaltsit', '']]), ' and players', + 'a longer word is not cut'); + assert.equal(redact('a.b a+b', [['a.b', 'R']]), 'R a+b', 'regex characters in a value are literal'); + assert.equal(redact('char chess_char_1013_chen2', [['char', '']]), ' chess_char_1013_chen2', + 'a name does not eat a chess id'); + }); + + test('a name like char replaces a whole token and an exact JSON string, never a longer id', () => { + const named = { + ...state, + me: { ...state.me, name: 'char' }, + room: { ...state.room, seats: [{ name: 'char' }, { name: 'Ship' }], spectators: [] }, + match: { ...state.match, public: { ...state.match.public, players: [{ playerId: 'p_1', name: 'char' }, { playerId: 'p_2', name: 'Ship' }] } }, + }; + recordError('sim', 'char failed on chess_char_1013_chen2', 'yanShip stayed'); + const spec = { + v: 1, + units: [{ id: 'chess_char_1013_chen2', key: 'char', note: 'charge the char', ship: 'yanShip' }], + name: 'char', + tag: 'Ship', + }; + const text = buildDiagnostics({ + state: named, env, battle: { battleId: 'k1', fieldId: 'u', kind: 'unite', spec, time: 1 }, + }); + assert.match(text, /sim: failed on chess_char_1013_chen2/); + assert.ok(text.includes('yanShip stayed'), 'Ship does not match inside yanShip'); + const json = JSON.parse(text.slice(text.indexOf('```json\n') + 8, text.lastIndexOf('\n```'))); + assert.equal(json.spec.units[0].id, 'chess_char_1013_chen2'); + assert.equal(json.spec.units[0].key, ''); + assert.equal(json.spec.units[0].note, 'charge the char'); + assert.equal(json.spec.units[0].ship, 'yanShip'); + assert.equal(json.spec.name, ''); + assert.equal(json.spec.tag, ''); + assert.equal(JSON.stringify(json).includes('"char"'), false); + assert.deepEqual(redactValues( + { id: 'chess_char_1013_chen2', char: 'keep', name: 'char', note: 'charge the char', list: ['char', 'chess_char_1013_chen2'] }, + [['char', '']], + ), { id: 'chess_char_1013_chen2', '': 'keep', name: '', note: 'charge the char', list: ['', 'chess_char_1013_chen2'] }); + }); + + test('the battle on screen is attached as a b.start the dev tools replay (player ids, no names)', () => { + const spec = { v: 1, battleId: 'k1-1.9.40.u', fieldId: 'u', kind: 'unite', seed: 7, round: 9, players: [{ playerId: 'p_1' }, { playerId: 'p_2' }] }; + const text = buildDiagnostics({ state, env, battle: { battleId: spec.battleId, fieldId: 'u', kind: 'unite', spec, time: 12.4 } }); + assert.ok(text.includes('\n\nbattle k1-1.9.40.u | unite | round 9 | copied at 12.40 s\n```json\n')); + const json = JSON.parse(text.slice(text.indexOf('```json\n') + 8, text.lastIndexOf('\n```'))); + assert.deepEqual(json, { t: 'b.start', battleId: spec.battleId, fieldId: 'u', kind: 'unite', spec }); + }); + + test('an empty page: the title screen, nothing recorded, unknown fields left out', () => { + const text = buildDiagnostics({ state: {}, now: 0, pageStart: 0 }); + assert.match(text, /\nwhere: title\n/); + assert.match(text, /\nconnection: unknown\n/); + assert.match(text, /\ndevice: unknown\n/); + assert.match(text, /\nerrors: none recorded\n```$/); + assert.ok(!/browser:|gpu:|battle:|settings:/.test(text)); + }); + + test('the battle source: none, a battle, and a source that throws', () => { + setBattleSource(null); + assert.equal(currentBattle(), null); + setBattleSource(() => ({ battleId: 'b', spec: {} })); + assert.deepEqual(currentBattle(), { battleId: 'b', spec: {} }); + setBattleSource(() => { throw new Error('gone'); }); + assert.equal(currentBattle(), null); + setBattleSource(null); + }); +});