decla-red/backend/src/game-server.ts
2026-08-22 18:35:53 +01:00

338 lines
10 KiB
TypeScript

import { PhysicalContainer } from './physics/containers/physical-container';
import { Server, Socket } from 'socket.io';
import {
TransportEvents,
deserialize,
settings,
ServerInformation,
PlayerInformation,
UpdateGameState,
CharacterTeam,
GameEndCommand,
GameStartCommand,
Command,
CommandReceiver,
CommandExecutors,
ServerAnnouncement,
JoinRejectionReason,
beginPropertyUpdateGeneration,
} from 'shared';
import { createWorld } from './create-world';
import { Options } from './options';
import { PlayerContainer } from './players/player-container';
import { ServerFullError } from './players/server-full-error';
import { Player } from './players/player';
import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands';
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
export class GameServer extends CommandReceiver {
private objects!: PhysicalContainer;
private players!: PlayerContainer;
private lastPhysicsBase: [number, number] = process.hrtime();
private bluePoints = 0;
private redPoints = 0;
private matchPointAnnounced: Partial<Record<CharacterTeam, boolean>> = {};
private isInEndGame = false;
private timeScaling = 1;
private statReportMs = Date.now();
private initialize() {
const previousPlayers = this.players;
this.releaseJoinedSockets();
this.objects = new PhysicalContainer();
createWorld(this.objects);
this.objects.initialize();
this.players = new PlayerContainer(
this.objects,
this.options.playerLimit,
this.options.npcCount,
);
this.lastPhysicsBase = process.hrtime();
this.bluePoints = 0;
this.redPoints = 0;
this.matchPointAnnounced = {};
this.isInEndGame = false;
this.timeScaling = 1;
previousPlayers?.queueCommandForEachClient(new GameStartCommand());
previousPlayers?.sendQueuedCommands();
}
protected commandExecutors: CommandExecutors = {
[GeneratePointsCommand.type]: this.addPoints.bind(this),
[AnnounceCommand.type]: ({ text }: AnnounceCommand) =>
this.players.queueCommandForEachClient(new ServerAnnouncement(text)),
};
constructor(
private readonly io: Server,
private options: Options,
) {
super();
this.initialize();
io.on('connection', (socket: Socket) => {
socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) =>
this.handleJoin(socket, playerInfo),
);
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
socket.join(gameStateSubscribedRoom);
});
});
}
private readonly joinedSockets = new Map<Socket, Player>();
private handleJoin(socket: Socket, playerInfo: PlayerInformation) {
if (this.joinedSockets.has(socket)) {
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.AlreadyJoined);
return;
}
if (this.isInEndGame) {
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.RoundEnding);
return;
}
if (this.players.isFull) {
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull);
return;
}
let player: Player;
try {
player = this.players.createPlayer(playerInfo, socket);
} catch (e) {
console.error('Failed to register joining player', e);
socket.emit(
TransportEvents.JoinRejected,
e instanceof ServerFullError
? JoinRejectionReason.ServerFull
: JoinRejectionReason.InvalidRequest,
);
socket.disconnect();
return;
}
const onPlayerToServer = (json: string) => {
try {
if (json.length > settings.maxInboundMessageBytes) {
return;
}
if (!this.allowInboundMessage(socket)) {
return;
}
const commands: Array<Command> = deserialize(json);
commands.forEach((c) => player.handleCommand(c));
} catch (e) {
console.error('Error while processing command', e);
}
};
const onDisconnect = () => {
const p = this.joinedSockets.get(socket);
this.joinedSockets.delete(socket);
this.inboundBudget.delete(socket);
if (!p) {
return;
}
p.detachFromSocket();
const { kills, deaths } = player.scoreSnapshot;
this.players.reserveScore(
player.reconnectToken,
player.team,
kills,
deaths,
Date.now(),
);
player.destroy();
this.players.deletePlayer(player);
this.sendServerStateUpdate();
};
socket.on(TransportEvents.PlayerToServer, onPlayerToServer);
socket.on('disconnect', onDisconnect);
this.joinedSockets.set(socket, player);
player.reconnectToken = this.players.issueToken();
socket.emit(TransportEvents.PlayerJoined, player.reconnectToken);
this.sendServerStateUpdate();
}
private readonly inboundBudget = new Map<Socket, { tokens: number; lastMs: number }>();
private droppedInboundMessages = 0;
private allowInboundMessage(socket: Socket): boolean {
const nowMs = Date.now();
const burst = settings.maxInboundMessageBurst;
const perSecond = settings.maxInboundMessagesPerSecond;
const budget = this.inboundBudget.get(socket) ?? { tokens: burst, lastMs: nowMs };
budget.tokens = Math.min(
burst,
budget.tokens + ((nowMs - budget.lastMs) / 1000) * perSecond,
);
budget.lastMs = nowMs;
this.inboundBudget.set(socket, budget);
if (budget.tokens < 1) {
this.droppedInboundMessages++;
return false;
}
budget.tokens -= 1;
return true;
}
private releaseJoinedSockets() {
for (const [socket, player] of this.joinedSockets) {
socket.removeAllListeners();
player.detachFromSocket();
}
this.joinedSockets.clear();
this.inboundBudget.clear();
}
private timeSinceLastServerStateUpdate = 0;
public sendServerStateUpdate() {
this.io
.to(gameStateSubscribedRoom)
.emit(TransportEvents.ServerInfoUpdate, [
this.players.count,
(Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100,
]);
}
public start() {
this.handlePhysics();
}
private addPoints({ blue, red }: GeneratePointsCommand) {
if (this.isInEndGame) {
return;
}
this.bluePoints += blue;
this.redPoints += red;
if (this.bluePoints >= this.options.scoreLimit) {
this.endGame(CharacterTeam.blue);
} else if (this.redPoints >= this.options.scoreLimit) {
this.endGame(CharacterTeam.red);
} else {
this.announceMatchPointOnce(CharacterTeam.blue, this.bluePoints);
this.announceMatchPointOnce(CharacterTeam.red, this.redPoints);
}
}
private announceMatchPointOnce(team: CharacterTeam, points: number) {
if (
!this.matchPointAnnounced[team] &&
points >= this.options.scoreLimit * settings.matchPointScoreRatio
) {
this.matchPointAnnounced[team] = true;
this.players.queueCommandForEachClient(
new ServerAnnouncement(
`Match point — team <span class="${team}">${team}</span>!`,
),
);
}
}
private endGame(winningTeam: CharacterTeam) {
this.isInEndGame = true;
const endTitleLength = 6;
this.players.endGame(winningTeam);
this.players.queueCommandForEachClient(new GameEndCommand());
setTimeout(() => this.initialize(), endTitleLength * 1000 * 1.1);
}
private timeSinceLastPointUpdate = 0;
private physicsAccumulator = 0;
private saturatedFrames = 0;
private handlePhysics() {
const now = process.hrtime(this.lastPhysicsBase);
const delta = now[0] + now[1] / 1e9;
this.lastPhysicsBase = process.hrtime();
if (Date.now() - this.statReportMs > 30000) {
this.statReportMs = Date.now();
const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`;
console.info(`Memory: ${mem}, Players: ${this.players.count}`);
if (this.droppedInboundMessages > 0) {
console.warn(`Rate limited ${this.droppedInboundMessages} inbound msg(s)`);
this.droppedInboundMessages = 0;
}
if (this.saturatedFrames > 0) {
console.warn(`Physics saturated on ${this.saturatedFrames} frame(s)`);
this.saturatedFrames = 0;
}
}
if ((this.timeSinceLastServerStateUpdate += delta) > 4) {
this.timeSinceLastServerStateUpdate = 0;
this.sendServerStateUpdate();
}
if ((this.timeSinceLastPointUpdate += delta) > 0.5) {
this.timeSinceLastPointUpdate = 0;
this.players.queueCommandForEachClient(
new UpdateGameState(this.bluePoints, this.redPoints, this.options.scoreLimit),
);
}
const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
const maxSubstepsPerFrame = 5;
const maxBacklogSeconds = 0.25;
this.physicsAccumulator += delta;
let substeps = Math.floor(this.physicsAccumulator / fixedDelta);
if (substeps > maxSubstepsPerFrame) {
this.saturatedFrames++;
this.physicsAccumulator = Math.min(
this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta,
maxBacklogSeconds,
);
substeps = maxSubstepsPerFrame;
} else {
this.physicsAccumulator -= substeps * fixedDelta;
}
for (let i = 0; i < substeps; i++) {
let scaledDelta = fixedDelta;
if (this.isInEndGame) {
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta);
scaledDelta /= this.timeScaling;
}
this.objects.handleCommand(new StepCommand(scaledDelta, this));
this.players.step(scaledDelta);
}
beginPropertyUpdateGeneration();
this.players.stepCommunication(delta);
this.objects.resetRemoteCalls();
const elapsed = process.hrtime(now);
const physicsDelta = elapsed[0] + elapsed[1] / 1e9;
setTimeout(
this.handlePhysics.bind(this),
Math.max(0, fixedDelta - physicsDelta) * 1000,
);
}
public get serverInfo(): ServerInformation {
return {
serverName: this.options.name,
playerCount: this.players.count,
playerLimit: this.options.playerLimit,
gameStatePercent:
(Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100,
};
}
}