This commit is contained in:
Andras Schmelczer 2026-08-23 10:27:53 +01:00
commit 82e092ba55
5 changed files with 373 additions and 539 deletions

View file

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

View file

@ -12,9 +12,17 @@ const { applyArrayPlugins, settings, MoveActionCommand, ClientHeartbeatCommand }
applyArrayPlugins();
const drive = (frameRate: number, seconds: number, onFrame?: (socket: any) => void) => {
const drive = (
frameRate: number,
seconds: number,
onFrame?: (socket: any) => void,
connected = true,
) => {
const batches: Array<string> = [];
const socket = { emit: (_event: string, payload: string) => batches.push(payload) };
const socket = {
connected,
emit: (_event: string, payload: string) => batches.push(payload),
};
const commandSocket = new CommandSocket(socket as never);
const frames = Math.round(frameRate * seconds);
@ -64,4 +72,19 @@ describe('client send cadence', () => {
expect(batches.length).toBeGreaterThan(0);
expect(batches.every((b) => b.includes(ClientHeartbeatCommand.name))).toBe(true);
});
// socket.io-client buffers emits made while disconnected in an uncapped
// sendBuffer and flushes them on reconnect, ahead of the re-join — so every
// one of them is both a leak and wasted bandwidth.
it('sends nothing at all while the transport is down', () => {
const batches = drive(
60,
30,
(commandSocket) =>
commandSocket.handleCommand(new MoveActionCommand(vec2.fromValues(1, 0), 0)),
false,
);
expect(batches).toHaveLength(0);
});
});

View file

@ -142,6 +142,25 @@ describe('hostile payloads', () => {
expect(server.serverInfo.playerCount).toBe(4);
});
// socket.io dispatches listeners with no try/catch of its own, so anything
// thrown out of a connection-scoped handler is an uncaught exception: the
// process exits and every player in the match is dropped with it.
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();
@ -231,6 +250,43 @@ describe('inbound rate limiting', () => {
});
});
describe('pre-join rate limiting', () => {
// These listeners exist before any join, so the limiter inside
// onPlayerToServer never sees them: unmetered, each one is a reply or an
// allocation an unauthenticated client can ask for at line rate.
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;

View file

@ -3,6 +3,8 @@ import { describe, it, expect } from 'vitest';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
// Numerics match production because test/setup.mjs has already applied
// glMatrix.setMatrixArrayType(Array) — without it this would pin f32.
const shared = require('../shared/lib/main.js');
const { vec2 } = require('../shared/node_modules/gl-matrix');
@ -58,7 +60,13 @@ const makeSpinningPlanetWorld = () => {
angularVelocity: 0.09,
advance: (dt) => (rotation += surface.angularVelocity * dt),
distance: (target) =>
planetDistance(target, vertices, base.center, Math.cos(rotation), Math.sin(rotation)),
planetDistance(
target,
vertices,
base.center,
Math.cos(rotation),
Math.sin(rotation),
),
gravityAt: (target) => planetGravity(base.center, base.radius, target),
};
const grounds = [surface];
@ -89,8 +97,7 @@ const runPlanetSimulation = () => {
for (let i = 0; i < 900; i++) {
// Fall in, then walk right, then left.
const input =
i < 200 ? vec2.create() : vec2.fromValues(i < 600 ? 1 : -1, 0);
const input = i < 200 ? vec2.create() : vec2.fromValues(i < 600 ? 1 : -1, 0);
tickPlanetDetachment(state, stepSeconds);
stepCharacterMovement(state, world, input, stepSeconds);
surface.advance(stepSeconds);

20
test/setup.mjs Normal file
View file

@ -0,0 +1,20 @@
// Production calls glMatrix.setMatrixArrayType(Array) before anything else
// (backend/src/main.ts, frontend/src/index.ts), which makes every vector an f64
// Array instead of the default Float32Array. Without the same call here the
// suite pins the numerics of a build nobody ships — including module-level
// scratch vectors, which are allocated the moment a module is imported.
//
// One call per installed gl-matrix: the prebuilt shared bundle keeps gl-matrix
// external and resolves shared/node_modules, while the backend and frontend
// sources the tests import directly resolve their own copies.
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);
}