mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-13 00:55:59 +00:00
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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+
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user