Community report: on 战场#04 活性源石 the lower-gate enemies walked two tiles left of the red gate and then straight up instead of crossing diagonally from row 9 into row 10 as in the official game. The playtest #2 road-over-floor smoothing counted the corner tiles of a diagonal step as crossed, so it bent the official (9,10) -> (10,7) into (9,10) -> (9,8) -> (10,8) although that line only brushes the corner of the floor tile (10,9). grid.js now keeps the official flow field (research 08 §3.1) and replaces a tile's pointer by the road-over-floor preference one only where its route crosses strictly fewer non-blockable tiles (or as few with a farther waypoint); a segment crosses the tiles whose interior it passes through (segmentTiles, exact geometry). 140 of the 154 stage x gate x field routes are now official (was 109); the 14 left avoid floor the official route walks over (战场#01's col-8 road of playtest #2, boss exits of 战场#01 / #02, two 联防 partner gates). data/stages.json groundPaths regenerated.
233 lines
13 KiB
JavaScript
233 lines
13 KiB
JavaScript
// Official ground pathing (research 08 §3): 4-direction SPFA flow field from the goal (UP, RIGHT, DOWN, LEFT), crates
|
||
// cost 1000, Bresenham line-of-sight smoothing; enemies walk tile centre to tile centre along next[tile].
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { Grid, OBSTACLE_COST, bresenhamTiles } from '../../server/sim/grid.js';
|
||
import { getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
|
||
import { makeBattle, flatStage, enemyRec } from '../helpers/battleHarness.js';
|
||
import { remainingDistance } from '../../server/sim/ai.js';
|
||
|
||
const REAL = { skip: !hasGeneratedData() && 'no generated data' };
|
||
const NORMAL = { r0: 9, r1: 12, c0: 0, c1: 10 };
|
||
const walker = (o = {}) => enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1, ...o });
|
||
const K = (r, c) => r * 21 + c;
|
||
|
||
/** Grid of a real stage with its match-start devices (crates cost 1000, platforms / mounds blocked). */
|
||
function stageGrid(id) {
|
||
const st = getDefaultSource().getStage(id);
|
||
const g = new Grid(st, NORMAL);
|
||
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);
|
||
}
|
||
return g;
|
||
}
|
||
const wp = (g, r, c) => g.waypoints(r, c, 9, 2).map((p) => `(${p})`).join(' ');
|
||
|
||
test('flow field: 4 directions only, strict-improvement SPFA with the official UP/RIGHT/DOWN/LEFT tie-break', () => {
|
||
const g = new Grid(flatStage(), NORMAL);
|
||
const f = g.flowField(9, 2);
|
||
assert.equal(f.dist[K(9, 2)], 0);
|
||
assert.equal(f.dist[K(9, 10)], 8, 'Manhattan distance along the open lane');
|
||
assert.equal(f.dist[K(12, 10)], 11, 'no diagonal edges: 3 + 8');
|
||
// every raw parent is a 4-neighbour one step closer
|
||
for (let k = 0; k < f.dist.length; k++) {
|
||
if (f.dist[k] <= 0) continue;
|
||
const p = f.parent[k];
|
||
assert.equal(Math.abs(((p / 21) | 0) - ((k / 21) | 0)) + Math.abs((p % 21) - (k % 21)), 1);
|
||
assert.equal(f.dist[p], f.dist[k] - 1);
|
||
}
|
||
// equal costs: a tile keeps the first parent that reached it — (10,4) is reached from (10,3) (RIGHT of it) before
|
||
// (9,4) (UP of it) is expanded, because (10,3) entered the queue first (UP from (9,3))
|
||
assert.equal(f.parent[K(10, 4)], K(10, 3));
|
||
// walls are not walkable: the high ground at col 2 of rows 10–12 has no distance
|
||
assert.equal(f.dist[K(11, 2)], -1);
|
||
});
|
||
|
||
test('smoothing: next[] jumps to the farthest ancestor in Bresenham line of sight; diagonal steps need both orthogonals', () => {
|
||
const g = new Grid(flatStage(), NORMAL);
|
||
assert.deepEqual(g.waypoints(9, 10, 9, 2), [[9, 10], [9, 2]], 'straight lane');
|
||
assert.deepEqual(g.waypoints(12, 10, 9, 2), [[12, 10], [9, 2]], 'open field: one straight line to the goal');
|
||
// the crossed tiles form an 8-connected chain that never cuts a corner
|
||
const p = g.findPath(12, 10, 9, 2);
|
||
assert.deepEqual(p, bresenhamTiles([12, 10], [9, 2]));
|
||
for (let i = 1; i < p.length; i++) {
|
||
const [r0, c0] = p[i - 1], [r1, c1] = p[i];
|
||
assert.ok(Math.abs(r1 - r0) <= 1 && Math.abs(c1 - c0) <= 1);
|
||
if (r1 !== r0 && c1 !== c0) assert.ok(g.walkable(r0, c1) && g.walkable(r1, c0));
|
||
}
|
||
// a wall stub at (10,6)…(12,6): the upper route bends around its end instead of cutting through
|
||
for (const r of [10, 11, 12]) g.setObstacle(r, 6, true);
|
||
const bent = g.waypoints(12, 10, 9, 2);
|
||
assert.ok(bent.length > 2);
|
||
for (const [r, c] of g.findPath(12, 10, 9, 2)) assert.ok(!(c === 6 && r >= 10), `crosses the wall at (${r},${c})`);
|
||
// allowDiagonal=false: line of sight only along a row / column
|
||
const fx = g.flowField(9, 2, { allowDiagonal: false });
|
||
let k = K(12, 10);
|
||
while (k !== fx.dest) {
|
||
const n = fx.next[k];
|
||
assert.ok(((n / 21) | 0) === ((k / 21) | 0) || n % 21 === k % 21, 'axis-aligned hop');
|
||
k = n;
|
||
}
|
||
});
|
||
|
||
test('crates: walkable at cost 1000 (blocks line of sight), blocks are impassable; each kind toggles its own bit', () => {
|
||
const g = new Grid(flatStage(), NORMAL);
|
||
const v0 = g.version;
|
||
g.setObstacle(9, 6, true, 'crate');
|
||
assert.ok(g.version > v0 && g.isObstacle(9, 6) && g.isCrate(9, 6) && !g.isBlocked(9, 6));
|
||
assert.equal(g.groundPassable(9, 6), false, 'not a placement / displacement tile');
|
||
assert.equal(g.walkable(9, 6), true, 'but walkable for the flow field');
|
||
let f = g.flowField(9, 2);
|
||
assert.equal(f.dist[K(9, 6)], 3 + OBSTACLE_COST, "(9,5) is 3 from the goal, entering the crate costs 1000");
|
||
assert.ok(!g.findPath(9, 10, 9, 2).some(([r, c]) => r === 9 && c === 6), 'detours around the crate');
|
||
// wall off every detour: the crate is the only way — the path goes through it (the enemy will break it)
|
||
for (const r of [10, 11, 12]) g.setObstacle(r, 6, true);
|
||
f = g.flowField(9, 2);
|
||
assert.ok(f.dist[K(9, 10)] > OBSTACLE_COST);
|
||
assert.ok(g.findPath(9, 10, 9, 2).some(([r, c]) => r === 9 && c === 6), 'through the crate');
|
||
// a crate turned into a platform on the same tile: both bits, order-independent
|
||
g.setObstacle(9, 6, true, 'block');
|
||
g.setObstacle(9, 6, false, 'crate');
|
||
assert.ok(g.isBlocked(9, 6) && !g.isCrate(9, 6));
|
||
assert.equal(g.findPath(9, 10, 9, 2), null, 'fully walled');
|
||
assert.ok(g.findPath(9, 10, 9, 2, { ignoreObstacles: true }));
|
||
});
|
||
|
||
test('flow fields are cached per destination and grid version', () => {
|
||
const g = new Grid(flatStage(), NORMAL);
|
||
const a = g.flowField(9, 2);
|
||
assert.equal(g.flowField(9, 2), a);
|
||
assert.notEqual(g.flowField(9, 3), a);
|
||
g.setObstacle(10, 5, true);
|
||
assert.notEqual(g.flowField(9, 2), a, 'rebuilt after an obstacle change');
|
||
g.setObstacle(10, 5, true); // no change → no version bump
|
||
const b = g.flowField(9, 2);
|
||
assert.equal(g.flowField(9, 2), b);
|
||
});
|
||
|
||
test('real stages: the official lanes of research 08 §3.2 (crates = non-hidden level predefines, platforms blocking) + blockable-ground preference', REAL, () => {
|
||
// m01 lower gate: equal-length choice between the col-9 floor lane and the col-8 road — the road (blockable) wins
|
||
// (official: (9,9) → (12,8) through the floor of (10,9)); m04 lower gate: the official diagonal (9,10) → (10,7) only
|
||
// brushes the corner of the floor (10,9) and stays (community report D5, test/sim/pathing-official.test.js)
|
||
const lanes = {
|
||
act1autochess_m01: ['(12,10) (12,4) (9,4) (9,2)', '(9,10) (9,8) (12,8) (12,4) (9,4) (9,2)'],
|
||
act1autochess_m02: ['(12,10) (12,3) (9,3) (9,2)', '(9,10) (9,9) (11,9) (11,6) (9,6) (9,2)'],
|
||
act1autochess_m03: ['(12,10) (12,6) (11,6) (9,5) (9,2)', '(9,10) (9,2)'], // 射击台 on (10,3)/(10,4) block [ASSUMED]
|
||
act1autochess_m04: ['(12,10) (12,9) (11,9) (11,4) (9,4) (9,2)', '(9,10) (10,7) (10,4) (9,4) (9,2)'],
|
||
act2autochess_m01: ['(12,10) (12,9) (9,9) (9,7) (12,7) (12,5) (9,5) (9,2)', '(9,10) (9,7) (12,7) (12,5) (9,5) (9,2)'],
|
||
act2autochess_m02: ['(12,10) (12,7) (9,7) (9,2)', '(9,10) (9,2)'],
|
||
act2autochess_m03: ['(12,10) (12,4) (9,4) (9,2)', '(9,10) (9,9) (10,9) (10,6) (9,6) (9,2)'],
|
||
act2autochess_m04: ['(12,10) (12,4) (9,4) (9,2)', '(9,10) (9,6) (11,6) (11,4) (9,4) (9,2)'],
|
||
};
|
||
for (const [id, [up, low]] of Object.entries(lanes)) {
|
||
const g = stageGrid(id);
|
||
assert.equal(wp(g, 12, 10), up, `${id} upper gate`);
|
||
assert.equal(wp(g, 9, 10), low, `${id} lower gate`);
|
||
}
|
||
});
|
||
|
||
/** Tiles an enemy of `route` visits (rounded position per tick) on a real stage. */
|
||
function walk(stageId, route) {
|
||
const h = makeBattle({ stageId, defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker', route }], content: 'none', autoFinish: false, timeLimit: 120 });
|
||
const seen = [];
|
||
h.b.on('tick', () => {
|
||
for (const e of h.b.enemies) {
|
||
const k = `${Math.round(e.y)},${Math.round(e.x)}`;
|
||
if (seen[seen.length - 1] !== k) seen.push(k);
|
||
}
|
||
});
|
||
h.runUntil(() => h.b.enemies.length === 0 && h.b.time > 1, 120);
|
||
return { seen, leaked: h.result().perPlayer.p1.leaked.length };
|
||
}
|
||
|
||
test('act1 m02 (下半, no crates): an upper-gate enemy keeps the top road to col 3 instead of dropping into the lower lane', REAL, () => {
|
||
const { seen, leaked } = walk('act1autochess_m02', { motion: 'WALK', start: [12, 10], end: [9, 2], checkpoints: [] });
|
||
assert.equal(leaked, 1);
|
||
const i3 = seen.indexOf('12,3');
|
||
assert.ok(i3 > 0, `reaches (12,3): ${seen.join(' ')}`);
|
||
for (const t of seen.slice(0, i3)) assert.equal(t.split(',')[0], '12', `stays on row 12 before col 3: ${seen.join(' ')}`);
|
||
assert.ok(!seen.includes('9,6') && !seen.includes('10,6'), 'never takes col 6 down to the lower lane');
|
||
// the battle spawned no crate on m02 at match start
|
||
const h = makeBattle({ stageId: 'act1autochess_m02', content: 'none', timeLimit: 1 });
|
||
h.step();
|
||
assert.equal(h.b.allyUnits.filter((u) => u.kind === 'device').length, 0);
|
||
});
|
||
|
||
test('act1 m04: an upper-gate enemy stays on row 11 down to col 4 (does not join the lower-gate lane at once)', REAL, () => {
|
||
const { seen } = walk('act1autochess_m04', { motion: 'WALK', start: [12, 10], end: [9, 2], checkpoints: [] });
|
||
const i4 = seen.indexOf('11,4');
|
||
assert.ok(i4 > 0, seen.join(' '));
|
||
for (const t of seen.slice(seen.indexOf('11,9'), i4 + 1)) assert.equal(t.split(',')[0], '11', `row 11: ${seen.join(' ')}`);
|
||
const low = walk('act1autochess_m04', { motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] }).seen;
|
||
assert.ok(low.includes('10,7') && low.includes('10,4') && !low.includes('11,6'), `lower gate on row 10: ${low.join(' ')}`);
|
||
});
|
||
|
||
test('crates in battle: detour when one exists; otherwise the enemy walks into the crate, is blocked and breaks it', () => {
|
||
// detour available: never enters the crate tile
|
||
const h = makeBattle({ flat: { crates: [[9, 6]] }, defs: { enemies: { enemy_walker: walker({ atk: 100 }) } }, enemies: [{ key: 'enemy_walker' }], content: 'none', autoFinish: false });
|
||
let touched = false;
|
||
h.b.on('tick', () => { for (const u of h.b.enemies) if (Math.round(u.y) === 9 && Math.round(u.x) === 6) touched = true; });
|
||
h.runUntil(() => h.b.enemies.length === 0 && h.b.time > 1, 40);
|
||
assert.equal(touched, false);
|
||
assert.equal(h.result().perPlayer.p1.leaked.length, 1);
|
||
// only way: through the crate
|
||
const rows = { 10: '##hrrr#rrrfrrrrrrrf##', 11: '##hrrr#rrrfrrrrrrrf##', 12: '##hrrr#rrrSrrrrrrrS##' };
|
||
const h2 = makeBattle({ flat: { rows, crates: [[9, 6]] }, defs: { enemies: { enemy_walker: walker({ atk: 500, bat: 1 }) } }, enemies: [{ key: 'enemy_walker' }], content: 'none', autoFinish: false });
|
||
h2.step();
|
||
const crate = h2.b.allyUnits.find((u) => u.kind === 'device');
|
||
assert.ok(crate && h2.b.grid.isCrate(9, 6));
|
||
const e = h2.enemy('enemy_walker');
|
||
assert.ok(h2.runUntil(() => e.blockedBy === crate, 20), 'blocked by the crate');
|
||
assert.ok(h2.runUntil(() => !crate.alive, 10), 'breaks it');
|
||
assert.equal(h2.b.grid.isObstacle(9, 6), false);
|
||
h2.runUntil(() => !e.alive, 30);
|
||
assert.equal(h2.result().perPlayer.p1.leaked.length, 1);
|
||
});
|
||
|
||
test('remaining distance follows the smoothed flow-field route (targeting "closest to the goal")', REAL, () => {
|
||
const route = { motion: 'WALK', start: [12, 10], end: [9, 2], checkpoints: [] };
|
||
const h = makeBattle({ defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker', route }], content: 'none', autoFinish: false });
|
||
h.run(2);
|
||
const e = h.enemy('enemy_walker');
|
||
approx(remainingDistance(h.b, e), Math.hypot(e.y - 9, e.x - 2), 'open field: one straight segment');
|
||
// act1 m02: along the top road — (12,3) then down col 3
|
||
const h2 = makeBattle({ stageId: 'act1autochess_m02', defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker', route }], content: 'none', autoFinish: false });
|
||
h2.run(2);
|
||
const e2 = h2.enemy('enemy_walker');
|
||
approx(remainingDistance(h2.b, e2), Math.hypot(e2.y - 12, e2.x - 3) + 3 + 1, 'to (12,3), down to (9,3), then (9,2)');
|
||
});
|
||
|
||
function approx(a, b, msg) { assert.ok(Math.abs(a - b) < 1e-6, `${msg}: ${a} vs ${b}`); }
|
||
|
||
test('a pushed ground enemy re-plans from its tile centre: never cuts a fence corner or breaks the stage crate on it (act1 m03, tile 11,5)', REAL, () => {
|
||
// Regression: after Battle.displace() the enemy steered straight from its off-centre position to next[tile], cut
|
||
// the corner of the FLY-only fence tile (11,5), got blocked by the decorative crate standing there and broke it.
|
||
const cases = [[12.57, { x: -1, y: 0.2 }], [11, { x: -1, y: -1 }], [13, { x: -1, y: 1 }]];
|
||
for (const [at, dir] of cases) {
|
||
const h = makeBattle({
|
||
stageId: 'act1autochess_m03', routes: [{ motion: 'WALK', start: [12, 10], end: [9, 2], checkpoints: [] }],
|
||
enemies: [{ key: 'enemy_1267_nhpbr', route: 0 }], units: [], timeLimit: 120,
|
||
});
|
||
const b = h.b;
|
||
h.run(at);
|
||
const e = b.enemies[0];
|
||
const crate = b.units.find((u) => u.defId === 'trap_1105_accrate' && u.tileR === 11 && u.tileC === 5);
|
||
assert.ok(e && crate && crate.alive, 'enemy walking, crate on (11,5)');
|
||
assert.ok(b.displace(e, dir, 0.5, { force: 3 }) > 0, 'pushed');
|
||
const ver = b.grid.version;
|
||
const off = [];
|
||
while (e.alive && !b.finished) {
|
||
b.step();
|
||
const r = Math.round(e.y), c = Math.round(e.x);
|
||
if (e.alive && !b.grid.walkable(r, c, true)) off.push(`${b.time.toFixed(2)}@${r},${c}`);
|
||
assert.notEqual(e.blockedBy, crate, `t=${at}: blocked by the fence crate`);
|
||
}
|
||
assert.deepEqual(off, [], `t=${at} push ${JSON.stringify(dir)}: stood on a tile it cannot walk`);
|
||
assert.ok(crate.alive, 'the stage crate survives');
|
||
assert.equal(b.grid.version, ver, 'no obstacle change');
|
||
assert.equal(h.result().perPlayer.p1.leaked.length, 1, 'still reaches the goal');
|
||
}
|
||
});
|