// 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, []); }); });