mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-28 03:15:01 +00:00
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:
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+11
-1
@@ -48,7 +48,8 @@ export type Intent =
|
||||
| UpgradeStructureIntent
|
||||
| DeleteUnitIntent
|
||||
| KickPlayerIntent
|
||||
| TogglePauseIntent;
|
||||
| TogglePauseIntent
|
||||
| UpdateGameConfigIntent;
|
||||
|
||||
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
|
||||
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
|
||||
@@ -81,6 +82,9 @@ export type AllianceExtensionIntent = z.infer<
|
||||
export type DeleteUnitIntent = z.infer<typeof DeleteUnitIntentSchema>;
|
||||
export type KickPlayerIntent = z.infer<typeof KickPlayerIntentSchema>;
|
||||
export type TogglePauseIntent = z.infer<typeof TogglePauseIntentSchema>;
|
||||
export type UpdateGameConfigIntent = z.infer<
|
||||
typeof UpdateGameConfigIntentSchema
|
||||
>;
|
||||
|
||||
export type Turn = z.infer<typeof TurnSchema>;
|
||||
export type GameConfig = z.infer<typeof GameConfigSchema>;
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
//
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user