From ce525125591ddfcdbb5c63bf60aae746267f4f14 Mon Sep 17 00:00:00 2001 From: Cloudnyco <74037807+Cloudnyco@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:38:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E6=A8=A1=E6=8B=9F?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E6=80=A7=E6=A3=80=E6=9F=A5=E9=A1=B5=EF=BC=88?= =?UTF-8?q?/dev/sim-check.html=EF=BC=89=EF=BC=8C=E4=BB=BB=E6=84=8F?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=9A=84=E6=B5=8F=E8=A7=88=E5=99=A8=E9=87=8D?= =?UTF-8?q?=E6=94=BE=E9=BB=84=E9=87=91=E7=BB=93=E6=9E=9C=E7=9A=84=E6=95=B4?= =?UTF-8?q?=E5=A5=97=E6=88=98=E6=96=97=EF=BC=8C=E9=80=90=E5=B8=A7=E4=B8=8E?= =?UTF-8?q?=20Node=20=E6=AF=94=E8=BE=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + CONTRIBUTING.md | 8 ++ docs/SIM.md | 20 +++ public/dev/sim-check.html | 24 ++++ public/dev/sim-check.js | 45 +++++++ public/dev/simcheck/core.js | 212 ++++++++++++++++++++++++++++++ public/dev/simcheck/node.mjs | 102 ++++++++++++++ public/dev/simcheck/stronghold.js | 62 +++++++++ public/dev/simcheck/ui.js | 206 +++++++++++++++++++++++++++++ test/sim/simcheck.test.js | 60 +++++++++ tools/sim-check.mjs | 85 ++++++++++++ 11 files changed, 826 insertions(+) create mode 100644 public/dev/sim-check.html create mode 100644 public/dev/sim-check.js create mode 100644 public/dev/simcheck/core.js create mode 100644 public/dev/simcheck/node.mjs create mode 100644 public/dev/simcheck/stronghold.js create mode 100644 public/dev/simcheck/ui.js create mode 100644 test/sim/simcheck.test.js create mode 100644 tools/sim-check.mjs diff --git a/.gitignore b/.gitignore index 37a6479b..d5ba9a6c 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ desktop.ini .idea/ .vscode/ .claude/ +# tools/sim-check.mjs: the corpus and its Node traces for /dev/sim-check.html +public/dev/simcheck/data/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f9e694a..38d823ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,12 @@ RENDER_E2E=1 node --test 'test/render/*.browser.test.js' # 渲染 SIM_E2E=1 node --test 'test/sim/*.browser.test.js' # 浏览器里的战斗模拟 ``` +改了模拟(`server/sim/`)之后,可以检查各浏览器算出的战斗和服务器是否仍然逐位一致:`node tools/sim-check.mjs` +在 Node 里记录黄金结果的整套战斗,然后在任意设备(包括手机)的浏览器里打开服务器的 `/dev/sim-check.html` 重放比较; +加 `--engines chromium,firefox,webkit` 直接在无界面的浏览器里跑(Firefox 和 WebKit 需要先装 playwright)。不一致时 +页面指出从哪一帧开始、是结果、画面还是状态不同,`--diff` 用下载的数据找出第一个不同的单位和字段。细节见 docs/SIM.md +「11. Tools」。 + **黄金结果**([test/golden/README.md](test/golden/README.md)):只重构、不改玩法的提交不能改变 `test/golden/*.json`;有意改变玩法时运行 `npm run golden:update`,把新文件和改动放在同一个提交里,并在提交说明里写清 哪些场景变了、为什么。 @@ -110,6 +116,8 @@ SIM_E2E=1 node --test 'test/sim/*.browser.test.js' # 浏览器里 are opt-in: `SP_E2E=1`, `SP_REAL_E2E=1` (needs downloaded art), `RENDER_E2E=1`, `SIM_E2E=1` (a local Chrome, `CHROME_PATH`). A refactor never changes `test/golden/*.json`; an intended gameplay change runs `npm run golden:update` and commits the digests with the change, naming the scenarios that moved. + After a sim change, `node tools/sim-check.mjs` checks that browsers still give the server's bits: it traces the golden + battle corpus in Node; `/dev/sim-check.html` replays it on any device (`--engines chromium,firefox,webkit` headless). - **Fidelity**: the official tables (`.cache/gamedata/excel/`) and PRTS (including the 备注 notes) are the sources; numbers come from the blackboards. Anything no source settles is marked `[ASSUMED]` in the code and the pull request; owner decisions are cited with their date; deliberate deviations are the owner's call and go into the design diff --git a/docs/SIM.md b/docs/SIM.md index a0040d45..ded734c2 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -1496,6 +1496,26 @@ skill × module, every bond at its threshold and at 999 layers, every leader fie reduced to digests in `test/golden/*.json`; `test/golden.test.js` fails, naming the scenario and the field, when a digest moves. A refactor must leave them unchanged; `npm run golden:update` records an intended gameplay change. +Cross-engine check (`tools/sim-check.mjs`, `public/dev/sim-check.html`): a browser simulates its battles and the server +re-simulates them (SP_VERIFY, takeovers), so every engine must give the bits Node gives. `node tools/sim-check.mjs` +builds the golden battle corpus (roster, bonds, fields, standins, diy — 267 BattleSpecs) and traces it in Node into +`public/dev/simcheck/data/` (git-ignored, about a minute): after every tick the state (battle time, RNG state and per +unit alive / removed / deployed, x, y, hp, atkCd, SP, the five element gauges, damage dealt / taken, healing, blocking +count) hashed to the exact IEEE-754 bits, one hash per 30 ticks; every 30 ticks `b.snapshot()` (what viewers are sent); +every 150 ticks the deep state (every value reachable from the units, the definitions and buffs.js's process-wide `seq` +left out); and `resultDigest(b.result())`. `/dev/sim-check.html` — on any device that reaches the server, a phone +included — runs the corpus (or its fast subset, or a family) with the client's own loader (`loadBrowserSim`) and +compares block by block: a battle that parts is reported with the first ticks that differ, the level (the outcome, the +snapshot, the state, or the hidden state only), and a download of that block's state vectors; +`node tools/sim-check.mjs --diff ` replays the same ticks in Node and names the first unit and field that differ +(with both values' bits). `--engines chromium,firefox,webkit [--subset fast|all|]` runs the page headless after +the trace (Firefox and WebKit through the `playwright` package when it is installed, `npx playwright install firefox +webkit`; Chrome through puppeteer-core otherwise) and exits 1 on a difference. The tracing, comparison and page +(`public/dev/simcheck/core.js`, `ui.js`, `node.mjs`) have no dependency on this repository; `stronghold.js` is the part +that knows the sim. At 0.2.4 all 267 battles are bit-identical in Chrome 154, Firefox 157 and WebKit 27.2; with +`Math.hypot` put back into `detmath.js hypot`, 25 of the 55 fast battles part in Firefox and 27 in WebKit, the first at +tick 569 of `roster-004` (an enemy's `y`, one ulp). + ## 12. Data notes (simdata.js) Defs are normalised from data/*.json (DATA.md) with research JSON as a fallback. Chess: `stats {maxHp, atk, def, res, aspd, diff --git a/public/dev/sim-check.html b/public/dev/sim-check.html new file mode 100644 index 00000000..c70c36f6 --- /dev/null +++ b/public/dev/sim-check.html @@ -0,0 +1,24 @@ + + + + + + + Sim check · 卫戍协议:盟约 + + + + + + +

