From 199c19a3ee63a95cacaf5e4a5f31cb3eff7859ae Mon Sep 17 00:00:00 2001 From: Alexandre Sakr Date: Tue, 15 Sep 2026 13:02:16 -0400 Subject: [PATCH] Add lobby pool routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lobby can declare itself one member of a pool: a group of sibling lobbies that arriving players are spread across, so a single advertised entry point can absorb more players than one lobby holds. Which member a player belongs to is a hash of their identity (publicId, or the hashed persistentID for a player without one) mixed with the pool id, so every member computes the same answer with no shared state, no coordination between the workers hosting them, and the same answer every time — leaving and rejoining puts a player back where they were. Every member carries the same config and recognises itself by its own game id, so there is no per-member field to get wrong. A joiner who lands on the wrong member gets a new `redirect` frame naming their member and a normal close, and navigates there. A one-shot sessionStorage latch stops a bounce; it is cleared as soon as a lobby takes the player, and a second redirect from the same lobby falls through to the ordinary refusal dialog rather than into silence. Admins and publicIds named in allowedPublicIds bypass the hash. Spectators are routed only when they ask for a seat: they take none on the way in, so a caster can watch any member, but the lobby Play/Spectate toggle checks the pool so the spectator flag is not a way past it. A player already in the game is never routed, so a mid-game reconnect is not sent out of the game they are playing. `pool` is admin-bot-only (CreateGameInputSchema omits it) and host-only: stripped from the start info and from the advertised lobby config, and not copied by update_game_config, because pool membership is state every member has to agree on and a patch reaches exactly one GameServer. The assignment helper is server-side: pool membership is lobby admission, not simulation. It never runs in the sim worker, and what it needs is the same answer on this server across calls, not lockstep reproducibility between clients. Pools are created through one route, POST /api/adminbot/create_pool: members name each other by game id, and an id does not exist until the server mints it, so a pool cannot be assembled one create_game at a time. The route mints every id first, builds one sibling list, and creates each member carrying it. `pool` stays create-time only, so a pool can never change after its members exist. Nothing sets `pool` on its own — a caller configures each member explicitly, so the feature is inert until deliberately used. Appends a GameConfig field and a ServerMessage variant, so client and server ship together (zbin/README.md). --- resources/lang/en.json | 1 + src/client/Transport.ts | 40 ++ src/core/CloseCodes.ts | 3 + src/core/Schemas.ts | 46 +- src/core/WorkerSchemas.ts | 4 +- src/server/AdminBotRoutes.ts | 410 ++++++++++++------ src/server/ConfigPatch.ts | 3 +- src/server/GameServer.ts | 95 +++- src/server/PoolRouting.ts | 28 ++ src/server/Worker.ts | 9 + src/server/WorkerLobbyService.ts | 1 + tests/NewLobbyMessages.test.ts | 39 ++ tests/client/TransportPoolRedirect.test.ts | 176 ++++++++ tests/server/AdminBotCreateListed.test.ts | 22 + tests/server/AdminBotCreatePool.test.ts | 169 ++++++++ tests/server/ConfigPatch.test.ts | 10 + tests/server/HostedLobbyListing.test.ts | 3 + .../server/MatchTelemetryIntegration.test.ts | 20 + tests/server/PoolJoin.test.ts | 321 ++++++++++++++ tests/server/PoolRouting.test.ts | 111 +++++ 20 files changed, 1363 insertions(+), 148 deletions(-) create mode 100644 src/server/PoolRouting.ts create mode 100644 tests/client/TransportPoolRedirect.test.ts create mode 100644 tests/server/AdminBotCreatePool.test.ts create mode 100644 tests/server/PoolJoin.test.ts create mode 100644 tests/server/PoolRouting.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index cde995c593..e74f62b78e 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -460,6 +460,7 @@ "no_heartbeat": "The server stopped hearing from you.", "not_allowlisted": "You are not on this lobby's allowlist.", "not_trusted": "This lobby is limited to trusted accounts. You can still join any other lobby.", + "pool_redirect": "This lobby already sent you to another one. Please try again.", "protocol_error": "The connection was corrupted.", "ranked_limit_reached": "You are out of free ranked matches for today.", "turnstile_failed": "The bot check failed. Please reload the page and try again.", diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 3a47dc61d8..9cee48c911 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -223,6 +223,14 @@ export class SendSpectateEvent implements GameEvent { constructor(public readonly spectator: boolean) {} } +// One-shot marker that this lobby has already sent us to a sibling, so a +// redirect can never become a bounce. +const poolRedirectLatch = (gameID: string) => `pool-redirect:${gameID}`; + +// The lobby a redirect came FROM, carried across the navigation: the latch is +// keyed by the source, but only the target can see that the redirect worked. +const POOL_REDIRECT_FROM = "pool-redirect-from"; + export class Transport { // Retry budget for a dropped game socket. The first retry is immediate (a // blip should not cost a second), then exponential from the base to the @@ -466,11 +474,24 @@ export class Transport { new Uint8Array(event.data as ArrayBuffer), this.zbinCtx ?? undefined, ); + if (msg.type === "redirect") { + this.handlePoolRedirect(msg.gameID); + return; + } if (msg.type === "start") { // Seed the dictionary from the same players array, in the same // order, that the server seeded its own from. this.zbinCtx = createGameWireContext(msg.gameStartInfo.players); } + if (!this.isSessionReady) { + // We reached a lobby, so the redirect that sent us here is spent: + // drop the source's latch so it can route this player again later. + const from = sessionStorage.getItem(POOL_REDIRECT_FROM); + if (from !== null) { + sessionStorage.removeItem(poolRedirectLatch(from)); + sessionStorage.removeItem(POOL_REDIRECT_FROM); + } + } this.isSessionReady = true; this.flushBuffer(); this.onmessage(msg); @@ -524,6 +545,25 @@ export class Transport { }; } + // The lobby we asked for assigned us to a sibling. Getting here twice is + // ordinary — sent to a sibling, found it full, came back — so the latched + // branch falls through to the refusal dialog rather than leaving a dead + // loading screen, the way the WrongWorker recovery below does. + // + // The search string is dropped: it belongs to the lobby we asked for, not + // the one we land on. + private handlePoolRedirect(gameID: string) { + const from = this.lobbyConfig.gameID; + const latch = poolRedirectLatch(from); + if (sessionStorage.getItem(latch) !== null) { + this.handleConnectionRefused(CloseReason.PoolRedirect); + return; + } + sessionStorage.setItem(latch, "1"); + sessionStorage.setItem(POOL_REDIRECT_FROM, from); + window.location.href = ClientEnv.gamePath(gameID); + } + private handleConnectionRefused(reason: string) { if (this.connectionRefused) { return; diff --git a/src/core/CloseCodes.ts b/src/core/CloseCodes.ts index 2f508ef007..712436de33 100644 --- a/src/core/CloseCodes.ts +++ b/src/core/CloseCodes.ts @@ -51,6 +51,9 @@ export const CloseReason = { RankedLimitReached: "close_reason.ranked_limit_reached", InvalidClan: "close_reason.invalid_clan", ClanVerificationFailed: "close_reason.clan_verification_failed", + // Rendered in one case only: a second redirect from the same lobby, which + // the client refuses rather than following. The redirect itself is silent. + PoolRedirect: "close_reason.pool_redirect", // Shown for a terminal close whose reason is not one of ours. Unknown: "close_reason.unknown", } as const; diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 7a5ce1a9ab..a5d5486fb4 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -115,7 +115,8 @@ export type ServerMessage = | ServerPrestartMessage | ServerErrorMessage | ServerLobbyInfoMessage - | ServerNewLobbyMessage; + | ServerNewLobbyMessage + | ServerRedirectMessage; export type ServerTurnMessage = z.infer; export type ServerStartGameMessage = z.infer< @@ -129,6 +130,7 @@ export type ServerLobbyInfoMessage = z.infer< typeof ServerLobbyInfoMessageSchema >; export type ServerNewLobbyMessage = z.infer; +export type ServerRedirectMessage = z.infer; export type ClientSendWinnerMessage = z.infer; export type ClientSendLiveStatsMessage = z.infer< typeof ClientSendLiveStatsSchema @@ -475,6 +477,34 @@ export const OvertimeConfigSchema = z.object({ startMinutes: zb.uint({ min: 1, max: 120 }).optional(), }); +// A lobby pool: several lobbies that arriving players are spread across, so +// one advertised entry point can absorb more players than a single lobby +// holds. Assignment is a hash of the joiner's identity (server/PoolRouting.ts), +// so members need no shared state. Every member carries this same config and +// recognises itself by its own game id. +// +// The advertised entry point is itself a member rather than an empty router: +// a lobby nobody plays in would start, leave the Lobby phase, drop out of the +// listing and be reaped, taking the entry point with it. +export const PoolConfigSchema = z + .object({ + id: z.string().min(1).max(64), + // z.lazy because ID is declared further down this file, and GameConfigSchema + // — which embeds this — is evaluated before that point. + siblings: z + .lazy(() => ID) + .array() + .min(1) + .max(64), + }) + // Rejected rather than deduped: a repeated id holds more than one slot and + // draws proportionally more players than the rest. + .refine((pool) => new Set(pool.siblings).size === pool.siblings.length, { + error: "pool siblings must be unique", + path: ["siblings"], + }); +export type PoolConfig = z.infer; + export const GameConfigSchema = z.object({ gameMap: z.enum(GameMapType), difficulty: z.enum(Difficulty), @@ -553,6 +583,9 @@ export const GameConfigSchema = z.object({ startingGold: zb.uint({ max: 1000000000 }).nullable().optional(), }) .optional(), + // Stripped from gameStartInfo and from the advertised lobby config: sibling + // ids are private lobby ids, which are join secrets. + pool: PoolConfigSchema.optional(), }); export const TeamSchema = z.string(); @@ -1030,6 +1063,15 @@ export const ServerNewLobbyMessageSchema = z.object({ gameID: ID, }); +// Sent to a joiner this lobby's pool assigns elsewhere, immediately before the +// close. A close frame's reason is a fixed enum and cannot carry an id, so the +// target needs a frame of its own; the id is all the client needs, since it +// resolves the hosting worker from the id itself. +export const ServerRedirectMessageSchema = z.object({ + type: z.literal("redirect"), + gameID: ID, +}); + export const ServerMessageSchema = zb.discriminatedUnion("type", [ ServerTurnMessageSchema, ServerPrestartMessageSchema, @@ -1039,6 +1081,8 @@ export const ServerMessageSchema = zb.discriminatedUnion("type", [ ServerErrorSchema, ServerLobbyInfoMessageSchema, ServerNewLobbyMessageSchema, + // Appended, never inserted: variant order is the wire tag (zbin/README.md). + ServerRedirectMessageSchema, ]); // diff --git a/src/core/WorkerSchemas.ts b/src/core/WorkerSchemas.ts index 0a06b1571a..9f6720def6 100644 --- a/src/core/WorkerSchemas.ts +++ b/src/core/WorkerSchemas.ts @@ -1,7 +1,9 @@ import { z } from "zod"; import { GameConfigSchema } from "./Schemas"; -export const CreateGameInputSchema = GameConfigSchema.or( +// `pool` points joiners at other lobbies, so only the authenticated admin-bot +// route (which parses GameConfigSchema directly) may set one. +export const CreateGameInputSchema = GameConfigSchema.omit({ pool: true }).or( z .object({}) .strict() diff --git a/src/server/AdminBotRoutes.ts b/src/server/AdminBotRoutes.ts index 572a266f05..36117b8d66 100644 --- a/src/server/AdminBotRoutes.ts +++ b/src/server/AdminBotRoutes.ts @@ -11,13 +11,17 @@ import { z } from "zod"; import { GameMode, GameType } from "../core/game/Game"; import { ADMIN_BOT_CLIENT_ID, + type GameConfig, GameConfigSchema, + type GameID, ID, IntentSchema, + type LobbyAccent, LobbyAccentSchema, LobbyLabelSchema, } from "../core/Schemas"; import type { GameManager } from "./GameManager"; +import type { GameServer } from "./GameServer"; import { ServerEnv } from "./ServerEnv"; // Team-pinning caps. A lobby tops out well below these; they exist so a bad @@ -25,6 +29,12 @@ import { ServerEnv } from "./ServerEnv"; const MAX_TEAMS = 200; const MAX_TEAM_MEMBERS = 50; +// Every member of a pool is hosted by the worker that served the request (ids +// are minted to hash to it), each with its own lobby loop and per-second +// broadcast. So this is a budget for one worker's event loop, not the 64 the +// sibling list would otherwise allow. +const MAX_POOL_MEMBERS = 8; + function timingSafeEqualStr(a: string, b: string): boolean { const ab = Buffer.from(a); const bb = Buffer.from(b); @@ -52,6 +62,176 @@ export const requireAdminBotKey: RequestHandler = ( next(); }; +interface CreateGameRequest { + config: Partial; + listed: boolean; + featured: boolean; + label: string | undefined; + accent: LobbyAccent | undefined; + teams: string[][] | undefined; +} + +type ParsedCreateGame = + | { ok: true; value: CreateGameRequest } + | { ok: false; status: number; body: unknown }; + +const fail = (status: number, body: unknown): ParsedCreateGame => ({ + ok: false, + status, + body, +}); + +// Everything about a create request that can be judged before an id exists. +// Shared by create_game and create_pool so a pool's members are held to the +// same rules as a lone lobby. +function parseCreateGameRequest(reqBody: unknown): ParsedCreateGame { + const parsed = GameConfigSchema.partial().safeParse(reqBody ?? {}); + if (!parsed.success) { + return fail(400, { error: z.prettifyError(parsed.error) }); + } + const config = parsed.data; + // Optional public listing (#4480). Read alongside the config, not from it: + // `listed` lives on GameServer precisely so it can't be smuggled through + // GameConfig, and the schema parse above strips it from `config` for us. + // + // Set at CREATE time rather than via a follow-up toggle because a bot never + // needs to withdraw a listing by hand — a lobby delists itself the moment it + // starts, fills or dies. The human toggle (POST /api/game/:id/listing) can't + // serve a bot: it authorizes via isCreator + subscription, and an admin-bot + // lobby is deliberately created with NO creatorPersistentID (below), so it has + // no owner to match and no account to bill. + // + // `featured` rides alongside for the same reason. It lengthens the listing + // deadline and gives the row a label of the host's choosing, which is only + // safe because this endpoint is authenticated: an ordinary subscriber must + // not be able to name their lobby "Official Event" or hold a listing open. + const listedParsed = z + .object({ + listed: z.boolean().optional(), + featured: z.boolean().optional(), + label: LobbyLabelSchema.optional(), + accent: LobbyAccentSchema.optional(), + }) + .safeParse(reqBody ?? {}); + if (!listedParsed.success) { + return fail(400, { error: z.prettifyError(listedParsed.error) }); + } + const listed = listedParsed.data.listed === true; + const featured = listedParsed.data.featured === true; + + // Optional team pinning. Read alongside the config for the same reason as + // `listed`: it is not a GameConfig field, so the parse above strips it and it + // can never be smuggled in through update_game_config after the fact. + // + // Entries are publicIds. assignTeams honours a pinned slot unconditionally — + // before and regardless of clan/friend grouping — so this is how a tournament + // bot says who plays with whom instead of letting the balancer decide. + const teamsParsed = z + .object({ + teams: z + .array(z.array(z.string()).max(MAX_TEAM_MEMBERS)) + .max(MAX_TEAMS) + .optional(), + }) + .safeParse(reqBody ?? {}); + if (!teamsParsed.success) { + return fail(400, { error: z.prettifyError(teamsParsed.error) }); + } + const teams = teamsParsed.data.teams; + if (teams !== undefined) { + // FFA never runs assignTeams, so a pin there would be silently inert. + // Refuse rather than accept a request that cannot do what it asks. + if (config.gameMode !== GameMode.Team) { + return fail(400, { error: "teams require gameMode Team" }); + } + // A publicId in two teams has no single answer (findIndex takes the first), + // so the caller would get a team it did not ask for. + const seen = new Set(); + for (const team of teams) { + for (const publicId of team) { + if (seen.has(publicId)) { + return fail(400, { + error: `publicId in more than one team: ${publicId}`, + }); + } + seen.add(publicId); + } + } + // A pin is an index into the team list, so one past the end resolves to + // no team and the player is silently unpinned. Duos/Trios/Quads resolve + // their count at START from who turned up, and omitting it resolves to 0 + // (GameImpl throws "Too few teams"), so neither can be checked here. + const playerTeams = config.playerTeams; + if (teams.length > 0) { + if (typeof playerTeams !== "number") { + return fail(400, { error: "teams_require_numeric_player_teams" }); + } + if (teams.length > playerTeams) { + return fail(400, { + error: "teams_exceed_player_teams", + playerTeams, + teams: teams.length, + }); + } + } + } + // Private only: reject Public and Singleplayer. An omitted gameType defaults + // to Private in createGame, so it's allowed through. + if (config.gameType !== undefined && config.gameType !== GameType.Private) { + return fail(400, { error: "admin bot can only create private games" }); + } + + // Guard BEFORE minting a lobby, so an ineligible request doesn't leave an + // orphan behind. Both mirror the human endpoint's refusals: a whitelisted + // lobby would be advertised to everyone yet reject every joiner (and the + // whitelist is stripped from the broadcast, so browsers couldn't tell why), + // and host cheats give the host an edge over players recruited from the + // browser. The cluster-wide MAX_HOSTED_LOBBIES cap is left to the master, + // which already delists overflow as the authoritative backstop. + if (listed) { + if ((config.allowedPublicIds?.length ?? 0) > 0) { + return fail(409, { error: "listing_whitelist_enabled" }); + } + if (config.hostCheats !== undefined) { + return fail(409, { error: "listing_host_cheats_enabled" }); + } + } + // Featuring only means anything for a listed lobby: it governs the listing + // deadline and the browser row. Refuse rather than silently ignore, so a + // caller that forgot `listed` finds out. + if (featured && !listed) { + return fail(400, { error: "featured_requires_listed" }); + } + return { + ok: true, + value: { + config, + listed, + featured, + label: listedParsed.data.label, + accent: listedParsed.data.accent, + teams, + }, + }; +} + +function lobbyResponse(game: GameServer, id: GameID, workerId: number) { + return { + ...game.gameInfo(), + workerIndex: workerId, + workerPath: ServerEnv.workerPath(id), + }; +} + +// setListed first: the listing deadline is measured from the listing, and +// featuring is what decides how long that deadline is. +function applyListing(game: GameServer, request: CreateGameRequest): void { + if (request.listed) game.setListed(true); + if (request.featured) { + game.setFeatured({ label: request.label, accent: request.accent }); + } +} + export function registerAdminBotRoutes(opts: { app: Express; gm: GameManager; @@ -78,131 +258,10 @@ export function registerAdminBotRoutes(opts: { // the bot doesn't need to know the sharding. nginx (and the vite dev proxy) // randomly route here to spread new games across workers. app.post("/api/adminbot/create_game", requireAdminBotKey, (req, res) => { - const parsed = GameConfigSchema.partial().safeParse(req.body ?? {}); - if (!parsed.success) { - return res.status(400).json({ error: z.prettifyError(parsed.error) }); - } - const config = parsed.data; - // Optional public listing (#4480). Read alongside the config, not from it: - // `listed` lives on GameServer precisely so it can't be smuggled through - // GameConfig, and the schema parse above strips it from `config` for us. - // - // Set at CREATE time rather than via a follow-up toggle because a bot never - // needs to withdraw a listing by hand — a lobby delists itself the moment it - // starts, fills or dies. The human toggle (POST /api/game/:id/listing) can't - // serve a bot: it authorizes via isCreator + subscription, and an admin-bot - // lobby is deliberately created with NO creatorPersistentID (below), so it has - // no owner to match and no account to bill. - // - // `featured` rides alongside for the same reason. It lengthens the listing - // deadline and gives the row a label of the host's choosing, which is only - // safe because this endpoint is authenticated: an ordinary subscriber must - // not be able to name their lobby "Official Event" or hold a listing open. - const listedParsed = z - .object({ - listed: z.boolean().optional(), - featured: z.boolean().optional(), - label: LobbyLabelSchema.optional(), - accent: LobbyAccentSchema.optional(), - }) - .safeParse(req.body ?? {}); - if (!listedParsed.success) { - return res - .status(400) - .json({ error: z.prettifyError(listedParsed.error) }); - } - const listed = listedParsed.data.listed === true; - const featured = listedParsed.data.featured === true; - - // Optional team pinning. Read alongside the config for the same reason as - // `listed`: it is not a GameConfig field, so the parse above strips it and it - // can never be smuggled in through update_game_config after the fact. - // - // Entries are publicIds. assignTeams honours a pinned slot unconditionally — - // before and regardless of clan/friend grouping — so this is how a tournament - // bot says who plays with whom instead of letting the balancer decide. - const teamsParsed = z - .object({ - teams: z - .array(z.array(z.string()).max(MAX_TEAM_MEMBERS)) - .max(MAX_TEAMS) - .optional(), - }) - .safeParse(req.body ?? {}); - if (!teamsParsed.success) { - return res - .status(400) - .json({ error: z.prettifyError(teamsParsed.error) }); - } - const teams = teamsParsed.data.teams; - if (teams !== undefined) { - // FFA never runs assignTeams, so a pin there would be silently inert. - // Refuse rather than accept a request that cannot do what it asks. - if (config.gameMode !== GameMode.Team) { - return res.status(400).json({ error: "teams require gameMode Team" }); - } - // A publicId in two teams has no single answer (findIndex takes the first), - // so the caller would get a team it did not ask for. - const seen = new Set(); - for (const team of teams) { - for (const publicId of team) { - if (seen.has(publicId)) { - return res - .status(400) - .json({ error: `publicId in more than one team: ${publicId}` }); - } - seen.add(publicId); - } - } - // A pin is an index into the team list, so one past the end resolves to - // no team and the player is silently unpinned. Duos/Trios/Quads resolve - // their count at START from who turned up, and omitting it resolves to 0 - // (GameImpl throws "Too few teams"), so neither can be checked here. - const playerTeams = config.playerTeams; - if (teams.length > 0) { - if (typeof playerTeams !== "number") { - return res - .status(400) - .json({ error: "teams_require_numeric_player_teams" }); - } - if (teams.length > playerTeams) { - return res.status(400).json({ - error: "teams_exceed_player_teams", - playerTeams, - teams: teams.length, - }); - } - } - } - // Private only: reject Public and Singleplayer. An omitted gameType defaults - // to Private in createGame, so it's allowed through. - if (config.gameType !== undefined && config.gameType !== GameType.Private) { - return res - .status(400) - .json({ error: "admin bot can only create private games" }); - } - - // Guard BEFORE minting a lobby, so an ineligible request doesn't leave an - // orphan behind. Both mirror the human endpoint's refusals: a whitelisted - // lobby would be advertised to everyone yet reject every joiner (and the - // whitelist is stripped from the broadcast, so browsers couldn't tell why), - // and host cheats give the host an edge over players recruited from the - // browser. The cluster-wide MAX_HOSTED_LOBBIES cap is left to the master, - // which already delists overflow as the authoritative backstop. - if (listed) { - if ((config.allowedPublicIds?.length ?? 0) > 0) { - return res.status(409).json({ error: "listing_whitelist_enabled" }); - } - if (config.hostCheats !== undefined) { - return res.status(409).json({ error: "listing_host_cheats_enabled" }); - } - } - // Featuring only means anything for a listed lobby: it governs the listing - // deadline and the browser row. Refuse rather than silently ignore, so a - // caller that forgot `listed` finds out. - if (featured && !listed) { - return res.status(400).json({ error: "featured_requires_listed" }); - } + const parsed = parseCreateGameRequest(req.body); + if (!parsed.ok) return res.status(parsed.status).json(parsed.body); + const request = parsed.value; + const { config, teams } = request; const id = ServerEnv.generateGameIdForWorker(workerId); if (id === null) { @@ -210,6 +269,13 @@ export function registerAdminBotRoutes(opts: { return res.status(500).json({ error: "Could not allocate game id" }); } + // A member has to be in its own pool, or it keeps nobody and routes every + // joiner away. Only checkable once the id exists, so unlike the guards + // above it costs a minted id — but still no lobby. + if (config.pool !== undefined && !config.pool.siblings.includes(id)) { + return res.status(400).json({ error: "pool_missing_own_id", id }); + } + const game = gm.createGame( id, config, @@ -221,22 +287,90 @@ export function registerAdminBotRoutes(opts: { if (game === null) { return res.status(409).json({ error: "Game ID already exists" }); } - if (listed) { - game.setListed(true); + applyListing(game, request); + log.info(`admin bot created game ${id}`, { + listed: request.listed, + featured: request.featured, + }); + res.json(lobbyResponse(game, id, workerId)); + }); + + // Create a pool of sibling lobbies in one call. + // + // This exists because a pool cannot be assembled one create_game at a time: + // members name each other by game id, and an id does not exist until the + // server mints it. Minting all N up front is what breaks that circularity. + // `pool` stays create-time only — there is no patch route and ConfigPatch + // does not copy it — so a pool can never change after its members exist. + app.post("/api/adminbot/create_pool", requireAdminBotKey, (req, res) => { + // A pool of one would never redirect anyone; refuse rather than create a + // lobby that merely looks pooled. + const countParsed = z + .object({ count: z.number().int().min(2).max(MAX_POOL_MEMBERS) }) + .safeParse(req.body ?? {}); + if (!countParsed.success) { + return res + .status(400) + .json({ error: z.prettifyError(countParsed.error) }); } - // After setListed: the deadline is measured from the listing, and featuring - // is what decides how long that deadline is. - if (featured) { - game.setFeatured({ - label: listedParsed.data.label, - accent: listedParsed.data.accent, - }); + const count = countParsed.data.count; + + const parsed = parseCreateGameRequest(req.body); + if (!parsed.ok) return res.status(parsed.status).json(parsed.body); + const request = parsed.value; + + if (request.config.pool !== undefined) { + return res.status(400).json({ error: "pool_is_generated" }); } - log.info(`admin bot created game ${id}`, { listed, featured }); + // A team pin names publicIds for ONE lobby. Repeated across members it + // would be inert everywhere the hash did not send those players. + if (request.teams !== undefined) { + return res.status(400).json({ error: "teams_unsupported_for_pool" }); + } + + // Every id before any lobby: the sibling list has to be complete, and a + // half-created pool would name members that do not exist. Nothing runs + // between these checks and the creates below, so an id found free here is + // still free there. + const ids: GameID[] = []; + for (let i = 0; i < count; i++) { + const id = ServerEnv.generateGameIdForWorker(workerId); + if (id === null || ids.includes(id) || gm.game(id) !== null) { + log.warn(`admin bot: could not mint ${count} pool ids`, { workerId }); + return res.status(500).json({ error: "Could not allocate game ids" }); + } + ids.push(id); + } + + // One object, shared by every member: order is part of the assignment, so + // members that disagree on it disagree about who belongs where. + const pool = { id: crypto.randomUUID(), siblings: ids }; + const lobbies: GameServer[] = []; + for (const id of ids) { + const game = gm.createGame(id, { ...request.config, pool }); + if (game === null) { + // Unreachable after the check above, and there is no way to unmake the + // lobbies already created — so say so loudly rather than pretend. + log.error(`admin bot: pool id ${id} taken after being checked free`); + return res.status(500).json({ error: "Game ID already exists", id }); + } + lobbies.push(game); + } + + // Only the entry point is advertised. The pool exists so ONE row in the + // browser can absorb more players than one lobby holds; listing every + // member would spend N of the cluster's hosted-lobby slots on one event + // and gain nothing, since a joiner who picks any member is routed anyway. + applyListing(lobbies[0], request); + + log.info(`admin bot created a pool of ${count}`, { + poolId: pool.id, + entry: ids[0], + listed: request.listed, + }); res.json({ - ...game.gameInfo(), - workerIndex: workerId, - workerPath: ServerEnv.workerPath(id), + poolId: pool.id, + lobbies: lobbies.map((game, i) => lobbyResponse(game, ids[i], workerId)), }); }); diff --git a/src/server/ConfigPatch.ts b/src/server/ConfigPatch.ts index 871a32d6f7..c6c39ffea6 100644 --- a/src/server/ConfigPatch.ts +++ b/src/server/ConfigPatch.ts @@ -4,7 +4,8 @@ import { GameConfig } from "../core/Schemas"; // partial GameConfig. Only the keys listed here are taken from it. gameType, // maxPlayers and the listing flag are deliberately absent: each has its own // guarded path (handleIntent rejects a switch to Public; listing goes through -// the authenticated listing endpoint). +// the authenticated listing endpoint). `pool` is absent because every member +// of a pool has to agree on it and a patch reaches exactly one GameServer. // Copied whenever the patch carries them. const COPIED_KEYS = [ diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 58e376591c..dfacc8070a 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -43,6 +43,7 @@ import { ServerLobbyInfoMessage, ServerNewLobbyMessage, ServerPrestartMessageSchema, + ServerRedirectMessage, ServerStartGameMessage, ServerTurnMessage, StampedIntent, @@ -66,6 +67,7 @@ import { import { ListingState } from "./ListingState"; import { identityFor, MatchTelemetryRecorder } from "./MatchTelemetryRecorder"; import { friendsLookup, NameVisibility } from "./NameVisibility"; +import { poolTargetFor } from "./PoolRouting"; import { Roster } from "./Roster"; import { ServerEnv } from "./ServerEnv"; import { SocketIngress } from "./SocketIngress"; @@ -81,7 +83,9 @@ export type JoinResult = | "rejected" | "ended" | "not_allowlisted" - | "not_trusted"; + | "not_trusted" + // Not a refusal: the client was told which sibling lobby to go to instead. + | "redirected"; export enum GamePhase { Lobby = "LOBBY", @@ -296,11 +300,15 @@ export class GameServer { if (opts.startsAt !== undefined) { this.visibleAt = Date.now(); } + // Telemetry ships off-box, and sibling ids are join secrets. Same strip as + // gameInfo(), on a copy: the full config stays in use at runtime. + const telemetryConfig = { ...opts.gameConfig }; + delete telemetryConfig.pool; this.telemetry.emit( "match_opened", { lobbyCreatedAt: opts.createdAt, - config: opts.gameConfig, + config: telemetryConfig, publicGameType: opts.publicGameType, buildHash: this.deps.telemetryBuildHash, instanceId: ServerEnv.instanceId(), @@ -498,6 +506,26 @@ export class GameServer { return "not_trusted"; } + // Being routed is not a refusal, so it must not consume the "full" or + // "started" answer that belongs to the lobby they end up on. + const redirect = this.poolRedirectFor(client); + if (redirect !== null) { + this.log.info("assigning client to pool sibling", { + clientID: client.clientID, + target: redirect, + }); + client.ws.send( + encodeServerMessage( + { + type: "redirect", + gameID: redirect, + } satisfies ServerRedirectMessage, + this.zbinCtx, + ), + ); + return "redirected"; + } + // gameStartInfo.players is frozen at start, so a late arrival could never // spawn. They used to join as a player anyway; watching is what actually // happened to them, so it is what they join as. @@ -1010,6 +1038,7 @@ export class GameServer { const config = { ...this.gameConfig }; delete config.allowedPublicIds; delete config.nameRevealPublicIds; + delete config.pool; const result = GameStartInfoSchema.safeParse({ gameID: this.id, @@ -1183,18 +1212,63 @@ export class GameServer { return client.trusted; } + // ONE definition of which member a client belongs to, shared by every path + // that can seat someone here — joinClient and the Play/Spectate toggle — the + // same way passesAllowlist is. Null means seat them here. + // + // Naming a publicId in allowedPublicIds pins them to this member on purpose, + // so it overrides the hash. + private poolTargetForClient(client: Client): GameID | null { + const pool = this.gameConfig.pool; + if (pool === undefined) return null; + if (isAdminRole(client.role)) return null; + if ( + client.publicId !== undefined && + this.gameConfig.allowedPublicIds?.includes(client.publicId) === true + ) { + return null; + } + return poolTargetFor( + pool, + client.publicId ?? hashPersistentID(client.persistentID), + this.id, + ); + } + + // The join-path view. Both extra guards belong here and NOT in + // poolTargetForClient: a client that is already in this game is a known + // client, and a spectator that later asks for a seat is too, so sharing + // either one would make the seat toggle a way past the pool. + // + // Already here: a mid-game drop reconnects as a fresh join, and routing it + // out would take the player out of the game they are playing. (A reconnect + // that arrives as a rejoin never reaches any of this — rejoinClient hands + // an existing client a new socket and seats nobody.) + // + // Spectator: they take no seat on the way in, so a caster can watch whichever + // member they asked for. + private poolRedirectFor(client: Client): GameID | null { + if (this.getClientIdForPersistentId(client.persistentID) !== null) { + return null; + } + if (client.spectator) return null; + return this.poolTargetForClient(client); + } + // Switch a client between playing and watching from the lobby screen. Seating // is refused once the game has started (the player list is frozen), when the - // lobby is full, or when the allowlist does not name them — the toggle must - // not be a way past either. The allowlist can gain entries AFTER people are in - // the lobby (update_game_config replaces it), so someone admitted before it - // was set is not proof they may hold a seat now. + // lobby is full, when the allowlist does not name them, or when the pool puts + // them on another member — the toggle must not be a way past any of them. The + // allowlist can gain entries AFTER people are in the lobby + // (update_game_config replaces it), so someone admitted before it was set is + // not proof they may hold a seat now. private setSpectator(client: Client, spectator: boolean): void { if (client.spectator === spectator) return; if (!spectator) { if (this.stage === "started" || this.ended) return; if (!this.passesAllowlist(client)) return; if (!this.passesTrustGate(client)) return; + if (this.poolTargetForClient(client) !== null) return; const max = this.gameConfig.maxPlayers; if (max !== undefined && this.playerCount() >= max) return; } @@ -1516,11 +1590,18 @@ export class GameServer { // Omitting viewer (e.g. the HTTP /api/game/:id and link-preview routes) // anonymizes all names when the option is on. public gameInfo(viewer?: ClientID): GameInfo { + // Shallow copy, never the stored config: this goes out over the + // unauthenticated /api/game/:id route and the per-second lobby_info + // broadcast, and sibling ids are join secrets. Third site that sanitises + // a config for its own audience — one shared sanitiser would be a + // sensible follow-up, but the three strip different fields today. + const gameConfig = { ...this.gameConfig }; + delete gameConfig.pool; return { gameID: this.id, clients: this.names.lobbyClients(viewer, this.clients.active()), lobbyCreatorClientID: this.lobbyCreatorID, - gameConfig: this.gameConfig, + gameConfig, startsAt: this.startsAt, serverTime: Date.now(), publicGameType: this.publicGameType, diff --git a/src/server/PoolRouting.ts b/src/server/PoolRouting.ts new file mode 100644 index 0000000000..fbdd162bf5 --- /dev/null +++ b/src/server/PoolRouting.ts @@ -0,0 +1,28 @@ +// Which member of a lobby pool a player belongs to. See PoolConfigSchema for +// what a pool is and why it is shaped this way. +// +// Server-side, not core: this is lobby admission, not simulation. It never +// runs in the sim worker, and what it needs is the same answer on this server +// across calls — not lockstep reproducibility between clients. + +import { GameID, PoolConfig } from "../core/Schemas"; +import { simpleHash } from "../core/Util"; + +export function poolIndexFor(key: string, size: number): number { + return simpleHash(key) % size; +} + +// The pool id is mixed into the key so a player is not pinned to the same +// ordinal in every pool they meet: hashing identity alone would send the same +// people to member 0 of every equally-sized pool. +export function poolTargetFor( + pool: PoolConfig, + key: string, + selfId: GameID, +): GameID | null { + const { siblings } = pool; + // The schema requires a member, so this only guards a pool built in code. + if (siblings.length === 0) return null; + const target = siblings[poolIndexFor(`${pool.id}:${key}`, siblings.length)]; + return target === selfId ? null : target; +} diff --git a/src/server/Worker.ts b/src/server/Worker.ts index b7a8f6f71f..38790d0471 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -747,6 +747,15 @@ export async function startWorker() { workerId, }); ws.close(CloseCode.Forbidden, CloseReason.NotTrusted); + } else if (joinResult === "redirected") { + // Normal, not a rejection code: the game already sent this client + // where to go, and Normal is the client's silent branch, so no + // dialog appears while it navigates. + log.info("client redirected to a pool sibling", { + gameID: clientMsg.gameID, + workerId, + }); + ws.close(CloseCode.Normal, CloseReason.PoolRedirect); } else if (joinResult === "ended") { log.info(`client tried to join ended game ${clientMsg.gameID}`, { gameID: clientMsg.gameID, diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index a96da0133d..ee39e69047 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -30,6 +30,7 @@ function publicLobbyGameConfig(gc: GameConfig): GameConfig { delete sanitized.nameReveals; delete sanitized.nameRevealPublicIds; delete sanitized.hostCheats; + delete sanitized.pool; return sanitized; } diff --git a/tests/NewLobbyMessages.test.ts b/tests/NewLobbyMessages.test.ts index 2cf167f88d..463349e622 100644 --- a/tests/NewLobbyMessages.test.ts +++ b/tests/NewLobbyMessages.test.ts @@ -30,3 +30,42 @@ describe("reuse-lobby wire messages", () => { expect(parsed.success).toBe(false); }); }); + +// The other server frame that carries nothing but a game id: the pool +// redirect, sent to a joiner this lobby's pool assigns to a sibling. +describe("pool redirect wire message", () => { + it("accepts a redirect server message with a valid game id", () => { + const parsed = ServerMessageSchema.safeParse({ + type: "redirect", + gameID: "abcd1234", + }); + expect(parsed.success).toBe(true); + if (parsed.success && parsed.data.type === "redirect") { + expect(parsed.data.gameID).toBe("abcd1234"); + } + }); + + it("rejects a redirect message without a game id", () => { + expect(ServerMessageSchema.safeParse({ type: "redirect" }).success).toBe( + false, + ); + }); + + it("rejects a redirect game id that is not a valid id", () => { + const parsed = ServerMessageSchema.safeParse({ + type: "redirect", + gameID: "not a valid id!", + }); + expect(parsed.success).toBe(false); + }); + + it("carries the target and nothing else", () => { + // Deliberately minimal: which worker hosts a game is a pure function of + // its id, so the client resolves the route itself. + const parsed = ServerMessageSchema.parse({ + type: "redirect", + gameID: "abcd1234", + }); + expect(Object.keys(parsed).sort()).toEqual(["gameID", "type"]); + }); +}); diff --git a/tests/client/TransportPoolRedirect.test.ts b/tests/client/TransportPoolRedirect.test.ts new file mode 100644 index 0000000000..7076214904 --- /dev/null +++ b/tests/client/TransportPoolRedirect.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { LobbyConfig } from "../../src/client/ClientGameRunner"; +import { CloseReason } from "../../src/core/CloseCodes"; + +const modalMocks = vi.hoisted(() => ({ + showInGameConfirm: vi.fn<(message: string) => Promise>(), +})); + +vi.mock("../../src/client/InGameModal", () => ({ + showInGameConfirm: modalMocks.showInGameConfirm, +})); + +vi.mock("../../src/client/Utils", () => ({ + translateText: vi.fn((key: string, vars?: { reason?: string }) => + vars?.reason !== undefined ? `${key}:${vars.reason}` : key, + ), + homeHref: vi.fn(() => "/"), +})); + +vi.mock("src/client/ClientEnv", async (importOriginal) => { + const actual = + await importOriginal(); + return { + NoServerError: actual.NoServerError, + ClientEnv: { + gameWorkerPath: vi.fn(() => "w0"), + gameWsBase: vi.fn(() => "ws://game.test"), + gameHttpBase: vi.fn(() => "http://game.test"), + gamePath: vi.fn((gameID: string) => `/w0/game/${gameID}`), + }, + }; +}); + +import { Transport } from "../../src/client/Transport"; +import { EventBus } from "../../src/core/EventBus"; +import { encodeServerMessage } from "../../src/core/ZbinWire"; + +const ENTRY = "aaaa1111"; +const SIBLING = "bbbb2222"; +const HOME = "http://localhost:9000/w0/game/aaaa1111"; + +class FakeWebSocket { + static readonly OPEN = 1; + readyState = 0; + binaryType = ""; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + + constructor(_url: string | URL) { + sockets.push(this); + } + send() {} + close() { + this.readyState = 3; + } + + // Deliver a server frame the way the real socket does: a zbin ArrayBuffer. + deliver(msg: Parameters[0]) { + const bytes = encodeServerMessage(msg, undefined); + this.onmessage?.({ data: bytes.buffer } as MessageEvent); + } +} + +const sockets: FakeWebSocket[] = []; + +function lobbyConfig(gameID: string): LobbyConfig { + return { + cosmetics: {}, + playerName: "tester", + playerClanTag: null, + playerRole: null, + gameID, + turnstileToken: null, + }; +} + +// Connect a client to `gameID` and hand back the socket the server talks on. +function connect(gameID: string): FakeWebSocket { + const transport = new Transport(lobbyConfig(gameID), new EventBus()); + transport.connect( + () => undefined, + () => undefined, + ); + return sockets[sockets.length - 1]; +} + +const latch = (gameID: string) => + sessionStorage.getItem(`pool-redirect:${gameID}`); + +describe("Transport pool redirect", () => { + let mockLocationHref = ""; + + beforeEach(() => { + vi.useFakeTimers(); + sockets.length = 0; + sessionStorage.clear(); + mockLocationHref = HOME; + modalMocks.showInGameConfirm.mockReset(); + modalMocks.showInGameConfirm.mockResolvedValue(false); + + Object.defineProperty(window, "location", { + value: { + get href() { + return mockLocationHref; + }, + set href(value: string) { + mockLocationHref = value; + }, + search: "?ref=somewhere", + }, + writable: true, + configurable: true, + }); + + vi.stubGlobal("WebSocket", FakeWebSocket); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("navigates to the assigned sibling, without the search string", () => { + connect(ENTRY).deliver({ type: "redirect", gameID: SIBLING }); + + expect(window.location.href).toBe(`/w0/game/${SIBLING}`); + expect(modalMocks.showInGameConfirm).not.toHaveBeenCalled(); + }); + + it("clears the entry lobby's latch once the sibling takes us", () => { + // The latch is keyed by the lobby that redirected, but only the lobby we + // land on can see that the redirect worked — so the source id has to + // survive the navigation. + connect(ENTRY).deliver({ type: "redirect", gameID: SIBLING }); + expect(latch(ENTRY)).not.toBeNull(); + + connect(SIBLING).deliver({ + type: "lobby_info", + lobby: { gameID: SIBLING, serverTime: 1_700_000_000_000 }, + myClientID: "cl001234", + }); + + expect(latch(ENTRY)).toBeNull(); + expect(sessionStorage.getItem("pool-redirect-from")).toBeNull(); + }); + + it("routes again on a later visit to the entry lobby", () => { + // The ordinary path this protects: redirected to a sibling, found it + // full, came back to the entry point and tried again. + connect(ENTRY).deliver({ type: "redirect", gameID: SIBLING }); + connect(SIBLING).deliver({ type: "error", error: "full-lobby" }); + + mockLocationHref = HOME; + connect(ENTRY).deliver({ type: "redirect", gameID: SIBLING }); + + expect(window.location.href).toBe(`/w0/game/${SIBLING}`); + expect(modalMocks.showInGameConfirm).not.toHaveBeenCalled(); + }); + + it("refuses a second redirect from the same lobby instead of bouncing", () => { + // Members configured with pools that disagree. The first redirect is + // never answered by a lobby, so the latch is still armed. + connect(ENTRY).deliver({ type: "redirect", gameID: SIBLING }); + mockLocationHref = HOME; + connect(ENTRY).deliver({ type: "redirect", gameID: SIBLING }); + + expect(window.location.href).toBe(HOME); + expect(modalMocks.showInGameConfirm).toHaveBeenCalledTimes(1); + expect(modalMocks.showInGameConfirm.mock.calls[0][0]).toContain( + CloseReason.PoolRedirect, + ); + }); +}); diff --git a/tests/server/AdminBotCreateListed.test.ts b/tests/server/AdminBotCreateListed.test.ts index 19d8ab0543..62f87d4645 100644 --- a/tests/server/AdminBotCreateListed.test.ts +++ b/tests/server/AdminBotCreateListed.test.ts @@ -114,3 +114,25 @@ describe("admin bot create_game public listing", () => { expect(res.statusCode).toBe(400); }); }); + +describe("admin bot create_game pool membership", () => { + const pool = (siblings: string[]) => ({ id: "pool-1", siblings }); + + it("refuses a pool that does not contain the lobby being created", () => { + // Otherwise the lobby keeps nobody: every joiner is routed to a sibling. + const { handler, created } = captureCreateHandler({ setListed: vi.fn() }); + const res = mockRes(); + handler({ body: { ...BASE, pool: pool(["bbbb2222", "cccc3333"]) } }, res); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe("pool_missing_own_id"); + expect(created.config).toBeUndefined(); + }); + + it("creates the lobby when the pool contains its minted id", () => { + const { handler, created } = captureCreateHandler({ setListed: vi.fn() }); + const res = mockRes(); + handler({ body: { ...BASE, pool: pool(["aaaaaaaa", "bbbb2222"]) } }, res); + expect(res.statusCode).toBe(200); + expect(created.config?.pool).toEqual(pool(["aaaaaaaa", "bbbb2222"])); + }); +}); diff --git a/tests/server/AdminBotCreatePool.test.ts b/tests/server/AdminBotCreatePool.test.ts new file mode 100644 index 0000000000..024bed5b42 --- /dev/null +++ b/tests/server/AdminBotCreatePool.test.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { registerAdminBotRoutes } from "../../src/server/AdminBotRoutes"; +import { ServerEnv } from "../../src/server/ServerEnv"; + +// Capture the create_pool handler off a fake Express app, the way the other +// admin-bot route tests do. requireAdminBotKey is the preceding middleware and +// is tested separately. +function captureHandler(opts: { taken?: string[] } = {}) { + const routes: Record void> = {}; + const app: any = { + post(path: string, ...handlers: ((req: any, res: any) => void)[]) { + routes[path] = handlers[handlers.length - 1]; + }, + get() {}, + }; + const created: { id: string; config: any; listed: boolean }[] = []; + const taken = new Set(opts.taken ?? []); + const gm: any = { + game: (id: string) => (taken.has(id) ? {} : null), + createGame(id: string, config: any) { + const record = { id, config, listed: false }; + created.push(record); + return { + setListed: (v: boolean) => { + record.listed = v; + }, + setFeatured: vi.fn(), + gameInfo: () => ({ gameID: id }), + }; + }, + }; + const log: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + registerAdminBotRoutes({ app, gm, workerId: 0, log }); + return { handler: routes["/api/adminbot/create_pool"], created }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + body: undefined, + status(code: number) { + res.statusCode = code; + return res; + }, + json(payload: unknown) { + res.body = payload; + return res; + }, + }; + return res; +} + +const BASE = { gameMap: "World", gameMode: "Free For All" }; + +// Ids are minted one at a time; hand out a distinct one per call. +let minted: string[]; + +beforeEach(() => { + minted = ["aaaa1111", "bbbb2222", "cccc3333", "dddd4444"]; + vi.spyOn(ServerEnv, "generateGameIdForWorker").mockImplementation( + () => minted.shift() ?? null, + ); + vi.spyOn(ServerEnv, "workerPath").mockReturnValue("w0"); +}); + +describe("admin bot create_pool", () => { + it("creates every member with one identical pool", () => { + const { handler, created } = captureHandler(); + const res = mockRes(); + handler({ body: { ...BASE, count: 3 } }, res); + + expect(res.statusCode).toBe(200); + expect(created.map((c) => c.id)).toEqual([ + "aaaa1111", + "bbbb2222", + "cccc3333", + ]); + + // One pool object, same id and same sibling order on every member — the + // members have to agree or they disagree about who belongs where. + const pools = created.map((c) => c.config.pool); + for (const pool of pools) { + expect(pool).toEqual(pools[0]); + expect(pool.siblings).toEqual(["aaaa1111", "bbbb2222", "cccc3333"]); + } + // And each member is in its own pool, which is what the create_game guard + // refuses to create by hand. + for (const member of created) { + expect(member.config.pool.siblings).toContain(member.id); + } + expect(res.body.poolId).toBe(pools[0].id); + expect(res.body.lobbies.map((l: any) => l.gameID)).toEqual([ + "aaaa1111", + "bbbb2222", + "cccc3333", + ]); + }); + + it("lists only the entry lobby", () => { + const { handler, created } = captureHandler(); + handler({ body: { ...BASE, count: 3, listed: true } }, mockRes()); + expect(created.map((c) => c.listed)).toEqual([true, false, false]); + }); + + it("refuses a count above the cap", () => { + const { handler, created } = captureHandler(); + const res = mockRes(); + handler({ body: { ...BASE, count: 9 } }, res); + expect(res.statusCode).toBe(400); + expect(created).toEqual([]); + }); + + it("refuses a pool of one, which would never route anyone", () => { + const { handler, created } = captureHandler(); + const res = mockRes(); + handler({ body: { ...BASE, count: 1 } }, res); + expect(res.statusCode).toBe(400); + expect(created).toEqual([]); + }); + + it("creates nothing when an id cannot be allocated", () => { + // Third mint collides with a live game: the whole request fails before any + // lobby exists, rather than leaving members naming a sibling that does not. + const { handler, created } = captureHandler({ taken: ["cccc3333"] }); + const res = mockRes(); + handler({ body: { ...BASE, count: 3 } }, res); + + expect(res.statusCode).toBe(500); + expect(created).toEqual([]); + }); + + it("refuses a caller-supplied pool", () => { + const { handler, created } = captureHandler(); + const res = mockRes(); + handler( + { + body: { + ...BASE, + count: 2, + pool: { id: "mine", siblings: ["aaaa1111", "bbbb2222"] }, + }, + }, + res, + ); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe("pool_is_generated"); + expect(created).toEqual([]); + }); + + it("refuses team pins, which name one lobby", () => { + const { handler, created } = captureHandler(); + const res = mockRes(); + handler( + { + body: { + ...BASE, + gameMode: "Team", + playerTeams: 2, + count: 2, + teams: [], + }, + }, + res, + ); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe("teams_unsupported_for_pool"); + expect(created).toEqual([]); + }); +}); diff --git a/tests/server/ConfigPatch.test.ts b/tests/server/ConfigPatch.test.ts index 6f73793c80..0669417c78 100644 --- a/tests/server/ConfigPatch.test.ts +++ b/tests/server/ConfigPatch.test.ts @@ -103,6 +103,16 @@ describe("applyGameConfigPatch", () => { expect(target.maxPlayers).toBe(4); }); + it("ignores pool, which every member of a pool has to agree on", () => { + // A patch reaches exactly one GameServer, so a copied pool could only + // describe a group that disagrees with itself about who goes where. + const target = testGameConfig(); + applyGameConfigPatch(target, { + pool: { id: "pool-1", siblings: ["aaaa1111", "bbbb2222"] }, + }); + expect(target.pool).toBeUndefined(); + }); + it("ignores keys that are not part of GameConfig at all", () => { // e.g. the listing flag, which lives on the GameServer, not the config. const target = testGameConfig(); diff --git a/tests/server/HostedLobbyListing.test.ts b/tests/server/HostedLobbyListing.test.ts index 9b238c26a4..c40f8094c3 100644 --- a/tests/server/HostedLobbyListing.test.ts +++ b/tests/server/HostedLobbyListing.test.ts @@ -758,6 +758,7 @@ describe("WorkerLobbyService hosted lobbies", () => { nameReveals: ["c1"], nameRevealPublicIds: ["p2"], hostCheats: { infiniteGold: true }, + pool: { id: "pool-1", siblings: ["aaaa1111", "bbbb2222"] }, }); game.setListed(true); gm.listedLobbies.mockReturnValue([game]); @@ -775,6 +776,8 @@ describe("WorkerLobbyService hosted lobbies", () => { expect(reported.gameConfig.nameReveals).toBeUndefined(); expect(reported.gameConfig.nameRevealPublicIds).toBeUndefined(); expect(reported.gameConfig.hostCheats).toBeUndefined(); + // A listed pool advertises its entry point, not the sibling ids. + expect(reported.gameConfig.pool).toBeUndefined(); }); it("excludes matchmaking games (Public but no publicGameType) from the report", () => { diff --git a/tests/server/MatchTelemetryIntegration.test.ts b/tests/server/MatchTelemetryIntegration.test.ts index 9b686788cd..755344b48f 100644 --- a/tests/server/MatchTelemetryIntegration.test.ts +++ b/tests/server/MatchTelemetryIntegration.test.ts @@ -315,6 +315,26 @@ describe("GameServer match telemetry", () => { }); }); + it("keeps pool sibling ids out of match_opened", () => { + // Telemetry leaves the box, and sibling ids are join secrets. + const manager = new GameManager(log, telemetry, "build-hash"); + manager.createGame( + "poolMatch", + testGameConfig({ + gameType: GameType.Private, + bots: 7, + pool: { id: "pool-1", siblings: ["poolMatch", "bbbb2222"] }, + }), + ); + const opened = telemetry.events.find( + (event) => event.type === "match_opened", + ); + const config = (opened?.payload as { config: Record }) + .config; + expect(config).not.toHaveProperty("pool"); + expect(config.bots).toBe(7); + }); + it("GameManager forwards the worker emitter and build hash to each game", () => { const manager = new GameManager(log, telemetry, "build-hash"); const game = manager.createGame( diff --git a/tests/server/PoolJoin.test.ts b/tests/server/PoolJoin.test.ts new file mode 100644 index 0000000000..682f592550 --- /dev/null +++ b/tests/server/PoolJoin.test.ts @@ -0,0 +1,321 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GameType } from "../../src/core/game/Game"; +import { + GameConfig, + GameConfigSchema, + PoolConfig, + PoolConfigSchema, +} from "../../src/core/Schemas"; +import { CreateGameInputSchema } from "../../src/core/WorkerSchemas"; +import { poolIndexFor } from "../../src/server/PoolRouting"; +import { + cid, + makeClient as harnessClient, + makeGame as harnessGame, + makeMockWs, + mockWsOf, +} from "../util/GameServerHarness"; +import { testGameConfig } from "../util/Wire"; + +const C1 = cid("c1"); +const C2 = cid("c2"); +const C3 = cid("c3"); +const C4 = cid("c4"); + +const POOL_ID = "pool-1"; +// The game under test is the harness default id, and it is the entry point: +// a member recognises itself by its own id, so it has to be in this list. +const HERE = cid("game"); +const SIBLINGS = [HERE, "bbbb2222", "cccc3333", "dddd4444"]; + +// A publicId the pool assigns to `index`, found by search rather than +// hardcoded, so these tests say what they mean instead of depending on what +// simpleHash happens to produce for a particular string. +function publicIdFor(index: number): string { + for (let i = 0; i < 1000; i++) { + const publicId = `pub-${i}`; + if (poolIndexFor(`${POOL_ID}:${publicId}`, SIBLINGS.length) === index) { + return publicId; + } + } + throw new Error(`no publicId found for pool index ${index}`); +} + +const STAYS = publicIdFor(0); +const MOVES = publicIdFor(2); + +function makeClient( + clientID: string, + persistentID: string, + publicId: string | undefined, + opts: { role?: string | null; spectator?: boolean } = {}, +) { + return harnessClient({ + clientID, + persistentID, + publicId, + role: opts.role ?? null, + spectator: opts.spectator ?? false, + username: "TestUser", + }); +} + +const POOL: PoolConfig = { id: POOL_ID, siblings: SIBLINGS }; + +// Member 0 of the pool by default; `extra` layers on the other join gates. +function makeGame( + extra: Partial = {}, + pool: PoolConfig | null = POOL, +) { + return harnessGame({ + config: { + gameType: GameType.Private, + ...(pool === null ? {} : { pool }), + ...extra, + }, + }); +} + +const redirectsOf = (client: ReturnType) => + mockWsOf(client) + .sent() + .filter((m) => m.type === "redirect"); + +describe("GameServer - pool routing (GameConfig.pool)", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("admits a joiner this member is assigned", () => { + const game = makeGame(); + const client = makeClient(C1, "p1", STAYS); + expect(game.joinClient(client)).toBe("joined"); + expect(redirectsOf(client)).toEqual([]); + }); + + it("redirects a joiner assigned elsewhere to their own member", () => { + const game = makeGame(); + const client = makeClient(C1, "p1", MOVES); + expect(game.joinClient(client)).toBe("redirected"); + expect(redirectsOf(client)).toEqual([ + { type: "redirect", gameID: SIBLINGS[2] }, + ]); + }); + + it("gives a redirected joiner no seat here", () => { + const game = makeGame(); + expect(game.joinClient(makeClient(C1, "p1", MOVES))).toBe("redirected"); + expect(game.numClients()).toBe(0); + // And no reconnect mapping was left behind for them either. + expect(game.rejoinClient(makeMockWs() as any, "p1")).toBe(false); + }); + + it("sends the same joiner to the same member every time", () => { + // What makes leaving and coming back land you back where you were. + const game = makeGame(); + for (const clientID of [C1, C2, C3]) { + const client = makeClient(clientID, "p1", MOVES); + expect(game.joinClient(client)).toBe("redirected"); + expect(redirectsOf(client)).toEqual([ + { type: "redirect", gameID: SIBLINGS[2] }, + ]); + } + }); + + it("routes a joiner with no publicId by their persistentID", () => { + // Anonymous players are pooled like everyone else. Nothing is carved out + // for them, so both answers have to turn up across a spread of them. + const results = new Set( + Array.from({ length: 40 }, (_, i) => + makeGame().joinClient(makeClient(C1, `anon-${i}`, undefined)), + ), + ); + expect(results).toEqual(new Set(["joined", "redirected"])); + + // And the same persistentID gets the same answer every time. + const first = makeGame().joinClient(makeClient(C1, "anon-7", undefined)); + expect(makeGame().joinClient(makeClient(C2, "anon-7", undefined))).toBe( + first, + ); + }); + + it("does not route anyone when the lobby has no pool", () => { + const game = makeGame({}, null); + const client = makeClient(C1, "p1", MOVES); + expect(game.joinClient(client)).toBe("joined"); + expect(redirectsOf(client)).toEqual([]); + }); + + it("lets spectators watch the member they asked for", () => { + const game = makeGame(); + const client = makeClient(C1, "p1", MOVES, { spectator: true }); + expect(game.joinClient(client)).toBe("joined"); + expect(redirectsOf(client)).toEqual([]); + }); + + it("lets admins and root into any member", () => { + const game = makeGame(); + expect( + game.joinClient(makeClient(C1, "p1", MOVES, { role: "admin" })), + ).toBe("joined"); + expect(game.joinClient(makeClient(C2, "p2", MOVES, { role: "root" }))).toBe( + "joined", + ); + }); + + it("does not let mod or unknown roles bypass the pool", () => { + const game = makeGame(); + expect(game.joinClient(makeClient(C1, "p1", MOVES, { role: "mod" }))).toBe( + "redirected", + ); + expect( + game.joinClient(makeClient(C2, "p2", MOVES, { role: "flagged" })), + ).toBe("redirected"); + }); + + it("keeps an explicitly allowlisted publicId on this member", () => { + // Naming someone is the statement that the hash does not get a vote: + // this is how a caller pins a player to a chosen member. + const game = makeGame({ allowedPublicIds: [MOVES] }); + const client = makeClient(C1, "p1", MOVES); + expect(game.joinClient(client)).toBe("joined"); + expect(redirectsOf(client)).toEqual([]); + }); + + it("refuses a client the allowlist excludes before routing them", () => { + // Order matters: someone who may not be in this group of lobbies at all + // must hear that, not be sent on to a sibling that will refuse them too. + const game = makeGame({ allowedPublicIds: [STAYS] }); + const client = makeClient(C1, "p1", MOVES); + expect(game.joinClient(client)).toBe("not_allowlisted"); + expect(redirectsOf(client)).toEqual([]); + }); + + it("refuses an untrusted client before routing them", () => { + const game = makeGame({ trusted: true }); + const client = makeClient(C1, "p1", MOVES); + expect(game.joinClient(client)).toBe("not_trusted"); + expect(redirectsOf(client)).toEqual([]); + }); + + it("keeps a kicked client kicked rather than routing them", () => { + const game = makeGame(); + const client = makeClient(C1, "p1", STAYS); + expect(game.joinClient(client)).toBe("joined"); + game.kickClient(C1); + expect(game.joinClient(makeClient(C4, "p1", STAYS))).toBe("kicked"); + }); + + it("refuses a spectator a seat on a member that is not theirs", async () => { + // Spectators pick their own member, so the seat toggle is where the pool + // gets its say — otherwise joining with spectator: true and flipping is a + // way past routing altogether. + const game = makeGame(); + const caster = makeClient(C1, "p1", MOVES, { spectator: true }); + expect(game.joinClient(caster)).toBe("joined"); + + await mockWsOf(caster).emit({ type: "spectate", spectator: false }); + expect(caster.spectator).toBe(true); + }); + + it("lets a spectator take a seat on the member that is theirs", async () => { + const game = makeGame(); + const caster = makeClient(C2, "p2", STAYS, { spectator: true }); + expect(game.joinClient(caster)).toBe("joined"); + + await mockWsOf(caster).emit({ type: "spectate", spectator: false }); + expect(caster.spectator).toBe(false); + }); + + it("does not route a player who is already in this game", () => { + // A mid-game drop reconnects as a fresh join, so routing has to know that + // this player is here already — structurally, not by trusting that the + // hash still says the same thing. Here the reason they were admitted (the + // allowlist) is gone by the time they come back. + const game = makeGame({ allowedPublicIds: [MOVES] }); + const client = makeClient(C1, "p1", MOVES); + expect(game.joinClient(client)).toBe("joined"); + game.updateGameConfig({ allowedPublicIds: [] }); + game.start(); + + const back = makeClient(C4, "p1", MOVES); + expect(game.joinClient(back)).toBe("joined"); + expect(redirectsOf(back)).toEqual([]); + }); + + it("does not re-route a seated client that reconnects", () => { + const game = makeGame(); + const client = makeClient(C1, "p1", STAYS); + expect(game.joinClient(client)).toBe("joined"); + + const newWs = makeMockWs(); + expect(game.rejoinClient(newWs as any, "p1")).toBe(true); + expect(newWs.sent().filter((m) => m.type === "redirect")).toEqual([]); + }); + + it("keeps the pool out of gameInfo, without touching the stored config", () => { + // gameInfo goes out over the unauthenticated /api/game/:id route and the + // lobby_info broadcast every connected client gets, so the sibling ids + // would otherwise be readable by anyone who can reach the entry lobby. + const game = makeGame(); + expect(game.gameInfo().gameConfig?.pool).toBeUndefined(); + expect(game.gameConfig.pool).toEqual(POOL); + }); + + it("keeps the pool out of the start info (wire + archived record)", () => { + // Sibling ids are private lobby ids, which are join secrets. + const game = makeGame(); + const client = makeClient(C1, "p1", STAYS); + expect(game.joinClient(client)).toBe("joined"); + game.start(); + + const start = mockWsOf(client) + .sent() + .find((m) => m.type === "start"); + expect(start).toBeDefined(); + const config = start!.type === "start" ? start!.gameStartInfo.config : null; + expect(config?.pool).toBeUndefined(); + // The server still routes from its own config. + expect(game.gameConfig.pool?.siblings).toEqual(SIBLINGS); + }); +}); + +describe("PoolConfigSchema", () => { + it("rejects duplicate sibling ids", () => { + // A repeated id would hold more than one routing slot. + const parsed = PoolConfigSchema.safeParse({ + id: POOL_ID, + siblings: ["aaaa1111", "bbbb2222", "aaaa1111"], + }); + expect(parsed.success).toBe(false); + }); + + it("keeps the sibling list in the order given", () => { + // Order is part of the assignment, so a duplicate is refused rather than + // quietly sorted or deduped into a different mapping. + const siblings = ["cccc3333", "aaaa1111", "bbbb2222"]; + const parsed = PoolConfigSchema.parse({ id: POOL_ID, siblings }); + expect(parsed.siblings).toEqual(siblings); + }); +}); + +describe("pool is admin-bot-only", () => { + it("drops a pool from the public create_game input", () => { + const parsed = CreateGameInputSchema.parse({ + ...testGameConfig(), + pool: POOL, + }); + expect(parsed !== undefined && "pool" in parsed).toBe(false); + }); + + it("keeps a pool on the admin-bot input", () => { + const parsed = GameConfigSchema.partial().parse({ pool: POOL }); + expect(parsed.pool).toEqual(POOL); + }); +}); diff --git a/tests/server/PoolRouting.test.ts b/tests/server/PoolRouting.test.ts new file mode 100644 index 0000000000..c11a21a120 --- /dev/null +++ b/tests/server/PoolRouting.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { PoolConfig } from "../../src/core/Schemas"; +import { poolIndexFor, poolTargetFor } from "../../src/server/PoolRouting"; + +const SIBLINGS = ["aaaa1111", "bbbb2222", "cccc3333", "dddd4444"]; + +// Every member of a pool holds this same object; they differ only in which id +// each one answers to. +const POOL: PoolConfig = { id: "pool-1", siblings: SIBLINGS }; + +describe("poolIndexFor", () => { + it("stays inside the pool for any key", () => { + for (let i = 0; i < 200; i++) { + const index = poolIndexFor(`key-${i}`, 4); + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(4); + } + }); + + it("gives the same key the same answer every time", () => { + expect(poolIndexFor("player-abc", 7)).toBe(poolIndexFor("player-abc", 7)); + }); + + it("puts everyone in the only member of a pool of one", () => { + for (const key of ["a", "player-abc", ""]) { + expect(poolIndexFor(key, 1)).toBe(0); + } + }); + + it("spreads keys across every member", () => { + const hits = new Set(); + for (let i = 0; i < 200; i++) { + hits.add(poolIndexFor(`player-${i}`, 4)); + } + expect([...hits].sort()).toEqual([0, 1, 2, 3]); + }); +}); + +describe("poolTargetFor", () => { + it("agrees with poolIndexFor about where a key belongs", () => { + const key = "player-abc"; + const index = poolIndexFor(`pool-1:${key}`, SIBLINGS.length); + // Asked from every member in turn, the answer is that member's id — + // except from the one it belongs to, which says "stay". + SIBLINGS.forEach((selfId, i) => { + const target = poolTargetFor(POOL, key, selfId); + expect(target).toBe(i === index ? null : SIBLINGS[index]); + }); + }); + + it("returns null — stay here — exactly once per key", () => { + for (let i = 0; i < 50; i++) { + const stays = SIBLINGS.map((selfId) => + poolTargetFor(POOL, `player-${i}`, selfId), + ).filter((target) => target === null); + expect(stays).toHaveLength(1); + } + }); + + it("sends a key to the same member however it arrives", () => { + // This is what makes a rejoin land back where the player already was: the + // assignment is read off the key, not off which member was asked. + const key = "player-abc"; + const answers = SIBLINGS.map( + (selfId) => poolTargetFor(POOL, key, selfId) ?? selfId, + ); + expect(new Set(answers).size).toBe(1); + }); + + it("decorrelates pools of the same size through the pool id", () => { + // Without the id in the key, every pool of size 4 would send the same + // people to slot 0. + const other: PoolConfig = { id: "pool-2", siblings: SIBLINGS }; + const moved = Array.from({ length: 50 }, (_, i) => `player-${i}`).filter( + (key) => + poolTargetFor(POOL, key, SIBLINGS[0]) !== + poolTargetFor(other, key, SIBLINGS[0]), + ); + expect(moved.length).toBeGreaterThan(0); + }); + + it("keeps everyone put in a pool of one", () => { + const solo: PoolConfig = { id: "pool-1", siblings: ["aaaa1111"] }; + expect(poolTargetFor(solo, "player-abc", "aaaa1111")).toBeNull(); + }); + + it("keeps everyone put when the pool has no members", () => { + const empty: PoolConfig = { id: "pool-1", siblings: [] }; + expect(poolTargetFor(empty, "player-abc", "aaaa1111")).toBeNull(); + }); + + it("stays put when the pool lists this lobby twice", () => { + // The answer is compared against this lobby's own id, so a duplicated + // entry cannot send a player to the lobby they are already talking to. + const dup: PoolConfig = { + id: "pool-1", + siblings: ["aaaa1111", "aaaa1111"], + }; + for (let i = 0; i < 20; i++) { + expect(poolTargetFor(dup, `player-${i}`, "aaaa1111")).toBeNull(); + } + }); + + it("sends everyone away from a lobby that is not in its own pool", () => { + // The one misconfiguration left, and it is self-announcing: a member that + // does not list itself keeps nobody. + for (let i = 0; i < 20; i++) { + expect(poolTargetFor(POOL, `player-${i}`, "eeee5555")).not.toBeNull(); + } + }); +});