more
This commit is contained in:
parent
98c4d63756
commit
82e092ba55
17 changed files with 373 additions and 539 deletions
|
|
@ -1,31 +0,0 @@
|
|||
import { Command, CommandReceiver, GameObject } from 'shared';
|
||||
|
||||
export class StepCommand extends Command {
|
||||
constructor(
|
||||
public readonly deltaTimeInSeconds: number,
|
||||
public readonly game: CommandReceiver,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GeneratePointsCommand extends Command {
|
||||
constructor(
|
||||
public readonly blue: number,
|
||||
public readonly red: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class AnnounceCommand extends Command {
|
||||
constructor(public readonly text: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class ReactToCollisionCommand extends Command {
|
||||
constructor(public readonly other: GameObject) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { PhysicalContainer } from './physics/containers/physical-container';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import {
|
||||
TransportEvents,
|
||||
deserialize,
|
||||
|
|
@ -27,6 +28,23 @@ import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/
|
|||
|
||||
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
|
||||
|
||||
// The payload is whatever the client sent: `socket.emit('PlayerJoining')` with
|
||||
// no argument arrives as undefined. socket.io dispatches listeners inside a
|
||||
// `process.nextTick` with no try/catch of its own, so a dereference of an
|
||||
// unchecked payload here would surface as an uncaught exception and take the
|
||||
// whole match down with the process.
|
||||
const asPlayerInformation = (payload: unknown): PlayerInformation | undefined => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const { name, reconnectToken } = payload as Record<string, unknown>;
|
||||
return {
|
||||
// Coerced and length-capped downstream, in PlayerBase.createCharacter.
|
||||
name: name as string,
|
||||
reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export class GameServer extends CommandReceiver {
|
||||
private objects!: PhysicalContainer;
|
||||
private players!: PlayerContainer;
|
||||
|
|
@ -75,12 +93,27 @@ export class GameServer extends CommandReceiver {
|
|||
|
||||
this.initialize();
|
||||
|
||||
// Both listeners are metered: they exist before any join, so the limiter
|
||||
// inside onPlayerToServer cannot see them, and each one costs a reply or an
|
||||
// allocation an unauthenticated client could otherwise ask for at line rate.
|
||||
io.on('connection', (socket: Socket) => {
|
||||
socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) =>
|
||||
this.handleJoin(socket, playerInfo),
|
||||
);
|
||||
socket.on(TransportEvents.PlayerJoining, (payload: unknown) => {
|
||||
if (!this.allowInboundMessage(socket)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.handleJoin(socket, payload);
|
||||
} catch (e) {
|
||||
console.error('Failed to handle a join request', e);
|
||||
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest);
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
|
||||
if (!this.allowInboundMessage(socket)) {
|
||||
return;
|
||||
}
|
||||
socket.join(gameStateSubscribedRoom);
|
||||
});
|
||||
});
|
||||
|
|
@ -91,7 +124,13 @@ export class GameServer extends CommandReceiver {
|
|||
{ player: Player; release: () => void }
|
||||
>();
|
||||
|
||||
private handleJoin(socket: Socket, playerInfo: PlayerInformation) {
|
||||
private handleJoin(socket: Socket, payload: unknown) {
|
||||
const playerInfo = asPlayerInformation(payload);
|
||||
if (!playerInfo) {
|
||||
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.joinedSockets.has(socket)) {
|
||||
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.AlreadyJoined);
|
||||
return;
|
||||
|
|
@ -164,17 +203,22 @@ export class GameServer extends CommandReceiver {
|
|||
this.sendServerStateUpdate();
|
||||
}
|
||||
|
||||
private readonly inboundBudget = new Map<Socket, { tokens: number; lastMs: number }>();
|
||||
// A WeakMap, not a Map: sockets that never join are metered too, and they
|
||||
// have no join-scoped teardown to remove their entry.
|
||||
private inboundBudget = new WeakMap<Socket, { tokens: number; lastMs: number }>();
|
||||
private droppedInboundMessages = 0;
|
||||
private allowInboundMessage(socket: Socket): boolean {
|
||||
const nowMs = Date.now();
|
||||
// performance.now(), never Date.now(): a backwards wall-clock step (an NTP
|
||||
// correction, a VM resync) would make the refill negative and lock the
|
||||
// client out of sending input for as long as the step was.
|
||||
const nowMs = performance.now();
|
||||
const burst = settings.maxInboundMessageBurst;
|
||||
const perSecond = settings.maxInboundMessagesPerSecond;
|
||||
|
||||
const budget = this.inboundBudget.get(socket) ?? { tokens: burst, lastMs: nowMs };
|
||||
budget.tokens = Math.min(
|
||||
burst,
|
||||
budget.tokens + ((nowMs - budget.lastMs) / 1000) * perSecond,
|
||||
budget.tokens + (Math.max(0, nowMs - budget.lastMs) / 1000) * perSecond,
|
||||
);
|
||||
budget.lastMs = nowMs;
|
||||
this.inboundBudget.set(socket, budget);
|
||||
|
|
@ -219,7 +263,7 @@ export class GameServer extends CommandReceiver {
|
|||
private releaseJoinedSockets() {
|
||||
this.joinedSockets.forEach(({ release }) => release());
|
||||
this.joinedSockets.clear();
|
||||
this.inboundBudget.clear();
|
||||
this.inboundBudget = new WeakMap();
|
||||
}
|
||||
|
||||
private timeSinceLastServerStateUpdate = 0;
|
||||
|
|
@ -289,6 +333,15 @@ export class GameServer extends CommandReceiver {
|
|||
const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`;
|
||||
console.info(`Memory: ${mem}, Players: ${this.players.count}`);
|
||||
|
||||
const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0);
|
||||
if (rtts.length > 0) {
|
||||
rtts.sort((a, b) => a - b);
|
||||
console.info(
|
||||
`RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` +
|
||||
`(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.droppedInboundMessages > 0) {
|
||||
console.warn(`Rate limited ${this.droppedInboundMessages} inbound msg(s)`);
|
||||
this.droppedInboundMessages = 0;
|
||||
|
|
|
|||
54
backend/src/physics/bounding-box.ts
Normal file
54
backend/src/physics/bounding-box.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { BoundingBoxBase } from './bounding-box-base';
|
||||
|
||||
export class BoundingBox extends BoundingBoxBase {
|
||||
public get xMin(): number {
|
||||
return this._xMin;
|
||||
}
|
||||
|
||||
public set xMin(value: number) {
|
||||
this._xMin = value;
|
||||
}
|
||||
|
||||
public set xMax(value: number) {
|
||||
this._xMax = value;
|
||||
}
|
||||
|
||||
public get xMax(): number {
|
||||
return this._xMax;
|
||||
}
|
||||
|
||||
public set yMin(value: number) {
|
||||
this._yMin = value;
|
||||
}
|
||||
|
||||
public get yMin(): number {
|
||||
return this._yMin;
|
||||
}
|
||||
|
||||
public set yMax(value: number) {
|
||||
this._yMax = value;
|
||||
}
|
||||
|
||||
public get yMax(): number {
|
||||
return this._yMax;
|
||||
}
|
||||
|
||||
public get topLeft(): vec2 {
|
||||
return vec2.fromValues(this._xMin, this._yMax);
|
||||
}
|
||||
|
||||
public set topLeft(value: vec2) {
|
||||
this._xMin = value.x;
|
||||
this._yMax = value.y;
|
||||
}
|
||||
|
||||
public set size(value: vec2) {
|
||||
this._xMax = this.xMin + value.x;
|
||||
this._yMin = this.yMax - value.y;
|
||||
}
|
||||
|
||||
public get size(): vec2 {
|
||||
return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
export abstract class BoundingBoxBase {
|
||||
constructor(
|
||||
protected _xMin: number = 0,
|
||||
protected _xMax: number = 0,
|
||||
protected _yMin: number = 0,
|
||||
protected _yMax: number = 0,
|
||||
) {}
|
||||
|
||||
[key: number]: number | undefined;
|
||||
|
||||
public get 0(): number {
|
||||
return this._xMin;
|
||||
}
|
||||
|
||||
public get 1(): number {
|
||||
return this._xMax;
|
||||
}
|
||||
|
||||
public get 2(): number {
|
||||
return this._yMin;
|
||||
}
|
||||
|
||||
public get 3(): number {
|
||||
return this._yMax;
|
||||
}
|
||||
|
||||
public get xMin(): number {
|
||||
return this._xMin;
|
||||
}
|
||||
|
||||
public get xMax(): number {
|
||||
return this._xMax;
|
||||
}
|
||||
|
||||
public get yMin(): number {
|
||||
return this._yMin;
|
||||
}
|
||||
|
||||
public get yMax(): number {
|
||||
return this._yMax;
|
||||
}
|
||||
|
||||
public intersects(other: BoundingBoxBase): boolean {
|
||||
return (
|
||||
this._xMin < other._xMax &&
|
||||
this._xMax > other._xMin &&
|
||||
this._yMin < other._yMax &&
|
||||
this._yMax > other._yMin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import { BoundingBoxBase } from './bounding-box-base';
|
||||
|
||||
export class ImmutableBoundingBox extends BoundingBoxBase {}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { DynamicPhysical } from '../physicals/dynamic-physical';
|
||||
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
|
||||
|
||||
export class BoundingBoxList {
|
||||
constructor(private objects: Array<DynamicPhysical> = []) {}
|
||||
|
||||
public insert(object: DynamicPhysical) {
|
||||
this.objects.push(object);
|
||||
}
|
||||
|
||||
public remove(object: DynamicPhysical) {
|
||||
const index = this.objects.indexOf(object);
|
||||
if (index >= 0) {
|
||||
this.objects.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
public forEach(func: (object: DynamicPhysical) => unknown) {
|
||||
this.objects.forEach(func);
|
||||
}
|
||||
|
||||
public findIntersecting(boundingBox: BoundingBoxBase): Array<DynamicPhysical> {
|
||||
return this.objects.filter((b) => b.boundingBox.intersects(boundingBox));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
|
||||
import { StaticPhysical } from '../physicals/static-physical';
|
||||
|
||||
// source: https://github.com/ubilabs/kd-tree-javascript/blob/master/kdTree.js
|
||||
class Node {
|
||||
public left: Node | null = null;
|
||||
public right: Node | null = null;
|
||||
constructor(
|
||||
public object: StaticPhysical,
|
||||
public parent: Node | null,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class BoundingBoxTree {
|
||||
root?: Node | null;
|
||||
|
||||
constructor(objects: Array<StaticPhysical> = []) {
|
||||
this.build(objects);
|
||||
}
|
||||
|
||||
public build(objects: Array<StaticPhysical>) {
|
||||
this.root = this.buildRecursive(objects, 0, null);
|
||||
}
|
||||
|
||||
private buildRecursive(
|
||||
objects: Array<StaticPhysical>,
|
||||
depth: number,
|
||||
parent: Node | null,
|
||||
): Node | null {
|
||||
if (objects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (objects.length === 1) {
|
||||
return new Node(objects[0], parent);
|
||||
}
|
||||
|
||||
const dimension = depth % 4;
|
||||
|
||||
objects.sort((a, b) => a.boundingBox[dimension]! - b.boundingBox[dimension]!);
|
||||
|
||||
const median = Math.floor(objects.length / 2);
|
||||
|
||||
const node = new Node(objects[median], parent);
|
||||
node.left = this.buildRecursive(objects.slice(0, median), depth + 1, node);
|
||||
node.right = this.buildRecursive(objects.slice(median + 1), depth + 1, node);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
public insert(object: StaticPhysical) {
|
||||
const [insertPosition, depth] = this.findParent(object, this.root, 0, null);
|
||||
|
||||
if (insertPosition === null) {
|
||||
this.root = new Node(object, null);
|
||||
} else {
|
||||
const node = new Node(object, insertPosition);
|
||||
const dimension = depth % 4;
|
||||
|
||||
if (
|
||||
object.boundingBox[dimension]! < insertPosition.object.boundingBox[dimension]!
|
||||
) {
|
||||
insertPosition.left = node;
|
||||
} else {
|
||||
insertPosition.right = node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public findIntersecting(boundingBox: BoundingBoxBase): Array<StaticPhysical> {
|
||||
const maybeResults = this.findMaybeIntersecting(boundingBox, this.root!, 0);
|
||||
const results = maybeResults.filter((b) => b.boundingBox.intersects(boundingBox));
|
||||
return results;
|
||||
}
|
||||
|
||||
private findMaybeIntersecting(
|
||||
boundingBox: BoundingBoxBase,
|
||||
node: Node | null,
|
||||
depth: number,
|
||||
): Array<StaticPhysical> {
|
||||
if (node === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (depth % 4 == 0 && boundingBox.xMax < node.object.boundingBox.xMin) {
|
||||
return this.findMaybeIntersecting(boundingBox, node.left, depth + 1);
|
||||
}
|
||||
|
||||
if (depth % 4 == 1 && boundingBox.xMin > node.object.boundingBox.xMax) {
|
||||
return this.findMaybeIntersecting(boundingBox, node.right, depth + 1);
|
||||
}
|
||||
|
||||
if (depth % 4 == 2 && boundingBox.yMax < node.object.boundingBox.yMin) {
|
||||
return this.findMaybeIntersecting(boundingBox, node.left, depth + 1);
|
||||
}
|
||||
|
||||
if (depth % 4 == 3 && boundingBox.yMin > node.object.boundingBox.yMax) {
|
||||
return this.findMaybeIntersecting(boundingBox, node.right, depth + 1);
|
||||
}
|
||||
|
||||
return [
|
||||
node.object,
|
||||
...this.findMaybeIntersecting(boundingBox, node.left, depth + 1),
|
||||
...this.findMaybeIntersecting(boundingBox, node.right, depth + 1),
|
||||
];
|
||||
}
|
||||
|
||||
private findParent(
|
||||
object: StaticPhysical,
|
||||
node: Node | null | undefined,
|
||||
depth: number,
|
||||
parent: Node | null,
|
||||
): [Node | null, number] {
|
||||
if (!node) {
|
||||
return [parent, depth - 1];
|
||||
}
|
||||
|
||||
const dimension = depth % 4;
|
||||
|
||||
if (object.boundingBox[dimension]! < node.object.boundingBox[dimension]!) {
|
||||
return this.findParent(object, node.left, depth + 1, node);
|
||||
}
|
||||
|
||||
return this.findParent(object, node.right, depth + 1, node);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { PlanetPhysical } from '../../objects/planet-physical';
|
||||
import { Physical } from '../physicals/physical';
|
||||
|
||||
export const forceAtPosition = (position: vec2, objects: Array<Physical>) =>
|
||||
objects
|
||||
.filter((o) => o instanceof PlanetPhysical)
|
||||
.reduce(
|
||||
(sum: vec2, o: Physical) =>
|
||||
vec2.add(sum, sum, (o as PlanetPhysical).gravityAt(position)),
|
||||
vec2.create(),
|
||||
);
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Circle } from 'shared';
|
||||
import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box';
|
||||
|
||||
export const getBoundingBoxOfCircle = (circle: Circle): ImmutableBoundingBox =>
|
||||
new ImmutableBoundingBox(
|
||||
circle.center.x - circle.radius,
|
||||
circle.center.x + circle.radius,
|
||||
circle.center.y - circle.radius,
|
||||
circle.center.y + circle.radius,
|
||||
);
|
||||
54
backend/src/physics/physical-container.ts
Normal file
54
backend/src/physics/physical-container.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
|
||||
import { BoundingBoxList } from './bounding-box-list';
|
||||
import { BoundingBoxTree } from './bounding-box-tree';
|
||||
import { Physical } from '../physicals/physical';
|
||||
import { StaticPhysical } from '../physicals/static-physical';
|
||||
import { DynamicPhysical } from '../physicals/dynamic-physical';
|
||||
import { Command, CommandReceiver } from 'shared';
|
||||
|
||||
export class PhysicalContainer extends CommandReceiver {
|
||||
private isTreeInitialized = false;
|
||||
private staticBoundingBoxesWaitList: Array<StaticPhysical> = [];
|
||||
private staticBoundingBoxes = new BoundingBoxTree();
|
||||
private dynamicBoundingBoxes = new BoundingBoxList();
|
||||
private objects: Array<Physical> = [];
|
||||
|
||||
protected defaultCommandExecutor(c: Command) {
|
||||
this.objects.forEach((o) => o.handleCommand(c));
|
||||
}
|
||||
|
||||
public initialize() {
|
||||
this.staticBoundingBoxes.build(this.staticBoundingBoxesWaitList);
|
||||
this.isTreeInitialized = true;
|
||||
}
|
||||
|
||||
public addObject(physical: Physical) {
|
||||
this.objects.push(physical);
|
||||
|
||||
if (physical.canMove) {
|
||||
this.dynamicBoundingBoxes.insert(physical);
|
||||
} else {
|
||||
if (!this.isTreeInitialized) {
|
||||
this.staticBoundingBoxesWaitList.push(physical);
|
||||
} else {
|
||||
this.staticBoundingBoxes.insert(physical);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public removeObject(object: DynamicPhysical) {
|
||||
this.objects = this.objects.filter((p) => p !== object);
|
||||
this.dynamicBoundingBoxes.remove(object);
|
||||
}
|
||||
|
||||
public resetRemoteCalls() {
|
||||
this.objects.forEach((o) => o.gameObject.resetRemoteCalls());
|
||||
}
|
||||
|
||||
public findIntersecting(box: BoundingBoxBase): Array<Physical> {
|
||||
return [
|
||||
...this.staticBoundingBoxes.findIntersecting(box),
|
||||
...this.dynamicBoundingBoxes.findIntersecting(box),
|
||||
];
|
||||
}
|
||||
}
|
||||
4
backend/src/physics/physical.ts
Normal file
4
backend/src/physics/physical.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { DynamicPhysical } from './dynamic-physical';
|
||||
import { StaticPhysical } from './static-physical';
|
||||
|
||||
export type Physical = StaticPhysical | DynamicPhysical;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { PhysicalBase } from './physical-base';
|
||||
|
||||
export interface DynamicPhysical extends PhysicalBase {
|
||||
readonly canMove: true;
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { CommandReceiver, GameObject } from 'shared';
|
||||
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
|
||||
|
||||
export interface PhysicalBase extends CommandReceiver {
|
||||
readonly canCollide: boolean;
|
||||
readonly canMove: boolean;
|
||||
readonly boundingBox: BoundingBoxBase;
|
||||
readonly gameObject: GameObject;
|
||||
|
||||
distance(target: vec2): number;
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { PhysicalBase } from './physical-base';
|
||||
import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box';
|
||||
|
||||
export interface StaticPhysical extends PhysicalBase {
|
||||
readonly canMove: false;
|
||||
readonly boundingBox: ImmutableBoundingBox;
|
||||
}
|
||||
|
|
@ -121,6 +121,12 @@ export abstract class PlayerBase extends CommandReceiver {
|
|||
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
|
||||
let rotation = 0;
|
||||
let radius = 0;
|
||||
// preferredCenter is a planet centre, the single worst place to give up on:
|
||||
// depenetrateCircle only runs four passes, and a body left that deep inside
|
||||
// the rock registers a zero-distance hit every march and never moves again.
|
||||
// So the roomiest point the spiral saw is kept as the fallback instead.
|
||||
let roomiestPosition = vec2.clone(preferredCenter);
|
||||
let roomiestClearance = -Infinity;
|
||||
for (let attempt = 0; attempt < 512; attempt++) {
|
||||
const playerPosition = vec2.fromValues(
|
||||
radius * Math.cos(rotation) + preferredCenter.x,
|
||||
|
|
@ -132,18 +138,20 @@ export abstract class PlayerBase extends CommandReceiver {
|
|||
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
|
||||
const possibleIntersectors =
|
||||
this.objectContainer.findIntersecting(playerBoundingBox);
|
||||
if (
|
||||
evaluateSdf(playerBoundingCircle.center, possibleIntersectors) >=
|
||||
playerBoundingCircle.radius
|
||||
) {
|
||||
const clearance = evaluateSdf(playerBoundingCircle.center, possibleIntersectors);
|
||||
if (clearance >= playerBoundingCircle.radius) {
|
||||
return playerPosition;
|
||||
}
|
||||
if (clearance > roomiestClearance) {
|
||||
roomiestClearance = clearance;
|
||||
roomiestPosition = playerPosition;
|
||||
}
|
||||
|
||||
rotation += Math.PI / 8;
|
||||
radius += 30;
|
||||
}
|
||||
|
||||
return vec2.clone(preferredCenter);
|
||||
return roomiestPosition;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
|
|
|
|||
|
|
@ -56,12 +56,12 @@ export class Player extends PlayerBase {
|
|||
|
||||
public reconnectToken = '';
|
||||
|
||||
// Drives projectile lag compensation as well as the server-side latency stats.
|
||||
// Server-side latency stat, reported in GameServer's periodic log.
|
||||
public rttMs = 0;
|
||||
private timeSinceLastPing = 0;
|
||||
private lastPingSentMs = 0;
|
||||
// Nonce of the ping awaiting a reply: prevents a client forging Pongs or
|
||||
// answering a stale ping to corrupt the RTT that feeds lag compensation.
|
||||
// answering a stale ping to corrupt the measured RTT.
|
||||
private pendingPingNonce = 0;
|
||||
private nextPingNonce = 1;
|
||||
|
||||
|
|
@ -136,9 +136,10 @@ export class Player extends PlayerBase {
|
|||
this.socket.on(TransportEvents.Pong, this.onPong);
|
||||
}
|
||||
|
||||
// Only the outstanding ping's nonce counts, once. A client can sit on the
|
||||
// live ping to inflate measured RTT, so the result is clamped; the next ping
|
||||
// retires the nonce, bounding any stall at one ping interval.
|
||||
// Only the outstanding ping's nonce counts, once: a client cannot forge a
|
||||
// Pong or answer a stale ping to skew the stat. Sitting on the live ping to
|
||||
// inflate the measurement is bounded by the clamp, and the next ping retires
|
||||
// the nonce, so any stall costs at most one ping interval.
|
||||
private readonly onPong = (nonce: unknown) => {
|
||||
if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
export class ServerFullError extends Error {
|
||||
constructor() {
|
||||
super('Too many players');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue