Files
ark-stronghold-protocol/test/render/browser.test.js
T
sganggs d4f868cd19 Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
2026-09-30 05:35:45 +08:00

462 lines
26 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/browser.test.js — headless Chrome checks of the battlefield renderer through the dev demo
// (public/dev/render-demo.html): every scene renders without console errors, Spine models load, drag & drop
// round-trips, camera transitions work, per-frame JS cost stays bounded. Screenshots → test/e2e/out/render-*.png.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/browser.test.js
// 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 } 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 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('render engine in headless Chrome', { skip }, () => {
let srv, browser;
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'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
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}`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
const err = await page.evaluate(() => window.__demo.error || null);
assert.equal(err, null, 'demo boot');
return { page, problems };
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const SCENES = [
['prep', 'scene=prep', 1920, 1080],
['prep-720', 'scene=prep', 1280, 720],
['combat', 'scene=normal-m01&t=18', 1920, 1080],
['combat-720', 'scene=normal-m03&t=30', 1280, 720],
['unite', 'scene=unite-m01&t=25', 1920, 1080],
['boss', 'scene=boss-m02&t=30', 1920, 1080],
['boss-720', 'scene=boss-m02&t=12', 1280, 720],
];
for (const [name, query, w, h] of SCENES) {
test(`${name} ${w}×${h} renders cleanly`, async () => {
const { page, problems } = await open(query + '&panel=0', w, h);
await wait(3500);
const st = await page.evaluate(() => window.__demo.stats());
await page.screenshot({ path: path.join(OUT, `render-${name}.png`) });
await page.close();
assert.deepEqual(problems, []);
assert.ok(st.units > 0, 'units on screen');
assert.equal(st.spine.failed, 0, 'no Spine load failures');
assert.ok(st.fps > 20, `fps ${st.fps}`);
if (name.startsWith('combat') || name.startsWith('unite') || name.startsWith('boss')) {
assert.equal(st.mode, 'battle');
assert.ok(Math.abs(st.rate - 2) < 0.3, `2× clock (${st.rate})`);
}
});
}
test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => {
const { page, problems } = await open('scene=fx&panel=0', 1280, 720);
// cycle through the whole FX_KINDS table (one kind every 0.5 game s = 0.25 real s), watching the FX system: shots
// in flight (every projectile kind incl. the boomerang) and 蕾缪安's S3 locks (the gallery plays it at 'lock')
const kinds = await page.evaluate(async () => (await import('/js/render/fx.js')).FX_KINDS).then((k) => Object.keys(k).length);
const seen = await page.evaluate(async (ms) => {
const seen = { projectiles: 0, particles: 0, locks: 0, auras: 0 };
const t0 = performance.now();
while (performance.now() - t0 < ms) {
const st = window.__demo.stats();
for (const k of Object.keys(seen)) seen[k] = Math.max(seen[k], st[k] || 0);
await new Promise((r) => setTimeout(r, 100));
}
return seen;
}, Math.min(45000, kinds * 260 + 1500));
const st = await page.evaluate(() => window.__demo.stats());
await page.screenshot({ path: path.join(OUT, 'render-fx.png') });
await page.close();
assert.deepEqual(problems, []);
assert.equal(st.mode, 'battle');
assert.ok(st.units >= 10, `units ${st.units}`);
assert.ok(seen.projectiles > 0 && seen.particles > 20 && seen.auras > 0, `projectiles, particles and a skill aura drawn (${JSON.stringify(seen)})`);
assert.ok(seen.locks >= 2, `蕾缪安's lock reticles shown (${JSON.stringify(seen)})`);
if (existsSync(path.join(ROOT, 'public/assets/local/map/autochess/tiles.json'))) assert.equal(st.boardArt, true, 'real board art composed');
});
/** fps / JS-per-frame (frame() + Pixi render EMAs, app.js stats) / particles over `ms`, sampled every 200 ms. */
async function frameCost(page, ms) {
return page.evaluate(async (ms) => {
const rows = [];
const t0 = performance.now();
while (performance.now() - t0 < ms) {
const s = window.__demo.stats();
rows.push([s.fps, s.cpuMs, s.renderMs, s.particles, s.projectiles]);
await new Promise((r) => setTimeout(r, 200));
}
const avg = (i) => rows.reduce((a, r) => a + r[i], 0) / rows.length;
return { fps: avg(0), cpuMs: avg(1), renderMs: avg(2), particles: avg(3), maxParticles: Math.max(...rows.map((r) => r[3])), projectiles: avg(4) };
}, ms);
}
const costLine = (c) => `fps ${c.fps.toFixed(1)} · JS ${c.cpuMs.toFixed(2)} + render ${c.renderMs.toFixed(2)} ms/frame · particles ${c.particles.toFixed(0)} (max ${c.maxParticles}) · shots ${c.projectiles.toFixed(1)}`;
test('stress: 120 units + FX keep the per-frame JS cost bounded', async (t) => {
const { page, problems } = await open('scene=stress&panel=0');
await wait(3000);
const cost = await frameCost(page, 3000);
const st = await page.evaluate(() => window.__demo.stats());
await page.screenshot({ path: path.join(OUT, 'render-stress.png') });
await page.close();
t.diagnostic(`stress 1920×1080: ${costLine(cost)}`);
assert.deepEqual(problems, []);
assert.equal(st.units, 120);
assert.ok(st.impostor >= 2, 'crowded field switches to impostor rendering');
assert.ok(cost.cpuMs + cost.renderMs < 16, `JS per frame ${cost.cpuMs} + ${cost.renderMs} ms`);
});
test('crowded boss battle: the FX keep the per-frame JS cost bounded', async (t) => {
const { page, problems } = await open('scene=boss-m02&t=16&panel=0');
await wait(2500);
// t ≈ 21–29 (2×): 蕾缪安's S3 locks (21–24) and her shells one by one (24–26), skills, crowds of shots and hits
const cost = await frameCost(page, 4000);
await page.close();
t.diagnostic(`boss-m02 1920×1080: ${costLine(cost)}`);
assert.deepEqual(problems, []);
assert.ok(cost.fps > 20, `fps ${cost.fps}`);
assert.ok(cost.cpuMs + cost.renderMs < 16, `JS per frame ${cost.cpuMs} + ${cost.renderMs} ms`);
});
test('drag a hand piece onto a legal board tile; right-click opens detail', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
await wait(2000);
const info = await page.evaluate(() => {
const v = window.__demo.view, st = window.__demo.scene.state;
const uid = st.hand[0].uid;
const r = v.pieceScreenRect(uid);
const t = v.debug.cam.project(5, 10, 0);
return { uid, r, target: { x: t.x, y: t.y } };
});
assert.ok(info.r && info.r.width > 10, 'pieceScreenRect');
const cx = info.r.left + info.r.width / 2, cy = info.r.top + info.r.height * 0.6;
await page.mouse.move(cx, cy);
await page.mouse.down();
for (let i = 1; i <= 10; i++) await page.mouse.move(cx + (info.target.x - cx) * i / 10, cy + (info.target.y - cy) * i / 10);
await page.screenshot({ path: path.join(OUT, 'render-drag.png') });
await page.mouse.up();
await wait(700);
const placed = await page.evaluate((uid) => window.__demo.scene.state.board.find((p) => p.uid === uid) || null, info.uid);
assert.ok(placed, 'piece moved to the board');
assert.deepEqual([placed.row, placed.col], [10, 5]);
// picking is by tile (user playtest #4 item 1): right-click the unit's own tile
const r2 = await page.evaluate(() => { const p = window.__demo.scene.state.board[1]; return window.__demo.view.tileScreen(p.row, p.col); });
await page.mouse.click(r2.x, r2.y, { button: 'right' });
await wait(150);
const log = await page.evaluate(() => window.__demo.log.join('\n'));
assert.match(log, /pieceClick .*"detail":true/);
await page.close();
assert.deepEqual(problems, []);
});
test('API survives junk input, calls in any order, and double destroy', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1280, 720);
await wait(1000);
const res = await page.evaluate(async () => {
const { createFieldView } = await import('/js/render/app.js');
const host = document.createElement('div');
host.style.cssText = 'position:fixed;left:0;top:0;width:640px;height:360px;z-index:9';
document.body.appendChild(host);
const v = await createFieldView(host, { data: null, assets: null });
const out = [];
const tryit = (name, fn) => { try { out.push([name, fn()]); } catch (e) { out.push([name, 'THROW ' + e.message]); } };
tryit('pushSnapshot before battle', () => v.pushSnapshot({ t: 1, units: [[1, 2, 3, 4, 5, 6, 7, 8, 9]] }));
tryit('setStage junk', () => v.setStage({ rows: 5 }));
tryit('setStage null', () => v.setStage(null));
tryit('setCamera junk', () => v.setCamera(null, null));
tryit('setCamera weird', () => v.setCamera('zzz', { rect: { r0: 'a' } }));
tryit('setPrep null', () => v.setPrep(null));
tryit('setPrep junk', () => v.setPrep({ hand: 'x', temp: [null, 5, { uid: 'q' }], board: [{ uid: 1, row: 99, col: 1, kind: 'chess', id: 'nope' }, { uid: 2, row: 10, col: 4, kind: 'chess', id: 'nope' }] }, { editable: true, canPlace: () => { throw new Error('x'); } }));
tryit('highlight junk', () => v.highlightTiles('x', 'y'));
tryit('highlight mixed', () => v.highlightTiles([[1, 2], { row: 3, col: 4 }, null, [99, 99], ['a']], { color: 0xff0000 }));
tryit('highlight clear', () => v.highlightTiles(null));
tryit('enterBattle junk', () => v.enterBattle({ units: [null, { id: {} }, { id: 5, x: 'q' }] }));
tryit('pushSnapshot junk', () => [v.pushSnapshot(null), v.pushSnapshot({ t: NaN }), v.pushSnapshot({ t: 1, units: [[5, 1, 1, 10, 10, 0, 0, 0, 0], 'x', [7]] })]);
tryit('pushEvents junk', () => [v.pushEvents(null), v.pushEvents({ ev: 'x' }), v.pushEvents([['atk', 5, 999, 'arrow'], ['dmg', 5, 'x', 'phys'], ['heal'], ['status', 5], ['fx', 'burst', 'a', null, null], ['layer', 1, 'yanShip', 3], ['bounty', 1, 2], ['leak', 5], ['nope'], 7])]);
await new Promise((r) => setTimeout(r, 400));
tryit('snapshot after', () => v.pushSnapshot({ t: 1.2, units: [[5, 1.5, 1, 10, 10, 0, 0, 16, 2]] }));
await new Promise((r) => setTimeout(r, 300));
tryit('pieceScreenRect', () => [v.pieceScreenRect(-1), v.pieceScreenRect(undefined)]);
tryit('settings', () => { v.setSettings(null); v.setSettings({ quality: 'low', damageNumbers: false }); v.setSettings({ quality: 'nope' }); return true; });
tryit('on junk', () => typeof v.on('x', null));
tryit('stats', () => v.stats().mode);
tryit('destroy', () => { v.destroy(); v.destroy(); return true; });
tryit('after destroy', () => [v.setPrep({}), v.pushSnapshot({ t: 9, units: [] }), v.enterBattle({})]);
host.remove();
return out;
});
await wait(300);
await page.close();
const thrown = res.filter(([, r]) => typeof r === 'string' && r.startsWith('THROW'));
assert.deepEqual(thrown, []);
assert.deepEqual(problems.filter((p) => !/\[render\]/.test(p)), []);
});
test('raised blocks share the unit layer and sort around units; operators on walls stand on top', async () => {
// the 2D atlas board's block rows (the 3D board, default when the local art is installed, has none: board=2d)
const { page, problems } = await open('scene=normal-m01&t=4&panel=0&board=2d', 1600, 900);
await wait(2500);
const r = await page.evaluate(() => {
const { tiles, views } = window.__demo.view.debug;
const out = { rows: [...tiles.rowMeshes.keys()], shared: true, order: [], walls: [] };
for (const [id, v] of views) {
if (!v.root || v.destroyed || !v.alive) continue;
const row = Math.round(v.y);
if (Math.round(v.x) === 4 && row >= 10 && row <= 12) {
out.walls.push({ id, z: v.z, shadowOnSurface: v.shadow?.parent === tiles.rowSurfaces.get(row) });
}
for (const [br, { mesh: m }] of tiles.rowMeshes) {
if (m.parent !== v.root.parent) out.shared = false;
const kids = m.parent.children;
// after a render the layer is sorted: the unit is behind the row's blocks iff it stands further back
out.order.push({ id, y: v.y, br, before: kids.indexOf(v.root) < kids.indexOf(m) });
}
}
return out;
});
await page.screenshot({ path: path.join(OUT, 'render-walls.png') });
await page.close();
assert.deepEqual(problems, []);
assert.ok(r.rows.length > 0, 'raised rows have block meshes');
assert.ok(r.shared, 'block rows live in the unit layer');
const wrong = r.order.filter((o) => o.before !== (o.y > o.br + 0.5));
assert.deepEqual(wrong, [], 'units behind a block row are drawn before it, others after');
assert.ok(r.walls.length >= 2, `operators on the col-4 walls (${r.walls.length})`);
for (const w of r.walls) {
assert.ok(w.z > 0.12, `unit ${w.id} stands on the wall (z ${w.z})`);
assert.ok(w.shadowOnSurface, `unit ${w.id} shadow lies on the wall top`);
}
});
test('attack wind-up starts before the atk event reaches the render clock', async () => {
const { page, problems } = await open('scene=normal-m01&t=6&panel=0', 1280, 720);
await wait(800);
const log = await page.evaluate(async () => {
const { views, interp } = window.__demo.view.debug;
const log = [];
const hook = () => {
for (const [id, v] of views) {
if (v._hooked || !v.windUp) continue;
v._hooked = true;
const w = v.windUp.bind(v), a = v.onAttack.bind(v);
v.windUp = (lead) => { const ok = w(lead); if (ok) log.push(['wind', id, interp.renderT, lead]); return ok; };
v.onAttack = (tg, now) => { log.push(['atk', id, interp.renderT]); return a(tg, now); };
}
};
for (let i = 0; i < 80; i++) { hook(); await new Promise((r) => setTimeout(r, 50)); }
return log;
});
await page.close();
assert.deepEqual(problems, []);
const winds = log.filter((e) => e[0] === 'wind');
assert.ok(winds.length >= 3, `wind-ups started (${winds.length})`);
let paired = 0;
for (const [, id, t, lead] of winds) {
assert.ok(lead > 0 && lead <= 1.5, `lead ${lead}`);
const hit = log.find((e) => e[0] === 'atk' && e[1] === id && e[2] >= t);
if (hit && hit[2] - t > 0.02) paired++;
}
assert.ok(paired >= 2, `wind-ups followed by their attack (${paired})`);
});
test('a leaked enemy fades once and is not recreated from the trailing snapshots', async () => {
const { page, problems } = await open('scene=normal-m02&t=33.5&panel=0', 1280, 720);
const seen = await page.evaluate(async () => {
const { views, interp } = window.__demo.view.debug;
const seen = [];
for (let i = 0; i < 120; i++) {
seen.push([interp.renderT, views.has(18)]);
await new Promise((r) => setTimeout(r, 25));
}
return seen;
});
await page.close();
assert.deepEqual(problems, []);
const first = seen.findIndex(([, has]) => has);
assert.ok(first >= 0, 'enemy 18 shown before its leak');
const gone = seen.findIndex(([, has], i) => i > first && !has);
assert.ok(gone > first && seen[gone][0] > 35, `removed after the leak at 35 (at ${seen[gone]?.[0]})`);
const back = seen.slice(gone).filter(([, has]) => has);
assert.deepEqual(back, [], 'not resurrected by the snapshots that still list it');
});
test('prep scouting board (prep:true + one gt:0 snapshot) stays visible and static', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1280, 720);
await wait(1200);
const r = await page.evaluate(async () => {
const rec = await fetch('/dev/recordings/normal-m01.json').then((x) => x.json());
const v = window.__demo.view;
const snap = rec.frames[0].snap;
v.enterBattle({ fieldId: 'scout', kind: 'normal', rect: rec.field.rect, units: rec.field.units, prep: true });
v.pushSnapshot({ t: 'b.snap', fieldId: 'scout', gt: 0, units: snap.units });
const pos = () => [...v.debug.views].filter(([, u]) => u.root && !u.destroyed).map(([id, u]) => [id, +u.x.toFixed(3), +u.y.toFixed(3), u.root.visible && u.root.alpha > 0.5]);
await new Promise((res) => setTimeout(res, 1500));
const a = pos();
await new Promise((res) => setTimeout(res, 1500));
return { a, b: pos(), n: rec.field.units.length, mode: v.stats().mode };
});
await page.screenshot({ path: path.join(OUT, 'render-scout.png') });
await page.close();
assert.deepEqual(problems, []);
assert.equal(r.a.length, r.n, 'every unit has a view');
assert.ok(r.a.every((u) => u[3]), 'all visible');
assert.deepEqual(r.b, r.a, 'nothing moved or vanished');
});
test('facing hooks: tileScreen / holdPiece / setPieceDir; board pieces and battle allies are built with their dir', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
await wait(1500);
const r = await page.evaluate(async () => {
const v = window.__demo.view, st = window.__demo.scene.state;
const out = {};
// tileScreen = the camera projection of the tile-top centre, in client px (the wheel's geometry)
const ts = v.tileScreen(10, 5);
const cr = v.debug.app.view.getBoundingClientRect();
const c = v.debug.cam.project(5, 10, v.debug.tiles.heightAt(10, 5));
out.ts = ts && [Math.abs(ts.x - (cr.left + c.x)) < 0.01, Math.abs(ts.y - (cr.top + c.y)) < 0.01, ts.poly.length, ts.s > 0];
out.tsJunk = [v.tileScreen('a', 1), v.tileScreen(1.5, 2)];
// holdPiece pins a bench piece on a board tile across setPrep calls until released
const uid = st.hand[0].uid;
const pv = () => v.debug.views.get('p:' + uid);
v.holdPiece(uid, { row: 10, col: 5 });
await new Promise((res) => setTimeout(res, 450));
out.held = [+pv().x.toFixed(2), +pv().y.toFixed(2)];
v.setPrep(JSON.parse(JSON.stringify(st)), { editable: true });
await new Promise((res) => setTimeout(res, 1600)); // longer than the drop-pending grace
out.heldAfterPrep = [+pv().x.toFixed(2), +pv().y.toFixed(2)];
v.holdPiece(uid, null);
v.setPrep(JSON.parse(JSON.stringify(st)), { editable: true });
await new Promise((res) => setTimeout(res, 450));
out.released = [+pv().x.toFixed(2), +pv().y.toFixed(2)];
// setPieceDir re-orients a prep view
out.setDir = [v.setPieceDir(uid, 'UP'), pv().dir, v.setPieceDir(-5, 'UP'), v.setPieceDir(uid, 7)];
v.setPieceDir(uid, 'RIGHT');
// a board piece carrying dir is built facing it (after the round's rebuild: enterPrepMode clears every view)
const b0 = st.board[0];
v.enterBattle({ fieldId: 'x', kind: 'normal', units: [] });
const next = JSON.parse(JSON.stringify(st));
next.board[0].dir = 'LEFT';
if (next.board[1]) next.board[1].dir = 'UP';
v.setPrep(next, { editable: true });
const bv = (p) => v.debug.views.get('p:' + p.uid);
out.built = [bv(b0).dir, bv(b0).facing, next.board[1] ? bv(next.board[1]).dir : 'UP'];
// battle / scouting allies keep UnitInfo.dir (model + ground wedge); a unit without it derives RIGHT, no wedge
v.enterBattle({ fieldId: 'dirs', kind: 'normal', rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, prep: true, units: [
{ id: 1, kind: 'op', side: 'ally', defId: b0.id, x: 3, y: 10, facing: 1, dir: 'UP', maxHp: 100 },
{ id: 2, kind: 'op', side: 'ally', defId: b0.id, x: 4, y: 10, facing: -1, dir: 'LEFT', maxHp: 100 },
{ id: 3, kind: 'op', side: 'ally', defId: b0.id, x: 5, y: 10, facing: 1, maxHp: 100 },
] });
v.pushSnapshot({ fieldId: 'dirs', gt: 0, units: [[1, 3, 10, 100, 100, 0, 0, 0, 0], [2, 4, 10, 100, 100, 0, 0, 0, 0], [3, 5, 10, 100, 100, 0, 0, 0, 0]] });
await new Promise((res) => setTimeout(res, 500));
const u = (id) => v.debug.views.get(id);
out.battle = [1, 2, 3].map((id) => u(id) && [u(id).dir, u(id).hasDir, u(id).facing]);
return out;
});
await page.close();
assert.deepEqual(problems, []);
assert.deepEqual(r.ts, [true, true, 4, true], 'tileScreen = projected tile centre + 4 corners');
assert.deepEqual(r.tsJunk, [null, null]);
assert.deepEqual(r.held, [5, 10], 'held on the tile');
assert.deepEqual(r.heldAfterPrep, [5, 10], 'setPrep and the drop grace keep a held piece on its tile');
assert.notDeepEqual(r.released, [5, 10], 'released: back on its bench slot');
assert.deepEqual(r.setDir, [true, 'UP', false, false]);
assert.deepEqual(r.built, ['LEFT', -1, 'UP'], 'stored dir applied at build time');
assert.deepEqual(r.battle, [['UP', true, 1], ['LEFT', true, -1], ['RIGHT', false, 1]]);
});
test('drops outside the stage report client coords; a refused drop tweens back home', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
await wait(2000);
const info = await page.evaluate(() => {
window.__demo.refuseDrops = true;
const v = window.__demo.view, st = window.__demo.scene.state;
const uid = st.hand[0].uid;
const pv = v.debug.views.get('p:' + uid);
const t = v.debug.cam.project(5, 10, 0);
return { uid, r: v.pieceScreenRect(uid), home: [pv.x, pv.y], target: { x: t.x, y: t.y } };
});
const drag = async (tx, ty) => {
const cx = info.r.left + info.r.width / 2, cy = info.r.top + info.r.height * 0.6;
await page.mouse.move(cx, cy);
await page.mouse.down();
for (let i = 1; i <= 10; i++) await page.mouse.move(cx + (tx - cx) * i / 10, cy + (ty - cy) * i / 10);
await page.mouse.up();
};
await drag(8, 8);
await wait(500);
const log1 = await page.evaluate(() => window.__demo.log.join('\n'));
assert.match(log1, /pieceDrop .*"area":"outside","clientX":8,"clientY":8/);
await drag(info.target.x, info.target.y);
await wait(250);
const mid = await page.evaluate((uid) => { const pv = window.__demo.view.debug.views.get('p:' + uid); return [pv.x, pv.y]; }, info.uid);
await wait(1800);
const end = await page.evaluate((uid) => { const pv = window.__demo.view.debug.views.get('p:' + uid); return [pv.x, pv.y]; }, info.uid);
const onBoard = await page.evaluate((uid) => window.__demo.scene.state.board.some((p) => p.uid === uid), info.uid);
await page.close();
assert.deepEqual(problems, []);
assert.equal(onBoard, false, 'demo refused the move');
assert.ok(Math.hypot(mid[0] - 5, mid[1] - 10) < 0.3, `held at the drop tile while pending (${mid})`);
assert.ok(Math.hypot(end[0] - info.home[0], end[1] - info.home[1]) < 0.05, `back home (${end} vs ${info.home})`);
});
test('DPR change and resize re-fit the canvas', async () => {
const { page, problems } = await open('scene=normal-m02&t=10&panel=0', 1280, 720);
await wait(1000);
const size = () => page.evaluate(() => { const c = window.__demo.view.debug.app.view; return [c.width, c.height]; });
const a = await size();
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
await wait(700);
const b = await size();
await page.setViewport({ width: 900, height: 700, deviceScaleFactor: 1 });
await wait(700);
const c = await size();
await page.screenshot({ path: path.join(OUT, 'render-resize.png') });
await page.close();
assert.deepEqual(problems, []);
assert.ok(b[0] > a[0] * 1.3, `DPR 2 raises the backing resolution (${a} → ${b})`);
assert.ok(c[0] < b[0] && Math.abs(c[0] / c[1] - 900 / 700) < 0.05, `resized to 900×700 aspect (${c})`);
});
test('camera flies prep → boss → unite without errors', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1280, 720);
await wait(1500);
await page.evaluate(() => window.__demo.view.setCamera('boss'));
await wait(350);
await page.screenshot({ path: path.join(OUT, 'render-camera-mid.png') });
await wait(700);
const cam1 = await page.evaluate(() => window.__demo.view.stats().camera);
await page.evaluate(() => window.__demo.view.setCamera('unite'));
await wait(1000);
const cam2 = await page.evaluate(() => window.__demo.view.stats().camera);
await page.close();
assert.deepEqual(problems, []);
assert.ok(cam1.ty < 5, 'boss camera looks at rows 0–5');
assert.ok(cam2.tx > 9 && cam2.ty > 9, 'unite camera centred on the wide field');
});
});