This commit is contained in:
Andras Schmelczer 2026-09-14 21:29:18 +01:00
commit 244620105a
12 changed files with 2080 additions and 518 deletions

View file

@ -2,4 +2,4 @@
exports[`shared character simulation determinism > matches the pinned reference pose (changes only with intentional physics edits) 1`] = `"{"head":[76.14,123.412],"leftFoot":[83.214,105.036],"rightFoot":[79.974,123.01],"bodyVelocity":[0,0]}"`;
exports[`shared character simulation determinism > matches the pinned reference pose on a spinning planet 1`] = `"{"head":[320.826,-34],"leftFoot":[248.634,-50.688],"rightFoot":[281.09,-72.183],"direction":-0.314,"onSurface":true,"secondsSinceOnSurface":0}"`;
exports[`shared character simulation determinism > matches the pinned reference pose on a spinning planet 1`] = `"{"head":[321.49,-34.424],"leftFoot":[249.254,-51.093],"rightFoot":[281.714,-72.595],"direction":-0.315,"onSurface":true,"secondsSinceOnSurface":0}"`;

View file

@ -1,15 +1,32 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRequire } from 'node:module';
import { performance } from 'node:perf_hooks';
import { PhysicalContainer } from '../backend/src/physics/physical-container';
import { BoundingBox } from '../backend/src/physics/bounding-box';
import { ProjectilePhysical } from '../backend/src/objects/projectile-physical';
import { CharacterPhysical } from '../backend/src/objects/character-physical';
import { PlanetPhysical } from '../backend/src/objects/planet-physical';
import { PlayerContainer } from '../backend/src/players/player-container';
import { defaultOptions } from '../backend/src/options';
import { GameServer } from '../backend/src/game-server';
const require = createRequire(import.meta.url);
const shared = require('../shared/lib/main.js');
const { vec2 } = require('../shared/node_modules/gl-matrix');
const { Random, settings, CharacterTeam } = shared;
const {
Random,
settings,
CharacterTeam,
SetAspectRatioActionCommand,
TransportEvents,
deserialize,
CreateObjectsCommand,
PropertyUpdatesForObjects,
InputAcknowledgement,
LeapActionCommand,
calculateViewArea,
} = shared;
const step = settings.targetPhysicsDeltaTimeInSeconds;
@ -63,6 +80,196 @@ beforeEach(() => {
Random.seed = 1;
});
it('credits posthumous kills without making a corpse appear alive to clients', () => {
const { container } = makeWorld();
const character = spawnCharacter(container);
character.setHealth(0);
character.onDie();
character.addKill('late projectile victim');
expect(character.killCount).toBe(1);
expect(character.health).toBe(0);
expect(
character.getRemoteCalls().some((call) => call.functionName === 'setHealth'),
).toBe(false);
});
afterEach(() => {
vi.restoreAllMocks();
});
const controlledServer = () => {
let nowMs = 1000;
let nextTick: () => void = () => {};
vi.spyOn(process.hrtime, 'bigint').mockImplementation(() => BigInt(nowMs * 1e6));
vi.spyOn(performance, 'now').mockImplementation(() => nowMs);
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => {
nextTick = callback;
return 0;
}) as never);
const server = new GameServer(
{
on() {},
to() {
return { emit() {} };
},
} as never,
{
...defaultOptions,
npcCount: 0,
},
);
const internals = server as unknown as {
players: PlayerContainer;
objects: PhysicalContainer;
isInEndGame: boolean;
};
server.start();
return {
players: internals.players,
objects: internals.objects,
server,
tick(milliseconds: number) {
nowMs += milliseconds;
nextTick();
},
};
};
describe('server snapshot cadence', () => {
it('waits for physics before acknowledging input or clearing remote calls', () => {
const { players, objects, tick } = controlledServer();
const communicate = vi.spyOn(players, 'stepCommunication');
const character = spawnCharacter(objects);
character.shootTowards(vec2.fromValues(1000, 0));
tick(1);
expect(communicate).not.toHaveBeenCalled();
expect(
character.getRemoteCalls().some((call) => call.functionName === 'onShoot'),
).toBe(true);
tick(4);
expect(communicate).toHaveBeenCalledOnce();
expect(character.getRemoteCalls()).toHaveLength(0);
});
it.each([false, true])(
'sends 25 snapshots per simulated second (endgame: %s)',
(endGame) => {
const { players, server, tick } = controlledServer();
(server as unknown as { isInEndGame: boolean }).isInEndGame = endGame;
const batches: unknown[] = [];
const player = players.createPlayer({ name: 'player' }, {
on() {},
emit(event: string, batch: unknown) {
if (event === TransportEvents.ServerToPlayer) {
batches.push(batch);
}
},
} as never);
for (let i = 0; i < 200; i++) {
tick(5);
}
expect(batches).toHaveLength(25);
const snapshot = deserialize(batches[24] as string).find(
(command: object) => command instanceof PropertyUpdatesForObjects,
);
const character = snapshot.updates.find(
(object: { id: number }) => object.id === player.character!.id,
);
const strength = character.updates.find(
(update: { propertyKey: string }) => update.propertyKey === 'strength',
);
expect(strength.rateOfChange).toBeCloseTo(
settings.playerStrengthRegenerationPerSeconds * (endGame ? 0.5 : 1),
);
},
);
});
describe('slow-motion extrapolation', () => {
const wallDelta = step;
const timeScale = 0.25;
it('coasts projectiles at the same speed as the slowed server without changing momentum', () => {
const { container } = makeWorld();
const projectile = spawnProjectile(container);
const before = vec2.clone(projectile.center);
projectile.step(wallDelta * timeScale);
const update = projectile.getPropertyUpdates(timeScale).updates[0];
expect(
vec2.scaleAndAdd(vec2.create(), before, update.rateOfChange, wallDelta),
).toEqual(projectile.center);
expect(projectile.object.velocity).toEqual([3000, 0]);
const strength = projectile
.getPropertyUpdates(timeScale)
.updates.find((property) => property.propertyKey === 'strength')!;
expect(40 + strength.rateOfChange * wallDelta).toBeCloseTo(projectile.strength);
expect(strength.propertyValue).toBe(projectile.strength);
});
it('coasts planet rotation using wall-clock seconds', () => {
const { game } = makeWorld();
const planet = new PlanetPhysical(
[
[-100, -100],
[-100, 100],
[100, 100],
[100, -100],
],
false,
game,
);
const angularVelocity = planet.angularVelocity;
planet.step(wallDelta * timeScale);
const update = planet
.getPropertyUpdates(timeScale)
.updates.find((property) => property.propertyKey === 'rotation')!;
expect(update.rateOfChange * wallDelta).toBeCloseTo(planet.rotation);
expect(planet.angularVelocity).toBe(angularVelocity);
});
it('scales character pose derivatives without mutating the stored simulation rates', () => {
const { container } = makeWorld();
const character = spawnCharacter(container);
character.step(settings.spawnDespawnTime + step);
character.setMoveDirection(vec2.fromValues(1, 0));
vec2.set(character.bodyVelocity, 1000, 0);
const parts = [character.head, character.leftFoot, character.rightFoot];
const before = parts.map((part) => vec2.clone(part.center));
character.step(wallDelta * timeScale);
const originalRates = character.getPropertyUpdates();
const slowedRates = character.getPropertyUpdates(timeScale);
parts.forEach((part, i) => {
const rate = slowedRates.updates[i].rateOfChange;
expect(vec2.scaleAndAdd(vec2.create(), before[i], rate.center, wallDelta)).toEqual(
part.center,
);
});
expect(character.getPropertyUpdates()).toEqual(originalRates);
});
it('coasts spawn growth using the slowed radius derivative', () => {
const { container } = makeWorld();
const character = spawnCharacter(container);
character.step(0.1);
const before = character.head.radius;
character.step(wallDelta * timeScale);
const radiusSpeed =
character.getPropertyUpdates(timeScale).updates[0].rateOfChange.radius;
expect(before + radiusSpeed * wallDelta).toBeCloseTo(character.head.radius);
});
});
describe('projectile broadphase registration', () => {
it('follows the projectile instead of staying at the muzzle', () => {
const { container } = makeWorld();
@ -88,6 +295,168 @@ describe('projectile broadphase registration', () => {
expect(projectile.center[0]).toBeLessThanOrEqual(15);
});
it('does not advance a projectile destroyed earlier in the same world step', () => {
const { container } = makeWorld();
const projectile = spawnProjectile(container);
const before = vec2.clone(projectile.center);
const character = spawnCharacter(container);
character.step = () => projectile.destroy();
container.addObject(projectile);
container.step(step);
expect(projectile.center).toEqual(before);
expect(projectile.strength).toBe(40);
});
});
describe('authoritative held movement', () => {
it('distinguishes no leap from an attempted leap timestamped at zero', () => {
const { container } = makeWorld();
const players = new PlayerContainer(container, 1, 0);
const batches: string[] = [];
const player = players.createPlayer({ name: 'player' }, {
on() {},
emit(event: string, payload: string) {
if (event === TransportEvents.ServerToPlayer) {
batches.push(payload);
}
},
} as never);
const sendSnapshot = () =>
player.stepCommunications(settings.updateMessageInterval, 1000, (object) =>
object.getPropertyUpdates(),
);
const leapAck = () =>
deserialize(batches[batches.length - 1]).find(
(command: object) => command instanceof InputAcknowledgement,
).lastLeapClientTimeMs;
sendSnapshot();
expect(leapAck()).toBe(-1);
player.handleCommand(new LeapActionCommand(0));
sendSnapshot();
expect(leapAck()).toBe(0);
});
it('applies the latest direction when press and release arrive in the same tick', () => {
const { container } = makeWorld();
const character = spawnCharacter(container);
character.step(settings.spawnDespawnTime + step);
const before = vec2.clone(character.center);
character.setMoveDirection(vec2.fromValues(1, 0));
character.setMoveDirection(vec2.fromValues(0, 0));
character.step(step);
expect(character.center).toEqual(before);
});
it('uses the latest held direction for a leap received in the same batch', () => {
const { container } = makeWorld();
const character = spawnCharacter(container);
character.step(settings.spawnDespawnTime + step);
character.currentPlanet = {
center: vec2.fromValues(0, -100),
angularVelocity: 0,
} as never;
character.setMoveDirection(vec2.fromValues(1, 0));
character.leap();
expect(character.bodyVelocity[0]).toBeGreaterThan(0);
expect(character.movementSnapshot.leapCooldownRemaining).toBeCloseTo(
settings.leapCooldownSeconds,
);
character.step(step);
expect(character.movementSnapshot.leapCooldownRemaining).toBeCloseTo(
settings.leapCooldownSeconds - step,
);
});
});
describe('planets', () => {
it('does not capture using a character killed after registering its presence', () => {
const { container, game } = makeWorld();
const character = spawnCharacter(container);
const planet = new PlanetPhysical(
[
[-100, -100],
[-100, 100],
[100, 100],
[100, -100],
],
false,
game,
);
planet.registerPresence(character);
character.onDie();
planet.step(step);
expect(planet.ownership).toBe(0.5);
});
it.each([
{ aspect: 8, center: [0, 750] },
{ aspect: 0.2, center: [750, 0] },
])('sends offscreen gravity sources at aspect $aspect', ({ aspect, center }) => {
const { container, game } = makeWorld();
const batches: string[] = [];
const socket = {
on() {},
emit(event: string, payload: string) {
if (event === TransportEvents.ServerToPlayer) {
batches.push(payload);
}
},
};
const players = new PlayerContainer(container, 1, 0);
const player = players.createPlayer({ name: 'player' }, socket as never);
const [x, y] = center;
const planet = new PlanetPhysical(
[
[x - 20, y - 20],
[x - 20, y + 20],
[x + 20, y + 20],
[x + 20, y - 20],
],
false,
game,
);
container.addObject(planet);
player.handleCommand(new SetAspectRatioActionCommand(aspect));
const { topLeft, size } = calculateViewArea(player.center, aspect, 1.2);
expect(
planet.boundingBox.intersects(
new BoundingBox(
topLeft[0],
topLeft[0] + size[0],
topLeft[1] - size[1],
topLeft[1],
),
),
).toBe(false);
player.stepCommunications(settings.updateMessageInterval + step, 1000, (object) =>
object.getPropertyUpdates(),
);
const created = deserialize(batches[0]).find(
(command: object) => command instanceof CreateObjectsCommand,
);
expect(created.objects.map((object: { id: number }) => object.id)).toContain(
planet.id,
);
});
});
it('uses an integer default seed so separate server runs can generate different worlds', () => {
expect(Number.isInteger(defaultOptions.seed)).toBe(true);
expect(defaultOptions.seed).toBeGreaterThanOrEqual(0);
expect(defaultOptions.seed).toBeLessThan(0x100000000);
});
describe('scoring', () => {
@ -124,6 +493,23 @@ describe('scoring', () => {
});
});
describe('NPC population', () => {
it('replaces a departing human without adding extra bots for humans still playing', () => {
const { container } = makeWorld();
const players = new PlayerContainer(container, 16, 4);
const socket = { on() {} } as never;
const first = players.createPlayer({ name: 'first' }, socket);
players.createPlayer({ name: 'second' }, socket);
expect(players.players).toHaveLength(4);
first.destroy();
players.deletePlayer(first);
expect(players.count).toBe(1);
expect(players.players).toHaveLength(4);
});
});
describe('charged shots', () => {
it('scales the shot by the strength actually paid, not the requested charge', () => {
const { container } = makeWorld();

View file

@ -15,13 +15,19 @@ const {
CreateObjectsCommand,
DeleteObjectsCommand,
PropertyUpdatesForObjects,
PropertyUpdatesForObject,
UpdatePropertyCommand,
InputAcknowledgement,
UpdateMinimap,
ServerAnnouncement,
} = shared;
class FakeSocket {
public readonly sent: Array<{ event: string; payload: unknown }> = [];
public readonly conn = { transport: { socket: { bufferedAmount: 0 } } };
public readonly conn = {
transport: { socket: { bufferedAmount: 0 } },
writeBuffer: [] as Array<{ data: unknown }>,
};
public set bufferedAmount(bytes: number) {
this.conn.transport.socket.bufferedAmount = bytes;
@ -58,7 +64,19 @@ const queueOneSnapshot = (player: ReturnType<typeof makePlayer>['player']) => {
player.queueCommandSend(new DeleteObjectsCommand([1234]));
player.queueCommandSend(new ServerAnnouncement('team blue wins'));
player.queueCommandSend(new UpdateMinimap([]));
player.queueCommandSend(new PropertyUpdatesForObjects([], 1));
player.queueCommandSend(
new PropertyUpdatesForObjects(
[
new PropertyUpdatesForObject(1234, [
new UpdatePropertyCommand('ownership', 1, 0),
]),
],
1,
),
);
player.queueCommandSend(
new InputAcknowledgement(123, player.character!.movementSnapshot, 0, 0),
);
};
const typesIn = (batch: Array<{ constructor: { name: string } }>) =>
@ -80,6 +98,7 @@ describe('slow-client backpressure', () => {
ServerAnnouncement.name,
UpdateMinimap.name,
PropertyUpdatesForObjects.name,
InputAcknowledgement.name,
]);
});
@ -89,7 +108,8 @@ describe('slow-client backpressure', () => {
queueOneSnapshot(player);
player.sendQueuedCommandsToClient();
expect(typesIn(socket.lastBatch)).not.toContain(PropertyUpdatesForObjects.name);
const marker = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects);
expect(marker).toMatchObject({ updates: [], timestamp: 1 });
});
it('sheds only the state the next tick regenerates', () => {
@ -99,7 +119,8 @@ describe('slow-client backpressure', () => {
player.sendQueuedCommandsToClient();
const types = typesIn(socket.lastBatch);
expect(types).not.toContain(PropertyUpdatesForObjects.name);
expect(types).toContain(PropertyUpdatesForObjects.name);
expect(types).not.toContain(InputAcknowledgement.name);
expect(types).not.toContain(UpdateMinimap.name);
expect(types).toContain(CreateObjectsCommand.name);
expect(types).toContain(DeleteObjectsCommand.name);
@ -115,4 +136,32 @@ describe('slow-client backpressure', () => {
expect(socket.sent.length).toBe(before);
});
it('also measures packets waiting inside Engine.IO for the transport to drain', () => {
const { player, socket } = makePlayer();
socket.conn.writeBuffer.push({
data: Buffer.alloc(settings.maxBufferedBytesPerClient + 1),
});
queueOneSnapshot(player);
player.sendQueuedCommandsToClient();
const marker = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects);
expect(marker).toMatchObject({ updates: [], timestamp: 1 });
expect(typesIn(socket.lastBatch)).not.toContain(InputAcknowledgement.name);
});
it('sends a fresh pose and acknowledgement after the transport drains', () => {
const { player, socket } = makePlayer();
socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1;
queueOneSnapshot(player);
player.sendQueuedCommandsToClient();
socket.bufferedAmount = 0;
queueOneSnapshot(player);
player.sendQueuedCommandsToClient();
const snapshot = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects);
expect(snapshot).toMatchObject({ updates: [expect.anything()], timestamp: 1 });
expect(typesIn(socket.lastBatch)).toContain(InputAcknowledgement.name);
});
});

View file

@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRequire } from 'node:module';
const {
CreateObjectsCommand,
DeleteObjectsCommand,
PropertyUpdatesForObjects,
RemoteCallsForObjects,
RemoteCallsForObject,
RemoteCall,
UpdatePropertyCommand,
CharacterTeam,
} = createRequire(import.meta.url)('../shared/lib/main.js');
import { GameObjectContainer } from '../frontend/src/scripts/objects/game-object-container';
import { PlanetView } from '../frontend/src/scripts/objects/types/planet-view';
import { serverTimeline } from '../frontend/src/scripts/helper/server-timeline';
import { LinearInterpolator } from '../frontend/src/scripts/helper/interpolators/linear-interpolator';
import { ProjectileView } from '../frontend/src/scripts/objects/types/projectile-view';
vi.mock('../frontend/node_modules/sdf-2d', () => ({
CircleLight: class {
constructor(
public center: number[],
public color: number[],
public intensity: number,
) {}
},
}));
vi.mock('../frontend/src/scripts/objects/types/camera', () => ({
Camera: class {
draw() {}
follow() {}
},
}));
vi.mock('../frontend/src/scripts/objects/types/planet-view', () => ({
PlanetView: class {
beforeDestroy = vi.fn();
render = vi.fn();
processRemoteCalls = vi.fn();
step() {}
constructor(public id: number) {}
},
}));
vi.mock('../frontend/src/scripts/feedback-hud', () => ({ FeedbackHud: {} }));
let nowMs = 0;
beforeEach(() => {
nowMs = 0;
vi.spyOn(performance, 'now').mockImplementation(() => nowMs);
serverTimeline.reset();
});
describe('remote effects', () => {
it('plays remote effects on the render timeline and local feedback immediately', () => {
const container = new GameObjectContainer({} as never);
const remote = new PlanetView(1, []);
const local = {
id: 2,
health: 100,
position: [0, 0],
snapshotStrength: 80,
step() {},
processRemoteCalls: vi.fn(),
};
container.handleCommand(new CreateObjectsCommand([remote, local]));
container.player = local as never;
const calls = [new RemoteCall('onShoot', [40])];
container.handleCommand(
new RemoteCallsForObjects([
new RemoteCallsForObject(remote.id, calls),
new RemoteCallsForObject(local.id, calls),
]),
);
container.handleCommand(new PropertyUpdatesForObjects([], 5));
expect(local.processRemoteCalls).toHaveBeenCalledWith(calls);
step(0);
container.step(0);
expect(remote.processRemoteCalls).not.toHaveBeenCalled();
step(0.11);
container.step(0);
expect(remote.processRemoteCalls).toHaveBeenCalledWith(calls);
});
it('discards queued effects when a view is replaced or the game resets', () => {
const container = new GameObjectContainer({} as never);
const original = new PlanetView(1, []);
container.handleCommand(new CreateObjectsCommand([original]));
container.handleCommand(
new RemoteCallsForObjects([
new RemoteCallsForObject(1, [new RemoteCall('onFlipped', ['red'])]),
]),
);
container.handleCommand(new PropertyUpdatesForObjects([], 5));
const replacement = new PlanetView(1, []);
container.handleCommand(new CreateObjectsCommand([replacement]));
container.handleCommand(new PropertyUpdatesForObjects([], 5.04));
step(0.3);
container.step(0);
expect(original.processRemoteCalls).not.toHaveBeenCalled();
expect(replacement.processRemoteCalls).not.toHaveBeenCalled();
container.handleCommand(
new RemoteCallsForObjects([
new RemoteCallsForObject(1, [new RemoteCall('onFlipped', ['blue'])]),
]),
);
container.reset();
container.step(0);
expect(replacement.processRemoteCalls).not.toHaveBeenCalled();
});
});
afterEach(() => vi.restoreAllMocks());
const step = (deltaSeconds: number) => {
nowMs += deltaSeconds * 1000;
serverTimeline.step(deltaSeconds);
};
describe('snapshot object lifetime', () => {
it('keeps creation and deletion aligned with the delayed render timeline', () => {
const container = new GameObjectContainer({} as never);
const planet = new PlanetView(1, []);
container.handleCommand(new CreateObjectsCommand([planet]));
container.handleCommand(new PropertyUpdatesForObjects([], 5));
step(0);
container.render({} as never, {} as never, true);
expect(planet.render).not.toHaveBeenCalled();
step(0.11);
container.render({} as never, {} as never, true);
expect(planet.render).toHaveBeenCalledOnce();
container.handleCommand(new DeleteObjectsCommand([1]));
container.handleCommand(new PropertyUpdatesForObjects([], 5.2));
container.step(0);
expect(planet.beforeDestroy).not.toHaveBeenCalled();
step(0.3);
container.step(0);
expect(planet.beforeDestroy).toHaveBeenCalledOnce();
expect(container.planets).toHaveLength(0);
});
it('does not delete a replacement when an object re-enters before delayed retirement', () => {
const container = new GameObjectContainer({} as never);
const original = new PlanetView(1, []);
container.handleCommand(new CreateObjectsCommand([original]));
container.handleCommand(new PropertyUpdatesForObjects([], 5));
step(0);
container.handleCommand(new DeleteObjectsCommand([1]));
container.handleCommand(new PropertyUpdatesForObjects([], 5.04));
const replacement = new PlanetView(1, []);
container.handleCommand(new CreateObjectsCommand([replacement]));
container.handleCommand(new PropertyUpdatesForObjects([], 5.08));
step(0.3);
container.step(0);
container.render({} as never, {} as never, true);
expect(original.beforeDestroy).toHaveBeenCalledOnce();
expect(replacement.beforeDestroy).not.toHaveBeenCalled();
expect(replacement.render).toHaveBeenCalledOnce();
expect(container.planets).toEqual([replacement]);
});
});
describe('remote render timeline', () => {
it('samples projectile strength on the server timeline, including slowed fading', () => {
const projectile = new ProjectileView(1, [0, 0] as never, 10, CharacterTeam.red, 100);
serverTimeline.onSnapshot(5);
projectile.updateProperty(new UpdatePropertyCommand('strength', 100, -10));
serverTimeline.onSnapshot(5.2);
projectile.updateProperty(new UpdatePropertyCommand('strength', 98, -10));
step(0);
projectile.step(2);
expect(projectile.strength).toBeCloseTo(99, 6);
expect([...projectile.center]).toEqual([0, 0]);
});
it('does not accumulate an extra frame of lead while chasing its target', () => {
serverTimeline.onSnapshot(10);
step(0);
for (let i = 0; i < 600; i++) step(1 / 60);
expect(serverTimeline.renderTime).toBeCloseTo(19.9, 8);
});
it('interpolates positions and bounds extrapolation while snapshots are missing', () => {
const position = new LinearInterpolator(0);
serverTimeline.onSnapshot(10);
position.addFrame(0, 100);
serverTimeline.onSnapshot(10.1);
position.addFrame(10, 100);
step(0);
step(0.05);
expect(position.getValue(0.05)).toBeCloseTo(5, 6);
step(0.4);
expect(position.getValue(0.4)).toBeCloseTo(16, 6);
});
it('ages a new snapshot from its arrival, not the beginning of the render frame', () => {
nowMs = 15;
serverTimeline.onSnapshot(10);
nowMs = 16;
serverTimeline.step(0.016);
expect(serverTimeline.renderTime).toBeCloseTo(9.901, 6);
});
});

View file

@ -81,4 +81,21 @@ describe('client send cadence', () => {
expect(batches).toHaveLength(0);
});
it('discards commands from the previous connection before the next join', () => {
const batches: Array<string> = [];
const socket = new CommandSocket({
connected: true,
emit: (_event: string, payload: string) => batches.push(payload),
} as never);
setFrameTimeMs(1000);
socket.sendQueuedCommands();
socket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 1000));
socket.reset();
socket.sendQueuedCommands();
expect(batches).toHaveLength(2);
expect(batches[1]).toContain(ClientHeartbeatCommand.name);
expect(batches[1]).not.toContain(MoveActionCommand.name);
});
});

