This commit is contained in:
Andras Schmelczer 2026-08-22 20:59:46 +01:00
commit 98c4d63756
18 changed files with 155 additions and 223 deletions

View file

@ -1,5 +1,6 @@
import { PhysicalContainer } from './physics/containers/physical-container'; import { PhysicalContainer } from './physics/containers/physical-container';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { randomUUID } from 'node:crypto';
import { import {
TransportEvents, TransportEvents,
deserialize, deserialize,
@ -19,7 +20,7 @@ import {
} from 'shared'; } from 'shared';
import { createWorld } from './create-world'; import { createWorld } from './create-world';
import { Options } from './options'; 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 { ServerFullError } from './players/server-full-error';
import { Player } from './players/player'; import { Player } from './players/player';
import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands'; import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands';
@ -29,7 +30,7 @@ const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
export class GameServer extends CommandReceiver { export class GameServer extends CommandReceiver {
private objects!: PhysicalContainer; private objects!: PhysicalContainer;
private players!: PlayerContainer; private players!: PlayerContainer;
private lastPhysicsBase: [number, number] = process.hrtime(); private lastPhysicsBase = process.hrtime.bigint();
private bluePoints = 0; private bluePoints = 0;
private redPoints = 0; private redPoints = 0;
@ -50,7 +51,7 @@ export class GameServer extends CommandReceiver {
this.options.playerLimit, this.options.playerLimit,
this.options.npcCount, this.options.npcCount,
); );
this.lastPhysicsBase = process.hrtime(); this.lastPhysicsBase = process.hrtime.bigint();
this.bluePoints = 0; this.bluePoints = 0;
this.redPoints = 0; this.redPoints = 0;
this.matchPointAnnounced = {}; this.matchPointAnnounced = {};
@ -85,7 +86,10 @@ export class GameServer extends CommandReceiver {
}); });
} }
private readonly joinedSockets = new Map<Socket, Player>(); private readonly joinedSockets = new Map<
Socket,
{ player: Player; release: () => void }
>();
private handleJoin(socket: Socket, playerInfo: PlayerInformation) { private handleJoin(socket: Socket, playerInfo: PlayerInformation) {
if (this.joinedSockets.has(socket)) { if (this.joinedSockets.has(socket)) {
@ -98,6 +102,11 @@ export class GameServer extends CommandReceiver {
return; 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) { if (this.players.isFull) {
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull); socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull);
return; return;
@ -105,7 +114,7 @@ export class GameServer extends CommandReceiver {
let player: Player; let player: Player;
try { try {
player = this.players.createPlayer(playerInfo, socket); player = this.players.createPlayer(playerInfo, socket, carried);
} catch (e) { } catch (e) {
console.error('Failed to register joining player', e); console.error('Failed to register joining player', e);
socket.emit( socket.emit(
@ -134,31 +143,22 @@ export class GameServer extends CommandReceiver {
}; };
const onDisconnect = () => { const onDisconnect = () => {
const p = this.joinedSockets.get(socket); this.removeJoined(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.sendServerStateUpdate(); this.sendServerStateUpdate();
}; };
socket.on(TransportEvents.PlayerToServer, onPlayerToServer); socket.on(TransportEvents.PlayerToServer, onPlayerToServer);
socket.on('disconnect', onDisconnect); 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); socket.emit(TransportEvents.PlayerJoined, player.reconnectToken);
this.sendServerStateUpdate(); this.sendServerStateUpdate();
@ -188,11 +188,36 @@ export class GameServer extends CommandReceiver {
return true; return true;
} }
private releaseJoinedSockets() { private retireGhost(token?: string): CarriedScore | undefined {
for (const [socket, player] of this.joinedSockets) { if (!token) {
socket.removeAllListeners(); return undefined;
player.detachFromSocket();
} }
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.joinedSockets.clear();
this.inboundBudget.clear(); this.inboundBudget.clear();
} }
@ -255,9 +280,9 @@ export class GameServer extends CommandReceiver {
private saturatedFrames = 0; private saturatedFrames = 0;
private handlePhysics() { private handlePhysics() {
const now = process.hrtime(this.lastPhysicsBase); const frameStart = process.hrtime.bigint();
const delta = now[0] + now[1] / 1e9; const delta = Number(frameStart - this.lastPhysicsBase) / 1e9;
this.lastPhysicsBase = process.hrtime(); this.lastPhysicsBase = frameStart;
if (Date.now() - this.statReportMs > 30000) { if (Date.now() - this.statReportMs > 30000) {
this.statReportMs = Date.now(); this.statReportMs = Date.now();
@ -317,13 +342,9 @@ export class GameServer extends CommandReceiver {
this.players.stepCommunication(delta); this.players.stepCommunication(delta);
this.objects.resetRemoteCalls(); this.objects.resetRemoteCalls();
const elapsed = process.hrtime(now); const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9;
const physicsDelta = elapsed[0] + elapsed[1] / 1e9;
setTimeout( setTimeout(this.handlePhysics.bind(this), Math.max(0, fixedDelta - elapsed) * 1000);
this.handlePhysics.bind(this),
Math.max(0, fixedDelta - physicsDelta) * 1000,
);
} }
public get serverInfo(): ServerInformation { public get serverInfo(): ServerInformation {

View file

@ -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 ( if (
!this.isAlive || !this.isAlive ||
this.timeSinceLastShot < settings.projectileCreationInterval || this.timeSinceLastShot < settings.projectileCreationInterval ||
@ -295,7 +295,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
c, c,
); );
this.container.addObject(projectile); this.container.addObject(projectile);
projectile.fastForward(catchUpSeconds);
if (c > 0) { if (c > 0) {
vec2.scaleAndAdd( vec2.scaleAndAdd(

View file

@ -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) { private detectFlip(game: CommandReceiver) {
const currentTeam = this.flipTeam; const currentTeam = this.team;
if (currentTeam === this.lastTeam) { if (currentTeam === this.lastTeam) {
return; return;
} }

View file

@ -82,25 +82,6 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
wasCollision = hitSurface; wasCollision = hitSurface;
this.object.syncBoundingBox(); 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 // Live view of the circle's box, not a snapshot — the circle keeps it in sync

View file

@ -9,10 +9,10 @@ export class BoundingBoxList {
} }
public remove(object: DynamicPhysical) { public remove(object: DynamicPhysical) {
this.objects.splice( const index = this.objects.indexOf(object);
this.objects.findIndex((i) => i === object), if (index >= 0) {
1, this.objects.splice(index, 1);
); }
} }
public forEach(func: (object: DynamicPhysical) => unknown) { public forEach(func: (object: DynamicPhysical) => unknown) {

View file

@ -8,10 +8,10 @@ import {
settings, settings,
sanitizeName, sanitizeName,
boundRadius, boundRadius,
evaluateSdf,
} from 'shared'; } from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/containers/physical-container';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { evaluateSdf } from 'shared';
import { CharacterPhysical } from '../objects/character-physical'; import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical'; import { PlanetPhysical } from '../objects/planet-physical';
import { PlayerContainer } from './player-container'; import { PlayerContainer } from './player-container';

View file

@ -5,21 +5,18 @@ import { NPC } from './npc';
import { Player } from './player'; import { Player } from './player';
import { PlayerBase } from './player-base'; import { PlayerBase } from './player-base';
import { ServerFullError } from './server-full-error'; import { ServerFullError } from './server-full-error';
import { randomUUID } from 'node:crypto';
// Score held for a dropped player so a reconnect inside the grace window // Team and score carried over from the connection a reconnecting client is
// resumes the match instead of starting from zero. // replacing, so it resumes the match instead of starting from zero.
interface ReservedScore { export interface CarriedScore {
team: CharacterTeam; team: CharacterTeam;
kills: number; kills: number;
deaths: number; deaths: number;
expiresAtMs: number;
} }
export class PlayerContainer { export class PlayerContainer {
private _players: Array<Player> = []; private _players: Array<Player> = [];
private _npcs: Array<NPC> = []; private _npcs: Array<NPC> = [];
private reservedScores = new Map<string, ReservedScore>();
constructor( constructor(
private readonly objects: PhysicalContainer, 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) { if (this._players.length >= this.playerMaxCount) {
throw new ServerFullError(); throw new ServerFullError();
} }
const reserved = this.claimReservedScore(playerInfo.reconnectToken); const team = carried ? carried.team : this.getTeamOfNextPlayer();
const team = reserved ? reserved.team : this.getTeamOfNextPlayer();
const player = new Player(playerInfo, this, this.objects, team, socket); // Retire the bot before the player spawns: its body would otherwise push
// the spawn search away from the position it picked.
let npcToReplace = this._npcs.find((n) => n.team === team); const npcToReplace =
if (!npcToReplace) { this._npcs.find((n) => n.team === team) ?? this._npcs.find((n) => n.team !== team);
npcToReplace = this._npcs.find((n) => n.team !== team);
}
npcToReplace?.destroy(); npcToReplace?.destroy();
this._npcs = this._npcs.filter((n) => n !== npcToReplace); this._npcs = this._npcs.filter((n) => n !== npcToReplace);
const player = new Player(playerInfo, this, this.objects, team, socket);
this._players.push(player); this._players.push(player);
if (reserved) { if (carried) {
player.restoreScore(reserved.kills, reserved.deaths); player.restoreScore(carried.kills, carried.deaths);
} }
return player; 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<PlayerBase> { public get players(): Array<PlayerBase> {
return [...this._players, ...this._npcs]; return [...this._players, ...this._npcs];
} }

View file

@ -92,11 +92,7 @@ export class Player extends PlayerBase {
return; return;
} }
this.observeClientTime(c.clientTimeMs); this.observeClientTime(c.clientTimeMs);
this.character?.shootTowards( this.character?.shootTowards(position, finiteInRange(c.charge, 0, 1, 0));
position,
finiteInRange(c.charge, 0, 1, 0),
this.catchUpSeconds,
);
}, },
[LeapActionCommand.type]: (c: LeapActionCommand) => { [LeapActionCommand.type]: (c: LeapActionCommand) => {
if (!isFiniteNumber(c.clientTimeMs)) { 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( constructor(
playerInfo: PlayerInformation, playerInfo: PlayerInformation,
playerContainer: PlayerContainer, playerContainer: PlayerContainer,

View file

@ -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';
});

View file

@ -93,6 +93,7 @@ export class Game extends CommandReceiver {
this.gameObjects = new GameObjectContainer(this); this.gameObjects = new GameObjectContainer(this);
this.overlay.innerHTML = ''; this.overlay.innerHTML = '';
this.keystoneArrow = undefined; this.keystoneArrow = undefined;
this.connectionBanner = undefined;
this.lastMinimap = undefined; this.lastMinimap = undefined;
this.isEnding = false; this.isEnding = false;
this.lastAnnouncementText = ''; this.lastAnnouncementText = '';

View file

@ -5,6 +5,7 @@
"target": "es6", "target": "es6",
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,
"experimentalDecorators": true,
"moduleResolution": "node", "moduleResolution": "node",
"ignoreDeprecations": "6.0", "ignoreDeprecations": "6.0",
"skipLibCheck": true, "skipLibCheck": true,

View file

@ -1,23 +1,7 @@
let _seed = Math.random(); let seed = Math.random();
export function setRandomSeed(value: number) { function getRandom(): number {
_seed = value; let t = (seed += 0x6d2b79f5);
}
export function chooseRandom<T>(values: Array<T>): 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);
t = Math.imul(t ^ (t >>> 15), t | 1); t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61); t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296; return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
@ -25,12 +9,18 @@ export function getRandom(): number {
export const Random = { export const Random = {
get seed() { get seed() {
return _seed; return seed;
}, },
set seed(v: number) { set seed(value: number) {
_seed = v; seed = value;
}, },
choose: chooseRandom,
getRandomInRange,
getRandom, getRandom,
getRandomInRange(from: number, to: number): number {
return from + getRandom() * (to - from);
},
choose<T>(values: Array<T>): T | undefined {
return values.length === 0
? undefined
: values[Math.floor(getRandom() * values.length)];
},
}; };

View file

@ -34,8 +34,8 @@ export class PlanetBase extends GameObject {
this.vertices.length; this.vertices.length;
} }
// Single rule for the dead-band: scoring (server) and every tint (client) // The one capture rule: server flips and scoring, client tints and the
// read this rather than duplicating the comparison. // keystone arrow all read this.
public get team(): CharacterTeam { public get team(): CharacterTeam {
return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold
? CharacterTeam.neutral ? CharacterTeam.neutral

View file

@ -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 { return {
hitSurface: false, hitSurface: false,

View file

@ -30,12 +30,9 @@ export const settings = {
maxGravityDistance: 800, maxGravityDistance: 800,
minGravityDistance: 1, minGravityDistance: 1,
maxGravityQ: 5000, maxGravityQ: 5000,
// Neutral dead-band around 50% ownership; the rendered ring uses the same // Neutral dead-band around 50% ownership. The single capture rule: flips,
// threshold as scoring so visuals match state. // scoring and every client-side tint all read PlanetBase.team.
planetControlThreshold: 0.12, 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, playerMaxHealth: 100,
maxGravityStrength: 50000, maxGravityStrength: 50000,
planetMinReferenceRadius: 150, planetMinReferenceRadius: 150,
@ -171,11 +168,6 @@ export const settings = {
chargeShotRecoilMax: 650, 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 // DoS guard: parsing is synchronous with the physics loop, so one oversized
// message stalls the tick for everyone. // message stalls the tick for everyone.
maxInboundMessageBytes: 16 * 1024, maxInboundMessageBytes: 16 * 1024,

View file

@ -7,6 +7,7 @@
"target": "es6", "target": "es6",
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,
"experimentalDecorators": true,
"moduleResolution": "node", "moduleResolution": "node",
"ignoreDeprecations": "6.0", "ignoreDeprecations": "6.0",
"module": "commonjs", "module": "commonjs",

View file

@ -69,31 +69,7 @@ describe('projectile broadphase registration', () => {
expect(container.findIntersecting(boxAround([0, 0], 100))).not.toContain(projectile); expect(container.findIntersecting(boxAround([0, 0], 100))).not.toContain(projectile);
}); });
it('fast-forwards a shot by the sender latency', () => { it('spawns a shot just clear of the shooter, not a jump downrange', () => {
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(); const container = new PhysicalContainer();
container.initialize(); container.initialize();
const projectile = new ProjectilePhysical( const projectile = new ProjectilePhysical(
@ -106,9 +82,8 @@ describe('projectile broadphase registration', () => {
container, container,
0, 0,
); );
projectile.fastForward(10);
const cap = settings.chargeShotSpeedMax * settings.maxProjectileCatchUpSeconds; expect(projectile.center[0]).toBeLessThanOrEqual(15);
expect(projectile.center[0]).toBeLessThanOrEqual(cap + 50);
}); });
}); });

View file

@ -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', () => { describe('socket listeners', () => {
it('takes every listener back off when the player drops', () => { it('takes every listener back off when the player drops', () => {
const { attach } = startServer({ playerLimit: 4, npcCount: 0 }); const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
@ -266,7 +304,8 @@ describe('socket listeners', () => {
socket.fire('disconnect'); socket.fire('disconnect');
expect(socket.listenerCount(TransportEvents.Pong)).toBe(0); 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', () => { it('does not stack a listener per reconnect', () => {