卫戍协议:盟约 web remake — initial import
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).
This commit is contained in:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -0,0 +1,323 @@
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user