feat: subscriber-hosted public lobby listing (#4480)

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 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-05 20:25:56 -07:00
committed by GitHub
parent 22c873cf55
commit 5d21be826d
18 changed files with 1777 additions and 150 deletions
+57 -1
View File
@@ -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<string | false> {
}
}
// 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<boolean> {
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();
+16 -1
View File
@@ -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(),
)}
</div>
<!-- iOS Add to Home Screen banner -->
@@ -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(),
)}
</div>
</div>
@@ -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`
<button
@click=${onClick}
?disabled=${!this.inputValid}
class="flex items-center justify-center w-full h-full rounded-lg ${bgClass} transition-all duration-200 text-sm lg:text-base font-medium text-white uppercase tracking-wider text-center ${!this
class="relative flex items-center justify-center w-full h-full rounded-lg ${bgClass} transition-all duration-200 text-sm lg:text-base font-medium text-white uppercase tracking-wider text-center ${!this
.inputValid
? "opacity-50 cursor-not-allowed pointer-events-none"
: ""}"
>
${title}
${badge
? html`<span
class="absolute -top-2 -right-2 min-w-[1.375rem] h-[1.375rem] px-1.5 flex items-center justify-center rounded-full bg-red-500 text-white text-xs font-bold tracking-normal"
>${badge}</span
>`
: nothing}
</button>
`;
}
+162 -20
View File
@@ -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`
<span
class="text-white text-xl lg:text-2xl font-bold uppercase tracking-widest break-words hyphens-auto"
>
${translateText("host_modal.title")}
</span>
${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`
<button
class="px-3 py-1 text-[10px] font-bold uppercase tracking-widest rounded-full transition-all ${this
.publiclyListed === isPublic
? "bg-malibu-blue text-white"
: "text-white/50 hover:text-white"}"
@click=${() => this.handleVisibilitySelect(isPublic)}
>
${translateText(labelKey)}
</button>
`;
return html`
<div
class="flex items-center rounded-full border border-white/10 bg-white/5 p-0.5 shrink-0"
>
${segment("host_modal.visibility_private", false)}
${segment("host_modal.visibility_public", true)}
</div>
`;
}
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}
></toggle-input-card>`,
html`<toggle-input-card
.labelKey=${"host_modal.player_whitelist"}
.checked=${this.whitelistEnabled}
.inputType=${"text"}
.inputId=${"allowed-public-ids"}
.inputValue=${this.allowedPublicIds}
.inputAriaLabel=${translateText("host_modal.player_whitelist")}
.inputPlaceholder=${translateText(
"host_modal.player_whitelist_placeholder",
)}
.onToggle=${this.handleWhitelistToggle}
.onChange=${this.handleAllowedPublicIdsChange}
></toggle-input-card>`,
// 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`<toggle-input-card
.labelKey=${"host_modal.player_whitelist"}
.checked=${this.whitelistEnabled}
.inputType=${"text"}
.inputId=${"allowed-public-ids"}
.inputValue=${this.allowedPublicIds}
.inputAriaLabel=${translateText("host_modal.player_whitelist")}
.inputPlaceholder=${translateText(
"host_modal.player_whitelist_placeholder",
)}
.onToggle=${this.handleWhitelistToggle}
.onChange=${this.handleAllowedPublicIdsChange}
></toggle-input-card>`,
]),
];
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}
></o-button>
</div>
${this.showSubscriptionRequired
? html`<confirm-dialog
.heading=${translateText(
"host_modal.subscription_required_title",
)}
.message=${translateText("host_modal.subscription_required_body")}
variant="warning"
.showClose=${true}
.buttons=${"confirmOnly"}
.confirmText=${translateText("host_modal.view_subscriptions")}
@cancel=${() => (this.showSubscriptionRequired = false)}
@confirm=${() => {
this.showSubscriptionRequired = false;
window.location.href = "/#modal=store&tab=subscriptions";
}}
></confirm-dialog>`
: ""}
</div>
`;
}
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();
+116 -18
View File
@@ -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`
<form @submit=${this.joinLobbyFromInput} class="custom-scrollbar p-6 space-y-4 mr-1">
<div class="custom-scrollbar p-6 space-y-4 mr-1">
<form @submit=${this.joinLobbyFromInput}>
<div class="flex flex-col gap-3">
<div class="flex gap-2">
<input
@@ -242,12 +251,94 @@ export class JoinLobbyModal extends BaseModal {
submit
></o-button>
</div>
</div>
</form>
</form>
${this.renderHostedLobbies()}
</div>
`;
}
private renderHostedLobbies() {
return html`
<div class="pt-2">
<div
class="text-[10px] font-bold uppercase tracking-widest text-white/40 mb-2"
>
${translateText("private_lobby.open_lobbies")}
</div>
${this.hostedLobbies.length === 0
? html`<p class="text-sm text-white/50">
${translateText("private_lobby.no_open_lobbies")}
</p>`
: html`<div class="flex flex-col gap-2">
${this.hostedLobbies.map((lobby) =>
this.renderHostedLobbyRow(lobby),
)}
</div>`}
</div>
`;
}
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`
<button
type="button"
@click=${() => this.joinHostedLobby(lobby)}
class="w-full px-3 py-2 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 active:bg-white/15 transition-all flex items-center gap-3 text-left"
>
<img
src=${thumbnailUrl}
alt=${mapName}
class="w-12 h-12 rounded-lg object-cover border border-white/10 shrink-0"
@error=${(e: Event) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-sm font-bold text-white truncate">${mapName}</span>
<span class="text-xs text-white/60"
>${c ? this.modeSubtitle(c) : ""}</span
>
</div>
<div
class="flex items-center gap-1 text-white/80 text-xs font-bold shrink-0"
>
${lobby.numClients}${c?.maxPlayers ? `/${c.maxPlayers}` : ""}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.972 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
></path>
</svg>
</div>
</button>
`;
}
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<string, unknown>): 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[] = [];
+15
View File
@@ -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`
+7
View File
@@ -139,6 +139,13 @@ export type UserSubscription = NonNullable<
NonNullable<UserMeResponse["player"]["subscription"]>
>;
// 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,
+38 -3
View File
@@ -150,7 +150,31 @@ export type PublicGames = z.infer<typeof PublicGamesSchema>;
export type PublicGameInfo = z.infer<typeof PublicGameInfoSchema>;
export type PublicGameType = z.infer<typeof PublicGameTypeSchema>;
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({
+8
View File
@@ -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,
+128 -28
View File
@@ -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<ClientID>();
@@ -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<typeof setInterval> | 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,
+22 -3
View File
@@ -2,10 +2,11 @@ import { z } from "zod";
import {
GameConfigSchema,
PublicGameInfoSchema,
PublicGamesSchema,
PublicGameTypeSchema,
} from "../core/Schemas";
export type InternalGameInfo = z.infer<typeof InternalGameInfoSchema>;
export type InternalPublicGames = z.infer<typeof InternalPublicGamesSchema>;
export type WorkerLobbyList = z.infer<typeof WorkerLobbyListSchema>;
export type WorkerReady = z.infer<typeof WorkerReadySchema>;
export type MasterLobbiesBroadcast = z.infer<
@@ -17,12 +18,25 @@ export type MasterCreateGame = z.infer<typeof MasterCreateGameSchema>;
export type WorkerMessage = z.infer<typeof WorkerMessageSchema>;
export type MasterMessage = z.infer<typeof MasterMessageSchema>;
// 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.
+10 -6
View File
@@ -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<PublicGameType, GameMapType[]> = {
private playlists: Record<ScheduledPublicGameType, GameMapType[]> = {
ffa: [],
special: [],
team: [],
};
public async gameConfig(type: PublicGameType): Promise<GameConfig> {
public async gameConfig(type: ScheduledPublicGameType): Promise<GameConfig> {
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;
+80 -8
View File
@@ -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<number, Worker>();
// Worker id => the lobbies it owns.
private readonly workerLobbies = new Map<number, PublicGameInfo[]>();
private readonly workerLobbies = new Map<number, InternalGameInfo[]>();
private readonly readyWorkers = new Set<number>();
// 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<string, number>();
private started = false;
constructor(
@@ -78,13 +89,17 @@ export class MasterLobbyService {
}
}
private getAllLobbies(): Record<PublicGameType, PublicGameInfo[]> {
private getAllLobbies(): {
games: Record<PublicGameType, InternalGameInfo[]>;
losers: string[];
} {
const lobbies = Array.from(this.workerLobbies.values()).flat();
const result: Record<PublicGameType, PublicGameInfo[]> = {
const result: Record<PublicGameType, InternalGameInfo[]> = {
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<string>();
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+
+90
View File
@@ -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({
+180 -60
View File
@@ -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<WebSocket> = 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 {