From b463a79df2c0fd42389c15b63d954a1d42568ba2 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sun, 12 Jul 2026 22:43:28 -0400 Subject: [PATCH] subroom matchmake --- apps/match/src/match.app.ts | 142 ++++++++++++++------ apps/match/src/test/integration/api.test.ts | 108 ++++++++++++--- 2 files changed, 192 insertions(+), 58 deletions(-) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 8699a3d..3bf76d6 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -18,8 +18,8 @@ import { import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' -import type { Room, StoredPresence } from '@repo/domain' import type { Context } from 'hono' +import type { Room, StoredPresence } from '@repo/domain' import type { App } from './context' /** @@ -32,21 +32,45 @@ import type { App } from './context' */ /** - * Default `/player` payload, served whenever the `id` is missing/invalid or the - * account isn't found. Inlined here (Workers have no filesystem). + * The connection fields the client expects on a player payload but that only ever + * carry a value in a matchmaking response — the photon/voice credentials for the + * instance you were just placed into. Reading someone else's presence never hands + * out credentials, so they're always null here; the client needs the keys present. */ -const DEFAULT_GET_PLAYER = [ - { - playerId: 1, - statusVisibility: 0, - deviceClass: 0, - vrMovementMode: 1, - roomInstance: null, - isOnline: true, - appVersion: '20230302', - platform: 0, - }, -] +const NULL_CONNECTION_INFO = { + photonAuthToken: null, + photonRealtimeAppId: null, + photonVoiceAppId: null, + photonChatAppId: null, + photonRegion: null, + photonRoomId: null, + voiceConnectionInfo: null, + voiceServerId: null, + experiments: null, +} as const + +/** + * A player's presence as the client reads it (`/player`, `/player/heartbeat`). + * `isOnline` means "has a live presence row" — presence rows expire, so a player who + * stopped heartbeating drops offline — and is deliberately *not* derived from being + * in a room: you can be online in the lobby with `roomInstance` null. `errorCode` 0 + * is "no error"; it only turns non-zero on a failed matchmake. + */ +function playerPayload(playerId: number, presence?: Presence | null) { + return { + appVersion: presence?.appVersion || GAME_VERSION, + deviceClass: presence?.deviceClass ?? 0, + errorCode: 0, + // `getPresence` yields null and the batch map yields undefined — neither is online. + isOnline: presence != null, + playerId, + roomInstance: presence?.roomInstance ?? null, + statusVisibility: presence?.statusVisibility ?? 0, + vrMovementMode: presence?.vrMovementMode ?? 1, + platform: presence?.platform ?? 0, + ...NULL_CONNECTION_INFO, + } +} /** Heartbeat body posted by the client (all fields optional). */ interface HeartbeatRequest { @@ -98,6 +122,13 @@ const PRESENCE_REFRESH_THRESHOLD = 300 */ const GAME_VERSION = '20230302' +/** + * Default `/player` payload, served whenever the `id` is missing/invalid or the + * account isn't found. Inlined here (Workers have no filesystem). The stub player + * reads as online — it's a placeholder for a real, present player. + */ +const DEFAULT_GET_PLAYER = [{ ...playerPayload(1), isOnline: true }] + /** Store the room instance the player just matchmade into, preserving status. */ async function enterRoom(c: Context, id: number, roomInstance: RoomInstance): Promise { const prev = await getPresence(c.env.DB, id) @@ -165,10 +196,18 @@ function dormRoomInstance() { * Instance-relevant fields pulled from a stored room (scene, name, capacity, …). * The `location` is the SubRoom's real `UnitySceneId` — an empty/unknown location * makes the client reject the session with "unknown scene location ID". + * + * `subRoomId` picks which of the room's subrooms to enter (the client matchmakes + * into one with `/matchmake/room/{roomId}/{subRoomId}`); an unknown or unspecified + * subroom falls back to the room's first, which is its default entrance. */ -function instanceFieldsFromRoom(room: Room) { - const sub = (Array.isArray(room.SubRooms) ? room.SubRooms[0] : undefined) as - Record | undefined +function instanceFieldsFromRoom(room: Room, subRoomId?: number) { + const subRooms = (Array.isArray(room.SubRooms) ? room.SubRooms : []) as Array< + Record + > + const sub = + (subRoomId === undefined ? undefined : subRooms.find((s) => s.SubRoomId === subRoomId)) ?? + subRooms[0] const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback) const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback) // Room instance names are prefixed with `^` so the client resolves the instance @@ -197,9 +236,10 @@ function roomInstanceFromRoom( room: Room, isPrivate: boolean, instanceId: number, - photonRoomId: string + photonRoomId: string, + subRoomId?: number ): RoomInstance { - const f = instanceFieldsFromRoom(room) + const f = instanceFieldsFromRoom(room, subRoomId) return { roomInstanceId: instanceId, roomId: f.roomId, @@ -237,7 +277,8 @@ async function resolveRoomInstance( c: Context, roomKey: string, isPrivate: boolean, - ownerId: number + ownerId: number, + subRoomId?: number ): Promise { const id = Number.parseInt(roomKey, 10) const room = Number.isNaN(id) @@ -245,10 +286,11 @@ async function resolveRoomInstance( : await getRoomById(c.env.DB, id) if (!room) return null - const f = instanceFieldsFromRoom(room) - // Reuse an existing joinable public instance; private matchmakes always get a - // fresh instance. Create one when there's nothing to join. - let instance = isPrivate ? null : await getJoinableInstance(c.env.DB, f.roomId) + const f = instanceFieldsFromRoom(room, subRoomId) + // Reuse an existing joinable public instance *of the same subroom* — subrooms are + // separate places, so joining one must never land you in another. Private + // matchmakes always get a fresh instance. Create one when there's nothing to join. + let instance = isPrivate ? null : await getJoinableInstance(c.env.DB, f.roomId, f.subRoomId) if (!instance) { instance = await createRoomInstance(c.env.DB, { ownerAccountId: ownerId, @@ -263,7 +305,13 @@ async function resolveRoomInstance( roomInstanceType: f.roomInstanceType, }) } - return roomInstanceFromRoom(room, isPrivate, instance.roomInstanceId, instance.photonRoomId) + return roomInstanceFromRoom( + room, + isPrivate, + instance.roomInstanceId, + instance.photonRoomId, + f.subRoomId + ) } /** @@ -333,20 +381,7 @@ const app = new Hono() // One query for the whole batch (D1 `WHERE account_id IN (…)`), rather than a // point read per id as the KV store required. const presences = await getPresences(c.env.DB, ids) - const players = ids.map((playerId) => { - const p = presences.get(playerId) - return { - playerId, - statusVisibility: p?.statusVisibility ?? 0, - deviceClass: p?.deviceClass ?? 0, - vrMovementMode: p?.vrMovementMode ?? 1, - roomInstance: p?.roomInstance ?? null, - isOnline: p?.roomInstance != null, - appVersion: p?.appVersion || GAME_VERSION, - platform: p?.platform ?? 0, - } - }) - return c.json(players) + return c.json(ids.map((playerId) => playerPayload(playerId, presences.get(playerId)))) }) .post('/player/heartbeat', async (c) => { @@ -398,13 +433,13 @@ const app = new Hono() } } + // The heartbeat echoes the same player payload `/player` serves; with no stored + // presence it falls back to what the client just posted. return c.json({ - playerId: hb.playerId ? hb.playerId : id, + ...playerPayload(hb.playerId ? hb.playerId : id, presence), statusVisibility: presence?.statusVisibility ?? hb.statusVisibility ?? 0, deviceClass: presence?.deviceClass ?? hb.deviceClass ?? 0, vrMovementMode: presence?.vrMovementMode ?? (hb.vrMovementMode ? hb.vrMovementMode : 1), - roomInstance: presence?.roomInstance ?? null, - isOnline: presence?.roomInstance != null, appVersion: presence?.appVersion || hb.appVersion || GAME_VERSION, platform: presence?.platform ?? hb.platform ?? 0, }) @@ -463,6 +498,27 @@ const app = new Hono() if (id !== null) await enterRoom(c, id, instance) return c.json({ errorCode: 0, roomInstance: instance }) }) + // Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}` + // — the client uses this to enter a room's other scenes). The subroom decides the + // scene the client loads and which instances are joinable, so it must be carried + // through; an unknown subroom falls back to the room's first. + .post('/matchmake/room/:roomId/:subRoomId{[0-9]+}', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const joinMode = await readJoinMode(c) + const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10) + const instance = await resolveRoomInstance( + c, + c.req.param('roomId'), + joinMode === 2, + id, + subRoomId + ) + if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) + await enterRoom(c, id, instance) + return c.json({ errorCode: 0, roomInstance: instance }) + }) + // The 2023 client uses a two-segment matchmake/room/{roomId}. Look the room up // in D1 so the instance carries its real scene, and store it as presence. .post('/matchmake/room/:roomId', async (c) => { diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 67e3d51..6feef7d 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -22,6 +22,7 @@ const ORIGIN = 'https://example.com' // Matchmaking into a room resolves its real scene from the shared recflare D1. // Seed the schema + a couple of rooms (matching the rooms worker's migration). const RECCENTER_SCENE = 'cbad71af-0831-44d8-b8ef-69edafa841f6' +const SECOND_SUBROOM_SCENE = '3f0f6cd0-5c9f-42b2-9c07-2a5a2a1c9f11' const TEST_ROOMS = [ { RoomId: 1, @@ -53,6 +54,18 @@ const TEST_ROOMS = [ Accessibility: 1, SubRooms: [{ SubRoomId: 5, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 1 }], }, + { + // Two subrooms (separate scenes) — matchmaking into one must not land you in + // the other. + RoomId: 77, + Name: 'MultiRoom', + IsDorm: false, + Accessibility: 1, + SubRooms: [ + { SubRoomId: 34, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 10 }, + { SubRoomId: 35, UnitySceneId: SECOND_SUBROOM_SCENE, MaxPlayers: 6 }, + ], + }, ] beforeAll(async () => { @@ -132,18 +145,38 @@ describe('public endpoints', () => { test('GET /player?id=N synthesizes a player payload for that id', async () => { const res = await exports.default.fetch(`${ORIGIN}/player?id=99`) expect(res.status).toBe(200) - const players = (await res.json()) as Array<{ - playerId: number - isOnline: boolean - appVersion: string - roomInstance: unknown - }> - expect(players[0]).toMatchObject({ - playerId: 99, - isOnline: false, - appVersion: '20230302', - roomInstance: null, - }) + // The full presence shape the client deserializes — including the connection + // fields, which only ever carry values in a matchmaking response. + expect(await res.json()).toEqual([ + { + appVersion: '20230302', + deviceClass: 0, + errorCode: 0, + isOnline: false, + playerId: 99, + roomInstance: null, + statusVisibility: 0, + vrMovementMode: 1, + platform: 0, + photonAuthToken: null, + photonRealtimeAppId: null, + photonVoiceAppId: null, + photonChatAppId: null, + photonRegion: null, + photonRoomId: null, + voiceConnectionInfo: null, + voiceServerId: null, + experiments: null, + }, + ]) + }) + + test('GET /player?id=&id= returns one payload per id, in order', async () => { + const res = await exports.default.fetch(`${ORIGIN}/player?id=1070&id=1380`) + const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }> + expect(players.map((p) => p.playerId)).toEqual([1070, 1380]) + // Neither has presence → both offline. + expect(players.every((p) => p.isOnline === false)).toBe(true) }) test('GET /player without an id returns the default payload', async () => { @@ -190,6 +223,53 @@ describe('public endpoints', () => { }) }) + test('POST /matchmake/room/:roomId/:subRoomId enters that subroom', async () => { + type Instance = { + roomId: number + subRoomId: number + location: string + maxCapacity: number + roomInstanceId: number + } + const matchmake = async (path: string, sub: string): Promise => { + const res = await exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { + ...(await bearer(sub)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + // The client's real body: JoinMode 0 (public) plus flags we ignore. + body: 'BypassMovementModeRestriction=True&MaxPersistenceVersion=41&JoinMode=0&ClientJoinData=%7B%22WelcomeMatName%22%3A%22%22%7D&AdditionalPlayersAutoFollow=False', + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { errorCode: number; roomInstance: Instance } + expect(body.errorCode).toBe(0) + return body.roomInstance + } + + // Subroom 35 → that subroom's own scene and capacity, not the first subroom's. + const second = await matchmake('/matchmake/room/77/35', '90') + expect(second).toMatchObject({ + roomId: 77, + subRoomId: 35, + location: SECOND_SUBROOM_SCENE, + maxCapacity: 6, + }) + + // A second player asking for the same subroom joins the same instance... + const alsoSecond = await matchmake('/matchmake/room/77/35', '91') + expect(alsoSecond.roomInstanceId).toBe(second.roomInstanceId) + + // ...but the other subroom is a separate place, with its own instance + scene. + const first = await matchmake('/matchmake/room/77/34', '92') + expect(first.roomInstanceId).not.toBe(second.roomInstanceId) + expect(first).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE, maxCapacity: 10 }) + + // An unknown subroom falls back to the room's first (its default entrance). + const unknown = await matchmake('/matchmake/room/77/999', '93') + expect(unknown).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE }) + }) + test('POST /matchmake/room/:roomId returns NoSuchRoom for an unknown room', async () => { const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, { method: 'POST', @@ -615,9 +695,7 @@ describe('auth-gated endpoints', () => { test('GET /room/:id/instances is auth-gated, owner-only, and lists the room’s instances', async () => { // No token → 401. - expect( - (await exports.default.fetch(`${ORIGIN}/room/3/instances`)).status - ).toBe(401) + expect((await exports.default.fetch(`${ORIGIN}/room/3/instances`)).status).toBe(401) // Not the owner (room 3 is owned by account 42) → 403. expect(