卫戍协议:盟约 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:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -0,0 +1,99 @@
|
||||
// test/render/recordings.test.js — the recorded battles (tools/record-battle.mjs → public/dev/recordings) follow
|
||||
// the wire format of DESIGN §8.2 exactly, and replay cleanly through the interpolation buffer at real speed.
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SnapshotBuffer } from '../../public/js/render/interp.js';
|
||||
import { EV } from '../../shared/protocol.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const DIR = path.join(ROOT, 'public/dev/recordings');
|
||||
const indexFile = path.join(DIR, 'index.json');
|
||||
const has = existsSync(indexFile);
|
||||
const index = has ? JSON.parse(readFileSync(indexFile, 'utf8')) : [];
|
||||
const EV_KINDS = new Set(Object.values(EV));
|
||||
|
||||
describe('recordings', { skip: !has && 'no recordings (node tools/record-battle.mjs)' }, () => {
|
||||
test('index lists normal fields on ≥ 3 stages, a unite and a boss field', () => {
|
||||
const kinds = index.map((r) => r.kind);
|
||||
assert.ok(new Set(index.filter((r) => r.kind === 'normal').map((r) => r.stageId)).size >= 3);
|
||||
assert.ok(kinds.includes('unite'));
|
||||
assert.ok(kinds.includes('boss'));
|
||||
for (const r of index) assert.ok(existsSync(path.join(DIR, r.file)), r.file);
|
||||
});
|
||||
|
||||
for (const meta of index) {
|
||||
test(`${meta.name}: wire format (m.field, b.snap, b.ev)`, () => {
|
||||
const rec = JSON.parse(readFileSync(path.join(DIR, meta.file), 'utf8'));
|
||||
const f = rec.field;
|
||||
assert.equal(typeof f.fieldId, 'string');
|
||||
assert.equal(f.kind, rec.kind);
|
||||
for (const k of ['r0', 'r1', 'c0', 'c1']) assert.ok(Number.isInteger(f.rect[k]));
|
||||
assert.ok(Array.isArray(f.units));
|
||||
const info = (u) => {
|
||||
for (const k of ['id', 'kind', 'side', 'defId', 'x', 'y', 'facing', 'maxHp']) assert.ok(u[k] !== undefined, `UnitInfo.${k}`);
|
||||
assert.ok(u.side === 'ally' || u.side === 'enemy');
|
||||
};
|
||||
f.units.forEach(info);
|
||||
let lastT = -Infinity;
|
||||
const ids = new Set(f.units.map((u) => u.id));
|
||||
for (const fr of rec.frames) {
|
||||
const s = fr.snap;
|
||||
assert.equal(s.fieldId, f.fieldId);
|
||||
assert.ok(s.t > lastT, 'snapshot time strictly increases');
|
||||
lastT = s.t;
|
||||
for (const u of s.units) {
|
||||
assert.equal(u.length, 9);
|
||||
for (const v of u) assert.ok(Number.isFinite(v), 'finite tuple values');
|
||||
// server/sim/snapshot.js sends ceil(hp) but round(maxHp): hp may exceed maxHp by 1 (the renderer clamps)
|
||||
assert.ok(u[3] >= 0 && u[3] <= u[4] + 1, 'hp within maxHp');
|
||||
assert.ok(u[1] >= f.rect.c0 - 1 && u[1] <= f.rect.c1 + 1 && u[2] >= f.rect.r0 - 1 && u[2] <= f.rect.r1 + 1, 'inside the rect');
|
||||
}
|
||||
for (const e of fr.ev) {
|
||||
assert.ok(EV_KINDS.has(e[0]), `known event ${e[0]}`);
|
||||
if (e[0] === 'spawn') { info(e[1]); ids.add(e[1].id); }
|
||||
}
|
||||
for (const u of s.units) assert.ok(ids.has(u[0]), `unit ${u[0]} announced (m.field or spawn) before its first snapshot`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${meta.name}: replays through SnapshotBuffer at real speed without NaN or jumps`, () => {
|
||||
const rec = JSON.parse(readFileSync(path.join(DIR, meta.file), 'utf8'));
|
||||
const buf = new SnapshotBuffer();
|
||||
const out = new Map();
|
||||
const prev = new Map();
|
||||
let fi = 0, events = 0;
|
||||
const t0 = rec.frames[0].snap.t;
|
||||
const end = (rec.frames[rec.frames.length - 1].snap.t - t0) / 2 + 0.5;
|
||||
let maxStep = 0;
|
||||
for (let real = 0; real < end; real += 1 / 60) {
|
||||
while (fi < rec.frames.length && (rec.frames[fi].snap.t - t0) / 2 <= real) {
|
||||
buf.push(rec.frames[fi].snap, real);
|
||||
buf.pushEvents(rec.frames[fi].ev, real);
|
||||
fi++;
|
||||
}
|
||||
const rt = buf.update(real);
|
||||
if (!Number.isFinite(rt)) continue;
|
||||
events += buf.takeEvents(rt).length;
|
||||
buf.sample(rt, out);
|
||||
for (const [id, s] of out) {
|
||||
assert.ok(Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.hp), 'finite sample');
|
||||
const p = prev.get(id);
|
||||
if (p) {
|
||||
const d = Math.hypot(s.x - p.x, s.y - p.y);
|
||||
if (d < 2.4) maxStep = Math.max(maxStep, d); // teleports / redeploys excluded
|
||||
}
|
||||
prev.set(id, { x: s.x, y: s.y });
|
||||
}
|
||||
}
|
||||
assert.ok(events > 0);
|
||||
assert.ok(Math.abs(buf.rate - 2) < 0.2, `rate ≈ 2 (${buf.rate})`);
|
||||
// walking is ≤ ~0.1 tiles per 60 Hz frame; displacements (push/pull, dashes) move up to ~1 tile per snapshot
|
||||
// and are spread over ~3 frames by the lerp — anything bigger (but below the teleport cut) would be a bug
|
||||
assert.ok(maxStep < 0.6, `smooth motion (max step ${maxStep.toFixed(3)} tiles/frame)`);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user