Files
ark-stronghold-protocol/test/render/interp.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 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).
2026-09-29 16:21:31 +08:00

215 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.
// test/render/interp.test.js — snapshot interpolation buffer: clock, lerp, spawn/die mid-buffer, teleports,
// extrapolation guard, rate estimation, event stamping, robustness against junk.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { SnapshotBuffer, normalizeSnapshot, isCosmeticEvent, frameTime } from '../../public/js/render/interp.js';
const near = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
const snap = (t, units) => ({ fieldId: 'n:1', t, units, dp: 10, killed: 0, total: 5 });
const U = (id, x, y, hp = 100, anim = 0, flags = 0) => [id, x, y, hp, 100, 5, 10, flags, anim];
/** Feed snapshots every 0.1 game s at 2× (every 50 ms real) starting at real time 0. */
function feed(buf, frames, t0 = 0, realStep = 0.05, gameStep = 0.1) {
frames.forEach((units, i) => buf.push(snap(t0 + i * gameStep, units), i * realStep));
return (frames.length - 1) * realStep;
}
describe('normalizeSnapshot', () => {
test('rejects junk, keeps valid tuples, fills defaults', () => {
assert.equal(normalizeSnapshot(null), null);
assert.equal(normalizeSnapshot({ t: 'x' }), null);
assert.equal(normalizeSnapshot({ t: NaN, units: [] }), null);
const s = normalizeSnapshot({ t: 1, units: [U(1, 2, 3), [2, NaN, 1], 'x', [3], [{}, 1, 1], [4, 1, 2, 'hp']] });
assert.deepEqual([...s.units.keys()], [1, 4]);
assert.deepEqual(s.units.get(4), [4, 1, 2, 0, 0, 0, 0, 0, 0]);
});
test('wire frames: game time comes from `gt` (the frame `t` is the message type)', () => {
const wire = { t: 'b.snap', fieldId: 'n:a', gt: 12.5, units: [U(1, 2, 9)], killed: 1, total: 3 };
assert.equal(frameTime(wire), 12.5);
assert.equal(normalizeSnapshot(wire).t, 12.5);
assert.equal(frameTime({ t: 3.5 }), 3.5, 'raw Battle snapshot / recording');
assert.equal(frameTime({ t: 3.5, gt: 4 }), 4, 'gt wins');
assert.ok(Number.isNaN(frameTime({ t: 'b.snap' })));
assert.ok(Number.isNaN(frameTime({ t: 'b.ev', gt: Infinity })));
assert.ok(Number.isNaN(frameTime(null)));
assert.equal(normalizeSnapshot({ t: 'b.snap', units: [] }), null, 'a wire frame without gt is unusable');
// a buffer fed wire frames and gt-stamped events delivers the events at their snapshot time
const b = new SnapshotBuffer({ delay: 0.1, rate: 2 });
for (let i = 0; i <= 10; i++) {
b.pushEvents([['dmg', 1, 5, 'phys']], i * 0.05, frameTime({ t: 'b.ev', gt: i * 0.1 }));
assert.equal(b.push({ t: 'b.snap', gt: i * 0.1, units: [U(1, i * 0.1, 10)] }, i * 0.05), true);
}
assert.ok(near(b.newestT, 1.0));
const due = b.takeEvents(0.55);
assert.equal(due.length, 6, 'events stamped 0.0 … 0.5 are due at 0.55');
});
});
describe('SnapshotBuffer', () => {
test('render clock trails the newest snapshot by delay × rate', () => {
const b = new SnapshotBuffer({ delay: 0.1, rate: 2 });
const frames = [];
for (let i = 0; i <= 20; i++) frames.push([U(1, i * 0.1, 10)]);
const now = feed(b, frames);
b.update(now);
assert.ok(near(b.rate, 2, 0.05), `rate ${b.rate}`);
assert.ok(near(b.renderT, b.newestT - 0.2, 0.03), `renderT ${b.renderT} newest ${b.newestT}`);
// steady state: snapshots keep arriving every 50 ms real, frames every 16 ms → stays ~0.2 game s behind
let t = 2, real = now;
for (let i = 0; i < 60; i++) {
real += 0.05; t += 0.1;
b.push(snap(t, [U(1, t, 10)]), real);
for (let f = 1; f <= 3; f++) b.update(real + f * 0.0166);
}
const lag = b.newestT - b.renderT;
assert.ok(lag > 0.05 && lag < 0.25, `steady lag ${lag} (renderT ${b.renderT} newest ${b.newestT})`);
});
test('lerps positions / hp between bracketing snapshots; flags & anim from the older one', () => {
const b = new SnapshotBuffer();
b.push(snap(1.0, [U(1, 0, 10, 100, 1, 0)]), 0);
b.push(snap(1.1, [U(1, 1, 10.5, 50, 2, 16)]), 0.05);
const out = b.sample(1.05);
const s = out.get(1);
assert.ok(near(s.x, 0.5) && near(s.y, 10.25) && near(s.hp, 75));
assert.equal(s.anim, 1);
assert.equal(s.flags, 0);
assert.ok(near(s.vx, 10) && near(s.vy, 5));
const s2 = b.sample(1.1).get(1);
assert.equal(s2.anim, 2);
assert.equal(s2.flags, 16);
});
test('spawn mid-buffer appears only once renderT reaches it; die mid-buffer holds then disappears', () => {
const b = new SnapshotBuffer();
b.push(snap(0, [U(1, 0, 10), U(2, 5, 10)]), 0);
b.push(snap(0.1, [U(1, 1, 10), U(3, 9, 12)]), 0.05);
let out = b.sample(0.05);
assert.ok(out.has(1) && out.has(2), 'dying unit still shown before the newer snapshot');
assert.ok(!out.has(3), 'spawned unit not shown early');
assert.ok(near(out.get(2).x, 5), 'holds last position');
out = b.sample(0.1, out);
assert.ok(out.has(3) && !out.has(2), 'sample map reused and pruned');
});
test('teleports (> teleport tiles between snapshots) snap instead of sliding', () => {
const b = new SnapshotBuffer({ teleport: 2 });
b.push(snap(0, [U(1, 0, 10)]), 0);
b.push(snap(0.1, [U(1, 8, 10)]), 0.05);
assert.equal(b.sample(0.05).get(1).x, 0);
assert.equal(b.sample(0.1).get(1).x, 8);
});
test('extrapolation guard: never more than maxExtrapolate past the newest; then freezes', () => {
const b = new SnapshotBuffer({ delay: 0.1, rate: 2, maxExtrapolate: 0.1 });
const frames = [];
for (let i = 0; i <= 10; i++) frames.push([U(1, i * 0.1, 10)]); // 1 tile/s game
const now = feed(b, frames);
b.update(now);
for (let k = 1; k <= 200; k++) b.update(now + k * 0.02); // stream stalls for 4 s
assert.ok(b.renderT <= b.newestT + 0.1 * b.rate + 1e-9, `renderT ${b.renderT}`);
const s = b.sample();
assert.ok(s.get(1).x <= 1.0 + 0.2 + 1e-6 && s.get(1).x >= 1.0, `extrapolated x ${s.get(1).x}`);
});
test('large lag snaps the clock; a stream restart (t jumps back) resets the buffer', () => {
const b = new SnapshotBuffer({ delay: 0.1, rate: 2, snapAfter: 0.5 });
b.push(snap(0, [U(1, 0, 10)]), 0);
b.update(0);
b.push(snap(30, [U(1, 3, 10)]), 0.1); // server jumped ahead
b.update(0.11);
assert.ok(b.renderT > 29, `snapped to ${b.renderT}`);
b.pushEvents([['dmg', 1, 5, 'phys']], 0.12);
assert.ok(b.push(snap(0.5, [U(9, 1, 1)]), 0.2), 'accepted after reset');
assert.equal(b.size, 1);
assert.equal(b.events.length, 0, 'old events dropped on restart');
});
test('duplicate / out-of-order / junk snapshots are ignored', () => {
const b = new SnapshotBuffer();
assert.ok(b.push(snap(1, [U(1, 0, 0)]), 0));
assert.ok(!b.push(snap(1, [U(1, 9, 9)]), 0.01));
assert.ok(!b.push(snap(0.9, [U(1, 9, 9)]), 0.02));
assert.ok(!b.push({ t: 'no' }, 0.03));
assert.ok(!b.push(null, 0.03));
assert.equal(b.size, 1);
assert.equal(b.sample(1).get(1).x, 0);
});
test('rate estimation follows the stream (1× and 4×) within clamps', () => {
for (const rate of [1, 4]) {
const b = new SnapshotBuffer({ rate: 2 });
for (let i = 0; i < 80; i++) b.push(snap(i * 0.1, [U(1, 0, 0)]), i * 0.1 / rate);
assert.ok(near(b.rate, rate, 0.1), `estimated ${b.rate} for ${rate}`);
}
});
test('old snapshots are trimmed even without frames (hidden tab); events queue is bounded', () => {
const b = new SnapshotBuffer({ keep: 1 });
for (let i = 0; i < 200; i++) {
b.push(snap(i * 0.1, [U(1, i, 0)]), i * 0.05);
b.pushEvents([['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['die', i]], i * 0.05);
}
assert.ok(b.size < 40, `size ${b.size}`);
assert.ok(b.events.length <= 6000, `events ${b.events.length}`);
assert.equal(b.events.filter((e) => e.ev[0] === 'die').length, 200, 'state events never shed');
b.update(10);
const s = b.sample();
assert.ok(s.get(1), 'sample available');
});
test('events: stamped inside the latest interval, delivered in order when renderT passes', () => {
const b = new SnapshotBuffer({ delay: 0.1, rate: 2 });
b.push(snap(0, [U(1, 0, 0)]), 0);
b.push(snap(0.1, [U(1, 0, 0)]), 0.05);
b.pushEvents([['atk', 1, 2, 'arrow'], ['dmg', 2, 50, 'phys'], 'junk', [5]], 0.05);
b.push(snap(0.2, [U(1, 0, 0)]), 0.1);
b.pushEvents([['die', 2]], 0.1);
assert.equal(b.events.length, 3);
assert.deepEqual(b.takeEvents(0.0), []);
const first = b.takeEvents(0.06);
assert.deepEqual(first.map((e) => e[0]), ['atk', 'dmg']);
assert.deepEqual(b.takeEvents(0.2).map((e) => e[0]), ['die']);
// server-provided stamp
b.pushEvents([['skill', 1, 1]], 0.2, 0.15);
b.pushEvents([['skill', 1, 0]], 0.2, 5);
assert.deepEqual(b.takeEvents(0.16).map((e) => e[2]), [1]);
});
test('takeEvents can drop stale cosmetic events but keeps state events', () => {
const b = new SnapshotBuffer();
b.pushEvents([['dmg', 1, 5, 'phys'], ['die', 1], ['fx', 'burst', 1, 1, {}], ['spawn', { id: 3 }]], 0, 1);
const out = b.takeEvents(10, [], 5);
assert.deepEqual(out.map((e) => e[0]), ['die', 'spawn']);
assert.ok(isCosmeticEvent(['atk']) && !isCosmeticEvent(['leak']) && !isCosmeticEvent(null));
b.pushEvents([['leak', 4]], 0, 50);
assert.deepEqual(b.flushEvents().map((e) => e[0]), ['leak']);
});
test('sample before any snapshot / with NaN time is empty; update before snapshots is NaN', () => {
const b = new SnapshotBuffer();
assert.ok(Number.isNaN(b.update(1)));
assert.equal(b.sample().size, 0);
b.push(snap(2, [U(1, 1, 1)]), 1);
assert.equal(b.sample(NaN).size, 0);
assert.equal(b.sample(0).get(1).x, 1, 'time before the oldest clamps to the oldest');
});
test('no NaN anywhere through a noisy stream', () => {
const b = new SnapshotBuffer();
let now = 0, t = 0;
let seed = 7;
const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
const out = new Map();
for (let i = 0; i < 500; i++) {
now += 0.03 + rnd() * 0.05;
if (rnd() < 0.8) { t += 0.1; b.push(snap(t, [U(1, rnd() * 20, rnd() * 18, rnd() * 100), U(2 + (i % 5), 3, 3)]), now); }
b.update(now);
b.sample(undefined, out);
for (const s of out.values()) for (const k of ['x', 'y', 'hp', 'sp', 'vx', 'vy']) assert.ok(Number.isFinite(s[k]), k);
}
});
});