mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
48609fa70a
## Description:
- The lobby WebSocket broadcast (`/lobbies`) was re-sending the full
`PublicGames` snapshot — including each lobby's `gameConfig` — to every
connected client every 500ms. Almost nothing in that payload changes
tick-to-tick; only `numClients` moves.
- `WorkerLobbyService` now tracks the sorted set of `gameID`s it last
sent as a full snapshot. On each incoming broadcast it sends a `full`
only when that set changes; otherwise it sends a `counts` delta carrying
just `{gameID → numClients}`.
- This relies on the master-side coupling at
[MasterLobbyService.ts:140-159](src/server/MasterLobbyService.ts#L140-L159):
when master finds a lobby without `startsAt`, it both sets `startsAt`
AND schedules a fresh lobby on the same tick, so the gameID change
brings the `startsAt` (and `gameConfig`) along with it.
- New WS connections are primed with the worker's cached last `full` so
late joiners don't have to wait for the next structural change.
- `LobbySocket` parses the new discriminated union (`PublicLobbyMessage
= full | counts`), keeps the last full snapshot in memory, and merges
counts into it before invoking the existing callback. `GameModeSelector`
is unchanged.
- Master → worker IPC is unchanged — still sends the full snapshot every
500ms. The optimization only applies to the worker → WS-client boundary,
which is the fan-out point.
## 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
## Please put your Discord username so you can be contacted if a bug or
regression is found:
evan
187 lines
5.5 KiB
TypeScript
187 lines
5.5 KiB
TypeScript
import { ClientEnv } from "src/client/ClientEnv";
|
|
import { PublicGames, PublicLobbyMessageSchema } from "../core/Schemas";
|
|
|
|
interface LobbySocketOptions {
|
|
reconnectDelay?: number;
|
|
maxWsAttempts?: number;
|
|
pollIntervalMs?: number;
|
|
}
|
|
|
|
function getRandomWorkerPath(numWorkers: number): string {
|
|
const workerIndex = Math.floor(Math.random() * numWorkers);
|
|
return `/w${workerIndex}`;
|
|
}
|
|
|
|
export class PublicLobbySocket {
|
|
private ws: WebSocket | null = null;
|
|
private wsReconnectTimeout: number | null = null;
|
|
private wsConnectionAttempts = 0;
|
|
private wsAttemptCounted = false;
|
|
private workerPath: string = "";
|
|
private stopped = true;
|
|
// Latest full snapshot, used as the base for applying counts-only deltas.
|
|
private lastFull: PublicGames | null = null;
|
|
|
|
private readonly reconnectDelay: number;
|
|
private readonly maxWsAttempts: number;
|
|
|
|
constructor(
|
|
private onLobbiesUpdate: (data: PublicGames) => void,
|
|
options?: LobbySocketOptions,
|
|
) {
|
|
this.reconnectDelay = options?.reconnectDelay ?? 3000;
|
|
this.maxWsAttempts = options?.maxWsAttempts ?? 3;
|
|
}
|
|
|
|
async start() {
|
|
this.stopped = false;
|
|
this.wsConnectionAttempts = 0;
|
|
// Get config to determine number of workers, then pick a random one
|
|
this.workerPath = getRandomWorkerPath(ClientEnv.numWorkers());
|
|
this.connectWebSocket();
|
|
}
|
|
|
|
stop() {
|
|
this.stopped = true;
|
|
this.lastFull = null;
|
|
this.disconnectWebSocket();
|
|
}
|
|
|
|
private connectWebSocket() {
|
|
try {
|
|
// Clean up existing WebSocket before creating a new one
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
// Drop any cached snapshot — the server primes new connections with a
|
|
// fresh full message, and a stale base could mis-merge incoming deltas.
|
|
this.lastFull = null;
|
|
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const wsUrl = `${protocol}//${window.location.host}${this.workerPath}/lobbies`;
|
|
|
|
this.ws = new WebSocket(wsUrl);
|
|
this.wsAttemptCounted = false;
|
|
|
|
this.ws.addEventListener("open", () => this.handleOpen());
|
|
this.ws.addEventListener("message", (event) => this.handleMessage(event));
|
|
this.ws.addEventListener("close", () => this.handleClose());
|
|
this.ws.addEventListener("error", (error) => this.handleError(error));
|
|
} catch (error) {
|
|
this.handleConnectError(error);
|
|
}
|
|
}
|
|
|
|
private handleOpen() {
|
|
console.log("WebSocket connected: lobby updating");
|
|
this.wsConnectionAttempts = 0;
|
|
if (this.wsReconnectTimeout !== null) {
|
|
clearTimeout(this.wsReconnectTimeout);
|
|
this.wsReconnectTimeout = null;
|
|
}
|
|
}
|
|
|
|
private handleMessage(event: MessageEvent) {
|
|
try {
|
|
const message = PublicLobbyMessageSchema.parse(
|
|
JSON.parse(event.data as string),
|
|
);
|
|
if (message.type === "full") {
|
|
this.lastFull = {
|
|
serverTime: message.serverTime,
|
|
games: message.games,
|
|
};
|
|
this.onLobbiesUpdate(this.lastFull);
|
|
return;
|
|
}
|
|
// counts: patch numClients onto the last full snapshot. If we have no
|
|
// base yet (shouldn't happen — server primes on connect), ignore it
|
|
// and wait for the next full.
|
|
if (this.lastFull === null) {
|
|
return;
|
|
}
|
|
const patchedGames = { ...this.lastFull.games };
|
|
for (const type of Object.keys(patchedGames) as Array<
|
|
keyof typeof patchedGames
|
|
>) {
|
|
const list = patchedGames[type];
|
|
if (!list) continue;
|
|
patchedGames[type] = list.map((lobby) => {
|
|
const next = message.counts[lobby.gameID];
|
|
return next === undefined || next === lobby.numClients
|
|
? lobby
|
|
: { ...lobby, numClients: next };
|
|
});
|
|
}
|
|
this.lastFull = {
|
|
serverTime: message.serverTime,
|
|
games: patchedGames,
|
|
};
|
|
this.onLobbiesUpdate(this.lastFull);
|
|
} catch (error) {
|
|
console.error("Error parsing WebSocket message:", error);
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
this.ws.close();
|
|
} catch (closeError) {
|
|
console.error(
|
|
"Error closing WebSocket after parse failure:",
|
|
closeError,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private handleClose() {
|
|
if (this.stopped) return;
|
|
console.log("WebSocket disconnected, attempting to reconnect...");
|
|
if (!this.wsAttemptCounted) {
|
|
this.wsAttemptCounted = true;
|
|
this.wsConnectionAttempts++;
|
|
}
|
|
if (this.wsConnectionAttempts >= this.maxWsAttempts) {
|
|
console.error("Max WebSocket attempts reached");
|
|
} else {
|
|
this.scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
private handleError(error: Event) {
|
|
console.error("WebSocket error:", error);
|
|
}
|
|
|
|
private handleConnectError(error: unknown) {
|
|
console.error("Error connecting WebSocket:", error);
|
|
if (!this.wsAttemptCounted) {
|
|
this.wsAttemptCounted = true;
|
|
this.wsConnectionAttempts++;
|
|
}
|
|
if (this.wsConnectionAttempts >= this.maxWsAttempts) {
|
|
alert("error connecting to game service");
|
|
} else {
|
|
this.scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
private scheduleReconnect() {
|
|
if (this.wsReconnectTimeout !== null) return;
|
|
this.wsReconnectTimeout = window.setTimeout(() => {
|
|
this.wsReconnectTimeout = null;
|
|
this.connectWebSocket();
|
|
}, this.reconnectDelay);
|
|
}
|
|
|
|
private disconnectWebSocket() {
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
if (this.wsReconnectTimeout !== null) {
|
|
clearTimeout(this.wsReconnectTimeout);
|
|
this.wsReconnectTimeout = null;
|
|
}
|
|
}
|
|
}
|