From 5d21be826d3bce1fab791a5170cb60289c2d2e23 Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 5 Jul 2026 20:25:56 -0700 Subject: [PATCH] feat: subscriber-hosted public lobby listing (#4480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #4040 (v1 scope: listing + browser + per-subscriber limit; custom lobby name/description left for a follow-up). ## What Subscribers can toggle their **private lobby** to be **publicly listed**; a browsable **"Open Lobbies"** list appears in the Join Lobby modal. Hard limit of **one listed lobby per subscriber**, enforced cluster-wide. ## How **Semantics** — a listed lobby stays `GameType.Private`: the host keeps full control and starts the game manually; the toggle only controls visibility. The `listed` flag lives on `GameServer` (not `GameConfig`), so it cannot be smuggled in through `update_game_config` and never touches core/sim/records. **Distribution** — reuses the existing public-lobby pipeline end to end: a new `"hosted"` `PublicGameType` bucket flows worker → master IPC → `/lobbies` websocket → `PublicLobbySocket`. Master scheduling now iterates only `SCHEDULED_PUBLIC_GAME_TYPES` (`ffa`/`team`/`special`), so it never sets countdowns on or schedules replacements for hosted lobbies. Lobbies delist automatically when the game starts/fills/dies (phase change). The broadcast fingerprint now includes browser-visible config, so host edits (map/mode) refresh the list even though the gameID doesn't change. **Gating** — new authenticated endpoint `POST /api/game/:id/listing`: - creator-only (403), private + not-started only (409) - fresh subscription check via server-side `getUserMe` using the shared `hasActiveSubscription()` helper (`active`/`trialing`); skipped in `GameEnv.Dev` (same precedent as Turnstile) so it's testable locally - one-lobby-per-creator (409): a SHA-256 hash of the creator's persistentID rides worker↔master IPC (`PublicGameInfo.creatorID`); the master dedupes as a race backstop. The hash — and host-only config (whitelist, name reveals) — are **stripped from every client payload** (broadcast + primed snapshot). **Client** — subscriber-gated "List lobby publicly" toggle in the host modal (server rejection reverts the toggle and shows a translated message); "Open Lobbies" rows (map, mode, player count) in the Join Lobby modal that reuse the existing private-join flow. **Compat** — `PublicGames.games` is now a `partialRecord`, so newer clients tolerate servers that don't send every bucket. Note: already-open old clients will fail to parse broadcasts containing the new `hosted` key until refreshed (closed Zod enum) — same class of break as previous wire-schema changes. ## Testing - `tests/server/HostedLobbyListing.test.ts` (15 tests): listed-lobby filtering, flag not settable via config intent, master aggregation + creator dedupe + no scheduling of hosted, creatorID stripping (broadcast + primed snapshot), `creatorHasListedLobby` (broadcast + local), fingerprint refresh on config change - `hasActiveSubscription` cases in `ApiSchemas.test.ts`; hosted counts-delta patch in `LobbySocket.test.ts` - Full suite green (1723 + 141 tests), tsc/eslint/prettier clean - **E2E in the real app** (headless Chromium, two browser contexts): host lists lobby → appears in second browser's Join Lobby list (creatorID absent from payload) → join succeeds (2 players in lobby) → same creator's second lobby rejected 409 with toggle revert → unlist removes it from a fresh browser's list. Curl negatives: missing auth 400, bad token 401, non-creator 403, missing game 404, bad body 400. ## Known follow-ups - Custom lobby name/description in the browser (needs the censor pipeline) — rest of #4040 - A listed lobby whose host closes the tab stays advertised indefinitely (an empty private lobby never leaves the Lobby phase) — pre-existing lifecycle, now more visible; consider delisting on creator disconnect 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- resources/lang/en.json | 17 +- src/client/Api.ts | 58 +- src/client/GameModeSelector.ts | 17 +- src/client/HostLobbyModal.ts | 182 +++++- src/client/JoinLobbyModal.ts | 134 ++++- src/client/hud/layers/PlayerPanel.ts | 15 + src/core/ApiSchemas.ts | 7 + src/core/Schemas.ts | 41 +- src/server/GameManager.ts | 8 + src/server/GameServer.ts | 156 ++++- src/server/IPCBridgeSchema.ts | 25 +- src/server/MapPlaylist.ts | 16 +- src/server/MasterLobbyService.ts | 88 ++- src/server/Worker.ts | 90 +++ src/server/WorkerLobbyService.ts | 240 ++++++-- tests/ApiSchemas.test.ts | 64 ++ tests/LobbySocket.test.ts | 11 + tests/server/HostedLobbyListing.test.ts | 758 ++++++++++++++++++++++++ 18 files changed, 1777 insertions(+), 150 deletions(-) create mode 100644 tests/server/HostedLobbyListing.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 66fa02726..df9ec4f66 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -794,13 +794,18 @@ "starting_gold": "Starting Gold (Millions)", "starting_gold_placeholder": "5", "starting_in": "Starting in {time}. Click to cancel", + "subscription_required_body": "You must be subscribed to make your lobby public.", + "subscription_required_title": "Subscription Required", "team_count": "Number of Teams", "teams_Duos": "Duos (teams of 2)", "teams_Humans Vs Nations": "Humans vs Nations", "teams_Quads": "Quads (teams of 4)", "teams_Trios": "Trios (teams of 3)", - "title": "Create Private Lobby", + "title": "Create Lobby", "toggle_name_reveal": "Toggle whether this player can see real names", + "view_subscriptions": "View Subscriptions", + "visibility_private": "Private", + "visibility_public": "Public", "waiting": "Waiting for players...", "water_nukes": "Water nukes" }, @@ -1187,10 +1192,18 @@ "host_cheats": "Host Cheats", "join_lobby": "Join Lobby", "joined_waiting": "Lobby joined! Waiting for host to start...", + "listing_failed": "Failed to update the lobby listing. Please try again.", + "listing_full": "The public lobby list is full. Please try again later.", + "listing_host_cheats_enabled": "Disable host cheats to list your lobby publicly.", + "listing_limit_reached": "You already have a publicly listed lobby.", + "listing_requires_subscription": "An active subscription is required to list a lobby publicly.", + "listing_whitelist_enabled": "Disable the join whitelist to list your lobby publicly.", + "no_open_lobbies": "No open lobbies right now", "not_found": "Lobby not found. Please check the ID and try again.", + "open_lobbies": "Open Lobbies", "pvp_immunity": "PVP immunity duration", "starting_gold": "Starting Gold", - "title": "Join Private Lobby", + "title": "Join Lobby", "version_mismatch": "This game was created with a different version. Cannot join." }, "public_game_modifier": { diff --git a/src/client/Api.ts b/src/client/Api.ts index 82e55f6e0..7c24d5115 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -15,7 +15,8 @@ import { UserMeResponseSchema, } from "../core/ApiSchemas"; import { AnalyticsRecord, AnalyticsRecordSchema } from "../core/Schemas"; -import { getAuthHeader, logOut, userAuth } from "./Auth"; +import { getAuthHeader, getPlayToken, logOut, userAuth } from "./Auth"; +import { ClientEnv } from "./ClientEnv"; export async function fetchPlayerById( playerId: string, @@ -314,6 +315,61 @@ export async function openSubscriptionPortal(): Promise { } } +// GET /api/game/:id on the game server (worker) — whether the game is a +// publicly listed lobby. False on any failure: callers use this to hide +// host powers that the server blocks in listed games anyway, so the safe +// default is to change nothing. +export async function fetchLobbyListed(gameID: string): Promise { + try { + const res = await fetch( + `/${ClientEnv.workerPath(gameID)}/api/game/${gameID}`, + { headers: { Accept: "application/json" } }, + ); + if (!res.ok) return false; + const json = await res.json(); + return json?.listed === true; + } catch (e) { + console.warn("fetchLobbyListed: request failed", e); + return false; + } +} + +// POST /api/game/:id/listing on the game server (worker) — toggles whether a +// private lobby appears in the public lobby browser. Creator-only and +// server-authoritative (subscription, whitelist/cheat and quota checks). +// On failure, `error` is the server's rejection code when available (e.g. +// "subscription_required", "listing_limit_reached", "listing_full"). +export async function setLobbyListed( + gameID: string, + listed: boolean, +): Promise<{ ok: true; listed: boolean } | { ok: false; error?: string }> { + try { + const token = await getPlayToken(); + const response = await fetch( + `/${ClientEnv.workerPath(gameID)}/api/game/${gameID}/listing`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ listed }), + }, + ); + const body = await response.json().catch(() => null); + if (!response.ok) { + return { ok: false, error: body?.error }; + } + return { + ok: true, + listed: typeof body?.listed === "boolean" ? body.listed : listed, + }; + } catch (e) { + console.error("setLobbyListed: request failed", e); + return { ok: false }; + } +} + export function getApiBase() { const domainname = getAudience(); diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index b1dc74cb0..800f6b631 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -156,6 +156,7 @@ export class GameModeSelector extends LitElement { translateText("main.join"), this.openJoinLobby, "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", + this.hostedLobbyCount(), )} @@ -236,6 +237,7 @@ export class GameModeSelector extends LitElement { translateText("main.join"), this.openJoinLobby, "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", + this.hostedLobbyCount(), )} @@ -268,21 +270,34 @@ export class GameModeSelector extends LitElement { (document.querySelector("join-lobby-modal") as JoinLobbyModal)?.open(); }; + // Number of open hosted lobbies waiting in the browser; shown as a chip + // on the Join button. + private hostedLobbyCount(): number { + return this.lobbies?.games?.hosted?.length ?? 0; + } + private renderSmallActionCard( title: string, onClick: () => void, bgClass: string = CARD_BG, + badge?: number, ) { return html` `; } diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 969cf8620..d34317c51 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -5,8 +5,11 @@ import { calculateServerTimeOffset, getSecondsUntilServerTimestamp, renderDuration, + showToast, translateText, } from "../client/Utils"; +import { hasActiveSubscription } from "../core/ApiSchemas"; +import { GameEnv } from "../core/configuration/Config"; import { EventBus } from "../core/EventBus"; import { DoomsdayClockSpeed } from "../core/game/DoomsdayClock"; import { @@ -25,9 +28,11 @@ import { TeamCountConfig, isValidGameID, } from "../core/Schemas"; +import { getUserMe, setLobbyListed } from "./Api"; import { getPlayToken } from "./Auth"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; +import "./components/ConfirmDialog"; import { CopyButton } from "./components/CopyButton"; import "./components/GameConfigSettings"; import "./components/InputCard"; @@ -102,6 +107,11 @@ export class HostLobbyModal extends BaseModal { @state() private lobbyCreatorClientID: string = ""; @state() private lobbyStartAt: number | null = null; @state() private serverTimeOffset: number = 0; + // Whether this user may actually make the lobby public (subscribers, or + // anyone in dev). The toggle itself is always shown. + @state() private canListPublicly: boolean = false; + @state() private publiclyListed: boolean = false; + @state() private showSubscriptionRequired: boolean = false; @property({ attribute: false }) eventBus: EventBus | null = null; // Timers for debouncing slider changes @@ -112,6 +122,11 @@ export class HostLobbyModal extends BaseModal { private leaveLobbyOnClose = true; + // Guards against overlapping listing requests: rapid Public/Private clicks + // could otherwise be applied out of order by the server, leaving the UI + // showing the opposite of the real listed state. + private listingRequestInFlight = false; + private readonly handleLobbyInfo = (event: LobbyInfoEvent) => { const lobby = event.lobby; if (!this.lobbyId || lobby.gameID !== this.lobbyId) { @@ -125,6 +140,12 @@ export class HostLobbyModal extends BaseModal { if (lobby.clients) { this.clients = lobby.clients; } + // The server can delist on its own (join whitelist enabled, duplicate + // creator resolved by the master); follow its state unless our own + // toggle request is mid-flight. + if (!this.listingRequestInFlight && lobby.listed !== undefined) { + this.publiclyListed = lobby.listed; + } }; private getRandomString(): string { @@ -182,7 +203,14 @@ export class HostLobbyModal extends BaseModal { protected renderHeaderSlot() { return modalHeader({ - title: translateText("host_modal.title"), + titleContent: html` + + ${translateText("host_modal.title")} + + ${this.renderVisibilityToggle()} + `, onBack: () => { this.leaveLobbyOnClose = true; this.close(); @@ -198,6 +226,46 @@ export class HostLobbyModal extends BaseModal { }); } + // Private/Public segmented toggle in the header. Shown to everyone; + // non-subscribers get a subscription-required dialog instead of a listing + // request (the server re-checks the subscription regardless). + private renderVisibilityToggle() { + const segment = (labelKey: string, isPublic: boolean) => html` + + `; + return html` +
+ ${segment("host_modal.visibility_private", false)} + ${segment("host_modal.visibility_public", true)} +
+ `; + } + + private handleVisibilitySelect(isPublic: boolean) { + if ( + this.listingRequestInFlight || + isPublic === this.publiclyListed || + !this.lobbyId + ) { + return; + } + if (isPublic && !this.canListPublicly) { + this.showSubscriptionRequired = true; + return; + } + void this.handlePublicListingToggle(isPublic); + } + protected renderBody() { const secondsRemaining = this.lobbyStartAt !== null @@ -294,19 +362,26 @@ export class HostLobbyModal extends BaseModal { .onChange=${this.handleStartingGoldValueChanges} .onKeyDown=${this.handleStartingGoldValueKeyDown} >`, - html``, + // A join whitelist and public listing are mutually exclusive (the + // server rejects both combinations), so the control disappears while + // the lobby is listed. + ...(this.publiclyListed + ? [] + : [ + html``, + ]), ]; const hostCheatInputCards = [ @@ -431,16 +506,23 @@ export class HostLobbyModal extends BaseModal { checked: this.doomsdayClock, doomsdayClockSpeed: this.doomsdayClockSpeed, }, - { - labelKey: "host_modal.host_cheats", - checked: this.hostCheatsEnabled, - }, + // Host cheats and public listing are mutually exclusive + // (the server rejects both combinations), so the controls + // disappear while the lobby is listed. + ...(this.publiclyListed + ? [] + : [ + { + labelKey: "host_modal.host_cheats", + checked: this.hostCheatsEnabled, + }, + ]), ], inputCards, }, hostCheats: { titleKey: "host_modal.host_cheats", - visible: this.hostCheatsEnabled, + visible: this.hostCheatsEnabled && !this.publiclyListed, toggles: [ { labelKey: "host_modal.infinite_gold", @@ -481,7 +563,9 @@ export class HostLobbyModal extends BaseModal { .currentClientID=${this.lobbyCreatorClientID} .teamCount=${this.teamCount} .nationCount=${this.nations} - .onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)} + .onKickPlayer=${this.publiclyListed + ? undefined + : (clientID: string) => this.kickPlayer(clientID)} .onToggleNameReveal=${(clientID: string) => this.toggleNameReveal(clientID)} .nameReveals=${this.nameReveals} @@ -500,12 +584,37 @@ export class HostLobbyModal extends BaseModal { @click=${this.toggleGameStartTimer} > + + ${this.showSubscriptionRequired + ? html` (this.showSubscriptionRequired = false)} + @confirm=${() => { + this.showSubscriptionRequired = false; + window.location.href = "/#modal=store&tab=subscriptions"; + }} + >` + : ""} `; } protected onOpen(): void { this.startLobbyUpdates(); + void getUserMe().then((userMe) => { + // Dev skips the subscription gate (matching the server) so the + // listing flow is testable locally. + this.canListPublicly = + ClientEnv.env() === GameEnv.Dev || + (userMe !== false && hasActiveSubscription(userMe)); + }); // 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. @@ -627,6 +736,8 @@ export class HostLobbyModal extends BaseModal { this.hostCheatGoldMultiplierValue = undefined; this.hostCheatStartingGold = false; this.hostCheatStartingGoldValue = undefined; + this.publiclyListed = false; + this.showSubscriptionRequired = false; this.leaveLobbyOnClose = true; } @@ -1050,6 +1161,37 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); }; + // Server-authoritative: it re-verifies the subscription and enforces the + // listing limits, so a failed request reverts the toggle. + private async handlePublicListingToggle(checked: boolean) { + this.listingRequestInFlight = true; + this.publiclyListed = checked; + const result = await setLobbyListed(this.lobbyId, checked); + if (result.ok) { + this.publiclyListed = result.listed; + } else { + this.publiclyListed = !checked; + this.showListingError(result.error); + } + this.listingRequestInFlight = false; + } + + private showListingError(serverError?: string) { + let key = "private_lobby.listing_failed"; + if (serverError === "subscription_required") { + key = "private_lobby.listing_requires_subscription"; + } else if (serverError === "listing_limit_reached") { + key = "private_lobby.listing_limit_reached"; + } else if (serverError === "listing_whitelist_enabled") { + key = "private_lobby.listing_whitelist_enabled"; + } else if (serverError === "listing_host_cheats_enabled") { + key = "private_lobby.listing_host_cheats_enabled"; + } else if (serverError === "listing_full") { + key = "private_lobby.listing_full"; + } + showToast(translateText(key), "red", 3000); + } + private handleAllowedPublicIdsChange = (e: Event) => { this.allowedPublicIds = (e.target as HTMLInputElement).value; this.putGameConfig(); diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index e3270861d..e098472a8 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -29,6 +29,7 @@ import { } from "../core/game/Game"; import { getApiBase } from "./Api"; import { crazyGamesSDK } from "./CrazyGamesSDK"; +import { PublicLobbySocket } from "./LobbySocket"; import { JoinLobbyEvent } from "./Main"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { normaliseMapKey } from "./Utils"; @@ -55,11 +56,18 @@ export class JoinLobbyModal extends BaseModal { @state() private serverTimeOffset: number = 0; @state() private isConnecting: boolean = true; @state() private lobbyCreatorClientID: string | null = null; + // Subscriber-hosted private lobbies listed in the public browser, shown on + // the pre-join form. + @state() private hostedLobbies: PublicGameInfo[] = []; private leaveLobbyOnClose = true; private countdownTimerId: number | null = null; private handledJoinTimeout = false; + private readonly hostedLobbySocket = new PublicLobbySocket((lobbies) => { + this.hostedLobbies = lobbies.games?.hosted ?? []; + }); + private isPrivateLobby(): boolean { return this.gameConfig?.gameType === GameType.Private; } @@ -205,7 +213,8 @@ export class JoinLobbyModal extends BaseModal { private renderJoinForm() { return html` -
+
+
-
- + + ${this.renderHostedLobbies()} +
`; } + private renderHostedLobbies() { + return html` +
+
+ ${translateText("private_lobby.open_lobbies")} +
+ ${this.hostedLobbies.length === 0 + ? html`

+ ${translateText("private_lobby.no_open_lobbies")} +

` + : html`
+ ${this.hostedLobbies.map((lobby) => + this.renderHostedLobbyRow(lobby), + )} +
`} +
+ `; + } + + private renderHostedLobbyRow(lobby: PublicGameInfo) { + const c = lobby.gameConfig; + const mapName = c ? (getMapName(c.gameMap) ?? c.gameMap) : ""; + const thumbnailUrl = c + ? assetUrl( + `maps/${encodeURIComponent(normaliseMapKey(c.gameMap))}/thumbnail.webp`, + ) + : ""; + return html` + + `; + } + + private async joinHostedLobby(lobby: PublicGameInfo) { + const lobbyId = lobby.gameID; + this.startTrackingLobby(lobbyId, lobby); + try { + const gameExists = await this.checkActiveLobby(lobbyId); + if (!gameExists) { + // The lobby vanished between the broadcast and the click. + this.resetTrackingState(); + this.showMessage(translateText("private_lobby.not_found"), "red"); + } + } catch (error) { + console.error("Error joining hosted lobby:", error); + this.resetTrackingState(); + this.showMessage(translateText("private_lobby.error"), "red"); + } + } + protected onOpen(args?: Record): void { + void this.hostedLobbySocket.start(); const lobbyId = typeof args?.lobbyId === "string" ? args.lobbyId : ""; const lobbyInfo = args?.lobbyInfo as GameInfo | PublicGameInfo | undefined; if (lobbyId) { @@ -342,6 +433,8 @@ export class JoinLobbyModal extends BaseModal { } protected onClose(): void { + this.hostedLobbySocket.stop(); + this.hostedLobbies = []; this.clearCountdownTimer(); this.stopLobbyUpdates(); @@ -364,6 +457,7 @@ export class JoinLobbyModal extends BaseModal { } disconnectedCallback() { + this.hostedLobbySocket.stop(); this.clearCountdownTimer(); this.stopLobbyUpdates(); super.disconnectedCallback(); @@ -393,6 +487,24 @@ export class JoinLobbyModal extends BaseModal { // --- Game config rendering --- + private modeSubtitle(c: GameConfig): string { + if (c.gameMode !== GameMode.Team) { + return translateText("game_mode.ffa"); + } + if (c.playerTeams === HumansVsNations) { + return translateText("host_modal.teams_Humans Vs Nations"); + } + if (typeof c.playerTeams === "string") { + return translateText("host_modal.teams_" + c.playerTeams); + } + if (typeof c.playerTeams === "number") { + return translateText("public_lobby.teams", { + num: c.playerTeams, + }); + } + return translateText("game_mode.ffa"); + } + private renderGameConfig(): TemplateResult { if (!this.gameConfig) return html``; @@ -403,21 +515,7 @@ export class JoinLobbyModal extends BaseModal { `maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`, ); const isTeam = c.gameMode === GameMode.Team; - - let modeSubtitle: string; - if (!isTeam) { - modeSubtitle = translateText("game_mode.ffa"); - } else if (c.playerTeams === HumansVsNations) { - modeSubtitle = translateText("host_modal.teams_Humans Vs Nations"); - } else if (typeof c.playerTeams === "string") { - modeSubtitle = translateText("host_modal.teams_" + c.playerTeams); - } else if (typeof c.playerTeams === "number") { - modeSubtitle = translateText("public_lobby.teams", { - num: c.playerTeams, - }); - } else { - modeSubtitle = translateText("game_mode.ffa"); - } + const modeSubtitle = this.modeSubtitle(c); const pm = c.publicGameModifiers; const cards: TemplateResult[] = []; diff --git a/src/client/hud/layers/PlayerPanel.ts b/src/client/hud/layers/PlayerPanel.ts index 494c96978..452bcc9a2 100644 --- a/src/client/hud/layers/PlayerPanel.ts +++ b/src/client/hud/layers/PlayerPanel.ts @@ -5,6 +5,7 @@ import { assetUrl } from "../../../core/AssetUrls"; import { EventBus } from "../../../core/EventBus"; import { AllPlayers, + GameType, PlayerActions, PlayerProfile, PlayerType, @@ -12,6 +13,7 @@ import { } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { Emoji, flattenedEmojiTable } from "../../../core/Util"; +import { fetchLobbyListed } from "../../Api"; import { actionButton } from "../../components/ui/ActionButton"; import "../../components/ui/Divider"; import { Controller } from "../../Controller"; @@ -73,6 +75,9 @@ export class PlayerPanel extends LitElement implements Controller { @state() private suppressNextHide: boolean = false; @state() private moderationTarget: PlayerView | null = null; @state() private playerRole: string | null = null; + // Whether this game is a publicly listed lobby. Kept out of + // GameStartInfo (never touches records), so it's fetched from the worker. + @state() private gameListed = false; setRole(role: string | null): void { this.playerRole = role; @@ -113,6 +118,13 @@ export class PlayerPanel extends LitElement implements Controller { if (!this.ctModal) { console.warn("ChatModal element not found in DOM"); } + + // Only private games can be listed. + if (this.g.config().gameConfig().gameType === GameType.Private) { + void fetchLobbyListed(this.g.gameID()).then((listed) => { + this.gameListed = listed; + }); + } } async tick() { @@ -456,6 +468,9 @@ export class PlayerPanel extends LitElement implements Controller { isAdmin: boolean, ) { if (!my.isLobbyCreator() && !isAdmin) return html``; + // The host of a publicly listed game cannot kick (server-enforced), so + // don't offer the panel; admins keep it for moderation. + if (this.gameListed && !isAdmin) return html``; const moderationTitle = translateText("player_panel.moderation"); return html` diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 789e3eb76..1dfed0a9e 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -139,6 +139,13 @@ export type UserSubscription = NonNullable< NonNullable >; +// Whether the player currently has subscriber entitlements (e.g. may list a +// private lobby publicly). Trialing counts; past_due/canceled do not. +export function hasActiveSubscription(userMe: UserMeResponse): boolean { + const status = userMe.player.subscription?.status; + return status === "active" || status === "trialing"; +} + export const PlayerStatsLeafSchema = z.object({ wins: BigIntStringSchema, losses: BigIntStringSchema, diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 9253b24ae..1e3cd9d2f 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -150,7 +150,31 @@ export type PublicGames = z.infer; export type PublicGameInfo = z.infer; export type PublicGameType = z.infer; -export const PublicGameTypeSchema = z.enum(["ffa", "team", "special"]); +export const PublicGameTypeSchema = z.enum([ + "ffa", + "team", + "special", + "hosted", +]); + +// Lobby types the master schedules from the map playlist. "hosted" is +// excluded: those are player-created private lobbies that a subscriber has +// listed publicly, and the host (not the master) controls their lifecycle. +// Derived from PublicGameTypeSchema so a new lobby type is scheduled by +// default and opting out is the explicit act. +export const ScheduledPublicGameTypeSchema = PublicGameTypeSchema.exclude([ + "hosted", +]); +export const SCHEDULED_PUBLIC_GAME_TYPES = + ScheduledPublicGameTypeSchema.options; +export type ScheduledPublicGameType = z.infer< + typeof ScheduledPublicGameTypeSchema +>; + +// Cluster-wide cap on subscriber-listed (hosted) lobbies, to prevent listing +// spam. Workers reject listings past the cap; the master caps the broadcast +// and delists any overflow as the authoritative backstop. +export const MAX_HOSTED_LOBBIES = 10; export const UsernameSchema = z .string() @@ -178,8 +202,17 @@ export const GameInfoSchema = z.object({ serverTime: z.number(), gameConfig: z.lazy(() => GameConfigSchema).optional(), publicGameType: PublicGameTypeSchema.optional(), + // Private lobbies only: whether the lobby is publicly listed. Server-owned + // (only /api/game/:id/listing sets it); carried in lobby info so the host + // UI stays in sync when the server delists (whitelist enabled, duplicate + // creator resolved by the master). + listed: z.boolean().optional(), }); +// Browser-facing lobby info. Master/worker-internal fields (the creator hash +// used for the one-listed-lobby-per-creator check) live on +// InternalGameInfoSchema in IPCBridgeSchema.ts, so client payloads cannot +// carry them by construction. export const PublicGameInfoSchema = z.object({ gameID: z.string(), numClients: z.number(), @@ -190,7 +223,9 @@ export const PublicGameInfoSchema = z.object({ export const PublicGamesSchema = z.object({ serverTime: z.number(), - games: z.record(PublicGameTypeSchema, z.array(PublicGameInfoSchema)), + // partialRecord: every consumer already treats buckets as optional, and it + // lets clients tolerate servers that don't send every lobby type. + games: z.partialRecord(PublicGameTypeSchema, z.array(PublicGameInfoSchema)), }); // Wire message sent from server to lobby WebSocket clients. @@ -199,7 +234,7 @@ export const PublicGamesSchema = z.object({ export const PublicLobbyFullSchema = z.object({ type: z.literal("full"), serverTime: z.number(), - games: z.record(PublicGameTypeSchema, z.array(PublicGameInfoSchema)), + games: z.partialRecord(PublicGameTypeSchema, z.array(PublicGameInfoSchema)), }); export const PublicLobbyCountsSchema = z.object({ diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 29270f4b7..93a137b8a 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -28,6 +28,14 @@ export class GameManager { ); } + // Private lobbies a subscriber has listed in the public lobby browser. + // Leaving the Lobby phase (start/fill/expiry) delists them automatically. + public listedLobbies(): GameServer[] { + return Array.from(this.games.values()).filter( + (g) => g.phase() === GamePhase.Lobby && !g.isPublic() && g.isListed(), + ); + } + joinClient( client: Client, gameID: GameID, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index dc14f0338..2cfea3d07 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -1,3 +1,4 @@ +import { createHash } from "crypto"; import ipAnonymize from "ip-anonymize"; import { Logger } from "winston"; import WebSocket from "ws"; @@ -57,6 +58,10 @@ export interface IntentOutcome { error?: string; } +export function hashPersistentID(persistentID: string): string { + return createHash("sha256").update(persistentID).digest("hex"); +} + const KICK_REASON_DUPLICATE_SESSION = "kick_reason.duplicate_session"; const KICK_REASON_LOBBY_CREATOR = "kick_reason.lobby_creator"; const KICK_REASON_ADMIN = "kick_reason.admin"; @@ -64,6 +69,18 @@ const KICK_REASON_HOST_LEFT = "kick_reason.host_left"; const KICK_REASON_TOO_MUCH_DATA = "kick_reason.too_much_data"; const KICK_REASON_INVALID_MESSAGE = "kick_reason.invalid_message"; +// Whether the host-only cheat block actually grants anything: mere presence +// isn't enough, the client can send hostCheats with every field off. +function hostCheatsEnabled(hc: GameConfig["hostCheats"]): boolean { + return ( + hc !== undefined && + (hc.infiniteGold === true || + hc.infiniteTroops === true || + typeof hc.goldMultiplier === "number" || + typeof hc.startingGold === "number") + ); +} + export class GameServer { private sentDesyncMessageClients = new Set(); @@ -127,6 +144,12 @@ export class GameServer { private _hasEnded = false; + // Whether this private lobby is visible in the public lobby browser. + // Deliberately kept out of gameConfig so update_game_config can't set it; + // only the authenticated /api/game/:id/listing endpoint may (it verifies + // the creator's subscription). + private listed = false; + private lobbyInfoIntervalId: ReturnType | null = null; private visibleAt?: number; @@ -248,6 +271,12 @@ export class GameServer { } if (gameConfig.allowedPublicIds !== undefined) { this.gameConfig.allowedPublicIds = gameConfig.allowedPublicIds; + // A join whitelist and public listing are mutually exclusive: a listed + // lobby must be joinable by anyone who finds it in the lobby browser. + if (this.listed && this.hasJoinWhitelist()) { + this.listed = false; + this.log.info("delisted lobby: join whitelist enabled"); + } } if (gameConfig.waterNukes !== undefined) { this.gameConfig.waterNukes = gameConfig.waterNukes ?? undefined; @@ -293,6 +322,16 @@ export class GameServer { error: "only the lobby creator or an admin can kick players", }; } + // A listed lobby recruits strangers from the public browser; letting + // the host kick them is a griefing vector. Admins keep the power for + // moderation. The listed flag survives game start on purpose, so a + // publicly recruited game stays kick-free like a real public game. + if (this.isListed() && !actor.isAdmin) { + return { + status: 403, + error: "the host cannot kick players in a publicly listed lobby", + }; + } // Resolve the target to a clientID: an explicit clientID, or an account // publicId matched against allClients (a superset of activeClients that // retains disconnected players), so a disconnected account can still be @@ -340,6 +379,16 @@ export class GameServer { if (stamped.config.gameType === GameType.Public) { return { status: 400, error: "cannot change a game to public" }; } + // Host cheats give the host an asymmetric advantage over players + // recruited from the lobby browser. Listing is likewise rejected + // while cheats are on (Worker's listing endpoint), so a listed + // lobby can never have them. + if (this.isListed() && hostCheatsEnabled(stamped.config.hostCheats)) { + return { + status: 409, + error: "cannot enable host cheats in a publicly listed lobby", + }; + } this.updateGameConfig(stamped.config); return { status: 200 }; } @@ -426,6 +475,11 @@ export class GameServer { public joinClient( client: Client, ): "joined" | "kicked" | "rejected" | "not_allowlisted" { + // e.g. the host left an unstarted lobby and GameManager hasn't pruned + // it yet. + if (this._hasEnded) { + return "rejected"; + } if (this.kickedPersistentIds.has(client.persistentID)) { return "kicked"; } @@ -679,27 +733,7 @@ export class GameServer { clientID: client.clientID, persistentID: client.persistentID, }); - this.activeClients = this.activeClients.filter( - (c) => c.clientID !== client.clientID, - ); - - if (!this._hasStarted) { - // Remove persistentId if the game has not started to prevent going over max players - this.persistentIdToClientId.delete(client.persistentID); - // Close lobby when host leaves before game starts - if ( - !this.isPublic() && - client.persistentID === this.creatorPersistentID - ) { - this.log.info("Host left, closing lobby", { - gameID: this.id, - }); - for (const c of [...this.activeClients]) { - this.kickClient(c.clientID, KICK_REASON_HOST_LEFT); - } - this._hasEnded = true; - } - } + this.handleClientDisconnect(client); }); client.ws.on("error", (error: Error) => { if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") { @@ -707,19 +741,40 @@ export class GameServer { } }); - // Check if WebSocket already closed before we added the listener (race condition) + // Check if WebSocket already closed before we added the listener (race + // condition) — the 'close' event has already fired, so the handler above + // will never run for this client. if (client.ws.readyState >= 2) { this.log.info("client WebSocket already closing/closed, removing", { clientID: client.clientID, readyState: client.ws.readyState, }); - this.activeClients = this.activeClients.filter( - (c) => c.clientID !== client.clientID, - ); - // Remove persistentId if the game has not started to prevent going over max players - if (!this._hasStarted) { - this.persistentIdToClientId.delete(client.persistentID); + this.handleClientDisconnect(client); + } + } + + private handleClientDisconnect(client: Client) { + this.activeClients = this.activeClients.filter( + (c) => c.clientID !== client.clientID, + ); + + if (this._hasStarted) { + return; + } + // Remove persistentId if the game has not started to prevent going over max players + this.persistentIdToClientId.delete(client.persistentID); + // Close lobby when host leaves before game starts: without a host it can + // never start, and a listed one would haunt the lobby browser and hold + // the creator's one-listing quota. phase() reports Finished once ended, + // so GameManager's next tick prunes it. + if (!this.isPublic() && client.persistentID === this.creatorPersistentID) { + this.log.info("Host left, closing lobby", { + gameID: this.id, + }); + for (const c of [...this.activeClients]) { + this.kickClient(c.clientID, KICK_REASON_HOST_LEFT); } + this._hasEnded = true; } } @@ -1019,6 +1074,13 @@ export class GameServer { } phase(): GamePhase { + // An ended game (e.g. an unstarted lobby whose host left) must report + // Finished: GameManager prunes on Finished, and a ghost that kept + // reporting Lobby would stay advertised in the lobby browser and hold + // the creator's one-listing quota until the max-duration cutoff. + if (this._hasEnded) { + return GamePhase.Finished; + } const now = Date.now(); const alive: Client[] = []; for (const client of this.activeClients) { @@ -1091,6 +1153,7 @@ export class GameServer { startsAt: this.startsAt, serverTime: Date.now(), publicGameType: this.publicGameType, + listed: this.isPublic() ? undefined : this.listed, }; } @@ -1115,6 +1178,43 @@ export class GameServer { return this.gameConfig.gameType === GameType.Public; } + public isListed(): boolean { + return this.listed; + } + + public setListed(listed: boolean): void { + this.listed = listed; + } + + // Whether joining is restricted to an allowlist of publicIds. A lobby with + // a join whitelist must not be publicly listed (it would advertise a lobby + // that rejects every joiner). + public hasJoinWhitelist(): boolean { + return (this.gameConfig.allowedPublicIds?.length ?? 0) > 0; + } + + // Whether any host-only cheat is actually granted. A lobby with host + // cheats must not be publicly listed. + public hasHostCheats(): boolean { + return hostCheatsEnabled(this.gameConfig.hostCheats); + } + + public isCreator(persistentId: string): boolean { + return ( + this.creatorPersistentID !== undefined && + this.creatorPersistentID === persistentId + ); + } + + // Hash of the creator's persistentID, safe to share between master and + // workers (never sent to browsers) for the one-listed-lobby-per-creator + // check. The raw persistentID must not leave this class. + public hashedCreatorID(): string | undefined { + return this.creatorPersistentID === undefined + ? undefined + : hashPersistentID(this.creatorPersistentID); + } + public kickClient( clientID: ClientID, reasonKey: string = KICK_REASON_DUPLICATE_SESSION, diff --git a/src/server/IPCBridgeSchema.ts b/src/server/IPCBridgeSchema.ts index 48293614d..2de366122 100644 --- a/src/server/IPCBridgeSchema.ts +++ b/src/server/IPCBridgeSchema.ts @@ -2,10 +2,11 @@ import { z } from "zod"; import { GameConfigSchema, PublicGameInfoSchema, - PublicGamesSchema, PublicGameTypeSchema, } from "../core/Schemas"; +export type InternalGameInfo = z.infer; +export type InternalPublicGames = z.infer; export type WorkerLobbyList = z.infer; export type WorkerReady = z.infer; export type MasterLobbiesBroadcast = z.infer< @@ -17,12 +18,25 @@ export type MasterCreateGame = z.infer; export type WorkerMessage = z.infer; export type MasterMessage = z.infer; +// Master/worker-internal lobby info: PublicGameInfo plus the hashed creator +// ID (hosted lobbies only) used for the one-listed-lobby-per-creator check. +// Never sent to browsers — WorkerLobbyService.sanitizeGames converts to plain +// PublicGameInfo before anything reaches a client. +export const InternalGameInfoSchema = PublicGameInfoSchema.extend({ + creatorID: z.string().optional(), +}); + +export const InternalPublicGamesSchema = z.object({ + serverTime: z.number(), + games: z.partialRecord(PublicGameTypeSchema, z.array(InternalGameInfoSchema)), +}); + // --- Worker Messages --- // Worker tells the master about its lobbies. const WorkerLobbyListSchema = z.object({ type: z.literal("lobbyList"), - lobbies: z.array(PublicGameInfoSchema), + lobbies: z.array(InternalGameInfoSchema), }); const WorkerReadySchema = z.object({ @@ -48,7 +62,12 @@ const MasterUpdateGameSchema = z.object({ // it can send it to the client. const MasterLobbiesBroadcastSchema = z.object({ type: z.literal("lobbiesBroadcast"), - publicGames: PublicGamesSchema, + publicGames: InternalPublicGamesSchema, + // Hosted lobbies the master wants delisted: a creator got two lobbies + // listed concurrently on different workers, and only the dedup winner may + // stay advertised. The owning worker clears the loser's listed flag so + // worker state, host UI, and the broadcast agree. + delistGameIDs: z.array(z.string()).optional(), }); // Master sends a message to worker to schedule a new public game/lobby. diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 842f1e113..255bcc786 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -15,7 +15,11 @@ import { UnitType, } from "../core/game/Game"; import { PseudoRandom } from "../core/PseudoRandom"; -import { GameConfig, PublicGameType, TeamCountConfig } from "../core/Schemas"; +import { + GameConfig, + ScheduledPublicGameType, + TeamCountConfig, +} from "../core/Schemas"; import { logger } from "./Logger"; import { getMapLandTiles } from "./MapLandTiles"; @@ -122,13 +126,13 @@ const MUTUALLY_EXCLUSIVE_MODIFIERS: [ModifierKey, ModifierKey][] = [ ]; export class MapPlaylist { - private playlists: Record = { + private playlists: Record = { ffa: [], special: [], team: [], }; - public async gameConfig(type: PublicGameType): Promise { + public async gameConfig(type: ScheduledPublicGameType): Promise { if (type === "special") { return this.getSpecialConfig(); } @@ -410,7 +414,7 @@ export class MapPlaylist { } satisfies GameConfig; } - private getNextMap(type: PublicGameType): GameMapType { + private getNextMap(type: ScheduledPublicGameType): GameMapType { const playlist = this.playlists[type]; if (playlist.length === 0) { playlist.push(...this.generateNewPlaylist(type)); @@ -418,7 +422,7 @@ export class MapPlaylist { return playlist.shift()!; } - private generateNewPlaylist(type: PublicGameType): GameMapType[] { + private generateNewPlaylist(type: ScheduledPublicGameType): GameMapType[] { const maps = this.buildMapsList(type); const rand = new PseudoRandom(Date.now()); const playlist: GameMapType[] = []; @@ -467,7 +471,7 @@ export class MapPlaylist { return false; } - private buildMapsList(type: PublicGameType): GameMapType[] { + private buildMapsList(type: ScheduledPublicGameType): GameMapType[] { const maps: GameMapType[] = []; allMaps.forEach((mapInfo) => { const map = mapInfo.type; diff --git a/src/server/MasterLobbyService.ts b/src/server/MasterLobbyService.ts index 25e4d23df..035a537f5 100644 --- a/src/server/MasterLobbyService.ts +++ b/src/server/MasterLobbyService.ts @@ -1,8 +1,13 @@ import { Worker } from "cluster"; import winston from "winston"; -import { PublicGameInfo, PublicGameType } from "../core/Schemas"; +import { + MAX_HOSTED_LOBBIES, + PublicGameType, + SCHEDULED_PUBLIC_GAME_TYPES, +} from "../core/Schemas"; import { generateID } from "../core/Util"; import { + InternalGameInfo, MasterCreateGame, MasterLobbiesBroadcast, MasterUpdateGame, @@ -21,8 +26,14 @@ export interface MasterLobbyServiceOptions { export class MasterLobbyService { private readonly workers = new Map(); // Worker id => the lobbies it owns. - private readonly workerLobbies = new Map(); + private readonly workerLobbies = new Map(); private readonly readyWorkers = new Set(); + // gameID => consecutive broadcast cycles a hosted lobby has lost the + // per-creator dedup or overflowed the cluster-wide cap. Losing once can be + // a stale worker report (a delisted lobby lingers for one report + // round-trip); losing twice means the conflict is real, and the loser gets + // delisted. + private readonly loserStreaks = new Map(); private started = false; constructor( @@ -78,13 +89,17 @@ export class MasterLobbyService { } } - private getAllLobbies(): Record { + private getAllLobbies(): { + games: Record; + losers: string[]; + } { const lobbies = Array.from(this.workerLobbies.values()).flat(); - const result: Record = { + const result: Record = { ffa: [], team: [], special: [], + hosted: [], }; for (const lobby of lobbies) { @@ -104,16 +119,71 @@ export class MasterLobbyService { }); } - return result; + // One listed lobby per creator, cluster-wide. Workers enforce this at + // listing time, but two workers can list concurrently between broadcasts; + // dropping duplicates here (deterministically, after the sort above) + // keeps the extra lobby from ever being advertised. Losers are reported + // so broadcastLobbies can tell the owning worker to clear the loser's + // listed flag — otherwise it would stay flagged Public on its worker + // while never appearing in any browser. + const seenCreators = new Set(); + const losers: string[] = []; + result.hosted = result.hosted.filter((lobby) => { + if (lobby.creatorID === undefined) return true; + if (seenCreators.has(lobby.creatorID)) { + losers.push(lobby.gameID); + return false; + } + seenCreators.add(lobby.creatorID); + return true; + }); + + // Cluster-wide cap to prevent listing spam. Workers reject listings past + // the cap too, but their view lags by a broadcast round-trip; overflow + // (deterministically the sort losers) is delisted like dedup losers. + if (result.hosted.length > MAX_HOSTED_LOBBIES) { + for (const lobby of result.hosted.slice(MAX_HOSTED_LOBBIES)) { + losers.push(lobby.gameID); + } + result.hosted = result.hosted.slice(0, MAX_HOSTED_LOBBIES); + } + + return { games: result, losers }; + } + + // Losers (creator dedup or cap overflow) are only delisted after losing + // two consecutive broadcast cycles: a single loss can be a stale worker + // report (a just-delisted lobby lingers for one report round-trip), and + // delisting on it would clear a legitimately listed lobby. + private delistGameIDs(losers: string[]): string[] { + const loserSet = new Set(losers); + for (const gameID of this.loserStreaks.keys()) { + if (!loserSet.has(gameID)) this.loserStreaks.delete(gameID); + } + const delist: string[] = []; + for (const gameID of losers) { + const streak = (this.loserStreaks.get(gameID) ?? 0) + 1; + this.loserStreaks.set(gameID, streak); + if (streak >= 2) delist.push(gameID); + } + if (delist.length > 0) { + this.log.info( + `delisting hosted lobbies (duplicate creator or over cap): ${delist.join(", ")}`, + ); + } + return delist; } private broadcastLobbies() { + const { games, losers } = this.getAllLobbies(); + const delist = this.delistGameIDs(losers); const msg = { type: "lobbiesBroadcast", publicGames: { serverTime: Date.now(), - games: this.getAllLobbies(), + games, }, + delistGameIDs: delist.length > 0 ? delist : undefined, } satisfies MasterLobbiesBroadcast; for (const [workerId, worker] of this.workers.entries()) { worker.send(msg, (e) => { @@ -129,9 +199,11 @@ export class MasterLobbyService { } private async maybeScheduleLobby() { - const lobbiesByType = this.getAllLobbies(); + const lobbiesByType = this.getAllLobbies().games; - for (const type of Object.keys(lobbiesByType) as PublicGameType[]) { + // Scheduled types only: hosted lobbies are started by their host, never + // given a countdown or replaced by the master. + for (const type of SCHEDULED_PUBLIC_GAME_TYPES) { const lobbies = lobbiesByType[type]; // Always ensure the next lobby has a timer, even if we already have 2+ diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 9aab63de5..95dee5c24 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -7,10 +7,12 @@ import path from "path"; import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; import { z } from "zod"; +import { hasActiveSubscription } from "../core/ApiSchemas"; import { GameEnv } from "../core/configuration/Config"; import { GameType } from "../core/game/Game"; import { ClientMessageSchema, + MAX_HOSTED_LOBBIES, PartialGameRecordSchema, ServerErrorMessage, } from "../core/Schemas"; @@ -195,6 +197,94 @@ export async function startWorker() { }); }); + // Toggle whether a private lobby is visible in the public lobby browser. + // Creator-only; listing requires an active subscription (checked fresh + // against the API) and is limited to one listed lobby per creator. + app.post("/api/game/:id/listing", async (req, res) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + return res.status(400).json({ error: "Authorization header required" }); + } + const token = authHeader.substring("Bearer ".length); + const auth = await verifyClientToken(token); + if (auth.type !== "success") { + return res.status(401).json({ error: "Invalid token" }); + } + + const parsed = z.object({ listed: z.boolean() }).safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: z.prettifyError(parsed.error) }); + } + const { listed } = parsed.data; + + const game = gm.game(req.params.id); + if (game === null) { + return res.status(404).json({ error: "Game not found" }); + } + if (!game.isCreator(auth.persistentId)) { + return res + .status(403) + .json({ error: "Only the lobby creator can change its listing" }); + } + if (game.isPublic() || game.hasStarted()) { + return res.status(409).json({ error: "Game cannot be listed" }); + } + + if (listed) { + // A whitelisted lobby would be advertised to everyone yet reject every + // joiner; the whitelist itself is stripped from the broadcast, so + // browsers could not even tell why. + if (game.hasJoinWhitelist()) { + return res.status(409).json({ error: "listing_whitelist_enabled" }); + } + + // Host cheats give the host an asymmetric advantage over players + // recruited from the lobby browser. Enabling them while listed is + // likewise rejected (GameServer's update_game_config handling). + if (game.hasHostCheats()) { + return res.status(409).json({ error: "listing_host_cheats_enabled" }); + } + + // Dev has no subscription backend; skip the check so the feature is + // testable locally (same precedent as Turnstile). + if (ServerEnv.env() !== GameEnv.Dev) { + const userMe = await getUserMe(token); + if (userMe.type === "error") { + log.warn( + `listing rejected, user me fetch failed: ${userMe.message}`, + { + gameID: req.params.id, + }, + ); + return res.status(403).json({ error: "subscription_required" }); + } + if (!hasActiveSubscription(userMe.response)) { + return res.status(403).json({ error: "subscription_required" }); + } + } + + const creatorID = game.hashedCreatorID(); + if ( + creatorID !== undefined && + lobbyService.creatorHasListedLobby(creatorID, game.id) + ) { + return res.status(409).json({ error: "listing_limit_reached" }); + } + + // Cluster-wide cap to prevent listing spam. Approximate here (the + // broadcast lags by ~1s); the master's cap is the backstop. + if (lobbyService.hostedLobbyCount() >= MAX_HOSTED_LOBBIES) { + return res.status(409).json({ error: "listing_full" }); + } + } + + game.setListed(listed); + log.info(`lobby listing ${listed ? "enabled" : "disabled"}`, { + gameID: game.id, + }); + res.json({ listed }); + }); + app.get("/api/game/:id/exists", async (req, res) => { const lobbyId = req.params.id; res.json({ diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index 6e7f405a1..24694f9dd 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -1,30 +1,46 @@ import http from "http"; import { WebSocket, WebSocketServer } from "ws"; import { + GameConfig, PublicGameInfo, PublicGames, PublicLobbyMessage, } from "../core/Schemas"; import { GameManager } from "./GameManager"; import { + InternalGameInfo, + InternalPublicGames, MasterMessageSchema, WorkerLobbyList, WorkerReady, } from "./IPCBridgeSchema"; import { logger } from "./Logger"; +// The game config advertised for a listed private lobby: everything the +// host configured minus host-only fields. The server already rejects +// enabling the whitelist or host cheats while listed; stripping them here +// just keeps host-only data off the wire. A new host-only GameConfig field +// must be added to this delete list. +function publicLobbyGameConfig(gc: GameConfig): GameConfig { + const sanitized = { ...gc }; + delete sanitized.allowedPublicIds; + delete sanitized.nameReveals; + delete sanitized.nameRevealPublicIds; + delete sanitized.hostCheats; + return sanitized; +} + export class WorkerLobbyService { private readonly lobbiesWss: WebSocketServer; private readonly lobbyClients: Set = new Set(); // Most recent snapshot from master, serialized on demand for new // connections so they don't have to wait for the next broadcast. - private lastPublicGames: PublicGames | null = null; - // Sorted gameIDs of the last full we broadcast, or null if we've never - // broadcast one. When the set changes we send a fresh full; otherwise a - // counts-only delta is enough. This relies on master creating a new lobby - // whenever it sets startsAt on the previous one, so structural state - // (startsAt, gameConfig) rides along with a gameID change. Null (not "") - // is used so that an empty-lobby first broadcast still emits a full. + private lastPublicGames: InternalPublicGames | null = null; + // Fingerprint (sorted per-lobby JSON of everything clients receive except + // player counts) of the last full we broadcast, or null if we've never + // broadcast one. When it changes we send a fresh full; otherwise a + // counts-only delta is enough. Null (not "") is used so that an + // empty-lobby first broadcast still emits a full. private lastFullGameIds: string | null = null; constructor( @@ -43,62 +59,79 @@ export class WorkerLobbyService { } private setupIPCListener() { - process.on("message", (raw: unknown) => { - const result = MasterMessageSchema.safeParse(raw); - if (!result.success) { - this.log.error("Invalid IPC message from master:", raw); - return; - } + process.on("message", (raw: unknown) => this.handleMasterMessage(raw)); + } - const msg = result.data; - switch (msg.type) { - case "lobbiesBroadcast": - this.lastPublicGames = msg.publicGames; - // Forward message to all clients - this.broadcastLobbiesToClients(msg.publicGames); - // Update master with my lobby info - this.sendMyLobbiesToMaster(); - break; - case "createGame": { - if (this.gm.game(msg.gameID) !== null) { - this.log.warn(`Game ${msg.gameID} already exists, skipping create`); - return; + // Separate from setupIPCListener so tests can dispatch messages without + // touching the real process IPC channel (which vitest forks use). + private handleMasterMessage(raw: unknown) { + const result = MasterMessageSchema.safeParse(raw); + if (!result.success) { + this.log.error("Invalid IPC message from master:", raw); + return; + } + + const msg = result.data; + switch (msg.type) { + case "lobbiesBroadcast": + // The master resolved a duplicate-creator race: clear the loser's + // listed flag so this worker's state follows the broadcast. Done + // before sendMyLobbiesToMaster so the next report reflects it. + for (const gameID of msg.delistGameIDs ?? []) { + const game = this.gm.game(gameID); + if (game?.isListed()) { + game.setListed(false); + this.log.info(`delisted by master: duplicate creator`, { gameID }); } - this.log.info(`Creating public game ${msg.gameID} from master`); - const game = this.gm.createGame( - msg.gameID, - msg.gameConfig, - undefined, - undefined, - msg.publicGameType, - ); - if (game === null) { - this.log.warn(`Game ${msg.gameID} already exists, skipping create`); - } - break; } - case "updateLobby": { - const game = this.gm.game(msg.gameID); - if (!game) { - this.log.warn("cannot update game, not found", { - gameID: msg.gameID, - }); - return; - } - game.setStartsAt(msg.startsAt); - break; + this.lastPublicGames = msg.publicGames; + // Forward message to all clients + this.broadcastLobbiesToClients(msg.publicGames); + // Update master with my lobby info + this.sendMyLobbiesToMaster(); + break; + case "createGame": { + if (this.gm.game(msg.gameID) !== null) { + this.log.warn(`Game ${msg.gameID} already exists, skipping create`); + return; } + this.log.info(`Creating public game ${msg.gameID} from master`); + const game = this.gm.createGame( + msg.gameID, + msg.gameConfig, + undefined, + undefined, + msg.publicGameType, + ); + if (game === null) { + this.log.warn(`Game ${msg.gameID} already exists, skipping create`); + } + break; } - }); + case "updateLobby": { + const game = this.gm.game(msg.gameID); + if (!game) { + this.log.warn("cannot update game, not found", { + gameID: msg.gameID, + }); + return; + } + game.setStartsAt(msg.startsAt); + break; + } + } } sendReady(workerId: number) { - const msg: WorkerReady = { type: "workerReady", workerId }; + this.sendToMaster({ type: "workerReady", workerId }); + } + + private sendToMaster(msg: WorkerReady | WorkerLobbyList) { process.send?.(msg); } private sendMyLobbiesToMaster() { - const lobbies = this.gm + const publicLobbies = this.gm .publicLobbies() .map((g) => g.gameInfo()) .map((gi) => { @@ -110,7 +143,87 @@ export class WorkerLobbyService { publicGameType: gi.publicGameType!, } satisfies PublicGameInfo; }); - process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList); + // Subscriber-listed private lobbies. creatorID (a hash of the creator's + // persistentID) rides along for the one-listed-lobby-per-creator check; + // sanitizeGames strips it before anything reaches browsers. The config is + // reduced to the publicLobbyGameConfig allowlist. + const hostedLobbies = this.gm.listedLobbies().map((g) => { + const gi = g.gameInfo(); + return { + gameID: gi.gameID, + numClients: gi.clients?.length ?? 0, + startsAt: gi.startsAt, + gameConfig: gi.gameConfig && publicLobbyGameConfig(gi.gameConfig), + publicGameType: "hosted", + creatorID: g.hashedCreatorID(), + } satisfies InternalGameInfo; + }); + this.sendToMaster({ + type: "lobbyList", + lobbies: [...publicLobbies, ...hostedLobbies], + } satisfies WorkerLobbyList); + } + + // Whether the creator (hashed persistentID) already has a listed lobby + // other than `excludeGameID`. Checks the cluster-wide view from the last + // master broadcast plus this worker's own lobbies (fresher than the + // broadcast interval). + public creatorHasListedLobby( + hashedCreatorID: string, + excludeGameID: string, + ): boolean { + const broadcast = this.lastPublicGames?.games["hosted"] ?? []; + if ( + broadcast.some((l) => { + if (l.gameID === excludeGameID || l.creatorID !== hashedCreatorID) { + return false; + } + // Broadcast entries lag a delist by up to two master cycles. For + // games this worker owns, local state is authoritative — a + // just-delisted lobby must not block the creator from listing a + // new one. + const local = this.gm.game(l.gameID); + return local === null || local.isListed(); + }) + ) { + return true; + } + return this.gm + .listedLobbies() + .some( + (g) => + g.id !== excludeGameID && g.hashedCreatorID() === hashedCreatorID, + ); + } + + // Cluster-wide count of listed hosted lobbies: the master broadcast plus + // this worker's own listed lobbies that haven't reached it yet. Approximate + // by up to a broadcast round-trip; the master's cap is the backstop. + public hostedLobbyCount(): number { + const broadcast = this.lastPublicGames?.games["hosted"] ?? []; + const broadcastIds = new Set(broadcast.map((l) => l.gameID)); + const localExtra = this.gm + .listedLobbies() + .filter((g) => !broadcastIds.has(g.id)).length; + return broadcast.length + localExtra; + } + + // Strips worker/master-internal fields (creatorID) before lobby info is + // sent to browser clients, converting InternalGameInfo to the + // browser-facing PublicGameInfo. + private sanitizeGames( + games: InternalPublicGames["games"], + ): PublicGames["games"] { + const sanitized: PublicGames["games"] = {}; + for (const [type, list] of Object.entries(games) as [ + keyof PublicGames["games"], + InternalGameInfo[], + ][]) { + sanitized[type] = list.map( + ({ creatorID: _creatorID, ...rest }): PublicGameInfo => rest, + ); + } + return sanitized; } private setupUpgradeHandler() { @@ -138,7 +251,7 @@ export class WorkerLobbyService { const fullJson = JSON.stringify({ type: "full", serverTime: this.lastPublicGames.serverTime, - games: this.lastPublicGames.games, + games: this.sanitizeGames(this.lastPublicGames.games), } satisfies PublicLobbyMessage); ws.send(fullJson); } @@ -166,15 +279,22 @@ export class WorkerLobbyService { }); } - private broadcastLobbiesToClients(publicGames: PublicGames) { - const gameIds: string[] = []; - for (const list of Object.values(publicGames.games)) { + private broadcastLobbiesToClients(publicGames: InternalPublicGames) { + // Per-lobby token is the JSON of exactly what clients receive, minus the + // player count: hosted lobbies can change config without a gameID + // change, and anything a client could render must trigger a fresh full. + // Fingerprinting the sanitized payload keeps "what forces a full" and + // "what clients see" from drifting apart. + const sanitizedGames = this.sanitizeGames(publicGames.games); + const lobbyTokens: string[] = []; + for (const list of Object.values(sanitizedGames)) { for (const lobby of list) { - gameIds.push(lobby.gameID); + // JSON.stringify drops undefined-valued keys, excluding the count. + lobbyTokens.push(JSON.stringify({ ...lobby, numClients: undefined })); } } - gameIds.sort(); - const fingerprint = gameIds.join(","); + lobbyTokens.sort(); + const fingerprint = lobbyTokens.join(","); const shouldSendFull = fingerprint !== this.lastFullGameIds; let payload: PublicLobbyMessage; @@ -182,7 +302,7 @@ export class WorkerLobbyService { payload = { type: "full", serverTime: publicGames.serverTime, - games: publicGames.games, + games: sanitizedGames, }; this.lastFullGameIds = fingerprint; } else { diff --git a/tests/ApiSchemas.test.ts b/tests/ApiSchemas.test.ts index 2249ba136..40286904c 100644 --- a/tests/ApiSchemas.test.ts +++ b/tests/ApiSchemas.test.ts @@ -1,12 +1,14 @@ import { GoogleUser, GoogleUserSchema, + hasActiveSubscription, PlayerGameModeFilterSchema, PlayerGameResultSchema, PlayerGameTypeFilterSchema, PlayerProfileSchema, PublicPlayerGameSchema, PublicPlayerGamesResponseSchema, + UserMeResponse, } from "../src/core/ApiSchemas"; describe("GoogleUserSchema", () => { @@ -200,3 +202,65 @@ describe("PublicPlayerGamesResponseSchema", () => { ).toBe(false); }); }); + +describe("hasActiveSubscription", () => { + function userMeWith( + subscription: UserMeResponse["player"]["subscription"], + ): UserMeResponse { + return { + user: {}, + player: { + publicId: "p1", + adfree: false, + achievements: { singleplayerMap: [] }, + friends: [], + subscription, + }, + }; + } + + it("is true for an active subscription", () => { + expect( + hasActiveSubscription( + userMeWith({ + tier: "supporter", + status: "active", + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + }), + ), + ).toBe(true); + }); + + it("is true while trialing", () => { + expect( + hasActiveSubscription( + userMeWith({ + tier: "supporter", + status: "trialing", + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + }), + ), + ).toBe(true); + }); + + it("is false for canceled or past_due subscriptions", () => { + for (const status of ["canceled", "past_due", "incomplete"]) { + expect( + hasActiveSubscription( + userMeWith({ + tier: "supporter", + status, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + }), + ), + ).toBe(false); + } + }); + + it("is false without a subscription", () => { + expect(hasActiveSubscription(userMeWith(null))).toBe(false); + }); +}); diff --git a/tests/LobbySocket.test.ts b/tests/LobbySocket.test.ts index 21b88aa71..b3705e178 100644 --- a/tests/LobbySocket.test.ts +++ b/tests/LobbySocket.test.ts @@ -124,6 +124,17 @@ describe("PublicLobbySocket.handleMessage", () => { expect(callback).not.toHaveBeenCalled(); }); + it("patches counts onto hosted lobbies too", () => { + const { callback, dispatch } = makeSocket(); + dispatch(fullMessage(1000, { hosted: [lobby("h1", 2, "hosted")] })); + callback.mockClear(); + + dispatch(countsMessage(1500, { h1: 6 })); + + const arg = callback.mock.calls[0][0]; + expect(arg.games.hosted).toEqual([lobby("h1", 6, "hosted")]); + }); + it("does not mutate the previously-delivered snapshot when applying counts", () => { const { callback, dispatch } = makeSocket(); dispatch(fullMessage(1000, { ffa: [lobby("g1", 3)] })); diff --git a/tests/server/HostedLobbyListing.test.ts b/tests/server/HostedLobbyListing.test.ts new file mode 100644 index 000000000..c388dcb00 --- /dev/null +++ b/tests/server/HostedLobbyListing.test.ts @@ -0,0 +1,758 @@ +import EventEmitter from "events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WebSocket } from "ws"; +import { + Difficulty, + GameMapSize, + GameMapType, + GameMode, + GameType, +} from "../../src/core/game/Game"; +import { MAX_HOSTED_LOBBIES } from "../../src/core/Schemas"; +import { Client } from "../../src/server/Client"; +import { GameManager } from "../../src/server/GameManager"; +import { + GamePhase, + GameServer, + hashPersistentID, +} from "../../src/server/GameServer"; +import { + InternalGameInfo, + InternalPublicGames, +} from "../../src/server/IPCBridgeSchema"; +import { MasterLobbyService } from "../../src/server/MasterLobbyService"; +import { ServerEnv } from "../../src/server/ServerEnv"; +import { WorkerLobbyService } from "../../src/server/WorkerLobbyService"; + +vi.mock("../../src/server/Logger", () => ({ + logger: { + child: () => ({ + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }), + }, +})); + +vi.mock("../../src/server/PollingLoop", () => ({ + startPolling: vi.fn(), +})); + +const mockLogger: any = { + child: vi.fn().mockReturnThis(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +const CREATOR = "11111111-1111-4111-8111-111111111111"; +const OTHER_CREATOR = "22222222-2222-4222-8222-222222222222"; + +function makeGame( + id = "test-game", + creatorPersistentID: string | undefined = CREATOR, + config: Record = {}, +) { + return new GameServer( + id, + mockLogger, + Date.now(), + { gameType: GameType.Private, ...config } as any, + creatorPersistentID, + ); +} + +describe("GameServer listing", () => { + it("is unlisted by default and toggles via setListed", () => { + const game = makeGame(); + expect(game.isListed()).toBe(false); + game.setListed(true); + expect(game.isListed()).toBe(true); + game.setListed(false); + expect(game.isListed()).toBe(false); + }); + + it("cannot be listed through update_game_config", () => { + const game = makeGame(); + const result = game.handleIntent( + { type: "update_game_config", config: { listed: true } } as any, + { + clientID: "c1", + isLobbyCreator: true, + isAdmin: false, + isAdminBot: false, + }, + ); + expect(result.status).toBe(200); + expect(game.isListed()).toBe(false); + }); + + it("identifies its creator", () => { + const game = makeGame(); + expect(game.isCreator(CREATOR)).toBe(true); + expect(game.isCreator(OTHER_CREATOR)).toBe(false); + }); + + it("never matches a creator when created without one", () => { + const game = new GameServer("no-creator", mockLogger, Date.now(), { + gameType: GameType.Private, + } as any); + expect(game.isCreator(CREATOR)).toBe(false); + expect(game.hashedCreatorID()).toBeUndefined(); + }); + + it("exposes a stable hash of the creator id, not the raw id", () => { + const game = makeGame(); + const hashed = game.hashedCreatorID(); + expect(hashed).toBe(hashPersistentID(CREATOR)); + expect(hashed).not.toContain(CREATOR); + expect(hashed).toMatch(/^[0-9a-f]{64}$/); + }); + + it("reports a join whitelist only when non-empty", () => { + expect(makeGame().hasJoinWhitelist()).toBe(false); + expect( + makeGame("g", CREATOR, { allowedPublicIds: [] }).hasJoinWhitelist(), + ).toBe(false); + expect( + makeGame("g", CREATOR, { allowedPublicIds: ["p1"] }).hasJoinWhitelist(), + ).toBe(true); + }); + + it("delists when a join whitelist is added via config update", () => { + const game = makeGame(); + game.setListed(true); + + game.updateGameConfig({ allowedPublicIds: [] }); + expect(game.isListed()).toBe(true); + + game.updateGameConfig({ allowedPublicIds: ["p1"] }); + expect(game.isListed()).toBe(false); + }); + + it("exposes the listed flag in gameInfo for private lobbies only", () => { + const game = makeGame(); + expect(game.gameInfo().listed).toBe(false); + game.setListed(true); + expect(game.gameInfo().listed).toBe(true); + + const pub = new GameServer("pub", mockLogger, Date.now(), { + gameType: GameType.Public, + } as any); + expect(pub.gameInfo().listed).toBeUndefined(); + }); +}); + +describe("GameManager.listedLobbies", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("returns only listed private lobbies still in the lobby phase", () => { + const gm = new GameManager(mockLogger); + const unlisted = gm.createGame("g-unlisted", undefined, CREATOR)!; + const listed = gm.createGame("g-listed", undefined, CREATOR)!; + listed.setListed(true); + + expect(gm.listedLobbies().map((g) => g.id)).toEqual(["g-listed"]); + expect(gm.publicLobbies()).toEqual([]); + expect(unlisted.isListed()).toBe(false); + }); + + it("excludes public games even if flagged listed", () => { + const gm = new GameManager(mockLogger); + const pub = gm.createGame( + "g-public", + { gameType: GameType.Public } as any, + undefined, + )!; + pub.setListed(true); + + expect(gm.listedLobbies()).toEqual([]); + expect(gm.publicLobbies().map((g) => g.id)).toEqual(["g-public"]); + }); + + it("drops a listed lobby once its game starts", () => { + const gm = new GameManager(mockLogger); + const game = gm.createGame("g-started", undefined, CREATOR)!; + game.setListed(true); + expect(gm.listedLobbies()).toHaveLength(1); + + (game as any)._hasStarted = true; + expect(gm.listedLobbies()).toEqual([]); + }); +}); + +function fakeWs() { + const ws = new EventEmitter() as any; + ws.readyState = WebSocket.OPEN; + ws.send = vi.fn(); + ws.close = vi.fn(); + return ws; +} + +function makeClient(clientID: string, persistentID: string, ws: any) { + return new Client( + clientID, + persistentID, + null, + null, + undefined, + "1.2.3.4", + `user_${clientID}`, + null, + ws, + undefined, + undefined, + [], + ); +} + +describe("host-left lobby teardown", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("ends, delists and prunes an unstarted lobby when the host leaves", () => { + const gm = new GameManager(mockLogger); + const game = gm.createGame("g-host-leaves", undefined, CREATOR)!; + game.setListed(true); + + const hostWs = fakeWs(); + const guestWs = fakeWs(); + expect(game.joinClient(makeClient("host", CREATOR, hostWs))).toBe("joined"); + expect(game.joinClient(makeClient("guest", OTHER_CREATOR, guestWs))).toBe( + "joined", + ); + expect(gm.listedLobbies()).toHaveLength(1); + + hostWs.emit("close"); + + // Remaining players are kicked and the ghost leaves the listing... + expect(guestWs.close).toHaveBeenCalled(); + expect(game.phase()).toBe(GamePhase.Finished); + expect(gm.listedLobbies()).toEqual([]); + + // ...and the next manager tick prunes the game entirely, freeing the + // creator's one-listing quota. + gm.tick(); + expect(gm.game("g-host-leaves")).toBeNull(); + }); + + it("tears down even when the host socket was already dead on join", () => { + const gm = new GameManager(mockLogger); + const game = gm.createGame("g-dead-socket", undefined, CREATOR)!; + game.setListed(true); + + const hostWs = fakeWs(); + hostWs.readyState = WebSocket.CLOSED; + game.joinClient(makeClient("host", CREATOR, hostWs)); + + expect(game.phase()).toBe(GamePhase.Finished); + expect(gm.listedLobbies()).toEqual([]); + }); + + it("rejects joins into an ended lobby before it is pruned", () => { + const game = makeGame(); + (game as any)._hasEnded = true; + expect(game.joinClient({} as any)).toBe("rejected"); + }); +}); + +describe("listed lobby host powers", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + const asHost = { + clientID: "host", + isLobbyCreator: true, + isAdmin: false, + isAdminBot: false, + }; + + it("reports host cheats only when a cheat is actually granted", () => { + expect(makeGame().hasHostCheats()).toBe(false); + expect(makeGame("g", CREATOR, { hostCheats: {} }).hasHostCheats()).toBe( + false, + ); + expect( + makeGame("g", CREATOR, { + hostCheats: { infiniteGold: false, infiniteTroops: false }, + }).hasHostCheats(), + ).toBe(false); + expect( + makeGame("g", CREATOR, { + hostCheats: { infiniteGold: true }, + }).hasHostCheats(), + ).toBe(true); + expect( + makeGame("g", CREATOR, { + hostCheats: { goldMultiplier: 2 }, + }).hasHostCheats(), + ).toBe(true); + }); + + it("blocks host kicks while listed; admins still can", () => { + const game = makeGame("g-kick"); + game.joinClient(makeClient("host", CREATOR, fakeWs())); + game.joinClient(makeClient("guest", OTHER_CREATOR, fakeWs())); + game.setListed(true); + + const kick = { type: "kick_player", targetClientID: "guest" } as any; + expect(game.handleIntent(kick, asHost).status).toBe(403); + + // Unlisting restores the host's kick power. + game.setListed(false); + expect(game.handleIntent(kick, asHost).status).toBe(200); + }); + + it("lets admins kick in a listed lobby", () => { + const game = makeGame("g-admin-kick"); + game.joinClient(makeClient("host", CREATOR, fakeWs())); + game.joinClient(makeClient("guest", OTHER_CREATOR, fakeWs())); + game.setListed(true); + + const asAdmin = { + clientID: "admin", + isLobbyCreator: false, + isAdmin: true, + isAdminBot: false, + }; + expect( + game.handleIntent( + { type: "kick_player", targetClientID: "guest" } as any, + asAdmin, + ).status, + ).toBe(200); + }); + + it("rejects enabling host cheats while listed", () => { + const game = makeGame(); + game.setListed(true); + + const cheats = { + type: "update_game_config", + config: { hostCheats: { infiniteGold: true } }, + } as any; + expect(game.handleIntent(cheats, asHost).status).toBe(409); + expect(game.hasHostCheats()).toBe(false); + + // A neutral hostCheats block still goes through (the client always + // sends the field), as do real cheats once the lobby is unlisted. + expect( + game.handleIntent( + { type: "update_game_config", config: { hostCheats: {} } } as any, + asHost, + ).status, + ).toBe(200); + game.setListed(false); + expect(game.handleIntent(cheats, asHost).status).toBe(200); + expect(game.hasHostCheats()).toBe(true); + }); +}); + +function hostedLobby( + gameID: string, + creatorID: string | undefined, + extra: Partial = {}, +): InternalGameInfo { + return { + gameID, + numClients: 0, + publicGameType: "hosted", + creatorID, + ...extra, + }; +} + +describe("MasterLobbyService hosted lobbies", () => { + function createService() { + vi.spyOn(ServerEnv, "numWorkers").mockReturnValue(2); + vi.spyOn(ServerEnv, "workerIndex").mockReturnValue(1); + vi.spyOn(ServerEnv, "gameCreationRate").mockReturnValue(60_000); + const playlist = { + gameConfig: vi.fn().mockResolvedValue({ gameType: GameType.Public }), + }; + const log = { info: vi.fn(), error: vi.fn() } as any; + const service = new MasterLobbyService(playlist as any, log); + + const workers = [1, 2].map((id) => { + const worker = new EventEmitter(); + (worker as any).send = vi.fn(); + service.registerWorker(id, worker as any); + return worker; + }); + return { service, workers }; + } + + function sentMessages(worker: EventEmitter): any[] { + return ((worker as any).send as ReturnType).mock.calls.map( + (c) => c[0], + ); + } + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("aggregates hosted lobbies and dedupes by creator across workers", () => { + const { service, workers } = createService(); + workers[0].emit("message", { + type: "lobbyList", + lobbies: [ + hostedLobby("bbb", "creator-a"), + hostedLobby("ccc", "creator-b"), + ], + }); + // Same creator listed a second lobby on another worker before the first + // broadcast landed; only the stable-sort winner may survive. + workers[1].emit("message", { + type: "lobbyList", + lobbies: [hostedLobby("aaa", "creator-a")], + }); + + (service as any).broadcastLobbies(); + + const broadcast = sentMessages(workers[0]).find( + (m) => m.type === "lobbiesBroadcast", + ); + expect(broadcast).toBeDefined(); + const hosted = broadcast.publicGames.games.hosted; + expect(hosted.map((l: InternalGameInfo) => l.gameID)).toEqual([ + "aaa", + "ccc", + ]); + }); + + it("delists a dedup loser only after two consecutive losing broadcasts", () => { + const { service, workers } = createService(); + workers[0].emit("message", { + type: "lobbyList", + lobbies: [hostedLobby("bbb", "creator-a")], + }); + workers[1].emit("message", { + type: "lobbyList", + lobbies: [hostedLobby("aaa", "creator-a")], + }); + + (service as any).broadcastLobbies(); + (service as any).broadcastLobbies(); + + const broadcasts = sentMessages(workers[0]).filter( + (m) => m.type === "lobbiesBroadcast", + ); + // First loss could be a stale worker report; only the second in a row + // triggers the delist. + expect(broadcasts[0].delistGameIDs).toBeUndefined(); + expect(broadcasts[1].delistGameIDs).toEqual(["bbb"]); + }); + + it("caps hosted lobbies at MAX_HOSTED_LOBBIES and delists the overflow", () => { + const { service, workers } = createService(); + const lobbies = Array.from({ length: MAX_HOSTED_LOBBIES + 1 }, (_, i) => + hostedLobby(`g${String(i).padStart(2, "0")}`, `creator-${i}`), + ); + workers[0].emit("message", { type: "lobbyList", lobbies }); + + (service as any).broadcastLobbies(); + (service as any).broadcastLobbies(); + + const broadcasts = sentMessages(workers[0]).filter( + (m) => m.type === "lobbiesBroadcast", + ); + expect(broadcasts[0].publicGames.games.hosted).toHaveLength( + MAX_HOSTED_LOBBIES, + ); + // The sort loser (highest gameID) is dropped from the broadcast and, + // after two consecutive losing cycles, delisted. + expect(broadcasts[0].delistGameIDs).toBeUndefined(); + expect(broadcasts[1].delistGameIDs).toEqual([`g${MAX_HOSTED_LOBBIES}`]); + }); + + it("does not delist when the duplicate disappears after one broadcast", () => { + const { service, workers } = createService(); + workers[0].emit("message", { + type: "lobbyList", + lobbies: [hostedLobby("bbb", "creator-a")], + }); + workers[1].emit("message", { + type: "lobbyList", + lobbies: [hostedLobby("aaa", "creator-a")], + }); + (service as any).broadcastLobbies(); + + // The losing entry was stale: the next report no longer contains it. + workers[0].emit("message", { type: "lobbyList", lobbies: [] }); + (service as any).broadcastLobbies(); + + for (const msg of sentMessages(workers[0])) { + if (msg.type === "lobbiesBroadcast") { + expect(msg.delistGameIDs).toBeUndefined(); + } + } + }); + + it("never schedules or sets countdowns on hosted lobbies", async () => { + const { service, workers } = createService(); + workers[0].emit("message", { + type: "lobbyList", + lobbies: [hostedLobby("hosted1", "creator-a")], + }); + + await (service as any).maybeScheduleLobby(); + + for (const worker of workers) { + for (const msg of sentMessages(worker)) { + if (msg.type === "updateLobby") { + expect(msg.gameID).not.toBe("hosted1"); + } + if (msg.type === "createGame") { + expect(msg.publicGameType).not.toBe("hosted"); + } + } + } + // The scheduled types still get their replacement lobbies. + const created = workers.flatMap((w) => + sentMessages(w).filter((m) => m.type === "createGame"), + ); + expect(created.map((m) => m.publicGameType).sort()).toEqual([ + "ffa", + "special", + "team", + ]); + }); +}); + +describe("WorkerLobbyService hosted lobbies", () => { + let service: WorkerLobbyService; + let gm: any; + let sendToMaster: ReturnType; + + beforeEach(() => { + gm = { + publicLobbies: vi.fn().mockReturnValue([]), + listedLobbies: vi.fn().mockReturnValue([]), + game: vi.fn().mockReturnValue(null), + }; + const server = new EventEmitter(); + service = new WorkerLobbyService( + server as any, + { handleUpgrade: vi.fn() } as any, + gm, + mockLogger, + ); + // Never touch the real process IPC channel: vitest forks use it. + sendToMaster = vi.fn(); + (service as any).sendToMaster = sendToMaster; + }); + + function emitBroadcast( + games: InternalPublicGames["games"], + serverTime = 1000, + delistGameIDs?: string[], + ) { + (service as any).handleMasterMessage({ + type: "lobbiesBroadcast", + publicGames: { serverTime, games }, + delistGameIDs, + }); + } + + function connectClient() { + const ws = { + send: vi.fn(), + on: vi.fn(), + readyState: WebSocket.OPEN, + }; + (service as any).lobbiesWss.emit("connection", ws); + return ws; + } + + function sentPayloads(ws: { send: ReturnType }): any[] { + return ws.send.mock.calls.map((c) => JSON.parse(c[0])); + } + + it("reports listed lobbies to master as hosted, with creatorID and without host-only config", () => { + const game = makeGame("hosted-g1", CREATOR, { + allowedPublicIds: ["p1"], + nameReveals: ["c1"], + nameRevealPublicIds: ["p2"], + hostCheats: { infiniteGold: true }, + }); + game.setListed(true); + gm.listedLobbies.mockReturnValue([game]); + + emitBroadcast({ ffa: [], team: [], special: [], hosted: [] }); + + const lobbyList = sendToMaster.mock.calls + .map((c: any[]) => c[0]) + .find((m: any) => m.type === "lobbyList"); + expect(lobbyList.lobbies).toHaveLength(1); + const reported = lobbyList.lobbies[0]; + expect(reported.publicGameType).toBe("hosted"); + expect(reported.creatorID).toBe(hashPersistentID(CREATOR)); + expect(reported.gameConfig.allowedPublicIds).toBeUndefined(); + expect(reported.gameConfig.nameReveals).toBeUndefined(); + expect(reported.gameConfig.nameRevealPublicIds).toBeUndefined(); + expect(reported.gameConfig.hostCheats).toBeUndefined(); + }); + + it("strips creatorID from broadcasts and primed snapshots sent to clients", () => { + const ws = connectClient(); + emitBroadcast({ + ffa: [], + team: [], + special: [], + hosted: [hostedLobby("g1", "secret-hash")], + }); + + const full = sentPayloads(ws).find((p) => p.type === "full"); + expect(full.games.hosted[0].gameID).toBe("g1"); + expect(full.games.hosted[0].creatorID).toBeUndefined(); + + // A client connecting after the broadcast gets the primed snapshot, + // which must be sanitized too. + const lateWs = connectClient(); + const primed = sentPayloads(lateWs)[0]; + expect(primed.type).toBe("full"); + expect(primed.games.hosted[0].creatorID).toBeUndefined(); + }); + + it("re-sends a full when a hosted lobby's config changes without a gameID change", () => { + const ws = connectClient(); + // A schema-valid config: an invalid one would be rejected by the IPC + // message parse and the broadcast silently dropped. + const games = (gameMap: GameMapType) => ({ + ffa: [], + team: [], + special: [], + hosted: [ + hostedLobby("g1", "hash", { + gameConfig: { + gameMap, + difficulty: Difficulty.Easy, + donateGold: false, + donateTroops: false, + gameType: GameType.Private, + gameMode: GameMode.FFA, + gameMapSize: GameMapSize.Normal, + nations: "default", + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + } as any, + }), + ], + }); + + emitBroadcast(games(GameMapType.World), 1000); + emitBroadcast(games(GameMapType.World), 2000); // unchanged -> counts delta + emitBroadcast(games(GameMapType.Europe), 3000); // host changed map -> fresh full + + const types = sentPayloads(ws).map((p) => p.type); + expect(types).toEqual(["full", "counts", "full"]); + }); + + describe("creatorHasListedLobby", () => { + it("finds other lobbies by the same creator in the master broadcast", () => { + emitBroadcast({ + ffa: [], + team: [], + special: [], + hosted: [hostedLobby("g1", "hash-a")], + }); + + expect(service.creatorHasListedLobby("hash-a", "other-game")).toBe(true); + // The lobby being toggled itself does not count. + expect(service.creatorHasListedLobby("hash-a", "g1")).toBe(false); + expect(service.creatorHasListedLobby("hash-b", "other-game")).toBe(false); + }); + + it("also checks this worker's own lobbies, ahead of the broadcast", () => { + const game = makeGame("local-g1", CREATOR); + game.setListed(true); + gm.listedLobbies.mockReturnValue([game]); + + const hash = hashPersistentID(CREATOR); + expect(service.creatorHasListedLobby(hash, "other-game")).toBe(true); + expect(service.creatorHasListedLobby(hash, "local-g1")).toBe(false); + }); + + it("ignores stale broadcast entries for own games no longer listed", () => { + // The lobby was just delisted on this worker, but the master broadcast + // still contains it for a round-trip or two. Local state wins so the + // creator can immediately list a new lobby. + const game = makeGame("stale-g1", CREATOR); + game.setListed(false); + gm.game.mockImplementation((id: string) => + id === "stale-g1" ? game : null, + ); + const hash = hashPersistentID(CREATOR); + emitBroadcast({ + ffa: [], + team: [], + special: [], + hosted: [hostedLobby("stale-g1", hash)], + }); + + expect(service.creatorHasListedLobby(hash, "other-game")).toBe(false); + + game.setListed(true); + expect(service.creatorHasListedLobby(hash, "other-game")).toBe(true); + }); + }); + + it("counts broadcast lobbies plus local listed ones not yet reported", () => { + const local = makeGame("local-g1", CREATOR); + local.setListed(true); + gm.listedLobbies.mockReturnValue([local]); + + emitBroadcast({ + ffa: [], + team: [], + special: [], + hosted: [hostedLobby("g1", "hash-a"), hostedLobby("g2", "hash-b")], + }); + expect(service.hostedLobbyCount()).toBe(3); + + // Once the local lobby reaches the broadcast it only counts once. + emitBroadcast({ + ffa: [], + team: [], + special: [], + hosted: [hostedLobby("g1", "hash-a"), hostedLobby("local-g1", "hash-c")], + }); + expect(service.hostedLobbyCount()).toBe(2); + }); + + it("clears the listed flag when the master delists a duplicate", () => { + const game = makeGame("dup-g1", CREATOR); + game.setListed(true); + gm.game.mockImplementation((id: string) => (id === "dup-g1" ? game : null)); + + emitBroadcast({ ffa: [], team: [], special: [], hosted: [] }, 1000, [ + "dup-g1", + "not-mine", + ]); + + expect(game.isListed()).toBe(false); + }); +});