Skip to content
Open
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
28 changes: 25 additions & 3 deletions src/client/JoinLobbyModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
GameRecordSchema,
LobbyInfoEvent,
PublicGameInfo,
PublicGames,
} from "../core/Schemas";
import {
Difficulty,
Expand All @@ -30,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";
Expand Down Expand Up @@ -65,6 +67,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;
Expand All @@ -79,6 +82,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;
});
Expand Down Expand Up @@ -318,11 +322,19 @@ export class JoinLobbyModal extends BaseModal {
this.serverTimeOffset,
)
: null;
const queuePosition =
this.gameConfig?.gameType === GameType.Public
? getLobbyQueuePosition(this.publicLobbies, this.currentLobbyId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read the queue from the joined lobby's server

When a public team lobby is opened through a shared URL or Steam invite and its ID belongs to a different deployment than the recipient's sticky picked server, this lookup never finds it: hostedLobbySocket connects through ClientEnv.serverWsBase(), while the actual lobby connection is routed by the ID through ClientEnv.gameWsBase(). Public lobby rosters are server-local, so publicLobbies contains the picked server's queue and the modal permanently falls back to “Waiting for players.” The queue feed used here needs to target the joined game's owning server (or receive an equivalent snapshot from it).

Useful? React with 👍 / 👎.

: null;
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),
Expand Down Expand Up @@ -611,8 +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.hostedLobbiesLoaded = false;
void this.hostedLobbySocket.start();
const lobbyId = typeof args?.lobbyId === "string" ? args.lobbyId : "";
const lobbyInfo = args?.lobbyInfo as GameInfo | PublicGameInfo | undefined;
if (lobbyId) {
Expand All @@ -621,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,
Expand Down Expand Up @@ -670,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;
Expand All @@ -696,6 +716,7 @@ export class JoinLobbyModal extends BaseModal {
this.currentLobbyId = "";
this.currentClientID = "";
this.isConnecting = false;
if (this.isModalOpen) this.startLobbyFeed();
}

private leaveLobby() {
Expand All @@ -718,6 +739,7 @@ export class JoinLobbyModal extends BaseModal {

protected onClose(): void {
this.hostedLobbySocket.stop();
this.publicLobbies = null;
this.hostedLobbies = [];
this.hostedLobbiesLoaded = false;
this.clearCountdownTimer();
Expand Down
17 changes: 17 additions & 0 deletions src/client/LobbyQueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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 SCHEDULED_PUBLIC_GAME_TYPES) {
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;
}
56 changes: 43 additions & 13 deletions src/client/LobbySocket.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;

Expand All @@ -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();
Expand Down Expand Up @@ -84,17 +89,21 @@ 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
// page while the API is unreachable), which is a connection failure like
// 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());
Expand All @@ -105,34 +114,53 @@ 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.
this.lastFull = null;

// 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);
}
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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);
Expand Down
13 changes: 5 additions & 8 deletions src/client/components/DetailedGameViewModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
shouldBlockSocketSourcedAction,
} from "../GameModeSelector";
import { JoinLobbyModal } from "../JoinLobbyModal";
import { getLobbyQueuePosition } from "../LobbyQueue";
import { PublicLobbySocket } from "../LobbySocket";
import { JoinLobbyEvent } from "../Main";
import { UsernameInput } from "../UsernameInput";
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading