import { TemplateResult, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { copyToClipboard, translateText } from "../client/Utils"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { Difficulty, Duos, GameMapSize, GameMapType, GameMode, HumansVsNations, Quads, Trios, UnitType, mapCategories, } from "../core/game/Game"; import { getCompactMapNationCount } from "../core/game/NationCreation"; import { UserSettings } from "../core/game/UserSettings"; import { ClientInfo, GameConfig, GameInfo, TeamCountConfig, } from "../core/Schemas"; import { generateID } from "../core/Util"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import "./components/FluentSlider"; import "./components/LobbyTeamView"; import "./components/Maps"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { JoinLobbyEvent } from "./Main"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; import randomMap from "/images/RandomMap.webp?url"; @customElement("host-lobby-modal") export class HostLobbyModal extends BaseModal { @state() private selectedMap: GameMapType = GameMapType.World; @state() private selectedDifficulty: Difficulty = Difficulty.Medium; @state() private disableNations = false; @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 lobbyId = ""; @state() private copySuccess = false; @state() private clients: ClientInfo[] = []; @state() private useRandomMap: boolean = false; @state() private disabledUnits: UnitType[] = []; @state() private lobbyCreatorClientID: string = ""; @state() private lobbyIdVisible: boolean = true; @state() private nationCount: number = 0; private playersInterval: NodeJS.Timeout | null = null; // Add a new timer for debouncing bot changes private botsUpdateTimer: number | null = null; private userSettings: UserSettings = new UserSettings(); private mapLoader = terrainMapFileLoader; private renderOptionToggle( labelKey: string, checked: boolean, onChange: (val: boolean) => void, hidden: boolean = false, ): TemplateResult { if (hidden) return html``; return html` `; } render() { const content = html`
${translateText("host_modal.title")}

${translateText("map.map")}

${Object.entries(mapCategories).map( ([categoryKey, maps]) => html`

${translateText(`map_categories.${categoryKey}`)}

${maps.map((mapValue) => { const mapKey = Object.entries(GameMapType).find( ([, v]) => v === mapValue, )?.[0]; return html`
this.handleMapSelection(mapValue)} class="cursor-pointer transition-transform duration-200 active:scale-95" >
`; })}
`, )}

${translateText("map_categories.special")}

${translateText("difficulty.difficulty")}

${Object.entries(Difficulty) .filter(([key]) => isNaN(Number(key))) .map(([key, value]) => { const isSelected = this.selectedDifficulty === value; const isDisabled = this.disableNations; return html` `; })}

${translateText("host_modal.mode")}

${[GameMode.FFA, GameMode.Team].map((mode) => { const isSelected = this.gameMode === mode; return html` `; })}
${this.gameMode === GameMode.FFA ? "" : html`
${translateText("host_modal.team_count")}
${[ 2, 3, 4, 5, 6, 7, Quads, Trios, Duos, HumansVsNations, ].map((o) => { const isSelected = this.teamCount === o; return html` `; })}
`}

${translateText("host_modal.options_title")}

${!( this.gameMode === GameMode.Team && this.teamCount === HumansVsNations ) ? this.renderOptionToggle( "host_modal.disable_nations", this.disableNations, this.handleDisableNationsChange, ) : ""} ${this.renderOptionToggle( "host_modal.instant_build", this.instantBuild, this.handleInstantBuildChange, )} ${this.renderOptionToggle( "host_modal.random_spawn", this.randomSpawn, this.handleRandomSpawnChange, )} ${this.renderOptionToggle( "host_modal.donate_gold", this.donateGold, this.handleDonateGoldChange, )} ${this.renderOptionToggle( "host_modal.donate_troops", this.donateTroops, this.handleDonateTroopsChange, )} ${this.renderOptionToggle( "host_modal.infinite_gold", this.infiniteGold, this.handleInfiniteGoldChange, )} ${this.renderOptionToggle( "host_modal.infinite_troops", this.infiniteTroops, this.handleInfiniteTroopsChange, )} ${this.renderOptionToggle( "host_modal.compact_map", this.compactMap, this.handleCompactMapChange, )}
this.maxTimer, (val) => (this.maxTimer = val), () => this.maxTimerValue, (val) => (this.maxTimerValue = val), 30, ).click} @keydown=${this.createToggleHandlers( () => this.maxTimer, (val) => (this.maxTimer = val), () => this.maxTimerValue, (val) => (this.maxTimerValue = val), 30, ).keydown} class="relative p-3 rounded-xl border transition-all duration-200 flex flex-col items-center justify-between gap-2 h-full cursor-pointer min-h-[100px] ${this .maxTimer ? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]" : "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80"}" >
${this.maxTimer ? html` ` : ""}
${this.maxTimer ? html` e.stopPropagation()} @input=${this.handleMaxTimerValueChanges} @keydown=${this.handleMaxTimerValueKeyDown} placeholder=${translateText( "host_modal.mins_placeholder", )} /> ` : html`
`}
${translateText("host_modal.max_timer")}
this.spawnImmunity, (val) => (this.spawnImmunity = val), () => this.spawnImmunityDurationMinutes, (val) => (this.spawnImmunityDurationMinutes = val), 5, ).click} @keydown=${this.createToggleHandlers( () => this.spawnImmunity, (val) => (this.spawnImmunity = val), () => this.spawnImmunityDurationMinutes, (val) => (this.spawnImmunityDurationMinutes = val), 5, ).keydown} class="relative p-3 rounded-xl border transition-all duration-200 flex flex-col items-center justify-between gap-2 h-full cursor-pointer min-h-[100px] ${this .spawnImmunity ? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]" : "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80"}" >
${this.spawnImmunity ? html` ` : ""}
${this.spawnImmunity ? html` e.stopPropagation()} @input=${this.handleSpawnImmunityDurationInput} @keydown=${this.handleSpawnImmunityDurationKeyDown} placeholder=${translateText( "host_modal.mins_placeholder", )} /> ` : html`
`}
${translateText("host_modal.player_immunity_duration")}

