From 1295828ac86e7b1dd10249a3fd4786960e671925 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 22 Aug 2026 11:03:58 +0100 Subject: [PATCH] Claude fixes --- backend/src/default-options.ts | 4 +- backend/src/game-server.ts | 176 ++++++++-- backend/src/main.ts | 3 +- backend/src/objects/character-physical.ts | 42 ++- backend/src/objects/circle-physical.ts | 11 +- backend/src/objects/lamp-physical.ts | 2 + backend/src/objects/planet-physical.ts | 21 +- backend/src/objects/projectile-physical.ts | 29 +- backend/src/players/player-base.ts | 21 +- backend/src/players/player-container.ts | 87 ++++- backend/src/players/player.ts | 196 ++++++++++- backend/src/players/server-full-error.ts | 5 + frontend/src/index.ts | 17 +- frontend/src/main.scss | 30 +- .../src/scripts/commands/command-socket.ts | 45 ++- .../src/scripts/commands/keyboard-listener.ts | 26 +- .../src/scripts/commands/mouse-listener.ts | 25 +- .../src/scripts/commands/touch-listener.ts | 105 ++++-- frontend/src/scripts/feedback-hud.ts | 12 + frontend/src/scripts/game.ts | 120 ++++++- .../prediction/local-character-predictor.ts | 72 ++-- .../scripts/objects/game-object-container.ts | 98 +++++- frontend/src/scripts/sound-handler.ts | 17 +- package.json | 2 +- .../types/actions/client-heartbeat.ts | 19 ++ .../commands/types/actions/primary-action.ts | 8 +- .../commands/types/input-acknowledgement.ts | 15 +- .../src/communication/player-information.ts | 3 + shared/src/communication/transport-events.ts | 9 + shared/src/helper/finite.ts | 60 ++++ shared/src/main.ts | 2 + shared/src/objects/game-object.ts | 21 +- shared/src/physics/character-movement.ts | 10 +- shared/src/physics/depenetrate-circle.ts | 7 +- shared/src/physics/march-circle.ts | 72 ++-- shared/src/settings.ts | 52 +++ test/backend-regressions.test.ts | 225 ++++++++++++ test/backpressure.test.ts | 137 ++++++++ test/client-send-cadence.test.ts | 80 +++++ test/connection-lifecycle.test.ts | 319 ++++++++++++++++++ test/prediction-reconciliation.test.ts | 86 +++++ 41 files changed, 2086 insertions(+), 205 deletions(-) create mode 100644 backend/src/players/server-full-error.ts create mode 100644 shared/src/commands/types/actions/client-heartbeat.ts create mode 100644 shared/src/helper/finite.ts create mode 100644 test/backend-regressions.test.ts create mode 100644 test/backpressure.test.ts create mode 100644 test/client-send-cadence.test.ts create mode 100644 test/connection-lifecycle.test.ts diff --git a/backend/src/default-options.ts b/backend/src/default-options.ts index 1580103..338088a 100644 --- a/backend/src/default-options.ts +++ b/backend/src/default-options.ts @@ -1,8 +1,10 @@ import { Options } from './options'; +const isDevelopment = process.env.NODE_ENV !== 'production'; + export const defaultOptions: Options = { port: 3000, - name: 'Test server', + name: isDevelopment ? 'Dev server' : 'Doppler server', playerLimit: 16, npcCount: 8, seed: Math.random(), diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 5e92f3b..ce02d58 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -14,17 +14,27 @@ import { CommandReceiver, CommandExecutors, ServerAnnouncement, + JoinRejectionReason, + beginPropertyUpdateGeneration, } from 'shared'; import { createWorld } from './create-world'; import { DeltaTimeCalculator } from './helper/delta-time-calculator'; import { Options } from './options'; import { PlayerContainer } from './players/player-container'; +import { ServerFullError } from './players/server-full-error'; +import { Player } from './players/player'; import { StepCommand } from './commands/step'; import { GeneratePointsCommand } from './commands/generate-points'; import { AnnounceCommand } from './commands/announce'; const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; +interface JoinedSocket { + player: Player; + onPlayerToServer: (json: string) => void; + onDisconnect: () => void; +} + export class GameServer extends CommandReceiver { private objects!: PhysicalContainer; private players!: PlayerContainer; @@ -43,6 +53,7 @@ export class GameServer extends CommandReceiver { private initialize() { const previousPlayers = this.players; + this.releaseJoinedSockets(); this.objects = new PhysicalContainer(); createWorld(this.objects); this.objects.initialize(); @@ -80,30 +91,9 @@ export class GameServer extends CommandReceiver { this.initialize(); io.on('connection', (socket: Socket) => { - socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => { - try { - const player = this.players.createPlayer(playerInfo, socket); - socket.on(TransportEvents.PlayerToServer, (json: string) => { - try { - const commands: Array = deserialize(json); - commands.forEach((c) => player.handleCommand(c)); - } catch (e) { - console.error('Error while processing command', e); - } - }); - - this.sendServerStateUpdate(); - - socket.on('disconnect', () => { - player.destroy(); - this.players.deletePlayer(player); - this.sendServerStateUpdate(); - }); - } catch (e) { - console.error('Failed to register joining player; disconnecting socket', e); - socket.disconnect(); - } - }); + socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => + this.handleJoin(socket, playerInfo), + ); socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => { socket.join(gameStateSubscribedRoom); @@ -111,6 +101,133 @@ export class GameServer extends CommandReceiver { }); } + private readonly joinedSockets = new Map(); + + private handleJoin(socket: Socket, playerInfo: PlayerInformation) { + if (this.joinedSockets.has(socket)) { + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.AlreadyJoined); + return; + } + + if (this.isInEndGame) { + // The round is already over and the world is about to be rebuilt; joining + // now drops the player into an arena running in slow motion with no + // end-of-round card. + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.RoundEnding); + return; + } + + if (this.players.isFull) { + socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull); + return; + } + + let player: Player; + try { + player = this.players.createPlayer(playerInfo, socket); + } catch (e) { + console.error('Failed to register joining player', e); + socket.emit( + TransportEvents.JoinRejected, + e instanceof ServerFullError + ? JoinRejectionReason.ServerFull + : JoinRejectionReason.InvalidRequest, + ); + socket.disconnect(); + return; + } + + const onPlayerToServer = (json: string) => { + try { + if (typeof json !== 'string' || json.length > settings.maxInboundMessageBytes) { + return; + } + if (!this.allowInboundMessage(socket)) { + return; + } + const commands: Array = deserialize(json); + if (!Array.isArray(commands)) { + return; + } + commands.forEach((c) => player.handleCommand(c)); + } catch (e) { + console.error('Error while processing command', e); + } + }; + + const onDisconnect = () => { + const record = this.joinedSockets.get(socket); + this.joinedSockets.delete(socket); + // The bucket is keyed by the Socket object, so leaving the entry behind + // pins the socket for the rest of the round. + this.inboundBudget.delete(socket); + if (!record) { + return; + } + record.player.detachFromSocket(); + const { kills, deaths } = player.scoreSnapshot; + // Hold the score briefly so a client whose transport blipped can rejoin as + // itself rather than as a blank slate. + this.players.reserveScore( + player.reconnectToken, + player.team, + kills, + deaths, + Date.now(), + ); + player.destroy(); + this.players.deletePlayer(player); + this.sendServerStateUpdate(); + }; + + socket.on(TransportEvents.PlayerToServer, onPlayerToServer); + socket.on('disconnect', onDisconnect); + this.joinedSockets.set(socket, { player, onPlayerToServer, onDisconnect }); + + player.reconnectToken = this.players.issueToken(); + socket.emit(TransportEvents.PlayerJoined, player.reconnectToken); + + this.sendServerStateUpdate(); + } + + // Cheap per-socket token bucket, sized well above what a legitimate client + // produces so it only ever trips on a flood. + private readonly inboundBudget = new Map(); + private droppedInboundMessages = 0; + private allowInboundMessage(socket: Socket): boolean { + const nowMs = Date.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.lastMs = nowMs; + this.inboundBudget.set(socket, budget); + + if (budget.tokens < 1) { + this.droppedInboundMessages++; + return false; + } + + budget.tokens -= 1; + return true; + } + + // Detach the per-join listeners of every socket still attached to the round + // that is ending, so a client that survives the restart can join the new one. + private releaseJoinedSockets() { + for (const [socket, record] of this.joinedSockets) { + socket.off(TransportEvents.PlayerToServer, record.onPlayerToServer); + socket.off('disconnect', record.onDisconnect); + record.player.detachFromSocket(); + } + this.joinedSockets.clear(); + this.inboundBudget.clear(); + } + private timeSinceLastServerStateUpdate = 0; public sendServerStateUpdate() { this.io @@ -225,6 +342,7 @@ export class GameServer extends CommandReceiver { this.players.step(scaledDelta); } + beginPropertyUpdateGeneration(); this.players.stepCommunication(delta); this.objects.resetRemoteCalls(); @@ -259,10 +377,18 @@ export class GameServer extends CommandReceiver { rtts.sort((a, b) => a - b); console.info( `Player 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})`, + `(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 message(s) since last report — ` + + 'client input was discarded', + ); + this.droppedInboundMessages = 0; + } + if (this.saturatedFrames > 0) { console.warn( `Physics saturated on ${this.saturatedFrames} frame(s) since last report — shedding backlog`, diff --git a/backend/src/main.ts b/backend/src/main.ts index c492c7a..fa2ad92 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -2,7 +2,7 @@ import { Server as IoServer } from 'socket.io'; import express from 'express'; import { Server } from 'http'; import cors from 'cors'; -import { applyArrayPlugins, Random, serverInformationEndpoint } from 'shared'; +import { applyArrayPlugins, Random, serverInformationEndpoint, settings } from 'shared'; import minimist from 'minimist'; import { glMatrix } from 'gl-matrix'; import { GameServer } from './game-server'; @@ -24,6 +24,7 @@ const app = express(); const server = new Server(app); const io = new IoServer(server, { parser, + maxHttpBufferSize: settings.maxInboundMessageBytes, cors: { origin: true, credentials: true, diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index bdc8873..de85b1d 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -171,17 +171,25 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical }; } + private hasFiredSinceSpawn = false; private get isSpawnProtected(): boolean { return ( + !this.hasFiredSinceSpawn && this.timeAlive < settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds ); } private hasGeneratedPoints = false; + private wasKilledInCombat = false; private getPoints(game: CommandReceiver) { if (!this.isAlive && !this.hasGeneratedPoints) { this.hasGeneratedPoints = true; + + if (!this.wasKilledInCombat) { + return; + } + const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint; const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint; @@ -260,7 +268,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical other.direction, mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge), ); - this.onDie(); + this.onDie(true); other.originator.addKill(this.name, other.charge); } else { other.originator.registerHit(other.charge); @@ -268,7 +276,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } } - public shootTowards(position: vec2, charge = 0) { + public shootTowards(position: vec2, charge = 0, catchUpSeconds = 0) { if ( !this.isAlive || this.timeSinceLastShot < settings.projectileCreationInterval || @@ -277,21 +285,31 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical return; } - this.timeSinceLastShot = 0; + const direction = vec2.subtract(vec2.create(), position, this.center); + if (vec2.length(direction) === 0) { + return; + } - const c = clamp01(charge); + this.timeSinceLastShot = 0; + this.hasFiredSinceSpawn = true; + + const requestedCharge = clamp01(charge); const desiredStrength = mix( settings.chargeShotStrengthMin, settings.chargeShotStrengthMax, - c, + requestedCharge, ); const strength = Math.min(desiredStrength, this.projectileStrength); this.projectileStrength -= strength; + const c = clamp01( + (strength - settings.chargeShotStrengthMin) / + (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), + ); + const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c); const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c); - const direction = vec2.subtract(vec2.create(), position, this.center); vec2.normalize(direction, direction); // Keep the unit direction before vec2.scale repurposes it as the velocity. const shotDirection = vec2.clone(direction); @@ -307,6 +325,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical c, ); this.container.addObject(projectile); + // Lag compensation: advance the shot by the time the command spent reaching + // us, so the shooter does not have to lead by their own latency as well as + // by the projectile's travel time. + projectile.fastForward(catchUpSeconds); if (c > 0) { vec2.scaleAndAdd( @@ -435,8 +457,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ), (this.rightFoot.radius - oldRightFoot.radius) / deltaTime, ); - - this.animateScaling(1); } private step({ deltaTimeInSeconds, game }: StepCommand) { @@ -467,6 +487,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical if (this.hasJustBorn) { if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) { this.hasJustBorn = false; + this.animateScaling(1); } else { this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime); } @@ -486,7 +507,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.projectileStrength = Math.min( settings.playerMaxStrength, this.projectileStrength + - settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, + settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, ); this.regenerateHealth(deltaTimeInSeconds); @@ -550,7 +571,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } } - public onDie() { + public onDie(killedInCombat = false) { + this.wasKilledInCombat = killedInCombat; this.isDestroyed = true; this.remoteCall('onDie'); } diff --git a/backend/src/objects/circle-physical.ts b/backend/src/objects/circle-physical.ts index e1a6abf..037708f 100644 --- a/backend/src/objects/circle-physical.ts +++ b/backend/src/objects/circle-physical.ts @@ -39,7 +39,7 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh ) { super(); this._boundingBox = new BoundingBox(); - this.recalculateBoundingBox(); + this.syncBoundingBox(); } public get boundingBox(): BoundingBoxBase { @@ -52,7 +52,7 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh public set center(value: vec2) { this._center = value; - this.recalculateBoundingBox(); + this.syncBoundingBox(); } public onCollision(c: ReactToCollisionCommand) { @@ -69,14 +69,14 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh public set radius(value: number) { this._radius = value; - this.recalculateBoundingBox(); + this.syncBoundingBox(); } public distance(target: vec2): number { return vec2.distance(target, this.center) - this.radius; } - private recalculateBoundingBox() { + public syncBoundingBox() { this._boundingBox.xMin = this.center.x - this._radius; this._boundingBox.xMax = this.center.x + this._radius; this._boundingBox.yMin = this.center.y - this._radius; @@ -121,6 +121,9 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh }, ); + // The body has moved; re-register it where it actually is. + this.syncBoundingBox(); + return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity }; } diff --git a/backend/src/objects/lamp-physical.ts b/backend/src/objects/lamp-physical.ts index 591b17c..020c412 100644 --- a/backend/src/objects/lamp-physical.ts +++ b/backend/src/objects/lamp-physical.ts @@ -33,6 +33,8 @@ export class LampPhysical extends LampBase implements StaticPhysical { } public queueSetLight(color: vec3, lightness: number) { + this.color = vec3.clone(color); + this.lightness = lightness; this.remoteCall('setLight', color, lightness); } diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index 4305b18..d6ea396 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -69,7 +69,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { const sizeClass = clamp01( (this.radius - settings.planetMinReferenceRadius) / - (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), + (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), ); this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass); @@ -222,8 +222,25 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { } } + // hysteresis + private get flipTeam(): CharacterTeam { + const control = this.ownership - 0.5; + const enter = settings.planetControlThreshold + settings.planetFlipHysteresis; + + if (control > enter) { + return CharacterTeam.red; + } + if (control < -enter) { + return CharacterTeam.blue; + } + if (Math.abs(control) < settings.planetControlThreshold) { + return CharacterTeam.neutral; + } + return this.lastTeam; + } + private detectFlip(game: CommandReceiver) { - const currentTeam = this.team; + const currentTeam = this.flipTeam; if (currentTeam === this.lastTeam) { return; } diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index 8a967f1..1de3872 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -73,15 +73,38 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica vec2.normalize(vec2.create(), this.velocity), 10, ); - while (wasCollision) { + // Bounded: each pass advances by `delta`, but a degenerate direction would + // otherwise spin here forever inside the physics tick. + let passes = 0; + while (wasCollision && passes++ < 32) { const intersecting = this.container .findIntersecting(this.boundingBox) .filter((g) => g instanceof CharacterPhysical && g.team === this.team); const { hitSurface } = marchCircle(this.object, delta, intersecting, true); wasCollision = hitSurface; + this.object.syncBoundingBox(); } vec2.add(this.center, this.center, delta); vec2.add(this.center, this.center, delta); + this.object.syncBoundingBox(); + } + + /** + * Lag compensation. Runs the projectile forward by the time its command spent + * travelling to the server, so a shot lands where the shooter aimed rather + * than forcing them to lead by their own latency on top of the projectile's + * travel time. Capped by settings.maxProjectileCatchUpSeconds so a bad or + * forged timestamp cannot spawn a shot arbitrarily far downrange. + */ + public fastForward(seconds: number) { + const step = settings.targetPhysicsDeltaTimeInSeconds; + let remaining = Math.min(Math.max(seconds, 0), settings.maxProjectileCatchUpSeconds); + + while (remaining > 0 && this.isAlive) { + const delta = Math.min(step, remaining); + this.advance(delta); + remaining -= delta; + } } public get boundingBox(): ImmutableBoundingBox { @@ -123,6 +146,10 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica } private handleStep({ deltaTimeInSeconds }: StepCommand) { + this.advance(deltaTimeInSeconds); + } + + private advance(deltaTimeInSeconds: number) { super.step(deltaTimeInSeconds); if (this.strength <= 0) { diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index 6de34cd..c69e7ae 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -6,6 +6,7 @@ import { CharacterTeam, Random, settings, + sanitizeName, } from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; @@ -14,6 +15,8 @@ import { CharacterPhysical } from '../objects/character-physical'; import { PlanetPhysical } from '../objects/planet-physical'; import { PlayerContainer } from './player-container'; +const maximumNameLength = 40; + export abstract class PlayerBase extends CommandReceiver { public character?: CharacterPhysical | null; public center: vec2 = vec2.create(); @@ -32,7 +35,12 @@ export abstract class PlayerBase extends CommandReceiver { protected createCharacter() { this.character = new CharacterPhysical( - this.playerInfo.name.slice(0, 20), + // Coerced, not just truncated. `.slice()` also exists on arrays, so a + // name that is an array passed straight through, was serialized into + // CreateObjects, and was revived as a class on every peer that received + // it — throwing inside the deserializer's reviver and killing that + // client's whole message batch. + sanitizeName(this.playerInfo.name, maximumNameLength), this.sumKills, this.sumDeaths, this.team, @@ -72,10 +80,17 @@ export abstract class PlayerBase extends CommandReceiver { return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center); } + // Seed a reconnecting player with the score it held before the drop, so a + // network blip costs you your character but not your match. + public restoreScore(kills: number, deaths: number) { + this.sumKills = kills; + this.sumDeaths = deaths; + } + protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { let rotation = 0; let radius = 0; - for (;;) { + for (let attempt = 0; attempt < 512; attempt++) { const playerPosition = vec2.fromValues( radius * Math.cos(rotation) + preferredCenter.x, radius * Math.sin(rotation) + preferredCenter.y, @@ -96,6 +111,8 @@ export abstract class PlayerBase extends CommandReceiver { rotation += Math.PI / 8; radius += 30; } + + return vec2.clone(preferredCenter); } public destroy() { diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index 01a1c96..1df8720 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -4,10 +4,22 @@ import { PhysicalContainer } from '../physics/containers/physical-container'; import { NPC } from './npc'; import { Player } from './player'; import { PlayerBase } from './player-base'; +import { ServerFullError } from './server-full-error'; +import { randomUUID } from 'node:crypto'; + +// Score held for a player whose socket dropped, so a reconnect inside the grace +// window resumes the match instead of starting from zero. +interface ReservedScore { + team: CharacterTeam; + kills: number; + deaths: number; + expiresAtMs: number; +} export class PlayerContainer { private _players: Array = []; private _npcs: Array = []; + private reservedScores = new Map(); constructor( private readonly objects: PhysicalContainer, @@ -31,11 +43,15 @@ export class PlayerContainer { } public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player { - if (this._players.length === this.playerMaxCount) { - throw new Error('Too many players'); + if (this._players.length >= this.playerMaxCount) { + throw new ServerFullError(); } - const team = this.getTeamOfNextPlayer(); + const reserved = this.claimReservedScore(playerInfo.reconnectToken); + const team = reserved ? reserved.team : this.getTeamOfNextPlayer(); + + const player = new Player(playerInfo, this, this.objects, team, socket); + let npcToReplace = this._npcs.find((n) => n.team === team); if (!npcToReplace) { npcToReplace = this._npcs.find((n) => n.team !== team); @@ -43,12 +59,63 @@ export class PlayerContainer { npcToReplace?.destroy(); this._npcs = this._npcs.filter((n) => n !== npcToReplace); - const player = new Player(playerInfo, this, this.objects, team, socket); this._players.push(player); + if (reserved) { + player.restoreScore(reserved.kills, reserved.deaths); + } + return player; } + /** + * A fresh token for a joining player. Handed to the client immediately; it + * only becomes redeemable once the player actually drops (see reserveScore). + */ + public issueToken(): string { + return randomUUID(); + } + + /** + * Hold a dropped player's team and score against its token for the grace + * window, so a client whose transport blipped rejoins as itself. + */ + public reserveScore( + token: string, + team: CharacterTeam, + kills: number, + deaths: number, + nowMs: number, + ) { + this.expireReservations(nowMs); + this.reservedScores.set(token, { + team, + kills, + deaths, + expiresAtMs: nowMs + settings.reconnectGraceSeconds * 1000, + }); + } + + private claimReservedScore(token?: string): ReservedScore | undefined { + if (!token || typeof token !== 'string') { + return undefined; + } + const reserved = this.reservedScores.get(token); + if (!reserved) { + return undefined; + } + this.reservedScores.delete(token); + return reserved; + } + + private expireReservations(nowMs: number) { + for (const [token, reserved] of this.reservedScores) { + if (reserved.expiresAtMs <= nowMs) { + this.reservedScores.delete(token); + } + } + } + public get players(): Array { return [...this._players, ...this._npcs]; } @@ -57,6 +124,10 @@ export class PlayerContainer { return this._players.length; } + public get isFull(): boolean { + return this._players.length >= this.playerMaxCount; + } + // Measured round-trip times (ms) of the real connected players, for // server-side latency stats. NPCs have no socket and are excluded. public get connectedPlayerRttsMs(): Array { @@ -96,7 +167,13 @@ export class PlayerContainer { } public deletePlayer(player: Player) { + const had = this._players.includes(player); this._players = this._players.filter((p) => p !== player); - this.createNPCs(); + // Only refill bots if this player was actually ours. A socket left over from + // a previous round reports its disconnect against the new container, and + // topping up on that would over-fill the roster. + if (had) { + this.createNPCs(); + } } } diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 923be65..459a88e 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -25,16 +25,30 @@ import { PrimaryActionCommand, LeapActionCommand, InputAcknowledgement, + ClientHeartbeatCommand, + finiteInRange, + finiteVec2, + isFiniteNumber, } from 'shared'; import { Socket } from 'socket.io'; import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { PlayerContainer } from './player-container'; import { PlayerBase } from './player-base'; +import { CharacterPhysical } from '../objects/character-physical'; // How often the server pings each client to measure round-trip time. const pingIntervalSeconds = 1; +// Aspect ratio is used to size the streamed view area; a zero, negative or +// non-finite value collapses it and the player stops receiving the world. +const minimumAspectRatio = 0.2; +const maximumAspectRatio = 8; + +// How much unflushed data on the socket counts as "this client is not keeping +// up". See sendQueuedCommandsToClient. +const maximumBufferedBytes = settings.maxBufferedBytesPerClient; + export class Player extends PlayerBase { // default, until the clients sends its real value private aspectRatio: number = 16 / 9; @@ -42,34 +56,98 @@ export class Player extends PlayerBase { private timeSinceLastMessage = 0; private objectsPreviouslyInViewArea: Array = []; private lastInputClientTimeMs = 0; + private lastInputReceiptMs = 0; private lastLeapClientTimeMs = 0; - // Measured round-trip time to this client (ms) — the latency primitive that - // lag compensation, a latency HUD, and adaptive interpolation build on. + // Token this session was issued at join. On a drop it is what the held score + // is filed under, and what the returning client presents to reclaim it. + public reconnectToken = ''; + + // Measured round-trip time to this client (ms) — drives projectile lag + // compensation as well as the server-side latency stats. public rttMs = 0; private timeSinceLastPing = 0; private lastPingSentMs = 0; + // Nonce of the ping currently awaiting a reply, cleared once answered. Without + // it a Pong was just a bare event: any client could send a stream of them, or + // answer a ping it had sat on for a minute, and the RTT it produced feeds + // projectile lag compensation. + private pendingPingNonce = 0; + private nextPingNonce = 1; + // Nothing below trusts a field it was handed. Every one of these arrives + // straight from `deserialize`, which is a bare JSON.parse — and JSON carries + // Infinity happily as `1e999`. A single non-finite number reaching the + // simulation is unrecoverable: it spreads through the shared SDF and leaves a + // character that never dies and never respawns. protected commandExecutors: CommandExecutors = { - [SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => - (this.aspectRatio = v.aspectRatio), + [SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => { + this.aspectRatio = finiteInRange( + v.aspectRatio, + minimumAspectRatio, + maximumAspectRatio, + this.aspectRatio, + ); + }, [MoveActionCommand.type]: (c: MoveActionCommand) => { - // Remember how far into this client's input timeline we've consumed, to - // echo back for client-side prediction reconciliation. - this.lastInputClientTimeMs = c.clientTimeMs; - this.character?.handleMovementAction(c); + const direction = finiteVec2(c.direction, 1); + if (!direction) { + return; + } + this.observeClientTime(c.clientTimeMs); + this.character?.handleMovementAction(new MoveActionCommand(direction, 0)); + }, + [PrimaryActionCommand.type]: (c: PrimaryActionCommand) => { + const position = finiteVec2(c.position, settings.maxClientPositionMagnitude); + if (!position) { + return; + } + this.observeClientTime(c.clientTimeMs); + this.character?.shootTowards( + position, + finiteInRange(c.charge, 0, 1, 0), + this.catchUpSeconds, + ); }, - [PrimaryActionCommand.type]: (c: PrimaryActionCommand) => - this.character?.shootTowards(c.position, c.charge), [LeapActionCommand.type]: (c: LeapActionCommand) => { + if (!isFiniteNumber(c.clientTimeMs)) { + return; + } // Record receipt (whether or not leap() accepts it): either way its effect // on bodyVelocity is now reflected in the streamed launch momentum, so the // predictor must stop replaying this leap. this.lastLeapClientTimeMs = c.clientTimeMs; + this.observeClientTime(c.clientTimeMs); this.character?.leap(); }, + // Closes every batch. Movement is only sent when it changes, so a held key + // used to freeze the acknowledged input time — and with it the window the + // client's predictor is allowed to replay. This advances the acknowledgement + // at the client's send rate regardless of whether the input changed. + [ClientHeartbeatCommand.type]: (c: ClientHeartbeatCommand) => + this.observeClientTime(c.clientTimeMs), }; + // The newest client-clock instant whose commands have all been applied, and + // the server-clock instant it arrived at. The client's predictor needs both: + // the first tells it which of its inputs are already reflected in the + // snapshot, the second how stale that answer was by the time the snapshot was + // taken (see InputAcknowledgement.ackAgeMs). + private observeClientTime(clientTimeMs: number) { + if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) { + this.lastInputClientTimeMs = clientTimeMs; + this.lastInputReceiptMs = performance.now(); + } + } + + // How long a command spent reaching us, for projectile lag compensation. + // Deliberately derived from the measured RTT and NOT from the client's own + // timestamp, so a forged stamp cannot buy extra compensation — which is why it + // takes no argument. ProjectilePhysical.fastForward caps the result. + private get catchUpSeconds(): number { + return Math.max(0, this.rttMs / 2 / 1000); + } + constructor( playerInfo: PlayerInformation, playerContainer: PlayerContainer, @@ -84,11 +162,29 @@ export class Player extends PlayerBase { // The client already echoes a Pong for every Ping (see game.ts). Only one // ping is ever in flight, so RTT is simply now − send-time; no payload // needed and no client change required. - this.socket.on(TransportEvents.Pong, () => { - if (this.lastPingSentMs > 0) { - this.rttMs = performance.now() - this.lastPingSentMs; - } - }); + this.socket.on(TransportEvents.Pong, this.onPong); + } + + // Only the outstanding ping counts, and only once: a stale, duplicated or + // invented reply is ignored. A client can still sit on the live ping to + // inflate its measured RTT, so the result is clamped — and the next ping + // retires the nonce, bounding the stall at one ping interval. + private readonly onPong = (nonce: unknown) => { + if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) { + return; + } + this.pendingPingNonce = 0; + this.rttMs = Math.min( + performance.now() - this.lastPingSentMs, + settings.maxMeasuredRttMs, + ); + }; + + // Fix 7a: the Pong listener outlives the round it belongs to unless it is + // taken off explicitly — the socket survives a restart, so a retired Player + // would stay reachable (and keep updating) through it. + public detachFromSocket() { + this.socket.off(TransportEvents.Pong, this.onPong); } protected createCharacter() { @@ -103,6 +199,11 @@ export class Player extends PlayerBase { this.winnerTeam = winnerTeam; } + // The corpse a projectile may still credit a kill to. Kept so a shot that was + // already in flight when we died is not thrown away: killCount is re-read at + // respawn instead of being snapshotted the instant the body died. + private dyingCharacter?: CharacterPhysical | null; + public step(deltaTimeInSeconds: number) { if (this.character) { this.center = this.character?.center; @@ -111,11 +212,16 @@ export class Player extends PlayerBase { this.sumDeaths++; this.sumKills = this.character.killCount; + this.dyingCharacter = this.character; this.character = null; this.timeUntilRespawn = settings.playerDiedTimeout; } } else { if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { + if (this.dyingCharacter) { + this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount); + this.dyingCharacter = null; + } this.createCharacter(); this.center = this.character!.center; } @@ -164,7 +270,7 @@ export class Player extends PlayerBase { this.queueCommandSend( new PropertyUpdatesForObjects( this.objectsPreviouslyInViewArea - .map((o) => o.getPropertyUpdates()) + .map((o) => o.getPropertyUpdatesForFrame()) .filter((u) => u) as Array, performance.now() / 1000, ), @@ -179,6 +285,9 @@ export class Player extends PlayerBase { this.lastInputClientTimeMs, this.character.launchMomentum, this.lastLeapClientTimeMs, + this.lastInputReceiptMs > 0 + ? Math.max(0, performance.now() - this.lastInputReceiptMs) + : 0, ), ); } @@ -212,7 +321,8 @@ export class Player extends PlayerBase { if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) { this.timeSinceLastPing = 0; this.lastPingSentMs = performance.now(); - this.socket.emit(TransportEvents.Ping); + this.pendingPingNonce = this.nextPingNonce++; + this.socket.emit(TransportEvents.Ping, this.pendingPingNonce); } if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) { @@ -223,11 +333,63 @@ export class Player extends PlayerBase { } } + // Bytes written to this client's socket but not yet flushed to the network. + // engine.io's own Socket exposes no such number — it lives on the underlying + // ws socket, and only the websocket transport has one — so reading + // `socket.conn.bufferedAmount` (as this did) always produced undefined and the + // backpressure check below never once ran. + private get bufferedBytes(): number { + const conn = this.socket.conn as + | { transport?: { socket?: { bufferedAmount?: unknown } } } + | undefined; + const buffered = conn?.transport?.socket?.bufferedAmount; + return typeof buffered === 'number' ? buffered : 0; + } + public sendQueuedCommandsToClient() { + if (this.commandsToBeSent.length === 0) { + return; + } + + // A client that is not draining gets the cheap half of the snapshot shed: + // piling more state behind it only grows a backlog it will never catch up + // on, and the freshest property update is the only one that matters. + // + // Only the state that next tick regenerates in full may be dropped. Create + // and delete are one-shot: handleViewAreaUpdate has ALREADY advanced + // objectsPreviouslyInViewArea by the time this runs, so dropping them leaves + // this client permanently missing objects it was never told about and + // holding ghosts it was never told to remove. Announcements and the end-game + // card are one-shot for the same reason. They are small; they go out even + // while backed up. + if (this.bufferedBytes > maximumBufferedBytes) { + // Matched by command type rather than `instanceof`, the same way every + // other dispatch in the codebase does it — the identity of a class from a + // bundled shared module is not something to depend on. + const sheddable: ReadonlyArray = [ + PropertyUpdatesForObjects.type, + UpdateMinimap.type, + ]; + this.commandsToBeSent = this.commandsToBeSent.filter( + (c) => !sheddable.includes(c.type), + ); + if (this.commandsToBeSent.length === 0) { + return; + } + } + this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent)); this.commandsToBeSent = []; } + /** Score to hold for this player if it reconnects inside the grace window. */ + public get scoreSnapshot(): { kills: number; deaths: number } { + return { + kills: Math.max(this.sumKills, this.character?.killCount ?? 0), + deaths: this.sumDeaths, + }; + } + private handleAnnouncements() { let announcement = ''; if (this.winnerTeam) { diff --git a/backend/src/players/server-full-error.ts b/backend/src/players/server-full-error.ts new file mode 100644 index 0000000..720e5b3 --- /dev/null +++ b/backend/src/players/server-full-error.ts @@ -0,0 +1,5 @@ +export class ServerFullError extends Error { + constructor() { + super('Too many players'); + } +} diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 00f04c0..5dad0b3 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -1,5 +1,6 @@ import { glMatrix } from 'gl-matrix'; import { + applyArrayPlugins, LampBase, overrideDeserialization, PlanetBase, @@ -29,6 +30,7 @@ import { PlanetView } from './scripts/objects/types/planet-view'; import { ProjectileView } from './scripts/objects/types/projectile-view'; glMatrix.setMatrixArrayType(Array); +applyArrayPlugins(); overrideDeserialization(CharacterBase, CharacterView); overrideDeserialization(PlanetBase, PlanetView); @@ -53,6 +55,11 @@ const enableMusic = document.querySelector('#enable-music') as HTMLInputElement; const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement; const spinner = document.querySelector('#spinner-container') as HTMLElement; +const joinNotice = document.createElement('p'); +joinNotice.className = 'join-notice'; +joinNotice.style.display = 'none'; +joinGameForm.prepend(joinNotice); + const toggleSettings = () => { settings.className = settings.className === 'open' ? '' : 'open'; SoundHandler.play(Sounds.click); @@ -122,7 +129,7 @@ const main = async () => { }); window.onpopstate = () => game.destroy(); - for (;;) { + for (; ;) { show(spinner); hide(logoutButton, true); show(landingUI, true, 'flex'); @@ -150,6 +157,14 @@ const main = async () => { hide(spinner); show(logoutButton, true, 'block'); await gameOver; + + const reason = game.lastRejectionReason; + if (reason) { + joinNotice.innerText = Game.rejectionText(reason); + joinNotice.style.display = 'block'; + } else { + joinNotice.style.display = 'none'; + } } } catch (e) { console.error(e); diff --git a/frontend/src/main.scss b/frontend/src/main.scss index 3ab46d2..0009666 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -247,9 +247,8 @@ body { border-radius: 1000px; background-color: rgba(0, 0, 0, 0.35); - box-shadow: - inset 0 0 3px 0 rgba(0, 0, 0, 0.4), - 0 0 10px rgba(0, 0, 0, 0.3); + box-shadow: inset 0 0 3px 0 rgba(0, 0, 0, 0.4), + 0 0 10px rgba(0, 0, 0, 0.3); border: $border-width solid rgba(255, 255, 255, 0.25); z-index: 100; } @@ -937,4 +936,29 @@ body { opacity: 1; transform: translate(-50%, -50%) scale(1); } +} + +.connection-banner { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + padding: 6px 14px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.62); + color: #fff; + font-size: 0.9rem; + letter-spacing: 0.02em; + pointer-events: none; + z-index: 20; +} + +.join-notice { + margin: 0 0 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: 4px; + background: rgba(209, 86, 82, 0.18); + color: #f2c9c7; + font-size: 0.85rem; + text-align: center; } \ No newline at end of file diff --git a/frontend/src/scripts/commands/command-socket.ts b/frontend/src/scripts/commands/command-socket.ts index 830b19b..b7d1129 100644 --- a/frontend/src/scripts/commands/command-socket.ts +++ b/frontend/src/scripts/commands/command-socket.ts @@ -1,5 +1,21 @@ -import { Command, CommandReceiver, serialize, TransportEvents } from 'shared'; +import { + ClientHeartbeatCommand, + Command, + CommandReceiver, + serialize, + settings, + TransportEvents, +} from 'shared'; import { Socket } from 'socket.io-client'; +import { predictorNowMs } from '../helper/prediction/local-character-predictor'; + +const heartbeatIntervalMs = settings.clientSendInterval * 1000; + +// A frame that lands a hair short of the deadline still counts as due. At an +// exact ratio of frame rate to interval — 60 Hz against 30 Hz — whether the gap +// compares as reached is decided by floating-point noise, and the heartbeat +// slips a whole frame every third beat, drifting to ~21 Hz instead of 30. +const dueToleranceMs = 1; export class CommandSocket extends CommandReceiver { constructor(private readonly socket: Socket) { @@ -7,14 +23,35 @@ export class CommandSocket extends CommandReceiver { } private commandQueue: Array = []; + private lastSendMs = -Infinity; + protected defaultCommandExecutor(command: Command) { this.commandQueue.push(command); } public sendQueuedCommands() { - if (this.commandQueue.length > 0) { - this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue)); - this.commandQueue = []; + const nowMs = predictorNowMs(); + + // Real input goes out on the frame it happens — that is the whole latency + // budget the player feels. An idle client still has to keep the server's + // input acknowledgement moving (a held key generates no fresh command), but + // that heartbeat is paced by the clock rather than by the frame rate. + // + // Sending once per rendered frame instead put every display above + // settings.maxInboundMessagesPerSecond permanently over the server's inbound + // allowance; once the burst was spent it silently discarded whole batches, + // and since movement commands are edge-triggered and never re-sent, a lost + // batch meant the direction change simply never happened. + if ( + this.commandQueue.length === 0 && + nowMs - this.lastSendMs < heartbeatIntervalMs - dueToleranceMs + ) { + return; } + this.lastSendMs = nowMs; + + this.commandQueue.push(new ClientHeartbeatCommand(Math.round(nowMs))); + this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue)); + this.commandQueue = []; } } diff --git a/frontend/src/scripts/commands/keyboard-listener.ts b/frontend/src/scripts/commands/keyboard-listener.ts index 342d851..971a0c9 100644 --- a/frontend/src/scripts/commands/keyboard-listener.ts +++ b/frontend/src/scripts/commands/keyboard-listener.ts @@ -14,20 +14,26 @@ export class KeyboardListener extends CommandGenerator { } private keyDownListener = (event: KeyboardEvent) => { - const key = event.key.toLowerCase(); - // Space leaps (W / ArrowUp already cover walking up). Edge-triggered so a - // held key's auto-repeat doesn't spam leaps. - if ((key === ' ' || key === 'shift') && !this.keysDown.has(key)) { + const key = event.code; + if ( + (key === 'Space' || key === 'ShiftLeft' || key === 'ShiftRight') && + !this.keysDown.has(key) + ) { const clientTimeMs = localCharacterPredictor.recordLeap(); this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs)); } + + if (this.keysDown.has(key)) { + return; + } this.keysDown.add(key); this.generateCommands(); }; private keyUpListener = (event: KeyboardEvent) => { - this.keysDown.delete(event.key.toLowerCase()); - this.generateCommands(); + if (this.keysDown.delete(event.code)) { + this.generateCommands(); + } }; private blurListener = () => { @@ -36,10 +42,10 @@ export class KeyboardListener extends CommandGenerator { }; private generateCommands() { - const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup')); - const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown')); - const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft')); - const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright')); + const up = ~~(this.keysDown.has('KeyW') || this.keysDown.has('ArrowUp')); + const down = ~~(this.keysDown.has('KeyS') || this.keysDown.has('ArrowDown')); + const left = ~~(this.keysDown.has('KeyA') || this.keysDown.has('ArrowLeft')); + const right = ~~(this.keysDown.has('KeyD') || this.keysDown.has('ArrowRight')); const movement = vec2.fromValues(right - left, up - down); if (vec2.squaredLength(movement) > 0) { diff --git a/frontend/src/scripts/commands/mouse-listener.ts b/frontend/src/scripts/commands/mouse-listener.ts index c4bd30d..cff0be2 100644 --- a/frontend/src/scripts/commands/mouse-listener.ts +++ b/frontend/src/scripts/commands/mouse-listener.ts @@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix'; import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared'; import { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; +import { predictorNowMs } from '../helper/prediction/local-character-predictor'; import { Pointer } from '../helper/pointer'; export class MouseListener extends CommandGenerator { @@ -16,9 +17,13 @@ export class MouseListener extends CommandGenerator { super(); target.addEventListener('mousedown', this.mouseDownListener); - target.addEventListener('mouseup', this.mouseUpListener); target.addEventListener('mousemove', this.mouseMoveListener); target.addEventListener('contextmenu', this.contextMenuListener); + // Release is watched on the window, not the canvas: a charge released over + // the settings gear, the fullscreen icon, or outside the window otherwise + // never fired the shot and left the charge ring up until the next press. + window.addEventListener('mouseup', this.mouseUpListener); + window.addEventListener('blur', this.cancelPrimary); } // Only the screen position is stored; it is reprojected to world space each @@ -46,10 +51,23 @@ export class MouseListener extends CommandGenerator { const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000); this.primaryDownAt = null; this.sendCommandToSubscribers( - new PrimaryActionCommand(this.positionFromEvent(event), charge), + new PrimaryActionCommand( + this.positionFromEvent(event), + charge, + Math.round(predictorNowMs()), + ), ); }; + // Losing the window mid-charge cannot produce a meaningful aim point, so drop + // the charge rather than leaving the indicator running. + private cancelPrimary = () => { + if (this.primaryDownAt !== null) { + this.primaryDownAt = null; + ChargeIndicator.end(); + } + }; + // Suppress the browser context menu on the canvas; right-click has no action. private contextMenuListener = (event: MouseEvent) => { event.preventDefault(); @@ -64,8 +82,9 @@ export class MouseListener extends CommandGenerator { public destroy() { ChargeIndicator.end(); this.target.removeEventListener('mousedown', this.mouseDownListener); - this.target.removeEventListener('mouseup', this.mouseUpListener); this.target.removeEventListener('mousemove', this.mouseMoveListener); this.target.removeEventListener('contextmenu', this.contextMenuListener); + window.removeEventListener('mouseup', this.mouseUpListener); + window.removeEventListener('blur', this.cancelPrimary); } } diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index ab91ce8..ae3a827 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -10,7 +10,10 @@ import { } from 'shared'; import { Game } from '../game'; import { ChargeIndicator } from '../charge-indicator'; -import { localCharacterPredictor } from '../helper/prediction/local-character-predictor'; +import { + localCharacterPredictor, + predictorNowMs, +} from '../helper/prediction/local-character-predictor'; export class TouchListener extends CommandGenerator { private static readonly deadZone = 8; @@ -24,6 +27,7 @@ export class TouchListener extends CommandGenerator { private isJoystickActive = false; private touchStartPosition!: vec2; private primaryDownAt: number | null = null; + private gestureTouchId: number | null = null; private fireButton: HTMLElement; private fireStrengthRing: HTMLElement; @@ -69,23 +73,43 @@ export class TouchListener extends CommandGenerator { target.addEventListener('touchstart', this.touchStartListener); target.addEventListener('touchmove', this.touchMoveListener); target.addEventListener('touchend', this.touchEndListener); + target.addEventListener('touchcancel', this.touchCancelListener); + } + + // The contact that started this gesture, or undefined if it is not in `list`. + private findGestureTouch(list: TouchList): Touch | undefined { + if (this.gestureTouchId === null) { + return undefined; + } + for (let i = 0; i < list.length; i++) { + if (list[i].identifier === this.gestureTouchId) { + return list[i]; + } + } + return undefined; } private touchStartListener = (event: TouchEvent) => { event.preventDefault(); if (this.isJoystickActive) { const center = vec2.fromValues( - last(event.touches)!.clientX, - last(event.touches)!.clientY, + last(event.changedTouches)!.clientX, + last(event.changedTouches)!.clientY, ); this.sendCommandToSubscribers( - new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)), + new PrimaryActionCommand( + this.game.displayToWorldCoordinates(center), + 0, + Math.round(predictorNowMs()), + ), ); } else { - this.touchStartPosition = vec2.fromValues( - event.touches[0].clientX, - event.touches[0].clientY, - ); + const touch = event.changedTouches[0]; + if (!touch) { + return; + } + this.gestureTouchId = touch.identifier; + this.touchStartPosition = vec2.fromValues(touch.clientX, touch.clientY); this.primaryDownAt = performance.now(); ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y); } @@ -94,10 +118,12 @@ export class TouchListener extends CommandGenerator { private touchMoveListener = (event: TouchEvent) => { event.preventDefault(); - const touchPosition = vec2.fromValues( - event.touches[0].clientX, - event.touches[0].clientY, - ); + const touch = this.findGestureTouch(event.touches); + if (!touch || !this.touchStartPosition) { + return; + } + + const touchPosition = vec2.fromValues(touch.clientX, touch.clientY); const delta = vec2.subtract(vec2.create(), touchPosition, this.touchStartPosition); vec2.scale(delta, delta, TouchListener.deltaScaling); @@ -133,6 +159,14 @@ export class TouchListener extends CommandGenerator { private touchEndListener = (event: TouchEvent) => { event.preventDefault(); + // Only the contact that owns the gesture ends it — not whichever finger + // happens to lift, and not "the screen is now empty". + const touch = this.findGestureTouch(event.changedTouches); + if (!touch) { + return; + } + this.gestureTouchId = null; + if (!this.isJoystickActive) { ChargeIndicator.end(); const charge = @@ -140,20 +174,39 @@ export class TouchListener extends CommandGenerator { ? 0 : holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000); this.primaryDownAt = null; - const center = vec2.fromValues( - event.changedTouches[0].clientX, - event.changedTouches[0].clientY, - ); + const center = vec2.fromValues(touch.clientX, touch.clientY); this.sendCommandToSubscribers( - new PrimaryActionCommand(this.game.displayToWorldCoordinates(center), charge), + new PrimaryActionCommand( + this.game.displayToWorldCoordinates(center), + charge, + Math.round(predictorNowMs()), + ), ); - } else if (event.touches.length === 0) { - this.isJoystickActive = false; - this.joystick.parentElement?.removeChild(this.joystick); - this.sendMove(vec2.create()); + } else { + this.releaseJoystick(); } }; + // Also reached from touchcancel: a system gesture, an incoming call or a + // palm rejection would otherwise leave the stick latched at full tilt. + private touchCancelListener = (event: TouchEvent) => { + if (!this.findGestureTouch(event.changedTouches)) { + return; + } + this.gestureTouchId = null; + this.primaryDownAt = null; + ChargeIndicator.end(); + if (this.isJoystickActive) { + this.releaseJoystick(); + } + }; + + private releaseJoystick() { + this.isJoystickActive = false; + this.joystick.parentElement?.removeChild(this.joystick); + this.sendMove(vec2.create()); + } + private swallowTouch = (event: TouchEvent) => { event.preventDefault(); event.stopPropagation(); @@ -237,7 +290,9 @@ export class TouchListener extends CommandGenerator { direction, settings.touchAimRange, ); - this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge)); + this.sendCommandToSubscribers( + new PrimaryActionCommand(aim, charge, Math.round(predictorNowMs())), + ); }; public update(_deltaTimeInSeconds: number) { @@ -250,9 +305,8 @@ export class TouchListener extends CommandGenerator { const character = this.game.gameObjects.player; if (character) { - this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${ - character.strengthFraction * 360 - }deg, transparent 0deg)`; + this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360 + }deg, transparent 0deg)`; } } @@ -261,6 +315,7 @@ export class TouchListener extends CommandGenerator { this.target.removeEventListener('touchstart', this.touchStartListener); this.target.removeEventListener('touchmove', this.touchMoveListener); this.target.removeEventListener('touchend', this.touchEndListener); + this.target.removeEventListener('touchcancel', this.touchCancelListener); this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener); this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener); diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts index adbf068..553a5a9 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -99,6 +99,18 @@ export abstract class FeedbackHud { this.elimination = undefined; } + /** + * Tear the HUD down entirely. Its root lives on document.body rather than the + * game overlay, so leaving a match while dead used to leave "Eliminated / + * Respawning…" painted over the landing page. + */ + public static reset(): void { + this.hideElimination(); + this.root?.parentElement?.removeChild(this.root); + this.root = undefined; + this.killfeed = undefined; + } + private static streakName(streak: number): string | undefined { switch (streak) { case 2: diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 4b8678f..b63ad51 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -21,6 +21,7 @@ import { Command, settings, InputAcknowledgement, + JoinRejectionReason, } from 'shared'; import { io, Socket } from 'socket.io-client'; import { KeyboardListener } from './commands/keyboard-listener'; @@ -40,6 +41,7 @@ import { Tutorial } from './tutorial'; import { Scoreboard } from './scoreboard'; import { Minimap } from './minimap'; import { ScreenShake } from './screen-shake'; +import { FeedbackHud } from './feedback-hud'; export class Game extends CommandReceiver { public gameObjects = new GameObjectContainer(this); @@ -60,6 +62,11 @@ export class Game extends CommandReceiver { private keystoneArrow?: HTMLElement; private socketReceiver!: CommandSocket; private tutorial!: Tutorial; + // Issued by the server on join; presented on reconnect to reclaim this + // player's team and score instead of coming back as a blank slate. + private reconnectToken?: string; + private connectionBanner?: HTMLElement; + private rejectionReason?: JoinRejectionReason; constructor( private readonly playerDecision: PlayerDecision, @@ -110,19 +117,76 @@ export class Game extends CommandReceiver { this.socket.io.opts.transports = ['polling', 'websocket']; }); + // A transport drop is not the end of the match. Previously this tore the + // game down on the first `disconnect`, which stopped the render loop, which + // closed the socket — cancelling the reconnection the client is configured + // for before it could ever run, and dumping the player on the server list. this.socket.on('disconnect', () => { - if (!this.isBetweenGames) { - this.destroy(); + if (this.isBetweenGames) { + return; } + this.showConnectionBanner('Reconnecting…'); }); - this.socket.on(TransportEvents.Ping, () => { - this.socket.emit(TransportEvents.Pong); + this.socket.on('connect', () => { + if (this.isBetweenGames) { + return; + } + // A reconnect is a brand-new server-side connection that has never seen a + // join, so the join has to be re-sent or the client sits connected and + // invisible forever. + this.hideConnectionBanner(); + serverTimeline.reset(); + localCharacterPredictor.reset(); + // ...and it is a brand-new server-side *player*, whose view-area + // bookkeeping starts empty. Anything the dropped session was told about + // will never be retracted, so the old world has to go here. + this.gameObjects.reset(); + this.socket.emit(TransportEvents.PlayerJoining, { + ...this.playerDecision, + reconnectToken: this.reconnectToken, + }); + }); + + this.socket.io.on('reconnect_failed', () => { + this.showConnectionBanner('Connection lost'); + this.destroy(); + }); + + this.socket.on(TransportEvents.PlayerJoined, (token: string) => { + this.reconnectToken = typeof token === 'string' ? token : undefined; + this.hideConnectionBanner(); + }); + + this.socket.on(TransportEvents.JoinRejected, (reason: JoinRejectionReason) => { + this.rejectionReason = reason; + this.showConnectionBanner(Game.rejectionText(reason)); + this.destroy(); + }); + + // Echo the nonce: the server only accepts a reply that matches the ping + // still outstanding, so a stale or duplicated Pong cannot move its RTT. + this.socket.on(TransportEvents.Ping, (nonce: unknown) => { + this.socket.emit(TransportEvents.Pong, nonce); }); this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => { - const commands: Array = deserialize(serializedCommands); - commands.forEach((c) => this.handleCommand(c)); + // One malformed object must not take down the message pump. deserialize + // revives classes by name from the payload, so a hostile or corrupt field + // can throw inside JSON.parse's reviver — and every later batch would be + // lost with it. + try { + const commands: Array = deserialize(serializedCommands); + commands.forEach((c) => { + try { + this.handleCommand(c); + } catch (e) { + console.error('Failed to apply a server command', e); + } + }); + } catch (e) { + console.error('Dropped an undecodable server message', e); + } }); this.socketReceiver = new CommandSocket(this.socket); @@ -138,9 +202,10 @@ export class Game extends CommandReceiver { this.touchListener.subscribe(this.socketReceiver); this.touchListener.subscribe(this.tutorial); + // The join is emitted from the socket's `connect` handler above, which fires + // for the first connection and for every reconnection alike — so one code + // path covers both, and a reconnect can never be left unjoined. this.isBetweenGames = false; - - this.socket.emit(TransportEvents.PlayerJoining, this.playerDecision); } protected defaultCommandExecutor(c: Command) { @@ -163,6 +228,7 @@ export class Game extends CommandReceiver { c.clientTimeMs, c.bodyVelocity, c.lastLeapClientTimeMs, + c.ackAgeMs, ), [GameEndCommand.type]: () => (this.isEnding = true), [UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c), @@ -208,11 +274,49 @@ export class Game extends CommandReceiver { ); this.socket.close(); this.overlay.innerHTML = ''; + this.hideConnectionBanner(); + // The HUD root lives on document.body, not the overlay, so it has to be torn + // down explicitly or it stays painted over the landing page. + FeedbackHud.reset(); this.keyboardListener.destroy(); this.mouseListener.destroy(); this.touchListener.destroy(); } + /** Why the player was sent back to the server list, if they were. */ + public get lastRejectionReason(): JoinRejectionReason | undefined { + return this.rejectionReason; + } + + public static rejectionText(reason: JoinRejectionReason): string { + switch (reason) { + case JoinRejectionReason.ServerFull: + return 'That server is full — pick another'; + case JoinRejectionReason.RoundEnding: + return 'That round is just finishing — try again in a moment'; + case JoinRejectionReason.AlreadyJoined: + return 'Already joined on this connection'; + default: + return 'The server refused the connection'; + } + } + + private showConnectionBanner(text: string) { + if (!this.connectionBanner) { + this.connectionBanner = document.createElement('div'); + this.connectionBanner.className = 'connection-banner'; + this.overlay.appendChild(this.connectionBanner); + } + this.connectionBanner.innerText = text; + this.connectionBanner.style.display = 'block'; + } + + private hideConnectionBanner() { + if (this.connectionBanner) { + this.connectionBanner.style.display = 'none'; + } + } + public displayToWorldCoordinates(p: vec2): vec2 { return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create(); } diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index 5d23c1c..5862347 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -25,10 +25,16 @@ export const setPredictorClockForTesting = (clock: () => number): void => { nowMs = clock; }; +// The same clock the predictor stamps input with. Anything that tells the server +// "this is how far my input timeline has got" must read it from here, or the +// acknowledgement comes back in a different time base than the replay window. +export const predictorNowMs = (): number => nowMs(); + // Don't replay more than this far back: if the last acknowledged input is older -// (a stall, or a backgrounded tab catching up) snap to the authoritative pose -// instead of grinding through hundreds of steps. -const maxReplayMs = 300; +// (a stall, or a backgrounded tab catching up) fall back to a shorter window +// instead of grinding through hundreds of steps. Comfortably covers the replay +// a 300 ms round trip needs; beyond that the body simply trails a little. +const maxReplayMs = 400; // Render-side easing of the correction the reconciliation produces each frame, // so a snapshot that disagrees with the prediction is smoothed out instead of @@ -60,15 +66,33 @@ export class LocalCharacterPredictor { private readonly world = new ClientCharacterWorld(); private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle }; - private lastAckClientTimeMs?: number; - // Wall-clock (client) time the latest authoritative snapshot was received. The - // replay predicts forward from HERE by the snapshot's age, so the local pose - // advances smoothly with real time between the 25 Hz snapshots. Anchoring to - // the last *acked input* time instead breaks when input is sent only on change - // (a held key sends nothing): that time freezes, the window pins to the - // maxReplayMs clamp, the replay displacement goes constant, and the pose - // stair-steps at the snapshot rate. - private authReceiptMs = 0; + // Client-clock instant the authoritative pose below belongs to, less one + // one-way trip — which is exactly the lead the local body should have, since + // the input the player is giving right now will not reach the server for that + // long. Replaying from here reproduces every input the server has not applied + // yet, so the body responds immediately at any latency. + // + // It is built from BOTH halves of the acknowledgement: the client-clock time + // of the newest input the server had applied, plus how long after that the + // snapshot was actually taken. Both halves are needed. + // + // Anchoring on the acknowledged input time ALONE beats against the send + // cadence: input is sent once per frame, so at snapshot time the newest input + // the server holds is 0..1 frame old depending on where the client's frames + // fell, and that age lands directly in the replay window. Measured on + // localhost it walked a sawtooth — 17, 10, 6, 1, 11, 6, 2, 12 ms — so the + // window jumped by up to a frame of travel every snapshot and the body + // stuttered at 25 Hz. ackAgeMs cancels it exactly. + // + // Anchoring on the snapshot's ARRIVAL is free of that beat, but it under- + // advances by a one-way trip, so the local body trails its own input at any + // real latency. This anchor degenerates to arrival on a zero-latency link and + // grows into full compensation as latency rises. + // + // A held key sends no fresh movement, which would freeze the acknowledgement + // altogether; that is fixed at the source, by closing every outgoing batch + // with a ClientHeartbeatCommand. + private replayAnchorMs?: number; // Authoritative launch momentum at the last snapshot — seeds each replay so a // leap/slingshot/recoil flight is reproduced and continuously corrected. private authoritativeBodyVelocity = vec2.create(); @@ -123,14 +147,13 @@ export class LocalCharacterPredictor { clientTimeMs: number, bodyVelocity: vec2, lastLeapClientTimeMs: number, + ackAgeMs = 0, ): void { // Inputs only advance the acknowledgement forward; the launch momentum and // leap boundary always adopt the latest authoritative values. - if ( - this.lastAckClientTimeMs === undefined || - clientTimeMs > this.lastAckClientTimeMs - ) { - this.lastAckClientTimeMs = clientTimeMs; + const anchor = clientTimeMs + Math.max(0, ackAgeMs); + if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) { + this.replayAnchorMs = anchor; } vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]); this.lastLeapAckMs = lastLeapClientTimeMs; @@ -138,7 +161,6 @@ export class LocalCharacterPredictor { public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void { this.authoritative = { head, leftFoot, rightFoot }; - this.authReceiptMs = Math.round(nowMs()); } // The player pressed leap; remember when, so the replay applies the same @@ -168,8 +190,7 @@ export class LocalCharacterPredictor { this.leapHistory = []; this.lastLeapAckMs = -Infinity; this.authoritative = undefined; - this.lastAckClientTimeMs = undefined; - this.authReceiptMs = 0; + this.replayAnchorMs = undefined; vec2.zero(this.authoritativeBodyVelocity); this.currentStrength = settings.playerMaxStrength; this.carriedPlanetId = undefined; @@ -179,7 +200,7 @@ export class LocalCharacterPredictor { } public get canPredict(): boolean { - return this.authoritative !== undefined && this.lastAckClientTimeMs !== undefined; + return this.authoritative !== undefined && this.replayAnchorMs !== undefined; } // During spawn-in and death the server freezes walking and only scales the @@ -232,11 +253,10 @@ export class LocalCharacterPredictor { private simulate(): CharacterMovementState { const auth = this.authoritative!; const now = Math.round(nowMs()); - // Predict forward from the latest snapshot by its age, clamped so a stall - // (or a backgrounded tab catching up) snaps instead of grinding hundreds of - // steps. This advances with wall-clock time even while a held key sends no - // fresh input, so the pose no longer pins to the 25 Hz snapshot cadence. - const startMs = Math.max(this.authReceiptMs, now - maxReplayMs); + // Replay every input the server has not confirmed yet: from the anchor up to + // now, clamped so a stall (or a backgrounded tab catching up) cannot grind + // through hundreds of steps. + const startMs = Math.min(now, Math.max(this.replayAnchorMs!, now - maxReplayMs)); const windowMs = Math.max(0, now - startMs); const steps = Math.floor(windowMs / stepMs); const remainderSeconds = (windowMs - steps * stepMs) / 1000; diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 2b03836..2ee5507 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -15,6 +15,7 @@ import { UpdatePropertyCommand, } from 'shared'; import { BeforeDestroyCommand } from '../commands/types/before-destroy'; +import { RenderCommand } from '../commands/types/render'; import { StepCommand } from '../commands/types/step'; import { FeedbackHud } from '../feedback-hud'; import { Game } from '../game'; @@ -31,6 +32,19 @@ export class GameObjectContainer extends CommandReceiver { public camera: Camera = new Camera(this.game); private wasLocalPlayerAlive = false; + // Create and delete arrive on the wire, but positions are drawn + // interpolationDelaySeconds in the past — so applying them the instant a + // packet lands made every projectile pop into existence and hang at its muzzle + // for 100 ms, then disappear a few hundred units before its rendered impact. + // Objects are therefore admitted and retired on the render timeline instead: + // they exist (and accumulate interpolation frames) from the moment they + // arrive, but only draw once the cursor reaches the snapshot that introduced + // them, and keep drawing until it reaches the one that removed them. + private visibleFrom = new Map(); + private deleteAt = new Map(); + private awaitingCreateStamp: Array = []; + private awaitingDeleteStamp: Array = []; + protected commandExecutors: CommandExecutors = { [CreatePlayerCommand.type]: (c: CreatePlayerCommand) => { this.player = c.character as CharacterView; @@ -45,9 +59,17 @@ export class GameObjectContainer extends CommandReceiver { }, [CreateObjectsCommand.type]: (c: CreateObjectsCommand) => - c.objects.forEach((o) => this.addObject(o as GameObject)), + c.objects.forEach((o) => { + this.addObject(o as GameObject); + this.awaitingCreateStamp.push((o as GameObject).id); + }), [StepCommand.type]: (c: StepCommand) => { + // A batch carrying no property updates (a bare announcement, say) leaves + // entries unstamped; fall back to the newest known snapshot so nothing is + // stranded invisible. + this.stampPending(serverTimeline.snapshotTime); + this.retireDueObjects(); this.defaultCommandExecutor(c); // The local body is alive only while its object still exists (the server @@ -91,6 +113,9 @@ export class GameObjectContainer extends CommandReceiver { [PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => { serverTimeline.onSnapshot(c.timestamp); + // Create/delete precede the property updates inside a batch, so this is + // where the batch's server timestamp becomes known to them. + this.stampPending(c.timestamp); c.updates.forEach((u) => { u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)); if (this.player && u.id === this.player.id) { @@ -100,17 +125,36 @@ export class GameObjectContainer extends CommandReceiver { }, [DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) => - c.ids.forEach((id: Id) => this.deleteObject(id)), + c.ids.forEach((id: Id) => this.awaitingDeleteStamp.push(id)), }; constructor(private game: Game) { super(); } - // The local player's world position, but only while the body is alive. On - // death the server deletes the character object (yet `player` keeps pointing - // at the now-stale view), so gate on the object still being present — otherwise - // the minimap would pin the "you" dot at the death spot for the whole respawn. + /** + * Forget everything the previous connection was told about. + * + * A reconnect is a brand-new server-side player whose view-area bookkeeping + * starts empty, so it re-announces what is in view NOW and never sends a + * delete for anything the dropped session held. Without this, every object + * that has since moved out of view — the old character included — stays in the + * map and on screen forever, as a frozen ghost. + * + * `player` is deliberately left pointing at the stale view: every read of it + * is gated on the object still being in `objects`, so clearing the map is what + * makes it inert, and the next CreatePlayerCommand replaces it. + */ + public reset() { + this.objects.forEach((o) => o.handleCommand(new BeforeDestroyCommand())); + this.objects.clear(); + this.visibleFrom.clear(); + this.deleteAt.clear(); + this.awaitingCreateStamp = []; + this.awaitingDeleteStamp = []; + this.wasLocalPlayerAlive = false; + } + public get localPlayerPosition(): vec2 | undefined { return this.player && this.objects.has(this.player.id) ? this.player.position @@ -128,10 +172,48 @@ export class GameObjectContainer extends CommandReceiver { } protected defaultCommandExecutor(c: Command) { - this.objects.forEach((o) => o.handleCommand(c)); + const isRender = c.type === RenderCommand.type; + this.objects.forEach((o) => { + // Stepping always runs, so an object that has not appeared yet is still + // accumulating interpolation frames and enters the scene already moving. + if (isRender && !this.isVisible(o.id)) { + return; + } + o.handleCommand(c); + }); this.camera.handleCommand(c); } + private isVisible(id: Id): boolean { + const from = this.visibleFrom.get(id); + return from === undefined || serverTimeline.renderTime >= from; + } + + private stampPending(timestamp: number) { + for (const id of this.awaitingCreateStamp) { + this.visibleFrom.set(id, timestamp); + } + this.awaitingCreateStamp = []; + + for (const id of this.awaitingDeleteStamp) { + this.deleteAt.set(id, timestamp); + } + this.awaitingDeleteStamp = []; + } + + private retireDueObjects() { + if (this.deleteAt.size === 0) { + return; + } + const renderTime = serverTimeline.renderTime; + for (const [id, at] of [...this.deleteAt]) { + if (renderTime >= at) { + this.deleteAt.delete(id); + this.deleteObject(id); + } + } + } + // Hand the local player's raw authoritative pose to the predictor (the // interpolated pose would already be ~100 ms stale). The three body parts // arrive together in one snapshot. @@ -168,5 +250,7 @@ export class GameObjectContainer extends CommandReceiver { const object = this.objects.get(id); object?.handleCommand(new BeforeDestroyCommand()); this.objects.delete(id); + this.visibleFrom.delete(id); + this.deleteAt.delete(id); } } diff --git a/frontend/src/scripts/sound-handler.ts b/frontend/src/scripts/sound-handler.ts index 81607ad..7346f64 100644 --- a/frontend/src/scripts/sound-handler.ts +++ b/frontend/src/scripts/sound-handler.ts @@ -57,13 +57,20 @@ export abstract class SoundHandler { return; } - const audio = - this.sounds[sound].currentTime > 0 - ? (this.sounds[sound].cloneNode(true) as HTMLAudioElement) - : this.sounds[sound]; + // Reuse the pooled element unless it is still mid-playback; only overlapping + // sounds need a clone. Testing `currentTime > 0` instead stayed true forever + // after the first play, so every later effect allocated a fresh element. + const pooled = this.sounds[sound]; + const isBusy = !pooled.paused && !pooled.ended; + const audio = isBusy ? (pooled.cloneNode(true) as HTMLAudioElement) : pooled; + if (!isBusy) { + audio.currentTime = 0; + } audio.volume = Math.max(0, Math.min(1, volume)); audio.playbackRate = playbackRate; - audio.play(); + // A rejected play() (autoplay policy, or a clone GC'd mid-play) is expected + // and must not surface as an unhandled rejection. + void audio.play().catch(() => undefined); } public static playAmbient() { diff --git a/package.json b/package.json index 3e123bd..8edb68a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,6 @@ "test": "vitest run", "test:watch": "vitest", "init": "cd shared && npm install && cd ../frontend && npm install && cd ../backend && npm install", - "dev": "concurrently --kill-others-on-fail \"cd shared && npm dev\" \"cd backend && npm dev\" \"cd frontend && npm dev\"" + "dev": "concurrently --kill-others-on-fail --names shared,backend,frontend \"cd shared && npm run dev\" \"cd backend && npm run dev\" \"cd frontend && npm run dev\"" } } diff --git a/shared/src/commands/types/actions/client-heartbeat.ts b/shared/src/commands/types/actions/client-heartbeat.ts new file mode 100644 index 0000000..0724817 --- /dev/null +++ b/shared/src/commands/types/actions/client-heartbeat.ts @@ -0,0 +1,19 @@ +import { serializable } from '../../../serialization/serializable'; +import { Command } from '../../command'; + +// Appended to every client -> server batch. Movement is only transmitted when it +// changes, so on its own the server's "newest input I have applied" marker +// freezes whenever a key is simply held down — which pins the client predictor's +// replay window and stops reconciliation from covering the un-acknowledged +// input. This carries the client's wall-clock for the batch, so the marker keeps +// advancing at the send rate whether or not the input changed. +@serializable +export class ClientHeartbeatCommand extends Command { + public constructor(public readonly clientTimeMs: number = 0) { + super(); + } + + public toArray(): Array { + return [this.clientTimeMs]; + } +} diff --git a/shared/src/commands/types/actions/primary-action.ts b/shared/src/commands/types/actions/primary-action.ts index 54b92c9..32ec1b8 100644 --- a/shared/src/commands/types/actions/primary-action.ts +++ b/shared/src/commands/types/actions/primary-action.ts @@ -7,11 +7,17 @@ export class PrimaryActionCommand extends Command { public constructor( public readonly position: vec2, public readonly charge: number = 0, + // Client wall-clock at the moment the shot was released. Appended last so + // the existing wire order is unchanged. The server uses it (together with + // the measured RTT) to fast-forward the projectile by the time the command + // spent in flight, so the shooter does not have to lead by their own + // latency on top of the projectile's travel time. + public readonly clientTimeMs: number = 0, ) { super(); } public toArray(): Array { - return [this.position, this.charge]; + return [this.position, this.charge, this.clientTimeMs]; } } diff --git a/shared/src/commands/types/input-acknowledgement.ts b/shared/src/commands/types/input-acknowledgement.ts index 3f488e7..f11021b 100644 --- a/shared/src/commands/types/input-acknowledgement.ts +++ b/shared/src/commands/types/input-acknowledgement.ts @@ -18,11 +18,24 @@ export class InputAcknowledgement extends Command { // or before it is already reflected in bodyVelocity, so the predictor must // not replay it. public readonly lastLeapClientTimeMs: number, + // How long before this snapshot the acknowledged input arrived. The client + // sends at its frame rate, so by the time a snapshot is taken the newest + // input it has is already 0..1 frame old — and that age varies with where + // the client's frames happened to fall between two snapshots. Adding it + // back places the replay anchor at the snapshot instant itself instead of + // at whichever input happened to land last, which is what keeps the + // predicted pose free of the send-cadence beat. See LocalCharacterPredictor. + public readonly ackAgeMs: number = 0, ) { super(); } public toArray(): Array { - return [this.clientTimeMs, this.bodyVelocity, this.lastLeapClientTimeMs]; + return [ + this.clientTimeMs, + this.bodyVelocity, + this.lastLeapClientTimeMs, + this.ackAgeMs, + ]; } } diff --git a/shared/src/communication/player-information.ts b/shared/src/communication/player-information.ts index 1130b7b..3180ecd 100644 --- a/shared/src/communication/player-information.ts +++ b/shared/src/communication/player-information.ts @@ -1,3 +1,6 @@ export interface PlayerInformation { name: string; + // Issued by the server on a previous join. Presented on reconnect to reclaim + // the team and score held during the grace window; absent on a first join. + reconnectToken?: string; } diff --git a/shared/src/communication/transport-events.ts b/shared/src/communication/transport-events.ts index 395a564..da2b01d 100644 --- a/shared/src/communication/transport-events.ts +++ b/shared/src/communication/transport-events.ts @@ -6,4 +6,13 @@ export enum TransportEvents { ServerInfoUpdate = 'ServerInfoUpdate', Ping = 'Ping', Pong = 'Pong', + PlayerJoined = 'PlayerJoined', + JoinRejected = 'JoinRejected', +} + +export enum JoinRejectionReason { + ServerFull = 'ServerFull', + RoundEnding = 'RoundEnding', + AlreadyJoined = 'AlreadyJoined', + InvalidRequest = 'InvalidRequest', } diff --git a/shared/src/helper/finite.ts b/shared/src/helper/finite.ts new file mode 100644 index 0000000..f5c79d8 --- /dev/null +++ b/shared/src/helper/finite.ts @@ -0,0 +1,60 @@ +import { vec2 } from 'gl-matrix'; + + +export const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +/** A finite number within [min, max], or `fallback` if the input is unusable. */ +export const finiteInRange = ( + value: unknown, + min: number, + max: number, + fallback: number, +): number => { + if (!isFiniteNumber(value)) { + return fallback; + } + return Math.min(max, Math.max(min, value)); +}; + +/** + * A finite 2-vector whose magnitude is within `maxMagnitude`, or `undefined` if + * the input is not a usable pair of finite numbers. Callers should treat + * `undefined` as "drop this command" rather than substituting a default, so a + * malformed action is ignored instead of being silently reinterpreted. + */ +export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const candidate = value as ArrayLike; + if (candidate.length < 2) { + return undefined; + } + + const x = candidate[0]; + const y = candidate[1]; + if (!isFiniteNumber(x) || !isFiniteNumber(y)) { + return undefined; + } + + const result = vec2.fromValues(x, y); + const length = vec2.length(result); + if (length > maxMagnitude) { + vec2.scale(result, result, maxMagnitude / length); + } + + return result; +}; + +/** + * A display name that is safe to store and re-serialize. Anything that is not a + * string is coerced, because a non-string survives `.slice()` — an array name + * round-trips through the serializer and is revived as a class on every peer + * that receives it, throwing inside the deserializer's reviver. + */ +export const sanitizeName = (value: unknown, maxLength: number): string => { + const text = typeof value === 'string' ? value : String(value ?? ''); + return text.slice(0, maxLength); +}; diff --git a/shared/src/main.ts b/shared/src/main.ts index 7adfcf6..a26a4ce 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -15,6 +15,7 @@ export * from './commands/command-generator'; export * from './commands/types/actions/move-action'; export * from './commands/types/actions/primary-action'; export * from './commands/types/actions/leap-action'; +export * from './commands/types/actions/client-heartbeat'; export * from './commands/types/actions/set-aspect-ratio-action'; export * from './helper/array'; export * from './helper/last'; @@ -23,6 +24,7 @@ export * from './helper/hsl'; export * from './helper/rgb255'; export * from './helper/mix-rgb'; export * from './helper/clamp'; +export * from './helper/finite'; export * from './helper/charge'; export * from './helper/calculate-view-area'; export * from './helper/circle'; diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts index f851137..beb03b9 100644 --- a/shared/src/objects/game-object.ts +++ b/shared/src/objects/game-object.ts @@ -8,7 +8,7 @@ export class RemoteCall { constructor( public readonly functionName: string, public readonly args: Array, - ) {} + ) { } public toArray(): Array { return [this.functionName, this.args]; @@ -49,15 +49,22 @@ export class PropertyUpdatesForObject { constructor( public readonly id: Id, public readonly updates: Array, - ) {} + ) { } public toArray(): Array { return [this.id, this.updates]; } } +let currentUpdateGeneration = 0; +export const beginPropertyUpdateGeneration = (): void => { + currentUpdateGeneration++; +}; + export abstract class GameObject extends CommandReceiver { private remoteCalls: Array = []; + private updateGeneration = -1; + private cachedPropertyUpdates?: PropertyUpdatesForObject; // The only methods a peer may invoke over the wire. processRemoteCalls // dispatches by a raw string taken straight off the network, so without this @@ -95,7 +102,15 @@ export abstract class GameObject extends CommandReceiver { }); } - public getPropertyUpdates(): PropertyUpdatesForObject | void {} + public getPropertyUpdates(): PropertyUpdatesForObject | void { } + + public getPropertyUpdatesForFrame(): PropertyUpdatesForObject | undefined { + if (this.updateGeneration !== currentUpdateGeneration) { + this.updateGeneration = currentUpdateGeneration; + this.cachedPropertyUpdates = this.getPropertyUpdates() ?? undefined; + } + return this.cachedPropertyUpdates; + } public getRemoteCalls(): Array { return this.remoteCalls; diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index 21355a5..084ce65 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -255,7 +255,7 @@ export const decayMomentum = ( : settings.airMomentumFriction; const target = Math.min( speed * Math.exp(-friction * deltaTimeInSeconds) - - settings.momentumStopDeceleration * deltaTimeInSeconds, + settings.momentumStopDeceleration * deltaTimeInSeconds, settings.maxBodyMomentum, ); if (target <= 1) { @@ -326,9 +326,13 @@ export const stepCharacterMovement = ( vec2.add(leftFootGravity, leftFootGravity, rightFootGravity); const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5); + const movementLength = vec2.length(movementForce); + const gravityLength = vec2.length(gravity); if ( + movementLength > 0 && + gravityLength > 0 && vec2.dot(movementForce, gravity) < - -vec2.length(movementForce) * settings.climbDotThreshold + -movementLength * gravityLength * settings.climbDotThreshold ) { vec2.scale(gravity, gravity, settings.climbGravityScale); } @@ -359,7 +363,7 @@ export const stepCharacterMovement = ( latchGround(state, world.stepBody(state.leftFoot, deltaTimeInSeconds)); latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds)); - latchGround(state, world.stepBody(state.head, deltaTimeInSeconds)); + world.stepBody(state.head, deltaTimeInSeconds); decayBodyMomentum(state, deltaTimeInSeconds); }; diff --git a/shared/src/physics/depenetrate-circle.ts b/shared/src/physics/depenetrate-circle.ts index 7232f73..9a0a356 100644 --- a/shared/src/physics/depenetrate-circle.ts +++ b/shared/src/physics/depenetrate-circle.ts @@ -26,11 +26,6 @@ export const depenetrateCircle = ( } vec2.copy(body.lastNormal, normal); - body.center = vec2.scaleAndAdd( - vec2.create(), - body.center, - normal, - body.radius - distance + 0.01, - ); + vec2.scaleAndAdd(body.center, body.center, normal, body.radius - distance + 0.01); } }; diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts index 0a8f0c8..bd8d9d2 100644 --- a/shared/src/physics/march-circle.ts +++ b/shared/src/physics/march-circle.ts @@ -9,12 +9,18 @@ export interface MarchResult { hitObject?: Sdf; } +const minimumStep = 0.5; +const maximumSteps = 256; + // Raymarch a circle by `delta`, stopping at the first surface it would overlap. -// Extracted verbatim from the backend's move-circle so server and client -// resolve motion identically. The collision *reaction* is no longer dispatched -// here: on a real (non-ignored) hit, `onHit(intersecting)` is invoked at the -// exact point the backend used to dispatch its ReactToCollisionCommands, and -// the backend wrapper supplies that callback. The client passes none. +// Shared so server and client resolve motion identically. The collision +// *reaction* is not dispatched here: on a real (non-ignored) hit, +// `onHit(intersecting)` is invoked at the point the backend used to dispatch its +// ReactToCollisionCommands, and the backend wrapper supplies that callback. The +// client passes none. +// +// The march advances by the *free gap* (distance minus the circle's radius), +// which is what sphere-tracing a circle of radius r requires. export const marchCircle = ( body: PhysicsBody, delta: vec2, @@ -22,23 +28,23 @@ export const marchCircle = ( ignoreCollision = false, onHit?: (intersecting: Sdf) => void, ): MarchResult => { - const direction = vec2.clone(delta); + const deltaLength = vec2.length(delta); - if (vec2.length(delta) > 0) { - vec2.normalize(direction, direction); + // A zero-length move cannot resolve a contact by advancing, so report no hit + // rather than letting a caller that loops on `hitSurface` spin forever. + if (!(deltaLength > 0)) { + return { hitSurface: false }; } - const deltaLength = vec2.length(delta); - let travelled = 0; + const direction = vec2.normalize(vec2.create(), delta); + const rayEnd = vec2.create(); - let prevMinDistance = 0; - while (travelled < deltaLength) { - travelled += prevMinDistance; - vec2.add( - rayEnd, - body.center, - vec2.scale(vec2.create(), direction, Math.min(travelled, deltaLength)), - ); + let travelled = 0; + // Furthest point along the ray known to be overlap-free; a hit rewinds here. + let lastFreeDistance = 0; + + for (let step = 0; step < maximumSteps; step++) { + vec2.scaleAndAdd(rayEnd, body.center, direction, travelled); const minDistance = evaluateSdf(rayEnd, possibleIntersectors); @@ -48,31 +54,37 @@ export const marchCircle = ( )!; if (ignoreCollision) { - body.center = vec2.add(body.center, body.center, delta); - } else { - onHit?.(intersecting); + // Pass straight through, but still report the contact so callers that + // march repeatedly to escape geometry keep making progress. + vec2.scaleAndAdd(body.center, body.center, direction, deltaLength); + return { hitSurface: true, hitObject: intersecting }; } - vec2.add( - rayEnd, - body.center, - vec2.scale(vec2.create(), direction, travelled - prevMinDistance), - ); + onHit?.(intersecting); + vec2.scaleAndAdd(rayEnd, body.center, direction, lastFreeDistance); vec2.copy(body.center, rayEnd); - const normal = sdfNormal(rayEnd, [intersecting]); return { hitSurface: true, - normal, + normal: sdfNormal(rayEnd, [intersecting]), hitObject: intersecting, }; } - prevMinDistance = minDistance; + lastFreeDistance = travelled; + + if (travelled >= deltaLength) { + break; + } + + travelled = Math.min( + travelled + Math.max(minDistance - body.radius, minimumStep), + deltaLength, + ); } - vec2.add(body.center, body.center, delta); + vec2.scaleAndAdd(body.center, body.center, direction, deltaLength); return { hitSurface: false, diff --git a/shared/src/settings.ts b/shared/src/settings.ts index 61041a5..3de1367 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -38,6 +38,11 @@ export const settings = { // exceeds this, and the rendered ownership ring stays neutral until the same // point — so what you see matches what scores. planetControlThreshold: 0.12, + // Extra margin a planet must clear *beyond* planetControlThreshold before a + // capture flip pays out again (a Schmitt trigger). Without it, ownership + // hovering on the dead-band edge re-triggers the reward, the announcement and + // the flare every time it grazes the boundary. + planetFlipHysteresis: 0.06, playerMaxHealth: 100, maxGravityStrength: 50000, planetMinReferenceRadius: 150, @@ -202,6 +207,53 @@ export const settings = { // Recoil speed imparted opposite a shot, scaled by its charge (0 for taps). chargeShotRecoilMax: 650, + // Lag compensation for projectiles. A shot is fast-forwarded by the time its + // command spent reaching the server, so the shooter does not have to lead by + // their own latency on top of the projectile's travel time. Capped so a very + // bad (or forged) timestamp cannot spawn a shot arbitrarily far downrange. + maxProjectileCatchUpSeconds: 0.2, + + // How long a dropped player's score and team are held so a reconnect within + // the window rejoins as the same player instead of a blank slate. + reconnectGraceSeconds: 30, + + // Hard ceiling on one inbound client message. socket.io's 1 MB default is far + // more than any legitimate command batch, and parsing is synchronous with the + // physics loop — a single oversized message stalls the tick for everyone. + maxInboundMessageBytes: 16 * 1024, + + // How often an idle client flushes its command queue. Input is flushed on the + // frame it happens, so this only paces the heartbeat that keeps the server's + // input acknowledgement moving while a key is simply held. + // + // It must NOT be the frame rate. Sending once per rendered frame put a 144 Hz + // display permanently over the inbound allowance below, and once the burst was + // spent the server silently discarded whole batches — including the + // edge-triggered movement commands, which are never re-sent, so the player + // kept walking the old way until they pressed something else. + clientSendInterval: 1 / 30, + + // Inbound allowance per socket: a token bucket sized far above anything a + // legitimate client produces (a 30 Hz heartbeat plus whatever discrete input + // events a human generates), so it only ever trips on a flood. Dropping a + // batch loses real input, so the headroom is deliberate. + maxInboundMessagesPerSecond: 240, + maxInboundMessageBurst: 480, + + // Ceiling on a measured round-trip time. The Pong that carries it comes from + // the client, which can always answer late; the nonce check makes the reply + // unforgeable and single-use, and this bounds what stalling one can buy. + maxMeasuredRttMs: 1000, + + // Skip the bulky part of a snapshot when the socket already has this much + // unflushed. A slow or stalled client otherwise accumulates a backlog it will + // never catch up on, and the newest snapshot is the only one that matters. + maxBufferedBytesPerClient: 256 * 1024, + + // Sanity bound on a client-supplied world position (the arena is + // worldRadius across, so anything past this is nonsense). + maxClientPositionMagnitude: 1e6, + // The central giant is a named, always-contested focus. Its neutral decay is // slowed so control lingers and teams keep fighting over it; flips are // announced to everyone and an off-screen arrow points the way. diff --git a/test/backend-regressions.test.ts b/test/backend-regressions.test.ts new file mode 100644 index 0000000..2231356 --- /dev/null +++ b/test/backend-regressions.test.ts @@ -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); + }); +}); diff --git a/test/backpressure.test.ts b/test/backpressure.test.ts new file mode 100644 index 0000000..0fc2dea --- /dev/null +++ b/test/backpressure.test.ts @@ -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['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); + }); +}); diff --git a/test/client-send-cadence.test.ts b/test/client-send-cadence.test.ts new file mode 100644 index 0000000..d80bc3a --- /dev/null +++ b/test/client-send-cadence.test.ts @@ -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 = []; + 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); + }); +}); diff --git a/test/connection-lifecycle.test.ts b/test/connection-lifecycle.test.ts new file mode 100644 index 0000000..cfb91f2 --- /dev/null +++ b/test/connection-lifecycle.test.ts @@ -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 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 = {}) => { + 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'); + } + }); +}); diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts index 98ffc1d..b3b4a5c 100644 --- a/test/prediction-reconciliation.test.ts +++ b/test/prediction-reconciliation.test.ts @@ -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); + }); });