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
+49
View File
@@ -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) {
+1 -36
View File
@@ -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({