import { html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { EventBus } from "../core/EventBus"; import { Difficulty, GameMapSize, GameMapType, GameMode, UnitType, } from "../core/game/Game"; 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 "./components/CopyButton"; import "./components/GameConfigSettings"; 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 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 lobbyId = ""; @state() private lobbyUrlSuffix = ""; @state() private clients: ClientInfo[] = []; @state() private useRandomMap: boolean = false; @state() private disabledUnits: UnitType[] = []; @state() private lobbyCreatorClientID: string = ""; @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 leaveLobbyOnClose = true; private readonly handleLobbyInfo = (event: LobbyInfoEvent) => { const lobby = event.lobby; if (!this.lobbyId || lobby.gameID !== this.lobbyId) { return; } 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; } } const config = await getServerConfigFromClient(); return `${window.location.origin}/${config.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()) { history.replaceState(null, "", url); } } 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); } render() { const inputCards = [ html``, html``, html``, html``, ]; const content = html`
${modalHeader({ title: translateText("host_modal.title"), onBack: () => { this.leaveLobbyOnClose = true; this.close(); }, ariaLabel: translateText("common.back"), rightContent: html` `, })}
this.kickPlayer(clientID)} >
`; if (this.inline) { return content; } return html` ${content} `; } 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 // 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); const url = await this.constructUrl(); this.updateHistory(url); }) .then(() => { this.dispatchEvent( new CustomEvent("join-lobby", { detail: { gameID: this.lobbyId, source: "host", } as JoinLobbyEvent, bubbles: true, composed: true, }), ); }); 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.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.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; 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 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 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.updateHistory(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 : undefined, playerTeams: this.teamCount, nations: sliderToNationsConfig( this.nations, this.defaultNationCount, ), maxTimerValue: this.maxTimer === true ? this.maxTimerValue : undefined, goldMultiplier: this.goldMultiplier === true ? this.goldMultiplierValue : undefined, startingGold: this.startingGold === true && this.startingGoldValue !== undefined ? Math.round(this.startingGoldValue * 1_000_000) : undefined, } satisfies Partial, }, bubbles: true, composed: true, }), ); } private async startGame() { 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; const config = await getServerConfigFromClient(); const response = await fetch( `${window.location.origin}/${config.workerPath(this.lobbyId)}/api/start_game/${this.lobbyId}`, { method: "POST", headers: { "Content-Type": "application/json", }, }, ); if (!response.ok) { this.leaveLobbyOnClose = true; } return response; } 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 { const config = await getServerConfigFromClient(); // 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( `/${config.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; } }