Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash

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

No files matched your search

+257 -149
View File
@@ -1,16 +1,19 @@
// 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).
// 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 is held under the pointer — 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.
@@ -169,7 +172,7 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
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 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);
@@ -184,177 +187,282 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
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);
/** 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})`);
}
};
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})`);
}
// 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') });
// battle: the same presses on the battle views (ids → piece uids)
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(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 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, units: last.units.map((u) => { const c = u.slice(); c[7] = 0; return c; }) });
net._emit('b.snap', { ...last, gt: last.gt + 0.2 });
});
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 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, []);
});
// 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, []);
});
/** The dragged view against the pointer (client px): its drawn feet, px per tile, and the Spine model's drawn bounds. */
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 } };
}, uid);
/** 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('7: a dragged unit stands on the pointer; the drop and its direction wheel are that tile', async () => {
test('#4.1 drag (mouse): the unit is held under the pointer; target, tileHover and the direction wheel are the pointer\'s 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
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 <= 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); }
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 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})`);
assertHeld(await ghostAt(page, hand.uid), to, '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 whole way: the model stays under the pointer (sampled at every step of a second drag leg)
const leg = await tileSpot(page, 10, 5);
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);
assertHeld(await ghostAt(page, hand.uid), pt, `mouse step ${i}`);
}
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 }; });
assert.ok(Math.hypot(w.x - to.x, w.y - to.y) < 2, 'the wheel opens on that tile');
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, []);
});
// 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 () => {
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 holds the model like the mouse (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): held under the finger, 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 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');
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: held under the finger, the wheel on the finger's 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 () => assertHeld(await ghostAt(page, hand.uid), to, '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 overlaps the lower half of bench 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);
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);
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, []);
});