Files
ark-stronghold-protocol/test/render/recovery.browser.test.js
T
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
2026-10-11 01:58:52 +08:00

150 lines
9.3 KiB
JavaScript

import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME);
const art = existsSync(new URL('../../public/assets/local/map/autochess/TX_autochessi_D.png', import.meta.url));
describe('renderer recovery after transient resource failures', { skip: !enabled }, () => {
let srv, browser, serverRun = false;
before(async () => {
const { startServer } = await import('../../server/index.js');
const { Match } = await import('../../server/match/Match.js');
class RecoveryMatch extends Match { constructor(opts) { super({ ...opts, clientCombat: !serverRun }); } }
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, MatchClass: RecoveryMatch, seedFn: () => 123 });
const P = (await import('puppeteer-core')).default;
browser = await P.launch({ executablePath: CHROME, headless: true,
args: ['--no-first-run', '--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist'] });
mkdirSync(new URL('../e2e/out/', import.meta.url), { recursive: true });
});
after(async () => { await browser?.close(); await srv?.close(); });
for (const streamed of [false, true]) test(`a live ${streamed ? 'server' : 'local'} battle survives renderer replacement with current enemy metadata`, { timeout: 45000 }, async () => {
serverRun = streamed;
const context = await browser.createBrowserContext(), page = await context.newPage(); let failing = true;
try {
await page.setRequestInterception(true);
page.on('request', req => {
if (failing && new URL(req.url()).pathname === '/vendor/pixi.min.js') void req.abort('failed'); else void req.continue();
});
await page.evaluateOnNewDocument(() => { localStorage.setItem('sp.name', 'Recovery'); sessionStorage.setItem('sp.entered', '1'); });
await page.goto(`http://127.0.0.1:${srv.port}/?board=2d`);
await page.waitForFunction(() => window.__SP__?.store.get().connection.status === 'online');
const send = (type, args = {}) => page.evaluate(async (t, a) => (await import('/js/net.js')).net.request(t, a), type, args);
await send('room.create', { mode: 'solo', difficulty: 'FUNNY' }); await send('room.start');
await page.waitForFunction(() => window.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
await send('g.infoReady', { setupRevision: 0 });
await page.waitForFunction(() => window.__SP__.store.get().match.public?.phase === 'BAND_DRAFT');
await send('g.band', { bandId: 'band_bldsk' });
await page.waitForFunction(() => window.__SP__.store.get().match.public?.phase === 'PREP');
await page.waitForFunction(() => window.__SP_VIEW__?.kind === 'fallback');
await send('g.ready', { ready: true });
await page.waitForSelector('.ff-unit.is-enemy:not(.ff-pen__enemy)');
const code = await page.evaluate(() => window.__SP__.store.get().room.code);
const match = srv.lobby.rooms.get(code).match;
const serverBattle = streamed ? match.fields[0].battle : null;
const serverTime = serverBattle?.time;
const enemies = streamed ? match.fields[0].battle.enemies.filter(e => e.alive).map(e => [e.id, e.defId])
: await page.evaluate(async () => {
const runner = (await import('/js/battle/runner.js')).battleRunner;
const entry = [...runner._entries.values()].find(e => e.battle && !e.done);
window.__originalBattle = entry.battle; window.__originalTime = entry.battle.time;
return entry.battle.enemies.filter(e => e.alive).map(e => [e.id, e.defId]);
});
assert.ok(enemies.length > 0, 'enemies spawned after the original field metadata');
failing = false; await page.evaluate(() => window.dispatchEvent(new Event('online')));
await page.waitForFunction(() => window.__SP_VIEW__?.kind === 'engine', { timeout: 15000 });
await page.waitForFunction(([id, defId]) => {
const v = window.__SP_VIEW__.raw;
return v.stats().mode === 'battle' && v.debug.views.get(id)?.info.defId === defId;
}, { timeout: 7000 }, enemies[0]);
assert.equal(srv.lobby.rooms.get(code).match, match, 'renderer recovery never restarts the match');
if (streamed) {
assert.equal(match.fields[0].battle, serverBattle); assert.ok(serverBattle.time >= serverTime);
} else {
assert.equal(await page.evaluate(async () => {
const runner = (await import('/js/battle/runner.js')).battleRunner;
return [...runner._entries.values()].some(e => e.battle === window.__originalBattle && e.battle.time >= window.__originalTime);
}), true, 'the same local simulation continues from its current clock');
}
await send('g.leave');
} finally { await context.close(); }
});
for (const forced of [false, true]) test(`field hook retries a failed Pixi download; forced fallback=${forced}`, async () => {
const page = await browser.newPage(); let pixiRequests = 0, failing = true;
try {
await page.setRequestInterception(true);
page.on('request', req => {
const pixi = new URL(req.url()).pathname === '/vendor/pixi.min.js';
if (pixi) pixiRequests++;
if (pixi && failing) void req.abort('failed'); else void req.continue();
});
await page.goto(`http://127.0.0.1:${srv.port}/?${forced ? 'render=fallback&' : ''}board=2d`);
await page.evaluate(async () => {
const { render, h } = await import('/vendor/preact.module.js');
const { useRef, useEffect } = await import('/vendor/hooks.module.js');
const { useFieldView } = await import('/js/ui/fieldHost.js');
const { data } = await import('/js/data.js');
await data.loadAll('stages', 'chess', 'assets', 'local');
function Harness() {
const host = useRef(null), state = useFieldView(host);
useEffect(() => {
if (!state.view) return;
state.view.setStage(data.lookup('stages', 'act2autochess_m01'));
state.view.setPrep({ playerId: 'p', board: [], hand: [] });
}, [state.view]);
return h('div', { ref: host, id: 'recovery-host', style: 'width:100vw;height:100vh' });
}
render(h(Harness), document.getElementById('app'));
window.__unmount = () => render(null, document.getElementById('app'));
});
await page.waitForFunction(() => window.__SP_VIEW__?.kind === 'fallback');
const before = pixiRequests; failing = false;
await page.evaluate(() => {
window.__firstView = window.__SP_VIEW__;
for (let i = 0; i < 5; i++) window.dispatchEvent(new Event('online'));
});
if (forced) {
assert.equal(await page.evaluate(() => window.__SP_VIEW__ === window.__firstView), true);
assert.equal(pixiRequests, before, 'explicit fallback never requests an engine retry');
} else {
await page.waitForFunction(() => window.__SP_VIEW__?.kind === 'engine', { timeout: 20000 });
assert.equal(pixiRequests, before + 1, 'one successful retry after the failed prefetch and initialization');
const result = await page.evaluate(() => {
const view = window.__SP_VIEW__;
for (let i = 0; i < 5; i++) document.dispatchEvent(new Event('visibilitychange'));
return { same: view === window.__SP_VIEW__, canvases: document.querySelectorAll('#recovery-host canvas').length,
field: view.raw.stats().mode };
});
assert.deepEqual(result, { same: true, canvases: 1, field: 'prep' });
}
await page.evaluate(() => window.__unmount());
assert.equal(await page.evaluate(() => window.__SP_VIEW__), null, 'unmount releases the recovery view');
} finally { await page.close(); }
});
for (const resource of ['atlas', 'three']) test(`${resource} download can recover the 3D board on visibility`, { skip: !art }, async () => {
const page = await browser.newPage(); let failing = true, requests = 0;
try {
await page.setRequestInterception(true);
page.on('request', req => {
const p = new URL(req.url()).pathname;
const selected = resource === 'atlas' ? /\/TX_autochessi_D\.(?:png|webp)$/.test(p) : p === '/vendor/three.module.js';
if (selected) requests++;
if (selected && failing) void req.abort('failed'); else void req.continue();
});
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=prep&stage=act2autochess_m01&board=3d&panel=0`);
await page.waitForFunction(() => window.__demo?.ready, { timeout: 30000 });
assert.equal(await page.evaluate(() => window.__demo.stats().board3d.on), false);
const before = requests; failing = false;
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')));
await page.waitForFunction(() => window.__demo.stats().board3d.on, { timeout: 15000 });
assert.ok(requests > before, 'the failed resource is actually requested again');
assert.equal(await page.evaluate(() => window.__demo.view.debug.app.view.parentNode.querySelectorAll('canvas').length), 2);
await page.screenshot({ path: new URL(`../e2e/out/recovery-${resource}.png`, import.meta.url).pathname });
} finally { await page.close(); }
});
});