Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
217 lines
10 KiB
JavaScript
217 lines
10 KiB
JavaScript
// Adversarial cross-check of grid.js against an independent port of the official client pathing (research 08 §3.4,
|
|
// `Torappu.Battle.SPFA`: FIFO SPFA from the destination over the WHOLE level map, UP/RIGHT/DOWN/LEFT, crates cost 1000,
|
|
// strict improvement; row-major in-place Bresenham smoothing with the diagonal corner rule), run on the raw level files
|
|
// in .cache/gamedata (skipped when absent), extended by our blockable-ground preference (grid.js header: equal-length
|
|
// ties go to the chain with the fewest non-blockable tiles; a smoothed line crosses non-blockable tiles only on its own
|
|
// raw chain):
|
|
// * the 8 active stages with their match-start devices: the smoothed chain from EVERY walkable tile of the normal,
|
|
// 联防 and boss rects to every goal equals the full-map reference one (the sim's rect limit changes nothing);
|
|
// * 400 random crate / block layouts: identical chains to the same algorithm limited to the rect;
|
|
// * against the PURE official algorithm: identical route lengths everywhere, never more non-blockable tiles crossed.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { Grid } from '../../server/sim/grid.js';
|
|
import { getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
|
|
import { GEO } from '../../shared/constants.js';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
const LEVELS = join(ROOT, '.cache', 'gamedata', 'levels', 'activities');
|
|
const STAGES = ['act1autochess_m01', 'act1autochess_m02', 'act1autochess_m03', 'act1autochess_m04', 'act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04'];
|
|
const file = (sid) => join(LEVELS, sid.slice(0, 13), `level_${sid}.json`);
|
|
const SKIP = { skip: (!hasGeneratedData() && 'no generated data') || (!STAGES.every((s) => existsSync(file(s))) && 'no .cache/gamedata levels') };
|
|
const FOUR = [[1, 0], [0, 1], [-1, 0], [0, -1]];
|
|
|
|
/**
|
|
* The official algorithm on a raw level (non-hidden crates cost 1000; platforms / mounds blocked like the sim [ASSUMED]).
|
|
* `prefer` adds the blockable-ground preference (lexicographic (dist, non-blockable count) relaxation; smoothing lines
|
|
* cross non-blockable tiles only on the tile's own raw chain between its ends). Returns dest → (start → chain string),
|
|
* with `.dist(start)` / `.covered(start)` (tiles of the smoothed route incl. diagonal-step corners) on each dest.
|
|
*/
|
|
function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rect = null, prefer = true } = {}) {
|
|
const lv = JSON.parse(readFileSync(file(sid), 'utf8'));
|
|
const md = lv.mapData;
|
|
const H = md.map.length, W = md.map[0].length;
|
|
const tile = (r, c) => md.tiles[md.map[H - 1 - r][c]];
|
|
const crates = new Set(), blocked = new Set();
|
|
for (const t of lv.predefines?.tokenInsts || []) {
|
|
if (t.hidden) continue;
|
|
const k = `${t.position.row},${t.position.col}`;
|
|
if (t.inst.characterKey === 'trap_1105_accrate') crates.add(k);
|
|
if (t.inst.characterKey === 'trap_1106_achplat' || t.inst.characterKey === 'trap_032_mound') blocked.add(k);
|
|
}
|
|
for (const [r, c] of extraCrates) crates.add(`${r},${c}`);
|
|
for (const [r, c] of extraBlocks) blocked.add(`${r},${c}`);
|
|
const inside = (r, c) => (rect ? r >= rect.r0 && r <= rect.r1 && c >= rect.c0 && c <= rect.c1 : r >= 0 && r < H && c >= 0 && c < W);
|
|
const passable = (r, c) => inside(r, c) && ['ALL', 'WALK_ONLY'].includes(tile(r, c).passableMask) && !blocked.has(`${r},${c}`);
|
|
// non-blockable walkable terrain: not LOWLAND buildable for melee (floor, gates, goal, teleports)
|
|
const nb = (r, c) => {
|
|
const t = tile(r, c);
|
|
return !(t.heightType === 'LOWLAND' && (t.buildableType === 'ALL' || t.buildableType === 'MELEE'));
|
|
};
|
|
const bres = ([r0, c0], [r1, c1], clear) => {
|
|
const dr = Math.abs(r1 - r0), dc = Math.abs(c1 - c0), sr = r1 > r0 ? 1 : -1, sc = c1 > c0 ? 1 : -1;
|
|
let err = dc - dr, r = r0, c = c0;
|
|
const out = [[r, c]];
|
|
while (r !== r1 || c !== c1) {
|
|
const e2 = 2 * err;
|
|
let nr = r, nc = c;
|
|
if (e2 > -dr) { err -= dr; nc += sc; }
|
|
if (e2 < dc) { err += dc; nr += sr; }
|
|
if (nr !== r && nc !== c) out.push([nr, c], [r, nc]);
|
|
r = nr; c = nc;
|
|
out.push([r, c]);
|
|
}
|
|
return clear ? out.every(([a, b]) => clear(a, b)) : out;
|
|
};
|
|
return (dest) => {
|
|
const D = dest.join();
|
|
const dist = new Map([[D, 0]]);
|
|
const pen = new Map([[D, 0]]);
|
|
const par = new Map();
|
|
const q = [dest];
|
|
const inq = new Set([D]);
|
|
while (q.length) {
|
|
const cur = q.shift();
|
|
const ck = cur.join();
|
|
inq.delete(ck);
|
|
for (const [dr, dc] of FOUR) {
|
|
const nb2 = [cur[0] + dr, cur[1] + dc];
|
|
const nk = nb2.join();
|
|
if (!passable(nb2[0], nb2[1])) continue;
|
|
const nd = dist.get(ck) + (crates.has(nk) ? 1000 : 1);
|
|
const np = pen.get(ck) + (nb(nb2[0], nb2[1]) ? 1 : 0);
|
|
const better = !dist.has(nk) || nd < dist.get(nk) || (prefer && nd === dist.get(nk) && np < pen.get(nk));
|
|
if (better) {
|
|
dist.set(nk, nd);
|
|
pen.set(nk, np);
|
|
par.set(nk, cur);
|
|
if (!inq.has(nk)) { q.push(nb2); inq.add(nk); }
|
|
}
|
|
}
|
|
}
|
|
const nxt = new Map(par);
|
|
for (let r = 0; r < H; r++) {
|
|
for (let c = 0; c < W; c++) {
|
|
const k = `${r},${c}`;
|
|
if (!nxt.has(k)) continue;
|
|
const chain = new Set();
|
|
for (let x = [r, c]; x; x = par.get(x.join())) chain.add(x.join());
|
|
const los = (a) => {
|
|
const lo = dist.get(a.join());
|
|
return bres([r, c], a, (y, x) => passable(y, x) && !crates.has(`${y},${x}`)
|
|
&& (!prefer || !nb(y, x) || (chain.has(`${y},${x}`) && dist.get(`${y},${x}`) >= lo)));
|
|
};
|
|
let b = nxt.get(k);
|
|
while (nxt.has(b.join()) && los(nxt.get(b.join()))) b = nxt.get(b.join());
|
|
nxt.set(k, b);
|
|
}
|
|
}
|
|
const chainOf = (start) => {
|
|
if (!dist.has(start.join())) return null;
|
|
const out = [start];
|
|
let k = start.join();
|
|
while (k !== D && out.length < 100) { const n = nxt.get(k); out.push(n); k = n.join(); }
|
|
return out;
|
|
};
|
|
const fn = (start) => { const ch = chainOf(start); return ch ? ch.map((p) => `(${p})`).join(' ') : null; };
|
|
fn.dist = (start) => dist.get(start.join()) ?? -1;
|
|
fn.coveredNb = (start) => {
|
|
const ch = chainOf(start);
|
|
if (!ch) return null;
|
|
const s = new Set();
|
|
for (let i = 1; i < ch.length; i++) for (const [y, x] of bres(ch[i - 1], ch[i])) if (nb(y, x)) s.add(`${y},${x}`);
|
|
if (nb(...ch[0])) s.add(ch[0].join());
|
|
return s;
|
|
};
|
|
return fn;
|
|
};
|
|
}
|
|
|
|
function simGrid(sid, rect, extra = {}) {
|
|
const st = getDefaultSource().getStage(sid);
|
|
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);
|
|
}
|
|
for (const [r, c] of extra.crates || []) g.setObstacle(r, c, true, 'crate');
|
|
for (const [r, c] of extra.blocks || []) g.setObstacle(r, c, true);
|
|
return g;
|
|
}
|
|
|
|
function compare(sid, rect, dests, extra = {}, officialRect = null) {
|
|
const off = official(sid, { ...extra, rect: officialRect, prefer: true });
|
|
const g = simGrid(sid, rect, extra);
|
|
let n = 0;
|
|
for (const dest of dests) {
|
|
const chainOf = off(dest);
|
|
for (let r = rect.r0; r <= rect.r1; r++) {
|
|
for (let c = rect.c0; c <= rect.c1; c++) {
|
|
if (!g.walkable(r, c)) continue;
|
|
const wp = g.waypoints(r, c, dest[0], dest[1]);
|
|
assert.equal(wp ? wp.map((p) => `(${p})`).join(' ') : null, chainOf([r, c]), `${sid} (${r},${c}) → (${dest}) ${JSON.stringify(extra)}`);
|
|
n++;
|
|
}
|
|
}
|
|
}
|
|
return n;
|
|
}
|
|
|
|
test('every walkable tile of the normal / 联防 / boss fields of the 8 stages follows the full-map reference chain', SKIP, () => {
|
|
let n = 0;
|
|
for (const sid of STAGES) {
|
|
n += compare(sid, GEO.NORMAL_RECT, [[9, 2]]);
|
|
n += compare(sid, GEO.UNITE_RECT, [[9, 2]]);
|
|
n += compare(sid, GEO.BOSS_RECT, [[1, 3], [1, 17], [2, 2], [2, 18]]);
|
|
}
|
|
assert.ok(n > 1500, `${n} chains`);
|
|
});
|
|
|
|
test('random crate / block layouts: identical flow fields and smoothing to the reference algorithm (same rect)', SKIP, () => {
|
|
let seed = 7;
|
|
const rnd = () => ((seed = (Math.imul(seed, 1103515245) + 12345) >>> 0) / 4294967296);
|
|
let n = 0;
|
|
for (let t = 0; t < 400; t++) {
|
|
const sid = STAGES[t % STAGES.length];
|
|
const crates = [], blocks = [];
|
|
for (let i = 0; i < 6; i++) crates.push([9 + Math.floor(rnd() * 4), Math.floor(rnd() * 11)]);
|
|
for (let i = 0; i < 2; i++) blocks.push([9 + Math.floor(rnd() * 4), 3 + Math.floor(rnd() * 7)]);
|
|
n += compare(sid, GEO.NORMAL_RECT, [[9, 2]], { crates, blocks }, GEO.NORMAL_RECT);
|
|
}
|
|
assert.ok(n > 5000, `${n} chains`);
|
|
});
|
|
|
|
test('vs the PURE official algorithm: same route length from every tile, never more non-blockable tiles crossed', SKIP, () => {
|
|
let n = 0, fewer = 0;
|
|
const fields = [[GEO.NORMAL_RECT, [[9, 2]]], [GEO.UNITE_RECT, [[9, 2]]], [GEO.BOSS_RECT, [[1, 3], [1, 17], [2, 2], [2, 18]]]];
|
|
for (const sid of STAGES) {
|
|
const pure = official(sid, { prefer: false });
|
|
const ours = official(sid, { prefer: true });
|
|
for (const [rect, dests] of fields) {
|
|
const g = simGrid(sid, rect);
|
|
for (const dest of dests) {
|
|
const p = pure(dest), o = ours(dest);
|
|
const f = g.flowField(dest[0], dest[1]);
|
|
for (let r = rect.r0; r <= rect.r1; r++) {
|
|
for (let c = rect.c0; c <= rect.c1; c++) {
|
|
if (!g.walkable(r, c) || p.dist([r, c]) < 0) continue;
|
|
assert.equal(f.dist[r * 21 + c], p.dist([r, c]), `${sid} (${r},${c}) → (${dest}): official length`);
|
|
const a = p.coveredNb([r, c]).size, b = o.coveredNb([r, c]).size;
|
|
assert.ok(b <= a, `${sid} (${r},${c}) → (${dest}): ${b} non-blockable tiles vs official ${a}`);
|
|
if (b < a) fewer++;
|
|
n++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert.ok(n > 1500 && fewer > 0, `${n} chains, ${fewer} with fewer non-blockable tiles`);
|
|
// the user's report: 战场#01 lower gate — official climbs the col-9 floor lane, ours keeps to the col-8 road
|
|
assert.equal(official('act1autochess_m01', { prefer: false })([9, 2])([9, 10]), '(9,10) (9,9) (12,8) (12,4) (9,4) (9,2)');
|
|
assert.equal(official('act1autochess_m01', { prefer: true })([9, 2])([9, 10]), '(9,10) (9,8) (12,8) (12,4) (9,4) (9,2)');
|
|
});
|