View file

@ -0,0 +1,302 @@
import { EventEmitter } from 'node:events';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { KeyboardListener } from '../frontend/src/scripts/commands/keyboard-listener';
import { TouchListener } from '../frontend/src/scripts/commands/touch-listener';
import { JoinFormHandler } from '../frontend/src/scripts/join-form-handler';
import { initializeOptions, options } from '../frontend/src/scripts/options-handler';
import { loadStoredValue, saveStoredValue } from '../frontend/src/scripts/helper/storage';
const { connect } = vi.hoisted(() => ({ connect: vi.fn() }));
vi.mock('../frontend/node_modules/socket.io-client/build/esm-debug/index.js', () => ({
io: connect,
}));
vi.mock('../frontend/src/scripts/configuration', () => ({
servers: ['https://first.test', 'https://second.test'],
}));
vi.mock('../frontend/src/scripts/sound-handler', () => ({
Sounds: { click: 'click' },
SoundHandler: { play: vi.fn(), playAmbient: vi.fn(), stopAmbient: vi.fn() },
}));
vi.mock('../frontend/src/scripts/charge-indicator', () => ({
ChargeIndicator: { begin: vi.fn(), end: vi.fn() },
}));
// Only the DOM operations exercised by the chooser and input listeners are needed.
class Element extends EventTarget {
public children: Element[] = [];
public parentElement?: Element;
public style: Record<string, string> = {};
public className = '';
public checked = false;
public disabled = false;
public onsubmit: ((event: Event) => void) | null = null;
public appendChild(child: Element) {
child.remove();
this.children.push(child);
child.parentElement = this;
}
public append(...children: Element[]) {
children.forEach((child) => this.appendChild(child));
}
public remove() {
if (this.parentElement) {
this.parentElement.children = this.parentElement.children.filter((c) => c !== this);
this.parentElement = undefined;
}
}
public querySelector() {
return this.children[0];
}
public getBoundingClientRect() {
return { left: 0, top: 0, width: 100, height: 100 };
}
}
class Socket extends EventEmitter {
public io = new EventEmitter();
public close = vi.fn();
}
const dispatch = (target: EventTarget, type: string, fields = {}) => {
const event = Object.assign(new Event(type, { cancelable: true }), fields);
target.dispatchEvent(event);
return event;
};
const touch = (identifier: number, clientX = 50, clientY = 50) => ({
identifier,
clientX,
clientY,
});
const touchEvent = (
target: EventTarget,
type: string,
changedTouches: unknown[],
touches = changedTouches,
) => dispatch(target, type, { changedTouches, touches });
let windowTarget: EventTarget;
let sockets: Socket[];
beforeEach(() => {
vi.useFakeTimers();
windowTarget = new EventTarget();
sockets = [];
vi.stubGlobal('window', windowTarget);
vi.stubGlobal('addEventListener', windowTarget.addEventListener.bind(windowTarget));
vi.stubGlobal(
'removeEventListener',
windowTarget.removeEventListener.bind(windowTarget),
);
vi.stubGlobal('document', { createElement: () => new Element() });
vi.stubGlobal('localStorage', { getItem: vi.fn(() => null), setItem: vi.fn() });
connect.mockImplementation(() => {
const socket = new Socket();
sockets.push(socket);
return socket;
});
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
const chooser = () => {
const form = new Element();
form.appendChild(new Element());
const container = new Element();
const handler = new JoinFormHandler(form as never, container as never);
return { form, container, handler };
};
const serverInfo = {
serverName: 'test',
playerCount: 1,
playerLimit: 8,
gameStatePercent: 20,
};
describe('server chooser lifecycle', () => {
it('aborts discovery and ignores responses completing after the chooser closes', async () => {
const signals: AbortSignal[] = [];
let resolveBody!: (value: unknown) => void;
const body = new Promise((resolve) => {
resolveBody = resolve;
});
vi.stubGlobal(
'fetch',
vi.fn((_url, { signal }) => {
signals.push(signal);
return Promise.resolve({ ok: true, json: () => body });
}),
);
const { form, container, handler } = chooser();
await Promise.resolve();
handler.destroy();
resolveBody(serverInfo);
await vi.runAllTimersAsync();
expect(signals.every((signal) => signal.aborted)).toBe(true);
expect(sockets).toHaveLength(0);
expect(container.children).toHaveLength(0);
expect(form.onsubmit).toBeNull();
expect(vi.getTimerCount()).toBe(0);
});
it('selects a remaining server when the selected connection fails and removes listeners on exit', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: true, json: async () => serverInfo })),
);
const { form, container, handler } = chooser();
await vi.advanceTimersByTimeAsync(0);
expect(container.children).toHaveLength(2);
expect(container.children[0].children[0].checked).toBe(true);
expect(container.children[1].children[0].checked).toBe(false);
sockets[0].io.emit('reconnect_failed');
expect(container.children).toHaveLength(1);
expect(container.children[0].children[0].checked).toBe(true);
expect(form.children[0].disabled).toBe(false);
handler.destroy();
expect(
sockets.every((socket) => socket.io.listenerCount('reconnect_failed') === 0),
).toBe(true);
expect(container.children).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
});
});
describe('movement input', () => {
it('ignores unrelated keys, prevents browser movement defaults, and resends held keys after respawn', () => {
const send = vi.fn();
const listener = new KeyboardListener(send);
dispatch(windowTarget, 'keydown', { code: 'KeyP' });
expect(send).not.toHaveBeenCalled();
const key = dispatch(windowTarget, 'keydown', { code: 'ArrowRight' });
expect(key.defaultPrevented).toBe(true);
expect(send.mock.lastCall?.[0].direction).toEqual([1, 0]);
listener.resendMovement();
expect(send).toHaveBeenCalledTimes(2);
dispatch(windowTarget, 'blur');
expect(send.mock.lastCall?.[0].direction).toEqual([0, 0]);
listener.resendMovement();
expect(send).toHaveBeenCalledTimes(3);
listener.destroy();
});
});
const touchControls = () => {
const canvas = new Element();
const overlay = new Element();
const send = vi.fn();
const game = {
displayToWorldCoordinates: (position: number[]) => position,
gameObjects: {
localPlayer: { bodyCenter: [0, 0], facingDirection: [1, 0], strengthFraction: 1 },
},
};
const listener = new TouchListener(
canvas as never,
overlay as never,
game as never,
send,
);
listener.update();
return { canvas, overlay, send, listener, fire: overlay.children[0] };
};
describe('touch ownership', () => {
it('keeps the first canvas gesture when a second finger lands before dragging', () => {
const { canvas, send, listener } = touchControls();
touchEvent(canvas, 'touchstart', [touch(1, 10, 20)]);
touchEvent(
canvas,
'touchstart',
[touch(2, 80, 90)],
[touch(1, 10, 20), touch(2, 80, 90)],
);
touchEvent(canvas, 'touchend', [touch(2, 80, 90)], [touch(1, 10, 20)]);
expect(send).not.toHaveBeenCalled();
touchEvent(canvas, 'touchend', [touch(1, 10, 20)], []);
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.lastCall?.[0].constructor.name).toBe('PrimaryActionCommand');
listener.destroy();
});
it('does not fire when a charge is cancelled or another finger releases', () => {
const { fire, send, listener } = touchControls();
touchEvent(fire, 'touchstart', [touch(1)]);
touchEvent(fire, 'touchstart', [touch(2)]);
touchEvent(fire, 'touchend', [touch(2)], [touch(1)]);
expect(send).not.toHaveBeenCalled();
touchEvent(fire, 'touchcancel', [touch(1)], []);
touchEvent(fire, 'touchend', [touch(1)], []);
expect(send).not.toHaveBeenCalled();
touchEvent(fire, 'touchstart', [touch(3)]);
touchEvent(fire, 'touchend', [touch(3)], []);
expect(send).toHaveBeenCalledTimes(1);
listener.destroy();
});
it('resends a held joystick after respawn and stops movement when focus is lost', () => {
const { canvas, overlay, send, listener } = touchControls();
touchEvent(canvas, 'touchstart', [touch(1)]);
touchEvent(canvas, 'touchmove', [touch(1, 100, 50)]);
listener.resendMovement();
expect(send.mock.lastCall?.[0].direction[0]).toBe(1);
expect(send.mock.lastCall?.[0].direction[1]).toBeCloseTo(0);
dispatch(windowTarget, 'blur');
expect(send.mock.lastCall?.[0].direction).toEqual([0, 0]);
expect(overlay.children).toHaveLength(2);
touchEvent(canvas, 'touchstart', [touch(2)]);
touchEvent(canvas, 'touchmove', [touch(2, 100, 50)]);
listener.destroy();
expect(overlay.children).toHaveLength(0);
});
});
describe('stored preferences', () => {
it('ignores corrupt JSON and unavailable storage', () => {
vi.mocked(localStorage.getItem).mockReturnValue('{');
expect(loadStoredValue('userName')).toBeUndefined();
vi.mocked(localStorage.getItem).mockImplementation(() => {
throw new Error('disabled');
});
vi.mocked(localStorage.setItem).mockImplementation(() => {
throw new Error('disabled');
});
expect(loadStoredValue('options')).toBeUndefined();
expect(() => saveStoredValue('userName', 'player')).not.toThrow();
});
it('only loads boolean options that the game recognizes', () => {
Object.assign(options, {
soundsEnabled: true,
musicEnabled: true,
vibrationEnabled: true,
});
vi.mocked(localStorage.getItem).mockReturnValue(
JSON.stringify({
soundsEnabled: 'false',
musicEnabled: false,
vibrationEnabled: null,
extra: true,
}),
);
initializeOptions({
soundsEnabled: new Element(),
musicEnabled: new Element(),
vibrationEnabled: new Element(),
} as never);
expect(options).toEqual({
soundsEnabled: true,
musicEnabled: false,
vibrationEnabled: true,
});
});
});

