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
+22
View File
@@ -38,6 +38,28 @@ export type Env = SharedHonoEnv & {
* touching the client. See `roomRedirects` in match.app.ts.
*/
ROOM_REDIRECTS?: string
/**
* The Photon Realtime application id the client connects to, and the app the Photon auth
* token is minted for (`GET /player/connection-info`). Optional, and EMPTY when unset:
* this repo ships no Photon application, so a deployment that wants working networking
* has to name its own.
*
* Not a secret — the client is handed all three ids in the clear — so these are plain
* vars rather than Secrets Store entries.
*/
PHOTON_REALTIME_APP_ID?: string
/** The Photon Voice application id. Optional; see {@link Env.PHOTON_REALTIME_APP_ID}. */
PHOTON_VOICE_APP_ID?: string
/** The Photon Chat application id. Optional; see {@link Env.PHOTON_REALTIME_APP_ID}. */
PHOTON_CHAT_APP_ID?: string
/**
* The Photon region every session is pinned to — both the region named in the connection
* info and the one stamped on every room instance, which must agree. Optional; unlike the
* app ids this DOES default (`us`, us-east1 in the QoS list), because an instance stamped
* with an empty region is one the client can't connect to. One deployment, one region:
* the QoS pings the client reports are ranked but never acted on here.
*/
PHOTON_REGION?: string
/**
* Which linked arms a ban is enforced through, as a comma-separated list out of `ip`
* and `platform` — or `off` for neither. Unset means BOTH: a ban reaches the accounts
+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',
+160 -7
View File
@@ -140,14 +140,24 @@ export const PlayerDto = z.object({
})
/**
* The matchmake result envelope. `errorCode` 0 with a `roomInstance` is success;
* a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
* The matchmake result envelope. Code 0 with a `roomInstance` is success; a non-zero
* code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
*
* `result` and `errorCode` are the same code under two names: `result` is what the
* client reads, `errorCode` is what this server has always sent (and what its own tests
* read), so both are served and they always agree. `correlationId` echoes the
* `CorrelationId` the request was tagged with — without it the client never matches the
* response to the attempt and fails with "Unable to connect to game session".
*/
export const MatchmakeResponse = z.object({
result: z.int().describe('The join-result code the client checks first; same as errorCode'),
errorCode: z
.int()
.describe('0 = success; 20 = NoSuchRoom; 55 = banned from the room (the one non-opaque code)'),
roomInstance: RoomInstanceDto.nullable(),
correlationId: z
.string()
.describe('Echoes the requests CorrelationId; all-zero GUID when it sent none'),
})
/**
@@ -170,6 +180,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.literal('').describe('Empty — no separate voice server'),
voiceServerId: z.literal('').describe('Empty — 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`,
@@ -199,11 +268,27 @@ export const NotifyDisconnectRequest = z.object({
RoomInstanceId: z.string().describe('The room instance they dropped'),
})
/**
* The `CorrelationId` every matchmake carries — a GUID the client generates per attempt
* and expects back on the response (see `MatchmakeResponse`). Posted in the form body;
* the field is matched case-insensitively and a query param is accepted too, for the
* matchmakes that post no body at all.
*
* This is the whole body of the target-less matchmakes (`/matchmake/dorm`,
* `/matchmake/none`, `/matchmake/player/:id`, `/matchmake/instance/:id`), which is why
* it's a schema of its own; the room matchmakes extend it. Other fields the client sends
* (`LoginLock`, `MaxPersistenceVersion`, `VoiceServerVersion`,
* `BypassMovementModeRestriction`) are accepted and ignored.
*/
export const CorrelationIdRequest = z.object({
CorrelationId: z.string().optional().describe('Per-attempt GUID; echoed on the response'),
})
/**
* The `JoinMode` form field the matchmake routes read (`2` = a private instance;
* anything else = public). Posted as a urlencoded/multipart body.
*/
export const JoinModeRequest = z.object({
export const JoinModeRequest = CorrelationIdRequest.extend({
JoinMode: z.string().optional().describe('"2" requests a private instance'),
})
@@ -211,11 +296,12 @@ export const JoinModeRequest = z.object({
* The room-matchmake form body (`/matchmake/room/:roomId[/:subRoomId]`). Beyond
* `JoinMode` the 2023 client posts `AdditionalPlayerIds` — the caller's party — so each
* of them is invited (a game invite) into the instance the leader lands in. It's a
* repeated field (one id each, not comma-separated). Other fields the client sends
* (`LoginLock`, `MaxPersistenceVersion`, `BypassMovementModeRestriction`) are accepted
* and ignored.
* repeated field (one id each, not comma-separated). `CorrelationId` rides along as it
* does on every matchmake. Other fields the client sends (`LoginLock`,
* `MaxPersistenceVersion`, `VoiceServerVersion`, `BypassMovementModeRestriction`) are
* accepted and ignored.
*/
export const MatchmakeRoomRequest = z.object({
export const MatchmakeRoomRequest = CorrelationIdRequest.extend({
JoinMode: z.string().optional().describe('"2" requests a private instance'),
AdditionalPlayerIds: z
.string()
@@ -223,6 +309,73 @@ export const MatchmakeRoomRequest = z.object({
.describe('Party members to invite into the room; repeated once per id'),
})
/**
* The v2 room-matchmake body (`/matchmake/v2/room/:roomId[/:subRoomId]`). Unlike the 2023
* client's urlencoded form, the newer client posts JSON with real types: `JoinMode` is a
* number, and `AdditionalPlayerIds` is an array — `null`, not `[]`, when the player is
* alone. Only `JoinMode`, `AdditionalPlayerIds` and `CorrelationId` are read; the rest are
* accepted and ignored, and are recorded here because they are what the client actually
* sends.
*/
export const MatchmakeRoomV2Request = z.object({
CorrelationId: z.string().optional().describe('Per-attempt GUID; echoed on the response'),
JoinMode: z.int().optional().describe('2 requests a private instance'),
AdditionalPlayerIds: z
.array(z.int())
.nullable()
.optional()
.describe('Party members to invite into the room; null when the player is alone'),
InviteMode: z.int().optional(),
ShouldKeepPlayerWithParty: z.boolean().optional(),
BypassMovementModeRestriction: z.boolean().optional(),
MaxPersistenceVersion: z.int().optional(),
Ugc1SubVersion: z.int().optional(),
Ugc2SubVersion: z.int().optional(),
VoiceServerVersion: z.string().optional(),
LoginLock: z.string().optional(),
ClientJoinData: z.string().nullable().optional(),
PlayerScores: z.unknown().optional(),
})
/**
* The v2 client's room instance: PascalCase, and a SUBSET of `RoomInstanceDto`. The
* reference server's v2 response carries no `DataBlob` and no Photon coordinates at all,
* and adds `MatchmakingPolicy` — this mirrors it field for field. The instance behind it
* is the same row a v1 matchmake answers with; only the projection differs.
*/
export const RoomInstanceV2Dto = z.object({
RoomInstanceId: z.int(),
RoomId: z.int(),
SubRoomId: z.int(),
Location: z.string().describe('SubRoom Unity scene id; empty is rejected by the client'),
EventId: z.int(),
ClubId: z.int(),
RoomCode: z.string(),
Name: z.string(),
MaxCapacity: z.int(),
IsFull: z.boolean(),
IsPrivate: z.boolean(),
IsInProgress: z.boolean(),
EncryptVoiceChat: z.boolean(),
RoomInstanceType: RoomInstanceType,
MatchmakingPolicy: z.int().describe('Always 0; this server has no policy to express'),
})
/**
* The v2 matchmake envelope. Same codes and the same correlation-id echo as
* `MatchmakeResponse`, but PascalCase and with no `result` twin — the v2 client reads
* `ErrorCode`. Refusals answer `RoomInstance: null` with a non-zero `ErrorCode`.
*/
export const MatchmakeV2Response = z.object({
ErrorCode: z
.int()
.describe('0 = success; 20 = NoSuchRoom; 55 = banned from the room (the one non-opaque code)'),
CorrelationId: z
.string()
.describe('Echoes the requests CorrelationId; all-zero GUID when it sent none'),
RoomInstance: RoomInstanceV2Dto.nullable(),
})
/** `POST /invite` form body — invite a player into the caller's room instance. */
export const InviteRequest = z.object({
playerId: z.string().describe('The account to invite; a non-zero integer (else 400)'),
+499 -37
View File
@@ -38,6 +38,24 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
/** What a matchmake answers with when the request named no `CorrelationId`. */
const EMPTY_CORRELATION_ID = '00000000-0000-0000-0000-000000000000'
/**
* A refused matchmake, whole: the code under both names the client reads (`result` and
* the legacy `errorCode`, always equal), a null instance, and the correlation echo — an
* empty GUID here, since these requests carry no `CorrelationId`. A refusal has to
* correlate too, or the client goes on waiting for a response it never matches up.
*/
function refused(code: number) {
return {
errorCode: code,
result: code,
roomInstance: null,
correlationId: EMPTY_CORRELATION_ID,
}
}
// 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'
@@ -242,10 +260,13 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub = '42'): Promise<Record<string, string>> {
// `version` mints the `rn.ver` claim auth stamps from the client's posted `ver`; left
// off, the token carries none — which is what a token issued before the claim carried the
// client's own build looks like to presence.
async function bearer(sub = '42', version?: string): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
JSON.stringify({ sub, exp: now + 3600, ...(version && { 'rn.ver': version }) })
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -592,6 +613,136 @@ describe('public endpoints', () => {
expect(unknown).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE })
})
test('the /matchmake/v2 routes take a JSON body and answer the PascalCase envelope', async () => {
// The newer client posts JSON with real types (JoinMode a number, AdditionalPlayerIds
// null when alone) and reads back `ErrorCode`/`CorrelationId`/`RoomInstance`.
type V2Instance = {
RoomInstanceId: number
RoomId: number
SubRoomId: number
Location: string
MaxCapacity: number
IsPrivate: boolean
RoomInstanceType: number
MatchmakingPolicy: number
}
type V2Body = { ErrorCode: number; CorrelationId: string; RoomInstance: V2Instance | null }
const correlationId = 'e3f1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b'
const matchmake = async (
path: string,
player: string,
body: Record<string, unknown> = {}
): Promise<V2Body> => {
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(player)), 'Content-Type': 'application/json' },
// The client's real body, verbatim.
body: JSON.stringify({
AdditionalPlayerIds: null,
BypassMovementModeRestriction: false,
MaxPersistenceVersion: 12,
Ugc1SubVersion: 0,
Ugc2SubVersion: 0,
VoiceServerVersion: '1.0',
LoginLock: EMPTY_CORRELATION_ID,
ClientJoinData: null,
CorrelationId: correlationId,
JoinMode: 0,
InviteMode: 0,
ShouldKeepPlayerWithParty: true,
PlayerScores: null,
...body,
}),
})
expect(res.status).toBe(200)
return (await res.json()) as V2Body
}
const room = await matchmake('/matchmake/v2/room/2', '8901')
expect(room.ErrorCode).toBe(0)
// The JSON body's CorrelationId is read and echoed — a form-only body read would
// have lost it here and the client would never match the response to its attempt.
expect(room.CorrelationId).toBe(correlationId)
expect(room.RoomInstance).toMatchObject({
RoomId: 2,
Location: RECCENTER_SCENE,
IsPrivate: false,
MatchmakingPolicy: 0,
})
// The v2 instance is a strict field set: no camelCase twins, no DataBlob, no Photon
// coordinates (the reference server sends none).
expect(Object.keys(room.RoomInstance!).sort()).toEqual(
[
'ClubId',
'EncryptVoiceChat',
'EventId',
'IsFull',
'IsInProgress',
'IsPrivate',
'Location',
'MaxCapacity',
'MatchmakingPolicy',
'Name',
'RoomCode',
'RoomId',
'RoomInstanceId',
'RoomInstanceType',
'SubRoomId',
].sort()
)
// ...and the envelope has no `result`/`roomInstance` camelCase twins either.
expect(Object.keys(room).sort()).toEqual(['CorrelationId', 'ErrorCode', 'RoomInstance'])
// By name, and the subroom form carries the subroom through.
expect((await matchmake('/matchmake/v2/room/RecCenter', '8902')).RoomInstance).toMatchObject({
RoomId: 2,
})
expect((await matchmake('/matchmake/v2/room/77/35', '8903')).RoomInstance).toMatchObject({
RoomId: 77,
SubRoomId: 35,
Location: SECOND_SUBROOM_SCENE,
})
// JoinMode is a NUMBER here: 2 still means a private instance.
const priv = await matchmake('/matchmake/v2/room/2', '8905', { JoinMode: 2 })
expect(priv.RoomInstance).toMatchObject({ IsPrivate: true })
// A v2 and a v1 player asking for the same public room land in the SAME instance —
// only the wire shape differs. Its own room, so the two players it seats don't count
// against another test's capacity.
await seedRoomWithSubRooms(env.DB, {
RoomId: 79,
Name: 'V2Room',
IsDorm: false,
Accessibility: 1,
CreatorAccountId: 8907,
SubRooms: [{ SubRoomId: 37, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 10 }],
} as unknown as Record<string, unknown>)
const v1 = await exports.default.fetch(`${ORIGIN}/matchmake/room/79`, {
method: 'POST',
headers: {
...(await bearer('8906')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ JoinMode: '0' }).toString(),
})
const v1Instance = (
(await v1.json()) as { roomInstance: { roomInstanceId: number; roomId: number } }
).roomInstance
expect(v1Instance.roomId).toBe(79)
const v2 = await matchmake('/matchmake/v2/room/79', '8907')
expect(v2.RoomInstance!.RoomInstanceId).toBe(v1Instance.roomInstanceId)
// Refusals answer in v2 too, correlation id echoed — including the ban gate, which
// answers before any route runs.
const unknown = await matchmake('/matchmake/v2/room/99999', '8904')
expect(unknown).toEqual({ ErrorCode: 20, CorrelationId: correlationId, RoomInstance: null })
// Unauthenticated is a 401, not a matchmake refusal.
const anon = await exports.default.fetch(`${ORIGIN}/matchmake/v2/room/2`, { method: 'POST' })
expect(anon.status).toBe(401)
})
test('matchmaking serves the PUBLISHED save to everyone, creator included', async () => {
// The client offers the owner "latest or published" itself, from the
// `/subrooms/{id}/saves` list — matchmaking never picks. Serving a staged blob to
@@ -687,10 +838,7 @@ describe('public endpoints', () => {
['/matchmake/club/5', '120'],
['/matchmake/club/9999', '120'],
] as const) {
expect(await (await matchmake(path, sub)).json()).toEqual({
errorCode: 20,
roomInstance: null,
})
expect(await (await matchmake(path, sub)).json()).toEqual(refused(20))
}
// Signed out is a 401, not a matchmaking error.
@@ -732,10 +880,7 @@ describe('public endpoints', () => {
expect((await join('/matchmake/event/8', '302')).errorCode).toBe(0)
// A stranger doesn't — and is told why (35 EventIsPrivate), not fobbed off with 20.
expect(await join('/matchmake/event/8', '399')).toEqual({
errorCode: 35,
roomInstance: null,
})
expect(await join('/matchmake/event/8', '399')).toEqual(refused(35))
// Public and unlisted are open to anyone: unlisted only keeps an event out of the
// listings, it doesn't close it.
@@ -743,10 +888,7 @@ describe('public endpoints', () => {
expect((await join('/matchmake/event/10', '399')).errorCode).toBe(0)
// An unknown event is the opaque NoSuchRoom, so ids can't be probed.
expect(await join('/matchmake/event/9999', '399')).toEqual({
errorCode: 20,
roomInstance: null,
})
expect(await join('/matchmake/event/9999', '399')).toEqual(refused(20))
// Signed out is a 401, not a matchmaking error.
expect((await matchmake('/matchmake/event/9')).status).toBe(401)
@@ -777,7 +919,7 @@ describe('public endpoints', () => {
headers: await bearer('88'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ errorCode: 20, roomInstance: null })
expect(await res.json()).toEqual(refused(20))
})
test('ROOM_REDIRECTS switches a matchmake out to another room', async () => {
@@ -858,6 +1000,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 +1060,164 @@ 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-]+$/),
// Empty until the operator names their own Photon apps — this server ships none,
// so there is no id to hand out.
photonRealtimeAppId: '',
photonVoiceAppId: '',
photonChatAppId: '',
// Matches the region every room instance is stamped with. This one DOES have a
// default: an instance stamped with an empty region can't be connected to.
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,
// Empty strings, not nulls — unlike the presence payload's connection fields,
// which stay null (they never carry credentials).
voiceConnectionInfo: '',
voiceServerId: '',
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('the Photon apps and region come from the operators vars', async () => {
// Unset, every value is the shipped default — asserted by the test above. Set, the
// vars win, and the region has to reach BOTH the connection info and the instance:
// the client authenticates against the app named here and connects to the region on
// its instance, so a mismatch is a session it can't join.
const original = {
realtime: env.PHOTON_REALTIME_APP_ID,
voice: env.PHOTON_VOICE_APP_ID,
chat: env.PHOTON_CHAT_APP_ID,
region: env.PHOTON_REGION,
}
try {
env.PHOTON_REALTIME_APP_ID = '11111111-1111-4111-8111-111111111111'
env.PHOTON_VOICE_APP_ID = '22222222-2222-4222-8222-222222222222'
env.PHOTON_CHAT_APP_ID = '33333333-3333-4333-8333-333333333333'
env.PHOTON_REGION = 'eu'
const matchmaked = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('961'),
})
).json()) as { roomInstance: { photonRegion: string; photonRegionId: string } }
expect(matchmaked.roomInstance).toMatchObject({ photonRegion: 'eu', photonRegionId: 'eu' })
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('961'),
})
expect((await res.json()) as { value: Record<string, unknown> }).toMatchObject({
value: {
photonRealtimeAppId: '11111111-1111-4111-8111-111111111111',
photonVoiceAppId: '22222222-2222-4222-8222-222222222222',
photonChatAppId: '33333333-3333-4333-8333-333333333333',
photonRegion: 'eu',
},
})
// A whitespace-only var is not a value: the app id reads as unset (empty) rather
// than as a blank-but-present id, and the region falls back to its default.
env.PHOTON_REALTIME_APP_ID = ' '
env.PHOTON_REGION = ' '
const blank = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('961'),
})
expect((await blank.json()) as { value: Record<string, unknown> }).toMatchObject({
value: { photonRealtimeAppId: '', photonRegion: 'us' },
})
} finally {
env.PHOTON_REALTIME_APP_ID = original.realtime
env.PHOTON_VOICE_APP_ID = original.voice
env.PHOTON_CHAT_APP_ID = original.chat
env.PHOTON_REGION = original.region
}
})
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. Asserted as agreement
// rather than a pinned literal: PHOTON_APPS is hardcoded until it moves to wrangler
// vars, and a token minted for a different app than the client is handed is the bug
// worth catching here.
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 +1271,97 @@ describe('auth-gated endpoints', () => {
})
})
test('a matchmake echoes the requests CorrelationId (and mirrors errorCode as result)', async () => {
// The client tags each attempt with a GUID and won't accept a session whose
// response doesn't carry the same one back ("Unable to connect to game session").
const correlationId = 'b71abbbb-93e1-4d67-94da-64e6f554863a'
const dorm = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: {
...(await bearer('43')),
'content-type': 'application/x-www-form-urlencoded',
},
// Verbatim from the client, unread fields included.
body: `BypassMovementModeRestriction=False&LoginLock=40bacd8f-7c60-4d49-93f9-462b096602de&VoiceServerVersion=gameserver-2&CorrelationId=${correlationId}&MaxPersistenceVersion=227`,
})
).json()) as { errorCode: number; result: number; correlationId: string }
expect(dorm.correlationId).toBe(correlationId)
// Both names for the one code, always in agreement.
expect(dorm.result).toBe(0)
expect(dorm.errorCode).toBe(0)
// A room matchmake echoes it too, and so does a refusal — a refused attempt the
// client can't correlate is one it goes on waiting for.
const room = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/999999`, {
method: 'POST',
headers: {
...(await bearer('43')),
'content-type': 'application/x-www-form-urlencoded',
},
body: `JoinMode=0&CorrelationId=${correlationId}`,
})
).json()) as { errorCode: number; result: number; roomInstance: null; correlationId: string }
expect(room).toEqual({
errorCode: 20,
result: 20,
roomInstance: null,
correlationId,
})
})
test('a matchmake with no CorrelationId answers the empty GUID, not null', async () => {
// The client reads correlationId as a Guid, not a nullable one — an older client
// that sends none still has to get a parseable value back.
const body = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer('43'),
})
).json()) as { correlationId: string }
expect(body.correlationId).toBe(EMPTY_CORRELATION_ID)
})
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.
@@ -1051,6 +1461,59 @@ describe('auth-gated endpoints', () => {
})
})
// The build a player reports is the one their TOKEN carries (`rn.ver`, from the `ver`
// they posted to /connect/token) — not this server's GAME_VERSION, which is only the
// fallback for a token that names none.
test('presence reports the build from the callers token', async () => {
const headers = await bearer('9710', '20250718.01')
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
).json()) as { appVersion: string }
expect(hb.appVersion).toBe('20250718.01')
// And it is what everyone else sees of them, since it was written to the row.
const [player] = (await (
await exports.default.fetch(`${ORIGIN}/player?id=9710`)
).json()) as Array<{ appVersion: string }>
expect(player.appVersion).toBe('20250718.01')
})
// A player who quit and relaunched on a new build heartbeats with a NEW token against
// the row the old session left behind; the heartbeat adopts it rather than waiting for
// a re-matchmake.
test('a heartbeat on a new build updates the stored version', async () => {
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer('9711', '20250424.01'),
})
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: await bearer('9711', '20250718.01'),
})
).json()) as { appVersion: string }
expect(hb.appVersion).toBe('20250718.01')
const [player] = (await (
await exports.default.fetch(`${ORIGIN}/player?id=9711`)
).json()) as Array<{ appVersion: string }>
expect(player.appVersion).toBe('20250718.01')
})
// A token issued before the claim carried the client's build still has to produce a
// usable version — an empty one breaks the client's presence handling.
test('a token with no rn.ver falls back to GAME_VERSION', async () => {
const headers = await bearer('9712')
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
).json()) as { appVersion: string }
expect(hb.appVersion).toBe(GAME_VERSION)
})
test('heartbeat pushes no websocket frame', async () => {
// The notify DO is stubbed to record every send (see vitest.config).
type Sent = { playerId: number; notificationType: number; data: Record<string, unknown> }
@@ -1511,14 +1974,14 @@ describe('auth-gated endpoints', () => {
headers: await bearer('999'),
})
expect(stranger.status).toBe(200)
expect(await stranger.json()).toEqual({ errorCode: 20, roomInstance: null })
expect(await stranger.json()).toEqual(refused(20))
// Unknown instance → same refusal.
const unknown = await exports.default.fetch(`${ORIGIN}/matchmake/instance/9999999`, {
method: 'POST',
headers: await bearer('42'),
})
expect(await unknown.json()).toEqual({ errorCode: 20, roomInstance: null })
expect(await unknown.json()).toEqual(refused(20))
// Park the owner somewhere else first, so this is a real transition.
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
@@ -1858,11 +2321,11 @@ describe('auth-gated endpoints', () => {
expect(hb.roomInstance?.roomInstanceId).toBe(friendMM.roomInstance?.roomInstanceId)
// A non-friend can't be followed → NoSuchRoom, null instance (no leak of their state).
expect(await (await follow(9802, '9800')).json()).toEqual({ errorCode: 20, roomInstance: null })
expect(await (await follow(9802, '9800')).json()).toEqual(refused(20))
// You can't follow yourself.
expect(await (await follow(9800, '9800')).json()).toEqual({ errorCode: 20, roomInstance: null })
expect(await (await follow(9800, '9800')).json()).toEqual(refused(20))
// A friend who isn't in any room → nothing to join.
expect(await (await follow(9803, '9800')).json()).toEqual({ errorCode: 20, roomInstance: null })
expect(await (await follow(9803, '9800')).json()).toEqual(refused(20))
// No token → 401.
expect((await follow(9801)).status).toBe(401)
@@ -1875,10 +2338,7 @@ describe('auth-gated endpoints', () => {
VALUES (2, 9800, 0, 1, '2026-01-01T00:00:00.000Z')`
).run()
try {
expect(await (await follow(9801, '9800')).json()).toEqual({
errorCode: 55,
roomInstance: null,
})
expect(await (await follow(9801, '9800')).json()).toEqual(refused(55))
} finally {
await env.DB.prepare(
'DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800'
@@ -1907,12 +2367,12 @@ describe('auth-gated endpoints', () => {
// is nothing for the banned player to join. errorCode 55 rather than the opaque
// NoSuchRoom every other refusal answers — a banned player already knows the room
// exists, so the client can say why. Applies to the subroom path as well.
expect(await matchmake('9701')).toEqual({ errorCode: 55, roomInstance: null })
expect(await matchmake('9701')).toEqual(refused(55))
const sub = await exports.default.fetch(`${ORIGIN}/matchmake/room/2/2`, {
method: 'POST',
headers: await bearer('9701'),
})
expect(await sub.json()).toEqual({ errorCode: 55, roomInstance: null })
expect(await sub.json()).toEqual(refused(55))
// Refused before any instance is created, and no presence was recorded for them.
expect(
@@ -2019,6 +2479,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,9 +2489,12 @@ 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}',
'POST /matchmake/v2/room/{roomId}',
'POST /matchmake/v2/room/{roomId}/{subRoomId}',
'POST /player/exclusivelogin',
'POST /player/heartbeat',
'POST /player/login',
@@ -2077,7 +2542,7 @@ describe('account bans', () => {
]) {
const res = await matchmake(path, '6001')
expect(res.status, path).toBe(200)
expect(await res.json(), path).toEqual({ errorCode: 55, roomInstance: null })
expect(await res.json(), path).toEqual(refused(55))
}
})
@@ -2104,10 +2569,7 @@ describe('account bans', () => {
test('a ban that has not expired yet blocks a matchmake', async () => {
await banAccount(6004, new Date(Date.now() + 3_600_000).toISOString())
expect(await (await matchmake('/matchmake/room/2', '6004')).json()).toEqual({
errorCode: 55,
roomInstance: null,
})
expect(await (await matchmake('/matchmake/room/2', '6004')).json()).toEqual(refused(55))
})
// A report on its own is not a ban — only a moderator converting it is.
@@ -2180,7 +2642,7 @@ describe('ban evasion at matchmake', () => {
await account(6202)
await link(6202, 0, 'steam-evader')
expect(await matchmake('6202')).toEqual({ errorCode: 55, roomInstance: null })
expect(await matchmake('6202')).toEqual(refused(55))
})
test('a new account sharing a banned accounts signup IP is refused', async () => {
@@ -2188,7 +2650,7 @@ describe('ban evasion at matchmake', () => {
await banAccount(6203)
await account(6204, { signupIp: '203.0.113.203' })
expect(await matchmake('6204')).toEqual({ errorCode: 55, roomInstance: null })
expect(await matchmake('6204')).toEqual(refused(55))
})
// The address the request arrives from counts too, so an account that has never
@@ -2198,7 +2660,7 @@ describe('ban evasion at matchmake', () => {
await banAccount(6205)
await account(6206)
expect(await matchmake('6206', '203.0.113.205')).toEqual({ errorCode: 55, roomInstance: null })
expect(await matchmake('6206', '203.0.113.205')).toEqual(refused(55))
// From anywhere else, that same account plays.
expect((await matchmake('6206', '198.51.100.50')).errorCode).toBe(0)
})
@@ -2226,14 +2688,14 @@ describe('ban evasion at matchmake', () => {
try {
env.BAN_EVASION_MATCH = 'platform'
expect((await matchmake('6211')).errorCode).toBe(0)
expect(await matchmake('6212')).toEqual({ errorCode: 55, roomInstance: null })
expect(await matchmake('6212')).toEqual(refused(55))
// The banned account itself is still refused, whatever the knob says.
expect(await matchmake('6210')).toEqual({ errorCode: 55, roomInstance: null })
expect(await matchmake('6210')).toEqual(refused(55))
env.BAN_EVASION_MATCH = 'off'
expect((await matchmake('6211')).errorCode).toBe(0)
expect((await matchmake('6212')).errorCode).toBe(0)
expect(await matchmake('6210')).toEqual({ errorCode: 55, roomInstance: null })
expect(await matchmake('6210')).toEqual(refused(55))
} finally {
env.BAN_EVASION_MATCH = original
}
+7 -4
View File
@@ -61,10 +61,13 @@
"head_sampling_rate": 1 // 100%
}
},
// The room substitutions (ROOM_REDIRECTS) are deliberately NOT set here. They're
// injected at deploy time from the gitignored .env (RECFLARE_ROOM_REDIRECTS, see
// .env.example), so swapping a room out never means editing a versioned file. Unset —
// the default — means every matchmake enters the room it asked for.
// The operator's knobs — the room substitutions (ROOM_REDIRECTS) and the Photon app ids
// and region (PHOTON_REALTIME_APP_ID, PHOTON_VOICE_APP_ID, PHOTON_CHAT_APP_ID,
// PHOTON_REGION) — are deliberately NOT set here. They're injected at deploy time from
// the gitignored .env (RECFLARE_<VAR>, see .env.example), so swapping a room out or
// pointing at your own Photon apps never means editing a versioned file. Unset — the
// default — means every matchmake enters the room it asked for, the Photon app ids are
// empty (recflare ships none of its own), and sessions run in the `us` region.
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment