support for 202507 endpoints (#37)

* [auth][api] accept the 20250424.01 client

* [2025] unstable

* 20250718.0

* correct one this time

* stubs

* more stubs

* more stubs

* [lists] add worker

* [ai] route stubs

* [api] player photo setting

* [econ] add roomEconConfig route

* [infra] update worker generators

* [worker] add cards/moderation/platformnotification workers

* [lists] updates to some endpoints

* [clubs] stub out announcement endpoint, for now

* [econ] stub out season endpoints for now

* [chat] apps/chat stub out party endpoint not sure the shape yet

* [api] stub out statsig and lockeditems

* [doc] new services

* [lists] stub the bulk endpoint

* [datacollection] add placeholder service until we can kill it

* [api] set gifting to lvl5

* update lock

* [cdn] enable cache

* [match] matchmake v2

* [lists] stub some lists

* [ai] stubs

* [rooms] new subroom save endpoint

* [econ] add bulk purchase endpoint

* [discovery] update featured creator to 1 for fun

* [api] add photo settings flag

* [chat] fixup chat permissions (sorta)

* [auth] restrictions endpoint

* [rooms] contributed endpoint

* [api] fix outfit endpoint

* [discovery] attempt to fix store

* [chat] privacy endpoints

* [api] cheered images

* [rooms] add xp endpoint (disbaled)

* [rooms] add xp endpoint (disabled)

* update images-db for cheers

* [rooms] add autocomplete endpoint

* [cdn/img] increase cache ttl for statics

* [api] bulk route for images

* [accounts] add banner image

* [api] add misc missing endpoints

* [discovery] remove AI tab

* [platformnotifications] stub some endpoints

* [lists] add some more lists

* [rooms] additional endpoints

* [chat] stub a few privacy endpoints

* [econ] stub some endpoints

* misc db fixes

* [api] tweak shape for images v6

* [rooms] dont show trending RROs
This commit is contained in:
devin
2026-08-18 23:07:24 -04:00
committed by Devin Zuczek
parent 66c09806f9
commit 178d3b5b0e
162 changed files with 114930 additions and 469 deletions
+608 -95
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, validateAndGetVersion } 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,8 @@ import {
AUTHED,
AvoidJuniorsRequest,
AvoidJuniorsResponse,
ConnectionInfoResponse,
CorrelationIdRequest,
EMPTY_OK,
ExclusiveLoginResponse,
form,
@@ -60,11 +62,15 @@ import {
InviteRequest,
JoinModeRequest,
json,
jsonBody,
LoginLockRequest,
MatchmakeResponse,
MatchmakeRoomRequest,
MatchmakeRoomV2Request,
MatchmakeV2Response,
NotifyDisconnectRequest,
PlayerDto,
QosRegion,
RoomInstanceDto,
RoomInstanceSummaryDto,
StatusVisibilityRequest,
@@ -102,16 +108,109 @@ const NULL_CONNECTION_INFO = {
experiments: null,
} as const
/**
* The Photon region every session runs in, when the operator names none. Unlike the app
* ids below this has a real default: it is stamped on every room instance, and an instance
* carrying an empty region is one the client can't connect to. `us` resolves to us-east1.
*/
const DEFAULT_PHOTON_REGION = 'us'
/** A var's value, or the fallback when it is unset, empty, or only whitespace. */
function varOr(value: string | undefined, fallback: string): string {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback
}
/**
* The Photon credentials `GET /player/connection-info` hands out, from the operator's vars.
*
* The app ids default to EMPTY — this repo ships no Photon application of its own, and
* baking somebody else's ids in would silently point every player at an app the operator
* doesn't control. A deployment that wants working voice and networking sets all three
* (`PHOTON_REALTIME_APP_ID`, `PHOTON_VOICE_APP_ID`, `PHOTON_CHAT_APP_ID`); until then the
* client is handed empty ids and connects to nothing, which is the honest answer for a
* server with no Photon apps configured. They are not secrets — the client receives all
* three in the clear — so they are plain vars rather than Secrets Store entries.
*
* `photonRegion` is the exception, and is ALSO stamped on every room instance
* ({@link instancePhotonRegion}): the two must not disagree, since the client connects to
* the region on its instance and authenticates against the app named here. One var feeds
* both for that reason.
*/
function photonApps(env: Env) {
return {
photonRealtimeAppId: varOr(env.PHOTON_REALTIME_APP_ID, ''),
photonVoiceAppId: varOr(env.PHOTON_VOICE_APP_ID, ''),
photonChatAppId: varOr(env.PHOTON_CHAT_APP_ID, ''),
photonRegion: instancePhotonRegion(env),
}
}
/**
* The Photon region every room instance is stamped with. Pinned to one region for the whole
* deployment: the QoS list the client probes ranks regions it can't act on here, since an
* instance carries whichever region this says. Defaults to {@link DEFAULT_PHOTON_REGION}.
*/
function instancePhotonRegion(env: Env): string {
return varOr(env.PHOTON_REGION, DEFAULT_PHOTON_REGION)
}
/**
* 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 `instancePhotonRegion` 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
* 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.
*
* `callerVersion` (the `rn.ver` off the caller's token) may be passed ONLY when the
* payload is the caller's own — the batch lookup serves other players, whose build this
* caller's token knows nothing about.
*/
function playerPayload(playerId: number, presence?: Presence | null) {
function playerPayload(
playerId: number,
presence?: Presence | null,
callerVersion?: string | null
) {
return {
appVersion: presence?.appVersion || GAME_VERSION,
// The stored row is authoritative — for the caller it was just synced from their
// token, and for anyone else the caller's token says nothing. `callerVersion` only
// covers the caller having no presence row at all (they aren't in a room yet), where
// the alternative is reporting a build nobody is running.
appVersion: presence?.appVersion || callerVersion || GAME_VERSION,
deviceClass: presence?.deviceClass ?? 0,
errorCode: 0,
// `getPresence` yields null and the batch map yields undefined — neither is online.
@@ -139,6 +238,24 @@ function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/**
* The game build the CALLER is running, from their token's `rn.ver` claim — the `ver`
* they posted to `auth`'s `/connect/token`. This is what presence records, so a player
* reports the build they are actually on rather than this server's GAME_VERSION.
*
* `null` when the request carries no valid token, or an older one issued before the claim
* carried the client's own value; callers then keep whatever presence already held, and
* only fall back to GAME_VERSION when there is nothing at all. Never write an empty
* version — the client's presence DTO reads `appVersion` as a string and an empty one
* breaks its version handling.
*
* Only ever used for the caller's OWN presence. Another player's version comes off their
* stored row; this token says nothing about them.
*/
async function callerVersion(c: Context<App>): Promise<string | null> {
return validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* The "avoid juniors" preference, spelled the way the client posts it — the key a NEW
* setting is written under, and the one every stored spelling is matched against.
@@ -306,11 +423,16 @@ function redactInstanceForPresence(instance: RoomInstance) {
* The SubscriptionUpdatePresence message a friend receives when the player changes rooms:
* a presence snapshot of who, and the redacted instance they're now in (null when in no
* room → `isOnline` false). `statusVisibility` is forced to 0 (Everyone) so the player
* isn't hidden from friends. `appVersion` MUST be a string — the client's presence DTO
* reads it with a string reader, and a numeric value aborts the whole SignalR frame
* ("expected String Begin Token"), dropping the room/presence update.
* isn't hidden from friends. `appVersion` is the subject's own build (from their presence
* row) and MUST be a string — the client's presence DTO reads it with a string reader, and
* a numeric value aborts the whole SignalR frame ("expected String Begin Token"), dropping
* the room/presence update.
*/
function presenceUpdateMessage(playerId: number, instance: RoomInstance | null) {
function presenceUpdateMessage(
playerId: number,
instance: RoomInstance | null,
appVersion?: string | null
) {
return {
playerId,
statusVisibility: 0,
@@ -318,7 +440,10 @@ function presenceUpdateMessage(playerId: number, instance: RoomInstance | null)
vrMovementMode: 0,
roomInstance: instance ? redactInstanceForPresence(instance) : null,
isOnline: instance != null,
appVersion: GAME_VERSION,
// The build the SUBJECT is running, off the presence row their own token wrote —
// this frame describes them to their friends, so GAME_VERSION would report this
// server's build as theirs.
appVersion: appVersion || GAME_VERSION,
}
}
@@ -339,7 +464,7 @@ async function notifyFriendsPresence(c: Context<App>, playerId: number): Promise
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
friendIds,
NotificationType.SubscriptionUpdatePresence,
presenceUpdateMessage(playerId, presence?.roomInstance ?? null)
presenceUpdateMessage(playerId, presence?.roomInstance ?? null, presence?.appVersion)
)
} catch (err) {
logger.error('failed to push SubscriptionUpdatePresence to friends', {
@@ -371,7 +496,10 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
deviceClass: prev?.deviceClass ?? account?.deviceClass ?? 0,
vrMovementMode: prev?.vrMovementMode ?? 1,
platform: prev?.platform ?? account?.platform ?? 0,
appVersion: prev?.appVersion || GAME_VERSION,
// The token's build wins over the stored one: the token belongs to the session
// making this call, while `prev` can be a row left by an earlier session on an
// older build.
appVersion: (await callerVersion(c)) ?? prev?.appVersion ?? GAME_VERSION,
// Carry the session lock recorded at login forward, so matchmake doesn't wipe it
// and the heartbeat can keep verifying against it.
loginLock: prev?.loginLock,
@@ -536,6 +664,7 @@ function instanceFieldsFromRoom(room: Room, subRoomId?: number) {
* same instance share them).
*/
function roomInstanceFromRoom(
env: Env,
room: Room,
isPrivate: boolean,
instanceId: number,
@@ -543,6 +672,8 @@ function roomInstanceFromRoom(
subRoomId?: number
) {
const f = instanceFieldsFromRoom(room, subRoomId)
// The same region the connection info names its Photon apps for — see photonApps.
const region = instancePhotonRegion(env)
return {
roomInstanceId: instanceId,
roomId: f.roomId,
@@ -553,8 +684,8 @@ function roomInstanceFromRoom(
eventId: 0,
clubId: 0,
roomCode: '',
photonRegion: 'us',
photonRegionId: 'us',
photonRegion: region,
photonRegionId: region,
photonRoomId,
name: f.name,
maxCapacity: f.maxCapacity,
@@ -565,46 +696,200 @@ function roomInstanceFromRoom(
}
}
/** Read the session's `LoginLock` GUID from a form body (undefined when absent/empty). */
async function readLoginLock(c: Context<App>): Promise<string | undefined> {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
return typeof body.LoginLock === 'string' && body.LoginLock ? body.LoginLock : undefined
}
/**
* The GUID a matchmake answers with when the request named none. The client's response
* DTO reads `correlationId` as a Guid, not a nullable one, so a null (or a missing key)
* is a decode failure on a field it checks before anything else — the all-zero
* `Guid.Empty` is what an unset Guid serializes as, and it reads as "no correlation".
*/
const EMPTY_CORRELATION_ID = '00000000-0000-0000-0000-000000000000'
/** 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
/**
* Read a matchmake request body as a flat field map, whichever way the client encoded it.
*
* The 2023 client posts form-urlencoded (`JoinMode=0`, every value a string, arrays sent
* as a repeated field). The v2 client posts JSON with real types instead —
* `{"JoinMode": 0, "AdditionalPlayerIds": null, "CorrelationId": "…"}` — so a body read
* that only knows `parseBody` sees an EMPTY body on every v2 matchmake: no correlation
* id echoed (the client then can't match the response to its attempt), no JoinMode, no
* party. Both encodings land here as a field map and the readers below coerce per field,
* so one set of readers serves both.
*
* Hono caches the parsed body, so the several readers that call this on one request
* parse it once.
*/
async function readRequestFields(c: Context<App>): Promise<Record<string, unknown>> {
const empty = {} as Record<string, unknown>
if ((c.req.header('content-type') ?? '').includes('application/json')) {
const parsed: unknown = await c.req.json().catch(() => null)
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: empty
}
return await c.req.parseBody({ all: true }).catch(() => empty)
}
/**
* Read the room-matchmake form body once: `JoinMode` (2 = private) plus the party members
* to pull along (`AdditionalPlayerIds`). The 2023 client posts its party on a room
* matchmake so they can be invited into the instance the leader lands in;
* `AdditionalPlayerIds` is a repeated field (one id each, never comma-separated), and
* ids are parsed defensively, de-duplicated, and non-positive/garbage entries dropped.
* Parsed with `{ all: true }` in one pass so the repeated fields survive.
* Look a field up case-insensitively — the client's casing isn't guaranteed across its
* surfaces — taking the first value when the form encoding repeated it.
*/
function field(fields: Record<string, unknown>, name: string): unknown {
const key = Object.keys(fields).find((k) => k.toLowerCase() === name.toLowerCase())
const value = key === undefined ? undefined : fields[key]
return Array.isArray(value) ? value[0] : value
}
/** A field as a non-empty string (JSON sends one directly; form sends everything as one). */
function fieldString(fields: Record<string, unknown>, name: string): string | undefined {
const value = field(fields, name)
return typeof value === 'string' && value ? value : undefined
}
/** A field as an integer — JSON sends a number, form sends its decimal spelling. */
function fieldInt(fields: Record<string, unknown>, name: string): number | undefined {
const value = field(fields, name)
if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : undefined
if (typeof value !== 'string') return undefined
const parsed = Number.parseInt(value.trim(), 10)
return Number.isNaN(parsed) ? undefined : parsed
}
/**
* The `CorrelationId` the client tagged this matchmake with. The client generates one
* per matchmake attempt and refuses the session ("Unable to connect to game session")
* unless the response carries the same GUID back, so this is echoed on EVERY matchmake
* response — the refusals included, since a refusal the client can't correlate is a
* matchmake it goes on waiting for.
*
* Read out of the form body (`CorrelationId=<guid>`, what the client posts), falling
* back to a query param for the matchmakes that carry no body, and matched
* case-insensitively like the other reverse-engineered fields here. `EMPTY_CORRELATION_ID`
* when the request named none — an older client that doesn't send one still gets a
* well-formed GUID rather than a null its decoder would choke on.
*/
async function readCorrelationId(c: Context<App>): Promise<string> {
const posted = fieldString(await readRequestFields(c), 'CorrelationId')
if (posted !== undefined) return posted
const queried = c.req.query('CorrelationId') ?? c.req.query('correlationId')
return queried || EMPTY_CORRELATION_ID
}
/**
* How the instance is matched into, echoed on every v2 instance. The reference server
* sends 0 and this server has no policy to express, so it is a constant — kept as a
* named field rather than dropped, because the client's decoder wants the key.
*/
const DEFAULT_MATCHMAKING_POLICY = 0
/**
* The v2 client's projection of an instance: PascalCase, and a SUBSET of the fields the
* older wire shape carries. The reference server's v2 response has no `DataBlob` and no
* Photon coordinates (`PhotonRegion`/`PhotonRegionId`/`PhotonRoomId`) at all, and adds
* `MatchmakingPolicy`; this mirrors it exactly rather than PascalCasing the v1 object,
* because sending fields the reference doesn't send is how you find out the hard way
* that the client reads one of them.
*
* Only the wire shape differs — the instance itself is the same row, so a v1 and a v2
* client asking for the same public room land in the same instance.
*/
function toV2RoomInstance(instance: RoomInstance) {
return {
RoomInstanceId: instance.roomInstanceId,
RoomId: instance.roomId,
SubRoomId: instance.subRoomId,
Location: instance.location,
EventId: instance.eventId,
ClubId: instance.clubId,
RoomCode: instance.roomCode,
Name: instance.name,
MaxCapacity: instance.maxCapacity,
IsFull: instance.isFull,
IsPrivate: instance.isPrivate,
IsInProgress: instance.isInProgress,
EncryptVoiceChat: instance.EncryptVoiceChat,
RoomInstanceType: instance.roomInstanceType,
MatchmakingPolicy: DEFAULT_MATCHMAKING_POLICY,
}
}
/**
* A matchmake response: the join-result code, the instance (null on a refusal), and the
* request's correlation id echoed back. Every matchmake route answers through here, so
* none of them can forget the echo.
*
* `result` and `errorCode` are the SAME code under two names. The client reads `result`
* first; `errorCode` is kept because that's what this server has always sent and what
* every other consumer (and this worker's own tests) reads. They must never disagree —
* that's why nothing builds this envelope by hand.
*
* The `/matchmake/v2/*` routes answer a different envelope — PascalCase `ErrorCode`,
* `CorrelationId`, `RoomInstance`, no `result` twin — so the shape is chosen HERE, off
* the request path, rather than in the handlers. That keeps the two spellings from
* drifting and, more importantly, means everything that answers on a v2 path answers in
* v2: the ban gate, a refusal, and the success all go through this one function.
*/
async function matchmakeResult(
c: Context<App>,
errorCode: MatchmakingErrorCode | number,
roomInstance: RoomInstance | null
) {
const correlationId = await readCorrelationId(c)
if (c.req.path.startsWith('/matchmake/v2/')) {
return c.json({
ErrorCode: errorCode,
CorrelationId: correlationId,
RoomInstance: roomInstance === null ? null : toV2RoomInstance(roomInstance),
})
}
return c.json({
errorCode,
result: errorCode,
roomInstance,
correlationId,
})
}
/** Read the session's `LoginLock` GUID from the body (undefined when absent/empty). */
async function readLoginLock(c: Context<App>): Promise<string | undefined> {
return fieldString(await readRequestFields(c), 'LoginLock')
}
/** Read the `JoinMode` field (2 = private instance). */
async function readJoinMode(c: Context<App>): Promise<number> {
return fieldInt(await readRequestFields(c), 'JoinMode') ?? 0
}
/**
* Read the room-matchmake body once: `JoinMode` (2 = private) plus the party members to
* pull along (`AdditionalPlayerIds`). The 2023 client posts its party on a room matchmake
* so they can be invited into the instance the leader lands in; there it's a REPEATED form
* field (one id each, never comma-separated), while the v2 client sends a JSON array — or
* `null` when the player is alone, which is not an empty array and must not parse as one
* bad id. Ids are parsed defensively, de-duplicated, and non-positive/garbage dropped.
*/
async function readMatchmakeBody(
c: Context<App>
): Promise<{ joinMode: number; additionalPlayerIds: number[] }> {
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const firstString = (v: unknown): string | undefined => {
const first = Array.isArray(v) ? v[0] : v
return typeof first === 'string' ? first : undefined
}
const joinModeRaw = firstString(body.JoinMode)
const joinMode = joinModeRaw === undefined ? 0 : Number.parseInt(joinModeRaw, 10) || 0
const body = await readRequestFields(c)
const joinMode = fieldInt(body, 'JoinMode') ?? 0
const key = Object.keys(body).find((k) => k.toLowerCase() === 'additionalplayerids')
const raw = key === undefined ? [] : body[key]
const values = Array.isArray(raw) ? raw : [raw]
const raw = key === undefined ? null : body[key]
// Repeated form field or JSON array → the values; a lone value → a one-element list;
// null/absent → nothing.
const values = Array.isArray(raw) ? raw : raw === null || raw === undefined ? [] : [raw]
const additionalPlayerIds = [
...new Set(
values
.filter((v): v is string => typeof v === 'string')
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n) && n > 0)
.map((v) =>
typeof v === 'number'
? Math.trunc(v)
: typeof v === 'string'
? Number.parseInt(v.trim(), 10)
: Number.NaN
)
.filter((n) => Number.isFinite(n) && n > 0)
),
]
return { joinMode, additionalPlayerIds }
@@ -769,6 +1054,7 @@ async function resolveRoomInstance(
}
return {
instance: roomInstanceFromRoom(
c.env,
room,
isPrivate,
instance.roomInstanceId,
@@ -779,6 +1065,36 @@ async function resolveRoomInstance(
}
}
/**
* The room matchmake, shared by the 2023 client's `/matchmake/room/{roomId}` (plus its
* `/{subRoomId}` form) and the newer client's `/matchmake/v2/…` spelling of the same two
* routes. The v2 paths behave identically for now — the client sends the same body and
* reads the same envelope back — so they are the same handler under a second path rather
* than a copy that can drift.
*
* `subRoomId` is optional: absent, `resolveRoomInstance` falls back to the room's first
* subroom (its default entrance).
*/
async function matchmakeIntoRoom(c: Context<App>) {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
const rawSubRoomId = c.req.param('subRoomId')
const subRoomId = rawSubRoomId === undefined ? undefined : Number.parseInt(rawSubRoomId, 10)
const { instance, errorCode } = await resolveRoomInstance(
c,
c.req.param('roomId') ?? '',
joinMode === 2,
id,
subRoomId
)
if (!instance) return matchmakeResult(c, errorCode, null)
await enterRoom(c, id, instance)
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
await inviteParty(c, id, additionalPlayerIds, instance)
return matchmakeResult(c, 0, instance)
}
/**
* The authed player's personal dorm instance. Gets-or-creates their dorm room,
* then backs it with a single persistent private `room_instance` so the dorm has
@@ -805,7 +1121,7 @@ async function playerDormInstance(c: Context<App>, accountId: number): Promise<R
roomInstanceType: f.roomInstanceType,
})
}
return roomInstanceFromRoom(room, true, instance.roomInstanceId, instance.photonRoomId)
return roomInstanceFromRoom(c.env, room, true, instance.roomInstanceId, instance.photonRoomId)
}
const app = new Hono<App>()
@@ -852,7 +1168,7 @@ const app = new Hono<App>()
bannedAccountId: match.bannedAccountId,
path: c.req.path,
})
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
return matchmakeResult(c, BANNED_FROM_ROOM, null)
}
}
await next()
@@ -899,7 +1215,7 @@ const app = new Hono<App>()
deviceClass: account?.deviceClass ?? 0,
vrMovementMode: 1,
platform: account?.platform ?? 0,
appVersion: GAME_VERSION,
appVersion: (await callerVersion(c)) ?? GAME_VERSION,
loginLock,
})
}
@@ -1068,6 +1384,12 @@ const app = new Hono<App>()
// yields no lock (parseBody fails and is swallowed).
const postedLock = await readLoginLock(c)
// The build this session is on, off its own token (see callerVersion). The
// heartbeat is where a version change shows up first: a player who quit and
// relaunched on a new build heartbeats with a new token against the presence row
// the old session left behind.
const version = await callerVersion(c)
// Return the player's stored presence (set at login/matchmake), mirroring the
// reference server's HeartbeatDB.GetPlayerHeartbeat. No presence → the player
// isn't in a room yet, so roomInstance=null / isOnline=false.
@@ -1084,16 +1406,23 @@ const app = new Hono<App>()
return c.body(null, 200)
}
// The heartbeat's only side effect is refreshing the TTL, and only once it's
// within PRESENCE_REFRESH_THRESHOLD (s) of lapsing — a still player is refreshed
// periodically rather than re-written on every beat. `expiresAt` is epoch seconds.
// Adopt the token's build when it differs from what the row holds, so the
// version friends see follows the player onto their new build rather than
// waiting for a re-matchmake.
const versionChanged = version !== null && presence.appVersion !== version
if (versionChanged) presence.appVersion = version
// Otherwise the heartbeat's only side effect is refreshing the TTL, and only
// once it's within PRESENCE_REFRESH_THRESHOLD (s) of lapsing — a still player is
// refreshed periodically rather than re-written on every beat. `expiresAt` is
// epoch seconds.
const nowSeconds = Math.floor(Date.now() / 1000)
if (presence.expiresAt - nowSeconds <= PRESENCE_REFRESH_THRESHOLD) {
if (versionChanged || presence.expiresAt - nowSeconds <= PRESENCE_REFRESH_THRESHOLD) {
await setPresence(c.env.DB, presence)
}
}
return c.json(playerPayload(id, presence))
return c.json(playerPayload(id, presence, version))
}
)
@@ -1193,6 +1522,10 @@ const app = new Hono<App>()
// Each matchmake persists the resulting instance as the player's presence so the
// heartbeat can replay it (keeping client presence in sync).
//
// Every route here answers through `matchmakeResult`, which stamps the response with
// the `result` code and echoes back the request's `CorrelationId` — the client won't
// accept a session it can't correlate to the attempt it made.
//
// Matchmake into a club's clubhouse (`/matchmake/club/{clubId}`). Registered before
// the single-segment `/matchmake/:room` route so `club` isn't read as a room name.
// Members only: the clubhouse is the club's private space, so a non-member (or
@@ -1236,9 +1569,9 @@ const app = new Hono<App>()
// One response for "no such club", "no clubhouse", and "not a member": the
// client only needs "you're not going there", and a distinct code for the last
// case would tell a non-member which clubs exist and have a clubhouse.
if (!club?.clubhouseRoomId) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
if (!club?.clubhouseRoomId) return matchmakeResult(c, NO_SUCH_ROOM, null)
if (!(await isClubMember(c.env.DB, clubId, id))) {
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
return matchmakeResult(c, NO_SUCH_ROOM, null)
}
const joinMode = await readJoinMode(c)
@@ -1248,9 +1581,9 @@ const app = new Hono<App>()
joinMode === 2,
id
)
if (!instance) return c.json({ errorCode, roomInstance: null })
if (!instance) return matchmakeResult(c, errorCode, null)
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
return matchmakeResult(c, 0, instance)
}
)
@@ -1304,7 +1637,7 @@ const app = new Hono<App>()
const event = await getEventById(c.env.DB, eventId)
// Opaque, like the club path: an unknown event and one the caller can't see
// shouldn't be distinguishable by probing ids.
if (event === null) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
if (event === null) return matchmakeResult(c, NO_SUCH_ROOM, null)
const open =
event.Accessibility === Accessibility.Public ||
@@ -1318,7 +1651,7 @@ const app = new Hono<App>()
(await getEventResponse(c.env.DB, eventId, id)) === null
) {
logger.info('matchmake refused: not invited to private event', { eventId, id })
return c.json({ errorCode: EVENT_IS_PRIVATE, roomInstance: null })
return matchmakeResult(c, EVENT_IS_PRIVATE, null)
}
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
@@ -1329,10 +1662,10 @@ const app = new Hono<App>()
id,
event.SubRoomId ?? undefined
)
if (!instance) return c.json({ errorCode, roomInstance: null })
if (!instance) return matchmakeResult(c, errorCode, null)
await enterRoom(c, id, instance)
await inviteParty(c, id, additionalPlayerIds, instance)
return c.json({ errorCode: 0, roomInstance: instance })
return matchmakeResult(c, 0, instance)
}
)
@@ -1359,6 +1692,7 @@ const app = new Hono<App>()
'going through the room resolver, so it carries its own ban check.',
].join(' '),
security: AUTHED,
requestBody: form(CorrelationIdRequest, 'The attempts CorrelationId'),
parameters: [
{
name: 'playerId',
@@ -1384,26 +1718,26 @@ const app = new Hono<App>()
// Friends only, and never yourself — otherwise refuse without leaking whether the
// target is even online (same opaque NoSuchRoom the club path uses).
if (targetId === id || !(await areFriends(c.env.DB, id, targetId))) {
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
return matchmakeResult(c, NO_SUCH_ROOM, null)
}
// The instance the friend is currently in, straight off their presence row.
const targetPresence = await getPresence<RoomInstance>(c.env.DB, targetId)
const instance = targetPresence?.roomInstance ?? null
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
if (!instance) return matchmakeResult(c, NO_SUCH_ROOM, null)
// This path hands out a Photon room id without going through
// resolveRoomInstance, so the room's bans have to be checked here too —
// otherwise following a friend in is a way around a ban.
if (await isPlayerBannedFromRoom(c.env.DB, instance.roomId, id)) {
logger.info('follow refused: player banned from room', { roomId: instance.roomId, id })
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
return matchmakeResult(c, BANNED_FROM_ROOM, null)
}
// Join that same instance (same id + Photon room) and store it as the caller's
// presence, so the heartbeat replays it and their own friend fan-out fires.
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
return matchmakeResult(c, 0, instance)
}
)
@@ -1430,6 +1764,7 @@ const app = new Hono<App>()
'when banned.',
].join(' '),
security: AUTHED,
requestBody: form(CorrelationIdRequest, 'The attempts CorrelationId'),
parameters: [
{
name: 'instanceId',
@@ -1455,16 +1790,16 @@ const app = new Hono<App>()
const stored = await getRoomInstance(c.env.DB, instanceId)
// One opaque refusal for "no such instance", "no such room" and "not yours":
// a distinct code for the last would confirm which instance ids are live.
if (!stored) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
if (!stored) return matchmakeResult(c, NO_SUCH_ROOM, null)
const room = await getRoomById(c.env.DB, stored.roomId)
if (!room) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
if (!room) return matchmakeResult(c, NO_SUCH_ROOM, null)
if (!canManageRoom(room, id)) {
logger.info('instance matchmake refused: not the rooms owner', {
roomInstanceId: instanceId,
roomId: stored.roomId,
accountId: id,
})
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
return matchmakeResult(c, NO_SUCH_ROOM, null)
}
// Like the follow-a-friend path, this hands out a Photon room id without going
@@ -1476,13 +1811,14 @@ const app = new Hono<App>()
roomId: stored.roomId,
id,
})
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
return matchmakeResult(c, BANNED_FROM_ROOM, null)
}
// Rebuild the wire instance from the room (fresh scene + published save) keyed to
// this instance's own id and Photon room, so the owner lands in exactly the
// session they picked rather than a new one alongside it.
const instance = roomInstanceFromRoom(
c.env,
room,
stored.isPrivate,
stored.roomInstanceId,
@@ -1490,7 +1826,7 @@ const app = new Hono<App>()
stored.subRoomId
)
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
return matchmakeResult(c, 0, instance)
}
)
@@ -1527,24 +1863,7 @@ const app = new Hono<App>()
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const { instance, errorCode } = await resolveRoomInstance(
c,
c.req.param('roomId'),
joinMode === 2,
id,
subRoomId
)
if (!instance) return c.json({ errorCode, roomInstance: null })
await enterRoom(c, id, instance)
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
await inviteParty(c, id, additionalPlayerIds, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
matchmakeIntoRoom
)
// The 2023 client uses a two-segment matchmake/room/{roomId}. Look the room up
@@ -1569,23 +1888,113 @@ const app = new Hono<App>()
401: UNAUTHORIZED_RESPONSE,
},
}),
matchmakeIntoRoom
)
// The newer client asks for the same two room matchmakes under a `/v2/` prefix
// (`/matchmake/v2/room/{roomId}` and `/matchmake/v2/room/{roomId}/{subRoomId}`). The
// MATCHMAKING is the same — same rooms, same instances, same refusals, so a v1 and a
// v2 player asking for the same public room stand in the same place — and so these
// share the handler. What differs is the wire on both ends: the request is JSON with
// real types rather than a urlencoded form, and the response is the PascalCase
// envelope (`ErrorCode`/`CorrelationId`/`RoomInstance`, no `result` twin, no Photon
// coordinates or DataBlob on the instance, plus `MatchmakingPolicy`). Neither is
// handled here: `readRequestFields` takes either encoding and `matchmakeResult` picks
// the envelope off the path, so the ban gate's refusal is a v2 refusal too.
//
// Registered after the unprefixed pair (order is irrelevant to matching: `v2` is a
// literal second segment, so nothing else can claim these paths).
.post(
'/matchmake/v2/room/:roomId/:subRoomId{[0-9]+}',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake into a specific subroom (v2)',
description: [
'The newer clients `/v2/` spelling of the subroom matchmake. Enters the same',
'instances as `POST /matchmake/room/{roomId}/{subRoomId}`; the body is JSON and the',
'response is the PascalCase v2 envelope.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(MatchmakeRoomV2Request, 'Optional JoinMode and AdditionalPlayerIds'),
parameters: [
{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } },
{
name: 'subRoomId',
in: 'path',
required: true,
description: 'Subroom id (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(
MatchmakeV2Response,
'The instance (or a null RoomInstance with ErrorCode 20 on an unknown room, 55 when banned)'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
matchmakeIntoRoom
)
.post(
'/matchmake/v2/room/:roomId',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake into a room (v2, default subroom)',
description: [
'The newer clients `/v2/` spelling of the room matchmake. Enters the same instances',
'as `POST /matchmake/room/{roomId}`; the body is JSON and the response is the',
'PascalCase v2 envelope.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(MatchmakeRoomV2Request, 'Optional JoinMode and AdditionalPlayerIds'),
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
200: json(
MatchmakeV2Response,
'The instance (or a null RoomInstance with ErrorCode 20 on an unknown room, 55 when banned)'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
matchmakeIntoRoom
)
// 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,
requestBody: form(CorrelationIdRequest, 'The attempts CorrelationId'),
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 { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
const { instance, errorCode } = await resolveRoomInstance(
c,
c.req.param('roomId'),
joinMode === 2,
id
)
if (!instance) return c.json({ errorCode, roomInstance: null })
await enterRoom(c, id, instance)
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
await inviteParty(c, id, additionalPlayerIds, instance)
return c.json({ errorCode: 0, roomInstance: instance })
const presence = await getPresence<RoomInstance>(c.env.DB, id)
const current = presence?.roomInstance ?? (await playerDormInstance(c, id))
await enterRoom(c, id, current)
return matchmakeResult(c, 0, current)
}
)
.post(
'/matchmake/dorm',
describeRoute({
@@ -1598,6 +2007,7 @@ const app = new Hono<App>()
'account is banned: a ban keeps a player out of their own dorm too.',
].join(' '),
security: AUTHED,
requestBody: form(CorrelationIdRequest, 'The attempts CorrelationId'),
responses: {
200: json(MatchmakeResponse, 'The dorm instance (or a null instance with errorCode 55)'),
401: UNAUTHORIZED_RESPONSE,
@@ -1609,10 +2019,113 @@ const app = new Hono<App>()
const instance = await playerDormInstance(c, id)
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
return matchmakeResult(c, 0, instance)
}
)
// 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 apps = photonApps(c.env)
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: apps.photonRealtimeAppId,
},
await c.env.JWT_SECRET.get()
)
return c.json({
success: true,
value: {
photonAuthToken,
...apps,
photonRoomId,
// Empty strings rather than null: there's no separate voice server either
// way, and the client's decoder is likelier to accept a missing-value string
// than a null on a string field. The presence payload's
// NULL_CONNECTION_INFO keeps its nulls — that one never carries credentials.
voiceConnectionInfo: '',
voiceServerId: '',
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',