decla-red/test/prediction-reconciliation.test.ts
2026-08-22 11:03:58 +01:00

182 lines
7.7 KiB
TypeScript

// Reconciliation guard for the REAL client predictor. It exercises
// LocalCharacterPredictor end-to-end with an injected clock, verifying the two
// properties reconciliation depends on:
// 1. a fully-acknowledged snapshot reproduces the authoritative pose exactly
// (no spurious drift on top of server truth), and
// 2. un-acknowledged input is replayed forward deterministically.
// This is the net for prediction-touching changes (the death-while-dead fix,
// future netcode work) — a regression shows up as drift or non-determinism.
import { describe, it, expect } from 'vitest';
import {
LocalCharacterPredictor,
setPredictorClockForTesting,
} from '../frontend/src/scripts/helper/prediction/local-character-predictor';
const HEAD_RADIUS = 50;
const FEET_RADIUS = 20;
// A Circle-shaped pose; the predictor only reads .center / .radius, so plain
// objects with array centres are sufficient (and avoid importing gl-matrix).
const poseAt = (cx: number, cy: number) => ({
head: { center: [cx, cy + 37], radius: HEAD_RADIUS },
leftFoot: { center: [cx - 33, cy - 18], radius: FEET_RADIUS },
rightFoot: { center: [cx + 33, cy - 18], radius: FEET_RADIUS },
});
describe('local prediction reconciliation', () => {
it('reproduces the authoritative pose exactly when all input is acknowledged', () => {
let clock = 1000;
setPredictorClockForTesting(() => clock);
const predictor = new LocalCharacterPredictor();
const auth = poseAt(500, 500);
const t = predictor.recordInput([1, 0]);
predictor.acknowledge(t, [0, 0], -Infinity); // server has consumed this input
predictor.setStrength(80);
predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
// No clock advance → zero replay window → predicted pose == authoritative.
const used = predictor.update([], 1 / 60);
expect(used).toBe(true);
expect(predictor.head.center[0]).toBeCloseTo(auth.head.center[0], 5);
expect(predictor.head.center[1]).toBeCloseTo(auth.head.center[1], 5);
expect(predictor.leftFoot.center[0]).toBeCloseTo(auth.leftFoot.center[0], 5);
expect(predictor.rightFoot.center[0]).toBeCloseTo(auth.rightFoot.center[0], 5);
});
it('replays un-acknowledged input deterministically', () => {
const drive = () => {
let clock = 0;
setPredictorClockForTesting(() => clock);
const predictor = new LocalCharacterPredictor();
clock = 1000;
predictor.acknowledge(900, [0, 0], -Infinity); // baseline ack (older than the input below)
predictor.setStrength(80);
const auth = poseAt(0, 0);
predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
predictor.recordInput([1, 0]); // unacked rightward input at t=1000
clock = 1100; // replay ~100 ms forward
predictor.update([], 1 / 60);
return [
predictor.head.center[0],
predictor.head.center[1],
predictor.leftFoot.center[0],
predictor.rightFoot.center[0],
];
};
const a = drive();
const b = drive();
expect(a).toEqual(b); // deterministic replay
expect(a.every((n) => Number.isFinite(n))).toBe(true);
expect(a[0]).toBeGreaterThan(0.5); // rightward input actually moved the body
});
it('suppresses prediction while the local player is dead', () => {
let clock = 5000;
setPredictorClockForTesting(() => clock);
const predictor = new LocalCharacterPredictor();
const auth = poseAt(200, 200);
const t = predictor.recordInput([1, 0]);
predictor.acknowledge(t, [0, 0], -Infinity);
predictor.setStrength(80);
predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
predictor.setAlive(false); // dead, awaiting respawn
clock = 5200; // input + elapsed time that would otherwise be replayed forward
// Even with a valid authoritative pose and pending input, a dead body must
// not be predicted/moved — that was the "move while dead" bug.
expect(predictor.update([], 1 / 60)).toBe(false);
});
// The replay window spans everything the server has not acknowledged, not just
// the snapshot's age. Anchoring on the snapshot's arrival made the window just
// that age, so prediction cancelled the interpolation buffer and nothing else
// and the local body sat a full one-way trip behind the server.
it('replays the whole un-acknowledged span, so a longer trip predicts further', () => {
const driveWithUnackedSpan = (ackAgeMs: number) => {
let clock = 0;
setPredictorClockForTesting(() => clock);
const predictor = new LocalCharacterPredictor();
predictor.setStrength(80);
// The key went down well before the acknowledged span and is still held —
// the case that used to freeze the acknowledgement, because movement is
// only transmitted when it changes.
clock = 9_000;
predictor.recordInput([1, 0]);
clock = 10_000;
const auth = poseAt(0, 0);
// The server has confirmed input only up to `ackAgeMs` ago; the snapshot
// carrying that acknowledgement arrives now.
predictor.acknowledge(clock - ackAgeMs, [0, 0], -Infinity);
predictor.setAuthoritative(
auth.head as never,
auth.leftFoot as never,
auth.rightFoot as never,
);
predictor.update([], 1 / 60);
return predictor.head.center[0];
};
const shortTrip = driveWithUnackedSpan(40);
const longTrip = driveWithUnackedSpan(200);
expect(shortTrip).toBeGreaterThan(0);
// A 200 ms un-acknowledged span must predict meaningfully further ahead than
// a 40 ms one; when the window was pinned to the snapshot they were equal.
expect(longTrip).toBeGreaterThan(shortTrip * 2);
});
// ...but the anchor must not also inherit the AGE of whichever input happened
// to be the newest one the server had when it took the snapshot. Input is sent
// once per frame, so that age is 0..1 frame depending on where the client's
// frames fell between two snapshots, and it walks a sawtooth: measured against
// a real server on localhost it ran 5, 16, 12, 8, 3, 14, 9, 1 ms and so on.
// Left in the window, it jumped the predicted body by up to a frame of travel
// 25 times a second — the local body was choppy on a link with no latency at
// all to hide it. The server reports the age with the acknowledgement so the
// anchor lands on the snapshot instant instead.
it('cancels the age of the acknowledged input, so the send cadence cannot beat', () => {
// Both runs describe the SAME snapshot: taken 20 ms ago, of the same pose.
// They differ only in how long before it the server's newest input arrived.
const snapshotAgeMs = 20;
const drive = (inputAgeMs: number, serverReportsAge: boolean) => {
let clock = 0;
setPredictorClockForTesting(() => clock);
const predictor = new LocalCharacterPredictor();
predictor.setStrength(80);
clock = 9_000;
predictor.recordInput([1, 0]); // held down throughout
clock = 10_000;
predictor.acknowledge(
clock - snapshotAgeMs - inputAgeMs,
[0, 0],
-Infinity,
serverReportsAge ? inputAgeMs : 0,
);
const auth = poseAt(0, 0);
predictor.setAuthoritative(
auth.head as never,
auth.leftFoot as never,
auth.rightFoot as never,
);
predictor.update([], 1 / 60);
return predictor.head.center[0];
};
expect(drive(16, true)).toBeCloseTo(drive(0, true), 6);
// And the difference being cancelled is a real one: uncancelled, the same
// 16 ms moves the body, so the assertion above does not hold vacuously.
expect(Math.abs(drive(16, false) - drive(0, false))).toBeGreaterThan(0.5);
});
});