Claude fixes
This commit is contained in:
parent
5d854440ec
commit
1295828ac8
1 changed files with 2086 additions and 205 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue