import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import { GameInfo, GameRecordSchema } from "../core/Schemas"; import { generateID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { JoinLobbyEvent } from "./Main"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; import { getApiBase } from "./jwt"; @customElement("join-private-lobby-modal") export class JoinPrivateLobbyModal extends LitElement { @query("o-modal") private modalEl!: HTMLElement & { open: () => void; close: () => void; }; @query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement; @state() private message: string = ""; @state() private hasJoined = false; @state() private players: string[] = []; private playersInterval: NodeJS.Timeout | null = null; connectedCallback() { super.connectedCallback(); window.addEventListener("keydown", this.handleKeyDown); } disconnectedCallback() { window.removeEventListener("keydown", this.handleKeyDown); super.disconnectedCallback(); } private handleKeyDown = (e: KeyboardEvent) => { if (e.code === "Escape") { e.preventDefault(); this.close(); } }; render() { return html` ${this.message} ${this.hasJoined && this.players.length > 0 ? html` ${this.players.length} ${this.players.length === 1 ? translateText("private_lobby.player") : translateText("private_lobby.players")} ${this.players.map( (player) => html`${player}`, )} ` : ""} ${!this.hasJoined ? html` ` : ""} `; } createRenderRoot() { return this; // light DOM } public open(id: string = "") { this.modalEl?.open(); if (id) { this.setLobbyId(id); this.joinLobby(); } } public close() { this.lobbyIdInput.value = ""; this.modalEl?.close(); if (this.playersInterval) { clearInterval(this.playersInterval); this.playersInterval = null; } } public closeAndLeave() { this.close(); this.hasJoined = false; this.message = ""; this.dispatchEvent( new CustomEvent("leave-lobby", { detail: { lobby: this.lobbyIdInput.value }, bubbles: true, composed: true, }), ); } private extractLobbyIdFromUrl(input: string): string { if (input.startsWith("http")) { if (input.includes("#join=")) { const params = new URLSearchParams(input.split("#")[1]); return params.get("join") ?? input; } else if (input.includes("join/")) { return input.split("join/")[1]; } else { return input; } } else { 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.lobbyIdInput.value; console.log(`Joining lobby with ID: ${lobbyId}`); this.message = `${translateText("private_lobby.checking")}`; 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.message = `${translateText("private_lobby.not_found")}`; return; case "version_mismatch": this.message = `${translateText("private_lobby.version_mismatch")}`; return; case "error": this.message = `${translateText("private_lobby.error")}`; return; } } catch (error) { console.error("Error checking lobby existence:", error); this.message = `${translateText("private_lobby.error")}`; } } 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.message = translateText("private_lobby.joined_waiting"); this.hasJoined = true; this.dispatchEvent( new CustomEvent("join-lobby", { detail: { gameID: lobbyId, clientID: generateID(), } as JoinLobbyEvent, bubbles: true, composed: true, }), ); this.playersInterval = setInterval(() => this.pollPlayers(), 1000); return true; } return false; } private async checkArchivedGame( lobbyId: string, ): Promise<"success" | "not_found" | "version_mismatch" | "error"> { const archivePromise = fetch(`${getApiBase()}/game/${lobbyId}`, { method: "GET", headers: { "Content-Type": "application/json", }, }); const gitCommitPromise = fetch(`/commit.txt`, { method: "GET", headers: { "Content-Type": "application/json" }, cache: "no-cache", }); const [archiveResponse, gitCommitResponse] = await Promise.all([ archivePromise, gitCommitPromise, ]); 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"; } let myGitCommit = ""; if (gitCommitResponse.status === 404) { // commit.txt is not found when running locally myGitCommit = "DEV"; } else if (gitCommitResponse.status === 200) { myGitCommit = (await gitCommitResponse.text()).trim(); } else { console.error("Error getting git commit:", gitCommitResponse.status); return "error"; } // Allow DEV to join games created with a different version for debugging. if (myGitCommit !== "DEV" && parsed.data.gitCommit !== myGitCommit) { console.warn( `Git commit hash mismatch for game ${lobbyId}`, 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() { if (!this.lobbyIdInput?.value) return; const config = await getServerConfigFromClient(); fetch( `/${config.workerPath(this.lobbyIdInput.value)}/api/game/${this.lobbyIdInput.value}`, { method: "GET", headers: { "Content-Type": "application/json", }, }, ) .then((response) => response.json()) .then((data: GameInfo) => { this.players = data.clients?.map((p) => p.username) ?? []; }) .catch((error) => { console.error("Error polling players:", error); }); } }