// 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'; 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); 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); 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; }); } });