Files
OpenFrontIO/src/server/GameServer.ts
Ryan aa451e217f [BUGFIX] allow users to update username pre-game (#3298)
## Description:

Fix player rename in pre-game lobby on rejoin

Previously, when a player left a lobby, changed their name, and
rejoined, the server reused the original Client object without updating
the username. Now rejoinClient accepts the new username and applies it
if the game hasn't started yet, while still preserving names mid-game
for consistency.


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

w.o.n
2026-02-28 22:22:10 +00:00

1114 lines
33 KiB
TypeScript

import ipAnonymize from "ip-anonymize";
import { Logger } from "winston";
import WebSocket from "ws";
import { z } from "zod";
import { GameEnv, ServerConfig } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import {
ClientID,
ClientMessageSchema,
ClientSendWinnerMessage,
GameConfig,
GameInfo,
GameStartInfo,
GameStartInfoSchema,
PlayerRecord,
PublicGameType,
ServerDesyncSchema,
ServerErrorMessage,
ServerLobbyInfoMessage,
ServerPrestartMessageSchema,
ServerStartGameMessage,
ServerTurnMessage,
StampedIntent,
Turn,
} from "../core/Schemas";
import { createPartialGameRecord, getClanTag } from "../core/Util";
import { archive, finalizeGameRecord } from "./Archive";
import { Client } from "./Client";
export enum GamePhase {
Lobby = "LOBBY",
Active = "ACTIVE",
Finished = "FINISHED",
}
const KICK_REASON_DUPLICATE_SESSION = "kick_reason.duplicate_session";
const KICK_REASON_LOBBY_CREATOR = "kick_reason.lobby_creator";
export class GameServer {
private sentDesyncMessageClients = new Set<ClientID>();
private maxGameDuration = 3 * 60 * 60 * 1000; // 3 hours
private disconnectedTimeout = 1 * 30 * 1000; // 30 seconds
private turns: Turn[] = [];
private intents: StampedIntent[] = [];
public activeClients: Client[] = [];
private allClients: Map<ClientID, Client> = new Map();
// Map persistentID to clientID for reconnection lookup
private persistentIdToClientId: Map<string, ClientID> = new Map();
private clientsDisconnectedStatus: Map<ClientID, boolean> = new Map();
private _hasStarted = false;
private _startTime: number | null = null;
private endTurnIntervalID: ReturnType<typeof setInterval> | undefined;
private lastPingUpdate = 0;
private winner: ClientSendWinnerMessage | null = null;
// Note: This can be undefined if accessed before the game starts.
private gameStartInfo!: GameStartInfo;
private log: Logger;
private _hasPrestarted = false;
private kickedPersistentIds: Set<string> = new Set();
private outOfSyncClients: Set<ClientID> = new Set();
private isPaused = false;
private websockets: Set<WebSocket> = new Set();
private winnerVotes: Map<
string,
{ winner: ClientSendWinnerMessage; ips: Set<string> }
> = new Map();
private _hasEnded = false;
public desyncCount = 0;
private lobbyInfoIntervalId: ReturnType<typeof setInterval> | null = null;
constructor(
public readonly id: string,
readonly log_: Logger,
public readonly createdAt: number,
private config: ServerConfig,
public gameConfig: GameConfig,
private creatorPersistentID?: string,
private startsAt?: number,
private publicGameType?: PublicGameType,
) {
this.log = log_.child({ gameID: id });
}
private get lobbyCreatorID(): ClientID | undefined {
return this.creatorPersistentID
? this.persistentIdToClientId.get(this.creatorPersistentID)
: undefined;
}
public updateGameConfig(gameConfig: Partial<GameConfig>): void {
if (gameConfig.gameMap !== undefined) {
this.gameConfig.gameMap = gameConfig.gameMap;
}
if (gameConfig.gameMapSize !== undefined) {
this.gameConfig.gameMapSize = gameConfig.gameMapSize;
}
if (gameConfig.difficulty !== undefined) {
this.gameConfig.difficulty = gameConfig.difficulty;
}
if (gameConfig.disableNations !== undefined) {
this.gameConfig.disableNations = gameConfig.disableNations;
}
if (gameConfig.bots !== undefined) {
this.gameConfig.bots = gameConfig.bots;
}
if (gameConfig.infiniteGold !== undefined) {
this.gameConfig.infiniteGold = gameConfig.infiniteGold;
}
if (gameConfig.donateGold !== undefined) {
this.gameConfig.donateGold = gameConfig.donateGold;
}
if (gameConfig.infiniteTroops !== undefined) {
this.gameConfig.infiniteTroops = gameConfig.infiniteTroops;
}
if (gameConfig.donateTroops !== undefined) {
this.gameConfig.donateTroops = gameConfig.donateTroops;
}
if (gameConfig.maxTimerValue !== undefined) {
this.gameConfig.maxTimerValue = gameConfig.maxTimerValue;
}
if (gameConfig.instantBuild !== undefined) {
this.gameConfig.instantBuild = gameConfig.instantBuild;
}
if (gameConfig.randomSpawn !== undefined) {
this.gameConfig.randomSpawn = gameConfig.randomSpawn;
}
if (gameConfig.spawnImmunityDuration !== undefined) {
this.gameConfig.spawnImmunityDuration = gameConfig.spawnImmunityDuration;
}
if (gameConfig.gameMode !== undefined) {
this.gameConfig.gameMode = gameConfig.gameMode;
}
if (gameConfig.disabledUnits !== undefined) {
this.gameConfig.disabledUnits = gameConfig.disabledUnits;
}
if (gameConfig.playerTeams !== undefined) {
this.gameConfig.playerTeams = gameConfig.playerTeams;
}
if (gameConfig.goldMultiplier !== undefined) {
this.gameConfig.goldMultiplier = gameConfig.goldMultiplier;
}
if (gameConfig.startingGold !== undefined) {
this.gameConfig.startingGold = gameConfig.startingGold;
}
}
private isKicked(clientID: ClientID): boolean {
const persistentID = this.allClients.get(clientID)?.persistentID;
return (
persistentID !== undefined && this.kickedPersistentIds.has(persistentID)
);
}
// Get existing clientID for this persistentID, or null if new player
public getClientIdForPersistentId(persistentID: string): ClientID | null {
const clientID = this.persistentIdToClientId.get(persistentID);
if (!clientID) return null;
if (this.kickedPersistentIds.has(persistentID)) return null;
return clientID;
}
public joinClient(client: Client): "joined" | "kicked" | "rejected" {
if (this.kickedPersistentIds.has(client.persistentID)) {
return "kicked";
}
if (
this.gameConfig.maxPlayers &&
this.activeClients.length >= this.gameConfig.maxPlayers
) {
this.log.warn(`cannot add client, game full`, {
clientID: client.clientID,
});
client.ws.send(
JSON.stringify({
type: "error",
error: "full-lobby",
} satisfies ServerErrorMessage),
);
return "rejected";
}
this.log.info("client joining game", {
clientID: client.clientID,
persistentID: client.persistentID,
clientIP: ipAnonymize(client.ip),
});
if (
this.gameConfig.gameType === GameType.Public &&
this.activeClients.filter(
(c) => c.ip === client.ip && c.clientID !== client.clientID,
).length >= 3
) {
this.log.warn("cannot add client, already have 3 ips", {
clientID: client.clientID,
clientIP: ipAnonymize(client.ip),
});
return "rejected";
}
if (this.config.env() === GameEnv.Prod) {
// Prevent multiple clients from using the same account in prod
const conflicting = this.activeClients.find(
(c) =>
c.persistentID === client.persistentID &&
c.clientID !== client.clientID,
);
if (conflicting !== undefined) {
this.log.warn("client ids do not match", {
clientID: client.clientID,
clientIP: ipAnonymize(client.ip),
clientPersistentID: client.persistentID,
existingIP: ipAnonymize(conflicting.ip),
existingPersistentID: conflicting.persistentID,
});
// Kick the existing client instead of the new one, because this was causing issues when
// a client wanted to replay the game afterwards.
this.kickClient(conflicting.clientID, KICK_REASON_DUPLICATE_SESSION);
}
}
// Client connection accepted
this.websockets.add(client.ws);
this.persistentIdToClientId.set(client.persistentID, client.clientID);
this.activeClients.push(client);
client.lastPing = Date.now();
this.markClientDisconnected(client.clientID, false);
this.allClients.set(client.clientID, client);
this.addListeners(client);
this.startLobbyInfoBroadcast();
// In case a client joined the game late and missed the start message.
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, 0);
}
return "joined";
}
// Attempt to reconnect a client by persistentID. Returns true if successful.
// Only the WebSocket is updated — username, cosmetics, etc. are preserved
// from the original join to maintain consistency throughout the game session.
// Exception: in the pre-game lobby, the username is updated so players can
// rename between leaving and rejoining.
public rejoinClient(
ws: WebSocket,
persistentID: string,
lastTurn: number = 0,
newUsername?: string,
): boolean {
const clientID = this.getClientIdForPersistentId(persistentID);
if (!clientID) return false;
const client = this.allClients.get(clientID);
if (!client) return false;
this.websockets.add(ws);
this.log.info("client rejoining", { clientID, lastTurn });
// Close old WebSocket to prevent resource leaks
if (client.ws !== ws) {
client.ws.removeAllListeners();
client.ws.close();
}
this.activeClients = this.activeClients.filter(
(c) => c.clientID !== client.clientID,
);
this.activeClients.push(client);
client.lastPing = Date.now();
this.markClientDisconnected(client.clientID, false);
// Allow username updates in the pre-game lobby
if (!this._hasStarted && newUsername !== undefined) {
client.username = newUsername;
}
client.ws = ws;
this.addListeners(client);
this.startLobbyInfoBroadcast();
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, lastTurn);
}
return true;
}
private addListeners(client: Client) {
client.ws.removeAllListeners("message");
client.ws.on("message", async (message: string) => {
try {
const parsed = ClientMessageSchema.safeParse(JSON.parse(message));
if (!parsed.success) {
const error = z.prettifyError(parsed.error);
this.log.warn(`Failed to parse client message ${error}`, {
clientID: client.clientID,
});
client.ws.send(
JSON.stringify({
type: "error",
error,
message: `Server could not parse message from client: ${message}`,
} satisfies ServerErrorMessage),
);
return;
}
const clientMsg = parsed.data;
switch (clientMsg.type) {
case "rejoin": {
// Client is already connected, no auth required, send start game message if game has started
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, clientMsg.lastTurn);
}
break;
}
case "intent": {
// Server stamps clientID from the authenticated connection
const stampedIntent = {
...clientMsg.intent,
clientID: client.clientID,
};
switch (stampedIntent.type) {
case "mark_disconnected": {
this.log.warn(
`Should not receive mark_disconnected intent from client`,
);
return;
}
// Handle kick_player intent via WebSocket
case "kick_player": {
// Check if the authenticated client is the lobby creator
if (client.clientID !== this.lobbyCreatorID) {
this.log.warn(`Only lobby creator can kick players`, {
clientID: client.clientID,
creatorID: this.lobbyCreatorID,
target: stampedIntent.target,
gameID: this.id,
});
return;
}
// Don't allow lobby creator to kick themselves
if (client.clientID === stampedIntent.target) {
this.log.warn(`Cannot kick yourself`, {
clientID: client.clientID,
});
return;
}
// Log and execute the kick
this.log.info(`Lobby creator initiated kick of player`, {
creatorID: client.clientID,
target: stampedIntent.target,
gameID: this.id,
kickMethod: "websocket",
});
this.kickClient(
stampedIntent.target,
KICK_REASON_LOBBY_CREATOR,
);
return;
}
case "update_game_config": {
// Only lobby creator can update config
if (client.clientID !== this.lobbyCreatorID) {
this.log.warn(`Only lobby creator can update game config`, {
clientID: client.clientID,
creatorID: this.lobbyCreatorID,
gameID: this.id,
});
return;
}
if (this.isPublic()) {
this.log.warn(`Cannot update public game via WebSocket`, {
gameID: this.id,
clientID: client.clientID,
});
return;
}
if (this.hasStarted()) {
this.log.warn(
`Cannot update game config after it has started`,
{
gameID: this.id,
clientID: client.clientID,
},
);
return;
}
if (stampedIntent.config.gameType === GameType.Public) {
this.log.warn(`Cannot update game to public via WebSocket`, {
gameID: this.id,
clientID: client.clientID,
});
return;
}
this.log.info(
`Lobby creator updated game config via WebSocket`,
{
creatorID: client.clientID,
gameID: this.id,
},
);
this.updateGameConfig(stampedIntent.config);
return;
}
case "toggle_pause": {
// Only lobby creator can pause/resume
if (client.clientID !== this.lobbyCreatorID) {
this.log.warn(`Only lobby creator can toggle pause`, {
clientID: client.clientID,
creatorID: this.lobbyCreatorID,
gameID: this.id,
});
return;
}
if (stampedIntent.paused) {
// Pausing: send intent and complete current turn before pause takes effect
this.addIntent(stampedIntent);
this.endTurn();
this.isPaused = true;
} else {
// Unpausing: clear pause flag before sending intent so next turn can execute
this.isPaused = false;
this.addIntent(stampedIntent);
this.endTurn();
}
this.log.info(`Game ${this.isPaused ? "paused" : "resumed"}`, {
clientID: client.clientID,
gameID: this.id,
});
break;
}
default: {
// Don't process intents while game is paused
if (!this.isPaused) {
this.addIntent(stampedIntent);
}
break;
}
}
break;
}
case "ping": {
this.lastPingUpdate = Date.now();
client.lastPing = Date.now();
break;
}
case "hash": {
client.hashes.set(clientMsg.turnNumber, clientMsg.hash);
break;
}
case "winner": {
this.handleWinner(client, clientMsg);
break;
}
default: {
this.log.warn(`Unknown message type: ${(clientMsg as any).type}`, {
clientID: client.clientID,
});
break;
}
}
} catch (error) {
this.log.info(
`error handline websocket request in game server: ${error}`,
{
clientID: client.clientID,
},
);
}
});
client.ws.on("close", () => {
this.log.info("client disconnected", {
clientID: client.clientID,
persistentID: client.persistentID,
});
this.activeClients = this.activeClients.filter(
(c) => c.clientID !== client.clientID,
);
});
client.ws.on("error", (error: Error) => {
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
client.ws.close(1002, "WS_ERR_UNEXPECTED_RSV_1");
}
});
// Check if WebSocket already closed before we added the listener (race condition)
if (client.ws.readyState >= 2) {
this.log.info("client WebSocket already closing/closed, removing", {
clientID: client.clientID,
readyState: client.ws.readyState,
});
this.activeClients = this.activeClients.filter(
(c) => c.clientID !== client.clientID,
);
}
}
public setStartsAt(startsAt: number) {
this.startsAt = startsAt;
}
public numClients(): number {
return this.activeClients.length;
}
public prestart() {
if (this.hasStarted()) {
return;
}
this._hasPrestarted = true;
const prestartMsg = ServerPrestartMessageSchema.safeParse({
type: "prestart",
gameMap: this.gameConfig.gameMap,
gameMapSize: this.gameConfig.gameMapSize,
});
if (!prestartMsg.success) {
console.error(
`error creating prestart message for game ${this.id}, ${prestartMsg.error}`.substring(
0,
250,
),
);
return;
}
const msg = JSON.stringify(prestartMsg.data);
this.activeClients.forEach((c) => {
this.log.info("sending prestart message", {
clientID: c.clientID,
persistentID: c.persistentID,
});
c.ws.send(msg);
});
}
private startLobbyInfoBroadcast() {
if (this._hasStarted || this._hasEnded) {
return;
}
if (this.lobbyInfoIntervalId !== null) {
return;
}
this.broadcastLobbyInfo();
this.lobbyInfoIntervalId = setInterval(() => {
if (
this._hasStarted ||
this._hasEnded ||
this.activeClients.length === 0
) {
this.stopLobbyInfoBroadcast();
return;
}
this.broadcastLobbyInfo();
}, 1000);
}
private stopLobbyInfoBroadcast() {
if (this.lobbyInfoIntervalId === null) {
return;
}
clearInterval(this.lobbyInfoIntervalId);
this.lobbyInfoIntervalId = null;
}
private broadcastLobbyInfo() {
const lobbyInfo = this.gameInfo();
this.activeClients.forEach((c) => {
if (c.ws.readyState === WebSocket.OPEN) {
const msg = JSON.stringify({
type: "lobby_info",
lobby: lobbyInfo,
myClientID: c.clientID,
} satisfies ServerLobbyInfoMessage);
c.ws.send(msg);
}
});
}
public start() {
if (this._hasStarted || this._hasEnded) {
return;
}
this._hasStarted = true;
this._startTime = Date.now();
// Set last ping to start so we don't immediately stop the game
// if no client connects/pings.
this.lastPingUpdate = Date.now();
const result = GameStartInfoSchema.safeParse({
gameID: this.id,
lobbyCreatedAt: this.createdAt,
config: this.gameConfig,
players: this.activeClients.map((c) => ({
username: c.username,
clientID: c.clientID,
cosmetics: c.cosmetics,
isLobbyCreator: this.lobbyCreatorID === c.clientID,
})),
});
if (!result.success) {
const error = z.prettifyError(result.error);
this.log.error("Error parsing game start info", { message: error });
return;
}
this.gameStartInfo = result.data satisfies GameStartInfo;
this.endTurnIntervalID = setInterval(
() => this.endTurn(),
this.config.turnIntervalMs(),
);
this.activeClients.forEach((c) => {
this.log.info("sending start message", {
clientID: c.clientID,
persistentID: c.persistentID,
});
this.sendStartGameMsg(c.ws, 0);
});
}
private addIntent(intent: StampedIntent) {
this.intents.push(intent);
}
private sendStartGameMsg(ws: WebSocket, lastTurn: number) {
// Find which client this websocket belongs to
const client = this.activeClients.find((c) => c.ws === ws);
if (!client) {
this.log.warn("Could not find client for websocket in sendStartGameMsg");
return;
}
this.log.info(`Sending start message to client`, {
clientID: client.clientID,
lobbyCreatorID: this.lobbyCreatorID,
isLobbyCreator: this.lobbyCreatorID === client.clientID,
});
try {
ws.send(
JSON.stringify({
type: "start",
turns: this.turns.slice(lastTurn),
gameStartInfo: this.gameStartInfo,
lobbyCreatedAt: this.createdAt,
myClientID: client.clientID,
} satisfies ServerStartGameMessage),
);
} catch (error) {
throw new Error(
`error sending start message for game ${this.id}, ${error}`.substring(
0,
250,
),
);
}
}
private endTurn() {
// Skip turn execution if game is paused
if (this.isPaused) {
return;
}
const pastTurn: Turn = {
turnNumber: this.turns.length,
intents: this.intents,
};
this.turns.push(pastTurn);
this.intents = [];
this.handleSynchronization();
this.checkDisconnectedStatus();
const msg = JSON.stringify({
type: "turn",
turn: pastTurn,
} satisfies ServerTurnMessage);
this.activeClients.forEach((c) => {
c.ws.send(msg);
});
}
async end() {
this._hasEnded = true;
// Close all WebSocket connections
if (this.endTurnIntervalID) {
clearInterval(this.endTurnIntervalID);
this.endTurnIntervalID = undefined;
}
this.websockets.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.close(1000, "game has ended");
}
});
if (!this._hasPrestarted && !this._hasStarted) {
this.log.info(`game not started, not archiving game`);
return;
}
this.log.info(`ending game with ${this.turns.length} turns`);
try {
if (this.allClients.size === 0) {
this.log.info("no clients joined, not archiving game", {
gameID: this.id,
});
} else if (this.winner !== null) {
this.log.info("game already archived", {
gameID: this.id,
});
} else {
this.archiveGame();
}
} catch (error) {
let errorDetails;
if (error instanceof Error) {
errorDetails = {
message: error.message,
stack: error.stack,
};
} else if (Array.isArray(error)) {
errorDetails = error; // Now we'll actually see the array contents
} else {
try {
errorDetails = JSON.stringify(error, null, 2);
} catch (e) {
errorDetails = String(error);
}
}
this.log.error("Error archiving game record details:", {
gameId: this.id,
errorType: typeof error,
error: errorDetails,
});
}
}
phase(): GamePhase {
const now = Date.now();
const alive: Client[] = [];
for (const client of this.activeClients) {
if (now - client.lastPing > 60_000) {
this.log.info("no pings received, terminating connection", {
clientID: client.clientID,
persistentID: client.persistentID,
});
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close(1000, "no heartbeats received, closing connection");
}
} else {
alive.push(client);
}
}
this.activeClients = alive;
if (now > this.createdAt + this.maxGameDuration) {
this.log.warn("game past max duration", {
gameID: this.id,
});
return GamePhase.Finished;
}
const noRecentPings = now > this.lastPingUpdate + 20 * 1000;
const noActive = this.activeClients.length === 0;
if (this.gameConfig.gameType !== GameType.Public) {
if (this._hasStarted) {
if (noActive && noRecentPings) {
this.log.info("private game complete", {
gameID: this.id,
});
return GamePhase.Finished;
} else {
return GamePhase.Active;
}
} else {
return GamePhase.Lobby;
}
}
// Public Games
const lessThanLifetime = this.startsAt ? Date.now() < this.startsAt : true;
const notEnoughPlayers =
this.gameConfig.gameType === GameType.Public &&
this.gameConfig.maxPlayers &&
this.activeClients.length < this.gameConfig.maxPlayers;
if (lessThanLifetime && notEnoughPlayers) {
return GamePhase.Lobby;
}
const warmupOver = now > this.startsAt! + 30 * 1000;
if (noActive && warmupOver && noRecentPings) {
return GamePhase.Finished;
}
return GamePhase.Active;
}
hasStarted(): boolean {
return this._hasStarted || this._hasPrestarted;
}
public gameInfo(): GameInfo {
return {
gameID: this.id,
clients: this.activeClients.map((c) => ({
username: c.username,
clientID: c.clientID,
})),
lobbyCreatorClientID: this.lobbyCreatorID,
gameConfig: this.gameConfig,
startsAt: this.startsAt,
serverTime: Date.now(),
publicGameType: this.publicGameType,
};
}
public isPublic(): boolean {
return this.gameConfig.gameType === GameType.Public;
}
public kickClient(
clientID: ClientID,
reasonKey: string = KICK_REASON_DUPLICATE_SESSION,
): void {
if (this.isKicked(clientID)) {
this.log.warn(`cannot kick client, already kicked`, {
clientID,
reasonKey,
});
return;
}
const clientToKick = this.allClients.get(clientID);
if (!clientToKick) {
this.log.warn(`cannot kick client, not found in game`, {
clientID,
reasonKey,
});
return;
}
this.kickedPersistentIds.add(clientToKick.persistentID);
const client = this.activeClients.find((c) => c.clientID === clientID);
if (client) {
this.log.info("Kicking client from game", {
clientID: client.clientID,
persistentID: client.persistentID,
reasonKey,
});
client.ws.send(
JSON.stringify({
type: "error",
error: reasonKey,
} satisfies ServerErrorMessage),
);
client.ws.close(1000, reasonKey);
this.activeClients = this.activeClients.filter(
(c) => c.clientID !== clientID,
);
} else {
this.log.warn(`cannot kick client, not found in game`, {
clientID,
reasonKey,
});
}
}
private checkDisconnectedStatus() {
if (this.turns.length % 5 !== 0) {
return;
}
const now = Date.now();
for (const [clientID, client] of this.allClients) {
const isDisconnected = this.isClientDisconnected(clientID);
if (!isDisconnected && now - client.lastPing > this.disconnectedTimeout) {
this.markClientDisconnected(clientID, true);
} else if (
isDisconnected &&
now - client.lastPing < this.disconnectedTimeout
) {
this.markClientDisconnected(clientID, false);
}
}
}
public isClientDisconnected(clientID: string): boolean {
return this.clientsDisconnectedStatus.get(clientID) ?? true;
}
private markClientDisconnected(clientID: string, isDisconnected: boolean) {
this.clientsDisconnectedStatus.set(clientID, isDisconnected);
this.addIntent({
type: "mark_disconnected",
clientID: clientID,
isDisconnected: isDisconnected,
});
}
private archiveGame() {
this.log.info("archiving game", {
gameID: this.id,
winner: this.winner?.winner,
});
// Players must stay in the same order as the game start info.
const playerRecords: PlayerRecord[] = this.gameStartInfo.players.map(
(player) => {
const stats = this.winner?.allPlayersStats[player.clientID];
if (stats === undefined) {
this.log.warn(`Unable to find stats for clientID ${player.clientID}`);
}
return {
clientID: player.clientID,
username: player.username,
persistentID:
this.allClients.get(player.clientID)?.persistentID ?? "",
stats,
cosmetics: player.cosmetics,
clanTag: getClanTag(player.username) ?? undefined,
} satisfies PlayerRecord;
},
);
archive(
finalizeGameRecord(
createPartialGameRecord(
this.id,
this.gameStartInfo.config,
playerRecords,
this.turns,
this._startTime ?? 0,
Date.now(),
this.winner?.winner,
this.createdAt,
),
),
);
}
private handleSynchronization() {
if (this.activeClients.length <= 1) {
return;
}
if (this.turns.length % 10 !== 0 || this.turns.length < 10) {
// Check hashes every 10 turns
return;
}
const lastHashTurn = this.turns.length - 10;
const { mostCommonHash, outOfSyncClients } =
this.findOutOfSyncClients(lastHashTurn);
this.desyncCount += outOfSyncClients.length;
if (outOfSyncClients.length === 0) {
this.turns[lastHashTurn].hash = mostCommonHash;
return;
}
const serverDesync = ServerDesyncSchema.safeParse({
type: "desync",
turn: lastHashTurn,
correctHash: mostCommonHash,
clientsWithCorrectHash:
this.activeClients.length - outOfSyncClients.length,
totalActiveClients: this.activeClients.length,
});
if (!serverDesync.success) {
this.log.warn("failed to create desync message", {
gameID: this.id,
error: serverDesync.error,
});
return;
}
const desyncMsg = JSON.stringify(serverDesync.data);
for (const c of outOfSyncClients) {
this.outOfSyncClients.add(c.clientID);
if (this.sentDesyncMessageClients.has(c.clientID)) {
continue;
}
this.sentDesyncMessageClients.add(c.clientID);
this.log.info("sending desync to client", {
gameID: this.id,
clientID: c.clientID,
persistentID: c.persistentID,
});
c.ws.send(desyncMsg);
}
}
findOutOfSyncClients(turnNumber: number): {
mostCommonHash: number | null;
outOfSyncClients: Client[];
} {
const counts = new Map<number, number>();
// Count occurrences of each hash
for (const client of this.activeClients) {
if (client.hashes.has(turnNumber)) {
const clientHash = client.hashes.get(turnNumber)!;
counts.set(clientHash, (counts.get(clientHash) ?? 0) + 1);
}
}
// Find the most common hash
let mostCommonHash: number | null = null;
let maxCount = 0;
for (const [hash, count] of counts.entries()) {
if (count > maxCount) {
mostCommonHash = hash;
maxCount = count;
}
}
// Create a list of clients whose hash doesn't match the most common one
let outOfSyncClients: Client[] = [];
for (const client of this.activeClients) {
if (client.hashes.has(turnNumber)) {
const clientHash = client.hashes.get(turnNumber)!;
if (clientHash !== mostCommonHash) {
outOfSyncClients.push(client);
}
}
}
// If half clients out of sync assume all are out of sync.
if (outOfSyncClients.length >= Math.floor(this.activeClients.length / 2)) {
outOfSyncClients = this.activeClients;
}
return {
mostCommonHash,
outOfSyncClients,
};
}
private handleWinner(client: Client, clientMsg: ClientSendWinnerMessage) {
if (
this.outOfSyncClients.has(client.clientID) ||
this.isKicked(client.clientID) ||
this.winner !== null ||
client.reportedWinner !== null
) {
return;
}
client.reportedWinner = clientMsg.winner;
// Add client vote
const winnerKey = JSON.stringify(clientMsg.winner);
if (!this.winnerVotes.has(winnerKey)) {
this.winnerVotes.set(winnerKey, { ips: new Set(), winner: clientMsg });
}
const potentialWinner = this.winnerVotes.get(winnerKey)!;
potentialWinner.ips.add(client.ip);
const activeUniqueIPs = new Set(this.activeClients.map((c) => c.ip));
const ratio = `${potentialWinner.ips.size}/${activeUniqueIPs.size}`;
this.log.info(
`received winner vote ${clientMsg.winner}, ${ratio} votes for this winner`,
{
clientID: client.clientID,
},
);
if (potentialWinner.ips.size * 2 < activeUniqueIPs.size) {
return;
}
// Vote succeeded
this.winner = potentialWinner.winner;
this.log.info(
`Winner determined by ${potentialWinner.ips.size}/${activeUniqueIPs.size} active IPs`,
{
winnerKey: winnerKey,
},
);
this.archiveGame();
}
}