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

+130 -177
View File
@@ -1,206 +1,159 @@
// test/render/pick.test.js — render/pick.js, the one picking rule of every "which unit is under the pointer" path
// (user playtest #3 item 7: "clicking an operator often selects the one a row below it; equipment too"), checked on the
// geometry of the official cameras (render/projection.js presetCamera: prep / battle at 1920×1080 and 1280×720): a unit
// B standing directly behind a unit A (same column, adjacent rows; B on the same height or on a raised tile).
// test/render/pick.test.js — render/pick.js, the one picking rule of every "which unit is under the pointer" path (user
// playtest #4 item 1: "地上都画好了一个一个方格,点击对应方格就选中那个方格的人物就行" — a press on a tile selects the unit on
// that tile). Checked on the geometry of the official cameras (render/projection.js presetCamera + pickTile: prep and
// battle at 1920×1080 and 1280×720, raised tops first): units in adjacent rows and columns, on raised high ground, on
// the bench (row 7) and the temporary bench (row 8); and the battle rule for walking / flying enemies (ground position,
// drawn body).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { presetCamera } from '../../public/js/render/projection.js';
import { pickBody, hitBody, coreDistance, bodyBounds, bodyParts, BODY_H, probeCandidates, PROBE_MARGIN } from '../../public/js/render/pick.js';
import { presetCamera, pickTile } from '../../public/js/render/projection.js';
import { pickOnTile, pickBattle, ENEMY_REACH } from '../../public/js/render/pick.js';
import { unitDepthKey } from '../../public/js/render/units.js';
const COL = 4;
/** A / B bodies on a camera: A at row 9 (height zA), B at row 10 (height zB), model heights hA / hB. */
function pair(kind, W, H, { zA = 0, zB = 0, hA = BODY_H, hB = BODY_H } = {}) {
const cam = presetCamera(kind, { width: W, height: H });
const body = (key, row, z, h) => { const p = cam.project(COL, row, z); return { key, x: p.x, y: p.y, s: p.s, h, flip: 1, depth: unitDepthKey(cam, COL, row), tile: { row, col: COL } }; };
const A = body('A', 9, zA, hA), B = body('B', 10, zB, hB);
const heights = new Map([[`9,${COL}`, zA], [`10,${COL}`, zB]]);
// the tile under a screen point, raised tops first (render/projection.js pickTile semantics)
const ground = (x, y) => {
for (const hz of [...new Set([zA, zB, 0])].sort((a, b) => b - a)) {
const g = cam.unproject(x, y, hz);
if (!g) continue;
const t = { row: Math.round(g.y), col: Math.round(g.x) };
if (hz === 0 || Math.abs((heights.get(`${t.row},${t.col}`) || 0) - hz) < 1e-6) return t;
}
return null;
};
/** Who a press `up` tiles above the feet of `u` (and `dx` tiles to the side) picks. */
const at = (u, up, dx = 0, bodies = [A, B]) => { const x = u.x + dx * u.s, y = u.y - up * u.s; return pickBody(bodies, x, y, ground(x, y))?.key ?? null; };
return { cam, A, B, at, rowGap: (A.y - B.y) / A.s };
}
import { TILE_H } from '../../public/js/render/style.js';
import { GEO } from '../../shared/constants.js';
const CAMS = [['prep', 1920, 1080], ['prep', 1280, 720], ['normal', 1920, 1080], ['normal', 1280, 720]];
describe('pickBody on the official cameras: B directly behind A', () => {
/** A board on camera `kind`: tile heights (`raised` = [[r, c, z]…]), units standing on tiles, the ground under a point. */
function board(kind, W, H, raised = []) {
const cam = presetCamera(kind, { width: W, height: H });
const hz = new Map(raised.map(([r, c, z]) => [`${r},${c}`, z]));
const heightAt = (r, c) => hz.get(`${r},${c}`) || 0;
const levels = [...new Set([0, ...raised.map((x) => x[2])])];
const ground = (x, y) => { const t = pickTile(cam, x, y, heightAt, levels); return t && { row: t.row, col: t.col, x: t.x, y: t.y }; };
const unit = (key, row, col) => ({ key, tile: { row, col }, x: col, y: row, depth: unitDepthKey(cam, col, row) });
/** Screen point of a spot on the top face of tile (row, col): (u, v) ∈ [−0.5, 0.5] across / along the tile. */
const spot = (row, col, u = 0, v = 0) => cam.project(col + u, row + v, heightAt(row, col));
return { cam, heightAt, ground, unit, spot };
}
/** Spots inside a tile's top face: the centre and near its four edges and corners (0.4 of a tile out). */
const SPOTS = [[0, 0], [-0.4, 0], [0.4, 0], [0, -0.4], [0, 0.4], [-0.4, -0.4], [0.4, 0.4], [-0.4, 0.4], [0.4, -0.4]];
describe('pickOnTile: a press on a tile selects the unit on that tile (official cameras)', () => {
for (const [kind, W, H] of CAMS) {
test(`${kind} ${W}×${H}, same height: B's head / face / torso / legs / tile beside A are B's; A's face, torso and feet are A's`, () => {
const { at, A, B, rowGap } = pair(kind, W, H);
assert.ok(rowGap > 0.8 && rowGap < 0.95, `rows ${rowGap.toFixed(2)} of a tile apart on screen (test premise)`);
assert.ok(BODY_H > rowGap, 'a chibi is taller than a row: A covers B\'s feet (test premise)');
for (const up of [1.2, 0.86, 0.6, 0.45, 0.3]) assert.equal(at(B, up), 'B', `B ${up} tile above its feet`);
for (const dx of [-0.4, 0.4]) assert.equal(at(B, 0, dx), 'B', `B's tile beside A's head (${dx})`);
for (const up of [0.86, 0.6, 0.3, 0.05, -0.1]) assert.equal(at(A, up), 'A', `A ${up} tile above its feet`);
// the feet of B are drawn behind A's face: that press is A's (nothing of B shows there)
assert.equal(at(B, 0), 'A');
// A's hair crown over B's legs / tile goes to B; with nobody behind it is A's
assert.equal(at(A, 1.2), 'B');
assert.equal(at(A, 1.2, 0, [A]), 'A');
assert.equal(at(A, 0.86, 0, [A]), 'A');
test(`${kind} ${W}×${H}: a 3×3 block of units, the back row on high ground — every spot of a tile is its unit's`, () => {
// rows 9–11 × cols 3–5; row 11 raised like the ranged high ground (TILE_H.wall)
const B = board(kind, W, H, [[11, 3, TILE_H.wall], [11, 4, TILE_H.wall], [11, 5, TILE_H.wall]]);
const units = [];
for (let r = 9; r <= 11; r++) for (let c = 3; c <= 5; c++) units.push(B.unit(`${r},${c}`, r, c));
for (const u of units) {
for (const [du, dv] of SPOTS) {
const p = B.spot(u.tile.row, u.tile.col, du, dv);
assert.equal(pickOnTile(units, B.ground(p.x, p.y))?.key, u.key, `tile ${u.key} at (${du}, ${dv})`);
}
}
});
test(`${kind} ${W}×${H}, B on a raised tile (ranged high ground behind a melee lane): B's feet are B's too`, () => {
const { at, B, A } = pair(kind, W, H, { zB: 0.42 });
for (const up of [1.2, 0.86, 0.45, 0.2, 0]) assert.equal(at(B, up), 'B', `B ${up} tile above its feet`);
for (const up of [0.86, 0.45, 0.05]) assert.equal(at(A, up), 'A', `A ${up} tile above its feet`);
test(`${kind} ${W}×${H}: a press on a unit's head (drawn over the tile behind) is a press on that tile`, () => {
const B = board(kind, W, H);
const front = B.unit('front', 9, 4), behind = B.unit('behind', 10, 4);
const p = B.spot(9, 4);
const head = { x: p.x, y: p.y - 1.0 * p.s }; // a chibi is ~1.27 tiles tall, rows only ~0.85 tile apart on screen
assert.deepEqual([B.ground(head.x, head.y).row, B.ground(head.x, head.y).col], [10, 4], 'premise: the head is over row 10');
assert.equal(pickOnTile([front, behind], B.ground(head.x, head.y)).key, 'behind');
assert.equal(pickOnTile([front], B.ground(head.x, head.y)), null, 'an empty tile selects nothing');
const feet = B.spot(10, 4);
assert.equal(pickOnTile([front, behind], B.ground(feet.x, feet.y)).key, 'behind', 'its own tile, where the front one\'s head is drawn');
});
}
test('the old rule would have picked A for most of B\'s lower half (the playtest report)', () => {
// the pre-fix hit box: 0.7 tile wide, 1.18 tiles above the feet + 0.1 below, front-most wins
const { A, B } = pair('prep', 1920, 1080);
const box = (u, x, y) => Math.abs(x - u.x) <= 0.35 * u.s && y >= u.y - 1.18 * u.s && y <= u.y + 0.1 * u.s;
const old = (x, y) => (box(A, x, y) ? 'A' : box(B, x, y) ? 'B' : null);
let wrong = 0;
for (const up of [0.3, 0.2, 0.1, 0]) for (const dx of [-0.3, 0, 0.3]) if (old(B.x + dx * B.s, B.y - up * B.s) === 'A') wrong++;
assert.ok(wrong >= 6, `old rule: ${wrong}/12 presses on B's lower body / feet went to A`);
test('the bench (row 7) and the temporary bench (row 8): raised pads, one piece per slot', () => {
const pads = [];
for (let c = 0; c < GEO.HAND_SIZE; c++) pads.push([GEO.HAND_ROW, c, TILE_H.bench]);
for (let i = 0; i < GEO.TEMP_SIZE; i++) pads.push([GEO.TEMP_ROW, GEO.TEMP_C0 + i, TILE_H.bench]);
for (const [W, H] of [[1920, 1080], [1280, 720], [844, 390]]) {
const B = board('prep', W, H, pads);
const units = pads.map(([r, c]) => B.unit(`${r},${c}`, r, c));
// a board unit right in front of the temp row and behind nothing
units.push(B.unit('9,5', 9, 5));
for (const u of units) {
for (const [du, dv] of SPOTS) {
const p = B.spot(u.tile.row, u.tile.col, du * 0.9, dv * 0.9);
assert.equal(pickOnTile(units, B.ground(p.x, p.y))?.key, u.key, `${W}×${H} slot ${u.key} at (${du}, ${dv})`);
}
}
}
});
test('several on one tile (pen clusters, a held piece): the nearest to the pointer\'s point, ties the front-most', () => {
const t = { row: 15, col: 9 };
const a = { key: 'a', tile: t, x: 8.8, y: 15.15, depth: 1 }, b = { key: 'b', tile: t, x: 9.2, y: 15.15, depth: 1 }, c = { key: 'c', tile: t, x: 9, y: 14.8, depth: 2 };
assert.equal(pickOnTile([a, b, c], { ...t, x: 8.75, y: 15.2 }).key, 'a');
assert.equal(pickOnTile([a, b, c], { ...t, x: 9.3, y: 15.1 }).key, 'b');
assert.equal(pickOnTile([a, b, c], { ...t, x: 9, y: 14.7 }).key, 'c');
const d = { key: 'd', tile: t, x: 9, y: 15, depth: 5 }, e = { key: 'e', tile: t, x: 9, y: 15, depth: 3 };
assert.equal(pickOnTile([e, d], { ...t, x: 9.1, y: 15 }).key, 'd', 'same spot: the front-most');
assert.equal(pickOnTile([e, d], t).key, 'd', 'no point on the tile: the front-most');
});
test('bad input: nothing', () => {
const u = { tile: { row: 1, col: 1 }, x: 1, y: 1 };
assert.equal(pickOnTile([u], null), null);
assert.equal(pickOnTile([u], { row: 1.5, col: 1 }), null);
assert.equal(pickOnTile(null, { row: 1, col: 1 }), null);
assert.equal(pickOnTile([null, { x: 1, y: 1 }, { tile: null, x: 1, y: 1 }], { row: 1, col: 1 }), null);
assert.equal(pickOnTile([u], { row: 1, col: 2 }), null);
});
});
describe('pickBody rule details', () => {
const cam = presetCamera('prep', { width: 1920, height: 1080 });
const at = (row, col, extra = {}) => { const p = cam.project(col, row, 0); return { x: p.x, y: p.y, s: p.s, h: BODY_H, depth: unitDepthKey(cam, col, row), tile: { row, col }, ...extra }; };
describe('pickBattle: allies by their tile, walking enemies by their ground position or drawn body, flying ones by their body', () => {
const ally = (key, row, col, depth = 0) => ({ key, tile: { row, col }, x: col, y: row, depth });
const foe = (key, x, y, depth = 0) => ({ key, tile: null, x, y, depth });
const at = (x, y) => ({ row: Math.round(y), col: Math.round(x), x, y });
test('off every body, the unit standing on the pressed tile is picked; an empty tile picks nothing', () => {
const U = { key: 'U', ...at(10, 6) };
const tile = cam.project(6, 10, 0);
// the tile's corner, well away from the body
const cx = tile.x + 0.45 * tile.s, cy = tile.y + 0.3 * tile.s;
assert.equal(hitBody(U, cx, cy), 0, 'premise: not on the body');
assert.equal(pickBody([U], cx, cy, { row: 10, col: 6 }), U);
assert.equal(pickBody([U], cx, cy, { row: 10, col: 7 }), null);
assert.equal(pickBody([U], cx, cy, null), null);
test('an ally on the tile under the pointer; an empty tile with no enemy near: nothing', () => {
const A = ally('A', 10, 4);
assert.equal(pickBattle([A], at(4.3, 10.4), 0, 0).key, 'A');
assert.equal(pickBattle([A], at(4.3, 10.6), 0, 0), null, 'the tile behind');
assert.equal(pickBattle([A], null, 0, 0), null, 'off the grid');
});
test('side by side (same row): each unit keeps its own body, weapon edges go by the tile', () => {
const L = { key: 'L', ...at(10, 5) }, R = { key: 'R', ...at(10, 6) };
for (const u of [L, R]) for (const up of [0.9, 0.5, 0.15]) assert.equal(pickBody([L, R], u.x, u.y - up * u.s, u.tile), u);
const mid = (L.x + R.x) / 2;
assert.equal(pickBody([L, R], mid - 2, L.y - 0.3 * L.s, { row: 10, col: 5 }), L);
assert.equal(pickBody([L, R], mid + 2, L.y - 0.3 * L.s, { row: 10, col: 6 }), R);
test(`an enemy within ${ENEMY_REACH} tile of the pointer's ground point, the nearest of several`, () => {
const e1 = foe('e1', 6.3, 12.1), e2 = foe('e2', 6.9, 12.0);
assert.equal(pickBattle([e1, e2], at(6.4, 12.2), 0, 0).key, 'e1');
assert.equal(pickBattle([e1, e2], at(6.8, 11.9), 0, 0).key, 'e2');
assert.equal(pickBattle([e1], at(6.3 + ENEMY_REACH - 0.01, 12.1), 0, 0).key, 'e1');
assert.equal(pickBattle([e1], at(6.3 + ENEMY_REACH + 0.01, 12.1), 0, 0), null, 'too far');
});
test('a moving unit (no tile, e.g. an enemy walking in front) is picked by its body only, front-most on cores', () => {
const op = { key: 'op', ...at(10, 5) };
const foe = { key: 'foe', ...at(9, 5), tile: null, w: 1.1, h: 1.3 };
assert.equal(pickBody([op, foe], foe.x, foe.y - 0.5 * foe.s, { row: 9, col: 5 }), foe, 'its torso');
assert.equal(pickBody([op, foe], op.x, op.y - 0.6 * op.s, { row: 10, col: 5 }), op, 'the operator above it');
// off its body on the tile it walks over: nothing stands there
assert.equal(pickBody([foe], foe.x + 0.48 * foe.s, foe.y + 0.35 * foe.s, { row: 9, col: 5 }), null);
test('an ally\'s tile and an enemy both there (a blocked enemy at the edge of the blocker\'s tile): the nearer, ties the ally', () => {
const A = ally('A', 10, 5), e = foe('e', 5.45, 10);
assert.equal(pickBattle([A, e], at(5.05, 10.05), 0, 0).key, 'A', 'the tile centre: the operator');
assert.equal(pickBattle([A, e], at(5.42, 10.02), 0, 0).key, 'e', 'on the enemy');
assert.equal(pickBattle([e, A], at(5.225, 10), 0, 0).key, 'A', 'halfway: the ally');
assert.equal(pickBattle([A, e], at(5.8, 10), 0, 0).key, 'e', 'the next tile: only the enemy is near');
});
test('the HUD (tier chip / bars) is an outline: its own unit when nothing else is there, never over a torso', () => {
const B = { key: 'B', ...at(10, 5) }, C = { key: 'C', ...at(11, 5) };
const chipY = B.y - 1.3 * B.s;
B.hud = { x0: B.x - 12, y0: chipY - 12, x1: B.x + 12, y1: chipY + 12 };
assert.equal(pickBody([B], B.x, chipY, null), B, 'alone: the chip is its unit\'s');
assert.equal(hitBody(C, B.x, chipY), 2, 'premise: C\'s legs are behind B\'s chip');
assert.equal(pickBody([B, C], B.x, chipY, { row: 11, col: 5 }), C);
test('a flying enemy: its drawn body (feet to head) within the reach × its px per tile, on screen', () => {
const f = { key: 'f', tile: null, x: 7, y: 11, fly: true, body: { x: 500, top: 260, feet: 340, s: 80 } };
const A = ally('A', 11, 7);
assert.equal(pickBattle([f], null, 510, 330).key, 'f', 'off any tile under it');
assert.equal(pickBattle([f], null, 505, 265).key, 'f', 'its head');
assert.equal(pickBattle([f], at(7, 11), 500 + ENEMY_REACH * 80 + 2, 300), null, 'the ground position does not count');
assert.equal(pickBattle([f], null, 500, 340 + ENEMY_REACH * 80 + 2), null, 'below its feet, beyond the reach');
assert.equal(pickBattle([f, A], at(7.4, 11.3), 505, 305).key, 'f', 'on the flyer, over an ally\'s tile');
assert.equal(pickBattle([f, A], at(7.02, 11), 500, 340 + 0.2 * 80).key, 'A', 'the ally\'s tile centre, the flyer 0.2 tile away: the ally is nearer');
});
test('model height, width factor and facing shape the outline', () => {
const tall = { ...at(10, 5), h: 1.45 }, short = { ...at(10, 5), h: 1.1 };
const topOf = (b) => bodyBounds(b).y;
assert.ok(topOf(tall) < topOf(short), 'taller model → higher outline');
assert.ok(Math.abs(topOf(tall) - (tall.y - 1.45 * tall.s)) < 1, 'the outline tops out at the model height');
const big = { ...at(10, 5), h: 2.4, w: 2 };
assert.ok(bodyBounds(big).width > 1.9 * bodyBounds(short).width, 'enemies scale with their size');
const right = bodyParts({ ...at(10, 5), flip: 1 }).outline[1].rect, left = bodyParts({ ...at(10, 5), flip: -1 }).outline[1].rect;
const x = at(10, 5).x;
assert.ok(x - right.x0 > right.x1 - x && left.x1 - x > x - left.x0, 'the back side (hair, capes) is the wider one');
test('a walking enemy: its drawn body counts too — a press on a tall enemy\'s / a boss\'s torso or head picks it', () => {
// a boss 2.4 tiles tall standing at (8, 12): its torso is drawn ~1.2 tiles above its feet, over the ground ~1 tile
// behind it — beyond the ground reach, on its body
const boss = { key: 'boss', tile: null, x: 8, y: 12, body: { x: 600, top: 600 - 2.4 * 90, feet: 600, s: 90 } };
assert.equal(pickBattle([boss], at(8.05, 13.1), 605, 600 - 1.2 * 90).key, 'boss', 'its torso');
assert.equal(pickBattle([boss], at(8, 13.8), 598, 600 - 2.3 * 90).key, 'boss', 'its head');
assert.equal(pickBattle([boss], at(8.3, 12.2), 700, 610).key, 'boss', 'at its feet (the ground rule)');
assert.equal(pickBattle([boss], at(8, 14.5), 600 + 0.7 * 90, 600 - 1.2 * 90), null, 'beside its body, beyond the reach');
// an ally on the tile its head is drawn over: the ally's tile centre stays the ally's, the head the enemy's
const e = { key: 'e', tile: null, x: 5, y: 10, body: { x: 400, top: 400 - 1.2 * 80, feet: 400, s: 80 } };
const A = ally('A', 11, 5);
assert.equal(pickBattle([A, e], at(5, 11), 400 + 0.3 * 80, 400 - 1.1 * 80).key, 'A', 'the ally\'s tile centre, 0.3 tile beside the head');
assert.equal(pickBattle([A, e], at(5, 10.6), 400, 400 - 1.0 * 80).key, 'e', 'on the enemy\'s head (over the near edge of that tile)');
});
test('avatar diamonds and item plates', () => {
const d = { kind: 'diamond', ...at(10, 5), d: 0.78 };
const centre = d.y - (0.08 + 0.39) * d.s;
assert.equal(hitBody(d, d.x, centre), 2);
assert.equal(hitBody(d, d.x + 0.33 * d.s, centre), 1, 'near its tip');
assert.equal(hitBody(d, d.x + 0.45 * d.s, centre), 0);
const plate = { kind: 'plate', x: 500, y: 300, s: 100, r: 0.31 };
assert.equal(hitBody(plate, 500, 300), 2);
assert.equal(hitBody(plate, 528, 300), 1);
assert.equal(hitBody(plate, 540, 300), 0);
assert.deepEqual(bodyBounds(plate), { x: 469, y: 269, width: 62, height: 62 });
assert.equal(coreDistance(plate, 500, 300), 0);
assert.ok(Math.abs(coreDistance(plate, 541.7, 300) - 0.2) < 1e-6, 'px to the core / px per tile');
});
test('junk input picks nothing', () => {
const U = { key: 'U', ...at(10, 5) };
assert.equal(pickBody([U], NaN, 3), null);
assert.equal(pickBody(null, 3, 3), null);
assert.equal(pickBody([null, { x: 1, y: 1, s: 0 }, { x: NaN, y: 1, s: 5 }], 1, 1, { row: 1, col: 1 }), null);
assert.equal(hitBody(null, 0, 0), 0);
});
});
// Review of item 7: measured against the rendered pixels of the mock match (every drawn unit pixel of the prep board at
// 1920×1080), the shape rule alone put ~8 % of the presses on the wrong unit — mostly the unit BEHIND: the hair crown
// and the sides (arms, clothes, weapons) of the unit in front lie over the legs / tile of the one behind. The pixel
// probe (render/app.js drawnAt) settles every overlap by what is really drawn on top.
describe('pickBody with the pixel probe', () => {
const { A, B, cam } = pair('prep', 1920, 1080);
const ground = (x, y) => { const g = cam.unproject(x, y, 0); return g ? { row: Math.round(g.y), col: Math.round(g.x) } : null; };
/** A probe that says which bodies draw the point (and records who was asked, in order). */
const probeOf = (drawing, calls = []) => (b) => { calls.push(b.key); return drawing.includes(b.key); };
test('A\'s hair crown drawn over B\'s torso: A (the shape rule alone says B)', () => {
const x = A.x, y = A.y - 1.2 * A.s;
assert.equal(pickBody([A, B], x, y, ground(x, y))?.key, 'B', 'premise: the rule gives it to B');
const calls = [];
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['A', 'B'], calls))?.key, 'A');
assert.deepEqual(calls, ['A'], 'front-most first; stops at the first that draws');
});
test('B\'s legs showing beside A\'s head: B', () => {
const x = B.x + 0.3 * B.s, y = B.y - 0.3 * B.s;
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['B']))?.key, 'B');
});
test('A\'s arm over the tile of B, outside B\'s outline: A (a single candidate the rule did not pick)', () => {
// a point on B's tile in front of B's feet, beside A's body: the rule picks B (standing on the pressed tile)
const t = cam.project(COL, 10, 0);
const x = t.x + 0.45 * t.s, y = t.y + 0.3 * t.s;
assert.equal(ground(x, y)?.row, 10, 'premise: B\'s tile');
assert.equal(pickBody([A, B], x, y, ground(x, y))?.key, 'B');
const cand = probeCandidates([A, B], x, y);
if (cand.length === 1) assert.equal(cand[0].key, 'A');
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['A']))?.key, 'A');
// nothing drawn there: the rule (B's tile)
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf([]))?.key, 'B');
});
test('the probe is only asked where it can change the answer; null / throwing → the shape rule', () => {
const lone = { ...A, key: 'L' };
const calls = [];
assert.equal(pickBody([lone], lone.x, lone.y - 0.5 * lone.s, null, probeOf([], calls))?.key, 'L');
assert.deepEqual(calls, [], 'one body that the rule picks: no probe');
assert.equal(pickBody([lone], lone.x + 5 * lone.s, lone.y, null, probeOf([], calls)), null);
assert.deepEqual(calls, [], 'no body around: no probe');
const x = A.x, y = A.y - 1.2 * A.s;
assert.equal(pickBody([A, B], x, y, ground(x, y), () => null)?.key, 'B', 'cannot tell → the rule');
assert.equal(pickBody([A, B], x, y, ground(x, y), () => { throw new Error('lost context'); })?.key, 'B');
// the one in front cannot tell: the rule decides (never a unit behind an unknown one)
assert.equal(pickBody([A, B], x, y, ground(x, y), (b) => (b.key === 'A' ? null : true))?.key, 'B');
assert.equal(pickBody([A, B], x, y, ground(x, y), (b) => (b.key === 'A' ? null : true)), pickBody([A, B], x, y, ground(x, y)));
});
test('probe candidates: bodies whose outline + PROBE_MARGIN holds the point, front-most first', () => {
const r = bodyBounds({ ...A, hud: null });
assert.deepEqual(probeCandidates([B, A], A.x, A.y - 0.9 * A.s).map((b) => b.key), ['A', 'B']);
assert.deepEqual(probeCandidates([A], r.x - (PROBE_MARGIN - 0.05) * A.s, A.y - 0.5 * A.s).map((b) => b.key), ['A']);
assert.deepEqual(probeCandidates([A], r.x - (PROBE_MARGIN + 0.05) * A.s, A.y - 0.5 * A.s), []);
assert.deepEqual(probeCandidates([null, { x: NaN, y: 0, s: 1 }], 0, 0), []);
test('bad input: nothing', () => {
assert.equal(pickBattle(null, at(1, 1), 0, 0), null);
assert.equal(pickBattle([null, foe('x', NaN, 1), { key: 'y', tile: null, x: 1, y: 1, fly: true, body: { x: 1, top: 0, feet: 1, s: 0 } }], at(1, 1), 1, 1), null);
});
});