import { html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ClientEnv } from "src/client/ClientEnv"; import { calculateServerTimeOffset, getSecondsUntilServerTimestamp, renderDuration, translateText, } from "../client/Utils"; import { EventBus } from "../core/EventBus"; import { Difficulty, GameMapSize, GameMapType, GameMode, UnitType, } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import { ClientInfo, GameConfig, GameInfo, LobbyInfoEvent, TeamCountConfig, isValidGameID, } from "../core/Schemas"; import { generateID } from "../core/Util"; import { getPlayToken } from "./Auth"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import { CopyButton } from "./components/CopyButton"; import "./components/GameConfigSettings"; import "./components/InputCard"; import "./components/LobbyPlayerView"; import "./components/ToggleInputCard"; import { modalHeader } from "./components/ui/ModalHeader"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { JoinLobbyEvent } from "./Main"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { getBotsForCompactMap, getNationsForCompactMap, getRandomMapType, getUpdatedDisabledUnits, parseBoundedFloatFromInput, parseBoundedIntegerFromInput, preventDisallowedKeys, sliderToNationsConfig, toOptionalNumber, } from "./utilities/GameConfigHelpers"; @customElement("host-lobby-modal") export class HostLobbyModal extends BaseModal { @state() private selectedMap: GameMapType = GameMapType.World; @state() private selectedDifficulty: Difficulty = Difficulty.Easy; @state() private nations: number = 0; @state() private defaultNationCount: number = 0; @state() private gameMode: GameMode = GameMode.FFA; @state() private teamCount: TeamCountConfig = 2; constructor() { super(); this.id = "page-host-lobby"; } @state() private bots: number = 400; @state() private spawnImmunity: boolean = false; @state() private spawnImmunityDurationMinutes: number | undefined = undefined; @state() private infiniteGold: boolean = false; @state() private donateGold: boolean = false; @state() private infiniteTroops: boolean = false; @state() private donateTroops: boolean = false; @state() private maxTimer: boolean = false; @state() private maxTimerValue: number | undefined = undefined; @state() private startDelayValue: number | undefined = 3; @state() private instantBuild: boolean = false; @state() private randomSpawn: boolean = false; @state() private compactMap: boolean = false; @state() private goldMultiplier: boolean = false; @state() private goldMultiplierValue: number | undefined = undefined; @state() private startingGold: boolean = false; @state() private startingGoldValue: number | undefined = undefined; @state() private disableAlliances: boolean = false; @state() private waterNukes: boolean = false; @state() private lobbyId = ""; @state() private lobbyUrlSuffix = ""; @state() private clients: ClientInfo[] = []; @state() private useRandomMap: boolean = false; @state() private disabledUnits: UnitType[] = []; @state() private hostCheatsEnabled: boolean = false; @state() private hostCheatInfiniteGold: boolean = false; @state() private hostCheatInfiniteTroops: boolean = false; @state() private hostCheatGoldMultiplier: boolean = false; @state() private hostCheatGoldMultiplierValue: number | undefined = undefined; @state() private hostCheatStartingGold: boolean = false; @state() private hostCheatStartingGoldValue: number | undefined = undefined; @state() private lobbyCreatorClientID: string = ""; @state() private lobbyStartAt: number | null = null; @state() private serverTimeOffset: number = 0; @property({ attribute: false }) eventBus: EventBus | null = null; // Timers for debouncing slider changes private botsUpdateTimer: number | null = null; private nationsUpdateTimer: number | null = null; private mapLoader = terrainMapFileLoader; private userSettings = new UserSettings(); private leaveLobbyOnClose = true; private readonly handleLobbyInfo = (event: LobbyInfoEvent) => { const lobby = event.lobby; if (!this.lobbyId || lobby.gameID !== this.lobbyId) { return; } if ("serverTime" in lobby && typeof lobby.serverTime === "number") { this.serverTimeOffset = calculateServerTimeOffset(lobby.serverTime); } this.lobbyStartAt = lobby.startsAt ?? null; this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? ""; if (lobby.clients) { this.clients = lobby.clients; } }; private getRandomString(): string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; return Array.from( { length: 5 }, () => chars[Math.floor(Math.random() * chars.length)], ).join(""); } private async buildLobbyUrl(): Promise { if (crazyGamesSDK.isOnCrazyGames()) { const link = crazyGamesSDK.createInviteLink(this.lobbyId); if (link !== null) { return link; } } return `${window.location.origin}/${ClientEnv.workerPath(this.lobbyId)}/game/${this.lobbyId}?lobby&s=${encodeURIComponent(this.lobbyUrlSuffix)}`; } private async constructUrl(): Promise { this.lobbyUrlSuffix = this.getRandomString(); return await this.buildLobbyUrl(); } private updateHistory(url: string): void { if (crazyGamesSDK.isOnCrazyGames()) { return; } history.replaceState(null, "", url); } private updateLobbyHistory(lobbyUrl: string): void { if (crazyGamesSDK.isOnCrazyGames()) { return; } const lobbyIdHidden = !this.userSettings.lobbyIdVisibility(); history.replaceState(null, "", lobbyIdHidden ? "/streamer-mode" : lobbyUrl); } private startLobbyUpdates() { this.stopLobbyUpdates(); if (!this.eventBus) { console.warn( "HostLobbyModal: eventBus not set, cannot subscribe to lobby updates", ); return; } this.eventBus.on(LobbyInfoEvent, this.handleLobbyInfo); } private stopLobbyUpdates() { this.eventBus?.off(LobbyInfoEvent, this.handleLobbyInfo); } protected renderHeaderSlot() { return modalHeader({ title: translateText("host_modal.title"), onBack: () => { this.leaveLobbyOnClose = true; this.close(); }, ariaLabel: translateText("common.back"), rightContent: html` `, }); } protected renderBody() { const secondsRemaining = this.lobbyStartAt !== null ? getSecondsUntilServerTimestamp( this.lobbyStartAt, this.serverTimeOffset, ) : null; const statusLabel = secondsRemaining === null ? this.clients.length === 1 ? translateText("host_modal.waiting") : translateText("host_modal.start") : translateText("host_modal.starting_in", { time: renderDuration(secondsRemaining), }); const inputCards = [ html``, html``, html``, html``, html``, ]; const hostCheatInputCards = [ html``, html``, ]; return html`
this.kickPlayer(clientID)} >
`; } protected onOpen(): void { this.startLobbyUpdates(); this.lobbyId = generateID(); // Note: clientID will be assigned by server when we join the lobby // lobbyCreatorClientID stays empty until then // Copy immediately so the host can share the link without waiting for the // server. If lobby creation fails, clear the clipboard to avoid a dead link. void this.constructUrl().then(async (url) => { this.updateLobbyHistory(url); await this.updateComplete; void (this.querySelector("copy-button") as CopyButton)?.handleCopy(); }); // Pass auth token for creator identification (server extracts persistentID from it) createLobby(this.lobbyId) .then(async (lobby) => { this.lobbyId = lobby.gameID; if (!isValidGameID(this.lobbyId)) { throw new Error(`Invalid lobby ID format: ${this.lobbyId}`); } crazyGamesSDK.showInviteButton(this.lobbyId); }) .then(() => { this.dispatchEvent( new CustomEvent("join-lobby", { detail: { gameID: this.lobbyId, source: "host", } as JoinLobbyEvent, bubbles: true, composed: true, }), ); }) .catch(() => { // Clear clipboard so the host doesn't accidentally share a dead link void navigator.clipboard.writeText("").catch(() => {}); }); if (this.modalEl) { this.modalEl.onClose = () => { this.close(); }; } this.loadNationCount(); } private leaveLobby() { if (!this.lobbyId) { return; } this.dispatchEvent( new CustomEvent("leave-lobby", { detail: { lobby: this.lobbyId }, bubbles: true, composed: true, }), ); } public confirmBeforeClose(): boolean { return confirm(translateText("host_modal.leave_confirmation")); } protected onClose(): void { console.log("Closing host lobby modal"); this.stopLobbyUpdates(); if (this.leaveLobbyOnClose) { this.leaveLobby(); this.updateHistory("/"); // Reset URL to base } crazyGamesSDK.hideInviteButton(); // Clean up timers and resources if (this.botsUpdateTimer !== null) { clearTimeout(this.botsUpdateTimer); this.botsUpdateTimer = null; } if (this.nationsUpdateTimer !== null) { clearTimeout(this.nationsUpdateTimer); this.nationsUpdateTimer = null; } // Reset all transient form state to ensure clean slate this.selectedMap = GameMapType.World; this.selectedDifficulty = Difficulty.Easy; this.nations = 0; this.defaultNationCount = 0; this.gameMode = GameMode.FFA; this.teamCount = 2; this.bots = 400; this.spawnImmunity = false; this.spawnImmunityDurationMinutes = undefined; this.infiniteGold = false; this.donateGold = false; this.infiniteTroops = false; this.donateTroops = false; this.maxTimer = false; this.maxTimerValue = undefined; this.startDelayValue = 3; this.instantBuild = false; this.randomSpawn = false; this.compactMap = false; this.useRandomMap = false; this.disabledUnits = []; this.lobbyId = ""; this.clients = []; this.lobbyCreatorClientID = ""; this.goldMultiplier = false; this.goldMultiplierValue = undefined; this.startingGold = false; this.startingGoldValue = undefined; this.disableAlliances = false; this.waterNukes = false; this.hostCheatsEnabled = false; this.hostCheatInfiniteGold = false; this.hostCheatInfiniteTroops = false; this.hostCheatGoldMultiplier = false; this.hostCheatGoldMultiplierValue = undefined; this.hostCheatStartingGold = false; this.hostCheatStartingGoldValue = undefined; this.leaveLobbyOnClose = true; } private async handleSelectRandomMap() { this.useRandomMap = true; this.selectedMap = getRandomMapType(); await this.loadNationCount(); this.putGameConfig(); } private handleConfigRandomMapSelected = () => { void this.handleSelectRandomMap(); }; private async handleMapSelection(value: GameMapType) { this.selectedMap = value; this.useRandomMap = false; await this.loadNationCount(); this.putGameConfig(); } private handleConfigMapSelected = (e: Event) => { const customEvent = e as CustomEvent<{ map: GameMapType }>; void this.handleMapSelection(customEvent.detail.map); }; private async handleDifficultySelection(value: Difficulty) { this.selectedDifficulty = value; this.putGameConfig(); } private handleConfigDifficultySelected = (e: Event) => { const customEvent = e as CustomEvent<{ difficulty: Difficulty }>; void this.handleDifficultySelection(customEvent.detail.difficulty); }; private handleConfigGameModeSelected = (e: Event) => { const customEvent = e as CustomEvent<{ mode: GameMode }>; void this.handleGameModeSelection(customEvent.detail.mode); }; private handleConfigTeamCountSelected = (e: Event) => { const customEvent = e as CustomEvent<{ count: TeamCountConfig }>; void this.handleTeamCountSelection(customEvent.detail.count); }; private handleConfigOptionToggleChanged = (e: Event) => { const customEvent = e as CustomEvent<{ labelKey: string; checked: boolean; }>; const { labelKey, checked } = customEvent.detail; switch (labelKey) { case "host_modal.instant_build": this.handleInstantBuildChange(checked); break; case "host_modal.random_spawn": this.handleRandomSpawnChange(checked); break; case "host_modal.donate_gold": this.handleDonateGoldChange(checked); break; case "host_modal.donate_troops": this.handleDonateTroopsChange(checked); break; case "host_modal.infinite_gold": this.handleInfiniteGoldChange(checked); break; case "host_modal.infinite_troops": this.handleInfiniteTroopsChange(checked); break; case "host_modal.compact_map": this.handleCompactMapChange(checked); break; case "host_modal.disable_alliances": this.disableAlliances = checked; this.putGameConfig(); break; case "host_modal.water_nukes": this.waterNukes = checked; this.putGameConfig(); break; case "host_modal.host_cheats": this.hostCheatsEnabled = checked; this.putGameConfig(); break; default: break; } }; private handleConfigHostCheatToggleChanged = (e: Event) => { const customEvent = e as CustomEvent<{ labelKey: string; checked: boolean; }>; const { labelKey, checked } = customEvent.detail; switch (labelKey) { case "host_modal.infinite_gold": this.hostCheatInfiniteGold = checked; this.putGameConfig(); break; case "host_modal.infinite_troops": this.hostCheatInfiniteTroops = checked; this.putGameConfig(); break; default: break; } }; private handleConfigUnitToggleChanged = (e: Event) => { const customEvent = e as CustomEvent<{ unit: UnitType; checked: boolean }>; const { unit, checked } = customEvent.detail; this.disabledUnits = getUpdatedDisabledUnits( this.disabledUnits, unit, checked, ); this.putGameConfig(); }; // Modified to include debouncing private handleBotsChange = (e: Event) => { const customEvent = e as CustomEvent<{ value: number }>; const value = customEvent.detail.value; if (isNaN(value) || value < 0 || value > 400) { return; } // Update the display value immediately this.bots = value; // Clear any existing timer if (this.botsUpdateTimer !== null) { clearTimeout(this.botsUpdateTimer); } // Set a new timer to call putGameConfig after 300ms of inactivity this.botsUpdateTimer = window.setTimeout(() => { this.putGameConfig(); this.botsUpdateTimer = null; }, 300); }; private handleInstantBuildChange = (val: boolean) => { this.instantBuild = val; this.putGameConfig(); }; private handleMaxTimerToggle = ( checked: boolean, value: number | string | undefined, ) => { this.maxTimer = checked; this.maxTimerValue = toOptionalNumber(value); this.putGameConfig(); }; private handleSpawnImmunityToggle = ( checked: boolean, value: number | string | undefined, ) => { this.spawnImmunity = checked; this.spawnImmunityDurationMinutes = toOptionalNumber(value); this.putGameConfig(); }; private handleGoldMultiplierToggle = ( checked: boolean, value: number | string | undefined, ) => { this.goldMultiplier = checked; this.goldMultiplierValue = toOptionalNumber(value); this.putGameConfig(); }; private handleStartingGoldToggle = ( checked: boolean, value: number | string | undefined, ) => { this.startingGold = checked; this.startingGoldValue = toOptionalNumber(value); this.putGameConfig(); }; private handleSpawnImmunityDurationKeyDown = (e: KeyboardEvent) => { preventDisallowedKeys(e, ["-", "+", "e", "E"]); }; private handleSpawnImmunityDurationInput = (e: Event) => { const input = e.target as HTMLInputElement; const value = parseBoundedIntegerFromInput(input, { min: 0, max: 120 }); if (value === undefined) { return; } this.spawnImmunityDurationMinutes = value; this.putGameConfig(); }; private handleGoldMultiplierValueKeyDown = (e: KeyboardEvent) => { preventDisallowedKeys(e, ["+", "-", "e", "E"]); }; private handleGoldMultiplierValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000 }); if (value === undefined) { this.goldMultiplierValue = undefined; input.value = ""; } else { this.goldMultiplierValue = value; } this.putGameConfig(); }; private handleStartingGoldValueKeyDown = (e: KeyboardEvent) => { preventDisallowedKeys(e, ["-", "+", "e", "E"]); }; private handleStartingGoldValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000, }); if (value === undefined) { this.startingGoldValue = undefined; input.value = ""; } else { this.startingGoldValue = value; } this.putGameConfig(); }; private handleHostCheatGoldMultiplierToggle = ( checked: boolean, value: number | string | undefined, ) => { this.hostCheatGoldMultiplier = checked; this.hostCheatGoldMultiplierValue = toOptionalNumber(value); this.putGameConfig(); }; private handleHostCheatGoldMultiplierValueKeyDown = (e: KeyboardEvent) => { preventDisallowedKeys(e, ["+", "-", "e", "E"]); }; private handleHostCheatGoldMultiplierValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000 }); if (value === undefined) { this.hostCheatGoldMultiplierValue = undefined; input.value = ""; } else { this.hostCheatGoldMultiplierValue = value; } this.putGameConfig(); }; private handleHostCheatStartingGoldToggle = ( checked: boolean, value: number | string | undefined, ) => { this.hostCheatStartingGold = checked; this.hostCheatStartingGoldValue = toOptionalNumber(value); this.putGameConfig(); }; private handleHostCheatStartingGoldValueKeyDown = (e: KeyboardEvent) => { preventDisallowedKeys(e, ["-", "+", "e", "E"]); }; private handleHostCheatStartingGoldValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000, }); if (value === undefined) { this.hostCheatStartingGoldValue = undefined; input.value = ""; } else { this.hostCheatStartingGoldValue = value; } this.putGameConfig(); }; private handleRandomSpawnChange = (val: boolean) => { this.randomSpawn = val; this.putGameConfig(); }; private handleInfiniteGoldChange = (val: boolean) => { this.infiniteGold = val; this.putGameConfig(); }; private handleDonateGoldChange = (val: boolean) => { this.donateGold = val; this.putGameConfig(); }; private handleInfiniteTroopsChange = (val: boolean) => { this.infiniteTroops = val; this.putGameConfig(); }; private handleCompactMapChange = (val: boolean) => { this.compactMap = val; this.bots = getBotsForCompactMap(this.bots, val); this.nations = getNationsForCompactMap( this.nations, this.defaultNationCount, val, ); this.putGameConfig(); }; private handleDonateTroopsChange = (val: boolean) => { this.donateTroops = val; this.putGameConfig(); }; private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => { preventDisallowedKeys(e, ["-", "+", "e"]); }; private handleMaxTimerValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; const value = parseBoundedIntegerFromInput(input, { min: 1, max: 120, stripPattern: /[e+-]/gi, }); if (value === undefined) { return; } this.maxTimerValue = value; this.putGameConfig(); }; private handleStartDelayValueKeyDown = (e: KeyboardEvent) => { preventDisallowedKeys(e, ["-", "+", "e", "E", "."]); }; private handleStartDelayValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; const value = parseBoundedIntegerFromInput(input, { min: 0, max: 600, }); if (value === undefined) { this.startDelayValue = undefined; input.value = ""; } else { this.startDelayValue = value; } this.putGameConfig(); }; private handleNationsChange = (e: Event) => { const customEvent = e as CustomEvent<{ value: number }>; const value = customEvent.detail.value; if (isNaN(value) || value < 0 || value > 400) { return; } this.nations = value; if (this.nationsUpdateTimer !== null) { clearTimeout(this.nationsUpdateTimer); } this.nationsUpdateTimer = window.setTimeout(() => { this.putGameConfig(); this.nationsUpdateTimer = null; }, 300); }; private async handleGameModeSelection(value: GameMode) { this.gameMode = value; if (this.gameMode === GameMode.Team) { this.donateGold = true; this.donateTroops = true; } else { this.donateGold = false; this.donateTroops = false; } this.putGameConfig(); } private async handleTeamCountSelection(value: TeamCountConfig) { this.teamCount = value; this.putGameConfig(); } private async putGameConfig() { const spawnImmunityTicks = this.spawnImmunityDurationMinutes ? this.spawnImmunityDurationMinutes * 60 * 10 : 0; const url = await this.constructUrl(); this.updateLobbyHistory(url); this.dispatchEvent( new CustomEvent("update-game-config", { detail: { config: { gameMap: this.selectedMap, gameMapSize: this.compactMap ? GameMapSize.Compact : GameMapSize.Normal, difficulty: this.selectedDifficulty, bots: this.bots, infiniteGold: this.infiniteGold, donateGold: this.donateGold, infiniteTroops: this.infiniteTroops, donateTroops: this.donateTroops, instantBuild: this.instantBuild, randomSpawn: this.randomSpawn, gameMode: this.gameMode, disabledUnits: this.disabledUnits, spawnImmunityDuration: this.spawnImmunity ? spawnImmunityTicks : null, playerTeams: this.teamCount, nations: sliderToNationsConfig( this.nations, this.defaultNationCount, ), maxTimerValue: this.maxTimer === true ? this.maxTimerValue : null, startDelay: this.startDelayValue, goldMultiplier: this.goldMultiplier === true ? this.goldMultiplierValue : null, startingGold: this.startingGold === true && this.startingGoldValue !== undefined ? Math.round(this.startingGoldValue * 1_000_000) : null, disableAlliances: this.disableAlliances || null, waterNukes: this.waterNukes ? true : null, hostCheats: this.hostCheatsEnabled ? { infiniteGold: this.hostCheatInfiniteGold || undefined, infiniteTroops: this.hostCheatInfiniteTroops || undefined, goldMultiplier: this.hostCheatGoldMultiplier === true ? this.hostCheatGoldMultiplierValue : null, startingGold: this.hostCheatStartingGold === true && this.hostCheatStartingGoldValue !== undefined ? Math.round(this.hostCheatStartingGoldValue * 1_000_000) : null, } : undefined, } satisfies Partial, }, bubbles: true, composed: true, }), ); } private async toggleGameStartTimer() { await this.putGameConfig(); console.log( `Starting private game with map: ${GameMapType[this.selectedMap as keyof typeof GameMapType]} ${this.useRandomMap ? " (Randomly selected)" : ""}`, ); // If the modal closes as part of starting the game, do not leave the lobby this.leaveLobbyOnClose = false; this.dispatchEvent( new CustomEvent("toggle_game_start_timer", { bubbles: true, composed: true, }), ); } private kickPlayer(clientID: string) { // Dispatch event to be handled by WebSocket instead of HTTP this.dispatchEvent( new CustomEvent("kick-player", { detail: { target: clientID }, bubbles: true, composed: true, }), ); } private async loadNationCount() { const currentMap = this.selectedMap; try { const mapData = this.mapLoader.getMapData(currentMap); const manifest = await mapData.manifest(); // Only update if the map hasn't changed if (this.selectedMap === currentMap) { this.defaultNationCount = manifest.nations.length; this.nations = this.compactMap ? Math.max(0, Math.floor(manifest.nations.length * 0.25)) : manifest.nations.length; } } catch (error) { console.warn("Failed to load nation count", error); // Leave existing values unchanged so the UI stays consistent } } } async function createLobby(gameID: string): Promise { // Send JWT token for creator identification - server extracts persistentID from it // persistentID should never be exposed to other clients const token = await getPlayToken(); try { const response = await fetch( `/${ClientEnv.workerPath(gameID)}/api/create_game/${gameID}`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, }, ); if (!response.ok) { const errorText = await response.text(); console.error("Server error response:", errorText); throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); console.log("Success:", data); return data as GameInfo; } catch (error) { console.error("Error creating lobby:", error); throw error; } }