From 5ba57210b9cbe6753f0ba416d9f3838f6d05a298 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 13 Oct 2026 15:31:03 -0400 Subject: [PATCH] [match] restrict matchmaking to event rooms only --- apps/match/src/match.app.ts | 34 ++++++++++--- apps/match/src/test/integration/api.test.ts | 54 ++++++++++++++++++--- packages/domain/src/room-instance-db.ts | 13 +++-- 3 files changed, 85 insertions(+), 16 deletions(-) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 06dbc2a..1579b67 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -1070,7 +1070,8 @@ function roomInstanceFromRoom( isPrivate: boolean, instanceId: number, photonRoomId: string, - subRoomId?: number + subRoomId?: number, + eventId = 0 ) { const f = instanceFieldsFromRoom(room, subRoomId) // The same region the connection info names its Photon apps for — see photonApps. @@ -1082,7 +1083,7 @@ function roomInstanceFromRoom( roomInstanceType: f.roomInstanceType, location: f.location, dataBlob: f.dataBlob, - eventId: 0, + eventId, clubId: 0, roomCode: '', photonRegion: region, @@ -1518,7 +1519,8 @@ async function resolveRoomInstance( isPrivate: boolean, ownerId: number, requestedSubRoomId?: number, - gateOnAccessibility = false + gateOnAccessibility = false, + eventId = 0 ): Promise { const id = Number.parseInt(roomKey, 10) const requested = Number.isNaN(id) @@ -1606,6 +1608,10 @@ async function resolveRoomInstance( // reuses a public instance; an unpublished one reuses its single private session. // A private matchmake into a published room always gets a fresh instance. Create // one when there's nothing to join. + // + // An event's sessions are its own: an event matchmake reuses only an instance stamped + // with that event (and stamps the one it creates), and a room matchmake — `eventId` 0 + // — never reuses an event's. let instance = unpublished ? await getSharedPrivateInstance( c.env.DB, @@ -1616,7 +1622,14 @@ async function resolveRoomInstance( ) : privateInstance ? null - : await getJoinableInstance(c.env.DB, f.roomId, gameVersion, f.subRoomId, currentInstanceId) + : await getJoinableInstance( + c.env.DB, + f.roomId, + gameVersion, + f.subRoomId, + currentInstanceId, + eventId + ) if (!instance) { instance = await createRoomInstance(c.env.DB, { ownerAccountId: ownerId, @@ -1630,6 +1643,7 @@ async function resolveRoomInstance( isPrivate: privateInstance || f.isDorm, roomInstanceType: f.roomInstanceType, gameVersion, + eventId, }) } return { @@ -1639,7 +1653,8 @@ async function resolveRoomInstance( privateInstance, instance.roomInstanceId, instance.photonRoomId, - f.subRoomId + f.subRoomId, + instance.eventId ), errorCode: MatchmakingErrorCode.Success, } @@ -2428,12 +2443,16 @@ const app = new Hono() } const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c) + // The event id scopes the session: the caller joins an instance already stamped + // with this event, or opens one that is — never the room's ordinary instances. const { instance, errorCode } = await resolveRoomInstance( c, String(event.RoomId), joinMode === 2, id, - event.SubRoomId ?? undefined + event.SubRoomId ?? undefined, + false, + eventId ) if (!instance) return matchmakeResult(c, errorCode, null) await enterRoom(c, id, instance) @@ -2961,7 +2980,8 @@ const app = new Hono() stored.isPrivate, stored.roomInstanceId, stored.photonRoomId, - stored.subRoomId + stored.subRoomId, + stored.eventId ) await enterRoom(c, id, instance) return matchmakeResult(c, 0, instance) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 13fd08a..e603b31 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -1071,28 +1071,70 @@ describe('public endpoints', () => { }) type Body = { errorCode: number - roomInstance: { roomId: number; location: string; roomInstanceId: number } | null + roomInstance: { + roomId: number + location: string + roomInstanceId: number + eventId: number + } | null } const join = async (path: string, sub?: string) => (await (await matchmake(path, sub)).json()) as Body - // An invited player lands in an instance of the event's room (2)... + // An invited player lands in an instance of the event's room (2), which names the + // event it is for... const invited = await join('/matchmake/event/8', '301') expect(invited.errorCode).toBe(0) - expect(invited.roomInstance).toMatchObject({ roomId: 2, location: RECCENTER_SCENE }) + expect(invited.roomInstance).toMatchObject({ + roomId: 2, + location: RECCENTER_SCENE, + eventId: 8, + }) // ...recorded as their presence, like any other matchmake. const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1') .bind(301) .first<{ data: string }>() - const presence = JSON.parse(row!.data) as { roomInstance: { roomInstanceId: number } } + const presence = JSON.parse(row!.data) as { + roomInstance: { roomInstanceId: number; eventId: number } + } expect(presence.roomInstance.roomInstanceId).toBe(invited.roomInstance!.roomInstanceId) + expect(presence.roomInstance.eventId).toBe(8) + + // The instance itself is stamped, which is what the next guest's search matches on. + const stored = await env.DB.prepare('SELECT event_id FROM room_instance WHERE id = ?1') + .bind(invited.roomInstance!.roomInstanceId) + .first<{ event_id: number }>() + expect(stored!.event_id).toBe(8) // The creator gets in, and so does someone who was invited and DECLINED — the row - // is the invite, whatever the answer. - expect((await join('/matchmake/event/8', '300')).errorCode).toBe(0) + // is the invite, whatever the answer. They join the SAME session: the event's. + const creator = await join('/matchmake/event/8', '300') + expect(creator.errorCode).toBe(0) + expect(creator.roomInstance).toMatchObject({ + roomInstanceId: invited.roomInstance!.roomInstanceId, + eventId: 8, + }) expect((await join('/matchmake/event/8', '302')).errorCode).toBe(0) + // A plain matchmake into the same room stays out of the event's session, and + // another event in that room gets a session of its own. + const plain = await join('/matchmake/room/2', '399') + expect(plain.errorCode).toBe(0) + expect(plain.roomInstance!.eventId).toBe(0) + expect(plain.roomInstance!.roomInstanceId).not.toBe(invited.roomInstance!.roomInstanceId) + const other = await join('/matchmake/event/9', '303') + expect(other.roomInstance!.eventId).toBe(9) + expect(other.roomInstance!.roomInstanceId).not.toBe(invited.roomInstance!.roomInstanceId) + + // Coming back to the event from elsewhere lands in the event's session again, not + // the room's ordinary one. + expect((await join('/matchmake/room/2', '300')).roomInstance!.eventId).toBe(0) + expect((await join('/matchmake/event/8', '300')).roomInstance).toMatchObject({ + roomInstanceId: invited.roomInstance!.roomInstanceId, + eventId: 8, + }) + // A stranger doesn't — and is told why (35 EventIsPrivate), not fobbed off with 20. expect(await join('/matchmake/event/8', '399')).toEqual(refused(35)) diff --git a/packages/domain/src/room-instance-db.ts b/packages/domain/src/room-instance-db.ts index c54241d..325846d 100644 --- a/packages/domain/src/room-instance-db.ts +++ b/packages/domain/src/room-instance-db.ts @@ -504,15 +504,22 @@ export async function deleteEmptyRoomInstances( * * `gameVersion` is matched as a BUILD ({@link gameBuild}): `20250718.01` and `20250718.2` * both find the `20250718` session, which is what the rows store. + * + * `eventId` scopes the search to one player event's sessions, and the default 0 to the + * room's ordinary ones — the two never mix. An event matchmake only reuses an instance + * stamped with that event, so its guests gather in the event's session rather than + * scattering into whatever public instance the room has open; and a plain room matchmake + * never lands in an event's session, which for a private event is its guest list's alone. */ export async function getJoinableInstance( db: D1Database, roomId: number, gameVersion: string, subRoomId?: number, - excludeInstanceId?: number + excludeInstanceId?: number, + eventId = 0 ): Promise { - const binds: Array = [roomId, gameBuild(gameVersion) ?? ''] + const binds: Array = [roomId, gameBuild(gameVersion) ?? '', eventId] const filters: string[] = [] if (subRoomId !== undefined) { binds.push(subRoomId) @@ -525,7 +532,7 @@ export async function getJoinableInstance( const row = await db .prepare( `SELECT data FROM room_instance - WHERE room_id = ?1 AND game_version = ?2 + WHERE room_id = ?1 AND game_version = ?2 AND COALESCE(event_id, 0) = ?3 AND is_private = 0 AND is_full = 0 AND join_disabled = 0 AND is_in_progress = 0 AND matchmaking_policy != ${MatchmakingPolicy.Ignore} ${filters.join(' ')}