- render/interp.js isCosmeticEvent: an fx with a form is state, never dropped as stale nor shed from a full queue; a late one switches the model without its telegraph, its closing clip shortened (render/app.js) - battle/runner.js: a backlog past HELD_MAX keeps only the last status / skill toggle per unit; a battle that ended while the tab was hidden delivers its backlog and last snapshot when the tab is shown - screens/game.js: a new field meta for the field on screen buffers its frames until the view re-enters it - browser test stalls the main thread (blocking 1 s long tasks); Node tests drive the runner into the render buffer
240 lines
12 KiB
JavaScript
240 lines
12 KiB
JavaScript
// 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 fxFormOf = (e) => (e && e[0] === 'fx' && e[4] && Object.hasOwn(e[4], 'form') ? e[4].form : undefined);
|
||
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('an enemy\'s form fx is state (player report #5 after 0.1.0): it survives the stale drop and the full-queue shed, a plain fx does not', () => {
|
||
const form = (id, f) => ['fx', 'phase', 1, 1, { id, kind: f, form: f, dur: 2 }];
|
||
assert.ok(!isCosmeticEvent(form(3, 'translator_youling')), 'a form fx is never cosmetic');
|
||
assert.ok(!isCosmeticEvent(['fx', 'revive', 1, 1, { id: 3, form: null }]), 'back to the base clips (form null) too');
|
||
assert.ok(isCosmeticEvent(['fx', 'phase', 1, 1, { id: 3, kind: 'artsBarrier' }]), 'a barrier phase stays cosmetic');
|
||
// a 1 s stall at 2×: the render clock jumps past the 1.5 game s window
|
||
const b = new SnapshotBuffer();
|
||
b.pushEvents([['dmg', 3, 5, 'phys'], form(3, 'translator_youling'), ['fx', 'burst', 1, 1, {}]], 0, 1);
|
||
const late = new Map();
|
||
const out = b.takeEvents(10, [], 10 - 1.5, late);
|
||
assert.deepEqual(out.map((e) => e[4]?.form ?? e[0]), ['translator_youling'], 'only the form fx is handed out');
|
||
assert.ok(Math.abs(late.get(out[0]) - 9) < 1e-9, 'with its lateness (game s)');
|
||
// replayed without a stamp right after a reset (screens/game.js early buffer: stamped 0)
|
||
const r = new SnapshotBuffer();
|
||
r.pushEvents([form(4, 'husk'), ['fx', 'ember', 1, 1, { r: 1 }]], 0);
|
||
assert.deepEqual(r.takeEvents(30, [], 28.5).map(fxFormOf), ['husk']);
|
||
// a hidden-tab flood: the shed keeps it
|
||
const q = new SnapshotBuffer();
|
||
q.pushEvents([form(5, 'reborn')], 0, 0.5);
|
||
for (let i = 0; i < 70; i++) q.pushEvents(Array.from({ length: 100 }, () => ['fx', 'burst', 1, 1, {}]), 0, 1 + i * 0.01);
|
||
assert.ok(q.events.length <= 6000, `bounded (${q.events.length})`);
|
||
assert.equal(q.events.filter((e) => e.ev[4]?.form === 'reborn').length, 1, 'never shed');
|
||
});
|
||
|
||
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);
|
||
}
|
||
});
|
||
});
|