From b990fe6ae5e0b238d5ebddc0c882c0a999d765b4 Mon Sep 17 00:00:00 2001 From: Vahant Sharma Date: Fri, 2 Jan 2026 06:16:53 +0530 Subject: [PATCH] Use WebSocket intent for lobby game configuration updates (#2761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2758 ## Description: This PR migrates lobby configuration updates from the HTTP PUT `/game/:id` endpoint to a WebSocket-based intent flow. The lobby creator is already authenticated via the game WebSocket, so updating configuration through intents removes redundant authentication and aligns with existing real-time lobby actions such as `kick_player` and `toggle_pause`. ## Changes Made - Added `update_game_config` WebSocket intent schema - Wired client → transport → server intent handling - Refactored `putGameConfig()` to emit WebSocket intent instead of HTTP fetch - Preserved all existing validation, partial-update semantics, and client-side debouncing - Left the REST endpoint untouched for backward compatibility ## Testing - All existing automated tests pass - Manual verification completed: - Lobby creator can update all lobby settings - Non-creators are rejected - Updates are rejected after game start - Bots slider debounce (300ms) remains intact - No `PUT /api/game/:id` requests are made from the lobby UI ## Checklist: - [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 --- src/client/HostLobbyModal.ts | 69 +++++++++++++++++------------------- src/client/Main.ts | 18 +++++++++- src/client/Transport.ts | 17 +++++++++ src/core/Schemas.ts | 12 ++++++- src/server/GameServer.ts | 49 +++++++++++++++++++++++++ src/server/Worker.ts | 37 +------------------ 6 files changed, 128 insertions(+), 74 deletions(-) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index f1c794f9e..bb40a713c 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -753,44 +753,41 @@ export class HostLobbyModal extends LitElement { } private async putGameConfig() { - const config = await getServerConfigFromClient(); - const response = await fetch( - `${window.location.origin}/${config.workerPath(this.lobbyId)}/api/game/${this.lobbyId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", + this.dispatchEvent( + new CustomEvent("update-game-config", { + detail: { + config: { + gameMap: this.selectedMap, + gameMapSize: this.compactMap + ? GameMapSize.Compact + : GameMapSize.Normal, + difficulty: this.selectedDifficulty, + bots: this.bots, + infiniteGold: this.infiniteGold, + donateGold: this.donateGold, + infiniteTroops: this.infiniteTroops, + donateTroops: this.donateTroops, + instantBuild: this.instantBuild, + randomSpawn: this.randomSpawn, + gameMode: this.gameMode, + disabledUnits: this.disabledUnits, + playerTeams: this.teamCount, + ...(this.gameMode === GameMode.Team && + this.teamCount === HumansVsNations + ? { + disableNations: false, + } + : { + disableNations: this.disableNations, + }), + maxTimerValue: + this.maxTimer === true ? this.maxTimerValue : undefined, + } satisfies Partial, }, - body: JSON.stringify({ - gameMap: this.selectedMap, - gameMapSize: this.compactMap - ? GameMapSize.Compact - : GameMapSize.Normal, - difficulty: this.selectedDifficulty, - bots: this.bots, - infiniteGold: this.infiniteGold, - donateGold: this.donateGold, - infiniteTroops: this.infiniteTroops, - donateTroops: this.donateTroops, - instantBuild: this.instantBuild, - randomSpawn: this.randomSpawn, - gameMode: this.gameMode, - disabledUnits: this.disabledUnits, - playerTeams: this.teamCount, - ...(this.gameMode === GameMode.Team && - this.teamCount === HumansVsNations - ? { - disableNations: false, - } - : { - disableNations: this.disableNations, - }), - maxTimerValue: - this.maxTimer === true ? this.maxTimerValue : undefined, - } satisfies Partial), - }, + bubbles: true, + composed: true, + }), ); - return response; } private toggleUnit(unit: UnitType, checked: boolean): void { diff --git a/src/client/Main.ts b/src/client/Main.ts index e44af2353..58dffff72 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -36,7 +36,10 @@ import { SinglePlayerModal } from "./SinglePlayerModal"; import "./StatsModal"; import { TerritoryPatternsModal } from "./TerritoryPatternsModal"; import { TokenLoginModal } from "./TokenLoginModal"; -import { SendKickPlayerIntentEvent } from "./Transport"; +import { + SendKickPlayerIntentEvent, + SendUpdateGameConfigIntentEvent, +} from "./Transport"; import { UserSettingModal } from "./UserSettingModal"; import "./UsernameInput"; import { UsernameInput } from "./UsernameInput"; @@ -187,6 +190,10 @@ class Client { document.addEventListener("join-lobby", this.handleJoinLobby.bind(this)); document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this)); document.addEventListener("kick-player", this.handleKickPlayer.bind(this)); + document.addEventListener( + "update-game-config", + this.handleUpdateGameConfig.bind(this), + ); const spModal = document.querySelector( "single-player-modal", @@ -624,6 +631,15 @@ class Client { } } + private handleUpdateGameConfig(event: CustomEvent) { + const { config } = event.detail; + + // Forward to eventBus if available + if (this.eventBus) { + this.eventBus.emit(new SendUpdateGameConfigIntentEvent(config)); + } + } + private initializeFuseTag() { const tryInitFuseTag = (): boolean => { if (window.fusetag && typeof window.fusetag.pageInit === "function") { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index fa89c9176..1f35131a4 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -19,6 +19,7 @@ import { ClientPingMessage, ClientRejoinMessage, ClientSendWinnerMessage, + GameConfig, Intent, ServerMessage, ServerMessageSchema, @@ -175,6 +176,10 @@ export class SendKickPlayerIntentEvent implements GameEvent { constructor(public readonly target: string) {} } +export class SendUpdateGameConfigIntentEvent implements GameEvent { + constructor(public readonly config: Partial) {} +} + export class Transport { private socket: WebSocket | null = null; @@ -260,6 +265,10 @@ export class Transport { this.eventBus.on(SendKickPlayerIntentEvent, (e) => this.onSendKickPlayerIntent(e), ); + + this.eventBus.on(SendUpdateGameConfigIntentEvent, (e) => + this.onSendUpdateGameConfigIntent(e), + ); } private startPing() { @@ -659,6 +668,14 @@ export class Transport { }); } + private onSendUpdateGameConfigIntent(event: SendUpdateGameConfigIntentEvent) { + this.sendIntent({ + type: "update_game_config", + clientID: this.lobbyConfig.clientID, + config: event.config, + }); + } + private sendIntent(intent: Intent) { if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { const msg = { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 49c76440c..b9bc2e7a8 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -48,7 +48,8 @@ export type Intent = | UpgradeStructureIntent | DeleteUnitIntent | KickPlayerIntent - | TogglePauseIntent; + | TogglePauseIntent + | UpdateGameConfigIntent; export type AttackIntent = z.infer; export type CancelAttackIntent = z.infer; @@ -81,6 +82,9 @@ export type AllianceExtensionIntent = z.infer< export type DeleteUnitIntent = z.infer; export type KickPlayerIntent = z.infer; export type TogglePauseIntent = z.infer; +export type UpdateGameConfigIntent = z.infer< + typeof UpdateGameConfigIntentSchema +>; export type Turn = z.infer; export type GameConfig = z.infer; @@ -363,6 +367,11 @@ export const TogglePauseIntentSchema = BaseIntentSchema.extend({ paused: z.boolean().default(false), }); +export const UpdateGameConfigIntentSchema = BaseIntentSchema.extend({ + type: z.literal("update_game_config"), + config: GameConfigSchema.partial(), +}); + const IntentSchema = z.discriminatedUnion("type", [ AttackIntentSchema, CancelAttackIntentSchema, @@ -387,6 +396,7 @@ const IntentSchema = z.discriminatedUnion("type", [ DeleteUnitIntentSchema, KickPlayerIntentSchema, TogglePauseIntentSchema, + UpdateGameConfigIntentSchema, ]); // diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index b6f109406..3aa8d53b6 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -350,6 +350,55 @@ export class GameServer { this.kickClient(clientMsg.intent.target); return; } + case "update_game_config": { + // Only lobby creator can update config + if (client.clientID !== this.lobbyCreatorID) { + this.log.warn(`Only lobby creator can update game config`, { + clientID: client.clientID, + creatorID: this.lobbyCreatorID, + gameID: this.id, + }); + return; + } + + if (this.isPublic()) { + this.log.warn(`Cannot update public game via WebSocket`, { + gameID: this.id, + clientID: client.clientID, + }); + return; + } + + if (this.hasStarted()) { + this.log.warn( + `Cannot update game config after it has started`, + { + gameID: this.id, + clientID: client.clientID, + }, + ); + return; + } + + if (clientMsg.intent.config.gameType === GameType.Public) { + this.log.warn(`Cannot update game to public via WebSocket`, { + gameID: this.id, + clientID: client.clientID, + }); + return; + } + + this.log.info( + `Lobby creator updated game config via WebSocket`, + { + creatorID: client.clientID, + gameID: this.id, + }, + ); + + this.updateGameConfig(clientMsg.intent.config); + return; + } case "toggle_pause": { // Only lobby creator can pause/resume if (client.clientID !== this.lobbyCreatorID) { diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 996c9f9fe..ae3a6870f 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -17,7 +17,7 @@ import { ServerErrorMessage, } from "../core/Schemas"; import { generateID, replacer } from "../core/Util"; -import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; +import { CreateGameInputSchema } from "../core/WorkerSchemas"; import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; @@ -176,41 +176,6 @@ export async function startWorker() { res.status(200).json({ success: true }); }); - app.put("/api/game/:id", async (req, res) => { - const result = GameInputSchema.safeParse(req.body); - if (!result.success) { - const error = z.prettifyError(result.error); - return res.status(400).json({ error }); - } - const config = result.data; - // TODO: only update public game if from local host - const lobbyID = req.params.id; - if (config.gameType === GameType.Public) { - log.info(`cannot update game ${lobbyID} to public`); - return res.status(400).json({ error: "Cannot update public game" }); - } - const game = gm.game(lobbyID); - if (!game) { - return res.status(400).json({ error: "Game not found" }); - } - if (game.isPublic()) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const clientIP = req.ip || req.socket.remoteAddress || "unknown"; - log.warn( - `cannot update public game ${game.id}, ip: ${ipAnonymize(clientIP)}`, - ); - return res.status(400).json({ error: "Cannot update public game" }); - } - if (game.hasStarted()) { - log.warn(`cannot update game ${game.id} after it has started`); - return res - .status(400) - .json({ error: "Cannot update game after it has started" }); - } - game.updateGameConfig(config); - res.status(200).json({ success: true }); - }); - app.get("/api/game/:id/exists", async (req, res) => { const lobbyId = req.params.id; res.json({