[match] restrict matchmaking to event rooms only

This commit is contained in:
Devin Zuczek
2026-10-13 15:31:03 -04:00
parent ba6c703050
commit 5ba57210b9
3 changed files with 85 additions and 16 deletions
+27 -7
View File
@@ -1070,7 +1070,8 @@ function roomInstanceFromRoom(
isPrivate: boolean, isPrivate: boolean,
instanceId: number, instanceId: number,
photonRoomId: string, photonRoomId: string,
subRoomId?: number subRoomId?: number,
eventId = 0
) { ) {
const f = instanceFieldsFromRoom(room, subRoomId) const f = instanceFieldsFromRoom(room, subRoomId)
// The same region the connection info names its Photon apps for — see photonApps. // The same region the connection info names its Photon apps for — see photonApps.
@@ -1082,7 +1083,7 @@ function roomInstanceFromRoom(
roomInstanceType: f.roomInstanceType, roomInstanceType: f.roomInstanceType,
location: f.location, location: f.location,
dataBlob: f.dataBlob, dataBlob: f.dataBlob,
eventId: 0, eventId,
clubId: 0, clubId: 0,
roomCode: '', roomCode: '',
photonRegion: region, photonRegion: region,
@@ -1518,7 +1519,8 @@ async function resolveRoomInstance(
isPrivate: boolean, isPrivate: boolean,
ownerId: number, ownerId: number,
requestedSubRoomId?: number, requestedSubRoomId?: number,
gateOnAccessibility = false gateOnAccessibility = false,
eventId = 0
): Promise<ResolvedInstance> { ): Promise<ResolvedInstance> {
const id = Number.parseInt(roomKey, 10) const id = Number.parseInt(roomKey, 10)
const requested = Number.isNaN(id) const requested = Number.isNaN(id)
@@ -1606,6 +1608,10 @@ async function resolveRoomInstance(
// reuses a public instance; an unpublished one reuses its single private session. // 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 // A private matchmake into a published room always gets a fresh instance. Create
// one when there's nothing to join. // 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 let instance = unpublished
? await getSharedPrivateInstance( ? await getSharedPrivateInstance(
c.env.DB, c.env.DB,
@@ -1616,7 +1622,14 @@ async function resolveRoomInstance(
) )
: privateInstance : privateInstance
? null ? 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) { if (!instance) {
instance = await createRoomInstance(c.env.DB, { instance = await createRoomInstance(c.env.DB, {
ownerAccountId: ownerId, ownerAccountId: ownerId,
@@ -1630,6 +1643,7 @@ async function resolveRoomInstance(
isPrivate: privateInstance || f.isDorm, isPrivate: privateInstance || f.isDorm,
roomInstanceType: f.roomInstanceType, roomInstanceType: f.roomInstanceType,
gameVersion, gameVersion,
eventId,
}) })
} }
return { return {
@@ -1639,7 +1653,8 @@ async function resolveRoomInstance(
privateInstance, privateInstance,
instance.roomInstanceId, instance.roomInstanceId,
instance.photonRoomId, instance.photonRoomId,
f.subRoomId f.subRoomId,
instance.eventId
), ),
errorCode: MatchmakingErrorCode.Success, errorCode: MatchmakingErrorCode.Success,
} }
@@ -2428,12 +2443,16 @@ const app = new Hono<App>()
} }
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c) 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( const { instance, errorCode } = await resolveRoomInstance(
c, c,
String(event.RoomId), String(event.RoomId),
joinMode === 2, joinMode === 2,
id, id,
event.SubRoomId ?? undefined event.SubRoomId ?? undefined,
false,
eventId
) )
if (!instance) return matchmakeResult(c, errorCode, null) if (!instance) return matchmakeResult(c, errorCode, null)
await enterRoom(c, id, instance) await enterRoom(c, id, instance)
@@ -2961,7 +2980,8 @@ const app = new Hono<App>()
stored.isPrivate, stored.isPrivate,
stored.roomInstanceId, stored.roomInstanceId,
stored.photonRoomId, stored.photonRoomId,
stored.subRoomId stored.subRoomId,
stored.eventId
) )
await enterRoom(c, id, instance) await enterRoom(c, id, instance)
return matchmakeResult(c, 0, instance) return matchmakeResult(c, 0, instance)
+48 -6
View File
@@ -1071,28 +1071,70 @@ describe('public endpoints', () => {
}) })
type Body = { type Body = {
errorCode: number 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) => const join = async (path: string, sub?: string) =>
(await (await matchmake(path, sub)).json()) as Body (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') const invited = await join('/matchmake/event/8', '301')
expect(invited.errorCode).toBe(0) 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. // ...recorded as their presence, like any other matchmake.
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1') const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
.bind(301) .bind(301)
.first<{ data: string }>() .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.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 // The creator gets in, and so does someone who was invited and DECLINED — the row
// is the invite, whatever the answer. // is the invite, whatever the answer. They join the SAME session: the event's.
expect((await join('/matchmake/event/8', '300')).errorCode).toBe(0) 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) 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. // 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)) expect(await join('/matchmake/event/8', '399')).toEqual(refused(35))
+10 -3
View File
@@ -504,15 +504,22 @@ export async function deleteEmptyRoomInstances(
* *
* `gameVersion` is matched as a BUILD ({@link gameBuild}): `20250718.01` and `20250718.2` * `gameVersion` is matched as a BUILD ({@link gameBuild}): `20250718.01` and `20250718.2`
* both find the `20250718` session, which is what the rows store. * 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( export async function getJoinableInstance(
db: D1Database, db: D1Database,
roomId: number, roomId: number,
gameVersion: string, gameVersion: string,
subRoomId?: number, subRoomId?: number,
excludeInstanceId?: number excludeInstanceId?: number,
eventId = 0
): Promise<RoomInstanceDto | null> { ): Promise<RoomInstanceDto | null> {
const binds: Array<number | string> = [roomId, gameBuild(gameVersion) ?? ''] const binds: Array<number | string> = [roomId, gameBuild(gameVersion) ?? '', eventId]
const filters: string[] = [] const filters: string[] = []
if (subRoomId !== undefined) { if (subRoomId !== undefined) {
binds.push(subRoomId) binds.push(subRoomId)
@@ -525,7 +532,7 @@ export async function getJoinableInstance(
const row = await db const row = await db
.prepare( .prepare(
`SELECT data FROM room_instance `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_private = 0 AND is_full = 0 AND join_disabled = 0
AND is_in_progress = 0 AND is_in_progress = 0
AND matchmaking_policy != ${MatchmakingPolicy.Ignore} ${filters.join(' ')} AND matchmaking_policy != ${MatchmakingPolicy.Ignore} ${filters.join(' ')}