From 95e6a06db5529c43c6f67116f07940ebf4007675 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 15 Jun 2026 01:19:15 -0400 Subject: [PATCH] working rec center, I think --- apps/match/src/context.ts | 3 + apps/match/src/match.app.ts | 92 ++++++++++++++------ apps/match/src/rooms-db.ts | 31 +++++++ apps/match/src/test/integration/api.test.ts | 93 ++++++++++++++++++--- apps/match/wrangler.jsonc | 8 ++ 5 files changed, 187 insertions(+), 40 deletions(-) create mode 100644 apps/match/src/rooms-db.ts diff --git a/apps/match/src/context.ts b/apps/match/src/context.ts index a9c03a7..a7f6634 100644 --- a/apps/match/src/context.ts +++ b/apps/match/src/context.ts @@ -6,6 +6,9 @@ export type Env = SharedHonoEnv & { // matchmake/goto, read by the heartbeat, cleared on login — mirrors the // reference server's HeartbeatDB. MATCH_PRESENCE: KVNamespace + // Shared rooms DB (owned by the `rooms` worker). Read-only here to resolve a + // room's real scene/subroom when matchmaking into it. + DB: D1Database } /** Variables can be extended */ diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 521ee62..757ac98 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -4,9 +4,11 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from './jwt' +import { getRoomById, getRoomByName } from './rooms-db' import type { Context } from 'hono' import type { App } from './context' +import type { Room } from './rooms-db' /** * Ported from the C# `MatchmakingController`. Endpoints the C# backs with EF Core @@ -122,6 +124,9 @@ async function enterRoom(c: Context, id: number, roomInstance: RoomInstance */ const DORM_PHOTON_ROOM_ID = '00000000-0000-4000-8000-000000000001' +/** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */ +const NO_SUCH_ROOM = 20 + /** * The canonical dorm room instance (room 1, instance 1.1). Returned identically * by every dorm entry point and the presence heartbeat so the client's local @@ -150,32 +155,62 @@ function dormRoomInstance() { } } -/** Synthesize a non-dorm room instance. No Rooms DB, so location is empty and - * the photon id is freshly minted — it's persisted as presence and replayed by - * the heartbeat, so it stays consistent for the session. */ -function buildRoomInstance(roomName: string, isPrivate: boolean): RoomInstance { +/** + * Build a room instance from a stored D1 room — crucially using the room's real + * SubRoom `UnitySceneId` as the instance `location` (an empty/unknown location + * makes the client reject the session with "unknown scene location ID"). + */ +function roomInstanceFromRoom(room: Room, isPrivate: boolean): RoomInstance { + const subRooms = room.SubRooms + const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as + | Record + | undefined + const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback) + const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback) return { roomInstanceId: 1, - roomId: Number.parseInt(roomName, 10) || 1, - subRoomId: 0, - roomInstanceType: 2, - location: '', - dataBlob: '', + roomId: num(room.RoomId, 1), + subRoomId: num(sub?.SubRoomId, 0), + roomInstanceType: room.IsDorm === true ? 2 : 0, + location: str(sub?.UnitySceneId), + dataBlob: str(sub?.DataBlob), eventId: 0, clubId: 0, roomCode: '', photonRegion: 'us', photonRegionId: 'us', photonRoomId: crypto.randomUUID(), - name: roomName, - maxCapacity: 4, + name: str(room.Name), + maxCapacity: num(sub?.MaxPlayers, 4), isFull: false, - isPrivate, + isPrivate: isPrivate || room.IsDorm === true, isInProgress: false, EncryptVoiceChat: false, } } +/** Read the `JoinMode` form field (2 = private instance). */ +async function readJoinMode(c: Context): Promise { + const body = await c.req.parseBody().catch(() => ({}) as Record) + return typeof body.JoinMode === 'string' ? Number.parseInt(body.JoinMode, 10) || 0 : 0 +} + +/** + * Resolve a room by `:room` path segment (numeric id or name) from D1 and build + * its instance. Returns null when the room isn't found. + */ +async function resolveRoomInstance( + c: Context, + roomKey: string, + isPrivate: boolean +): Promise { + const id = Number.parseInt(roomKey, 10) + const room = Number.isNaN(id) + ? await getRoomByName(c.env.DB, roomKey) + : await getRoomById(c.env.DB, id) + return room ? roomInstanceFromRoom(room, isPrivate) : null +} + const app = new Hono() .use( '*', @@ -299,10 +334,12 @@ const app = new Hono() if (id === null) return unauthorized(c) const room = c.req.param('room') - const isDorm = room.toLowerCase() === 'dormroom' - const body = await c.req.parseBody().catch(() => ({}) as Record) - const joinMode = typeof body.JoinMode === 'string' ? Number.parseInt(body.JoinMode, 10) || 0 : 0 - const instance = isDorm ? dormRoomInstance() : buildRoomInstance(room, joinMode === 2) + const joinMode = await readJoinMode(c) + const instance = + room.toLowerCase() === 'dormroom' + ? dormRoomInstance() + : await resolveRoomInstance(c, room, joinMode === 2) + if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) await enterRoom(c, id, instance) return c.json({ errorCode: 0, roomInstance: instance }) }) @@ -315,15 +352,14 @@ const app = new Hono() if (id !== null) await enterRoom(c, id, instance) return c.json({ errorCode: 0, roomInstance: instance }) }) - // The 2023 client uses a two-segment matchmake/room/{roomId}. Synthesize the - // room instance and store it as presence. + // 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) => { const id = await authedId(c) if (id === null) return unauthorized(c) - const roomId = c.req.param('roomId') - const body = await c.req.parseBody().catch(() => ({}) as Record) - const joinMode = typeof body.JoinMode === 'string' ? Number.parseInt(body.JoinMode, 10) || 0 : 0 - const instance = roomId === '1' ? dormRoomInstance() : buildRoomInstance(roomId, joinMode === 2) + const joinMode = await readJoinMode(c) + const instance = await resolveRoomInstance(c, c.req.param('roomId'), joinMode === 2) + if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) await enterRoom(c, id, instance) return c.json({ errorCode: 0, roomInstance: instance }) }) @@ -332,11 +368,13 @@ const app = new Hono() if (id === null) return unauthorized(c) const room = c.req.param('room') - // Identical to goto/room/:room except the C# dorm check here is "dorm". - const isDorm = room.toLowerCase() === 'dorm' - const body = await c.req.parseBody().catch(() => ({}) as Record) - const joinMode = typeof body.JoinMode === 'string' ? Number.parseInt(body.JoinMode, 10) || 0 : 0 - const instance = isDorm ? dormRoomInstance() : buildRoomInstance(room, joinMode === 2) + const joinMode = await readJoinMode(c) + // The C# dorm check here is "dorm" (goto/room uses "dormroom"). + const instance = + room.toLowerCase() === 'dorm' + ? dormRoomInstance() + : await resolveRoomInstance(c, room, joinMode === 2) + if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) await enterRoom(c, id, instance) return c.json({ errorCode: 0, roomInstance: instance }) }) diff --git a/apps/match/src/rooms-db.ts b/apps/match/src/rooms-db.ts new file mode 100644 index 0000000..8496f19 --- /dev/null +++ b/apps/match/src/rooms-db.ts @@ -0,0 +1,31 @@ +/** + * Read helpers for the shared `rec-rooms` D1 database. The schema, migrations, + * and seed are owned by the `rooms` worker (apps/rooms/src/rooms-db.ts + + * migrations); this worker binds the same database read-only to resolve a room's + * real scene/subroom when building a matchmake instance. Keep these queries in + * sync with the rooms worker's. + */ + +/** A stored room — the parsed JSON blob (full client-facing room response). */ +export type Room = Record + +interface RoomRow { + data: string +} + +const parseOne = (row: RoomRow | null): Room | null => (row ? (JSON.parse(row.data) as Room) : null) + +export async function getRoomById(db: D1Database, roomId: number): Promise { + return parseOne( + await db.prepare('SELECT data FROM rooms WHERE room_id = ?1').bind(roomId).first() + ) +} + +export async function getRoomByName(db: D1Database, name: string): Promise { + return parseOne( + await db + .prepare('SELECT data FROM rooms WHERE name_lower = ?1') + .bind(name.toLowerCase()) + .first() + ) +} diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index f62fe46..f8067e1 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -1,10 +1,49 @@ +import { env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' -import { describe, expect, test } from 'vitest' +import { beforeAll, describe, expect, test } from 'vitest' import '../../match.app' +import type { Env } from '../../context' + +declare module 'cloudflare:test' { + interface ProvidedEnv extends Env {} +} + const ORIGIN = 'https://match.rec.djdevin.net' +// Matchmaking into a room resolves its real scene from the shared rec-rooms D1. +// Seed the schema + a couple of rooms (matching the rooms worker's migration). +const RECCENTER_SCENE = 'cbad71af-0831-44d8-b8ef-69edafa841f6' +const TEST_ROOMS = [ + { + RoomId: 1, + Name: 'DormRoom', + IsDorm: true, + Accessibility: 2, + SubRooms: [{ SubRoomId: 1, UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163' }], + }, + { + RoomId: 2, + Name: 'RecCenter', + IsDorm: false, + Accessibility: 1, + SubRooms: [{ SubRoomId: 2, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 12 }], + }, +] + +beforeAll(async () => { + await env.DB.prepare( + `CREATE TABLE IF NOT EXISTS rooms ( + data TEXT NOT NULL, + room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL, + name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL + )` + ).run() + const insert = env.DB.prepare('INSERT OR IGNORE INTO rooms (data) VALUES (?1)') + await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r)))) +}) + // Mint a token the way the `auth` worker does, using the same dev secret, so the // match worker's validation accepts it. Kept inline to avoid a cross-package // import. @@ -85,16 +124,34 @@ describe('public endpoints', () => { expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/) }) - test('POST /matchmake/room/:roomId synthesizes an instance and stores presence', async () => { + test('POST /matchmake/room/:roomId resolves the room scene from D1', async () => { const headers = await bearer('88') - const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/42`, { + const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ JoinMode: '2' }).toString(), }) expect(res.status).toBe(200) - const body = (await res.json()) as { roomInstance: { roomId: number; isPrivate: boolean } } - expect(body.roomInstance).toMatchObject({ roomId: 42, isPrivate: true }) + const body = (await res.json()) as { + errorCode: number + roomInstance: { roomId: number; location: string; isPrivate: boolean; name: string } + } + expect(body.errorCode).toBe(0) + expect(body.roomInstance).toMatchObject({ + roomId: 2, + name: 'RecCenter', + location: RECCENTER_SCENE, + isPrivate: true, + }) + }) + + test('POST /matchmake/room/:roomId returns NoSuchRoom for an unknown room', async () => { + const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, { + method: 'POST', + headers: await bearer('88'), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ errorCode: 20, roomInstance: null }) }) test('POST /matchmake/none returns the offline dorm', async () => { @@ -156,17 +213,22 @@ describe('auth-gated endpoints', () => { }) }) - test('POST /goto/room/:id synthesizes an instance and honors JoinMode', async () => { - const res = await exports.default.fetch(`${ORIGIN}/goto/room/42`, { + test('POST /goto/room/:id resolves a real room scene from D1', async () => { + const res = await exports.default.fetch(`${ORIGIN}/goto/room/2`, { method: 'POST', headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ JoinMode: '2' }).toString(), }) expect(res.status).toBe(200) const body = (await res.json()) as { - roomInstance: { roomId: number; isPrivate: boolean; name: string } + roomInstance: { roomId: number; isPrivate: boolean; name: string; location: string } } - expect(body.roomInstance).toMatchObject({ roomId: 42, isPrivate: true, name: '42' }) + expect(body.roomInstance).toMatchObject({ + roomId: 2, + name: 'RecCenter', + location: RECCENTER_SCENE, + isPrivate: true, + }) }) test('POST /matchmake/:room 401s without a token', async () => { @@ -193,17 +255,22 @@ describe('auth-gated endpoints', () => { }) }) - test('POST /matchmake/:id synthesizes an instance and honors JoinMode', async () => { - const res = await exports.default.fetch(`${ORIGIN}/matchmake/42`, { + test('POST /matchmake/:room resolves a room by name from D1', async () => { + const res = await exports.default.fetch(`${ORIGIN}/matchmake/RecCenter`, { method: 'POST', headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ JoinMode: '2' }).toString(), }) expect(res.status).toBe(200) const body = (await res.json()) as { - roomInstance: { roomId: number; isPrivate: boolean; name: string } + roomInstance: { roomId: number; name: string; location: string; isPrivate: boolean } } - expect(body.roomInstance).toMatchObject({ roomId: 42, isPrivate: true, name: '42' }) + expect(body.roomInstance).toMatchObject({ + roomId: 2, + name: 'RecCenter', + location: RECCENTER_SCENE, + isPrivate: true, + }) }) test('POST /player/heartbeat 401s without a token', async () => { diff --git a/apps/match/wrangler.jsonc b/apps/match/wrangler.jsonc index 442ea2b..e206681 100644 --- a/apps/match/wrangler.jsonc +++ b/apps/match/wrangler.jsonc @@ -18,6 +18,14 @@ "id": "9f53f04b7dd244658d59f515a14748b6" } ], + // Shared rooms DB (created + migrated by the `rooms` worker; bound read-only here). + "d1_databases": [ + { + "binding": "DB", + "database_name": "rec-rooms", + "database_id": "d44083e1-5bfe-4467-aa9a-f13c5c2496d5" + } + ], "logpush": false, "upload_source_maps": true, "observability": {