diff --git a/resources/lang/en.json b/resources/lang/en.json index af45bd6bfe..b704cdf686 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -5,6 +5,12 @@ "svg": "uk_us_flag", "lang_code": "en" }, + "units": { + "second_short": "s", + "minute_short": "m", + "minute": "min", + "million_short": "M" + }, "common": { "not_logged_in": "Not logged in", "close": "Close", @@ -527,7 +533,7 @@ "error": "Error" }, "private_lobby": { - "title": "Join Private Lobby", + "title": "Join Custom Lobby", "enter_id": "Enter Lobby ID", "join_lobby": "Join Lobby", "not_found": "Lobby not found. Please check the ID and try again.", @@ -573,7 +579,7 @@ "tag_invalid_chars": "Clan tag can only contain letters and numbers." }, "host_modal": { - "title": "Create Private Lobby", + "title": "Create Custom Lobby", "mode": "Mode", "team_count": "Number of Teams", "options_title": "Options", @@ -616,7 +622,12 @@ "starting_gold": "Starting Gold (Millions)", "starting_gold_placeholder": "5", "host_cheats": "Host Cheats", - "leave_confirmation": "Are you sure you want to leave the lobby?" + "leave_confirmation": "Are you sure you want to leave the lobby?", + "open_to_public_title": "Open to Public", + "open_to_public_desc": "Allow anyone to find and join this lobby from the main menu.", + "open_to_public_active": "Open to Public", + "open_button": "Open to Public", + "close_to_public_button": "Close to Public" }, "team_colors": { "red": "Red", @@ -644,8 +655,17 @@ }, "game_mode": { "ffa": "Free for All", + "team": "Teams", "teams": "Teams" }, + "join_lobby": { + "open_custom_section_title": "Open Custom Lobbies", + "no_open_custom_lobbies": "No open custom lobbies at the moment.", + "join_button": "Join", + "expand": "Show details", + "collapse": "Hide details", + "no_custom_options": "Default settings" + }, "mode_selector": { "teams_title": "Teams", "teams_count": "{teamCount} teams", diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index 660dd222a8..312c8071cb 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -81,6 +81,13 @@ export class GameModeSelector extends LitElement { ); this.requestUpdate(); + const joinModal = document.querySelector( + "join-lobby-modal", + ) as JoinLobbyModal | null; + if (joinModal) { + joinModal.openLobbies = lobbies.openLobbies ?? []; + } + const allGames = Object.values(lobbies.games ?? {}).flat(); for (const game of allGames) { const mapType = game.gameConfig?.gameMap as GameMapType; diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 5b683ba568..143fb656ca 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -86,6 +86,7 @@ export class HostLobbyModal extends BaseModal { @state() private hostCheatStartingGold: boolean = false; @state() private hostCheatStartingGoldValue: number | undefined = undefined; @state() private lobbyCreatorClientID: string = ""; + @state() private openToPublicType: "ffa" | "team" | "special" | null = null; @property({ attribute: false }) eventBus: EventBus | null = null; // Timers for debouncing slider changes @@ -104,6 +105,8 @@ export class HostLobbyModal extends BaseModal { if (lobby.clients) { this.clients = lobby.clients; } + this.openToPublicType = + (lobby.openCustomType as "ffa" | "team" | "special" | null) ?? null; }; private getRandomString(): string { @@ -398,6 +401,8 @@ export class HostLobbyModal extends BaseModal { .nationCount=${this.nations} .onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)} > + + ${this.renderOpenToPublicSection()} @@ -417,6 +422,71 @@ export class HostLobbyModal extends BaseModal { `; } + private renderOpenToPublicSection() { + return html` +
+
+
+

+ ${translateText("host_modal.open_to_public_title")} +

+

+ ${translateText("host_modal.open_to_public_desc")} +

+
+ + ${this.openToPublicType !== null + ? html` +
+ + ${translateText("host_modal.open_to_public_active")} + + +
+ ` + : html` + + `} +
+
+ `; + } + + private openToPublic() { + const publicGameType = this.gameMode === GameMode.Team ? "team" : "ffa"; + this.dispatchEvent( + new CustomEvent("open-to-public", { + detail: { publicGameType }, + bubbles: true, + composed: true, + }), + ); + } + + private closeToPublic() { + this.dispatchEvent( + new CustomEvent("open-to-public", { + detail: { publicGameType: null }, + bubbles: true, + composed: true, + }), + ); + } + protected onOpen(): void { this.startLobbyUpdates(); this.lobbyId = generateID(); @@ -537,6 +607,7 @@ export class HostLobbyModal extends BaseModal { this.hostCheatGoldMultiplierValue = undefined; this.hostCheatStartingGold = false; this.hostCheatStartingGoldValue = undefined; + this.openToPublicType = null; this.leaveLobbyOnClose = true; } @@ -932,6 +1003,7 @@ export class HostLobbyModal extends BaseModal { detail: { config: { gameMap: this.selectedMap, + useRandomMap: this.useRandomMap, gameMapSize: this.compactMap ? GameMapSize.Compact : GameMapSize.Normal, diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index 115bff0fa4..447c77377c 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -44,6 +44,7 @@ export class JoinLobbyModal extends BaseModal { @query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement; @property({ attribute: false }) eventBus: EventBus | null = null; + @property({ attribute: false }) openLobbies: PublicGameInfo[] = []; @state() private players: ClientInfo[] = []; @state() private playerCount: number = 0; @@ -55,6 +56,7 @@ export class JoinLobbyModal extends BaseModal { @state() private serverTimeOffset: number = 0; @state() private isConnecting: boolean = true; @state() private lobbyCreatorClientID: string | null = null; + @state() private expandedLobbies: Set = new Set(); private leaveLobbyOnClose = true; private countdownTimerId: number | null = null; @@ -218,7 +220,8 @@ export class JoinLobbyModal extends BaseModal { private renderJoinForm() { return html` -
+
+
+ + + ${this.renderOpenLobbies()} +
+ `; + } + + private renderOpenLobbies() { + return html` +
+

+ ${translateText("join_lobby.open_custom_section_title")} +

+ ${this.openLobbies.length === 0 + ? html`

+ ${translateText("join_lobby.no_open_custom_lobbies")} +

` + : html`
+ ${this.openLobbies.map((lobby) => + this.renderOpenLobbyCard(lobby), + )} +
`} +
+ `; + } + + private gameModeLabel(lobby: PublicGameInfo): string { + const c = lobby.gameConfig; + const isTeam = c?.gameMode === GameMode.Team; + if (!isTeam || !c) return translateText("game_mode.ffa"); + if (c.playerTeams === HumansVsNations) { + return translateText("host_modal.teams_Humans Vs Nations"); + } + if (typeof c.playerTeams === "string") { + return translateText("host_modal.teams_" + c.playerTeams); + } + if (typeof c.playerTeams === "number") { + return translateText("public_lobby.teams", { num: c.playerTeams }); + } + return translateText("game_mode.team"); + } + + private readonly randomMapThumbnail = assetUrl("images/RandomMap.webp"); + + private renderOpenLobbyCard(lobby: PublicGameInfo) { + // =================================================================== + // EXPLICIT ISOLATED RANDOM BYPASS — do not touch / do not merge with + // the normal map resolution logic below. If the host selected Random, + // force name and thumbnail directly: no lookup, no fallback, no resolve. + // =================================================================== + const isRandomMap = lobby.gameConfig?.useRandomMap === true; + + let mapName: string; + let thumbnailUrl: string; + if (isRandomMap) { + mapName = translateText("map.random"); + thumbnailUrl = this.randomMapThumbnail; + } else { + // ----- Normal map logic (unchanged) ----- + const mapKey = lobby.gameConfig?.gameMap; + mapName = getMapName(mapKey) ?? mapKey ?? ""; + thumbnailUrl = mapKey + ? assetUrl( + `maps/${encodeURIComponent(normaliseMapKey(mapKey))}/thumbnail.webp`, + ) + : this.randomMapThumbnail; + } + + const playerCount = lobby.numClients; + const maxPlayers = lobby.gameConfig?.maxPlayers ?? 0; + const categoryLabel = this.gameModeLabel(lobby); + const isExpanded = this.expandedLobbies.has(lobby.gameID); + + return html` +
+ +
+ + ${mapName} { + if (!isRandomMap) { + (e.target as HTMLImageElement).src = this.randomMapThumbnail; + } + }} + /> + + +
+ ${mapName} + ${categoryLabel} +
+ + +
+
+ + + + + ${maxPlayers > 0 ? `${playerCount}/${maxPlayers}` : playerCount} + +
+ + +
+
+ + + ${isExpanded && lobby.gameConfig + ? this.renderOpenLobbyDetails(lobby.gameConfig) + : html``} +
+ `; + } + + private renderOpenLobbyDetails(c: GameConfig): TemplateResult { + const isTeam = c.gameMode === GameMode.Team; + const isCompact = + c.gameMapSize === GameMapSize.Compact || c.publicGameModifiers?.isCompact; + + const tags: string[] = []; + + if (c.difficulty !== Difficulty.Easy) + tags.push(translateText(`difficulty.${c.difficulty.toLowerCase()}`)); + if (c.instantBuild) tags.push(translateText("host_modal.instant_build")); + if (c.randomSpawn) tags.push(translateText("host_modal.random_spawn")); + if (c.infiniteGold) tags.push(translateText("host_modal.infinite_gold")); + if (c.infiniteTroops) + tags.push(translateText("host_modal.infinite_troops")); + if (c.disableAlliances) + tags.push(translateText("public_game_modifier.disable_alliances_label")); + if (c.waterNukes) + tags.push(translateText("public_game_modifier.water_nukes_label")); + if (isCompact) tags.push(translateText("host_modal.compact_map")); + if ((isTeam && !c.donateGold) || (!isTeam && c.donateGold)) + tags.push( + `${translateText("host_modal.donate_gold")}: ${translateText(c.donateGold ? "common.enabled" : "common.disabled")}`, + ); + if ((isTeam && !c.donateTroops) || (!isTeam && c.donateTroops)) + tags.push( + `${translateText("host_modal.donate_troops")}: ${translateText(c.donateTroops ? "common.enabled" : "common.disabled")}`, + ); + if (c.maxTimerValue) + tags.push( + `${translateText("private_lobby.game_length")}: ${c.maxTimerValue} ${translateText("units.minute")}`, + ); + if ( + c.spawnImmunityDuration && + Math.round(c.spawnImmunityDuration / 10) !== 5 + ) { + const s = Math.round(c.spawnImmunityDuration / 10); + const val = + s < 60 + ? `${s}${translateText("units.second_short")}` + : s % 60 > 0 + ? `${Math.floor(s / 60)}${translateText("units.minute_short")} ${s % 60}${translateText("units.second_short")}` + : `${Math.floor(s / 60)} ${translateText("units.minute")}`; + tags.push(`${translateText("private_lobby.pvp_immunity")}: ${val}`); + } + if (c.startingGold) + tags.push( + `${translateText("private_lobby.starting_gold")}: ${parseFloat((c.startingGold / 1_000_000).toPrecision(12))}${translateText("units.million_short")}`, + ); + if (c.goldMultiplier) + tags.push( + `${translateText("host_modal.gold_multiplier")}: x${c.goldMultiplier}`, + ); + if (c.bots !== (isCompact ? 100 : 400)) + tags.push(`${translateText("host_modal.bots")} ${c.bots}`); + if (typeof c.nations === "number") + tags.push(`${translateText("host_modal.nations")}: ${c.nations}`); + if (c.nations === "disabled") + tags.push( + `${translateText("host_modal.nations")}: ${translateText("common.disabled")}`, + ); + + const unitKeys: Record = { + City: "unit_type.city", + Port: "unit_type.port", + "Defense Post": "unit_type.defense_post", + "SAM Launcher": "unit_type.sam_launcher", + "Missile Silo": "unit_type.missile_silo", + Warship: "unit_type.warship", + Factory: "unit_type.factory", + "Atom Bomb": "unit_type.atom_bomb", + "Hydrogen Bomb": "unit_type.hydrogen_bomb", + MIRV: "unit_type.mirv", + "Trade Ship": "player_stats_table.unit.trade", + Transport: "player_stats_table.unit.trans", + "MIRV Warhead": "player_stats_table.unit.mirvw", + }; + const disabledUnits = c.disabledUnits ?? []; + + if (tags.length === 0 && disabledUnits.length === 0) { + return html` +
+

+ ${translateText("join_lobby.no_custom_options")} +

- + `; + } + + return html` +
+ ${tags.length > 0 + ? html` +
+ ${tags.map( + (tag) => html` + + ${tag} + + `, + )} +
+ ` + : html``} + ${disabledUnits.length > 0 + ? html` +
+ ${disabledUnits.map((unit) => { + const key = unitKeys[unit]; + const name = key ? translateText(key) : unit; + return html` + + ${name} + + `; + })} +
+ ` + : html``} +
`; } + private toggleLobbyExpanded(gameID: string) { + const next = new Set(this.expandedLobbies); + if (next.has(gameID)) { + next.delete(gameID); + } else { + next.add(gameID); + } + this.expandedLobbies = next; + } + + private async joinOpenLobby(gameID: string) { + this.startTrackingLobby(gameID); + try { + const gameExists = await this.checkActiveLobby(gameID); + if (this.currentLobbyId !== gameID) return; + if (!gameExists) { + this.resetTrackingState(); + this.showMessage(translateText("private_lobby.not_found"), "red"); + } + } catch { + if (this.currentLobbyId !== gameID) return; + this.resetTrackingState(); + this.showMessage(translateText("private_lobby.error"), "red"); + } + } + protected onOpen(args?: Record): void { const lobbyId = typeof args?.lobbyId === "string" ? args.lobbyId : ""; const lobbyInfo = args?.lobbyInfo as GameInfo | PublicGameInfo | undefined; @@ -374,6 +685,7 @@ export class JoinLobbyModal extends BaseModal { this.lobbyCreatorClientID = null; this.isConnecting = true; this.leaveLobbyOnClose = true; + this.expandedLobbies = new Set(); } disconnectedCallback() { @@ -410,11 +722,15 @@ export class JoinLobbyModal extends BaseModal { if (!this.gameConfig) return html``; const c = this.gameConfig; - const mapName = getMapName(c.gameMap); - const normalizedMap = normaliseMapKey(c.gameMap); - const thumbnailUrl = assetUrl( - `maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`, - ); + const isRandomMap = c.useRandomMap === true; + const mapName = isRandomMap + ? translateText("map.random") + : (getMapName(c.gameMap) ?? c.gameMap); + const thumbnailUrl = isRandomMap + ? this.randomMapThumbnail + : assetUrl( + `maps/${encodeURIComponent(normaliseMapKey(c.gameMap))}/thumbnail.webp`, + ); const isTeam = c.gameMode === GameMode.Team; let modeSubtitle: string; @@ -598,7 +914,9 @@ export class JoinLobbyModal extends BaseModal { alt=${mapName ?? c.gameMap} class="w-20 h-20 rounded-lg object-cover border border-white/10 shrink-0" @error=${(e: Event) => { - (e.target as HTMLImageElement).style.display = "none"; + if (!isRandomMap) { + (e.target as HTMLImageElement).src = this.randomMapThumbnail; + } }} />
diff --git a/src/client/Main.ts b/src/client/Main.ts index dc3fb83321..1a60ac7f82 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -54,6 +54,7 @@ import { TerritoryPatternsModal } from "./TerritoryPatternsModal"; import { TokenLoginModal } from "./TokenLoginModal"; import { SendKickPlayerIntentEvent, + SendOpenToPublicIntentEvent, SendStartGameEvent, SendUpdateGameConfigIntentEvent, } from "./Transport"; @@ -225,6 +226,7 @@ declare global { userMeResponse: CustomEvent; "leave-lobby": CustomEvent; "update-game-config": CustomEvent; + "open-to-public": CustomEvent<{ publicGameType: string | null }>; } // Fixes the globalThis.addEventListener errors @@ -381,6 +383,10 @@ class Client { "update-game-config", this.handleUpdateGameConfig.bind(this), ); + document.addEventListener( + "open-to-public", + this.handleOpenToPublic.bind(this), + ); document.addEventListener( "open-matchmaking", this.handleOpenMatchmaking.bind(this), @@ -1028,6 +1034,23 @@ class Client { } } + private handleOpenToPublic( + event: CustomEvent<{ publicGameType: string | null }>, + ) { + if (!this.eventBus) return; + const raw = event.detail.publicGameType; + const validTypes = ["ffa", "team", "special"] as const; + const isValid = + raw === null || (validTypes as readonly string[]).includes(raw); + if (!isValid) { + console.error(`[open-to-public] invalid publicGameType: ${raw}`); + return; + } + this.eventBus.emit( + new SendOpenToPublicIntentEvent(raw as "ffa" | "team" | "special" | null), + ); + } + private async getTurnstileToken( lobby: JoinLobbyEvent, ): Promise { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index fee60b966c..b99dce6a75 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -22,6 +22,7 @@ import { ClientSendWinnerMessage, GameConfig, Intent, + PublicGameType, ServerMessage, ServerMessageSchema, Winner, @@ -176,6 +177,10 @@ export class SendUpdateGameConfigIntentEvent implements GameEvent { export class SendStartGameEvent implements GameEvent {} +export class SendOpenToPublicIntentEvent implements GameEvent { + constructor(public readonly publicGameType: PublicGameType | null) {} +} + export class Transport { private socket: WebSocket | null = null; @@ -267,6 +272,10 @@ export class Transport { ); this.eventBus.on(SendStartGameEvent, () => this.onSendStartGame()); + + this.eventBus.on(SendOpenToPublicIntentEvent, (e) => + this.onSendOpenToPublicIntent(e), + ); } private startPing() { @@ -651,6 +660,13 @@ export class Transport { this.sendIntent({ type: "start_game" }); } + private onSendOpenToPublicIntent(event: SendOpenToPublicIntentEvent) { + this.sendIntent({ + type: "open_to_public", + publicGameType: event.publicGameType, + }); + } + private sendIntent(intent: Intent) { if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { const msg = { diff --git a/src/client/Utils.ts b/src/client/Utils.ts index 943e764a4a..f66233cc6d 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -1,6 +1,7 @@ import IntlMessageFormat from "intl-messageformat"; import { Duos, + GameMapType, GameMode, HumansVsNations, MessageType, @@ -16,6 +17,10 @@ import { Platform } from "./Platform"; export const TUTORIAL_VIDEO_URL = "https://www.youtube.com/embed/EN2oOog3pSs"; export function normaliseMapKey(mapName: string): string { + const enumKey = Object.keys(GameMapType).find( + (k) => GameMapType[k as keyof typeof GameMapType] === mapName, + ); + if (enumKey) return enumKey.toLowerCase(); return mapName.toLowerCase().replace(/[\s.]+/g, ""); } diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 44a19e2f54..4404a7b923 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -149,6 +149,7 @@ export const PlayerStatsTreeSchema = z.object({ Singleplayer: GameModeStatsSchema.optional(), Public: GameModeStatsSchema.optional(), Private: GameModeStatsSchema.optional(), + Custom: GameModeStatsSchema.optional(), Ranked: z.partialRecord(z.enum(RankedType), PlayerStatsLeafSchema).optional(), }); export type PlayerStatsTree = z.infer; diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 4a1636e195..71351e9b52 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -51,7 +51,8 @@ export type Intent = | KickPlayerIntent | TogglePauseIntent | UpdateGameConfigIntent - | StartGameIntent; + | StartGameIntent + | OpenToPublicIntent; export type AttackIntent = z.infer; export type CancelAttackIntent = z.infer; @@ -86,6 +87,7 @@ export type UpdateGameConfigIntent = z.infer< typeof UpdateGameConfigIntentSchema >; export type StartGameIntent = z.infer; +export type OpenToPublicIntent = z.infer; export type Turn = z.infer; export type GameConfig = z.infer; @@ -166,6 +168,7 @@ export const GameInfoSchema = z.object({ serverTime: z.number(), gameConfig: z.lazy(() => GameConfigSchema).optional(), publicGameType: PublicGameTypeSchema.optional(), + openCustomType: PublicGameTypeSchema.nullable().optional(), }); export const PublicGameInfoSchema = z.object({ @@ -179,6 +182,7 @@ export const PublicGameInfoSchema = z.object({ export const PublicGamesSchema = z.object({ serverTime: z.number(), games: z.record(PublicGameTypeSchema, z.array(PublicGameInfoSchema)), + openLobbies: z.array(PublicGameInfoSchema).optional(), }); export class LobbyInfoEvent implements GameEvent { @@ -253,6 +257,7 @@ export const GameConfigSchema = z.object({ disableAlliances: z.boolean().nullable().optional(), waterNukes: z.boolean().nullable().optional(), randomSpawn: z.boolean(), + useRandomMap: z.boolean().optional(), maxPlayers: z.number().optional(), maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes spawnImmunityDuration: z.number().int().min(0).nullable().optional(), // In ticks @@ -459,6 +464,12 @@ export const StartGameIntentSchema = z.object({ type: z.literal("start_game"), }); +export const OpenToPublicIntentSchema = z.object({ + type: z.literal("open_to_public"), + // null closes the lobby to public; non-null opens it under the given category + publicGameType: PublicGameTypeSchema.nullable(), +}); + const IntentSchema = z.discriminatedUnion("type", [ AttackIntentSchema, CancelAttackIntentSchema, @@ -485,6 +496,7 @@ const IntentSchema = z.discriminatedUnion("type", [ TogglePauseIntentSchema, UpdateGameConfigIntentSchema, StartGameIntentSchema, + OpenToPublicIntentSchema, ]); // StampedIntent = Intent with server-stamped clientID (used in turns and execution) diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 0c71f45845..503517b890 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -273,6 +273,7 @@ export enum GameType { Singleplayer = "Singleplayer", Public = "Public", Private = "Private", + Custom = "Custom", } export const isGameType = (value: unknown): value is GameType => isEnumValue(GameType, value); diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 72065a2067..786c641609 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -28,6 +28,12 @@ export class GameManager { ); } + public openCustomLobbies(): GameServer[] { + return Array.from(this.games.values()).filter( + (g) => g.phase() === GamePhase.Lobby && g.openCustomLobbyType() !== null, + ); + } + joinClient( client: Client, gameID: GameID, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 43f26ce388..7eefd3c651 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -4,7 +4,7 @@ import WebSocket from "ws"; import { z } from "zod"; import { isAdminRole } from "../core/ApiSchemas"; import { GameEnv } from "../core/configuration/Config"; -import { GameType } from "../core/game/Game"; +import { GameMode, GameType } from "../core/game/Game"; import { ClientID, ClientMessageSchema, @@ -13,6 +13,7 @@ import { GameInfo, GameStartInfo, GameStartInfoSchema, + OpenToPublicIntent, PlayerRecord, PublicGameType, ServerDesyncSchema, @@ -93,6 +94,11 @@ export class GameServer { private visibleAt?: number; + // When set, this private lobby is open for anyone to join via the custom list. + // Null means the lobby is closed to public. Once set to non-null at least once, + // gameConfig.gameType is permanently set to GameType.Custom. + private openCustomType: PublicGameType | null = null; + constructor( public readonly id: string, readonly log_: Logger, @@ -118,6 +124,9 @@ export class GameServer { if (gameConfig.gameMap !== undefined) { this.gameConfig.gameMap = gameConfig.gameMap; } + if (gameConfig.useRandomMap !== undefined) { + this.gameConfig.useRandomMap = gameConfig.useRandomMap; + } if (gameConfig.gameMapSize !== undefined) { this.gameConfig.gameMapSize = gameConfig.gameMapSize; } @@ -482,6 +491,54 @@ export class GameServer { ); this.updateGameConfig(stampedIntent.config); + // Keep openCustomType in sync with gameMode when open to public + if ( + this.openCustomType !== null && + stampedIntent.config.gameMode !== undefined + ) { + this.openCustomType = + stampedIntent.config.gameMode === GameMode.Team + ? "team" + : "ffa"; + } + return; + } + case "open_to_public": { + if (client.clientID !== this.lobbyCreatorID) { + this.log.warn(`Only lobby creator can open lobby to public`, { + clientID: client.clientID, + creatorID: this.lobbyCreatorID, + gameID: this.id, + }); + return; + } + if (this.isPublic()) { + this.log.warn( + `Cannot open a system-managed public game to public`, + { gameID: this.id }, + ); + return; + } + if (this.hasStarted()) { + this.log.warn( + `Cannot change visibility after game has started`, + { gameID: this.id }, + ); + return; + } + const intent = stampedIntent as StampedIntent & + OpenToPublicIntent; + this.openCustomType = intent.publicGameType; + if (intent.publicGameType !== null) { + // Permanently mark as Custom once opened to public + this.gameConfig.gameType = GameType.Custom; + this.log.info(`Lobby opened to public`, { + gameID: this.id, + category: intent.publicGameType, + }); + } else { + this.log.info(`Lobby closed to public`, { gameID: this.id }); + } return; } case "start_game": { @@ -961,7 +1018,8 @@ export class GameServer { gameConfig: this.gameConfig, startsAt: this.startsAt, serverTime: Date.now(), - publicGameType: this.publicGameType, + publicGameType: this.publicGameType ?? this.openCustomType ?? undefined, + openCustomType: this.openCustomType ?? undefined, }; } @@ -969,6 +1027,10 @@ export class GameServer { return this.gameConfig.gameType === GameType.Public; } + public openCustomLobbyType(): PublicGameType | null { + return this.openCustomType; + } + public kickClient( clientID: ClientID, reasonKey: string = KICK_REASON_DUPLICATE_SESSION, diff --git a/src/server/IPCBridgeSchema.ts b/src/server/IPCBridgeSchema.ts index 48293614d9..353763c721 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), + openLobbies: z.array(PublicGameInfoSchema).optional(), }); const WorkerReadySchema = z.object({ diff --git a/src/server/MasterLobbyService.ts b/src/server/MasterLobbyService.ts index 25e4d23df2..61ffe4a820 100644 --- a/src/server/MasterLobbyService.ts +++ b/src/server/MasterLobbyService.ts @@ -22,6 +22,8 @@ export class MasterLobbyService { private readonly workers = new Map(); // Worker id => the lobbies it owns. private readonly workerLobbies = new Map(); + // Worker id => open custom lobbies it owns. + private readonly workerOpenLobbies = new Map(); private readonly readyWorkers = new Set(); private started = false; @@ -47,6 +49,7 @@ export class MasterLobbyService { break; case "lobbyList": this.workerLobbies.set(workerId, msg.lobbies); + this.workerOpenLobbies.set(workerId, msg.openLobbies ?? []); break; } }); @@ -55,6 +58,7 @@ export class MasterLobbyService { removeWorker(workerId: number) { this.workers.delete(workerId); this.workerLobbies.delete(workerId); + this.workerOpenLobbies.delete(workerId); this.readyWorkers.delete(workerId); } @@ -108,11 +112,13 @@ export class MasterLobbyService { } private broadcastLobbies() { + const openLobbies = Array.from(this.workerOpenLobbies.values()).flat(); const msg = { type: "lobbiesBroadcast", publicGames: { serverTime: Date.now(), games: this.getAllLobbies(), + openLobbies, }, } satisfies MasterLobbiesBroadcast; for (const [workerId, worker] of this.workers.entries()) { diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index 3c5dab1d52..67a7d77de3 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -95,7 +95,28 @@ export class WorkerLobbyService { publicGameType: gi.publicGameType!, } satisfies PublicGameInfo; }); - process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList); + + const openLobbies = this.gm + .openCustomLobbies() + .map((g) => g.gameInfo()) + .filter( + (gi) => gi.openCustomType !== null && gi.openCustomType !== undefined, + ) + .map((gi) => { + return { + gameID: gi.gameID, + numClients: gi.clients?.length ?? 0, + startsAt: gi.startsAt, + gameConfig: gi.gameConfig, + publicGameType: gi.openCustomType!, + } satisfies PublicGameInfo; + }); + + process.send?.({ + type: "lobbyList", + lobbies, + openLobbies, + } satisfies WorkerLobbyList); } private setupUpgradeHandler() {