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
+15 -2
View File
@@ -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": {
+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 {
+64
View File
@@ -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);
});
});
+11
View File
@@ -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)] }));
+758
View File
@@ -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<string, unknown> = {},
) {
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> = {},
): 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<typeof vi.fn>).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<typeof vi.fn>;
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<typeof vi.fn> }): 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);
});
});