Files
ark-stronghold-protocol/test/sim/pathing-official.test.js
T
sganggs 7a3918eab9 feedback1d WO: the preference route is 0.1.0's again; equal floor keeps the official route
Review follow-up to the D5 fix. Two changes in grid.js _buildField:

- The road-over-floor preference smoothing is 0.1.0's again: its line of sight
  may cover non-blockable tiles (Bresenham footprint, diagonal-step corners
  included) only on the tile's own raw chain, and the jump stops at the first
  ancestor it cannot see so. Exact crossings (a corner touch is not a crossing)
  are used only to choose between the official and the preference pointer.
  The previous version let the preference brush the corner of off-chain floor,
  so on 战场#02's Final Assault the right-half exit route (2,10) -> (3,11)
  slipped past an operator on (2,11) while the left half was still blocked on
  (2,9). Both halves now mirror each other again ((2,10) -> (2,11) -> (4,11)).
- On an equal floor count the official pointer stays; the preference pointer
  wins only when it merely skips a collinear official waypoint (same walk).
  The old "farther waypoint" tie-break could bend an official route without
  saving any floor, e.g. a crate on (10,6) of 战场#04 brought back the L via
  (9,8) for the lower gate.

Still 140 of 154 stage routes official; data/stages.json groundPaths
regenerated (战场#02 boss right exit back to 0.1.0's, device-less helper paths
of #03 / #04 / #06 follow the same rule). New tests: 战场#02 boss exits mirror
and are blocked beside the exit; a single crate on any tile of an active stage
never leaves a gate route non-official without crossing strictly less floor.
The cross-check reference port follows both rules.
2026-10-03 04:57:30 +08:00

183 lines
11 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.
// Official route shapes (community report after 0.1.0, D5: on 战场#04 活性源石 the lower-gate enemies walked two tiles
// straight left of the red gate and then straight up, instead of crossing diagonally from the 4th row to the 3rd as in
// the official game). grid.js keeps the official flow field (research 08 §3.1) unless the blockable-ground preference
// route of user playtest #2 (0.1.0's) crosses strictly fewer non-blockable tiles (floor, gates) — a segment that only
// brushes a floor tile's corner does not cross it. Audit: every stage (active or not) × gate × field against the pure
// official algorithm; the routes that still differ are listed, each avoiding floor the official route walks over.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Grid, segmentTiles } from '../../server/sim/grid.js';
import { getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
import { GEO } from '../../shared/constants.js';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
const REAL = { skip: !hasGeneratedData() && 'no generated data' };
const COLS = 21;
const M04 = 'act1autochess_m04';
const RECT = { normal: GEO.NORMAL_RECT, unite: GEO.UNITE_RECT, boss: GEO.BOSS_RECT };
/** Grid of a real stage with its match-start devices; `pure` = the official algorithm (every tile counted blockable). */
function stageGrid(id, rect, pure = false) {
const st = getDefaultSource().getStage(id);
if (!st) return null;
const g = new Grid(st, rect);
for (const d of st.raw.devices) {
if (!d.active || !d.pos) continue;
if (d.role === 'crate') g.setObstacle(d.pos[0], d.pos[1], true, 'crate');
if (d.role === 'platform' || d.role === 'mound') g.setObstacle(d.pos[0], d.pos[1], true);
}
if (pure) g.unblockable.fill(0);
return g;
}
const str = (wp) => (wp ? wp.map((p) => p.join(',')).join(' ') : 'null');
test('D5 战场#04 活性源石: the lower-gate route is the official diagonal from row 9 into row 10 (normal and 联防 fields)', REAL, () => {
const official = [[9, 10], [10, 7], [10, 4], [9, 4], [9, 2]];
assert.deepEqual(stageGrid(M04, GEO.NORMAL_RECT, true).waypoints(9, 10, 9, 2), official, 'the pure official algorithm');
assert.deepEqual(stageGrid(M04, GEO.NORMAL_RECT).waypoints(9, 10, 9, 2), official, 'normal field');
assert.deepEqual(stageGrid(M04, GEO.UNITE_RECT).waypoints(9, 10, 9, 2), official, '联防 field, own lower gate');
// the segment (9,10) → (10,7) only brushes the corner of the floor tile (10,9): it crosses no non-blockable tile
assert.deepEqual(segmentTiles([9, 10], [10, 7]), [[9, 10], [9, 9], [10, 8], [10, 7]]);
});
test('D5 real battle (act1 m04, round-1 template act1autochess_01): the 源石虫 leave the gate diagonally, never along row 9 to col 8', REAL, () => {
const h = makeBattle({ stageId: M04, waveTemplate: 'act1autochess_01', units: [], autoFinish: false });
let id = null, defId = null;
const tiles = [];
let atCol9 = null;
h.b.on('tick', () => {
const e = h.b.enemies.find((u) => (id == null ? u.motion === 'WALK' : u.id === id));
if (!e) return;
id = e.id;
defId = e.defId;
const k = `${Math.round(e.y)},${Math.round(e.x)}`;
if (tiles[tiles.length - 1] !== k) tiles.push(k);
if (atCol9 == null && e.x <= 9) atCol9 = e.y;
});
h.run(30);
assert.equal(defId, 'enemy_1007_slime', 'the round-1 源石虫 of the lower gate');
assert.deepEqual(tiles.slice(0, 5), ['9,10', '9,9', '10,8', '10,7', '10,6'], `tiles: ${tiles.join(' ')}`);
assert.ok(!tiles.includes('9,8'), `never on (9,8): ${tiles.join(' ')}`);
// on the official segment (9,10) → (10,7) the walker is a third of a row up when it passes the col-9 centre line
assert.ok(Math.abs(atCol9 - (9 + 1 / 3)) < 0.02, `row ${atCol9} at col 9 (straight along row 9 would be 9.00)`);
});
// Routes that differ from the official one (ours, as waypoints): each crosses fewer non-blockable tiles — user playtest
// #2's 战场#01 col-9 floor lane (its 联防 partner routes pass the same exit), the boss exits (2,10) of 战场#01 / #02
// that would cut across the arena's central floor, 战场#02's 联防 partner lower gate (floor (12,17)) and the inactive
// 战场#07 (上半)'s 联防 partner upper gate (row 12 through (12,9) vs row 9). Every other route of every stage is official.
const DEVIATIONS = {
'act1autochess_m01 normal 9,10>9,2': '9,10 9,8 12,8 12,4 9,4 9,2',
'act1autochess_m01 unite 9,10>9,2': '9,10 9,8 12,8 12,4 9,4 9,2',
'act1autochess_m01 unite 9,18>9,2': '9,18 9,16 12,16 12,12 9,12 9,8 12,8 12,4 9,4 9,2',
'act1autochess_m01 unite 12,18>9,2': '12,18 12,12 9,12 9,8 12,8 12,4 9,4 9,2',
'act1autochess_m01 boss 2,10>1,3': '2,10 2,8 5,8 5,4 2,4 2,3 1,3',
'act1autochess_m01 boss 2,10>2,2': '2,10 2,8 5,8 5,4 2,4 2,2',
'act1autochess_m01 boss 2,10>1,17': '2,10 2,12 5,12 5,16 2,16 2,17 1,17',
'act1autochess_m01 boss 2,10>2,18': '2,10 2,12 5,12 5,16 2,16 2,18',
'act1autochess_m02 unite 9,18>9,2': '9,18 9,17 11,17 12,12 12,3 9,3 9,2',
'act1autochess_m02 boss 2,10>1,3': '2,10 2,9 4,9 4,6 2,6 2,3 1,3',
'act1autochess_m02 boss 2,10>2,2': '2,10 2,9 4,9 4,6 2,6 2,2',
'act1autochess_m02 boss 2,10>1,17': '2,10 2,11 4,11 4,14 2,14 2,17 1,17',
'act1autochess_m02 boss 2,10>2,18': '2,10 2,11 4,11 4,14 2,14 2,18',
'act1autochess_m07 unite 12,18>9,2': '12,18 12,11 9,11 9,2',
};
test('audit: every route of every stage (11, active or not) × gate × field is the official one, except the listed floor-avoiding ones', REAL, () => {
const stages = ['act1autochess_m01', 'act1autochess_m02', 'act1autochess_m03', 'act1autochess_m04', 'act1autochess_m05', 'act1autochess_m06', 'act1autochess_m07',
'act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04'];
const legs = [
['normal', [9, 10], [9, 2]], ['normal', [12, 10], [9, 2]],
['unite', [9, 10], [9, 2]], ['unite', [12, 10], [9, 2]], ['unite', [9, 18], [9, 2]], ['unite', [12, 18], [9, 2]],
...[[5, 10], [2, 10]].flatMap((s) => [[1, 3], [2, 2], [1, 17], [2, 18]].map((e) => ['boss', s, e])),
];
const floor = (g, wp) => {
const out = [];
for (let i = 1; i < wp.length; i++) for (const [r, c] of segmentTiles(wp[i - 1], wp[i]).slice(1)) if (g.unblockable[r * COLS + c]) out.push(`${r},${c}`);
return out;
};
const seen = new Set();
let official = 0;
for (const sid of stages) {
for (const [kind, s, e] of legs) {
const g = stageGrid(sid, RECT[kind]), p = stageGrid(sid, RECT[kind], true);
assert.ok(g, `${sid} in data/stages.json`);
const ours = g.waypoints(s[0], s[1], e[0], e[1]), off = p.waypoints(s[0], s[1], e[0], e[1]);
const key = `${sid} ${kind} ${s}>${e}`;
assert.ok(ours && off, `${key} reachable`);
assert.equal(g.flowField(e[0], e[1]).dist[s[0] * COLS + s[1]], p.flowField(e[0], e[1]).dist[s[0] * COLS + s[1]], `${key}: official length`);
if (str(ours) === str(off)) { assert.ok(!(key in DEVIATIONS), `${key} is official now: drop it from DEVIATIONS`); official++; continue; }
seen.add(key);
assert.equal(str(ours), DEVIATIONS[key], `${key} differs from the official ${str(off)}`);
const fo = floor(g, ours), ff = floor(g, off);
assert.ok(fo.length < ff.length, `${key}: crosses ${fo.join(' ')}, the official route ${ff.join(' ')}`);
}
}
assert.equal(seen.size, Object.keys(DEVIATIONS).length, 'every listed deviation still exists');
assert.equal(official, stages.length * legs.length - seen.size);
});
test('D5: an operator on (9,9), (9,8) or (10,8) blocks the lower-gate enemy on the official diagonal', REAL, () => {
const guard = chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 0, blockCnt: 3, maxHp: 1e6 }, skill: null });
const walker = enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1 });
const route = { motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] };
for (const [r, c] of [[9, 9], [9, 8], [10, 8]]) {
const h = makeBattle({ stageId: M04, defs: { chess: { t_guard: guard }, enemies: { enemy_walker: walker } },
units: [{ chessId: 't_guard', row: r, col: c }], enemies: [{ key: 'enemy_walker', route }], content: 'none', autoFinish: false, timeLimit: 60 });
const u = h.unit('t_guard');
assert.ok(h.runUntil(() => h.enemies()[0]?.blockedBy === u, 30), `blocked by the operator on (${r},${c})`);
}
});
test('战场#02 Final Assault: the (2,10) exit routes mirror each other and an operator beside the exit on (2,9) / (2,11) blocks them', REAL, () => {
// the preference route is 0.1.0's (a diagonal step's corner tiles count in its line of sight); only the choice between
// it and the official route counts exact crossings — otherwise the right half slipped diagonally past (2,11)
const g = stageGrid('act1autochess_m02', GEO.BOSS_RECT);
const mirror = (wp) => wp.map(([r, c]) => [r, 20 - c]);
assert.deepEqual(g.waypoints(2, 10, 1, 17), mirror(g.waypoints(2, 10, 1, 3)));
assert.deepEqual(g.waypoints(2, 10, 2, 18), mirror(g.waypoints(2, 10, 2, 2)));
const guard = chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 0, blockCnt: 3, maxHp: 1e6 }, skill: null });
const walker = enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1 });
for (const [tile, end] of [[[2, 9], [1, 3]], [[2, 9], [2, 2]], [[2, 11], [1, 17]], [[2, 11], [2, 18]]]) {
const h = makeBattle({ stageId: 'act1autochess_m02', kind: 'boss', defs: { chess: { t_guard: guard }, enemies: { enemy_walker: walker } },
units: [{ chessId: 't_guard', row: tile[0], col: tile[1] }], enemies: [{ key: 'enemy_walker', route: { motion: 'WALK', start: [2, 10], end, checkpoints: [] } }],
content: 'none', autoFinish: false, timeLimit: 60 });
const u = h.unit('t_guard');
assert.ok(h.runUntil(() => h.enemies()[0]?.blockedBy === u, 20), `the enemy (2,10) → (${end}) is blocked on (${tile})`);
assert.ok(u.deployed && u.tileR === tile[0] && u.tileC === tile[1], `operator on (${tile})`);
}
});
test('equal floor never bends the official route: one crate on any tile of an active stage leaves every gate route official or crossing strictly fewer non-blockable tiles', REAL, () => {
const stages = ['act1autochess_m01', 'act1autochess_m02', 'act1autochess_m03', 'act1autochess_m04', 'act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04'];
const floor = (g, wp) => {
let n = 0;
for (let i = 1; i < wp.length; i++) for (const [r, c] of segmentTiles(wp[i - 1], wp[i]).slice(1)) n += g.unblockable[r * COLS + c];
return n;
};
const R = GEO.NORMAL_RECT;
let n = 0;
for (const sid of stages) {
for (let r = R.r0; r <= R.r1; r++) {
for (let c = R.c0; c <= R.c1; c++) {
const g = stageGrid(sid, R), p = stageGrid(sid, R, true);
if (!g.walkable(r, c) || g.obstacle[r * COLS + c]) continue;
g.setObstacle(r, c, true, 'crate');
p.setObstacle(r, c, true, 'crate');
for (const s of [[9, 10], [12, 10]]) {
const ours = g.waypoints(s[0], s[1], 9, 2), off = p.waypoints(s[0], s[1], 9, 2);
if (!ours || str(ours) === str(off)) continue;
n++;
assert.ok(floor(g, ours) < floor(g, off), `${sid} crate (${r},${c}) from (${s}): ${str(ours)} vs the official ${str(off)}`);
}
}
}
}
assert.ok(n > 0, 'some layouts keep a floor-avoiding deviation');
// the case that used to slip through: a crate on (10,6) of 战场#04 — official (9,10) → (9,9) → (11,7), as much floor as
// the L via (9,8), so the official one stays
const g = stageGrid(M04, R);
g.setObstacle(10, 6, true, 'crate');
assert.equal(str(g.waypoints(9, 10, 9, 2)), '9,10 9,9 11,7 11,4 9,4 9,2');
});