View file

@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { createRequire } from 'node:module';
import { execFileSync } from 'node:child_process';
const require = createRequire(import.meta.url);
const shared = require('../shared/lib/main.js');
@ -99,6 +100,41 @@ const runPlanetSimulation = () => {
};
describe('shared character simulation determinism', () => {
it('does not depend on configuring vector precision before imports', () => {
const run = (configureBeforeImport) =>
execFileSync(
process.execPath,
[
'-e',
`
const { glMatrix, vec2 } = require('./shared/node_modules/gl-matrix');
if (${configureBeforeImport}) glMatrix.setMatrixArrayType(Array);
const s = require('./shared/lib/main.js');
glMatrix.setMatrixArrayType(Array);
const body = (offset, radius) => ({
center: vec2.clone(offset), radius, velocity: vec2.create(),
lastNormal: [0, 1], restitution: 0,
});
const state = {
head: body(s.headOffset, s.headRadius),
leftFoot: body(s.leftFootOffset, s.feetRadius),
rightFoot: body(s.rightFootOffset, s.feetRadius),
direction: 0, currentPlanet: undefined,
secondsSinceOnSurface: 1, bodyVelocity: [0, 0],
};
const world = {
groundsNear: () => [],
stepBody: (body, dt) => s.resolveCircleMovement(body, dt, []),
};
for (let i = 0; i < 100; i++) s.stepCharacterMovement(state, world, [1, 0], 0.005);
process.stdout.write(JSON.stringify(state));
`,
],
{ encoding: 'utf8' },
);
expect(run(false)).toBe(run(true));
});
it('produces identical output across independent runs', () => {
expect(runSimulation()).toBe(runSimulation());
});

View file

@ -38,7 +38,7 @@ describe('local prediction reconciliation', () => {
auth.rightFoot as never,
);
const used = predictor.update([], 1 / 60);
const used = predictor.update([]);
expect(used).toBe(true);
expect(predictor.head.center[0]).toBeCloseTo(auth.head.center[0], 5);
@ -62,7 +62,7 @@ describe('local prediction reconciliation', () => {
predictor.recordInput([1, 0]);
setFrameTimeMs(1100);
predictor.update([], 1 / 60);
predictor.update([]);
return [
predictor.head.center[0],
predictor.head.center[1],
@ -94,7 +94,7 @@ describe('local prediction reconciliation', () => {
predictor.setAlive(false);
setFrameTimeMs(5200);
expect(predictor.update([], 1 / 60)).toBe(false);
expect(predictor.update([])).toBe(false);
});
it('replays the whole un-acknowledged span, so a longer trip predicts further', () => {
@ -113,7 +113,7 @@ describe('local prediction reconciliation', () => {
auth.rightFoot as never,
);
predictor.update([], 1 / 60);
predictor.update([]);
return predictor.head.center[0];
};
@ -146,7 +146,7 @@ describe('local prediction reconciliation', () => {
auth.rightFoot as never,
);
predictor.update([], 1 / 60);
predictor.update([]);
return predictor.head.center[0];
};
@ -175,7 +175,7 @@ describe('local prediction reconciliation', () => {
);
setFrameTimeMs(40);
predictor.update([], 1 / 60);
predictor.update([]);
return [predictor.head.center[0], predictor.head.center[1]];
};
@ -189,3 +189,127 @@ describe('local prediction reconciliation', () => {
expect(apart).toBeGreaterThan(1);
});
});
const settledPoseAt = (x: number) => ({
head: { center: [x, 55 - 55 / 3], radius: HEAD_RADIUS },
leftFoot: { center: [x - 20, -55 / 3], radius: FEET_RADIUS },
rightFoot: { center: [x + 20, -55 / 3], radius: FEET_RADIUS },
});
const install = (predictor: LocalCharacterPredictor, timeMs: number, x: number) => {
const pose = settledPoseAt(x);
predictor.acknowledge(timeMs, movementState() as never, -Infinity);
predictor.setAuthoritative(
pose.head as never,
pose.leftFoot as never,
pose.rightFoot as never,
);
};
describe('prediction corrections and interruptions', () => {
it('blends an authoritative correction and converges without slowing ordinary motion', () => {
const predictor = new LocalCharacterPredictor();
setFrameTimeMs(1000);
install(predictor, 1000, 0);
predictor.update([]);
setFrameTimeMs(1016);
install(predictor, 1016, 100);
predictor.update([]);
expect(predictor.head.center[0]).toBeCloseTo(0, 5);
setFrameTimeMs(1032);
predictor.update([]);
expect(predictor.head.center[0]).toBeGreaterThan(0);
expect(predictor.head.center[0]).toBeLessThan(100);
setFrameTimeMs(1516);
predictor.update([]);
expect(predictor.head.center[0]).toBeCloseTo(100, 0);
});
it('freezes at the replay horizon during an outage even if held input changes', () => {
const predictor = new LocalCharacterPredictor();
setFrameTimeMs(0);
install(predictor, 0, 0);
predictor.recordInput([1, 0] as never);
setFrameTimeMs(400);
predictor.update([]);
const frozen = [...predictor.head.center];
setFrameTimeMs(450);
predictor.recordInput([-1, 0] as never);
setFrameTimeMs(600);
predictor.update([]);
expect([...predictor.head.center]).toEqual(frozen);
setFrameTimeMs(2000);
predictor.recordInput([0, 0] as never);
setFrameTimeMs(2500);
predictor.update([]);
expect([...predictor.head.center]).toEqual(frozen);
});
it('clears visual correction on respawn and disables prediction for end-game snapshots', () => {
const predictor = new LocalCharacterPredictor();
setFrameTimeMs(1000);
install(predictor, 1000, 0);
predictor.update([]);
install(predictor, 1000, 100);
predictor.update([]);
predictor.reset();
install(predictor, 1000, 1000);
predictor.update([]);
expect(predictor.head.center[0]).toBe(1000);
predictor.enabled = false;
expect(predictor.update([])).toBe(false);
predictor.reset();
install(predictor, 1000, 1000);
expect(predictor.update([])).toBe(false);
});
});
const ground = {
id: 1,
vertices: Array.from({ length: 15 }, (_, i) => {
const angle = (i / 15) * -Math.PI * 2;
return [800 * Math.cos(angle), 800 * Math.sin(angle)];
}),
snapshotRotation: 0,
snapshotRotationSpeed: 0,
};
const leapHeight = (cooldown: number, leap: boolean) => {
const predictor = new LocalCharacterPredictor();
setFrameTimeMs(1000);
const pose = poseAt(0, 900);
predictor.acknowledge(
1000,
{
...movementState(),
groundPlanetId: 1,
secondsSinceOnSurface: 0,
leapCooldownRemaining: cooldown,
} as never,
-Infinity,
);
predictor.setAuthoritative(
pose.head as never,
pose.leftFoot as never,
pose.rightFoot as never,
);
if (leap) predictor.recordLeap();
setFrameTimeMs(1002);
predictor.update([ground] as never);
return predictor.head.center[1];
};
describe('leap prediction', () => {
it('applies a newly pressed leap in the very next fractional render tick', () => {
expect(leapHeight(0, true)).toBeGreaterThan(leapHeight(0, false) + 1);
});
it('does not predict a leap the authoritative cooldown will reject', () => {
expect(leapHeight(0.2, true)).toEqual(leapHeight(0.2, false));
});
});

View file

@ -10,7 +10,6 @@ import {
headRadius,
feetRadius,
} from '../shared/src/physics/character-movement';
import { followVec2 } from '../shared/src/helper/ease';
import { settings } from '../shared/src/settings';
const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds;
@ -51,20 +50,23 @@ interface Conditions {
dropRate: number;
/** Server work that can delay a socket callback past the packet's arrival. */
serverBusyMs: number;
wireJitterMs?: number;
emitJitterMs?: number;
changeInput?: boolean;
}
interface Result {
/** Peak-to-peak variation in apparent speed, over its mean. Zero means perfectly even motion. */
speedSpread: number;
/** Peak-to-peak wobble of the character's screen position about its own mean, in world units. */
screenWobble: number;
/** How far the rendered character trails the true server pose, in world units. */
lagUnits: number;
finalError: number;
maximumSpeed: number;
}
// Runs a real server tick loop, a real wire, and a real render loop against the predictor, and
// reports how steady the character's *on-screen* travel is. On-screen, not in world space: the
// camera follows the character, so what the player sees is the residual between the two.
// Exercise shared movement with independent server, network, and render clocks.
// Measure world travel: the camera tracks the local character exactly, so measuring
// their relative position would always report zero even when the world visibly stutters.
const measure = ({
fps,
wireMs,
@ -72,6 +74,9 @@ const measure = ({
frameJitter,
dropRate,
serverBusyMs,
wireJitterMs = 0,
emitJitterMs = 0,
changeInput = false,
}: Conditions): Result => {
const world = new ClientCharacterWorld();
world.sync([planetAt(0)]);
@ -86,28 +91,30 @@ const measure = ({
bodyVelocity: [0, 0],
};
const input = [1, 0];
let input = [1, 0];
let serverInput = [1, 0];
const predictor = new LocalCharacterPredictor();
predictor.setStrength(settings.playerMaxStrength);
// Deterministic frame-time noise: a real display never delivers a perfectly even cadence.
let seed = 12345;
const random = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
const random = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
const nominalFrameMs = 1000 / fps;
const toServer: Array<{ atMs: number; clientStampMs: number }> = [];
const toClient: Array<{ atMs: number; ackAgeMs: number; clientStampMs: number; pose: any }> = [];
const toServer: Array<{ atMs: number; clientStampMs: number; direction?: number[] }> =
[];
const toClient: Array<{
atMs: number;
ackAgeMs: number;
clientStampMs: number;
pose: any;
}> = [];
const samples: Array<{
screen: Array<number>;
world: Array<number>;
atMs: number;
error: number;
}> = [];
// The client's own view of the character, and the camera that chases it.
const camera = [0, planetRadius + 90];
const cameraPreviousTarget = [0, planetRadius + 90];
let simMs = 0;
let nextSnapshotMs = 0;
let nextFrameAtMs = 0;
@ -115,27 +122,33 @@ const measure = ({
let lastInputClientTimeMs = 0;
let lastInputReceiptMs = 0;
let hasPressed = false;
let lastServerArrivalMs = 0;
let lastClientArrivalMs = 0;
while (simMs < 8000) {
tickPlanetDetachment(server, stepSeconds);
stepCharacterMovement(server, world, input.slice() as never, stepSeconds);
stepCharacterMovement(server, world, serverInput.slice() as never, stepSeconds);
simMs += stepSeconds * 1000;
while (toServer.length > 0 && toServer[0].atMs <= simMs) {
const packet = toServer.shift()!;
if (packet.direction) serverInput = packet.direction;
if (packet.clientStampMs > lastInputClientTimeMs) {
lastInputClientTimeMs = packet.clientStampMs;
// Socket callbacks run between physics frames, so this is the true arrival instant,
// not the frame boundary — plus however long the frame in progress still had to run.
lastInputReceiptMs = packet.atMs + serverBusyMs;
lastInputReceiptMs = packet.atMs;
}
}
if (simMs >= nextSnapshotMs) {
nextSnapshotMs += snapshotMs;
lastClientArrivalMs = Math.max(
lastClientArrivalMs,
simMs + Math.max(0, wireMs + (random() * 2 - 1) * wireJitterMs),
);
toClient.push({
atMs: simMs + wireMs,
atMs: lastClientArrivalMs,
clientStampMs: lastInputClientTimeMs,
ackAgeMs: lastInputReceiptMs > 0 ? Math.max(0, simMs - lastInputReceiptMs) : 0,
pose: {
@ -175,8 +188,16 @@ const measure = ({
// Movement input is edge-triggered, exactly as KeyboardListener sends it: recorded once
// when the key goes down, then held by both sides.
if (!hasPressed) {
const desiredInput =
changeInput && nextFrameAtMs >= 4500
? nextFrameAtMs >= 6000
? [0, 0]
: [-1, 0]
: [1, 0];
const inputChanged = !hasPressed || desiredInput[0] !== input[0];
if (inputChanged) {
hasPressed = true;
input = desiredInput;
predictor.recordInput(input as never);
}
@ -185,30 +206,35 @@ const measure = ({
const frameMs = nominalFrameMs * jitter * (dropped ? 2 : 1);
if (!dropped) {
predictor.update([planetAt(0)], frameMs / 1000);
predictor.update([planetAt(0)]);
const head = predictor.head.center;
followVec2(
camera as never,
head,
cameraPreviousTarget as never,
frameMs / 1000,
0.08,
1500,
);
samples.push({
screen: [head[0] - camera[0], head[1] - camera[1]],
world: [head[0], head[1]],
atMs: frameClientMs,
error: Math.hypot(head[0] - server.head.center[0], head[1] - server.head.center[1]),
error: Math.hypot(
head[0] - server.head.center[0],
head[1] - server.head.center[1],
),
});
}
// Stamped at the top of the frame, emitted at the bottom.
if (frameClientMs - lastHeartbeatClientMs >= heartbeatMs - 1) {
if (
inputChanged ||
(!dropped && frameClientMs - lastHeartbeatClientMs >= heartbeatMs - 1)
) {
lastHeartbeatClientMs = frameClientMs;
lastServerArrivalMs = Math.max(
lastServerArrivalMs,
nextFrameAtMs +
serverBusyMs +
Math.max(0, emitLagMs + (random() * 2 - 1) * emitJitterMs) +
Math.max(0, wireMs + (random() * 2 - 1) * wireJitterMs),
);
toServer.push({
atMs: nextFrameAtMs + emitLagMs + wireMs,
atMs: lastServerArrivalMs,
clientStampMs: frameClientMs,
direction: inputChanged ? input.slice() : undefined,
});
}
@ -229,60 +255,115 @@ const measure = ({
);
const meanSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
// Screen position: the camera settles to a constant lag, so anything left is visible wobble.
const centre = [0, 1].map((axis) => {
const values = tail.map((s) => s.screen[axis]);
return values.reduce((a, b) => a + b, 0) / values.length;
});
const wobble = Math.max(
...tail.map((s) => Math.hypot(s.screen[0] - centre[0], s.screen[1] - centre[1])),
);
if ((globalThis as any).__dump) {
console.log('--- apparent speed (u/ms) ---');
speeds.slice(0, 40).forEach((v, i) => console.log(`${i} ${v.toFixed(4)}`));
}
return {
speedSpread: (Math.max(...speeds) - Math.min(...speeds)) / meanSpeed,
screenWobble: wobble,
lagUnits: tail.reduce((sum, s) => sum + s.error, 0) / tail.length,
finalError: tail[tail.length - 1].error,
maximumSpeed: Math.max(...speeds),
};
};
const CONDITIONS: Array<[string, Conditions]> = [
['localhost, clean frames', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }],
['localhost, 144 Hz', { fps: 144, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }],
['localhost, frame jitter', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.15, dropRate: 0, serverBusyMs: 0 }],
['localhost, 2% dropped', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.05, dropRate: 0.02, serverBusyMs: 0 }],
['localhost, variable CPU', { fps: 60, wireMs: 0.2, emitLagMs: 8, frameJitter: 0.15, dropRate: 0.02, serverBusyMs: 0 }],
['30 ms link', { fps: 60, wireMs: 15, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }],
['120 ms link', { fps: 60, wireMs: 60, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }],
[
'localhost, clean frames',
{ fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 },
],
[
'localhost, 144 Hz',
{ fps: 144, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 },
],
[
'localhost, frame jitter',
{
fps: 60,
wireMs: 0.2,
emitLagMs: 2,
frameJitter: 0.15,
dropRate: 0,
serverBusyMs: 0,
},
],
[
'localhost, 2% dropped',
{
fps: 60,
wireMs: 0.2,
emitLagMs: 2,
frameJitter: 0.05,
dropRate: 0.02,
serverBusyMs: 0,
},
],
[
'localhost, slower CPU',
{
fps: 60,
wireMs: 0.2,
emitLagMs: 8,
frameJitter: 0.15,
dropRate: 0.02,
serverBusyMs: 0,
},
],
[
'30 ms link',
{
fps: 60,
wireMs: 15,
emitLagMs: 2,
frameJitter: 0.05,
dropRate: 0,
serverBusyMs: 0,
},
],
[
'120 ms link',
{
fps: 60,
wireMs: 60,
emitLagMs: 2,
frameJitter: 0.05,
dropRate: 0,
serverBusyMs: 0,
},
],
];
describe('local character render smoothness', () => {
it('keeps on-screen travel even under realistic clocks and frame pacing', () => {
;(globalThis as any).__dump = true
measure(CONDITIONS[0][1])
;(globalThis as any).__dump = false
const results: Array<[string, Result]> = CONDITIONS.map(([label, c]) => [label, measure(c)]);
it.each(CONDITIONS)('keeps travel even with %s', (_label, conditions) => {
const result = measure(conditions);
expect(result.speedSpread).toBeLessThan(0.05);
if (conditions.wireMs < 1) expect(result.lagUnits).toBeLessThan(5);
});
for (const [label, r] of results) {
console.log(
`${label.padEnd(26)} speed=${(r.speedSpread * 100).toFixed(1).padStart(6)}% ` +
`wobble=${r.screenWobble.toFixed(2).padStart(6)}u ` +
`lag=${r.lagUnits.toFixed(1).padStart(5)}u `,
);
}
it('absorbs changing network and client CPU delay without snapshot jumps', () => {
const result = measure({
fps: 144,
wireMs: 30,
emitLagMs: 5,
wireJitterMs: 10,
emitJitterMs: 5,
frameJitter: 0.15,
dropRate: 0.02,
serverBusyMs: 2,
});
expect(result.speedSpread).toBeLessThan(0.4);
expect(result.lagUnits).toBeLessThan(25);
});
for (const [label, r] of results) {
// Even travel: what the eye reads as smooth motion.
expect(r.speedSpread, label).toBeLessThan(0.05);
// No sub-pixel swimming against the camera, which follows the same character.
expect(r.screenWobble, label).toBeLessThan(1);
}
// Prediction exists to cancel latency; a follower that lags undoes that on every link.
const localhost = results.find(([label]) => label === 'localhost, clean frames')![1];
expect(localhost.lagUnits).toBeLessThan(5);
it('reconciles direction changes and stopping across an irregular link', () => {
const result = measure({
fps: 144,
wireMs: 30,
emitLagMs: 5,
wireJitterMs: 10,
emitJitterMs: 5,
frameJitter: 0.15,
dropRate: 0.02,
serverBusyMs: 2,
changeInput: true,
});
expect(result.maximumSpeed).toBeLessThan(1);
expect(result.finalError).toBeLessThan(3);
});
});

View file

@ -22,9 +22,17 @@ const {
headOffset,
leftFootOffset,
rightFootOffset,
CharacterMovementSnapshot,
} = shared;
describe('serialization round-trip (built shared lib)', () => {
it('preserves the authoritative leap cooldown for prediction', () => {
const snapshot = new CharacterMovementSnapshot(0, [1, 2], [0, 1], [0, 1], 5, 0, 0.25);
const out = deserialize(serialize(snapshot));
expect(out).toBeInstanceOf(CharacterMovementSnapshot);
expect(out.leapCooldownRemaining).toBe(0.25);
});
it('reconstructs a ServerAnnouncement as the right class with its payload', () => {
const [out] = deserialize(serialize([new ServerAnnouncement('Hello <b>world</b>')]));
expect(out).toBeInstanceOf(ServerAnnouncement);

View file

@ -1,4 +1,5 @@
// Production makes every vector an f64 Array before any module-level vector is allocated.
// Use production's f64 vector type in every package. The import-order regression separately
// verifies that physics constants also retain full precision before startup configures it.
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);

View file

@ -0,0 +1,111 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { marchCircle } from '../shared/src/physics/march-circle';
import { resolveCircleMovement } from '../shared/src/physics/resolve-circle-movement';
import { finiteVec2, sanitizeName } from '../shared/src/helper/finite';
import { CommandReceiver } from '../shared/src/commands/command-receiver';
import type { PhysicsBody, Sdf } from '../shared/src/physics/sdf';
const bodyAt = (x: number, y: number, vx: number, vy: number): PhysicsBody => ({
center: [x, y],
radius: 1,
velocity: [vx, vy],
lastNormal: [0, 1],
restitution: 1,
});
const rightWall: Sdf = { canCollide: true, distance: ([x]) => 10 - x };
describe('circle collision movement', () => {
it('spends only the remaining step time after a bounce', () => {
const body = bodyAt(0, 0, 100, 0);
expect(resolveCircleMovement(body, 0.2, [rightWall])).toBe(rightWall);
// 9 units to the wall, then 11 units back: 20 units of total travel.
expect(body.center[0]).toBeCloseTo(-2, 6);
expect(body.velocity[0]).toBeCloseTo(-100, 6);
});
it('resolves velocity at the second contact instead of leaving it aimed into a wall', () => {
const body = bodyAt(0, 0, 100, 0);
const leftWall: Sdf = { canCollide: true, distance: ([x]) => x + 5 };
resolveCircleMovement(body, 0.3, [rightWall, leftWall]);
expect(body.center[0]).toBeCloseTo(-4, 6);
expect(body.velocity[0]).toBeCloseTo(100, 6);
});
it('allows slow tangential sliding along a surface', () => {
const body = { ...bodyAt(9, 0, 100, 10), restitution: 0 };
resolveCircleMovement(body, 0.1, [rightWall]);
expect(body.center[0]).toBeCloseTo(9, 6);
expect(body.center[1]).toBeCloseTo(1, 6);
expect(body.velocity[0]).toBeCloseTo(0, 6);
});
it('ignores disabled objects when identifying the contacted surface', () => {
const inactive: Sdf = { canCollide: false, distance: () => -100 };
const onHit = vi.fn();
const body = bodyAt(0, 0, 100, 0);
const result = marchCircle(body, [20, 0], [inactive, rightWall], onHit);
expect(result.hitObject).toBe(rightWall);
expect(onHit).toHaveBeenCalledExactlyOnceWith(rightWall);
expect(result.normal).toEqual([-1, 0]);
});
it('samples the collision normal before the callback removes the obstacle', () => {
const wall = { ...rightWall };
const body = bodyAt(0, 0, 100, 0);
resolveCircleMovement(body, 0.2, [wall], () => {
wall.canCollide = false;
});
expect(body.center.every(Number.isFinite)).toBe(true);
expect(body.velocity[0]).toBeCloseTo(-100, 6);
});
});
describe('network input helpers', () => {
it('clamps huge finite vectors before allocating a float vector', () => {
const direction = finiteVec2([1e300, 1e300], 1)!;
expect(direction.every(Number.isFinite)).toBe(true);
expect(Math.hypot(...direction)).toBeCloseTo(1, 6);
expect(direction[0]).toBeGreaterThan(0.7);
});
it('never calls methods on an untrusted player name', () => {
expect(sanitizeName({ toString: {} }, 10)).toBe('');
expect(sanitizeName('abcdefghijk', 10)).toBe('abcdefghij');
});
it('does not dispatch inherited object methods as commands', () => {
class Receiver extends CommandReceiver {
public fallback = vi.fn();
protected defaultCommandExecutor(command: object) {
this.fallback(command);
}
}
const receiver = new Receiver();
const command = { constructor: { name: 'constructor' } };
receiver.handleCommand(command);
expect(receiver.fallback).toHaveBeenCalledExactlyOnceWith(command);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('random world seeds', () => {
it('uses the random seed bits instead of truncating every initial seed to zero', async () => {
const random = vi.spyOn(Math, 'random');
const firstValues: number[] = [];
for (const seed of [0.1, 0.5, 0.9]) {
random.mockReturnValue(seed);
vi.resetModules();
const { Random } = await import('../shared/src/helper/random');
firstValues.push(Random.getRandom());
Random.seed = 42;
const expected = [Random.getRandom(), Random.getRandom()];
Random.seed = 42;
expect([Random.getRandom(), Random.getRandom()]).toEqual(expected);
}
expect(new Set(firstValues).size).toBe(3);
});
});