Files
ark-stronghold-protocol/test/render/pen.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

170 lines
11 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.
// test/render/pen.test.js — the enemy preview pen layout (render/pen.js, research 09 §2.2 / 08 §4.2) and the Final
// Assault prep field transform (render/prepfield.js, research 09 §1.2).
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 { layoutPen, penZones, thinPreview, penEntries, penSignature, parsePenRect, MAX_PREVIEW, PER_TILE, inPen } from '../../public/js/render/pen.js';
import { bossPrepField, IDENTITY, mirrorDir, tilesToDisp } from '../../public/js/render/prepfield.js';
import { bossFieldPlacement } from '../../server/match/finalAssault.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const STAGES = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
const stageList = Object.values(STAGES.stages || STAGES);
const stage = (STAGES.stages || STAGES).act2autochess_m01;
const E = (enemyKey, count, gate, t, extra = {}) => ({ enemyKey, count, gate, t, fly: false, elite: false, boss: false, source: 'wave', ...extra });
describe('enemy preview pen: zones', () => {
test('every stage: two zones of 13 tiles (rows 14–15 / 17–18), anchors (15,7) and (18,7), row 16 never used', () => {
for (const st of stageList) {
const z = penZones(st);
assert.deepEqual(z.rect, { r0: 14, r1: 18, c0: 7, c1: 13 }, st.id);
assert.deepEqual(z.lower.anchor, [15, 7]);
assert.deepEqual(z.upper.anchor, [18, 7]);
assert.equal(z.lower.tiles.length, 13);
assert.equal(z.upper.tiles.length, 13);
for (const [r] of z.lower.tiles) assert.ok(r === 14 || r === 15);
for (const [r] of z.upper.tiles) assert.ok(r === 17 || r === 18);
// row-major, as the client lists them (research 08 §4.2 step 3 [DATA]: low row first, col 7 → 13)
const rowMajor = (rows) => rows.flatMap((r) => [7, 8, 9, 10, 11, 12, 13].map((c) => [r, c])).filter(([r, c]) => !(c === 7 && (r === 15 || r === 18)));
assert.deepEqual(z.lower.tiles, rowMajor([14, 15]), st.id);
assert.deepEqual(z.upper.tiles, rowMajor([17, 18]), st.id);
}
assert.deepEqual(penZones(null).lower.tiles, penZones(stage).lower.tiles, 'defaults = the official layout');
assert.deepEqual(parsePenRect('((14,7),(18,13))'), { r0: 14, r1: 18, c0: 7, c1: 13 });
assert.equal(parsePenRect('nope'), null);
});
});
describe('enemy preview pen: layout', () => {
test('upper-gate enemies stand in rows 17–18, lower-gate ones in rows 14–15; one figure per enemy; row 16 empty', () => {
const list = [E('enemy_a', 3, 'lower', 3), E('enemy_b', 2, 'upper', 5), E('enemy_c', 4, 'lower', 12, { fly: true })];
const pen = layoutPen(list, { stage });
assert.equal(pen.figures.length, 9);
assert.equal(pen.total, 9);
for (const f of pen.figures) {
assert.ok(inPen(f.row, f.col), `${f.row},${f.col}`);
assert.notEqual(f.row, 16);
if (f.enemyKey === 'enemy_b') assert.ok(f.row >= 17, 'upper gate'); else assert.ok(f.row <= 15, 'lower gate');
assert.ok(Math.abs(f.x - f.col) < 0.5 && Math.abs(f.y - f.row) < 0.5, 'stays on its tile');
}
assert.ok(pen.figures.filter((f) => f.enemyKey === 'enemy_c').every((f) => f.fly));
});
test('spawn-time order: earlier spawns stand nearer the gate (col 7)', () => {
const list = [E('late', 4, 'lower', 40), E('early', 4, 'lower', 2)];
const pen = layoutPen(list, { stage });
const meanCol = (k) => { const f = pen.figures.filter((x) => x.enemyKey === k); return f.reduce((s, x) => s + x.col, 0) / f.length; };
assert.ok(meanCol('early') < meanCol('late'), `${meanCol('early')} < ${meanCol('late')}`);
assert.equal(pen.figures[0].enemyKey, 'early');
// the client's index divisor is totalShowCnt = min(50, Σ count) (both zones): a small upper group of a big wave
// stays at the start of its row-major list — row 17 next to the (18,7) gate — instead of spreading over the zone
const small = layoutPen([E('n', 40, 'lower', 1), E('u', 5, 'upper', 2)], { stage });
for (const f of small.figures.filter((x) => x.enemyKey === 'u')) assert.ok(f.row === 17 && f.col <= 9, `${f.row},${f.col}`);
});
test('at most 3 figures per tile, several may share one; deterministic for the same preview', () => {
const list = [E('enemy_a', 30, 'lower', 1), E('enemy_b', 20, 'upper', 2)];
const a = layoutPen(list, { stage }), b = layoutPen(JSON.parse(JSON.stringify(list)), { stage });
assert.deepEqual(a, b);
const load = new Map();
for (const f of a.figures) load.set(`${f.row},${f.col}`, (load.get(`${f.row},${f.col}`) || 0) + 1);
assert.ok(Math.max(...load.values()) <= PER_TILE);
assert.ok([...load.values()].some((n) => n > 1), 'clusters');
// figures on a shared tile do not stand on the same spot
const pts = a.figures.map((f) => [f.x, f.y]);
for (let i = 0; i < pts.length; i++) for (let j = i + 1; j < pts.length; j++) assert.ok(Math.hypot(pts[i][0] - pts[j][0], pts[i][1] - pts[j][1]) > 0.15, 'no exact overlap');
});
test('cap 50: thinning keeps the ratio, elites and bosses are always shown in full', () => {
const list = [E('n1', 40, 'lower', 1), E('n2', 30, 'upper', 2), E('boss', 1, 'lower', 3, { boss: true, elite: true }), E('el', 4, 'upper', 9, { elite: true })];
const pen = layoutPen(list, { stage });
assert.equal(pen.total, 75);
assert.ok(pen.figures.length <= MAX_PREVIEW + 5);
assert.equal(pen.figures.filter((f) => f.enemyKey === 'boss').length, 1);
assert.equal(pen.figures.filter((f) => f.enemyKey === 'el').length, 4);
const n1 = pen.figures.filter((f) => f.enemyKey === 'n1').length, n2 = pen.figures.filter((f) => f.enemyKey === 'n2').length;
assert.ok(n1 + n2 <= MAX_PREVIEW - 5 + 1e-9, `${n1} + ${n2}`);
assert.ok(Math.abs(n1 / n2 - 40 / 30) < 0.35, 'ratio kept');
// an entry never disappears through rounding
const tiny = thinPreview(penEntries([E('a', 200, 'lower', 1), E('b', 1, 'lower', 2)]));
assert.ok(tiny.find((p) => p.entry.enemyKey === 'b'), 'a single-enemy action still shows');
// capacity overflow: a zone whose tiles all hold 3 stacks the rest on its own least-loaded tiles — never in the
// other gate's zone (research 08 §4.2 step 6 "keep the grouping exact: one zone per gate"), never in row 16
const big = layoutPen([E('x', 50, 'lower', 1)], { stage });
assert.equal(big.figures.length, 50);
for (const f of big.figures) assert.ok(f.row === 14 || f.row === 15, `lower gate only: ${f.row},${f.col}`);
const perTile = new Map();
for (const f of big.figures) perTile.set(`${f.row},${f.col}`, (perTile.get(`${f.row},${f.col}`) || 0) + 1);
assert.equal(perTile.size, 13, 'every lower tile used');
assert.ok(Math.max(...perTile.values()) <= 4, 'the overflow is spread evenly (≤ 4 per tile for 50 on 13 tiles)');
const pts = big.figures.map((f) => [f.x, f.y]);
for (let i = 0; i < pts.length; i++) for (let j = i + 1; j < pts.length; j++) assert.ok(Math.hypot(pts[i][0] - pts[j][0], pts[i][1] - pts[j][1]) > 0.15, 'no exact overlap in a 4-figure cluster');
const up = layoutPen([E('a', 10, 'lower', 1), E('u', 45, 'upper', 2)], { stage });
for (const f of up.figures) if (f.enemyKey === 'u') assert.ok(f.row >= 17, `upper-gate overflow stays in rows 17–18: ${f.row}`);
});
test('garbage in: ignored entries, empty list, signature follows the content', () => {
assert.deepEqual(layoutPen(null).figures, []);
assert.deepEqual(layoutPen([null, 1, { count: 3 }, { enemyKey: '' }]).figures, []);
assert.equal(penSignature([E('a', 2, 'lower', 1)]), penSignature([E('a', 2, 'lower', 1)]));
assert.notEqual(penSignature([E('a', 2, 'lower', 1)]), penSignature([E('a', 3, 'lower', 1)]));
assert.equal(layoutPen([{ enemyKey: 'a', count: 1e9 }]).figures.length, MAX_PREVIEW);
});
});
describe('Final Assault prep field (board ⇄ boss field)', () => {
test('matches the sim mapping (finalAssault.bossFieldPlacement) for every board / bench / temp tile, both sides', () => {
for (const side of ['L', 'R']) {
const xf = bossPrepField(side);
for (let r = 7; r <= 12; r++) for (let c = 0; c <= 10; c++) {
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
const want = bossFieldPlacement(side, r, c, dir);
const got = xf.toDisp(r, c);
assert.deepEqual([got.row, got.col, xf.dirToDisp(dir)], [want.row, want.col, want.dir], `${side} ${r},${c} ${dir}`);
assert.equal(xf.dirToBoard(xf.dirToDisp(dir)), dir);
}
const d = xf.toDisp(r, c);
assert.deepEqual(xf.toBoard(d.row, d.col), { row: r, col: c }, 'round trip');
}
}
});
test('board rows 9–12 land on boss rows 2–5; bench row 7 → 0, temp row 8 → 1; the right side is mirrored', () => {
const L = bossPrepField('L'), R = bossPrepField('R');
assert.deepEqual(L.toDisp(9, 2), { row: 2, col: 2 });
assert.deepEqual(L.toDisp(12, 8), { row: 5, col: 8 });
assert.deepEqual(R.toDisp(12, 8), { row: 5, col: 12 });
assert.deepEqual(R.toDisp(7, 0), { row: 0, col: 20 });
assert.deepEqual(R.toDisp(8, 4), { row: 1, col: 16 });
assert.equal(R.mirror, true);
assert.equal(L.mirror, false);
assert.equal(R.toBoard(3, 5), null, 'the left half is not the right-hand player’s');
assert.equal(L.toBoard(8, 4), null, 'rows beyond the boss field');
assert.equal(mirrorDir('RIGHT'), 'LEFT');
assert.equal(mirrorDir('up'), 'UP');
assert.deepEqual(tilesToDisp(R, [[9, 2], { row: 10, col: 3 }, null]), [[2, 18], [3, 17]]);
assert.deepEqual(IDENTITY.toDisp(9, 2), { row: 9, col: 2 });
});
test('range tiles past the field: a DOWN range below the bench never lands on the boss rows; an UP range past the top wall is dropped', () => {
for (const side of ['L', 'R']) {
const xf = bossPrepField(side);
const m = (c) => (side === 'R' ? 20 - c : c);
// a DOWN-facing sniper on board row 9: range rows 8, 7 (bench / temp: filtered by the view), 6 and 5 — on the
// boss field those are rows −1 / −2 (off the grid), not the boss rows 6 / 5 (the wall and the own top row)
assert.deepEqual(xf.toDisp(6, 4), { row: -1, col: m(4) });
assert.deepEqual(tilesToDisp(xf, [[9, 4], [6, 4], [5, 4]]), [[2, m(4)]], side);
// UP from board row 12: 13 (the wall above the field → boss wall row 6) is kept, 14 / 15 (the pen → the normal
// board's bench / temp rows 7 / 8 of the boss view) are not
assert.deepEqual(tilesToDisp(xf, [[12, 7], [13, 7], [14, 7], [15, 7]]), [[5, m(7)], [6, m(7)]], side);
// a range reaching into the partner's half stays (the boss field is shared in battle)
assert.deepEqual(tilesToDisp(xf, [[10, 12]]), [[3, m(12)]]);
}
assert.deepEqual(tilesToDisp(IDENTITY, [[6, 4], [14, 7]]), [[6, 4], [14, 7]], 'the own board keeps every tile');
});
});