diff --git a/backend/src/commands/commands.ts b/backend/src/commands/commands.ts deleted file mode 100644 index 981f1fd..0000000 --- a/backend/src/commands/commands.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Command, CommandReceiver, GameObject } from 'shared'; - -export class StepCommand extends Command { - constructor( - public readonly deltaTimeInSeconds: number, - public readonly game: CommandReceiver, - ) { - super(); - } -} - -export class GeneratePointsCommand extends Command { - constructor( - public readonly blue: number, - public readonly red: number, - ) { - super(); - } -} - -export class AnnounceCommand extends Command { - constructor(public readonly text: string) { - super(); - } -} - -export class ReactToCollisionCommand extends Command { - constructor(public readonly other: GameObject) { - super(); - } -} diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 0c076e5..9d5ec8f 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -1,6 +1,7 @@ import { PhysicalContainer } from './physics/containers/physical-container'; import { Server, Socket } from 'socket.io'; import { randomUUID } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; import { TransportEvents, deserialize, @@ -27,6 +28,23 @@ import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/ const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; +// The payload is whatever the client sent: `socket.emit('PlayerJoining')` with +// no argument arrives as undefined. socket.io dispatches listeners inside a +// `process.nextTick` with no try/catch of its own, so a dereference of an +// unchecked payload here would surface as an uncaught exception and take the +// whole match down with the process. +const asPlayerInformation = (payload: unknown): PlayerInformation | undefined => { + if (!payload || typeof payload !== 'object') { + return undefined; + } + const { name, reconnectToken } = payload as Record; + return { + // Coerced and length-capped downstream, in PlayerBase.createCharacter. + name: name as string, + reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined, + }; +}; + export class GameServer extends CommandReceiver { private objects!: PhysicalContainer; private players!: PlayerContainer; @@ -75,12 +93,27 @@ export class GameServer extends CommandReceiver { this.initialize(); + // Both listeners are metered: they exist before any join, so the limiter + // inside onPlayerToServer cannot see them, and each one costs a reply or an + // allocation an unauthenticated client could otherwise ask for at line rate. io.on('connection', (socket: Socket) => { - socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => - this.handleJoin(socket, playerInfo), - ); + socket.on(TransportEvents.PlayerJoining, (payload: unknown) => { + if (!this.allowInboundMessage(socket)) { + return; + } + try { + this.handleJoin(socket, payload); + } catch (e) { + console.error('Failed to handle a join request', e); + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest); + socket.disconnect(); + } + }); socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => { + if (!this.allowInboundMessage(socket)) { + return; + } socket.join(gameStateSubscribedRoom); }); }); @@ -91,7 +124,13 @@ export class GameServer extends CommandReceiver { { player: Player; release: () => void } >(); - private handleJoin(socket: Socket, playerInfo: PlayerInformation) { + private handleJoin(socket: Socket, payload: unknown) { + const playerInfo = asPlayerInformation(payload); + if (!playerInfo) { + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest); + return; + } + if (this.joinedSockets.has(socket)) { socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.AlreadyJoined); return; @@ -164,17 +203,22 @@ export class GameServer extends CommandReceiver { this.sendServerStateUpdate(); } - private readonly inboundBudget = new Map(); + // A WeakMap, not a Map: sockets that never join are metered too, and they + // have no join-scoped teardown to remove their entry. + private inboundBudget = new WeakMap(); private droppedInboundMessages = 0; private allowInboundMessage(socket: Socket): boolean { - const nowMs = Date.now(); + // performance.now(), never Date.now(): a backwards wall-clock step (an NTP + // correction, a VM resync) would make the refill negative and lock the + // client out of sending input for as long as the step was. + const nowMs = performance.now(); const burst = settings.maxInboundMessageBurst; const perSecond = settings.maxInboundMessagesPerSecond; const budget = this.inboundBudget.get(socket) ?? { tokens: burst, lastMs: nowMs }; budget.tokens = Math.min( burst, - budget.tokens + ((nowMs - budget.lastMs) / 1000) * perSecond, + budget.tokens + (Math.max(0, nowMs - budget.lastMs) / 1000) * perSecond, ); budget.lastMs = nowMs; this.inboundBudget.set(socket, budget); @@ -219,7 +263,7 @@ export class GameServer extends CommandReceiver { private releaseJoinedSockets() { this.joinedSockets.forEach(({ release }) => release()); this.joinedSockets.clear(); - this.inboundBudget.clear(); + this.inboundBudget = new WeakMap(); } private timeSinceLastServerStateUpdate = 0; @@ -289,6 +333,15 @@ export class GameServer extends CommandReceiver { const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`; console.info(`Memory: ${mem}, Players: ${this.players.count}`); + const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0); + if (rtts.length > 0) { + rtts.sort((a, b) => a - b); + console.info( + `RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` + + `(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`, + ); + } + if (this.droppedInboundMessages > 0) { console.warn(`Rate limited ${this.droppedInboundMessages} inbound msg(s)`); this.droppedInboundMessages = 0; diff --git a/backend/src/physics/bounding-boxes/bounding-box.ts b/backend/src/physics/bounding-box.ts similarity index 100% rename from backend/src/physics/bounding-boxes/bounding-box.ts rename to backend/src/physics/bounding-box.ts diff --git a/backend/src/physics/bounding-boxes/bounding-box-base.ts b/backend/src/physics/bounding-boxes/bounding-box-base.ts deleted file mode 100644 index 6e107ed..0000000 --- a/backend/src/physics/bounding-boxes/bounding-box-base.ts +++ /dev/null @@ -1,51 +0,0 @@ -export abstract class BoundingBoxBase { - constructor( - protected _xMin: number = 0, - protected _xMax: number = 0, - protected _yMin: number = 0, - protected _yMax: number = 0, - ) {} - - [key: number]: number | undefined; - - public get 0(): number { - return this._xMin; - } - - public get 1(): number { - return this._xMax; - } - - public get 2(): number { - return this._yMin; - } - - public get 3(): number { - return this._yMax; - } - - public get xMin(): number { - return this._xMin; - } - - public get xMax(): number { - return this._xMax; - } - - public get yMin(): number { - return this._yMin; - } - - public get yMax(): number { - return this._yMax; - } - - public intersects(other: BoundingBoxBase): boolean { - return ( - this._xMin < other._xMax && - this._xMax > other._xMin && - this._yMin < other._yMax && - this._yMax > other._yMin - ); - } -} diff --git a/backend/src/physics/bounding-boxes/immutable-bounding-box.ts b/backend/src/physics/bounding-boxes/immutable-bounding-box.ts deleted file mode 100644 index 43b87e1..0000000 --- a/backend/src/physics/bounding-boxes/immutable-bounding-box.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { BoundingBoxBase } from './bounding-box-base'; - -export class ImmutableBoundingBox extends BoundingBoxBase {} diff --git a/backend/src/physics/containers/bounding-box-list.ts b/backend/src/physics/containers/bounding-box-list.ts deleted file mode 100644 index 1c85f99..0000000 --- a/backend/src/physics/containers/bounding-box-list.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { DynamicPhysical } from '../physicals/dynamic-physical'; -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; - -export class BoundingBoxList { - constructor(private objects: Array = []) {} - - public insert(object: DynamicPhysical) { - this.objects.push(object); - } - - public remove(object: DynamicPhysical) { - const index = this.objects.indexOf(object); - if (index >= 0) { - this.objects.splice(index, 1); - } - } - - public forEach(func: (object: DynamicPhysical) => unknown) { - this.objects.forEach(func); - } - - public findIntersecting(boundingBox: BoundingBoxBase): Array { - return this.objects.filter((b) => b.boundingBox.intersects(boundingBox)); - } -} diff --git a/backend/src/physics/containers/bounding-box-tree.ts b/backend/src/physics/containers/bounding-box-tree.ts deleted file mode 100644 index 39981ff..0000000 --- a/backend/src/physics/containers/bounding-box-tree.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; -import { StaticPhysical } from '../physicals/static-physical'; - -// source: https://github.com/ubilabs/kd-tree-javascript/blob/master/kdTree.js -class Node { - public left: Node | null = null; - public right: Node | null = null; - constructor( - public object: StaticPhysical, - public parent: Node | null, - ) {} -} - -export class BoundingBoxTree { - root?: Node | null; - - constructor(objects: Array = []) { - this.build(objects); - } - - public build(objects: Array) { - this.root = this.buildRecursive(objects, 0, null); - } - - private buildRecursive( - objects: Array, - depth: number, - parent: Node | null, - ): Node | null { - if (objects.length === 0) { - return null; - } - - if (objects.length === 1) { - return new Node(objects[0], parent); - } - - const dimension = depth % 4; - - objects.sort((a, b) => a.boundingBox[dimension]! - b.boundingBox[dimension]!); - - const median = Math.floor(objects.length / 2); - - const node = new Node(objects[median], parent); - node.left = this.buildRecursive(objects.slice(0, median), depth + 1, node); - node.right = this.buildRecursive(objects.slice(median + 1), depth + 1, node); - - return node; - } - - public insert(object: StaticPhysical) { - const [insertPosition, depth] = this.findParent(object, this.root, 0, null); - - if (insertPosition === null) { - this.root = new Node(object, null); - } else { - const node = new Node(object, insertPosition); - const dimension = depth % 4; - - if ( - object.boundingBox[dimension]! < insertPosition.object.boundingBox[dimension]! - ) { - insertPosition.left = node; - } else { - insertPosition.right = node; - } - } - } - - public findIntersecting(boundingBox: BoundingBoxBase): Array { - const maybeResults = this.findMaybeIntersecting(boundingBox, this.root!, 0); - const results = maybeResults.filter((b) => b.boundingBox.intersects(boundingBox)); - return results; - } - - private findMaybeIntersecting( - boundingBox: BoundingBoxBase, - node: Node | null, - depth: number, - ): Array { - if (node === null) { - return []; - } - - if (depth % 4 == 0 && boundingBox.xMax < node.object.boundingBox.xMin) { - return this.findMaybeIntersecting(boundingBox, node.left, depth + 1); - } - - if (depth % 4 == 1 && boundingBox.xMin > node.object.boundingBox.xMax) { - return this.findMaybeIntersecting(boundingBox, node.right, depth + 1); - } - - if (depth % 4 == 2 && boundingBox.yMax < node.object.boundingBox.yMin) { - return this.findMaybeIntersecting(boundingBox, node.left, depth + 1); - } - - if (depth % 4 == 3 && boundingBox.yMin > node.object.boundingBox.yMax) { - return this.findMaybeIntersecting(boundingBox, node.right, depth + 1); - } - - return [ - node.object, - ...this.findMaybeIntersecting(boundingBox, node.left, depth + 1), - ...this.findMaybeIntersecting(boundingBox, node.right, depth + 1), - ]; - } - - private findParent( - object: StaticPhysical, - node: Node | null | undefined, - depth: number, - parent: Node | null, - ): [Node | null, number] { - if (!node) { - return [parent, depth - 1]; - } - - const dimension = depth % 4; - - if (object.boundingBox[dimension]! < node.object.boundingBox[dimension]!) { - return this.findParent(object, node.left, depth + 1, node); - } - - return this.findParent(object, node.right, depth + 1, node); - } -} diff --git a/backend/src/physics/functions/force-at-position.ts b/backend/src/physics/functions/force-at-position.ts deleted file mode 100644 index 20b066e..0000000 --- a/backend/src/physics/functions/force-at-position.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { PlanetPhysical } from '../../objects/planet-physical'; -import { Physical } from '../physicals/physical'; - -export const forceAtPosition = (position: vec2, objects: Array) => - objects - .filter((o) => o instanceof PlanetPhysical) - .reduce( - (sum: vec2, o: Physical) => - vec2.add(sum, sum, (o as PlanetPhysical).gravityAt(position)), - vec2.create(), - ); diff --git a/backend/src/physics/functions/get-bounding-box-of-circle.ts b/backend/src/physics/functions/get-bounding-box-of-circle.ts deleted file mode 100644 index 43ef937..0000000 --- a/backend/src/physics/functions/get-bounding-box-of-circle.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Circle } from 'shared'; -import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box'; - -export const getBoundingBoxOfCircle = (circle: Circle): ImmutableBoundingBox => - new ImmutableBoundingBox( - circle.center.x - circle.radius, - circle.center.x + circle.radius, - circle.center.y - circle.radius, - circle.center.y + circle.radius, - ); diff --git a/backend/src/physics/containers/physical-container.ts b/backend/src/physics/physical-container.ts similarity index 100% rename from backend/src/physics/containers/physical-container.ts rename to backend/src/physics/physical-container.ts diff --git a/backend/src/physics/physicals/physical.ts b/backend/src/physics/physical.ts similarity index 100% rename from backend/src/physics/physicals/physical.ts rename to backend/src/physics/physical.ts diff --git a/backend/src/physics/physicals/dynamic-physical.ts b/backend/src/physics/physicals/dynamic-physical.ts deleted file mode 100644 index d72cd9d..0000000 --- a/backend/src/physics/physicals/dynamic-physical.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PhysicalBase } from './physical-base'; - -export interface DynamicPhysical extends PhysicalBase { - readonly canMove: true; -} diff --git a/backend/src/physics/physicals/physical-base.ts b/backend/src/physics/physicals/physical-base.ts deleted file mode 100644 index ab41eac..0000000 --- a/backend/src/physics/physicals/physical-base.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { CommandReceiver, GameObject } from 'shared'; -import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; - -export interface PhysicalBase extends CommandReceiver { - readonly canCollide: boolean; - readonly canMove: boolean; - readonly boundingBox: BoundingBoxBase; - readonly gameObject: GameObject; - - distance(target: vec2): number; -} diff --git a/backend/src/physics/physicals/static-physical.ts b/backend/src/physics/physicals/static-physical.ts deleted file mode 100644 index eef168b..0000000 --- a/backend/src/physics/physicals/static-physical.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { PhysicalBase } from './physical-base'; -import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box'; - -export interface StaticPhysical extends PhysicalBase { - readonly canMove: false; - readonly boundingBox: ImmutableBoundingBox; -} diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index 22e6163..5ce7552 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -121,6 +121,12 @@ export abstract class PlayerBase extends CommandReceiver { protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { let rotation = 0; let radius = 0; + // preferredCenter is a planet centre, the single worst place to give up on: + // depenetrateCircle only runs four passes, and a body left that deep inside + // the rock registers a zero-distance hit every march and never moves again. + // So the roomiest point the spiral saw is kept as the fallback instead. + let roomiestPosition = vec2.clone(preferredCenter); + let roomiestClearance = -Infinity; for (let attempt = 0; attempt < 512; attempt++) { const playerPosition = vec2.fromValues( radius * Math.cos(rotation) + preferredCenter.x, @@ -132,18 +138,20 @@ export abstract class PlayerBase extends CommandReceiver { const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle); const possibleIntersectors = this.objectContainer.findIntersecting(playerBoundingBox); - if ( - evaluateSdf(playerBoundingCircle.center, possibleIntersectors) >= - playerBoundingCircle.radius - ) { + const clearance = evaluateSdf(playerBoundingCircle.center, possibleIntersectors); + if (clearance >= playerBoundingCircle.radius) { return playerPosition; } + if (clearance > roomiestClearance) { + roomiestClearance = clearance; + roomiestPosition = playerPosition; + } rotation += Math.PI / 8; radius += 30; } - return vec2.clone(preferredCenter); + return roomiestPosition; } public destroy() { diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 4238441..8787332 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -56,12 +56,12 @@ export class Player extends PlayerBase { public reconnectToken = ''; - // Drives projectile lag compensation as well as the server-side latency stats. + // Server-side latency stat, reported in GameServer's periodic log. public rttMs = 0; private timeSinceLastPing = 0; private lastPingSentMs = 0; // Nonce of the ping awaiting a reply: prevents a client forging Pongs or - // answering a stale ping to corrupt the RTT that feeds lag compensation. + // answering a stale ping to corrupt the measured RTT. private pendingPingNonce = 0; private nextPingNonce = 1; @@ -136,9 +136,10 @@ export class Player extends PlayerBase { this.socket.on(TransportEvents.Pong, this.onPong); } - // Only the outstanding ping's nonce counts, once. A client can sit on the - // live ping to inflate measured RTT, so the result is clamped; the next ping - // retires the nonce, bounding any stall at one ping interval. + // Only the outstanding ping's nonce counts, once: a client cannot forge a + // Pong or answer a stale ping to skew the stat. Sitting on the live ping to + // inflate the measurement is bounded by the clamp, and the next ping retires + // the nonce, so any stall costs at most one ping interval. private readonly onPong = (nonce: unknown) => { if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) { return; diff --git a/backend/src/players/server-full-error.ts b/backend/src/players/server-full-error.ts deleted file mode 100644 index 720e5b3..0000000 --- a/backend/src/players/server-full-error.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class ServerFullError extends Error { - constructor() { - super('Too many players'); - } -} diff --git a/frontend/src/scripts/charge-indicator.ts b/frontend/src/scripts/charge-indicator.ts index 670711b..1a3d01d 100644 --- a/frontend/src/scripts/charge-indicator.ts +++ b/frontend/src/scripts/charge-indicator.ts @@ -4,9 +4,9 @@ import { pointer } from './helper/pointer'; let element: HTMLElement | undefined; let heldSince = 0; let raf = 0; -let _followPointer = false; +let isFollowingPointer = false; -export function beginChargeIndicator(x: number, y: number, followPointer = false) { +function beginChargeIndicator(x: number, y: number, followPointer = false) { endChargeIndicator(); const el = document.createElement('div'); @@ -17,11 +17,11 @@ export function beginChargeIndicator(x: number, y: number, followPointer = false element = el; heldSince = performance.now(); - _followPointer = followPointer; + isFollowingPointer = followPointer; raf = requestAnimationFrame(updateChargeIndicator); } -export function endChargeIndicator() { +function endChargeIndicator() { if (element) { cancelAnimationFrame(raf); element.parentElement?.removeChild(element); @@ -41,7 +41,7 @@ function updateChargeIndicator() { }deg, rgba(255, 255, 255, 0.15) 0deg)`; element.classList.toggle('full', charge >= 1); - if (_followPointer) { + if (isFollowingPointer) { const cursor = pointer.displayPosition; if (cursor) { element.style.left = `${cursor.x}px`; @@ -52,6 +52,7 @@ function updateChargeIndicator() { raf = requestAnimationFrame(updateChargeIndicator); } +// The one public surface; the functions above stay module-private. export const ChargeIndicator = { begin: beginChargeIndicator, end: endChargeIndicator, diff --git a/frontend/src/scripts/commands/command-socket.ts b/frontend/src/scripts/commands/command-socket.ts index f7f04d5..da06121 100644 --- a/frontend/src/scripts/commands/command-socket.ts +++ b/frontend/src/scripts/commands/command-socket.ts @@ -30,6 +30,17 @@ export class CommandSocket extends CommandReceiver { } public sendQueuedCommands() { + // Nothing goes out while the transport is down. socket.io-client would + // otherwise buffer every emit in an uncapped sendBuffer and flush it on + // reconnect BEFORE the `connect` handler re-sends PlayerJoining — the + // server has no PlayerToServer listener attached yet and drops the lot. + // The local queue is dropped with it: these are timestamped inputs, and + // replaying a minute-old shot after the gap would be worse than losing it. + if (!this.socket.connected) { + this.commandQueue = []; + return; + } + const nowMs = predictorNowMs(); // Real input goes out on the frame it happens — that is the latency budget the player feels. An idle client still keeps the server's input acknowledgement moving (a held key generates no fresh command), paced by the clock not the frame rate; sending every rendered frame would exceed settings.maxInboundMessagesPerSecond, and since movement commands are edge-triggered and never re-sent, a discarded batch would drop the direction change for good. diff --git a/frontend/src/scripts/commands/types/before-destroy.ts b/frontend/src/scripts/commands/types/before-destroy.ts deleted file mode 100644 index 3020623..0000000 --- a/frontend/src/scripts/commands/types/before-destroy.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Command } from 'shared'; - -export class BeforeDestroyCommand extends Command { - public constructor() { - super(); - } -} diff --git a/frontend/src/scripts/commands/types/render.ts b/frontend/src/scripts/commands/types/render.ts deleted file mode 100644 index c83f5a1..0000000 --- a/frontend/src/scripts/commands/types/render.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Renderer } from 'sdf-2d'; -import { Command } from 'shared'; - -export class RenderCommand extends Command { - public constructor( - public readonly renderer: Renderer, - public readonly overlay: HTMLElement, - public readonly shouldChangeLayout: boolean, - ) { - super(); - } -} diff --git a/frontend/src/scripts/commands/types/step.ts b/frontend/src/scripts/commands/types/step.ts deleted file mode 100644 index ff0d3cc..0000000 --- a/frontend/src/scripts/commands/types/step.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Command } from 'shared'; - -export class StepCommand extends Command { - public constructor(public readonly deltaTimeInSeconds: number) { - super(); - } -} diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts index 939b99f..ccc4636 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -33,7 +33,7 @@ function addTransient(el: HTMLElement, lifetimeMs: number) { setTimeout(() => el.parentElement?.removeChild(el), lifetimeMs); } -export function hitMarker(charge = 0) { +function hitMarker(charge = 0) { const { x, y } = focusPoint(); const marker = document.createElement('div'); marker.className = @@ -43,7 +43,7 @@ export function hitMarker(charge = 0) { addTransient(marker, 250); } -export function killConfirmed(victimName?: string, streak = 1, charge = 0) { +function killConfirmed(victimName?: string, streak = 1, charge = 0) { const { killfeed: kf } = ensureRoot(); const charged = charge >= settings.chargedHitThreshold; @@ -74,7 +74,7 @@ export function killConfirmed(victimName?: string, streak = 1, charge = 0) { } } -export function showElimination(): void { +function showElimination(): void { if (elimination) { return; } @@ -88,12 +88,12 @@ export function showElimination(): void { elimination = el; } -export function hideElimination(): void { +function hideElimination(): void { elimination?.parentElement?.removeChild(elimination); elimination = undefined; } -export function resetFeedbackHud(): void { +function resetFeedbackHud(): void { hideElimination(); root?.parentElement?.removeChild(root); root = undefined; @@ -119,6 +119,7 @@ function escapeHtml(text: string): string { return div.innerHTML; } +// The one public surface; the functions above stay module-private. export const FeedbackHud = { hitMarker, killConfirmed, diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index aa88ce8..29ca72e 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -46,6 +46,10 @@ import { Minimap } from './minimap'; import { ScreenShake } from './screen-shake'; import { FeedbackHud } from './feedback-hud'; +// Ten attempts at the configured backoff is on the order of a minute of +// retrying before the client gives up and returns to the landing page. +const maximumReconnectionAttempts = 10; + export class Game extends CommandReceiver { public gameObjects = new GameObjectContainer(this); public renderer?: Renderer; @@ -106,6 +110,10 @@ export class Game extends CommandReceiver { this.socket = io(this.playerDecision.server, { reconnectionDelayMax: 10000, + // Must be finite. The default is Infinity, and with it the manager never + // gives up, so `reconnect_failed` below never fires and a client whose + // server is gone sits on a frozen world behind the banner forever. + reconnectionAttempts: maximumReconnectionAttempts, transports: ['websocket'], forceNew: true, parser, @@ -138,6 +146,14 @@ export class Game extends CommandReceiver { ...this.playerDecision, reconnectToken: this.reconnectToken, }); + // A reconnect gets a brand-new server-side Player, back on the default + // aspect ratio; the camera only reports a ratio when it changes, so + // without this re-send it would stay wrong for the rest of the match. + if (this.lastAspectRatio !== undefined) { + this.socketReceiver.handleCommand( + new SetAspectRatioActionCommand(this.lastAspectRatio), + ); + } }); this.socket.io.on('reconnect_failed', () => { @@ -303,7 +319,9 @@ export class Game extends CommandReceiver { return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create(); } + private lastAspectRatio?: number; public aspectRatioChanged(aspectRatio: number) { + this.lastAspectRatio = aspectRatio; this.socketReceiver.handleCommand(new SetAspectRatioActionCommand(aspectRatio)); } diff --git a/frontend/src/scripts/helper/interpolators/circle-interpolator.ts b/frontend/src/scripts/helper/interpolators/circle-interpolator.ts new file mode 100644 index 0000000..62816e5 --- /dev/null +++ b/frontend/src/scripts/helper/interpolators/circle-interpolator.ts @@ -0,0 +1,22 @@ +import { Circle } from 'shared'; +import { LinearInterpolator } from './linear-interpolator'; +import { Vec2Interpolator } from './vec2-interpolator'; + +export class CircleInterpolator { + private center: Vec2Interpolator; + private radius: LinearInterpolator; + + constructor(currentValue: Circle) { + this.center = new Vec2Interpolator(currentValue.center); + this.radius = new LinearInterpolator(currentValue.radius); + } + + public addFrame(value: Circle, rateOfChange: Circle) { + this.center.addFrame(value.center, rateOfChange.center); + this.radius.addFrame(value.radius, rateOfChange.radius); + } + + public getValue(deltaTime: number): Circle { + return new Circle(this.center.getValue(deltaTime), this.radius.getValue(deltaTime)); + } +} diff --git a/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts new file mode 100644 index 0000000..5a4544e --- /dev/null +++ b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts @@ -0,0 +1,22 @@ +import { vec2 } from 'gl-matrix'; +import { LinearInterpolator } from './linear-interpolator'; + +// Indexed access, not .x/.y, so it never depends on the vec2 prototype plugin. +export class Vec2Interpolator { + private x: LinearInterpolator; + private y: LinearInterpolator; + + constructor(currentValue: vec2) { + this.x = new LinearInterpolator(currentValue[0]); + this.y = new LinearInterpolator(currentValue[1]); + } + + public addFrame(value: vec2, rateOfChange: vec2) { + this.x.addFrame(value[0], rateOfChange[0]); + this.y.addFrame(value[1], rateOfChange[1]); + } + + public getValue(deltaTime: number): vec2 { + return vec2.fromValues(this.x.getValue(deltaTime), this.y.getValue(deltaTime)); + } +} diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index e0434b3..3b9b004 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -1,10 +1,9 @@ -import { vec2, vec3 } from 'gl-matrix'; +import { vec2 } from 'gl-matrix'; import { calculateViewArea, - clamp01, CommandExecutors, CommandReceiver, - mix, + mixRgb, settings, } from 'shared'; import { RenderCommand } from '../../commands/types/render'; @@ -60,16 +59,9 @@ export class Camera extends CommandReceiver { const viewArea = calculateViewArea(shakenCenter, canvasAspectRatio, scale * scale); renderer.setViewArea(viewArea.topLeft, viewArea.size); + const [near, far] = settings.backgroundGradient; renderer.setRuntimeSettings({ - ambientLight: (() => { - const q = clamp01(vec2.length(this.center) / settings.worldRadius); - const [a, b] = settings.backgroundGradient; - return vec3.fromValues( - mix(a[0], b[0], q), - mix(a[1], b[1], q), - mix(a[2], b[2], q), - ); - })(), + ambientLight: mixRgb(near, far, vec2.length(this.center) / settings.worldRadius), }); } } diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index d806d21..6fe951b 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -18,6 +18,7 @@ import { import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; import { RenderCommand } from '../../commands/types/render'; import { StepCommand } from '../../commands/types/step'; +import { CircleInterpolator } from '../../helper/interpolators/circle-interpolator'; import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; import { pointer } from '../../helper/pointer'; import { CharacterShape } from '../../shapes/character-shape'; @@ -59,15 +60,9 @@ export class CharacterView extends CharacterBase { public isMainCharacter = false; - private lfx = new LinearInterpolator(0); - private lfy = new LinearInterpolator(0); - private lfr = new LinearInterpolator(0); - private rfx = new LinearInterpolator(0); - private rfy = new LinearInterpolator(0); - private rfr = new LinearInterpolator(0); - private hdx = new LinearInterpolator(0); - private hdy = new LinearInterpolator(0); - private hdr = new LinearInterpolator(0); + private headInterpolator: CircleInterpolator; + private leftFootInterpolator: CircleInterpolator; + private rightFootInterpolator: CircleInterpolator; protected commandExecutors: CommandExecutors = { [RenderCommand.type]: this.draw.bind(this), @@ -96,18 +91,9 @@ export class CharacterView extends CharacterBase { ); this.deathBurst = new CircleLight(vec2.clone(this.head!.center), deathBurstColor, 0); - const lf = this.leftFoot!; - this.lfx = new LinearInterpolator(lf.center[0]); - this.lfy = new LinearInterpolator(lf.center[1]); - this.lfr = new LinearInterpolator(lf.radius); - const rf = this.rightFoot!; - this.rfx = new LinearInterpolator(rf.center[0]); - this.rfy = new LinearInterpolator(rf.center[1]); - this.rfr = new LinearInterpolator(rf.radius); - const hd = this.head!; - this.hdx = new LinearInterpolator(hd.center[0]); - this.hdy = new LinearInterpolator(hd.center[1]); - this.hdr = new LinearInterpolator(hd.radius); + this.headInterpolator = new CircleInterpolator(this.head!); + this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!); + this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!); this.nameElement.className = 'player-tag ' + this.team; this.nameElement.innerText = this.name; @@ -163,19 +149,13 @@ export class CharacterView extends CharacterBase { rateOfChange, }: UpdatePropertyCommand) { if (propertyKey === 'head') { - this.hdx.addFrame(propertyValue.center[0], rateOfChange.center[0]); - this.hdy.addFrame(propertyValue.center[1], rateOfChange.center[1]); - this.hdr.addFrame(propertyValue.radius, rateOfChange.radius); + this.headInterpolator.addFrame(propertyValue, rateOfChange); } if (propertyKey === 'leftFoot') { - this.lfx.addFrame(propertyValue.center[0], rateOfChange.center[0]); - this.lfy.addFrame(propertyValue.center[1], rateOfChange.center[1]); - this.lfr.addFrame(propertyValue.radius, rateOfChange.radius); + this.leftFootInterpolator.addFrame(propertyValue, rateOfChange); } if (propertyKey === 'rightFoot') { - this.rfx.addFrame(propertyValue.center[0], rateOfChange.center[0]); - this.rfy.addFrame(propertyValue.center[1], rateOfChange.center[1]); - this.rfr.addFrame(propertyValue.radius, rateOfChange.radius); + this.rightFootInterpolator.addFrame(propertyValue, rateOfChange); } if (propertyKey === 'strength') { this.strengthInterpolator.addFrame(propertyValue, rateOfChange); @@ -242,27 +222,9 @@ export class CharacterView extends CharacterBase { } private step({ deltaTimeInSeconds }: StepCommand): void { - this.head! = new Circle( - vec2.fromValues( - this.hdx.getValue(deltaTimeInSeconds), - this.hdy.getValue(deltaTimeInSeconds), - ), - this.hdr.getValue(deltaTimeInSeconds), - ); - this.leftFoot! = new Circle( - vec2.fromValues( - this.lfx.getValue(deltaTimeInSeconds), - this.lfy.getValue(deltaTimeInSeconds), - ), - this.lfr.getValue(deltaTimeInSeconds), - ); - this.rightFoot! = new Circle( - vec2.fromValues( - this.rfx.getValue(deltaTimeInSeconds), - this.rfy.getValue(deltaTimeInSeconds), - ), - this.rfr.getValue(deltaTimeInSeconds), - ); + this.head! = this.headInterpolator.getValue(deltaTimeInSeconds); + this.leftFoot! = this.leftFootInterpolator.getValue(deltaTimeInSeconds); + this.rightFoot! = this.rightFootInterpolator.getValue(deltaTimeInSeconds); this.strength = clamp( this.strengthInterpolator.getValue(deltaTimeInSeconds), diff --git a/frontend/src/scripts/objects/types/lamp-view.ts b/frontend/src/scripts/objects/types/lamp-view.ts index f6f0d3f..e4db4f6 100644 --- a/frontend/src/scripts/objects/types/lamp-view.ts +++ b/frontend/src/scripts/objects/types/lamp-view.ts @@ -1,6 +1,6 @@ import { vec2, vec3 } from 'gl-matrix'; import { CircleLight } from 'sdf-2d'; -import { clamp01, CommandExecutors, Id, LampBase, mix, settings } from 'shared'; +import { CommandExecutors, Id, LampBase, mixRgb, settings } from 'shared'; import { RenderCommand } from '../../commands/types/render'; import { StepCommand } from '../../commands/types/step'; @@ -29,12 +29,7 @@ export class LampView extends LampBase { private step({ deltaTimeInSeconds }: StepCommand): void { const t = 1 - Math.exp(-deltaTimeInSeconds / settings.lampLerpSeconds); - const q = clamp01(t); - this.light.color = vec3.fromValues( - mix(this.light.color[0], this.targetColor[0], q), - mix(this.light.color[1], this.targetColor[1], q), - mix(this.light.color[2], this.targetColor[2], q), - ); + this.light.color = mixRgb(this.light.color, this.targetColor, t); this.light.intensity += (this.targetLightness - this.light.intensity) * t; } diff --git a/frontend/src/scripts/objects/types/projectile-view.ts b/frontend/src/scripts/objects/types/projectile-view.ts index ba1a255..5843e59 100644 --- a/frontend/src/scripts/objects/types/projectile-view.ts +++ b/frontend/src/scripts/objects/types/projectile-view.ts @@ -10,13 +10,12 @@ import { } from 'shared'; import { RenderCommand } from '../../commands/types/render'; import { StepCommand } from '../../commands/types/step'; -import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; +import { Vec2Interpolator } from '../../helper/interpolators/vec2-interpolator'; export class ProjectileView extends ProjectileBase { private light: CircleLight; - private centerX = new LinearInterpolator(0); - private centerY = new LinearInterpolator(0); + private centerInterpolator: Vec2Interpolator; protected commandExecutors: CommandExecutors = { [RenderCommand.type]: this.draw.bind(this), @@ -37,22 +36,17 @@ export class ProjectileView extends ProjectileBase { settings.paletteDim[settings.colorIndices[team]], 0, ); - this.centerX = new LinearInterpolator(center[0]); - this.centerY = new LinearInterpolator(center[1]); + this.centerInterpolator = new Vec2Interpolator(center); } private updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand): void { - this.centerX.addFrame(propertyValue[0], rateOfChange[0]); - this.centerY.addFrame(propertyValue[1], rateOfChange[1]); + this.centerInterpolator.addFrame(propertyValue, rateOfChange); } private handleStep({ deltaTimeInSeconds }: StepCommand): void { this.step(deltaTimeInSeconds); - this.center = vec2.fromValues( - this.centerX.getValue(deltaTimeInSeconds), - this.centerY.getValue(deltaTimeInSeconds), - ); + this.center = this.centerInterpolator.getValue(deltaTimeInSeconds); this.light.center = this.center; this.light.intensity = Math.min( 0.1, diff --git a/frontend/src/scripts/screen-shake.ts b/frontend/src/scripts/screen-shake.ts index bc7a6d7..98b84e6 100644 --- a/frontend/src/scripts/screen-shake.ts +++ b/frontend/src/scripts/screen-shake.ts @@ -17,21 +17,21 @@ function reducedMotion(): boolean { ); } -export function addTrauma(t: number): void { +function addTrauma(t: number): void { if (reducedMotion()) { return; } trauma = Math.min(1, trauma + t); } -export function addPunch(amount: number): void { +function addPunch(amount: number): void { if (reducedMotion()) { return; } punch = Math.min(1, punch + amount); } -export function stepScreenShake(deltaTimeInSeconds: number): void { +function stepScreenShake(deltaTimeInSeconds: number): void { trauma = Math.max(0, trauma - traumaDecayPerSecond * deltaTimeInSeconds); punch = Math.max(0, punch - punchDecayPerSecond * deltaTimeInSeconds); const shake = trauma * trauma; @@ -39,37 +39,27 @@ export function stepScreenShake(deltaTimeInSeconds: number): void { offsetYValue = maxTranslation * shake * (Math.random() * 2 - 1); } -function getOffsetX(): number { - return offsetXValue; -} - -function getOffsetY(): number { - return offsetYValue; -} - -function getViewScale(): number { - return 1 - maxZoom * punch * punch; -} - -export function resetScreenShake(): void { +function resetScreenShake(): void { trauma = 0; punch = 0; offsetXValue = 0; offsetYValue = 0; } +// The one public surface. The functions above stay module-private so each +// operation has exactly one name. export const ScreenShake = { add: addTrauma, addPunch, step: stepScreenShake, get offsetX() { - return getOffsetX(); + return offsetXValue; }, get offsetY() { - return getOffsetY(); + return offsetYValue; }, get viewScale() { - return getViewScale(); + return 1 - maxZoom * punch * punch; }, reset: resetScreenShake, }; diff --git a/shared/src/commands/command-generator.ts b/shared/src/commands/command-generator.ts deleted file mode 100644 index 27db5f3..0000000 --- a/shared/src/commands/command-generator.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { CommandReceiver } from './command-receiver'; -import { Command } from './command'; - -export abstract class CommandGenerator { - private subscribers: Array = []; - - public subscribe(subscriber: CommandReceiver): void { - this.subscribers.push(subscriber); - } - - public clearSubscribers(): void { - this.subscribers = []; - } - - protected sendCommandToSubscribers(command: Command): void { - this.subscribers.forEach((s) => s.handleCommand(command)); - } -} diff --git a/shared/src/commands/types/actions/primary-action.ts b/shared/src/commands/types/actions/primary-action.ts index e0365e3..762feeb 100644 --- a/shared/src/commands/types/actions/primary-action.ts +++ b/shared/src/commands/types/actions/primary-action.ts @@ -7,9 +7,9 @@ export class PrimaryActionCommand extends Command { public constructor( public readonly position: vec2, public readonly charge: number = 0, - // Client wall-clock at shot release. Appended last so the existing wire - // order is unchanged. The server fast-forwards the projectile by its - // in-flight time so the shooter need not lead by their own latency. + // Client wall-clock at shot release, echoed back via InputAcknowledgement + // like the other actions' — it advances the predictor's replay anchor past + // this shot. Appended last so the existing wire order is unchanged. public readonly clientTimeMs: number = 0, ) { super(); diff --git a/shared/src/helper/array.ts b/shared/src/helper/array.ts deleted file mode 100644 index a7f4554..0000000 --- a/shared/src/helper/array.ts +++ /dev/null @@ -1,33 +0,0 @@ -declare global { - // `T` must stay to merge with the global `Array` (otherwise TS2428). - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface Array { - x: number; - y: number; - } - - interface Float32Array { - x: number; - y: number; - } -} - -const setIndexAlias = (name: string, index: number, type: any) => { - if (!Object.prototype.hasOwnProperty.call(type.prototype, name)) { - Object.defineProperty(type.prototype, name, { - get() { - return this[index]; - }, - set(value) { - this[index] = value; - }, - }); - } -}; - -export const applyArrayPlugins = () => { - setIndexAlias('x', 0, Array); - setIndexAlias('y', 1, Array); - setIndexAlias('x', 0, Float32Array); - setIndexAlias('y', 1, Float32Array); -}; diff --git a/shared/src/helper/mix-rgb.ts b/shared/src/helper/mix-rgb.ts new file mode 100644 index 0000000..b3959b4 --- /dev/null +++ b/shared/src/helper/mix-rgb.ts @@ -0,0 +1,14 @@ +import { vec3 } from 'gl-matrix'; +import { clamp01 } from './clamp'; +import { mix } from './mix'; + +// Component-wise lerp of two colours. Kept as a helper because the alternative +// is the same clamp-plus-three-mixes expansion repeated at every call site. +export const mixRgb = (a: vec3, b: vec3, q: number): vec3 => { + const clampedQ = clamp01(q); + return vec3.fromValues( + mix(a[0], b[0], clampedQ), + mix(a[1], b[1], clampedQ), + mix(a[2], b[2], clampedQ), + ); +}; diff --git a/shared/src/main.ts b/shared/src/main.ts index 5776194..8501417 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -25,6 +25,7 @@ export * from './helper/charge'; export * from './helper/calculate-view-area'; export * from './helper/circle'; export * from './helper/mix'; +export * from './helper/mix-rgb'; export * from './helper/random'; export * from './communication/communication'; export * from './objects/game-object'; diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index acd99ee..49cac9a 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -341,6 +341,10 @@ export const stepCharacterMovement = ( applyBodyMomentum(state); + // Only the feet latch. The grounded branch above projects gravity onto the + // foot contact normals and leap() launches off them, so latching on a head + // contact would ground the body against normals no part of it is touching — + // a head-first tumble would read as standing. latchGround(state, world.stepBody(state.leftFoot, deltaTimeInSeconds)); latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds)); world.stepBody(state.head, deltaTimeInSeconds); diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts index 5f7836b..7d72ac1 100644 --- a/shared/src/physics/march-circle.ts +++ b/shared/src/physics/march-circle.ts @@ -49,9 +49,16 @@ export const marchCircle = ( )!; if (ignoreCollision) { - // Pass through, but still report the contact so repeated marches keep progressing. + // Pass through, but still report the contact so repeated marches keep + // progressing. The normal comes along: `hitSurface: true` is the + // contract callers read it under, and one without it would be a + // TypeError in whichever caller first enables pass-through. vec2.scaleAndAdd(body.center, body.center, direction, deltaLength); - return { hitSurface: true, hitObject: intersecting }; + return { + hitSurface: true, + normal: sdfNormal(rayEnd, [intersecting]), + hitObject: intersecting, + }; } onHit?.(intersecting); diff --git a/shared/src/physics/planet-sdf.ts b/shared/src/physics/planet-sdf.ts index be61212..ea7e72f 100644 --- a/shared/src/physics/planet-sdf.ts +++ b/shared/src/physics/planet-sdf.ts @@ -18,13 +18,17 @@ export const toPlanetLocalFrame = ( // Innermost leaf — module-level scratch to avoid per-call allocation. Nothing // re-enters planetDistance and none of these outlive the call. -const localPoint = vec2.create(); -const targetFromDelta = vec2.create(); -const toFromDelta = vec2.create(); -const closestOnEdge = vec2.create(); -// Stays a vec2 (Float32Array) because rounding to f32 is part of the outline -// the server and the client both have to agree on. -const edgeSample = vec2.create(); +// +// Plain array literals, NOT vec2.create(): imports are evaluated before either +// entry point reaches its glMatrix.setMatrixArrayType(Array) call, so +// vec2.create() here would hand back Float32Array and quietly run the one +// outline the server and the client must agree on at f32 while every other +// vector in the simulation is f64. +const localPoint: vec2 = [0, 0]; +const targetFromDelta: vec2 = [0, 0]; +const toFromDelta: vec2 = [0, 0]; +const closestOnEdge: vec2 = [0, 0]; +const edgeSample: vec2 = [0, 0]; // The one and only planet outline: the authoritative server and the client // predictor both collide against it, which is what lets prediction reconcile. diff --git a/shared/src/physics/sdf-normal.ts b/shared/src/physics/sdf-normal.ts index 028d16a..96ba9cc 100644 --- a/shared/src/physics/sdf-normal.ts +++ b/shared/src/physics/sdf-normal.ts @@ -3,8 +3,10 @@ import { Sdf } from './sdf'; import { evaluateSdf } from './evaluate-sdf'; // Reused probe point — sdfNormal is called on every depenetration pass and -// raymarch hit. -const probe = vec2.create(); +// raymarch hit. A plain array literal for the same reason as planet-sdf's +// scratch: it is allocated before setMatrixArrayType(Array) runs, and the +// gradient must be computed at f64 like the rest of the simulation. +const probe: vec2 = [0, 0]; const sampleAt = (x: number, y: number, objects: Array): number => { vec2.set(probe, x, y); diff --git a/shared/src/serialization/serializable-mapping.ts b/shared/src/serialization/serializable-mapping.ts index a287fe5..2de8ad3 100644 --- a/shared/src/serialization/serializable-mapping.ts +++ b/shared/src/serialization/serializable-mapping.ts @@ -1,3 +1,9 @@ export type SerializableClass = { new (...args: Array): unknown; name: string }; +// Wire format: the property the decorators stamp on and serialize() looks for +// to recognise a decorated class. Defined once on purpose — per-file copies +// would have to agree byte-for-byte, and a typo in one of them compiles and +// type-checks cleanly while every networked object degrades to a raw array. +export const MANGLE = '__serializable_type'; + export const serializableMapping = new Map(); diff --git a/shared/src/serialization/serializable.ts b/shared/src/serialization/serializable.ts index b2a0309..79852f3 100644 --- a/shared/src/serialization/serializable.ts +++ b/shared/src/serialization/serializable.ts @@ -1,6 +1,4 @@ -import { SerializableClass, serializableMapping } from './serializable-mapping'; - -const MANGLE = '__serializable_type'; +import { MANGLE, SerializableClass, serializableMapping } from './serializable-mapping'; export const serializable = (type: SerializableClass): any => { if (!serializableMapping.has(type.name)) { diff --git a/shared/src/serialization/serialize.ts b/shared/src/serialization/serialize.ts index c5b1529..2a91848 100644 --- a/shared/src/serialization/serialize.ts +++ b/shared/src/serialization/serialize.ts @@ -1,4 +1,4 @@ -const MANGLE = '__serializable_type'; +import { MANGLE } from './serializable-mapping'; export const serialize = (object: any): string => { return JSON.stringify(object, (_, value) => { diff --git a/shared/src/serialization/serialized-fields.ts b/shared/src/serialization/serialized-fields.ts deleted file mode 100644 index dd32dc0..0000000 --- a/shared/src/serialization/serialized-fields.ts +++ /dev/null @@ -1,7 +0,0 @@ -// deserialize reconstructs via `new Ctor(...array.slice(1))`, so the field list -// MUST name the constructor's parameters in order. Derived (non-serialized) -// fields are deliberately absent. -export const toArrayFromFields = ( - object: any, - fields: ReadonlyArray, -): Array => fields.map((field) => object[field]); diff --git a/shared/src/serialization/serializes-to.ts b/shared/src/serialization/serializes-to.ts deleted file mode 100644 index ed1296e..0000000 --- a/shared/src/serialization/serializes-to.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { SerializableClass, serializableMapping } from './serializable-mapping'; - -const MANGLE = '__serializable_type'; - -export const serializesTo = (target: SerializableClass) => { - return (actual: SerializableClass): any => { - if (!serializableMapping.has(target.name)) { - serializableMapping.set(target.name, target); - } - - Object.defineProperty(actual, MANGLE, { value: target.name }); - Object.defineProperty(actual.prototype, MANGLE, { value: target.name }); - - return actual; - }; -}; diff --git a/test/__snapshots__/physics-determinism.test.mjs.snap b/test/__snapshots__/physics-determinism.test.mjs.snap index 8c0ab06..ab4977d 100644 --- a/test/__snapshots__/physics-determinism.test.mjs.snap +++ b/test/__snapshots__/physics-determinism.test.mjs.snap @@ -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}"`; diff --git a/test/client-send-cadence.test.ts b/test/client-send-cadence.test.ts index 9e4e8e1..4093ff2 100644 --- a/test/client-send-cadence.test.ts +++ b/test/client-send-cadence.test.ts @@ -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 = []; - 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); + }); }); diff --git a/test/connection-lifecycle.test.ts b/test/connection-lifecycle.test.ts index 730c893..06cb90d 100644 --- a/test/connection-lifecycle.test.ts +++ b/test/connection-lifecycle.test.ts @@ -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; diff --git a/test/physics-determinism.test.mjs b/test/physics-determinism.test.mjs index 2a4ad65..a4409c9 100644 --- a/test/physics-determinism.test.mjs +++ b/test/physics-determinism.test.mjs @@ -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); diff --git a/test/setup.mjs b/test/setup.mjs new file mode 100644 index 0000000..fb99774 --- /dev/null +++ b/test/setup.mjs @@ -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); +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..086f2a0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Runs before any test module is imported, which matters: module-level + // vectors are allocated at import time and cannot be converted afterwards. + setupFiles: ['./test/setup.mjs'], + }, +});