From 90978c0e925abd2c5a38029161f32d073e60ce70 Mon Sep 17 00:00:00 2001 From: Evan Date: Sat, 21 Feb 2026 21:08:33 -0600 Subject: [PATCH] bugfix: set lobby start time only when it's the next lobby in rotation (#3261) ## Description: The master set lobby start times on creation, which caused an issue if the previous lobby filled up and started before its timer ran out, the next lobby would have its timer set too far back. For example, if lobby time is 60 seconds, and the first lobby fills up after 10s, the subsequent lobby would have its timer set for 110 seconds (60+50). Instead we have the master set the lobby start time only when it is next up in rotation. So all lobbies behind it don't have a start time, because we don't actually know what it should be. ## Please complete the following: - [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 ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/GameModeSelector.ts | 36 +++++++++------- src/core/Schemas.ts | 2 +- src/server/GameServer.ts | 6 ++- src/server/IPCBridgeSchema.ts | 10 ++++- src/server/MasterLobbyService.ts | 71 ++++++++++++++++++-------------- src/server/WorkerLobbyService.ts | 15 ++++++- 6 files changed, 89 insertions(+), 51 deletions(-) diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index 0c98d2635..5698df9ce 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -178,11 +178,24 @@ export class GameModeSelector extends LitElement { ) { const mapType = lobby.gameConfig!.gameMap as GameMapType; const mapImageSrc = terrainMapFileLoader.getMapData(mapType).webpPath; - const timeRemaining = Math.max( - 0, - Math.floor((lobby.startsAt - this.serverTimeOffset - Date.now()) / 1000), - ); - const timeDisplay = renderDuration(timeRemaining); + const timeRemaining = lobby.startsAt + ? Math.max( + 0, + Math.floor( + (lobby.startsAt - this.serverTimeOffset - Date.now()) / 1000, + ), + ) + : undefined; + + let timeDisplay: string = ""; + if (timeRemaining === undefined) { + timeDisplay = "-s"; + } else if (timeRemaining > 0) { + timeDisplay = renderDuration(timeRemaining); + } else { + timeDisplay = translateText("public_lobby.starting_game"); + } + const mapName = getMapName(lobby.gameConfig?.gameMap); const modifierLabels = this.getModifierLabels( @@ -222,15 +235,10 @@ export class GameModeSelector extends LitElement { ` : html`
`}
- ${timeRemaining > 0 - ? html`${timeDisplay}` - : html`${translateText("public_lobby.starting_game")}`} + ${timeDisplay}
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 5647eb744..86c74292d 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -159,7 +159,7 @@ export const GameInfoSchema = z.object({ export const PublicGameInfoSchema = z.object({ gameID: z.string(), numClients: z.number(), - startsAt: z.number(), + startsAt: z.number().optional(), gameConfig: z.lazy(() => GameConfigSchema).optional(), publicGameType: PublicGameTypeSchema, }); diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index fa4ebfb6c..42b46a68d 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -514,6 +514,10 @@ export class GameServer { } } + public setStartsAt(startsAt: number) { + this.startsAt = startsAt; + } + public numClients(): number { return this.activeClients.length; } @@ -795,7 +799,7 @@ export class GameServer { // Public Games - const lessThanLifetime = Date.now() < this.startsAt!; + const lessThanLifetime = this.startsAt ? Date.now() < this.startsAt : true; const notEnoughPlayers = this.gameConfig.gameType === GameType.Public && this.gameConfig.maxPlayers && diff --git a/src/server/IPCBridgeSchema.ts b/src/server/IPCBridgeSchema.ts index 100b0cf52..48293614d 100644 --- a/src/server/IPCBridgeSchema.ts +++ b/src/server/IPCBridgeSchema.ts @@ -11,6 +11,8 @@ export type WorkerReady = z.infer; export type MasterLobbiesBroadcast = z.infer< typeof MasterLobbiesBroadcastSchema >; + +export type MasterUpdateGame = z.infer; export type MasterCreateGame = z.infer; export type WorkerMessage = z.infer; export type MasterMessage = z.infer; @@ -35,6 +37,12 @@ export const WorkerMessageSchema = z.discriminatedUnion("type", [ // --- Master Messages --- +const MasterUpdateGameSchema = z.object({ + type: z.literal("updateLobby"), + gameID: z.string(), + startsAt: z.number(), +}); + // Broadcasts all public game info to all workers. // Workers need information on all public lobbies so // it can send it to the client. @@ -48,11 +56,11 @@ const MasterCreateGameSchema = z.object({ type: z.literal("createGame"), gameID: z.string(), gameConfig: GameConfigSchema, - startsAt: z.number(), publicGameType: PublicGameTypeSchema, }); export const MasterMessageSchema = z.discriminatedUnion("type", [ MasterLobbiesBroadcastSchema, MasterCreateGameSchema, + MasterUpdateGameSchema, ]); diff --git a/src/server/MasterLobbyService.ts b/src/server/MasterLobbyService.ts index 31e58b55d..17a41a285 100644 --- a/src/server/MasterLobbyService.ts +++ b/src/server/MasterLobbyService.ts @@ -6,6 +6,7 @@ import { generateID } from "../core/Util"; import { MasterCreateGame, MasterLobbiesBroadcast, + MasterUpdateGame, WorkerMessageSchema, } from "./IPCBridgeSchema"; import { logger } from "./Logger"; @@ -93,7 +94,16 @@ export class MasterLobbyService { } for (const type of Object.keys(result) as PublicGameType[]) { - result[type].sort((a, b) => a.startsAt - b.startsAt); + result[type].sort((a, b) => { + if (a.startsAt === undefined && b.startsAt === undefined) { + // Sort by game id for stability. + return a.gameID > b.gameID ? 1 : -1; + } + // If a lobby has startsAt set, we assume it's the active one. + if (a.startsAt === undefined) return 1; + if (b.startsAt === undefined) return -1; + return a.startsAt - b.startsAt; + }); } return result; @@ -124,39 +134,36 @@ export class MasterLobbyService { if (lobbies.length >= 2) { continue; } - - const lastStart = lobbies.reduce( - (max, pb) => Math.max(max, pb.startsAt), - Date.now(), - ); - - const gameID = generateID(); - const workerId = this.config.workerIndex(gameID); - - const gameConfig = await this.playlist.gameConfig(type); - const worker = this.workers.get(workerId); - if (!worker) { - this.log.error(`Worker ${workerId} not found`); - continue; + const nextLobby = lobbies[0]; + if (nextLobby && nextLobby.startsAt === undefined) { + // The previous game has started, so we need to set the timer on the next game. + this.sendMessageToWorker({ + type: "updateLobby", + gameID: nextLobby.gameID, + startsAt: Date.now() + this.config.gameCreationRate(), + }); } - worker.send( - { - type: "createGame", - gameID, - gameConfig, - startsAt: lastStart + this.config.gameCreationRate(), - publicGameType: type, - } satisfies MasterCreateGame, - (e) => { - if (e) { - this.log.error("Failed to schedule lobby on worker:", e); - } - }, - ); - this.log.info( - `Scheduled public game ${gameID} (${type}) on worker ${workerId}`, - ); + this.sendMessageToWorker({ + type: "createGame", + gameID: generateID(), + gameConfig: await this.playlist.gameConfig(type), + publicGameType: type, + } satisfies MasterCreateGame); } } + + private sendMessageToWorker(msg: MasterCreateGame | MasterUpdateGame): void { + const workerId = this.config.workerIndex(msg.gameID); + const worker = this.workers.get(workerId); + if (!worker) { + this.log.error(`Worker ${workerId} not found`); + return; + } + worker.send(msg, (e) => { + if (e) { + this.log.error("Failed to send message to worker:", e); + } + }); + } } diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index e9d7b7709..2bbd50e08 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -51,10 +51,21 @@ export class WorkerLobbyService { msg.gameID, msg.gameConfig, undefined, - msg.startsAt, + undefined, msg.publicGameType, ); break; + case "updateLobby": { + const game = this.gm.game(msg.gameID); + if (!game) { + this.log.warn("cannot update game, not found", { + gameID: msg.gameID, + }); + return; + } + game.setStartsAt(msg.startsAt); + break; + } } }); } @@ -72,7 +83,7 @@ export class WorkerLobbyService { return { gameID: gi.gameID, numClients: gi.clients?.length ?? 0, - startsAt: gi.startsAt!, + startsAt: gi.startsAt, gameConfig: gi.gameConfig, publicGameType: gi.publicGameType!, } satisfies PublicGameInfo;