Claude fixes
This commit is contained in:
parent
5d854440ec
commit
1295828ac8
5 changed files with 2086 additions and 205 deletions
225
test/backend-regressions.test.ts
Normal file
225
test/backend-regressions.test.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
// Regression net for the backend defects found in the audit. These drive the
|
||||
// real classes rather than re-implementing their logic, so they fail if the
|
||||
// behaviour regresses rather than if the code is merely reshaped.
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { PhysicalContainer } from '../backend/src/physics/containers/physical-container';
|
||||
import { BoundingBox } from '../backend/src/physics/bounding-boxes/bounding-box';
|
||||
import { ProjectilePhysical } from '../backend/src/objects/projectile-physical';
|
||||
import { CharacterPhysical } from '../backend/src/objects/character-physical';
|
||||
import { StepCommand } from '../backend/src/commands/step';
|
||||
import { GeneratePointsCommand } from '../backend/src/commands/generate-points';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const shared = require('../shared/lib/main.js');
|
||||
const { vec2 } = require('../shared/node_modules/gl-matrix');
|
||||
const { applyArrayPlugins, Random, settings, CharacterTeam } = shared;
|
||||
|
||||
applyArrayPlugins();
|
||||
|
||||
const step = settings.targetPhysicsDeltaTimeInSeconds;
|
||||
|
||||
// Collects the points a stepped object asks the game to award.
|
||||
class PointsSpy {
|
||||
public blue = 0;
|
||||
public red = 0;
|
||||
public handleCommand(command: unknown) {
|
||||
if (command instanceof GeneratePointsCommand) {
|
||||
this.blue += command.blue;
|
||||
this.red += command.red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const boxAround = (center: number[], radius: number) => {
|
||||
const box = new BoundingBox();
|
||||
box.xMin = center[0] - radius;
|
||||
box.xMax = center[0] + radius;
|
||||
box.yMin = center[1] - radius;
|
||||
box.yMax = center[1] + radius;
|
||||
return box;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Random.seed = 1;
|
||||
});
|
||||
|
||||
describe('projectile broadphase registration', () => {
|
||||
// The bounding box is only rebuilt by the center/radius setters, but the
|
||||
// geometry helpers write `center` in place — so a projectile stayed registered
|
||||
// at its muzzle for its whole flight. View-area streaming is driven by that
|
||||
// box, so shots fired off-screen were never sent, and shots in view vanished
|
||||
// as soon as their muzzle scrolled away.
|
||||
it('follows the projectile instead of staying at the muzzle', () => {
|
||||
const container = new PhysicalContainer();
|
||||
container.initialize();
|
||||
|
||||
const projectile = new ProjectilePhysical(
|
||||
vec2.fromValues(0, 0),
|
||||
20,
|
||||
40,
|
||||
CharacterTeam.blue,
|
||||
vec2.fromValues(3000, 0),
|
||||
{ team: CharacterTeam.blue } as never,
|
||||
container,
|
||||
0,
|
||||
);
|
||||
container.addObject(projectile);
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
container.handleCommand(new StepCommand(step, new PointsSpy() as never));
|
||||
}
|
||||
|
||||
expect(projectile.center[0]).toBeGreaterThan(400);
|
||||
expect(container.findIntersecting(boxAround(projectile.center, 100))).toContain(
|
||||
projectile,
|
||||
);
|
||||
expect(container.findIntersecting(boxAround([0, 0], 100))).not.toContain(projectile);
|
||||
});
|
||||
|
||||
it('fast-forwards a shot by the sender latency', () => {
|
||||
const container = new PhysicalContainer();
|
||||
container.initialize();
|
||||
const spawn = vec2.fromValues(0, 0);
|
||||
|
||||
const make = () =>
|
||||
new ProjectilePhysical(
|
||||
vec2.clone(spawn),
|
||||
20,
|
||||
40,
|
||||
CharacterTeam.blue,
|
||||
vec2.fromValues(3000, 0),
|
||||
{ team: CharacterTeam.blue } as never,
|
||||
container,
|
||||
0,
|
||||
);
|
||||
|
||||
const plain = make();
|
||||
const compensated = make();
|
||||
compensated.fastForward(0.1);
|
||||
|
||||
expect(compensated.center[0]).toBeGreaterThan(plain.center[0] + 200);
|
||||
});
|
||||
|
||||
it('never fast-forwards further than the cap', () => {
|
||||
const container = new PhysicalContainer();
|
||||
container.initialize();
|
||||
const projectile = new ProjectilePhysical(
|
||||
vec2.fromValues(0, 0),
|
||||
20,
|
||||
40,
|
||||
CharacterTeam.blue,
|
||||
vec2.fromValues(3000, 0),
|
||||
{ team: CharacterTeam.blue } as never,
|
||||
container,
|
||||
0,
|
||||
);
|
||||
projectile.fastForward(10);
|
||||
const cap = settings.chargeShotSpeedMax * settings.maxProjectileCatchUpSeconds;
|
||||
expect(projectile.center[0]).toBeLessThanOrEqual(cap + 50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoring', () => {
|
||||
const spawnCharacter = (container: PhysicalContainer) => {
|
||||
const character = new CharacterPhysical(
|
||||
'tester',
|
||||
0,
|
||||
0,
|
||||
CharacterTeam.blue,
|
||||
container,
|
||||
vec2.fromValues(0, 0),
|
||||
);
|
||||
container.addObject(character);
|
||||
return character;
|
||||
};
|
||||
|
||||
// A disconnect and an NPC being retired on join both run through onDie(), and
|
||||
// used to award the opposing team a full kill, so ordinary connection churn
|
||||
// moved the match score with nobody playing.
|
||||
it('awards nothing for an administrative death', () => {
|
||||
const container = new PhysicalContainer();
|
||||
container.initialize();
|
||||
const character = spawnCharacter(container);
|
||||
const game = new PointsSpy();
|
||||
|
||||
character.onDie(); // what PlayerBase.destroy() does on a socket drop
|
||||
container.handleCommand(new StepCommand(step, game as never));
|
||||
|
||||
expect(game.blue).toBe(0);
|
||||
expect(game.red).toBe(0);
|
||||
});
|
||||
|
||||
it('still awards a kill for a combat death', () => {
|
||||
const container = new PhysicalContainer();
|
||||
container.initialize();
|
||||
const character = spawnCharacter(container);
|
||||
const game = new PointsSpy();
|
||||
|
||||
character.onDie(true);
|
||||
container.handleCommand(new StepCommand(step, game as never));
|
||||
|
||||
expect(game.red).toBe(settings.playerKillPoint);
|
||||
expect(game.blue).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('charged shots', () => {
|
||||
// Capping only the COST meant a max-charge request on a nearly empty pool paid
|
||||
// a tap's price while still getting full-charge radius, speed and recoil.
|
||||
it('scales the shot by the strength actually paid, not the requested charge', () => {
|
||||
const container = new PhysicalContainer();
|
||||
container.initialize();
|
||||
const character = new CharacterPhysical(
|
||||
'tester',
|
||||
0,
|
||||
0,
|
||||
CharacterTeam.blue,
|
||||
container,
|
||||
vec2.fromValues(0, 0),
|
||||
);
|
||||
container.addObject(character);
|
||||
|
||||
// Drain the pool down to roughly a tap's worth.
|
||||
(character as unknown as { projectileStrength: number }).projectileStrength =
|
||||
settings.chargeShotStrengthMin;
|
||||
|
||||
const before = container.findIntersecting(boxAround([0, 0], 5000)).length;
|
||||
character.shootTowards(vec2.fromValues(1000, 0), 1);
|
||||
const spawned = container
|
||||
.findIntersecting(boxAround([0, 0], 5000))
|
||||
.filter((o) => o.gameObject instanceof ProjectilePhysical)
|
||||
.map((o) => o.gameObject as ProjectilePhysical);
|
||||
|
||||
expect(container.findIntersecting(boxAround([0, 0], 5000)).length).toBeGreaterThan(
|
||||
before,
|
||||
);
|
||||
expect(spawned[0].charge).toBeCloseTo(0, 5);
|
||||
expect(spawned[0].radius).toBeCloseTo(settings.chargeShotRadiusMin, 5);
|
||||
});
|
||||
|
||||
it('ignores a shot aimed at the shooter itself', () => {
|
||||
const container = new PhysicalContainer();
|
||||
container.initialize();
|
||||
const character = new CharacterPhysical(
|
||||
'tester',
|
||||
0,
|
||||
0,
|
||||
CharacterTeam.blue,
|
||||
container,
|
||||
vec2.fromValues(0, 0),
|
||||
);
|
||||
container.addObject(character);
|
||||
|
||||
const projectilesBefore = container
|
||||
.findIntersecting(boxAround([0, 0], 5000))
|
||||
.filter((o) => o.gameObject instanceof ProjectilePhysical).length;
|
||||
character.shootTowards(vec2.clone(character.center), 1);
|
||||
const projectilesAfter = container
|
||||
.findIntersecting(boxAround([0, 0], 5000))
|
||||
.filter((o) => o.gameObject instanceof ProjectilePhysical).length;
|
||||
|
||||
expect(projectilesAfter).toBe(projectilesBefore);
|
||||
});
|
||||
});
|
||||
137
test/backpressure.test.ts
Normal file
137
test/backpressure.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// What a Player sheds when its socket stops draining. The guard used to read
|
||||
// `socket.conn.bufferedAmount`, which engine.io's Socket does not have, so it
|
||||
// never once ran — and had it run, it dropped the whole batch, including the
|
||||
// create/delete that handleViewAreaUpdate had already committed to having sent.
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { PhysicalContainer } from '../backend/src/physics/containers/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 {
|
||||
applyArrayPlugins,
|
||||
Random,
|
||||
settings,
|
||||
TransportEvents,
|
||||
deserialize,
|
||||
CreateObjectsCommand,
|
||||
DeleteObjectsCommand,
|
||||
PropertyUpdatesForObjects,
|
||||
UpdateMinimap,
|
||||
ServerAnnouncement,
|
||||
} = shared;
|
||||
|
||||
applyArrayPlugins();
|
||||
|
||||
// A socket whose write buffer we control, shaped the way engine.io really nests
|
||||
// it: the byte count lives on the ws socket under the transport.
|
||||
class FakeSocket {
|
||||
public readonly sent: Array<{ event: string; payload: unknown }> = [];
|
||||
public readonly conn = { transport: { socket: { bufferedAmount: 0 } } };
|
||||
|
||||
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<{ type: 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();
|
||||
createWorld(objects);
|
||||
objects.initialize();
|
||||
const players = new PlayerContainer(objects, 4, 0);
|
||||
const socket = new FakeSocket();
|
||||
const player = players.createPlayer({ name: 'player' }, socket as never);
|
||||
// The constructor already queues this player's own CreatePlayerCommand; flush
|
||||
// it so each case starts from an empty queue.
|
||||
player.sendQueuedCommandsToClient();
|
||||
socket.sent.length = 0;
|
||||
return { player, socket };
|
||||
};
|
||||
|
||||
// One snapshot's worth of queued traffic: the bulky repeated state, and the
|
||||
// one-shot bookkeeping that is never regenerated.
|
||||
const queueOneSnapshot = (player: ReturnType<typeof makePlayer>['player']) => {
|
||||
player.queueCommandSend(new CreateObjectsCommand([]));
|
||||
player.queueCommandSend(new DeleteObjectsCommand([1234]));
|
||||
player.queueCommandSend(new ServerAnnouncement('team blue wins'));
|
||||
player.queueCommandSend(new UpdateMinimap([]));
|
||||
player.queueCommandSend(new PropertyUpdatesForObjects([], 1));
|
||||
};
|
||||
|
||||
const typesIn = (batch: Array<{ type: string }>) => batch.map((c) => c.type);
|
||||
|
||||
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,
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the buffered byte count engine.io actually exposes', () => {
|
||||
const { player, socket } = makePlayer();
|
||||
socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1;
|
||||
queueOneSnapshot(player);
|
||||
player.sendQueuedCommandsToClient();
|
||||
|
||||
// If the guard cannot see the buffer it degrades to sending everything, so
|
||||
// anything shed at all proves it is reading the right property.
|
||||
expect(typesIn(socket.lastBatch)).not.toContain(PropertyUpdatesForObjects.name);
|
||||
});
|
||||
|
||||
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);
|
||||
// Repeated in full every tick — safe to drop.
|
||||
expect(types).not.toContain(PropertyUpdatesForObjects.name);
|
||||
expect(types).not.toContain(UpdateMinimap.name);
|
||||
// One-shot. handleViewAreaUpdate has already advanced its record of what
|
||||
// this client knows, so these are never re-derived: dropping them leaves the
|
||||
// client permanently missing objects and holding ghosts.
|
||||
expect(types).toContain(CreateObjectsCommand.name);
|
||||
expect(types).toContain(DeleteObjectsCommand.name);
|
||||
expect(types).toContain(ServerAnnouncement.name);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
80
test/client-send-cadence.test.ts
Normal file
80
test/client-send-cadence.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// The client's outbound cadence, which is what the server's inbound allowance
|
||||
// has to be sized against. Sending once per rendered frame put every display
|
||||
// above the allowance permanently over it; once the burst was spent the server
|
||||
// silently discarded whole batches, and movement commands are edge-triggered
|
||||
// and never re-sent, so a lost batch meant the direction change never happened.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { CommandSocket } from '../frontend/src/scripts/commands/command-socket';
|
||||
import { setPredictorClockForTesting } 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 { applyArrayPlugins, settings, MoveActionCommand, ClientHeartbeatCommand } = shared;
|
||||
|
||||
applyArrayPlugins();
|
||||
|
||||
// Collects what would go on the wire.
|
||||
const drive = (frameRate: number, seconds: number, onFrame?: (socket: any) => void) => {
|
||||
let clock = 0;
|
||||
setPredictorClockForTesting(() => clock);
|
||||
|
||||
const batches: Array<string> = [];
|
||||
const socket = { 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++) {
|
||||
clock = (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);
|
||||
// One heartbeat per interval, give or take where the frames fall. The
|
||||
// point is that the count barely moves across a 4x range of frame rates —
|
||||
// sending once per frame gave 180, 432 and 720 here.
|
||||
expect(batches.length).toBeLessThanOrEqual(expected * 1.1);
|
||||
expect(batches.length).toBeGreaterThanOrEqual(expected * 0.85);
|
||||
}
|
||||
});
|
||||
|
||||
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', () => {
|
||||
// Input on frame 1 of a 144 Hz client: far inside the heartbeat interval, so
|
||||
// pacing must not hold it back. Latency the player feels is one frame.
|
||||
let sentOn = -1;
|
||||
const batches = drive(144, 0.1, (commandSocket) => {
|
||||
if (sentOn === -1) {
|
||||
commandSocket.handleCommand(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);
|
||||
});
|
||||
});
|
||||
319
test/connection-lifecycle.test.ts
Normal file
319
test/connection-lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
// Drives the real GameServer through a fake socket.io to cover the connection
|
||||
// lifecycle: the join guard, refusal reasons, and reconnect. None of this is
|
||||
// reachable from a unit test of any single class, and it is where the audit
|
||||
// found the most severe defects.
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { GameServer } from '../backend/src/game-server';
|
||||
import { defaultOptions } from '../backend/src/default-options';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const shared = require('../shared/lib/main.js');
|
||||
const { applyArrayPlugins, Random, TransportEvents, JoinRejectionReason, settings } =
|
||||
shared;
|
||||
|
||||
applyArrayPlugins();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Number of listeners the server has attached for an event. */
|
||||
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', () => {
|
||||
// One socket used to be able to emit PlayerJoining as often as it liked, each
|
||||
// pass spawning another Player and stacking another set of listeners, until it
|
||||
// held every slot on the server.
|
||||
it('accepts exactly one join per connection', () => {
|
||||
const { server, attach } = startServer({ playerLimit: 4, npcCount: 1 });
|
||||
const socket = attach();
|
||||
|
||||
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'] });
|
||||
}
|
||||
|
||||
// The joins are accepted (the name is coerced) and no bot is stranded.
|
||||
expect(server.serverInfo.playerCount).toBe(4);
|
||||
});
|
||||
|
||||
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' });
|
||||
|
||||
// Would previously be handed straight to JSON.parse on the physics thread.
|
||||
expect(() =>
|
||||
socket.fire(TransportEvents.PlayerToServer, 'x'.repeat(2 * 1024 * 1024)),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconnect', () => {
|
||||
// A transport blip used to end the match: the client tore itself down and the
|
||||
// server deleted the character, with no way back to the same player.
|
||||
it('hands out a token on join and honours it on the way back', () => {
|
||||
const { server, attach } = startServer({ playerLimit: 4, npcCount: 0 });
|
||||
|
||||
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();
|
||||
// A fresh token for the new session, so the old one cannot be replayed.
|
||||
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', () => {
|
||||
// The bucket exists to survive a flood, but a dropped batch is LOST input,
|
||||
// not delayed input: movement is edge-triggered and nothing is ever re-sent,
|
||||
// so a discarded batch means the direction change simply never happened. The
|
||||
// ceiling used to be 120/s on the assumption of "one batch per frame", which
|
||||
// any display above 120 Hz exceeds outright — after the burst was spent, a
|
||||
// 144 Hz client had roughly one batch in six silently swallowed.
|
||||
//
|
||||
// Each message that gets past the limiter is unparseable and so logs exactly
|
||||
// once, which counts admissions without reaching into the server.
|
||||
const admitted = (socket: { fire: (e: string, p: string) => void }, count: number) => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
for (let i = 0; i < count; i++) {
|
||||
socket.fire(TransportEvents.PlayerToServer, '{');
|
||||
}
|
||||
const seen = spy.mock.calls.length;
|
||||
spy.mockRestore();
|
||||
return seen;
|
||||
};
|
||||
|
||||
// The allowance has to sit well clear of what a well-behaved client actually
|
||||
// sends, or the limiter eats gameplay input instead of floods. It used to be
|
||||
// 120/s against a client that sent once per rendered frame — a ratio below 1
|
||||
// for anyone on a 144 Hz display.
|
||||
it('allows several times what a well-behaved client sends', () => {
|
||||
const clientMessagesPerSecond = 1 / settings.clientSendInterval;
|
||||
|
||||
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' });
|
||||
|
||||
// The paced heartbeat plus a generous allowance for discrete input events.
|
||||
const oneSecond = Math.ceil(1 / settings.clientSendInterval) + 100;
|
||||
|
||||
expect(admitted(socket, oneSecond)).toBe(oneSecond);
|
||||
});
|
||||
|
||||
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('reconnect tokens', () => {
|
||||
// They used to be minted from `Random` — the seeded Mulberry32 stream shared
|
||||
// with world generation, NPC decisions and planet spin, the last of which is
|
||||
// streamed to every client. That made them guessable, and made every draw
|
||||
// after a join shift, so a seed no longer reproduced a match.
|
||||
const tokenFromFreshServer = () => {
|
||||
Random.seed = 1;
|
||||
const { attach } = startServer({ playerLimit: 4, npcCount: 0, seed: 1 });
|
||||
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());
|
||||
});
|
||||
|
||||
// Shape, because that is what distinguishes the two sources: 122 random bits
|
||||
// from the platform CSPRNG, rather than a counter and one 32-bit draw off the
|
||||
// gameplay stream. Minting from `Random` also advanced the stream the world
|
||||
// generator runs on, so the same seed stopped replaying the same match — but
|
||||
// that is not assertable here: this harness seeds a different instance of the
|
||||
// shared bundle than the server source resolves.
|
||||
it('mints them from the platform CSPRNG, not the gameplay stream', () => {
|
||||
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
|
||||
const socket = attach();
|
||||
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('socket listeners', () => {
|
||||
// The Player registers its own Pong listener, so nothing else knew to take it
|
||||
// off. A socket outlives the round it joined, so the retired Player stayed
|
||||
// reachable — and kept updating its RTT — through a listener nobody removed.
|
||||
it('takes every listener back off when the player drops', () => {
|
||||
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
|
||||
const socket = attach();
|
||||
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(1);
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -93,4 +93,90 @@ describe('local prediction reconciliation', () => {
|
|||
// not be predicted/moved — that was the "move while dead" bug.
|
||||
expect(predictor.update([], 1 / 60)).toBe(false);
|
||||
});
|
||||
|
||||
// The replay window spans everything the server has not acknowledged, not just
|
||||
// the snapshot's age. Anchoring on the snapshot's arrival made the window just
|
||||
// that age, so prediction cancelled the interpolation buffer and nothing else
|
||||
// and the local body sat a full one-way trip behind the server.
|
||||
it('replays the whole un-acknowledged span, so a longer trip predicts further', () => {
|
||||
const driveWithUnackedSpan = (ackAgeMs: number) => {
|
||||
let clock = 0;
|
||||
setPredictorClockForTesting(() => clock);
|
||||
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
predictor.setStrength(80);
|
||||
// The key went down well before the acknowledged span and is still held —
|
||||
// the case that used to freeze the acknowledgement, because movement is
|
||||
// only transmitted when it changes.
|
||||
clock = 9_000;
|
||||
predictor.recordInput([1, 0]);
|
||||
|
||||
clock = 10_000;
|
||||
const auth = poseAt(0, 0);
|
||||
// The server has confirmed input only up to `ackAgeMs` ago; the snapshot
|
||||
// carrying that acknowledgement arrives now.
|
||||
predictor.acknowledge(clock - ackAgeMs, [0, 0], -Infinity);
|
||||
predictor.setAuthoritative(
|
||||
auth.head as never,
|
||||
auth.leftFoot as never,
|
||||
auth.rightFoot as never,
|
||||
);
|
||||
|
||||
predictor.update([], 1 / 60);
|
||||
return predictor.head.center[0];
|
||||
};
|
||||
|
||||
const shortTrip = driveWithUnackedSpan(40);
|
||||
const longTrip = driveWithUnackedSpan(200);
|
||||
|
||||
expect(shortTrip).toBeGreaterThan(0);
|
||||
// A 200 ms un-acknowledged span must predict meaningfully further ahead than
|
||||
// a 40 ms one; when the window was pinned to the snapshot they were equal.
|
||||
expect(longTrip).toBeGreaterThan(shortTrip * 2);
|
||||
});
|
||||
// ...but the anchor must not also inherit the AGE of whichever input happened
|
||||
// to be the newest one the server had when it took the snapshot. Input is sent
|
||||
// once per frame, so that age is 0..1 frame depending on where the client's
|
||||
// frames fell between two snapshots, and it walks a sawtooth: measured against
|
||||
// a real server on localhost it ran 5, 16, 12, 8, 3, 14, 9, 1 ms and so on.
|
||||
// Left in the window, it jumped the predicted body by up to a frame of travel
|
||||
// 25 times a second — the local body was choppy on a link with no latency at
|
||||
// all to hide it. The server reports the age with the acknowledgement so the
|
||||
// anchor lands on the snapshot instant instead.
|
||||
it('cancels the age of the acknowledged input, so the send cadence cannot beat', () => {
|
||||
// Both runs describe the SAME snapshot: taken 20 ms ago, of the same pose.
|
||||
// They differ only in how long before it the server's newest input arrived.
|
||||
const snapshotAgeMs = 20;
|
||||
const drive = (inputAgeMs: number, serverReportsAge: boolean) => {
|
||||
let clock = 0;
|
||||
setPredictorClockForTesting(() => clock);
|
||||
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
predictor.setStrength(80);
|
||||
clock = 9_000;
|
||||
predictor.recordInput([1, 0]); // held down throughout
|
||||
|
||||
clock = 10_000;
|
||||
predictor.acknowledge(
|
||||
clock - snapshotAgeMs - inputAgeMs,
|
||||
[0, 0],
|
||||
-Infinity,
|
||||
serverReportsAge ? inputAgeMs : 0,
|
||||
);
|
||||
const auth = poseAt(0, 0);
|
||||
predictor.setAuthoritative(
|
||||
auth.head as never,
|
||||
auth.leftFoot as never,
|
||||
auth.rightFoot as never,
|
||||
);
|
||||
|
||||
predictor.update([], 1 / 60);
|
||||
return predictor.head.center[0];
|
||||
};
|
||||
|
||||
expect(drive(16, true)).toBeCloseTo(drive(0, true), 6);
|
||||
// And the difference being cancelled is a real one: uncancelled, the same
|
||||
// 16 ms moves the body, so the assertion above does not hold vacuously.
|
||||
expect(Math.abs(drive(16, false) - drive(0, false))).toBeGreaterThan(0.5);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue