卫戍协议:盟约 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

+323
View File
@@ -0,0 +1,323 @@
// 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);
});
});