正在载入战斗模拟…

+ + + diff --git a/public/dev/sim-check.js b/public/dev/sim-check.js new file mode 100644 index 00000000..2e3971f8 --- /dev/null +++ b/public/dev/sim-check.js @@ -0,0 +1,45 @@ +// The cross-engine check page (docs/SIM.md「Cross-engine check」): this browser runs the golden battle corpus with the +// client's own sim loader (public/js/battle/runner.js loadBrowserSim) and compares every tick with the Node reference +// that `node tools/sim-check.mjs` wrote to /dev/simcheck/data/ — any device on the LAN can open it from the server. +import { mountCheck } from './simcheck/ui.js'; +import { strongholdAdapter, scenariosOf } from './simcheck/stronghold.js'; + +const app = document.getElementById('app'); +const get = async (url) => { + const r = await fetch(url, { cache: 'no-cache' }); + if (!r.ok) throw Object.assign(new Error(`${r.status} ${url}`), { missing: r.status === 404 }); + return r.json(); +}; + +try { + const [doc, reference, { loadBrowserSim }, { APP_VERSION }] = await Promise.all([ + get('/dev/simcheck/data/specs.json'), get('/dev/simcheck/data/reference.json'), import('/js/battle/runner.js'), import('/shared/constants.js'), + ]); + const { spec, ds } = await loadBrowserSim(); + const scenarios = scenariosOf(doc); + const FAMILIES = [['roster', '干员'], ['bonds', '盟约'], ['fields', '首领战场与联防'], ['standins', '补位'], ['diy', '自选']]; + const stale = reference.made && reference.made.version && reference.made.version !== APP_VERSION; + mountCheck(app, { + project: 'Stronghold Protocol', + title: '模拟一致性检查', + subtitle: '在这台设备的浏览器里运行黄金结果的整套战斗,逐帧与服务器(Node)的结果比较。浏览器自己模拟战斗、服务器再复算校验,两边必须逐位一致。' + + (stale ? `
参考数据由 ${reference.made.version} 生成,这台服务器是 ${APP_VERSION}:请在服务器上重新运行 node tools/sim-check.mjs。` : ''), + adapter: strongholdAdapter({ spec, ds, doc }), + scenarios, + subsets: [ + { id: 'fast', label: '快速(黄金结果的快速子集)', pick: (sc) => sc.fast }, + { id: 'all', label: '全部', pick: () => true }, + ...FAMILIES.map(([id, label]) => ({ id, label, pick: (sc) => sc.family === id })), + ], + reference, + info: [['游戏版本', APP_VERSION]], + }); +} catch (e) { + app.innerHTML = ''; + const p = document.createElement('p'); + p.style.cssText = 'color:var(--text-md);padding:16px;max-width:760px;margin:0 auto;font:14px/1.6 var(--font-cjk)'; + p.textContent = e.missing + ? '还没有参考数据:请在服务器的目录里运行 node tools/sim-check.mjs(生成 public/dev/simcheck/data/),然后刷新这个页面。' + : `载入失败:${e && e.message || e}`; + app.appendChild(p); +} diff --git a/public/dev/simcheck/core.js b/public/dev/simcheck/core.js new file mode 100644 index 00000000..976a3ba1 --- /dev/null +++ b/public/dev/simcheck/core.js @@ -0,0 +1,212 @@ +// simcheck — whether a deterministic simulation gives the same bits in every JavaScript engine: the server's Node and +// the players' browsers (Chrome / Edge, Firefox, Safari, on desktops and phones). A run of one scenario is traced: +// state the simulation's observable numbers after every tick (the adapter's state(): positions, HP, timers …), +// hashed to their exact IEEE-754 bits; each block of `every` ticks keeps one hash of its ticks' hashes +// view what a viewer is shown (the adapter's view(): a wire snapshot), hashed at the end of every block and at the +// end of the run +// deep the hidden state (the adapter's deep(): every value reachable from the units), hashed every `deepEvery` +// ticks (a multiple of `every`; the walk is costly) and at the end of the run +// result the outcome's digest (the adapter's result()) +// A run elsewhere is compared with a reference run (Node) block by block: the first block that differs, at any level, +// says when the two parted; a dump of that block's state vectors from both sides then names the first field that +// differs (diffDumps). +// +// Integer operations only (Math.imul, shifts) over the numbers' bits: the hashes are the same in every engine. NaN is +// hashed as one value (engines may keep different NaN payloads). +// +// Written for Stronghold Protocol (GPL-3.0-or-later) and Duel Channel (AGPL-3.0-or-later) by the same author, and kept +// the same in both. No dependencies; Node and browsers. + +const DV = new DataView(new ArrayBuffer(8)); +const rotl = (x, r) => (x << r) | (x >>> (32 - r)); +// murmur3's block mix and finaliser +function mix(h, k) { + k = Math.imul(k, 0xcc9e2d51); k = rotl(k, 15); k = Math.imul(k, 0x1b873593); + h ^= k; h = rotl(h, 13); + return (Math.imul(h, 5) + 0xe6546b64) | 0; +} +function fmix(h) { + h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16; + return h >>> 0; +} +// the kind of an object by its tag, not instanceof: a simulation may run in another realm (a Node VM context) than the +// hashing code, with its own Map / Set constructors +const tag = (v) => Object.prototype.toString.call(v); +const isList = (v) => Array.isArray(v) || (ArrayBuffer.isView(v) && tag(v) !== '[object DataView]'); +const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()); + +/** A 32-bit hash fed with words, numbers (their bits), strings and plain values. */ +class Hash { + constructor(seed = 0x9747b28c) { this.h = seed | 0; this.n = 0; } + word(w) { this.h = mix(this.h, w | 0); this.n++; return this; } + num(x) { + if (x !== x) return this.word(0x7ff80000).word(0); + DV.setFloat64(0, x, true); + return this.word(DV.getUint32(0, true)).word(DV.getUint32(4, true)); + } + str(s) { this.word(0x5354 ^ s.length); for (let i = 0; i < s.length; i++) this.word(s.charCodeAt(i)); return this; } + // null / undefined / booleans / numbers / strings / arrays / plain objects (keys in their own order, which is the + // order the same code created them in, in any engine) + value(v, depth = 0) { + if (v === null || v === undefined) return this.word(v === null ? 0x6e756c6c : 0x756e6466); + switch (typeof v) { + case 'number': return this.num(v); + case 'string': return this.str(v); + case 'boolean': return this.word(v ? 0x74727565 : 0x66616c73); + case 'bigint': return this.str(v.toString()); + case 'object': break; + default: return this.word(0x66756e63); + } + if (depth > 32) return this.word(0x64656570); + if (isList(v)) { this.word(0x41525259 ^ v.length); for (const x of v) this.value(x, depth + 1); return this; } + const keys = Object.keys(v); + this.word(0x4f424a54 ^ keys.length); + for (const k of keys) { this.str(k); this.value(v[k], depth + 1); } + return this; + } + done() { return fmix(this.h ^ this.n); } +} + +const hex = (u) => (u >>> 0).toString(16).padStart(8, '0'); +/** The hash of a list of numbers (a state vector). */ +function hashNumbers(v) { const h = new Hash(); for (let i = 0; i < v.length; i++) h.num(v[i]); return h.done(); } +/** The hash of any plain value. */ +function hashValue(v) { return new Hash().value(v).done(); } + +/** + * The hash of everything reachable from roots: own properties (non-enumerable too, data properties only), arrays, + * typed arrays, Maps, Sets; another root met on the way counts as its index, an object met twice counts once, the keys + * in `skip` are left out (references back to shared definitions, process-wide counters …). + * @param {object[]} roots @param {{ skip?: Iterable, maxDepth?: number }} [o] + */ +function deepHash(roots, { skip = [], maxDepth = 8 } = {}) { + const idx = new Map(roots.map((r, i) => [r, i])), seen = new Set(), skipped = new Set(skip), h = new Hash(); + const walk = (v, depth) => { + if (v === null || v === undefined) { h.word(7); return; } + const t = typeof v; + if (t === 'number') { h.num(v); return; } + if (t === 'string') { h.str(v); return; } + if (t === 'boolean') { h.word(v ? 3 : 5); return; } + if (t !== 'object' || depth > maxDepth) return; + if (depth > 0 && idx.has(v)) { h.word(0x55550000 + idx.get(v)); return; } + if (seen.has(v)) { h.word(11); return; } + seen.add(v); + if (isList(v)) { h.word(v.length); for (const x of v) walk(x, depth + 1); return; } + const kind = tag(v); + if (kind === '[object Map]') { h.word(v.size); for (const [k, x] of v) { walk(k, depth + 1); walk(x, depth + 1); } return; } + if (kind === '[object Set]') { h.word(v.size); for (const x of v) walk(x, depth + 1); return; } + for (const k of Object.getOwnPropertyNames(v)) { + if (skipped.has(k)) continue; + const d = Object.getOwnPropertyDescriptor(v, k); + if (!d || !('value' in d) || typeof d.value === 'function') continue; + h.str(k); walk(d.value, depth + 1); + } + }; + for (const r of roots) walk(r, 0); + return h.done(); +} + +/** + * A run of one scenario, traced as it goes; advance() runs it for a time budget (a page stays responsive), finish() + * gives the trace. The adapter: + * start(scenario) → run; step(run) → true when a tick was simulated, false when the run is over; + * state(run) → number[] (after every tick); labels?(run) → string[] (the names of state's entries, for dumps); + * view?(run) → plain value; deep?(run) → uint32; result(run) → string; maxTicks? (a runaway guard) + * dump: [first, last] ticks whose state vectors (and labels) are kept. + */ +function tracer(adapter, scenario, { every = 30, deepEvery = every * 5, dump = null } = {}) { + const run = adapter.start(scenario), max = adapter.maxTicks || 1e6; + let t = 0, block = new Hash(), over = false; + const blocks = [], views = [], deeps = [], dumps = {}; + const sample = (end) => { + if (adapter.view) views.push(hashValue(adapter.view(run))); + if (adapter.deep && (end || t % deepEvery === 0)) deeps.push(adapter.deep(run) >>> 0); + }; + return { + get ticks() { return t; }, + get done() { return over; }, + advance(budgetMs = Infinity) { + const until = budgetMs === Infinity ? Infinity : now() + budgetMs; + while (!over) { + if (t >= max || !adapter.step(run)) { + over = true; + if (t % every) { blocks.push(block.done()); sample(true); } else if (adapter.deep && t % deepEvery) deeps.push(adapter.deep(run) >>> 0); + break; + } + t++; + const s = adapter.state(run); + block.word(hashNumbers(s)); + if (dump && t >= dump[0] && t <= dump[1]) dumps[t] = { state: Array.from(s, (x) => (x !== x ? 'NaN' : x)), labels: adapter.labels ? adapter.labels(run) : null }; + if (t % every === 0) { blocks.push(block.done()); block = new Hash(); sample(false); } + if ((t & 31) === 0 && now() > until) break; + } + return over; + }, + finish() { + while (!over) this.advance(); + return { ticks: t, blocks: blocks.map(hex).join(''), views: views.map(hex).join(''), deeps: deeps.map(hex).join(''), result: String(adapter.result(run)), ...(dump ? { dumps } : {}) }; + }, + }; +} +/** A whole run's trace (see tracer). */ +function trace(adapter, scenario, opts) { return tracer(adapter, scenario, opts).finish(); } + +const firstDiff = (a = '', b = '') => { + const n = Math.max(a.length, b.length); + for (let i = 0; i < n; i += 8) if (a.slice(i, i + 8) !== b.slice(i, i + 8)) return i / 8; + return -1; +}; +/** + * A trace against its reference. ok, or: `at` — where they first parted (level 'state' / 'view' / 'deep', the block, + * its ticks [from, to]); `result` — the outcomes differ; `ticks` — the runs' lengths differ. The level whose difference + * shows first is the one reported — by the tick it is seen at (a block's or a sample's last tick; a deep sample covers + * deepEvery ticks), the finer level on a tie; a run whose state never differs but whose view does still counts. + */ +function compare(ref, got, every = 30, deepEvery = every * 5) { + if (!ref) return { ok: false, missing: true }; + let at = null; + for (const level of ['state', 'view', 'deep']) { + const key = level === 'state' ? 'blocks' : level === 'view' ? 'views' : 'deeps', span = level === 'deep' ? deepEvery : every; + const i = firstDiff(ref[key], got[key]); + if (i < 0) continue; + const to = Math.min((i + 1) * span, Math.max(ref.ticks, got.ticks)); + if (!at || to < at.to) at = { level, block: i, from: i * span + 1, to }; + } + const result = ref.result !== got.result, ticks = ref.ticks !== got.ticks; + return { ok: !at && !result && !ticks, at, result, ticks: ticks ? [ref.ticks, got.ticks] : null }; +} +/** How bad a comparison is, for sorting and summaries: 3 the outcome differs, 2 what viewers see, 1 only inside. */ +function severity(c) { + if (c.ok) return 0; + if (c.missing || c.result || c.ticks) return 3; + return c.at && c.at.level !== 'deep' ? 2 : 1; +} + +const bitsOf = (x) => { + if (x === 'NaN') return 'NaN'; + DV.setFloat64(0, x, true); + return hex(DV.getUint32(4, true)) + hex(DV.getUint32(0, true)); +}; +/** + * Two dumps of the same ticks (tracer `dump`) compared field by field: the differences in tick order, each with the + * tick, the entry's index and label, both values and their bits. Stops after `limit`. + */ +function diffDumps(a, b, limit = 20) { + const out = []; + const ticks = [...new Set([...Object.keys(a || {}), ...Object.keys(b || {})])].map(Number).sort((x, y) => x - y); + for (const t of ticks) { + const x = a[t], y = b[t]; + if (!x || !y) { out.push({ tick: t, index: -1, label: x ? 'missing on the other side' : 'missing in the reference' }); if (out.length >= limit) break; continue; } + const n = Math.max(x.state.length, y.state.length); + for (let i = 0; i < n && out.length < limit; i++) { + const p = x.state[i], q = y.state[i]; + if (p === q || (p !== undefined && q !== undefined && bitsOf(p) === bitsOf(q))) continue; + out.push({ tick: t, index: i, label: (x.labels && x.labels[i]) || (y.labels && y.labels[i]) || `#${i}`, ref: p, got: q, refBits: p === undefined ? '' : bitsOf(p), gotBits: q === undefined ? '' : bitsOf(q) }); + } + if (out.length >= limit) break; + } + return out; +} + +const VERSION = 1; +export { VERSION, Hash, hex, hashNumbers, hashValue, deepHash, tracer, trace, compare, severity, diffDumps }; diff --git a/public/dev/simcheck/node.mjs b/public/dev/simcheck/node.mjs new file mode 100644 index 00000000..59fc2c24 --- /dev/null +++ b/public/dev/simcheck/node.mjs @@ -0,0 +1,102 @@ +// simcheck's Node side: the reference (every scenario traced in Node, core.js), headless runs of the check page in +// several engines, and the field-by-field comparison of a dump a browser downloaded with Node's run of the same ticks. +// Engines: Playwright's chromium / firefox / webkit when the `playwright` package is installed (its browsers fetched +// with `npx playwright install firefox webkit`), else a local Chrome through puppeteer-core ($CHROME_PATH or the usual +// install paths). +// +// Written for Stronghold Protocol (GPL-3.0-or-later) and Duel Channel (AGPL-3.0-or-later) by the same author, and kept +// the same in both. +import { existsSync, readFileSync } from 'node:fs'; +import { tracer, diffDumps, VERSION } from './core.js'; + +/** + * Trace every scenario in Node: the reference a page compares with. + * @param {object} adapter @param {{ id: string }[]} scenarios + * @param {{ every?: number, project: string, made?: object, onProgress?: (i: number, n: number, id: string) => void }} o + */ +export function buildReference(adapter, scenarios, { every = 30, deepEvery = every * 5, project, made = {}, onProgress } = {}) { + const out = {}; + scenarios.forEach((sc, i) => { + if (onProgress) onProgress(i, scenarios.length, sc.id); + const { ticks, blocks, views, deeps, result } = tracer(adapter, sc, { every, deepEvery }).finish(); + out[sc.id] = { ticks, blocks, views, deeps, result }; + }); + return { simcheck: VERSION, project, every, deepEvery, made: { engine: `Node ${process.version}`, date: new Date().toISOString().slice(0, 10), ...made }, scenarios: out }; +} + +const CHROMES = [process.env.CHROME_PATH, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser']; +async function tryImport(name) { try { return await import(name); } catch { return null; } } + +/** + * Open the check page (url, with ?auto=1 and the subset) in each engine and wait for its results. + * @param {string} url @param {string[]} engines 'chromium' | 'firefox' | 'webkit' | 'chrome' (a local Chrome) + * @returns {Promise>} + */ +export async function runEngines(url, engines, { timeoutMs = 30 * 60000, log = () => {} } = {}) { + const pw = await tryImport('playwright'), pp = pw ? null : await tryImport('puppeteer-core'); + const out = {}; + for (const name of engines) { + let browser = null; + try { + if (pw && pw[name === 'chrome' ? 'chromium' : name]) { + const type = pw[name === 'chrome' ? 'chromium' : name], exe = CHROMES.find((p) => p && existsSync(p)); + // chromium / chrome: Playwright's own build when it is installed, else the local Chrome + try { browser = await type.launch(name === 'chrome' && exe ? { executablePath: exe } : {}); } catch (e) { + if (type !== pw.chromium || !exe) throw e; + browser = await type.launch({ executablePath: exe }); + } + } else if (pp && (name === 'chrome' || name === 'chromium')) { + const exe = CHROMES.find((p) => p && existsSync(p)); + if (!exe) throw new Error('no Chrome found: set CHROME_PATH'); + browser = await (pp.default || pp).launch({ executablePath: exe, headless: true }); + } else throw new Error(pw ? `playwright has no ${name}` : `${name} needs the playwright package (npm i -D playwright && npx playwright install ${name})`); + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(String(e.message || e))); + await page.goto(url, { timeout: 120000 }); + const t0 = Date.now(); + let last = -1; + for (;;) { + const st = await page.evaluate(() => { const s = globalThis.__simcheck; return s ? { status: s.status, n: s.results.length } : null; }); + if (st && (st.status === 'done' || st.status === 'stopped')) break; + if (errors.length && !st) throw new Error(errors.join('; ')); + if (st && st.n !== last && st.n % 25 === 0) { log(` ${name}: ${st.n}`); last = st.n; } + if (Date.now() - t0 > timeoutMs) throw new Error('timed out'); + await new Promise((r) => setTimeout(r, 1000)); + } + const res = await page.evaluate(() => { const s = globalThis.__simcheck; return { status: s.status, summary: s.summary, results: s.results, report: s.report }; }); + out[name] = { version: browser.version ? await browser.version() : '', ...res, errors }; + } catch (e) { + out[name] = { status: 'error', error: String(e && e.message || e) }; + } finally { if (browser) await browser.close().catch(() => {}); } + } + return out; +} + +/** + * A dump a browser downloaded (the page's 下载这一段的状态) against Node's run of the same ticks: the differing entries, + * first ones first, as printable lines. + * @param {string} file @param {object} adapter @param {{ id: string }[]} scenarios + */ +export function diffDumpFile(file, adapter, scenarios, { every = 30, deepEvery = every * 5, limit = 20 } = {}) { + const d = JSON.parse(readFileSync(file, 'utf8')); + const sc = scenarios.find((s) => s.id === d.id); + if (!sc) return [`no scenario ${d.id} here`]; + const ref = tracer(adapter, sc, { every, deepEvery, dump: d.window }).finish(); + const diffs = diffDumps(ref.dumps, d.dumps, limit); + const lines = [`${d.id}, ticks ${d.window[0]}–${d.window[1]}: Node ${process.version} vs ${d.engine}`]; + if (!diffs.length) lines.push(' the same in every entry of these ticks (the difference is in what the state vector does not hold: view / deep)'); + for (const x of diffs) lines.push(x.index < 0 ? ` tick ${x.tick}: ${x.label}` : ` tick ${x.tick} ${x.label}: ${x.ref} (${x.refBits}) vs ${x.got} (${x.gotBits})`); + return lines; +} + +/** One line per engine for a console. */ +export function formatEngines(res) { + return Object.entries(res).map(([name, r]) => { + if (r.status === 'error') return `${name}: error — ${r.error}`; + const s = r.summary || {}; + const bad = (r.results || []).filter((x) => !x.c.ok).map((x) => x.id); + return `${name} ${r.version || ''}: ${s.ok}/${s.n} the same${bad.length ? ` — different: ${bad.slice(0, 12).join(', ')}${bad.length > 12 ? ' …' : ''}` : ''}${r.errors && r.errors.length ? ` (page errors: ${r.errors.length})` : ''}`; + }); +} diff --git a/public/dev/simcheck/stronghold.js b/public/dev/simcheck/stronghold.js new file mode 100644 index 00000000..e3500ecf --- /dev/null +++ b/public/dev/simcheck/stronghold.js @@ -0,0 +1,62 @@ +// Stronghold Protocol's battles for simcheck (core.js): the golden battle corpus — tools/golden.mjs's families roster, +// bonds, fields, standins and diy (its matches are not single battles) — as BattleSpecs (written by +// tools/sim-check.mjs), each built with /sim/spec.js createBattleFromSpec as the browser runner does +// (public/js/battle/runner.js) and stepped tick by tick; a field battle stops at its golden cap like the corpus. +// state after every tick: the battle time, the RNG state and per unit FIELDS (exact bits) +// view b.snapshot(): what viewers are sent +// deep every value reachable from the units, the definitions and the process-wide sequence numbers left out +// result resultDigest(b.result()).hash: what the server's SP_VERIFY compares +// The page (sim-check.js) and the tool (tools/sim-check.mjs) both use it, with the sim and its data loaded their way. +import { deepHash } from './core.js'; + +/** The data files the sim reads: public/js/battle/runner.js SIM_DATA_FILES (test/sim/simcheck.test.js keeps them equal). */ +export const SIM_DATA_FILES = Object.freeze(['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects', 'backups']); +/** Per unit in the state vector. */ +export const FIELDS = Object.freeze(['alive', 'removed', 'deployed', 'x', 'y', 'hp', 'atkCd', 'sp', 'burn', 'neural', 'necrosis', 'apoptosis', 'erosion', + 'dmg', 'taken', 'heal', 'blocking']); +// left out of the deep state: references back to definitions and the battle, and buffs.js's process-wide `seq` (it +// depends on what ran before in the same page or process) +const DEEP_SKIP = Object.freeze(['def', 'profile', 'kit', 'battle', 'b', 'seq']); +const QUIET = Object.freeze({ error() {}, warn() {}, info() {}, debug() {} }); + +/** The scenario list of a specs document: id, golden family, in the fast subset. */ +export const scenariosOf = (doc) => doc.specs.map((s) => ({ id: s.id, family: s.fam, fast: s.fast })); + +/** + * @param {{ spec: { createBattleFromSpec: Function, resultDigest: Function }, ds: object, doc: { specs: object[] }, logger?: object }} o + * spec: the /sim/spec.js module; ds: a simdata DataSource over SIM_DATA_FILES; doc: the specs document + */ +export function strongholdAdapter({ spec, ds, doc, logger = QUIET }) { + const byId = new Map(doc.specs.map((s) => [s.id, s])); + return { + maxTicks: 200000, + start(sc) { + const s = byId.get(sc.id); + return { b: spec.createBattleFromSpec(s.spec, ds, { logger }), cap: s.cap ?? null }; + }, + step(r) { + const b = r.b; + if (b.finished) return false; + if (r.cap != null && b.time >= r.cap - 1e-9) { b.forceEnd('forced'); return false; } + b.step(); + return true; + }, + state(r) { + const b = r.b, v = [b.time, b.rng.state() >>> 0]; + for (const u of b.units) { + const e = u.elem || {}, s = u.stats || {}; + v.push(u.alive ? 1 : 0, u.removed ? 1 : 0, u.deployed ? 1 : 0, u.x, u.y, u.hp, u.atkCd, u.skill && u.skill.sp != null ? u.skill.sp : -1, + e.burn ?? 0, e.neural ?? 0, e.necrosis ?? 0, e.apoptosis ?? 0, e.erosion ?? 0, s.dmg ?? 0, s.taken ?? 0, s.heal ?? 0, (u.blocking || []).length); + } + return v; + }, + labels(r) { + const out = ['time', 'rng']; + for (const u of r.b.units) { const name = `${u.side === 'enemy' ? 'enemy' : 'ally'} ${u.defId}#${u.id}`; for (const f of FIELDS) out.push(`${name}.${f}`); } + return out; + }, + view(r) { return r.b.snapshot(); }, + deep(r) { return deepHash(r.b.units, { skip: DEEP_SKIP }); }, + result(r) { return spec.resultDigest(r.b.result()).hash; }, + }; +} diff --git a/public/dev/simcheck/ui.js b/public/dev/simcheck/ui.js new file mode 100644 index 00000000..b838387f --- /dev/null +++ b/public/dev/simcheck/ui.js @@ -0,0 +1,206 @@ +// simcheck's page: runs a corpus of scenarios in this browser, compares every run with the reference made in Node +// (core.js compare) and says which ones parted, when, and how badly — the outcome, what viewers see, or only the +// hidden state. For a run that parted, the state vectors of the block where it parted can be downloaded and compared +// field by field with Node's (the project's tool, `--diff`). The summary can be copied as a report. +// Plain DOM, no framework; colours from CSS variables (--sc-*), so a page can wear its project's theme. Headless +// drivers read window.__simcheck ({ status, results, summary, report }) and may pass ?subset=&auto=1 (&only=a,b). +// +// Written for Stronghold Protocol (GPL-3.0-or-later) and Duel Channel (AGPL-3.0-or-later) by the same author, and kept +// the same in both. +import { tracer, compare, severity, VERSION } from './core.js'; + +const TEXT = { + run: '开始检查', stop: '停止', subset: '范围', scenarios: (n) => `${n} 场`, + idle: '准备好了。检查只在本机进行,不会上传任何数据。', + running: (i, n, id) => `正在检查 ${i} / ${n}:${id}`, stopped: '已停止', done: (n, t) => `完成:${n} 场,用时 ${t}`, + ok: '一致', bad: '不一致', result: '结果不同', view: '画面不同', state: '状态不同', deep: '仅内部状态不同', ticks: '长度不同', missing: '参考里没有', + where: (c, hz) => `第 ${(c.at.from / hz).toFixed(1)} 秒起(第 ${c.at.from}–${c.at.to} 帧)${({ state: '状态', view: '画面', deep: '内部状态' })[c.at.level]}不同`, + outcome: '结果不同', length: (a, b) => `长度 ${a} → ${b} 帧`, + dump: '下载这一段的状态', dumping: '正在记录…', + copy: '复制报告', copied: '已复制报告', copyFail: '无法写入剪贴板,请手动复制下面的内容', + allOk: '全部一致:这个浏览器算出的战斗与服务器逐位相同。', + engine: '这个浏览器', reference: '参考', none: '没有不一致的场次。', error: '运行出错', +}; + +const CSS = ` +.sc { --_bg: var(--sc-bg, #101413); --_panel: var(--sc-panel, #171d1b); --_fg: var(--sc-fg, #e6ece9); --_muted: var(--sc-muted, #93a19b); + --_line: var(--sc-line, #2a3532); --_accent: var(--sc-accent, #4ed8af); --_ok: var(--sc-ok, #4ed8af); --_warn: var(--sc-warn, #f2c14e); + --_bad: var(--sc-bad, #ff6b5e); --_font: var(--sc-font, system-ui, sans-serif); --_mono: var(--sc-mono, ui-monospace, monospace); + box-sizing: border-box; max-width: 760px; margin: 0 auto; padding: 16px; color: var(--_fg); font: 14px/1.55 var(--_font); } +.sc *, .sc *::before, .sc *::after { box-sizing: inherit; } +.sc h1 { margin: 0 0 4px; font-size: 18px; } +.sc-sub { margin: 0 0 12px; color: var(--_muted); } +.sc-info { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; margin: 0 0 14px; padding: 10px 12px; background: var(--_panel); + border: 1px solid var(--_line); font-size: 13px; } +.sc-info dt { color: var(--_muted); } .sc-info dd { margin: 0; overflow-wrap: anywhere; } +.sc-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 12px; } +.sc button, .sc select { font: inherit; color: var(--_fg); background: var(--_panel); border: 1px solid var(--_line); min-height: 40px; padding: 6px 12px; cursor: pointer; } +.sc button.sc-primary { border-color: var(--_accent); color: var(--_accent); } +.sc button:disabled { opacity: .5; cursor: default; } +.sc-bar { height: 6px; background: var(--_panel); border: 1px solid var(--_line); margin: 0 0 6px; } +.sc-bar i { display: block; height: 100%; width: 0; background: var(--_accent); transition: width .2s; } +.sc-status { margin: 0 0 12px; color: var(--_muted); min-height: 1.55em; overflow-wrap: anywhere; } +.sc-sum { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0 0 12px; } +.sc-sum b { font: 600 18px/1 var(--_mono); margin-left: 4px; } +.sc-sum .is-ok b { color: var(--_ok); } .sc-sum .is-warn b { color: var(--_warn); } .sc-sum .is-bad b { color: var(--_bad); } +.sc-list { list-style: none; margin: 0 0 12px; padding: 0; display: grid; gap: 6px; } +.sc-list li { padding: 8px 10px; background: var(--_panel); border-left: 3px solid var(--_warn); overflow-wrap: anywhere; } +.sc-list li.is-bad { border-left-color: var(--_bad); } +.sc-list code { font: 13px var(--_mono); } +.sc-list button { min-height: 32px; padding: 3px 10px; margin-top: 6px; font-size: 13px; } +.sc-note { color: var(--_muted); font-size: 13px; } +.sc-allok { color: var(--_ok); } +.sc textarea { width: 100%; min-height: 160px; margin-top: 8px; background: var(--_panel); color: var(--_fg); border: 1px solid var(--_line); + font: 12px/1.5 var(--_mono); padding: 8px; } +`; + +const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); +const clock = (ms) => { const s = Math.round(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }; +// a task boundary that background tabs do not throttle like timers +const yieldTask = () => new Promise((r) => { const ch = new MessageChannel(); ch.port1.onmessage = () => r(); ch.port2.postMessage(0); }); + +/** This browser, briefly: the engine family and version. */ +function engineName() { + const ua = (typeof navigator !== 'undefined' && navigator.userAgent) || ''; + const m = (re) => (ua.match(re) || [])[1]; + if (m(/Edg\/(\d+)/)) return `Edge ${m(/Edg\/(\d+)/)} (Blink)`; + if (m(/Firefox\/(\d+)/)) return `Firefox ${m(/Firefox\/(\d+)/)} (Gecko)`; + if (/AppleWebKit/.test(ua) && !/Chrome|Chromium|CriOS/.test(ua)) return `Safari ${m(/Version\/([\d.]+)/) || ''} (WebKit)`.replace(' ', ' '); + if (m(/CriOS\/(\d+)/)) return `Chrome iOS ${m(/CriOS\/(\d+)/)} (WebKit)`; + if (m(/Chrom(?:e|ium)\/(\d+)/)) return `Chrome ${m(/Chrom(?:e|ium)\/(\d+)/)} (Blink)`; + return ua || 'unknown'; +} + +async function copyText(text) { + try { if (navigator.clipboard && globalThis.isSecureContext) { await navigator.clipboard.writeText(text); return true; } } catch { /* the textarea */ } + const ta = document.createElement('textarea'); + ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;left:-9999px;top:0;font-size:16px'; + document.body.appendChild(ta); + try { ta.focus(); ta.select(); ta.setSelectionRange(0, text.length); return document.activeElement === ta && !!document.execCommand('copy'); } catch { return false; } finally { ta.remove(); } +} +function download(name, text) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([text], { type: 'application/json' })); + a.download = name; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 10000); +} + +/** + * Mount the check into root. + * @param {HTMLElement} root + * @param {{ project: string, title: string, subtitle?: string, adapter: object, scenarios: { id: string, family?: string }[], + * subsets: { id: string, label: string, pick: (sc: object) => boolean }[], reference: { every: number, made?: object, + * scenarios: Record }, hz?: number, info?: [string, string][], text?: object }} o + */ +function mountCheck(root, o) { + const T = { ...TEXT, ...(o.text || {}) }, every = o.reference.every || 30, deepEvery = o.reference.deepEvery || every * 5, hz = o.hz || 30; + const q = new URLSearchParams(location.search); + if (!document.getElementById('sc-style')) { const st = document.createElement('style'); st.id = 'sc-style'; st.textContent = CSS; document.head.appendChild(st); } + const engine = engineName(), made = o.reference.made || {}; + const info = [[T.engine, engine], [T.reference, [made.engine, made.commit, made.date].filter(Boolean).join(' · ') || '—'], ...(o.info || [])]; + root.classList.add('sc'); + root.innerHTML = `

