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
+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 {