Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@
<emoji-table></emoji-table>
<build-menu></build-menu>
<win-modal></win-modal>
<new-lobby-prompt></new-lobby-prompt>
<game-starting-modal></game-starting-modal>
<div
class="flex flex-col items-end fixed top-0 right-0 min-[1200px]:top-4 min-[1200px]:right-4 z-1000 gap-2"
Expand Down
8 changes: 8 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,13 @@
"teams_of": "{teamCount} teams of {playersPerTeam}",
"teams_title": "Teams"
},
"new_lobby_prompt": {
"confirm": "Start a new lobby? All players will be invited to join.",
"dismiss": "Dismiss",
"failed": "Could not create a new lobby. Please try again.",
"join": "Join",
"message": "Host started a new lobby"
},
"news": {
"title": "Release Notes"
},
Expand Down Expand Up @@ -1563,6 +1570,7 @@
"join_server": "Join Server",
"keep": "Keep Playing",
"nation_won": "Nation {nation} has won!",
"new_lobby": "New Lobby",
"other_team": "{team} team has won!",
"other_won": "{player} has won!",
"requeue": "Play Again",
Expand Down
33 changes: 32 additions & 1 deletion src/client/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import {
UserMeResponse,
UserMeResponseSchema,
} from "../core/ApiSchemas";
import { AnalyticsRecord, AnalyticsRecordSchema } from "../core/Schemas";
import {
AnalyticsRecord,
AnalyticsRecordSchema,
GameInfo,
} from "../core/Schemas";
import { getAuthHeader, getPlayToken, logOut, userAuth } from "./Auth";
import { ClientEnv } from "./ClientEnv";

Expand Down Expand Up @@ -404,6 +408,33 @@ export async function setLobbyListed(
}
}

// POST /wX/api/create_game?previous=<gameID>, 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<GameInfo> {
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();

Expand Down
7 changes: 7 additions & 0 deletions src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { GoToPlayerEvent } from "./TransformHandler";
import {
MoveWarshipIntentEvent,
NewLobbyEvent,
SendAllianceExtensionIntentEvent,
SendAllianceRequestIntentEvent,
SendAttackIntentEvent,
Expand Down Expand Up @@ -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() &&
Expand Down
44 changes: 43 additions & 1 deletion src/client/HostLobbyModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,7 @@ export class HostLobbyModal extends BaseModal {
`;
}

protected onOpen(): void {
protected onOpen(args?: Record<string, unknown>): 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 —
Expand All @@ -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;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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.

Expand Down Expand Up @@ -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<void> {
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;
Expand Down
18 changes: 15 additions & 3 deletions src/client/InGameModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,21 @@ function showDialog(props: Partial<ConfirmDialog>): Promise<boolean> {
});
}

/** In-game replacement for `confirm()`. Resolves true when confirmed. */
export function showInGameConfirm(message: string): Promise<boolean> {
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<ConfirmDialog>,
): Promise<boolean> {
return showDialog({
message,
variant: "danger",
buttons: "confirmCancel",
...options,
});
}

/** In-game replacement for `alert()`. Resolves once dismissed. */
Expand Down
15 changes: 15 additions & 0 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
6 changes: 6 additions & 0 deletions src/client/Transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down
11 changes: 11 additions & 0 deletions src/client/hud/GameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -322,6 +332,7 @@ export function createRenderer(
controlPanel,
playerInfo,
winModal,
newLobbyPrompt,
replayPanel,
settingsModal,
graphicsSettingsModal,
Expand Down
60 changes: 59 additions & 1 deletion src/client/hud/layers/GameRightSidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -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;

Expand All @@ -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) => {
Expand Down Expand Up @@ -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"));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private async onExitButtonClick() {
const isAlive = this.game.myPlayer()?.isAlive();
if (isAlive) {
Expand Down Expand Up @@ -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
Expand All @@ -311,6 +351,24 @@ export class GameRightSidebar extends LitElement implements Controller {
</div>
`
: ""}
${showNewLobbyButton
? html`
<div
class="cursor-pointer ${this.newLobbyRequested
? "opacity-50 pointer-events-none"
: ""}"
@click=${this.onNewLobbyButtonClick}
title=${translateText("win_modal.new_lobby")}
>
<img
src=${newLobbyIcon}
alt=${translateText("win_modal.new_lobby")}
width="20"
height="20"
/>
</div>
`
: ""}
`;
}
}
Loading
Loading