Add tests

This commit is contained in:
Andras Schmelczer 2026-06-16 07:54:06 +01:00
parent d0265ad90e
commit b6db7e8dc7
18 changed files with 494 additions and 109 deletions

View file

@ -99,7 +99,8 @@ export class GameServer extends CommandReceiver {
this.players.deletePlayer(player);
this.sendServerStateUpdate();
});
} catch {
} catch (e) {
console.error('Failed to register joining player; disconnecting socket', e);
socket.disconnect();
}
});
@ -168,6 +169,9 @@ export class GameServer extends CommandReceiver {
private timeSinceLastPointUpdate = 0;
private physicsAccumulator = 0;
// Frames since the last stats report where physics ran over budget (more
// substeps than the cap). Surfaced by handleStats as a saturation signal.
private saturatedFrames = 0;
private handlePhysics() {
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true });
@ -189,12 +193,24 @@ export class GameServer extends CommandReceiver {
const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
const maxSubstepsPerFrame = 5;
// Cap on retained physics backlog when saturated, so a long stall can't
// accumulate an unrecoverable catch-up.
const maxBacklogSeconds = 0.25;
this.physicsAccumulator += delta;
let substeps = Math.floor(this.physicsAccumulator / fixedDelta);
if (substeps > maxSubstepsPerFrame) {
// Saturated: run the cap's worth of substeps but KEEP the remaining
// backlog (clamped) instead of zeroing it. Dropping it silently slowed
// simulated time for everyone — and diverged client prediction, whose
// wall-clock keeps running. Clamping bounds the catch-up so a transient
// spike recovers without a death spiral.
this.saturatedFrames++;
this.physicsAccumulator = Math.min(
this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta,
maxBacklogSeconds,
);
substeps = maxSubstepsPerFrame;
this.physicsAccumulator = 0;
} else {
this.physicsAccumulator -= substeps * fixedDelta;
}
@ -237,6 +253,23 @@ export class GameServer extends CommandReceiver {
console.info(
`Memory used: ${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`,
);
const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0);
if (rtts.length > 0) {
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})`,
);
}
if (this.saturatedFrames > 0) {
console.warn(
`Physics saturated on ${this.saturatedFrames} frame(s) since last report — shedding backlog`,
);
this.saturatedFrames = 0;
}
this.deltaTimes = [];
}
}

View file

@ -20,6 +20,12 @@ import {
CharacterMovementState,
CharacterWorld,
GroundSurface,
headRadius,
feetRadius,
headOffset,
leftFootOffset,
rightFootOffset,
boundRadius,
} from 'shared';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { CirclePhysical } from './circle-physical';
@ -38,48 +44,14 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
public readonly canCollide = true;
public readonly canMove = true;
private static readonly headRadius = 50;
private static readonly feetRadius = 20;
private projectileStrength = settings.playerMaxStrength;
// offsets are measured from (0, 0). The head sits this far above the feet,
// which sets the leg length: kept short so the body reads as a compact head on
// stubby legs rather than a long-legged strider.
private static readonly desiredHeadOffset = vec2.fromValues(0, 55);
private static readonly desiredLeftFootOffset = vec2.fromValues(-20, 0);
private static readonly desiredRightFootOffset = vec2.fromValues(20, 0);
private static readonly centerOfMass = vec2.scale(
vec2.create(),
vec2.add(
vec2.create(),
vec2.add(
vec2.create(),
CharacterPhysical.desiredHeadOffset,
CharacterPhysical.desiredLeftFootOffset,
),
CharacterPhysical.desiredRightFootOffset,
),
1 / 3,
);
private static readonly headOffset = vec2.subtract(
vec2.create(),
CharacterPhysical.desiredHeadOffset,
CharacterPhysical.centerOfMass,
);
private static readonly leftFootOffset = vec2.subtract(
vec2.create(),
CharacterPhysical.desiredLeftFootOffset,
CharacterPhysical.centerOfMass,
);
private static readonly rightFootOffset = vec2.subtract(
vec2.create(),
CharacterPhysical.desiredRightFootOffset,
CharacterPhysical.centerOfMass,
);
public static readonly boundRadius =
(CharacterPhysical.headRadius + CharacterPhysical.feetRadius * 2) * 2;
// Body geometry (head/foot radii, posture offsets, bound radius) is defined
// once in the shared movement module and imported here, so the authoritative
// body and the client's predicted body are bit-identical by construction
// instead of by a hand-synced "copied verbatim" duplicate. Re-exposed as a
// static only because external callers reference CharacterPhysical.boundRadius.
public static readonly boundRadius = boundRadius;
private timeSinceDying = 0;
private isDestroyed = false;
@ -129,20 +101,20 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
) {
super(id(), name, killCount, deathCount, team, settings.playerMaxHealth);
this.head = new CirclePhysical(
vec2.add(vec2.create(), startPosition, CharacterPhysical.headOffset),
CharacterPhysical.headRadius,
vec2.add(vec2.create(), startPosition, headOffset),
headRadius,
this,
container,
);
this.leftFoot = new CirclePhysical(
vec2.add(vec2.create(), startPosition, CharacterPhysical.leftFootOffset),
CharacterPhysical.feetRadius,
vec2.add(vec2.create(), startPosition, leftFootOffset),
feetRadius,
this,
container,
);
this.rightFoot = new CirclePhysical(
vec2.add(vec2.create(), startPosition, CharacterPhysical.rightFootOffset),
CharacterPhysical.feetRadius,
vec2.add(vec2.create(), startPosition, rightFootOffset),
feetRadius,
this,
container,
);
@ -411,8 +383,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
}
private animateScaling(q: number) {
this.head.radius = CharacterPhysical.headRadius * q;
this.leftFoot.radius = this.rightFoot.radius = CharacterPhysical.feetRadius * q;
this.head.radius = headRadius * q;
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
}
public getPropertyUpdates(): PropertyUpdatesForObject {

View file

@ -57,6 +57,12 @@ export class PlayerContainer {
return this._players.length;
}
// 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> {
return this._players.map((p) => p.rttMs);
}
public step(deltaTimeInSeconds: number) {
this.players.forEach((p) => p.step(deltaTimeInSeconds));
}

View file

@ -32,6 +32,9 @@ import { PhysicalContainer } from '../physics/containers/physical-container';
import { PlayerContainer } from './player-container';
import { PlayerBase } from './player-base';
// How often the server pings each client to measure round-trip time.
const pingIntervalSeconds = 1;
export class Player extends PlayerBase {
// default, until the clients sends its real value
private aspectRatio: number = 16 / 9;
@ -41,6 +44,12 @@ export class Player extends PlayerBase {
private lastInputClientTimeMs = 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.
public rttMs = 0;
private timeSinceLastPing = 0;
private lastPingSentMs = 0;
protected commandExecutors: CommandExecutors = {
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) =>
(this.aspectRatio = v.aspectRatio),
@ -71,6 +80,15 @@ export class Player extends PlayerBase {
super(playerInfo, playerContainer, objectContainer, team);
this.createCharacter();
this.step(0);
// 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;
}
});
}
protected createCharacter() {
@ -191,6 +209,12 @@ export class Player extends PlayerBase {
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
}
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
this.timeSinceLastPing = 0;
this.lastPingSentMs = performance.now();
this.socket.emit(TransportEvents.Ping);
}
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
this.handleAnnouncements();
this.handleViewAreaUpdate();