From b0e17b653f2eb56b99138e354e7dec1b0425963c Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:05:47 +0000 Subject: [PATCH 1/4] fix(client): show queue position in public lobby modal --- src/client/JoinLobbyModal.ts | 28 +++++++++++- tests/client/JoinLobbyModal.test.ts | 68 +++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index 1d2dd658a9..9419232452 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -20,6 +20,7 @@ import { GameRecordSchema, LobbyInfoEvent, PublicGameInfo, + PublicGames, } from "../core/Schemas"; import { Difficulty, @@ -65,6 +66,7 @@ export class JoinLobbyModal extends BaseModal { // the pre-join form. @state() private hostedLobbies: PublicGameInfo[] = []; @state() private hostedLobbiesLoaded = false; + @state() private publicLobbies: PublicGames | null = null; // Deliberately not persisted: the bell starts off and is re-armed by hand // for each game (reset in startTrackingLobby). @state() private notifyOnStart = false; @@ -79,6 +81,7 @@ export class JoinLobbyModal extends BaseModal { private handledJoinTimeout = false; private readonly hostedLobbySocket = new PublicLobbySocket((lobbies) => { + this.publicLobbies = lobbies; this.hostedLobbies = lobbies.games?.hosted ?? []; this.hostedLobbiesLoaded = true; }); @@ -87,6 +90,22 @@ export class JoinLobbyModal extends BaseModal { return this.gameConfig?.gameType === GameType.Private; } + private get queuePosition(): number | null { + if (this.gameConfig?.gameType !== GameType.Public) return null; + // Match the browser's position within the full scheduled bucket, excluding + // its active countdown. A missing snapshot/lobby must not show Queue: 0. + for (const type of ["ffa", "team", "special"] as const) { + const queue = this.publicLobbies?.games[type]?.filter( + (lobby) => lobby.startsAt === undefined, + ); + const index = queue?.findIndex( + (lobby) => lobby.gameID === this.currentLobbyId, + ); + if (index !== undefined && index >= 0) return index + 1; + } + return null; + } + // Read off the server's own view of us, so a switch it refused (lobby full, // game already started) shows the real state instead of what was asked for. private get isSpectating(): boolean { @@ -318,11 +337,16 @@ export class JoinLobbyModal extends BaseModal { this.serverTimeOffset, ) : null; + const queuePosition = this.queuePosition; const statusLabel = secondsRemaining === null ? this.isPrivateLobby() ? translateText("private_lobby.joined_waiting") - : translateText("public_lobby.waiting_for_players") + : queuePosition !== null + ? translateText("detailed_view.queue_position", { + position: queuePosition, + }) + : translateText("public_lobby.waiting_for_players") : secondsRemaining > 0 ? translateText("public_lobby.starting_in", { time: renderDuration(secondsRemaining), @@ -611,6 +635,7 @@ export class JoinLobbyModal extends BaseModal { // disarmLeaveOnClose() runs, no close cascade can re-arm it and // disconnect the player mid game-start. this.leaveLobbyOnClose = true; + this.publicLobbies = null; this.hostedLobbiesLoaded = false; void this.hostedLobbySocket.start(); const lobbyId = typeof args?.lobbyId === "string" ? args.lobbyId : ""; @@ -718,6 +743,7 @@ export class JoinLobbyModal extends BaseModal { protected onClose(): void { this.hostedLobbySocket.stop(); + this.publicLobbies = null; this.hostedLobbies = []; this.hostedLobbiesLoaded = false; this.clearCountdownTimer(); diff --git a/tests/client/JoinLobbyModal.test.ts b/tests/client/JoinLobbyModal.test.ts index fa10117c78..77af4f2e2a 100644 --- a/tests/client/JoinLobbyModal.test.ts +++ b/tests/client/JoinLobbyModal.test.ts @@ -22,6 +22,74 @@ vi.mock("../../src/client/DesktopPresence", () => ({ import { JoinLobbyModal } from "../../src/client/JoinLobbyModal"; import { GameMode, GameType } from "../../src/core/game/Game"; +describe("JoinLobbyModal queue status", () => { + function setup() { + const modal = new JoinLobbyModal(); + const state = modal as any; + state.currentLobbyId = "queued-2"; + state.gameConfig = { gameType: GameType.Public }; + state.isConnecting = true; + const language = document.createElement("lang-selector") as any; + language.currentLang = "debug"; + document.body.append(language); + const queued = [{ gameID: "queued-1" }, { gameID: "queued-2" }]; + const update = (queue = queued) => + state.hostedLobbySocket.onLobbiesUpdate({ + serverTime: Date.now(), + games: { + team: [{ gameID: "other-bucket" }], + ffa: [{ gameID: "active", startsAt: Date.now() + 60_000 }, ...queue], + }, + }); + const status = () => { + const container = document.createElement("div"); + render(state.renderBody(), container); + return container.textContent; + }; + return { state, update, status }; + } + + it("updates the position within its own queue as earlier lobbies leave", () => { + const { update, status } = setup(); + update(); + expect(status()).toContain("detailed_view.queue_position::position=2"); + update([{ gameID: "queued-2" }]); + expect(status()).toContain("detailed_view.queue_position::position=1"); + }); + + it("falls back to waiting before the feed arrives or when the lobby is absent", () => { + const { update, status } = setup(); + expect(status()).toContain("public_lobby.waiting_for_players"); + update([]); + expect(status()).toContain("public_lobby.waiting_for_players"); + expect(status()).not.toContain("detailed_view.queue_position"); + }); + + it("lets the lobby countdown take precedence over a stale queue snapshot", () => { + const { state, update, status } = setup(); + update(); + state.lobbyStartAt = Date.now() + 30_000; + expect(status()).toContain("public_lobby.starting_in"); + expect(status()).not.toContain("detailed_view.queue_position"); + }); + + it("preserves private and hosted lobby waiting status", () => { + const { state, update, status } = setup(); + update(); + state.gameConfig = { gameType: GameType.Private }; + expect(status()).toContain("private_lobby.joined_waiting"); + expect(status()).not.toContain("detailed_view.queue_position"); + }); + + it("clears the snapshot when the modal closes", () => { + const { state, update } = setup(); + update(); + state.leaveLobbyOnClose = false; + state.onClose(); + expect(state.publicLobbies).toBeNull(); + }); +}); + describe("JoinLobbyModal server time offset", () => { let nowMs = 0; From 95371cbb9a5f67014c8ff95de08ed50848638c21 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:09:57 +0000 Subject: [PATCH 2/4] refactor(client): share lobby queue position calculation --- src/client/JoinLobbyModal.ts | 22 ++----- .../components/DetailedGameViewModal.ts | 13 ++--- src/client/utilities/LobbyQueuePosition.ts | 17 ++++++ tests/client/LobbyQueuePosition.test.ts | 57 +++++++++++++++++++ 4 files changed, 84 insertions(+), 25 deletions(-) create mode 100644 src/client/utilities/LobbyQueuePosition.ts create mode 100644 tests/client/LobbyQueuePosition.test.ts diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index 9419232452..7263e2048f 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -45,6 +45,7 @@ import "./components/LobbyPlayerView"; import { inviteFriendsButton } from "./components/ui/InviteFriendsButton"; import { DEFAULT_TITLE_CLASS, modalHeader } from "./components/ui/ModalHeader"; import { nationsConfigToSlider } from "./utilities/GameConfigHelpers"; +import { getLobbyQueuePosition } from "./utilities/LobbyQueuePosition"; @customElement("join-lobby-modal") export class JoinLobbyModal extends BaseModal { @@ -90,22 +91,6 @@ export class JoinLobbyModal extends BaseModal { return this.gameConfig?.gameType === GameType.Private; } - private get queuePosition(): number | null { - if (this.gameConfig?.gameType !== GameType.Public) return null; - // Match the browser's position within the full scheduled bucket, excluding - // its active countdown. A missing snapshot/lobby must not show Queue: 0. - for (const type of ["ffa", "team", "special"] as const) { - const queue = this.publicLobbies?.games[type]?.filter( - (lobby) => lobby.startsAt === undefined, - ); - const index = queue?.findIndex( - (lobby) => lobby.gameID === this.currentLobbyId, - ); - if (index !== undefined && index >= 0) return index + 1; - } - return null; - } - // Read off the server's own view of us, so a switch it refused (lobby full, // game already started) shows the real state instead of what was asked for. private get isSpectating(): boolean { @@ -337,7 +322,10 @@ export class JoinLobbyModal extends BaseModal { this.serverTimeOffset, ) : null; - const queuePosition = this.queuePosition; + const queuePosition = + this.gameConfig?.gameType === GameType.Public + ? getLobbyQueuePosition(this.publicLobbies, this.currentLobbyId) + : null; const statusLabel = secondsRemaining === null ? this.isPrivateLobby() diff --git a/src/client/components/DetailedGameViewModal.ts b/src/client/components/DetailedGameViewModal.ts index 6bf3c93470..8f37d04dc3 100644 --- a/src/client/components/DetailedGameViewModal.ts +++ b/src/client/components/DetailedGameViewModal.ts @@ -20,6 +20,7 @@ import { JoinLobbyModal } from "../JoinLobbyModal"; import { PublicLobbySocket } from "../LobbySocket"; import { JoinLobbyEvent } from "../Main"; import { UsernameInput } from "../UsernameInput"; +import { getLobbyQueuePosition } from "../utilities/LobbyQueuePosition"; import { calculateServerTimeOffset, getGameModeLabel, @@ -477,14 +478,10 @@ export class DetailedGameViewModal extends BaseModal { if (lobby.publicGameType === "hosted") { return translateText("public_lobby.waiting_for_players"); } - // Use the full server queue so filtering doesn't renumber waiting lobbies. - const queue = this.lobbies?.games[lobby.publicGameType]?.filter( - (candidate) => candidate.startsAt === undefined, - ); - const position = - (queue?.findIndex((candidate) => candidate.gameID === lobby.gameID) ?? - -1) + 1; - return translateText("detailed_view.queue_position", { position }); + const position = getLobbyQueuePosition(this.lobbies, lobby.gameID); + return position !== null + ? translateText("detailed_view.queue_position", { position }) + : translateText("public_lobby.waiting_for_players"); } const seconds = getSecondsUntilServerTimestamp( lobby.startsAt, diff --git a/src/client/utilities/LobbyQueuePosition.ts b/src/client/utilities/LobbyQueuePosition.ts new file mode 100644 index 0000000000..31ccc9c0b9 --- /dev/null +++ b/src/client/utilities/LobbyQueuePosition.ts @@ -0,0 +1,17 @@ +import type { PublicGames } from "../../core/Schemas"; + +/** One-based position in the full scheduled bucket, excluding its countdown. */ +export function getLobbyQueuePosition( + lobbies: PublicGames | null, + gameId: string, +): number | null { + for (const type of ["ffa", "team", "special"] as const) { + const queue = lobbies?.games[type]?.filter( + (lobby) => lobby.startsAt === undefined, + ); + const index = queue?.findIndex((lobby) => lobby.gameID === gameId); + if (index !== undefined && index >= 0) return index + 1; + } + // Hosted, active, and missing lobbies have no scheduled queue position. + return null; +} diff --git a/tests/client/LobbyQueuePosition.test.ts b/tests/client/LobbyQueuePosition.test.ts new file mode 100644 index 0000000000..1f0be53e79 --- /dev/null +++ b/tests/client/LobbyQueuePosition.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { getLobbyQueuePosition } from "../../src/client/utilities/LobbyQueuePosition"; +import type { PublicGameInfo, PublicGames } from "../../src/core/Schemas"; + +function lobby( + gameID: string, + publicGameType: PublicGameInfo["publicGameType"], + startsAt?: number, +): PublicGameInfo { + return { gameID, publicGameType, numClients: 0, startsAt }; +} + +describe("getLobbyQueuePosition", () => { + it.each(["ffa", "team", "special"] as const)( + "counts only waiting lobbies in the %s bucket, in server order", + (type) => { + const lobbies: PublicGames = { + serverTime: 0, + games: { + ffa: [lobby("unrelated", "ffa")], + team: [lobby("unrelated", "team")], + special: [lobby("unrelated", "special")], + [type]: [ + lobby("active", type, 0), + lobby("first", type), + lobby("second", type), + ], + }, + }; + expect(getLobbyQueuePosition(lobbies, "first")).toBe(1); + expect(getLobbyQueuePosition(lobbies, "second")).toBe(2); + expect(getLobbyQueuePosition(lobbies, "active")).toBeNull(); + }, + ); + + it("has no position for missing snapshots, buckets, or lobbies", () => { + expect(getLobbyQueuePosition(null, "missing")).toBeNull(); + expect( + getLobbyQueuePosition({ serverTime: 0, games: {} }, "missing"), + ).toBeNull(); + expect( + getLobbyQueuePosition( + { serverTime: 0, games: { ffa: [lobby("first", "ffa")] } }, + "missing", + ), + ).toBeNull(); + }); + + it("does not number hosted lobbies", () => { + expect( + getLobbyQueuePosition( + { serverTime: 0, games: { hosted: [lobby("hosted", "hosted")] } }, + "hosted", + ), + ).toBeNull(); + }); +}); From 594529a42eb0348f9d539b7387ecf0faffbea3f0 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:26:08 +0000 Subject: [PATCH 3/4] refactor(client): colocate lobby queue logic and reuse scheduled types --- src/client/JoinLobbyModal.ts | 2 +- src/client/{utilities/LobbyQueuePosition.ts => LobbyQueue.ts} | 4 ++-- src/client/components/DetailedGameViewModal.ts | 2 +- tests/client/LobbyQueuePosition.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename src/client/{utilities/LobbyQueuePosition.ts => LobbyQueue.ts} (79%) diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index 7263e2048f..f9142d599d 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -31,6 +31,7 @@ import { } from "../core/game/Game"; import { getApiBase } from "./Api"; import { crazyGamesSDK } from "./CrazyGamesSDK"; +import { getLobbyQueuePosition } from "./LobbyQueue"; import { PublicLobbySocket } from "./LobbySocket"; import { JoinLobbyEvent } from "./Main"; import { ensureServerList, redirectToGameVersion } from "./ServerList"; @@ -45,7 +46,6 @@ import "./components/LobbyPlayerView"; import { inviteFriendsButton } from "./components/ui/InviteFriendsButton"; import { DEFAULT_TITLE_CLASS, modalHeader } from "./components/ui/ModalHeader"; import { nationsConfigToSlider } from "./utilities/GameConfigHelpers"; -import { getLobbyQueuePosition } from "./utilities/LobbyQueuePosition"; @customElement("join-lobby-modal") export class JoinLobbyModal extends BaseModal { diff --git a/src/client/utilities/LobbyQueuePosition.ts b/src/client/LobbyQueue.ts similarity index 79% rename from src/client/utilities/LobbyQueuePosition.ts rename to src/client/LobbyQueue.ts index 31ccc9c0b9..653c9dce0a 100644 --- a/src/client/utilities/LobbyQueuePosition.ts +++ b/src/client/LobbyQueue.ts @@ -1,11 +1,11 @@ -import type { PublicGames } from "../../core/Schemas"; +import { SCHEDULED_PUBLIC_GAME_TYPES, type PublicGames } from "../core/Schemas"; /** One-based position in the full scheduled bucket, excluding its countdown. */ export function getLobbyQueuePosition( lobbies: PublicGames | null, gameId: string, ): number | null { - for (const type of ["ffa", "team", "special"] as const) { + for (const type of SCHEDULED_PUBLIC_GAME_TYPES) { const queue = lobbies?.games[type]?.filter( (lobby) => lobby.startsAt === undefined, ); diff --git a/src/client/components/DetailedGameViewModal.ts b/src/client/components/DetailedGameViewModal.ts index 8f37d04dc3..4248ef558d 100644 --- a/src/client/components/DetailedGameViewModal.ts +++ b/src/client/components/DetailedGameViewModal.ts @@ -17,10 +17,10 @@ import { shouldBlockSocketSourcedAction, } from "../GameModeSelector"; import { JoinLobbyModal } from "../JoinLobbyModal"; +import { getLobbyQueuePosition } from "../LobbyQueue"; import { PublicLobbySocket } from "../LobbySocket"; import { JoinLobbyEvent } from "../Main"; import { UsernameInput } from "../UsernameInput"; -import { getLobbyQueuePosition } from "../utilities/LobbyQueuePosition"; import { calculateServerTimeOffset, getGameModeLabel, diff --git a/tests/client/LobbyQueuePosition.test.ts b/tests/client/LobbyQueuePosition.test.ts index 1f0be53e79..2f34a9c463 100644 --- a/tests/client/LobbyQueuePosition.test.ts +++ b/tests/client/LobbyQueuePosition.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getLobbyQueuePosition } from "../../src/client/utilities/LobbyQueuePosition"; +import { getLobbyQueuePosition } from "../../src/client/LobbyQueue"; import type { PublicGameInfo, PublicGames } from "../../src/core/Schemas"; function lobby( From 846a81cd8b9d890a49fae62505d686d48c577140 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:16:38 +0000 Subject: [PATCH 4/4] fix(client): follow joined lobby server for queue updates --- src/client/JoinLobbyModal.ts | 14 ++- src/client/LobbySocket.ts | 56 +++++++++--- tests/LobbySocketRouting.test.ts | 129 ++++++++++++++++++++++++++++ tests/client/JoinLobbyModal.test.ts | 31 +++++++ 4 files changed, 214 insertions(+), 16 deletions(-) create mode 100644 tests/LobbySocketRouting.test.ts diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index f9142d599d..13fb8504a8 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -623,9 +623,6 @@ export class JoinLobbyModal extends BaseModal { // disarmLeaveOnClose() runs, no close cascade can re-arm it and // disconnect the player mid game-start. this.leaveLobbyOnClose = true; - this.publicLobbies = null; - this.hostedLobbiesLoaded = false; - void this.hostedLobbySocket.start(); const lobbyId = typeof args?.lobbyId === "string" ? args.lobbyId : ""; const lobbyInfo = args?.lobbyInfo as GameInfo | PublicGameInfo | undefined; if (lobbyId) { @@ -634,9 +631,18 @@ export class JoinLobbyModal extends BaseModal { if (!lobbyInfo) { this.handleUrlJoin(lobbyId, args?.spectate === true); } + } else { + this.startLobbyFeed(); } } + private startLobbyFeed(lobbyId?: string) { + this.publicLobbies = null; + this.hostedLobbies = []; + this.hostedLobbiesLoaded = false; + void this.hostedLobbySocket.start(lobbyId); + } + private async handleUrlJoin( lobbyId: string, spectator = false, @@ -683,6 +689,7 @@ export class JoinLobbyModal extends BaseModal { lobbyInfo?: GameInfo | PublicGameInfo, ) { this.currentLobbyId = lobbyId; + this.startLobbyFeed(lobbyId); // clientID will be assigned by server via lobby_info message this.currentClientID = ""; this.gameConfig = null; @@ -709,6 +716,7 @@ export class JoinLobbyModal extends BaseModal { this.currentLobbyId = ""; this.currentClientID = ""; this.isConnecting = false; + if (this.isModalOpen) this.startLobbyFeed(); } private leaveLobby() { diff --git a/src/client/LobbySocket.ts b/src/client/LobbySocket.ts index 511aba57f4..22e688f188 100644 --- a/src/client/LobbySocket.ts +++ b/src/client/LobbySocket.ts @@ -1,5 +1,5 @@ import { ClientEnv, NoServerError } from "src/client/ClientEnv"; -import { PublicGames } from "../core/Schemas"; +import { GameID, PublicGames } from "../core/Schemas"; import { decodeLobbyMessage } from "../core/ZbinWire"; import { showInGameAlert } from "./InGameModal"; import { ensureServerList, reloadWouldRescue } from "./ServerList"; @@ -26,6 +26,8 @@ export class PublicLobbySocket { private wsAttemptCounted = false; private workerPath: string = ""; private stopped = true; + private generation = 0; + private gameID: GameID | undefined; // Latest full snapshot, used as the base for applying counts-only deltas. private lastFull: PublicGames | null = null; @@ -43,7 +45,10 @@ export class PublicLobbySocket { this.onUpdateAvailable = options?.onUpdateAvailable; } - async start() { + /** Follow a joined game's server, or the picked server for lobby browsing. */ + async start(gameID?: GameID) { + this.stop(); + this.gameID = gameID; this.stopped = false; this.wsConnectionAttempts = 0; await this.discoverAndConnect(); @@ -84,9 +89,10 @@ export class PublicLobbySocket { // path never gets that far, so without clearing it here the counter // would freeze at one and the retry would run every reconnectDelay // forever, never reaching maxWsAttempts and never telling the player. + const generation = this.generation; this.wsAttemptCounted = false; const listStatus = await ensureServerList(); - if (this.stopped) return; + if (this.stopped || generation !== this.generation) return; if (listStatus === "outdated") this.fireUpdateAvailable(); // Get config to determine number of workers, then pick a random one. // With no list and nothing injected there is no server to ask (a static @@ -94,7 +100,10 @@ export class PublicLobbySocket { // any other: take the same path a refused socket does rather than // rejecting a promise most callers never await. try { - this.workerPath = getRandomWorkerPath(ClientEnv.numWorkers()); + this.workerPath = + this.gameID !== undefined + ? `/${ClientEnv.gameWorkerPath(this.gameID)}` + : getRandomWorkerPath(ClientEnv.numWorkers()); } catch (e) { if (!(e instanceof NoServerError)) throw e; this.handleConnectError(e, () => void this.discoverAndConnect()); @@ -105,16 +114,19 @@ export class PublicLobbySocket { stop() { this.stopped = true; + this.generation++; this.lastFull = null; this.disconnectWebSocket(); } private connectWebSocket() { + if (this.stopped) return; try { // Clean up existing WebSocket before creating a new one if (this.ws) { - this.ws.close(); + const oldSocket = this.ws; this.ws = null; + oldSocket.close(); } // Drop any cached snapshot — the server primes new connections with a // fresh full message, and a stale base could mis-merge incoming deltas. @@ -122,17 +134,33 @@ export class PublicLobbySocket { // WS origin comes from ClientEnv (same-origin on web, audience-derived on // the desktop app://openfront origin), not window.location.host. - const wsUrl = `${ClientEnv.serverWsBase()}${this.workerPath}/lobbies`; + const base = + this.gameID !== undefined + ? ClientEnv.gameWsBase(this.gameID) + : ClientEnv.serverWsBase(); + const wsUrl = `${base}${this.workerPath}/lobbies`; - this.ws = new WebSocket(wsUrl); + const ws = new WebSocket(wsUrl); + this.ws = ws; // Frames are zbin payloads; without this they would arrive as Blobs. this.ws.binaryType = "arraybuffer"; this.wsAttemptCounted = false; - this.ws.addEventListener("open", () => this.handleOpen()); - this.ws.addEventListener("message", (event) => this.handleMessage(event)); - this.ws.addEventListener("close", () => this.handleClose()); - this.ws.addEventListener("error", (error) => this.handleError(error)); + // Switching the joined game replaces this socket. Late callbacks from + // its predecessor must not update the new queue or schedule a reconnect. + const isCurrent = () => !this.stopped && this.ws === ws; + ws.addEventListener("open", () => { + if (isCurrent()) this.handleOpen(); + }); + ws.addEventListener("message", (event) => { + if (isCurrent()) this.handleMessage(event); + }); + ws.addEventListener("close", () => { + if (isCurrent()) this.handleClose(); + }); + ws.addEventListener("error", (error) => { + if (isCurrent()) this.handleError(error); + }); } catch (error) { this.handleConnectError(error); } @@ -266,8 +294,9 @@ export class PublicLobbySocket { if (this.updateAvailableFired || this.onUpdateAvailable === undefined) { return; } + const generation = this.generation; const listStatus = await ensureServerList(); - if (this.stopped) return; + if (this.stopped || generation !== this.generation) return; if (reloadWouldRescue(listStatus)) this.fireUpdateAvailable(); } @@ -306,8 +335,9 @@ export class PublicLobbySocket { private disconnectWebSocket() { if (this.ws) { - this.ws.close(); + const oldSocket = this.ws; this.ws = null; + oldSocket.close(); } if (this.wsReconnectTimeout !== null) { clearTimeout(this.wsReconnectTimeout); diff --git a/tests/LobbySocketRouting.test.ts b/tests/LobbySocketRouting.test.ts new file mode 100644 index 0000000000..c7b7d63a62 --- /dev/null +++ b/tests/LobbySocketRouting.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ClientEnv } from "../src/client/ClientEnv"; +import { PublicLobbySocket } from "../src/client/LobbySocket"; +import { lobbyFrame } from "./util/Wire"; + +const { ensureServerList } = vi.hoisted(() => ({ + ensureServerList: vi.fn(async () => "api"), +})); +vi.mock("../src/client/ServerList", () => ({ + ensureServerList, + reloadWouldRescue: vi.fn(() => false), +})); + +class FakeWebSocket extends EventTarget { + static OPEN = 1; + static instances: FakeWebSocket[] = []; + readyState = 1; + binaryType = ""; + constructor(public url: string) { + super(); + FakeWebSocket.instances.push(this); + } + close() { + this.readyState = 3; + } + sendLobby(gameID: string) { + const frame = lobbyFrame({ + type: "full", + serverTime: 0, + games: { team: [{ gameID, publicGameType: "team", numClients: 1 }] }, + }); + this.dispatchEvent(new MessageEvent("message", { data: frame.buffer })); + } +} + +describe("PublicLobbySocket joined-game routing", () => { + let socket: PublicLobbySocket; + const foreignGame = "b123456789"; + + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.stubGlobal("WebSocket", FakeWebSocket); + FakeWebSocket.instances = []; + ensureServerList.mockReset().mockResolvedValue("api"); + ClientEnv.reset(); + ClientEnv.applyServerList( + { + servers: { + a: { + host: "picked.example", + numWorkers: 2, + version: "abcdef0", + state: "open", + }, + b: { + host: "owner.example", + numWorkers: 7, + version: "abcdef0", + state: "open", + }, + }, + }, + "a", + ); + socket = new PublicLobbySocket(vi.fn()); + }); + + afterEach(() => { + socket.stop(); + ClientEnv.reset(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("keeps the picked-server feed when no game is specified", async () => { + await socket.start(); + expect(FakeWebSocket.instances[0].url).toMatch( + /^wss:\/\/picked\.example\/w[01]\/lobbies$/, + ); + }); + + it("uses the game's owning server and worker, including reconnects", async () => { + await socket.start(foreignGame); + const expected = `wss://owner.example/${ClientEnv.gameWorkerPath(foreignGame)}/lobbies`; + expect(FakeWebSocket.instances[0].url).toBe(expected); + FakeWebSocket.instances[0].dispatchEvent(new Event("close")); + await vi.advanceTimersByTimeAsync(3000); + expect(FakeWebSocket.instances[1].url).toBe(expected); + }); + + it("ignores old socket messages and close events after switching feeds", async () => { + const update = vi.fn(); + socket = new PublicLobbySocket(update); + await socket.start(); + const old = FakeWebSocket.instances[0]; + await socket.start(foreignGame); + expect(old.readyState).toBe(3); + old.sendLobby("stale"); + old.dispatchEvent(new Event("close")); + await vi.advanceTimersByTimeAsync(3000); + expect(update).not.toHaveBeenCalled(); + expect(FakeWebSocket.instances).toHaveLength(2); + const current = FakeWebSocket.instances[1]; + current.sendLobby(foreignGame); + expect(update.mock.calls[0][0].games.team[0].gameID).toBe(foreignGame); + socket.stop(); + current.sendLobby("after-stop"); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("discards discovery from a previous start after closing and reopening", async () => { + let finishDiscovery!: (value: string) => void; + ensureServerList.mockImplementationOnce( + () => + new Promise((resolve) => { + finishDiscovery = resolve; + }), + ); + const oldStart = socket.start(); + socket.stop(); + await socket.start(foreignGame); + finishDiscovery("api"); + await oldStart; + expect(FakeWebSocket.instances).toHaveLength(1); + expect(FakeWebSocket.instances[0].url).toContain("owner.example"); + }); +}); diff --git a/tests/client/JoinLobbyModal.test.ts b/tests/client/JoinLobbyModal.test.ts index 77af4f2e2a..5c90b98b0a 100644 --- a/tests/client/JoinLobbyModal.test.ts +++ b/tests/client/JoinLobbyModal.test.ts @@ -22,6 +22,37 @@ vi.mock("../../src/client/DesktopPresence", () => ({ import { JoinLobbyModal } from "../../src/client/JoinLobbyModal"; import { GameMode, GameType } from "../../src/core/game/Game"; +describe("JoinLobbyModal queue feed target", () => { + it("follows the joined game and restores the default feed on returning to the form", () => { + const modal = new JoinLobbyModal(); + const state = modal as any; + const start = vi + .spyOn(state.hostedLobbySocket, "start") + .mockResolvedValue(undefined); + state.startLobbyUpdates = vi.fn(); + state.stopLobbyUpdates = vi.fn(); + + state.onOpen(); + expect(start).toHaveBeenLastCalledWith(undefined); + start.mockClear(); + state.onOpen({ lobbyId: "b123456789", lobbyInfo: {} }); + expect(start).toHaveBeenCalledExactlyOnceWith("b123456789"); + + state.publicLobbies = { serverTime: 0, games: {} }; + state.startTrackingLobby("c123456789"); + expect(start).toHaveBeenLastCalledWith("c123456789"); + expect(state.publicLobbies).toBeNull(); + state.isModalOpen = true; + state.resetTrackingState(); + expect(start).toHaveBeenLastCalledWith(undefined); + start.mockClear(); + state.isModalOpen = false; + state.resetTrackingState(); + expect(start).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); +}); + describe("JoinLobbyModal queue status", () => { function setup() { const modal = new JoinLobbyModal();