Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
324 lines
18 KiB
JavaScript
324 lines
18 KiB
JavaScript
// test/render/fieldview.browser.test.js — field-view features in headless Chrome through the dev demo
|
||
// (public/dev/render-demo.html): the enemy preview pen (research 09 §2.2 / 08 §4.2) on the 3D and the 2D board and
|
||
// the 0.25 s pan to it and back, the Final Assault prep on the player's half of the boss field (research 09 §1.2:
|
||
// drop targets / directions stay in board space, the right half mirrored), damage numbers that never touch, the
|
||
// automatic rebuild of a 3D board whose WebGL context was lost, and frame rates on a 4× throttled CPU.
|
||
// Screenshots → test/e2e/out/pen-*.png, fa-prep-*.png, dmgnum-*.png; perf → test/e2e/out/fieldview-perf.json.
|
||
//
|
||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/fieldview.browser.test.js
|
||
// Run browser test files one at a time. Chrome path: $CHROME_PATH or the macOS default.
|
||
|
||
import { test, describe, before, after } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { createRequire } from 'node:module';
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||
const hasArt = existsSync(path.join(ROOT, 'public/assets/local/map/autochess/TX_autochessi_D.png'));
|
||
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
|
||
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
|
||
|
||
describe('field view features in headless Chrome', { skip }, () => {
|
||
let srv, browser;
|
||
const perf = { generated: new Date().toISOString(), throttle: 4, views: [] };
|
||
before(async () => {
|
||
const require = createRequire(path.join(ROOT, 'package.json'));
|
||
const puppeteer = require('puppeteer-core');
|
||
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: 'new', args: ['--no-first-run', '--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist'] });
|
||
mkdirSync(OUT, { recursive: true });
|
||
});
|
||
after(async () => {
|
||
try { if (perf.views.length) writeFileSync(path.join(OUT, 'fieldview-perf.json'), JSON.stringify(perf, null, 1)); } catch { /* ignore */ }
|
||
await browser?.close();
|
||
await srv?.close();
|
||
});
|
||
|
||
async function open(query, w = 1920, h = 1080) {
|
||
const page = await browser.newPage();
|
||
const problems = [];
|
||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
|
||
await page.setViewport({ width: w, height: h });
|
||
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?${query}&panel=0`);
|
||
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 40000 });
|
||
assert.equal(await page.evaluate(() => window.__demo.error || null), null, 'demo boot');
|
||
return { page, problems };
|
||
}
|
||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
for (const board of ['3d', '2d']) {
|
||
test(`enemy preview pen (${board}): R13 official preview idles in the pen, facing left, no bars; 0.25 s pan there and back`, async () => {
|
||
if (board === '3d' && !hasArt) return;
|
||
const { page, problems } = await open(`scene=prep&pen=late-m01-r13&board=${board}`);
|
||
await wait(2500);
|
||
await page.screenshot({ path: path.join(OUT, `pen-prep-${board}.png`) });
|
||
const r = await page.evaluate(async () => {
|
||
const v = window.__demo.view, st = window.__demo.scene.state;
|
||
const out = { board3d: v.stats().board3d.on, pen: v.stats().pen, total: st.nextEnemies.reduce((s, e) => s + e.count, 0) };
|
||
const pens = [...v.debug.penViews.values()];
|
||
out.rows = [...new Set(pens.map((p) => Math.round(p.y)))].sort();
|
||
out.cols = [Math.min(...pens.map((p) => Math.round(p.x))), Math.max(...pens.map((p) => Math.round(p.x)))];
|
||
out.left = pens.every((p) => p.visFacing === -1);
|
||
out.bars = pens.some((p) => p.hud.visible && (p.hpFill.visible || p.spFill.visible));
|
||
out.upper = st.nextEnemies.filter((e) => e.gate === 'upper').length;
|
||
out.upperOk = pens.filter((p) => p.info.enemyKey && Math.round(p.y) >= 17).length > 0 === out.upper > 0;
|
||
const before = v.debug.cam.params();
|
||
const t0 = performance.now();
|
||
v.setCamera('pen', { side: 'L' });
|
||
await new Promise((res) => { const f = () => (v.debug.camKind === 'pen' && performance.now() - t0 > 20 && v.debug.cam.params().ty === v.debug.cam.params().ty ? res() : requestAnimationFrame(f)); f(); });
|
||
let settled = null;
|
||
let last = JSON.stringify(v.debug.cam.params());
|
||
while (performance.now() - t0 < 1500) {
|
||
await new Promise((res) => requestAnimationFrame(res));
|
||
const now = JSON.stringify(v.debug.cam.params());
|
||
if (now === last && settled == null && performance.now() - t0 > 60) settled = performance.now() - t0;
|
||
if (now !== last) settled = null;
|
||
last = now;
|
||
}
|
||
out.panMs = settled;
|
||
out.penCam = v.debug.cam.params();
|
||
out.inPenView = pens.filter((p) => !p.culled).length;
|
||
v.setCamera('prep');
|
||
await new Promise((res) => setTimeout(res, 500));
|
||
const back = v.debug.cam.params();
|
||
out.backSame = Object.keys(before).every((k) => Math.abs(before[k] - back[k]) < 1e-6);
|
||
return out;
|
||
});
|
||
assert.equal(r.board3d, board === '3d', 'board layer');
|
||
assert.ok(r.total > 50 && r.pen <= 50 + 8 && r.pen >= 45, `thinned to ~50 of ${r.total}: ${r.pen}`);
|
||
assert.ok(r.rows.every((row) => row >= 14 && row <= 18 && row !== 16), `pen rows ${r.rows}`);
|
||
assert.ok(r.cols[0] >= 7 && r.cols[1] <= 13, `pen cols ${r.cols}`);
|
||
assert.ok(r.left, 'every pen enemy faces left');
|
||
assert.equal(r.bars, false, 'no HP / SP bars in the pen');
|
||
assert.ok(r.upperOk, 'upper-gate enemies in rows 17–18');
|
||
assert.ok(r.panMs != null && r.panMs < 450, `pan to the pen ≈ 0.25 s (${r.panMs} ms)`);
|
||
assert.ok(r.inPenView >= 40, `the pen view shows the pen (${r.inPenView} on screen)`);
|
||
assert.ok(r.backSame, 'setCamera(prevKind) returns to the exact camera used before the pen');
|
||
await page.evaluate(() => window.__demo.view.setCamera('pen', { instant: true }));
|
||
await wait(700);
|
||
await page.screenshot({ path: path.join(OUT, `pen-view-${board}.png`) });
|
||
await page.close();
|
||
assert.deepEqual(problems, []);
|
||
});
|
||
}
|
||
|
||
test('pen lifecycle: a real battle empties it; a scouted teammate board shows their preview; tapping an enemy emits its key', async () => {
|
||
const { page, problems } = await open('scene=prep', 1600, 900);
|
||
await wait(2000);
|
||
const r = await page.evaluate(async () => {
|
||
const v = window.__demo.view;
|
||
const out = { prep: v.stats().pen };
|
||
v.setCamera('pen', { instant: true });
|
||
await new Promise((res) => setTimeout(res, 400));
|
||
const clicks = [];
|
||
const off = v.on('pieceClick', (e) => clicks.push(e));
|
||
const pv = [...v.debug.penViews.values()].find((p) => !p.culled);
|
||
const b = pv.bounds();
|
||
const rect = v.debug.app.view.getBoundingClientRect();
|
||
out.click = { x: rect.left + b.x + b.width / 2, y: rect.top + b.y + b.height * 0.6, key: pv.info.enemyKey };
|
||
window.__clicks = clicks; window.__offClick = off;
|
||
return out;
|
||
});
|
||
await page.mouse.click(r.click.x, r.click.y);
|
||
await wait(200);
|
||
const r2 = await page.evaluate(async () => {
|
||
const v = window.__demo.view;
|
||
const clicks = window.__clicks.map((e) => ({ enemyKey: e.enemyKey, preview: e.preview, side: e.unit?.side }));
|
||
window.__offClick();
|
||
const scout = [{ enemyKey: 'enemy_1007_slime', count: 4, gate: 'upper', t: 1, fly: false, elite: false, source: 'wave' }];
|
||
v.enterBattle({ fieldId: 'n:mate', kind: 'normal', rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, stageId: 'act2autochess_m01', prep: true, units: [], nextEnemies: scout });
|
||
const scouted = { pen: v.stats().pen, keys: [...new Set([...v.debug.penViews.values()].map((p) => p.info.enemyKey))], rows: [...new Set([...v.debug.penViews.values()].map((p) => Math.round(p.y)))] };
|
||
v.enterBattle({ fieldId: 'n:me', kind: 'normal', rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, stageId: 'act2autochess_m01', units: [] });
|
||
return { clicks, scouted, battle: v.stats().pen };
|
||
});
|
||
await page.close();
|
||
assert.deepEqual(problems, []);
|
||
assert.ok(r.prep > 0, 'the demo prep shows its preview');
|
||
assert.ok(r2.clicks.some((c) => c.enemyKey === r.click.key && c.preview === true && c.side === 'enemy'), JSON.stringify(r2.clicks));
|
||
assert.equal(r2.scouted.pen, 4);
|
||
assert.deepEqual(r2.scouted.keys, ['enemy_1007_slime']);
|
||
assert.ok(r2.scouted.rows.every((row) => row >= 17), 'upper gate → rows 17–18');
|
||
assert.equal(r2.battle, 0, 'cleared at combat start');
|
||
});
|
||
|
||
for (const side of ['L', 'R']) {
|
||
test(`Final Assault prep on the ${side} half: pieces on the boss field, drops and directions in board space${side === 'R' ? ', mirrored' : ''}`, async () => {
|
||
const { page, problems } = await open(`scene=prep&fa=${side}&stage=act2autochess_m02`, 1920, 1080);
|
||
await wait(2500);
|
||
const info = await page.evaluate(() => {
|
||
const v = window.__demo.view, st = window.__demo.scene.state;
|
||
const stage = window.__demo.scene.stageId;
|
||
const pf = v.prepField();
|
||
const b = st.board[0];
|
||
const view = v.debug.views.get('p:' + b.uid);
|
||
const hand = st.hand[0];
|
||
const hr = v.pieceScreenRect(hand.uid);
|
||
// a free melee tile of the own board (board space)
|
||
const taken = new Set(st.board.map((p) => `${p.row},${p.col}`));
|
||
const melee = (window.__deploy || null);
|
||
void melee; void stage;
|
||
return {
|
||
pf, boardPiece: { row: b.row, col: b.col, x: view.x, y: view.y, dir: view.dir }, hand: { uid: hand.uid, r: hr }, taken: [...taken],
|
||
ts: v.tileScreen(9, 2), camTx: v.debug.cam.tx,
|
||
};
|
||
});
|
||
assert.deepEqual(info.pf, { kind: 'bossPrep', side, mirror: side === 'R' });
|
||
// the board piece stands on its boss-field tile: row − 7, col mirrored on the right half, facing mirrored
|
||
assert.equal(info.boardPiece.y, info.boardPiece.row - 7);
|
||
assert.equal(info.boardPiece.x, side === 'R' ? 20 - info.boardPiece.col : info.boardPiece.col);
|
||
assert.equal(info.boardPiece.dir, side === 'R' ? 'LEFT' : 'RIGHT');
|
||
assert.equal(info.ts.mirror, side === 'R');
|
||
assert.ok(side === 'R' ? info.camTx > 10 : info.camTx < 10, `camera frames the own half (tx ${info.camTx})`);
|
||
await page.screenshot({ path: path.join(OUT, `fa-prep-${side}.png`) });
|
||
// drag the first hand piece onto a free legal board tile: the drop target comes back in board coordinates
|
||
const target = await page.evaluate(async () => {
|
||
const v = window.__demo.view;
|
||
const st = window.__demo.scene.state;
|
||
const taken = new Set(st.board.map((p) => `${p.row},${p.col}`));
|
||
const stage = (await import('/js/data.js')).data.lookup('stages', window.__demo.scene.stageId);
|
||
const tiles = (stage.deployTiles.normal.melee || []).filter(([r, c]) => !taken.has(`${r},${c}`));
|
||
const [row, col] = tiles[0];
|
||
const t = v.tileScreen(row, col);
|
||
window.__drops = [];
|
||
v.on('pieceDrop', (e) => window.__drops.push(e.target));
|
||
return { row, col, x: t.x, y: t.y };
|
||
});
|
||
const r = info.hand.r;
|
||
const cx = r.left + r.width / 2, cy = r.top + r.height * 0.6;
|
||
await page.mouse.move(cx, cy);
|
||
await page.mouse.down();
|
||
for (let i = 1; i <= 12; i++) await page.mouse.move(cx + (target.x - cx) * i / 12, cy + (target.y - cy) * i / 12);
|
||
await page.mouse.up();
|
||
await wait(400);
|
||
const drops = await page.evaluate(() => window.__drops);
|
||
const dirs = await page.evaluate((uid) => {
|
||
const v = window.__demo.view;
|
||
const b = window.__demo.scene.state.board[0];
|
||
v.setPieceDir(b.uid, 'RIGHT');
|
||
const a = v.debug.views.get('p:' + b.uid).dir;
|
||
v.setPieceDir(b.uid, 'UP');
|
||
const u = v.debug.views.get('p:' + b.uid).dir;
|
||
void uid;
|
||
return { a, u };
|
||
}, info.hand.uid);
|
||
// review regression: a range reaching past the field has no place on the boss field (a DOWN range below the
|
||
// bench used to land on the boss rows 6 / 5 — the wall and the own top row); the HUD padding of this prep is
|
||
// the prep one (shop bar below)
|
||
const past = await page.evaluate(async () => {
|
||
const v = window.__demo.view;
|
||
const { hudPadding } = await import('/js/ui/fieldHost.js');
|
||
const sz = { width: 1024, height: 1366 };
|
||
return {
|
||
field: !!v.tileScreen(9, 5), wallBelow: v.tileScreen(6, 5), below: v.tileScreen(5, 5), wallAbove: !!v.tileScreen(13, 5), pen: v.tileScreen(14, 5),
|
||
pad: JSON.stringify(hudPadding('bossPrep', sz)) === JSON.stringify(hudPadding('prep', sz)),
|
||
};
|
||
});
|
||
await page.close();
|
||
assert.deepEqual(problems, []);
|
||
assert.deepEqual(drops[0], { area: 'board', row: target.row, col: target.col }, `drop target in board space: ${JSON.stringify(drops)}`);
|
||
assert.deepEqual(dirs, { a: side === 'R' ? 'LEFT' : 'RIGHT', u: 'UP' });
|
||
assert.deepEqual(past, { field: true, wallBelow: null, below: null, wallAbove: true, pen: null, pad: true });
|
||
});
|
||
}
|
||
|
||
test('damage numbers: a boss and a knot of minions under rapid mixed hits never show touching numbers', async () => {
|
||
const { page, problems } = await open('scene=numbers', 1920, 1080);
|
||
await wait(2500);
|
||
const r = await page.evaluate(async () => {
|
||
const v = window.__demo.view;
|
||
let frames = 0, bad = 0, maxLive = 0;
|
||
const t0 = performance.now();
|
||
while (performance.now() - t0 < 4000) {
|
||
await new Promise((res) => requestAnimationFrame(res));
|
||
// the digits' box of every readable number (BitmapText layout metrics × scale; anchor 0.5 / 1)
|
||
const live = v.debug.fx.nums.filter((n) => n.text.visible && n.text.alpha > 0.35).map((n) => {
|
||
const t = n.text, sc = t.scale.x, w = t.textWidth * sc, h = t.textHeight * sc;
|
||
return { x0: t.position.x - w / 2, x1: t.position.x + w / 2, y0: t.position.y - h * 0.85, y1: t.position.y - h * 0.1 };
|
||
});
|
||
maxLive = Math.max(maxLive, live.length);
|
||
frames++;
|
||
let hit = false;
|
||
for (let i = 0; i < live.length && !hit; i++) for (let j = i + 1; j < live.length; j++) {
|
||
const a = live[i], b = live[j];
|
||
// 2 px apart at least: numbers side by side must never read as one ('41509')
|
||
if (a.x0 - 2 < b.x1 && b.x0 - 2 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1) { hit = true; break; }
|
||
}
|
||
if (hit) bad++;
|
||
}
|
||
return { frames, bad, maxLive, stats: v.stats() };
|
||
});
|
||
await page.screenshot({ path: path.join(OUT, 'dmgnum-1080.png') });
|
||
await page.close();
|
||
assert.deepEqual(problems, []);
|
||
assert.ok(r.maxLive >= 8, `busy scene (${r.maxLive} numbers)`);
|
||
assert.ok(r.bad <= r.frames * 0.03, `${r.bad}/${r.frames} frames with touching numbers`);
|
||
});
|
||
|
||
test('lost WebGL context: the 2D board takes over, then the 3D board rebuilds itself', async () => {
|
||
if (!hasArt) return;
|
||
const { page, problems } = await open('scene=normal-m03&t=20', 1280, 720);
|
||
await wait(1800);
|
||
const r = await page.evaluate(async () => {
|
||
const v = window.__demo.view;
|
||
const out = { before: v.stats().board3d.on };
|
||
v.debug.board3d.renderer.forceContextLoss();
|
||
await new Promise((res) => setTimeout(res, 400));
|
||
out.during = v.stats().board3d;
|
||
await new Promise((res) => setTimeout(res, 2600));
|
||
out.after = v.stats().board3d;
|
||
out.units = v.stats().units;
|
||
return out;
|
||
});
|
||
await page.screenshot({ path: path.join(OUT, 'board3d-context-recovered.png') });
|
||
await page.close();
|
||
assert.deepEqual(problems, []);
|
||
assert.equal(r.before, true);
|
||
assert.equal(r.during.on, false, 'the 2D board while the context is gone');
|
||
assert.equal(r.during.recovering, true, 'a rebuild is scheduled');
|
||
assert.equal(r.after.on, true, 'rebuilt on a fresh context');
|
||
assert.equal(r.after.losses, 1);
|
||
assert.ok(r.units > 0);
|
||
});
|
||
|
||
const PERF = [
|
||
['late-r13', 'scene=late-m01-r13&t=50'], ['late-r12', 'scene=late-m03-r12&t=40'], ['unite', 'scene=unite-m01&t=25'], ['boss', 'scene=boss-m02&t=30'], ['prep-pen', 'scene=prep&pen=late-m01-r13'],
|
||
];
|
||
for (const board of ['3d', '2d']) {
|
||
test(`4× throttled CPU at 1920×1080 (${board}): typical late-round / 联防 / boss / pen scenes hold ≥ 50 fps`, async () => {
|
||
if (board === '3d' && !hasArt) return;
|
||
const worst = [];
|
||
for (const [name, q] of PERF) {
|
||
const { page, problems } = await open(`${q}&board=${board}`, 1920, 1080);
|
||
await wait(2500);
|
||
const cdp = await page.target().createCDPSession();
|
||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||
await wait(1500);
|
||
const r = await page.evaluate(async () => {
|
||
const times = [];
|
||
let last = performance.now();
|
||
const t0 = last;
|
||
await new Promise((res) => { const f = (now) => { times.push(now - last); last = now; if (now - t0 < 4000) requestAnimationFrame(f); else res(); }; requestAnimationFrame(f); });
|
||
times.shift();
|
||
const mean = times.reduce((a, b) => a + b, 0) / times.length;
|
||
const st = window.__demo.stats();
|
||
return { fps: Math.round((1000 / mean) * 10) / 10, cpuMs: st.cpuMs, renderMs: st.renderMs, units: st.units, pen: st.pen, lod: st.lod, culled: st.culled };
|
||
});
|
||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 });
|
||
await page.close();
|
||
assert.deepEqual(problems, []);
|
||
perf.views.push({ name, board, ...r });
|
||
worst.push(`${name} ${r.fps}`);
|
||
assert.ok(r.fps >= 50, `${name} (${board}): ${r.fps} fps at 4× throttle (${JSON.stringify(r)})`);
|
||
}
|
||
void worst;
|
||
});
|
||
}
|
||
});
|