Improve netcode and simplify (#4)
- 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:
parent
5d854440ec
commit
93a7fe01ab
14 changed files with 15073 additions and 4754 deletions
|
|
@ -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}"`;
|
||||
|
|
|
|||
536
test/backend-regressions.test.ts
Normal file
536
test/backend-regressions.test.ts
Normal 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
167
test/backpressure.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
206
test/client-object-lifecycle.test.ts
Normal file
206
test/client-object-lifecycle.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
101
test/client-send-cadence.test.ts
Normal file
101
test/client-send-cadence.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
363
test/connection-lifecycle.test.ts
Normal file
363
test/connection-lifecycle.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
302
test/frontend-regressions.test.ts
Normal file
302
test/frontend-regressions.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
367
test/render-smoothness.test.ts
Normal file
367
test/render-smoothness.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
13
test/setup.mjs
Normal 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);
|
||||
}
|
||||
111
test/shared-regressions.test.ts
Normal file
111
test/shared-regressions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
276
test/suspended-rendering.test.ts
Normal file
276
test/suspended-rendering.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue