From 72336bcacb7d0b727b4b1cc815bda09537048691 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Sat, 16 May 2026 22:01:31 +0800 Subject: [PATCH] test --- resources/lang/en.json | 11 +- src/client/HostLobbyModal.ts | 28 +++ src/client/JoinLobbyModal.ts | 252 +++++++++++++++++++- src/client/Main.ts | 10 +- src/client/components/GameConfigSettings.ts | 22 ++ src/core/ListedPrivateGame.ts | 7 + src/core/Schemas.ts | 9 +- src/server/GameManager.ts | 9 + src/server/GameServer.ts | 18 ++ src/server/MapPlaylist.ts | 12 +- src/server/MasterLobbyService.ts | 7 +- src/server/Worker.ts | 24 +- src/server/WorkerLobbyService.ts | 32 ++- 13 files changed, 417 insertions(+), 24 deletions(-) create mode 100644 src/core/ListedPrivateGame.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index bb81cda9a..c1a6bd0ef 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -27,7 +27,8 @@ "click_to_copy": "Click to copy", "enabled": "Enabled", "disabled": "Disabled", - "map_default": "Map default" + "map_default": "Map default", + "show_less": "Show Less" }, "main": { "title": "OpenFront (ALPHA)", @@ -537,6 +538,12 @@ "error": "An error occurred. Please try again or contact support.", "joined_waiting": "Lobby joined! Waiting for host to start...", "version_mismatch": "This game was created with a different version. Cannot join.", + "listed_private_games": "Listed Private Games", + "no_listed_private_games": "No listed private games available.", + "show_all_listed_private_games": "Show More Lobbies (+{count})", + "show_all_settings": "Show All Settings (+{count})", + "show_fewer_settings": "Show Fewer Settings", + "join_by_lobby_id": "Join by Lobby ID", "disabled_units": "Disabled Units", "game_length": "Game length", "pvp_immunity": "PVP immunity duration", @@ -619,6 +626,8 @@ "starting_gold": "Starting Gold (Millions)", "starting_gold_placeholder": "5", "host_cheats": "Host Cheats", + "listed_private_game": "Listed Private Game", + "listed_private_game_desc": "Show this private lobby in the Join Lobby menu.", "leave_confirmation": "Are you sure you want to leave the lobby?" }, "team_colors": { diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 5b683ba56..0093ae1e6 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -10,6 +10,7 @@ import { GameMode, UnitType, } from "../core/game/Game"; +import { hasListedPrivateGameFlare } from "../core/ListedPrivateGame"; import { ClientInfo, GameConfig, @@ -19,6 +20,7 @@ import { isValidGameID, } from "../core/Schemas"; import { generateID } from "../core/Util"; +import { getUserMe } from "./Api"; import { getPlayToken } from "./Auth"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; @@ -85,6 +87,8 @@ export class HostLobbyModal extends BaseModal { @state() private hostCheatGoldMultiplierValue: number | undefined = undefined; @state() private hostCheatStartingGold: boolean = false; @state() private hostCheatStartingGoldValue: number | undefined = undefined; + @state() private canListPrivateGame: boolean = false; + @state() private listedPrivateGame: boolean = false; @state() private lobbyCreatorClientID: string = ""; @property({ attribute: false }) eventBus: EventBus | null = null; @@ -352,6 +356,12 @@ export class HostLobbyModal extends BaseModal { labelKey: "host_modal.host_cheats", checked: this.hostCheatsEnabled, }, + { + labelKey: "host_modal.listed_private_game", + descriptionKey: "host_modal.listed_private_game_desc", + checked: this.listedPrivateGame, + hidden: !this.canListPrivateGame, + }, ], inputCards, }, @@ -419,6 +429,7 @@ export class HostLobbyModal extends BaseModal { protected onOpen(): void { this.startLobbyUpdates(); + void this.loadListedPrivateGameAccess(); this.lobbyId = generateID(); // Note: clientID will be assigned by server when we join the lobby // lobbyCreatorClientID stays empty until then @@ -537,6 +548,7 @@ export class HostLobbyModal extends BaseModal { this.hostCheatGoldMultiplierValue = undefined; this.hostCheatStartingGold = false; this.hostCheatStartingGoldValue = undefined; + this.listedPrivateGame = false; this.leaveLobbyOnClose = true; } @@ -625,6 +637,10 @@ export class HostLobbyModal extends BaseModal { this.hostCheatsEnabled = checked; this.putGameConfig(); break; + case "host_modal.listed_private_game": + this.listedPrivateGame = this.canListPrivateGame && checked; + this.putGameConfig(); + break; default: break; } @@ -944,6 +960,8 @@ export class HostLobbyModal extends BaseModal { instantBuild: this.instantBuild, randomSpawn: this.randomSpawn, gameMode: this.gameMode, + listedPrivateGame: + this.canListPrivateGame && this.listedPrivateGame, disabledUnits: this.disabledUnits, spawnImmunityDuration: this.spawnImmunity ? spawnImmunityTicks @@ -1030,6 +1048,16 @@ export class HostLobbyModal extends BaseModal { // Leave existing values unchanged so the UI stays consistent } } + + private async loadListedPrivateGameAccess() { + const userMe = await getUserMe(); + const flares = userMe === false ? [] : (userMe.player.flares ?? []); + this.canListPrivateGame = hasListedPrivateGameFlare(flares); + if (!this.canListPrivateGame && this.listedPrivateGame) { + this.listedPrivateGame = false; + this.putGameConfig(); + } + } } async function createLobby(gameID: string): Promise { diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index 115bff0fa..8c7563640 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -11,6 +11,7 @@ import { } from "../client/Utils"; import { assetUrl } from "../core/AssetUrls"; import { EventBus } from "../core/EventBus"; +import { LISTED_PRIVATE_GAME_TYPE } from "../core/ListedPrivateGame"; import { ClientInfo, GAME_ID_REGEX, @@ -19,6 +20,7 @@ import { GameRecordSchema, LobbyInfoEvent, PublicGameInfo, + PublicGames, } from "../core/Schemas"; import { Difficulty, @@ -55,6 +57,9 @@ export class JoinLobbyModal extends BaseModal { @state() private serverTimeOffset: number = 0; @state() private isConnecting: boolean = true; @state() private lobbyCreatorClientID: string | null = null; + @state() private publicGames: PublicGames | null = null; + @state() private listedPrivateGamesExpanded: boolean = false; + @state() private expandedListedPrivateGameSettings: Set = new Set(); private leaveLobbyOnClose = true; private countdownTimerId: number | null = null; @@ -77,6 +82,11 @@ export class JoinLobbyModal extends BaseModal { }); }; + private readonly handlePublicLobbiesUpdate = (event: Event) => { + const customEvent = event as CustomEvent<{ payload: PublicGames }>; + this.publicGames = customEvent.detail.payload; + }; + protected renderHeaderSlot() { if (!this.currentLobbyId) { return modalHeader({ @@ -217,8 +227,25 @@ export class JoinLobbyModal extends BaseModal { } private renderJoinForm() { + const listedPrivateGames = + this.publicGames?.games?.[LISTED_PRIVATE_GAME_TYPE] ?? []; + const visibleListedPrivateGames = this.listedPrivateGamesExpanded + ? listedPrivateGames + : listedPrivateGames.slice(0, 3); + const hiddenListedPrivateGameCount = + listedPrivateGames.length - visibleListedPrivateGames.length; + return html` -
+ +
+

+ ${translateText("private_lobby.join_by_lobby_id")} +

+ +
+
+

+ ${translateText("private_lobby.listed_private_games")} +

+ ${listedPrivateGames.length > 3 + ? html` + + ` + : ""} +
+ ${listedPrivateGames.length === 0 + ? html` +
+ ${translateText("private_lobby.no_listed_private_games")} +
+ ` + : html` +
+ ${visibleListedPrivateGames.map((lobby) => + this.renderListedPrivateGame(lobby), + )} +
+ `} +
`; } + private renderListedPrivateGame(lobby: PublicGameInfo): TemplateResult { + const mapName = getMapName(lobby.gameConfig?.gameMap); + const settings = this.listedPrivateGameSettings(lobby.gameConfig); + const settingsExpanded = this.expandedListedPrivateGameSettings.has( + lobby.gameID, + ); + const visibleSettings = settingsExpanded ? settings : settings.slice(0, 3); + const hiddenSettingsCount = settings.length - visibleSettings.length; + const mode = + lobby.gameConfig?.gameMode === GameMode.Team + ? translateText("game_mode.teams") + : translateText("game_mode.ffa"); + + return html` +
+
+
+
+ ${mapName ?? lobby.gameConfig?.gameMap ?? lobby.gameID} +
+
+ ${mode} +
+
+ ${settings.length > 0 + ? html` +
+ ${visibleSettings.map( + (setting) => html` +
+ ${setting} +
+ `, + )} +
+ ${settings.length > 3 + ? html` + + ` + : ""} + ` + : ""} +
+
+ ${lobby.numClients}/${lobby.gameConfig?.maxPlayers ?? "-"} +
+ +
+
+
+ `; + } + + private listedPrivateGameSettings(config: GameConfig | undefined): string[] { + if (!config) return []; + const settings: string[] = []; + const isTeam = config.gameMode === GameMode.Team; + const isCompact = + config.gameMapSize === GameMapSize.Compact || + config.publicGameModifiers?.isCompact; + + if (isCompact) settings.push(translateText("host_modal.compact_map")); + if (config.difficulty !== Difficulty.Easy) { + settings.push( + translateText(`difficulty.${config.difficulty.toLowerCase()}`), + ); + } + if (config.infiniteTroops) + settings.push(translateText("host_modal.infinite_troops")); + if (config.infiniteGold) + settings.push(translateText("host_modal.infinite_gold")); + if (config.instantBuild) + settings.push(translateText("host_modal.instant_build")); + if (config.randomSpawn) + settings.push(translateText("host_modal.random_spawn")); + if (config.maxTimerValue) + settings.push( + `${translateText("private_lobby.game_length")}: ${config.maxTimerValue} min`, + ); + if (config.spawnImmunityDuration) { + const seconds = Math.round(config.spawnImmunityDuration / 10); + settings.push( + `${translateText("private_lobby.pvp_immunity")}: ${renderDuration(seconds)}`, + ); + } + if (config.startingGold) { + const millions = parseFloat( + (config.startingGold / 1_000_000).toPrecision(12), + ); + settings.push( + `${translateText("private_lobby.starting_gold")}: ${millions}M`, + ); + } + if (config.goldMultiplier) { + settings.push( + `${translateText("host_modal.gold_multiplier")}: x${config.goldMultiplier}`, + ); + } + if (config.disableAlliances) { + settings.push(translateText("host_modal.disable_alliances")); + } + if (config.waterNukes) { + settings.push(translateText("host_modal.water_nukes")); + } + if ((isTeam && !config.donateGold) || (!isTeam && config.donateGold)) { + settings.push(translateText("host_modal.donate_gold")); + } + if ((isTeam && !config.donateTroops) || (!isTeam && config.donateTroops)) { + settings.push(translateText("host_modal.donate_troops")); + } + if (config.disabledUnits && config.disabledUnits.length > 0) { + settings.push(translateText("private_lobby.disabled_units")); + } + if (config.hostCheats) { + settings.push(translateText("private_lobby.host_cheats")); + } + return settings; + } + + private toggleListedPrivateGameSettings(gameID: string) { + const expanded = new Set(this.expandedListedPrivateGameSettings); + if (expanded.has(gameID)) { + expanded.delete(gameID); + } else { + expanded.add(gameID); + } + this.expandedListedPrivateGameSettings = expanded; + } + + private toggleListedPrivateGamesExpanded = () => { + this.listedPrivateGamesExpanded = !this.listedPrivateGamesExpanded; + }; + + private joinListedPrivateGame(lobby: PublicGameInfo) { + this.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { + gameID: lobby.gameID, + source: "listed-private", + publicLobbyInfo: lobby, + } as JoinLobbyEvent, + bubbles: true, + composed: true, + }), + ); + } + protected onOpen(args?: Record): void { const lobbyId = typeof args?.lobbyId === "string" ? args.lobbyId : ""; const lobbyInfo = args?.lobbyInfo as GameInfo | PublicGameInfo | undefined; @@ -374,14 +610,28 @@ export class JoinLobbyModal extends BaseModal { this.lobbyCreatorClientID = null; this.isConnecting = true; this.leaveLobbyOnClose = true; + this.listedPrivateGamesExpanded = false; + this.expandedListedPrivateGameSettings = new Set(); } disconnectedCallback() { this.clearCountdownTimer(); this.stopLobbyUpdates(); + document.removeEventListener( + "public-lobbies-update", + this.handlePublicLobbiesUpdate, + ); super.disconnectedCallback(); } + connectedCallback() { + super.connectedCallback(); + document.addEventListener( + "public-lobbies-update", + this.handlePublicLobbiesUpdate, + ); + } + public closeAndLeave() { this.leaveLobby(); try { diff --git a/src/client/Main.ts b/src/client/Main.ts index bd73b5f46..e89720b66 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -240,7 +240,13 @@ export interface JoinLobbyEvent { gameStartInfo?: GameStartInfo; // GameRecord exists when replaying an archived game. gameRecord?: GameRecord; - source?: "public" | "private" | "host" | "matchmaking" | "singleplayer"; + source?: + | "public" + | "listed-private" + | "private" + | "host" + | "matchmaking" + | "singleplayer"; publicLobbyInfo?: GameInfo | PublicGameInfo; } @@ -824,7 +830,7 @@ class Client { this.lobbyHandle.stop(true); document.body.classList.remove("in-game"); } - if (lobby.source === "public") { + if (lobby.source === "public" || lobby.source === "listed-private") { this.joinModal?.open({ lobbyId: lobby.gameID, lobbyInfo: lobby.publicLobbyInfo, diff --git a/src/client/components/GameConfigSettings.ts b/src/client/components/GameConfigSettings.ts index 39184d267..39acd3357 100644 --- a/src/client/components/GameConfigSettings.ts +++ b/src/client/components/GameConfigSettings.ts @@ -163,6 +163,7 @@ function renderSectionHeader( export interface ToggleOptionConfig { labelKey: string; + descriptionKey?: string; checked: boolean; hidden?: boolean; } @@ -284,6 +285,27 @@ export class GameConfigSettings extends LitElement { private renderOptionToggle(toggle: ToggleOptionConfig): TemplateResult { if (toggle.hidden) return html``; + if (toggle.descriptionKey) { + return html` + + `; + } + return renderTextCardButton( translateText(toggle.labelKey), toggle.checked, diff --git a/src/core/ListedPrivateGame.ts b/src/core/ListedPrivateGame.ts new file mode 100644 index 000000000..b1c89846f --- /dev/null +++ b/src/core/ListedPrivateGame.ts @@ -0,0 +1,7 @@ +export const LISTED_PRIVATE_GAME_FLARE = "game:*"; +export const LISTED_PRIVATE_GAME_TYPE = "listed-private"; + +export function hasListedPrivateGameFlare(flares: readonly string[]): boolean { + return true; + // return flares.includes(LISTED_PRIVATE_GAME_FLARE); +} diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 4a1636e19..f3567ec42 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -20,6 +20,7 @@ import { Trios, UnitType, } from "./game/Game"; +import { LISTED_PRIVATE_GAME_TYPE } from "./ListedPrivateGame"; import { PlayerStatsSchema } from "./StatsSchemas"; import { flattenedEmojiTable } from "./Util"; @@ -139,7 +140,12 @@ export type PublicGames = z.infer; export type PublicGameInfo = z.infer; export type PublicGameType = z.infer; -export const PublicGameTypeSchema = z.enum(["ffa", "team", "special"]); +export const PublicGameTypeSchema = z.enum([ + "ffa", + "team", + "special", + LISTED_PRIVATE_GAME_TYPE, +] as const); export const UsernameSchema = z .string() @@ -221,6 +227,7 @@ export const GameConfigSchema = z.object({ donateTroops: z.boolean(), // Configures donations to humans only gameType: z.enum(GameType), gameMode: z.enum(GameMode), + listedPrivateGame: z.boolean().optional(), rankedType: z.enum(RankedType).optional(), // Only set for ranked games. gameMapSize: z.enum(GameMapSize), publicGameModifiers: z diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 72065a206..455fcc160 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -28,6 +28,15 @@ export class GameManager { ); } + public listedPrivateLobbies(): GameServer[] { + return Array.from(this.games.values()).filter( + (g) => + g.phase() === GamePhase.Lobby && + !g.isPublic() && + g.gameConfig.listedPrivateGame === true, + ); + } + joinClient( client: Client, gameID: GameID, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 43f26ce38..eea98c5bf 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { isAdminRole } from "../core/ApiSchemas"; import { GameEnv } from "../core/configuration/Config"; import { GameType } from "../core/game/Game"; +import { hasListedPrivateGameFlare } from "../core/ListedPrivateGame"; import { ClientID, ClientMessageSchema, @@ -177,6 +178,9 @@ export class GameServer { if (gameConfig.waterNukes !== undefined) { this.gameConfig.waterNukes = gameConfig.waterNukes ?? undefined; } + if (gameConfig.listedPrivateGame !== undefined) { + this.gameConfig.listedPrivateGame = gameConfig.listedPrivateGame; + } this.gameConfig.hostCheats = gameConfig.hostCheats; } @@ -473,6 +477,20 @@ export class GameServer { return; } + if ( + stampedIntent.config.listedPrivateGame === true && + !hasListedPrivateGameFlare(client.flares ?? []) + ) { + this.log.warn( + `Client without required flare attempted to list private game`, + { + gameID: this.id, + clientID: client.clientID, + }, + ); + return; + } + this.log.info( `Lobby creator updated game config via WebSocket`, { diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 99e7031ee..9745344c9 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -189,14 +189,16 @@ const MUTUALLY_EXCLUSIVE_MODIFIERS: [ModifierKey, ModifierKey][] = [ ["isNukesDisabled", "isWaterNukes"], ]; +type ScheduledPublicGameType = Exclude; + export class MapPlaylist { - private playlists: Record = { + private playlists: Record = { ffa: [], special: [], team: [], }; - public async gameConfig(type: PublicGameType): Promise { + public async gameConfig(type: ScheduledPublicGameType): Promise { if (type === "special") { return this.getSpecialConfig(); } @@ -488,7 +490,7 @@ export class MapPlaylist { } satisfies GameConfig; } - private getNextMap(type: PublicGameType): GameMapType { + private getNextMap(type: ScheduledPublicGameType): GameMapType { const playlist = this.playlists[type]; if (playlist.length === 0) { playlist.push(...this.generateNewPlaylist(type)); @@ -496,7 +498,7 @@ export class MapPlaylist { return playlist.shift()!; } - private generateNewPlaylist(type: PublicGameType): GameMapType[] { + private generateNewPlaylist(type: ScheduledPublicGameType): GameMapType[] { const maps = this.buildMapsList(type); const rand = new PseudoRandom(Date.now()); const playlist: GameMapType[] = []; @@ -545,7 +547,7 @@ export class MapPlaylist { return false; } - private buildMapsList(type: PublicGameType): GameMapType[] { + private buildMapsList(type: ScheduledPublicGameType): GameMapType[] { const maps: GameMapType[] = []; (Object.keys(GameMapType) as GameMapName[]).forEach((key) => { const map = GameMapType[key]; diff --git a/src/server/MasterLobbyService.ts b/src/server/MasterLobbyService.ts index 25e4d23df..5d689ad4a 100644 --- a/src/server/MasterLobbyService.ts +++ b/src/server/MasterLobbyService.ts @@ -1,5 +1,6 @@ import { Worker } from "cluster"; import winston from "winston"; +import { LISTED_PRIVATE_GAME_TYPE } from "../core/ListedPrivateGame"; import { PublicGameInfo, PublicGameType } from "../core/Schemas"; import { generateID } from "../core/Util"; import { @@ -85,9 +86,13 @@ export class MasterLobbyService { ffa: [], team: [], special: [], + [LISTED_PRIVATE_GAME_TYPE]: [], }; for (const lobby of lobbies) { + if (!result[lobby.publicGameType]) { + continue; + } result[lobby.publicGameType].push(lobby); } @@ -131,7 +136,7 @@ export class MasterLobbyService { private async maybeScheduleLobby() { const lobbiesByType = this.getAllLobbies(); - for (const type of Object.keys(lobbiesByType) as PublicGameType[]) { + for (const type of ["ffa", "team", "special"] as const) { const lobbies = lobbiesByType[type]; // Always ensure the next lobby has a timer, even if we already have 2+ diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 39e830b61..0bd1be291 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -9,6 +9,7 @@ import { WebSocket, WebSocketServer } from "ws"; import { z } from "zod"; import { GameEnv } from "../core/configuration/Config"; import { GameType } from "../core/game/Game"; +import { hasListedPrivateGameFlare } from "../core/ListedPrivateGame"; import { ClientMessageSchema, GameID, @@ -145,10 +146,11 @@ export async function startWorker() { // Extract persistentID from Authorization header token // Never accept persistentID directly from client let creatorPersistentID: string | undefined; + let creatorToken: string | undefined; const authHeader = req.headers.authorization; if (authHeader?.startsWith("Bearer ")) { - const token = authHeader.substring("Bearer ".length); - const result = await verifyClientToken(token); + creatorToken = authHeader.substring("Bearer ".length); + const result = await verifyClientToken(creatorToken); if (result.type === "success") { creatorPersistentID = result.persistentId; } else { @@ -186,6 +188,24 @@ export async function startWorker() { return res.status(401).send("Unauthorized"); } + if (gc?.listedPrivateGame === true) { + if (creatorToken === undefined) { + return res.status(401).json({ error: "Unauthorized" }); + } + const userMe = await getUserMe(creatorToken); + if (userMe.type === "error") { + log.warn(`cannot verify listed private game flare: ${userMe.message}`); + return res.status(401).json({ error: "Unauthorized" }); + } + const creatorFlares = userMe.response.player.flares ?? []; + if (!hasListedPrivateGameFlare(creatorFlares)) { + log.warn( + `Forbidden: player without game:* flare attempted to create listed private game`, + ); + return res.status(403).json({ error: "Forbidden" }); + } + } + // Double-check this worker should host this game const expectedWorkerId = ServerEnv.workerIndex(id); if (expectedWorkerId !== workerId) { diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index 3c5dab1d5..dba6393ef 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -1,6 +1,7 @@ import http from "http"; import { WebSocket, WebSocketServer } from "ws"; -import { PublicGameInfo, PublicGames } from "../core/Schemas"; +import { LISTED_PRIVATE_GAME_TYPE } from "../core/ListedPrivateGame"; +import { GameInfo, PublicGameInfo, PublicGames } from "../core/Schemas"; import { GameManager } from "./GameManager"; import { MasterMessageSchema, @@ -83,18 +84,27 @@ export class WorkerLobbyService { } private sendMyLobbiesToMaster() { - const lobbies = this.gm + const toLobbyInfo = ( + gi: GameInfo, + publicGameType: PublicGameInfo["publicGameType"], + ) => { + return { + gameID: gi.gameID, + numClients: gi.clients?.length ?? 0, + startsAt: gi.startsAt, + gameConfig: gi.gameConfig, + publicGameType, + } satisfies PublicGameInfo; + }; + const publicLobbies = this.gm .publicLobbies() .map((g) => g.gameInfo()) - .map((gi) => { - return { - gameID: gi.gameID, - numClients: gi.clients?.length ?? 0, - startsAt: gi.startsAt, - gameConfig: gi.gameConfig, - publicGameType: gi.publicGameType!, - } satisfies PublicGameInfo; - }); + .map((gi) => toLobbyInfo(gi, gi.publicGameType!)); + const listedPrivateLobbies = this.gm + .listedPrivateLobbies() + .map((g) => g.gameInfo()) + .map((gi) => toLobbyInfo(gi, LISTED_PRIVATE_GAME_TYPE)); + const lobbies = [...publicLobbies, ...listedPrivateLobbies]; process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList); }