Claude fixes

This commit is contained in:
Andras Schmelczer 2026-08-22 11:03:58 +01:00
commit 1295828ac8
4 changed files with 2086 additions and 205 deletions

View file

@ -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() {

View file

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

View file

@ -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) {

View file

@ -0,0 +1,5 @@
export class ServerFullError extends Error {
constructor() {
super('Too many players');
}
}