mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
working rec center, I think
This commit is contained in:
@@ -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 */
|
||||
|
||||
+65
-27
@@ -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<App>, 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<string, unknown>
|
||||
| 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<App>): Promise<number> {
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
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<App>,
|
||||
roomKey: string,
|
||||
isPrivate: boolean
|
||||
): Promise<RoomInstance | null> {
|
||||
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<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -299,10 +334,12 @@ const app = new Hono<App>()
|
||||
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<string, unknown>)
|
||||
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<App>()
|
||||
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<string, unknown>)
|
||||
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<App>()
|
||||
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<string, unknown>)
|
||||
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 })
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>
|
||||
|
||||
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<Room | null> {
|
||||
return parseOne(
|
||||
await db.prepare('SELECT data FROM rooms WHERE room_id = ?1').bind(roomId).first<RoomRow>()
|
||||
)
|
||||
}
|
||||
|
||||
export async function getRoomByName(db: D1Database, name: string): Promise<Room | null> {
|
||||
return parseOne(
|
||||
await db
|
||||
.prepare('SELECT data FROM rooms WHERE name_lower = ?1')
|
||||
.bind(name.toLowerCase())
|
||||
.first<RoomRow>()
|
||||
)
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user