Files
ark-stronghold-protocol/test/ui/facing.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 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).
2026-09-29 16:21:31 +08:00

324 lines
19 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.
// Facing / direction step of the prep board (research 09 §1.2, §5, §6.1 5, §6.5; DESIGN §3 "Facing (corrected)"):
// the range rotation, the wheel's swipe → direction mapping with its dead-zone, which drops enter the direction step,
// the committed intents (g.move / g.art carry `dir`), the underframe actions (撤退 / 出售 +N / 销毁), the 撤退 target
// slot, the in-place re-orient drop of render/drag.js, and the g.move / g.art payloads of ui/gameActions.js.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
DIRS, normDir, rotateOffset, rangeTiles, dirFromDelta, dirFromKey, pieceDir, previewGrid, artHasFacing, needsFacing,
facingIntent, underframeActions, retreatSlot, DEAD_ZONE_TILES,
} from '../../public/js/ui/facing.js';
import { placementContext, canPlace, dropIntent } from '../../public/js/ui/gameLogic.js';
import { resolveDrop, createDragController, pieceSlot } from '../../public/js/render/drag.js';
import { GEO } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const load = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), 'utf8'));
const chess = load('chess.json');
const stages = load('stages.json');
const items = load('items.json');
const tokens = load('tokens.json');
const getChess = (id) => chess[id] || null;
const getItem = (id) => items[id] || null;
const getToken = (id) => tokens[id] || null;
const STAGE = stages.act2autochess_m01;
const MELEE = 'chess_char_1_02_a';
const RANGED = 'chess_char_1_01_a';
const EQUIP = 'chess_item_1_01_e_a';
const SCROLL = 'chess_item_6_02_m'; // 画卷: range [[0,0],[0,1]]
const CUSTOMER = 'chess_item_6_01_m'; // “神秘顾客”: range [[0,0]]
let uid = 100;
const piece = (id, extra = {}) => ({ uid: ++uid, kind: 'chess', id, golden: false, tier: chess[id]?.tier ?? 1, items: [], ...extra });
const item = (id) => ({ uid: ++uid, kind: 'item', id, golden: false, tier: 1 });
function privWith({ board = [], hand = [] } = {}) {
const h = new Array(GEO.HAND_SIZE).fill(null);
hand.forEach((p, i) => { if (p) h[p.idx ?? i] = p.piece ?? p; });
return { alive: true, ready: false, funds: 10, board, hand: h, temp: new Array(GEO.TEMP_SIZE).fill(null), deployCap: 8,
deployCount: board.filter((p) => p.kind === 'chess').length, shop: { level: 3, slots: [] } };
}
const ctxFor = (priv) => placementContext({ priv, stage: STAGE, editable: true, getChess, getItem, getToken });
describe('rotation (DESIGN §3: RIGHT (dr,dc) · UP (dc,−dr) · LEFT (−dr,−dc) · DOWN (−dc,dr); row 0 = bottom)', () => {
test('rotateOffset per direction', () => {
assert.deepEqual(rotateOffset(1, 2, 'RIGHT'), [1, 2]);
assert.deepEqual(rotateOffset(1, 2, 'UP'), [2, -1]);
assert.deepEqual(rotateOffset(1, 2, 'LEFT'), [-1, -2]);
assert.deepEqual(rotateOffset(1, 2, 'DOWN'), [-2, 1]);
assert.deepEqual(rotateOffset(0, 1, 'up'), [1, 0], 'the tile in front of an UP unit is the next row up');
assert.deepEqual(rotateOffset(0, 1, 'DOWN'), [-1, 0]);
assert.deepEqual(rotateOffset(0, 1, null), [0, 1], 'default RIGHT');
});
test('rangeTiles: a sniper grid (3 wide, 4 deep) turns with the facing; clipped to the stage; deduplicated', () => {
const grid = chess[RANGED].rangeGrid;
assert.ok(grid.length >= 6);
const right = rangeTiles(grid, 10, 5, 'RIGHT');
const up = rangeTiles(grid, 10, 5, 'UP');
const left = rangeTiles(grid, 10, 5, 'LEFT');
const down = rangeTiles(grid, 10, 5, 'DOWN');
for (const t of [right, up, left, down]) assert.equal(t.length, new Set(grid.map(([a, b]) => `${a},${b}`)).size);
assert.ok(right.every(([, c]) => c >= 5), 'RIGHT reaches columns to the right');
assert.ok(left.every(([, c]) => c <= 5), 'LEFT mirrors it');
assert.ok(up.every(([r]) => r >= 10), 'UP reaches the rows above (higher row index)');
assert.ok(down.every(([r]) => r <= 10), 'DOWN reaches the rows below');
const clipped = rangeTiles([[0, 0], [0, 1], [0, 30]], 10, 18, 'RIGHT');
assert.deepEqual(clipped, [[10, 18], [10, 19]], 'off-stage tiles dropped');
assert.deepEqual(rangeTiles([[0, 1], [0, 1]], 10, 5, 'RIGHT'), [[10, 6]]);
assert.deepEqual(rangeTiles(null, 10, 5, 'RIGHT'), []);
assert.deepEqual(rangeTiles([[0, 0], [0, 1]], 10, 5, 'UP', { extend: 1 }), [[10, 5], [11, 5], [12, 5]], 'rangeExtend grows along +dCol before rotating');
});
test('normDir / pieceDir: case-insensitive, legacy ±1, default RIGHT', () => {
assert.deepEqual(DIRS, ['UP', 'RIGHT', 'DOWN', 'LEFT']);
assert.equal(normDir('left'), 'LEFT');
assert.equal(normDir(-1), 'LEFT');
assert.equal(normDir('sideways'), 'RIGHT');
assert.equal(pieceDir({ dir: 'UP' }), 'UP');
assert.equal(pieceDir({}), 'RIGHT', 'server default when absent');
});
});
describe('the wheel: swipe → direction with a ~0.5 tile dead-zone', () => {
test('dominant axis decides; screen up = UP; inside the dead-zone nothing', () => {
const s = 80; const dead = s * DEAD_ZONE_TILES;
assert.equal(DEAD_ZONE_TILES, 0.5);
assert.equal(dirFromDelta(10, 5, dead), null, 'inside the centre');
assert.equal(dirFromDelta(60, 10, dead), 'RIGHT');
assert.equal(dirFromDelta(-60, 10, dead), 'LEFT');
assert.equal(dirFromDelta(5, -60, dead), 'UP');
assert.equal(dirFromDelta(5, 60, dead), 'DOWN');
assert.equal(dirFromDelta(50, -50, dead), 'RIGHT', 'ties go horizontal');
assert.equal(dirFromDelta(NaN, 0, dead), null);
assert.equal(dirFromKey('ArrowUp'), 'UP');
assert.equal(dirFromKey('KeyW'), null);
});
});
describe('which drops enter the direction step', () => {
test('units on board tiles (move / swap / own tile) do; bench slots, equipment and single-tile Arts do not; 画卷 does', () => {
const m = { ...piece(MELEE), row: 9, col: 3, dir: 'UP' };
const r = piece(RANGED);
const eq = item(EQUIP);
const scroll = item(SCROLL);
const customer = item(CUSTOMER);
const ctx = ctxFor(privWith({ board: [m], hand: [r, eq, scroll, customer] }));
const check = (u, t) => needsFacing(ctx, u, t, canPlace(ctx, u, t));
const free = ctx.deploy.ranged.values().next().value.split(',').map(Number);
assert.equal(check(r.uid, { area: 'board', row: free[0], col: free[1] }), true, 'hand → board');
assert.equal(check(m.uid, { area: 'board', row: 9, col: 3 }), true, 'own tile: re-orient in place');
assert.equal(canPlace(ctx, m.uid, { area: 'board', row: 9, col: 3 }).action, 'orient');
assert.equal(check(m.uid, { area: 'hand', idx: 5 }), false, 'withdraw to the bench');
assert.equal(check(eq.uid, { area: 'board', row: 9, col: 3 }), false, 'equip');
assert.equal(check(scroll.uid, { area: 'board', row: 10, col: 5 }), true, '画卷 picks its front tile with the wheel');
assert.equal(check(customer.uid, { area: 'board', row: 10, col: 5 }), false, 'a single-tile Art needs no direction');
assert.equal(artHasFacing(getItem(SCROLL)), true);
assert.equal(artHasFacing(getItem(CUSTOMER)), false);
assert.equal(needsFacing(ctx, r.uid, { area: 'board', row: 0, col: 0 }, { ok: false }), false, 'illegal never');
});
test('facingIntent: g.move {uid, to, dir} for units, g.art {itemUid, row, col, dir} for Arts', () => {
const r = piece(RANGED);
// `to.dir` too: Match.handle forwards only msg.to to PlayerState.move, which reads to.dir when dir is absent
assert.deepEqual(facingIntent(r, { row: 10, col: 4 }, 'up'), { t: 'g.move', fields: { uid: r.uid, to: { area: 'board', row: 10, col: 4, dir: 'UP' }, dir: 'UP' } });
const s = item(SCROLL);
assert.deepEqual(facingIntent(s, { row: 11, col: 6 }, 'LEFT'), { t: 'g.art', fields: { itemUid: s.uid, row: 11, col: 6, dir: 'LEFT' } });
});
test('previewGrid: the unit / summon / Art range grid (null without one)', () => {
assert.deepEqual(previewGrid({ getChess, getToken, getItem }, { kind: 'chess', id: RANGED }), chess[RANGED].rangeGrid);
assert.deepEqual(previewGrid({ getChess, getToken, getItem }, { kind: 'item', id: SCROLL }), [[0, 0], [0, 1]]);
assert.equal(previewGrid({ getChess, getToken, getItem }, { kind: 'item', id: EQUIP }), null);
assert.equal(previewGrid(null, { kind: 'chess', id: RANGED }), null);
});
});
describe('underframe (research 09 §5): 撤退 / 出售 +N / 销毁', () => {
test('actions per piece kind and place', () => {
const b = { ...piece(MELEE), row: 9, col: 3 };
const h = piece(RANGED);
const it = item(EQUIP);
const tokId = Object.keys(tokens)[0];
const bt = { uid: ++uid, kind: 'token', id: tokId, count: 1, ownerUid: b.uid, row: 10, col: 4 };
const ht = { uid: ++uid, kind: 'token', id: tokId, count: 1, ownerUid: b.uid };
const ctx = ctxFor(privWith({ board: [b, bt], hand: [h, it, ht] }));
const sell = chess[MELEE].sellPrice;
assert.deepEqual(underframeActions(ctx, b.uid), { retreat: true, sell, destroy: false }, 'board operator');
assert.deepEqual(underframeActions(ctx, h.uid), { retreat: false, sell: chess[RANGED].sellPrice, destroy: false }, 'bench operator: 出售 only');
assert.deepEqual(underframeActions(ctx, it.uid), { retreat: false, sell: null, destroy: true }, 'items: 销毁 only');
assert.deepEqual(underframeActions(ctx, bt.uid), { retreat: true, sell: null, destroy: false }, 'board summon: 撤退 only');
assert.equal(underframeActions(ctx, ht.uid), null, 'bench summon: nothing');
assert.equal(underframeActions(ctx, 999999), null);
});
test('retreatSlot: the first free bench slot; a full bench → null (summons always go back onto their stack)', () => {
const b = { ...piece(MELEE), row: 9, col: 3 };
const tok = { uid: ++uid, kind: 'token', id: Object.keys(tokens)[0], count: 1, ownerUid: b.uid, row: 10, col: 4 };
const hand = [];
for (let i = 0; i < 3; i++) hand.push({ idx: i, piece: piece(RANGED) });
assert.deepEqual(retreatSlot(ctxFor(privWith({ board: [b], hand })), b.uid), { area: 'hand', idx: 3 });
const full = [];
for (let i = 0; i < GEO.HAND_SIZE; i++) full.push({ idx: i, piece: piece(RANGED) });
assert.equal(retreatSlot(ctxFor(privWith({ board: [b], hand: full })), b.uid), null);
assert.deepEqual(retreatSlot(ctxFor(privWith({ board: [b, tok], hand: full })), tok.uid), { area: 'hand', idx: 0 });
const own = full.slice(0, GEO.HAND_SIZE - 1).concat([{ idx: GEO.HAND_SIZE - 1, piece: { uid: ++uid, kind: 'token', id: tok.id, count: 2, ownerUid: b.uid } }]);
assert.deepEqual(retreatSlot(ctxFor(privWith({ board: [b], hand: own })), b.uid), { area: 'hand', idx: GEO.HAND_SIZE - 1 }, 'its own summon stack frees a slot');
assert.equal(retreatSlot(ctxFor(privWith({ hand: full })), full[0].piece.uid), null, 'not on the board');
});
});
describe('render/drag.js: dropping a board piece on its own tile is a drop (re-orient)', () => {
test('resolveDrop: own board tile → drop; own bench slot still cancels', () => {
const b = { uid: 7, area: 'board', row: 10, col: 4 };
const d = resolveDrop({ piece: b, from: pieceSlot(b), tile: { row: 10, col: 4 }, overCanvas: true, clientX: 0, clientY: 0 });
assert.deepEqual(d, { kind: 'drop', target: { area: 'board', row: 10, col: 4 } });
const refused = resolveDrop({ piece: b, from: pieceSlot(b), tile: { row: 10, col: 4 }, overCanvas: true, canPlace: () => false });
assert.equal(refused.kind, 'cancel', 'canPlace still rules');
const h = { uid: 8, area: 'hand', idx: 3 };
assert.equal(resolveDrop({ piece: h, from: pieceSlot(h), tile: { row: GEO.HAND_ROW, col: 3 }, overCanvas: true }).reason, 'same');
});
test('controller: dragging a board unit away and back onto its tile emits pieceDrop on that tile, lit as legal', () => {
const events = [];
const pieces = [{ uid: 2, area: 'board', row: 10, col: 4, x: 450, y: 250 }];
const c = createDragController({
hitPiece: (x, y) => pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null,
pickTile: (x, y) => ({ row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }),
emit: (n, p) => events.push([n, p]),
setTimeout: () => 0, clearTimeout: () => {},
});
c.setEditable(true);
const ev = (t, x, y) => c[t]({ pointerId: 1, pointerType: 'mouse', button: 0, x, y, clientX: x, clientY: y });
ev('pointerDown', 450, 250);
ev('pointerMove', 470, 250);
ev('pointerMove', 560, 250);
ev('pointerMove', 452, 252);
const mv = events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.equal(mv.legal, true, 'its own tile is a legal target while dragging');
ev('pointerUp', 452, 252);
const drop = events.find((e) => e[0] === 'pieceDrop');
assert.ok(drop, 'pieceDrop emitted');
assert.deepEqual(drop[1].target, { area: 'board', row: 10, col: 4 });
});
});
describe('gameActions: g.move / g.art carry the chosen dir', () => {
test('payloads', async () => {
const { net } = await import('../../public/js/net.js');
const { actions } = await import('../../public/js/ui/gameActions.js');
const sent = [];
const orig = net.request;
net.request = async (t, f) => { sent.push([t, f]); return {}; };
try {
await actions.move(5, { area: 'board', row: 10, col: 4 }, 'UP');
await actions.move(5, { area: 'hand', idx: 2 });
await actions.art(9, 11, 6, 'LEFT');
await actions.art(9, 11, 6);
} finally { net.request = orig; }
assert.deepEqual(sent, [
['g.move', { uid: 5, to: { area: 'board', row: 10, col: 4 }, dir: 'UP' }],
['g.move', { uid: 5, to: { area: 'hand', idx: 2 } }],
['g.art', { itemUid: 9, row: 11, col: 6, dir: 'LEFT' }],
['g.art', { itemUid: 9, row: 11, col: 6 }],
]);
});
test('dropIntent of a same-tile orient is a plain g.move (the wheel adds dir)', () => {
const m = { ...piece(MELEE), row: 9, col: 3 };
const ctx = ctxFor(privWith({ board: [m] }));
assert.equal(dropIntent(ctx, m.uid, { area: 'board', row: 9, col: 3 }).t, 'g.move');
});
});
// ---- review regressions ------------------------------------------------------------------------------------------
describe('the wheel\'s intent reaches the stored facing through the real Match.handle', () => {
test('placement, in-place re-orient and the next round keep the chosen dir (no snap back to RIGHT)', async () => {
const { makeMatch, give, chessOfTier, legalTileFor } = await import('../match/harness.js');
const { PHASE } = await import('../../shared/constants.js');
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 14 }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
const id = chessOfTier(1, (c) => c.position === 'MELEE').find((x) => m.pool.has(x) && !chess[x].tokens?.length);
const a = give(m, ps, id, 'hand');
const [row, col] = legalTileFor(m, ps, id);
const send = (dir, r = row, c = col) => {
const it = facingIntent({ uid: a.uid, kind: 'chess', id }, { row: r, col: c }, dir);
return m.handle('p_0', { t: it.t, ...it.fields });
};
const stored = () => ps.privateView().board.find((p) => p.uid === a.uid)?.dir;
assert.deepEqual(send('UP'), { ok: true });
assert.equal(stored(), 'UP', 'placed facing UP');
assert.deepEqual(send('LEFT'), { ok: true });
assert.equal(stored(), 'LEFT', 're-oriented in place (own tile)');
assert.equal(ps.privateView().board.filter((p) => p.uid === a.uid).length, 1);
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === 2), 'next prep');
assert.equal(stored(), 'LEFT', 'kept across the round');
m.dispose();
});
});
describe('client range preview = sim range (every rangeGrid of chess / tokens / Arts, 4 dirs, rangeExtend 0..3)', () => {
test('rangeTiles matches server/sim/targeting.js absoluteRangeKeys tile for tile', async () => {
const { absoluteRangeKeys } = await import('../../server/sim/targeting.js');
const grids = new Map();
for (const src of [chess, tokens, items]) for (const rec of Object.values(src)) {
if (Array.isArray(rec?.rangeGrid) && rec.rangeGrid.length) grids.set(JSON.stringify(rec.rangeGrid), rec.rangeGrid);
}
assert.ok(grids.size >= 10, `distinct range shapes (${grids.size})`);
let n = 0;
for (const g of grids.values()) for (const dir of DIRS) for (const ext of [0, 1, 2, 3]) for (const [r, c] of [[9, 0], [10, 5], [12, 9], [11, 2]]) {
const a = rangeTiles(g, r, c, dir, { extend: ext }).map(([rr, cc]) => rr * GEO.COLS + cc).sort((x, y) => x - y);
const b = absoluteRangeKeys(g, r, c, dir, ext).slice().sort((x, y) => x - y);
assert.deepEqual(a, b, `${JSON.stringify(g)} ${dir} +${ext} @${r},${c}`);
n++;
}
assert.ok(n > 500);
});
});
describe('wheel stripes go under the units on the 3D board (review regression)', () => {
test('stripeMount: between the 3D canvas and the Pixi canvas only when the 3D board is on; stripePolys offsets', async () => {
const { stripeMount, stripePolys } = await import('../../public/js/ui/facingWheel.js');
const host = { insertBefore() {} };
const canvas = { parentElement: host, getBoundingClientRect: () => ({ left: 10, top: 20 }) };
const cam = { project: (x, y) => ({ x: x * 10, y: 100 - y * 10, s: 10 }) };
const view = (board3d) => ({ raw: { debug: { app: { view: canvas }, cam, board3d, tiles: { heightAt: () => 0 } } } });
assert.deepEqual(stripeMount(view({ on: true })), { host, before: canvas }, '3D board: under the Pixi canvas');
assert.equal(stripeMount(view(null)), null, '2D board (opaque Pixi canvas): overlay');
assert.equal(stripeMount({ raw: { tileScreen: () => null } }), null, 'DOM fallback view: overlay');
assert.equal(stripeMount(null), null);
const polys = stripePolys(view({ on: true }), [[9, 2]], 10, 20);
assert.equal(polys.length, 1);
// tile (9,2): corners (±0.5) projected, + canvas offset (10,20), − host offset (10,20)
assert.equal(polys[0].pts, '15.0,5.0 25.0,5.0 25.0,15.0 15.0,15.0');
});
});
describe('wheel on the mirrored Final Assault half (research 09 §1.2: RIGHT ↔ LEFT for the right-hand player)', () => {
test('boardDir maps screen directions to board directions and back; UP / DOWN unchanged', async () => {
const { boardDir, viewMirrored } = await import('../../public/js/ui/facing.js');
const { bossPrepField } = await import('../../public/js/render/prepfield.js');
for (const d of DIRS) {
assert.equal(boardDir(d, false), d);
assert.equal(boardDir(boardDir(d, true), true), d, 'its own inverse');
// the direction drawn for a board dir on the right half is the screen direction that picks it
assert.equal(bossPrepField('R').dirToDisp(boardDir(d, true)), d);
}
assert.equal(boardDir('RIGHT', true), 'LEFT');
assert.equal(boardDir('UP', true), 'UP');
assert.equal(boardDir(null, true), null);
assert.equal(boardDir('nope', true), null);
assert.equal(viewMirrored({ raw: { prepField: () => ({ kind: 'bossPrep', side: 'R', mirror: true }) } }), true);
assert.equal(viewMirrored({ raw: { prepField: () => ({ kind: 'board', side: 'L', mirror: false }) } }), false);
assert.equal(viewMirrored({ raw: {} }), false, 'views without the hook (DOM fallback)');
assert.equal(viewMirrored({ raw: { prepField: () => { throw new Error('x'); } } }), false);
assert.equal(viewMirrored(null), false);
});
});