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.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";
}
}