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 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.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; // 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: generateID(), } 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.dispatchEvent( new CustomEvent("join-lobby", { detail: { gameID: lobbyId, gameRecord: parsed.data, clientID: generateID(), } 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; } } } }