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).
170 lines
11 KiB
JavaScript
170 lines
11 KiB
JavaScript
// 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');
|
||
});
|
||
});
|