GLM
This commit is contained in:
parent
b9274a42c7
commit
5017ff168a
7 changed files with 1004 additions and 2007 deletions
|
|
@ -1,6 +1,4 @@
|
|||
// Regression net for the backend defects found in the audit. These drive the
|
||||
// real classes rather than re-implementing their logic, so they fail if the
|
||||
// behaviour regresses rather than if the code is merely reshaped.
|
||||
// Regression net for backend defects — drives the real classes.
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
|
|
@ -8,8 +6,7 @@ import { PhysicalContainer } from '../backend/src/physics/containers/physical-co
|
|||
import { BoundingBox } from '../backend/src/physics/bounding-boxes/bounding-box';
|
||||
import { ProjectilePhysical } from '../backend/src/objects/projectile-physical';
|
||||
import { CharacterPhysical } from '../backend/src/objects/character-physical';
|
||||
import { StepCommand } from '../backend/src/commands/step';
|
||||
import { GeneratePointsCommand } from '../backend/src/commands/generate-points';
|
||||
import { StepCommand, GeneratePointsCommand } from '../backend/src/commands/commands';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const shared = require('../shared/lib/main.js');
|
||||
|
|
@ -20,7 +17,6 @@ applyArrayPlugins();
|
|||
|
||||
const step = settings.targetPhysicsDeltaTimeInSeconds;
|
||||
|
||||
// Collects the points a stepped object asks the game to award.
|
||||
class PointsSpy {
|
||||
public blue = 0;
|
||||
public red = 0;
|
||||
|
|
@ -46,11 +42,6 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
describe('projectile broadphase registration', () => {
|
||||
// The bounding box is only rebuilt by the center/radius setters, but the
|
||||
// geometry helpers write `center` in place — so a projectile stayed registered
|
||||
// at its muzzle for its whole flight. View-area streaming is driven by that
|
||||
// box, so shots fired off-screen were never sent, and shots in view vanished
|
||||
// as soon as their muzzle scrolled away.
|
||||
it('follows the projectile instead of staying at the muzzle', () => {
|
||||
const container = new PhysicalContainer();
|
||||
container.initialize();
|
||||
|
|
@ -135,9 +126,6 @@ describe('scoring', () => {
|
|||
return character;
|
||||
};
|
||||
|
||||
// A disconnect and an NPC being retired on join both run through onDie(), and
|
||||
// used to award the opposing team a full kill, so ordinary connection churn
|
||||
// moved the match score with nobody playing.
|
||||
it('awards nothing for an administrative death', () => {
|
||||
const container = new PhysicalContainer();
|
||||
container.initialize();
|
||||
|
|
@ -166,8 +154,6 @@ describe('scoring', () => {
|
|||
});
|
||||
|
||||
describe('charged shots', () => {
|
||||
// Capping only the COST meant a max-charge request on a nearly empty pool paid
|
||||
// a tap's price while still getting full-charge radius, speed and recoil.
|
||||
it('scales the shot by the strength actually paid, not the requested charge', () => {
|
||||
const container = new PhysicalContainer();
|
||||
container.initialize();
|
||||
|
|
@ -181,7 +167,6 @@ describe('charged shots', () => {
|
|||
);
|
||||
container.addObject(character);
|
||||
|
||||
// Drain the pool down to roughly a tap's worth.
|
||||
(character as unknown as { projectileStrength: number }).projectileStrength =
|
||||
settings.chargeShotStrengthMin;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
// What a Player sheds when its socket stops draining. The guard used to read
|
||||
// `socket.conn.bufferedAmount`, which engine.io's Socket does not have, so it
|
||||
// never once ran — and had it run, it dropped the whole batch, including the
|
||||
// create/delete that handleViewAreaUpdate had already committed to having sent.
|
||||
// Slow-client backpressure: what a Player sheds when its socket stops draining.
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
|
|
@ -26,8 +23,7 @@ const {
|
|||
|
||||
applyArrayPlugins();
|
||||
|
||||
// A socket whose write buffer we control, shaped the way engine.io really nests
|
||||
// it: the byte count lives on the ws socket under the transport.
|
||||
// Shaped like engine.io: the byte count lives on the ws socket under the transport.
|
||||
class FakeSocket {
|
||||
public readonly sent: Array<{ event: string; payload: unknown }> = [];
|
||||
public readonly conn = { transport: { socket: { bufferedAmount: 0 } } };
|
||||
|
|
@ -58,15 +54,11 @@ const makePlayer = () => {
|
|||
const players = new PlayerContainer(objects, 4, 0);
|
||||
const socket = new FakeSocket();
|
||||
const player = players.createPlayer({ name: 'player' }, socket as never);
|
||||
// The constructor already queues this player's own CreatePlayerCommand; flush
|
||||
// it so each case starts from an empty queue.
|
||||
player.sendQueuedCommandsToClient();
|
||||
socket.sent.length = 0;
|
||||
return { player, socket };
|
||||
};
|
||||
|
||||
// One snapshot's worth of queued traffic: the bulky repeated state, and the
|
||||
// one-shot bookkeeping that is never regenerated.
|
||||
const queueOneSnapshot = (player: ReturnType<typeof makePlayer>['player']) => {
|
||||
player.queueCommandSend(new CreateObjectsCommand([]));
|
||||
player.queueCommandSend(new DeleteObjectsCommand([1234]));
|
||||
|
|
@ -102,8 +94,6 @@ describe('slow-client backpressure', () => {
|
|||
queueOneSnapshot(player);
|
||||
player.sendQueuedCommandsToClient();
|
||||
|
||||
// If the guard cannot see the buffer it degrades to sending everything, so
|
||||
// anything shed at all proves it is reading the right property.
|
||||
expect(typesIn(socket.lastBatch)).not.toContain(PropertyUpdatesForObjects.name);
|
||||
});
|
||||
|
||||
|
|
@ -114,12 +104,8 @@ describe('slow-client backpressure', () => {
|
|||
player.sendQueuedCommandsToClient();
|
||||
|
||||
const types = typesIn(socket.lastBatch);
|
||||
// Repeated in full every tick — safe to drop.
|
||||
expect(types).not.toContain(PropertyUpdatesForObjects.name);
|
||||
expect(types).not.toContain(UpdateMinimap.name);
|
||||
// One-shot. handleViewAreaUpdate has already advanced its record of what
|
||||
// this client knows, so these are never re-derived: dropping them leaves the
|
||||
// client permanently missing objects and holding ghosts.
|
||||
expect(types).toContain(CreateObjectsCommand.name);
|
||||
expect(types).toContain(DeleteObjectsCommand.name);
|
||||
expect(types).toContain(ServerAnnouncement.name);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
// The client's outbound cadence, which is what the server's inbound allowance
|
||||
// has to be sized against. Sending once per rendered frame put every display
|
||||
// above the allowance permanently over it; once the burst was spent the server
|
||||
// silently discarded whole batches, and movement commands are edge-triggered
|
||||
// and never re-sent, so a lost batch meant the direction change never happened.
|
||||
// Client outbound send cadence vs. server inbound allowance.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
|
|
@ -16,7 +12,6 @@ const { applyArrayPlugins, settings, MoveActionCommand, ClientHeartbeatCommand }
|
|||
|
||||
applyArrayPlugins();
|
||||
|
||||
// Collects what would go on the wire.
|
||||
const drive = (frameRate: number, seconds: number, onFrame?: (socket: any) => void) => {
|
||||
const batches: Array<string> = [];
|
||||
const socket = { emit: (_event: string, payload: string) => batches.push(payload) };
|
||||
|
|
@ -38,9 +33,6 @@ describe('client send cadence', () => {
|
|||
|
||||
for (const frameRate of [60, 144, 240]) {
|
||||
const batches = drive(frameRate, seconds);
|
||||
// One heartbeat per interval, give or take where the frames fall. The
|
||||
// point is that the count barely moves across a 4x range of frame rates —
|
||||
// sending once per frame gave 180, 432 and 720 here.
|
||||
expect(batches.length).toBeLessThanOrEqual(expected * 1.1);
|
||||
expect(batches.length).toBeGreaterThanOrEqual(expected * 0.85);
|
||||
}
|
||||
|
|
@ -54,8 +46,6 @@ describe('client send cadence', () => {
|
|||
});
|
||||
|
||||
it('still flushes real input on the frame it happens', () => {
|
||||
// Input on frame 1 of a 144 Hz client: far inside the heartbeat interval, so
|
||||
// pacing must not hold it back. Latency the player feels is one frame.
|
||||
let sentOn = -1;
|
||||
const batches = drive(144, 0.1, (commandSocket) => {
|
||||
if (sentOn === -1) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
// Drives the real GameServer through a fake socket.io to cover the connection
|
||||
// lifecycle: the join guard, refusal reasons, and reconnect. None of this is
|
||||
// reachable from a unit test of any single class, and it is where the audit
|
||||
// found the most severe defects.
|
||||
// Connection lifecycle: join guard, refusal reasons, reconnect — driven through a fake socket.io.
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { GameServer } from '../backend/src/game-server';
|
||||
import { defaultOptions } from '../backend/src/default-options';
|
||||
import { defaultOptions } from '../backend/src/options';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const shared = require('../shared/lib/main.js');
|
||||
|
|
@ -91,9 +88,6 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
describe('join guard', () => {
|
||||
// One socket used to be able to emit PlayerJoining as often as it liked, each
|
||||
// pass spawning another Player and stacking another set of listeners, until it
|
||||
// held every slot on the server.
|
||||
it('accepts exactly one join per connection', () => {
|
||||
const { server, attach } = startServer({ playerLimit: 4, npcCount: 1 });
|
||||
const socket = attach();
|
||||
|
|
@ -145,7 +139,6 @@ describe('hostile payloads', () => {
|
|||
attach().fire(TransportEvents.PlayerJoining, { name: ['CharacterBase'] });
|
||||
}
|
||||
|
||||
// The joins are accepted (the name is coerced) and no bot is stranded.
|
||||
expect(server.serverInfo.playerCount).toBe(4);
|
||||
});
|
||||
|
||||
|
|
@ -154,7 +147,6 @@ describe('hostile payloads', () => {
|
|||
const socket = attach();
|
||||
socket.fire(TransportEvents.PlayerJoining, { name: 'sender' });
|
||||
|
||||
// Would previously be handed straight to JSON.parse on the physics thread.
|
||||
expect(() =>
|
||||
socket.fire(TransportEvents.PlayerToServer, 'x'.repeat(2 * 1024 * 1024)),
|
||||
).not.toThrow();
|
||||
|
|
@ -162,8 +154,6 @@ describe('hostile payloads', () => {
|
|||
});
|
||||
|
||||
describe('reconnect', () => {
|
||||
// A transport blip used to end the match: the client tore itself down and the
|
||||
// server deleted the character, with no way back to the same player.
|
||||
it('hands out a token on join and honours it on the way back', () => {
|
||||
const { server, attach } = startServer({ playerLimit: 4, npcCount: 0 });
|
||||
|
||||
|
|
@ -181,7 +171,7 @@ describe('reconnect', () => {
|
|||
expect(server.serverInfo.playerCount).toBe(1);
|
||||
expect(second.disconnected).toBe(false);
|
||||
expect(second.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined();
|
||||
// A fresh token for the new session, so the old one cannot be replayed.
|
||||
// Fresh token: the old one cannot be replayed.
|
||||
expect(second.lastPayloadFor(TransportEvents.PlayerJoined)).not.toBe(token);
|
||||
});
|
||||
|
||||
|
|
@ -199,15 +189,6 @@ describe('reconnect', () => {
|
|||
});
|
||||
|
||||
describe('inbound rate limiting', () => {
|
||||
// The bucket exists to survive a flood, but a dropped batch is LOST input,
|
||||
// not delayed input: movement is edge-triggered and nothing is ever re-sent,
|
||||
// so a discarded batch means the direction change simply never happened. The
|
||||
// ceiling used to be 120/s on the assumption of "one batch per frame", which
|
||||
// any display above 120 Hz exceeds outright — after the burst was spent, a
|
||||
// 144 Hz client had roughly one batch in six silently swallowed.
|
||||
//
|
||||
// Each message that gets past the limiter is unparseable and so logs exactly
|
||||
// once, which counts admissions without reaching into the server.
|
||||
const admitted = (socket: { fire: (e: string, p: string) => void }, count: number) => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
for (let i = 0; i < count; i++) {
|
||||
|
|
@ -218,10 +199,6 @@ describe('inbound rate limiting', () => {
|
|||
return seen;
|
||||
};
|
||||
|
||||
// The allowance has to sit well clear of what a well-behaved client actually
|
||||
// sends, or the limiter eats gameplay input instead of floods. It used to be
|
||||
// 120/s against a client that sent once per rendered frame — a ratio below 1
|
||||
// for anyone on a 144 Hz display.
|
||||
it('allows several times what a well-behaved client sends', () => {
|
||||
const clientMessagesPerSecond = 1 / settings.clientSendInterval;
|
||||
|
||||
|
|
@ -238,7 +215,6 @@ describe('inbound rate limiting', () => {
|
|||
const socket = attach();
|
||||
socket.fire(TransportEvents.PlayerJoining, { name: 'player' });
|
||||
|
||||
// The paced heartbeat plus a generous allowance for discrete input events.
|
||||
const oneSecond = Math.ceil(1 / settings.clientSendInterval) + 100;
|
||||
|
||||
expect(admitted(socket, oneSecond)).toBe(oneSecond);
|
||||
|
|
@ -256,10 +232,6 @@ describe('inbound rate limiting', () => {
|
|||
});
|
||||
|
||||
describe('reconnect tokens', () => {
|
||||
// They used to be minted from `Random` — the seeded Mulberry32 stream shared
|
||||
// with world generation, NPC decisions and planet spin, the last of which is
|
||||
// streamed to every client. That made them guessable, and made every draw
|
||||
// after a join shift, so a seed no longer reproduced a match.
|
||||
const tokenFromFreshServer = () => {
|
||||
Random.seed = 1;
|
||||
const { attach } = startServer({ playerLimit: 4, npcCount: 0, seed: 1 });
|
||||
|
|
@ -272,12 +244,6 @@ describe('reconnect tokens', () => {
|
|||
expect(tokenFromFreshServer()).not.toBe(tokenFromFreshServer());
|
||||
});
|
||||
|
||||
// Shape, because that is what distinguishes the two sources: 122 random bits
|
||||
// from the platform CSPRNG, rather than a counter and one 32-bit draw off the
|
||||
// gameplay stream. Minting from `Random` also advanced the stream the world
|
||||
// generator runs on, so the same seed stopped replaying the same match — but
|
||||
// that is not assertable here: this harness seeds a different instance of the
|
||||
// shared bundle than the server source resolves.
|
||||
it('mints them from the platform CSPRNG, not the gameplay stream', () => {
|
||||
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
|
||||
const socket = attach();
|
||||
|
|
@ -290,9 +256,6 @@ describe('reconnect tokens', () => {
|
|||
});
|
||||
|
||||
describe('socket listeners', () => {
|
||||
// The Player registers its own Pong listener, so nothing else knew to take it
|
||||
// off. A socket outlives the round it joined, so the retired Player stayed
|
||||
// reachable — and kept updating its RTT — through a listener nobody removed.
|
||||
it('takes every listener back off when the player drops', () => {
|
||||
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
|
||||
const socket = attach();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
// Determinism guard for the shared character simulation. The client predictor
|
||||
// and the authoritative server run the EXACT same stepCharacterMovement; if it
|
||||
// were non-deterministic (a stray Math.random / Date, or an order-dependent
|
||||
// reduce) prediction would rubber-band and could never reconcile. This is also
|
||||
// the regression net for the upcoming GC/scratch-pool perf rewrites: the pinned
|
||||
// hash must not change unless physics behaviour is intentionally changed.
|
||||
// Determinism guard: client predictor and server run the same stepCharacterMovement.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
|
|
@ -34,9 +29,7 @@ const makeBody = (center, radius) => ({
|
|||
restitution: 0,
|
||||
});
|
||||
|
||||
// A free-space world (no planets): the body is driven purely by the movement
|
||||
// input force, posture springs and momentum decay — enough to exercise the
|
||||
// deterministic core without coupling the test to planet SDF geometry.
|
||||
// Free-space world (no planets): exercises the deterministic core without planet SDF geometry.
|
||||
const emptyWorld = {
|
||||
groundsNear: () => [],
|
||||
stepBody: (body, dt) => {
|
||||
|
|
@ -47,11 +40,7 @@ const emptyWorld = {
|
|||
|
||||
const stepSeconds = 1 / 200;
|
||||
|
||||
// A spinning planet, built exactly the way create-world builds one, wired up as
|
||||
// the GroundSurface the shared movement collides against and is pulled by. The
|
||||
// free-space run above deliberately avoids planet geometry, so without this the
|
||||
// SDF, the gravity falloff and the on-surface branch — the parts most likely to
|
||||
// be touched by a perf rewrite — have no pin at all.
|
||||
// A spinning planet, built the way create-world builds one, so the SDF/gravity/on-surface branches have a pin too.
|
||||
const makeSpinningPlanetWorld = () => {
|
||||
Random.seed = 20;
|
||||
const vertices = PlanetBase.createPlanetVertices(
|
||||
|
|
@ -85,8 +74,6 @@ const makeSpinningPlanetWorld = () => {
|
|||
};
|
||||
};
|
||||
|
||||
// Walk a body across a spinning planet: gravity, the SDF outline, collision
|
||||
// resolution, surface latching and the rotation carry all participate.
|
||||
const runPlanetSimulation = () => {
|
||||
const { surface, world } = makeSpinningPlanetWorld();
|
||||
const start = vec2.fromValues(0, 250);
|
||||
|
|
@ -120,8 +107,6 @@ const runPlanetSimulation = () => {
|
|||
});
|
||||
};
|
||||
|
||||
// Run a fixed, scripted input sequence through the shared sim and return a
|
||||
// stable string snapshot of the final pose + carried momentum.
|
||||
const runSimulation = () => {
|
||||
const start = vec2.fromValues(100, 100);
|
||||
const state = {
|
||||
|
|
@ -164,8 +149,6 @@ describe('shared character simulation determinism', () => {
|
|||
});
|
||||
|
||||
it('matches the pinned reference pose on a spinning planet', () => {
|
||||
// Covers the planet SDF, the gravity falloff, collision resolution and the
|
||||
// rotation carry, none of which the free-space run above touches.
|
||||
expect(runPlanetSimulation()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,4 @@
|
|||
// Reconciliation guard for the REAL client predictor. It exercises
|
||||
// LocalCharacterPredictor end-to-end with a driven frame clock, verifying the
|
||||
// properties reconciliation depends on:
|
||||
// 1. a fully-acknowledged snapshot reproduces the authoritative pose exactly
|
||||
// (no spurious drift on top of server truth),
|
||||
// 2. un-acknowledged input is replayed forward deterministically, and
|
||||
// 3. the replayed body advances smoothly as the window grows.
|
||||
// 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.
|
||||
// Reconciliation guard for the client predictor: acknowledged pose, deterministic replay, replay-window behaviour.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
LocalCharacterPredictor,
|
||||
|
|
@ -16,17 +8,12 @@ import {
|
|||
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 },
|
||||
});
|
||||
|
||||
// The authoritative movement state that rides along with every snapshot: the
|
||||
// pose is only half of what the simulation carries between ticks. Upright,
|
||||
// airborne and with no launch momentum unless a test says otherwise.
|
||||
const movementState = (direction = 0) => ({
|
||||
direction,
|
||||
bodyVelocity: [0, 0],
|
||||
|
|
@ -44,7 +31,7 @@ describe('local prediction reconciliation', () => {
|
|||
const auth = poseAt(500, 500);
|
||||
|
||||
const t = predictor.recordInput([1, 0]);
|
||||
predictor.acknowledge(t, movementState() as never, -Infinity); // server has consumed this input
|
||||
predictor.acknowledge(t, movementState() as never, -Infinity);
|
||||
predictor.setStrength(80);
|
||||
predictor.setAuthoritative(
|
||||
auth.head as never,
|
||||
|
|
@ -52,7 +39,6 @@ describe('local prediction reconciliation', () => {
|
|||
auth.rightFoot as never,
|
||||
);
|
||||
|
||||
// No clock advance → zero replay window → predicted pose == authoritative.
|
||||
const used = predictor.update([], 1 / 60);
|
||||
|
||||
expect(used).toBe(true);
|
||||
|
|
@ -66,7 +52,7 @@ describe('local prediction reconciliation', () => {
|
|||
const drive = () => {
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
setFrameTimeMs(1000);
|
||||
predictor.acknowledge(900, movementState() as never, -Infinity); // baseline ack (older than the input below)
|
||||
predictor.acknowledge(900, movementState() as never, -Infinity);
|
||||
predictor.setStrength(80);
|
||||
const auth = poseAt(0, 0);
|
||||
predictor.setAuthoritative(
|
||||
|
|
@ -74,9 +60,9 @@ describe('local prediction reconciliation', () => {
|
|||
auth.leftFoot as never,
|
||||
auth.rightFoot as never,
|
||||
);
|
||||
predictor.recordInput([1, 0]); // unacked rightward input at t=1000
|
||||
predictor.recordInput([1, 0]);
|
||||
|
||||
setFrameTimeMs(1100); // replay ~100 ms forward
|
||||
setFrameTimeMs(1100);
|
||||
predictor.update([], 1 / 60);
|
||||
return [
|
||||
predictor.head.center[0],
|
||||
|
|
@ -88,9 +74,9 @@ describe('local prediction reconciliation', () => {
|
|||
|
||||
const a = drive();
|
||||
const b = drive();
|
||||
expect(a).toEqual(b); // deterministic replay
|
||||
expect(a).toEqual(b);
|
||||
expect(a.every((n) => Number.isFinite(n))).toBe(true);
|
||||
expect(a[0]).toBeGreaterThan(0.5); // rightward input actually moved the body
|
||||
expect(a[0]).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it('suppresses prediction while the local player is dead', () => {
|
||||
|
|
@ -106,32 +92,22 @@ describe('local prediction reconciliation', () => {
|
|||
auth.leftFoot as never,
|
||||
auth.rightFoot as never,
|
||||
);
|
||||
predictor.setAlive(false); // dead, awaiting respawn
|
||||
predictor.setAlive(false);
|
||||
|
||||
setFrameTimeMs(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.
|
||||
setFrameTimeMs(5200);
|
||||
// Regression: a dead body must not be predicted/moved.
|
||||
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) => {
|
||||
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.
|
||||
setFrameTimeMs(9_000);
|
||||
predictor.recordInput([1, 0]);
|
||||
|
||||
setFrameTimeMs(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(10_000 - ackAgeMs, movementState() as never, -Infinity);
|
||||
predictor.setAuthoritative(
|
||||
auth.head as never,
|
||||
|
|
@ -147,29 +123,16 @@ describe('local prediction reconciliation', () => {
|
|||
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
|
||||
// at a fixed cadence, so that age is 0..1 send interval depending on where the
|
||||
// client's sends 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) => {
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
predictor.setStrength(80);
|
||||
setFrameTimeMs(9_000);
|
||||
predictor.recordInput([1, 0]); // held down throughout
|
||||
predictor.recordInput([1, 0]);
|
||||
|
||||
setFrameTimeMs(10_000);
|
||||
predictor.acknowledge(
|
||||
|
|
@ -190,30 +153,17 @@ describe('local prediction reconciliation', () => {
|
|||
};
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// The jitter this file exists to catch. A walking body's posture springs lag,
|
||||
// so its head trails the upright position — and the facing direction is NOT
|
||||
// recoverable from such a pose: the angle you get back is tens of degrees off.
|
||||
// The predictor used to derive it that way rather than take the server's, so
|
||||
// every replay opened by twisting the body towards the pose-implied posture,
|
||||
// by an amount that depended on how many ticks the window happened to contain.
|
||||
// The rendered body then advanced a different distance each frame.
|
||||
it('replays the streamed facing direction, not one guessed from the pose', () => {
|
||||
// Head pulled 35 units behind the upright position: the steady-state posture
|
||||
// lag of a body walking to the right. atan2 on this pose gives ~0.57 rad;
|
||||
// the server's own direction is 0.
|
||||
// Head trails 35 units behind: atan2 gives ~0.57 rad, server direction is 0.
|
||||
const walkingPose = () => ({
|
||||
head: { center: [-35, 37], radius: HEAD_RADIUS },
|
||||
leftFoot: { center: [-33, -18], radius: FEET_RADIUS },
|
||||
rightFoot: { center: [33, -18], radius: FEET_RADIUS },
|
||||
});
|
||||
|
||||
// One snapshot interval of replay, which is as much as reconciliation ever
|
||||
// asks for on a healthy link.
|
||||
const predictWithDirection = (direction: number) => {
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
predictor.setStrength(80);
|
||||
|
|
@ -227,8 +177,6 @@ describe('local prediction reconciliation', () => {
|
|||
auth.rightFoot as never,
|
||||
);
|
||||
|
||||
// A fresh predictor renders its first pose by snapping, so this is the raw
|
||||
// prediction with no easing laid over it.
|
||||
setFrameTimeMs(40);
|
||||
predictor.update([], 1 / 60);
|
||||
return [predictor.head.center[0], predictor.head.center[1]];
|
||||
|
|
@ -241,10 +189,6 @@ describe('local prediction reconciliation', () => {
|
|||
upright[1] - asThePoseImplies[1],
|
||||
);
|
||||
|
||||
// The guess moved the body by a visible amount every 40 ms — and by a
|
||||
// different amount each frame, because the correction it triggers decays
|
||||
// over the replay. If the streamed direction were ignored again the two
|
||||
// would come out identical (this was exactly 0 before the fix).
|
||||
expect(apart).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
// Round-trip tests for the custom wire serializer — the single most fragile,
|
||||
// highest-blast-radius mechanism in the codebase (every networked message goes
|
||||
// through it, and dispatch is keyed on class name). We exercise the BUILT shared
|
||||
// bundle (shared/lib/main.js) rather than the TS source, so the decorators are
|
||||
// already applied exactly as they ship and there is no transform/ESM ambiguity.
|
||||
// Round-trip tests for the wire serializer, exercising the BUILT shared bundle.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
|
|
@ -68,8 +64,7 @@ describe('serialization round-trip (built shared lib)', () => {
|
|||
});
|
||||
|
||||
it('preserves per-item class identity across a mixed batch', () => {
|
||||
// A clobbered name→constructor mapping (two classes sharing a name) would
|
||||
// surface here as an item deserializing to the wrong class.
|
||||
// A clobbered name→constructor mapping surfaces as the wrong class.
|
||||
const batch = [
|
||||
new ServerAnnouncement('a'),
|
||||
new MoveActionCommand([0, 1], 5),
|
||||
|
|
|
|||
Loading…
Reference in a new issue