卫戍协议:盟约 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:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+323
View File
@@ -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;
});
}
});