diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index be9eb3806..c80b8c4f8 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -1011,21 +1011,12 @@ export class HostLobbyModal extends BaseModal { // If the modal closes as part of starting the game, do not leave the lobby this.leaveLobbyOnClose = false; - const config = await getRuntimeClientServerConfig(); - const response = await fetch( - `${window.location.origin}/${config.workerPath(this.lobbyId)}/api/start_game/${this.lobbyId}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - }, + this.dispatchEvent( + new CustomEvent("start-game", { + bubbles: true, + composed: true, + }), ); - - if (!response.ok) { - this.leaveLobbyOnClose = true; - } - return response; } private kickPlayer(clientID: string) { diff --git a/src/client/Main.ts b/src/client/Main.ts index 93dfc47df..2f418c030 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -51,6 +51,7 @@ import { TerritoryPatternsModal } from "./TerritoryPatternsModal"; import { TokenLoginModal } from "./TokenLoginModal"; import { SendKickPlayerIntentEvent, + SendStartGameEvent, SendUpdateGameConfigIntentEvent, } from "./Transport"; import { UserSettingModal } from "./UserSettingModal"; @@ -215,6 +216,7 @@ declare global { interface DocumentEventMap { "join-lobby": CustomEvent; "kick-player": CustomEvent; + "start-game": CustomEvent; "join-changed": CustomEvent; "open-matchmaking": CustomEvent; } @@ -311,6 +313,7 @@ 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("start-game", this.handleStartGame.bind(this)); document.addEventListener( "update-game-config", this.handleUpdateGameConfig.bind(this), @@ -932,6 +935,12 @@ class Client { } } + private handleStartGame() { + if (this.eventBus) { + this.eventBus.emit(new SendStartGameEvent()); + } + } + private handleUpdateGameConfig(event: CustomEvent) { const { config } = event.detail; diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 2fac031d0..1e4526a8c 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -173,6 +173,8 @@ export class SendUpdateGameConfigIntentEvent implements GameEvent { constructor(public readonly config: Partial) {} } +export class SendStartGameEvent implements GameEvent {} + export class Transport { private socket: WebSocket | null = null; @@ -262,6 +264,8 @@ export class Transport { this.eventBus.on(SendUpdateGameConfigIntentEvent, (e) => this.onSendUpdateGameConfigIntent(e), ); + + this.eventBus.on(SendStartGameEvent, () => this.onSendStartGame()); } private startPing() { @@ -644,6 +648,10 @@ export class Transport { }); } + private onSendStartGame() { + this.sendIntent({ type: "start_game" }); + } + 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 870dcff55..4a1636e19 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -50,7 +50,8 @@ export type Intent = | DeleteUnitIntent | KickPlayerIntent | TogglePauseIntent - | UpdateGameConfigIntent; + | UpdateGameConfigIntent + | StartGameIntent; export type AttackIntent = z.infer; export type CancelAttackIntent = z.infer; @@ -84,6 +85,7 @@ export type TogglePauseIntent = z.infer; export type UpdateGameConfigIntent = z.infer< typeof UpdateGameConfigIntentSchema >; +export type StartGameIntent = z.infer; export type Turn = z.infer; export type GameConfig = z.infer; @@ -453,6 +455,10 @@ export const UpdateGameConfigIntentSchema = z.object({ config: GameConfigSchema.partial(), }); +export const StartGameIntentSchema = z.object({ + type: z.literal("start_game"), +}); + const IntentSchema = z.discriminatedUnion("type", [ AttackIntentSchema, CancelAttackIntentSchema, @@ -478,6 +484,7 @@ const IntentSchema = z.discriminatedUnion("type", [ KickPlayerIntentSchema, TogglePauseIntentSchema, UpdateGameConfigIntentSchema, + StartGameIntentSchema, ]); // StampedIntent = Intent with server-stamped clientID (used in turns and execution) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index ab95da102..2f6175776 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -487,6 +487,35 @@ export class GameServer { this.updateGameConfig(stampedIntent.config); return; } + case "start_game": { + if (client.clientID !== this.lobbyCreatorID) { + this.log.warn(`Only lobby creator can start game`, { + clientID: client.clientID, + creatorID: this.lobbyCreatorID, + gameID: this.id, + }); + return; + } + if (this.isPublic()) { + this.log.warn(`Cannot start public game via WebSocket`, { + gameID: this.id, + }); + return; + } + if (this.hasStarted()) { + this.log.warn(`Cannot start game that has already started`, { + gameID: this.id, + clientID: client.clientID, + }); + return; + } + this.log.info(`Lobby creator starting game via WebSocket`, { + creatorID: client.clientID, + gameID: this.id, + }); + this.start(); + 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 b775d393a..2c6b36d63 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -210,25 +210,6 @@ export async function startWorker() { res.json(game.gameInfo()); }); - // Add other endpoints from your original server - app.post("/api/start_game/:id", async (req, res) => { - log.info(`starting private lobby with id ${req.params.id}`); - const game = gm.game(req.params.id); - if (!game) { - return; - } - if (game.isPublic()) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const clientIP = req.ip || req.socket.remoteAddress || "unknown"; - log.info( - `cannot start public game ${game.id}, game is public, ip: ${ipAnonymize(clientIP)}`, - ); - return; - } - game.start(); - res.status(200).json({ success: true }); - }); - app.get("/api/game/:id/exists", async (req, res) => { const lobbyId = req.params.id; res.json({