subroom matchmake

This commit is contained in:
Devin Zuczek
2026-07-12 22:43:28 -04:00
parent f5a86f66fe
commit b463a79df2
2 changed files with 192 additions and 58 deletions
+99 -43
View File
@@ -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<App>, id: number, roomInstance: RoomInstance): Promise<void> {
const prev = await getPresence<RoomInstance>(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<string, unknown> | undefined
function instanceFieldsFromRoom(room: Room, subRoomId?: number) {
const subRooms = (Array.isArray(room.SubRooms) ? room.SubRooms : []) as Array<
Record<string, unknown>
>
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<App>,
roomKey: string,
isPrivate: boolean,
ownerId: number
ownerId: number,
subRoomId?: number
): Promise<RoomInstance | null> {
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<App>()
// 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<RoomInstance>(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<App>()
}
}
// 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<App>()
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) => {
+93 -15
View File
@@ -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<Instance> => {
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 rooms 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(