From 7c307d861394744eab5a9583449b247bf5c0973f Mon Sep 17 00:00:00 2001 From: siruimei07 Date: Thu, 8 Oct 2026 18:41:33 +0800 Subject: [PATCH 01/10] feedback7 G-TOOLS: a reconnect token cannot be claimed twice by two tabs started apart (PR #299 by @siruimei07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied as-is. A tab that is still choosing a reconnect token now answers another tab's query for the same candidate hashes, so a tab started during the 150 ms window cannot adopt a token the earlier tab is also considering. The smaller tab id still wins, and a tab that already holds the token keeps it. Tests: node --test test/client-identity-session.test.js test/client-static.test.js — 319 pass, 0 fail. --- public/js/net.js | 13 ++- test/client-identity-session.test.js | 143 +++++++++++++++++++++++++++ test/client-static.test.js | 52 ++++++++++ 3 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 test/client-identity-session.test.js diff --git a/public/js/net.js b/public/js/net.js index b921a444..5916d3bd 100644 --- a/public/js/net.js +++ b/public/js/net.js @@ -726,7 +726,7 @@ export function createIdentity(deps = {}) { let initPromise = null; let initialized = false; let current = null; // token this tab uses (null = let the server create a session) - let resolving = null; // { taken: Set } while init() waits for claims + let resolving = null; // { hashes: Set, taken: Set } while init() waits for claims const readRecent = () => { try { @@ -751,7 +751,12 @@ export function createIdentity(deps = {}) { if (m.hashes.includes(h)) post({ type: 'mine', hash: h, to: m.from }); } // Another tab is choosing at the same time: the smaller tab id has precedence. - if (resolving && m.from < tabId) for (const h of m.hashes) if (typeof h === 'string') resolving.taken.add(h); + // Answer for pending candidates too: a later channel may have missed our initial query. + if (resolving) for (const h of m.hashes) { + if (typeof h !== 'string') continue; + if (m.from < tabId) resolving.taken.add(h); + else if (resolving.hashes.has(h)) post({ type: 'mine', hash: h, to: m.from }); + } } else if (m.type === 'mine' && resolving && m.to === tabId && typeof m.hash === 'string') { resolving.taken.add(m.hash); } @@ -768,8 +773,8 @@ export function createIdentity(deps = {}) { // Without a channel we cannot tell whether a shared token is in use: never adopt one. const candidates = [...new Set([ownOk, ...(channel ? readRecent() : [])].filter(isToken))]; if (!channel || candidates.length === 0) return ownOk; - resolving = { taken: new Set() }; - post({ type: 'who', hashes: candidates.map(tokenHash) }); + resolving = { hashes: new Set(candidates.map(tokenHash)), taken: new Set() }; + post({ type: 'who', hashes: [...resolving.hashes] }); await new Promise((r) => setTimer(r, queryMs)); const { taken } = resolving; resolving = null; diff --git a/test/client-identity-session.test.js b/test/client-identity-session.test.js new file mode 100644 index 00000000..18a97e1f --- /dev/null +++ b/test/client-identity-session.test.js @@ -0,0 +1,143 @@ +// Real BroadcastChannel election followed by real client/server session handshakes. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { BroadcastChannel } from 'node:worker_threads'; +import WebSocket from 'ws'; +import { startServer } from '../server/index.js'; +import { CLAIM_QUERY_MS, createIdentity, Net } from '../public/js/net.js'; + +function storage(seed = {}) { + const values = new Map(Object.entries(seed)); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + removeItem: (key) => values.delete(key), + }; +} + +function nextEvent(net, type) { + return new Promise((resolve, reject) => { + const off = net.on(type, (value) => { clearTimeout(timer); off(); resolve(value); }); + const timer = setTimeout(() => { off(); reject(new Error(`Timed out waiting for ${type}`)); }, 2000); + }); +} + +async function harness(t) { + const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + const clients = [], channels = [], replaced = []; + const local = storage(); + const channelName = `sp.identity.test.${process.pid}.${srv.port}`; + t.after(async () => { + clients.forEach((net) => net.close()); + channels.forEach((channel) => channel.close()); + await srv.close(); + }); + return { + local, replaced, + tab(tabId, session = storage(), setTimeout) { + const channel = new BroadcastChannel(channelName); + channels.push(channel); + const id = createIdentity({ local, session, channel, tabId, setTimeout }); + return { id, session, channel }; + }, + async connect(name, tab) { + const net = new Net({ url: `ws://127.0.0.1:${srv.port}/ws`, WebSocket, getToken: () => tab?.id.getToken() ?? null }); + clients.push(net); + net.on('replaced', (error) => replaced.push({ name, code: error.code })); + net.on('welcome', (welcome) => tab?.id.saveToken(welcome.token)); + const welcome = nextEvent(net, 'welcome'); + net.setName(name); + return { net, welcome: await welcome }; + }, + }; +} + +async function closeClient(net) { + const closed = once(net.ws, 'close'); + net.close(); + await closed; +} + +async function assertOnline(h, clients) { + // A round trip from each socket also lets a queued replacement close reach the client. + const replies = await Promise.allSettled(clients.map(({ net }) => net.request('ping', { c: Date.now() }))); + assert.deepEqual(h.replaced, [], 'no client is replaced by another tab'); + for (const [index, client] of clients.entries()) { + assert.equal(client.net.status, 'online'); + assert.equal(client.net.lastError, null); + assert.equal(replies[index].status, 'fulfilled'); + assert.equal(replies[index].value.t, 'pong'); + } +} + +for (const [firstId, secondId] of [['a-tab', 'b-tab'], ['b-tab', 'a-tab']]) { + test(`staggered tabs ${firstId} then ${secondId} keep separate server sessions`, { timeout: 5000 }, async (t) => { + const h = await harness(t); + const seed = await h.connect('Seed'); + await closeClient(seed.net); + h.local.setItem('sp.tokens', JSON.stringify([seed.welcome.token])); + + const deadlines = []; + const holdDeadline = (fn, ms) => { assert.equal(ms, CLAIM_QUERY_MS); deadlines.push(fn); }; + const first = h.tab(firstId, storage(), holdDeadline); + const firstInit = first.id.init(); + // The first who has already been sent when the second channel is created. + const second = h.tab(secondId, storage(), holdDeadline); + const delivered = new Promise((resolve) => { + second.channel.addEventListener('message', (event) => { + if (event.data.type === 'test.barrier') resolve(); + }); + }); + first.channel.addEventListener('message', (event) => { + if (event.data.type === 'who') first.channel.postMessage({ type: 'test.barrier' }); + }); + const secondInit = second.id.init(); + // FIFO delivery puts this barrier after any real reply to the later who. Both default + // 150 ms deadlines remain pending, without relying on sleeps or a fake channel hub. + await delivered; + assert.equal(deadlines.length, 2); + deadlines[0](); + await firstInit; + deadlines[1](); + await secondInit; + + const a = await h.connect('First', first); + const b = await h.connect('Second', second); + await assertOnline(h, [a, b]); + assert.notEqual(a.welcome.token, b.welcome.token); + assert.notEqual(a.welcome.playerId, b.welcome.playerId); + const resumed = firstId < secondId ? a : b; + assert.equal(resumed.welcome.token, seed.welcome.token); + assert.equal(resumed.welcome.playerId, seed.welcome.playerId); + }); +} + +test('live token ownership survives a duplicated tab, reload and reconnect', { timeout: 5000 }, async (t) => { + const h = await harness(t); + const owner = h.tab('z-owner'); + await owner.id.init(); + const a = await h.connect('Owner', owner); + + // A duplicated tab copies sessionStorage, but its smaller tab ID cannot evict a live owner. + const duplicate = h.tab('a-duplicate', storage({ 'sp.token': a.welcome.token })); + await duplicate.id.init(); + const b = await h.connect('Duplicate', duplicate); + assert.notEqual(a.welcome.token, b.welcome.token); + assert.notEqual(a.welcome.playerId, b.welcome.playerId); + await assertOnline(h, [a, b]); + + await closeClient(a.net); + owner.channel.close(); + const reloaded = h.tab('z-reloaded', owner.session); + await reloaded.id.init(); + const a2 = await h.connect('Owner', reloaded); + assert.equal(a2.welcome.token, a.welcome.token); + assert.equal(a2.welcome.playerId, a.welcome.playerId); + const reconnected = nextEvent(a2.net, 'welcome'); + a2.net.reconnectNow(); + const welcome = await reconnected; + assert.equal(welcome.token, a.welcome.token); + assert.equal(welcome.playerId, a.welcome.playerId); + await assertOnline(h, [a2, b]); +}); diff --git a/test/client-static.test.js b/test/client-static.test.js index af58833d..db8c8437 100644 --- a/test/client-static.test.js +++ b/test/client-static.test.js @@ -790,6 +790,58 @@ describe('identity (reconnect-token selection across tabs)', () => { assert.equal(ty, null); }); + for (const [firstId, secondId, expected] of [ + ['a-tab', 'b-tab', ['tokShared', null]], + ['b-tab', 'a-tab', [null, 'tokShared']], + ]) { + test(`staggered channels: ${firstId} starts before ${secondId}`, async () => { + const { createIdentity, CLAIM_QUERY_MS } = await mod('net.js'); + const hub = channelHub(), timers = fakeTimers(), local = memStorage(); + local.setItem('sp.tokens', JSON.stringify(['tokShared'])); + const firstSession = memStorage(), secondSession = memStorage(); + const first = createIdentity({ local, session: firstSession, tabId: firstId, + channel: hub.create(), setTimeout: timers.setTimeout }); + const firstPick = first.init(); + timers.advance(30); + // This channel did not exist when the first tab broadcast its query. + const second = createIdentity({ local, session: secondSession, tabId: secondId, + channel: hub.create(), setTimeout: timers.setTimeout }); + const secondPick = second.init(); + await new Promise((r) => setImmediate(r)); + timers.advance(CLAIM_QUERY_MS - 30); + assert.equal(await firstPick, expected[0]); + timers.advance(30); + assert.equal(await secondPick, expected[1]); + assert.deepEqual([first.getToken(), second.getToken()], expected); + assert.deepEqual([firstSession.getItem('sp.token'), secondSession.getItem('sp.token')], expected); + }); + } + + test('staggered elections reserve fallback candidates while a live owner keeps its token', async () => { + const { createIdentity, CLAIM_QUERY_MS } = await mod('net.js'); + const hub = channelHub(), timers = fakeTimers(), local = memStorage(); + const owner = createIdentity({ local, session: memStorage(), tabId: 'z-owner', channel: hub.create() }); + await owner.init(); + owner.saveToken('tokOwned'); + local.setItem('sp.tokens', JSON.stringify(['tokOwned', 'tokFree'])); + const first = createIdentity({ local, session: memStorage(), tabId: 'a-tab', + channel: hub.create(), setTimeout: timers.setTimeout }); + const firstPick = first.init(); + timers.advance(30); + const duplicateSession = memStorage(); + duplicateSession.setItem('sp.token', 'tokOwned'); + const second = createIdentity({ local, session: duplicateSession, tabId: 'b-tab', + channel: hub.create(), setTimeout: timers.setTimeout }); + const secondPick = second.init(); + await new Promise((r) => setImmediate(r)); + timers.advance(CLAIM_QUERY_MS - 30); + assert.equal(await firstPick, 'tokFree'); + timers.advance(30); + assert.equal(await secondPick, null); + assert.equal(duplicateSession.getItem('sp.token'), null, 'the copied live token is discarded'); + assert.equal(owner.getToken(), 'tokOwned', 'a live owner outranks even a smaller tab id'); + }); + test('without BroadcastChannel only the own token is used; init is idempotent; welcome during init wins', async () => { const { createIdentity } = await mod('net.js'); const local = memStorage(); From 5662b7ef6576b476590f9afaa6b01f40571c2802 Mon Sep 17 00:00:00 2001 From: siruimei07 Date: Thu, 8 Oct 2026 18:41:58 +0800 Subject: [PATCH 02/10] feedback7 G-TOOLS: out-of-order language-pack responses no longer overwrite the newer choice (PR #304 by @siruimei07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied as-is. initLang and switchLang record a request counter before the first await and apply a pack only when that request is still the latest. Choosing the language already on screen cancels an in-flight load. A failed latest choice still saves the preference and leaves the previous UI. Tests: node --test test/ui/lang.test.js test/ui/i18n.e2e.test.js test/i18n-packs.test.js test/i18n-server.test.js — 29 pass, 0 fail. The Chrome suite in test/ui/i18n.e2e.test.js skipped (SP_E2E unset). --- public/js/ui/lang.js | 24 ++++- test/ui/i18n.e2e.test.js | 121 +++++++++++++++++++++ test/ui/lang.test.js | 222 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 test/ui/lang.test.js diff --git a/public/js/ui/lang.js b/public/js/ui/lang.js index 5f95bfbf..644318b5 100644 --- a/public/js/ui/lang.js +++ b/public/js/ui/lang.js @@ -39,6 +39,8 @@ const uiLoads = new Map(); let indexLoad = null; /** Whether the pack index arrived (without it the chain of a pack is taken from its own `_meta` as it loads). */ let indexOk = false; +/** Only the latest boot or explicit selection may apply its loaded language. */ +let langRequest = 0; const INDEX_URL = `${PACKS_URL}${PACK_INDEX_FILE}`; const defaultFetch = (...a) => globalThis.fetch(...a); @@ -194,20 +196,23 @@ function applyLang(lang) { * @returns {Promise} the language in effect */ export async function initLang() { + const request = ++langRequest; wire(); applyDocument(getLang()); const TIMEOUT = Symbol('timeout'); const boot = (async () => { const listed = await loadLangIndex(); const { lang, fromUrl } = initialLang(undefined, undefined, { tentative: !listed }); - if (fromUrl) { savePref(PREF_KEY, lang); stripLangParam(); } + if (fromUrl) stripLangParam(); + if (request !== langRequest) return null; + if (fromUrl) savePref(PREF_KEY, lang); if (lang === DEFAULT_LANG) return null; return (await loadLangChain(lang)) ? lang : null; })(); const got = await Promise.race([boot, new Promise((r) => setTimeout(() => r(TIMEOUT), BOOT_WAIT_MS))]); if (got === TIMEOUT) { - boot.then((lang) => { if (lang && normalizeLang(loadPref(PREF_KEY, null)) === lang) applyLang(lang); }); - } else if (got) applyLang(got); + boot.then((lang) => { if (request === langRequest && lang && normalizeLang(loadPref(PREF_KEY, null)) === lang) applyLang(lang); }); + } else if (request === langRequest && got) applyLang(got); return getLang(); } @@ -218,10 +223,12 @@ export async function initLang() { * @returns {Promise} the language in effect */ export async function switchLang(lang) { + const request = ++langRequest; wire(); const want = normalizeLang(lang) || DEFAULT_LANG; savePref(PREF_KEY, want); if (want !== DEFAULT_LANG && !(await loadLangChain(want))) return getLang(); + if (request !== langRequest) return getLang(); applyLang(want); return getLang(); } @@ -290,10 +297,17 @@ export function machineTranslationNote() { export function LangToggle({ class: cls }) { const lang = useLang(); const menu = langMenuModel(useLangs(), lang); - const pick = (code) => { if (code && code !== getLang()) switchLang(code); }; + const pick = async (code, select = null) => { + if (!code) return; + const switched = switchLang(code); + const request = langRequest; + await switched; + // A failed current request keeps the displayed language; stale requests must not reset a newer pending pick. + if (select && request === langRequest) select.value = getLang(); + }; if (menu.kind === 'select') { return html``; diff --git a/test/ui/i18n.e2e.test.js b/test/ui/i18n.e2e.test.js index dfd76425..eb955237 100644 --- a/test/ui/i18n.e2e.test.js +++ b/test/ui/i18n.e2e.test.js @@ -179,4 +179,125 @@ describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E= assert.deepEqual(problems, []); await page.close(); }); + + // Hold only UI pack fetches; the real loader, cache, Preact menu and document updates still run. + const holdPacks = (page, codes) => page.evaluate((held) => { + const fetch = globalThis.fetch.bind(globalThis); + globalThis.languageRequests = {}; + const gates = Object.fromEntries(held.map((code) => { + let release; + const ready = new Promise((resolve) => { release = resolve; }); + return [code, { ready, release }]; + })); + globalThis.releaseLanguage = async (code, ok) => { + const { loadLangChain } = await import('/js/ui/lang.js'); + const settled = loadLangChain(code); + gates[code].release(ok); + await settled; + await new Promise(requestAnimationFrame); + }; + globalThis.fetch = (url, ...args) => { + const code = held.find((c) => url === `/i18n/${c}.json`); + if (!code) return fetch(url, ...args); + globalThis.languageRequests[code] = (globalThis.languageRequests[code] || 0) + 1; + return gates[code].ready.then((ok) => ok ? fetch(url, ...args) : new Response('', { status: 503 })); + }; + }, codes); + const waitForPack = (page, code) => page.waitForFunction((c) => globalThis.languageRequests[c] > 0, {}, code); + const releasePack = (page, code, ok = true) => page.evaluate((c, success) => globalThis.releaseLanguage(c, success), code, ok); + const languageState = (page) => page.evaluate(async () => ({ + current: (await import('/shared/i18n.js')).getLang(), + document: document.documentElement.lang, + dataLang: document.documentElement.dataset.lang, + script: document.documentElement.dataset.script, + title: document.title, + start: document.querySelector('.title-login .btn--primary').textContent.trim(), + selected: document.querySelector('[data-testid="lang-toggle"] select').value, + saved: JSON.parse(localStorage.getItem('sp.pref.lang')), + })); + const expectedLanguage = { + zh: { current: 'zh', document: 'zh-CN', dataLang: 'zh', script: 'cjk', title: '卫戍协议:盟约 · STRONGHOLD PROTOCOL', start: '开始', selected: 'zh', saved: 'zh' }, + en: { current: 'en', document: 'en', dataLang: 'en', script: 'alphabetic', title: 'Stronghold Protocol: Alliance · Web Simulation', start: 'Start', selected: 'en', saved: 'en' }, + 'zh-TW': { current: 'zh-TW', document: 'zh-TW', dataLang: 'zh-TW', script: 'cjk', title: '衛戍協議:盟約 · STRONGHOLD PROTOCOL', start: '開始', selected: 'zh-TW', saved: 'zh-TW' }, + }; + + test('a late English pack cannot overwrite a newer Traditional Chinese selection; its cache remains reusable', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + await pick(page, 'zh-TW'); + await page.waitForFunction(() => document.documentElement.lang === 'zh-TW'); + await releasePack(page, 'en'); + assert.deepEqual(await languageState(page), expectedLanguage['zh-TW']); + await pick(page, 'en'); + await page.waitForFunction(() => document.documentElement.lang === 'en'); + assert.deepEqual(await languageState(page), expectedLanguage.en); + assert.equal(await page.evaluate(() => globalThis.languageRequests.en), 1, 'the stale pack was cached'); + }); + + test('selecting the displayed default language cancels a pending pack', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + assert.equal(await page.$eval('[data-testid="lang-toggle"] select', (el) => el.value), 'en', 'the native menu can still change back to Chinese'); + assert.equal(await page.$eval('[data-testid="lang-toggle"] select', (el) => el.disabled), false); + await pick(page, 'zh'); + await releasePack(page, 'en'); + assert.deepEqual(await languageState(page), expectedLanguage.zh); + }); + + test('repeated selections reuse a pending pack and still supersede an intervening selection', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en', 'zh-TW']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + await pick(page, 'en'); + await pick(page, 'zh-TW'); + await waitForPack(page, 'zh-TW'); + await pick(page, 'en'); + await releasePack(page, 'en'); + assert.deepEqual(await languageState(page), expectedLanguage.en); + await releasePack(page, 'zh-TW'); + assert.deepEqual(await languageState(page), expectedLanguage.en); + assert.equal(await page.evaluate(() => globalThis.languageRequests.en), 1); + }); + + test('a failed latest selection keeps the last successful UI and menu; an older pack stays stale', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en', 'zh-TW']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + await pick(page, 'zh-TW'); + await waitForPack(page, 'zh-TW'); + await releasePack(page, 'zh-TW', false); + const unchanged = { ...expectedLanguage.zh, saved: 'zh-TW' }; + assert.deepEqual(await languageState(page), unchanged, 'failed preference stays saved, as before'); + await releasePack(page, 'en'); + assert.deepEqual(await languageState(page), unchanged); + }); + + test('a stale failure leaves a newer pending dropdown selection intact', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en', 'zh-TW']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + await pick(page, 'zh-TW'); + await waitForPack(page, 'zh-TW'); + await releasePack(page, 'en', false); + assert.deepEqual(await languageState(page), { ...expectedLanguage.zh, saved: 'zh-TW', selected: 'zh-TW' }); + await releasePack(page, 'zh-TW'); + assert.deepEqual(await languageState(page), expectedLanguage['zh-TW']); + }); }); diff --git a/test/ui/lang.test.js b/test/ui/lang.test.js new file mode 100644 index 00000000..ebbfd7b8 --- /dev/null +++ b/test/ui/lang.test.js @@ -0,0 +1,222 @@ +// Controlled UI-pack completions exercise real language switching without network timing or a browser. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getLang, onLangChange, registerLangs, setLang, setMessages, setI18nWarn, t } from '../../shared/i18n.js'; + +const TITLE = '卫戍协议:盟约 · STRONGHOLD PROTOCOL'; +const PACKS = { + en: { 开始: 'Start', 卫戍协议: 'Stronghold Protocol', [TITLE]: 'Stronghold Protocol: Alliance' }, + 'zh-TW': { 开始: '開始', 卫戍协议: '衛戍協議', [TITLE]: '衛戍協議:盟約' }, +}; +const STATES = { + zh: { lang: 'zh', text: '开始', htmlLang: 'zh-CN', dataLang: 'zh', script: 'cjk', title: TITLE }, + en: { lang: 'en', text: 'Start', htmlLang: 'en', dataLang: 'en', script: 'alphabetic', title: 'Stronghold Protocol: Alliance' }, + 'zh-TW': { lang: 'zh-TW', text: '開始', htmlLang: 'zh-TW', dataLang: 'zh-TW', script: 'cjk', title: '衛戍協議:盟約' }, +}; +const INDEX = { version: 1, packs: Object.keys(PACKS).map((code) => ({ + id: code, type: 'lang', lang: code, name: code, files: { ui: `/i18n/${code}.json` }, +})) }; +const turn = () => new Promise((resolve) => setImmediate(resolve)); +let imports = 0; + +async function setup(ctx, preference = null, address = 'https://example.test/') { + const previous = new Map(['document', 'localStorage', 'fetch', 'location', 'history'].map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const stored = new Map(preference ? [['sp.pref.lang', JSON.stringify(preference)]] : []); + const doc = { documentElement: { lang: 'zh-CN', dataset: { lang: 'zh', script: 'cjk' } }, title: TITLE }; + const pending = new Map(); + const calls = []; + globalThis.document = doc; + globalThis.localStorage = { getItem: (key) => stored.get(key) ?? null, setItem: (key, value) => stored.set(key, value) }; + globalThis.location = new URL(address); + globalThis.history = { + state: null, + replaceState(state, _title, relative) { + this.state = state; + globalThis.location.href = new URL(relative, globalThis.location.href).href; + }, + }; + globalThis.fetch = (url) => { + calls.push(url); + const request = Promise.withResolvers(); + if (!pending.has(url)) pending.set(url, []); + pending.get(url).push(request); + return request.promise; + }; + const warnings = []; + ctx.mock.method(console, 'warn', (message) => warnings.push(message)); + registerLangs(Object.keys(PACKS).map((code) => ({ code, fallback: [], base: null, data: false, ui: `/i18n/${code}.json` }))); + setLang('zh'); + for (const code of Object.keys(PACKS)) setMessages(code, {}); + const url = new URL('../../public/js/ui/lang.js', import.meta.url); + url.searchParams.set('test', String(++imports)); + const lang = await import(url.href); + ctx.after(() => { + setLang('zh'); + setI18nWarn(null); + for (const code of Object.keys(PACKS)) setMessages(code, {}); + for (const [key, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } + }); + const finish = (path, body, status = 200) => { + const request = pending.get(path)?.shift(); + assert.ok(request, `a request is pending for ${path}`); + request.resolve({ ok: status === 200, status, json: async () => body }); + }; + const state = (code, saved = code) => assert.deepEqual({ + lang: getLang(), text: t('开始'), htmlLang: doc.documentElement.lang, dataLang: doc.documentElement.dataset.lang, + script: doc.documentElement.dataset.script, title: doc.title, saved: stored.get('sp.pref.lang') ?? null, + }, { ...STATES[code], saved: saved == null ? null : JSON.stringify(saved) }); + return { + ...lang, calls, warnings, state, + finish: (code, status = 200) => finish(`/i18n/${code}.json`, PACKS[code], status), + finishIndex: () => finish('/packs/index.json', INDEX), + }; +} + +test('a delayed language cannot overwrite the later successful selection', async (ctx) => { + const h = await setup(ctx); + const first = h.switchLang('en'); + const latest = h.switchLang('zh-TW'); + h.finish('zh-TW'); + assert.equal(await latest, 'zh-TW'); + h.state('zh-TW'); + h.finish('en'); + assert.equal(await first, 'zh-TW'); + h.state('zh-TW'); +}); + +test('selecting the default language invalidates a pending pack', async (ctx) => { + const h = await setup(ctx); + const first = h.switchLang('en'); + assert.equal(await h.switchLang('zh'), 'zh'); + h.state('zh'); + h.finish('en'); + assert.equal(await first, 'zh'); + h.state('zh'); +}); + +test('reselecting the current language cancels a different pending selection and uses its cached pack', async (ctx) => { + const h = await setup(ctx); + const initial = h.switchLang('zh-TW'); + h.finish('zh-TW'); + await initial; + const pending = h.switchLang('en'); + await h.switchLang('zh-TW'); + h.finish('en'); + await pending; + h.state('zh-TW'); + assert.deepEqual(h.calls, ['/i18n/zh-TW.json', '/i18n/en.json']); +}); + +test('A, B, A shares the in-flight A pack and applies only the latest selection', async (ctx) => { + const h = await setup(ctx); + const changes = []; + ctx.after(onLangChange((code) => changes.push(code))); + const first = h.switchLang('en'); + const middle = h.switchLang('zh-TW'); + const latest = h.switchLang('en'); + h.finish('zh-TW'); + await middle; + h.state('zh', 'en'); + h.finish('en'); + await Promise.all([first, latest]); + h.state('en'); + assert.deepEqual(changes, ['en']); + await h.switchLang('en'); + assert.deepEqual(h.calls, ['/i18n/en.json', '/i18n/zh-TW.json']); + assert.deepEqual(changes, ['en'], 'repeated successful selections do not notify a language change'); +}); + +test('a failed latest request retains the successful interface, rejects stale success, and can retry', async (ctx) => { + const h = await setup(ctx); + const first = h.switchLang('en'); + const latest = h.switchLang('zh-TW'); + h.finish('zh-TW', 503); + assert.equal(await latest, 'zh'); + h.state('zh', 'zh-TW'); + assert.equal(h.warnings.length, 1); + assert.match(h.warnings[0], /HTTP 503.*interface stays as it is/); + h.finish('en'); + await first; + h.state('zh', 'zh-TW'); + const retry = h.switchLang('zh-TW'); + h.finish('zh-TW'); + await retry; + h.state('zh-TW'); + await h.switchLang('en'); + h.state('en'); + assert.deepEqual(h.calls, ['/i18n/en.json', '/i18n/zh-TW.json', '/i18n/zh-TW.json'], 'failed loads retry; stale successful packs remain cached'); +}); + +test('startup applies the stored language after its pack is ready', async (ctx) => { + const h = await setup(ctx, 'en'); + ctx.mock.method(globalThis, 'setTimeout', () => 0); + const boot = h.initLang(); + h.finishIndex(); + await turn(); + h.finish('en'); + assert.equal(await boot, 'en'); + h.state('en'); +}); + +test('startup cannot overwrite a newer switch before the boot timeout', async (ctx) => { + const h = await setup(ctx, 'en'); + ctx.mock.method(globalThis, 'setTimeout', () => 0); + const boot = h.initLang(); + h.finishIndex(); + await turn(); + const latest = h.switchLang('zh-TW'); + h.finish('zh-TW'); + await latest; + h.finish('en'); + await boot; + h.state('zh-TW'); +}); + +test('a delayed startup index cannot restore the URL choice after a newer selection', async (ctx) => { + const h = await setup(ctx, null, 'https://example.test/play?room=alliance&lang=en#settings'); + ctx.mock.method(globalThis, 'setTimeout', () => 0); + const boot = h.initLang(); + const latest = h.switchLang('zh-TW'); + h.finish('zh-TW'); + await latest; + h.finishIndex(); + await turn(); + h.state('zh-TW'); + assert.equal(await boot, 'zh-TW'); + assert.deepEqual(h.calls, ['/packs/index.json', '/i18n/zh-TW.json']); + assert.equal(globalThis.location.href, 'https://example.test/play?room=alliance#settings'); + assert.deepEqual(h.initialLang(), { lang: 'zh-TW', fromUrl: false }, 'reload uses the newer saved choice'); +}); + +test('timed-out startup applies its pack later when the selection has not changed', async (ctx) => { + const h = await setup(ctx, 'en'); + let timeout; + ctx.mock.method(globalThis, 'setTimeout', (fn) => { timeout = fn; return 0; }); + const boot = h.initLang(); + h.finishIndex(); + await turn(); + timeout(); + assert.equal(await boot, 'zh'); + h.state('zh', 'en'); + h.finish('en'); + await turn(); + h.state('en'); +}); + +test('timed-out startup cannot overwrite a newer default selection', async (ctx) => { + const h = await setup(ctx, 'en'); + let timeout; + ctx.mock.method(globalThis, 'setTimeout', (fn) => { timeout = fn; return 0; }); + const boot = h.initLang(); + h.finishIndex(); + await turn(); + timeout(); + await boot; + await h.switchLang('zh'); + h.finish('en'); + await turn(); + h.state('zh'); +}); From 063408e47b69335735a8532c6b44bf22f6edaca3 Mon Sep 17 00:00:00 2001 From: siruimei07 Date: Thu, 8 Oct 2026 18:42:23 +0800 Subject: [PATCH 03/10] feedback7 G-TOOLS: build-data --refresh fails instead of falling back to an old cache (PR #350 by @siruimei07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied as-is. When --refresh is set, a download that still fails after four attempts throws instead of returning the older cached file, so one build cannot mix a new table with an old one. --offline and a warm cache without --refresh are unchanged. Files already written to the cache stay; generated data/ and the previous report are left in place. Tests: node --test test/build-data-refresh.test.js — 11 pass, 0 fail. --- docs/DATA.md | 9 +- test/build-data-refresh.test.js | 153 ++++++++++++++++++++++++++++++++ tools/build-data.mjs | 5 +- 3 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 test/build-data-refresh.test.js diff --git a/docs/DATA.md b/docs/DATA.md index abcc2b76..04c1621d 100644 --- a/docs/DATA.md +++ b/docs/DATA.md @@ -12,7 +12,7 @@ downloads failed on this machine unless `--allow-shrink` (or `--prune`) is passe ``` node tools/build-data.mjs # build (downloads missing official files into .cache/gamedata/) node tools/build-data.mjs --offline # never download; fail if a cached file is missing -node tools/build-data.mjs --refresh # re-download every official file +node tools/build-data.mjs --refresh # re-download every official file; fail if any download fails node tools/build-data.mjs --no-research --out /tmp/x # research-free build (fallback defaults), other dir node tools/build-data.mjs --cache --report # other cache dir / report file (tests, CI) node tools/build-data.mjs --force # write data/ even when integrity checks fail (debugging only) @@ -23,8 +23,11 @@ Unknown options or a missing option value are errors (exit code 2); `--refresh` - **Cache.** Official files live under `.cache/gamedata/` (`excel/activity_table.json`, `levels/enemydata/enemy_database.json`, `levels/activities/act1autochess/level_*.json`, …) and are fetched from `https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/` when missing. - Downloads are validated (must parse as JSON) and written atomically. -- **Report.** Every run writes `.cache/build-data-report.json` (counts, byte sizes, warnings (each once), errors, `written`). + Downloads are validated (must parse as JSON) and written atomically. With `--refresh`, a download that still fails + after four attempts aborts the build (exit code 1), even if an older cached file exists. Generated files and the + previous report remain untouched, including with `--force`; successfully downloaded cache entries are not rolled + back. Retry `--refresh` to complete the refresh; cached files are not verified to share an upstream revision. +- **Report.** A run that reaches integrity validation writes `.cache/build-data-report.json` (counts, byte sizes, warnings (each once), errors, `written`). Integrity errors (see §17) make the exit code 1 **and leave the previous output untouched** (unless `--force`); warnings never do. Each output file is written atomically (temp file + rename). - **Determinism.** Same inputs ⇒ byte-identical outputs (stable key order, no timestamps, no randomness). diff --git a/test/build-data-refresh.test.js b/test/build-data-refresh.test.js new file mode 100644 index 00000000..3556beb1 --- /dev/null +++ b/test/build-data-refresh.test.js @@ -0,0 +1,153 @@ +// Issue #344: a failed --refresh must not feed an unverified stale table into the build. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const SCRIPT = fileURLToPath(new URL('../tools/build-data.mjs', import.meta.url)); +const source = await readFile(SCRIPT, 'utf8'); +// Exercise the real downloader without running the CLI or needing full official game tables. +const begin = source.indexOf('async function ensureGamedata('); +const end = source.indexOf('/** Load (and cache in memory)', begin); +assert.ok(begin >= 0 && end > begin, 'locate the official-data downloader'); +const downloaderSource = source.slice(begin, end); +const TABLES = ['excel/character_table.json', 'excel/skill_table.json']; +const OLD = '{"revision":"old"}'; +const NEW = '{"revision":"new"}'; + +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), 'sp-data-refresh-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cache = join(dir, 'cache'); + await mkdir(join(cache, 'excel'), { recursive: true }); + for (const rel of TABLES) await writeFile(join(cache, rel), OLD); + return { dir, cache }; +} + +function downloader(cache, fetchImpl, opts = { refresh: true }) { + const requests = [], warnings = []; + const env = { + CACHE_DIR: cache, OPTS: { refresh: false, offline: false, ...opts }, + GAMEDATA_URL: 'https://example.invalid/gamedata/', + existsSync, mkdir, dirname, join, writeFile, rename, process, AbortSignal, + log: () => {}, warn: (message) => warnings.push(message), + // Skip only retry delays; all attempts, JSON validation and file writes are real. + setTimeout: (callback) => callback(), + fetch: async (url) => { requests.push(url); return fetchImpl(url); }, + }; + const ensure = new Function(...Object.keys(env), `${downloaderSource}\nreturn ensureGamedata;`)(...Object.values(env)); + return { ensure, requests, warnings }; +} + +test('refresh rejects mixed fresh/stale inputs after one table exhausts its retries', async (t) => { + const { cache } = await fixture(t); + const { ensure, requests, warnings } = downloader(cache, async (url) => { + if (url.endsWith('skill_table.json')) throw new Error('simulated outage'); + return new Response(NEW); + }); + const results = await Promise.allSettled(TABLES.map(ensure)); + assert.equal(results[0].status, 'fulfilled'); + assert.equal(results[1].status, 'rejected', 'an old cache cannot satisfy a refresh'); + assert.match(results[1].reason.message, /cannot obtain excel\/skill_table.json: simulated outage/); + assert.equal(requests.filter((url) => url.endsWith('skill_table.json')).length, 4); + assert.equal(await readFile(join(cache, TABLES[0]), 'utf8'), NEW); + assert.equal(await readFile(join(cache, TABLES[1]), 'utf8'), OLD); + assert.deepEqual(warnings, [], 'download failure is fatal, not a stale-cache warning'); +}); + +for (const [name, response] of [ + ['HTTP failure', () => new Response('', { status: 503 })], + ['invalid JSON', () => new Response('{truncated')], +]) { + test(`refresh rejects ${name} even with an existing cache`, async (t) => { + const { cache } = await fixture(t); + const { ensure, requests } = downloader(cache, response); + await assert.rejects(ensure(TABLES[1]), /cannot obtain excel\/skill_table.json:/); + assert.equal(requests.length, 4); + assert.equal(await readFile(join(cache, TABLES[1]), 'utf8'), OLD); + }); +} + +test('refresh without a cached table reports the download failure', async (t) => { + const { cache } = await fixture(t); + await rm(join(cache, TABLES[1])); + const { ensure, requests } = downloader(cache, () => { throw new Error('simulated outage'); }); + await assert.rejects(ensure(TABLES[1]), /cannot obtain excel\/skill_table.json: simulated outage/); + assert.equal(requests.length, 4); + assert.equal(existsSync(join(cache, TABLES[1])), false); +}); + +test('successful refresh replaces every cached table', async (t) => { + const { cache } = await fixture(t); + const { ensure, requests, warnings } = downloader(cache, () => new Response(NEW)); + const files = await Promise.all(TABLES.map(ensure)); + assert.deepEqual(await Promise.all(files.map((file) => readFile(file, 'utf8'))), [NEW, NEW]); + assert.equal(requests.length, 2); + assert.deepEqual(warnings, []); +}); + +test('refresh can recover on the final retry', async (t) => { + const { cache } = await fixture(t); + let attempts = 0; + const { ensure, warnings } = downloader(cache, () => { + if (++attempts < 4) throw new Error('temporary outage'); + return new Response(NEW); + }); + assert.equal(await readFile(await ensure(TABLES[1]), 'utf8'), NEW); + assert.equal(attempts, 4); + assert.deepEqual(warnings, []); +}); + +for (const offline of [false, true]) { + test(`${offline ? 'offline' : 'normal'} mode reuses cached tables without a request`, async (t) => { + const { cache } = await fixture(t); + const { ensure, requests } = downloader(cache, () => { throw new Error('unexpected request'); }, { offline }); + assert.equal(await readFile(await ensure(TABLES[1]), 'utf8'), OLD); + assert.deepEqual(requests, []); + }); +} + +test('offline mode rejects a missing table without a request', async (t) => { + const { cache } = await fixture(t); + await rm(join(cache, TABLES[1])); + const { ensure, requests } = downloader(cache, () => { throw new Error('unexpected request'); }, { offline: true }); + await assert.rejects(ensure(TABLES[1]), /missing cached file excel\/skill_table.json \(offline mode\)/); + assert.deepEqual(requests, []); +}); + +for (const force of [false, true]) { + test(`CLI refresh failure leaves generated files untouched${force ? ' even with --force' : ''}`, async (t) => { + const { dir, cache } = await fixture(t); + const out = join(dir, 'data'); + const report = join(dir, 'report.json'); + await mkdir(out); + for (const name of ['chess.json', 'config.json']) await writeFile(join(out, name), OLD); + await writeFile(report, OLD); + const preload = join(dir, 'mock-fetch.mjs'); + await writeFile(preload, ` + globalThis.fetch = async (url) => { + if (url.endsWith('/skill_table.json')) throw new Error('simulated outage'); + return new Response(${JSON.stringify(NEW)}); + }; + `); + const result = spawnSync(process.execPath, [ + '--import', pathToFileURL(preload).href, SCRIPT, + '--refresh', '--cache', cache, '--out', out, '--report', report, + ...(force ? ['--force'] : []), + ], { encoding: 'utf8', timeout: 30_000 }); + assert.equal(result.error, undefined); + assert.equal(result.status, 1, result.stderr); + // A schema error from these tiny fixtures would also exit 1: require the actual download error. + assert.match(result.stderr, /cannot obtain excel\/skill_table.json: simulated outage/); + assert.doesNotMatch(result.stdout + result.stderr, /using stale cache|building…/); + assert.equal(await readFile(join(cache, TABLES[0]), 'utf8'), NEW, 'another download succeeded'); + assert.equal(await readFile(join(cache, TABLES[1]), 'utf8'), OLD); + assert.deepEqual((await readdir(out)).sort(), ['chess.json', 'config.json']); + for (const name of ['chess.json', 'config.json']) assert.equal(await readFile(join(out, name), 'utf8'), OLD); + assert.equal(await readFile(report, 'utf8'), OLD); + }); +} diff --git a/tools/build-data.mjs b/tools/build-data.mjs index b7be4107..4d4a14ff 100644 --- a/tools/build-data.mjs +++ b/tools/build-data.mjs @@ -8,7 +8,7 @@ // // Usage: node tools/build-data.mjs [--refresh | --offline] [--out ] [--cache ] // [--report ] [--quiet] [--no-research] [--force] -// --refresh re-download every official file even if cached +// --refresh re-download every official file; fail on download errors even if cached // --offline never download; fail when a file is missing from the cache // --out output directory (default: /data) // --cache official-data cache directory (default: /.cache/gamedata) @@ -127,7 +127,8 @@ async function ensureGamedata(rel) { if (attempt < 4) await new Promise((r) => setTimeout(r, 500 * attempt)); } } - if (existsSync(abs)) { warn(`download failed for ${rel}, using stale cache: ${lastErr.message}`); return abs; } + // A refresh cannot mix new downloads with an unverified cache from an earlier run. + if (!OPTS.refresh && existsSync(abs)) { warn(`download failed for ${rel}, using stale cache: ${lastErr.message}`); return abs; } throw new Error(`cannot obtain ${rel}: ${lastErr && lastErr.message}`); } From 8597b66df354a625a73890d80098ac6a438313c4 Mon Sep 17 00:00:00 2001 From: siruimei07 Date: Thu, 8 Oct 2026 18:42:46 +0800 Subject: [PATCH 04/10] feedback7 G-TOOLS: the boot build tag includes sim, shared and data changes (PR #351 by @siruimei07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied as-is. BUILD_INPUTS now covers server/sim, shared and data as well as the page, so a restart after a simulation-only or data-only change is visible to an already-open page. The tag is still computed once per process. A page still waits until the match, including settlement, is over before it reloads, and a failed check still does not reload. Tests: node --test test/build.test.js test/ui/build-runtime.browser.test.js — 8 pass, 1 skipped (the Chrome restart test; SP_E2E unset), 0 fail. --- server/http/buildTag.js | 9 +- test/build.test.js | 66 ++++++--- test/ui/build-runtime.browser.test.js | 197 ++++++++++++++++++++++++++ 3 files changed, 251 insertions(+), 21 deletions(-) create mode 100644 test/ui/build-runtime.browser.test.js diff --git a/server/http/buildTag.js b/server/http/buildTag.js index ebff88c0..ec693893 100644 --- a/server/http/buildTag.js +++ b/server/http/buildTag.js @@ -15,11 +15,12 @@ import { ROOT } from './config.js'; * without this signal a deployed fix could never reach a player who does not reload — a client-only battle fix * shipped exactly that way and stayed invisible on a page that had been opened before the deploy. * - * `server/`, `data/` and `shared/` are deliberately NOT in here: this process read them once at startup, so when they - * change without a restart the server still runs the old simulation and data — a page that reloaded into the new files - * would be out of step with the server that validates its battles (and DEPLOY.md restarts the server for every update). + * The browser also imports `/sim/` from `server/sim/` and `/shared/`, and caches JSON from `/data/`. Include those + * trees so a simulation-only or data-only deploy reaches old pages after the server restarts. `buildTag()` caches + * this fingerprint for the process lifetime: files changing on disk do not advertise a new build before restart. + * The rest of `server/` and downloaded media are not included. */ -export const BUILD_INPUTS = Object.freeze(['public/index.html', 'public/js', 'public/css']); +export const BUILD_INPUTS = Object.freeze(['public/index.html', 'public/js', 'public/css', 'server/sim', 'shared', 'data']); /** Names the static server never serves: dot files (`.DS_Store`, `.main.js.swp`) and editor backups (`main.js~`). */ const isIgnoredBuildName = (name) => name.startsWith('.') || name.endsWith('~'); diff --git a/test/build.test.js b/test/build.test.js index a3979c2f..44d1a82b 100644 --- a/test/build.test.js +++ b/test/build.test.js @@ -6,26 +6,56 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { computeBuildTag, buildTag, resetBuildTag, BUILD_INPUTS } from '../server/index.js'; +import { computeBuildTag, buildTag, resetBuildTag } from '../server/index.js'; +import { checkBuildOnce } from '../public/js/ui/buildGuard.js'; -/** A throwaway root with the browser runtime layout (one file per BUILD_INPUTS entry). */ +/** A throwaway browser runtime, independent of the build tag's input list. */ function fixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-build-')); - for (const rel of BUILD_INPUTS) { + for (const rel of [ + 'public/index.html', 'public/js/file.js', 'public/css/file.css', + 'server/sim/content/kits/skill.js', 'shared/protocol.js', 'data/chess.json', + ]) { const abs = path.join(root, rel); - if (path.extname(rel)) { - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, 'x'); - } else { - fs.mkdirSync(abs, { recursive: true }); - fs.writeFileSync(path.join(abs, 'file.js'), 'x'); - } + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, 'x'); } return root; } -test('BUILD_INPUTS: only the runtime the browser loads (server/, data/ and shared/ are not part of it)', () => { - assert.deepEqual([...BUILD_INPUTS], ['public/index.html', 'public/js', 'public/css']); +for (const rel of ['server/sim/content/kits/skill.js', 'shared/protocol.js', 'data/chess.json']) { + test(`buildTag: an update to ${rel} is visible to an old page after restart only`, async () => { + const root = fixture(); + try { + resetBuildTag(); + const known = buildTag(root); + const fetchFn = async () => ({ ok: true, json: async () => ({ build: buildTag(root) }) }); + fs.writeFileSync(path.join(root, rel), 'updated runtime'); + assert.notEqual(computeBuildTag(root), known, 'the updated file changes the startup fingerprint'); + assert.equal(buildTag(root), known, 'the running process keeps its startup tag'); + assert.equal((await checkBuildOnce({ known, fetchFn })).status, 'current'); + resetBuildTag(); // The next process computes its tag from the deployed files. + assert.equal((await checkBuildOnce({ known, fetchFn })).status, 'new'); + } finally { + resetBuildTag(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +} + +test('computeBuildTag: server-only code and downloaded media do not change the browser build', () => { + const root = fixture(); + try { + const known = computeBuildTag(root); + for (const rel of ['server/index.js', 'server/http/routes.js', 'server/data.js', 'public/assets/sprite.png']) { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, 'changed'); + } + assert.equal(computeBuildTag(root), known); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } }); test('computeBuildTag: stable for one tree, different when a runtime file changes (size or mtime)', () => { @@ -39,7 +69,7 @@ test('computeBuildTag: stable for one tree, different when a runtime file change const b = computeBuildTag(root); assert.notEqual(b, a, 'a changed runtime file is a new build'); // …and so is a rewrite with the same size but a new mtime (a deploy of identical bytes keeps its timestamp) - const target = path.join(root, 'public/css/file.js'); + const target = path.join(root, 'public/css/file.css'); const st = fs.statSync(target); fs.utimesSync(target, st.atime, new Date(st.mtimeMs + 5000)); assert.notEqual(computeBuildTag(root), b); @@ -52,10 +82,12 @@ test('computeBuildTag: dot files and editor backups are ignored (the static serv const root = fixture(); try { const a = computeBuildTag(root); - fs.writeFileSync(path.join(root, 'public/js/.DS_Store'), 'x'); - fs.writeFileSync(path.join(root, 'public/css/main.css~'), 'x'); - fs.mkdirSync(path.join(root, 'public/js/.cache'), { recursive: true }); - fs.writeFileSync(path.join(root, 'public/js/.cache/leftover.js'), 'x'); + for (const rel of ['public/js', 'server/sim', 'shared', 'data']) { + fs.writeFileSync(path.join(root, rel, '.DS_Store'), 'x'); + fs.writeFileSync(path.join(root, rel, 'file.js~'), 'x'); + fs.mkdirSync(path.join(root, rel, '.cache'), { recursive: true }); + fs.writeFileSync(path.join(root, rel, '.cache/leftover.js'), 'x'); + } assert.equal(computeBuildTag(root), a, 'a .DS_Store / backup / dot directory in the tree is not a new build'); } finally { fs.rmSync(root, { recursive: true, force: true }); diff --git a/test/ui/build-runtime.browser.test.js b/test/ui/build-runtime.browser.test.js new file mode 100644 index 00000000..ec038991 --- /dev/null +++ b/test/ui/build-runtime.browser.test.js @@ -0,0 +1,197 @@ +// Issue #343: a page keeps the browser simulator in its module map across a server restart. +// Exercise the real server, WebSocket client, simulator loader, route selector and build guard in Chrome. +// Only a throwaway install changes; no game assets or tracked runtime files are modified. +// Run: SP_E2E=1 node --test test/ui/build-runtime.browser.test.js (CHROME_PATH overrides system Chrome). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const enabled = process.env.SP_E2E === '1' && fs.existsSync(CHROME); + +const PAGE = `Build runtime regression +

Build runtime regression

Loading simulator

+ +`; + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-build-browser-')); + for (const rel of ['server', 'shared', 'data', 'public/js', 'public/css', 'public/index.html', + 'public/vendor/hooks.module.js', 'public/vendor/preact.module.js', 'package.json']) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.cpSync(path.join(ROOT, rel), path.join(root, rel), { recursive: true }); + } + fs.symlinkSync(path.join(ROOT, 'node_modules'), path.join(root, 'node_modules'), 'junction'); + fs.mkdirSync(path.join(root, 'public/dev'), { recursive: true }); + fs.writeFileSync(path.join(root, 'public/dev/build-runtime.html'), PAGE); + fs.writeFileSync(path.join(root, 'start.mjs'), ` +import { startServer } from './server/index.js'; +const srv = await startServer({ port: Number(process.env.PORT), host: '127.0.0.1', quiet: true }); +process.send({ port: srv.port }); +process.on('SIGTERM', async () => { await srv.close(); process.exit(0); }); +`); + return root; +} + +async function start(root, port = 0) { + const child = fork(path.join(root, 'start.mjs'), [], { + cwd: root, env: { ...process.env, PORT: String(port) }, silent: true, + }); + let stderr = ''; + child.stderr.on('data', (buf) => { stderr += buf; }); + try { + const [ready] = await once(child, 'message', { signal: AbortSignal.timeout(15000) }); + return { port: ready.port, stop: async () => { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, 'exit'); + child.kill('SIGTERM'); + await exited; + } }; + } catch (error) { + child.kill('SIGKILL'); + throw new Error(`Fixture server failed to start: ${stderr}`, { cause: error }); + } +} + +test('an old simulator page reconnects safely, then reloads before the next match after a runtime-only deploy', { + skip: enabled ? false : 'set SP_E2E=1 (needs Chrome)', timeout: 120000, +}, async () => { + const root = fixture(); + let server, browser; + try { + server = await start(root); + const port = server.port; + const puppeteer = (await import('puppeteer-core')).default; + browser = await puppeteer.launch({ executablePath: CHROME, headless: true, protocolTimeout: 30000, + args: ['--no-first-run', '--no-sandbox'] }); + const page = await browser.newPage(); + const problems = []; + let navigations = 0, simRequests = 0; + page.on('pageerror', (error) => problems.push(error.message)); + page.on('console', (message) => { + // The two deliberate server outages produce browser transport errors, not application errors. + if (message.type() === 'error' && !/net::ERR_CONNECTION_REFUSED|WebSocket connection to .* failed/.test(message.text())) { + problems.push(message.text()); + } + }); + page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) navigations++; }); + page.on('request', (request) => { if (new URL(request.url()).pathname === '/sim/rng.js') simRequests++; }); + await page.goto(`http://127.0.0.1:${port}/dev/build-runtime.html`); + const ready = async () => { + try { await page.waitForFunction(() => window.fixture?.guard.known() && window.fixture.net.status === 'online'); } + catch (error) { throw new Error(`Fixture page failed: ${JSON.stringify(problems)}`, { cause: error }); } + }; + const state = () => page.evaluate(() => window.fixture.state()); + const check = () => page.evaluate(() => window.fixture.guard.check()); + await ready(); + assert.equal(await page.title(), 'Build runtime regression'); + await page.click('#battle'); + const old = await state(); + assert.equal(old.route, 'game'); + assert.equal(simRequests, 1, 'the first match imported the actual simulator'); + + await server.stop(); + await page.waitForFunction(() => window.fixture.net.status === 'reconnecting'); + assert.equal((await check()).status, 'unknown', 'a stopped server never causes a mid-match reload'); + await page.click('#lobby'); + assert.equal((await check()).status, 'unknown', 'a stopped server never causes an outside-match reload either'); + assert.equal(navigations, 1); + await page.click('#battle'); + + // Change an actual simulator dependency, retaining its size to exercise the mtime portion of the build tag. + const rngPath = path.join(root, 'server/sim/rng.js'); + const source = fs.readFileSync(rngPath, 'utf8'); + assert.match(source, /0x6d2b79f5/); + const stat = fs.statSync(rngPath); + fs.writeFileSync(rngPath, source.replace('0x6d2b79f5', '0x6d2b79f4')); + fs.utimesSync(rngPath, stat.atime, new Date(stat.mtimeMs + 5000)); + const { createRng } = await import(pathToFileURL(rngPath).href); + const freshFingerprint = createRng(91)(); + assert.notEqual(freshFingerprint, old.fingerprint); + + server = await start(root, port); + await ready(); + assert.equal((await state()).boots, 1, 'the original page actually reconnected'); + assert.equal((await state()).fingerprint, old.fingerprint, 'the already loaded simulator still has old code'); + assert.equal(simRequests, 1, 'reconnect does not refetch an imported ES module'); + assert.equal((await check()).status, 'new', 'the restarted server advertises the simulator-only deploy'); + assert.equal((await state()).stale, false, 'one new tag is not confirmation'); + assert.equal((await check()).status, 'stale'); + assert.equal((await state()).stale, true, 'confirmed update is exposed to the in-match banner'); + assert.equal(navigations, 1, 'a confirmed update never interrupts combat'); + await page.click('#result'); + assert.equal((await check()).status, 'stale'); + assert.equal(navigations, 1, 'settlement also remains on screen'); + + // Even an already-confirmed update must wait while the server is unavailable. + await server.stop(); + await page.click('#lobby'); + assert.equal((await check()).status, 'unknown'); + assert.equal(navigations, 1); + server = await start(root, port); + await ready(); + assert.equal((await check()).status, 'new', 'the failed check resets consecutive confirmations'); + assert.equal(navigations, 1, 'one successful check after downtime is insufficient'); + await Promise.all([ + page.waitForNavigation(), + page.evaluate(() => { window.fixture.guard.check(); }), + ]); + await ready(); + assert.equal(navigations, 2, 'the confirmed healthy build reloads exactly once in the lobby'); + assert.equal((await state()).boots, 2); + assert.notEqual((await state()).known, old.known); + await page.click('#battle'); + assert.equal((await state()).fingerprint, freshFingerprint, 'the next battle uses the updated simulator'); + assert.equal(simRequests, 2, 'the real reload creates a fresh module map'); + assert.equal((await check()).status, 'current', 'the refreshed page does not enter a reload loop'); + assert.equal(navigations, 2); + assert.deepEqual(problems, []); + if (process.env.SP_BUILD_SCREENSHOT) await page.screenshot({ path: process.env.SP_BUILD_SCREENSHOT }); + } finally { + await browser?.close(); + await server?.stop(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); From a2a3f524187cb0ce39a286da5926d006fed787bf Mon Sep 17 00:00:00 2001 From: siruimei07 Date: Thu, 8 Oct 2026 18:47:53 +0800 Subject: [PATCH 05/10] feedback7 G-TOOLS: SP_VERIFY re-runs slice work in the existing 8 ms budget instead of blocking the event loop (PR #352 by @siruimei07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied as-is. All-mode verification runs the existing HeadlessJob in headlessSliceMs slices (8 ms on a real scheduler; synchronous when that value is not finite, including the virtual scheduler). The field still settles with the server digest. Sample mode still accepts the client result immediately and logs afterwards. A comparison error in all mode falls back to the server result. Dispose cancels the pending slice timer. Tests: node --test test/match/verification-slices.test.js — 11 pass, 0 fail; node --test test/match/clientCombat.test.js test/match/clientCombat-review.test.js — 39 pass, 0 fail. --- server/match/Match.js | 4 +- server/match/match/clientCombat.js | 8 +- server/match/match/reports.js | 88 ++++++--- test/match/verification-slices.test.js | 249 +++++++++++++++++++++++++ 4 files changed, 317 insertions(+), 32 deletions(-) create mode 100644 test/match/verification-slices.test.js diff --git a/server/match/Match.js b/server/match/Match.js index b95e5bb4..65f40713 100644 --- a/server/match/Match.js +++ b/server/match/Match.js @@ -106,7 +106,7 @@ // (g.bandFocus → timeoutBand); g.unitStats answers m.unitStats: the stats the board's units start their next battle with. // opts.clientCombat default true (env SP_COMBAT=server → false: the legacy server-run + snapshot streaming mode) // opts.verify 'off' | 'sample' | 'all' (env SP_VERIFY, default 'off'): re-simulate accepted client results -// ('sample': ~1 in 8, in a later callback, mismatches logged; 'all': before accepting — the +// ('sample': ~1 in 8, in later slices, mismatches logged; 'all': before accepting — the // server's result wins on a mismatch) // // Engine-only extra options (tests / tools; the lobby never passes them): @@ -121,7 +121,7 @@ // opts.botSliceMs wall-clock ms of rehearsal per scheduler callback (default 8 with a real scheduler, unbounded // with a virtual one); the rest runs in later callbacks (scheduleBotPrep) // opts.headlessSliceMs wall-clock ms per callback of a server-run normal / 联防 field (client-side combat: bots, -// takeovers; default 8 with a real scheduler, at once with a virtual one) +// takeovers, result verification; default 8 with a real scheduler, at once with a virtual one) // Seats may be all bots (tools/matchrun.mjs); the lobby always has ≥ 1 human. // // Diagnostics: m.errors / m.errorCount (engine), m.dispatcher.errors / .errorsByKey (meta handlers), m.simErrors diff --git a/server/match/match/clientCombat.js b/server/match/match/clientCombat.js index 3b78229d..a62b9004 100644 --- a/server/match/match/clientCombat.js +++ b/server/match/match/clientCombat.js @@ -24,8 +24,9 @@ export class MatchClientCombat { _clearFieldTimers(f) { if (!f || !f.cc) return; - for (const k of ['deadlineTimer', 'doneTimer', 'waitTimer', 'sliceTimer']) if (f[k]) { this.cancel(f[k]); f[k] = null; } + for (const k of ['deadlineTimer', 'doneTimer', 'waitTimer', 'sliceTimer', 'verifyTimer']) if (f[k]) { this.cancel(f[k]); f[k] = null; } f.job = null; + f.verifyJob = null; } /** A client-combat field record: the JSON BattleSpec of its Battle options plus the authority / result state. */ @@ -84,7 +85,7 @@ export class MatchClientCombat { return { t: 'b.start', battleId: f.battleId, fieldId: f.fieldId, kind: f.kind, spec: this.spectators.has(pid) ? this._spectatorSpec(f) : f.spec, - authoritative: !!(!f.done && f.mode === 'client' && f.authority === pid && !watch), + authoritative: !!(!f.done && !f.verifyJob && f.mode === 'client' && f.authority === pid && !watch), startAt: f.startAt, serverNow: this.sched.now(), elapsed: Math.round(this._fieldElapsed(f) * 1000) / 1000, speed: this.gameSpeed, watch: !!watch, done: !!f.done, }; @@ -121,6 +122,7 @@ export class MatchClientCombat { /** A client field's result deadline: its time limit on the field clock + RESULT_GRACE_MS (then the server takes over). */ _armDeadline(f) { if (f.deadlineTimer) { this.cancel(f.deadlineTimer); f.deadlineTimer = null; } + if (f.verifyJob) return; const lim = f.spec.timeLimit > 0 ? f.spec.timeLimit : 60; const at = f.startAt + Math.round((lim / this.gameSpeed) * 1000) + RESULT_GRACE_MS; f.deadlineTimer = this.later(Math.max(0, at - this.sched.now()), () => { @@ -225,7 +227,7 @@ export class MatchClientCombat { /** An authoritative human disconnected / left: normal & 联防 fields → server takeover; boss → the partner or the server. */ _authorityLost(ps, why) { for (const f of this.fields) { - if (!f.cc || f.done || f.mode !== 'client' || f.authority !== ps.playerId) continue; + if (!f.cc || f.done || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) continue; if (f.kind === 'boss' || f.kind === 'hidden') this._bossHandover(f, why); else this._runOnServer(f, why); } diff --git a/server/match/match/reports.js b/server/match/match/reports.js index 1ee18482..065ede77 100644 --- a/server/match/match/reports.js +++ b/server/match/match/reports.js @@ -5,7 +5,7 @@ // Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). import { ERR } from '../../../shared/constants.js'; -import { runHeadless, syntheticResult, validateClientResult, HARD_CAP_SECONDS } from '../fields.js'; +import { HeadlessJob, syntheticResult, validateClientResult, HARD_CAP_SECONDS } from '../fields.js'; import { resultDigest, compactResult as compactForVerify } from '../../sim/spec.js'; import { OK, fail } from './common.js'; @@ -18,7 +18,7 @@ export class MatchReports { _onProgress(ps, msg) { if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat'); const f = this._fieldByBattle(msg.battleId); - if (!f || f.done || f.mode !== 'client' || f.authority !== ps.playerId) return OK; + if (!f || f.done || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) return OK; const p = f.progress; const maxTotal = Math.max(p.total, (f.spec.spawns.length + 1) * 400); p.gt = Math.max(p.gt, Math.min(Number(msg.gt) || 0, HARD_CAP_SECONDS)); @@ -46,7 +46,7 @@ export class MatchReports { _onResult(ps, msg) { if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat'); const f = this._fieldByBattle(msg.battleId); - if (!f || f.done || f.heldResult || f.mode !== 'client' || f.authority !== ps.playerId) return OK; + if (!f || f.done || f.heldResult || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) return OK; const bossLike = f.kind === 'boss' || f.kind === 'hidden'; const v = validateClientResult(f.spec, msg.result, { gd: this.gd }); if (!v.ok) { @@ -57,7 +57,7 @@ export class MatchReports { else this._runOnServer(f, 'invalid'); return OK; } - let result = v.result; + const result = v.result; if (bossLike) { // the final b.progress normally carried everything; the result's per-player damage is a lower bound const by = {}; @@ -91,13 +91,15 @@ export class MatchReports { return OK; } this._bossResultDamage(f, result); - } else { - result = this._verifyResult(f, result); } - f.result = result; - f.resultSource = 'client'; - this._fieldDone(f); - if (bossLike) { this._checkFinalEnd(); this._broadcastPool(false); } + const accept = (verified) => { + f.result = verified; + f.resultSource = 'client'; + this._fieldDone(f); + if (bossLike) { this._checkFinalEnd(); this._broadcastPool(false); } + }; + if (bossLike) accept(result); + else this._verifyResult(f, result, accept); return OK; } @@ -107,27 +109,59 @@ export class MatchReports { } /** - * SP_VERIFY: re-simulate an accepted client result ('all': now, the server's result wins on a mismatch; 'sample': - * ~1 battle in 8 in a later callback, mismatches are only logged). + * SP_VERIFY: re-simulate in headless slices. 'all' waits before accepting (the server wins on a mismatch); + * 'sample' accepts immediately and only logs differences for ~1 battle in 8, even after the field's phase ends. */ - _verifyResult(f, result) { - if (this.verifyMode === 'off') return result; - const check = () => { - const run = runHeadless(this._specBattle(f.spec), { players: f.players }); - const mine = validateClientResult(f.spec, compactForVerify(run.result), { gd: this.gd }); - const server = mine.ok ? mine.result : run.result; - this.verifyStats.checked++; - if (resultDigest(server).hash !== resultDigest(result).hash) { - this.verifyStats.mismatches++; - this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: client result differs from the server's simulation`); - return server; + _verifyResult(f, result, accept) { + if (this.verifyMode === 'off') { accept(result); return; } + const required = this.verifyMode === 'all'; + const start = () => { + const job = new HeadlessJob(this._specBattle(f.spec), { players: f.players, onError: (e) => this.reportError('verify', e) }); + if (required) { + // The result has arrived: duplicate reports, disconnects and the old deadline must not start another run. + f.verifyJob = job; + this.cancel(f.deadlineTimer); + f.deadlineTimer = null; + f.rearmDeadline = false; } - return null; + const schedule = () => { + const timer = this.later(0, slice); + if (required) f.verifyTimer = timer; + }; + const slice = () => { + if (required) f.verifyTimer = null; + if (this.disposed || this.ended) return; + if (required && (f.verifyJob !== job || f.done || f.mode !== 'client' || !this.fields.includes(f))) return; + let verified = result; + try { + if (!job.run(this.headlessSliceMs)) { schedule(); return; } + const run = job.output(); + const mine = validateClientResult(f.spec, compactForVerify(run.result), { gd: this.gd }); + const server = mine.ok ? mine.result : run.result; + this.verifyStats.checked++; + if (resultDigest(server).hash !== resultDigest(result).hash) { + this.verifyStats.mismatches++; + this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: client result differs from the server's simulation`); + verified = server; + } + } catch (e) { + this.reportError('verify', e); + if (required) { + this._clearFieldTimers(f); + this._runOnServer(f, 'verify-error'); + } + return; + } + if (required) { f.verifyJob = null; accept(verified); } + }; + if (Number.isFinite(this.headlessSliceMs)) schedule(); + else slice(); // Virtual schedulers retain their synchronous fast path. }; - if (this.verifyMode === 'all') return check() || result; + if (required) { start(); return; } let h = 0; for (let i = 0; i < f.battleId.length; i++) h = (h * 31 + f.battleId.charCodeAt(i)) >>> 0; - if (h % 8 === 0) this.later(0, () => { try { check(); } catch (e) { this.reportError('verify', e); } }); - return result; + // Sample jobs use only match-level timers: field completion must not cancel the diagnostic work. + if (h % 8 === 0) this.later(0, start); + accept(result); } } diff --git a/test/match/verification-slices.test.js b/test/match/verification-slices.test.js new file mode 100644 index 00000000..8939bdf5 --- /dev/null +++ b/test/match/verification-slices.test.js @@ -0,0 +1,249 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setTimeout as pause } from 'node:timers/promises'; +import { PHASE } from '../../shared/constants.js'; +import { runHeadless, validateClientResult, RESULT_GRACE_MS } from '../../server/match/fields.js'; +import { RealScheduler } from '../../server/match/scheduler.js'; +import { compactResult, createBattleFromSpec, resultDigest } from '../../server/sim/spec.js'; +import { makeMatch } from './harness.js'; + +const SLICE_MS = 0.000001; + +function combat(t, verify = 'all', options = {}) { + const h = makeMatch({ mode: 'coop', humans: 1, bots: 0, seed: 345, clientCombat: true, + clients: false, verify, headlessSliceMs: SLICE_MS, timerScale: 0, ...options }); + t.after(() => h.m.dispose()); + h.start(); + assert.ok(h.drive(() => h.m.phase === PHASE.COMBAT)); + return h; +} + +function results(h, f) { + const run = runHeadless(createBattleFromSpec(f.spec, h.m.ds, { recordEvents: false, quiet: true }), { players: f.players }); + const checked = validateClientResult(f.spec, compactResult(run.result), { gd: h.m.gd }); + assert.ok(checked.ok, checked.reason); + const server = checked.result; + assert.ok(server.perPlayer.p_0.leaked.length > 0, 'the empty board leaks real enemies'); + const forged = structuredClone(server); + forged.killed = forged.total; + Object.assign(forged.perPlayer.p_0, { leaked: [], perfect: true, killed: forged.perPlayer.p_0.total }); + const plausible = validateClientResult(f.spec, forged, { gd: h.m.gd }); + assert.ok(plausible.ok, plausible.reason); + return { server, forged: plausible.result }; +} + +function report(h, f, result) { + assert.deepEqual(h.m.handle('p_0', { t: 'b.result', battleId: f.battleId, result }), { ok: true }); +} + +function observeSimulation(m, onStep = () => {}) { + const seen = { battles: 0, steps: 0 }; + const create = m._specBattle.bind(m); + m._specBattle = (...args) => { + const battle = create(...args); + seen.battles++; + const step = battle.step.bind(battle); + battle.step = (...stepArgs) => { + const result = step(...stepArgs); + seen.steps++; + onStep(battle, seen); + return result; + }; + return battle; + }; + return seen; +} + +test('all verification yields between slices and settles with the synchronous server digest', (t) => { + const h = combat(t); + const m = h.m; + const f = m.fields[0]; + const { server, forged } = results(h, f); + let between = null; + const seen = observeSimulation(m, (battle, state) => { + if (state.steps === 1) h.sched.setTimeout(() => { + between = { steps: state.steps, finished: battle.finished, checked: m.verifyStats.checked, done: f.done }; + }, 0); + }); + report(h, f, forged); + assert.equal(seen.steps, 0, 'report handling schedules simulation without stepping it'); + assert.equal(f.done, false, 'the result awaits verification'); + assert.equal(m.lastResults.has('p_0'), false, 'settlement cannot consume the unverified report'); + assert.ok(h.run(() => m.verifyStats.checked === 1)); + assert.ok(between && between.steps > 0, 'an unrelated timer runs after simulation starts'); + assert.deepEqual([between.finished, between.checked, between.done], [false, 0, false], 'the timer runs between unfinished slices'); + assert.equal(resultDigest(f.result).hash, resultDigest(server).hash, 'sliced verification matches synchronous simulation'); + assert.equal(m.verifyStats.mismatches, 1); + assert.equal(m.verifyStats.rejected, 0, 'the forgery passes plausibility validation'); + h.runToPhase(PHASE.SETTLE); + assert.deepEqual(m.lastResults.get('p_0').leaked, server.perPlayer.p_0.leaked); + assert.equal(m.errorCount, 0); +}); + +for (const verify of ['all', 'sample']) { + test(`${verify} verification lets an unrelated real timer run between battle slices`, async (t) => { + const h = makeMatch({ mode: 'coop', humans: 1, bots: 0, seed: 345, clientCombat: true, + clients: false, verify, headlessSliceMs: SLICE_MS, timerScale: 0 }); + const m = h.m; + // Install the real scheduler before start, while the harness has no queued timers. + const scheduler = new RealScheduler(); + m.sched = scheduler; + t.after(() => { m.dispose(); scheduler.dispose(); }); + const waitFor = async (predicate) => { + const deadline = Date.now() + 10000; + while (!predicate() && Date.now() < deadline) await pause(1); + assert.ok(predicate(), `timed out in phase ${m.phase}`); + }; + h.autoHumans().start(); + await waitFor(() => m.phase === PHASE.COMBAT); + const f = m.fields[0]; + const result = compactResult(runHeadless(createBattleFromSpec(f.spec, m.ds, { recordEvents: false, quiet: true }), { players: f.players }).result); + let between = null; + observeSimulation(m, (battle, seen) => { + if (seen.steps === 1) setTimeout(() => { + between = { steps: seen.steps, finished: battle.finished, checked: m.verifyStats.checked, done: f.done }; + }, 0); + }); + report(h, f, result); + await waitFor(() => between !== null); + assert.ok(between.steps > 0, 'the timer was scheduled by the first actual battle step'); + assert.deepEqual([between.finished, between.checked, between.done], [false, 0, verify === 'sample'], 'Node executes the timer while verification is unfinished'); + await waitFor(() => m.verifyStats.checked === 1); + assert.equal(resultDigest(f.result).hash, resultDigest(result).hash); + assert.equal(m.verifyStats.mismatches, 0); + assert.equal(m.errorCount, 0); + }); +} + +test('duplicate reports during all verification start one simulation and settle once', (t) => { + const h = combat(t); + const m = h.m; + const f = m.fields[0]; + const { forged } = results(h, f); + const seen = observeSimulation(m); + let settlements = 0; + const settle = m.settle.bind(m); + m.settle = (...args) => { settlements++; return settle(...args); }; + report(h, f, forged); + assert.equal(f.done, false, 'the duplicate arrives while verification is pending'); + report(h, f, forged); + assert.ok(h.run(() => seen.steps > 0)); + report(h, f, forged); + h.runToPhase(PHASE.SETTLE); + report(h, f, forged); + assert.equal(seen.battles, 1); + assert.equal(m.verifyStats.checked, 1); + assert.equal(m.verifyStats.mismatches, 1); + assert.equal(settlements, 1); +}); + +test('sample verification keeps the accepted result and finishes after the phase changes', (t) => { + // Seed 345 selects the first field for sampling; zero phase delays let settlement overtake verification. + const h = combat(t, 'sample'); + const m = h.m; + const f = m.fields[0]; + const { forged } = results(h, f); + const seen = observeSimulation(m); + report(h, f, forged); + assert.equal(f.done, true, 'sampling accepts the report immediately'); + assert.equal(seen.steps, 0); + h.runToPhase(PHASE.SETTLE); + assert.equal(m.verifyStats.checked, 0, 'the sampled battle is still running after settlement'); + assert.ok(h.run(() => m.verifyStats.checked === 1)); + assert.equal(m.verifyStats.mismatches, 1); + assert.equal(resultDigest(f.result).hash, resultDigest(forged).hash); + assert.deepEqual(m.lastResults.get('p_0').leaked, [], 'sampling never replaces the accepted result'); + assert.ok(h.logs.warn.some((line) => line.includes('client result differs'))); + assert.equal(m.errorCount, 0); +}); + +for (const event of ['disconnect and reconnect', 'expired deadline']) { + test(`pending all verification does not restart simulation after ${event}`, (t) => { + const h = combat(t); + const m = h.m; + const f = m.fields[0]; + const { forged } = results(h, f); + const seen = observeSimulation(m); + // Leave an already-due deadline callback queued when the result arrives. + if (event === 'expired deadline') h.sched.t += f.spec.timeLimit / m.gameSpeed * 1000 + RESULT_GRACE_MS + 1; + report(h, f, forged); + assert.equal(f.done, false); + if (event === 'disconnect and reconnect') { + m.onDisconnect('p_0'); + m.onReconnect('p_0'); + assert.equal(h.lastTo('p_0', 'b.start').authoritative, false, 'reconnect does not request a second result'); + } + h.runToPhase(PHASE.SETTLE); + assert.equal(seen.battles, 1); + assert.equal(m.verifyStats.takeovers, 0, 'a received result no longer needs a client authority'); + assert.equal(m.verifyStats.checked, 1); + assert.equal(m.verifyStats.mismatches, 1); + }); +} + +test('resuming a solo pause does not rearm a result deadline while verification is pending', (t) => { + const h = combat(t, 'all', { mode: 'solo' }); + const m = h.m; + const f = m.fields[0]; + const { forged } = results(h, f); + const seen = observeSimulation(m); + assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: true }), { ok: true }); + report(h, f, forged); + assert.equal(f.done, false); + assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: false }), { ok: true }); + h.sched.t += f.spec.timeLimit / m.gameSpeed * 1000 + RESULT_GRACE_MS + 1; + h.runToPhase(PHASE.SETTLE); + assert.equal(seen.battles, 1); + assert.equal(m.verifyStats.takeovers, 0); + assert.equal(m.verifyStats.checked, 1); +}); + +test('a verification comparison error falls back to a server result and completes the field', (t) => { + const h = combat(t); + const m = h.m; + const f = m.fields[0]; + const { server, forged } = results(h, f); + const seen = observeSimulation(m); + const create = m._specBattle.bind(m); + m._specBattle = (...args) => { + const battle = create(...args); + if (seen.battles === 1) { + const result = battle.result.bind(battle); + battle.result = () => { + const out = result(); + Object.defineProperty(out.perPlayer.p_0, 'unitStats', { get() { throw new Error('verification result cannot be normalized'); } }); + return out; + }; + } + return battle; + }; + report(h, f, forged); + h.runToPhase(PHASE.SETTLE); + assert.equal(seen.battles, 2, 'the failed comparison is followed by one independent server run'); + assert.equal(f.resultSource, 'server'); + assert.equal(resultDigest(f.result).hash, resultDigest(server).hash, 'fallback never accepts the forged result'); + assert.equal(m.errorCount, 1); + assert.ok(h.logs.error.some((line) => line.includes('verification result cannot be normalized'))); + assert.deepEqual(h.sched.errors, []); +}); + +for (const verify of ['all', 'sample']) { + test(`dispose stops pending ${verify} verification without later completion`, (t) => { + const h = combat(t, verify, { timerScale: 1 }); + const m = h.m; + const f = m.fields[0]; + const { forged } = results(h, f); + const seen = observeSimulation(m); + report(h, f, forged); + assert.ok(h.run(() => seen.steps > 0)); + assert.equal(m.verifyStats.checked, 0, 'dispose interrupts an unfinished verification'); + m.dispose(); + const steps = seen.steps; + h.sched.runAll({ maxSteps: 10000 }); + assert.equal(seen.steps, steps, 'no further battle steps execute'); + assert.equal(m.verifyStats.checked, 0); + assert.equal(m.verifyStats.mismatches, 0); + assert.equal(f.done, verify === 'sample'); + assert.equal(m.errorCount, 0); + }); +} From 1f147a288fea4c479f7ee94367499cabd27efbd1 Mon Sep 17 00:00:00 2001 From: anAtheist987 Date: Thu, 8 Oct 2026 18:48:16 +0800 Subject: [PATCH 06/10] feedback7 G-TOOLS: tools/ci.mjs and npm run ci run the CI checks locally (PR #367 by @anAtheist987) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied as-is. tools/ci.mjs runs the checks already listed in .github/workflows/ci.yml, in that order, and skips checkout, setup-node and npm ci. Children are spawned with process.execPath, not a shell. test/ci.test.js keeps the step list, smoke script, job env and package.json scripts in step with the workflow. Tests: node --test test/ci.test.js — 8 pass, 0 fail. npm run ci was not run (it installs and runs the suite). --- CONTRIBUTING.md | 9 +- package.json | 1 + test/ci.test.js | 149 +++++++++++++++++++ tools/ci.mjs | 373 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 531 insertions(+), 1 deletion(-) create mode 100644 test/ci.test.js create mode 100644 tools/ci.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe73e468..1f9e694a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,11 @@ npm run golden:update # 有意改变 npm run lint && npm run check:imports && npm run typecheck # 代码检查、导入边界、类型 ``` +提交 PR 前可以一条命令跑一遍 GitHub CI(`.github/workflows/ci.yml`)的全部检查:`npm run ci`(或 `node tools/ci.mjs`)。 +依次是 setup 检查、`node --test`、服务器冒烟测试(在空闲端口启动、请求 `/healthz` 和首页、运行 doctor,然后关掉)、lint、导入边界、 +类型;第一步失败就停下并打印每步结果和耗时。`--keep-going` 失败后继续,`--only lint,test` 只跑指定步骤,`--list` 列出步骤。 +不包含 `npm ci`(依赖请自己装),也不跑 Node 版本 × 系统的矩阵。 + 浏览器测试默认不跑,需要本机 Chrome(路径不标准时设置 `CHROME_PATH`),用环境变量打开: ```bash @@ -99,7 +104,9 @@ SIM_E2E=1 node --test 'test/sim/*.browser.test.js' # 浏览器里 - **Setup**: Node.js 22 or 24; `npm install`, `npm run setup` (art and audio — optional for tests), `npm run dev`. The code map is [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). - **Tests**: run the targeted files (`node --test `, `--test-name-pattern`), then `node --test` and - `npm run golden` before a pull request; `npm run lint`, `npm run check:imports`, `npm run typecheck`. Browser suites + `npm run golden` before a pull request; `npm run lint`, `npm run check:imports`, `npm run typecheck`. + `npm run ci` (or `node tools/ci.mjs`) runs the checks of `.github/workflows/ci.yml` in one go and stops at the first + failure (`--keep-going`, `--only `, `--list`); it does not run `npm ci`. Browser suites 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. diff --git a/package.json b/package.json index efd9251e..aa34bb0f 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "lint": "eslint .", "typecheck": "tsc --noEmit --checkJs -p jsconfig.json", "check:imports": "node tools/check-imports.mjs", + "ci": "node tools/ci.mjs", "package": "node tools/package.mjs", "package:lite": "node tools/package.mjs --lite" }, diff --git a/test/ci.test.js b/test/ci.test.js new file mode 100644 index 00000000..e45379b2 --- /dev/null +++ b/test/ci.test.js @@ -0,0 +1,149 @@ +// test/ci.test.js — tools/ci.mjs (the local run of .github/workflows/ci.yml): its step list is ci.yml's, the reader of +// the workflow, the arguments, the env and the summary. Nothing here boots a server or runs a step. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + CI_TEST_ENV, ROOT, SKIPPED_CI_STEPS, SMOKE_PATHS, STEPS, binPath, formatDuration, formatSummary, isSkippedCiStep, + parseArgs, parseWorkflow, selectSteps, stepEnv, +} from '../tools/ci.mjs'; + +const workflow = parseWorkflow(fs.readFileSync(path.join(ROOT, '.github/workflows/ci.yml'), 'utf8')); +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); + +/** ci.yml's smoke run block as smoke() in tools/ci.mjs mirrors it (lines trimmed, comments dropped). */ +const SMOKE_CI_RUN = [ + 'node server/index.js > server.log 2>&1 &', + 'pid=$!', + 'ok=', + 'for i in $(seq 1 30); do', + 'if curl -fsS http://127.0.0.1:3000/healthz; then ok=1; break; fi', + 'sleep 1', + 'done', + 'echo', + '[ -n "$ok" ] || { cat server.log; exit 1; }', + 'curl -fsS -o index.out http://127.0.0.1:3000/', + "grep -qi ' { + const ciSteps = Object.entries(workflow.jobs).flatMap(([job, j]) => j.steps.map((s) => ({ job, ...s }))); + assert.ok(ciSteps.length >= 6, 'ci.yml was read'); + const unknown = ciSteps.filter((s) => !isSkippedCiStep(s) && !STEPS.some((l) => l.ci.job === s.job && l.ci.name === s.name)); + assert.deepEqual(unknown.map((s) => `${s.job}: ${s.name || s.uses}`), [], 'ci.yml steps tools/ci.mjs does not run'); + for (const l of STEPS) { + assert.ok(ciSteps.some((s) => s.job === l.ci.job && s.name === l.ci.name), `${l.id} mirrors no ci.yml step (${l.ci.job}: ${l.ci.name})`); + } + for (const k of SKIPPED_CI_STEPS) assert.ok(ciSteps.some((s) => isSkippedCiStep(s) && (k.uses ? s.uses?.startsWith(k.uses) : s.name === k.name)), `stale skip ${k.uses || k.name}`); + // the local order is the workflow's (job test, then job tooling) + const order = ciSteps.filter((s) => !isSkippedCiStep(s)).map((s) => STEPS.find((l) => l.ci.job === s.job && l.ci.name === s.name).id); + assert.deepEqual(order, STEPS.map((s) => s.id)); +}); + +test('the commands match ci.yml: the run lines, the npm scripts ci.yml calls, the smoke script, the job env, no step config', () => { + const run = (job, name) => workflow.jobs[job].steps.find((s) => s.name === name).run; + for (const l of STEPS) { + const r = run(l.ci.job, l.ci.name); + if (l.npm) { + assert.equal(r, `npm run ${l.npm}`, l.id); + assert.equal(pkg.scripts[l.npm], l.describe, `package.json script ${l.npm} is what ${l.id} runs`); + } else if (l.id !== 'smoke') assert.equal(r, l.describe, l.id); + } + const smokeRun = run('test', STEPS.find((s) => s.id === 'smoke').ci.name); + const urls = [...smokeRun.matchAll(/http:\/\/127\.0\.0\.1:3000([^\s;]*)/g)].map((m) => m[1] || '/'); + assert.deepEqual([...new Set(urls)].sort(), [...SMOKE_PATHS].sort()); + // The whole smoke script, comments and indentation aside: any edit to it (the wait loop, the page check, …) fails + // here, so that smoke() in tools/ci.mjs is looked at and this copy updated with it. + const normalized = smokeRun.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')); + assert.deepEqual(normalized, SMOKE_CI_RUN, "ci.yml's smoke step changed: update smoke() in tools/ci.mjs and SMOKE_CI_RUN"); + // job env: ci.yml's job test env is CI_TEST_ENV; any other job has none (stepEnv gives the tooling steps nothing) + for (const [job, j] of Object.entries(workflow.jobs)) { + assert.deepEqual(j.env, job === 'test' ? CI_TEST_ENV : {}, `env of job ${job}`); + } + // per-step config (env, if, continue-on-error, working-directory, with, …) on a mirrored step is not mirrored + for (const l of STEPS) { + const s = workflow.jobs[l.ci.job].steps.find((x) => x.name === l.ci.name); + assert.deepEqual(s.other || [], [], `${l.id}: ci.yml step keys tools/ci.mjs does not mirror`); + assert.ok(!s.shell || l.id === 'smoke', `${l.id}: shell: ${s.shell}`); + } + assert.equal(pkg.scripts.ci, 'node tools/ci.mjs'); +}); + +test('the workflow reader: jobs, flat env, named and uses steps, run blocks; comments and nested blocks skipped, other step keys named', () => { + const yml = [ + 'name: X', 'on:', ' push:', 'jobs:', ' a:', ' runs-on: x', ' env:', " K: '1'", ' L: "two"', ' steps:', + ' - uses: actions/checkout@v7', ' - uses: actions/setup-node@v7', ' with:', ' node-version: 22', + ' # a comment', ' - name: One', ' if: always()', ' run: echo 1', ' env:', ' X: 1', + ' - name: Block', ' shell: bash', ' run: |', + ' a', '', ' b', ' c', ' b:', ' steps:', ' - run: x', 'other: 1', ' ignored: 2', + ].join('\r\n'); + assert.deepEqual(parseWorkflow(yml), { + jobs: { + a: { env: { K: '1', L: 'two' }, steps: [ + { uses: 'actions/checkout@v7' }, { uses: 'actions/setup-node@v7', other: ['with'] }, { name: 'One', run: 'echo 1', other: ['if', 'env'] }, + { name: 'Block', shell: 'bash', run: 'a\n\n b\nc' }, + ] }, + b: { env: {}, steps: [{ run: 'x' }] }, + }, + }); + assert.deepEqual(parseWorkflow('name: no jobs\n'), { jobs: {} }); +}); + +test('arguments: --keep-going, --only (comma list, repeated, = form) in workflow order, --list, --help; bad ones throw', () => { + assert.deepEqual(parseArgs([]), { keepGoing: false, only: null, list: false, help: false }); + assert.deepEqual(parseArgs(['-k', '--only', 'lint,test', '--only=smoke']), { keepGoing: true, only: ['lint', 'test', 'smoke'], list: false, help: false }); + assert.deepEqual(selectSteps(['lint', 'test', 'smoke']).map((s) => s.id), ['test', 'smoke', 'lint']); + assert.equal(selectSteps(null).length, STEPS.length); + assert.equal(parseArgs(['--list']).list, true); + assert.equal(parseArgs(['-h']).help, true); + assert.throws(() => parseArgs(['--only', 'golden']), /unknown step "golden"/); + assert.throws(() => parseArgs(['--only']), /needs step names/); + assert.throws(() => parseArgs(['--fast']), /unknown option --fast/); +}); + +test("env: the job test steps get ci.yml's env for unset keys only; the tooling steps get the caller's env as is", () => { + const test_ = STEPS.find((s) => s.id === 'test'); + const lint = STEPS.find((s) => s.id === 'lint'); + assert.deepEqual(stepEnv(test_, { PATH: 'p', SP_E2E: '1' }), { PATH: 'p', SP_E2E: '1', SP_REAL_E2E: '0', RENDER_E2E: '0', NO_COLOR: '1' }); + assert.deepEqual(stepEnv(lint, { PATH: 'p' }), { PATH: 'p' }); +}); + +test('summary: one row per step (status, name, duration, note), the failed steps named; durations', () => { + assert.equal(formatDuration(42.4), '42ms'); + assert.equal(formatDuration(1234), '1.2s'); + assert.equal(formatDuration(125_000), '2m05s'); + const s = formatSummary([ + { id: 'setup', status: 'ok', ms: 400 }, + { id: 'test', status: 'fail', ms: 61_000, note: 'exit 1' }, + { id: 'typecheck', status: 'not run' }, + ]); + assert.equal(s, [ + '', '── ci summary ──', + ' ok setup 400ms', + ' FAIL test 1m01s exit 1', + ' - typecheck', + 'FAILED: test', '', + ].join('\n')); + assert.match(formatSummary([{ id: 'lint', status: 'ok', ms: 1 }]), /all steps passed/); +}); + +test('the eslint and tsc entry points come from their package.json bin; a missing package says to run npm ci', () => { + assert.ok(fs.existsSync(binPath('eslint', 'eslint'))); + assert.ok(fs.existsSync(binPath('typescript', 'tsc'))); + assert.throws(() => binPath('no-such-package-sp', 'x'), /npm ci/); +}); + +test('node tools/ci.mjs --list and a bad option run without npm (exit 0 / 2)', () => { + const list = spawnSync(process.execPath, ['tools/ci.mjs', '--list'], { cwd: ROOT, encoding: 'utf8' }); + assert.equal(list.status, 0, list.stderr); + for (const s of STEPS) assert.match(list.stdout, new RegExp(`^${s.id} `, 'm')); + const bad = spawnSync(process.execPath, ['tools/ci.mjs', '--only', 'nope'], { cwd: ROOT, encoding: 'utf8' }); + assert.equal(bad.status, 2); + assert.match(bad.stderr, /unknown step "nope"/); +}); diff --git a/tools/ci.mjs b/tools/ci.mjs new file mode 100644 index 00000000..c4f3d733 --- /dev/null +++ b/tools/ci.mjs @@ -0,0 +1,373 @@ +#!/usr/bin/env node +// tools/ci.mjs — run the checks of .github/workflows/ci.yml locally, one after another (CONTRIBUTING.md §2). +// +// node tools/ci.mjs every step; stops at the first failing one (exit 1) +// node tools/ci.mjs --keep-going run every step even after a failure (exit 1 when any failed) +// node tools/ci.mjs --only lint,test only the named steps (in the workflow's order) +// node tools/ci.mjs --list print the steps and the ci.yml step each one mirrors +// npm run ci the same as the first line +// +// Steps, in the order of ci.yml (job `test`, then job `tooling`): +// setup node tools/setup.mjs --check --no-local +// test node --test +// smoke the server on a free port on 127.0.0.1: GET /healthz, / (must contain /package.json `bin`), +// never npm or a shell, so the run does not depend on PATH or on the shell npm uses for scripts. +// test/ci.test.js keeps this list and ci.yml in step: a ci.yml step this file does not know fails it. + +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** The env of ci.yml's job `test` (test/ci.test.js compares it with the workflow). */ +export const CI_TEST_ENV = { SP_E2E: '0', SP_REAL_E2E: '0', RENDER_E2E: '0', NO_COLOR: '1' }; + +/** The paths the smoke step requests (the curl URLs of ci.yml's smoke step; test/ci.test.js compares them). */ +export const SMOKE_PATHS = ['/healthz', '/', '/vendor/pixi.min.js']; + +/** ci.yml steps that only make sense on a GitHub runner, by `uses:` action or by step name. */ +export const SKIPPED_CI_STEPS = [ + { uses: 'actions/checkout', why: 'GitHub runner: the checkout is this folder' }, + { uses: 'actions/setup-node', why: 'GitHub runner: the Node.js of this machine is used' }, + { name: 'Install (npm ci → postinstall copies public/vendor)', why: 'run `npm ci` yourself; it is not a check' }, +]; + +/** + * The local steps (runStep runs them). `ci` names the ci.yml job and step it mirrors; `npm` the package.json script + * ci.yml runs there, and `describe` is that script's command (test/ci.test.js checks both). + * @typedef {{ id: string, ci: { job: string, name: string }, npm?: string, describe: string }} Step + * @type {Step[]} + */ +export const STEPS = [ + { id: 'setup', ci: { job: 'test', name: 'Setup check (no downloads)' }, describe: 'node tools/setup.mjs --check --no-local' }, + { id: 'test', ci: { job: 'test', name: 'Unit & integration tests' }, describe: 'node --test' }, + { id: 'smoke', ci: { job: 'test', name: 'Server smoke test (boot, /healthz, index.html, doctor)' }, + describe: `server on a free port: GET ${SMOKE_PATHS.join(', ')}; node tools/doctor.mjs` }, + { id: 'lint', ci: { job: 'tooling', name: 'Lint (warnings allowed)' }, npm: 'lint', describe: 'eslint .' }, + { id: 'imports', ci: { job: 'tooling', name: 'Import boundaries' }, npm: 'check:imports', describe: 'node tools/check-imports.mjs' }, + { id: 'typecheck', ci: { job: 'tooling', name: 'Typecheck' }, npm: 'typecheck', describe: 'tsc --noEmit --checkJs -p jsconfig.json' }, +]; + +// --------------------------------------------------------------------------------------------------------------------- +// ci.yml, read just far enough: jobs, each job's env (flat KEY: value) and steps (name, uses, run, shell; the names +// of any other step keys in `other`, so test/ci.test.js can refuse per-step config this script does not mirror). + +const unquote = (s) => { + const v = s.trim(); + return /^(['"]).*\1$/.test(v) ? v.slice(1, -1) : v; +}; + +/** + * A line-based reader for the shape of .github/workflows/ci.yml (block mappings, `- ` step lists, `run: |` blocks). + * Not a YAML parser: flow mappings, anchors and multi-document files are not handled. + * @param {string} text + * @returns {{ jobs: Record, steps: { name?: string, uses?: string, run?: string, shell?: string, other?: string[] }[] }> }} + */ +export function parseWorkflow(text) { + const lines = text.replace(/\r\n?/g, '\n').split('\n'); + const indentOf = (l) => l.length - l.trimStart().length; + const blank = (l) => l.trim() === '' || l.trim().startsWith('#'); + /** @type {Record, steps: any[] }>} */ + const jobs = {}; + let i = lines.findIndex((l) => /^jobs:\s*$/.test(l)); + if (i < 0) return { jobs }; + let jobIndent = -1; + let job = null; + let section = null; // 'env' | 'steps' | null + let sectionIndent = -1; + let step = null; + let stepIndent = -1; + for (i += 1; i < lines.length; i++) { + const line = lines[i]; + if (blank(line)) continue; + const ind = indentOf(line); + if (ind === 0) break; // the next top-level key + const t = line.trim(); + if (jobIndent < 0) jobIndent = ind; + if (ind === jobIndent) { + const m = /^([\w-]+):\s*$/.exec(t); + job = m ? (jobs[m[1]] = { env: {}, steps: [] }) : null; + section = null; step = null; + continue; + } + if (!job) continue; + if (section && ind <= sectionIndent) { section = null; step = null; } + if (!section) { + const m = /^(env|steps):\s*$/.exec(t); + if (m) { section = m[1]; sectionIndent = ind; } + continue; + } + if (section === 'env') { + const m = /^([\w-]+):\s*(.*)$/.exec(t); + if (m) job.env[m[1]] = unquote(m[2]); + continue; + } + // steps + let body = t; + if (t.startsWith('- ')) { + step = /** @type {{ name?: string, uses?: string, run?: string, shell?: string, other?: string[] }} */ ({}); + job.steps.push(step); + stepIndent = ind + 2; + body = t.slice(2).trim(); + } else if (!step || ind < stepIndent) continue; + else if (ind > stepIndent) continue; // nested (with: …) + const m = /^(name|uses|run|shell):\s*(.*)$/.exec(body); + if (!m) { + const k = /^([\w-]+):/.exec(body); // env, if, with, continue-on-error, working-directory, …: the key name only + if (k) (step.other ||= []).push(k[1]); + continue; + } + if (m[1] === 'run' && /^[|>][-+]?$/.test(m[2].trim())) { + const block = []; + let j = i + 1; + let blockIndent = -1; + for (; j < lines.length; j++) { + const l = lines[j]; + if (l.trim() === '') { block.push(''); continue; } + const li = indentOf(l); + if (li < stepIndent || (blockIndent < 0 && li <= stepIndent)) break; + if (blockIndent < 0) blockIndent = li; + if (li < blockIndent) break; + block.push(l.slice(blockIndent)); + } + i = j - 1; + step.run = block.join('\n').replace(/\n+$/, ''); + } else { + step[m[1]] = unquote(m[2]); + } + } + return { jobs }; +} + +/** Is a ci.yml step one of SKIPPED_CI_STEPS? @param {{ name?: string, uses?: string }} s */ +export function isSkippedCiStep(s) { + return SKIPPED_CI_STEPS.some((k) => (k.uses ? (s.uses || '').split('@')[0] === k.uses : s.name === k.name)); +} + +// --------------------------------------------------------------------------------------------------------------------- +// Arguments, env, summary. + +/** + * @param {string[]} argv + * @returns {{ keepGoing: boolean, only: string[] | null, list: boolean, help: boolean }} + */ +export function parseArgs(argv) { + const o = { keepGoing: false, only: /** @type {string[] | null} */ (null), list: false, help: false }; + const ids = new Set(STEPS.map((s) => s.id)); + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--keep-going' || a === '-k') o.keepGoing = true; + else if (a === '--list') o.list = true; + else if (a === '--help' || a === '-h') o.help = true; + else if (a === '--only' || a.startsWith('--only=')) { + const v = a === '--only' ? argv[++i] : a.slice('--only='.length); + if (!v) throw new Error('--only needs step names: ' + [...ids].join(',')); + for (const id of v.split(',').map((x) => x.trim()).filter(Boolean)) { + if (!ids.has(id)) throw new Error(`unknown step "${id}" (steps: ${[...ids].join(', ')})`); + (o.only ??= []).push(id); + } + } else throw new Error(`unknown option ${a} (node tools/ci.mjs --help)`); + } + return o; +} + +/** The steps to run, in workflow order. @param {string[] | null} only */ +export function selectSteps(only) { + return only ? STEPS.filter((s) => only.includes(s.id)) : STEPS.slice(); +} + +/** A child's env: `base` plus ci.yml's job env for the keys `base` does not set. */ +export function stepEnv(step, base = process.env) { + const env = { ...base }; + if (step.ci.job === 'test') for (const [k, v] of Object.entries(CI_TEST_ENV)) if (env[k] === undefined) env[k] = v; + return env; +} + +/** @param {number} ms */ +export function formatDuration(ms) { + if (ms < 1000) return `${Math.round(ms)}ms`; + const s = ms / 1000; + if (s < 60) return `${s.toFixed(1)}s`; + return `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, '0')}s`; +} + +/** + * The summary table. `status`: ok | fail | not run. + * @param {{ id: string, status: 'ok' | 'fail' | 'not run', ms?: number, note?: string }[]} results + */ +export function formatSummary(results) { + const w = Math.max(...results.map((r) => r.id.length), 4); + const label = { ok: 'ok', fail: 'FAIL', 'not run': '-' }; + const rows = results.map((r) => { + const time = r.ms == null ? '' : formatDuration(r.ms); + return ` ${label[r.status].padEnd(5)} ${r.id.padEnd(w)} ${time.padStart(7)}${r.note ? ' ' + r.note : ''}`.trimEnd(); + }); + const failed = results.filter((r) => r.status === 'fail').map((r) => r.id); + const tail = failed.length ? `FAILED: ${failed.join(', ')}` : results.some((r) => r.status === 'ok') ? 'all steps passed' : 'nothing ran'; + return ['', '── ci summary ──', ...rows, tail, ''].join('\n'); +} + +// --------------------------------------------------------------------------------------------------------------------- +// Running. + +/** The JS entry of a dependency's bin, e.g. ('eslint', 'eslint') → node_modules/eslint/bin/eslint.js. */ +export function binPath(pkg, name, root = ROOT) { + const dir = path.join(root, 'node_modules', pkg); + let manifest; + try { manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { + throw new Error(`${pkg} is not installed (node_modules/${pkg}) — run npm ci first`); + } + const rel = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[name]; + if (!rel) throw new Error(`node_modules/${pkg}/package.json has no bin "${name}"`); + return path.join(dir, rel); +} + +/** Run `node ` in ROOT with inherited stdio; resolves to the exit code (1 when killed by a signal). */ +function node(args, env) { + return new Promise((resolve) => { + const child = spawn(process.execPath, args, { cwd: ROOT, env, stdio: 'inherit' }); + child.on('error', (e) => { console.error(e.message); resolve(1); }); + child.on('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); + }); +} + +/** A free TCP port on 127.0.0.1. */ +export function freePort() { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.unref(); + srv.on('error', reject); + srv.listen(0, '127.0.0.1', () => { + const { port } = /** @type {net.AddressInfo} */ (srv.address()); + srv.close(() => resolve(port)); + }); + }); +} + +/** Live server children, killed if this process exits early. @type {Set} */ +const servers = new Set(); +process.on('exit', () => { for (const c of servers) { try { c.kill('SIGKILL'); } catch { /* gone */ } } }); + +/** Stop a server child: SIGTERM (graceful on POSIX; on Windows kill() terminates), SIGKILL after 8 s. */ +function stopServer(child) { + if (child.exitCode !== null || child.signalCode !== null) { servers.delete(child); return Promise.resolve(); } + return new Promise((resolve) => { + const force = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 8000); + child.once('exit', () => { clearTimeout(force); servers.delete(child); resolve(); }); + try { child.kill('SIGTERM'); } catch { clearTimeout(force); servers.delete(child); resolve(); } + }); +} + +/** The smoke step of ci.yml's job `test`, on a free port instead of 3000 (a running game server must not clash). */ +async function smoke(env) { + const port = await freePort(); + // [ASSUMED] HOST=127.0.0.1 instead of ci.yml's default 0.0.0.0: the requests go to 127.0.0.1 either way, and + // listening only on loopback spares a firewall prompt on Windows / macOS. + const child = spawn(process.execPath, ['server/index.js'], { + cwd: ROOT, env: { ...env, PORT: String(port), HOST: '127.0.0.1' }, stdio: ['ignore', 'pipe', 'pipe'], + }); + servers.add(child); + let log = ''; + child.stdout.on('data', (d) => { log += d; }); + child.stderr.on('data', (d) => { log += d; }); + const base = `http://127.0.0.1:${port}`; + const fail = async (msg) => { + await stopServer(child); + console.error(`smoke: ${msg}\n── server log ──\n${log}`); + return 1; + }; + try { + let health = null; + for (let i = 0; i < 30 && !health; i++) { + if (child.exitCode !== null) return await fail(`the server exited (code ${child.exitCode})`); + try { + const r = await fetch(base + '/healthz', { signal: AbortSignal.timeout(2000) }); + if (r.ok) health = await r.text(); + } catch { /* not up yet */ } + if (!health) await new Promise((r) => setTimeout(r, 1000)); + } + if (!health) return await fail(`no 200 from ${base}/healthz within 30 s`); + console.log(`GET /healthz → ${health.trim()}`); + const index = await fetch(base + '/', { signal: AbortSignal.timeout(10000) }); + const html = await index.text(); + if (!index.ok) return await fail(`GET / → HTTP ${index.status}`); + if (!/} env */ +function runStep(step, env) { + switch (step.id) { + case 'setup': return node(['tools/setup.mjs', '--check', '--no-local'], env); + case 'test': return node(['--test'], env); + case 'smoke': return smoke(env); + case 'lint': return node([binPath('eslint', 'eslint'), '.'], env); + case 'imports': return node(['tools/check-imports.mjs'], env); + case 'typecheck': return node([binPath('typescript', 'tsc'), '--noEmit', '--checkJs', '-p', 'jsconfig.json'], env); + default: throw new Error(`no runner for step ${step.id}`); + } +} + +export async function main(argv = process.argv.slice(2)) { + let opts; + try { opts = parseArgs(argv); } catch (e) { console.error(e.message); return 2; } + if (opts.help) { + console.log('node tools/ci.mjs [--keep-going] [--only ] [--list] — the checks of .github/workflows/ci.yml'); + console.log('steps: ' + STEPS.map((s) => s.id).join(', ')); + return 0; + } + if (opts.list) { + for (const s of STEPS) console.log(`${s.id.padEnd(10)} ${s.describe}\n${''.padEnd(10)} ← ci.yml ${s.ci.job}: ${s.ci.name}`); + for (const s of SKIPPED_CI_STEPS) console.log(`(skipped) ${s.uses || s.name} — ${s.why}`); + return 0; + } + const steps = selectSteps(opts.only); + const results = steps.map((s) => ({ id: s.id, status: /** @type {'ok' | 'fail' | 'not run'} */ ('not run'), ms: undefined, note: undefined })); + let stopped = false; + for (let i = 0; i < steps.length; i++) { + if (stopped) break; + const step = steps[i]; + console.log(`\n▶ ${step.id} — ${step.describe}`); + const t0 = performance.now(); + let code; + try { code = await runStep(step, stepEnv(step)); } catch (e) { console.error(e?.message || e); code = 1; } + results[i].ms = performance.now() - t0; + results[i].status = code === 0 ? 'ok' : 'fail'; + if (code !== 0) { + results[i].note = `exit ${code}`; + if (!opts.keepGoing) stopped = true; + } + } + console.log(formatSummary(results)); + return results.some((r) => r.status === 'fail') ? 1 : 0; +} + +function isMain() { + try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } +} + +if (isMain()) main().then((code) => { process.exitCode = code; }, (e) => { console.error(e?.stack || e); process.exitCode = 1; }); From e8ba09129812fbef7c1d9a5edb36384ea3018a7c Mon Sep 17 00:00:00 2001 From: Cloudnyco Date: Thu, 8 Oct 2026 18:48:42 +0800 Subject: [PATCH 07/10] feedback7 G-TOOLS: add the battle performance bench under public/dev (PR #198 by @Cloudnyco) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied as-is. Captured real battles live in public/dev/perf (including the round-13 联防 on the wide round battlefield), with a browser speed page and a headless benchmark. public/dev stays out of the package. The Chrome benchmark was not run. The numbers in the original pull request are from 0.1.4 and are not quoted here. Tests: node --test test/perfbench.test.js test/render/battle-perf.browser.test.js — 13 pass, 0 fail; the Chrome suite skipped (RENDER_E2E unset). --- README.md | 2 + public/dev/battle-perf.html | 128 +++++++++ public/dev/battle-perf.js | 331 ++++++++++++++++++++++++ public/dev/frame-stats.js | 22 ++ public/dev/perf/hard-boss-r14.json | 1 + public/dev/perf/hard-hidden-r15.json | 1 + public/dev/perf/hard-normal-r13.json | 1 + public/dev/perf/hard-unite-r13.json | 1 + public/dev/perf/index.json | 38 +++ test/perfbench.test.js | 123 +++++++++ test/render/battle-perf.browser.test.js | 61 +++++ tools/capture-specs.mjs | 103 ++++++++ tools/perfbench.mjs | 172 ++++++++++++ 13 files changed, 984 insertions(+) create mode 100644 public/dev/battle-perf.html create mode 100644 public/dev/battle-perf.js create mode 100644 public/dev/frame-stats.js create mode 100644 public/dev/perf/hard-boss-r14.json create mode 100644 public/dev/perf/hard-hidden-r15.json create mode 100644 public/dev/perf/hard-normal-r13.json create mode 100644 public/dev/perf/hard-unite-r13.json create mode 100644 public/dev/perf/index.json create mode 100644 test/perfbench.test.js create mode 100644 test/render/battle-perf.browser.test.js create mode 100644 tools/capture-specs.mjs create mode 100644 tools/perfbench.mjs diff --git a/README.md b/README.md index d88a2217..70170017 100644 --- a/README.md +++ b/README.md @@ -199,9 +199,11 @@ SP_E2E=1 node --test test/ui/mock.e2e.test.js # 浏览器端到端测试 SP_REAL_E2E=1 node --test test/ui/real.e2e.test.js # 需要 Chrome + 已下载的素材 RENDER_E2E=1 node --test 'test/render/*.browser.test.js' # 渲染测试,部分需要本地提取的棋盘贴图 GOLDEN_FULL=1 node --test test/golden.test.js # 黄金结果:固定种子的整套战斗与人机对局摘要(默认只跑快速子集) +node tools/perfbench.mjs --cpu 1,4,6 --profile # 真实战斗的帧耗时基准(Chrome 降速 CPU 近似中低端手机),需要 Chrome + 已下载的素材 ``` - 游戏数据由 `npm run build-data`(`tools/build-data.mjs`)从官方数据表生成,不要手工修改 `data/*.json`。 +- 性能测试:`/dev/battle-perf.html` 在浏览器里跑一场真实战斗(对局用的战斗运行器、模拟和渲染),实时显示帧率与逐帧耗时;在手机上打开后点击「开始测量(10 秒)」,生成的报告可以复制后附在反馈中。用到的战斗来自 `node tools/capture-specs.mjs` 从固定种子的机器人对局里截取的数据(`public/dev/perf/`)。 - 只重构、不改玩法的提交不能改变 `test/golden/*.json`;有意改变玩法时运行 `npm run golden:update`,检查差异后随改动一起提交(见 [test/golden/README.md](test/golden/README.md))。 - GitHub Actions([.github/workflows/ci.yml](.github/workflows/ci.yml))在 Ubuntu 与 Windows、Node 22 / 24 上运行 `npm ci`、`node --test` 和服务器冒烟测试。 - 代码怎么分层、改某个规则该从哪个文件入手,见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。 diff --git a/public/dev/battle-perf.html b/public/dev/battle-perf.html new file mode 100644 index 00000000..7183871b --- /dev/null +++ b/public/dev/battle-perf.html @@ -0,0 +1,128 @@ + + + + + + + Battle perf · 卫戍协议:盟约 + + + + + + + + + + +
+
+ +
+

战斗性能测试

+
+ + + +
+
+
–FPS
+
+ 帧耗时– + 渲染 CPU– + 模拟单步– +
+
正在加载战斗…
+
+
+ + +
+
≤ 16.7 ms(≥ 60 FPS)16.7–33.3 ms> 33.3 ms(卡顿帧)
+ + +
+
+ + + diff --git a/public/dev/battle-perf.js b/public/dev/battle-perf.js new file mode 100644 index 00000000..79d6b15f --- /dev/null +++ b/public/dev/battle-perf.js @@ -0,0 +1,331 @@ +// Battle perf page (dev): one real battle in the browser, run the way the match screen runs it — the client-side battle +// runner (js/battle/runner.js: the shared sim at the battle speed) feeding the field view (js/render/app.js) — from a +// BattleSpec captured off a bot match (tools/capture-specs.mjs → /dev/perf/.json). Players can open it on a +// phone and report the numbers (「开始测量」→ a verdict and a copyable report); tools/perfbench.mjs drives it headlessly. +// +// Query: ?spec= (default: the first of /dev/perf/index.json) &quality=high|medium|low &panel=0 (hide the panel) +// &board=2d|3d (render/app.js reads it) +// Puppeteer hook: window.__perf = { ready, error, view, runner, sample(ms) → figures over `ms` of animation frames } +// +// The panel must not disturb what it measures: frame times go into a ring buffer (one store per frame), the strip is +// redrawn 4× a second from it, binned to pixel columns, and both the strip and the live figures stand still during a +// measurement (only a CSS progress animation runs). + +import { createFieldView } from '../js/render/app.js'; +import { data } from '../js/data.js'; +import { assets } from '../js/assets.js'; +import { createBattleRunner } from '../js/battle/runner.js'; +import { round, frameFigures } from './frame-stats.js'; + +const $ = (id) => document.getElementById(id); +const q = new URLSearchParams(location.search); +const perf = { ready: false, error: null }; +window.__perf = perf; + +const longTasks = []; +try { + new PerformanceObserver((list) => { for (const e of list.getEntries()) longTasks.push(e.duration); }).observe({ type: 'longtask', buffered: true }); +} catch { /* not supported */ } + +// Frame-time budgets (ms) shared by the verdict, the strip colours and the stutter share: 16.7 / 33.3 ms are 60 / 30 FPS. +// The 60 FPS budget allows 5% for vsync jitter (a 60 Hz display measures frames of 16.6–17.4 ms); a frame over the 30 FPS +// budget counts as a stutter frame. +export const FRAME_60_MS = 17.5; +export const FRAME_30_MS = 33.4; +const QUALITY_NAME = { high: '高', medium: '中', low: '低' }; + +/** + * Frame figures over `ms` of animation frames: average fps, frame-time percentiles, the share of frames over 33.4 ms + * (below 30 fps), long tasks, the sim's ticks and their cost (runner.stats), and the view's own CPU / render times. + * The first frame time is partial (from the call to the first frame) and dropped, so the sample runs for at least two + * frames whatever `ms` is: a sample that saw a single frame (a short `ms`, a frame longer than `ms`) had no frame time + * left and divided by zero. + */ +perf.sample = (ms = 10000) => new Promise((resolve) => { + const deltas = []; + let last = performance.now(); + const t0 = last; + const r0 = { ...perf.runner.stats() }; + const lt0 = longTasks.length; + const step = (t) => { + deltas.push(t - last); + last = t; + if (t - t0 < ms || deltas.length < 2) { requestAnimationFrame(step); return; } + deltas.shift(); + const r1 = perf.runner.stats(); + const v = perf.view.stats(); + const lt = longTasks.slice(lt0); + const ticks = r1.ticks - r0.ticks; + resolve({ + ...frameFigures(deltas, FRAME_30_MS), + longTasks: lt.length, longTaskMs: Math.round(lt.reduce((a, b) => a + b, 0)), + ticks, simMs: round(r1.stepMs - r0.stepMs), simMsPerTick: ticks > 0 ? round((r1.stepMs - r0.stepMs) / ticks, 3) : null, + viewCpuMs: v.cpuMs, renderMs: v.renderMs, units: v.units, lod: v.lod, particles: v.particles, + board: v.board3d?.on ? '3d' : '2d', done: !!perf.runner.state()?.done, + }); + }; + requestAnimationFrame(step); +}); + +/** The device line of a report: renderer, screen, cores (what a phone report needs to be comparable). */ +function deviceInfo() { + let gpu = '?'; + try { + const gl = document.createElement('canvas').getContext('webgl2') || document.createElement('canvas').getContext('webgl'); + const ext = gl && gl.getExtension('WEBGL_debug_renderer_info'); + gpu = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : (gl ? gl.getParameter(gl.RENDERER) : 'no WebGL'); + } catch { /* ignore */ } + return { + ua: navigator.userAgent, gpu, cores: navigator.hardwareConcurrency || null, memoryGB: navigator.deviceMemory || null, + viewport: `${innerWidth}×${innerHeight}`, dpr: devicePixelRatio, + }; +} + +/** The copyable report: one item per line, the panel's terms and units (pasted into feedback as is). */ +function report(spec, title, quality, s) { + const d = deviceInfo(); + const lines = [ + '战斗性能测试报告(/dev/battle-perf.html)', + `场景:${title}(${spec})`, + `画质:${QUALITY_NAME[quality] || quality};棋盘:${s.board === '3d' ? '3D' : '2D'}`, + `帧率:${s.fps} FPS`, + `帧耗时:P50 ${s.p50} ms,P95 ${s.p95} ms,P99 ${s.p99} ms`, + `卡顿帧占比(单帧 > 33.3 ms):${s.over33}%`, + `模拟单步:${s.simMsPerTick} ms(共 ${s.ticks} 步)`, + `渲染 CPU:${s.viewCpuMs} ms;渲染提交:${s.renderMs} ms`, + `场上单位:${s.units};自适应负载档位:${s.lod}`, + `长任务:${s.longTasks} 次,共 ${s.longTaskMs} ms`, + `GPU:${d.gpu}`, + `设备:${d.cores ?? '未知'} 个 CPU 线程${d.memoryGB ? `,内存约 ${d.memoryGB} GB` : ''};视口 ${d.viewport},像素比 ${d.dpr}`, + `浏览器:${d.ua}`, + ]; + if (s.done) lines.splice(10, 0, '注意:战斗在测量期间结束,结果仅供参考'); + return lines.join('\n'); +} + +/** The verdict a player reads first, on the frame-time budgets of the strip (60 / 30 FPS). */ +export function verdictOf(s) { + if (s.p95 <= FRAME_60_MS && s.over33 < 1) return { label: '流畅', note: `P95 帧耗时 ${s.p95} ms,达到 60 FPS`, color: '--mint-500' }; + if (s.p95 <= FRAME_30_MS && s.over33 < 1) return { label: '基本流畅', note: `P95 帧耗时 ${s.p95} ms,介于 30–60 FPS`, color: '--amber' }; + return { label: '卡顿', note: `卡顿帧占比 ${s.over33}%,P95 帧耗时 ${s.p95} ms`, color: '--red' }; +} + +// ---- frame ring buffer + strip ------------------------------------------------------------------------------------ + +const RING = 4096; +const ringT = new Float64Array(RING); // frame end (performance.now) +const ringD = new Float32Array(RING); // frame time (ms) +let ringN = 0; +let lastT = 0; +function recordFrames(t) { + if (lastT) { const i = ringN % RING; ringT[i] = t; ringD[i] = t - lastT; ringN++; } + lastT = t; + requestAnimationFrame(recordFrames); +} + +const SPAN_MS = 10000; // the strip shows the last 10 s +const TOP_MS = 50; // frame times above clip to the top (a red spike) +let palette = null; +function colors() { + if (!palette) { + const cs = getComputedStyle(document.documentElement); + const v = (n) => cs.getPropertyValue(n).trim(); + palette = { good: v('--mint-500'), warn: v('--amber'), bad: v('--red'), line: v('--line-2'), text: v('--text-lo') }; + } + return palette; +} +const band = (ms) => (ms <= FRAME_60_MS ? 'good' : ms <= FRAME_30_MS ? 'warn' : 'bad'); + +/** Draw the last SPAN_MS of frame times into `cv`: one column per pixel (its slowest frame), budget lines at 60 / 30 fps. */ +function drawStrip(cv, { labels = true } = {}) { + const w = cv.clientWidth, h = cv.clientHeight; + if (!w || !h) return; + const dpr = Math.min(2, devicePixelRatio || 1); + if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); } + const g = cv.getContext('2d'); + g.setTransform(dpr, 0, 0, dpr, 0, 0); + g.clearRect(0, 0, w, h); + const c = colors(); + const pad = labels ? 4 : 1; + const y = (ms) => h - pad - (Math.min(ms, TOP_MS) / TOP_MS) * (h - pad * 2); + g.fillStyle = c.line; + for (const ms of [16.7, 33.3]) g.fillRect(0, Math.round(y(ms)), w, 1); + if (labels) { + g.fillStyle = c.text; + g.font = '10px "Noto Sans SC", sans-serif'; + g.textAlign = 'right'; + g.fillText('60 FPS', w - 3, y(16.7) - 3); + g.fillText('30 FPS', w - 3, y(33.3) - 3); + } + const now = performance.now(); + const cols = new Float32Array(Math.ceil(w)); + for (let k = 1; k <= Math.min(ringN, RING); k++) { + const i = (ringN - k) % RING; + const age = now - ringT[i]; + if (age > SPAN_MS) break; + const x = Math.floor(w - 1 - (age / SPAN_MS) * w); + if (x >= 0 && ringD[i] > cols[x]) cols[x] = ringD[i]; + } + const barW = labels ? 1 : 1.5; + for (let x = 0; x < cols.length; x++) { + const ms = cols[x]; + if (!ms) continue; + g.fillStyle = c[band(ms)]; + const top = y(ms); + g.fillRect(x, top, barW, h - pad - top); + } +} + +// ---- panel ---------------------------------------------------------------------------------------------------------- + +const PANEL_KEY = 'sp.dev.battlePerfPanel'; +function loadPanelPref() { + try { return localStorage.getItem(PANEL_KEY); } catch { return null; } +} +function savePanelPref(v) { + try { localStorage.setItem(PANEL_KEY, v); } catch { /* private mode */ } +} + +function setupPanel() { + const panel = $('panel'); + const set = (collapsed, save = true) => { + panel.classList.toggle('is-collapsed', collapsed); + $('expand').setAttribute('aria-expanded', String(!collapsed)); + $('collapse').setAttribute('aria-expanded', String(!collapsed)); + if (save) savePanelPref(collapsed ? 'collapsed' : 'open'); + }; + const pref = loadPanelPref(); + // phones (a landscape phone is short): start folded, the battle is what is being measured + set(pref ? pref === 'collapsed' : (innerWidth < 900 || innerHeight < 500), false); + $('collapse').onclick = () => { set(true); $('expand').focus(); }; + $('expand').onclick = () => { set(false); $('collapse').focus(); }; +} + +async function main() { + if (q.get('panel') === '0') $('panel').classList.add('is-hidden'); + setupPanel(); + const index = await fetch('/dev/perf/index.json').then((r) => (r.ok ? r.json() : []), () => []); + const specName = q.get('spec') || index[0]?.name; + if (!specName) throw new Error('未找到战斗数据。请先运行 node tools/capture-specs.mjs 生成。'); + const quality = ['high', 'medium', 'low'].includes(q.get('quality')) ? q.get('quality') : 'high'; + for (const s of index) $('spec').append(new Option(s.title || s.name, s.name)); + $('spec').value = specName; + $('quality').value = quality; + const reload = () => { + const p = new URLSearchParams(location.search); + p.set('spec', $('spec').value); + p.set('quality', $('quality').value); + location.search = p.toString(); + }; + $('restart').onclick = reload; + $('spec').onchange = reload; + $('quality').onchange = reload; + + await data.loadAll('chess', 'tokens', 'items', 'enemies', 'stages', 'bonds', 'config'); + await assets.ready(); + const msg = await fetch(`/dev/perf/${encodeURIComponent(specName)}.json`).then((r) => { + if (!r.ok) throw new Error(`战斗数据 ${specName} 加载失败(HTTP ${r.status})。请确认该文件位于 public/dev/perf/。`); + return r.json(); + }); + const view = await createFieldView($('field'), { data, assets, settings: { quality, damageNumbers: true } }); + view.setStage(data.lookup('stages', msg.spec.stageId)); + // the runner needs no server here: reports go nowhere, the battle runs from its start + const net = { on: () => () => {}, send() {}, request: async () => ({ ok: true }) }; + const runner = createBattleRunner({ net, store: null }); + const camKind = msg.kind === 'hidden' ? 'boss' : msg.kind; + runner.on('field', (field) => { view.enterBattle(field); view.setCamera(camKind, { rect: field.rect, side: 'L', instant: true }); }); + runner.on('snap', (snap) => view.pushSnapshot(snap)); + runner.on('ev', (ev) => view.pushEvents(ev)); + perf.view = view; + perf.runner = runner; + await runner.onStart({ ...msg, elapsed: 0, authoritative: true, watch: false, done: false }); + perf.ready = true; + // frame times from here on: the loading hitches before the battle are not the battle's + requestAnimationFrame(recordFrames); + + let measuring = false; + let prev = { ...runner.stats() }; + const live = () => { + if (measuring) return; + const v = view.stats(); + const r = runner.stats(); + const ticks = r.ticks - prev.ticks; + const simMs = ticks > 0 ? (r.stepMs - prev.stepMs) / ticks : 0; + prev = { ...r }; + const fps = Math.round(v.fps); + $('fps').textContent = fps; + $('f-frame').textContent = `${v.frameMs.toFixed(1)} ms`; + $('f-view').textContent = `${v.cpuMs.toFixed(1)} ms`; + $('f-sim').textContent = `${simMs.toFixed(2)} ms`; + $('chip-fps').textContent = fps; + $('chip-ms').textContent = `FPS / ${v.frameMs.toFixed(1)} ms`; + const st = runner.state(); + $('meta').textContent = st?.done + ? '战斗已结束。如需继续测量,请点击「重新开始」。' + : `场上单位 ${v.units} 个,自适应负载 ${v.lod} 档,${v.board3d?.on ? '3D' : '2D'} 棋盘`; + }; + setInterval(live, 500); + setInterval(() => { + if (measuring) return; + if ($('panel').classList.contains('is-collapsed')) drawStrip($('chip-strip'), { labels: false }); + else drawStrip($('strip')); + }, 250); + + const btn = $('measure'); + btn.disabled = false; + btn.onclick = async () => { + if (measuring) return; + measuring = true; + btn.classList.add('is-running'); + btn.setAttribute('aria-disabled', 'true'); + $('freeze').hidden = false; + const t0 = Date.now(); + const tick = setInterval(() => { $('measure-label').textContent = `测量中(剩余 ${Math.max(1, 10 - Math.floor((Date.now() - t0) / 1000))} 秒)`; }, 1000); + $('measure-label').textContent = '测量中(剩余 10 秒)'; + const s = await perf.sample(10000); + clearInterval(tick); + measuring = false; + btn.classList.remove('is-running'); + btn.removeAttribute('aria-disabled'); + $('freeze').hidden = true; + $('measure-label').textContent = '重新测量(10 秒)'; + + const v = verdictOf(s); + const verdict = $('verdict'); + verdict.style.setProperty('--v', `var(${v.color})`); + verdict.replaceChildren(document.createTextNode(v.label)); + const note = document.createElement('small'); + note.textContent = v.note; + verdict.append(note); + $('r-fps').textContent = s.fps; + $('r-p95').textContent = `${s.p95} ms`; + $('r-over').textContent = `${s.over33}%`; + const text = report(specName, index.find((x) => x.name === specName)?.title || specName, quality, s); + $('out').textContent = text; + $('result').hidden = false; + const copy = $('copy'); + copy.textContent = '复制报告'; + copy.onclick = () => { + const done = () => { copy.textContent = '已复制到剪贴板'; }; + const fallback = () => { + const d = $('result').querySelector('details'); + d.open = true; + const range = document.createRange(); + range.selectNodeContents($('out')); + const sel = getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + copy.textContent = '无法写入剪贴板,已选中报告文本,请手动复制'; + }; + if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(done, fallback); + else fallback(); + }; + }; +} + +main().catch((err) => { + perf.error = String(err?.stack || err); + console.error(err); + const meta = document.getElementById('meta'); + if (meta) meta.textContent = `加载失败:${err?.message || err}`; +}); diff --git a/public/dev/frame-stats.js b/public/dev/frame-stats.js new file mode 100644 index 00000000..b1b882e2 --- /dev/null +++ b/public/dev/frame-stats.js @@ -0,0 +1,22 @@ +// public/dev/frame-stats.js — the frame figures of the battle perf page (battle-perf.js), free of the DOM so the Node +// tests can import them (test/perfbench.test.js). + +/** `v` rounded to `d` decimals; null for NaN / ±Infinity (a figure the report shows as missing, not as 0). */ +export const round = (v, d = 1) => (Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null); + +/** + * Average fps, frame-time percentiles (ms) and the share (%) of frames over `stutterMs`, from a sample's frame times. + * Every figure is null without a frame time (perf.sample() always records one, but an empty run must not divide by 0). + * @param {number[]} deltas frame times in ms (sorted in place) + * @param {number} stutterMs a frame over this counts as a stutter frame + */ +export function frameFigures(deltas, stutterMs) { + if (!deltas.length) return { fps: null, p50: null, p95: null, p99: null, over33: null }; + deltas.sort((a, b) => a - b); + const pick = (p) => deltas[Math.min(deltas.length - 1, Math.floor(deltas.length * p))]; + const avg = deltas.reduce((a, b) => a + b, 0) / deltas.length; + return { + fps: avg > 0 ? round(1000 / avg) : null, p50: round(pick(0.5)), p95: round(pick(0.95)), p99: round(pick(0.99)), + over33: round((deltas.filter((x) => x > stutterMs).length / deltas.length) * 100), + }; +} diff --git a/public/dev/perf/hard-boss-r14.json b/public/dev/perf/hard-boss-r14.json new file mode 100644 index 00000000..080bac4f --- /dev/null +++ b/public/dev/perf/hard-boss-r14.json @@ -0,0 +1 @@ +{"t":"b.start","battleId":"7.14.63.b1","fieldId":"b1","kind":"boss","spec":{"v":1,"battleId":"7.14.63.b1","fieldId":"b1","kind":"boss","seed":3380376830,"modeId":"mode_multi_hard","round":14,"stageId":"act2autochess_m01","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"timeLimit":null,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":0,"units":[{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":12,"col":8,"dir":"DOWN","items":[],"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":11,"col":4,"dir":"RIGHT","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":8,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst"},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo"},{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":10,"col":5,"dir":"RIGHT","items":[],"skillIndex":1},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":10,"col":8,"dir":"RIGHT","items":["chess_item_1_01_e_a","chess_item_4_04_e_b"],"skillIndex":1,"moduleId":"uniequip_002_wildmn"},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":9,"col":8,"dir":"RIGHT","items":["chess_item_3_04_e_a","chess_item_5_02_e_b"],"skillIndex":1,"moduleId":"uniequip_002_lionhd"},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":9,"col":9,"dir":"UP","items":["chess_item_3_05_e_a","chess_item_4_05_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":74},"preciShip":{"count":3,"active":true,"tier":2,"layers":98},"swiftShip":{"count":3,"active":true,"tier":1,"layers":82},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":26},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"lpForBoss":1572,"contentInfo":{"round":14,"kind":"boss","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":3,"buys":4,"sells":0,"spent":10,"gainedChess":0,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}},{"playerId":"p_1","seat":1,"side":"R","colOffset":8,"units":[{"uid":243,"kind":"chess","chessId":"chess_char_4_20_b","row":12,"col":8,"dir":"DOWN","items":["chess_item_4_12_e_a","chess_item_3_08_e_b"],"skillIndex":2,"moduleId":"uniequip_002_fartth"},{"uid":240,"kind":"chess","chessId":"chess_char_6_16_a","row":11,"col":4,"dir":"DOWN","items":[],"skillIndex":2},{"uid":73,"kind":"chess","chessId":"chess_char_1_01_b","row":11,"col":8,"dir":"RIGHT","items":["chess_item_1_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_inside"},{"uid":246,"kind":"chess","chessId":"chess_char_6_05_a","row":10,"col":4,"dir":"RIGHT","items":["chess_item_4_02_e_a","chess_item_4_08_e_a"],"skillIndex":2},{"uid":175,"kind":"chess","chessId":"chess_char_2_18_b","row":10,"col":5,"dir":"RIGHT","items":["chess_item_4_10_e_b","chess_item_2_03_e_a"],"skillIndex":0,"moduleId":"uniequip_002_ashlok"},{"uid":130,"kind":"chess","chessId":"chess_char_4_01_a","row":10,"col":7,"dir":"RIGHT","items":["chess_item_4_04_e_a","chess_item_3_05_e_a"],"skillIndex":2},{"uid":177,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":8,"dir":"RIGHT","items":["chess_item_2_01_e_a","chess_item_4_06_e_a"],"skillIndex":2},{"uid":100,"kind":"chess","chessId":"chess_char_4_02_a","row":9,"col":9,"dir":"UP","items":["chess_item_3_07_e_a","chess_item_3_06_e_b"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":11},"sargonShip":{"count":1,"active":false,"tier":0,"layers":4},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":5},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":16},"lateranoShip":{"count":3,"active":true,"tier":1,"layers":4},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":0,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":3,"active":true,"tier":1,"layers":93},"preciShip":{"count":2,"active":true,"tier":1,"layers":83},"swiftShip":{"count":3,"active":true,"tier":1,"layers":86},"skillfulShip":{"count":1,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":2,"active":true,"tier":1,"layers":12},"steadShip":{"count":2,"active":true,"tier":1,"layers":32},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":3},"investShip":{"count":1,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":0},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":0,"active":false,"tier":0,"layers":0},"suntShip":{"count":3,"active":true,"tier":1,"layers":0}},"bandId":"band_kirara","playerEffects":[],"deviceOverrides":{},"lpForBoss":1572,"contentInfo":{"round":14,"kind":"boss","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":2,"buys":4,"sells":2,"spent":13,"gainedChess":4,"arts":0},"matchBands":["band_kirara","band_sarkazb","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_0","bandId":"band_sarkazb"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}}],"spawns":[{"time":3,"enemyKey":"enemy_1402_tgshd_2","routeIndex":0,"count":1,"interval":0,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":0,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":5,"enemyKey":"enemy_1402_tgshd_2","routeIndex":1,"count":1,"interval":0,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":1,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":7,"enemyKey":"enemy_1006_shield_2","routeIndex":4,"count":2,"interval":35,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":4,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":25,"enemyKey":"enemy_1006_shield_2","routeIndex":5,"count":2,"interval":20,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":5,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":42,"enemyKey":"enemy_1006_shield_2","routeIndex":6,"count":3,"interval":35,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":6,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":7,"enemyKey":"enemy_1006_shield_2","routeIndex":7,"count":2,"interval":35,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":7,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":25,"enemyKey":"enemy_1006_shield_2","routeIndex":8,"count":2,"interval":35,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":8,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":42,"enemyKey":"enemy_1006_shield_2","routeIndex":9,"count":3,"interval":20,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":9,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":0,"enemyKey":"enemy_9013_acstmk","routeIndex":10,"count":1,"interval":0,"mods":{"atkMul":2.143589,"speedMul":1,"slot":"N"},"actionIndex":10,"preview":{"gate":"lower","start":[3,10],"fly":false,"elite":true,"boss":true},"tag":"boss"}],"routes":[{"motion":"WALK","start":[2,10],"end":[1,17],"checkpoints":[[1,3]],"steps":[{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,3],"checkpoints":[[1,17]],"steps":[{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"FLY","start":[5,10],"end":[1,3],"checkpoints":[[5,11],[2,11],[2,13],[5,13],[5,15],[2,15],[2,17],[1,17],[5,9],[2,9],[2,7],[5,7],[5,5],[2,5],[2,3]],"steps":[{"t":"move","p":[5,11]},{"t":"move","p":[2,11]},{"t":"move","p":[2,13]},{"t":"move","p":[5,13]},{"t":"move","p":[5,15]},{"t":"move","p":[2,15]},{"t":"move","p":[2,17]},{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":5},{"t":"appear","p":[5,10]},{"t":"move","p":[5,9]},{"t":"move","p":[2,9]},{"t":"move","p":[2,7]},{"t":"move","p":[5,7]},{"t":"move","p":[5,5]},{"t":"move","p":[2,5]},{"t":"move","p":[2,3]}]},{"motion":"FLY","start":[2,10],"end":[1,17],"checkpoints":[[2,9],[5,9],[5,8],[2,8],[2,6],[5,6],[5,5],[2,5],[2,3],[1,3],[5,12],[2,12],[2,14],[5,14],[5,15],[2,15],[2,17]],"steps":[{"t":"move","p":[2,9]},{"t":"move","p":[5,9]},{"t":"move","p":[5,8]},{"t":"move","p":[2,8]},{"t":"move","p":[2,6]},{"t":"move","p":[5,6]},{"t":"move","p":[5,5]},{"t":"move","p":[2,5]},{"t":"move","p":[2,3]},{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":5},{"t":"appear","p":[5,10]},{"t":"move","p":[5,12]},{"t":"move","p":[2,12]},{"t":"move","p":[2,14]},{"t":"move","p":[5,14]},{"t":"move","p":[5,15]},{"t":"move","p":[2,15]},{"t":"move","p":[2,17]}]},{"motion":"WALK","start":[2,10],"end":[1,17],"checkpoints":[[1,3]],"steps":[{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,17],"checkpoints":[[1,3]],"steps":[{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,17],"checkpoints":[[1,3]],"steps":[{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,3],"checkpoints":[[1,17]],"steps":[{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,3],"checkpoints":[[1,17]],"steps":[{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,3],"checkpoints":[[1,17]],"steps":[{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[3,10],"end":[2,2],"checkpoints":[[2,3]],"steps":[{"t":"wait","s":999},{"t":"move","p":[2,3]}]}],"flags":{"layerGainsEnabled":false,"dpInit":10,"dpPerSec":1,"dpMax":99,"enemyScale":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1}},"enemyOverrides":{},"waveId":"act1autochess_h07_01","bossId":"boss_1","content":"full","boss":{"poolHp":7200000,"poolMax":7200000}},"authoritative":true,"startAt":0,"serverNow":0,"elapsed":0,"speed":2,"watch":false,"done":false} \ No newline at end of file diff --git a/public/dev/perf/hard-hidden-r15.json b/public/dev/perf/hard-hidden-r15.json new file mode 100644 index 00000000..df85455e --- /dev/null +++ b/public/dev/perf/hard-hidden-r15.json @@ -0,0 +1 @@ +{"t":"b.start","battleId":"7.15.65.b1","fieldId":"b1","kind":"hidden","spec":{"v":1,"battleId":"7.15.65.b1","fieldId":"b1","kind":"hidden","seed":725852008,"modeId":"mode_multi_hard","round":15,"stageId":"act2autochess_m01","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"timeLimit":null,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":0,"units":[{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":12,"col":8,"dir":"DOWN","items":["chess_item_3_03_e_a"],"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":11,"col":4,"dir":"RIGHT","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":8,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst"},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo"},{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":10,"col":5,"dir":"RIGHT","items":[],"skillIndex":1},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":10,"col":8,"dir":"RIGHT","items":["chess_item_1_01_e_a","chess_item_4_04_e_b"],"skillIndex":1,"moduleId":"uniequip_002_wildmn"},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":9,"col":8,"dir":"RIGHT","items":["chess_item_3_04_e_a","chess_item_5_02_e_b"],"skillIndex":1,"moduleId":"uniequip_002_lionhd"},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":9,"col":9,"dir":"UP","items":["chess_item_4_05_e_a","chess_item_4_02_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":74},"preciShip":{"count":3,"active":true,"tier":2,"layers":108},"swiftShip":{"count":3,"active":true,"tier":1,"layers":92},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":32},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"lpForBoss":1205,"contentInfo":{"round":15,"kind":"hidden","shopLevel":6,"funds":0,"handUnits":6,"handChess":6,"roundStats":{"refreshes":3,"buys":4,"sells":0,"spent":11,"gainedChess":2,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}},{"playerId":"p_1","seat":1,"side":"R","colOffset":8,"units":[{"uid":246,"kind":"chess","chessId":"chess_char_6_05_a","row":12,"col":8,"dir":"DOWN","items":["chess_item_4_02_e_a","chess_item_4_08_e_a"],"skillIndex":2},{"uid":243,"kind":"chess","chessId":"chess_char_4_20_b","row":11,"col":8,"dir":"DOWN","items":["chess_item_4_12_e_a","chess_item_3_08_e_b"],"skillIndex":2,"moduleId":"uniequip_002_fartth"},{"uid":240,"kind":"chess","chessId":"chess_char_6_16_a","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":2},{"uid":175,"kind":"chess","chessId":"chess_char_2_18_b","row":10,"col":5,"dir":"RIGHT","items":["chess_item_4_10_e_b","chess_item_2_03_e_a"],"skillIndex":0,"moduleId":"uniequip_002_ashlok"},{"uid":274,"kind":"chess","chessId":"chess_char_2_17_b","row":10,"col":7,"dir":"RIGHT","items":[],"skillIndex":1,"moduleId":"uniequip_002_branch"},{"uid":177,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":8,"dir":"RIGHT","items":["chess_item_2_01_e_a","chess_item_4_06_e_b"],"skillIndex":2},{"uid":100,"kind":"chess","chessId":"chess_char_4_02_a","row":9,"col":8,"dir":"RIGHT","items":["chess_item_3_07_e_a","chess_item_3_06_e_b"],"skillIndex":2},{"uid":130,"kind":"chess","chessId":"chess_char_4_01_a","row":9,"col":9,"dir":"UP","items":["chess_item_4_04_e_a","chess_item_3_05_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":11},"sargonShip":{"count":1,"active":false,"tier":0,"layers":4},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":5},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":16},"lateranoShip":{"count":2,"active":false,"tier":0,"layers":4},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":0,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":3,"active":true,"tier":1,"layers":93},"preciShip":{"count":2,"active":true,"tier":1,"layers":83},"swiftShip":{"count":2,"active":true,"tier":1,"layers":86},"skillfulShip":{"count":1,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":2,"active":true,"tier":1,"layers":20},"steadShip":{"count":3,"active":true,"tier":2,"layers":40},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":3},"investShip":{"count":1,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":0},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":3,"active":true,"tier":1,"layers":0}},"bandId":"band_kirara","playerEffects":[],"deviceOverrides":{},"lpForBoss":1205,"contentInfo":{"round":15,"kind":"hidden","shopLevel":6,"funds":0,"handUnits":6,"handChess":6,"roundStats":{"refreshes":2,"buys":4,"sells":0,"spent":12,"gainedChess":4,"arts":0},"matchBands":["band_kirara","band_sarkazb","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_0","bandId":"band_sarkazb"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}}],"spawns":[{"time":15,"enemyKey":"enemy_1249_lysdb_2","routeIndex":0,"count":5,"interval":12,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":0,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":false,"boss":false}},{"time":25,"enemyKey":"enemy_1249_lysdb_2","routeIndex":1,"count":5,"interval":12,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":1,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":false,"boss":false}},{"time":11,"enemyKey":"enemy_10027_vtsk","routeIndex":4,"count":3,"interval":30,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":4,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":12,"enemyKey":"enemy_10027_vtsk","routeIndex":5,"count":3,"interval":30,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":5,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":0,"enemyKey":"enemy_9013_acstmk_2","routeIndex":6,"count":1,"interval":0,"mods":{"atkMul":2.143589,"speedMul":1,"slot":"N"},"actionIndex":6,"preview":{"gate":"lower","start":[3,10],"fly":false,"elite":true,"boss":true},"tag":"boss"},{"time":0,"enemyKey":"enemy_9014_acstma","routeIndex":7,"count":1,"interval":0,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":7,"preview":{"gate":"lower","start":[3,12],"fly":false,"elite":true,"boss":false},"tag":"part","countInTotal":false},{"time":0,"enemyKey":"enemy_9015_acstmb","routeIndex":8,"count":1,"interval":0,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":8,"preview":{"gate":"lower","start":[3,8],"fly":false,"elite":true,"boss":false},"tag":"part","countInTotal":false}],"routes":[{"motion":"WALK","start":[2,10],"end":[1,3],"checkpoints":[[1,17]],"steps":[{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,17],"checkpoints":[[1,3]],"steps":[{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"FLY","start":[5,10],"end":[1,3],"checkpoints":[[5,11],[2,11],[2,13],[5,13],[5,15],[2,15],[2,17],[1,17],[5,9],[2,9],[2,7],[5,7],[5,5],[2,5],[2,3]],"steps":[{"t":"move","p":[5,11]},{"t":"move","p":[2,11]},{"t":"move","p":[2,13]},{"t":"move","p":[5,13]},{"t":"move","p":[5,15]},{"t":"move","p":[2,15]},{"t":"move","p":[2,17]},{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":5},{"t":"appear","p":[5,10]},{"t":"move","p":[5,9]},{"t":"move","p":[2,9]},{"t":"move","p":[2,7]},{"t":"move","p":[5,7]},{"t":"move","p":[5,5]},{"t":"move","p":[2,5]},{"t":"move","p":[2,3]}]},{"motion":"FLY","start":[2,10],"end":[1,17],"checkpoints":[[2,9],[5,9],[5,8],[2,8],[2,6],[5,6],[5,5],[2,5],[2,3],[1,3],[5,12],[2,12],[2,14],[5,14],[5,15],[2,15],[2,17]],"steps":[{"t":"move","p":[2,9]},{"t":"move","p":[5,9]},{"t":"move","p":[5,8]},{"t":"move","p":[2,8]},{"t":"move","p":[2,6]},{"t":"move","p":[5,6]},{"t":"move","p":[5,5]},{"t":"move","p":[2,5]},{"t":"move","p":[2,3]},{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":5},{"t":"appear","p":[5,10]},{"t":"move","p":[5,12]},{"t":"move","p":[2,12]},{"t":"move","p":[2,14]},{"t":"move","p":[5,14]},{"t":"move","p":[5,15]},{"t":"move","p":[2,15]},{"t":"move","p":[2,17]}]},{"motion":"WALK","start":[2,10],"end":[1,17],"checkpoints":[[1,3]],"steps":[{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,3],"checkpoints":[[1,17]],"steps":[{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[3,10],"end":[2,2],"checkpoints":[[2,3]],"steps":[{"t":"wait","s":999},{"t":"move","p":[2,3]}]},{"motion":"WALK","start":[3,12],"end":[1,3],"checkpoints":[]},{"motion":"WALK","start":[3,8],"end":[1,3],"checkpoints":[]}],"flags":{"layerGainsEnabled":false,"dpInit":10,"dpPerSec":1,"dpMax":99,"enemyScale":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1}},"enemyOverrides":{},"waveId":"act1autochess_h08_01","bossId":"boss_8","content":"full","boss":{"poolHp":14400000,"poolMax":14400000}},"authoritative":true,"startAt":0,"serverNow":0,"elapsed":0,"speed":2,"watch":false,"done":false} \ No newline at end of file diff --git a/public/dev/perf/hard-normal-r13.json b/public/dev/perf/hard-normal-r13.json new file mode 100644 index 00000000..96bfd55f --- /dev/null +++ b/public/dev/perf/hard-normal-r13.json @@ -0,0 +1 @@ +{"t":"b.start","battleId":"7.13.58.n:p_0","fieldId":"n:p_0","kind":"normal","spec":{"v":1,"battleId":"7.13.58.n:p_0","fieldId":"n:p_0","kind":"normal","seed":720961930,"modeId":"mode_multi_hard","round":13,"stageId":"act2autochess_m01","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"timeLimit":230,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":0,"units":[{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":12,"col":4,"dir":"RIGHT","items":[],"skillIndex":1},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":12,"col":5,"dir":"DOWN","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo"},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":4,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst"},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":11,"col":5,"dir":"DOWN","items":["chess_item_4_04_e_a","chess_item_1_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_wildmn"},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":11,"col":7,"dir":"LEFT","items":[],"skillIndex":1,"moduleId":"uniequip_002_lionhd"},{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":5,"dir":"DOWN","items":["chess_item_3_05_e_a","chess_item_4_05_e_a"],"skillIndex":2},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":9,"col":7,"dir":"UP","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":58},"preciShip":{"count":3,"active":true,"tier":2,"layers":80},"swiftShip":{"count":3,"active":true,"tier":1,"layers":64},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":20},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"contentInfo":{"round":13,"kind":"normal","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":4,"buys":5,"sells":2,"spent":14,"gainedChess":2,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}}],"spawns":[{"time":7,"enemyKey":"enemy_1166_dusbr_2","routeIndex":0,"count":3,"interval":3,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":0,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":10,"enemyKey":"enemy_1166_dusbr_2","routeIndex":1,"count":3,"interval":5,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":1,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":39,"enemyKey":"enemy_1166_dusbr_2","routeIndex":2,"count":3,"interval":5,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":2,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":62,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":3,"interval":4,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":3,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":11,"enemyKey":"enemy_1169_duphlx_2","routeIndex":4,"count":5,"interval":5.4,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"actionIndex":4,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":37,"enemyKey":"enemy_1169_duphlx_2","routeIndex":5,"count":5,"interval":7.2,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"actionIndex":5,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":55,"enemyKey":"enemy_1169_duphlx_2","routeIndex":6,"count":5,"interval":5.4,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"actionIndex":6,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":2,"enemyKey":"enemy_1172_dugago","routeIndex":7,"count":2,"interval":20,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"actionIndex":7,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":true,"boss":false},"ownerPlayerId":"p_0"},{"time":10,"enemyKey":"enemy_1172_dugago","routeIndex":8,"count":2,"interval":14,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"actionIndex":8,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":true,"boss":false},"ownerPlayerId":"p_0"},{"time":32,"enemyKey":"enemy_1172_dugago","routeIndex":9,"count":2,"interval":15,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"actionIndex":9,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":true,"boss":false},"ownerPlayerId":"p_0"},{"time":7,"enemyKey":"enemy_1166_dusbr_2","routeIndex":10,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":10,"preview":{"gate":"upper","start":[12,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":3,"enemyKey":"enemy_1169_duphlx_2","routeIndex":11,"count":4,"interval":5,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"actionIndex":11,"preview":{"gate":"upper","start":[12,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":21,"enemyKey":"enemy_1172_dugago","routeIndex":12,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"actionIndex":12,"preview":{"gate":"upper","start":[12,10],"fly":false,"elite":true,"boss":false},"ownerPlayerId":"p_0"}],"routes":[{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[12,10],"end":[9,2],"checkpoints":[[12,9]]},{"motion":"WALK","start":[12,10],"end":[9,2],"checkpoints":[[12,9]]},{"motion":"WALK","start":[12,10],"end":[9,2],"checkpoints":[[12,9]]},{"motion":"FLY","start":[12,10],"end":[9,2],"checkpoints":[[12,9],[9,9],[9,7],[12,7],[12,6],[9,6],[9,5],[12,5],[12,4],[9,4]]},{"motion":"FLY","start":[12,10],"end":[9,2],"checkpoints":[[12,9],[9,9],[9,7],[12,7],[12,6],[9,6],[9,5],[12,5],[12,4],[9,4]]},{"motion":"FLY","start":[12,10],"end":[9,2],"checkpoints":[[12,9],[9,9],[9,7],[12,7],[12,6],[9,6],[9,5],[12,5],[12,4],[9,4]]},{"motion":"FLY","start":[12,10],"end":[9,2],"checkpoints":[[12,9],[9,9],[9,7],[12,7],[12,6],[9,6],[9,5],[12,5],[12,4],[9,4]]},{"motion":"FLY","start":[12,10],"end":[9,2],"checkpoints":[[12,9],[9,9],[9,7],[12,7],[12,6],[9,6],[9,5],[12,5],[12,4],[9,4]]},{"motion":"FLY","start":[12,10],"end":[9,2],"checkpoints":[[12,9],[11,8],[11,6],[10,6],[10,5],[11,5],[11,3],[9,3]]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,8],[12,8],[12,5],[9,5],[9,4],[12,4],[12,3],[9,3]]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,8],[12,8],[12,5],[9,5],[9,4],[12,4],[12,3],[9,3]]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,8],[12,8],[12,5],[9,5],[9,4],[12,4],[12,3],[9,3]]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,8],[11,8],[11,6],[10,6],[10,5],[11,5],[11,3],[9,3]]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,8],[11,8],[11,6],[10,6],[10,5],[11,5],[11,3],[9,3]]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,8],[11,8],[11,6],[10,6],[10,5],[11,5],[11,3],[9,3]]}],"flags":{"layerGainsEnabled":true,"dpInit":10,"dpPerSec":1,"dpMax":99},"enemyOverrides":{},"waveId":"act1autochess_h06","bossId":null,"content":"full","boss":null},"authoritative":true,"startAt":0,"serverNow":0,"elapsed":0,"speed":2,"watch":false,"done":false} \ No newline at end of file diff --git a/public/dev/perf/hard-unite-r13.json b/public/dev/perf/hard-unite-r13.json new file mode 100644 index 00000000..8e4dd44c --- /dev/null +++ b/public/dev/perf/hard-unite-r13.json @@ -0,0 +1 @@ +{"t":"b.start","battleId":"7.13.62.u","fieldId":"u","kind":"unite","spec":{"v":1,"battleId":"7.13.62.u","fieldId":"u","kind":"unite","seed":3495193848,"modeId":"mode_multi_hard","round":13,"stageId":"act2autochess_m01","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"timeLimit":230,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":8,"units":[{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":12,"col":4,"dir":"RIGHT","items":[],"skillIndex":1,"carryState":{"hpPct":0.3342892236024844,"sp":0}},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":12,"col":5,"dir":"DOWN","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo","carryState":{"down":true}},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":4,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst","carryState":{"hpPct":1,"sp":8}},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":11,"col":5,"dir":"DOWN","items":["chess_item_4_04_e_a","chess_item_1_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_wildmn","carryState":{"down":true}},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":11,"col":7,"dir":"LEFT","items":[],"skillIndex":1,"moduleId":"uniequip_002_lionhd","carryState":{"down":true}},{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":2,"moduleId":"uniequip_002_mostma","carryState":{"hpPct":1,"sp":0}},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":5,"dir":"DOWN","items":["chess_item_3_05_e_a","chess_item_4_05_e_a"],"skillIndex":2,"carryState":{"hpPct":0.7218991466789668,"sp":0}},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":9,"col":7,"dir":"UP","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2,"carryState":{"down":true}}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":74},"preciShip":{"count":3,"active":true,"tier":2,"layers":88},"swiftShip":{"count":3,"active":true,"tier":1,"layers":72},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":20},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"contentInfo":{"round":13,"kind":"unite","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":4,"buys":5,"sells":2,"spent":14,"gainedChess":2,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}},{"playerId":"ai_3","seat":3,"side":"L","colOffset":0,"units":[{"uid":80,"kind":"chess","chessId":"chess_char_3_11_a","row":12,"col":4,"dir":"RIGHT","items":["chess_item_1_02_e_b","chess_item_6_04_e_a"],"skillIndex":1,"carryState":{"hpPct":1,"sp":13.13}},{"uid":143,"kind":"chess","chessId":"chess_char_4_22_a","row":12,"col":5,"dir":"DOWN","items":["chess_item_3_02_e_a","chess_item_6_09_e_a"],"skillIndex":2,"carryState":{"hpPct":1,"sp":0.17}},{"uid":158,"kind":"chess","chessId":"chess_char_5_11_a","row":12,"col":6,"dir":"RIGHT","items":["chess_item_4_06_e_a","chess_item_4_04_e_b"],"skillIndex":1,"carryState":{"hpPct":0.9422580123247037,"sp":4.57}},{"uid":112,"kind":"chess","chessId":"chess_char_4_13_a","row":11,"col":4,"dir":"RIGHT","items":["chess_item_3_10_e_a"],"skillIndex":1,"carryState":{"hpPct":1,"sp":1.85}},{"uid":59,"kind":"chess","chessId":"chess_char_3_20_a","row":11,"col":5,"dir":"RIGHT","items":["chess_item_6_06_e_a","chess_item_6_07_e_a"],"skillIndex":1,"carryState":{"hpPct":1,"sp":0}},{"uid":146,"kind":"chess","chessId":"chess_char_4_21_a","row":11,"col":7,"dir":"LEFT","items":[],"skillIndex":1,"carryState":{"down":true}},{"uid":81,"kind":"chess","chessId":"chess_char_3_14_a","row":10,"col":4,"dir":"RIGHT","items":["chess_item_6_01_e_a","chess_item_3_11_e_b"],"skillIndex":1,"carryState":{"hpPct":1,"sp":31.97}},{"uid":82,"kind":"chess","chessId":"chess_char_1_02_a","row":10,"col":5,"dir":"DOWN","items":["chess_item_5_02_e_a","chess_item_1_02_e_a"],"skillIndex":1,"carryState":{"hpPct":1,"sp":46.61}}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":2},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":6,"active":true,"tier":2,"layers":133},"lateranoShip":{"count":0,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":0,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":0,"active":false,"tier":0,"layers":0},"preciShip":{"count":2,"active":true,"tier":1,"layers":20},"swiftShip":{"count":0,"active":false,"tier":0,"layers":0},"skillfulShip":{"count":2,"active":true,"tier":1,"layers":20},"arcaneShip":{"count":0,"active":false,"tier":0,"layers":17},"steadShip":{"count":2,"active":true,"tier":1,"layers":14},"deputShip":{"count":2,"active":true,"tier":1,"layers":20},"visiShip":{"count":1,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":0},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":21},"suntShip":{"count":0,"active":false,"tier":0,"layers":0}},"bandId":"band_cathy","playerEffects":[],"deviceOverrides":{},"contentInfo":{"round":13,"kind":"unite","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":5,"buys":5,"sells":1,"spent":11,"gainedChess":0,"arts":0},"matchBands":["band_cathy","band_sarkazb","band_kirara","band_bldsk"],"teammates":[{"playerId":"p_0","bandId":"band_sarkazb"},{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"}]}}],"spawns":[{"time":3,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":3.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":5.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":7,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":7.5,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":9,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":9.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":11,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":11.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":13,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":13.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":15,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":15.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":17,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":17.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":19,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":19.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":21,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":21.5,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":23,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":23.5,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":25,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":25.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":27.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":29.5,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":31.5,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":33.5,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":35.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":37.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":39.5,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":41.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":43.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":45.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":47.5,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":49.5,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":51.5,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":53.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":55.5,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":57.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3}],"routes":[{"motion":"FLY","start":[9,18],"end":[9,2],"checkpoints":[[9,17],[12,17],[12,15],[10,15],[10,13],[12,13],[12,11],[10,11],[10,9],[12,9],[12,7],[10,7],[10,5],[12,5],[12,3],[9,3]],"spawnRandom":[0.3,0.3]},{"motion":"FLY","start":[9,18],"end":[9,2],"checkpoints":[[9,17],[12,17],[12,15],[10,15],[10,13],[12,13],[12,11],[10,11],[10,9],[12,9],[12,7],[10,7],[10,5],[12,5],[12,3],[9,3]],"spawnRandom":[0.3,0.3]},{"motion":"FLY","start":[12,18],"end":[9,2],"checkpoints":[[12,15],[10,15],[10,13],[12,13],[12,11],[10,11],[10,9],[12,9],[12,7],[10,7],[10,5],[12,5],[12,3],[9,3]],"spawnRandom":[0.3,0.3]},{"motion":"WALK","start":[9,18],"end":[9,2],"checkpoints":[[9,10]]},{"motion":"WALK","start":[9,18],"end":[9,2],"checkpoints":[[9,10]]},{"motion":"WALK","start":[9,18],"end":[9,2],"checkpoints":[[9,10]]},{"motion":"WALK","start":[9,18],"end":[9,2],"checkpoints":[[9,10]]},{"motion":"FLY","start":[9,18],"end":[9,2],"checkpoints":[[9,17],[12,17],[12,15],[10,15],[10,13],[12,13],[12,11],[10,11],[10,9],[12,9],[12,7],[10,7],[10,5],[12,5],[12,3],[9,3]],"spawnRandom":[0.3,0.3]}],"flags":{"layerGainsEnabled":false,"dpInit":10,"dpPerSec":1,"dpMax":99},"enemyOverrides":{},"waveId":"act1autochess_escaped_multi","bossId":null,"content":"full","boss":null},"authoritative":true,"startAt":0,"serverNow":0,"elapsed":0,"speed":2,"watch":false,"done":false} \ No newline at end of file diff --git a/public/dev/perf/index.json b/public/dev/perf/index.json new file mode 100644 index 00000000..b6626410 --- /dev/null +++ b/public/dev/perf/index.json @@ -0,0 +1,38 @@ +[ + { + "name": "hard-normal-r13", + "title": "普通战 · 第 13 回合", + "kind": "normal", + "round": 13, + "stageId": "act2autochess_m01", + "players": 1, + "units": 8 + }, + { + "name": "hard-unite-r13", + "title": "联防 · 第 13 回合", + "kind": "unite", + "round": 13, + "stageId": "act2autochess_m01", + "players": 2, + "units": 16 + }, + { + "name": "hard-boss-r14", + "title": "最终攻势 · 第 14 回合", + "kind": "boss", + "round": 14, + "stageId": "act2autochess_m01", + "players": 2, + "units": 16 + }, + { + "name": "hard-hidden-r15", + "title": "隐秘核心 · 第 15 回合", + "kind": "hidden", + "round": 15, + "stageId": "act2autochess_m01", + "players": 2, + "units": 16 + } +] diff --git a/test/perfbench.test.js b/test/perfbench.test.js new file mode 100644 index 00000000..7b00d2f6 --- /dev/null +++ b/test/perfbench.test.js @@ -0,0 +1,123 @@ +// test/perfbench.test.js — the battle perf tooling: the committed specs of public/dev/perf (tools/capture-specs.mjs) still +// build and run with the current data and sim, the capture helpers, the perf page's frame figures (public/dev/frame-stats.js), +// and tools/perfbench.mjs's profile summary and per-platform Chrome flags. The page itself: +// test/render/battle-perf.browser.test.js (opt-in). + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { getData } from '../server/data.js'; +import { DataSource } from '../server/sim/simdata.js'; +import { createBattleFromSpec } from '../server/sim/spec.js'; +import { specMeta, pickDefaults, cleanStart } from '../tools/capture-specs.mjs'; +import { aggregateProfile, gpuArgs, areaOf } from '../tools/perfbench.mjs'; +import { frameFigures } from '../public/dev/frame-stats.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DIR = path.join(ROOT, 'public/dev/perf'); +const index = JSON.parse(readFileSync(path.join(DIR, 'index.json'), 'utf8')); + +describe('committed perf specs (public/dev/perf)', () => { + const ds = new DataSource(getData({ log: { warn() {}, error() {}, info() {} } }), null); + + test('the index lists one spec of each kind, each file a clean b.start', () => { + assert.deepEqual(index.map((s) => s.kind), ['normal', 'unite', 'boss', 'hidden']); + for (const s of index) { + const f = path.join(DIR, `${s.name}.json`); + assert.ok(existsSync(f), s.name); + const msg = JSON.parse(readFileSync(f, 'utf8')); + assert.equal(msg.t, 'b.start'); + assert.equal(msg.kind, s.kind); + assert.equal(msg.elapsed, 0); + assert.equal(msg.authoritative, true); + assert.equal(msg.spec.stageId, s.stageId); + assert.equal(msg.spec.players.length, s.players); + } + }); + + for (const s of index) { + test(`${s.name}: builds with the current data and runs 20 game seconds`, () => { + const msg = JSON.parse(readFileSync(path.join(DIR, `${s.name}.json`), 'utf8')); + const b = createBattleFromSpec(msg.spec, ds, { recordEvents: false }); + for (let i = 0; i < 600 && !b.finished; i++) b.step(); + assert.ok(b.finished || b.time >= 19.9, `battle time ${b.time}`); + const snap = b.snapshot(); + assert.ok(Array.isArray(snap.units) && snap.units.length > 0, 'units on the field'); + }); + } +}); + +describe('tools/capture-specs.mjs helpers', () => { + const start = (kind, n) => ({ t: 'b.start', kind, spec: { stageId: 'st', players: [{ units: Array(n).fill({}) }, { units: Array(n).fill({}) }] }, elapsed: 3.5, startAt: 9, serverNow: 9, authoritative: false }); + test('specMeta names and counts a b.start', () => { + assert.deepEqual(specMeta('HARD', start('unite', 8), 9), { name: 'hard-unite-r9', title: '联防 · 第 9 回合', kind: 'unite', round: 9, stageId: 'st', players: 2, units: 16 }); + }); + test('pickDefaults keeps the latest normal / unite / boss / hidden, the last send of a resent battle', () => { + const resent = start('boss', 8); + const picked = pickDefaults([ + { round: 4, msg: start('normal', 1) }, { round: 13, msg: start('normal', 8) }, { round: 7, msg: start('unite', 4) }, + { round: 9, msg: start('unite', 8) }, { round: 14, msg: start('boss', 8) }, { round: 14, msg: resent }, + ]); + assert.deepEqual(picked.map((c) => `${c.msg.kind}-r${c.round}`), ['normal-r13', 'unite-r9', 'boss-r14']); + assert.equal(picked[2].msg, resent); + }); + test('cleanStart: from the start, authoritative, no capture clocks', () => { + const c = cleanStart(start('normal', 1)); + assert.deepEqual([c.elapsed, c.startAt, c.serverNow, c.authoritative, c.watch, c.done], [0, 0, 0, true, false, false]); + }); +}); + +describe("the perf page's frame figures (public/dev/frame-stats.js)", () => { + test('average fps, nearest-rank percentiles and the stutter share of a sample', () => { + assert.deepEqual(frameFigures([40, 16, 17, 17], 33.4), { fps: 44.4, p50: 17, p95: 40, p99: 40, over33: 25 }); + }); + + test('one frame time is enough; no frame time gives null figures, not NaN / Infinity (the single-frame sample)', () => { + assert.deepEqual(frameFigures([16.7], 33.4), { fps: 59.9, p50: 16.7, p95: 16.7, p99: 16.7, over33: 0 }); + assert.deepEqual(frameFigures([], 33.4), { fps: null, p50: null, p95: null, p99: null, over33: null }); + assert.equal(frameFigures([0, 0], 33.4).fps, null); + }); +}); + +describe('tools/perfbench.mjs', () => { + test('Chrome GPU flags per platform: D3D11 on Windows, Metal on macOS, the default elsewhere', () => { + assert.ok(gpuArgs('win32').includes('--use-angle=d3d11')); + assert.ok(gpuArgs('darwin').includes('--use-angle=metal')); + assert.ok(!gpuArgs('linux').some((a) => a.startsWith('--use-angle'))); + }); + + test('areaOf sorts call frames by source', () => { + assert.equal(areaOf({ url: 'http://h/vendor/pixi-spine.js', functionName: 'apply' }), 'spine'); + assert.equal(areaOf({ url: 'http://h/vendor/pixi.min.js', functionName: 'render' }), 'pixi'); + assert.equal(areaOf({ url: 'http://h/sim/Battle.js', functionName: 'step' }), 'sim'); + assert.equal(areaOf({ url: '', functionName: '(idle)' }), 'idle'); + }); + + test('aggregateProfile: idle excluded; self time by area / function; stages inclusive, once per stack', () => { + const cf = (functionName, url) => ({ functionName, url, lineNumber: 0 }); + const profile = { + nodes: [ + { id: 1, callFrame: cf('(root)', ''), children: [2, 6, 7] }, + { id: 2, callFrame: cf('frameBody', 'http://h/js/render/app.js'), children: [3] }, + { id: 3, callFrame: cf('update', 'http://h/js/render/spine.js'), children: [4] }, + { id: 4, callFrame: cf('update', 'http://h/js/render/spine.js'), children: [5] }, // recursion: counted once + { id: 5, callFrame: cf('apply', 'http://h/vendor/pixi-spine.js'), children: [] }, + { id: 6, callFrame: cf('(idle)', ''), children: [] }, + { id: 7, callFrame: cf('(program)', ''), children: [] }, + ], + samples: [5, 5, 3, 6, 6, 7], + timeDeltas: [1000, 1000, 1000, 5000, 5000, 1000], + }; + const s = aggregateProfile(profile); + assert.equal(s.busyMs, 4); + assert.deepEqual(s.areas.map((a) => [a.area, a.pct]), [['spine', 50], ['render', 25], ['program', 25]]); + const st = Object.fromEntries(s.stages.map((x) => [x.stage, x.pct])); + assert.equal(st.spinePose, 75); + assert.equal(st.frameBody, 75); + assert.equal(st.program, 25); + assert.equal(st.sim, 0); + assert.match(s.top[0].fn, /^apply \/vendor\/pixi-spine\.js:1$/); + }); +}); diff --git a/test/render/battle-perf.browser.test.js b/test/render/battle-perf.browser.test.js new file mode 100644 index 00000000..6f17fbd2 --- /dev/null +++ b/test/render/battle-perf.browser.test.js @@ -0,0 +1,61 @@ +// test/render/battle-perf.browser.test.js — the battle perf page (public/dev/battle-perf.html) in headless Chrome: every +// committed spec starts, the battle runner steps the sim and the field view draws it, with a clean console; the sample +// hook tools/perfbench.mjs reads returns its figures. +// +// Opt-in (starts Chrome, needs the downloaded assets): RENDER_E2E=1 node --test test/render/battle-perf.browser.test.js +// Chrome path: $CHROME_PATH or the platform default of tools/perfbench.mjs. + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gpuArgs } from '../../tools/perfbench.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const CHROME = process.env.CHROME_PATH || { + win32: 'C:/Program Files/Google/Chrome/Application/chrome.exe', + darwin: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + linux: '/usr/bin/google-chrome', +}[process.platform]; +const enabled = process.env.RENDER_E2E === '1' && !!CHROME && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets')); +const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and the downloaded assets)'; +const index = JSON.parse(readFileSync(path.join(ROOT, 'public/dev/perf/index.json'), 'utf8')); + +describe('battle perf page in headless Chrome', { skip }, () => { + let srv, browser; + before(async () => { + const puppeteer = (await import('puppeteer-core')).default; + const { startServer } = await import('../../server/index.js'); + srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: gpuArgs() }); + }); + after(async () => { + await browser?.close(); + await srv?.close(); + }); + + for (const s of index) { + test(`${s.name}: the battle runs and draws, the sample hook reports`, async () => { + const page = await browser.newPage(); + const problems = []; + page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); + page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); }); + page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); }); + await page.setViewport({ width: 844, height: 390, deviceScaleFactor: 2 }); + await page.goto(`http://127.0.0.1:${srv.port}/dev/battle-perf.html?spec=${s.name}`); + await page.waitForFunction('window.__perf && (window.__perf.ready || window.__perf.error)', { timeout: 60000 }); + assert.equal(await page.evaluate(() => window.__perf.error), null); + await new Promise((r) => setTimeout(r, 3000)); + const r = await page.evaluate(() => window.__perf.sample(2000)); + const short = await page.evaluate(() => window.__perf.sample(0)); // still a full frame time, not 0 / 0 + await page.close(); + assert.deepEqual(problems, []); + assert.ok(r.fps > 10, `fps ${r.fps}`); + assert.ok(r.ticks > 60, `sim ticks ${r.ticks}`); + assert.ok(r.units > 0, `units ${r.units}`); + assert.ok(Number.isFinite(r.p95) && r.p95 > 0); + assert.ok(Number.isFinite(short.fps) && short.fps > 0 && short.p95 > 0, `sample(0): ${JSON.stringify(short)}`); + }); + } +}); diff --git a/tools/capture-specs.mjs b/tools/capture-specs.mjs new file mode 100644 index 00000000..5bb484f4 --- /dev/null +++ b/tools/capture-specs.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +// tools/capture-specs.mjs — capture real BattleSpecs for the battle perf page (public/dev/battle-perf.html) and +// tools/perfbench.mjs: the b.start a browser receives under client-side combat (DESIGN §14), taken from a headless bot +// match (virtual time, real simulation, like tools/matchrun.mjs). Co-op with 4 seats: the first two are humans on AI +// autoplay (so the match sends b.start to seat 1, whose battles are kept) and two bots; every player starts with a high +// LP so the match reaches the Final Assault and the Hidden Core. By default the last normal round, the last 联防 field +// of seat 1, the Final Assault and the Hidden Core are written: +// +// /--r.json the b.start message (elapsed / clocks zeroed, authoritative) +// /index.json [{ name, title, kind, round, stageId, players, units }] +// +// The match is deterministic for a seed, so a plain run reproduces the committed specs byte for byte as long as the +// game data and the bots do not change. +// +// Usage: node tools/capture-specs.mjs [--difficulty HARD] [--seed 7] [--out public/dev/perf] [--all] +// --all write every battle seat 1 played, not only the four defaults + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Match } from '../server/match/Match.js'; +import { VirtualScheduler } from '../server/match/scheduler.js'; +import { getData } from '../server/data.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const KIND_TITLE = { normal: '普通战', unite: '联防', boss: '最终攻势', hidden: '隐秘核心' }; + +/** One spec's file name and index row. */ +export function specMeta(difficulty, msg, round) { + const spec = msg.spec || {}; + const players = Array.isArray(spec.players) ? spec.players : []; + return { + name: `${difficulty.toLowerCase()}-${msg.kind}-r${round}`, + title: `${KIND_TITLE[msg.kind] || msg.kind} · 第 ${round} 回合`, + kind: msg.kind, round, stageId: spec.stageId, + players: players.length, + units: players.reduce((n, p) => n + (Array.isArray(p?.units) ? p.units.length : 0), 0), + }; +} + +/** The defaults: the last normal round, the last 联防, the Final Assault and the Hidden Core (latest send of each). */ +export function pickDefaults(captured) { + const last = new Map(); + for (const c of captured) last.set(`${c.msg.kind}-r${c.round}`, c); + const all = [...last.values()]; + const latest = (kind) => all.filter((c) => c.msg.kind === kind).sort((a, b) => b.round - a.round)[0]; + return ['normal', 'unite', 'boss', 'hidden'].map(latest).filter(Boolean); +} + +/** A b.start as the perf page starts it: from the beginning, authoritative, no clocks of the capturing run. */ +export function cleanStart(msg) { + return { ...msg, elapsed: 0, startAt: 0, serverNow: 0, authoritative: true, watch: false, done: false }; +} + +/** Run one bot match and return seat 1's b.start messages in order: [{ round, msg }]. */ +export function captureMatch({ difficulty = 'HARD', seed = 7, data = getData({ log: { warn() {}, error() {}, info() {} } }) } = {}) { + const sched = new VirtualScheduler(); + const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: i < 2 ? `p_${i}` : `ai_${i}`, name: i < 2 ? `P${i + 1}` : `AI-${i + 1}`, isBot: i >= 2, connected: true })); + const captured = []; + let summary = null; + const m = new Match({ + roomCode: 'PERF', mode: 'coop', difficulty, seats, seed, data, scheduler: sched, clientCombat: true, + log: { info() {}, debug() {}, warn() {}, error() {} }, + send: (pid, msg) => { + if (pid === 'p_0' && msg && msg.t === 'b.start' && !msg.watch) captured.push({ round: m.round, msg: JSON.parse(JSON.stringify(msg)) }); + return true; + }, + broadcast: () => {}, onEnd: (s) => { summary = s; }, + }); + for (const ps of m.players.values()) if (!ps.isBot) ps.autoplay = true; + let lpSet = false; + m.start(); + sched.runUntil(() => { + if (!lpSet && m.phase === 'PREP' && m.round === 1) { for (const ps of m.players.values()) ps.lp = 400; lpSet = true; } + return summary != null; + }, { maxSteps: 5e6 }); + m.dispose(); + return { captured, summary }; +} + +function main() { + const argv = process.argv.slice(2); + const opt = (k, d) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? argv[i + 1] : d; }; + const difficulty = String(opt('difficulty', 'HARD')).toUpperCase(); + const seed = Number(opt('seed', 7)); + const out = path.resolve(ROOT, opt('out', 'public/dev/perf')); + const { captured, summary } = captureMatch({ difficulty, seed }); + if (!summary) { console.error('the match did not end'); return 1; } + const keep = argv.includes('--all') ? [...new Map(captured.map((c) => [`${c.msg.kind}-r${c.round}`, c])).values()] : pickDefaults(captured); + fs.mkdirSync(out, { recursive: true }); + const index = []; + for (const c of keep) { + const meta = specMeta(difficulty, c.msg, c.round); + fs.writeFileSync(path.join(out, `${meta.name}.json`), JSON.stringify(cleanStart(c.msg))); + index.push(meta); + console.log(`${meta.name.padEnd(18)} ${meta.title} stage ${meta.stageId} players ${meta.players} units ${meta.units}`); + } + fs.writeFileSync(path.join(out, 'index.json'), `${JSON.stringify(index, null, 1)}\n`); + console.log(`${index.length} specs → ${path.relative(ROOT, out)} (match: ${summary.reason}, rounds ${summary.roundsPassed})`); + return 0; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main(); diff --git a/tools/perfbench.mjs b/tools/perfbench.mjs new file mode 100644 index 00000000..287499d8 --- /dev/null +++ b/tools/perfbench.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node +// tools/perfbench.mjs — frame-time benchmark of real battles in headless Chrome (public/dev/battle-perf.html: the battle +// runner + sim + field view, as in a match) for every spec × quality × CPU slowdown. Chrome DevTools CPU throttling +// stands in for slower phones (4× ≈ mid-range, 6× ≈ low-end — an approximation: no phone GPU, thermals or Safari). +// With --profile, a CPU profile of each sample is summarised by source area, by function (self time) and by pipeline +// stage (inclusive time: Spine pose, impostor redraw, Pixi render, three.js board, battle runner, sim, …). +// +// Usage: CHROME_PATH= node tools/perfbench.mjs [--specs a,b] [--quality high,low] [--cpu 1,4,6] [--warm 20] +// [--sample 6] [--width 844] [--height 390] [--dpr 2] [--profile] [--out test/e2e/out/perf] [--json] +// --specs names of public/dev/perf/index.json (default: all) --warm seconds before sampling (default 20) +// --sample seconds per sample (default 6) --profile also record and summarise a CPU profile +// Needs the downloaded assets (public/assets: real Spine models; without them every unit is a diamond and the numbers +// mean little). Writes /results.json (+ --x.cpuprofile / .summary.json with --profile). + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DEFAULT_CHROME = { + win32: 'C:/Program Files/Google/Chrome/Application/chrome.exe', + darwin: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + linux: '/usr/bin/google-chrome', +}; + +/** GPU flags for headless Chrome per platform (ANGLE backend; Metal exists on macOS only, D3D11 on Windows only). */ +export function gpuArgs(platform = process.platform) { + const angle = platform === 'win32' ? ['--use-angle=d3d11'] : platform === 'darwin' ? ['--use-angle=metal'] : []; + return ['--no-first-run', '--enable-gpu', '--ignore-gpu-blocklist', ...angle]; +} + +/** Source area of a profile call frame (vendor libraries, the game's own modules, the browser's own work). */ +export function areaOf(cf) { + const url = cf.url || ''; + if (!url) { + if (cf.functionName === '(garbage collector)') return 'gc'; + if (cf.functionName === '(idle)') return 'idle'; + if (cf.functionName === '(program)') return 'program'; + return 'native'; + } + if (/\/sim\//.test(url)) return 'sim'; + if (/\/js\/battle\//.test(url)) return 'runner'; + if (/\/js\/render\//.test(url)) return 'render'; + if (/\/js\/(ui|screens)\//.test(url)) return 'ui'; + if (/pixi-spine/.test(url)) return 'spine'; + if (/pixi(\.min)?\.js/.test(url)) return 'pixi'; + if (/three\./.test(url)) return 'three'; + return 'other'; +} + +/** Pipeline stages measured inclusively (a sample counts once per stage on its stack). */ +export const STAGES = [ + ['spinePose', 'Spine pose (actor.update)', (cf) => cf.functionName === 'update' && /render\/spine\.js/.test(cf.url)], + ['impostorFlush', 'impostor redraw (skeletons → atlas)', (cf) => cf.functionName === 'flush' && /render\/impostor\.js/.test(cf.url)], + ['unitUpdate', 'unit views (units.js update)', (cf) => cf.functionName === 'update' && /render\/units\.js/.test(cf.url)], + ['frameBody', 'view frame (all JS before the Pixi render)', (cf) => cf.functionName === 'frameBody' && /render\/app\.js/.test(cf.url)], + ['pixiRender', 'Pixi render (main + render textures)', (cf) => cf.functionName === 'render' && /pixi(\.min)?\.js/.test(cf.url)], + ['board3d', 'three.js board render', (cf) => cf.functionName === 'render' && /board3d\/scene\.js/.test(cf.url)], + ['runner', 'battle runner frame', (cf) => cf.functionName === 'frame' && /js\/battle\/runner\.js/.test(cf.url)], + ['sim', 'sim (any /sim/ module)', (cf) => /\/sim\//.test(cf.url || '')], + ['gc', 'garbage collector', (cf) => cf.functionName === '(garbage collector)'], + ['program', 'browser-native (program)', (cf) => cf.functionName === '(program)'], +]; + +/** + * Summary of a CDP CPU profile ({ nodes, samples, timeDeltas }): busy time (idle excluded), the share of each source + * area (self time), the top functions by self time, and each STAGES entry's inclusive share. + */ +export function aggregateProfile(profile, top = 30) { + const nodes = new Map(profile.nodes.map((n) => [n.id, n])); + const parent = new Map(); + for (const n of profile.nodes) for (const c of n.children || []) parent.set(c, n.id); + const area = new Map(), fn = new Map(), stage = new Map(STAGES.map(([k]) => [k, 0])); + let busy = 0; + for (let i = 0; i < profile.samples.length; i++) { + const dt = profile.timeDeltas[i] || 0; + const leaf = nodes.get(profile.samples[i]); + if (!leaf) continue; + const a = areaOf(leaf.callFrame); + if (a === 'idle') continue; + busy += dt; + area.set(a, (area.get(a) || 0) + dt); + const cf = leaf.callFrame; + const key = `${cf.functionName || '(anonymous)'} ${cf.url ? `${cf.url.replace(/^[a-z]+:\/\/[^/]+/, '')}:${cf.lineNumber + 1}` : ''}`.trim(); + fn.set(key, (fn.get(key) || 0) + dt); + const hit = new Set(); + for (let id = profile.samples[i]; id != null; id = parent.get(id)) { + const f = nodes.get(id).callFrame; + for (const [k, , test] of STAGES) if (!hit.has(k) && test(f)) hit.add(k); + } + for (const k of hit) stage.set(k, stage.get(k) + dt); + } + const pct = (us) => (busy > 0 ? Math.round((us / busy) * 1000) / 10 : 0); + const ms = (us) => Math.round(us / 100) / 10; + return { + busyMs: ms(busy), + areas: [...area].sort((x, y) => y[1] - x[1]).map(([name, us]) => ({ area: name, ms: ms(us), pct: pct(us) })), + stages: STAGES.map(([k, label]) => ({ stage: k, label, ms: ms(stage.get(k)), pct: pct(stage.get(k)) })), + top: [...fn].sort((x, y) => y[1] - x[1]).slice(0, top).map(([name, us]) => ({ fn: name, ms: ms(us), pct: pct(us) })), + }; +} + +async function main() { + const argv = process.argv.slice(2); + const opt = (k, d) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? argv[i + 1] : d; }; + const list = (k, d) => String(opt(k, d)).split(',').map((s) => s.trim()).filter(Boolean); + const index = JSON.parse(fs.readFileSync(path.join(ROOT, 'public/dev/perf/index.json'), 'utf8')); + const specs = argv.includes('--specs') ? list('specs', '') : index.map((s) => s.name); + const qualities = list('quality', 'high'); + const cpus = list('cpu', '1,4,6').map(Number); + const warm = Number(opt('warm', 20)), sample = Number(opt('sample', 6)); + const width = Number(opt('width', 844)), height = Number(opt('height', 390)), dpr = Number(opt('dpr', 2)); + const profileOn = argv.includes('--profile'); + const out = path.resolve(ROOT, opt('out', 'test/e2e/out/perf')); + const chrome = process.env.CHROME_PATH || DEFAULT_CHROME[process.platform]; + if (!chrome || !fs.existsSync(chrome)) { console.error(`Chrome not found (${chrome}); set CHROME_PATH`); return 2; } + if (!fs.existsSync(path.join(ROOT, 'public/assets/spine'))) console.warn('warning: public/assets/spine is missing (npm run setup): units draw as diamonds'); + fs.mkdirSync(out, { recursive: true }); + + const puppeteer = (await import('puppeteer-core')).default; + const { startServer } = await import('../server/index.js'); + const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: gpuArgs() }); + const results = []; + try { + for (const spec of specs) for (const quality of qualities) for (const cpu of cpus) { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setViewport({ width, height, deviceScaleFactor: dpr }); + await page.goto(`http://127.0.0.1:${srv.port}/dev/battle-perf.html?spec=${encodeURIComponent(spec)}&quality=${quality}&panel=0`); + await page.waitForFunction('window.__perf && (window.__perf.ready || window.__perf.error)', { timeout: 60000 }); + const err = await page.evaluate(() => globalThis.__perf.error); + if (err) { console.log(`${spec}: ${err.split('\n')[0]}`); await page.close(); continue; } + const cdp = await page.createCDPSession(); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: cpu }); + await new Promise((r) => setTimeout(r, warm * 1000)); + if (profileOn) { + await cdp.send('Profiler.enable'); + await cdp.send('Profiler.setSamplingInterval', { interval: 200 }); + await cdp.send('Profiler.start'); + } + const s = await page.evaluate((ms) => globalThis.__perf.sample(ms), sample * 1000); + let summary = null; + if (profileOn) { + const { profile } = await cdp.send('Profiler.stop'); + summary = aggregateProfile(profile); + const base = path.join(out, `${spec}-${quality}-${cpu}x`); + fs.writeFileSync(`${base}.cpuprofile`, JSON.stringify(profile)); + fs.writeFileSync(`${base}.summary.json`, JSON.stringify(summary, null, 1)); + } + const rec = { spec, quality, cpu, ...s, profiled: profileOn, errors: errors.slice(0, 3) }; + results.push(rec); + if (!argv.includes('--json')) { + console.log(`${spec.padEnd(18)} ${quality.padEnd(6)} ${String(cpu).padStart(2)}x fps ${String(s.fps).padStart(6)} p95 ${String(s.p95).padStart(6)} ms >33ms ${String(s.over33).padStart(5)}%` + + ` sim ${s.simMsPerTick} ms/tick view CPU ${s.viewCpuMs} ms units ${s.units} load ${s.lod}${errors.length ? ` ERRORS ${errors.length}` : ''}`); + if (summary) console.log(` ${summary.stages.filter((x) => x.pct > 0).map((x) => `${x.stage} ${x.pct}%`).join(' · ')}`); + } + await page.close(); + } + } finally { + fs.writeFileSync(path.join(out, 'results.json'), JSON.stringify(results, null, 1)); + await browser.close(); + await srv.close(); + } + if (argv.includes('--json')) console.log(JSON.stringify(results)); + return results.some((r) => r.errors.length) ? 1 : 0; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().then((code) => { process.exitCode = code; }, (err) => { console.error(err); process.exitCode = 1; }); +} From c36320fe4a73fb94e48b5ca0fe18e14254d62e9d Mon Sep 17 00:00:00 2001 From: Griseo-bh3rd Date: Thu, 8 Oct 2026 18:49:20 +0800 Subject: [PATCH 08/10] feedback7 G-TOOLS: add GitHub issue templates for bugs and feature requests (PR #300 by @Griseo-bh3rd) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied the two issue forms, then adjusted them so GitHub accepts the feature form. The 期望效果 textarea now has id expected-effect. The checkbox that told people not to tick it is gone from both forms. The feature checklist searches the lower-case bug label, the same spelling the bug form applies. No config.yml (blank issues stay available). The YAML does not create the bug or enhancement labels. Checked with Ruby's YAML parser: both files parse, every non-markdown field has a unique id, and the labels are bug and enhancement. No project test covers these files. --- .github/ISSUE_TEMPLATE/BugReport.yml | 79 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/FeatureRequest.yml | 49 ++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/BugReport.yml create mode 100644 .github/ISSUE_TEMPLATE/FeatureRequest.yml diff --git a/.github/ISSUE_TEMPLATE/BugReport.yml b/.github/ISSUE_TEMPLATE/BugReport.yml new file mode 100644 index 00000000..7dd3d7ed --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BugReport.yml @@ -0,0 +1,79 @@ +name: Bug 反馈 +description: 在使用的过程中遇到了 Bug。 +title: "[Bug]:" +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + 感谢您进行 Bug 反馈。请在上面的文本框⬆️起一个能够清晰描述您的问题的标题,便于开发者解决您的问题。 + + > [!important] + > 重要:如果要提出**多个 Bug**,请为每一个 Bug 开一个单独的 issue。 + > + > 如果您不知道如何有效、精准地表述,我们建议您先阅读《提问的智慧》([链接](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md))。 + - type: checkboxes + id: checklist + attributes: + label: 检查清单 + description: 在开始反馈这个问题之前,请先检查: + options: + - label: 我已更新到最新版 ![最新的版本号](https://img.shields.io/github/v/release/sganggs/Stronghold-Protocol?include_prereleases&style=flat-square&label=) 或 ![最新的版本号](https://img.shields.io/github/v/release/sganggs/Stronghold-Protocol?style=flat-square&label=) 测试,并看过 [最新提交](https://github.com/sganggs/Stronghold-Protocol/commits/master/),确认这一 Bug 还没有修复。我也已在 [Issues](https://github.com/sganggs/Stronghold-Protocol/issues) 中检索,确认这一 Bug 未被提交过。 + required: true + - label: 我已知晓并同意,此处仅用于汇报程序中存在的问题(关于其他非程序本身的问题应当在 Discussion 板块提出)。 + required: true + - type: markdown + attributes: + value: | + ### Bug 信息 + + 描述您遇到的 Bug。您可以附上截图、录屏、堆栈跟踪、日志等材料,便于开发者追踪问题。 + + > [!note] + > 上传附件时**请优先使用 GitHub 的附件系统上传附件**,将需要上传的附件粘贴或拖动到撰写区域即可上传。多个文件可打包为 zip 格式后上传。 + - type: textarea + id: expected + attributes: + label: 期望的行为 + description: 详细的描述你期望发生的行为,突出与目前(可能不正确的)行为的不同。 + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: 实际结果 + description: 实际发生的行为。 + validations: + required: true + - type: textarea + id: reproduce-steps + attributes: + label: 重现步骤 + description: | + 详细描述要怎么操作才能再次触发这个 Bug。 + placeholder: | + 1. 首先…… + 2. 然后…… + 3. …… + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: 截图/录屏(可选,建议) + description: | + 如果可能,请提供可以用来进一步描述您的问题的截图或录屏。 + - type: textarea + id: stacktrace + attributes: + label: 堆栈跟踪(可选) + description: 如果在遇到这个 Bug 时发生了崩溃(弹出崩溃提示),或者产生了错误日志,请将产生的堆栈跟踪信息粘贴到此处,便于开发者定位 Bug。 + render: shell + - type: checkboxes + id: revision + attributes: + label: 最后一步 + description: 回顾您的回答 + options: + - label: 我认为上述的描述已经足以详细,以允许开发人员能复现该问题。如果我的 issue 没有按照上述的要求填写,可能会被无条件关闭。 + required: true diff --git a/.github/ISSUE_TEMPLATE/FeatureRequest.yml b/.github/ISSUE_TEMPLATE/FeatureRequest.yml new file mode 100644 index 00000000..a7846cd0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FeatureRequest.yml @@ -0,0 +1,49 @@ +name: 功能请求 +description: 提出一项新的功能。 +title: "[Feature]:" +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + 感谢您贡献您的想法。 + 请使用简洁明确的标题描述您希望增加或改进的功能。便于开发者了解您的想法。 + + > [!important] + > 重要:如果您想要提出 **多项功能**,请为每一个功能开一个单独的 issue。 + - type: checkboxes + id: checklist + attributes: + label: 检查清单 + description: 在开始反馈这个问题之前,请先检查: + options: + - label: 我已将本应用更新到最新测试版 ![最新的版本号](https://img.shields.io/github/v/release/sganggs/Stronghold-Protocol?include_prereleases&style=flat-square&label=),并看过 [最新提交](https://github.com/sganggs/Stronghold-Protocol/commits/master/),确认我想要的功能还没有实现。我也已在 [Issues](https://github.com/sganggs/Stronghold-Protocol/issues?q=-label%3Abug)中检索,确认我想要的功能未被提交过。 + required: true + - type: markdown + attributes: + value: | + > [!note] + > 上传附件时**请优先使用 GitHub 的附件系统上传附件**,将需要上传的附件粘贴或拖动到撰写区域即可上传。多个文件可打包为 zip 格式后上传。 + - type: textarea + id: requested-feature + attributes: + label: 想要实现或优化的功能 + description: 详细地描述一下你想要的功能,联系到哪些已经存在的 BUG 或者功能(如有),描述的越具体,采纳的可能性越高。 + validations: + required: true + - type: textarea + id: expected-effect + attributes: + label: 期望效果 + placeholder: | + 请详细描述这个功能实现的效果。 + validations: + required: true + - type: checkboxes + id: revision + attributes: + label: 最后一步 + description: 回顾您的回答 + options: + - label: 我认为上述的描述已经足以详细,以便开发人员理解您想要的功能。如果我的 issue 没有按照上述的要求填写,可能会被无条件关闭。 + required: true From 51c53de189aedefa51bf0676add002699e720d93 Mon Sep 17 00:00:00 2001 From: LimitlessHPPK Date: Thu, 8 Oct 2026 18:53:24 +0800 Subject: [PATCH 09/10] feedback7 G-TOOLS: listen on :: by default so one socket serves IPv6 and IPv4 (PR #291 by @LimitlessHPPK) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported only the dual-stack listen from PR #291 (the change accepted as #188). With HOST unset the server binds :: and, if that fails with EAFNOSUPPORT, EADDRNOTAVAIL or EINVAL, retries 0.0.0.0. An explicit HOST, including 0.0.0.0 and 127.0.0.1, is tried once. The boot banner and doctor list shareable IPv6 addresses in brackets, skip fe80::, and keep one URL per /64. An explicit IPv6 literal in the local URL is bracketed. The Windows service listing no longer drops those bracketed lines. Not ported: /dev/grant, the settings-default migration, and the firewall scripts. The Docker image stays on 0.0.0.0; the alpine fallback was not booted. Tests: node --test test/ipv6-bind.test.js test/doctor.test.js test/ci.test.js — 20 pass, 0 fail (8 bind, 4 doctor, 8 ci). --- README.md | 2 +- docs/DEPLOY.md | 4 +- scripts/install-service-windows.ps1 | 8 +- scripts/launch.mjs | 10 +- scripts/run-server.cmd | 2 + server/http/boot.js | 33 ++++- server/http/config.js | 24 +++- server/index.js | 45 ++++-- test/ipv6-bind.test.js | 208 ++++++++++++++++++++++++++++ tools/ci.mjs | 2 +- tools/doctor.mjs | 90 +++++++++--- 11 files changed, 374 insertions(+), 54 deletions(-) create mode 100644 test/ipv6-bind.test.js diff --git a/README.md b/README.md index 70170017..c4390d3c 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ npm start # 启动服务器:http://localhost:3000 | 环境变量 | 默认 | 说明 | |---|---|---| | `PORT` | `3000` | 监听端口 | -| `HOST` | `0.0.0.0` | 监听地址(`127.0.0.1` = 只允许本机,放在反向代理后面时使用) | +| `HOST` | `::` | 监听地址。默认 `::` 是双栈:同一个端口同时接受 IPv6 和 IPv4;`0.0.0.0` = 只 IPv4;`127.0.0.1` = 只允许本机,放在反向代理后面时使用 | | `SP_COMBAT` | `client` | `client`:各玩家浏览器模拟自己的战斗(服务器负载极低);`server`:由服务器模拟并推流 | | `SP_VERIFY` | `off` | 服务器复算客户端上报的战斗结果:`off` / `sample`(约 1/8 抽查)/ `all`(全部复算,更耗 CPU) | | `TRUST_PROXY` | `auto` | 是否信任 `X-Forwarded-For` 等转发头:`auto` 只信任来自本机 / 内网的代理;`1` 总是;`0` 从不 | diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index ea375c9c..bc7e5ce5 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -106,7 +106,7 @@ powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1 winget install NSSM.NSSM # 或从 https://nssm.cc 下载 nssm install StrongholdProtocol "C:\Program Files\nodejs\node.exe" server\index.js nssm set StrongholdProtocol AppDirectory C:\Stronghold-Protocol -nssm set StrongholdProtocol AppEnvironmentExtra PORT=3000 HOST=0.0.0.0 +nssm set StrongholdProtocol AppEnvironmentExtra PORT=3000 HOST=:: nssm set StrongholdProtocol AppStdout C:\Stronghold-Protocol\logs\server.log nssm set StrongholdProtocol AppStderr C:\Stronghold-Protocol\logs\server.log nssm start StrongholdProtocol @@ -266,7 +266,7 @@ services: [Service] WorkingDirectory=/opt/Stronghold-Protocol ExecStart=/usr/bin/node server/index.js - Environment=PORT=3000 HOST=0.0.0.0 + Environment=PORT=3000 HOST=:: Restart=always RestartSec=5 User=stronghold diff --git a/scripts/install-service-windows.ps1 b/scripts/install-service-windows.ps1 index 6b11443e..6696a4bd 100644 --- a/scripts/install-service-windows.ps1 +++ b/scripts/install-service-windows.ps1 @@ -13,7 +13,7 @@ #> param( [int]$Port = 3000, - [string]$BindHost = '0.0.0.0', + [string]$BindHost = '::', [ValidateSet('client', 'server')][string]$Combat = 'client', [ValidateSet('off', 'sample', 'all')][string]$Verify = 'off', [string]$TaskName = 'StrongholdProtocol', @@ -162,8 +162,10 @@ try { Start-ScheduledTask -TaskName $TaskName -ErrorAction Stop Start-Sleep -Seconds 4 Show-Status - Write-Host "`n朋友访问地址(局域网):" - & $nodeExe tools\doctor.mjs --port $Port | Select-String -Pattern 'http://\d' | ForEach-Object { Write-Host " $($_.Line.Trim())" } + Write-Host "`n朋友访问地址(局域网 / IPv6,方括号不能少):" + # doctor prints a full URL. An IPv6 literal is bracketed, so a pattern that requires a digit right after http:// + # would drop it. Keep every http:// line; the rest of the report is classification text. + & $nodeExe tools\doctor.mjs --port $Port | Select-String -Pattern 'http://' | ForEach-Object { Write-Host " $($_.Line.Trim())" } Write-Host "`n停止:-Stop 重启:-Restart 状态:-Status 卸载:-Uninstall 日志:$Log" } catch { Write-Host "`n出错:$($_.Exception.Message)" -ForegroundColor Red diff --git a/scripts/launch.mjs b/scripts/launch.mjs index b4eaf1c4..0ceab47c 100755 --- a/scripts/launch.mjs +++ b/scripts/launch.mjs @@ -2,7 +2,7 @@ // scripts/launch.mjs — cross-platform "prepare + start + open the browser", used by scripts/start-windows.bat, // scripts/start-windows.ps1 and scripts/start.sh (docs/DEPLOY.md). // -// node scripts/launch.mjs [--port 3000] [--host 0.0.0.0] [--no-open] [--no-setup] [setup options…] +// node scripts/launch.mjs [--port 3000] [--host ::] [--no-open] [--no-setup] [setup options…] // // 1. If our server already answers on the port, just open the browser (double-clicking twice is harmless). // 2. An update package extracted over the folder (UPDATE.json) is finished (server/update.js; the server does it too, @@ -30,11 +30,11 @@ if (Number(process.versions.node.split('.')[0]) < 22) { } const { c, mark } = await import('../tools/setup.mjs'); -const { probePort, classifyAddresses, KIND_LABEL } = await import('../tools/doctor.mjs'); +const { probePort, classifyAddresses, hostUrl, KIND_LABEL } = await import('../tools/doctor.mjs'); const { applyPendingUpdate, UPDATE_FILE } = await import('../server/update.js'); function parseArgs(argv) { - const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '0.0.0.0', open: !/^(1|true|yes)$/i.test(process.env.SP_NO_BROWSER || ''), setup: true, setupArgs: [], help: false }; + const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '::', open: !/^(1|true|yes)$/i.test(process.env.SP_NO_BROWSER || ''), setup: true, setupArgs: [], help: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; const [k, v] = a.split('='); @@ -61,9 +61,9 @@ function printShare(port) { console.log(`${mark.ok} ${c.bold('服务器已启动')} 本机打开:${c.cyan(`http://localhost:${port}`)}`); if (addrs.length) { console.log(' 发给朋友(需要能访问这台电脑的网络):'); - for (const a of addrs.slice(0, 4)) console.log(` ${c.cyan(`http://${a.address}:${port}`)} ${c.dim(KIND_LABEL[a.kind])}`); + for (const a of addrs.slice(0, 4)) console.log(` ${c.cyan(hostUrl(a.address, port))} ${c.dim(KIND_LABEL[a.kind])}`); } else { - console.log(c.warn(' 没有检测到局域网地址:朋友暂时无法连接(检查网线/Wi-Fi)。')); + console.log(c.warn(' 没有检测到可以分享的地址:朋友暂时无法连接(检查网线/Wi-Fi)。')); } console.log(c.dim(' 建房后把 4 位「同盟密钥」或「复制链接」(…/?room=密钥)发给朋友。')); console.log(c.dim(' 朋友打不开?运行 node tools/doctor.mjs 检查防火墙。按 Ctrl+C 停止服务器。')); diff --git a/scripts/run-server.cmd b/scripts/run-server.cmd index 7ae24ba7..ccdc8f77 100644 --- a/scripts/run-server.cmd +++ b/scripts/run-server.cmd @@ -5,6 +5,8 @@ rem Output goes to logs\server.log (rotated at 10 MB to logs\server.old.log); th setlocal EnableExtensions cd /d "%~dp0.." set "NODE_EXE=node" +rem Dual-stack default (IPv6 and IPv4 on one socket). scripts\service.env.cmd may override HOST. +set "HOST=::" if exist "%~dp0service.env.cmd" call "%~dp0service.env.cmd" if not exist "logs" mkdir "logs" diff --git a/server/http/boot.js b/server/http/boot.js index f1d3eb0a..ede2d96f 100644 --- a/server/http/boot.js +++ b/server/http/boot.js @@ -14,18 +14,43 @@ import fs from 'node:fs'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { APP_VERSION, DEV_BUILD } from '../../shared/constants.js'; +import { limitKeyOf } from '../net.js'; import { applyPendingUpdate } from '../update.js'; import { ROOT } from './config.js'; -/** Non-internal IPv4 addresses as http URLs. @param {number} port */ +/** + * Non-internal addresses as http URLs — IPv4 first, then IPv6 (an IPv6 literal needs brackets: `http://[240e:…]:3000`). + * Link-local (`fe80::`) is left out: a URL cannot carry the zone id. Several privacy addresses in one /64 collapse + * to one URL (`limitKeyOf`). + * @param {number} port + */ export function lanUrls(port) { - const out = []; + const v4 = []; + const v6 = []; + const v6Seen = new Set(); for (const addrs of Object.values(os.networkInterfaces())) { for (const a of addrs || []) { - if ((a.family === 'IPv4' || a.family === 4) && !a.internal) out.push(`http://${a.address}:${port}`); + if (a.internal) continue; + if (a.family === 'IPv4' || a.family === 4) { v4.push(`http://${a.address}:${port}`); continue; } + if (a.family !== 'IPv6' && a.family !== 6) continue; + if (/^fe80:/i.test(a.address)) continue; + const prefix = limitKeyOf(a.address); + if (v6Seen.has(prefix)) continue; + v6Seen.add(prefix); + v6.push(`http://[${a.address}]:${port}`); } } - return out; + return [...v4, ...v6]; +} + +/** + * The host as it appears in the local URL. A wildcard bind (`0.0.0.0` or `::`) reads `localhost`. An IPv6 literal + * is bracketed (`http://[2001:db8::1]:3000`); an IPv4 address or a name is kept. + * @param {string} host + */ +export function displayHost(host) { + if (host === '0.0.0.0' || host === '::') return 'localhost'; + return host.includes(':') ? `[${host}]` : host; } /** Is the module whose `import.meta.url` is `metaUrl` the file node was started with? */ diff --git a/server/http/config.js b/server/http/config.js index d512a142..3d1bd123 100644 --- a/server/http/config.js +++ b/server/http/config.js @@ -1,6 +1,6 @@ // server/http/config.js — where the server's settings come from. startServer() options win over the environment: // -// * PORT (default 3000), HOST (default 0.0.0.0); +// * PORT (default 3000), HOST (default '::', one dual-stack socket for IPv6 and IPv4); // * TRUST_PROXY ('auto' default: honour CF-Connecting-IP / X-Real-IP / X-Forwarded-For only from loopback/private // peers such as a local cloudflared; '1' always; '0' never) → net.js trustProxy; // * DEBUG → the console logger's debug level; @@ -21,18 +21,36 @@ const NET_OPTION_KEYS = ['reconnectWindowMs', 'heartbeatMs', 'helloTimeoutMs', ' const LOBBY_OPTION_KEYS = ['lobbyGraceMs', 'maxRooms', 'maxRoomsPerAddr', 'maxMatchesPerAddr', 'resyncMinGapMs', 'soloReconnectWindowMs']; /** - * Where to listen: the `port` / `host` options, else PORT / HOST, else port 3000 on 0.0.0.0. + * Bind address used when neither the `host` option nor `HOST` says otherwise: one dual-stack socket, so the server + * answers IPv6 and IPv4 alike without a second listener (Node keeps `ipv6Only` off for `::`). + * `HOST=0.0.0.0` still means IPv4 only, `HOST=127.0.0.1` still means loopback only (a reverse proxy in front). + */ +export const DEFAULT_BIND_HOST = '::'; + +/** + * Where to listen: the `port` / `host` options, else PORT / HOST, else port 3000 on DEFAULT_BIND_HOST. + * An empty host is treated as unset. * @param {{ port?: number, host?: string }} opts * @returns {{ port: number, host: string }} * @throws {RangeError} when the port is not an integer in 0…65535 */ export function listenAddress(opts) { const port = opts.port ?? (process.env.PORT != null && process.env.PORT !== '' ? Number(process.env.PORT) : 3000); - const host = opts.host ?? process.env.HOST ?? '0.0.0.0'; + const host = (opts.host || process.env.HOST) || DEFAULT_BIND_HOST; if (!Number.isInteger(port) || port < 0 || port > 65535) throw new RangeError(`invalid PORT ${port}`); return { port, host }; } +/** + * The hosts to try, in order, for a bind. Only the default is retried: a host with IPv6 switched off refuses `::` + * with EAFNOSUPPORT / EADDRNOTAVAIL / EINVAL, and falling back to IPv4 beats not booting. An explicit HOST is literal. + * @param {string} host a host out of listenAddress() + * @returns {string[]} + */ +export function bindCandidates(host) { + return host === DEFAULT_BIND_HOST ? [DEFAULT_BIND_HOST, '0.0.0.0'] : [host]; +} + /** * The directories the static server reads (packsDir: the pack folders, server/packs.js). * @param {{ publicDir?: string, dataDir?: string, sharedDir?: string, packsDir?: string }} opts diff --git a/server/index.js b/server/index.js index 440d2664..2543c3de 100644 --- a/server/index.js +++ b/server/index.js @@ -1,7 +1,7 @@ // server/index.js — process entry & boot (DESIGN §1, §2). Plain node:http + ws, no framework: startServer() below // wires the modules under server/http/, in this order — // -// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST 0.0.0.0, TRUST_PROXY auto, DEBUG), +// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST '::' dual-stack, TRUST_PROXY auto, DEBUG), // which startServer() options go to net.js / lobby.js, the console logger // http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB; // refused at upgrade with 404 / 429 per network / 503) @@ -24,7 +24,7 @@ import http from 'node:http'; import { getData, loadData } from './data.js'; -import { ROOT, listenAddress, serveDirs, makeLogger, parseTrustProxy } from './http/config.js'; +import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy } from './http/config.js'; import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js'; import { DATA_SHIM_JS, createStaticHandler } from './http/static.js'; import { createPackRegistry } from './packs.js'; @@ -32,7 +32,7 @@ import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js'; import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js'; import { createRequestHandler } from './http/routes.js'; import { answerClientError } from './http/common.js'; -import { lanUrls, isProcessEntry, runMain } from './http/boot.js'; +import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js'; // The public API of this module (tests and tools import it from here); the code lives in ./http/. export { @@ -77,14 +77,35 @@ export async function startServer(opts = {}) { server.on('clientError', answerClientError); const wss = attachWebSocket(server, { network, log }); + // The address actually bound. The default may fall back to IPv4; the returned host and url follow that. + let boundHost; try { - await new Promise((resolve, reject) => { - const onError = (e) => { server.off('listening', onListening); reject(e); }; - const onListening = () => { server.off('error', onError); resolve(); }; - server.once('error', onError); - server.once('listening', onListening); - server.listen(port, host); - }); + // A host with IPv6 switched off refuses '::'. Fall back to IPv4 rather than not booting. Only the default is + // retried: an explicit HOST is literal (server/http/config.js bindCandidates). + let bound = null; + let lastError = null; + const candidates = bindCandidates(host); + for (let i = 0; i < candidates.length; i++) { + const candidate = candidates[i]; + try { + await new Promise((resolve, reject) => { + const onError = (e) => { server.off('listening', onListening); reject(e); }; + const onListening = () => { server.off('error', onError); resolve(); }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(port, candidate); + }); + bound = candidate; + break; + } catch (e) { + lastError = e; + const retry = ['EAFNOSUPPORT', 'EADDRNOTAVAIL', 'EINVAL'].includes(e.code) && i < candidates.length - 1; + if (!retry) break; + log.warn(`[boot] cannot bind ${candidate} (${e.code}) — falling back to IPv4 only`); + } + } + if (bound === null) throw lastError; + boundHost = bound; } catch (e) { network.close(); // stop heartbeat/sweep timers of the half-built server throw e; @@ -93,7 +114,7 @@ export async function startServer(opts = {}) { const addr = server.address(); const actualPort = typeof addr === 'object' && addr ? addr.port : port; - const url = `http://${host === '0.0.0.0' || host === '::' ? 'localhost' : host}:${actualPort}`; + const url = `http://${displayHost(boundHost)}:${actualPort}`; let closing = null; async function close() { @@ -111,7 +132,7 @@ export async function startServer(opts = {}) { return closing; } - return { port: actualPort, host, url, server, wss, lobby, network, registry, packs, close }; + return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, close }; } // `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js). diff --git a/test/ipv6-bind.test.js b/test/ipv6-bind.test.js new file mode 100644 index 00000000..a3b0c434 --- /dev/null +++ b/test/ipv6-bind.test.js @@ -0,0 +1,208 @@ +// test/ipv6-bind.test.js — dual-stack listen (accepted as #188, ported from PR #291 without /dev/grant, +// the settings migration, the firewall scripts, or a Dockerfile HOST change). +// +// With HOST unset the server binds '::' (Node leaves ipv6Only off, so one socket answers IPv6 and IPv4). +// That default is the only host retried, and only on EAFNOSUPPORT / EADDRNOTAVAIL / EINVAL. An explicit HOST, +// including 0.0.0.0 and 127.0.0.1, is tried once. The local URL brackets an IPv6 literal. The image stays on +// 0.0.0.0 until an alpine boot of the fallback has actually been run. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import net from 'node:net'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { classifyAddresses, hostUrl } from '../tools/doctor.mjs'; +import { DEFAULT_BIND_HOST, listenAddress, bindCandidates } from '../server/http/config.js'; +import { displayHost } from '../server/http/boot.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const doc = (p) => readFileSync(join(ROOT, p), 'utf8'); + +function restoreHost(saved) { + if (saved === undefined) delete process.env.HOST; + else process.env.HOST = saved; +} + +test('the listen entry points default to ::, and the image stays on IPv4', () => { + assert.match(doc('server/http/config.js'), /export const DEFAULT_BIND_HOST = '::';/); + assert.match(doc('server/http/config.js'), /opts\.host \|\| process\.env\.HOST\) \|\| DEFAULT_BIND_HOST/); + assert.match(doc('server/http/boot.js'), /for \(const u of lanUrls\(srv\.port\)\)/); + assert.match(doc('scripts/run-server.cmd'), /^\s*set "HOST=::"$/m); + assert.match(doc('scripts/install-service-windows.ps1'), /\[string\]\$BindHost = '::',/); + assert.match(doc('scripts/install-service-windows.ps1'), /Select-String -Pattern 'http:\/\/'/); + assert.doesNotMatch(doc('scripts/install-service-windows.ps1'), /http:\/\/\\d/); + assert.match(doc('scripts/launch.mjs'), /process\.env\.HOST \|\| '::'/); + assert.match(doc('tools/doctor.mjs'), /process\.env\.HOST \|\| '::'/); + assert.match(doc('README.md'), /\| `HOST` \| `::` \|/); + assert.match(doc('Dockerfile'), /HOST=0\.0\.0\.0/); + assert.doesNotMatch(doc('Dockerfile'), /HOST=::/); +}); + +test('listenAddress: the option wins over the environment, the environment over the default', () => { + const saved = process.env.HOST; + try { + delete process.env.HOST; + assert.deepEqual(listenAddress({}), { port: 3000, host: DEFAULT_BIND_HOST }); + assert.equal(listenAddress({ host: '' }).host, DEFAULT_BIND_HOST, 'an empty host is no host'); + process.env.HOST = '127.0.0.1'; + assert.equal(listenAddress({}).host, '127.0.0.1', 'HOST is kept when it is set'); + assert.equal(listenAddress({ host: '0.0.0.0' }).host, '0.0.0.0', 'the option wins over HOST'); + } finally { + restoreHost(saved); + } + assert.equal(listenAddress({ port: 0 }).port, 0, 'port 0 is a real port (an ephemeral one)'); + assert.equal(listenAddress({ host: '127.0.0.1' }).host, '127.0.0.1'); + assert.throws(() => listenAddress({ port: 70000 }), RangeError); +}); + +test('bindCandidates: only the default is retried, an explicit host is literal', () => { + assert.deepEqual(bindCandidates(DEFAULT_BIND_HOST), ['::', '0.0.0.0']); + assert.deepEqual(bindCandidates('0.0.0.0'), ['0.0.0.0']); + assert.deepEqual(bindCandidates('127.0.0.1'), ['127.0.0.1']); + assert.deepEqual(bindCandidates('192.168.1.7'), ['192.168.1.7']); + assert.deepEqual(bindCandidates('2001:db8::1'), ['2001:db8::1']); +}); + +test('an IPv6 literal is bracketed in a URL, a wildcard bind reads localhost', () => { + assert.equal(hostUrl('192.168.1.7', 3000), 'http://192.168.1.7:3000'); + assert.equal(hostUrl('100.64.0.9', 3000), 'http://100.64.0.9:3000'); + assert.equal(hostUrl('240e:3b7:8c4:40f0::1000', 3000), 'http://[240e:3b7:8c4:40f0::1000]:3000'); + assert.equal(hostUrl('fe80::320d:9eff:fe07:e79a', 8080), 'http://[fe80::320d:9eff:fe07:e79a]:8080'); + assert.equal(displayHost('0.0.0.0'), 'localhost'); + assert.equal(displayHost('::'), 'localhost'); + assert.equal(displayHost('::1'), '[::1]'); + assert.equal(displayHost('2001:db8::1'), '[2001:db8::1]'); + assert.equal(displayHost('127.0.0.1'), '127.0.0.1'); +}); + +test('classifyAddresses: IPv6 kinds, and one entry per /64', () => { + const v6 = (name, ...addresses) => [name, addresses.map((address) => ({ family: 'IPv6', address, internal: false }))]; + const list = classifyAddresses(Object.fromEntries([ + v6('以太网', 'fe80::1', '240e:3b7:8c4:40f0:bb2b:c23f:a265:a95d', '240e:3b7:8c4:40f0:7c4a:8d15:f95:fc16', 'fd00::5'), + v6('vEthernet (Default Switch)', '2001:db8::1', '2002:c0a8:101::1'), + v6('Tailscale', 'fd7a:115c:a1e0::1'), + ['Loopback Pseudo-Interface 1', [{ family: 'IPv6', address: '::1', internal: true }]], + ])); + const kindOf = (address) => list.find((a) => a.address === address)?.kind; + assert.equal(kindOf('240e:3b7:8c4:40f0:bb2b:c23f:a265:a95d'), 'public'); + assert.equal(kindOf('fe80::1'), 'linklocal'); + assert.equal(kindOf('fd00::5'), 'lan'); + assert.equal(kindOf('fd7a:115c:a1e0::1'), 'vpn'); + assert.equal(kindOf('2001:db8::1'), 'virtual'); + assert.equal(kindOf('2002:c0a8:101::1'), 'virtual'); + assert.equal(list.filter((a) => a.kind === 'public').length, 1, 'the two addresses of one /64 collapse into one'); + assert.ok(!list.some((a) => a.address === '::1'), 'loopback is internal'); +}); + +test('lanUrls lists IPv4 first, brackets IPv6, and drops link-local', async () => { + const { lanUrls } = await import('../server/index.js'); + const urls = lanUrls(3000); + for (const u of urls) assert.match(u, /^http:\/\/(\[[0-9a-fA-F:]+\]|\d{1,3}(?:\.\d{1,3}){3}):3000$/, u); + assert.ok(!urls.some((u) => u.includes('[fe80:')), 'no link-local URLs'); + const firstV6 = urls.findIndex((u) => u.includes('[')); + if (firstV6 > 0) assert.ok(urls.slice(0, firstV6).every((u) => !u.includes('[')), 'IPv4 comes before IPv6'); +}); + +test('the default bind is one dual-stack socket, and an explicit host is not rewritten', async (t) => { + const hasV6 = await new Promise((resolve) => { + const probe = net.createServer(); + probe.once('error', () => resolve(false)); + probe.listen(0, '::', () => probe.close(() => resolve(true))); + }); + if (!hasV6) { t.skip('this host has no IPv6'); return; } + + const saved = process.env.HOST; + delete process.env.HOST; + const { startServer } = await import('../server/index.js'); + const get = (srv, host) => new Promise((resolve) => { + const req = http.get({ host, port: srv.port, path: '/healthz' }, (res) => { res.resume(); resolve(res.statusCode); }); + req.on('error', (e) => resolve(e.code || 0)); + }); + const dual = await startServer({ port: 0, quiet: true }); + try { + assert.equal(dual.host, '::'); + assert.equal(dual.server.address().family, 'IPv6'); + assert.match(dual.url, /^http:\/\/localhost:\d+$/); + assert.equal(await get(dual, '::1'), 200); + assert.equal(await get(dual, '127.0.0.1'), 200); + } finally { + await dual.close(); + } + + const loop = await startServer({ port: 0, host: '::1', quiet: true }); + try { + assert.equal(loop.host, '::1'); + assert.match(loop.url, /^http:\/\/\[::1\]:\d+$/); + assert.equal(await get(loop, '::1'), 200); + } finally { + await loop.close(); + } + + const v4 = await startServer({ port: 0, host: '0.0.0.0', quiet: true }); + try { + assert.equal(v4.host, '0.0.0.0'); + assert.match(v4.url, /^http:\/\/localhost:\d+$/); + assert.equal(await get(v4, '127.0.0.1'), 200); + assert.notEqual(await get(v4, '::1'), 200, 'HOST=0.0.0.0 stays IPv4 only'); + } finally { + await v4.close(); + restoreHost(saved); + } +}); + +test('when :: cannot be bound, the default falls back to 0.0.0.0; EADDRINUSE does not', async () => { + const proto = http.Server.prototype; + const orig = proto.listen; + const saved = process.env.HOST; + delete process.env.HOST; + const { startServer } = await import('../server/index.js'); + + const stub = (failCode) => { + const tried = []; + proto.listen = function (port, host, ...rest) { + tried.push(host); + if (host === '::') { + const err = Object.assign(new Error(failCode), { code: failCode }); + process.nextTick(() => this.emit('error', err)); + return this; + } + return orig.call(this, port, host, ...rest); + }; + return tried; + }; + + try { + const tried = stub('EAFNOSUPPORT'); + const srv = await startServer({ port: 0, quiet: true }); + try { + assert.deepEqual(tried, ['::', '0.0.0.0']); + assert.equal(srv.host, '0.0.0.0'); + assert.match(srv.url, /^http:\/\/localhost:\d+$/); + assert.equal(srv.server.address().family, 'IPv4'); + } finally { + await srv.close(); + } + + const refused = stub('EADDRINUSE'); + await assert.rejects(() => startServer({ port: 0, quiet: true }), (e) => e && e.code === 'EADDRINUSE'); + assert.deepEqual(refused, ['::'], 'a port conflict is not an IPv6-missing error'); + + const explicit = []; + proto.listen = function (port, host, ...rest) { + explicit.push(host); + const err = Object.assign(new Error('EAFNOSUPPORT'), { code: 'EAFNOSUPPORT' }); + process.nextTick(() => this.emit('error', err)); + return this; + }; + await assert.rejects( + () => startServer({ port: 0, host: '::1', quiet: true }), + (e) => e && e.code === 'EAFNOSUPPORT', + ); + assert.deepEqual(explicit, ['::1'], 'an explicit host is not retried'); + } finally { + proto.listen = orig; + restoreHost(saved); + } +}); diff --git a/tools/ci.mjs b/tools/ci.mjs index c4f3d733..a5dc8068 100644 --- a/tools/ci.mjs +++ b/tools/ci.mjs @@ -273,7 +273,7 @@ function stopServer(child) { /** The smoke step of ci.yml's job `test`, on a free port instead of 3000 (a running game server must not clash). */ async function smoke(env) { const port = await freePort(); - // [ASSUMED] HOST=127.0.0.1 instead of ci.yml's default 0.0.0.0: the requests go to 127.0.0.1 either way, and + // [ASSUMED] HOST=127.0.0.1 instead of the unset-HOST default (::): the requests go to 127.0.0.1 either way, and // listening only on loopback spares a firewall prompt on Windows / macOS. const child = spawn(process.execPath, ['server/index.js'], { cwd: ROOT, env: { ...env, PORT: String(port), HOST: '127.0.0.1' }, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/tools/doctor.mjs b/tools/doctor.mjs index 7c8aa45f..e7f59e7e 100755 --- a/tools/doctor.mjs +++ b/tools/doctor.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // tools/doctor.mjs — diagnose an install (docs/DEPLOY.md「排错」). Read-only: changes nothing. // -// node tools/doctor.mjs [--port 3000] [--host 0.0.0.0] +// node tools/doctor.mjs [--port 3000] [--host ::] // // Checks: Node/npm versions, dependencies, public/vendor, data/*.json, the shipped files against the release's // MANIFEST.json (server/update.js checkInstall; a source checkout has none) and an update package not applied yet, @@ -38,28 +38,69 @@ const VPN_IF = /(tailscale|zerotier|^zt|wireguard|^wg\d|tun\d|tap|radmin|hamachi function ipv4ToInt(ip) { return ip.split('.').reduce((n, x) => (n << 8) + Number(x), 0) >>> 0; } function inCidr(ip, base, bits) { const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; return (ipv4ToInt(ip) & mask) === (ipv4ToInt(base) & mask); } +/** The first two hextets of an IPv6 address (a zone id is dropped); null when it cannot be parsed. @param {string} ip */ +function ipv6Head(ip) { + const [a = '', b = ''] = String(ip).split('%')[0].split(':'); + const h1 = parseInt(a, 16); + if (!Number.isInteger(h1)) return null; + const h2 = parseInt(b, 16); + return [h1, Number.isInteger(h2) ? h2 : 0]; +} + +/** An http URL for one address. An IPv6 literal needs brackets (`http://[240e:…]:3000`). Also used by scripts/launch.mjs. */ +export function hostUrl(address, port) { + return `http://${String(address).includes(':') ? `[${address}]` : address}:${port}`; +} + +/** IPv6 counterpart of the IPv4 chain (same order, same kinds). @param {string} name @param {string} ip */ +function classifyV6(name, ip) { + const head = ipv6Head(ip); + if (!head) return 'virtual'; + const [h1, h2] = head; + // fe80::/10 needs a zone id (%12 / %eth0) that a URL cannot carry, so it is no use to a friend. + if ((h1 & 0xffc0) === 0xfe80) return 'linklocal'; + // 2002::/16 (6to4) and 2001:db8::/32 (documentation) are never an address to hand out. + if (h1 === 0x2002 || (h1 === 0x2001 && h2 === 0x0db8)) return 'virtual'; + if (VPN_IF.test(name)) return 'vpn'; + if (VIRTUAL_IF.test(name)) return 'virtual'; + if ((h1 & 0xfe00) === 0xfc00) return 'lan'; // fc00::/7 ULA + if ((h1 & 0xe000) === 0x2000) return 'public'; // 2000::/3 global unicast + return 'virtual'; +} + /** - * Classify every non-internal IPv4 address: 'lan' (RFC 1918, what friends at home use), 'vpn' (Tailscale / ZeroTier / - * Radmin / Hamachi / CGNAT 100.64/10), 'virtual' (Hyper-V, WSL, Docker, VirtualBox, a Clash/Mihomo TUN adapter … — - * not reachable from other machines, sharing them only confuses people), 'public' (a public address directly on this - * machine), 'linklocal' (169.254 — no DHCP, useless). + * Classify every non-internal address — IPv4 and IPv6 — as 'lan' (RFC 1918 / ULA fc00::/7), 'vpn' (Tailscale / + * ZeroTier / Radmin / Hamachi / CGNAT 100.64/10), 'virtual' (Hyper-V, WSL, Docker, a Clash/Mihomo TUN adapter), + * 'public' (an address on this machine that is not one of those; an IPv6 global unicast is the usual home-server + * way in) or 'linklocal' (169.254 / fe80::). * @returns {{ name: string, address: string, kind: string }[]} best first */ export function classifyAddresses(ifaces = os.networkInterfaces()) { const out = []; + const v6Seen = new Set(); // privacy extensions put several addresses of one /64 on a machine — one entry is enough for (const [name, addrs] of Object.entries(ifaces)) { for (const a of addrs || []) { - if (!(a.family === 'IPv4' || a.family === 4) || a.internal) continue; + if (a.internal) continue; const ip = a.address; - let kind; - if (inCidr(ip, '169.254.0.0', 16)) kind = 'linklocal'; - // 198.18.0.0/15 是 RFC 2544 的基准测试段:代理软件(Clash / Mihomo 的 fake-ip 池)拿它做本地 TUN 地址, - // 绝对不是能发给朋友的「公网 IP」。 - else if (inCidr(ip, '198.18.0.0', 15)) kind = 'virtual'; - else if (VPN_IF.test(name) || inCidr(ip, '100.64.0.0', 10)) kind = 'vpn'; - else if (VIRTUAL_IF.test(name)) kind = 'virtual'; - else if (inCidr(ip, '10.0.0.0', 8) || inCidr(ip, '172.16.0.0', 12) || inCidr(ip, '192.168.0.0', 16)) kind = 'lan'; - else kind = 'public'; + if (a.family === 'IPv4' || a.family === 4) { + let kind; + if (inCidr(ip, '169.254.0.0', 16)) kind = 'linklocal'; + // 198.18.0.0/15 是 RFC 2544 的基准测试段:代理软件(Clash / Mihomo 的 fake-ip 池)拿它做本地 TUN 地址, + // 绝对不是能发给朋友的「公网 IP」。 + else if (inCidr(ip, '198.18.0.0', 15)) kind = 'virtual'; + else if (VPN_IF.test(name) || inCidr(ip, '100.64.0.0', 10)) kind = 'vpn'; + else if (VIRTUAL_IF.test(name)) kind = 'virtual'; + else if (inCidr(ip, '10.0.0.0', 8) || inCidr(ip, '172.16.0.0', 12) || inCidr(ip, '192.168.0.0', 16)) kind = 'lan'; + else kind = 'public'; + out.push({ name, address: ip, kind }); + continue; + } + if (a.family !== 'IPv6' && a.family !== 6) continue; + const kind = classifyV6(name, ip); + if (kind === 'linklocal' || kind === 'virtual') { out.push({ name, address: ip, kind }); continue; } + const prefix = ip.split('%')[0].split(':').slice(0, 4).join(':'); + if (v6Seen.has(`${name}|${prefix}`)) continue; + v6Seen.add(`${name}|${prefix}`); out.push({ name, address: ip, kind }); } } @@ -95,10 +136,12 @@ function canListen(port, host) { } /** 'ours' (our server answers /healthz), 'free', 'busy' (another program) or 'denied'. */ -export async function probePort(port, host = '0.0.0.0') { +export async function probePort(port, host = '::') { const r = await getJson(`http://127.0.0.1:${port}/healthz`); if (r.json && r.json.ok === true && 'uptimeSec' in r.json) return { state: 'ours', health: r.json }; - const l = await canListen(port, host); + let l = await canListen(port, host); + // The same three errors startServer treats as "this host has no IPv6": don't report the port busy for that. + if (!l.ok && host === '::' && ['EAFNOSUPPORT', 'EADDRNOTAVAIL', 'EINVAL'].includes(l.code)) l = await canListen(port, '0.0.0.0'); if (l.ok) return { state: 'free' }; if (l.code === 'EACCES') return { state: 'denied', code: l.code }; return { state: 'busy', code: l.code, http: r.status }; @@ -151,7 +194,7 @@ function tool(cmd, args) { // --------------------------------------------------------------------------------------------------- function parseArgs(argv) { - const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '0.0.0.0', help: false }; + const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '::', help: false }; for (let i = 0; i < argv.length; i++) { const [k, v] = argv[i].split('='); const val = () => (v !== undefined ? v : argv[++i]); @@ -166,7 +209,7 @@ function parseArgs(argv) { async function main() { let opts; try { opts = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); return 2; } - if (opts.help) { console.log('node tools/doctor.mjs [--port 3000] [--host 0.0.0.0] — 只读诊断,不修改任何文件'); return 0; } + if (opts.help) { console.log('node tools/doctor.mjs [--port 3000] [--host ::] — 只读诊断,不修改任何文件'); return 0; } const rows = []; let bad = false; const row = (state, label, detail = '') => { rows.push([state, label, detail]); if (state === 'err') bad = true; }; @@ -224,16 +267,17 @@ async function main() { else if (port.state === 'denied') row('err', `端口 ${opts.port}`, '没有权限监听(Linux 上 < 1024 的端口需要 root)→ 换一个 PORT'); else row('err', `端口 ${opts.port}`, `被其他程序占用(${port.code})→ 关闭它或换端口:${IS_WIN ? '$env:PORT=3001; npm start' : 'PORT=3001 npm start'}`); const env = ['PORT', 'HOST', 'SP_COMBAT', 'SP_VERIFY', 'TRUST_PROXY', 'DEBUG'].filter((k) => process.env[k] != null && process.env[k] !== ''); - row('skip', '环境变量', env.length ? env.map((k) => `${k}=${process.env[k]}`).join(' ') : '全部默认(PORT=3000 HOST=0.0.0.0 SP_COMBAT=client SP_VERIFY=off)'); + row('skip', '环境变量', env.length ? env.map((k) => `${k}=${process.env[k]}`).join(' ') : '全部默认(PORT=3000 HOST=:: SP_COMBAT=client SP_VERIFY=off)'); section('朋友如何访问'); const addrs = classifyAddresses(); - if (!addrs.length) row('warn', '网络', '没有可用的 IPv4 地址(未联网?)'); + if (!addrs.length) row('warn', '网络', '没有可用的地址(未联网?)'); for (const a of addrs) { const usable = a.kind === 'lan' || a.kind === 'vpn' || a.kind === 'public'; - row(usable ? 'ok' : 'skip', `http://${a.address}:${opts.port}`, `${KIND_LABEL[a.kind]} · ${a.name}`); + // An IPv6 literal needs the brackets of hostUrl(): `http://240e:…:3000` is not a URL anyone can open. + row(usable ? 'ok' : 'skip', hostUrl(a.address, opts.port), `${KIND_LABEL[a.kind]} · ${a.name}`); } - if (opts.host !== '0.0.0.0' && opts.host !== '::') row('warn', 'HOST', `HOST=${opts.host}:只监听这个地址,其他电脑可能连不上(默认 0.0.0.0)`); + if (opts.host !== '0.0.0.0' && opts.host !== '::') row('warn', 'HOST', `HOST=${opts.host}:只监听这个地址,其他电脑可能连不上(默认 :: 双栈)`); section('防火墙'); for (const [m, text] of firewallHints(opts.port)) rows.push([m === '' ? 'raw' : 'mark', text, '', m]); From b5c29b53354122026689f1ef366ee765e72e93be Mon Sep 17 00:00:00 2001 From: weinibuliu Date: Thu, 8 Oct 2026 19:07:49 +0800 Subject: [PATCH 10/10] feedback7 G-TOOLS: asset downloads honour HTTP(S)_PROXY on Node 22.21+ and 24 (PR #289 by @weinibuliu) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Did not apply the patch. It set a global dispatcher on the npm undici package and rewrote the lockfile. Node's built-in fetch does not use that dispatcher, and node:undici is not importable. fetch() reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY only at startup, and only when that startup had NODE_USE_ENV_PROXY=1 (or --use-env-proxy). That is Node >=22.21.0 and >=24.0.0. tools/fetch-assets.mjs restarts itself once with NODE_USE_ENV_PROXY=1 before downloading. The default fetch in the downloader and the index cache rejects when a proxy is set but this process cannot use it, including Node 22.0–22.20 and 23, instead of fetching directly. No new dependency. Engines stay >=22. Tests: node --test test/assets-env-proxy.test.js — 8 pass, 0 fail. node --test test/asset-network.test.js test/assets-diy.test.js test/fetch-assets-shrink.test.js test/assets.test.js — 84 pass, 0 fail. Before this commit: npm run -s lint — 0 errors (135 existing warnings); npm run -s typecheck — ok; npm run -s check:imports — exit 0 (3 pre-existing sim-node notes). docs, version and package tests: 46 pass and 1 fail while env-proxy.mjs was still untracked; after git add, node --test test/package.test.js — 10 pass, 0 fail. --- test/assets-env-proxy.test.js | 305 ++++++++++++++++++++++++++++++++++ tools/assets/cache.mjs | 5 +- tools/assets/downloader.mjs | 6 +- tools/assets/env-proxy.mjs | 145 ++++++++++++++++ tools/fetch-assets.mjs | 5 +- 5 files changed, 463 insertions(+), 3 deletions(-) create mode 100644 test/assets-env-proxy.test.js create mode 100644 tools/assets/env-proxy.mjs diff --git a/test/assets-env-proxy.test.js b/test/assets-env-proxy.test.js new file mode 100644 index 00000000..93a3c38d --- /dev/null +++ b/test/assets-env-proxy.test.js @@ -0,0 +1,305 @@ +// HTTP(S)_PROXY for the asset downloader. Node's built-in fetch honours it only when +// the process was started with NODE_USE_ENV_PROXY=1 (Node >=22.21 and >=24). The +// npm undici dispatcher does not reach that fetch, so fetch-assets restarts once +// and every other default fetch fails closed. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn, spawnSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { connect as netConnect } from 'node:net'; +import { Downloader } from '../tools/assets/downloader.mjs'; +import { cachedJson } from '../tools/assets/cache.mjs'; +import { + PROXY_ENV_KEYS, REEXEC_MARKER, envProxyPlan, guardDefaultFetch, nodeHonoursEnvProxy, + proxyConfigured, restartForEnvProxy, startedWithEnvProxy, +} from '../tools/assets/env-proxy.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const SCRIPT = join(ROOT, 'tools', 'fetch-assets.mjs'); +const PROXY = 'http://127.0.0.1:9'; +const STRIP = [...PROXY_ENV_KEYS, 'NO_PROXY', 'no_proxy', 'ALL_PROXY', 'all_proxy', 'NODE_USE_ENV_PROXY', REEXEC_MARKER]; + +function cleanEnv(extra = {}) { + const env = { ...process.env }; + for (const key of STRIP) delete env[key]; + return Object.assign(env, extra); +} + +test('version gate: fetch() env proxy is Node >=22.21 and >=24, not 23 or early 22', () => { + assert.equal(nodeHonoursEnvProxy('22.20.0'), false); + assert.equal(nodeHonoursEnvProxy('22.21.0'), true); + assert.equal(nodeHonoursEnvProxy('v22.22.0'), true); + assert.equal(nodeHonoursEnvProxy('23.11.0'), false); + assert.equal(nodeHonoursEnvProxy('24.0.0'), true); + assert.equal(nodeHonoursEnvProxy('24.5.0'), true); + assert.equal(nodeHonoursEnvProxy('25.1.0'), true); + assert.equal(nodeHonoursEnvProxy(''), false); + assert.equal(nodeHonoursEnvProxy('nope'), false); +}); + +test('startup switch: the variable, the flag, and NODE_OPTIONS; a late or lookalike value does not count', () => { + assert.equal(startedWithEnvProxy({ NODE_USE_ENV_PROXY: '1' }, []), true); + assert.equal(startedWithEnvProxy({ NODE_USE_ENV_PROXY: 'true' }, []), false); + assert.equal(startedWithEnvProxy({ NODE_USE_ENV_PROXY: '' }, []), false); + assert.equal(startedWithEnvProxy({}, ['--use-env-proxy']), true); + assert.equal(startedWithEnvProxy({}, ['--use_env_proxy']), true); + assert.equal(startedWithEnvProxy({}, ['--no-warnings']), false); + assert.equal(startedWithEnvProxy({ NODE_OPTIONS: '--no-warnings --use-env-proxy' }, []), true); + assert.equal(startedWithEnvProxy({ NODE_OPTIONS: '--use-env-proxy-extra' }, []), false); + assert.equal(startedWithEnvProxy({}, []), false); +}); + +test('plan: unset proxy is off; a proxy restarts, is already on, or is refused', () => { + assert.equal(proxyConfigured({}), false); + assert.equal(proxyConfigured({ HTTP_PROXY: ' ', NO_PROXY: 'localhost' }), false); + assert.equal(envProxyPlan({}).action, 'off'); + assert.equal(envProxyPlan({ NO_PROXY: '127.0.0.1' }).action, 'off'); + assert.equal(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080' }, '22.20.0', false).action, 'unsupported'); + assert.match(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080' }, '23.11.0', true).reason, /Node 23\.11\.0/); + assert.equal(envProxyPlan({ http_proxy: 'http://proxy.example:8080' }, '22.21.0', false).action, 'reexec'); + assert.match(envProxyPlan({ HTTPS_PROXY: 'http://proxy.example:8080' }, '24.0.0', false).reason, /Refusing to fetch directly/); + // The flag on the object is not the startup snapshot: the third argument is. + assert.equal(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080', NODE_USE_ENV_PROXY: '1' }, '22.22.0', false).action, 'reexec'); + assert.equal(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080', NODE_USE_ENV_PROXY: '1' }, '24.5.0', true).action, 'already'); + assert.equal(envProxyPlan({ HTTPS_PROXY: 'http://proxy.example:8080' }, '25.0.0', true).action, 'already'); +}); + +test('an explicit fetch is returned unchanged', () => { + const fetchImpl = async () => new Response('ok'); + assert.equal(guardDefaultFetch(fetchImpl), fetchImpl); +}); + +test('downloader and index cache fail closed under HTTP_PROXY, and an injected fetch still runs', async (t) => { + // The only test that mutates process.env. Node runs tests in a file concurrently; + // the plan tests above pass their own env object and do not read process.env. + const dir = await mkdtemp(join(tmpdir(), 'sp-env-proxy-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const saved = STRIP.map((key) => [key, process.env[key]]); + for (const key of STRIP) delete process.env[key]; + process.env.HTTP_PROXY = 'http://127.0.0.1:9'; + process.env.HTTPS_PROXY = process.env.HTTP_PROXY; + try { + const plan = envProxyPlan(); + const bare = new Downloader({ + root: dir, ledgerPath: join(dir, 'ledger.json'), retries: 1, backoffMs: 0, log() {}, + }); + if (plan.action !== 'already') { + const refused = await bare.fetchWithRetries('https://example.invalid/a.json', 'json'); + assert.match(refused.error, /Refusing to fetch directly/); + await assert.rejects(guardDefaultFetch()('https://example.invalid/a.json'), /Refusing to fetch directly/); + // Set after startup. Node has already decided not to proxy, so this must still refuse. + process.env.NODE_USE_ENV_PROXY = '1'; + const late = await bare.fetchWithRetries('https://example.invalid/b.json', 'json'); + assert.match(late.error, /not started with NODE_USE_ENV_PROXY=1/); + delete process.env.NODE_USE_ENV_PROXY; + await assert.rejects( + cachedJson({ + cacheFile: join(dir, 'missing.json'), url: 'https://example.invalid/index.json', + log() {}, backoffMs: 0, timeoutMs: 1000, + }), + /Refusing to fetch directly/, + ); + } + let called = 0; + const injected = new Downloader({ + root: dir, ledgerPath: join(dir, 'ledger.json'), retries: 1, backoffMs: 0, log() {}, + fetchImpl: async () => { called += 1; return Response.json({ ok: true }); }, + }); + const got = await injected.fetchWithRetries('https://example.invalid/a.json', 'json'); + assert.equal(called, 1); + assert.equal(got.buf.toString(), '{"ok":true}'); + assert.deepEqual(await cachedJson({ + cacheFile: join(dir, 'ok.json'), url: 'https://example.invalid/index.json', log() {}, backoffMs: 0, + fetchImpl: async () => Response.json({ ok: true }), + }), { ok: true }); + } finally { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}); + +test('restart decision: nothing to do, one spawn, or a hard stop — and the marker cannot loop', () => { + const prev = process.exitCode; + const spawned = []; + const logs = []; + const child = new EventEmitter(); + const spawnImpl = (...args) => { spawned.push(args); return child; }; + try { + assert.equal(restartForEnvProxy({ + env: cleanEnv(), spawnImpl, error: (line) => logs.push(line), + }), false); + assert.equal(spawned.length, 0); + + const proxyEnv = cleanEnv({ HTTP_PROXY: 'http://proxy.example:8080', KEEP: 'yes' }); + const stopped = restartForEnvProxy({ + env: proxyEnv, + execPath: process.execPath, + execArgv: ['--no-warnings'], + argv: ['node', SCRIPT, '--help'], + spawnImpl, + error: (line) => logs.push(line), + }); + assert.equal(stopped, true); + if (!nodeHonoursEnvProxy(process.versions.node)) { + assert.equal(spawned.length, 0); + assert.match(logs.join('\n'), /does not route fetch/); + return; + } + assert.equal(spawned.length, 1); + const [bin, args, opts] = spawned[0]; + assert.equal(bin, process.execPath); + assert.deepEqual(args, ['--no-warnings', SCRIPT, '--help']); + assert.equal(opts.stdio, 'inherit'); + assert.equal(opts.env.NODE_USE_ENV_PROXY, '1'); + assert.equal(opts.env[REEXEC_MARKER], '1'); + assert.equal(opts.env.KEEP, 'yes'); + assert.equal(opts.env.HTTP_PROXY, 'http://proxy.example:8080'); + assert.match(logs.join('\n'), /restarting once with NODE_USE_ENV_PROXY=1/); + child.emit('close', 0); + + spawned.length = 0; + logs.length = 0; + assert.equal(restartForEnvProxy({ + env: cleanEnv({ HTTP_PROXY: 'http://proxy.example:8080', [REEXEC_MARKER]: '1' }), + spawnImpl, + error: (line) => logs.push(line), + }), true); + assert.equal(spawned.length, 0); + assert.match(logs.join('\n'), /refused to restart again/); + } finally { + process.exitCode = prev; + } +}); + +function runHelp(extra) { + return spawnSync(process.execPath, [SCRIPT, '--help'], { + encoding: 'utf8', env: cleanEnv(extra), timeout: 20000, + }); +} + +test('fetch-assets --help restarts once when HTTP(S)_PROXY is set, and a second restart is refused', () => { + const supported = nodeHonoursEnvProxy(process.versions.node); + const first = runHelp({ HTTP_PROXY: PROXY, HTTPS_PROXY: PROXY }); + if (!supported) { + assert.equal(first.status, 1, first.stderr); + assert.match(first.stderr, /does not route fetch/); + assert.equal(first.stdout, ''); + return; + } + assert.equal(first.status, 0, first.stderr); + assert.match(first.stdout, /--allow-shrink/); + assert.equal((first.stderr.match(/restarting once with NODE_USE_ENV_PROXY=1/g) || []).length, 1, first.stderr); + + const already = runHelp({ HTTP_PROXY: PROXY, NODE_USE_ENV_PROXY: '1' }); + assert.equal(already.status, 0, already.stderr); + assert.match(already.stdout, /--allow-shrink/); + assert.doesNotMatch(already.stderr, /restarting once/); + + const loop = runHelp({ HTTP_PROXY: PROXY, [REEXEC_MARKER]: '1' }); + assert.equal(loop.status, 1, loop.stderr); + assert.match(loop.stderr, /refused to restart again/); + assert.equal(loop.stdout, ''); +}); + +function listen(server) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); + }); +} + +test('built-in fetch tunnels through HTTP_PROXY only when NODE_USE_ENV_PROXY was set at startup', { timeout: 30000 }, async (t) => { + if (!nodeHonoursEnvProxy(process.versions.node)) return; + const hits = []; + const sockets = new Set(); + const proxy = createServer((req, res) => { + hits.push(`REQ ${req.method}`); + res.writeHead(500); + res.end(); + }); + proxy.on('connect', (req, client, head) => { + hits.push(`CONNECT ${req.url}`); + const cut = req.url.lastIndexOf(':'); + const upstream = netConnect(Number(req.url.slice(cut + 1)), req.url.slice(0, cut), () => { + client.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head.length) upstream.write(head); + upstream.pipe(client); + client.pipe(upstream); + }); + sockets.add(upstream); + upstream.on('error', () => client.destroy()); + client.on('error', () => upstream.destroy()); + }); + proxy.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + const target = createServer((req, res) => { + hits.push(`TARGET ${req.url}`); + res.end('from-target'); + }); + target.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + const proxyPort = await listen(proxy); + const targetPort = await listen(target); + t.after(() => { + for (const socket of sockets) socket.destroy(); + proxy.close(); + target.close(); + }); + const targetUrl = `http://127.0.0.1:${targetPort}/x`; + const proxyUrl = `http://127.0.0.1:${proxyPort}`; + const snippet = ` + const res = await fetch(process.env.TARGET, { signal: AbortSignal.timeout(2500) }); + console.log(JSON.stringify({ status: res.status, body: await res.text() })); + `; + // spawn, not spawnSync: a synchronous child would freeze this server, and fetch would time out. + const run = (extra) => new Promise((resolve, reject) => { + hits.length = 0; + const child = spawn(process.execPath, ['--input-type=module', '-e', snippet], { + env: cleanEnv({ TARGET: targetUrl, ...extra }), stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { child.kill(); reject(new Error(`fetch child hung\n${stderr}`)); }, 8000); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', (e) => { clearTimeout(timer); reject(e); }); + child.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) { reject(new Error(stderr || `exit ${code}`)); return; } + const body = JSON.parse(stdout.trim().split('\n').pop()); + resolve({ body, hits: [...hits] }); + }); + }); + + const direct = await run({}); + assert.deepEqual(direct.body, { status: 200, body: 'from-target' }); + assert.deepEqual(direct.hits, ['TARGET /x']); + + const ignored = await run({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl }); + assert.deepEqual(ignored.body, { status: 200, body: 'from-target' }); + assert.deepEqual(ignored.hits, ['TARGET /x'], 'a proxy set without the startup switch is ignored by fetch()'); + + const tunneled = await run({ http_proxy: proxyUrl, NODE_USE_ENV_PROXY: '1' }); + assert.deepEqual(tunneled.body, { status: 200, body: 'from-target' }); + assert.deepEqual(tunneled.hits, [`CONNECT 127.0.0.1:${targetPort}`, 'TARGET /x']); + + const both = await run({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NODE_USE_ENV_PROXY: '1' }); + assert.equal(both.body.body, 'from-target'); + assert.deepEqual(both.hits, [`CONNECT 127.0.0.1:${targetPort}`, 'TARGET /x']); + + const bypass = await run({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NO_PROXY: '127.0.0.1', NODE_USE_ENV_PROXY: '1' }); + assert.equal(bypass.body.body, 'from-target'); + assert.deepEqual(bypass.hits, ['TARGET /x']); +}); diff --git a/tools/assets/cache.mjs b/tools/assets/cache.mjs index db8fc325..3ad6f5f0 100644 --- a/tools/assets/cache.mjs +++ b/tools/assets/cache.mjs @@ -8,6 +8,7 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { RAW } from './sources.mjs'; import { MirrorPolicy } from './network.mjs'; +import { guardDefaultFetch } from './env-proxy.mjs'; /** * Read a cached JSON file, downloading it first when missing or unparsable. @@ -26,6 +27,8 @@ import { MirrorPolicy } from './network.mjs'; * @returns {Promise} parsed JSON */ export async function cachedJson({ cacheFile, url, refresh = false, offline = false, log = console.log, source = 'direct', proxyPrefix, fetchImpl = globalThis.fetch, mirrorPolicy, timeoutMs = 180000, backoffMs = 500 }) { + // Same rule as the file downloader: a configured proxy must not be skipped. + const fetchFn = guardDefaultFetch(fetchImpl); if (!refresh || offline) { try { return JSON.parse(await readFile(cacheFile, 'utf8')); } catch (e) { if (offline) throw new Error(`--offline: cached index ${cacheFile} is missing or corrupt (${e.message}); run once online`); @@ -40,7 +43,7 @@ export async function cachedJson({ cacheFile, url, refresh = false, offline = fa let text, json; try { log(`[cache] downloading ${src}`); - const res = await network.request(src, fetchImpl, {}, timeoutMs); + const res = await network.request(src, fetchFn, {}, timeoutMs); if (!res.ok) { await res.body?.cancel(); if (res.status === 404 || res.status === 410) { diff --git a/tools/assets/downloader.mjs b/tools/assets/downloader.mjs index 510821b4..375511e1 100644 --- a/tools/assets/downloader.mjs +++ b/tools/assets/downloader.mjs @@ -8,6 +8,9 @@ // a 404/410 moves on immediately. Each candidate uses its opt-in prefix proxy // first (when enabled), then the original URL and jsDelivr fallback. The proxy gets one short attempt // per file and shares a run-wide circuit breaker with the index/font fetchers. +// - HTTP(S)_PROXY: the default fetch uses it only when this process was started +// with NODE_USE_ENV_PROXY=1 (Node >=22.21 or >=24). Otherwise it fails closed +// (tools/assets/env-proxy.mjs). Pass fetchImpl to bypass that, as tests do. // - Idempotent: an existing file is kept when its size matches the ledger entry // of a previous download or the expected byte count from research, or (when // neither is known) when it passes format validation. The ledger lives in @@ -18,6 +21,7 @@ import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promis import { dirname, join } from 'node:path'; import { MirrorPolicy } from './network.mjs'; import { validate } from './formats.mjs'; +import { guardDefaultFetch } from './env-proxy.mjs'; /** * @typedef {{ rel: string, urls: string[], kind: string, bytes?: number, mutable?: boolean }} Job @@ -60,7 +64,7 @@ export class Downloader { /** @type {Set} the files (paths under root) this run wrote */ this.written = new Set(); this.log = log; - this.fetch = fetchImpl; + this.fetch = guardDefaultFetch(fetchImpl); this.network = mirrorPolicy ?? new MirrorPolicy({ source, proxyPrefix, log }); this.backoffMs = Math.max(0, Number(backoffMs) || 0); this.ledger = { files: {} }; diff --git a/tools/assets/env-proxy.mjs b/tools/assets/env-proxy.mjs new file mode 100644 index 00000000..c46b162b --- /dev/null +++ b/tools/assets/env-proxy.mjs @@ -0,0 +1,145 @@ +// HTTP(S)_PROXY for the asset downloader, without a new dependency. +// +// Node's built-in fetch reads HTTP_PROXY / HTTPS_PROXY / NO_PROXY only at process +// startup, and only when that startup had NODE_USE_ENV_PROXY=1 or --use-env-proxy. +// fetch() honours this on Node >=22.21.0 and >=24.0.0. The CLI flag on the 24 +// line is >=24.5.0, so the restart sets the variable, which 24.0.0 already honours. +// Assigning the variable later does nothing. Node 22.0–22.20 and 23 have no such +// switch; engines stay ">=22", and a proxy there is an error rather than a direct +// download. +// +// Nothing else reaches the built-in fetch: `node:undici` is not a public builtin, +// and setGlobalDispatcher on the npm `undici` package does not affect it. The +// fetch-assets script therefore restarts itself once. Any other caller of the +// default fetch is rejected here while a proxy is configured. + +import { spawn } from 'node:child_process'; + +/** Names Node's env proxy actually reads (both cases). NO_PROXY alone is not a proxy. */ +export const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']; + +/** Set on the one restart, so a failed hand-off cannot spawn forever. */ +export const REEXEC_MARKER = 'SP_ASSETS_ENV_PROXY_REEXEC'; + +/** + * @param {NodeJS.ProcessEnv | Record} env + * @returns {boolean} + */ +export function proxyConfigured(env) { + return PROXY_ENV_KEYS.some((key) => typeof env[key] === 'string' && env[key].trim() !== ''); +} + +/** + * fetch() routes through HTTP(S)_PROXY when NODE_USE_ENV_PROXY is on at startup. + * @param {string} version process.versions.node + * @returns {boolean} + */ +export function nodeHonoursEnvProxy(version) { + const match = /^v?(\d+)\.(\d+)/.exec(String(version ?? '')); + if (!match) return false; + const major = Number(match[1]); + const minor = Number(match[2]); + if (major === 22) return minor >= 21; + return major >= 24; +} + +/** + * True only for a process that was started with the switch. A value written onto + * `process.env` afterwards does not count: Node has already decided. + * @param {NodeJS.ProcessEnv | Record} env + * @param {string[]} [execArgv] + * @returns {boolean} + */ +export function startedWithEnvProxy(env, execArgv = []) { + if (env.NODE_USE_ENV_PROXY === '1') return true; + if (execArgv.some((arg) => arg === '--use-env-proxy' || arg === '--use_env_proxy')) return true; + const options = env.NODE_OPTIONS; + return typeof options === 'string' && /(?:^|\s)--use[-_]env[-_]proxy(?=\s|$)/.test(options); +} + +// Captured at load. Reading process.env.NODE_USE_ENV_PROXY at fetch time would +// treat a late assignment as if Node had seen it. +const STARTED_WITH_ENV_PROXY = startedWithEnvProxy(process.env, process.execArgv); + +/** + * @param {NodeJS.ProcessEnv | Record} [env] + * @param {string} [version] + * @param {boolean} [startedWithFlag] defaults to this process's startup snapshot, not to `env` + * @returns {{ action: 'off' | 'already' | 'reexec' | 'unsupported', reason: string }} + */ +export function envProxyPlan(env = process.env, version = process.versions.node, startedWithFlag = STARTED_WITH_ENV_PROXY) { + if (!proxyConfigured(env)) return { action: 'off', reason: '' }; + if (!nodeHonoursEnvProxy(version)) { + return { + action: 'unsupported', + reason: `HTTP(S)_PROXY is set, but Node ${version} does not route fetch() through it. ` + + 'Need Node >=22.21.0 or >=24.0.0, started with NODE_USE_ENV_PROXY=1. Refusing to fetch directly.', + }; + } + if (startedWithFlag) return { action: 'already', reason: '' }; + return { + action: 'reexec', + reason: 'HTTP(S)_PROXY is set, but this process was not started with NODE_USE_ENV_PROXY=1. ' + + 'Node reads that proxy only at startup (Node >=22.21.0 or >=24.0.0). Refusing to fetch directly.', + }; +} + +/** + * The built-in fetch, or the caller's own implementation unchanged. + * When a proxy is configured and this process cannot use it, the default fetch + * rejects instead of connecting to the origin. + * @param {typeof fetch} [fetchImpl] + * @returns {typeof fetch} + */ +export function guardDefaultFetch(fetchImpl = globalThis.fetch) { + if (fetchImpl !== globalThis.fetch) return fetchImpl; + return (input, init) => { + const plan = envProxyPlan(); + if (plan.action === 'reexec' || plan.action === 'unsupported') return Promise.reject(new Error(plan.reason)); + return fetchImpl(input, init); + }; +} + +/** + * Restart the current script once so Node enables env proxy before any fetch. + * @param {object} [io] + * @param {typeof import('node:child_process').spawn} [io.spawnImpl] + * @param {string} [io.execPath] + * @param {string[]} [io.execArgv] + * @param {string[]} [io.argv] + * @param {NodeJS.ProcessEnv} [io.env] + * @param {(line: string) => void} [io.error] + * @returns {boolean} true when the caller must not continue (restarted, or refused) + */ +export function restartForEnvProxy({ + spawnImpl = spawn, + execPath = process.execPath, + execArgv = process.execArgv, + argv = process.argv, + env = process.env, + error = (line) => console.error(line), +} = {}) { + const plan = envProxyPlan(env); + if (plan.action === 'off' || plan.action === 'already') return false; + if (plan.action === 'unsupported') { + error(`[assets] ${plan.reason}`); + process.exitCode = 1; + return true; + } + if (env[REEXEC_MARKER] === '1') { + error('[assets] refused to restart again for HTTP(S)_PROXY'); + process.exitCode = 1; + return true; + } + error('[assets] HTTP(S)_PROXY is set; restarting once with NODE_USE_ENV_PROXY=1 so fetch() uses it.'); + const child = spawnImpl(execPath, [...execArgv, ...argv.slice(1)], { + env: { ...env, NODE_USE_ENV_PROXY: '1', [REEXEC_MARKER]: '1' }, + stdio: 'inherit', + }); + child.on('error', (e) => { + error(`[assets] could not restart with NODE_USE_ENV_PROXY: ${e.message}`); + process.exitCode = 1; + }); + child.on('close', (code) => { process.exitCode = code ?? 1; }); + return true; +} diff --git a/tools/fetch-assets.mjs b/tools/fetch-assets.mjs index 4624cba9..3c07d85c 100644 --- a/tools/fetch-assets.mjs +++ b/tools/fetch-assets.mjs @@ -18,6 +18,8 @@ // Idempotent: existing files with the right size are skipped, so re-running is // cheap. Downloads use ~16 parallel connections, 3 retries per direct source, // a jsDelivr fallback and an opt-in GitHub proxy (one short attempt per URL). +// HTTP(S)_PROXY is picked up by restarting once with NODE_USE_ENV_PROXY=1 +// (Node >=22.21 or >=24). An older Node fails instead of fetching directly. // Spine atlases get `size:` (and `pma: true` for enemies); every skeleton is // parsed to resolve animation roles. // @@ -49,6 +51,7 @@ import { existsSync, realpathSync } from 'node:fs'; import { dirname, join, relative, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { Downloader } from './assets/downloader.mjs'; +import { restartForEnvProxy } from './assets/env-proxy.mjs'; import { MirrorPolicy, selectDownloadSource, validateSource } from './assets/network.mjs'; import { normalizeProxyPrefix } from './assets/sources.mjs'; import { loadIndexes } from './assets/cache.mjs'; @@ -463,7 +466,7 @@ async function main() { // run only as a script (tests import parseArgs / shrinkGuard) const invoked = (() => { try { return pathToFileURL(realpathSync(process.argv[1] || '')).href; } catch { return null; } })(); -if (invoked === import.meta.url) { +if (invoked === import.meta.url && !restartForEnvProxy()) { main().then((code) => { process.exitCode = code; }, (e) => { console.error(`[assets] FAILED: ${process.env.DEBUG ? e?.stack || e : e?.message || e}`); process.exitCode = 1;