Improve netcode and simplify (#4)
All checks were successful
Build & deploy / Build & deploy website (push) Successful in 32s
Build & deploy / Build & publish server image (push) Successful in 1m2s

- Simplify the logic wherever possible to remove overengineering and simplify the netcode
- Actually improve the netcode
- The new lines are mainly the package locks and tests

Reviewed-on: https://home.schmelczer.dev/git/andras/decla-red/pulls/4
This commit is contained in:
Andras Schmelczer 2026-09-15 07:57:57 +01:00
commit 93a7fe01ab
14 changed files with 15073 additions and 4754 deletions

View file

@ -1,3 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
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":[321.49,-34.424],"leftFoot":[249.254,-51.093],"rightFoot":[281.714,-72.595],"direction":-0.315,"onSurface":true,"secondsSinceOnSurface":0}"`;

View file

@ -0,0 +1,536 @@
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,
SetAspectRatioActionCommand,
TransportEvents,
deserialize,
CreateObjectsCommand,
PropertyUpdatesForObjects,
InputAcknowledgement,
LeapActionCommand,
calculateViewArea,
} = shared;
const step = settings.targetPhysicsDeltaTimeInSeconds;
class PointsSpy {
public blue = 0;
public red = 0;
public addPoints(blue: number, red: number) {
this.blue += blue;
this.red += red;
}
public announce() {}
}
const makeWorld = () => {
const game = new PointsSpy();
return { game, container: new PhysicalContainer(game) };
};
const spawnProjectile = (container: PhysicalContainer) =>
new ProjectilePhysical(
vec2.fromValues(0, 0),
20,
40,
CharacterTeam.blue,
vec2.fromValues(3000, 0),
{ team: CharacterTeam.blue } as never,
container,
0,
);
const spawnCharacter = (container: PhysicalContainer) => {
const character = new CharacterPhysical(
'tester',
0,
0,
CharacterTeam.blue,
container,
vec2.fromValues(0, 0),
);
container.addObject(character);
return character;
};
const projectilesIn = (container: PhysicalContainer) =>
container
.findIntersecting(BoundingBox.ofCircle([0, 0], 5000))
.filter((o) => o.gameObject instanceof ProjectilePhysical)
.map((o) => o.gameObject as ProjectilePhysical);
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();
const projectile = spawnProjectile(container);
container.addObject(projectile);
for (let i = 0; i < 40; i++) {
container.step(step);
}
expect(projectile.center[0]).toBeGreaterThan(400);
expect(
container.findIntersecting(BoundingBox.ofCircle(projectile.center, 100)),
).toContain(projectile);
expect(container.findIntersecting(BoundingBox.ofCircle([0, 0], 100))).not.toContain(
projectile,
);
});
it('spawns a shot just clear of the shooter, not a jump downrange', () => {
const { container } = makeWorld();
const projectile = spawnProjectile(container);
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', () => {
it('awards nothing for an administrative death', () => {
const { game, container } = makeWorld();
const character = spawnCharacter(container);
character.onDie();
container.step(step);
expect(game.blue).toBe(0);
expect(game.red).toBe(0);
});
it('still awards a kill for a combat death', () => {
const { game, container } = makeWorld();
const character = spawnCharacter(container);
character.onDie(true);
container.step(step);
expect(game.red).toBe(settings.playerKillPoint);
expect(game.blue).toBe(0);
});
it('awards a combat death only once', () => {
const { game, container } = makeWorld();
const character = spawnCharacter(container);
character.onDie(true);
character.onDie(true);
expect(game.red).toBe(settings.playerKillPoint);
});
});
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();
const character = spawnCharacter(container);
(character as unknown as { projectileStrength: number }).projectileStrength =
settings.chargeShotStrengthMin;
character.shootTowards(vec2.fromValues(1000, 0), 1);
const [spawned] = projectilesIn(container);
expect(spawned).toBeDefined();
expect(spawned.charge).toBeCloseTo(0, 5);
expect(spawned.radius).toBeCloseTo(settings.chargeShotRadiusMin, 5);
});
it('ignores a shot aimed at the shooter itself', () => {
const { container } = makeWorld();
const character = spawnCharacter(container);
character.shootTowards(vec2.clone(character.center), 1);
expect(projectilesIn(container)).toHaveLength(0);
});
});

167
test/backpressure.test.ts Normal file
View file

@ -0,0 +1,167 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createRequire } from 'node:module';
import { PhysicalContainer } from '../backend/src/physics/physical-container';
import { PlayerContainer } from '../backend/src/players/player-container';
import { createWorld } from '../backend/src/create-world';
const require = createRequire(import.meta.url);
const shared = require('../shared/lib/main.js');
const {
Random,
settings,
TransportEvents,
deserialize,
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 } },
writeBuffer: [] as Array<{ data: unknown }>,
};
public set bufferedAmount(bytes: number) {
this.conn.transport.socket.bufferedAmount = bytes;
}
public on() {}
public off() {}
public emit(event: string, payload?: unknown) {
this.sent.push({ event, payload });
}
public get lastBatch(): Array<{ constructor: { name: string } }> {
const last = [...this.sent]
.reverse()
.find((m) => m.event === TransportEvents.ServerToPlayer);
return last ? deserialize(last.payload as string) : [];
}
}
const makePlayer = () => {
Random.seed = 3;
const objects = new PhysicalContainer({ addPoints() {}, announce() {} });
createWorld(objects);
const players = new PlayerContainer(objects, 4, 0);
const socket = new FakeSocket();
const player = players.createPlayer({ name: 'player' }, socket as never);
player.sendQueuedCommandsToClient();
socket.sent.length = 0;
return { player, socket };
};
const queueOneSnapshot = (player: ReturnType<typeof makePlayer>['player']) => {
player.queueCommandSend(new CreateObjectsCommand([]));
player.queueCommandSend(new DeleteObjectsCommand([1234]));
player.queueCommandSend(new ServerAnnouncement('team blue wins'));
player.queueCommandSend(new UpdateMinimap([]));
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 } }>) =>
batch.map((c) => c.constructor.name);
describe('slow-client backpressure', () => {
beforeEach(() => {
Random.seed = 3;
});
it('sends everything while the socket is draining', () => {
const { player, socket } = makePlayer();
queueOneSnapshot(player);
player.sendQueuedCommandsToClient();
expect(typesIn(socket.lastBatch)).toEqual([
CreateObjectsCommand.name,
DeleteObjectsCommand.name,
ServerAnnouncement.name,
UpdateMinimap.name,
PropertyUpdatesForObjects.name,
InputAcknowledgement.name,
]);
});
it('reads the buffered byte count engine.io actually exposes', () => {
const { player, socket } = makePlayer();
socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1;
queueOneSnapshot(player);
player.sendQueuedCommandsToClient();
const marker = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects);
expect(marker).toMatchObject({ updates: [], timestamp: 1 });
});
it('sheds only the state the next tick regenerates', () => {
const { player, socket } = makePlayer();
socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1;
queueOneSnapshot(player);
player.sendQueuedCommandsToClient();
const types = typesIn(socket.lastBatch);
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);
expect(types).toContain(ServerAnnouncement.name);
});
it('stays quiet when a backed-up client has nothing but sheddable state', () => {
const { player, socket } = makePlayer();
socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1;
player.queueCommandSend(new PropertyUpdatesForObjects([], 1));
const before = socket.sent.length;
player.sendQueuedCommandsToClient();
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

@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { createRequire } from 'node:module';
import { CommandSocket } from '../frontend/src/scripts/commands/command-socket';
import { setFrameTimeMs } from '../frontend/src/scripts/helper/prediction/local-character-predictor';
const require = createRequire(import.meta.url);
const shared = require('../shared/lib/main.js');
const { vec2 } = require('../shared/node_modules/gl-matrix');
const { settings, MoveActionCommand, ClientHeartbeatCommand } = shared;
const drive = (
frameRate: number,
seconds: number,
onFrame?: (socket: any) => void,
connected = true,
) => {
const batches: Array<string> = [];
const socket = {
connected,
emit: (_event: string, payload: string) => batches.push(payload),
};
const commandSocket = new CommandSocket(socket as never);
const frames = Math.round(frameRate * seconds);
for (let f = 0; f < frames; f++) {
setFrameTimeMs((f * 1000) / frameRate);
onFrame?.(commandSocket);
commandSocket.sendQueuedCommands();
}
return batches;
};
describe('client send cadence', () => {
it('is paced by the clock, not by the frame rate', () => {
const seconds = 3;
const expected = seconds / settings.clientSendInterval;
for (const frameRate of [60, 144, 240]) {
const batches = drive(frameRate, seconds);
expect(batches.length).toBeLessThanOrEqual(expected * 1.1);
expect(batches.length).toBeGreaterThanOrEqual(expected * 0.85);
}
});
it('stays under the server allowance even at an absurd frame rate', () => {
const seconds = 5;
const batches = drive(1000, seconds);
expect(batches.length / seconds).toBeLessThan(settings.maxInboundMessagesPerSecond);
});
it('still flushes real input on the frame it happens', () => {
let sentOn = -1;
const batches = drive(144, 0.1, (commandSocket) => {
if (sentOn === -1) {
commandSocket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 0));
sentOn = 0;
}
});
expect(batches.length).toBeGreaterThan(0);
expect(batches[0]).toContain('MoveActionCommand');
});
it('closes every batch with a heartbeat, so a held key keeps the ack moving', () => {
const batches = drive(60, 1);
expect(batches.length).toBeGreaterThan(0);
expect(batches.every((b) => b.includes(ClientHeartbeatCommand.name))).toBe(true);
});
it('sends nothing at all while the transport is down', () => {
const batches = drive(
60,
30,
(commandSocket) =>
commandSocket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 0)),
false,
);
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,363 @@
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/options';
const require = createRequire(import.meta.url);
const shared = require('../shared/lib/main.js');
const { Random, TransportEvents, JoinRejectionReason, settings } = shared;
class FakeSocket {
public readonly sent: Array<{ event: string; payload: unknown }> = [];
public disconnected = false;
private handlers = new Map<string, Array<(...args: never[]) => void>>();
public on(event: string, handler: (...args: never[]) => void) {
const list = this.handlers.get(event) ?? [];
list.push(handler);
this.handlers.set(event, list);
}
public off(event: string, handler: (...args: never[]) => void) {
this.handlers.set(
event,
(this.handlers.get(event) ?? []).filter((h) => h !== handler),
);
}
public emit(event: string, payload?: unknown) {
this.sent.push({ event, payload });
}
public join() {}
public disconnect() {
this.disconnected = true;
}
public listenerCount(event: string): number {
return (this.handlers.get(event) ?? []).length;
}
public fire(event: string, ...args: unknown[]) {
for (const handler of [...(this.handlers.get(event) ?? [])]) {
(handler as (...a: unknown[]) => void)(...args);
}
}
public lastPayloadFor(event: string): unknown {
return [...this.sent].reverse().find((m) => m.event === event)?.payload;
}
}
class FakeIo {
public connect?: (socket: FakeSocket) => void;
public on(event: string, handler: (socket: FakeSocket) => void) {
if (event === 'connection') {
this.connect = handler;
}
}
public to() {
return { emit: () => undefined };
}
}
const startServer = (overrides: Partial<typeof defaultOptions> = {}) => {
const io = new FakeIo();
const server = new GameServer(io as never, {
...defaultOptions,
seed: 1,
...overrides,
});
const attach = () => {
const socket = new FakeSocket();
io.connect!(socket);
return socket;
};
return { server, attach };
};
beforeEach(() => {
Random.seed = 1;
});
describe('join guard', () => {
it('accepts exactly one join per connection', () => {
const { server, attach } = startServer({ playerLimit: 4, npcCount: 1 });
const socket = attach();
for (let i = 0; i < 8; i++) {
socket.fire(TransportEvents.PlayerJoining, { name: 'greedy' });
}
expect(server.serverInfo.playerCount).toBe(1);
expect(socket.listenerCount(TransportEvents.PlayerToServer)).toBe(1);
expect(socket.listenerCount('disconnect')).toBe(1);
expect(socket.lastPayloadFor(TransportEvents.JoinRejected)).toBe(
JoinRejectionReason.AlreadyJoined,
);
});
it('leaves room for everyone else', () => {
const { server, attach } = startServer({ playerLimit: 4, npcCount: 1 });
const hog = attach();
for (let i = 0; i < 8; i++) {
hog.fire(TransportEvents.PlayerJoining, { name: 'greedy' });
}
const other = attach();
other.fire(TransportEvents.PlayerJoining, { name: 'someone else' });
expect(server.serverInfo.playerCount).toBe(2);
expect(other.disconnected).toBe(false);
});
it('says why instead of hanging up silently when full', () => {
const { attach } = startServer({ playerLimit: 1, npcCount: 0 });
attach().fire(TransportEvents.PlayerJoining, { name: 'first' });
const refused = attach();
refused.fire(TransportEvents.PlayerJoining, { name: 'second' });
expect(refused.lastPayloadFor(TransportEvents.JoinRejected)).toBe(
JoinRejectionReason.ServerFull,
);
});
});
describe('hostile payloads', () => {
it('survives a name that is not a string, without leaking a bot', () => {
const { server, attach } = startServer({ playerLimit: 4, npcCount: 2 });
for (let i = 0; i < 4; i++) {
attach().fire(TransportEvents.PlayerJoining, { name: ['CharacterBase'] });
}
expect(server.serverInfo.playerCount).toBe(4);
});
it.each([
['no payload at all', undefined],
['null', null],
['a string', 'not-an-object'],
['a number', 7],
])('refuses a join payload that is %s instead of throwing', (_label, payload) => {
const { server, attach } = startServer({ playerLimit: 4, npcCount: 0 });
const socket = attach();
expect(() => socket.fire(TransportEvents.PlayerJoining, payload)).not.toThrow();
expect(socket.lastPayloadFor(TransportEvents.JoinRejected)).toBe(
JoinRejectionReason.InvalidRequest,
);
expect(server.serverInfo.playerCount).toBe(0);
});
it('ignores an oversized command batch instead of parsing it', () => {
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
const socket = attach();
socket.fire(TransportEvents.PlayerJoining, { name: 'sender' });
expect(() =>
socket.fire(TransportEvents.PlayerToServer, 'x'.repeat(2 * 1024 * 1024)),
).not.toThrow();
});
});
describe('reconnect', () => {
it('hands out a token on join and honours it on the way back', () => {
const { server, attach } = startServer({ playerLimit: 4, npcCount: 0 });
const first = attach();
first.fire(TransportEvents.PlayerJoining, { name: 'flaky' });
const token = first.lastPayloadFor(TransportEvents.PlayerJoined);
expect(typeof token).toBe('string');
first.fire('disconnect');
expect(server.serverInfo.playerCount).toBe(0);
const second = attach();
second.fire(TransportEvents.PlayerJoining, { name: 'flaky', reconnectToken: token });
expect(server.serverInfo.playerCount).toBe(1);
expect(second.disconnected).toBe(false);
expect(second.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined();
expect(second.lastPayloadFor(TransportEvents.PlayerJoined)).not.toBe(token);
});
it('treats an unknown token as an ordinary first join', () => {
const { server, attach } = startServer({ playerLimit: 4, npcCount: 0 });
const socket = attach();
socket.fire(TransportEvents.PlayerJoining, {
name: 'stranger',
reconnectToken: 'not-a-real-token',
});
expect(server.serverInfo.playerCount).toBe(1);
expect(socket.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined();
});
});
describe('inbound rate limiting', () => {
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++) {
socket.fire(TransportEvents.PlayerToServer, '{');
}
const seen = spy.mock.calls.length;
spy.mockRestore();
return seen;
};
it('allows several times what a well-behaved client sends', () => {
const clientMessagesPerSecond = 1 / settings.clientSendInterval;
expect(settings.maxInboundMessagesPerSecond).toBeGreaterThanOrEqual(
clientMessagesPerSecond * 4,
);
expect(settings.maxInboundMessageBurst).toBeGreaterThanOrEqual(
settings.maxInboundMessagesPerSecond,
);
});
it('does not drop a second of a real client arriving at once', () => {
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
const socket = attach();
socket.fire(TransportEvents.PlayerJoining, { name: 'player' });
const oneSecond = Math.ceil(1 / settings.clientSendInterval) + 100;
expect(admitted(socket, oneSecond)).toBe(oneSecond);
});
it('still clamps an actual flood', () => {
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
const socket = attach();
socket.fire(TransportEvents.PlayerJoining, { name: 'flooder' });
const flood = settings.maxInboundMessageBurst * 4;
expect(admitted(socket, flood)).toBeLessThan(flood);
});
});
describe('pre-join rate limiting', () => {
it('clamps a flood of joins on an already-joined connection', () => {
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
const socket = attach();
socket.fire(TransportEvents.PlayerJoining, { name: 'flooder' });
const flood = settings.maxInboundMessageBurst * 4;
for (let i = 0; i < flood; i++) {
socket.fire(TransportEvents.PlayerJoining, { name: 'flooder' });
}
const rejections = socket.sent.filter(
(m) => m.event === TransportEvents.JoinRejected,
);
expect(rejections.length).toBeLessThan(flood);
});
it('clamps a flood of server-info subscriptions', () => {
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
const socket = attach();
let joins = 0;
socket.join = () => {
joins++;
};
const flood = settings.maxInboundMessageBurst * 4;
for (let i = 0; i < flood; i++) {
socket.fire(TransportEvents.SubscribeForServerInfoUpdates);
}
expect(joins).toBeLessThan(flood);
});
});
describe('reconnect tokens', () => {
const tokenFromFreshServer = () => {
Random.seed = 1;
const { attach } = startServer({ playerLimit: 4, npcCount: 0, seed: 1 });
const socket = attach();
socket.fire(TransportEvents.PlayerJoining, { name: 'player' });
return socket.lastPayloadFor(TransportEvents.PlayerJoined) as string;
};
it('does not derive them from the seeded gameplay stream', () => {
expect(tokenFromFreshServer()).not.toBe(tokenFromFreshServer());
});
it('mints them from the platform CSPRNG, not the gameplay stream', () => {
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
const socket = attach();
socket.fire(TransportEvents.PlayerJoining, { name: 'player' });
expect(socket.lastPayloadFor(TransportEvents.PlayerJoined)).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
});
});
describe('reconnect while the dropped socket is still live', () => {
it('retires the ghost so the returning player keeps its slot, team and score', () => {
const { server, attach } = startServer({ playerLimit: 1, npcCount: 0 });
const first = attach();
first.fire(TransportEvents.PlayerJoining, { name: 'flaky' });
const token = first.lastPayloadFor(TransportEvents.PlayerJoined) as string;
expect(server.serverInfo.playerCount).toBe(1);
const second = attach();
second.fire(TransportEvents.PlayerJoining, { name: 'flaky', reconnectToken: token });
expect(second.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined();
expect(second.lastPayloadFor(TransportEvents.PlayerJoined)).toBeTruthy();
expect(server.serverInfo.playerCount).toBe(1);
expect(first.listenerCount(TransportEvents.PlayerToServer)).toBe(0);
});
it('does not hand a slot to an unknown token', () => {
const { server, attach } = startServer({ playerLimit: 1, npcCount: 0 });
attach().fire(TransportEvents.PlayerJoining, { name: 'holder' });
const stranger = attach();
stranger.fire(TransportEvents.PlayerJoining, {
name: 'stranger',
reconnectToken: 'not-a-real-token',
});
expect(stranger.lastPayloadFor(TransportEvents.JoinRejected)).toBe(
JoinRejectionReason.ServerFull,
);
expect(server.serverInfo.playerCount).toBe(1);
});
});
describe('socket listeners', () => {
it('takes every listener back off when the player drops', () => {
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
const socket = attach();
socket.fire(TransportEvents.PlayerJoining, { name: 'leaver' });
expect(socket.listenerCount(TransportEvents.Pong)).toBe(1);
socket.fire('disconnect');
expect(socket.listenerCount(TransportEvents.Pong)).toBe(0);
expect(socket.listenerCount(TransportEvents.PlayerToServer)).toBe(0);
expect(socket.listenerCount('disconnect')).toBe(0);
});
it('does not stack a listener per reconnect', () => {
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
for (let i = 0; i < 5; i++) {
const socket = attach();
socket.fire(TransportEvents.PlayerJoining, { name: 'flaky' });
expect(socket.listenerCount(TransportEvents.Pong)).toBe(1);
socket.fire('disconnect');
}
});
});

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,11 +1,6 @@
// 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.
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');
@ -13,7 +8,10 @@ const { vec2 } = require('../shared/node_modules/gl-matrix');
const {
stepCharacterMovement,
tickPlanetDetachment,
resolveCircleMovement,
PlanetBase,
Random,
headRadius,
feetRadius,
headOffset,
@ -21,6 +19,8 @@ const {
rightFootOffset,
} = shared;
const stepSeconds = 1 / 200;
const makeBody = (center, radius) => ({
center: vec2.clone(center),
radius,
@ -29,55 +29,125 @@ 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.
const emptyWorld = {
groundsNear: () => [],
const makeState = (start) => ({
head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius),
leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius),
rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius),
direction: 0,
currentPlanet: undefined,
secondsSinceOnSurface: 1,
bodyVelocity: vec2.create(),
});
const makeWorld = (grounds) => ({
groundsNear: () => grounds,
stepBody: (body, dt) => {
resolveCircleMovement(body, dt, []);
return undefined;
const hit = resolveCircleMovement(body, dt, grounds);
return hit && hit.isGround ? hit : undefined;
},
};
});
const stepSeconds = 1 / 200;
const round = (v) => Math.round(v * 1000) / 1000;
const roundVec = (v) => [round(v[0]), round(v[1])];
// 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 = {
head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius),
leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius),
rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius),
direction: 0,
currentPlanet: undefined,
secondsSinceOnSurface: 1,
bodyVelocity: vec2.create(),
};
const state = makeState(vec2.fromValues(100, 100));
const world = makeWorld([]);
for (let i = 0; i < 300; i++) {
const angle = i * 0.1;
const input = vec2.fromValues(Math.cos(angle), Math.sin(angle));
stepCharacterMovement(state, emptyWorld, input, stepSeconds);
stepCharacterMovement(
state,
world,
vec2.fromValues(Math.cos(angle), Math.sin(angle)),
stepSeconds,
);
}
const round = (v) => Math.round(v * 1000) / 1000;
return JSON.stringify({
head: [round(state.head.center[0]), round(state.head.center[1])],
leftFoot: [round(state.leftFoot.center[0]), round(state.leftFoot.center[1])],
rightFoot: [round(state.rightFoot.center[0]), round(state.rightFoot.center[1])],
bodyVelocity: [round(state.bodyVelocity[0]), round(state.bodyVelocity[1])],
head: roundVec(state.head.center),
leftFoot: roundVec(state.leftFoot.center),
rightFoot: roundVec(state.rightFoot.center),
bodyVelocity: roundVec(state.bodyVelocity),
});
};
const runPlanetSimulation = () => {
Random.seed = 20;
const planet = new PlanetBase(
1,
PlanetBase.createPlanetVertices(vec2.fromValues(0, -900), 1700, 1900, 70),
);
planet.angularVelocity = 0.09;
const world = makeWorld([planet]);
const state = makeState(vec2.fromValues(0, 250));
for (let i = 0; i < 900; i++) {
const input = i < 200 ? vec2.create() : vec2.fromValues(i < 600 ? 1 : -1, 0);
tickPlanetDetachment(state, stepSeconds);
stepCharacterMovement(state, world, input, stepSeconds);
planet.advanceRotation(stepSeconds);
}
return JSON.stringify({
head: roundVec(state.head.center),
leftFoot: roundVec(state.leftFoot.center),
rightFoot: roundVec(state.rightFoot.center),
direction: round(state.direction),
onSurface: !!state.currentPlanet,
secondsSinceOnSurface: round(state.secondsSinceOnSurface),
});
};
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());
});
it('matches the pinned reference pose (changes only with intentional physics edits)', () => {
// Regression pin — update deliberately when physics behaviour changes.
expect(runSimulation()).toMatchSnapshot();
});
it('is deterministic on a spinning planet too', () => {
expect(runPlanetSimulation()).toBe(runPlanetSimulation());
});
it('matches the pinned reference pose on a spinning planet', () => {
expect(runPlanetSimulation()).toMatchSnapshot();
});
});

View file

@ -1,43 +1,44 @@
// 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,
setFrameTimeMs,
} 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 },
});
const movementState = (direction = 0) => ({
direction,
bodyVelocity: [0, 0],
leftFootNormal: [0, 1],
rightFootNormal: [0, 1],
groundPlanetId: null,
secondsSinceOnSurface: 1,
});
describe('local prediction reconciliation', () => {
it('reproduces the authoritative pose exactly when all input is acknowledged', () => {
let clock = 1000;
setPredictorClockForTesting(() => clock);
setFrameTimeMs(1000);
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.acknowledge(t, movementState() as never, -Infinity);
predictor.setStrength(80);
predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
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);
const used = predictor.update([]);
expect(used).toBe(true);
expect(predictor.head.center[0]).toBeCloseTo(auth.head.center[0], 5);
@ -48,19 +49,20 @@ describe('local prediction reconciliation', () => {
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)
setFrameTimeMs(1000);
predictor.acknowledge(900, movementState() as never, -Infinity);
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
predictor.setAuthoritative(
auth.head as never,
auth.leftFoot as never,
auth.rightFoot as never,
);
predictor.recordInput([1, 0]);
clock = 1100; // replay ~100 ms forward
predictor.update([], 1 / 60);
setFrameTimeMs(1100);
predictor.update([]);
return [
predictor.head.center[0],
predictor.head.center[1],
@ -71,26 +73,243 @@ 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', () => {
let clock = 5000;
setPredictorClockForTesting(() => clock);
setFrameTimeMs(5000);
const predictor = new LocalCharacterPredictor();
const auth = poseAt(200, 200);
const t = predictor.recordInput([1, 0]);
predictor.acknowledge(t, [0, 0], -Infinity);
predictor.acknowledge(t, movementState() as never, -Infinity);
predictor.setStrength(80);
predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
predictor.setAlive(false); // dead, awaiting respawn
predictor.setAuthoritative(
auth.head as never,
auth.leftFoot as never,
auth.rightFoot as never,
);
predictor.setAlive(false);
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);
setFrameTimeMs(5200);
expect(predictor.update([])).toBe(false);
});
it('replays the whole un-acknowledged span, so a longer trip predicts further', () => {
const driveWithUnackedSpan = (ackAgeMs: number) => {
const predictor = new LocalCharacterPredictor();
predictor.setStrength(80);
setFrameTimeMs(9_000);
predictor.recordInput([1, 0]);
setFrameTimeMs(10_000);
const auth = poseAt(0, 0);
predictor.acknowledge(10_000 - ackAgeMs, movementState() as never, -Infinity);
predictor.setAuthoritative(
auth.head as never,
auth.leftFoot as never,
auth.rightFoot as never,
);
predictor.update([]);
return predictor.head.center[0];
};
const shortTrip = driveWithUnackedSpan(40);
const longTrip = driveWithUnackedSpan(200);
expect(shortTrip).toBeGreaterThan(0);
expect(longTrip).toBeGreaterThan(shortTrip * 2);
});
it('cancels the age of the acknowledged input, so the send cadence cannot beat', () => {
const snapshotAgeMs = 20;
const drive = (inputAgeMs: number, serverReportsAge: boolean) => {
const predictor = new LocalCharacterPredictor();
predictor.setStrength(80);
setFrameTimeMs(9_000);
predictor.recordInput([1, 0]);
setFrameTimeMs(10_000);
predictor.acknowledge(
10_000 - snapshotAgeMs - inputAgeMs,
movementState() as never,
-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([]);
return predictor.head.center[0];
};
expect(drive(16, true)).toBeCloseTo(drive(0, true), 6);
expect(Math.abs(drive(16, false) - drive(0, false))).toBeGreaterThan(0.5);
});
it('replays the streamed facing direction, not one guessed from the pose', () => {
const walkingPose = () => ({
head: { center: [-35, 37], radius: HEAD_RADIUS },
leftFoot: { center: [-33, -18], radius: FEET_RADIUS },
rightFoot: { center: [33, -18], radius: FEET_RADIUS },
});
const predictWithDirection = (direction: number) => {
const predictor = new LocalCharacterPredictor();
predictor.setStrength(80);
setFrameTimeMs(0);
predictor.recordInput([1, 0]);
predictor.acknowledge(0, movementState(direction) as never, -Infinity);
const auth = walkingPose();
predictor.setAuthoritative(
auth.head as never,
auth.leftFoot as never,
auth.rightFoot as never,
);
setFrameTimeMs(40);
predictor.update([]);
return [predictor.head.center[0], predictor.head.center[1]];
};
const upright = predictWithDirection(0);
const asThePoseImplies = predictWithDirection(0.566);
const apart = Math.hypot(
upright[0] - asThePoseImplies[0],
upright[1] - asThePoseImplies[1],
);
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

@ -0,0 +1,367 @@
import { describe, it, expect } from 'vitest';
import {
LocalCharacterPredictor,
setFrameTimeMs,
} from '../frontend/src/scripts/helper/prediction/local-character-predictor';
import { ClientCharacterWorld } from '../frontend/src/scripts/helper/prediction/client-character-world';
import {
stepCharacterMovement,
tickPlanetDetachment,
headRadius,
feetRadius,
} from '../shared/src/physics/character-movement';
import { settings } from '../shared/src/settings';
const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds;
const snapshotMs = settings.updateMessageInterval * 1000;
const heartbeatMs = settings.clientSendInterval * 1000;
const planetRadius = 800;
// The client's frame clock is rebased to its first frame (sdf-2d passes currentTime - startTime),
// so it shares no origin with the server's clock. Anything that only works when the two happen to
// agree is not a fix.
const clientClockOffsetMs = 987_654.321;
const vertices = Array.from({ length: settings.planetEdgeCount }, (_, i) => {
const angle = (i / settings.planetEdgeCount) * -Math.PI * 2;
return [planetRadius * Math.cos(angle), planetRadius * Math.sin(angle)];
});
const planetAt = (rotation: number) =>
({ id: 1, vertices, snapshotRotation: rotation, snapshotRotationSpeed: 0 }) as never;
const body = (center: Array<number>, radius: number) => ({
center: center.slice(),
radius,
velocity: [0, 0],
lastNormal: [0, 1],
restitution: 0,
});
interface Conditions {
fps: number;
/** One way. The round trip is twice this. */
wireMs: number;
/** Client CPU spent between the frame timestamp and the socket emit at the end of the loop. */
emitLagMs: number;
/** Peak random variation in frame duration, as a fraction of the nominal frame. */
frameJitter: number;
/** Probability that a frame is missed entirely and the next one is twice as long. */
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;
/** How far the rendered character trails the true server pose, in world units. */
lagUnits: number;
finalError: number;
maximumSpeed: number;
}
// 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,
emitLagMs,
frameJitter,
dropRate,
serverBusyMs,
wireJitterMs = 0,
emitJitterMs = 0,
changeInput = false,
}: Conditions): Result => {
const world = new ClientCharacterWorld();
world.sync([planetAt(0)]);
const server: any = {
head: body([0, planetRadius + 90], headRadius),
leftFoot: body([-33, planetRadius + 35], feetRadius),
rightFoot: body([33, planetRadius + 35], feetRadius),
direction: 0,
currentPlanet: world.surfaceById(1),
secondsSinceOnSurface: 0,
bodyVelocity: [0, 0],
};
let input = [1, 0];
let serverInput = [1, 0];
const predictor = new LocalCharacterPredictor();
predictor.setStrength(settings.playerMaxStrength);
let seed = 12345;
const random = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
const nominalFrameMs = 1000 / fps;
const toServer: Array<{ atMs: number; clientStampMs: number; direction?: number[] }> =
[];
const toClient: Array<{
atMs: number;
ackAgeMs: number;
clientStampMs: number;
pose: any;
}> = [];
const samples: Array<{
world: Array<number>;
atMs: number;
error: number;
}> = [];
let simMs = 0;
let nextSnapshotMs = 0;
let nextFrameAtMs = 0;
let lastHeartbeatClientMs = -Infinity;
let lastInputClientTimeMs = 0;
let lastInputReceiptMs = 0;
let hasPressed = false;
let lastServerArrivalMs = 0;
let lastClientArrivalMs = 0;
while (simMs < 8000) {
tickPlanetDetachment(server, 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;
}
}
if (simMs >= nextSnapshotMs) {
nextSnapshotMs += snapshotMs;
lastClientArrivalMs = Math.max(
lastClientArrivalMs,
simMs + Math.max(0, wireMs + (random() * 2 - 1) * wireJitterMs),
);
toClient.push({
atMs: lastClientArrivalMs,
clientStampMs: lastInputClientTimeMs,
ackAgeMs: lastInputReceiptMs > 0 ? Math.max(0, simMs - lastInputReceiptMs) : 0,
pose: {
head: server.head.center.slice(),
leftFoot: server.leftFoot.center.slice(),
rightFoot: server.rightFoot.center.slice(),
movement: {
direction: server.direction,
bodyVelocity: server.bodyVelocity.slice(),
leftFootNormal: server.leftFoot.lastNormal.slice(),
rightFootNormal: server.rightFoot.lastNormal.slice(),
groundPlanetId: 1,
secondsSinceOnSurface: server.secondsSinceOnSurface,
},
},
});
}
while (toClient.length > 0 && toClient[0].atMs <= simMs) {
const packet = toClient.shift()!;
predictor.acknowledge(
packet.clientStampMs,
packet.pose.movement,
-Infinity,
packet.ackAgeMs,
);
predictor.setAuthoritative(
{ center: packet.pose.head, radius: headRadius } as never,
{ center: packet.pose.leftFoot, radius: feetRadius } as never,
{ center: packet.pose.rightFoot, radius: feetRadius } as never,
);
}
while (nextFrameAtMs <= simMs) {
const frameClientMs = nextFrameAtMs + clientClockOffsetMs;
setFrameTimeMs(frameClientMs);
// Movement input is edge-triggered, exactly as KeyboardListener sends it: recorded once
// when the key goes down, then held by both sides.
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);
}
const dropped = random() < dropRate;
const jitter = 1 + (random() * 2 - 1) * frameJitter;
const frameMs = nominalFrameMs * jitter * (dropped ? 2 : 1);
if (!dropped) {
predictor.update([planetAt(0)]);
const head = predictor.head.center;
samples.push({
world: [head[0], head[1]],
atMs: frameClientMs,
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 (
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: lastServerArrivalMs,
clientStampMs: frameClientMs,
direction: inputChanged ? input.slice() : undefined,
});
}
nextFrameAtMs += frameMs;
}
}
const tail = samples.slice(Math.floor(samples.length / 2));
// Apparent speed: world travel divided by how long the frame was on screen. A longer frame
// should show proportionally more travel; anything else reads as a lurch.
const speeds = tail
.slice(1)
.map(
(s, i) =>
Math.hypot(s.world[0] - tail[i].world[0], s.world[1] - tail[i].world[1]) /
(s.atMs - tail[i].atMs),
);
const meanSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
return {
speedSpread: (Math.max(...speeds) - Math.min(...speeds)) / meanSpeed,
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, 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.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);
});
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);
});
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

@ -1,8 +1,3 @@
// 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.
import { describe, it, expect } from 'vitest';
import { createRequire } from 'node:module';
@ -17,21 +12,27 @@ const {
MoveActionCommand,
UpdatePropertyCommand,
PropertyUpdatesForObject,
// networked entity bases — their toArray() must mirror their constructor order
CharacterBase,
PlanetBase,
ProjectileBase,
LampBase,
// geometry — single source of truth shared by server & client prediction
headRadius,
feetRadius,
boundRadius,
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);
@ -41,7 +42,6 @@ describe('serialization round-trip (built shared lib)', () => {
it('round-trips a Circle and rounds floats to 3 decimals', () => {
const out = deserialize(serialize(new Circle([12.34567, -7.1], 5.43219)));
expect(out).toBeInstanceOf(Circle);
// serialize.ts rounds every float via toFixed(3)
expect(out.center[0]).toBeCloseTo(12.346, 6);
expect(out.center[1]).toBeCloseTo(-7.1, 6);
expect(out.radius).toBeCloseTo(5.432, 6);
@ -68,8 +68,6 @@ 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.
const batch = [
new ServerAnnouncement('a'),
new MoveActionCommand([0, 1], 5),
@ -132,14 +130,25 @@ describe('networked entity round-trips (toArray ↔ constructor contract)', () =
it('round-trips a PlanetBase (derived centre/radius recomputed from vertices)', () => {
const out = deserialize(
serialize(new PlanetBase(10, [[0, 0], [100, 0], [50, 100]], 0.7, true)),
serialize(
new PlanetBase(
10,
[
[0, 0],
[100, 0],
[50, 100],
],
0.7,
true,
),
),
);
expect(out).toBeInstanceOf(PlanetBase);
expect(out.id).toBe(10);
expect(out.vertices).toHaveLength(3);
expect(out.ownership).toBeCloseTo(0.7, 5);
expect(out.isKeystone).toBe(true);
expect(out.center[0]).toBeCloseTo(50, 5); // recomputed by the constructor
expect(out.center[0]).toBeCloseTo(50, 5);
});
});
@ -147,7 +156,7 @@ describe('shared character geometry — single source of truth', () => {
it('matches the known body layout', () => {
expect(headRadius).toBe(50);
expect(feetRadius).toBe(20);
expect(boundRadius).toBe((headRadius + feetRadius * 2) * 2); // 180
expect(boundRadius).toBe((headRadius + feetRadius * 2) * 2);
});
it('posture offsets are measured from the centre of mass (they sum to ~0)', () => {

13
test/setup.mjs Normal file
View file

@ -0,0 +1,13 @@
// 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);
for (const path of [
'../shared/node_modules/gl-matrix',
'../backend/node_modules/gl-matrix',
'../frontend/node_modules/gl-matrix',
]) {
require(path).glMatrix.setMatrixArrayType(Array);
}

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);
});
});

View file

@ -0,0 +1,276 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CharacterBase,
CharacterTeam,
Circle,
CreateObjectsCommand,
DeleteObjectsCommand,
GameObject,
PropertyUpdatesForObjects,
RemoteCall,
RemoteCallsForObject,
RemoteCallsForObjects,
} from '../frontend/node_modules/shared';
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';
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/sound-handler', () => ({
SoundHandler: { play: vi.fn() },
Sounds: {},
}));
vi.mock('../frontend/src/scripts/feedback-hud', () => ({ FeedbackHud: {} }));
let nowMs = 0;
beforeEach(() => {
nowMs = 0;
vi.spyOn(performance, 'now').mockImplementation(() => nowMs);
serverTimeline.reset();
});
afterEach(() => vi.restoreAllMocks());
function character(id = 1) {
const pose = new Circle([0, 0], 10);
const object = new CharacterBase(
id,
'Remote player',
0,
0,
CharacterTeam.red,
100,
pose,
pose,
pose,
);
return Object.assign(object, {
step: vi.fn(),
render: vi.fn(),
beforeDestroy: vi.fn(),
setHealth: vi.fn((health: number) => (object.health = health)),
onShoot: vi.fn(),
onDie: vi.fn(),
});
}
function calls(
container: GameObjectContainer,
id: number,
...entries: Array<[string, ...unknown[]]>
) {
container.handleCommand(
new RemoteCallsForObjects([
new RemoteCallsForObject(
id,
entries.map(([name, ...args]) => new RemoteCall(name, args)),
),
]),
);
}
function snapshot(container: GameObjectContainer, timestamp: number) {
nowMs = (timestamp - 5) * 1000;
container.handleCommand(new PropertyUpdatesForObjects([], timestamp));
}
function retained(container: GameObjectContainer) {
return container as unknown as {
objects: Map<number, unknown>;
deleteAt: Map<number, number>;
remoteCalls: Array<{ object: unknown }>;
};
}
describe('suspended rendering', () => {
it('bounds deleted views and effect batches as snapshots arrive without frames', () => {
const container = new GameObjectContainer({} as never);
const survivor = character();
container.handleCommand(new CreateObjectsCommand([survivor]));
const deleted: PlanetView[] = [];
for (let i = 0; i < 1000; i++) {
const object = new PlanetView(i + 100, []);
deleted.push(object);
container.handleCommand(new CreateObjectsCommand([object]));
calls(container, survivor.id, ['onShoot', 40]);
container.handleCommand(new DeleteObjectsCommand([object.id]));
snapshot(container, 5 + i * 0.04);
expect(container.planets.length).toBeLessThanOrEqual(15);
expect(retained(container).objects.size).toBeLessThanOrEqual(16);
expect(retained(container).deleteAt.size).toBeLessThanOrEqual(15);
expect(retained(container).remoteCalls.length).toBeLessThanOrEqual(15);
}
expect(deleted[0].beforeDestroy).toHaveBeenCalledOnce();
expect(deleted[984].beforeDestroy).toHaveBeenCalledOnce();
expect(survivor.onShoot).not.toHaveBeenCalled();
serverTimeline.step(0);
container.step(0);
expect(survivor.onShoot).not.toHaveBeenCalled();
expect(retained(container).remoteCalls.length).toBeLessThanOrEqual(3);
snapshot(container, 46);
expect(container.planets).toHaveLength(0);
expect(retained(container).objects.size).toBe(1);
expect(retained(container).deleteAt.size).toBe(0);
expect(retained(container).remoteCalls).toHaveLength(0);
serverTimeline.step(0);
container.step(0);
expect(survivor.onShoot).not.toHaveBeenCalled();
});
it('preserves expired state silently and applies fresh health feedback in order', () => {
const container = new GameObjectContainer({} as never);
const object = character();
container.handleCommand(new CreateObjectsCommand([object]));
calls(container, object.id, ['setHealth', 80], ['setKillCount', 1], ['onShoot', 40]);
snapshot(container, 5);
calls(container, object.id, ['setHealth', 60], ['setKillCount', 2], ['onDie']);
snapshot(container, 5.04);
snapshot(container, 5.6);
expect(object.health).toBe(60);
expect(object.killCount).toBe(2);
expect(object.setHealth).not.toHaveBeenCalled();
expect(object.onShoot).not.toHaveBeenCalled();
expect(object.onDie).not.toHaveBeenCalled();
calls(container, object.id, ['setHealth', 40], ['setKillCount', 3], ['onShoot', 50]);
snapshot(container, 5.7);
serverTimeline.step(0);
container.step(0);
expect(object.health).toBe(60);
expect(object.setHealth).not.toHaveBeenCalled();
nowMs += 110;
serverTimeline.step(0.11);
container.step(0);
expect(object.health).toBe(40);
expect(object.killCount).toBe(3);
expect(object.setHealth).toHaveBeenCalledExactlyOnceWith(40);
expect(object.onShoot).toHaveBeenCalledExactlyOnceWith(50);
expect(object.onDie).not.toHaveBeenCalled();
});
it('preserves final light and contested state while discarding transient planet effects', () => {
const container = new GameObjectContainer({} as never);
const object = Object.assign(new (class extends GameObject {})(1), {
contested: false,
lightness: 0,
color: [0, 0, 0],
step() {},
render() {},
setContested(value: boolean) {
this.contested = value;
},
setLight(color: number[], lightness: number) {
this.color = color;
this.lightness = lightness;
},
onFlipped: vi.fn(),
generatedPoints: vi.fn(),
});
container.handleCommand(new CreateObjectsCommand([object]));
calls(
container,
object.id,
['setContested', true],
['setLight', [1, 0, 0], 2],
['onFlipped', CharacterTeam.red],
['generatedPoints', 10],
);
snapshot(container, 5);
calls(
container,
object.id,
['setContested', false],
['setLight', [0, 0, 1], 3],
['onFlipped', CharacterTeam.blue],
['generatedPoints', 20],
);
snapshot(container, 5.04);
snapshot(container, 6);
expect(object.contested).toBe(false);
expect(object.color).toEqual([0, 0, 1]);
expect(object.lightness).toBe(3);
expect(object.onFlipped).not.toHaveBeenCalled();
expect(object.generatedPoints).not.toHaveBeenCalled();
expect(retained(container).remoteCalls).toHaveLength(0);
});
it('releases effect references immediately when a paused view is replaced', () => {
const container = new GameObjectContainer({} as never);
let object = character();
container.handleCommand(new CreateObjectsCommand([object]));
snapshot(container, 5);
for (let i = 0; i < 100; i++) {
calls(container, object.id, ['onShoot', 40]);
container.handleCommand(new DeleteObjectsCommand([object.id]));
const replacement = character(object.id);
container.handleCommand(new CreateObjectsCommand([replacement]));
expect(object.beforeDestroy).toHaveBeenCalledOnce();
expect(retained(container).remoteCalls).toHaveLength(0);
expect(retained(container).objects.get(object.id)).toBe(replacement);
object = replacement;
}
snapshot(container, 5.04);
nowMs += 200;
serverTimeline.step(0.2);
container.step(0);
expect(object.beforeDestroy).not.toHaveBeenCalled();
});
it('expires the last queued effects on resume even when snapshots also stopped', () => {
const container = new GameObjectContainer({} as never);
const object = character();
container.handleCommand(new CreateObjectsCommand([object]));
calls(container, object.id, ['setHealth', 20], ['setKillCount', 4], ['onShoot', 40]);
snapshot(container, 5);
nowMs += 10_000;
serverTimeline.step(10);
container.step(10);
expect(object.health).toBe(20);
expect(object.killCount).toBe(4);
expect(object.setHealth).not.toHaveBeenCalled();
expect(object.onShoot).not.toHaveBeenCalled();
expect(retained(container).remoteCalls).toHaveLength(0);
});
it('releases a deleted local player reference without waiting for a frame', () => {
const container = new GameObjectContainer({} as never);
const object = character();
container.handleCommand(new CreateObjectsCommand([object]));
container.player = object as never;
snapshot(container, 5);
container.handleCommand(new DeleteObjectsCommand([object.id]));
snapshot(container, 5.04);
expect(container.localPlayer).toBe(object);
snapshot(container, 6);
expect(object.beforeDestroy).toHaveBeenCalledOnce();
expect(container.player).toBeUndefined();
expect(container.localPlayer).toBeUndefined();
expect(retained(container).objects.size).toBe(0);
});
});