卫戍协议:盟约 web remake — initial import

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).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+231
View File
@@ -0,0 +1,231 @@
// 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 / m04 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) over the floor of (10,9) / (11,9), and (9,10) → (10,7) over (10,9))
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) (9,8) (10,8) (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');
}
});