diff --git a/resources/lang/en.json b/resources/lang/en.json index 60754c189..bb9300b36 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -432,7 +432,8 @@ "starting_gold": "Starting gold", "crowded": "Crowded modifier", "hard_nations": "Hard Nations", - "leave_confirmation": "Are you sure you want to leave the lobby?" + "leave_confirmation": "Are you sure you want to leave the lobby?", + "you_are_now_host": "You are now the host of this lobby." }, "team_colors": { "red": "Red", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index f55e869f9..90da2bfb7 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -35,6 +35,7 @@ import { getNationsForCompactMap, getRandomMapType, getUpdatedDisabledUnits, + nationsConfigToSlider, parseBoundedFloatFromInput, parseBoundedIntegerFromInput, preventDisallowedKeys, @@ -393,6 +394,71 @@ export class HostLobbyModal extends BaseModal { this.loadNationCount(); } + /** + * Open the host modal for an existing lobby (e.g. after host transfer). + * Unlike normal open(), this does NOT create a new lobby or emit join-lobby. + */ + public openExisting( + lobbyId: string, + gameConfig: GameConfig, + clients: ClientInfo[], + ): void { + super.open(); + this.startLobbyUpdates(); + this.lobbyId = lobbyId; + + // Hydrate form state from the existing game config + this.selectedMap = gameConfig.gameMap; + this.selectedDifficulty = gameConfig.difficulty; + this.gameMode = gameConfig.gameMode; + this.teamCount = gameConfig.playerTeams ?? 2; + this.bots = gameConfig.bots; + this.infiniteGold = gameConfig.infiniteGold; + this.donateGold = gameConfig.donateGold; + this.infiniteTroops = gameConfig.infiniteTroops; + this.donateTroops = gameConfig.donateTroops; + this.instantBuild = gameConfig.instantBuild; + this.randomSpawn = gameConfig.randomSpawn; + this.compactMap = gameConfig.gameMapSize === GameMapSize.Compact; + this.disabledUnits = gameConfig.disabledUnits ?? []; + this.spawnImmunity = + gameConfig.spawnImmunityDuration !== undefined && + gameConfig.spawnImmunityDuration > 0; + this.spawnImmunityDurationMinutes = this.spawnImmunity + ? Math.round((gameConfig.spawnImmunityDuration ?? 0) / (60 * 10)) + : undefined; + this.maxTimer = + gameConfig.maxTimerValue !== undefined && gameConfig.maxTimerValue > 0; + this.maxTimerValue = gameConfig.maxTimerValue; + this.goldMultiplier = + gameConfig.goldMultiplier !== undefined && gameConfig.goldMultiplier > 0; + this.goldMultiplierValue = gameConfig.goldMultiplier; + this.startingGold = + gameConfig.startingGold !== undefined && gameConfig.startingGold > 0; + this.startingGoldValue = + gameConfig.startingGold !== undefined + ? gameConfig.startingGold / 1_000_000 + : undefined; + + this.clients = clients; + + // Set up URL and invite button + void (async () => { + crazyGamesSDK.showInviteButton(this.lobbyId); + const url = await this.constructUrl(); + this.updateHistory(url); + })(); + + if (this.modalEl) { + this.modalEl.onClose = () => { + this.close(); + }; + } + // Load default nation count from map, then restore the actual nation + // value from the existing config (instead of resetting to the default). + this.loadNationCountThenRestore(gameConfig.nations); + } + private leaveLobby() { if (!this.lobbyId) { return; @@ -857,6 +923,26 @@ export class HostLobbyModal extends BaseModal { // Leave existing values unchanged so the UI stays consistent } } + + /** + * Load the default nation count from the map manifest, then restore + * the nation slider to the value from an existing GameConfig. + */ + private async loadNationCountThenRestore( + nations: GameConfig["nations"], + ): Promise { + const currentMap = this.selectedMap; + try { + const mapData = this.mapLoader.getMapData(currentMap); + const manifest = await mapData.manifest(); + if (this.selectedMap === currentMap) { + this.defaultNationCount = manifest.nations.length; + this.nations = nationsConfigToSlider(nations, this.defaultNationCount); + } + } catch (error) { + console.warn("Failed to load nation count for restore", error); + } + } } async function createLobby(gameID: string): Promise { diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index 7834045b7..7cce86b4e 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -50,6 +50,7 @@ export class JoinLobbyModal extends BaseModal { private leaveLobbyOnClose = true; private countdownTimerId: number | null = null; private handledJoinTimeout = false; + private hostTransferDispatched = false; private isPrivateLobby(): boolean { return this.gameConfig?.gameType === GameType.Private; @@ -66,6 +67,27 @@ export class JoinLobbyModal extends BaseModal { ...lobby, startsAt: lobby.startsAt ?? undefined, }); + // If this client has become the host of a private lobby, switch to HostLobbyModal + if ( + !this.hostTransferDispatched && + this.isPrivateLobby() && + this.currentClientID && + lobby.lobbyCreatorClientID && + this.currentClientID === lobby.lobbyCreatorClientID + ) { + this.hostTransferDispatched = true; + this.dispatchEvent( + new CustomEvent("host-transfer", { + detail: { + lobbyId: this.currentLobbyId, + gameConfig: this.gameConfig, + clients: this.players, + }, + bubbles: true, + composed: true, + }), + ); + } }; render() { @@ -329,6 +351,7 @@ export class JoinLobbyModal extends BaseModal { this.lobbyCreatorClientID = null; this.isConnecting = true; this.handledJoinTimeout = false; + this.hostTransferDispatched = false; this.startLobbyUpdates(); if (lobbyInfo) { this.updateFromLobby(lobbyInfo); @@ -378,6 +401,7 @@ export class JoinLobbyModal extends BaseModal { this.lobbyCreatorClientID = null; this.isConnecting = true; this.leaveLobbyOnClose = true; + this.hostTransferDispatched = false; } disconnectedCallback() { diff --git a/src/client/Main.ts b/src/client/Main.ts index 1300d8789..7a04eccaf 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -212,6 +212,7 @@ declare global { interface DocumentEventMap { "join-lobby": CustomEvent; "kick-player": CustomEvent; + "host-transfer": CustomEvent; "join-changed": CustomEvent; "open-matchmaking": CustomEvent; } @@ -313,6 +314,10 @@ class Client { document.addEventListener("join-lobby", this.handleJoinLobby.bind(this)); document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this)); document.addEventListener("kick-player", this.handleKickPlayer.bind(this)); + document.addEventListener( + "host-transfer", + this.handleHostTransfer.bind(this), + ); document.addEventListener( "update-game-config", this.handleUpdateGameConfig.bind(this), @@ -895,6 +900,29 @@ class Client { } } + private handleHostTransfer(event: CustomEvent) { + const { lobbyId, gameConfig, clients } = event.detail; + if (!lobbyId || !gameConfig) { + console.warn("host-transfer event missing required data"); + return; + } + // Close JoinLobbyModal without leaving the lobby (we're staying in the same game) + this.joinModal?.closeWithoutLeaving(); + // Switch to the HostLobbyModal page and open with existing lobby state + window.showPage?.("page-host-lobby"); + this.hostModal?.openExisting(lobbyId, gameConfig, clients ?? []); + // Notify the new host + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: translateText("host_modal.you_are_now_host"), + color: "green", + duration: 4000, + }, + }), + ); + } + private handleUpdateGameConfig(event: CustomEvent) { const { config } = event.detail; diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 48cf120ef..8d08f1f7d 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -503,6 +503,7 @@ export class GameServer { this.activeClients = this.activeClients.filter( (c) => c.clientID !== client.clientID, ); + this.maybeTransferHost(client); }); client.ws.on("error", (error: Error) => { if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") { @@ -522,6 +523,30 @@ export class GameServer { } } + private maybeTransferHost(disconnectedClient: Client): void { + // Only transfer host in the lobby phase for private games + if (this._hasStarted || this._hasEnded || this.isPublic()) { + return; + } + // Only transfer if the disconnected client was the host + if (disconnectedClient.clientID !== this.lobbyCreatorID) { + return; + } + const newHost = this.activeClients[0]; + if (!newHost) { + return; + } + this.creatorPersistentID = newHost.persistentID; + this.log.info("Transferred lobby host", { + oldHostClientID: disconnectedClient.clientID, + newHostClientID: newHost.clientID, + newHostPersistentID: newHost.persistentID, + gameID: this.id, + }); + // Immediately broadcast updated lobby info so clients see the new host + this.broadcastLobbyInfo(); + } + public setStartsAt(startsAt: number) { this.startsAt = startsAt; }