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)

This commit is contained in:
sganggs committed 2026-10-07 17:40:03 +08:00
1 parent ac6de8a12d
commit 75202776cc
25 files changed
+333 -281

No files matched your search

+105 -65
View File
@@ -1,16 +1,17 @@
// GitHub #41 (item 3): the 联防 field was the round's own stage — its water, crates and devices included — opened to both
// halves. Official: act2autochess constData escapedBattleTemplateMapSinglePlayer / MultiPlayer = level_act1autochess_
// escaped_single / _multi, the level of the 联防 battle and its map (two road halves joined at col 10). One helper →
// escaped_single (enemies enter at col 10), two helpers → escaped_multi (enemies enter at col 18 and pass (9,10)); the
// helpers' pieces stand on their prep tiles ("按休整期位置部署在场"), the first of two shifted 8 columns onto the right half
// ("率先迎敌(即位于右侧阵地)"). data/stages.json holds both maps (kind 'unite'); server/match/unite.js uniteStageId.
// 联防 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 { uniteStageId } from '../../server/match/unite.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, give, chessOfTier, legalTileFor, checkInvariants } from './harness.js';
@@ -19,18 +20,23 @@ 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 战场#01 (its row 9 is fenced 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 in its corner.
* 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 }) {
function scenario({ humans, clientCombat, stageId = 'act1autochess_m01', data = DATA, seed = 4101 + humans }) {
const h = makeMatch({
mode: 'coop', humans, seed: 4101 + humans, fake: true, clientCombat,
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('act1autochess_m01');
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++) {
@@ -52,78 +58,112 @@ function uniteField(m, clientCombat) {
return { opts: u.opts, battle: new NoFinish({ ...u.opts, data: m.ds, logger: { warn() {}, error() {}, info() {}, debug() {} } }) };
}
for (const clientCombat of [true, false]) {
test(`联防 with 1 helper (${clientCombat ? 'client-side combat' : 'server-run'}): escaped_single, not the round's stage — enemies enter at col 10 and walk row 9`, () => {
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_escaped_single');
assert.equal(uniteStageId(m.gd, 1), 'act1autochess_escaped_single');
assert.deepEqual(opts.rect, GEO.UNITE_RECT, 'the whole 19×21 map\'s field rows (both halves are road on it)');
assert.equal(b.stage.id, 'act1autochess_escaped_single');
assert.deepEqual(b.stage.devices, [], 'no crates or devices of 战场#01');
// the map: row 9 is road from the gate (9,10) to the objective (9,2); on 战场#01 cols 5–7 are fenced off
for (let c = 3; c <= 9; c++) assert.ok(b.grid.groundPassable(9, c), `(9,${c}) is road`);
assert.equal(m.stage.rows[9].slice(5, 8), '###', '战场#01 itself has no ground there');
for (const c of [19, 20]) assert.ok(!b.grid.groundPassable(9, c), `(9,${c}) is no ground`);
// the routes of escaped_single: every one starts at col 10
assert.ok(opts.routes.every((r) => (r.start ?? [r.startPosition?.row, r.startPosition?.col])[1] === 10));
// the helper's piece stands on its prep tile
const { ps, piece } = helpers[0];
const [r, c] = [...ps.board.entries()].find(([, p]) => p === piece)[0].split(',').map(Number);
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]);
// a walker crosses the tiles 战场#01 fences off
const crossed = new Set();
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') crossed.add(`${Math.round(e.y)},${Math.round(e.x)}`);
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) => crossed.has(k)), `a walker on row 9 cols 5–7 (${[...crossed].sort().join(' ')})`);
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('联防 with 2 helpers: escaped_multi — the first helper on the right half (col + 8), enemies enter at col 18 through (9,10); the client draws the field\'s map', () => {
const { h, m, helpers } = scenario({ humans: 3, clientCombat: false });
const order = m.unitePlan.helpers.map((p) => p.playerId);
assert.equal(order.length, 2);
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_escaped_multi');
assert.equal(b.stage.id, '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) => r.start[1] === 18), 'every route enters 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)');
b.step();
for (const { ps, piece } of helpers) {
const [r, c] = [...ps.board.entries()].find(([, p]) => p === piece)[0].split(',').map(Number);
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' : ''}`);
assert.equal(b.stage.rows[r][c + off], 'r', 'a road tile of the 联防 map');
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;
}
// what a watching browser receives: the m.field of the 联防 carries the map it is drawn on
m.handle('p_0', { t: 'g.watch', fieldId: 'u' });
const meta = h.lastTo('p_0', 'm.field');
assert.equal(meta && meta.stageId, 'act1autochess_escaped_multi');
assert.equal(m.stageId, 'act1autochess_m01', 'the match stage (m.public stageId, the boards) stays the round\'s');
checkInvariants(m);
assert.ok(burnt, 'a walker carries the 活性源石 effect');
assert.equal(b.errorCount || 0, 0);
m.dispose();
});
test('degraded data without the 联防 maps: the field keeps the round\'s stage', () => {
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 h = makeMatch({ mode: 'coop', humans: 2, seed: 4199, fake: true, data: { ...DATA, stages }, script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 2 } } : {}) }).start();
const m = h.m;
h.toPrep(1);
assert.equal(uniteStageId(m.gd, 1), null);
const ps = h.ps('p_1');
const id = chessOfTier(1, (c) => c.position === 'RANGED').find((x) => m.pool.has(x));
give(m, ps, id, 'board', legalTileFor(m, ps, id));
h.drive(() => m.phase === PHASE.UNITE);
assert.equal(FakeBattle.instances.find((b) => b.kind === 'unite').opts.stageId, m.stageId);
m.dispose();
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');
});
+2 -2
View File
@@ -225,7 +225,7 @@ describe('#7 audit: every stage × deploy field (server = official level data =
// buildableType (PRTS 深水区 地形信息 "地形机制:拒绝部署(待补充)" — player report #3 after 0.1.0, 战场#08's pool)
const ROLE = { trap_1105_accrate: 'block', trap_032_mound: 'block', trap_1106_achplat: 'platform', trap_040_canoe: 'water' };
for (const [id, st] of Object.entries(DATA.stages)) {
if (st.kind === 'unite') continue; // the 联防 maps: nobody deploys or prepares on them (no bench, no boss halves)
if (st.kind === 'unite') continue; // the escaped levels' maps: no field is fought on them (no bench, no boss halves)
const lv = JSON.parse(readFileSync(levelFile(id), 'utf8'));
const map = lv.mapData.map, tiles = lv.mapData.tiles, H = map.length;
const dev = new Map();
@@ -262,7 +262,7 @@ describe('#7 audit: every stage × deploy field (server = official level data =
test('carry-over: a tile legal on the normal board is legal (same class) on both boss halves of every active stage', () => {
for (const [id, st] of Object.entries(DATA.stages)) {
if (st.kind === 'unite') continue; // the 联防 maps have no boss field
if (st.kind === 'unite') continue; // the escaped levels' maps have no boss field
const n = buildDeployMap(st);
for (const field of ['bossL', 'bossR']) {
const b = buildDeployMap(st, { field });