Files
ark-stronghold-protocol/test/render/fieldview.browser.test.js
T

374 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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; a dragged unit stands on its target's
// boss-field tile) with the round's leader on its spawn tile and
// its red hit tiles beside a range preview (community report #12), 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, fa-leader-*.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';
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 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: ['--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 rect = v.debug.app.view.getBoundingClientRect();
// picking is by tile (user playtest #4 item 1): a press at the figure's feet, on its own tile
out.click = { x: rect.left + pv.screen.x, y: rect.top + pv.screen.y, 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);
// over the legal target the dragged unit stands on it (the official deploy drag): on that board tile's boss-field
// tile, as the drop will place it — row − 7, the column mirrored on the right half
const stood = await page.evaluate((uid) => { const v = window.__demo.view.debug.views.get('p:' + uid); return { x: v.x, y: v.y, lift: v.lift }; }, info.hand.uid);
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(stood, { x: side === 'R' ? 20 - target.col : target.col, y: target.row - 7, lift: 0.3 }, 'while dragged over it: standing on the target\'s boss-field tile');
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 });
});
}
for (const side of ['L', 'R']) {
test(`Final Assault prep (${side}): the leader stands on its spawn tile, out of the pen; a range preview lights its hit tiles in red (report #12)`, async () => {
const { page, problems } = await open(`scene=prep&fa=${side}&stage=act2autochess_m02`, 1920, 1080);
await wait(1500);
const r = await page.evaluate(async () => {
const v = window.__demo.view, d = v.debug;
// m.private as the server sends it in a boss round's prep: nextEnemies with the leader's spawn tile (waves.js previewOf)
const st = JSON.parse(JSON.stringify(window.__demo.scene.state));
st.nextEnemies = [
{ enemyKey: 'enemy_9013_acstmk', count: 1, gate: 'upper', t: 0, fly: false, elite: true, boss: true, source: 'wave', tag: 'boss', start: [3, 10] },
{ enemyKey: 'enemy_1007_slime', count: 3, gate: 'lower', t: 2, fly: false, elite: false, boss: false, source: 'wave', tag: null },
];
v.setPrep(st, { editable: true });
await new Promise((res) => setTimeout(res, 2000));
const L = d.leader;
const hl = () => (d.tiles.highlights.get('leaderHit')?.tiles || []).map((t) => t.join(','));
const out = { has: !!L, x: L?.view.x, y: L?.view.y, shown: !!L?.view.root.visible, facing: L?.view.visFacing, spine: !!L?.view.actor,
pen: [...d.penViews.values()].map((p) => p.info.defId), before: hl() };
v.highlightTiles([[10, 5], [10, 6], [10, 7]], { group: 'facing', color: 0xff9c33, fill: 0.36, line: 1 });
out.during = hl();
out.range = (d.tiles.highlights.get('facing')?.tiles || []).length;
return out;
});
await wait(300);
await page.screenshot({ path: path.join(OUT, `fa-leader-${side}.png`) });
const r2 = await page.evaluate(() => {
const v = window.__demo.view, d = v.debug;
v.highlightTiles([], { group: 'facing' });
const after = (d.tiles.highlights.get('leaderHit')?.tiles || []).length;
v.setCamera('prep', { instant: true });
return { after, hiddenOnBoard: !d.leader.view.root.visible };
});
await page.close();
assert.deepEqual(problems, []);
assert.deepEqual([r.has, r.x, r.y, r.shown], [true, 10, 3, true], 'on its spawn tile (3, 10), shown by the boss-field prep camera');
assert.equal(r.facing, side === 'R' ? 1 : -1, 'facing the player\'s half');
assert.ok(r.spine, 'its Spine model');
assert.ok(!r.pen.includes('enemy_9013_acstmk') && r.pen.length === 3, `not in the pen: ${r.pen}`);
assert.deepEqual(r.before, [], 'no red tiles without a range preview');
assert.equal(r.range, 3);
assert.equal(r.during.length, 15, `the 5 × 3 hit tiles: ${r.during}`);
assert.ok(['3,8', '3,12', '5,8', '5,12', '4,10'].every((t) => r.during.includes(t)));
assert.equal(r2.after, 0, 'gone with the range preview');
assert.equal(r2.hiddenOnBoard, true, 'the own-board camera does not show it');
});
}
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);
await page.emulateCPUThrottling(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 page.emulateCPUThrottling(null);
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;
});
}
});