decla-red/backend/src/players/player.ts
2026-08-22 15:29:33 +01:00

414 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { performance } from 'perf_hooks';
import { vec2 } from 'gl-matrix';
import {
CommandExecutors,
CreateObjectsCommand,
CreatePlayerCommand,
DeleteObjectsCommand,
MoveActionCommand,
serialize,
TransportEvents,
SetAspectRatioActionCommand,
calculateViewArea,
settings,
PlayerInformation,
CharacterTeam,
UpdateMinimap,
GameObject,
Command,
MinimapPlayer,
RemoteCallsForObject,
RemoteCallsForObjects,
ServerAnnouncement,
PropertyUpdatesForObjects,
PropertyUpdatesForObject,
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;
private timeSinceLastMessage = 0;
// What this client has already been told about, in the order it was told.
// A Set because the per-snapshot diff below is membership-heavy; iteration
// order is insertion order, so the streamed object order is unchanged.
private objectsInViewArea = new Set<GameObject>();
private lastInputClientTimeMs = 0;
private lastInputReceiptMs = 0;
private lastLeapClientTimeMs = 0;
// 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 = finiteInRange(
v.aspectRatio,
minimumAspectRatio,
maximumAspectRatio,
this.aspectRatio,
);
},
[MoveActionCommand.type]: (c: MoveActionCommand) => {
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,
);
},
[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,
objectContainer: PhysicalContainer,
team: CharacterTeam,
private readonly socket: Socket,
) {
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, 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() {
super.createCharacter();
this.objectsInViewArea.add(this.character!);
this.queueCommandSend(new CreatePlayerCommand(this.character!));
}
private winnerTeam?: CharacterTeam;
public onGameEnded(winnerTeam: CharacterTeam) {
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) {
this.stepLifecycle(deltaTimeInSeconds);
}
protected onCharacterDied(character: CharacterPhysical) {
this.dyingCharacter = character;
}
protected onBeforeRespawn() {
if (this.dyingCharacter) {
this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount);
this.dyingCharacter = null;
}
}
private handleViewAreaUpdate() {
const viewArea = calculateViewArea(this.center, this.aspectRatio, 1.2);
const bb = new BoundingBox();
bb.topLeft = viewArea.topLeft;
bb.size = viewArea.size;
const inViewArea = new Set(
this.objectContainer.findIntersecting(bb).map((o) => o.gameObject),
);
// The owning character must always be in its own snapshot, regardless of the
// view-area query, so the client predictor never loses its authoritative
// anchor (the body can ride a fast spinner to the very edge of the box).
if (this.character) {
inViewArea.add(this.character);
}
// Set membership rather than Array.includes: this diff runs per player per
// snapshot over everything on their screen, and was quadratic in that.
// Set membership rather than Array.includes: this diff runs per player per
// snapshot over everything on their screen, and was quadratic in that.
const newlyIntersecting = [...inViewArea].filter(
(o) => !this.objectsInViewArea.has(o),
);
const noLongerIntersecting = [...this.objectsInViewArea].filter(
(o) => !inViewArea.has(o),
);
this.objectsInViewArea = inViewArea;
if (noLongerIntersecting.length > 0) {
this.queueCommandSend(
new DeleteObjectsCommand(noLongerIntersecting.map((g) => g.id)),
);
}
if (newlyIntersecting.length > 0) {
this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting));
}
this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers()));
const propertyUpdates: Array<PropertyUpdatesForObject> = [];
for (const object of this.objectsInViewArea) {
const update = object.getPropertyUpdatesForFrame();
if (update) {
propertyUpdates.push(update);
}
}
this.queueCommandSend(
new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000),
);
// Tell the client how much of its own input is reflected in the snapshot it
// just received, so its predictor can replay the rest. Only while alive —
// a dead player isn't predicting.
if (this.character) {
this.queueCommandSend(
new InputAcknowledgement(
this.lastInputClientTimeMs,
this.character.movementSnapshot,
this.lastLeapClientTimeMs,
this.lastInputReceiptMs > 0
? Math.max(0, performance.now() - this.lastInputReceiptMs)
: 0,
),
);
}
}
// Every living player except this one, reported by absolute world position so
// the client can plot the whole circular arena on its minimap.
private getMinimapPlayers(): Array<MinimapPlayer> {
return this.playerContainer.players
.filter((p) => p !== this && p.character?.isAlive)
.map(
(p) =>
new MinimapPlayer(p.character!.id, vec2.clone(p.character!.center), p.team),
);
}
private commandsToBeSent: Array<Command> = [];
public queueCommandSend(command: Command) {
this.commandsToBeSent.push(command);
}
public stepCommunications(deltaTime: number) {
// Runs at the physics rate, and on most ticks nothing has fired — so test
// first and only build the command when there is something to send.
let remoteCalls: Array<RemoteCallsForObject> | undefined;
for (const object of this.objectsInViewArea) {
const calls = object.getRemoteCalls();
if (calls.length > 0) {
(remoteCalls ??= []).push(new RemoteCallsForObject(object.id, calls));
}
}
if (remoteCalls) {
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
}
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
this.timeSinceLastPing = 0;
this.lastPingSentMs = performance.now();
this.pendingPingNonce = this.nextPingNonce++;
this.socket.emit(TransportEvents.Ping, this.pendingPingNonce);
}
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
this.handleAnnouncements();
this.handleViewAreaUpdate();
this.sendQueuedCommandsToClient();
this.timeSinceLastMessage = 0;
}
}
// 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,
// Only meaningful together with the pose it describes, and regenerated
// in full next tick — so it is shed with it rather than advancing the
// client's replay anchor past a snapshot it never received.
InputAcknowledgement.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) {
announcement = `Team <span class="${this.winnerTeam}">${this.winnerTeam}</span> won 🎉`;
} else if (!this.character) {
announcement = `Reviving in ${Math.round(this.timeUntilRespawn)}…`;
}
if (announcement) {
this.queueCommandSend(new ServerAnnouncement(announcement));
}
}
}