Files

510 lines
36 KiB
JavaScript
Raw Permalink 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/models.browser.test.js — user playtest #3 item 1 and user playtest #4 item 1 in headless Chrome, through the
// in-match mock (public/dev/game-mock.html: the real game screen, a mock server behind net.request):
// #3.1 operator models never go missing: after a battle that outlasts the Spine LRU grace, the battle → prep switch
// (every battle view destroyed, the prep views built in the same task) used to unload the bench models and hand
// the doomed skeletons straight back (PIXI.Assets serves an unloading asset from its loader cache): invisible
// chibis. Also a burst of rapid drags / swaps / direction-wheel previews and commits / cancels / re-drags. After
// each: every prep piece has a view, visible, on its tile, drawing a live Spine model (textures alive) — and the
// canvas pixels over it change when it is hidden (it really draws something).
// #4.1 picking by tile ("地上都画好了一个一个方格,点击对应方格就选中那个方格的人物就行"): a press anywhere on a tile — board
// (adjacent rows and columns, high ground), bench, temporary bench — selects the unit on it and an empty tile
// nothing (a press on a unit's head, drawn over the tile behind it, is a press on that tile), in prep and in battle
// (allies by their tile, enemies by their ground position or drawn body); a dragged unit stands on the tile under
// the pointer while that is a legal target — wherever the pointer is on the tile, as in the official deploy drag
// (the owner's recording of 2026-10-09; PR #403's report) — and is held under the pointer elsewhere (over the
// shop bar: its drawn feet DRAG_HOLD_TILES below it, the model around it), for a mouse and a finger (phone,
// 844×390); the drop, its tileHover and the direction wheel are the tile under the pointer; equipment dropped on
// a tile equips the unit on it (not the one in front whose head is drawn there); on a phone a release on a bench
// slot lands there and one over the shop bar goes back.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/models.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/models-*.png.
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';
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)';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Page-side helpers (installed once per page): the prep pieces, their views, the invariants of item 1. */
function installHelpers() {
const G = { HAND_ROW: 7, TEMP_ROW: 8, TEMP_C0: 4 };
const raw = () => globalThis.__SP_VIEW__.raw;
const priv = () => globalThis.__MOCK__.store.get().match.private;
const texOk = (t) => !!t && !t.destroyed && !!t.baseTexture && !t.baseTexture.destroyed && t.baseTexture.valid !== false;
window.__t = {
pieces() {
const p = priv(), out = [];
(p.board || []).forEach((x) => x && out.push({ uid: x.uid, kind: x.kind, row: x.row, col: x.col }));
(p.hand || []).forEach((x, i) => x && out.push({ uid: x.uid, kind: x.kind, row: G.HAND_ROW, col: i }));
(p.temp || []).forEach((x, i) => x && out.push({ uid: x.uid, kind: x.kind, row: G.TEMP_ROW, col: G.TEMP_C0 + i }));
return out;
},
/** Client point `up` tiles above the feet of a view (+ `dx` tiles aside); views keyed 'p:uid' (prep) or unit id. */
at(key, up, dx = 0) {
const R = raw(), v = R.debug.views.get(key), cv = R.debug.app.view.getBoundingClientRect();
return { x: cv.left + v.screen.x + dx * v.screen.s, y: cv.top + v.screen.y - up * v.screen.s };
},
/** Item 1 invariants; returns the problems found. */
check() {
const R = raw(), problems = [];
for (const p of this.pieces()) {
const v = R.debug.views.get('p:' + p.uid), tag = `${p.uid}@${p.row},${p.col}`;
if (!v || v.destroyed) { problems.push(`${tag}: no view`); continue; }
if (!v.root.visible || !(v.root.alpha > 0.9)) problems.push(`${tag}: hidden (${v.root.visible}, ${v.root.alpha})`);
if (Math.abs(v.x - p.col) > 0.05 || Math.abs(v.y - p.row) > 0.05) problems.push(`${tag}: parked at ${v.x.toFixed(2)},${v.y.toFixed(2)}`);
if (p.kind === 'item') continue;
if (!v.actor || !v.spineReady) { problems.push(`${tag}: no Spine model`); continue; }
let dead = 0, n = 0;
for (const slot of v.actor.spine.skeleton.slots) { const s = slot.currentSprite || slot.currentMesh; if (!s || !s.texture) continue; n++; if (!texOk(s.texture)) dead++; }
if (dead) problems.push(`${tag}: ${dead}/${n} textures destroyed`);
if (v.imp) { if (!texOk(v.imp.sprite.texture)) problems.push(`${tag}: impostor texture destroyed`); }
else if (v.actor.spine.parent !== v.body || !v.actor.spine.visible || !(v.actor.spine.alpha > 0.9)) problems.push(`${tag}: model not shown`);
}
return problems;
},
/** Share of the pixels over each unit's body that change when the unit is hidden (≈ 0: nothing drawn). */
drawn() {
const R = raw(), app = R.debug.app, rd = app.renderer, gl = rd.gl, res = rd.resolution, H = rd.view.height, out = {};
for (const p of this.pieces()) {
if (p.kind === 'item') continue;
const v = R.debug.views.get('p:' + p.uid);
if (!v) continue;
const b = v.bounds();
const x0 = Math.max(0, Math.floor(b.x * res)), y0 = Math.max(0, Math.floor(b.y * res));
const w = Math.max(1, Math.min(Math.floor(b.width * res), rd.view.width - x0)), h = Math.max(1, Math.min(Math.floor(b.height * res), H - y0));
const read = () => { R.debug.ctx.impostors?.flush?.(); rd.render(app.stage); const px = new Uint8Array(w * h * 4); gl.readPixels(x0, H - y0 - h, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); return px; };
const a = read();
v.root.visible = false;
const c = read();
v.root.visible = true;
let diff = 0;
for (let i = 0; i < a.length; i += 4) if (Math.abs(a[i] - c[i]) + Math.abs(a[i + 1] - c[i + 1]) + Math.abs(a[i + 2] - c[i + 2]) + Math.abs(a[i + 3] - c[i + 3]) > 40) diff++;
out[p.uid] = diff / (w * h);
}
return out;
},
};
}
describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip }, () => {
let srv, browser;
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', '--force-device-scale-factor=1'] });
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}`));
await page.setViewport({ width: w, height: h });
await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=engine&${query}`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__?.raw?.debug, { timeout: 30000 });
await page.evaluate(installHelpers);
await sleep(2000); // Spine models
return { page, problems };
}
const setPhase = (page, ph) => page.evaluate(async (p) => { const { PHASE } = await import('/shared/constants.js'); globalThis.__MOCK__.setPhase(PHASE[p]); }, ph);
const uidAt = (page, row, col) => page.evaluate((r, c) => globalThis.__MOCK__.S().priv.board.find((p) => p.row === r && p.col === c)?.uid ?? null, row, col);
const assertAllDrawn = async (page, label) => {
assert.deepEqual(await page.evaluate(() => window.__t.check()), [], `${label}: every prep model alive and in place`);
const drawn = await page.evaluate(() => window.__t.drawn());
for (const [uid, frac] of Object.entries(drawn)) assert.ok(frac > 0.05, `${label}: unit ${uid} draws pixels (${frac.toFixed(3)})`);
};
test('1: the prep models come back alive after a battle that outlasts the Spine LRU grace', async () => {
const { page, problems } = await open('phase=PREP');
await assertAllDrawn(page, 'first prep');
// a short grace stands in for a long battle (default 15 s)
await page.evaluate(async () => { const { assets } = await import('/js/assets.js'); assets.spine.cache.idleGrace = 400; });
for (let round = 0; round < 2; round++) {
await setPhase(page, 'COMBAT');
await sleep(2000);
await setPhase(page, 'PREP');
await sleep(2500);
await assertAllDrawn(page, `prep after battle ${round + 1}`);
}
const st = await page.evaluate(async () => (await import('/js/assets.js')).assets.spine.stats());
assert.equal(st.unloading, 0);
await page.screenshot({ path: path.join(OUT, 'models-after-battles.png') });
await page.close();
assert.deepEqual(problems, []);
});
test('1: rapid drags, swaps, wheel previews (all four directions) with commit / Esc / ✕ and re-drags keep every model', async () => {
const { page, problems } = await open('phase=PREP', 1600, 900);
let seed = 7;
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 2 ** 32; };
const tiles = await page.evaluate(() => { const st = globalThis.__MOCK__.S().stage; return [...st.deployTiles.normal.melee, ...st.deployTiles.normal.rangedOnly]; });
const drag = async (from, to) => {
await page.mouse.move(from.x, from.y); await page.mouse.down();
for (let i = 1; i <= 6; i++) await page.mouse.move(from.x + ((to.x - from.x) * i) / 6, from.y + ((to.y - from.y) * i) / 6);
await page.mouse.up();
};
const wheel = async (how) => {
const g = await page.evaluate(() => { const el = document.querySelector('.fwheel__dia'); if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, h: r.height / 2 }; });
if (!g) return;
if (how === 'esc') { await page.keyboard.press('Escape'); return; }
if (how === 'x') { await page.click('.fwheel__cancel').catch(() => {}); return; }
await page.mouse.move(g.x, g.y); await page.mouse.down();
for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) await page.mouse.move(g.x + dx * g.h * 0.6, g.y + dy * g.h * 0.6, { steps: 3 });
if (how === 'up') await page.mouse.move(g.x, g.y - g.h * 0.6, { steps: 2 });
await page.mouse.up();
};
for (let burst = 0; burst < 2; burst++) {
for (let k = 0; k < 16; k++) {
const units = (await page.evaluate(() => window.__t.pieces())).filter((p) => p.kind !== 'item');
const p = units[Math.floor(rnd() * units.length)];
const [row, col] = rnd() < 0.8 ? tiles[Math.floor(rnd() * tiles.length)] : [7, Math.floor(rnd() * 10)];
const from = await page.evaluate((u) => window.__t.at('p:' + u, 0.1), p.uid); // on its own tile
const to = await page.evaluate((r, c) => globalThis.__SP_VIEW__.raw.tileScreen(r, c), row, col);
await drag(from, to);
await sleep(40 + rnd() * 80);
await wheel(['up', 'up', 'left', 'esc', 'x'][Math.floor(rnd() * 5)]);
await sleep(rnd() < 0.5 ? 20 : 300); // re-drag before the mock server answered, or after
}
await page.keyboard.press('Escape');
await sleep(1800);
await assertAllDrawn(page, `burst ${burst + 1}`);
}
await page.close();
assert.deepEqual(problems, []);
});
/** Client point on a board-space tile's top face: (u, v) ∈ [−1, 1] from its centre towards its left / right and near /
* far edges (view.tileScreen poly: far-left, far-right, near-right, near-left). */
const tileSpot = (page, row, col, u = 0, v = 0) => page.evaluate((r, c, u, v) => {
const t = globalThis.__SP_VIEW__.raw.tileScreen(r, c);
if (!t) return null;
const [fl, fr, nr, nl] = t.poly, a = (u + 1) / 2, b = (v + 1) / 2;
const near = [nl[0] + (nr[0] - nl[0]) * a, nl[1] + (nr[1] - nl[1]) * a], far = [fl[0] + (fr[0] - fl[0]) * a, fl[1] + (fr[1] - fl[1]) * a];
return { x: near[0] + (far[0] - near[0]) * b, y: near[1] + (far[1] - near[1]) * b };
}, row, col, u, v);
/** The render engine's own pick at a client point (render/pick.js through app.js pieceAt / battleUnitAt). */
const pickAt = (page, pt, battle = false) => page.evaluate((x, y, b) => {
const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect();
if (!b) return R.debug.pick.pieceAt(x - cv.left, y - cv.top)?.uid ?? null;
return R.debug.pick.battleUnitAt(x - cv.left, y - cv.top)?.id ?? null;
}, pt.x, pt.y, battle);
const listenClicks = (page) => page.evaluate(() => {
window.__picked = [];
window.__offPick?.();
window.__offPick = globalThis.__SP_VIEW__.raw.on('pieceClick', (e) => window.__picked.push(e.unitId ?? e.uid ?? null));
});
const clickAt = async (page, pt) => {
await page.mouse.click(pt.x, pt.y);
await sleep(90);
const got = await page.evaluate(() => window.__picked.splice(0));
await page.keyboard.press('Escape');
await sleep(40);
return got;
};
// spots of a tile's top face that show on screen: its centre, 0.3 tile out to the sides and 0.4 to the far edge, 0.15
// towards the near edge (a raised tile in front hides the strip of this one just behind it: a 0.3-high block the
// nearest 0.17 tile, the 0.55 separator wall in front of the bench 0.22 — the press there is on the block's top)
const SPOTS = [[0, 0], [-0.6, 0], [0.6, 0], [0, -0.3], [0, 0.8], [-0.6, 0.6], [0.6, 0.6], [-0.6, -0.3], [0.6, -0.3]];
test('#4.1 prep: a press anywhere on a tile selects the unit on it — board, high ground, bench, temp; an empty tile nothing', async () => {
const { page, problems } = await open('phase=PREP&variant=temp');
const pieces = await page.evaluate(() => window.__t.pieces());
const on = (r, c) => pieces.find((p) => p.row === r && p.col === c)?.uid ?? null;
assert.ok(on(9, 3) && on(9, 4) && on(9, 5) && on(10, 4) && on(11, 4), 'premise: neighbours in a row (9, 3–5) and a column (9–11, 4; rows 10–12 high ground)');
assert.ok(pieces.filter((p) => p.row === 7).length >= 5 && pieces.filter((p) => p.row === 8).length >= 2, 'premise: bench and temp pieces');
await listenClicks(page);
for (const p of pieces) {
for (const [u, v] of SPOTS) {
const pt = await tileSpot(page, p.row, p.col, u, v);
assert.equal(await pickAt(page, pt), p.uid, `${p.kind} ${p.uid} on (${p.row},${p.col}) at (${u}, ${v})`);
}
// the whole press path (canvas pointer events → the drag controller → pieceClick) at the centre and a corner
for (const [u, v] of [[0, 0], [0.6, 0.6]]) {
const got = await clickAt(page, await tileSpot(page, p.row, p.col, u, v));
assert.deepEqual(got.slice(-1), [p.uid], `click on (${p.row},${p.col}) at (${u}, ${v})`);
}
}
// a press on a unit's head, drawn over the tile behind it: the unit on that tile — or nothing when it is empty
const head = (uid) => page.evaluate((u) => window.__t.at('p:' + u, 1.0), uid);
assert.equal(await pickAt(page, await head(on(9, 4))), on(10, 4), 'the head of (9,4) is drawn over (10,4)');
assert.equal(on(10, 3), null, 'premise: (10,3) is a forbidden block');
assert.equal(await pickAt(page, await head(on(9, 3))), null, 'the head of (9,3) over the empty (10,3)');
assert.deepEqual(await clickAt(page, await head(on(9, 3))), [], 'no pieceClick');
// the nearest strip of (9,3) is hidden by the forbidden block (8,3) in front of it: a press there is on that block
const hidden = await tileSpot(page, 9, 3, 0, -0.9);
assert.deepEqual(await page.evaluate((x, y) => { const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), g = R.debug.pick.groundTile(x - cv.left, y - cv.top); return g && [g.row, g.col]; }, hidden.x, hidden.y), [8, 3]);
assert.equal(await pickAt(page, hidden), null);
await page.screenshot({ path: path.join(OUT, 'models-pick-prep.png') });
await page.close();
assert.deepEqual(problems, []);
});
test('#4.1 battle: allies by the tile under the pointer, enemies by their ground position or drawn body', async () => {
const { page, problems } = await open('phase=PREP');
await setPhase(page, 'COMBAT');
await sleep(6000); // several enemies spawned (one every 0.9 s)
// freeze the mock battle (one last snapshot): the enemies stand still for the presses
await page.evaluate(async () => {
const { net } = await import('/js/net.js');
const S = globalThis.__MOCK__.S();
const last = await new Promise((resolve) => { const off = net.on('b.snap', (m) => { off(); resolve(m); }); });
clearInterval(S.battle.timer);
net._emit('b.snap', { ...last, gt: last.gt + 0.2 });
});
await sleep(800);
const units = await page.evaluate(() => {
const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), out = [];
for (const [id, v] of R.debug.views) {
if (!v.info || !v.alive || v.culled || v.info.kind === 'device') continue;
const g = R.debug.cam.project(v.x, v.y, v.z);
out.push({ id, side: v.info.side, x: v.x, y: v.y, row: Math.round(v.y), col: Math.round(v.x), fly: !!v.flying, feet: { x: cv.left + g.x, y: cv.top + g.y },
body: { x: cv.left + v.screen.x, top: cv.top + v.screen.top, feet: cv.top + v.screen.y, s: v.screen.s } });
}
return out;
});
const allies = units.filter((u) => u.side === 'ally'), foes = units.filter((u) => u.side === 'enemy' && !u.fly);
assert.ok(allies.length >= 7 && foes.length >= 3, `premise: allies ${allies.length}, walking enemies ${foes.length}`);
await listenClicks(page);
const near = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
/** Tiles between a client point and an enemy's drawn body (the line from its feet up to its head). */
const bodyDist = (b, pt) => Math.hypot(pt.x - b.x, pt.y - Math.max(b.top, Math.min(b.feet, pt.y))) / b.s;
// the rule (render/pick.js), from the spot's own ground coordinates and client point: the ally on the tile, unless
// an enemy's ground position or drawn body is nearer (within 0.6 tile)
const expected = (a, g, pt) => {
let best = a, bd = near(a, g);
for (const f of foes) { const d = Math.min(near(f, g), bodyDist(f.body, pt)); if (d <= 0.6 && d < bd - 1e-6) { best = f; bd = d; } }
return best.id;
};
let byEnemy = 0;
for (const a of allies) {
for (const [u, v] of SPOTS) {
const pt = await tileSpot(page, a.row, a.col, u, v);
const g = await page.evaluate((x, y) => { const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(); return R.debug.pick.groundTile(x - cv.left, y - cv.top); }, pt.x, pt.y);
const want = expected(a, g, pt);
if (want !== a.id) byEnemy++;
assert.equal(await pickAt(page, pt, true), want, `ally ${a.id} on (${a.row},${a.col}) at (${u}, ${v})`);
}
if (expected(a, { x: a.col, y: a.row }, await tileSpot(page, a.row, a.col)) === a.id) assert.deepEqual((await clickAt(page, await tileSpot(page, a.row, a.col))).slice(-1), [a.id], `click on ally ${a.id}`);
}
assert.ok(byEnemy < allies.length * SPOTS.length / 3, `most spots of the allies' tiles are the allies' (${byEnemy} went to an enemy standing nearer)`);
for (const f of foes) {
// at its feet, and on its drawn torso / head (a tall enemy's body is drawn over the ground a tile behind it)
for (const [k, pt] of [['feet', f.feet], ['torso', { x: f.body.x, y: (f.body.top + f.body.feet) / 2 }], ['head', { x: f.body.x, y: f.body.top + 0.1 * f.body.s }]]) {
const got = await pickAt(page, pt, true);
const other = units.find((u) => u.id === got);
assert.ok(got === f.id || (other && other.side === 'enemy' && near(other, f) < 0.3), `enemy ${f.id} at its ${k} (got ${got})`);
}
}
const f = foes.find((x) => !foes.some((y) => y !== x && near(x, y) < 0.3)) || foes[0];
const got = await clickAt(page, f.feet);
assert.ok(got.length === 1 && units.find((u) => u.id === got[0])?.side === 'enemy', `a click at an enemy's feet selects an enemy (${got})`);
await page.screenshot({ path: path.join(OUT, 'models-pick-battle.png') });
await page.close();
assert.deepEqual(problems, []);
});
/** The dragged view against the pointer (client px): its drawn feet, px per tile, and the Spine model's drawn bounds;
* its world point (wx = col, wy = row, wz) and the height of the board tile under it (hz). */
const ghostAt = (page, uid) => page.evaluate((u) => {
const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), v = R.debug.views.get('p:' + u);
const b = v.actor && v.spineReady && !v.imp ? v.actor.spine.getBounds() : null;
return { lift: v.lift, x: cv.left + v.screen.x, y: cv.top + v.screen.y, s: v.screen.s, model: b && { x0: cv.left + b.x, y0: cv.top + b.y, x1: cv.left + b.x + b.width, y1: cv.top + b.y + b.height },
wx: v.x, wy: v.y, wz: v.z, hz: R.debug.tiles.heightAt(Math.round(v.y), Math.round(v.x)) };
}, uid);
/** Over a legal target the model stands on the pointer's tile (the official deploy drag): its world point is that
* tile — the tile's top on the board, the bench pad on the bench — and it is lifted. */
const assertStands = (g, [row, col], label) => {
assert.ok(g.lift > 0, `${label}: lifted`);
assert.ok(Math.abs(g.wx - col) < 1e-6 && Math.abs(g.wy - row) < 1e-6, `${label}: stands on (${row},${col}) (at ${g.wx.toFixed(3)},${g.wy.toFixed(3)})`);
if (row !== 7) assert.ok(Math.abs(g.wz - g.hz) < 1e-6, `${label}: on the tile's top (z ${g.wz} vs ${g.hz})`);
else assert.ok(g.wz > 0, `${label}: on the bench pad (z ${g.wz})`);
};
/** Elsewhere the model is under the pointer: its feet DRAG_HOLD_TILES (0.45) below it, the pointer inside its drawn body. */
const assertHeld = (g, ptr, label) => {
assert.ok(g.lift > 0, `${label}: lifted`);
assert.ok(Math.abs(g.x - ptr.x) < 1.5 && Math.abs(g.y - ptr.y - 0.45 * g.s) < 1.5, `${label}: feet 0.45 tile below the pointer (feet ${g.x.toFixed(1)},${g.y.toFixed(1)}, pointer ${ptr.x.toFixed(1)},${ptr.y.toFixed(1)}, s ${g.s.toFixed(1)})`);
if (g.model) {
const m = g.model, k = (ptr.y - m.y0) / (m.y1 - m.y0);
assert.ok(ptr.x > m.x0 && ptr.x < m.x1 && k > 0.2 && k < 0.8, `${label}: the pointer on the drawn model (${JSON.stringify(m)}, k ${k.toFixed(2)})`);
}
};
test('#4.1 drag (mouse): the unit stands on the pointer\'s tile over a legal target, held under the pointer elsewhere; target, tileHover and the direction wheel are the pointer\'s tile', async () => {
const { page, problems } = await open('phase=PREP');
const hand = await page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; const i = p.hand.findIndex((x) => x && x.kind === 'chess'); return { uid: p.hand[i].uid, idx: i }; });
await page.evaluate(() => { window.__hover = []; globalThis.__SP_VIEW__.raw.on('tileHover', (t) => window.__hover.push(t && [t.row, t.col])); });
const from = await tileSpot(page, 7, hand.idx);
const to = await tileSpot(page, 9, 4, 0.3, 0.4); // an occupied melee tile (a swap), off its centre
await page.mouse.move(from.x, from.y); await page.mouse.down();
for (let i = 1; i <= 12; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); }
await sleep(150);
const first = await ghostAt(page, hand.uid);
assertStands(first, [9, 4], 'mouse');
assert.deepEqual(await page.evaluate(() => window.__hover.at(-1)), [9, 4], 'tileHover = the tile under the pointer');
await page.screenshot({ path: path.join(OUT, 'models-drag-ghost.png') });
// the pointer moving about inside the tile leaves the model where it is (the official recording: the finger moves,
// the model stays on its tile)
for (const [u, v] of [[-0.6, -0.3], [0.6, -0.3], [-0.5, 0.7], [0, 0]]) { // (the nearest strip is hidden by row 8: SPOTS)
const pt = await tileSpot(page, 9, 4, u, v);
await page.mouse.move(pt.x, pt.y);
await sleep(40);
const g = await ghostAt(page, hand.uid);
assertStands(g, [9, 4], `mouse at (${u}, ${v}) of the tile`);
assert.ok(Math.abs(g.x - first.x) < 0.5 && Math.abs(g.y - first.y) < 0.5, `mouse at (${u}, ${v}): the model did not move on screen`);
}
// the whole way: on a legal tile the model stands on the pointer's tile, elsewhere it is under the pointer (sampled
// at every step of a second drag leg; the legal tiles are the view's own 'legal' highlight)
const legal = new Set(await page.evaluate(() => globalThis.__SP_VIEW__.raw.debug.tiles.highlights.get('legal').tiles.map(([r, c]) => `${r},${c}`)));
const leg = await tileSpot(page, 10, 5);
const seen = { stands: 0, held: 0 };
for (let i = 1; i <= 6; i++) {
const pt = { x: to.x + ((leg.x - to.x) * i) / 6, y: to.y + ((leg.y - to.y) * i) / 6 };
await page.mouse.move(pt.x, pt.y);
await sleep(40);
const tile = await page.evaluate(() => window.__hover.at(-1));
if (legal.has(`${tile}`)) { seen.stands++; assertStands(await ghostAt(page, hand.uid), tile, `mouse step ${i}`); }
else { seen.held++; assertHeld(await ghostAt(page, hand.uid), pt, `mouse step ${i}`); }
}
assert.ok(seen.stands > 0, `the leg crosses legal tiles (${JSON.stringify(seen)})`);
// over the shop bar (DOM over the canvas: no target) the model is held under the pointer, so the player still sees
// what they carry
const bar = await page.evaluate(() => { const el = [...document.querySelectorAll('.scard')][2], r = el && el.getBoundingClientRect(); return r && { x: r.left + r.width / 2, y: r.top + r.height * 0.3 }; });
assert.ok(bar, 'premise: a shop card');
assert.ok(await page.evaluate((x, y) => !!document.elementFromPoint(x, y)?.closest('.shopbar'), bar.x, bar.y), 'premise: the point is on the shop bar');
await page.mouse.move(bar.x, bar.y, { steps: 6 });
await sleep(60);
assert.equal(await page.evaluate(() => window.__hover.at(-1)), null, 'no tile under the shop bar');
assertHeld(await ghostAt(page, hand.uid), bar, 'mouse over the shop bar');
await page.mouse.move(to.x, to.y, { steps: 6 });
await sleep(60);
assertStands(await ghostAt(page, hand.uid), [9, 4], 'mouse back on (9,4)');
await page.mouse.move(to.x, to.y);
await sleep(60);
await page.mouse.up();
await page.waitForSelector('.fwheel__dia', { timeout: 3000 });
const w = await page.evaluate(() => { const r = document.querySelector('.fwheel__dia').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
const c = await tileSpot(page, 9, 4);
assert.ok(Math.hypot(w.x - c.x, w.y - c.y) < 2, `the wheel opens on the pointer's tile (${w.x},${w.y} vs ${c.x},${c.y})`);
await page.keyboard.press('Escape');
await page.close();
assert.deepEqual(problems, []);
});
test('#4.1 equipment dropped on a tile equips the unit on it (the one behind, a bench operator); an empty tile takes nothing', async () => {
const { page, problems } = await open('phase=PREP');
const S = () => page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; return { board: p.board.map((x) => ({ uid: x.uid, row: x.row, col: x.col, items: x.items.map((i) => i.uid) })), hand: p.hand.map((x) => x && { uid: x.uid, kind: x.kind, items: (x.items || []).map((i) => i.uid) }) }; });
// three items in the hand (slots 0, 1, 4 are free / an item in the mock)
await page.evaluate(() => globalThis.__MOCK__.mutate((M) => {
const src = M.priv.hand.find((x) => x && x.kind === 'item');
for (const i of [0, 1]) if (!M.priv.hand[i]) M.priv.hand[i] = { ...src, uid: 900 + i };
}));
await sleep(800);
let st = await S();
const items = st.hand.map((x, i) => x && x.kind === 'item' && { uid: x.uid, idx: i }).filter(Boolean);
const target = st.board.find((b) => b.row === 10 && b.col === 4), front = st.board.find((b) => b.row === 9 && b.col === 4);
const benchIdx = st.hand.findIndex((x) => x && x.kind === 'chess');
assert.ok(items.length >= 3 && target && front && benchIdx >= 0, 'premise');
const dragTo = async (it, to) => {
const from = await tileSpot(page, 7, it.idx);
await page.mouse.move(from.x, from.y); await page.mouse.down();
for (let i = 1; i <= 10; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 10, from.y + ((to.y - from.y) * i) / 10); await sleep(16); }
await sleep(120);
await page.mouse.up();
await sleep(700);
};
// (10,4) behind (9,4): the tile of the one behind — near its front edge, where the front one's head is drawn
await dragTo(items[0], await tileSpot(page, 10, 4, 0, -0.6));
st = await S();
assert.ok(st.board.find((b) => b.uid === target.uid).items.includes(items[0].uid), 'equipped on the unit behind');
assert.ok(!st.board.find((b) => b.uid === front.uid).items.includes(items[0].uid), 'not on the one in front');
await dragTo(items[1], await tileSpot(page, 7, benchIdx, 0.4, 0.4));
st = await S();
assert.ok(st.hand[benchIdx].items.includes(items[1].uid), 'equipped on the bench operator');
// the empty high-ground tile behind (12,4)? use a free deploy tile: the item stays in the hand
const free = await page.evaluate(() => { const S = globalThis.__MOCK__.S(), used = new Set(S.priv.board.map((b) => `${b.row},${b.col}`)); return S.stage.deployTiles.normal.melee.find(([r, c]) => !used.has(`${r},${c}`)); });
await dragTo(items[2], await tileSpot(page, free[0], free[1]));
st = await S();
assert.ok(st.hand.some((x) => x && x.uid === items[2].uid), 'an empty tile takes nothing: the item is back in the hand');
await page.close();
assert.deepEqual(problems, []);
});
// a phone (touch, landscape 844×390): the finger drags the model like the mouse — standing on the finger's tile over a
// legal target, under the finger elsewhere (no lift above the finger); on the bench (the lowest canvas row, the shop
// bar right under it) a release on the slot lands there, one on the shop bar goes back
test('#4.1 touch (phone): stands on the finger\'s tile (under the finger over the shop bar), dropped on the tile under it; a release over the shop bar goes back', async () => {
const page = await browser.newPage();
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
await page.setViewport({ width: 844, height: 390, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true });
await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=engine&phase=PREP`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__?.raw?.debug, { timeout: 30000 });
await page.evaluate(installHelpers);
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.hand[5] = null; }));
await sleep(2000);
const touchDrag = async (from, to, { hold = null } = {}) => {
await page.touchscreen.touchStart(from.x, from.y);
for (let i = 1; i <= 12; i++) { await page.touchscreen.touchMove(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); }
await sleep(100);
if (hold) await hold();
await page.touchscreen.touchEnd();
await sleep(800);
};
// a tap on a unit's tile selects it — and only that: the tap's compatibility click used to land on the underframe
// that had just opened over the tile (clamped under the top bar for rows 11–12) and pressed 撤退 (the unit left)
for (const row of [9, 10, 11, 12]) {
const u = await page.evaluate((r) => { const b = globalThis.__MOCK__.S().priv.board.find((p) => p.row === r && p.col === 4); return b && { uid: b.uid, n: globalThis.__MOCK__.S().requests.length }; }, row);
assert.ok(u, `premise: a unit on (${row},4)`);
const t = await tileSpot(page, row, 4);
await page.touchscreen.tap(t.x, t.y);
await sleep(600);
const s = await page.evaluate((u) => ({ onBoard: globalThis.__MOCK__.S().priv.board.some((p) => p.uid === u.uid), uframe: !!document.querySelector(`.uframe[data-uid="${u.uid}"]`), sent: globalThis.__MOCK__.S().requests.slice(u.n).map((r) => r[0]).filter((q) => q !== 'g.unitStats') }), u);
assert.deepEqual(s, { onBoard: true, uframe: true, sent: [] }, `a tap on (${row},4) selects its unit, nothing else`);
await page.keyboard.press('Escape');
await sleep(200);
}
// a bench operator onto the board: standing on the finger's tile, the wheel on that tile
const hand = await page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; const i = p.hand.findIndex((x) => x && x.kind === 'chess'); return { uid: p.hand[i].uid, idx: i }; });
const to = await tileSpot(page, 9, 4, -0.3, 0.3);
await touchDrag(await tileSpot(page, 7, hand.idx), to, { hold: async () => assertStands(await ghostAt(page, hand.uid), [9, 4], 'touch') });
await page.waitForSelector('.fwheel__dia', { timeout: 3000 });
const w = await page.evaluate(() => { const r = document.querySelector('.fwheel__dia').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
const c = await tileSpot(page, 9, 4);
assert.ok(Math.hypot(w.x - c.x, w.y - c.y) < 2, 'the wheel opens on the finger\'s tile');
await page.evaluate(() => document.querySelector('.fwheel__cancel')?.click());
await sleep(500);
// a board unit back onto the free bench slot 5: the finger on the slot → lands; on the shop bar below it → back
const unit = await page.evaluate(() => { const u = globalThis.__MOCK__.S().priv.board[0]; return { uid: u.uid, row: u.row, col: u.col }; });
const slot = await tileSpot(page, 7, 5);
// the first point below the slot's centre that the shop bar covers (it starts right below the bench — the prep
// camera keeps the bench clear of it, user playtest #5 item 9; before, it overlapped the lower half of slots 4–9)
const bar = await page.evaluate((p) => {
const cv = globalThis.__SP_VIEW__.raw.debug.app.view;
for (let dy = 2; dy < 80; dy += 2) { const el = document.elementFromPoint(p.x, p.y + dy); if (el && el !== cv && el.closest('.shopbar')) return { x: p.x, y: p.y + dy + 3 }; }
return null;
}, slot);
assert.ok(bar, 'premise: the shop bar covers the canvas right below the bench slot');
await touchDrag(await tileSpot(page, unit.row, unit.col), bar, { hold: async () => assertHeld(await ghostAt(page, unit.uid), bar, 'touch over the shop bar') });
assert.ok(await page.evaluate((u) => globalThis.__MOCK__.S().priv.board.some((b) => b.uid === u), unit.uid), 'released over the shop bar: back on the board');
await touchDrag(await tileSpot(page, unit.row, unit.col), slot, { hold: async () => assertStands(await ghostAt(page, unit.uid), [7, 5], 'touch on the free bench slot') });
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.hand[5]?.uid ?? null), unit.uid, 'released on the bench slot: it is there');
await page.screenshot({ path: path.join(OUT, 'models-touch-phone.png') });
await page.close();
assert.deepEqual(problems, []);
});
});