${esc(o.title)}

${o.subtitle ? `

${o.subtitle}

` : ''} +
${info.map(([k, v]) => `
${esc(k)}
${esc(v)}
`).join('')}
+
+
+

${esc(T.idle)}

+
    + `; + const $ = (s) => root.querySelector(s); + const sel = $('.sc-subset'), runBtn = $('.sc-run'), stopBtn = $('.sc-stop'), bar = $('.sc-bar i'), status = $('.sc-status'); + if (q.get('subset') && o.subsets.some((s) => s.id === q.get('subset'))) sel.value = q.get('subset'); + const state = { status: 'idle', results: [], summary: null, report: '' }; + globalThis.__simcheck = state; + let stop = false; + + const summarize = (list) => { + const s = { n: list.length, ok: 0, result: 0, view: 0, state: 0, deep: 0 }; + for (const r of list) { + if (r.c.ok) { s.ok++; continue; } + if (r.c.missing || r.c.result || r.c.ticks) s.result++; + else s[r.c.at.level]++; + } + return s; + }; + const describe = (r) => { + const c = r.c, parts = []; + if (c.missing) return T.missing; + if (c.error) return `${T.error}:${c.error}`; + if (c.at) parts.push(T.where(c, hz)); + if (c.result) parts.push(T.outcome); + if (c.ticks) parts.push(T.length(c.ticks[0], c.ticks[1])); + return parts.join(';'); + }; + const render = (list, total, elapsed, current) => { + const s = summarize(list); + bar.style.width = `${total ? (100 * list.length) / total : 0}%`; + $('.sc-sum').innerHTML = [[T.ok, s.ok, 'is-ok'], [T.result, s.result, 'is-bad'], [T.view, s.view, 'is-warn'], [T.state, s.state, 'is-warn'], [T.deep, s.deep, 'is-warn']] + .map(([k, v, cls]) => `${esc(k)}${v}`).join(''); + const bad = list.filter((r) => !r.c.ok).sort((a, b) => severity(b.c) - severity(a.c)); + $('.sc-list').innerHTML = bad.map((r, i) => `
  1. ${esc(r.id)}${r.family ? ` ${esc(r.family)}` : ''}
    ${esc(describe(r))} + ${r.c.at ? `
    ` : ''}
  2. `).join(''); + $('.sc-list').querySelectorAll('button[data-i]').forEach((b) => { b.onclick = () => dumpOne(bad[Number(b.dataset.i)], b); }); + if (current) status.textContent = T.running(list.length + 1, total, current); + state.summary = s; + return s; + }; + const reportText = (list, subset, elapsed) => { + const s = summarize(list), lines = [ + `simcheck ${VERSION} · ${o.project}`, `${T.engine}:${engine}`, `UA:${(navigator && navigator.userAgent) || ''}`, + `${T.reference}:${info[1][1]}`, `${T.subset}:${subset.label} · ${T.scenarios(list.length)} · ${clock(elapsed)}`, + `${T.ok} ${s.ok} · ${T.result} ${s.result} · ${T.view} ${s.view} · ${T.state} ${s.state} · ${T.deep} ${s.deep}`]; + for (const r of list.filter((x) => !x.c.ok).sort((a, b) => severity(b.c) - severity(a.c))) lines.push(`- ${r.id}:${describe(r)}`); + return lines.join('\n'); + }; + const dumpOne = async (r, btn) => { + const old = btn.textContent; + btn.disabled = true; btn.textContent = T.dumping; + await yieldTask(); + const tr = tracer(o.adapter, o.scenarios.find((x) => x.id === r.id), { every, deepEvery, dump: [r.c.at.from, r.c.at.to] }); + while (!tr.advance(25)) await yieldTask(); + const got = tr.finish(); + download(`simcheck-${o.project}-${r.id}-${r.c.at.from}.json`, JSON.stringify({ simcheck: VERSION, project: o.project, id: r.id, window: [r.c.at.from, r.c.at.to], + engine, ua: navigator.userAgent, dumps: got.dumps })); + btn.disabled = false; btn.textContent = old; + }; + + const run = async () => { + const subset = o.subsets.find((s) => s.id === sel.value) || o.subsets[0]; + const only = q.get('only') ? new Set(q.get('only').split(',')) : null; + const list = o.scenarios.filter((sc) => subset.pick(sc) && (!only || only.has(sc.id))); + stop = false; runBtn.disabled = true; sel.disabled = true; stopBtn.hidden = false; $('.sc-report').hidden = true; + state.status = 'running'; state.results = []; + const t0 = performance.now(); + for (const sc of list) { + if (stop) break; + render(state.results, list.length, performance.now() - t0, sc.id); + await yieldTask(); + const t1 = performance.now(), tr = tracer(o.adapter, sc, { every, deepEvery }); + let ok = true; + try { while (!tr.advance(25)) { await yieldTask(); if (stop) { ok = false; break; } } } catch (e) { ok = false; state.results.push({ id: sc.id, family: sc.family, c: { ok: false, missing: false, result: true, at: null, error: String(e && e.message || e) }, ms: 0 }); } + if (!ok) continue; + const got = tr.finish(); + state.results.push({ id: sc.id, family: sc.family, c: compare(o.reference.scenarios[sc.id], got, every, deepEvery), ms: performance.now() - t1 }); + } + const elapsed = performance.now() - t0, s = render(state.results, list.length, elapsed, null); + status.innerHTML = stop ? esc(T.stopped) : s.ok === s.n ? `${esc(T.allOk)} ${esc(T.done(s.n, clock(elapsed)))}` : esc(T.done(s.n, clock(elapsed))); + if (!state.results.some((r) => !r.c.ok)) $('.sc-list').innerHTML = `
  3. ${esc(T.none)}
  4. `; + state.report = reportText(state.results, subset, elapsed); + $('.sc-report').hidden = false; + runBtn.disabled = false; sel.disabled = false; stopBtn.hidden = true; + state.status = stop ? 'stopped' : 'done'; + }; + runBtn.onclick = run; + stopBtn.onclick = () => { stop = true; }; + $('.sc-copy').onclick = async () => { + const ok = await copyText(state.report), ta = $('.sc-report textarea'); + $('.sc-report .sc-note').textContent = ok ? T.copied : T.copyFail; + ta.hidden = ok; if (!ok) { ta.value = state.report; ta.focus(); ta.select(); } + }; + if (q.get('auto') === '1') run(); + return state; +} + +export { mountCheck, engineName }; diff --git a/test/sim/simcheck.test.js b/test/sim/simcheck.test.js new file mode 100644 index 00000000..81f77c33 --- /dev/null +++ b/test/sim/simcheck.test.js @@ -0,0 +1,60 @@ +// The cross-engine check (tools/sim-check.mjs, public/dev/simcheck — docs/SIM.md「11. Tools」): the hashes are pinned +// (references made before still compare; the same values as the copy of core.js in 争锋频道 / Duel Channel), a change in +// one tick is found in its block and named by the dump comparison, the deep walk sees another realm's Map / Set, the +// check page loads the data files the client loads, and the corpus covers the golden battle families, traces a battle +// the same whatever ran before it, and ends it with the result digest a plain run gives. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import { readFileSync } from 'node:fs'; +import { hex, hashNumbers, hashValue, deepHash, trace, tracer, compare, diffDumps } from '../../public/dev/simcheck/core.js'; +import { SIM_DATA_FILES, scenariosOf } from '../../public/dev/simcheck/stronghold.js'; +import { corpusSpecs, nodeAdapter, FAMILIES } from '../../tools/sim-check.mjs'; +import { createBattleFromSpec, resultDigest } from '../../server/sim/spec.js'; +import { DataSource, getDefaultSource } from '../../server/sim/simdata.js'; + +test('simcheck hashes: pinned values, NaN as one value, −0 apart from 0', () => { + assert.equal(hex(hashNumbers([0, -0, 1, 0.1, -2.5e-300, 1e300, NaN, Infinity])), '8b3e38ba'); + assert.equal(hex(hashValue({ a: [1, 'x', null, true], b: { c: 0.5 } })), 'a024c2ef'); + assert.equal(hex(hashNumbers([NaN])), hex(hashNumbers([0 / 0]))); + assert.notEqual(hex(hashNumbers([0])), hex(hashNumbers([-0]))); + assert.equal(hex(deepHash([{ s: new Set([1, 2]), m: new Map([['k', 3]]), arr: [4.5] }])), '92f56136'); + const other = vm.runInNewContext('({ s: new Set([1, 2]), m: new Map([["k", 3]]), arr: [4.5] })'); + assert.equal(hex(deepHash([other])), '92f56136', 'another realm\'s Map / Set'); +}); + +test('simcheck compare: a one-ulp change at tick 45 is found in its block and named by the dumps', () => { + const toy = (bump) => ({ + start: () => ({ n: 0, x: 0.1 }), + step: (r) => { if (r.n >= 100) return false; r.n++; r.x = r.x * 1.01 + 0.1; if (bump && r.n === 45) r.x += r.x * Number.EPSILON; return true; }, + state: (r) => [r.n, r.x], labels: () => ['n', 'x'], view: (r) => ({ x: Math.round(r.x) }), deep: (r) => hashNumbers([r.x]), result: (r) => String(Math.round(r.x)), + }); + const ref = trace(toy(false), { id: 't' }); + assert.deepEqual(compare(ref, trace(toy(false), { id: 't' })), { ok: true, at: null, result: false, ticks: null }); + assert.deepEqual(compare(ref, trace(toy(true), { id: 't' })).at, { level: 'state', block: 1, from: 31, to: 60 }); + const a = tracer(toy(false), { id: 't' }, { dump: [31, 60] }).finish(), b = tracer(toy(true), { id: 't' }, { dump: [31, 60] }).finish(); + const d = diffDumps(a.dumps, b.dumps); + assert.equal(d[0].tick, 45); + assert.equal(d[0].label, 'x'); +}); + +test('simcheck page: the data files are the browser runner\'s', () => { + const src = readFileSync(new URL('../../public/js/battle/runner.js', import.meta.url), 'utf8'); + const m = /export const SIM_DATA_FILES = Object\.freeze\((\[[^\]]*\])\)/.exec(src); + assert.ok(m, 'runner.js SIM_DATA_FILES'); + assert.deepEqual([...SIM_DATA_FILES], JSON.parse(m[1].replace(/'/g, '"'))); +}); + +test('simcheck corpus: the golden battle families, a battle traced the same whatever ran before, the plain run\'s result', () => { + const doc = { specs: corpusSpecs() }, sc = scenariosOf(doc); + assert.deepEqual([...new Set(sc.map((s) => s.family))], [...FAMILIES]); + assert.equal(new Set(sc.map((s) => s.id)).size, sc.length); + assert.ok(sc.some((s) => s.fast) && sc.some((s) => !s.fast)); + const pick = sc.filter((s) => s.family === 'bonds')[0], other = sc.filter((s) => s.family === 'roster')[0]; + const alone = trace(nodeAdapter(doc), pick), ad = nodeAdapter(doc); + trace(ad, other); + assert.deepEqual(trace(ad, pick), alone); + const spec = doc.specs.find((s) => s.id === pick.id).spec; + const plain = resultDigest(createBattleFromSpec(spec, new DataSource(getDefaultSource().raw, null), { quiet: true }).runToEnd(200000)).hash; + assert.equal(alone.result, plain); +}); diff --git a/tools/sim-check.mjs b/tools/sim-check.mjs new file mode 100644 index 00000000..080af428 --- /dev/null +++ b/tools/sim-check.mjs @@ -0,0 +1,85 @@ +// The cross-engine check of the battle sim (docs/SIM.md「Cross-engine check」). A browser simulates its battles and +// the server re-simulates them (SP_VERIFY, takeovers), so every engine must give the same bits as Node. The golden +// battle corpus traced in Node is the reference; public/dev/sim-check.html runs the corpus in any browser — a desktop +// or a phone on the LAN — and compares it tick by tick (public/dev/simcheck: core.js, ui.js, stronghold.js). +// +// node tools/sim-check.mjs write public/dev/simcheck/data/specs.json (the corpus's BattleSpecs) +// and reference.json (their Node traces); git-ignored +// node tools/sim-check.mjs --engines chromium,firefox,webkit [--subset fast|all|] [--only id,id] +// then start the server on a free port and run the page headless in each +// engine; exit 1 when one differs. Firefox and WebKit need the +// playwright package (npm i -D playwright && npx playwright install +// firefox webkit); without it `chrome` runs a local Chrome +// ($CHROME_PATH) through puppeteer-core +// node tools/sim-check.mjs --diff the block a browser downloaded (下载这一段的状态) against Node's run of +// the same ticks, entry by entry +import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import * as spec from '../server/sim/spec.js'; +import { DataSource, getDefaultSource } from '../server/sim/simdata.js'; +import { APP_VERSION } from '../shared/constants.js'; +import { scenariosOf as goldenScenarios, isFast } from './golden.mjs'; +import { strongholdAdapter, scenariosOf } from '../public/dev/simcheck/stronghold.js'; +import { buildReference, runEngines, formatEngines, diffDumpFile } from '../public/dev/simcheck/node.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +export const OUT_DIR = join(ROOT, 'public', 'dev', 'simcheck', 'data'); +export const FAMILIES = Object.freeze(['roster', 'bonds', 'fields', 'standins', 'diy']); + +/** The golden battle corpus as BattleSpecs, in the corpus's order; a field battle keeps its cap (golden's forceEnd). */ +export function corpusSpecs(families = FAMILIES) { + const specs = []; + for (const fam of families) { + for (const sc of goldenScenarios(fam)) { + specs.push({ id: sc.id, fam, fast: isFast(sc), cap: sc.cap ?? null, spec: spec.buildBattleSpec({ + battleId: sc.id, fieldId: sc.fieldId ?? 'g', kind: sc.kind, seed: sc.seed, modeId: sc.modeId, round: sc.round, stageId: sc.stageId, rect: sc.rect, + timeLimit: sc.timeLimit, players: sc.players, spawns: sc.spawns, routes: sc.routes, flags: sc.flags, enemyOverrides: sc.enemyOverrides ?? {}, + waveId: sc.waveId ?? null, bossId: sc.bossId ?? null, boss: sc.boss ?? null, content: 'full' }) }); + } + } + return specs; +} +/** The adapter over Node's sim and data (the same files the browser loads: loadBrowserSim reads data/*.json). */ +export function nodeAdapter(doc) { + return strongholdAdapter({ spec, ds: new DataSource(getDefaultSource().raw, null), doc }); +} +const commit = () => { try { return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim(); } catch { return ''; } }; + +async function main(argv) { + const opt = (k) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : null; }; + if (opt('--diff')) { + const doc = JSON.parse(readFileSync(join(OUT_DIR, 'specs.json'), 'utf8')); + for (const line of diffDumpFile(opt('--diff'), nodeAdapter(doc), scenariosOf(doc))) console.log(line); + return 0; + } + const t0 = Date.now(); + const doc = { simcheck: 1, made: { version: APP_VERSION, commit: commit() }, specs: corpusSpecs() }; + const scenarios = scenariosOf(doc); + let last = 0; + const reference = buildReference(nodeAdapter(doc), scenarios, { + project: 'stronghold', made: { version: APP_VERSION, commit: doc.made.commit }, + onProgress: (i, n) => { if (Date.now() - last > 2000) { last = Date.now(); process.stdout.write(`\r ${i} / ${n}`); } }, + }); + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(join(OUT_DIR, 'specs.json'), JSON.stringify(doc)); + writeFileSync(join(OUT_DIR, 'reference.json'), JSON.stringify(reference)); + console.log(`\r${scenarios.length} battles traced in ${((Date.now() - t0) / 1000).toFixed(1)} s → public/dev/simcheck/data/ (open /dev/sim-check.html)`); + + const engines = opt('--engines'); + if (!engines) return 0; + const { startServer } = await import('../server/index.js'); + const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + try { + const q = new URLSearchParams({ auto: '1', subset: opt('--subset') || 'all' }); + if (opt('--only')) q.set('only', opt('--only')); + const res = await runEngines(`http://127.0.0.1:${srv.port}/dev/sim-check.html?${q}`, engines.split(','), { log: (s) => console.log(s) }); + for (const line of formatEngines(res)) console.log(line); + return Object.values(res).every((r) => r.status === 'done' && r.summary && r.summary.ok === r.summary.n) ? 0 : 1; + } finally { await srv.close(); } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1] && existsSync(ROOT)) { + main(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(e); process.exit(2); }); +}