mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-15 20:26:36 +00:00
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:
+95
-81
@@ -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
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user