| 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.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 && 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`