Add tests
This commit is contained in:
parent
d0265ad90e
commit
b6db7e8dc7
18 changed files with 494 additions and 109 deletions
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue