diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index 20319614b..762d870be 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -88,9 +88,12 @@ export class GameModeSelector extends LitElement { ); this.requestUpdate(); - const allGames = Object.values(lobbies.games ?? {}).flat(); + const allGames = + lobbies.mode === "multi" + ? Object.values(lobbies.games).flat() + : [lobbies.lobby]; for (const game of allGames) { - const mapType = game.gameConfig?.gameMap as GameMapType; + const mapType = game?.gameConfig?.gameMap as GameMapType; if (mapType && !this.mapAspectRatios.has(mapType)) { // New Map reference triggers Lit reactivity; placeholder ratio 1 lets // has() guard against duplicate in-flight fetches. @@ -114,9 +117,15 @@ export class GameModeSelector extends LitElement { } render() { - const ffa = this.lobbies?.games?.["ffa"]?.[0]; - const teams = this.lobbies?.games?.["team"]?.[0]; - const special = this.lobbies?.games?.["special"]?.[0]; + const lobbies = this.lobbies; + const isSingle = lobbies?.mode === "single"; + const singleLobby = lobbies?.mode === "single" ? lobbies.lobby : undefined; + const ffa = + lobbies?.mode === "multi" ? lobbies.games?.["ffa"]?.[0] : undefined; + const teams = + lobbies?.mode === "multi" ? lobbies.games?.["team"]?.[0] : undefined; + const special = + lobbies?.mode === "multi" ? lobbies.games?.["special"]?.[0] : undefined; return html`
@@ -148,47 +157,74 @@ export class GameModeSelector extends LitElement {
- - ${special - ? html`` - : ffa - ? html`` - : nothing} + ${isSingle && singleLobby + ? html` + + +
+ ${singleLobby.publicGameType === "special" + ? this.renderSpecialLobbyCard(singleLobby) + : this.renderLobbyCard( + singleLobby, + this.getLobbyTitle(singleLobby), + )} +
+ ` + : html` + + ${special + ? html`` + : ffa + ? html`` + : nothing} - - + + - -
- ${special ? this.renderSpecialLobbyCard(special) : nothing} -
-
- ${ffa - ? this.renderLobbyCard(ffa, this.getLobbyTitle(ffa)) - : nothing} -
-
- ${teams - ? this.renderLobbyCard(teams, this.getLobbyTitle(teams)) - : nothing} -
+ +
+ ${special ? this.renderSpecialLobbyCard(special) : nothing} +
+
+ ${ffa + ? this.renderLobbyCard(ffa, this.getLobbyTitle(ffa)) + : nothing} +
+
+ ${teams + ? this.renderLobbyCard(teams, this.getLobbyTitle(teams)) + : nothing} +
+ `}
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 07b7f263e..8ad02faf1 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -164,11 +164,23 @@ export const PublicGameInfoSchema = z.object({ publicGameType: PublicGameTypeSchema, }); -export const PublicGamesSchema = z.object({ +const SingleLobbyGamesSchema = z.object({ + mode: z.literal("single"), + serverTime: z.number(), + lobby: PublicGameInfoSchema, +}); + +const MultiLobbyGamesSchema = z.object({ + mode: z.literal("multi"), serverTime: z.number(), games: z.record(PublicGameTypeSchema, z.array(PublicGameInfoSchema)), }); +export const PublicGamesSchema = z.discriminatedUnion("mode", [ + SingleLobbyGamesSchema, + MultiLobbyGamesSchema, +]); + export class LobbyInfoEvent implements GameEvent { constructor( public lobby: GameInfo, diff --git a/src/server/IPCBridgeSchema.ts b/src/server/IPCBridgeSchema.ts index 48293614d..eaf3ba493 100644 --- a/src/server/IPCBridgeSchema.ts +++ b/src/server/IPCBridgeSchema.ts @@ -23,6 +23,7 @@ export type MasterMessage = z.infer; const WorkerLobbyListSchema = z.object({ type: z.literal("lobbyList"), lobbies: z.array(PublicGameInfoSchema), + clientCount: z.number(), }); const WorkerReadySchema = z.object({ diff --git a/src/server/MasterLobbyService.ts b/src/server/MasterLobbyService.ts index 17a41a285..1c345af0e 100644 --- a/src/server/MasterLobbyService.ts +++ b/src/server/MasterLobbyService.ts @@ -19,12 +19,17 @@ export interface MasterLobbyServiceOptions { log: typeof logger; } +const CCU_SINGLE_LOBBY_THRESHOLD = 1500; +const SINGLE_LOBBY_ROTATION: PublicGameType[] = ["special", "team", "ffa"]; + export class MasterLobbyService { private readonly workers = new Map(); // Worker id => the lobbies it owns. private readonly workerLobbies = new Map(); + private readonly workerClientCounts = new Map(); private readonly readyWorkers = new Set(); private started = false; + private singleLobbyRotationIndex = 0; constructor( private config: ServerConfig, @@ -49,6 +54,7 @@ export class MasterLobbyService { break; case "lobbyList": this.workerLobbies.set(workerId, msg.lobbies); + this.workerClientCounts.set(workerId, msg.clientCount); break; } }); @@ -109,13 +115,27 @@ export class MasterLobbyService { return result; } + private getTotalCCU(): number { + let total = 0; + for (const count of this.workerClientCounts.values()) { + total += count; + } + return total; + } + private broadcastLobbies() { + const singleLobbyMode = this.getTotalCCU() < CCU_SINGLE_LOBBY_THRESHOLD; + const publicGames = singleLobbyMode + ? this.buildSingleLobbyBroadcast() + : { + mode: "multi" as const, + serverTime: Date.now(), + games: this.getAllLobbies(), + }; + const msg = { type: "lobbiesBroadcast", - publicGames: { - serverTime: Date.now(), - games: this.getAllLobbies(), - }, + publicGames, } satisfies MasterLobbiesBroadcast; for (const worker of this.workers.values()) { worker.send(msg, (e) => { @@ -126,7 +146,63 @@ export class MasterLobbyService { } } + private buildSingleLobbyBroadcast() { + const allLobbies = this.getAllLobbies(); + const type = SINGLE_LOBBY_ROTATION[this.singleLobbyRotationIndex]; + const lobby = allLobbies[type][0]; + // If the active rotation type has no lobby yet, fall back to any available lobby. + const activeLobby = + lobby ?? SINGLE_LOBBY_ROTATION.map((t) => allLobbies[t][0]).find(Boolean); + return { + mode: "single" as const, + serverTime: Date.now(), + lobby: activeLobby, + }; + } + private async maybeScheduleLobby() { + const singleLobbyMode = this.getTotalCCU() < CCU_SINGLE_LOBBY_THRESHOLD; + + if (singleLobbyMode) { + await this.maybeScheduleSingleLobby(); + } else { + await this.maybeScheduleAllLobbies(); + } + } + + private async maybeScheduleSingleLobby() { + const allLobbies = this.getAllLobbies(); + const type = SINGLE_LOBBY_ROTATION[this.singleLobbyRotationIndex]; + const lobbies = allLobbies[type]; + + // Advance rotation when the current lobby has started. + if (lobbies[0]?.startsAt !== undefined) { + this.singleLobbyRotationIndex = + (this.singleLobbyRotationIndex + 1) % SINGLE_LOBBY_ROTATION.length; + } + + // Clean up any stale lobbies from the other two types (don't schedule more of them). + // We only need to manage the active type. + if (lobbies.length >= 2) return; + + const nextLobby = lobbies[0]; + if (nextLobby && nextLobby.startsAt === undefined) { + this.sendMessageToWorker({ + type: "updateLobby", + gameID: nextLobby.gameID, + startsAt: Date.now() + this.config.gameCreationRate(), + }); + } + + this.sendMessageToWorker({ + type: "createGame", + gameID: generateID(), + gameConfig: await this.playlist.gameConfig(type), + publicGameType: type, + } satisfies MasterCreateGame); + } + + private async maybeScheduleAllLobbies() { const lobbiesByType = this.getAllLobbies(); for (const type of Object.keys(lobbiesByType) as PublicGameType[]) { diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index 2bbd50e08..3926f0dd7 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -88,7 +88,11 @@ export class WorkerLobbyService { publicGameType: gi.publicGameType!, } satisfies PublicGameInfo; }); - process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList); + process.send?.({ + type: "lobbyList", + lobbies, + clientCount: this.gm.activeClients(), + } satisfies WorkerLobbyList); } private setupUpgradeHandler() {