improve game websockt (re)connection (#2584)

Previously, the connection and reconnection logic were identical in
Worker.ts, so clients would need to be re-authorized for cosmetics etc
even when reconnecting. Now, on reconnect, Worker.ts only does
authentication - verifying the jwt is valid.

This will allow clients to require a valid turnstile token when first
connecting, and not when reconnecting after a broken ws connection.

## 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:

evan
This commit is contained in:
Evan
2025-12-08 14:07:07 -08:00
committed by GitHub
parent cb4cf091ff
commit 075c232d8a
7 changed files with 158 additions and 56 deletions
+2 -1
View File
@@ -18,7 +18,8 @@ export class Client {
public readonly flares: string[] | undefined,
public readonly ip: string,
public readonly username: string,
public readonly ws: WebSocket,
public ws: WebSocket,
public readonly cosmetics: PlayerCosmetics | undefined,
public readonly isRejoin: boolean = false,
) {}
}
+17 -3
View File
@@ -1,4 +1,5 @@
import { Logger } from "winston";
import WebSocket from "ws";
import { ServerConfig } from "../core/configuration/Config";
import {
Difficulty,
@@ -7,7 +8,7 @@ import {
GameMode,
GameType,
} from "../core/game/Game";
import { GameConfig, GameID } from "../core/Schemas";
import { ClientRejoinMessage, GameConfig, GameID } from "../core/Schemas";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
@@ -25,10 +26,23 @@ export class GameManager {
return this.games.get(id) ?? null;
}
addClient(client: Client, gameID: GameID, lastTurn: number): boolean {
joinClient(client: Client, gameID: GameID): boolean {
const game = this.games.get(gameID);
if (game) {
game.addClient(client, lastTurn);
game.joinClient(client);
return true;
}
return false;
}
rejoinClient(
ws: WebSocket,
persistentID: string,
msg: ClientRejoinMessage,
): boolean {
const game = this.games.get(msg.gameID);
if (game) {
game.rejoinClient(ws, persistentID, msg);
return true;
}
return false;
+73 -32
View File
@@ -7,6 +7,7 @@ import { GameType } from "../core/game/Game";
import {
ClientID,
ClientMessageSchema,
ClientRejoinMessage,
ClientSendWinnerMessage,
GameConfig,
GameInfo,
@@ -129,7 +130,7 @@ export class GameServer {
}
}
public addClient(client: Client, lastTurn: number) {
public joinClient(client: Client) {
this.websockets.add(client.ws);
if (this.kickedClients.has(client.clientID)) {
this.log.warn(`cannot add client, already kicked`, {
@@ -137,6 +138,14 @@ export class GameServer {
});
return;
}
if (this.allClients.has(client.clientID)) {
this.log.warn("cannot add client, already in game", {
clientID: client.clientID,
});
return;
}
// Log when lobby creator joins private game
if (client.clientID === this.lobbyCreatorID) {
this.log.info("Lobby creator joined", {
@@ -144,11 +153,10 @@ export class GameServer {
creatorID: this.lobbyCreatorID,
});
}
this.log.info("client (re)joining game", {
this.log.info("client joining game", {
clientID: client.clientID,
persistentID: client.persistentID,
clientIP: ipAnonymize(client.ip),
isRejoin: lastTurn > 0,
});
if (
@@ -185,36 +193,67 @@ export class GameServer {
}
}
// Remove stale client if this is a reconnect
const existing = this.activeClients.find(
(c) => c.clientID === client.clientID,
);
if (existing !== undefined) {
if (client.persistentID !== existing.persistentID) {
this.log.error("persistent ids do not match", {
clientID: client.clientID,
clientIP: ipAnonymize(client.ip),
clientPersistentID: client.persistentID,
existingIP: ipAnonymize(existing.ip),
existingPersistentID: existing.persistentID,
});
return;
}
client.lastPing = existing.lastPing;
client.reportedWinner = existing.reportedWinner;
this.activeClients = this.activeClients.filter((c) => c !== existing);
}
// Client connection accepted
this.activeClients.push(client);
client.lastPing = Date.now();
this.markClientDisconnected(client.clientID, false);
this.allClients.set(client.clientID, client);
this.addListeners(client);
// In case a client joined the game late and missed the start message.
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, 0);
}
}
public rejoinClient(
ws: WebSocket,
persistentID: string,
msg: ClientRejoinMessage,
): void {
this.websockets.add(ws);
if (this.kickedClients.has(msg.clientID)) {
this.log.warn("cannot rejoin client, client has been kicked", {
clientID: msg.clientID,
});
return;
}
const client = this.allClients.get(msg.clientID);
if (!client) {
this.log.warn("cannot rejoin client, existing client not found", {
clientID: msg.clientID,
});
return;
}
if (client.persistentID !== persistentID) {
this.log.error("persistent ids do not match", {
clientID: msg.clientID,
clientPersistentID: persistentID,
existingIP: ipAnonymize(client.ip),
existingPersistentID: client.persistentID,
});
return;
}
this.activeClients = this.activeClients.filter(
(c) => c.clientID !== msg.clientID,
);
this.activeClients.push(client);
client.lastPing = Date.now();
this.markClientDisconnected(msg.clientID, false);
client.ws = ws;
this.addListeners(client);
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, msg.lastTurn);
}
}
private addListeners(client: Client) {
client.ws.removeAllListeners("message");
client.ws.on("message", async (message: string) => {
try {
@@ -236,6 +275,13 @@ export class GameServer {
}
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": {
if (clientMsg.intent.clientID !== client.clientID) {
this.log.warn(
@@ -333,11 +379,6 @@ export class GameServer {
client.ws.close(1002, "WS_ERR_UNEXPECTED_RSV_1");
}
});
// In case a client joined the game late and missed the start message.
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, lastTurn);
}
}
public numClients(): number {
+19 -6
View File
@@ -317,7 +317,7 @@ export async function startWorker() {
if (clientMsg.type === "ping") {
// Ignore ping
return;
} else if (clientMsg.type !== "join") {
} else if (clientMsg.type !== "join" && clientMsg.type !== "rejoin") {
log.warn(
`Invalid message before join: ${JSON.stringify(clientMsg, replacer)}`,
);
@@ -342,6 +342,23 @@ export async function startWorker() {
}
const { persistentId, claims } = result;
if (clientMsg.type === "rejoin") {
log.info("rejoining game", {
gameID: clientMsg.gameID,
clientID: clientMsg.clientID,
persistentID: persistentId,
});
const wasFound = gm.rejoinClient(ws, persistentId, clientMsg);
if (!wasFound) {
log.warn(
`game ${clientMsg.gameID} not found on worker ${workerId}`,
);
ws.close(1002, "Game not found");
}
return;
}
let roles: string[] | undefined;
let flares: string[] | undefined;
@@ -402,11 +419,7 @@ export async function startWorker() {
cosmeticResult.cosmetics,
);
const wasFound = gm.addClient(
client,
clientMsg.gameID,
clientMsg.lastTurn,
);
const wasFound = gm.joinClient(client, clientMsg.gameID);
if (!wasFound) {
log.info(`game ${clientMsg.gameID} not found on worker ${workerId}`);