- 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.
217 lines
15 KiB
JavaScript
217 lines
15 KiB
JavaScript
// 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 };
|