[2025] unstable

This commit is contained in:
Devin Zuczek
2026-08-13 01:05:55 -04:00
committed by devin
parent 551585179a
commit ec52985ef2
32 changed files with 1396 additions and 109 deletions
+184 -1
View File
@@ -37,7 +37,7 @@ import {
subRoomDataBlob,
} from '@repo/domain'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { generatePhotonAuthToken, validateAndGetAccountId } from '@repo/jwt'
// The account-wide ban lives on a `report` row, whose table the api worker owns; its
// db module is plain D1 queries with no runtime deps, so it imports cleanly here (the
@@ -53,6 +53,7 @@ import {
AUTHED,
AvoidJuniorsRequest,
AvoidJuniorsResponse,
ConnectionInfoResponse,
EMPTY_OK,
ExclusiveLoginResponse,
form,
@@ -65,6 +66,7 @@ import {
MatchmakeRoomRequest,
NotifyDisconnectRequest,
PlayerDto,
QosRegion,
RoomInstanceDto,
RoomInstanceSummaryDto,
StatusVisibilityRequest,
@@ -102,6 +104,54 @@ const NULL_CONNECTION_INFO = {
experiments: null,
} as const
/**
* The Photon applications the client connects to (`GET /player/connection-info`).
* Temporary placeholders — move them to wrangler vars before they need to differ per
* environment. `photonRegion` matches the value `roomInstanceFromRoom` stamps on every
* instance, so the two can't disagree ('us' resolves to us-east1 for QoS).
*/
const PHOTON_APPS = {
photonRealtimeAppId: '',
photonVoiceAppId: '',
photonChatAppId: '',
photonRegion: 'us',
} as const
/**
* Networking feature flags the client reads off its connection info. Verbatim from
* the reference server — the client changes how it replicates based on these, so they
* are not free to tune. The load-bearing one is `shouldUseGameServerNetworking`:
* true makes the client connect to a local game server (127.0.0.1:7777) instead of
* Photon, which is not what recflare runs.
*/
const PHOTON_EXPERIMENTS = {
networkTransformSyncInterval: 10.0,
shouldUseUnreliableOnChange: false,
shouldAvoidDiscontinuityRPCs: true,
shouldAvoidRedundantDiscontinuity: false,
r2RuntimeStaticBaking: true,
r2AutoEmbodiment: true,
r2RuntimeStaticBakingMinShapeThreshold: 1,
r2UseCheapReplicas: true,
shouldUseGameServerNetworking: false,
} as const
/**
* The regions the client probes for latency (`GET /player/qos`), reporting the results
* back through `PUT /player/photonregionpings`. Rec Room's own QoS endpoints, served
* verbatim: recflare doesn't run probe servers, and the client only uses the timings to
* rank regions — a ranking it can't act on here, since `PHOTON_APPS.photonRegion` pins
* every session to one region regardless. `address` is `host:port`, not a URL.
*/
const QOS_REGIONS = [
{ id: 'us-west1', address: '34.169.254.144:50000' },
{ id: 'europe-west1', address: '35.205.141.119:50000' },
{ id: 'asia-northeast1', address: '35.200.67.228:50000' },
{ id: 'us-east1', address: '34.73.244.122:50000' },
{ id: 'us-central1', address: '34.69.179.51:50000' },
{ id: 'northamerica-northeast1', address: '34.152.4.100:50000' },
] 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
@@ -1586,6 +1636,41 @@ const app = new Hono<App>()
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Matchmake with no target. The client posts this when it needs an instance but isn't
// going anywhere in particular — at startup, and while sitting in Orientation. It
// answers the instance the player is ALREADY in, so it never warps anyone out of the
// room they're standing in; only a player with no live presence falls back to their
// dorm. Either way presence is re-committed, which refreshes its TTL.
.post(
'/matchmake/none',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake with no target',
description: [
'Answers the instance the caller is already in, rather than sending them anywhere —',
'this is what the client posts at startup and while in Orientation, so forcing a',
'destination here would warp the player out of the room they are standing in. A',
'caller with no live presence (their TTL lapsed, or they have never entered a room)',
'falls back to their personal dorm. Re-commits presence either way, refreshing its',
'TTL.',
].join(' '),
security: AUTHED,
responses: {
200: json(MatchmakeResponse, 'The callers current instance, or their dorm'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const presence = await getPresence<RoomInstance>(c.env.DB, id)
const current = presence?.roomInstance ?? (await playerDormInstance(c, id))
await enterRoom(c, id, current)
return c.json({ errorCode: 0, roomInstance: current })
}
)
.post(
'/matchmake/dorm',
describeRoute({
@@ -1613,6 +1698,104 @@ const app = new Hono<App>()
}
)
// The realtime credentials the caller should connect with: a freshly minted Photon
// auth token, the Photon applications, and the Photon room they belong in. That last
// one comes from the caller's own presence — the instance matchmaking put them in —
// so it's the same name every other player in that instance is given. The reference
// reads presence and nothing else; we fall back to looking the `roomInstanceId` query
// param up when presence has no room (it expires on a TTL, and the client sometimes
// asks before matchmaking has landed), and to an empty string when neither resolves.
.get(
'/player/connection-info',
describeRoute({
tags: ['Presence'],
summary: 'Photon connection info',
description: [
'The realtime (Photon) credentials the caller should connect with, in a',
'`{ success, value, error }` envelope: a freshly minted `photonAuthToken`, the',
'Photon application ids, and the `photonRoomId` of the instance the caller is in',
'(from their presence, falling back to the `roomInstanceId` query param). There is',
'no separate voice server, so the voice fields are null. `experiments` carries the',
'clients networking flags.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'roomInstanceId',
in: 'query',
required: false,
description: 'The instance being connected to; used only when presence has no room',
schema: { type: 'string' },
},
],
responses: {
200: json(ConnectionInfoResponse, 'The Photon credentials, room, and experiment flags'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const presence = await getPresence<RoomInstance>(c.env.DB, id)
// Presence first (it's the instance the player is actually in); the query param
// only stands in when there's no live presence to read.
let photonRoomId = presence?.roomInstance?.photonRoomId ?? ''
if (!photonRoomId) {
const requested = Number.parseInt(c.req.query('roomInstanceId') ?? '', 10)
if (!Number.isNaN(requested)) {
photonRoomId = (await getRoomInstance(c.env.DB, requested))?.photonRoomId ?? ''
}
}
// Identifies the player to Photon. Signed with the shared JWT secret; the token's
// `aud` is the realtime app it's for. Nothing verifies it while Photon is
// self-hosted, so it's identifying rather than authorizing.
const photonAuthToken = await generatePhotonAuthToken(
id,
{
platformId: (await getAccount(c.env.DB, id))?.platformId ?? '',
platform: presence?.platform ?? 0,
deviceClass: presence?.deviceClass ?? 0,
audience: PHOTON_APPS.photonRealtimeAppId,
},
await c.env.JWT_SECRET.get()
)
return c.json({
success: true,
value: {
photonAuthToken,
...PHOTON_APPS,
photonRoomId,
voiceConnectionInfo: null,
voiceServerId: null,
experiments: PHOTON_EXPERIMENTS,
},
error: null,
})
}
)
// The regions to probe, which the two ping-report routes below are the other half of.
// Unauthenticated: it's a fixed public list, and the client fetches it early. A bare
// array — no `{ success, value, error }` envelope.
.get(
'/player/qos',
describeRoute({
tags: ['Presence'],
summary: 'QoS probe targets',
description: [
'The regions the client pings to measure latency, reporting the results back through',
'`PUT /player/photonregionpings`. Rec Rooms own probe endpoints, served verbatim —',
'recflare runs none of its own, and the resulting ranking is unused anyway: every',
'session is pinned to the one region `/player/connection-info` hands out.',
].join(' '),
responses: { 200: json(QosRegion.array(), 'The regions to probe, as `host:port`') },
}),
(c) => c.json(QOS_REGIONS)
)
// Region ping reports — accept-and-ack (the reference returns Ok()).
.put(
'/player/photonregionpings',
+59
View File
@@ -170,6 +170,65 @@ export const AvoidJuniorsRequest = z.object({
/** `POST /player/exclusivelogin` — a bare error code. */
export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') })
/**
* The networking feature flags the client reads off its connection info — verbatim
* from the reference server. The client changes how it replicates based on these, so
* they are not free to tune. `shouldUseGameServerNetworking` is the load-bearing one:
* true points the client at a local game server (127.0.0.1:7777) instead of Photon.
*/
export const ConnectionExperiments = z.object({
networkTransformSyncInterval: z.number(),
shouldUseUnreliableOnChange: z.boolean(),
shouldAvoidDiscontinuityRPCs: z.boolean(),
shouldAvoidRedundantDiscontinuity: z.boolean(),
r2RuntimeStaticBaking: z.boolean(),
r2AutoEmbodiment: z.boolean(),
r2RuntimeStaticBakingMinShapeThreshold: z.int(),
r2UseCheapReplicas: z.boolean(),
shouldUseGameServerNetworking: z
.boolean()
.describe('true connects to a local game server instead of Photon'),
})
/**
* `GET /player/connection-info` — the realtime (Photon) credentials, in a
* `{ success, value, error }` envelope. The applications and region are fixed for
* recflare; what varies per caller is `photonAuthToken` (minted for them on the spot)
* and `photonRoomId`, the Photon room of the instance their presence says they're in
* — the same name every other player in that instance is handed. There's no separate
* voice server, so both voice fields are null. `photonRegion` matches the one stamped
* on every room instance, so the two can't disagree.
*/
export const ConnectionInfo = z.object({
photonAuthToken: z.string().describe('Short-lived HS256 token identifying the caller to Photon'),
photonRealtimeAppId: z.string().describe('Photon Realtime application id'),
photonVoiceAppId: z.string().describe('Photon Voice application id'),
photonChatAppId: z.string().describe('Photon Chat application id'),
photonRegion: z.string().describe('Region id, matching a room instances `photonRegion`'),
photonRoomId: z.string().describe('The callers current instance; empty when theyre in none'),
voiceConnectionInfo: z.null().describe('Null — no separate voice server'),
voiceServerId: z.null().describe('Null — no separate voice server'),
experiments: ConnectionExperiments,
})
/** `GET /player/connection-info` — the connection info in the client's standard envelope. */
export const ConnectionInfoResponse = z.object({
success: z.literal(true),
value: ConnectionInfo,
error: z.null(),
})
/**
* One QoS probe target (`GET /player/qos`) — a region the client pings to measure
* latency, then reports back through `PUT /player/photonregionpings`. A bare array,
* not the `{ success, value, error }` envelope. `id` is the region id the pings are
* keyed by; `address` is `host:port`, not a URL.
*/
export const QosRegion = z.object({
id: z.string().describe('Region id, e.g. `us-east1`'),
address: z.string().describe('`host:port` of the probe endpoint'),
})
/**
* The session `LoginLock` GUID form field. The client posts it on every presence
* lifecycle call — `POST /player/login`, `/player/exclusivelogin`, `/player/logout`,
+158
View File
@@ -858,6 +858,25 @@ describe('public endpoints', () => {
expect(res.status).toBe(200)
})
test('GET /player/connection-info 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`)
expect(res.status).toBe(401)
})
test('GET /player/qos returns the probe targets', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/qos`)
expect(res.status).toBe(200)
// A bare array, not the { success, value, error } envelope connection-info uses.
expect(await res.json()).toEqual([
{ id: 'us-west1', address: '34.169.254.144:50000' },
{ id: 'europe-west1', address: '35.205.141.119:50000' },
{ id: 'asia-northeast1', address: '35.200.67.228:50000' },
{ id: 'us-east1', address: '34.73.244.122:50000' },
{ id: 'us-central1', address: '34.69.179.51:50000' },
{ id: 'northamerica-northeast1', address: '34.152.4.100:50000' },
])
})
test('PUT /player/photonregionpings returns 200', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/photonregionpings`, { method: 'PUT' })
expect(res.status).toBe(200)
@@ -899,6 +918,103 @@ describe('auth-gated endpoints', () => {
expect(priv.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId)
})
test('GET /player/connection-info hands back the Photon room the caller matchmade into', async () => {
const matchmaked = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('960'),
})
).json()) as { roomInstance: { photonRoomId: string } }
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('960'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
success: true,
value: {
// A signed JWT, not an opaque id — three base64url segments.
photonAuthToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/),
photonRealtimeAppId: '',
photonVoiceAppId: '',
photonChatAppId: '',
// Matches the region every room instance is stamped with.
photonRegion: 'us',
// The room the client is told to join has to be the one matchmaking placed
// them in, or they end up alone in a room of their own.
photonRoomId: matchmaked.roomInstance.photonRoomId,
voiceConnectionInfo: null,
voiceServerId: null,
experiments: {
networkTransformSyncInterval: 10,
shouldUseUnreliableOnChange: false,
shouldAvoidDiscontinuityRPCs: true,
shouldAvoidRedundantDiscontinuity: false,
r2RuntimeStaticBaking: true,
r2AutoEmbodiment: true,
r2RuntimeStaticBakingMinShapeThreshold: 1,
r2UseCheapReplicas: true,
// true would send the client to a local game server instead of Photon.
shouldUseGameServerNetworking: false,
},
},
error: null,
})
})
test('GET /player/connection-info mints a token carrying the callers id', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('961'),
})
const body = (await res.json()) as {
value: { photonAuthToken: string; photonRealtimeAppId: string }
}
const claims = JSON.parse(atob(body.value.photonAuthToken.split('.')[1]!)) as {
sub: string
aud: string
exp: number
'rn.env': string
}
expect(claims.sub).toBe('961')
// Scoped to the realtime app the same response hands out — a placeholder empty
// string until PHOTON_APPS moves to wrangler vars, so assert the two agree rather
// than pinning the placeholder itself.
expect(claims.aud).toBe(body.value.photonRealtimeAppId)
expect(claims.exp).toBeGreaterThan(Math.floor(Date.now() / 1000))
// The client is built against prod regardless of which environment we run in.
expect(claims['rn.env']).toBe('prod')
})
test('GET /player/connection-info falls back to ?roomInstanceId when presence has no room', async () => {
// Player 962 never matchmade, so there's no presence to read the room from; the
// param names the instance they're trying to connect to.
const instance = await createRoomInstance(env.DB, {
roomId: 2,
subRoomId: 2,
roomInstanceType: 0,
photonRoomId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
maxCapacity: 12,
isPrivate: false,
ownerAccountId: 962,
})
const res = await exports.default.fetch(
`${ORIGIN}/player/connection-info?roomInstanceId=${instance.roomInstanceId}`,
{ headers: await bearer('962') }
)
expect(res.status).toBe(200)
const body = (await res.json()) as { value: { photonRoomId: string } }
expect(body.value.photonRoomId).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
})
test('GET /player/connection-info serves an empty photonRoomId when nothing resolves', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('963'),
})
const body = (await res.json()) as { value: { photonRoomId: string } }
expect(body.value.photonRoomId).toBe('')
})
test('re-matchmaking into your current room returns a different instance (id must change)', async () => {
// The client keys the room transition off a changing roomInstanceId; handing back
// the instance the player is already in hangs their join. RecCenter (cap 12) so
@@ -952,6 +1068,45 @@ describe('auth-gated endpoints', () => {
})
})
test('POST /matchmake/none 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, { method: 'POST' })
expect(res.status).toBe(401)
})
test('POST /matchmake/none keeps the caller where they are, else falls back to the dorm', async () => {
const none = async (sub: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/none`, {
method: 'POST',
headers: await bearer(sub),
})
).json()) as { errorCode: number; roomInstance: { roomId: number; roomInstanceId: number } }
// Account 44 has never entered a room → their personal dorm, and a second call is
// idempotent now that presence holds it.
const fresh = await none('44')
expect(fresh.errorCode).toBe(0)
expect(fresh.roomInstance.roomId).toBeGreaterThan(2)
expect((await none('44')).roomInstance).toMatchObject({
roomId: fresh.roomInstance.roomId,
roomInstanceId: fresh.roomInstance.roomInstanceId,
})
// Once in a real room, `none` must NOT warp them out of it — that is the whole
// point of the endpoint, since the client posts it while sitting in Orientation.
const entered = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('44'),
})
).json()) as { roomInstance: { roomId: number; roomInstanceId: number } }
expect(entered.roomInstance.roomId).toBe(2)
expect((await none('44')).roomInstance).toMatchObject({
roomId: 2,
roomInstanceId: entered.roomInstance.roomInstanceId,
})
})
test('each players dorm gets a distinct global subroom id', async () => {
// Dorms used to copy the template subroom verbatim, so every dorm carried SubRoomId 1.
// With subrooms minted from the global sequence, each dorm gets its own unique id.
@@ -2019,6 +2174,8 @@ describe('auth-gated endpoints', () => {
expect([...documented].sort()).toEqual([
'GET /player',
'GET /player/avoidjuniors',
'GET /player/connection-info',
'GET /player/qos',
'GET /room/{roomId}/instances',
'GET /rooms/requiring/developer',
'GET /rooms/requiring/rrplus',
@@ -2027,6 +2184,7 @@ describe('auth-gated endpoints', () => {
'POST /matchmake/dorm',
'POST /matchmake/event/{eventId}',
'POST /matchmake/instance/{instanceId}',
'POST /matchmake/none',
'POST /matchmake/player/{playerId}',
'POST /matchmake/room/{roomId}',
'POST /matchmake/room/{roomId}/{subRoomId}',