Files
ark-stronghold-protocol/test/match/feedback5-unite-map.test.js
T
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

170 lines
10 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.
// 联防 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');
});