卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
// 4-direction facing on the match side (research 09 §1.2 / §6.1 items 1–4, DESIGN §3): g.move / g.art `dir` (protocol
// validation, default RIGHT), piece.dir stored and kept across rounds, in-place re-orientation, swaps keep each dir,
// effect-placed pieces keep the tile's dir, m.private / battle input / BattleSpec carry dir, 画卷 front by dir, the Final
// Assault mirror, and the bot's direction choice.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { validateC2S, DIRS } from '../../shared/protocol.js';
import { pieceDir, parseDir, tileKey } from '../../server/match/board.js';
import { bossFieldPlacement } from '../../server/match/finalAssault.js';
import { planLayout, rangeTiles, LAYOUT_PARAMS, fieldModel, airflowAtkMul } from '../../server/match/bot.js';
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
import { DATA, makeMatch, give, giveItem, chessOfTier, checkInvariants, legalTileFor } from './harness.js';
const OK = { ok: true };
const MELEE = (c) => c.position === 'MELEE';
function prepMatch(o = {}) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: o.seed ?? 11, ...o }).start();
h.toPrep(1);
h.setStage('act2autochess_m01');
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
return { h, m: h.m, ps };
}
const meleeIds = (m, n) => chessOfTier(1, MELEE).filter((id) => m.pool.has(id) && !DATA.chess[id].tokens?.length).slice(0, n);
test('protocol: g.move / g.art accept an optional dir ∈ UP|RIGHT|DOWN|LEFT and reject anything else', () => {
assert.deepEqual([...DIRS], ['UP', 'RIGHT', 'DOWN', 'LEFT']);
const to = { area: 'board', row: 10, col: 4 };
assert.equal(validateC2S({ t: 'g.move', uid: 5, to }), null, 'dir optional');
for (const d of DIRS) assert.equal(validateC2S({ t: 'g.move', uid: 5, to, dir: d }), null, d);
for (const bad of ['up', 'NORTH', 1, null, {}]) assert.notEqual(validateC2S({ t: 'g.move', uid: 5, to, dir: bad }), null, String(bad));
assert.equal(validateC2S({ t: 'g.art', itemUid: 5, row: 10, col: 4 }), null);
assert.equal(validateC2S({ t: 'g.art', itemUid: 5, row: 10, col: 4, dir: 'DOWN' }), null);
assert.notEqual(validateC2S({ t: 'g.art', itemUid: 5, row: 10, col: 4, dir: 'SIDEWAYS' }), null);
assert.equal(parseDir(undefined), 'RIGHT');
assert.equal(parseDir('LEFT'), 'LEFT');
assert.equal(parseDir('left'), null);
assert.equal(pieceDir({}), 'RIGHT');
});
test('g.move stores the dir (default RIGHT); the view, the battle input and the BattleSpec carry it', () => {
const { m, ps } = prepMatch();
const [a, b] = meleeIds(m, 2).map((id) => give(m, ps, id, 'hand'));
assert.deepEqual(ps.move(a.uid, { area: 'board', row: 9, col: 3 }, 'UP'), OK);
assert.deepEqual(ps.move(b.uid, { area: 'board', row: 9, col: 4 }), OK);
assert.equal(a.dir, 'UP');
assert.equal(pieceDir(b), 'RIGHT', 'absent ⇒ RIGHT');
const view = ps.privateView();
assert.equal(view.board.find((p) => p.uid === a.uid).dir, 'UP');
assert.equal(view.board.find((p) => p.uid === b.uid).dir, 'RIGHT');
assert.equal(view.hand.filter(Boolean).every((p) => p.dir === undefined), true, 'hand pieces have no direction');
const input = ps.battleInput();
assert.equal(input.units.find((u) => u.uid === a.uid).dir, 'UP');
const spec = buildBattleSpec({ kind: 'normal', seed: 3, stageId: m.stageId, players: [input], spawns: [], routes: [], timeLimit: 30 });
assert.equal(spec.players[0].units.find((u) => u.uid === a.uid).dir, 'UP', 'the spec (JSON) keeps dir');
const battle = createBattleFromSpec(spec, DATA);
const unit = battle.allyUnits.find((u) => u.uid === a.uid);
assert.equal(unit.dir, 'UP', 'the spec-built battle deploys it facing UP');
assert.deepEqual(ps.move(b.uid, { area: 'board', row: 9, col: 5 }, 'NORTH'), { error: ERR.BAD_TARGET, detail: 'bad dir' });
assert.ok(ps.board.get(tileKey(9, 4)) === b, 'a rejected move changes nothing');
assert.deepEqual(ps.move(b.uid, { area: 'board', row: 9, col: 4, dir: 'DOWN' }), OK, '`to.dir` is read when the top-level dir is absent');
assert.equal(b.dir, 'DOWN');
checkInvariants(m);
m.dispose();
});
test('g.move onto the own tile re-orients in place; board↔board swaps keep each piece\'s dir; tokens too', () => {
const { m, ps } = prepMatch();
const [a, b] = meleeIds(m, 2).map((id) => give(m, ps, id, 'hand'));
ps.move(a.uid, { area: 'board', row: 9, col: 3 }, 'UP');
ps.move(b.uid, { area: 'board', row: 9, col: 4 }, 'LEFT');
const bonds0 = JSON.stringify(ps.bonds);
assert.deepEqual(ps.move(a.uid, { area: 'board', row: 9, col: 3 }, 'DOWN'), OK, 'same tile, new dir');
assert.equal(a.dir, 'DOWN');
assert.equal(ps.board.get(tileKey(9, 3)), a);
assert.equal(JSON.stringify(ps.bonds), bonds0);
assert.deepEqual(ps.move(a.uid, { area: 'board', row: 9, col: 4 }, 'RIGHT'), OK, 'swap');
assert.equal(ps.board.get(tileKey(9, 4)), a);
assert.equal(a.dir, 'RIGHT', 'the moved piece takes the new dir');
assert.equal(ps.board.get(tileKey(9, 3)), b);
assert.equal(b.dir, 'LEFT', 'the occupant keeps its own dir');
// withdraw-swap: a bench card takes the withdrawn piece's tile with that tile's facing
const c = give(m, ps, meleeIds(m, 3)[2], 'hand');
const idx = ps.hand.indexOf(c);
assert.deepEqual(ps.move(b.uid, { area: 'hand', idx }), OK);
assert.equal(ps.board.get(tileKey(9, 3)), c);
assert.equal(c.dir, 'LEFT');
// placeable summons: a token piece gets its dir, and re-orients in place
const summoner = [1, 2, 3, 4, 5, 6].flatMap((t) => chessOfTier(t)).find((id) => m.gd.placeableTokens(id).length && m.pool.has(id));
assert.ok(summoner, 'a chess with placeable summons exists');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
const [sr, sc] = legalTileFor(m, ps, summoner);
const s = give(m, ps, summoner, 'hand');
assert.deepEqual(ps.move(s.uid, { area: 'board', row: sr, col: sc }, 'UP'), OK);
const stack = [...ps.hand, ...ps.temp].find((p) => p && p.kind === 'token');
assert.ok(stack, 'its summons joined the hand');
const tokRec = m.gd.token(stack.id);
const free = [...ps.deployMap()].map(([k]) => k.split(',').map(Number)).find(([r, c]) => !ps.board.has(tileKey(r, c)) && ps._legal(stack, r, c));
assert.ok(free, `a legal tile for ${tokRec?.name ?? stack.id}`);
assert.deepEqual(ps.move(stack.uid, { area: 'board', row: free[0], col: free[1] }, 'DOWN'), OK);
const placed = ps.board.get(tileKey(free[0], free[1]));
assert.equal(placed.kind, 'token');
assert.equal(placed.dir, 'DOWN');
assert.deepEqual(ps.move(placed.uid, { area: 'board', row: free[0], col: free[1] }, 'LEFT'), OK, 'token re-oriented in place');
assert.equal(placed.dir, 'LEFT');
assert.equal(ps.battleInput().units.find((u) => u.uid === placed.uid).dir, 'LEFT');
checkInvariants(m);
m.dispose();
});
test('the dir persists across rounds and reaches the round\'s battle', () => {
const { h, m, ps } = prepMatch({ seed: 12 });
const [a] = meleeIds(m, 1).map((id) => give(m, ps, id, 'hand'));
ps.move(a.uid, { area: 'board', row: 9, col: 3 }, 'UP');
const inputs = [];
const newBattle = m.newBattle.bind(m);
m.newBattle = (opts) => { for (const p of opts.players || []) if (p.playerId === 'p_0') inputs.push(p); return newBattle(opts); };
h.toPrep(2);
const now = ps.find(a.uid);
assert.ok(now && now.area === 'board', 'still deployed');
assert.equal(now.piece.dir, 'UP', 'facing kept into the next prep');
const u = inputs.flatMap((p) => p.units).find((x) => x.uid === a.uid);
assert.ok(u, 'the round-1 battle got the piece');
assert.equal(u.dir, 'UP');
m.dispose();
});
test('effect-placed pieces: a transformation / a merge elite taking a consumed copy\'s tile keep that tile\'s dir', () => {
const { m, ps } = prepMatch({ seed: 13 });
const [id, other] = meleeIds(m, 2);
const a = give(m, ps, id, 'board', [9, 3]);
a.dir = 'DOWN';
const np = ps.transformChess(a, other);
assert.ok(np && ps.board.get(tileKey(9, 3)) === np, 'kept the tile');
assert.equal(np.dir, 'DOWN');
// merge with a full hand and temp: the elite takes a consumed copy's freed board tile, with its dir
ps.board.clear();
ps.hand.fill(null);
ps.temp.fill(null);
ps.recompute();
const c1 = give(m, ps, id, 'board', [9, 3]);
const c2 = give(m, ps, id, 'board', [9, 4]);
c1.dir = 'UP';
c2.dir = 'LEFT';
for (let i = 0; i < ps.hand.length; i++) ps.hand[i] = ps.newPiece('item', 'chess_item_3_01_e_a');
for (let i = 0; i < ps.temp.length; i++) ps.temp[i] = ps.newPiece('item', 'chess_item_3_03_e_a');
ps.recompute();
assert.equal(m.gd.mergeCount(id), 3);
const elite = ps.acquireChess(id, { source: 'test' });
assert.ok(elite && m.gd.isGolden(elite.id), 'merged');
const loc = ps.find(elite.uid);
assert.equal(loc.area, 'board');
const was = loc.key === tileKey(9, 3) ? 'UP' : 'LEFT';
assert.equal(elite.dir, was, 'the elite keeps the dir of the tile it took');
m.dispose();
});
test('画卷 (g.art dir): range 1-1 is the drop tile + the tile in front along the chosen direction', () => {
const { m, ps } = prepMatch({ seed: 14 });
const [id] = meleeIds(m, 1);
give(m, ps, id, 'board', [10, 3]);
const art = giveItem(m, ps, 'chess_item_6_02_m');
assert.equal(ps.useArt(art.uid, 9, 3, 'RIGHT').error, ERR.BAD_TARGET, 'RIGHT: (9,3)+(9,4) hold nobody');
assert.deepEqual(ps.useArt(art.uid, 9, 3, 'UP'), OK, 'UP: the tile above (10,3) is copied');
assert.equal(ps.hand.filter((p) => p && p.kind === 'chess' && p.id === id).length, 1, 'a copy in the hand');
const art2 = giveItem(m, ps, 'chess_item_6_02_m');
assert.deepEqual(ps.useArt(art2.uid, 9, 3, 'EAST'), { error: ERR.BAD_TARGET, detail: 'bad dir' });
m.dispose();
});
test('Final Assault mirror: bossFieldPlacement = the sim mapping (col c → 20 − c, RIGHT ↔ LEFT, UP / DOWN kept)', () => {
assert.deepEqual(bossFieldPlacement('R', 10, 4, 'RIGHT'), { row: 3, col: 16, dir: 'LEFT' });
assert.deepEqual(bossFieldPlacement('R', 12, 9, 'UP'), { row: 5, col: 11, dir: 'UP' });
assert.deepEqual(bossFieldPlacement('R', 9, 2, 'LEFT'), { row: 2, col: 18, dir: 'RIGHT' });
assert.deepEqual(bossFieldPlacement('L', 11, 6, 'DOWN'), { row: 4, col: 6, dir: 'DOWN' });
const rec = chessRec({ id: 't_x', rangeGrid: [[0, 0], [0, 1]], skill: null });
const units = [['RIGHT', 10, 4], ['UP', 11, 5], ['LEFT', 12, 6], ['DOWN', 9, 7]].map(([dir, row, col], i) => ({ uid: i + 1, kind: 'chess', chessId: 't_x', row, col, dir }));
const h = makeBattle({ kind: 'boss', defs: { chess: { t_x: rec } }, players: [{ playerId: 'R', seat: 1, side: 'R', colOffset: 8, units }] });
h.step();
for (const u of units) {
const f = bossFieldPlacement('R', u.row, u.col, u.dir);
const unit = h.b.allyUnits.find((x) => x.uid === u.uid);
assert.deepEqual([unit.tileR, unit.tileC, unit.dir], [f.row, f.col, f.dir], `uid ${u.uid}`);
}
});
test('bot: the planner turns a ranged unit toward the enemy path (and keeps RIGHT when every direction is equal)', () => {
const { m, ps } = prepMatch({ seed: 15 });
// a synthetic round: one ground route along row 10 (right → left); the only legal tile is (11,6) off the road
m._botPath = null;
const key = `${m.round}|${m.stageId}|${m.wave ? m.wave.templateId : 'boss'}|`;
const tiles = ['10,10', '10,9', '10,8', '10,7', '10,6', '10,5', '10,4', '10,3'];
const index = new Map(tiles.map((k, i) => [k, [[0, i]]]));
const ground = new Map(tiles.map((k, i) => [k, { flow: 5, prog: i / (tiles.length - 1) }]));
m._botPath = { key, routes: [{ n: 5, fly: false, tiles, tileTime: 2, hp: 3000, dwell: 0 }], index, ground, flyTotal: 0 };
ps._deployMap = new Map([['11,6', 'ranged']]);
const line = { chessId: 't_line', position: 'RANGED', stats: { atk: 500, bat: 1, aspd: 100, blockCnt: 0 }, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3]], attackKind: 'ranged', canHitFly: true };
const dot = { ...line, chessId: 't_dot', rangeGrid: [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]] };
const recs = { 1: line, 2: dot };
const plan = planLayout(m, ps, [{ uid: 1, kind: 'chess', id: 't_line' }], LAYOUT_PARAMS, { recOf: (p) => recs[p.uid] });
assert.equal(plan.get(1), '11,6');
assert.equal(plan.dirs.get(1), 'DOWN', 'facing DOWN covers the road tile (10,6); RIGHT / UP / LEFT cover nothing');
assert.ok(rangeTiles(line, 11, 6, 'DOWN').includes('10,6') && !rangeTiles(line, 11, 6, 'RIGHT').includes('10,6'));
const sym = planLayout(m, ps, [{ uid: 2, kind: 'chess', id: 't_dot' }], LAYOUT_PARAMS, { recOf: (p) => recs[p.uid] });
assert.equal(sym.dirs.get(2), 'RIGHT', 'a symmetric range keeps RIGHT');
ps.invalidateDeployMap();
m._botPath = null;
m.dispose();
});
test('bot: full preps place every board piece with a valid dir, sent to the battles', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], seed: 21 }).start();
const m = h.m;
const inputs = [];
const newBattle = m.newBattle.bind(m);
m.newBattle = (opts) => { for (const p of opts.players || []) inputs.push(p); return newBattle(opts); };
h.run(() => h.ended != null || (m.phase === PHASE.PREP && m.round === 4));
const ps = m.order[0];
for (const p of ps.board.values()) assert.ok(['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(pieceDir(p)));
const units = inputs.flatMap((p) => p.units || []);
assert.ok(units.length > 0);
for (const u of units) assert.ok(['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(u.dir), `unit ${u.uid} dir ${u.dir}`);
assert.equal(m.errorCount, 0);
checkInvariants(m);
m.dispose();
});
test('bot: 气流 tiles (act2 m01 DOWN blowers) scale the planned DPS by the blower relation — a symmetric range turns WITH the flow', () => {
const { m, ps } = prepMatch({ seed: 15 });
m._botPath = null;
const model = fieldModel(m, ps);
const bb = m.stage.devices.find((d) => d.role === 'blower').skill.bb;
// blower #001 (13,5) blows DOWN over (12,5) (11,5) (10,5) of the own board; #002 over col 9
for (const k of ['12,5', '11,5', '10,5']) assert.equal(model.airflow.get(k)?.dir, 'DOWN', k);
assert.ok(!model.airflow.has('11,4'));
assert.equal(airflowAtkMul(model, '11,5', 'DOWN'), 1 + bb['blower_s_character[equal].atk']);
assert.equal(airflowAtkMul(model, '11,5', 'UP'), 1 + bb['blower_s_character[opposite].atk']);
assert.equal(airflowAtkMul(model, '11,5', 'RIGHT'), 1 + bb['blower_s_character[vertical].atk']);
assert.equal(airflowAtkMul(model, '11,4', 'UP'), 1);
// one route along row 10; the only legal tile is (11,5) under the flow: every direction covers the same tiles
const key = model.key;
const tiles = ['10,10', '10,9', '10,8', '10,7', '10,6', '10,5', '10,4', '10,3'];
m._botPath = {
key, routes: [{ n: 5, fly: false, tiles, tileTime: 2, hp: 3000, dwell: 0 }], index: new Map(tiles.map((k, i) => [k, [[0, i]]])),
ground: new Map(tiles.map((k, i) => [k, { flow: 5, prog: i / (tiles.length - 1) }])), flyTotal: 0, airflow: model.airflow,
};
ps._deployMap = new Map([['11,5', 'ranged']]);
const dot = { chessId: 't_dot', position: 'RANGED', stats: { atk: 500, bat: 1, aspd: 100, blockCnt: 0 }, rangeGrid: [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]], attackKind: 'ranged', canHitFly: true };
const plan = planLayout(m, ps, [{ uid: 2, kind: 'chess', id: 't_dot' }], LAYOUT_PARAMS, { recOf: () => dot });
assert.equal(plan.get(2), '11,5');
assert.equal(plan.dirs.get(2), 'DOWN', 'same cover, ATK +30 % with the flow');
// off the flow the same unit keeps RIGHT
m._botPath = { ...m._botPath, airflow: new Map() };
assert.equal(planLayout(m, ps, [{ uid: 2, kind: 'chess', id: 't_dot' }], LAYOUT_PARAMS, { recOf: () => dot }).dirs.get(2), 'RIGHT');
ps.invalidateDeployMap();
m._botPath = null;
m.dispose();
});
test('a refused move never changes the facing (illegal tile, bad dir on the own tile, full board)', () => {
const { m, ps } = prepMatch({ seed: 13 });
const [a, b] = meleeIds(m, 2).map((id) => give(m, ps, id, 'hand'));
assert.deepEqual(ps.move(a.uid, { area: 'board', row: 9, col: 3 }, 'UP'), OK);
// not deployable (col 9 is the lane) → BAD_TILE, the piece stays where it was, still facing UP
const bad = [...Array(4)].map((_, i) => [9 + i, 9]).find(([r, c]) => !ps._legal(a, r, c));
assert.ok(bad, 'an illegal tile exists');
assert.equal(ps.move(a.uid, { area: 'board', row: bad[0], col: bad[1] }, 'DOWN').error, ERR.BAD_TILE);
assert.equal(a.dir, 'UP');
assert.equal(ps.board.get(tileKey(9, 3)), a);
// a bad direction on the own tile is refused too (no silent reset)
assert.equal(ps.move(a.uid, { area: 'board', row: 9, col: 3 }, 'up').error, ERR.BAD_TARGET);
assert.equal(a.dir, 'UP');
// full board: a hand piece dropped on a free tile is refused, the board piece keeps its facing
ps.deployCapBonus = -99;
assert.equal(ps.move(b.uid, { area: 'board', row: 9, col: 4 }, 'LEFT').error, ERR.BOARD_FULL);
assert.equal(b.dir, undefined, 'the refused piece got no facing');
ps.deployCapBonus = 0;
checkInvariants(m);
m.dispose();
});
// Integration (was a todo tripwire): Match._handle forwards the intent's top-level `dir` to PlayerState.move / useArt,
// so the deploy wheel's direction reaches the server (the browser E2E checks the same end to end).
test('g.move / g.art through Match.handle apply the top-level dir (and to.dir as a fallback)', () => {
const { m, ps } = prepMatch({ seed: 14 });
const [a, b, c] = meleeIds(m, 3).map((id) => give(m, ps, id, 'hand'));
// three melee-legal tiles: T1, T2, and a vertical pair (lo, hi = the tile above lo) for 画卷
const legal = [];
for (let r = 9; r <= 12; r++) for (let col = 2; col <= 10; col++) if (ps._legal(a, r, col)) legal.push([r, col]);
const lo = legal.find(([r, col]) => legal.some(([r2, c2]) => r2 === r + 1 && c2 === col));
assert.ok(lo, 'a vertical pair of melee tiles exists');
const hi = [lo[0] + 1, lo[1]];
const [t1, t2] = legal.filter(([r, col]) => !(col === lo[1] && (r === lo[0] || r === hi[0])));
const at = ([row, col]) => ({ area: 'board', row, col });
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: at(t1), dir: 'UP' }), OK);
assert.equal(a.dir, 'UP', 'the wheel direction reaches the board piece');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: at(t1), dir: 'LEFT' }), OK);
assert.equal(a.dir, 'LEFT', 're-orientation in place through the match');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: b.uid, to: { ...at(t2), dir: 'DOWN' } }), OK);
assert.equal(b.dir, 'DOWN', 'to.dir is read when the top-level dir is absent');
assert.equal(m.handle('p_0', { t: 'g.move', uid: b.uid, to: at(t2), dir: 'down' }).error, ERR.BAD_TARGET, 'a bad spelling is refused (the protocol layer refuses it earlier)');
assert.equal(b.dir, 'DOWN');
// 画卷 through the match: UP copies the unit above the drop tile (RIGHT finds nobody)
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: c.uid, to: at(hi) }), OK);
const art = giveItem(m, ps, 'chess_item_6_02_m');
const right = ps.board.get(tileKey(lo[0], lo[1] + 1));
if (!right) assert.equal(m.handle('p_0', { t: 'g.art', itemUid: art.uid, row: lo[0], col: lo[1], dir: 'RIGHT' }).error, ERR.BAD_TARGET);
const before = ps.hand.filter((p) => p && p.kind === 'chess' && p.id === c.id).length;
assert.deepEqual(m.handle('p_0', { t: 'g.art', itemUid: art.uid, row: lo[0], col: lo[1], dir: 'UP' }), OK);
assert.equal(ps.hand.filter((p) => p && p.kind === 'chess' && p.id === c.id).length, before + 1, 'the copy is in the hand');
// scouting a teammate's prep board (m.field prep: true) carries each piece's dir (ground wedge, Back model for UP)
const meta = m.prepFieldMeta(ps);
const byUid = new Map(meta.units.map((u) => [u.uid, u]));
assert.equal(byUid.get(a.uid).dir, 'LEFT');
assert.equal(byUid.get(a.uid).facing, -1);
assert.equal(byUid.get(b.uid).dir, 'DOWN');
assert.equal(byUid.get(b.uid).facing, 1);
assert.equal(byUid.get(c.uid).dir, 'RIGHT', 'absent ⇒ RIGHT');
m.dispose();
});