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(); + } + }); +});