diff --git a/src/server/IPCBridgeSchema.ts b/src/server/IPCBridgeSchema.ts index 2de366122..9e31c600b 100644 --- a/src/server/IPCBridgeSchema.ts +++ b/src/server/IPCBridgeSchema.ts @@ -33,10 +33,14 @@ export const InternalPublicGamesSchema = z.object({ // --- Worker Messages --- -// Worker tells the master about its lobbies. +// Worker tells the master about its lobbies. Entries are deliberately not +// validated here: the master checks each against InternalGameInfoSchema and +// drops bad ones (MasterLobbyService.validLobbies), so a single malformed +// lobby can't invalidate the whole report and freeze the master's view of +// this worker's lobbies. const WorkerLobbyListSchema = z.object({ type: z.literal("lobbyList"), - lobbies: z.array(InternalGameInfoSchema), + lobbies: z.array(z.unknown()), }); const WorkerReadySchema = z.object({ diff --git a/src/server/MasterLobbyService.ts b/src/server/MasterLobbyService.ts index 035a537f5..17ccaace6 100644 --- a/src/server/MasterLobbyService.ts +++ b/src/server/MasterLobbyService.ts @@ -8,6 +8,7 @@ import { import { generateID } from "../core/Util"; import { InternalGameInfo, + InternalGameInfoSchema, MasterCreateGame, MasterLobbiesBroadcast, MasterUpdateGame, @@ -57,12 +58,30 @@ export class MasterLobbyService { this.handleWorkerReady(msg.workerId); break; case "lobbyList": - this.workerLobbies.set(workerId, msg.lobbies); + this.workerLobbies.set(workerId, this.validLobbies(msg.lobbies)); break; } }); } + // Lobby entries are validated individually so one malformed entry only + // drops itself. Rejecting the whole report would freeze this worker's + // lobbies in the master's view for as long as the bad entry exists — + // stale broadcasts to every client, countdown resets, and duplicate + // scheduling. + private validLobbies(lobbies: unknown[]): InternalGameInfo[] { + const valid: InternalGameInfo[] = []; + for (const lobby of lobbies) { + const result = InternalGameInfoSchema.safeParse(lobby); + if (result.success) { + valid.push(result.data); + } else { + this.log.error("Dropping invalid lobby in worker report:", lobby); + } + } + return valid; + } + removeWorker(workerId: number) { this.workers.delete(workerId); this.workerLobbies.delete(workerId); diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index 24694f9dd..01e34572d 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -131,9 +131,13 @@ export class WorkerLobbyService { } private sendMyLobbiesToMaster() { + // Matchmaking games have a Public gameType (so they appear in + // publicLobbies()) but no publicGameType: they are invite-only and must + // never be advertised, and the master rejects entries without one. const publicLobbies = this.gm .publicLobbies() .map((g) => g.gameInfo()) + .filter((gi) => gi.publicGameType !== undefined) .map((gi) => { return { gameID: gi.gameID, diff --git a/tests/server/HostedLobbyListing.test.ts b/tests/server/HostedLobbyListing.test.ts index 1f8ce75ae..804337440 100644 --- a/tests/server/HostedLobbyListing.test.ts +++ b/tests/server/HostedLobbyListing.test.ts @@ -522,6 +522,27 @@ describe("MasterLobbyService hosted lobbies", () => { ]); }); + it("keeps the valid lobbies when a report contains a malformed entry", () => { + const { service, workers } = createService(); + workers[0].emit("message", { + type: "lobbyList", + lobbies: [ + // Missing publicGameType, like a matchmaking game reported by + // mistake. It must be dropped without discarding the whole report. + { gameID: "bad", numClients: 0 }, + hostedLobby("ok", "creator-a"), + ], + }); + + (service as any).broadcastLobbies(); + + const broadcast = sentMessages(workers[0]).find( + (m) => m.type === "lobbiesBroadcast", + ); + const hosted = broadcast.publicGames.games.hosted; + expect(hosted.map((l: InternalGameInfo) => l.gameID)).toEqual(["ok"]); + }); + it("delists a dedup loser only after two consecutive losing broadcasts", () => { const { service, workers } = createService(); workers[0].emit("message", { @@ -695,6 +716,35 @@ describe("WorkerLobbyService hosted lobbies", () => { expect(reported.gameConfig.hostCheats).toBeUndefined(); }); + it("excludes matchmaking games (Public but no publicGameType) from the report", () => { + const ranked = new GameServer( + "ranked-g1", + mockLogger, + Date.now(), + { gameType: GameType.Public, allowedPublicIds: ["p1", "p2"] } as any, + undefined, + Date.now() + 7000, + undefined, // matchmaking games are created without a publicGameType + ); + const ffa = new GameServer( + "ffa-g1", + mockLogger, + Date.now(), + { gameType: GameType.Public } as any, + undefined, + undefined, + "ffa", + ); + gm.publicLobbies.mockReturnValue([ranked, ffa]); + + emitBroadcast({ ffa: [], team: [], special: [], hosted: [] }); + + const lobbyList = sendToMaster.mock.calls + .map((c: any[]) => c[0]) + .find((m: any) => m.type === "lobbyList"); + expect(lobbyList.lobbies.map((l: any) => l.gameID)).toEqual(["ffa-g1"]); + }); + it("strips creatorID from broadcasts and primed snapshots sent to clients", () => { const ws = connectClient(); emitBroadcast({