more
This commit is contained in:
parent
98c4d63756
commit
82e092ba55
5 changed files with 373 additions and 539 deletions
|
|
@ -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}"`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
20
test/setup.mjs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue