0.2.5-dev: community round 30 and follow-ups

- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
This commit is contained in:
sganggs committed 2026-10-12 14:07:08 +08:00
1 parent bc50cac01f
commit 221e6b8dd2
134 files changed
+4590 -1201

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Sim check · 卫戍协议:盟约</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%230c0f0e'/%3E%3Cpath fill='%234ed8af' d='M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z'/%3E%3C/svg%3E" />
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="stylesheet" href="/css/theme.css" />
<style>
/* The check (simcheck/ui.js) in the game's tokens (css/theme.css): green-grey panels, mint, gold / red for the
differences. A long scrolling page, fit for a phone. */
html, body { margin: 0; height: auto; min-height: 100%; overflow-y: auto; background: var(--bg-0); }
select { min-width: 0; max-width: 100%; }
#app { --sc-bg: var(--bg-0); --sc-panel: var(--bg-1); --sc-fg: var(--text-hi); --sc-muted: var(--text-lo); --sc-line: var(--line);
--sc-accent: var(--mint-500); --sc-ok: var(--mint-500); --sc-warn: var(--gold-2); --sc-bad: var(--red-premium);
--sc-font: var(--font-cjk); --sc-mono: var(--font-mono); }
</style>
</head>
<body>
<main id="app"><p style="color:var(--text-lo);padding:16px">正在载入战斗模拟…</p></main>
<script type="module" src="./sim-check.js"></script>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
// 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';
import { VERSION, textFingerprint } from './simcheck/core.js';
const app = document.getElementById('app');
globalThis.__simcheck = { status: 'loading', results: [], summary: null, error: null };
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'),
]);
if (doc.simcheck !== VERSION || reference.simcheck !== VERSION || !Array.isArray(doc.files) || !doc.files.length
|| doc.identity !== reference.identity || doc.identity !== textFingerprint(JSON.stringify({ specs: doc.specs, files: doc.files }))) {
throw new Error('参考与场景文件不匹配,请重新生成参考数据。');
}
const verifiedData = new Map();
const validate = async () => {
for (let i = 0; i < doc.files.length; i += 8) await Promise.all(doc.files.slice(i, i + 8).map(async ({ url, hash }) => {
if (typeof url !== 'string' || !/^\/(?:sim\/|shared\/|data\/|data\.js$|js\/battle\/runner\.js$|dev\/simcheck\/)/.test(url) || url.includes('..')) throw new Error('invalid reference input path');
const response = await fetch(url, { cache: 'no-cache' });
if (!response.ok) throw new Error(`参考输入不可用:${url}`);
const text = await response.text();
if (textFingerprint(text) !== hash) throw new Error(`参考数据已过期:${url}。请重新生成参考并刷新页面。`);
if (url.startsWith('/data/')) verifiedData.set(url, text);
}));
};
await validate();
const { spec, ds } = await loadBrowserSim({ fetchFn: url => {
const text = verifiedData.get(url);
if (text == null) throw new Error(`unverified data input: ${url}`);
return Promise.resolve(new Response(text, { status: 200, headers: { 'Content-Type': 'application/json' } }));
} });
const scenarios = scenariosOf(doc);
const FAMILIES = [['roster', '干员'], ['bonds', '盟约'], ['fields', '首领战场与联防'], ['standins', '补位'], ['diy', '自选']];
if (reference.made?.version !== APP_VERSION) throw new Error('参考游戏版本已过期,请重新生成。');
mountCheck(app, {
project: 'Stronghold Protocol',
title: '模拟一致性检查',
subtitle: '逐帧比较已记录的单位状态与 Node 参考,并抽样比较快照和部分内部状态。输入文件按内容指纹核对;通过不代表全部画面、事件流或内部内存均已覆盖。状态下载仅定位状态向量字段。',
validate,
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) {
globalThis.__simcheck = { status: 'error', results: [], summary: null, error: String(e.message || 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);
}
+232
View File
@@ -0,0 +1,232 @@
// 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(); }
/** Portable content identity for local diagnostic inputs (not a security signature). */
function textFingerprint(s) { return hex(new Hash().str(s).done()) + hex(new Hash(0x53a9d61f).str(s).done()); }
function selectScenarios(scenarios, subsets, id, only = null) {
const subset = subsets.find(s => s.id === id);
if (!subset) throw new Error(`unknown subset: ${id}`);
const names = only == null ? null : new Set(String(only).split(',').map(s => s.trim()).filter(Boolean));
if (names) for (const name of names) if (!scenarios.some(s => s.id === name)) throw new Error(`unknown scenario: ${name}`);
const list = scenarios.filter(s => subset.pick(s) && (!names || names.has(s.id)));
if (!list.length) throw new Error('no scenarios selected');
return { subset, list };
}
/**
* 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<string>, 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 max = adapter.maxTicks || 1e6;
if (!Number.isInteger(every) || every < 1 || !Number.isInteger(deepEvery) || deepEvery < every || deepEvery % every) throw new Error('invalid sample interval');
if (dump && (!Array.isArray(dump) || dump.length !== 2 || !dump.every(Number.isInteger) || dump[0] < 1 || dump[1] < dump[0] || dump[1] > max)) throw new Error('invalid dump window');
const run = adapter.start(scenario);
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 (!adapter.step(run)) {
over = true;
if (t === 0) sample(true);
if (t % every) { blocks.push(block.done()); sample(true); } else if (adapter.deep && t % deepEvery) deeps.push(adapter.deep(run) >>> 0);
break;
}
if (t >= max) throw new Error(`scenario ${scenario.id}: maxTicks ${max} reached before completion`);
t++;
const s = adapter.state(run);
block.word(hashNumbers(s));
if (dump && t >= dump[0] && t <= dump[1]) dumps[t] = { state: Array.from(s, encodeNumber), 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 SPECIAL_NUMBERS = new Map([['NaN', NaN], ['Infinity', Infinity], ['-Infinity', -Infinity], ['-0', -0]]);
const encodeNumber = (x) => Number.isNaN(x) ? 'NaN' : x === Infinity ? 'Infinity' : x === -Infinity ? '-Infinity' : Object.is(x, -0) ? '-0' : x;
const bitsOf = (value) => {
const x = typeof value === 'string' && SPECIAL_NUMBERS.has(value) ? SPECIAL_NUMBERS.get(value) : value;
if (typeof x !== 'number') throw new Error('invalid numeric state entry');
if (Number.isNaN(x)) 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 (Object.is(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 = 2;
export { VERSION, Hash, hex, hashNumbers, hashValue, textFingerprint, selectScenarios, deepHash, tracer, trace, compare, severity, diffDumps };
+104
View File
@@ -0,0 +1,104 @@
// 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<Record<string, { version?: string, status: string, summary?: object, results?: object[], report?: string, error?: string }>>}
*/
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 && ['done', 'stopped', 'error'].includes(st.status)) break;
if (errors.length) 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, expected: s.expected, completed: s.completed, error: s.error, 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, identity = null } = {}) {
const d = JSON.parse(readFileSync(file, 'utf8'));
if (d.simcheck !== VERSION || !d.dumps || typeof d.dumps !== 'object' || !Array.isArray(d.window)) throw new Error('invalid dump format');
if (identity && d.identity !== identity) throw new Error('dump and reference inputs differ');
const sc = scenarios.find((s) => s.id === d.id);
if (!sc) throw new Error(`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})` : ''}`;
});
}
+62
View File
@@ -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; },
};
}
+216
View File
@@ -0,0 +1,216 @@
// 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=<id>&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, selectScenarios, 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.to / 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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<string, object> }, 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 = `<h1>${esc(o.title)}</h1>${o.subtitle ? `<p class="sc-sub">${esc(o.subtitle)}</p>` : ''}
<dl class="sc-info">${info.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>
<div class="sc-controls"><label>${esc(T.subset)} <select class="sc-subset">${o.subsets.map((s) =>
`<option value="${esc(s.id)}">${esc(s.label)} · ${esc(T.scenarios(o.scenarios.filter(s.pick).length))}</option>`).join('')}</select></label>
<button class="sc-primary sc-run">${esc(T.run)}</button><button class="sc-stop" hidden>${esc(T.stop)}</button></div>
<div class="sc-bar"><i></i></div><p class="sc-status" role="status">${esc(T.idle)}</p>
<div class="sc-sum"></div><ol class="sc-list"></ol>
<div class="sc-report" hidden><button class="sc-copy">${esc(T.copy)}</button> <span class="sc-note"></span><textarea readonly hidden></textarea></div>`;
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');
sel.onchange = () => q.set('subset', sel.value);
const state = { status: 'idle', results: [], summary: null, report: '', expected: 0, completed: 0 };
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]) => `<span class="${v && cls !== 'is-ok' ? cls : cls === 'is-ok' ? cls : ''}">${esc(k)}<b>${v}</b></span>`).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) => `<li class="${severity(r.c) === 3 ? 'is-bad' : ''}"><code>${esc(r.id)}</code>${r.family ? ` <span class="sc-note">${esc(r.family)}</span>` : ''}<br>${esc(describe(r))}
${r.c.at ? `<br><button data-i="${i}">${esc(T.dump)}</button>` : ''}</li>`).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;
try {
await o.validate?.();
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-${String(o.project).replace(/[^\w\u4e00-\u9fff]+/g, '-').toLowerCase()}-${r.id}-${r.c.at.from}.json`, JSON.stringify({ simcheck: VERSION, project: o.project, identity: o.reference.identity, id: r.id, window: [r.c.at.from, r.c.at.to],
engine, ua: navigator.userAgent, dumps: got.dumps }));
} catch (e) { status.textContent = `${T.error}:${String(e.message || e)}`; }
finally { btn.disabled = false; btn.textContent = old; }
};
const run = async () => {
stop = false; runBtn.disabled = true; sel.disabled = true; stopBtn.hidden = false; $('.sc-report').hidden = true;
state.status = 'running'; state.results = [];
state.expected = 0; state.completed = 0; state.error = null;
const t0 = performance.now();
try {
await o.validate?.();
if (q.has('subset') && !o.subsets.some(s => s.id === q.get('subset'))) throw new Error(`unknown subset: ${q.get('subset')}`);
const { subset, list } = selectScenarios(o.scenarios, o.subsets, sel.value, q.get('only'));
state.expected = list.length;
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 });
while (!tr.advance(25)) { await yieldTask(); if (stop) break; }
if (stop) break;
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 });
state.completed = state.results.length;
}
const elapsed = performance.now() - t0, s = render(state.results, list.length, elapsed, null);
const complete = s.n > 0 && state.completed === state.expected;
status.innerHTML = stop ? esc(T.stopped) : complete && s.ok === s.n ? `<span class="sc-allok">${esc(T.allOk)}</span> ${esc(T.done(s.n, clock(elapsed)))}` : esc(T.done(s.n, clock(elapsed)));
if (complete && !state.results.some(r => !r.c.ok)) $('.sc-list').innerHTML = `<li class="sc-note">${esc(T.none)}</li>`;
state.report = reportText(state.results, subset, elapsed);
state.status = stop ? 'stopped' : 'done';
} catch (e) {
state.status = 'error'; state.error = String(e.message || e); state.report = `${T.error}:${state.error}`;
status.textContent = state.report;
} finally { $('.sc-report').hidden = false; runBtn.disabled = false; sel.disabled = false; stopBtn.hidden = true; }
};
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 };