diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 3531be8..0c076e5 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -1,5 +1,6 @@ import { PhysicalContainer } from './physics/containers/physical-container'; import { Server, Socket } from 'socket.io'; +import { randomUUID } from 'node:crypto'; import { TransportEvents, deserialize, @@ -19,7 +20,7 @@ import { } from 'shared'; import { createWorld } from './create-world'; import { Options } from './options'; -import { PlayerContainer } from './players/player-container'; +import { CarriedScore, PlayerContainer } from './players/player-container'; import { ServerFullError } from './players/server-full-error'; import { Player } from './players/player'; import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands'; @@ -29,7 +30,7 @@ const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; export class GameServer extends CommandReceiver { private objects!: PhysicalContainer; private players!: PlayerContainer; - private lastPhysicsBase: [number, number] = process.hrtime(); + private lastPhysicsBase = process.hrtime.bigint(); private bluePoints = 0; private redPoints = 0; @@ -50,7 +51,7 @@ export class GameServer extends CommandReceiver { this.options.playerLimit, this.options.npcCount, ); - this.lastPhysicsBase = process.hrtime(); + this.lastPhysicsBase = process.hrtime.bigint(); this.bluePoints = 0; this.redPoints = 0; this.matchPointAnnounced = {}; @@ -85,7 +86,10 @@ export class GameServer extends CommandReceiver { }); } - private readonly joinedSockets = new Map(); + private readonly joinedSockets = new Map< + Socket, + { player: Player; release: () => void } + >(); private handleJoin(socket: Socket, playerInfo: PlayerInformation) { if (this.joinedSockets.has(socket)) { @@ -98,6 +102,11 @@ export class GameServer extends CommandReceiver { return; } + // A reconnect arrives seconds after the drop, long before engine.io times + // the dead socket out. Retire that ghost first so its slot, team and score + // go back to the returning client. + const carried = this.retireGhost(playerInfo.reconnectToken); + if (this.players.isFull) { socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull); return; @@ -105,7 +114,7 @@ export class GameServer extends CommandReceiver { let player: Player; try { - player = this.players.createPlayer(playerInfo, socket); + player = this.players.createPlayer(playerInfo, socket, carried); } catch (e) { console.error('Failed to register joining player', e); socket.emit( @@ -134,31 +143,22 @@ export class GameServer extends CommandReceiver { }; const onDisconnect = () => { - const p = this.joinedSockets.get(socket); - this.joinedSockets.delete(socket); - this.inboundBudget.delete(socket); - if (!p) { - return; - } - p.detachFromSocket(); - const { kills, deaths } = player.scoreSnapshot; - this.players.reserveScore( - player.reconnectToken, - player.team, - kills, - deaths, - Date.now(), - ); - player.destroy(); - this.players.deletePlayer(player); + this.removeJoined(socket); this.sendServerStateUpdate(); }; socket.on(TransportEvents.PlayerToServer, onPlayerToServer); socket.on('disconnect', onDisconnect); - this.joinedSockets.set(socket, player); + this.joinedSockets.set(socket, { + player, + release: () => { + socket.off(TransportEvents.PlayerToServer, onPlayerToServer); + socket.off('disconnect', onDisconnect); + player.detachFromSocket(); + }, + }); - player.reconnectToken = this.players.issueToken(); + player.reconnectToken = randomUUID(); socket.emit(TransportEvents.PlayerJoined, player.reconnectToken); this.sendServerStateUpdate(); @@ -188,11 +188,36 @@ export class GameServer extends CommandReceiver { return true; } - private releaseJoinedSockets() { - for (const [socket, player] of this.joinedSockets) { - socket.removeAllListeners(); - player.detachFromSocket(); + private retireGhost(token?: string): CarriedScore | undefined { + if (!token) { + return undefined; } + for (const [socket, { player }] of this.joinedSockets) { + if (player.reconnectToken === token) { + const carried = { team: player.team, ...player.scoreSnapshot }; + this.removeJoined(socket); + return carried; + } + } + return undefined; + } + + private removeJoined(socket: Socket) { + const joined = this.joinedSockets.get(socket); + if (!joined) { + return; + } + this.joinedSockets.delete(socket); + this.inboundBudget.delete(socket); + // Only the listeners this join added: socket.io keeps its own internal + // 'error' guard and the connection-scoped PlayerJoining handler here. + joined.release(); + joined.player.destroy(); + this.players.deletePlayer(joined.player); + } + + private releaseJoinedSockets() { + this.joinedSockets.forEach(({ release }) => release()); this.joinedSockets.clear(); this.inboundBudget.clear(); } @@ -255,9 +280,9 @@ export class GameServer extends CommandReceiver { private saturatedFrames = 0; private handlePhysics() { - const now = process.hrtime(this.lastPhysicsBase); - const delta = now[0] + now[1] / 1e9; - this.lastPhysicsBase = process.hrtime(); + const frameStart = process.hrtime.bigint(); + const delta = Number(frameStart - this.lastPhysicsBase) / 1e9; + this.lastPhysicsBase = frameStart; if (Date.now() - this.statReportMs > 30000) { this.statReportMs = Date.now(); @@ -317,13 +342,9 @@ export class GameServer extends CommandReceiver { this.players.stepCommunication(delta); this.objects.resetRemoteCalls(); - const elapsed = process.hrtime(now); - const physicsDelta = elapsed[0] + elapsed[1] / 1e9; + const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9; - setTimeout( - this.handlePhysics.bind(this), - Math.max(0, fixedDelta - physicsDelta) * 1000, - ); + setTimeout(this.handlePhysics.bind(this), Math.max(0, fixedDelta - elapsed) * 1000); } public get serverInfo(): ServerInformation { diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index 6c65b7f..5fba09a 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -249,7 +249,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } } - public shootTowards(position: vec2, charge = 0, catchUpSeconds = 0) { + public shootTowards(position: vec2, charge = 0) { if ( !this.isAlive || this.timeSinceLastShot < settings.projectileCreationInterval || @@ -295,7 +295,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical c, ); this.container.addObject(projectile); - projectile.fastForward(catchUpSeconds); if (c > 0) { vec2.scaleAndAdd( diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index b951e11..db54c33 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -175,24 +175,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { } } - 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.flipTeam; + const currentTeam = this.team; if (currentTeam === this.lastTeam) { return; } diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index 3fe0075..2437d0c 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -82,25 +82,6 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica 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. Capped by settings.maxProjectileCatchUpSeconds so - * a 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; - } } // Live view of the circle's box, not a snapshot — the circle keeps it in sync diff --git a/backend/src/physics/containers/bounding-box-list.ts b/backend/src/physics/containers/bounding-box-list.ts index 4311f62..1c85f99 100644 --- a/backend/src/physics/containers/bounding-box-list.ts +++ b/backend/src/physics/containers/bounding-box-list.ts @@ -9,10 +9,10 @@ export class BoundingBoxList { } public remove(object: DynamicPhysical) { - this.objects.splice( - this.objects.findIndex((i) => i === object), - 1, - ); + const index = this.objects.indexOf(object); + if (index >= 0) { + this.objects.splice(index, 1); + } } public forEach(func: (object: DynamicPhysical) => unknown) { diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index af2b2a3..22e6163 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -8,10 +8,10 @@ import { settings, sanitizeName, boundRadius, + evaluateSdf, } from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; -import { evaluateSdf } from 'shared'; import { CharacterPhysical } from '../objects/character-physical'; import { PlanetPhysical } from '../objects/planet-physical'; import { PlayerContainer } from './player-container'; diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index 0fce4a3..a430220 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -5,21 +5,18 @@ 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 dropped player so a reconnect inside the grace window -// resumes the match instead of starting from zero. -interface ReservedScore { +// Team and score carried over from the connection a reconnecting client is +// replacing, so it resumes the match instead of starting from zero. +export interface CarriedScore { 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, @@ -42,80 +39,34 @@ export class PlayerContainer { } } - public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player { + public createPlayer( + playerInfo: PlayerInformation, + socket: Socket, + carried?: CarriedScore, + ): Player { if (this._players.length >= this.playerMaxCount) { throw new ServerFullError(); } - const reserved = this.claimReservedScore(playerInfo.reconnectToken); - const team = reserved ? reserved.team : this.getTeamOfNextPlayer(); + const team = carried ? carried.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); - } + // Retire the bot before the player spawns: its body would otherwise push + // the spawn search away from the position it picked. + const npcToReplace = + this._npcs.find((n) => n.team === team) ?? this._npcs.find((n) => n.team !== team); 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); + if (carried) { + player.restoreScore(carried.kills, carried.deaths); } return player; } - /** - * A fresh token for a joining player; only 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 reconnecting client 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]; } diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index ed3cfb5..4238441 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -92,11 +92,7 @@ export class Player extends PlayerBase { return; } this.observeClientTime(c.clientTimeMs); - this.character?.shootTowards( - position, - finiteInRange(c.charge, 0, 1, 0), - this.catchUpSeconds, - ); + this.character?.shootTowards(position, finiteInRange(c.charge, 0, 1, 0)); }, [LeapActionCommand.type]: (c: LeapActionCommand) => { if (!isFiniteNumber(c.clientTimeMs)) { @@ -125,12 +121,6 @@ export class Player extends PlayerBase { } } - // Derived from the measured RTT, NOT the client's timestamp, so a forged - // stamp cannot buy extra lag compensation. fastForward caps the result. - private get catchUpSeconds(): number { - return Math.max(0, this.rttMs / 2 / 1000); - } - constructor( playerInfo: PlayerInformation, playerContainer: PlayerContainer, diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 893a816..99d95f2 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -173,4 +173,9 @@ const main = async () => { } }; -main(); +main().catch((error) => { + console.error(error); + hide(spinner); + joinNotice.innerText = 'Something went wrong. Please reload the page.'; + joinNotice.style.display = 'block'; +}); diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 25f684c..aa88ce8 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -93,6 +93,7 @@ export class Game extends CommandReceiver { this.gameObjects = new GameObjectContainer(this); this.overlay.innerHTML = ''; this.keystoneArrow = undefined; + this.connectionBanner = undefined; this.lastMinimap = undefined; this.isEnding = false; this.lastAnnouncementText = ''; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 07a4028..e404baf 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -5,6 +5,7 @@ "target": "es6", "esModuleInterop": true, "strict": true, + "experimentalDecorators": true, "moduleResolution": "node", "ignoreDeprecations": "6.0", "skipLibCheck": true, diff --git a/shared/src/helper/random.ts b/shared/src/helper/random.ts index 76c904a..7e49865 100644 --- a/shared/src/helper/random.ts +++ b/shared/src/helper/random.ts @@ -1,23 +1,7 @@ -let _seed = Math.random(); +let seed = Math.random(); -export function setRandomSeed(value: number) { - _seed = value; -} - -export function chooseRandom(values: Array): T | undefined { - const to = values.length; - if (to === 0) { - return undefined; - } - return values[Math.floor(getRandomInRange(0, to))]; -} - -export function getRandomInRange(from: number, to: number): number { - return from + getRandom() * (to - from); -} - -export function getRandom(): number { - let t = (_seed += 0x6d2b79f5); +function getRandom(): number { + let t = (seed += 0x6d2b79f5); t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; @@ -25,12 +9,18 @@ export function getRandom(): number { export const Random = { get seed() { - return _seed; + return seed; }, - set seed(v: number) { - _seed = v; + set seed(value: number) { + seed = value; }, - choose: chooseRandom, - getRandomInRange, getRandom, + getRandomInRange(from: number, to: number): number { + return from + getRandom() * (to - from); + }, + choose(values: Array): T | undefined { + return values.length === 0 + ? undefined + : values[Math.floor(getRandom() * values.length)]; + }, }; diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index bd3cabf..84a8e6d 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -34,8 +34,8 @@ export class PlanetBase extends GameObject { this.vertices.length; } - // Single rule for the dead-band: scoring (server) and every tint (client) - // read this rather than duplicating the comparison. + // The one capture rule: server flips and scoring, client tints and the + // keystone arrow all read this. public get team(): CharacterTeam { return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold ? CharacterTeam.neutral diff --git a/shared/src/physics/march-circle.ts b/shared/src/physics/march-circle.ts index 425bec6..5f7836b 100644 --- a/shared/src/physics/march-circle.ts +++ b/shared/src/physics/march-circle.ts @@ -78,7 +78,9 @@ export const marchCircle = ( ); } - vec2.scaleAndAdd(body.center, body.center, direction, deltaLength); + // lastFreeDistance, not deltaLength: on a completed march they are equal, and + // if the step budget ran out it is the furthest point actually checked. + vec2.scaleAndAdd(body.center, body.center, direction, lastFreeDistance); return { hitSurface: false, diff --git a/shared/src/settings.ts b/shared/src/settings.ts index a904913..67f4aeb 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -30,12 +30,9 @@ export const settings = { maxGravityDistance: 800, minGravityDistance: 1, maxGravityQ: 5000, - // Neutral dead-band around 50% ownership; the rendered ring uses the same - // threshold as scoring so visuals match state. + // Neutral dead-band around 50% ownership. The single capture rule: flips, + // scoring and every client-side tint all read PlanetBase.team. planetControlThreshold: 0.12, - // Schmitt trigger: extra margin beyond planetControlThreshold before a flip - // pays out again, so boundary-hovering doesn't re-trigger the reward. - planetFlipHysteresis: 0.06, playerMaxHealth: 100, maxGravityStrength: 50000, planetMinReferenceRadius: 150, @@ -171,11 +168,6 @@ export const settings = { chargeShotRecoilMax: 650, - // Capped so a forged timestamp cannot spawn a shot arbitrarily far downrange. - maxProjectileCatchUpSeconds: 0.2, - - reconnectGraceSeconds: 30, - // DoS guard: parsing is synchronous with the physics loop, so one oversized // message stalls the tick for everyone. maxInboundMessageBytes: 16 * 1024, diff --git a/shared/tsconfig.json b/shared/tsconfig.json index 4bae3bb..ca43c33 100644 --- a/shared/tsconfig.json +++ b/shared/tsconfig.json @@ -7,6 +7,7 @@ "target": "es6", "esModuleInterop": true, "strict": true, + "experimentalDecorators": true, "moduleResolution": "node", "ignoreDeprecations": "6.0", "module": "commonjs", diff --git a/test/backend-regressions.test.ts b/test/backend-regressions.test.ts index bf2b244..e3b9640 100644 --- a/test/backend-regressions.test.ts +++ b/test/backend-regressions.test.ts @@ -69,31 +69,7 @@ describe('projectile broadphase registration', () => { 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', () => { + it('spawns a shot just clear of the shooter, not a jump downrange', () => { const container = new PhysicalContainer(); container.initialize(); const projectile = new ProjectilePhysical( @@ -106,9 +82,8 @@ describe('projectile broadphase registration', () => { container, 0, ); - projectile.fastForward(10); - const cap = settings.chargeShotSpeedMax * settings.maxProjectileCatchUpSeconds; - expect(projectile.center[0]).toBeLessThanOrEqual(cap + 50); + + expect(projectile.center[0]).toBeLessThanOrEqual(15); }); }); diff --git a/test/connection-lifecycle.test.ts b/test/connection-lifecycle.test.ts index e9f3220..730c893 100644 --- a/test/connection-lifecycle.test.ts +++ b/test/connection-lifecycle.test.ts @@ -255,6 +255,44 @@ describe('reconnect tokens', () => { }); }); +describe('reconnect while the dropped socket is still live', () => { + // engine.io needs ~45 s to notice a transport drop; the client is back in ~1 s. + it('retires the ghost so the returning player keeps its slot, team and score', () => { + const { server, attach } = startServer({ playerLimit: 1, npcCount: 0 }); + + const first = attach(); + first.fire(TransportEvents.PlayerJoining, { name: 'flaky' }); + const token = first.lastPayloadFor(TransportEvents.PlayerJoined) as string; + expect(server.serverInfo.playerCount).toBe(1); + + // No 'disconnect' fired: the server still holds the dead socket's player. + const second = attach(); + second.fire(TransportEvents.PlayerJoining, { name: 'flaky', reconnectToken: token }); + + expect(second.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined(); + expect(second.lastPayloadFor(TransportEvents.PlayerJoined)).toBeTruthy(); + expect(server.serverInfo.playerCount).toBe(1); + expect(first.listenerCount(TransportEvents.PlayerToServer)).toBe(0); + }); + + it('does not hand a slot to an unknown token', () => { + const { server, attach } = startServer({ playerLimit: 1, npcCount: 0 }); + + attach().fire(TransportEvents.PlayerJoining, { name: 'holder' }); + + const stranger = attach(); + stranger.fire(TransportEvents.PlayerJoining, { + name: 'stranger', + reconnectToken: 'not-a-real-token', + }); + + expect(stranger.lastPayloadFor(TransportEvents.JoinRejected)).toBe( + JoinRejectionReason.ServerFull, + ); + expect(server.serverInfo.playerCount).toBe(1); + }); +}); + describe('socket listeners', () => { it('takes every listener back off when the player drops', () => { const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); @@ -266,7 +304,8 @@ describe('socket listeners', () => { socket.fire('disconnect'); expect(socket.listenerCount(TransportEvents.Pong)).toBe(0); - expect(socket.listenerCount(TransportEvents.PlayerToServer)).toBe(1); + expect(socket.listenerCount(TransportEvents.PlayerToServer)).toBe(0); + expect(socket.listenerCount('disconnect')).toBe(0); }); it('does not stack a listener per reconnect', () => {