import { html, TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { getActiveModifiers, getGameModeLabel, getMapName, renderDuration, renderNumber, translateText, } from "../client/Utils"; import { EventBus } from "../core/EventBus"; import { ClientInfo, GAME_ID_REGEX, GameConfig, GameInfo, GameRecordSchema, LobbyInfoEvent, PublicGameInfo, } from "../core/Schemas"; 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 isPrivateLobby(): boolean { return this.gameConfig?.gameType === GameType.Private; } private readonly handleLobbyInfo = (event: LobbyInfoEvent) => { const lobby = event.lobby; this.currentClientID = event.myClientID; // Only stop showing spinner when we have player info if (this.isConnecting && lobby.clients) { this.isConnecting = false; } this.updateFromLobby({ ...lobby, startsAt: lobby.startsAt ?? 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.players?.length ?? 0; const hostClientID = this.isPrivateLobby() ? (this.lobbyCreatorClientID ?? "") : ""; const content = html`
${modalHeader({ title: translateText("public_lobby.title"), onBack: () => this.closeAndLeave(), ariaLabel: translateText("common.close"), rightContent: this.currentLobbyId && this.isPrivateLobby() ? html` ` : undefined, })}
${this.isConnecting ? html`

${translateText("public_lobby.connecting")}

` : html` ${this.gameConfig ? this.renderGameConfig() : html``} ${this.players.length > 0 ? html` ` : ""} `}
${this.isPrivateLobby() ? 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 | PublicGameInfo) { super.open(); if (lobbyId) { this.startTrackingLobby(lobbyId, lobbyInfo); // If opened with lobbyId but no lobbyInfo (URL join case), auto-join the lobby if (!lobbyInfo) { 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 startTrackingLobby( lobbyId: string, lobbyInfo?: GameInfo | PublicGameInfo, ) { this.currentLobbyId = lobbyId; // clientID will be assigned by server via lobby_info message this.currentClientID = ""; this.gameConfig = null; this.players = []; 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 ("clients" in lobbyInfo && 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.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 = getMapName(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 | PublicGameInfo) { this.players = "clients" in lobby ? (lobby.clients ?? []) : []; this.lobbyStartAt = lobby.startsAt ?? null; this.syncCountdownTimer(); if (lobby.gameConfig) { const mapChanged = this.gameConfig?.gameMap !== lobby.gameConfig.gameMap; this.gameConfig = lobby.gameConfig; if (mapChanged) { this.loadNationCount(); } } this.lobbyCreatorClientID = "lobbyCreatorClientID" in lobby ? (lobby.lobbyCreatorClientID ?? null) : null; } 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(e: SubmitEvent): Promise { e.preventDefault(); 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, source: "private", } 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, source: "private", } as JoinLobbyEvent, bubbles: true, composed: true, }), ); return "success"; } }