Claude fixes

This commit is contained in:
Andras Schmelczer 2026-08-22 11:03:58 +01:00
commit 1295828ac8

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