Files
ark-stronghold-protocol/test/render/models.browser.test.js
T
sganggs 0aba602ecf Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners
- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight
  (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s
- #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks,
  hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped
  at one per frame
- #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates'
  pending LP in the team panel
- #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue);
  temp-row warning and ready-button reason
- #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx
- #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught)
- #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost
- #8 特质 right under the detail card header, with its official type icon
- #9 no spinning busy cursors / spinners next to texts; game data warmed before the match
- docs, recordings and tests updated (2414 unit tests, all browser suites green)
2026-09-29 23:42:55 +08:00

362 lines
25 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/models.browser.test.js — user playtest #3 items 1 and 7 in headless Chrome, through the in-match mock
// (public/dev/game-mock.html: the real game screen, a mock server behind net.request):
// 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).
// 7 picking: with an operator directly behind another (same column), presses on its head / torso / legs, on its
// feet where they show and on its tile beside the front one's head select IT; the front one's face selects the
// front one — in prep and in battle; equipment dropped on the body of the one behind equips it; a dragged unit's
// ghost stands on the pointer and the drop (direction wheel) is that tile. Every press on a drawn unit pixel (the
// whole prep board and a battle) picks the front-most unit drawn there (render/pick.js pixel probe).
//
// 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';
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)';
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 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', '--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.5), p.uid);
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, []);
});
test('7: picking an operator standing directly behind another — prep and battle; equipment on the one behind', async () => {
const { page, problems } = await open('phase=PREP');
// col 4: row 9 (ground) · rows 10–12 (high ground) — a ranged operator behind a melee one, and two on the same height
const [a9, b10, c11] = [await uidAt(page, 9, 4), await uidAt(page, 10, 4), await uidAt(page, 11, 4)];
assert.ok(a9 && b10 && c11, 'mock premise: three operators in one column');
const clicks = async (keyOf, idOf, cases) => {
await page.evaluate(() => { window.__picked = []; window.__offPick?.(); window.__offPick = globalThis.__SP_VIEW__.raw.on('pieceClick', (e) => window.__picked.push(e.uid)); });
for (const [label, key, up, dx, want] of cases) {
const pt = await page.evaluate((k, u, d) => window.__t.at(k, u, d), keyOf(key), up, dx);
await page.mouse.click(pt.x, pt.y);
await sleep(120);
const got = await page.evaluate(() => window.__picked.splice(0));
assert.deepEqual(got.slice(-1), [idOf(want)], `${label}`);
await page.keyboard.press('Escape');
await sleep(60);
}
};
const cases = [
// [label, whose feet, tiles up, tiles aside, expected]
['raised B: face', b10, 0.86, 0, b10], ['raised B: torso', b10, 0.45, 0, b10], ['raised B: legs', b10, 0.22, 0, b10],
// B's feet where they show (straight under B's centre, A's hair crown is drawn over them: that pixel is A's)
['raised B: feet', b10, 0.05, -0.12, b10],
['raised B: its tile beside A\'s head', b10, 0, 0.42, b10],
['A (in front): face', a9, 0.86, 0, a9], ['A: torso', a9, 0.45, 0, a9], ['A: feet', a9, 0.05, 0, a9],
// (B's head and staff are drawn over the middle of C's lower torso and legs: those pixels are B's)
['same height C behind B: face', c11, 0.86, 0, c11], ['C: torso', c11, 0.62, 0, c11], ['C: legs beside B\'s head', c11, 0.4, -0.2, c11],
['C: its tile beside B\'s head', c11, 0, -0.42, c11], ['B in front of C: face', b10, 0.86, 0, b10],
];
await clicks((u) => 'p:' + u, (u) => u, cases);
// equipment: an item dropped on the torso of the one behind (C) equips C; one on A's face equips A
const items = await page.evaluate(() => globalThis.__MOCK__.S().priv.hand.filter((x) => x && x.kind === 'item').map((x) => x.uid));
for (const [target, up] of [[c11, 0.62], [a9, 0.86]]) {
const it = items.shift();
const from = await page.evaluate((u) => { const r = globalThis.__SP_VIEW__.pieceScreenRect(u); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, it);
const to = await page.evaluate((u, k) => window.__t.at('p:' + u, k), target, up);
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);
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.debug.dragHit?.over ?? null), target, 'the plate is over the target (pieceDragOver)');
await page.mouse.up();
await sleep(600);
const eq = await page.evaluate((u) => globalThis.__MOCK__.S().priv.board.find((p) => p.uid === u).items.map((x) => x.uid), target);
assert.ok(eq.includes(it), `item ${it} equipped on ${target} (got ${eq})`);
}
await page.screenshot({ path: path.join(OUT, 'models-pick-prep.png') });
// battle: the same presses on the battle views (ids → piece uids)
await setPhase(page, 'COMBAT');
await sleep(2500);
// …at a moment of the battle when the units stand at rest: a press takes whatever is DRAWN under it (pixel probe),
// and the mock battle keeps every operator attacking (lunges, attack clips) and in random skill poses, which move
// the drawn face / torso / legs off the aimed points (then the unit in front or behind rightly takes the press —
// the pixel-truth test below covers moving poses). So the mock battle is stopped with one last snapshot that clears
// the skill flags, and the presses wait until every ally is back in its idle pose.
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, units: last.units.map((u) => { const c = u.slice(); c[7] = 0; return c; }) });
});
await page.waitForFunction(() => [...globalThis.__SP_VIEW__.raw.debug.views.values()]
.filter((v) => v.info?.side === 'ally' && v.alive && v.actor)
.every((v) => v.actor.mode === 'base' && !v.actor.skillOn && !(v.lunge > 0)), { timeout: 15000, polling: 100 });
await sleep(300);
const ids = await page.evaluate(() => { const out = {}; for (const [id, v] of globalThis.__SP_VIEW__.raw.debug.views) if (v.info?.uid != null) out[v.info.uid] = id; return out; });
await clicks((u) => ids[u], (u) => u, cases.filter(([label]) => !label.includes('tile')));
await page.screenshot({ path: path.join(OUT, 'models-pick-battle.png') });
await page.close();
assert.deepEqual(problems, []);
});
// review of item 7: the pick against what is really drawn. Every unit is rendered alone (its pixels = where its model
// is opaque); the truth at a pixel is the front-most unit drawn there. Sampled over the whole prep board and a
// battle: the shape rule alone got 7 % (battle 18 %) of these presses wrong, mostly giving the front unit's hair /
// arms to the unit behind; the pixel probe settles every overlap.
test('7: picks agree with the rendered pixels (prep and battle)', async () => {
const { page, problems } = await open('phase=PREP');
const measure = (phase) => page.evaluate((ph) => {
const R = globalThis.__SP_VIEW__.raw, app = R.debug.app, rd = app.renderer, gl = rd.gl, res = rd.resolution;
const keyOf = new Map([...R.debug.views].map(([k, v]) => [v, k]));
const views = [...R.debug.views].filter(([k, v]) => v.pickShape && v.info && v.info.kind !== 'item' && v.info.kind !== 'device'
&& v.alive && v.spineReady && !v.culled && (ph === 'PREP' ? k.startsWith('p:') : true));
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const [, v] of views) { const s = v.screen.s; x0 = Math.min(x0, v.screen.x - s); x1 = Math.max(x1, v.screen.x + s); y0 = Math.min(y0, v.screen.y - 2 * s); y1 = Math.max(y1, v.screen.y + 0.3 * s); }
x0 = Math.max(0, Math.floor(x0)); y0 = Math.max(0, Math.floor(y0));
x1 = Math.min(rd.view.width / res - 1, Math.ceil(x1)); y1 = Math.min(rd.view.height / res - 1, Math.ceil(y1));
const w = Math.floor((x1 - x0) * res), h = Math.floor((y1 - y0) * res), H = rd.view.height;
const all = [...R.debug.views.values()];
const saved = all.map((v) => [v, v.root?.visible, v.hud?.visible, v.shadow?.visible]);
for (const v of all) { if (v.hud) v.hud.visible = false; if (v.shadow) v.shadow.visible = false; if (v.root) v.root.visible = false; }
const read = () => { R.debug.ctx.impostors?.flush?.(); rd.render(app.stage); const px = new Uint8Array(w * h * 4); gl.readPixels(Math.floor(x0 * res), H - Math.floor(y0 * res) - h, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); return px; };
const bg = read();
const masks = [];
for (const [k, v] of views) {
v.root.visible = true; const a = read(); v.root.visible = false;
const m = new Uint8Array(w * h);
for (let i = 0; i < w * h; i++) { const j = i * 4; if (a[j + 3] > 40 && Math.abs(a[j] - bg[j]) + Math.abs(a[j + 1] - bg[j + 1]) + Math.abs(a[j + 2] - bg[j + 2]) + Math.abs(a[j + 3] - bg[j + 3]) > 40) m[i] = 1; }
masks.push({ k, m, z: v.root.zIndex });
}
for (const [v, a, b, c] of saved) { if (v.root) v.root.visible = a; if (v.hud) v.hud.visible = b; if (v.shadow) v.shadow.visible = c; }
const pick = (x, y) => { if (ph === 'PREP') { const p = R.debug.pick.pieceAt(x, y); return p ? 'p:' + p.uid : null; } const v = R.debug.pick.battleUnitAt(x, y); return v ? keyOf.get(v) : null; };
let n = 0, ok = 0, wrong = 0;
for (let py = 0; py < h; py += 4) for (let px = 0; px < w; px += 4) {
const i = (h - 1 - py) * w + px;
let truth = null, tz = -Infinity;
for (const mk of masks) if (mk.m[i] && mk.z > tz) { tz = mk.z; truth = mk.k; }
if (!truth) continue;
const got = pick(x0 + px / res, y0 + py / res);
n++;
if (got === truth) ok++; else if (got) wrong++;
}
return { units: views.length, n, ok: ok / n, wrong: wrong / n };
}, phase);
const prep = await measure('PREP');
assert.ok(prep.units >= 10 && prep.n > 3000, `premise: a full prep board (${JSON.stringify(prep)})`);
assert.ok(prep.ok > 0.95 && prep.wrong < 0.015, `prep: presses on a drawn unit pick it (${JSON.stringify(prep)})`);
await setPhase(page, 'COMBAT');
await sleep(3000);
const battle = await measure('COMBAT');
assert.ok(battle.units >= 10 && battle.n > 3000, `premise: a battle (${JSON.stringify(battle)})`);
assert.ok(battle.ok > 0.93 && battle.wrong < 0.03, `battle: presses on a drawn unit pick it (${JSON.stringify(battle)})`);
await page.close();
assert.deepEqual(problems, []);
});
test('7: a dragged unit stands on the pointer; the drop and its direction wheel are that tile', async () => {
const { page, problems } = await open('phase=PREP');
const hand = await page.evaluate(() => globalThis.__MOCK__.S().priv.hand.find((x) => x && x.kind === 'chess').uid);
const from = await page.evaluate((u) => window.__t.at('p:' + u, 0.5), hand);
const to = await page.evaluate(() => globalThis.__SP_VIEW__.raw.tileScreen(9, 4)); // an occupied melee tile: a swap
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(150);
const ghost = await page.evaluate((u) => { const v = globalThis.__SP_VIEW__.raw.debug.views.get('p:' + u); return { x: v.x, y: v.y, lift: v.lift }; }, hand);
assert.ok(Math.abs(ghost.x - 4) < 0.06 && Math.abs(ghost.y - 9) < 0.06 && ghost.lift > 0, `the ghost stands on the pointer's tile (${ghost.x}, ${ghost.y})`);
await page.screenshot({ path: path.join(OUT, 'models-drag-ghost.png') });
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 }; });
assert.ok(Math.hypot(w.x - to.x, w.y - to.y) < 2, 'the wheel opens on that tile');
await page.keyboard.press('Escape');
await page.close();
assert.deepEqual(problems, []);
});
// review of item 7: a touch drag holds its ghost (and drop point) 0.6 tile above the finger. On a phone the bench is
// the lowest canvas row with the shop bar right under it, so putting a unit back on the bench has the finger on the
// shop bar: the drop is judged where the ghost is, not under the finger (it used to send the piece back).
test('7: touch — a board unit dragged back onto a bench slot above the shop bar lands there (phone)', 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(() => globalThis.__MOCK__.mutate((S) => { S.priv.hand[5] = null; }));
await sleep(2000);
const g = await page.evaluate(() => {
const V = globalThis.__SP_VIEW__.raw, cv = V.debug.app.view.getBoundingClientRect();
const u = globalThis.__MOCK__.S().priv.board[0], v = V.debug.views.get('p:' + u.uid), t = V.tileScreen(7, 5);
const finger = { x: t.x, y: t.y + 0.6 * t.s };
const el = document.elementFromPoint(finger.x, finger.y);
return { uid: u.uid, from: { x: cv.left + v.screen.x, y: cv.top + v.screen.y - 0.5 * v.screen.s }, finger, onDom: !!el && el !== V.debug.app.view };
});
assert.ok(g.onDom, 'premise: the finger is on the DOM (shop bar) below the bench slot');
await page.touchscreen.touchStart(g.from.x, g.from.y);
for (let i = 1; i <= 12; i++) { await page.touchscreen.touchMove(g.from.x + ((g.finger.x - g.from.x) * i) / 12, g.from.y + ((g.finger.y - g.from.y) * i) / 12); await sleep(16); }
await sleep(100);
await page.touchscreen.touchEnd();
await sleep(800);
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.hand[5]?.uid ?? null), g.uid, 'the unit is on bench slot 5');
await page.close();
assert.deepEqual(problems, []);
});
});