mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user