Claude fixes
This commit is contained in:
parent
5d854440ec
commit
1295828ac8
12 changed files with 2086 additions and 205 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<Command> = 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<Socket, JoinedSocket>();
|
||||
|
||||
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<Command> = 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<Socket, { tokens: number; lastMs: number }>();
|
||||
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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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<Player> = [];
|
||||
private _npcs: Array<NPC> = [];
|
||||
private reservedScores = new Map<string, ReservedScore>();
|
||||
|
||||
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<PlayerBase> {
|
||||
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<number> {
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GameObject> = [];
|
||||
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<PropertyUpdatesForObject>,
|
||||
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<string> = [
|
||||
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) {
|
||||
|
|
|
|||
5
backend/src/players/server-full-error.ts
Normal file
5
backend/src/players/server-full-error.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class ServerFullError extends Error {
|
||||
constructor() {
|
||||
super('Too many players');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue