From 2baaebfef3b0a2aa6cae6c551812b2ba7058111e Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 3 Feb 2026 05:02:20 +0000 Subject: [PATCH] JoinLobbyModal for public and private lobbies (#3097) ## Description: Replaced the src/client/JoinPrivateLobbyModal.ts with a new src/client/JoinLobbyModal.ts which handles both public + private lobbies. image also made a "connecting" to the lobby image It also needed to be updated to address the issue with the modal using both polling + websockets ## 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: w.o.n --- index.html | 6 +- resources/lang/en.json | 13 +- src/client/ClientGameRunner.ts | 5 + src/client/JoinLobbyModal.ts | 848 +++++++++++++++++++++++ src/client/JoinPrivateLobbyModal.ts | 549 --------------- src/client/LangSelector.ts | 2 +- src/client/Main.ts | 58 +- src/client/PublicLobby.ts | 299 ++------ src/client/Utils.ts | 127 +++- src/client/components/LobbyConfigItem.ts | 29 + src/core/Schemas.ts | 20 +- src/server/GameServer.ts | 55 +- src/server/Master.ts | 2 +- vite.config.ts | 27 +- 14 files changed, 1201 insertions(+), 839 deletions(-) create mode 100644 src/client/JoinLobbyModal.ts delete mode 100644 src/client/JoinPrivateLobbyModal.ts create mode 100644 src/client/components/LobbyConfigItem.ts diff --git a/index.html b/index.html index 77bb7750d..800aaec14 100644 --- a/index.html +++ b/index.html @@ -179,11 +179,11 @@ inline class="hidden w-full h-full page-content" > - + > | null = null; const onmessage = (message: ServerMessage) => { + if (message.type === "lobby_info") { + eventBus.emit(new LobbyInfoEvent(message.lobby)); + return; + } if (message.type === "prestart") { console.log( `lobby: game prestarting: ${JSON.stringify(message, replacer)}`, diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts new file mode 100644 index 000000000..20e0e79b0 --- /dev/null +++ b/src/client/JoinLobbyModal.ts @@ -0,0 +1,848 @@ +import { html, TemplateResult } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { + getActiveModifiers, + getGameModeLabel, + normaliseMapKey, + renderDuration, + renderNumber, + translateText, +} from "../client/Utils"; +import { EventBus } from "../core/EventBus"; +import { + ClientInfo, + GAME_ID_REGEX, + GameConfig, + GameInfo, + GameRecordSchema, + LobbyInfoEvent, +} from "../core/Schemas"; +import { generateID } from "../core/Util"; +import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; +import { + GameMapSize, + GameMode, + GameType, + HumansVsNations, +} from "../core/game/Game"; +import { getApiBase } from "./Api"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; +import { JoinLobbyEvent } from "./Main"; +import { terrainMapFileLoader } from "./TerrainMapFileLoader"; +import { BaseModal } from "./components/BaseModal"; +import "./components/CopyButton"; +import "./components/LobbyConfigItem"; +import "./components/LobbyPlayerView"; +import { modalHeader } from "./components/ui/ModalHeader"; + +@customElement("join-lobby-modal") +export class JoinLobbyModal extends BaseModal { + @query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement; + + @property({ attribute: false }) eventBus: EventBus | null = null; + + @state() private players: ClientInfo[] = []; + @state() private playerCount: number = 0; + @state() private gameConfig: GameConfig | null = null; + @state() private currentLobbyId: string = ""; + @state() private currentClientID: string = ""; + @state() private nationCount: number = 0; + @state() private lobbyStartAt: number | null = null; + @state() private isConnecting: boolean = true; + @state() private lobbyCreatorClientID: string | null = null; + + private leaveLobbyOnClose = true; + private countdownTimerId: number | null = null; + private handledJoinTimeout = false; + + private readonly handleLobbyInfo = (event: LobbyInfoEvent) => { + const lobby = event.lobby; + if (!this.currentLobbyId || lobby.gameID !== this.currentLobbyId) { + return; + } + // Only stop showing spinner when we have player info + if (this.isConnecting && lobby.clients) { + this.isConnecting = false; + } + this.updateFromLobby({ + ...lobby, + msUntilStart: lobby.msUntilStart ?? undefined, + }); + }; + + render() { + // Pre-join state: show lobby ID input form + if (!this.currentLobbyId) { + return this.renderJoinForm(); + } + + // Post-join state: show lobby info (identical for public & private) + const secondsRemaining = + this.lobbyStartAt !== null + ? Math.max(0, Math.floor((this.lobbyStartAt - Date.now()) / 1000)) + : null; + const statusLabel = + secondsRemaining === null + ? translateText("public_lobby.waiting_for_players") + : secondsRemaining > 0 + ? translateText("public_lobby.starting_in", { + time: renderDuration(secondsRemaining), + }) + : translateText("public_lobby.started"); + const maxPlayers = this.gameConfig?.maxPlayers ?? 0; + const playerCount = this.playerCount; + const content = html` +
+ ${modalHeader({ + title: translateText("public_lobby.title"), + onBack: () => this.closeAndLeave(), + ariaLabel: translateText("common.close"), + rightContent: + this.currentLobbyId && + this.gameConfig?.gameType === GameType.Private + ? html` + + ` + : undefined, + })} +
+ ${this.isConnecting + ? html` +
+
+

+ ${translateText("public_lobby.connecting")} +

+
+ ` + : html` + ${this.gameConfig ? this.renderGameConfig() : html``} + ${this.players.length > 0 + ? html` + + ` + : ""} + `} +
+ + ${this.gameConfig?.gameType === GameType.Private + ? html` +
+ +
+ ` + : html` +
+
+
+ ${translateText("public_lobby.status")} + ${statusLabel} +
+ ${maxPlayers > 0 + ? html` +
+ ${playerCount}/${maxPlayers} + + + +
+ ` + : html``} +
+
+ `} +
+ `; + + if (this.inline) { + return content; + } + + return html` + + ${content} + + `; + } + + private renderJoinForm() { + const content = html` +
+ ${modalHeader({ + title: translateText("private_lobby.title"), + onBack: () => this.closeAndLeave(), + ariaLabel: translateText("common.close"), + })} +
+
+
+ + +
+ +
+
+
+ `; + + if (this.inline) { + return content; + } + + return html` + + ${content} + + `; + } + + public open(lobbyId: string = "", lobbyInfo?: GameInfo) { + super.open(); + if (lobbyId) { + this.startTrackingLobby(lobbyId, lobbyInfo); + // If opened with lobbyInfo (public lobby case), auto-join the lobby + if (lobbyInfo) { + this.joinPublicLobby(lobbyId, lobbyInfo); + } else { + // If opened with lobbyId but no lobbyInfo (URL join case), check if active and join + this.handleUrlJoin(lobbyId); + } + } + } + + private async handleUrlJoin(lobbyId: string): Promise { + try { + const gameExists = await this.checkActiveLobby(lobbyId); + if (gameExists) return; + + // Active lobby not found, check if it's an archived game + switch (await this.checkArchivedGame(lobbyId)) { + case "success": + return; + case "not_found": + this.resetTrackingState(); + this.showMessage(translateText("private_lobby.not_found"), "red"); + return; + case "version_mismatch": + this.resetTrackingState(); + this.showMessage( + translateText("private_lobby.version_mismatch"), + "red", + ); + return; + case "error": + this.resetTrackingState(); + this.showMessage(translateText("private_lobby.error"), "red"); + return; + } + } catch (error) { + console.error("Error checking lobby from URL:", error); + this.resetTrackingState(); + this.showMessage(translateText("private_lobby.error"), "red"); + } + } + + private joinPublicLobby(lobbyId: string, lobbyInfo: GameInfo) { + // Dispatch join-lobby event to actually connect to the lobby + this.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { + gameID: lobbyId, + clientID: this.currentClientID, + publicLobbyInfo: lobbyInfo, + } as JoinLobbyEvent, + bubbles: true, + composed: true, + }), + ); + } + + private startTrackingLobby(lobbyId: string, lobbyInfo?: GameInfo) { + this.currentLobbyId = lobbyId; + this.currentClientID = generateID(); + this.gameConfig = null; + this.players = []; + this.playerCount = 0; + this.nationCount = 0; + this.lobbyStartAt = null; + this.lobbyCreatorClientID = null; + this.isConnecting = true; + this.handledJoinTimeout = false; + this.startLobbyUpdates(); + if (lobbyInfo) { + this.updateFromLobby(lobbyInfo); + // Only stop showing spinner when we have player info + if (lobbyInfo.clients) { + this.isConnecting = false; + } + } + } + + private resetTrackingState() { + this.stopLobbyUpdates(); + this.currentLobbyId = ""; + this.currentClientID = ""; + this.isConnecting = false; + } + + private leaveLobby() { + if (!this.currentLobbyId) { + return; + } + this.dispatchEvent( + new CustomEvent("leave-lobby", { + detail: { lobby: this.currentLobbyId }, + bubbles: true, + composed: true, + }), + ); + } + + protected onClose(): void { + this.clearCountdownTimer(); + this.stopLobbyUpdates(); + + if (this.leaveLobbyOnClose) { + this.leaveLobby(); + this.updateHistory("/"); + } + + if (this.lobbyIdInput) this.lobbyIdInput.value = ""; + this.gameConfig = null; + this.players = []; + this.playerCount = 0; + this.currentLobbyId = ""; + this.currentClientID = ""; + this.nationCount = 0; + this.lobbyStartAt = null; + this.lobbyCreatorClientID = null; + this.isConnecting = true; + this.leaveLobbyOnClose = true; + } + + disconnectedCallback() { + this.clearCountdownTimer(); + this.stopLobbyUpdates(); + super.disconnectedCallback(); + } + + public closeAndLeave() { + this.leaveLobby(); + try { + this.updateHistory("/"); + } catch (error) { + console.warn("Failed to restore URL on leave:", error); + } + this.leaveLobbyOnClose = false; + this.close(); + } + + public closeWithoutLeaving() { + this.leaveLobbyOnClose = false; + this.close(); + } + + private updateHistory(url: string): void { + if (!crazyGamesSDK.isOnCrazyGames()) { + history.replaceState(null, "", url); + } + } + + // --- Game config rendering --- + + private renderGameConfig(): TemplateResult { + if (!this.gameConfig) return html``; + + const c = this.gameConfig; + const mapName = translateText("map." + normaliseMapKey(c.gameMap)); + const modeName = getGameModeLabel(c); + const modifiers = getActiveModifiers(c.publicGameModifiers); + + return html` +
+ + + ${modifiers.map( + (m) => html` + + `, + )} + ${c.gameMode !== GameMode.FFA && + c.playerTeams && + c.playerTeams !== HumansVsNations + ? html` + + ` + : html``} +
+ ${this.renderDisabledUnits()} + `; + } + + private renderDisabledUnits(): TemplateResult { + if ( + !this.gameConfig || + !this.gameConfig.disabledUnits || + this.gameConfig.disabledUnits.length === 0 + ) { + return html``; + } + + 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", + }; + + return html` +
+
+ ${translateText("private_lobby.disabled_units")} +
+
+ ${this.gameConfig.disabledUnits.map((unit) => { + const key = unitKeys[unit]; + const name = key ? translateText(key) : unit; + return html` + + ${name} + + `; + })} +
+
+ `; + } + + // --- Lobby event handling --- + + private updateFromLobby(lobby: GameInfo) { + if (lobby.clients) { + this.players = lobby.clients; + this.playerCount = lobby.clients.length; + this.lobbyCreatorClientID = lobby.clients[0]?.clientID ?? null; + } else { + this.players = []; + this.playerCount = lobby.numClients ?? 0; + } + if (lobby.msUntilStart !== undefined) { + this.lobbyStartAt = lobby.msUntilStart + Date.now(); + } else { + this.lobbyStartAt = null; + } + this.syncCountdownTimer(); + if (lobby.gameConfig) { + const mapChanged = this.gameConfig?.gameMap !== lobby.gameConfig.gameMap; + this.gameConfig = lobby.gameConfig; + if (mapChanged) { + this.loadNationCount(); + } + } + } + + private startLobbyUpdates() { + this.stopLobbyUpdates(); + if (!this.eventBus) { + console.warn( + "JoinLobbyModal: eventBus not set, cannot subscribe to lobby updates", + ); + return; + } + this.eventBus.on(LobbyInfoEvent, this.handleLobbyInfo); + } + + private stopLobbyUpdates() { + this.eventBus?.off(LobbyInfoEvent, this.handleLobbyInfo); + } + + // --- Countdown timer --- + + private syncCountdownTimer() { + if (this.lobbyStartAt === null) { + this.clearCountdownTimer(); + return; + } + if (this.countdownTimerId !== null) { + return; + } + this.countdownTimerId = window.setInterval(() => { + this.checkForJoinTimeout(); + this.requestUpdate(); + }, 1000); + } + + private clearCountdownTimer() { + if (this.countdownTimerId === null) { + return; + } + clearInterval(this.countdownTimerId); + this.countdownTimerId = null; + } + + private checkForJoinTimeout() { + if ( + this.handledJoinTimeout || + !this.isConnecting || + this.lobbyStartAt === null || + !this.isModalOpen + ) { + return; + } + if (Date.now() < this.lobbyStartAt) { + return; + } + this.handledJoinTimeout = true; + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: translateText("public_lobby.join_timeout"), + color: "red", + duration: 3500, + }, + }), + ); + this.closeAndLeave(); + } + + // --- Nation count --- + + private async loadNationCount() { + if (!this.gameConfig) { + this.nationCount = 0; + return; + } + const currentMap = this.gameConfig.gameMap; + try { + const mapData = terrainMapFileLoader.getMapData(currentMap); + const manifest = await mapData.manifest(); + if (this.gameConfig?.gameMap === currentMap) { + this.nationCount = manifest.nations.length; + } + } catch (error) { + console.warn("Failed to load nation count", error); + if (this.gameConfig?.gameMap === currentMap) { + this.nationCount = 0; + } + } + } + + // --- Private lobby join flow (lobby ID input) --- + + private isValidLobbyId(value: string): boolean { + return GAME_ID_REGEX.test(value); + } + + private normalizeLobbyId(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + const extracted = this.extractLobbyIdFromUrl(trimmed).trim(); + if (!this.isValidLobbyId(extracted)) return null; + return extracted; + } + + private sanitizeForLog(value: string): string { + return value.replace(/[\r\n]/g, ""); + } + + private extractLobbyIdFromUrl(input: string): string { + if (!input.startsWith("http")) { + return input; + } + + try { + const url = new URL(input); + const match = url.pathname.match(/game\/([^/]+)/); + const candidate = match?.[1]; + if (candidate && GAME_ID_REGEX.test(candidate)) return candidate; + + return input; + } catch (error) { + console.warn("Failed to parse lobby URL", error); + return input; + } + } + + private setLobbyId(id: string) { + if (this.lobbyIdInput) { + this.lobbyIdInput.value = this.extractLobbyIdFromUrl(id); + } + } + + private handleChange(e: Event) { + const value = (e.target as HTMLInputElement).value.trim(); + this.setLobbyId(value); + } + + private async pasteFromClipboard() { + try { + const clipText = await navigator.clipboard.readText(); + this.setLobbyId(clipText); + } catch (err) { + console.error("Failed to read clipboard contents: ", err); + } + } + + private async joinLobbyFromInput(): Promise { + const lobbyId = this.normalizeLobbyId(this.lobbyIdInput.value); + if (!lobbyId) { + this.showMessage(translateText("private_lobby.not_found"), "red"); + return; + } + + this.lobbyIdInput.value = lobbyId; + console.log(`Joining lobby with ID: ${this.sanitizeForLog(lobbyId)}`); + + // Initialize tracking state before checking/joining + this.startTrackingLobby(lobbyId); + + try { + const gameExists = await this.checkActiveLobby(lobbyId); + if (gameExists) return; + + switch (await this.checkArchivedGame(lobbyId)) { + case "success": + return; + case "not_found": + this.resetTrackingState(); + this.showMessage(translateText("private_lobby.not_found"), "red"); + return; + case "version_mismatch": + this.resetTrackingState(); + this.showMessage( + translateText("private_lobby.version_mismatch"), + "red", + ); + return; + case "error": + this.resetTrackingState(); + this.showMessage(translateText("private_lobby.error"), "red"); + return; + } + } catch (error) { + console.error("Error checking lobby existence:", error); + this.resetTrackingState(); + this.showMessage(translateText("private_lobby.error"), "red"); + } + } + + private showMessage(message: string, color: "green" | "red" = "green") { + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { message, duration: 3000, color }, + }), + ); + } + + private async checkActiveLobby(lobbyId: string): Promise { + const config = await getServerConfigFromClient(); + const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`; + + const response = await fetch(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + return false; + } + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + return false; + } + + let gameInfo: { exists?: boolean }; + try { + gameInfo = await response.json(); + } catch (error) { + console.warn("Failed to parse active lobby response", error); + return false; + } + + if (gameInfo.exists) { + this.showMessage(translateText("private_lobby.joined_waiting")); + + // Use the clientID that was already set by startTrackingLobby in open() + this.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { + gameID: lobbyId, + clientID: this.currentClientID, + } as JoinLobbyEvent, + bubbles: true, + composed: true, + }), + ); + + // Event tracking is already started by open() -> startTrackingLobby() + // LobbyInfoEvents will update the UI as they arrive + return true; + } + + return false; + } + + private async checkArchivedGame( + lobbyId: string, + ): Promise<"success" | "not_found" | "version_mismatch" | "error"> { + const archiveResponse = await fetch(`${getApiBase()}/game/${lobbyId}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (archiveResponse.status === 404) { + return "not_found"; + } + if (archiveResponse.status !== 200) { + return "error"; + } + + const archiveData = await archiveResponse.json(); + const parsed = GameRecordSchema.safeParse(archiveData); + if (!parsed.success) { + return "version_mismatch"; + } + + if ( + window.GIT_COMMIT !== "DEV" && + parsed.data.gitCommit !== window.GIT_COMMIT + ) { + const safeLobbyId = this.sanitizeForLog(lobbyId); + console.warn( + `Git commit hash mismatch for game ${safeLobbyId}`, + archiveData.details, + ); + return "version_mismatch"; + } + + // If the modal closes as part of joining the replay, do not leave/reset URL + this.leaveLobbyOnClose = false; + + this.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { + gameID: lobbyId, + gameRecord: parsed.data, + clientID: this.currentClientID, + } as JoinLobbyEvent, + bubbles: true, + composed: true, + }), + ); + return "success"; + } +} diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts deleted file mode 100644 index dadccccef..000000000 --- a/src/client/JoinPrivateLobbyModal.ts +++ /dev/null @@ -1,549 +0,0 @@ -import { html, TemplateResult } from "lit"; -import { customElement, query, state } from "lit/decorators.js"; -import { translateText } from "../client/Utils"; -import { - ClientInfo, - GAME_ID_REGEX, - GameConfig, - GameInfo, - GameRecordSchema, -} from "../core/Schemas"; -import { generateID } from "../core/Util"; -import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; -import { GameMapSize, GameMode } from "../core/game/Game"; -import { getApiBase } from "./Api"; -import { JoinLobbyEvent } from "./Main"; -import { terrainMapFileLoader } from "./TerrainMapFileLoader"; -import { BaseModal } from "./components/BaseModal"; -import "./components/CopyButton"; -import "./components/Difficulties"; -import "./components/LobbyPlayerView"; -import { modalHeader } from "./components/ui/ModalHeader"; -@customElement("join-private-lobby-modal") -export class JoinPrivateLobbyModal extends BaseModal { - @query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement; - @state() private message: string = ""; - @state() private hasJoined = false; - @state() private players: ClientInfo[] = []; - @state() private gameConfig: GameConfig | null = null; - @state() private lobbyCreatorClientID: string | null = null; - @state() private currentLobbyId: string = ""; - @state() private currentClientID: string = ""; - @state() private nationCount: number = 0; - - private playersInterval: NodeJS.Timeout | null = null; - private mapLoader = terrainMapFileLoader; - - private leaveLobbyOnClose = true; - - updated(changedProperties: Map) { - super.updated(changedProperties); - } - - render() { - const content = html` -
- ${modalHeader({ - title: translateText("private_lobby.title"), - onBack: this.closeAndLeave, - ariaLabel: translateText("common.close"), - rightContent: this.hasJoined - ? html` - - ` - : undefined, - })} -
- ${!this.hasJoined - ? html`
-
- - -
- -
` - : ""} - ${this.renderGameConfig()} - ${this.hasJoined && this.players.length > 0 - ? html` - - ` - : ""} -
- - ${this.hasJoined && this.players.length > 0 - ? html`
- -
` - : ""} -
- `; - - if (this.inline) { - return content; - } - - return html` - - ${content} - - `; - } - - private renderConfigItem( - label: string, - value: string | TemplateResult, - ): TemplateResult { - return html` -
- ${label} - ${value} -
- `; - } - - private renderGameConfig(): TemplateResult { - if (!this.gameConfig) return html``; - - const c = this.gameConfig; - const mapName = translateText( - "map." + c.gameMap.toLowerCase().replace(/ /g, ""), - ); - const modeName = - c.gameMode === "Free For All" - ? translateText("game_mode.ffa") - : translateText("game_mode.teams"); - const diffName = translateText( - "difficulty." + c.difficulty.toLowerCase().replace(/ /g, ""), - ); - - return html` -
- ${this.renderConfigItem(translateText("map.map"), mapName)} - ${this.renderConfigItem(translateText("host_modal.mode"), modeName)} - ${this.renderConfigItem( - translateText("difficulty.difficulty"), - diffName, - )} - ${this.renderConfigItem( - translateText("host_modal.bots"), - c.bots.toString(), - )} - ${c.gameMode !== "Free For All" && c.playerTeams - ? this.renderConfigItem( - typeof c.playerTeams === "string" - ? translateText("host_modal.team_type") - : translateText("host_modal.team_count"), - typeof c.playerTeams === "string" - ? translateText("host_modal.teams_" + c.playerTeams) - : c.playerTeams.toString(), - ) - : html``} -
- ${this.renderDisabledUnits()} - `; - } - - private renderDisabledUnits(): TemplateResult { - if ( - !this.gameConfig || - !this.gameConfig.disabledUnits || - this.gameConfig.disabledUnits.length === 0 - ) { - return html``; - } - - 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", - }; - - return html` -
-
- ${translateText("private_lobby.disabled_units")} -
-
- ${this.gameConfig.disabledUnits.map((unit) => { - const key = unitKeys[unit]; - const name = key ? translateText(key) : unit; - return html` - - ${name} - - `; - })} -
-
- `; - } - - public open(id: string = "") { - super.open(); - if (id) { - this.setLobbyId(id); - this.joinLobby(); - } - } - - private leaveLobby() { - if (!this.currentLobbyId || !this.hasJoined) { - return; - } - this.dispatchEvent( - new CustomEvent("leave-lobby", { - detail: { lobby: this.currentLobbyId }, - bubbles: true, - composed: true, - }), - ); - } - - protected onClose(): void { - if (this.lobbyIdInput) this.lobbyIdInput.value = ""; - this.gameConfig = null; - this.players = []; - if (this.playersInterval) { - clearInterval(this.playersInterval); - this.playersInterval = null; - } - if (this.leaveLobbyOnClose) { - this.leaveLobby(); - // Reset URL to base when modal closes - history.replaceState(null, "", window.location.origin + "/"); - } - - this.hasJoined = false; - this.message = ""; - this.currentLobbyId = ""; - this.currentClientID = ""; - this.nationCount = 0; - - this.leaveLobbyOnClose = true; - } - - public closeAndLeave() { - this.leaveLobbyOnClose = true; - this.close(); - } - - private isValidLobbyId(value: string): boolean { - return GAME_ID_REGEX.test(value); - } - - private normalizeLobbyId(input: string): string | null { - const trimmed = input.trim(); - if (!trimmed) return null; - const extracted = this.extractLobbyIdFromUrl(trimmed).trim(); - if (!this.isValidLobbyId(extracted)) return null; - return extracted; - } - - private sanitizeForLog(value: string): string { - return value.replace(/[\r\n]/g, ""); - } - - private extractLobbyIdFromUrl(input: string): string { - if (!input.startsWith("http")) { - return input; - } - - try { - const url = new URL(input); - const match = url.pathname.match(/game\/([^/]+)/); - const candidate = match?.[1]; - if (candidate && GAME_ID_REGEX.test(candidate)) return candidate; - - return input; - } catch (error) { - console.warn("Failed to parse lobby URL", error); - return input; - } - } - - private setLobbyId(id: string) { - this.lobbyIdInput.value = this.extractLobbyIdFromUrl(id); - } - - private handleChange(e: Event) { - const value = (e.target as HTMLInputElement).value.trim(); - this.setLobbyId(value); - } - - private async pasteFromClipboard() { - try { - const clipText = await navigator.clipboard.readText(); - this.setLobbyId(clipText); - } catch (err) { - console.error("Failed to read clipboard contents: ", err); - } - } - - private async joinLobby(): Promise { - const lobbyId = this.normalizeLobbyId(this.lobbyIdInput.value); - if (!lobbyId) { - this.showMessage(translateText("private_lobby.not_found"), "red"); - return; - } - - this.lobbyIdInput.value = lobbyId; - this.currentLobbyId = lobbyId; - console.log(`Joining lobby with ID: ${this.sanitizeForLog(lobbyId)}`); - - try { - // First, check if the game exists in active lobbies - const gameExists = await this.checkActiveLobby(lobbyId); - if (gameExists) return; - - // If not active, check archived games - switch (await this.checkArchivedGame(lobbyId)) { - case "success": - return; - case "not_found": - this.showMessage(translateText("private_lobby.not_found"), "red"); - this.message = ""; - return; - case "version_mismatch": - this.showMessage( - translateText("private_lobby.version_mismatch"), - "red", - ); - this.message = ""; - return; - case "error": - this.showMessage(translateText("private_lobby.error"), "red"); - this.message = ""; - return; - } - } catch (error) { - console.error("Error checking lobby existence:", error); - this.showMessage(translateText("private_lobby.error"), "red"); - this.message = ""; - } - } - - private showMessage(message: string, color: "green" | "red" = "green") { - window.dispatchEvent( - new CustomEvent("show-message", { - detail: { message, duration: 3000, color }, - }), - ); - } - - private async checkActiveLobby(lobbyId: string): Promise { - const config = await getServerConfigFromClient(); - const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`; - - const response = await fetch(url, { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); - - const gameInfo = await response.json(); - - if (gameInfo.exists) { - this.showMessage(translateText("private_lobby.joined_waiting")); - this.message = ""; - this.hasJoined = true; - this.currentClientID = generateID(); - - // If the modal closes as part of joining the game, do not leave the lobby - this.leaveLobbyOnClose = false; - - this.dispatchEvent( - new CustomEvent("join-lobby", { - detail: { - gameID: lobbyId, - clientID: this.currentClientID, - } as JoinLobbyEvent, - bubbles: true, - composed: true, - }), - ); - - this.pollPlayers(); - this.playersInterval = setInterval(() => this.pollPlayers(), 1000); - return true; - } - - return false; - } - - private async checkArchivedGame( - lobbyId: string, - ): Promise<"success" | "not_found" | "version_mismatch" | "error"> { - const archiveResponse = await fetch(`${getApiBase()}/game/${lobbyId}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - - if (archiveResponse.status === 404) { - return "not_found"; - } - if (archiveResponse.status !== 200) { - return "error"; - } - - const archiveData = await archiveResponse.json(); - const parsed = GameRecordSchema.safeParse(archiveData); - if (!parsed.success) { - return "version_mismatch"; - } - - // Allow DEV to join games created with a different version for debugging. - if ( - window.GIT_COMMIT !== "DEV" && - parsed.data.gitCommit !== window.GIT_COMMIT - ) { - const safeLobbyId = this.sanitizeForLog(lobbyId); - console.warn( - `Git commit hash mismatch for game ${safeLobbyId}`, - archiveData.details, - ); - return "version_mismatch"; - } - - this.currentClientID = generateID(); - this.dispatchEvent( - new CustomEvent("join-lobby", { - detail: { - gameID: lobbyId, - gameRecord: parsed.data, - clientID: this.currentClientID, - } as JoinLobbyEvent, - bubbles: true, - composed: true, - }), - ); - return "success"; - } - - private async pollPlayers() { - const lobbyId = this.currentLobbyId; - if (!lobbyId) return; - const config = await getServerConfigFromClient(); - - fetch(`/${config.workerPath(lobbyId)}/api/game/${lobbyId}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }) - .then((response) => response.json()) - .then((data: GameInfo) => { - this.lobbyCreatorClientID = data.clients?.[0]?.clientID ?? null; - this.players = data.clients ?? []; - if (data.gameConfig) { - const mapChanged = - this.gameConfig?.gameMap !== data.gameConfig.gameMap; - this.gameConfig = data.gameConfig; - if (mapChanged) { - this.loadNationCount(); - } - } - }) - .catch((error) => { - console.error("Error polling players:", error); - }); - } - - private async loadNationCount() { - if (!this.gameConfig) { - this.nationCount = 0; - return; - } - const currentMap = this.gameConfig.gameMap; - try { - const mapData = this.mapLoader.getMapData(currentMap); - const manifest = await mapData.manifest(); - // Only update if the map hasn't changed - if (this.gameConfig?.gameMap === currentMap) { - this.nationCount = manifest.nations.length; - } - } catch (error) { - console.warn("Failed to load nation count", error); - // Only update if the map hasn't changed - if (this.gameConfig?.gameMap === currentMap) { - this.nationCount = 0; - } - } - } -} diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index e6a71c778..98519f9b5 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -203,7 +203,7 @@ export class LangSelector extends LitElement { const components = [ "single-player-modal", "host-lobby-modal", - "join-private-lobby-modal", + "join-lobby-modal", "emoji-table", "leader-board", "leaderboard-tabs", diff --git a/src/client/Main.ts b/src/client/Main.ts index 49e14a11f..c48d0f06f 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -1,7 +1,12 @@ import version from "resources/version.txt?raw"; import { UserMeResponse } from "../core/ApiSchemas"; import { EventBus } from "../core/EventBus"; -import { GAME_ID_REGEX, GameRecord, GameStartInfo } from "../core/Schemas"; +import { + GAME_ID_REGEX, + GameInfo, + GameRecord, + GameStartInfo, +} from "../core/Schemas"; import { GameEnv } from "../core/configuration/Config"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; @@ -22,7 +27,7 @@ import "./GoogleAdElement"; import { GutterAds } from "./GutterAds"; import { HelpModal } from "./HelpModal"; import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal"; -import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal"; +import { JoinLobbyModal } from "./JoinLobbyModal"; import "./LangSelector"; import { LangSelector } from "./LangSelector"; import { initLayout } from "./Layout"; @@ -33,7 +38,7 @@ import { initNavigation } from "./Navigation"; import "./NewsModal"; import "./PatternInput"; import "./PublicLobby"; -import { PublicLobby } from "./PublicLobby"; +import { PublicLobby, ShowPublicLobbyModalEvent } from "./PublicLobby"; import { SinglePlayerModal } from "./SinglePlayerModal"; import { TerritoryPatternsModal } from "./TerritoryPatternsModal"; import { TokenLoginModal } from "./TokenLoginModal"; @@ -203,6 +208,7 @@ declare global { // Extend the global interfaces to include your custom events interface DocumentEventMap { "join-lobby": CustomEvent; + "show-public-lobby-modal": CustomEvent; "kick-player": CustomEvent; "join-changed": CustomEvent; } @@ -216,6 +222,8 @@ export interface JoinLobbyEvent { gameStartInfo?: GameStartInfo; // GameRecord exists when replaying an archived game. gameRecord?: GameRecord; + source?: "public" | "private" | "host" | "matchmaking" | "singleplayer"; + publicLobbyInfo?: GameInfo; } class Client { @@ -228,7 +236,7 @@ class Client { private flagInput: FlagInput | null = null; private hostModal: HostPrivateLobbyModal; - private joinModal: JoinPrivateLobbyModal; + private joinModal: JoinLobbyModal; private publicLobby: PublicLobby; private userSettings: UserSettings = new UserSettings(); private patternsModal: TerritoryPatternsModal; @@ -302,6 +310,10 @@ class Client { this.gutterAds = gutterAds; document.addEventListener("join-lobby", this.handleJoinLobby.bind(this)); + document.addEventListener( + "show-public-lobby-modal", + this.handleShowPublicLobbyModal.bind(this), + ); document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this)); document.addEventListener("kick-player", this.handleKickPlayer.bind(this)); document.addEventListener( @@ -504,7 +516,6 @@ class Client { hostLobbyButton.addEventListener("click", () => { if (this.usernameInput?.isValid()) { window.showPage?.("page-host-lobby"); - this.publicLobby.leaveLobby(); } else { window.dispatchEvent( new CustomEvent("show-message", { @@ -519,10 +530,12 @@ class Client { }); this.joinModal = document.querySelector( - "join-private-lobby-modal", - ) as JoinPrivateLobbyModal; - if (!this.joinModal || !(this.joinModal instanceof JoinPrivateLobbyModal)) { - console.warn("Join private lobby modal element not found"); + "join-lobby-modal", + ) as JoinLobbyModal; + if (!this.joinModal || !(this.joinModal instanceof JoinLobbyModal)) { + console.warn("Join lobby modal element not found"); + } else { + this.joinModal.eventBus = this.eventBus; } const joinPrivateLobbyButton = document.getElementById( "join-private-lobby-button", @@ -531,7 +544,7 @@ class Client { throw new Error("Missing join-private-lobby-button"); joinPrivateLobbyButton.addEventListener("click", () => { if (this.usernameInput?.isValid()) { - window.showPage?.("page-join-private-lobby"); + window.showPage?.("page-join-lobby"); } else { window.dispatchEvent( new CustomEvent("show-message", { @@ -631,7 +644,7 @@ class Client { private async handleUrl() { // Wait for modal custom elements to be defined await Promise.all([ - customElements.whenDefined("join-private-lobby-modal"), + customElements.whenDefined("join-lobby-modal"), customElements.whenDefined("host-lobby-modal"), ]); @@ -644,7 +657,7 @@ class Client { // Wait 2 seconds to ensure all elements are actually loaded, // On low end-chromebooks the join modal was not registered in time. await new Promise((resolve) => setTimeout(resolve, 2000)); - window.showPage?.("page-join-private-lobby"); + window.showPage?.("page-join-lobby"); this.joinModal?.open(lobbyId); console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`); return; @@ -733,7 +746,7 @@ class Client { const lobbyId = pathMatch && GAME_ID_REGEX.test(pathMatch[1]) ? pathMatch[1] : null; if (lobbyId) { - window.showPage?.("page-join-private-lobby"); + window.showPage?.("page-join-lobby"); this.joinModal.open(lobbyId); console.log(`joining lobby ${lobbyId}`); return; @@ -759,7 +772,10 @@ class Client { document.body.classList.remove("in-game"); } const config = await getServerConfigFromClient(); - this.updateJoinUrlForShare(lobby.gameID, config); + // Only update URL immediately for private lobbies, not public ones + if (!lobby.publicLobbyInfo && lobby.source !== "public") { + this.updateJoinUrlForShare(lobby.gameID, config); + } const pattern = this.userSettings.getSelectedPatternName( await fetchCosmetics(), @@ -796,10 +812,10 @@ class Client { document .getElementById("username-validation-error") ?.classList.add("hidden"); + this.joinModal?.closeWithoutLeaving(); [ "single-player-modal", "host-lobby-modal", - "join-private-lobby-modal", "game-starting-modal", "game-top-bar", "help-modal", @@ -884,6 +900,17 @@ class Client { } } + private handleShowPublicLobbyModal( + event: CustomEvent, + ) { + const { lobby } = event.detail; + console.log(`Opening JoinLobbyModal for public lobby ${lobby.gameID}`); + + // Open the join lobby modal page and pass the lobby info + window.showPage?.("page-join-lobby"); + this.joinModal?.open(lobby.gameID, lobby); + } + private async handleLeaveLobby(/* event: CustomEvent */) { if (this.gameStop === null) { return; @@ -902,7 +929,6 @@ class Client { document.body.classList.remove("in-game"); crazyGamesSDK.gameplayStop(); - this.publicLobby.leaveLobby(); } private handleKickPlayer(event: CustomEvent) { diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index e7610672e..dc60087fd 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -1,32 +1,25 @@ import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { renderDuration, translateText } from "../client/Utils"; -import { - Duos, - GameMapType, - GameMode, - HumansVsNations, - PublicGameModifiers, - Quads, - Trios, -} from "../core/game/Game"; +import { GameMapType } from "../core/game/Game"; import { GameID, GameInfo } from "../core/Schemas"; -import { generateID } from "../core/Util"; import { PublicLobbySocket } from "./LobbySocket"; -import { JoinLobbyEvent } from "./Main"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; +import { + getGameModeLabel, + getModifierLabels, + normaliseMapKey, + renderDuration, + translateText, +} from "./Utils"; + +export interface ShowPublicLobbyModalEvent { + lobby: GameInfo; +} @customElement("public-lobby") export class PublicLobby extends LitElement { @state() private lobbies: GameInfo[] = []; - @state() public isLobbyHighlighted: boolean = false; - @state() private isButtonDebounced: boolean = false; @state() private mapImages: Map = new Map(); - @state() private joiningDotIndex: number = 0; - - private joiningInterval: number | null = null; - private currLobby: GameInfo | null = null; - private debounceDelay: number = 150; private lobbyIDToStart = new Map(); private lobbySocket = new PublicLobbySocket((lobbies) => this.handleLobbiesUpdate(lobbies), @@ -44,7 +37,6 @@ export class PublicLobby extends LitElement { disconnectedCallback() { super.disconnectedCallback(); this.lobbySocket.stop(); - this.stopJoiningAnimation(); } private handleLobbiesUpdate(lobbies: GameInfo[]) { @@ -84,52 +76,16 @@ export class PublicLobby extends LitElement { const isStarting = timeRemaining <= 2; const timeDisplay = renderDuration(timeRemaining); - const teamCount = - lobby.gameConfig.gameMode === GameMode.Team - ? (lobby.gameConfig.playerTeams ?? 0) - : null; - - const maxPlayers = lobby.gameConfig.maxPlayers ?? 0; - const teamSize = this.getTeamSize(teamCount, maxPlayers); - const teamTotal = this.getTeamTotal(teamCount, teamSize, maxPlayers); - const modeLabel = this.getModeLabel( - lobby.gameConfig.gameMode, - teamCount, - teamTotal, - teamSize, - ); - // True when the detail label already includes the full mode text. - const { label: teamDetailLabel, isFullLabel: isTeamDetailFullLabel } = - this.getTeamDetailLabel( - lobby.gameConfig.gameMode, - teamCount, - teamTotal, - teamSize, - ); - - let fullModeLabel = modeLabel; - if (teamDetailLabel) { - fullModeLabel = isTeamDetailFullLabel - ? teamDetailLabel - : `${modeLabel} ${teamDetailLabel}`; - } - - const modifierLabel = this.getModifierLabels( + const modeLabel = getGameModeLabel(lobby.gameConfig); + const modifierLabels = getModifierLabels( lobby.gameConfig.publicGameModifiers, ); - const mapImageSrc = this.mapImages.get(lobby.gameID); return html`