Files
sganggs 75202776cc feedback6 R21B: 联防 plays on the round's battlefield again — its terrain, crates, water, devices and runes, as in 0.1.4 (the owner's decision of 2026-10-07, 「官服保留地形,改回去」; GitHub #244). 0.2.0's escaped-level map (8a436249, §25.6.4) is withdrawn: that map is the placeholder grid all 39 wave template levels carry, tile for tile; only the 11 stage levels have terrain. match/unitePhase.js _uniteOpts fields this.stageId in both combat modes, unite.js uniteStageId is removed, the client draws the 联防 on the board's stage again (screens/game.js: 0.1.4's ownStage / baseStage, no map switch, nothing to restore on leaving), the dev mock likewise. Kept from 0.2.0: own-board 突袭 / 乌尔比安 S3 landings (onOwnBoard, raidTile untouched), the bounty payee, the 联防 BGM, the SETTLE result box. data/stages.json keeps the two kind 'unite' records (no field uses them; engine tests use them as a plain two-halves road); tools/build-data.mjs comments only, an offline rebuild reproduces data/ byte for byte. The 联防 battle input is byte-identical to v0.1.4's in 20 cases (5 stages × 1 / 2 helpers × both combat modes); GitHub #41 item 3 (野鬃 pushing walkers back across the gate onto the other half) is back with v0.1.4's numbers, none off the map. Tests: test/match/feedback5-unite-map.test.js rewritten (6, all fail on the parent), test/e2e/coop.e2e.mjs checks the 联防 field's drawn stage; docs: DATA §12, META §4, PLAYING §5, design/geometry-time, I18N, the §25.6.4 pointer; golden: fields unite-1 (escaped_single → act1autochess_m02) and unite-2 (escaped_multi → act1autochess_m07), the stages of 0.1.4, and the co-op matches whose 联防 rounds come out differently on the round's map — coop2-NORMAL-3, coop2-HARD-4, coop3-ABYSS-5, coop4-ABYSS-7, coop2-NORMAL-8-serverrun, coop2-NORMAL-14-standins (now a victory)
2026-10-07 17:40:03 +08:00

171 lines
11 KiB
JavaScript
Raw Permalink 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'));
// the battle stages (the escaped levels' two maps, kind 'unite', have no preview pen: nobody prepares on them)
const stageList = Object.values(STAGES.stages || STAGES).filter((s) => s.kind !== 'unite');
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');
});
});