, targeted at the worker that owns
+// the finished game — mints a successor private lobby (same creator, default
+// settings) and has the old game broadcast the new id to everyone still
+// connected. Returns the successor's info; the caller navigates the host there.
+// Idempotent server-side: repeat calls return the same successor.
+export async function createNextLobby(
+ previousGameID: string,
+): Promise {
+ const token = await getPlayToken();
+ const response = await fetch(
+ `/${ClientEnv.workerPath(previousGameID)}/api/create_game?previous=${previousGameID}`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ },
+ );
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => "");
+ console.error("createNextLobby: server error response:", errorText);
+ throw new Error(`create next lobby failed: HTTP ${response.status}`);
+ }
+ return (await response.json()) as GameInfo;
+}
+
export function getApiBase() {
const domainname = getAudience();
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index 8b07806b74..9f99bd91ca 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -54,6 +54,7 @@ import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { GoToPlayerEvent } from "./TransformHandler";
import {
MoveWarshipIntentEvent,
+ NewLobbyEvent,
SendAllianceExtensionIntentEvent,
SendAllianceRequestIntentEvent,
SendAttackIntentEvent,
@@ -930,6 +931,12 @@ export class ClientGameRunner {
"error_modal.connection_error",
);
}
+ if (message.type === "new_lobby") {
+ // The host reused this private lobby: surface the successor id so the
+ // group can hop over. NewLobbyPrompt navigates the host and prompts
+ // everyone else.
+ this.eventBus.emit(new NewLobbyEvent(message.gameID));
+ }
if (message.type === "turn") {
if (
!this.gameView.inSpawnPhase() &&
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index eb36443c74..67e41c0d7a 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -645,7 +645,7 @@ export class HostLobbyModal extends BaseModal {
`;
}
- protected onOpen(): void {
+ protected onOpen(args?: Record): void {
// Re-armed here (not in onClose's reset) so that once
// closeWithoutLeaving() disarms it, no close cascade — e.g. another
// modal's close() navigating via showPage, which force-closes this one —
@@ -659,6 +659,22 @@ export class HostLobbyModal extends BaseModal {
ClientEnv.env() === GameEnv.Dev ||
(userMe !== false && hasActiveSubscription(userMe));
});
+
+ // Attach mode: the server already minted this successor lobby with us as
+ // creator (win-screen "New lobby" flow), so bind to the existing id instead
+ // of creating another game.
+ const existingLobbyId =
+ typeof args?.existingLobbyId === "string" ? args.existingLobbyId : null;
+ if (existingLobbyId !== null) {
+ this.attachToExistingLobby(existingLobbyId).catch(() => {
+ // Clear clipboard so the host doesn't accidentally share a dead link,
+ // matching the createLobby() failure path below.
+ void navigator.clipboard.writeText("").catch(() => {});
+ });
+ this.loadNationCount();
+ return;
+ }
+
// The server mints the game id, so we don't know it until createLobby
// resolves. clientID is assigned by the server when we join the lobby.
@@ -700,6 +716,32 @@ export class HostLobbyModal extends BaseModal {
this.loadNationCount();
}
+ // Bind the host view to a lobby the server already created (the successor of a
+ // finished game). Mirrors the createLobby() success path, minus the creation.
+ private async attachToExistingLobby(lobbyId: string): Promise {
+ if (!isValidGameID(lobbyId)) {
+ throw new Error(`Invalid lobby ID format: ${lobbyId}`);
+ }
+ this.lobbyId = lobbyId;
+ crazyGamesSDK.showInviteButton(this.lobbyId);
+
+ const url = await this.constructUrl();
+ this.updateLobbyHistory(url);
+ await this.updateComplete;
+ void (this.querySelector("copy-button") as CopyButton)?.handleCopy();
+
+ this.dispatchEvent(
+ new CustomEvent("join-lobby", {
+ detail: {
+ gameID: this.lobbyId,
+ source: "host",
+ } as JoinLobbyEvent,
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
private leaveLobby() {
if (!this.lobbyId) {
return;
diff --git a/src/client/InGameModal.ts b/src/client/InGameModal.ts
index 0271cad0d0..f565045f9e 100644
--- a/src/client/InGameModal.ts
+++ b/src/client/InGameModal.ts
@@ -31,9 +31,21 @@ function showDialog(props: Partial): Promise {
});
}
-/** In-game replacement for `confirm()`. Resolves true when confirmed. */
-export function showInGameConfirm(message: string): Promise {
- return showDialog({ message, variant: "danger", buttons: "confirmCancel" });
+/**
+ * In-game replacement for `confirm()`. Resolves true when confirmed.
+ * `options` overrides the dialog's presentation (variant, texts, heading)
+ * for confirmations that aren't destructive-red by nature.
+ */
+export function showInGameConfirm(
+ message: string,
+ options?: Partial,
+): Promise {
+ return showDialog({
+ message,
+ variant: "danger",
+ buttons: "confirmCancel",
+ ...options,
+ });
}
/** In-game replacement for `alert()`. Resolves once dismissed. */
diff --git a/src/client/Main.ts b/src/client/Main.ts
index 3fba486d43..6fabd3adf7 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -801,6 +801,21 @@ class Client {
const lobbyId =
pathMatch && GAME_ID_REGEX.test(pathMatch[1]) ? pathMatch[1] : null;
if (lobbyId) {
+ // ?host means the lobby creator is returning to a successor lobby they
+ // reused from the win screen: reopen the host view bound to the existing
+ // lobby instead of the join flow. Non-creators who hit this URL still get
+ // treated as normal joiners by the server.
+ const returningAsHost = new URLSearchParams(window.location.search).has(
+ "host",
+ );
+ if (returningAsHost) {
+ // open() reveals the inline page itself (it calls showPage internally).
+ // Calling showPage first would open the modal once with no args and
+ // spuriously create a lobby before this attach call runs.
+ this.hostModal.open({ existingLobbyId: lobbyId });
+ console.log(`reopening host lobby ${lobbyId}`);
+ return;
+ }
window.showPage?.("page-join-lobby");
this.joinModal.open({ lobbyId });
console.log(`joining lobby ${lobbyId}`);
diff --git a/src/client/Transport.ts b/src/client/Transport.ts
index b4790bcf27..f57e761c9f 100644
--- a/src/client/Transport.ts
+++ b/src/client/Transport.ts
@@ -166,6 +166,12 @@ export class SendHashEvent implements GameEvent {
) {}
}
+// Emitted when the server tells us the host started a successor lobby, carrying
+// the new game id to move the group to.
+export class NewLobbyEvent implements GameEvent {
+ constructor(public readonly gameID: string) {}
+}
+
export class MoveWarshipIntentEvent implements GameEvent {
constructor(
public readonly unitIds: number[],
diff --git a/src/client/hud/GameRenderer.ts b/src/client/hud/GameRenderer.ts
index 5fc8a50551..dc1f92b64b 100644
--- a/src/client/hud/GameRenderer.ts
+++ b/src/client/hud/GameRenderer.ts
@@ -33,6 +33,7 @@ import { InGamePromo } from "./layers/InGamePromo";
import { Leaderboard } from "./layers/Leaderboard";
import { MainRadialMenu } from "./layers/MainRadialMenu";
import { MultiTabModal } from "./layers/MultiTabModal";
+import { NewLobbyPrompt } from "./layers/NewLobbyPrompt";
import { PerformanceOverlay } from "./layers/PerformanceOverlay";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
@@ -169,6 +170,15 @@ export function createRenderer(
winModal.eventBus = eventBus;
winModal.game = game;
+ const newLobbyPrompt = document.querySelector(
+ "new-lobby-prompt",
+ ) as NewLobbyPrompt;
+ if (!(newLobbyPrompt instanceof NewLobbyPrompt)) {
+ console.error("new lobby prompt not found");
+ }
+ newLobbyPrompt.eventBus = eventBus;
+ newLobbyPrompt.game = game;
+
const replayPanel = document.querySelector("replay-panel") as ReplayPanel;
if (!(replayPanel instanceof ReplayPanel)) {
console.error("replay panel not found");
@@ -322,6 +332,7 @@ export function createRenderer(
controlPanel,
playerInfo,
winModal,
+ newLobbyPrompt,
replayPanel,
settingsModal,
graphicsSettingsModal,
diff --git a/src/client/hud/layers/GameRightSidebar.ts b/src/client/hud/layers/GameRightSidebar.ts
index 8889271b0e..5fb129752e 100644
--- a/src/client/hud/layers/GameRightSidebar.ts
+++ b/src/client/hud/layers/GameRightSidebar.ts
@@ -3,10 +3,12 @@ import { customElement, state } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { GameType } from "../../../core/game/Game";
+import { createNextLobby } from "../../Api";
+import { ClientEnv } from "../../ClientEnv";
import "../../components/DoomsdayClockPanel";
import { Controller } from "../../Controller";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
-import { showInGameConfirm } from "../../InGameModal";
+import { showInGameAlert, showInGameConfirm } from "../../InGameModal";
import { TogglePauseIntentEvent } from "../../InputHandler";
import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport";
import { translateText } from "../../Utils";
@@ -19,6 +21,7 @@ const exitIcon = assetUrl("images/ExitIconWhite.svg");
const FastForwardIconSolid = assetUrl("images/FastForwardIconSolidWhite.svg");
const pauseIcon = assetUrl("images/PauseIconWhite.svg");
const playIcon = assetUrl("images/PlayIconWhite.svg");
+const newLobbyIcon = assetUrl("images/ReplayRegularIconWhite.svg");
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
const fullscreenIcon = assetUrl("images/FullscreenIconWhite.svg");
const exitFullscreenIcon = assetUrl("images/ExitFullscreenIconWhite.svg");
@@ -50,6 +53,10 @@ export class GameRightSidebar extends LitElement implements Controller {
private readonly onCrazyGames = crazyGamesSDK.isOnCrazyGames();
private hasWinner = false;
private isLobbyCreator = false;
+ private isPrivateLobby = false;
+ // Guards the in-game "New lobby" button so a double click doesn't fire twice
+ // before we navigate to the successor lobby.
+ private newLobbyRequested = false;
private spawnBarVisible = false;
private immunityBarVisible = false;
@@ -67,6 +74,8 @@ export class GameRightSidebar extends LitElement implements Controller {
this._isSinglePlayer =
this.game?.config()?.gameConfig()?.gameType === GameType.Singleplayer ||
this.game.config().isReplay();
+ this.isPrivateLobby =
+ this.game?.config()?.gameConfig()?.gameType === GameType.Private;
this._isVisible = true;
this.eventBus.on(SpawnBarVisibleEvent, (e) => {
@@ -194,6 +203,34 @@ export class GameRightSidebar extends LitElement implements Controller {
this.eventBus.emit(new PauseGameIntentEvent(this.isPaused));
}
+ private async onNewLobbyButtonClick() {
+ if (this.newLobbyRequested) return;
+ // Confirm so a stray click next to pause/exit doesn't yank everyone into a
+ // new lobby mid-game.
+ const isConfirmed = await showInGameConfirm(
+ translateText("new_lobby_prompt.confirm"),
+ { variant: "warning" },
+ );
+ if (!isConfirmed) return;
+ if (this.newLobbyRequested) return; // clicked again while confirming
+ this.newLobbyRequested = true;
+ this.requestUpdate();
+ try {
+ // The worker mints the successor lobby and has the current game
+ // broadcast its id, so everyone else gets the NewLobbyPrompt. We (the
+ // host) navigate straight to the new host view from the response.
+ const lobby = await createNextLobby(this.game.gameID());
+ const id = lobby.gameID;
+ // ?host routes the creator back into the host view on load.
+ window.location.href = `${window.location.origin}/${ClientEnv.workerPath(id)}/game/${id}?host`;
+ } catch (error) {
+ console.error("Failed to create successor lobby", error);
+ this.newLobbyRequested = false;
+ this.requestUpdate();
+ void showInGameAlert(translateText("new_lobby_prompt.failed"));
+ }
+ }
+
private async onExitButtonClick() {
const isAlive = this.game.myPlayer()?.isAlive();
if (isAlive) {
@@ -285,6 +322,9 @@ export class GameRightSidebar extends LitElement implements Controller {
const isReplayOrSingleplayer =
this._isSinglePlayer || this.game?.config()?.isReplay();
const showPauseButton = isReplayOrSingleplayer || this.isLobbyCreator;
+ // The host of a private lobby can start a fresh lobby at any time, without
+ // waiting to die or for the game to end.
+ const showNewLobbyButton = this.isLobbyCreator && this.isPrivateLobby;
return html`
${isReplayOrSingleplayer
@@ -311,6 +351,24 @@ export class GameRightSidebar extends LitElement implements Controller {
`
: ""}
+ ${showNewLobbyButton
+ ? html`
+
+
+
+ `
+ : ""}
`;
}
}
diff --git a/src/client/hud/layers/NewLobbyPrompt.ts b/src/client/hud/layers/NewLobbyPrompt.ts
new file mode 100644
index 0000000000..3bd5eba265
--- /dev/null
+++ b/src/client/hud/layers/NewLobbyPrompt.ts
@@ -0,0 +1,105 @@
+import { html, LitElement } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { translateText } from "../../../client/Utils";
+import { EventBus } from "../../../core/EventBus";
+import { ClientEnv } from "../../ClientEnv";
+import { Controller } from "../../Controller";
+import { crazyGamesSDK } from "../../CrazyGamesSDK";
+import { NewLobbyEvent } from "../../Transport";
+import { GameView } from "../../view";
+
+// Shown to non-host players when the host reuses the private lobby for another
+// game. It reacts to NewLobbyEvent (fired when the server broadcasts the
+// successor's id) so it works even after the win modal has been dismissed and
+// the player is spectating. The host is sent straight to the host view instead.
+@customElement("new-lobby-prompt")
+export class NewLobbyPrompt extends LitElement implements Controller {
+ public game: GameView;
+ public eventBus: EventBus;
+
+ @state()
+ private isVisible = false;
+
+ private gameID: string | null = null;
+
+ // Override to prevent shadow DOM creation (so Tailwind classes apply).
+ createRenderRoot() {
+ return this;
+ }
+
+ init() {
+ this.eventBus.on(NewLobbyEvent, (e) => this.onNewLobby(e));
+ }
+
+ private onNewLobby(event: NewLobbyEvent) {
+ this.gameID = event.gameID;
+ // The host asked for this lobby, so send them back to the host view. The
+ // ?host flag routes them there instead of the join flow on reload.
+ if (this.game?.myPlayer()?.isLobbyCreator()) {
+ window.location.href = this.lobbyUrl(true);
+ return;
+ }
+ this.isVisible = true;
+ this.requestUpdate();
+ }
+
+ private lobbyUrl(asHost: boolean): string {
+ const id = this.gameID ?? "";
+ const url = `${window.location.origin}/${ClientEnv.workerPath(id)}/game/${id}`;
+ return asHost ? `${url}?host` : url;
+ }
+
+ private _handleJoin() {
+ if (this.gameID === null) {
+ return;
+ }
+ // On CrazyGames the page URL still carries the invite param of the OLD
+ // game, and it wins over the path on reload — navigating the iframe to our
+ // own game URL would route the player straight back into the finished
+ // lobby. Send the top page to a fresh CrazyGames invite link instead: it
+ // updates that param and keeps the player on crazygames.com. (The host is
+ // unaffected: games they created are ignored by the invite-param check.)
+ if (crazyGamesSDK.isOnCrazyGames()) {
+ const link = crazyGamesSDK.createInviteLink(this.gameID);
+ if (link !== null) {
+ try {
+ window.top!.location.href = link;
+ return;
+ } catch (error) {
+ console.error("CrazyGames: top navigation failed", error);
+ }
+ }
+ }
+ window.location.href = this.lobbyUrl(false);
+ }
+
+ private _handleDismiss() {
+ this.isVisible = false;
+ this.requestUpdate();
+ }
+
+ render() {
+ if (!this.isVisible) {
+ return html``;
+ }
+ return html`
+