Files
ark-stronghold-protocol/public/dev/simcheck/core.js
T
sganggs 221e6b8dd2 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.
2026-10-12 14:07:08 +08:00

233 lines
13 KiB
JavaScript

// 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 };