fix: transfer host when lobby creator disconnects

When the host of a private lobby disconnects, transfer host role to the
next active player. The new host's JoinLobbyModal automatically converts
to a HostLobbyModal with full controls (edit settings, kick, start).
Nation count and other config values are preserved from the original host.
This commit is contained in:
FloPinguin
2026-03-07 04:07:45 +01:00
parent 5594109641
commit 1e47e2c20d
5 changed files with 165 additions and 1 deletions
+2 -1
View File
@@ -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",
+86
View File
@@ -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<void> {
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<GameInfo> {
+24
View File
@@ -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() {
+28
View File
@@ -212,6 +212,7 @@ declare global {
interface DocumentEventMap {
"join-lobby": CustomEvent<JoinLobbyEvent>;
"kick-player": CustomEvent;
"host-transfer": CustomEvent;
"join-changed": CustomEvent;
"open-matchmaking": CustomEvent<undefined>;
}
@@ -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;
+25
View File
@@ -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;
}