diff --git a/index.html b/index.html index 4976e7991..96084097c 100644 --- a/index.html +++ b/index.html @@ -353,6 +353,7 @@ +
, 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 8b07806b7..9f99bd91c 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 eb36443c7..67e41c0d7 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 0271cad0d..f565045f9 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 8a3a60d9d..4e5c49b7b 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -820,6 +820,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 b4790bcf2..f57e761c9 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 5fc8a5055..dc1f92b64 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 8889271b0..5fb129752 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` +
+ ${translateText("win_modal.new_lobby")} +
+ ` + : ""} `; } } diff --git a/src/client/hud/layers/NewLobbyPrompt.ts b/src/client/hud/layers/NewLobbyPrompt.ts new file mode 100644 index 000000000..3bd5eba26 --- /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` +
+ ${translateText("new_lobby_prompt.message")} + + +
+ `; + } +} diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 9432d6005..fb48c44d0 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -110,7 +110,8 @@ export type ServerMessage = | ServerDesyncMessage | ServerPrestartMessage | ServerErrorMessage - | ServerLobbyInfoMessage; + | ServerLobbyInfoMessage + | ServerNewLobbyMessage; export type ServerTurnMessage = z.infer; export type ServerStartGameMessage = z.infer< @@ -123,6 +124,7 @@ export type ServerErrorMessage = z.infer; export type ServerLobbyInfoMessage = z.infer< typeof ServerLobbyInfoMessageSchema >; +export type ServerNewLobbyMessage = z.infer; export type ClientSendWinnerMessage = z.infer; export type ClientSendLiveStatsMessage = z.infer< typeof ClientSendLiveStatsSchema @@ -758,6 +760,14 @@ export const ServerLobbyInfoMessageSchema = z.object({ myClientID: ID, }); +// Broadcast by a finished private game's server to every still-connected client +// when the host starts a successor lobby, so the whole group can hop to the new +// game without re-sharing the link. gameID is the freshly minted successor. +export const ServerNewLobbyMessageSchema = z.object({ + type: z.literal("new_lobby"), + gameID: ID, +}); + export const ServerMessageSchema = z.discriminatedUnion("type", [ ServerTurnMessageSchema, ServerPrestartMessageSchema, @@ -766,6 +776,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [ ServerDesyncSchema, ServerErrorSchema, ServerLobbyInfoMessageSchema, + ServerNewLobbyMessageSchema, ]); // diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index c82914222..71a39652d 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -12,6 +12,7 @@ import { ClientSendLiveStatsMessage, ClientSendWinnerMessage, GameConfig, + GameID, GameInfo, GameStartInfo, GameStartInfoSchema, @@ -24,6 +25,7 @@ import { ServerDesyncSchema, ServerErrorMessage, ServerLobbyInfoMessage, + ServerNewLobbyMessage, ServerPrestartMessageSchema, ServerStartGameMessage, ServerTurnMessage, @@ -158,6 +160,11 @@ export class GameServer { private visibleAt?: number; + // The successor lobby this game has already spawned, if any. Kept so a + // repeated create_game?previous= call (e.g. a double click) reuses the same + // id instead of minting another lobby. + private successorLobbyId: GameID | null = null; + constructor( public readonly id: string, readonly log_: Logger, @@ -881,6 +888,35 @@ export class GameServer { }); } + // The worker created a successor lobby for this game (the host asked to + // reuse the private lobby via create_game?previous=). Remember it so repeat + // requests reuse the same lobby, and tell everyone still connected its id so + // they can hop over without re-sharing a link. + public setSuccessorLobby(gameID: GameID) { + this.successorLobbyId = gameID; + this.log.info("successor lobby created", { + gameID: this.id, + successorID: gameID, + }); + this.broadcastNewLobby(gameID); + } + + public successorLobby(): GameID | null { + return this.successorLobbyId; + } + + private broadcastNewLobby(gameID: GameID) { + const msg = JSON.stringify({ + type: "new_lobby", + gameID, + } satisfies ServerNewLobbyMessage); + this.activeClients.forEach((c) => { + if (c.ws.readyState === WebSocket.OPEN) { + c.ws.send(msg); + } + }); + } + public start() { if (this._hasStarted || this._hasEnded) { return; diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 95dee5c24..bf043567d 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -12,6 +12,7 @@ import { GameEnv } from "../core/configuration/Config"; import { GameType } from "../core/game/Game"; import { ClientMessageSchema, + ID, MAX_HOSTED_LOBBIES, PartialGameRecordSchema, ServerErrorMessage, @@ -23,6 +24,7 @@ import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; import { registerGamePreviewRoute } from "./GamePreviewRoute"; +import type { GameServer } from "./GameServer"; import { getUserMe, verifyClientToken } from "./jwt"; import { logger } from "./Logger"; @@ -173,6 +175,50 @@ export async function startWorker() { .json({ error: "Cannot create public games via this endpoint" }); } + // Reuse-lobby flow: ?previous= marks this creation as the successor + // of a finished private game, so its remaining players get told the new id + // and can hop over without re-sharing a link. The previous game lives on + // this same worker (callers hit /wX/api/create_game for it), which is also + // where the successor is minted. Going through this endpoint (instead of a + // websocket message) keeps game creation behind its rate limits. + let previousGame: GameServer | null = null; + if (req.query.previous !== undefined) { + const prevId = ID.safeParse(req.query.previous); + if (!prevId.success) { + return res.status(400).json({ error: "Invalid previous game id" }); + } + previousGame = gm.game(prevId.data); + if (previousGame === null) { + return res.status(404).json({ error: "Previous game not found" }); + } + if (!previousGame.isCreator(creatorPersistentID)) { + return res.status(403).json({ + error: "Only the lobby creator can create a successor lobby", + }); + } + // Reusing a lobby is a private-lobby feature: a public game's players + // never opted into following a host to another game. + if (previousGame.isPublic()) { + return res + .status(403) + .json({ error: "Public games cannot spawn a successor lobby" }); + } + // Idempotent: a repeat request (e.g. a double click) reuses the already + // minted successor instead of creating another one. + const existingId = previousGame.successorLobby(); + const existing = existingId !== null ? gm.game(existingId) : null; + if (existingId !== null && existing !== null) { + previousGame.setSuccessorLobby(existingId); // re-broadcast for late joiners + return res.json({ + ...existing.gameInfo(), + workerIndex: workerId, + workerPath: ServerEnv.workerPath(existingId), + }); + } + // A recorded successor that no longer exists (already cleaned up) falls + // through and gets replaced by a fresh lobby. + } + const id = ServerEnv.generateGameIdForWorker(workerId); if (id === null) { log.warn(`Failed to mint game id on worker ${workerId}`); @@ -185,6 +231,11 @@ export async function startWorker() { return res.status(409).json({ error: "Game ID already exists" }); } + // Tell the previous game about its successor: it remembers the id (for + // idempotency) and broadcasts it to everyone still connected. Done after + // creation so a failed creation never broadcasts a dead id. + previousGame?.setSuccessorLobby(id); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const clientIP = req.ip || req.socket.remoteAddress || "unknown"; log.info( diff --git a/tests/NewLobbyMessages.test.ts b/tests/NewLobbyMessages.test.ts new file mode 100644 index 000000000..2cf167f88 --- /dev/null +++ b/tests/NewLobbyMessages.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { ServerMessageSchema } from "../src/core/Schemas"; + +// Wire message that powers reusing a private lobby for back-to-back games: +// the server's "new_lobby" broadcast carrying the successor's id. (Creation +// itself goes through the rate-limited create_game?previous= HTTP endpoint, +// not the websocket.) +describe("reuse-lobby wire messages", () => { + it("accepts a new_lobby server message with a valid game id", () => { + const parsed = ServerMessageSchema.safeParse({ + type: "new_lobby", + gameID: "abcd1234", + }); + expect(parsed.success).toBe(true); + if (parsed.success && parsed.data.type === "new_lobby") { + expect(parsed.data.gameID).toBe("abcd1234"); + } + }); + + it("rejects a new_lobby message without a game id", () => { + const parsed = ServerMessageSchema.safeParse({ type: "new_lobby" }); + expect(parsed.success).toBe(false); + }); + + it("rejects a new_lobby game id that is not a valid 8-char id", () => { + const parsed = ServerMessageSchema.safeParse({ + type: "new_lobby", + gameID: "not a valid id!", + }); + expect(parsed.success).toBe(false); + }); +}); diff --git a/tests/server/CreateNextLobby.test.ts b/tests/server/CreateNextLobby.test.ts new file mode 100644 index 000000000..36e1bf94d --- /dev/null +++ b/tests/server/CreateNextLobby.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GameType } from "../../src/core/game/Game"; +import { Client } from "../../src/server/Client"; +import { GameServer } from "../../src/server/GameServer"; + +function makeMockWs() { + const handlers: Record any> = {}; + return { + on: (event: string, handler: (...args: any[]) => any) => { + handlers[event] = handler; + }, + removeAllListeners: (_event: string) => {}, + send: vi.fn(), + close: vi.fn(), + readyState: 1, + trigger: (event: string, ...args: any[]) => handlers[event]?.(...args), + }; +} + +function makeClient( + clientID: string, + persistentID: string, +): { client: Client; ws: ReturnType } { + const ws = makeMockWs(); + const client = new Client( + clientID, + persistentID, + null, + null, + undefined, + "127.0.0.1", + "TestUser", + null, + ws as any, + undefined, + undefined, + [], + ); + return { client, ws }; +} + +// The successor id the broadcast should carry (8-char id shape). +const SUCCESSOR_ID = "SUCCES01"; + +function newLobbyBroadcasts(ws: ReturnType): string[] { + return ws.send.mock.calls + .map((c: any[]) => c[0]) + .filter( + (m: unknown) => typeof m === "string" && m.includes('"type":"new_lobby"'), + ); +} + +// The worker's create_game?previous= flow calls setSuccessorLobby on the +// finished game after minting the successor: the game must remember the id +// (so repeat requests reuse it) and broadcast it to everyone still connected. +describe("GameServer - successor lobby", () => { + let mockLogger: any; + + beforeEach(() => { + vi.useFakeTimers(); + mockLogger = { + child: vi.fn().mockReturnThis(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllTimers(); + }); + + function makeGame(creatorPersistentID?: string) { + return new GameServer( + "test-game", + mockLogger, + Date.now(), + { gameType: GameType.Private } as any, + creatorPersistentID, + ); + } + + it("broadcasts the successor id to everyone still connected", () => { + const game = makeGame("creator-pid"); + const { client: creator, ws: creatorWs } = makeClient( + "creator-cid", + "creator-pid", + ); + const { client: other, ws: otherWs } = makeClient("other-cid", "other-pid"); + game.joinClient(creator); + game.joinClient(other); + + game.setSuccessorLobby(SUCCESSOR_ID); + + expect(newLobbyBroadcasts(creatorWs)).toHaveLength(1); + expect(newLobbyBroadcasts(otherWs)).toHaveLength(1); + expect(newLobbyBroadcasts(otherWs)[0]).toContain(SUCCESSOR_ID); + }); + + it("remembers the successor id so repeat requests can reuse it", () => { + const game = makeGame("creator-pid"); + expect(game.successorLobby()).toBeNull(); + + game.setSuccessorLobby(SUCCESSOR_ID); + + expect(game.successorLobby()).toBe(SUCCESSOR_ID); + }); + + it("re-broadcasts on a repeat call (double click) with the same id", () => { + const game = makeGame("creator-pid"); + const { client: creator, ws: creatorWs } = makeClient( + "creator-cid", + "creator-pid", + ); + game.joinClient(creator); + + game.setSuccessorLobby(SUCCESSOR_ID); + game.setSuccessorLobby(SUCCESSOR_ID); + + expect(newLobbyBroadcasts(creatorWs)).toHaveLength(2); + expect(game.successorLobby()).toBe(SUCCESSOR_ID); + }); + + it("authorizes successor creation by creator persistentID", () => { + const game = makeGame("creator-pid"); + // The worker checks isCreator() before minting a successor. + expect(game.isCreator("creator-pid")).toBe(true); + expect(game.isCreator("rando-pid")).toBe(false); + }); +});