Fix client reconnection after page refresh (#3117)

## Description:

- Removed all code related to generating a client ID on the client. The
server now assigns the client ID and sends it to the client in lobby
messages.

## 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
This commit is contained in:
Ryan
2026-02-09 01:10:11 +00:00
committed by GitHub
parent e7676b4260
commit 8dcc7cfb9a
15 changed files with 321 additions and 276 deletions
+95 -81
View File
@@ -7,13 +7,11 @@ import { GameType } from "../core/game/Game";
import {
ClientID,
ClientMessageSchema,
ClientRejoinMessage,
ClientSendWinnerMessage,
GameConfig,
GameInfo,
GameStartInfo,
GameStartInfoSchema,
Intent,
PlayerRecord,
ServerDesyncSchema,
ServerErrorMessage,
@@ -21,6 +19,7 @@ import {
ServerPrestartMessageSchema,
ServerStartGameMessage,
ServerTurnMessage,
StampedIntent,
Turn,
} from "../core/Schemas";
import { createPartialGameRecord, getClanTag } from "../core/Util";
@@ -43,9 +42,11 @@ export class GameServer {
private disconnectedTimeout = 1 * 30 * 1000; // 30 seconds
private turns: Turn[] = [];
private intents: Intent[] = [];
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;
@@ -63,7 +64,7 @@ export class GameServer {
private _hasPrestarted = false;
private kickedClients: Set<ClientID> = new Set();
private kickedPersistentIds: Set<string> = new Set();
private outOfSyncClients: Set<ClientID> = new Set();
private isPaused = false;
@@ -87,12 +88,18 @@ export class GameServer {
public readonly createdAt: number,
private config: ServerConfig,
public gameConfig: GameConfig,
private lobbyCreatorID?: string,
private creatorPersistentID?: string,
private startsAt?: number,
) {
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;
@@ -150,20 +157,24 @@ export class GameServer {
}
}
public joinClient(client: Client) {
this.websockets.add(client.ws);
if (this.kickedClients.has(client.clientID)) {
this.log.warn(`cannot add client, already kicked`, {
clientID: client.clientID,
});
return;
}
private isKicked(clientID: ClientID): boolean {
const persistentID = this.allClients.get(clientID)?.persistentID;
return (
persistentID !== undefined && this.kickedPersistentIds.has(persistentID)
);
}
if (this.allClients.has(client.clientID)) {
this.log.warn("cannot add client, already in game", {
clientID: client.clientID,
});
return;
// 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 (
@@ -180,16 +191,9 @@ export class GameServer {
error: "full-lobby",
} satisfies ServerErrorMessage),
);
return;
return "rejected";
}
// Log when lobby creator joins private game
if (client.clientID === this.lobbyCreatorID) {
this.log.info("Lobby creator joined", {
gameID: this.id,
creatorID: this.lobbyCreatorID,
});
}
this.log.info("client joining game", {
clientID: client.clientID,
persistentID: client.persistentID,
@@ -206,7 +210,7 @@ export class GameServer {
clientID: client.clientID,
clientIP: ipAnonymize(client.ip),
});
return;
return "rejected";
}
if (this.config.env() === GameEnv.Prod) {
@@ -231,6 +235,8 @@ export class GameServer {
}
// 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);
@@ -242,54 +248,47 @@ export class GameServer {
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.
public rejoinClient(
ws: WebSocket,
persistentID: string,
msg: ClientRejoinMessage,
): void {
lastTurn: number = 0,
): 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 });
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;
// 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 !== msg.clientID,
(c) => c.clientID !== client.clientID,
);
this.activeClients.push(client);
client.lastPing = Date.now();
this.markClientDisconnected(msg.clientID, false);
this.markClientDisconnected(client.clientID, false);
client.ws = ws;
this.addListeners(client);
this.startLobbyInfoBroadcast();
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, msg.lastTurn);
this.sendStartGameMsg(client.ws, lastTurn);
}
return true;
}
private addListeners(client: Client) {
@@ -321,13 +320,12 @@ export class GameServer {
break;
}
case "intent": {
if (clientMsg.intent.clientID !== client.clientID) {
this.log.warn(
`client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`,
);
return;
}
switch (clientMsg.intent.type) {
// 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`,
@@ -342,14 +340,14 @@ export class GameServer {
this.log.warn(`Only lobby creator can kick players`, {
clientID: client.clientID,
creatorID: this.lobbyCreatorID,
target: clientMsg.intent.target,
target: stampedIntent.target,
gameID: this.id,
});
return;
}
// Don't allow lobby creator to kick themselves
if (client.clientID === clientMsg.intent.target) {
if (client.clientID === stampedIntent.target) {
this.log.warn(`Cannot kick yourself`, {
clientID: client.clientID,
});
@@ -359,13 +357,13 @@ export class GameServer {
// Log and execute the kick
this.log.info(`Lobby creator initiated kick of player`, {
creatorID: client.clientID,
target: clientMsg.intent.target,
target: stampedIntent.target,
gameID: this.id,
kickMethod: "websocket",
});
this.kickClient(
clientMsg.intent.target,
stampedIntent.target,
KICK_REASON_LOBBY_CREATOR,
);
return;
@@ -400,7 +398,7 @@ export class GameServer {
return;
}
if (clientMsg.intent.config.gameType === GameType.Public) {
if (stampedIntent.config.gameType === GameType.Public) {
this.log.warn(`Cannot update game to public via WebSocket`, {
gameID: this.id,
clientID: client.clientID,
@@ -416,7 +414,7 @@ export class GameServer {
},
);
this.updateGameConfig(clientMsg.intent.config);
this.updateGameConfig(stampedIntent.config);
return;
}
case "toggle_pause": {
@@ -430,15 +428,15 @@ export class GameServer {
return;
}
if (clientMsg.intent.paused) {
if (stampedIntent.paused) {
// Pausing: send intent and complete current turn before pause takes effect
this.addIntent(clientMsg.intent);
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(clientMsg.intent);
this.addIntent(stampedIntent);
this.endTurn();
}
@@ -451,7 +449,7 @@ export class GameServer {
default: {
// Don't process intents while game is paused
if (!this.isPaused) {
this.addIntent(clientMsg.intent);
this.addIntent(stampedIntent);
}
break;
}
@@ -501,6 +499,17 @@ export class GameServer {
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 numClients(): number {
@@ -569,12 +578,14 @@ export class GameServer {
}
private broadcastLobbyInfo() {
const msg = JSON.stringify({
type: "lobby_info",
lobby: this.gameInfo(),
} satisfies ServerLobbyInfoMessage);
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);
}
});
@@ -621,7 +632,7 @@ export class GameServer {
});
}
private addIntent(intent: Intent) {
private addIntent(intent: StampedIntent) {
this.intents.push(intent);
}
@@ -646,6 +657,7 @@ export class GameServer {
turns: this.turns.slice(lastTurn),
gameStartInfo: this.gameStartInfo,
lobbyCreatedAt: this.createdAt,
myClientID: client.clientID,
} satisfies ServerStartGameMessage),
);
} catch (error) {
@@ -808,6 +820,7 @@ export class GameServer {
username: c.username,
clientID: c.clientID,
})),
lobbyCreatorClientID: this.lobbyCreatorID,
gameConfig: this.gameConfig,
startsAt: this.startsAt,
serverTime: Date.now(),
@@ -822,7 +835,7 @@ export class GameServer {
clientID: ClientID,
reasonKey: string = KICK_REASON_DUPLICATE_SESSION,
): void {
if (this.kickedClients.has(clientID)) {
if (this.isKicked(clientID)) {
this.log.warn(`cannot kick client, already kicked`, {
clientID,
reasonKey,
@@ -830,7 +843,8 @@ export class GameServer {
return;
}
if (!this.allClients.has(clientID)) {
const clientToKick = this.allClients.get(clientID);
if (!clientToKick) {
this.log.warn(`cannot kick client, not found in game`, {
clientID,
reasonKey,
@@ -838,7 +852,7 @@ export class GameServer {
return;
}
this.kickedClients.add(clientID);
this.kickedPersistentIds.add(clientToKick.persistentID);
const client = this.activeClients.find((c) => c.clientID === clientID);
if (client) {
@@ -1041,7 +1055,7 @@ export class GameServer {
private handleWinner(client: Client, clientMsg: ClientSendWinnerMessage) {
if (
this.outOfSyncClients.has(client.clientID) ||
this.kickedClients.has(client.clientID) ||
this.isKicked(client.clientID) ||
this.winner !== null ||
client.reportedWinner !== null
) {