mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
subroom matchmake
This commit is contained in:
+99
-43
@@ -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) => {
|
||||
|
||||
@@ -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 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(
|
||||
|
||||
Reference in New Issue
Block a user