170 lines
10 KiB
JavaScript
170 lines
10 KiB
JavaScript
// 联防 plays on the round's battlefield again (0.2.1, the owner's decision of 2026-10-07 after the report 「联防阶段地形全消失
|
||
// 了」: 「官服保留地形,改回去」). The 联防 field is the match stage opened to both halves (GEO.UNITE_RECT, cols 0–20) with its
|
||
// terrain, crates, water, devices and runes, as in 0.1.x; the escaped wave templates route the leaked enemies (one helper:
|
||
// escaped_single, entering at the middle gate (9,10); two helpers: escaped_multi, entering at col 18 through (9,10));
|
||
// every helper's pieces stand on their prep tiles, the first of two shifted 8 columns onto the right half ("率先迎敌(即位于右侧
|
||
// 阵地)"). 0.2.0 fought it on the escaped levels' own map, an empty road (GitHub #41 item 3, docs/history/0.2.0.md
|
||
// §25.6.4) — withdrawn; data/stages.json keeps those two records (kind 'unite'), which no field uses. Every test here
|
||
// fails on 0.2.0 (the field was act1autochess_escaped_single / _multi there).
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { GEO, PHASE } from '../../shared/constants.js';
|
||
import { Battle } from '../../server/sim/Battle.js';
|
||
import { createBattleFromSpec } from '../../server/sim/spec.js';
|
||
import { DataSource } from '../../server/sim/simdata.js';
|
||
import { FakeBattle } from './fakeBattle.js';
|
||
import { DATA, makeMatch, give, chessOfTier, legalTileFor, checkInvariants } from './harness.js';
|
||
|
||
/** A real battle that only ends by its time limit (the check follows the enemies, whatever the helpers do). */
|
||
class NoFinish extends Battle {
|
||
constructor(o) { super({ ...o, autoFinish: false }); }
|
||
}
|
||
|
||
const CRATE = 'trap_1105_accrate';
|
||
/** 战场#01's active 阻隔工事 on the 联防 rows: three on the left half (#001–#003), three on the right half (#101–#103). */
|
||
const M01_CRATES = [[10, 5], [10, 6], [10, 13], [10, 14], [11, 5], [11, 13]];
|
||
|
||
/**
|
||
* Co-op on `stageId` (战场#01's row 9 is walled off at cols 5–7: "##Err###rrSrr###rrS##"): p_0 leaks 3 enemies, the other
|
||
* players are perfect — 1 helper with 2 humans, 2 helpers with 3. Each helper fields one ranged operator on the first
|
||
* legal tile in reading order (战场#01: the 围墙 (12,3)).
|
||
*/
|
||
function scenario({ humans, clientCombat, stageId = 'act1autochess_m01', data = DATA, seed = 4101 + humans }) {
|
||
const h = makeMatch({
|
||
mode: 'coop', humans, seed, fake: true, clientCombat, data,
|
||
script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 3 } } : {}),
|
||
}).start();
|
||
const m = h.m;
|
||
h.toPrep(1);
|
||
h.setStage(stageId);
|
||
const ranged = chessOfTier(1, (c) => c.position === 'RANGED').filter((x) => m.pool.has(x));
|
||
const helpers = [];
|
||
for (let i = 1; i < humans; i++) {
|
||
const ps = h.ps(`p_${i}`);
|
||
const id = ranged[i];
|
||
helpers.push({ ps, piece: give(m, ps, id, 'board', legalTileFor(m, ps, id)) });
|
||
}
|
||
h.drive(() => m.phase === PHASE.UNITE);
|
||
return { h, m, helpers };
|
||
}
|
||
|
||
/** The 联防 field's spec / options as the match built them, and a real battle over them. */
|
||
function uniteField(m, clientCombat) {
|
||
if (clientCombat) {
|
||
const f = m.fields[0];
|
||
return { opts: f.spec, battle: createBattleFromSpec(f.spec, new DataSource(DATA, null), { BattleClass: NoFinish, recordEvents: false }) };
|
||
}
|
||
const u = FakeBattle.instances.find((b) => b.kind === 'unite');
|
||
return { opts: u.opts, battle: new NoFinish({ ...u.opts, data: m.ds, logger: { warn() {}, error() {}, info() {}, debug() {} } }) };
|
||
}
|
||
|
||
const tileOf = (ps, piece) => [...ps.board.entries()].find(([, p]) => p === piece)[0].split(',').map(Number);
|
||
const startOf = (r) => r.start ?? [r.startPosition?.row, r.startPosition?.col];
|
||
/** The crates standing on the field (device units, sorted by tile). */
|
||
const crates = (b) => b.allyUnits.filter((u) => u.kind === 'device' && u.defId === CRATE && u.alive).map((u) => [u.tileR, u.tileC])
|
||
.sort((a, b2) => a[0] - b2[0] || a[1] - b2[1]);
|
||
const MODES = [[true, 'client-side combat'], [false, 'server-run']];
|
||
|
||
for (const [clientCombat, label] of MODES) {
|
||
test(`联防 with 1 helper (${label}): the round's battlefield — 战场#01's walls and crates on both halves, the helper on its 围墙, walkers from the middle gate go round the walls`, () => {
|
||
const { m, helpers } = scenario({ humans: 2, clientCombat });
|
||
assert.deepEqual(m.unitePlan.helpers.map((p) => p.playerId), ['p_1']);
|
||
const { opts, battle: b } = uniteField(m, clientCombat);
|
||
assert.equal(opts.stageId, 'act1autochess_m01', 'the match stage (0.2.0: act1autochess_escaped_single)');
|
||
assert.equal(opts.stageId, m.stageId);
|
||
assert.deepEqual(opts.rect, GEO.UNITE_RECT, 'both halves of the stage');
|
||
assert.ok(opts.routes.every((r) => startOf(r)[1] === 10), 'escaped_single\'s routes enter at the middle gate (col 10)');
|
||
assert.equal(b.stage.id, 'act1autochess_m01');
|
||
assert.deepEqual(b.stage.rows, m.stage.rows, 'tile for tile the stage the boards stand on');
|
||
for (const c of [5, 6, 7]) assert.ok(!b.grid.groundPassable(9, c), `(9,${c}) is 战场#01's wall, no road`);
|
||
b.step();
|
||
assert.deepEqual(crates(b), M01_CRATES, 'the 阻隔工事 of both halves stand (0.2.0: none)');
|
||
// the helper's piece on its prep tile — the same 围墙 it stood on in prep
|
||
const { ps, piece } = helpers[0];
|
||
const [r, c] = tileOf(ps, piece);
|
||
assert.deepEqual([r, c], [12, 3]);
|
||
const u = b.allyUnits.find((x) => x.uid === piece.uid && x.ownerId === 'p_1');
|
||
assert.deepEqual([u.tileR, u.tileC], [r, c]);
|
||
assert.equal(b.grid.tile(r, c).key, 'tile_fence_bound', 'its tile is 战场#01\'s 围墙 (0.2.0: road)');
|
||
// walkers go round 战场#01's walls: never on row 9 cols 5–7, through the top row instead
|
||
const seen = new Set();
|
||
while (b.time < 60 && !b.finished) {
|
||
b.step();
|
||
for (const e of b.enemies) if (e.alive && e.motion !== 'FLY') seen.add(`${Math.round(e.y)},${Math.round(e.x)}`);
|
||
}
|
||
assert.ok(!['9,5', '9,6', '9,7'].some((k) => seen.has(k)), `no walker on the wall (${[...seen].sort().join(' ')})`);
|
||
assert.ok([4, 5, 6, 7].some((cc) => seen.has(`12,${cc}`)), `a walker on the top row (${[...seen].sort().join(' ')})`);
|
||
assert.equal(b.errorCount || 0, 0);
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
});
|
||
|
||
test(`联防 with 2 helpers (${label}): the round's battlefield — the first helper on the right half (+8 columns) on the same 围墙 as in prep, crates on both halves, every viewer gets the match stage`, () => {
|
||
const { h, m, helpers } = scenario({ humans: 3, clientCombat });
|
||
const order = m.unitePlan.helpers.map((p) => p.playerId);
|
||
assert.equal(order.length, 2);
|
||
const { opts, battle: b } = uniteField(m, clientCombat);
|
||
assert.equal(opts.stageId, 'act1autochess_m01', 'the match stage (0.2.0: act1autochess_escaped_multi)');
|
||
assert.deepEqual(opts.players.map((p) => [p.playerId, p.colOffset]), [[order[0], 8], [order[1], 0]]);
|
||
assert.ok(opts.routes.every((r) => startOf(r)[1] === 18), 'escaped_multi\'s routes enter at col 18');
|
||
assert.ok(opts.routes.filter((r) => r.motion === 'WALK').every((r) => r.checkpoints.some(([rr, cc]) => rr === 9 && cc === 10)), 'walkers pass (9,10)');
|
||
assert.deepEqual(b.stage.rows, m.stage.rows);
|
||
b.step();
|
||
assert.deepEqual(crates(b), M01_CRATES, 'the 阻隔工事 of both halves stand (0.2.0: none)');
|
||
for (const { ps, piece } of helpers) {
|
||
const [r, c] = tileOf(ps, piece);
|
||
const u = b.allyUnits.find((x) => x.uid === piece.uid && x.ownerId === ps.playerId);
|
||
const off = ps.playerId === order[0] ? 8 : 0;
|
||
assert.deepEqual([u.tileR, u.tileC], [r, c + off], `${ps.playerId}: its prep tile${off ? ' on the right half' : ''}`);
|
||
// the stage's right half is its left half + 8 columns: the shifted helper stands on the tile kind it prepared on
|
||
assert.equal(b.grid.tile(r, c + off).key, m.stage.tiles[m.stage.rows[r][c]].tileKey, `${ps.playerId}: the prep tile's kind`);
|
||
assert.equal(b.grid.tile(r, c + off).key, 'tile_fence_bound', `${ps.playerId}: 战场#01's 围墙 (0.2.0: road)`);
|
||
}
|
||
// what the other browsers are given: the 联防 field names the match stage, the one their boards are drawn on
|
||
if (clientCombat) {
|
||
const start = h.lastTo('p_0', 'b.start');
|
||
assert.equal(start && start.spec && start.spec.stageId, m.stageId, 'the leaker simulates the 联防 spec on the match stage');
|
||
} else {
|
||
m.handle('p_0', { t: 'g.watch', fieldId: 'u' });
|
||
const meta = h.lastTo('p_0', 'm.field');
|
||
assert.equal(meta && meta.stageId, m.stageId, 'a watcher\'s m.field names the match stage');
|
||
}
|
||
assert.equal(m.publicView().stageId, 'act1autochess_m01');
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
});
|
||
}
|
||
|
||
test('联防 on 战场#04: the round\'s special terrain is on the field — walkers pick up the 活性源石 on their way round the walls', () => {
|
||
const { m } = scenario({ humans: 2, clientCombat: false, stageId: 'act1autochess_m04' });
|
||
const { opts, battle: b } = uniteField(m, false);
|
||
assert.equal(opts.stageId, 'act1autochess_m04');
|
||
assert.ok(b.stage.special && b.stage.special.infection, 'the stage\'s 活性源石 parameters (0.2.0: none)');
|
||
const infection = [];
|
||
for (let r = GEO.UNITE_RECT.r0; r <= GEO.UNITE_RECT.r1; r++) for (let c = GEO.UNITE_RECT.c0; c <= GEO.UNITE_RECT.c1; c++) if (b.grid.tile(r, c).terrain === 'infection') infection.push([r, c]);
|
||
assert.deepEqual(infection, [[10, 6], [10, 14], [11, 6], [11, 14]], '战场#04\'s 活性源石 tiles on both halves');
|
||
let burnt = null;
|
||
while (b.time < 60 && !b.finished && !burnt) {
|
||
b.step();
|
||
burnt = b.enemies.find((e) => e.alive && e.findBuff && e.findBuff('terrain:infection')) || null;
|
||
}
|
||
assert.ok(burnt, 'a walker carries the 活性源石 effect');
|
||
assert.equal(b.errorCount || 0, 0);
|
||
m.dispose();
|
||
});
|
||
|
||
test('the 联防 field does not depend on the escaped levels\' map records: with or without them it is the same battlefield', () => {
|
||
const stages = Object.fromEntries(Object.entries(DATA.stages).filter(([, s]) => s.kind !== 'unite'));
|
||
const run = (data) => {
|
||
const { m } = scenario({ humans: 2, clientCombat: false, data, seed: 4199 });
|
||
const { opts } = uniteField(m, false);
|
||
const out = { stageId: opts.stageId, routes: opts.routes, offsets: opts.players.map((p) => [p.playerId, p.colOffset]) };
|
||
m.dispose();
|
||
return out;
|
||
};
|
||
const full = run(DATA);
|
||
const degraded = run({ ...DATA, stages });
|
||
assert.equal(full.stageId, 'act1autochess_m01');
|
||
assert.deepEqual(full, degraded, 'the same stage, routes and helper offsets');
|
||
});
|