${translateText("host_modal.enables_title")}

${renderUnitTypeOptions({ disabledUnits: this.disabledUnits, toggleUnit: this.toggleUnit.bind(this), })}
${this.clients.length} ${this.clients.length === 1 ? translateText("host_modal.player") : translateText("host_modal.players")} ${this.getEffectiveNationCount()} ${this.getEffectiveNationCount() === 1 ? translateText("host_modal.nation_player") : translateText("host_modal.nation_players")}
this.kickPlayer(clientID)} >
`; if (this.inline) { return content; } return html` ${content} `; } protected onOpen(): void { this.lobbyCreatorClientID = generateID(); this.lobbyIdVisible = this.userSettings.get( "settings.lobbyIdVisibility", true, ); createLobby(this.lobbyCreatorClientID) .then((lobby) => { this.lobbyId = lobby.gameID; crazyGamesSDK.showInviteButton(this.lobbyId); }) .then(() => { this.dispatchEvent( new CustomEvent("join-lobby", { detail: { gameID: this.lobbyId, clientID: this.lobbyCreatorClientID, } as JoinLobbyEvent, bubbles: true, composed: true, }), ); }); if (this.modalEl) { this.modalEl.onClose = () => { this.close(); }; } this.playersInterval = setInterval(() => this.pollPlayers(), 1000); this.loadNationCount(); } private createToggleHandlers( toggleStateGetter: () => boolean, toggleStateSetter: (val: boolean) => void, valueGetter: () => number | undefined, valueSetter: (val: number | undefined) => void, defaultValue: number = 0, ) { const toggleLogic = () => { const newState = !toggleStateGetter(); toggleStateSetter(newState); if (newState) { valueSetter(valueGetter() ?? defaultValue); } else { valueSetter(undefined); } this.putGameConfig(); this.requestUpdate(); }; return { click: (e: Event) => { if ((e.target as HTMLElement).tagName.toLowerCase() === "input") return; toggleLogic(); }, keydown: (e: KeyboardEvent) => { if ((e.target as HTMLElement).tagName.toLowerCase() === "input") return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleLogic(); } }, }; } private leaveLobby() { if (!this.lobbyId) { return; } this.dispatchEvent( new CustomEvent("leave-lobby", { detail: { lobby: this.lobbyId }, bubbles: true, composed: true, }), ); } protected onClose(): void { console.log("Closing host lobby modal"); crazyGamesSDK.hideInviteButton(); // Clean up timers and resources if (this.playersInterval) { clearInterval(this.playersInterval); this.playersInterval = null; } if (this.botsUpdateTimer !== null) { clearTimeout(this.botsUpdateTimer); this.botsUpdateTimer = null; } // Reset all transient form state to ensure clean slate this.selectedMap = GameMapType.World; this.selectedDifficulty = Difficulty.Medium; this.disableNations = false; 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.copySuccess = false; this.clients = []; this.lobbyCreatorClientID = ""; this.lobbyIdVisible = true; this.nationCount = 0; } private async handleSelectRandomMap() { this.useRandomMap = true; this.selectedMap = this.getRandomMap(); await this.loadNationCount(); this.putGameConfig(); } private async handleMapSelection(value: GameMapType) { this.selectedMap = value; this.useRandomMap = false; await this.loadNationCount(); this.putGameConfig(); } private async handleDifficultySelection(value: Difficulty) { this.selectedDifficulty = value; 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 handleSpawnImmunityDurationKeyDown(e: KeyboardEvent) { if (["-", "+", "e", "E"].includes(e.key)) { e.preventDefault(); } } private handleSpawnImmunityDurationInput(e: Event) { const input = e.target as HTMLInputElement; input.value = input.value.replace(/[eE+-]/g, ""); const value = parseInt(input.value, 10); if (Number.isNaN(value) || value < 0 || value > 120) { return; } this.spawnImmunityDurationMinutes = 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.putGameConfig(); }; private handleDonateTroopsChange = (val: boolean) => { this.donateTroops = val; this.putGameConfig(); }; private handleMaxTimerValueKeyDown(e: KeyboardEvent) { if (["-", "+", "e"].includes(e.key)) { e.preventDefault(); } } private handleMaxTimerValueChanges(e: Event) { (e.target as HTMLInputElement).value = ( e.target as HTMLInputElement ).value.replace(/[e+-]/gi, ""); const value = parseInt((e.target as HTMLInputElement).value); if (isNaN(value) || value < 0 || value > 120) { return; } this.maxTimerValue = value; this.putGameConfig(); } private handleDisableNationsChange = async (val: boolean) => { this.disableNations = val; console.log(`updating disable nations to ${this.disableNations}`); this.putGameConfig(); }; 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; 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, ...(this.gameMode === GameMode.Team && this.teamCount === HumansVsNations ? { disableNations: false, } : { disableNations: this.disableNations, }), maxTimerValue: this.maxTimer === true ? this.maxTimerValue : undefined, } satisfies Partial, }, bubbles: true, composed: true, }), ); } private toggleUnit(unit: UnitType, checked: boolean): void { this.disabledUnits = checked ? [...this.disabledUnits, unit] : this.disabledUnits.filter((u) => u !== unit); this.putGameConfig(); } private getRandomMap(): GameMapType { const maps = Object.values(GameMapType); const randIdx = Math.floor(Math.random() * maps.length); return maps[randIdx] as GameMapType; } private async startGame() { await this.putGameConfig(); console.log( `Starting private game with map: ${GameMapType[this.selectedMap as keyof typeof GameMapType]} ${this.useRandomMap ? " (Randomly selected)" : ""}`, ); 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", }, }, ); return response; } private async copyToClipboard() { await copyToClipboard( `${location.origin}/#join=${this.lobbyId}`, () => (this.copySuccess = true), () => (this.copySuccess = false), ); } private async pollPlayers() { const config = await getServerConfigFromClient(); fetch(`/${config.workerPath(this.lobbyId)}/api/game/${this.lobbyId}`, { method: "GET", headers: { "Content-Type": "application/json", }, }) .then((response) => response.json()) .then((data: GameInfo) => { this.clients = data.clients ?? []; }); } 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() { try { const mapData = this.mapLoader.getMapData(this.selectedMap); const manifest = await mapData.manifest(); this.nationCount = manifest.nations.length; } catch (error) { console.warn("Failed to load nation count", error); this.nationCount = 0; } } /** * Returns the effective nation count for display purposes. * In HumansVsNations mode, this equals the number of human players. * For compact maps, only 25% of nations are used. * Otherwise, it uses the manifest nation count (or 0 if nations are disabled). */ private getEffectiveNationCount(): number { if (this.disableNations) { return 0; } if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) { return this.clients.length; } return getCompactMapNationCount(this.nationCount, this.compactMap); } } async function createLobby(creatorClientID: string): Promise { const config = await getServerConfigFromClient(); try { const id = generateID(); const response = await fetch( `/${config.workerPath(id)}/api/create_game/${id}?creatorClientID=${encodeURIComponent(creatorClientID)}`, { method: "POST", headers: { "Content-Type": "application/json", }, // body: JSON.stringify(data), // Include this if you need to send data }, ); 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; } }