mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-15 13:24:41 +00:00
move lobby websockets to worker (#2974)
## Description: Currently only the master process sends public lobby updates to clients. This is not scalable since it could overload the master process. In this PR, the master uses IPC to send public lobby info to all workers. Then clients connect to a random worker to get public lobby updates via websocket. This way clients never connect directly to the master websocket. The flow looks like this: Every 100ms: 1. Master schedules a public game on a random worker if new games are needed 2. Master broadcasts public lobby info to all workers (all public games & num clients connected to each game) 3. Each worker responds to that update with the number of clients connected to its own public games 4. Master then updates its public lobby state so it knows how many clients are connected to each public game ## 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:
@@ -70,7 +70,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
}
|
||||
this.updateFromLobby({
|
||||
...lobby,
|
||||
msUntilStart: lobby.msUntilStart ?? undefined,
|
||||
startsAt: lobby.startsAt ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -94,7 +94,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
})
|
||||
: translateText("public_lobby.started");
|
||||
const maxPlayers = this.gameConfig?.maxPlayers ?? 0;
|
||||
const playerCount = this.playerCount;
|
||||
const playerCount = this.players?.length ?? 0;
|
||||
const hostClientID = this.isPrivateLobby()
|
||||
? (this.lobbyCreatorClientID ?? "")
|
||||
: "";
|
||||
@@ -283,13 +283,13 @@ export class JoinLobbyModal extends BaseModal {
|
||||
`;
|
||||
}
|
||||
|
||||
public open(lobbyId: string = "", lobbyInfo?: GameInfo) {
|
||||
public open(lobbyId: string = "", isPublic: boolean = false) {
|
||||
super.open();
|
||||
if (lobbyId) {
|
||||
this.startTrackingLobby(lobbyId, lobbyInfo);
|
||||
this.startTrackingLobby(lobbyId);
|
||||
// If opened with lobbyInfo (public lobby case), auto-join the lobby
|
||||
if (lobbyInfo) {
|
||||
this.joinPublicLobby(lobbyId, lobbyInfo);
|
||||
if (isPublic) {
|
||||
this.joinPublicLobby(lobbyId);
|
||||
} else {
|
||||
// If opened with lobbyId but no lobbyInfo (URL join case), check if active and join
|
||||
this.handleUrlJoin(lobbyId);
|
||||
@@ -329,14 +329,13 @@ export class JoinLobbyModal extends BaseModal {
|
||||
}
|
||||
}
|
||||
|
||||
private joinPublicLobby(lobbyId: string, lobbyInfo: GameInfo) {
|
||||
private joinPublicLobby(lobbyId: string) {
|
||||
// Dispatch join-lobby event to actually connect to the lobby
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
clientID: this.currentClientID,
|
||||
publicLobbyInfo: lobbyInfo,
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
@@ -349,7 +348,6 @@ export class JoinLobbyModal extends BaseModal {
|
||||
this.currentClientID = getClientIDForGame(lobbyId);
|
||||
this.gameConfig = null;
|
||||
this.players = [];
|
||||
this.playerCount = 0;
|
||||
this.nationCount = 0;
|
||||
this.lobbyStartAt = null;
|
||||
this.lobbyCreatorClientID = null;
|
||||
@@ -397,7 +395,6 @@ export class JoinLobbyModal extends BaseModal {
|
||||
if (this.lobbyIdInput) this.lobbyIdInput.value = "";
|
||||
this.gameConfig = null;
|
||||
this.players = [];
|
||||
this.playerCount = 0;
|
||||
this.currentLobbyId = "";
|
||||
this.currentClientID = "";
|
||||
this.nationCount = 0;
|
||||
@@ -536,18 +533,8 @@ export class JoinLobbyModal extends BaseModal {
|
||||
// --- Lobby event handling ---
|
||||
|
||||
private updateFromLobby(lobby: GameInfo) {
|
||||
if (lobby.clients) {
|
||||
this.players = lobby.clients;
|
||||
this.playerCount = lobby.clients.length;
|
||||
} else {
|
||||
this.players = [];
|
||||
this.playerCount = lobby.numClients ?? 0;
|
||||
}
|
||||
if (lobby.msUntilStart !== undefined) {
|
||||
this.lobbyStartAt = lobby.msUntilStart + Date.now();
|
||||
} else {
|
||||
this.lobbyStartAt = null;
|
||||
}
|
||||
this.players = lobby.clients ?? [];
|
||||
this.lobbyStartAt = lobby.startsAt ?? null;
|
||||
this.syncCountdownTimer();
|
||||
if (lobby.gameConfig) {
|
||||
const mapChanged = this.gameConfig?.gameMap !== lobby.gameConfig.gameMap;
|
||||
|
||||
+20
-51
@@ -1,6 +1,5 @@
|
||||
import { GameInfo } from "../core/Schemas";
|
||||
|
||||
type LobbyUpdateHandler = (lobbies: GameInfo[]) => void;
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { PublicGames, PublicGamesSchema } from "../core/Schemas";
|
||||
|
||||
interface LobbySocketOptions {
|
||||
reconnectDelay?: number;
|
||||
@@ -8,36 +7,39 @@ interface LobbySocketOptions {
|
||||
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 fallbackPollInterval: number | null = null;
|
||||
private wsConnectionAttempts = 0;
|
||||
private wsAttemptCounted = false;
|
||||
private workerPath: string = "";
|
||||
|
||||
private readonly reconnectDelay: number;
|
||||
private readonly maxWsAttempts: number;
|
||||
private readonly pollIntervalMs: number;
|
||||
private readonly onLobbiesUpdate: LobbyUpdateHandler;
|
||||
|
||||
constructor(
|
||||
onLobbiesUpdate: LobbyUpdateHandler,
|
||||
private onLobbiesUpdate: (data: PublicGames) => void,
|
||||
options?: LobbySocketOptions,
|
||||
) {
|
||||
this.onLobbiesUpdate = onLobbiesUpdate;
|
||||
this.reconnectDelay = options?.reconnectDelay ?? 3000;
|
||||
this.maxWsAttempts = options?.maxWsAttempts ?? 3;
|
||||
this.pollIntervalMs = options?.pollIntervalMs ?? 1000;
|
||||
}
|
||||
|
||||
start() {
|
||||
async start() {
|
||||
this.wsConnectionAttempts = 0;
|
||||
// Get config to determine number of workers, then pick a random one
|
||||
const config = await getServerConfigFromClient();
|
||||
this.workerPath = getRandomWorkerPath(config.numWorkers());
|
||||
this.connectWebSocket();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.disconnectWebSocket();
|
||||
this.stopFallbackPolling();
|
||||
}
|
||||
|
||||
private connectWebSocket() {
|
||||
@@ -49,7 +51,7 @@ export class PublicLobbySocket {
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/lobbies`;
|
||||
const wsUrl = `${protocol}//${window.location.host}${this.workerPath}/lobbies`;
|
||||
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
this.wsAttemptCounted = false;
|
||||
@@ -70,15 +72,14 @@ export class PublicLobbySocket {
|
||||
clearTimeout(this.wsReconnectTimeout);
|
||||
this.wsReconnectTimeout = null;
|
||||
}
|
||||
this.stopFallbackPolling();
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent) {
|
||||
try {
|
||||
const message = JSON.parse(event.data as string);
|
||||
if (message.type === "lobbies_update") {
|
||||
this.onLobbiesUpdate(message.data?.lobbies ?? []);
|
||||
}
|
||||
const publicGames = PublicGamesSchema.parse(
|
||||
JSON.parse(event.data as string),
|
||||
);
|
||||
this.onLobbiesUpdate(publicGames);
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error);
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
@@ -101,10 +102,7 @@ export class PublicLobbySocket {
|
||||
this.wsConnectionAttempts++;
|
||||
}
|
||||
if (this.wsConnectionAttempts >= this.maxWsAttempts) {
|
||||
console.log(
|
||||
"Max WebSocket attempts reached, falling back to HTTP polling",
|
||||
);
|
||||
this.startFallbackPolling();
|
||||
console.error("Max WebSocket attempts reached");
|
||||
} else {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
@@ -121,7 +119,7 @@ export class PublicLobbySocket {
|
||||
this.wsConnectionAttempts++;
|
||||
}
|
||||
if (this.wsConnectionAttempts >= this.maxWsAttempts) {
|
||||
this.startFallbackPolling();
|
||||
alert("error connecting to game service");
|
||||
} else {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
@@ -145,33 +143,4 @@ export class PublicLobbySocket {
|
||||
this.wsReconnectTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startFallbackPolling() {
|
||||
if (this.fallbackPollInterval !== null) return;
|
||||
console.log("Starting HTTP fallback polling");
|
||||
this.fetchLobbiesHTTP();
|
||||
this.fallbackPollInterval = window.setInterval(() => {
|
||||
this.fetchLobbiesHTTP();
|
||||
}, this.pollIntervalMs);
|
||||
}
|
||||
|
||||
private stopFallbackPolling() {
|
||||
if (this.fallbackPollInterval !== null) {
|
||||
clearInterval(this.fallbackPollInterval);
|
||||
this.fallbackPollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchLobbiesHTTP() {
|
||||
try {
|
||||
const response = await fetch(`/api/public_lobbies`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.onLobbiesUpdate(data.lobbies as GameInfo[]);
|
||||
} catch (error) {
|
||||
console.error("Error fetching lobbies via HTTP:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-9
@@ -1,12 +1,7 @@
|
||||
import version from "resources/version.txt?raw";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
import {
|
||||
GAME_ID_REGEX,
|
||||
GameInfo,
|
||||
GameRecord,
|
||||
GameStartInfo,
|
||||
} from "../core/Schemas";
|
||||
import { GAME_ID_REGEX, GameRecord, GameStartInfo } from "../core/Schemas";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { GameType } from "../core/game/Game";
|
||||
@@ -223,7 +218,6 @@ export interface JoinLobbyEvent {
|
||||
// GameRecord exists when replaying an archived game.
|
||||
gameRecord?: GameRecord;
|
||||
source?: "public" | "private" | "host" | "matchmaking" | "singleplayer";
|
||||
publicLobbyInfo?: GameInfo;
|
||||
}
|
||||
|
||||
class Client {
|
||||
@@ -773,7 +767,7 @@ class Client {
|
||||
}
|
||||
const config = await getServerConfigFromClient();
|
||||
// Only update URL immediately for private lobbies, not public ones
|
||||
if (!lobby.publicLobbyInfo && lobby.source !== "public") {
|
||||
if (lobby.source !== "public") {
|
||||
this.updateJoinUrlForShare(lobby.gameID, config);
|
||||
}
|
||||
|
||||
@@ -908,7 +902,7 @@ class Client {
|
||||
|
||||
// Open the join lobby modal page and pass the lobby info
|
||||
window.showPage?.("page-join-lobby");
|
||||
this.joinModal?.open(lobby.gameID, lobby);
|
||||
this.joinModal?.open(lobby.gameID, true);
|
||||
}
|
||||
|
||||
private async handleLeaveLobby(/* event: CustomEvent */) {
|
||||
|
||||
+22
-13
@@ -1,7 +1,7 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { GameMapType } from "../core/game/Game";
|
||||
import { GameID, GameInfo } from "../core/Schemas";
|
||||
import { GameID, PublicGameInfo, PublicGames } from "../core/Schemas";
|
||||
import { PublicLobbySocket } from "./LobbySocket";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import {
|
||||
@@ -13,16 +13,19 @@ import {
|
||||
} from "./Utils";
|
||||
|
||||
export interface ShowPublicLobbyModalEvent {
|
||||
lobby: GameInfo;
|
||||
lobby: PublicGameInfo;
|
||||
}
|
||||
|
||||
@customElement("public-lobby")
|
||||
export class PublicLobby extends LitElement {
|
||||
@state() private lobbies: GameInfo[] = [];
|
||||
@state() private publicGames: PublicGames | null = null;
|
||||
@state() public isLobbyHighlighted: boolean = false;
|
||||
@state() private mapImages: Map<GameID, string> = new Map();
|
||||
|
||||
private lobbyIDToStart = new Map<GameID, number>();
|
||||
private lobbySocket = new PublicLobbySocket((lobbies) =>
|
||||
this.handleLobbiesUpdate(lobbies),
|
||||
private serverTimeOffset = 0;
|
||||
private lobbySocket = new PublicLobbySocket((data) =>
|
||||
this.handleLobbiesUpdate(data),
|
||||
);
|
||||
|
||||
createRenderRoot() {
|
||||
@@ -39,12 +42,18 @@ export class PublicLobby extends LitElement {
|
||||
this.lobbySocket.stop();
|
||||
}
|
||||
|
||||
private handleLobbiesUpdate(lobbies: GameInfo[]) {
|
||||
this.lobbies = lobbies;
|
||||
this.lobbies.forEach((l) => {
|
||||
private handleLobbiesUpdate(publicGames: PublicGames) {
|
||||
this.publicGames = publicGames;
|
||||
|
||||
// Calculate offset between server time and client time
|
||||
if (this.publicGames) {
|
||||
this.serverTimeOffset = this.publicGames.serverTime - Date.now();
|
||||
}
|
||||
this.publicGames.games.forEach((l) => {
|
||||
if (!this.lobbyIDToStart.has(l.gameID)) {
|
||||
const msUntilStart = l.msUntilStart ?? 0;
|
||||
this.lobbyIDToStart.set(l.gameID, msUntilStart + Date.now());
|
||||
// Convert server's startsAt to client time by subtracting offset
|
||||
const startsAt = l.startsAt ?? Date.now();
|
||||
this.lobbyIDToStart.set(l.gameID, startsAt - this.serverTimeOffset);
|
||||
}
|
||||
|
||||
if (l.gameConfig && !this.mapImages.has(l.gameID)) {
|
||||
@@ -66,9 +75,9 @@ export class PublicLobby extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.lobbies.length === 0) return html``;
|
||||
if (!this.publicGames) return html``;
|
||||
|
||||
const lobby = this.lobbies[0];
|
||||
const lobby = this.publicGames.games[0];
|
||||
if (!lobby?.gameConfig) return html``;
|
||||
|
||||
const start = this.lobbyIDToStart.get(lobby.gameID) ?? 0;
|
||||
@@ -200,7 +209,7 @@ export class PublicLobby extends LitElement {
|
||||
this.lobbySocket.stop();
|
||||
}
|
||||
|
||||
private lobbyClicked(lobby: GameInfo) {
|
||||
private lobbyClicked(lobby: PublicGameInfo) {
|
||||
// Validate username before opening the modal
|
||||
const usernameInput = document.querySelector("username-input") as any;
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user