Use WebSocket intent for lobby game configuration updates (#2761)

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
This commit is contained in:
Vahant Sharma
2026-01-01 16:46:53 -08:00
committed by GitHub
parent 550b644a40
commit b990fe6ae5
6 changed files with 128 additions and 74 deletions
+33 -36
View File
@@ -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<GameConfig>,
},
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<GameConfig>),
},
bubbles: true,
composed: true,
}),
);
return response;
}
private toggleUnit(unit: UnitType, checked: boolean): void {
+17 -1
View File
@@ -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") {
+17
View File
@@ -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<GameConfig>) {}
}
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 = {