- 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.
105 lines
6.7 KiB
JavaScript
105 lines
6.7 KiB
JavaScript
// 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})` : ''}`;
|
||
});
|
||
}
|