more stubs

This commit is contained in:
Devin Zuczek
2026-08-15 13:54:19 -04:00
parent 8ea0caa1e5
commit 11b037a2f1
67 changed files with 38566 additions and 2652 deletions
+160 -43
View File
@@ -37,7 +37,7 @@ import {
subRoomDataBlob,
} from '@repo/domain'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { generatePhotonAuthToken, 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
@@ -54,6 +54,7 @@ import {
AvoidJuniorsRequest,
AvoidJuniorsResponse,
ConnectionInfoResponse,
CorrelationIdRequest,
EMPTY_OK,
ExclusiveLoginResponse,
form,
@@ -112,9 +113,9 @@ const NULL_CONNECTION_INFO = {
* instance, so the two can't disagree ('us' resolves to us-east1 for QoS).
*/
const PHOTON_APPS = {
photonRealtimeAppId: 'rf-8f322bdb',
photonVoiceAppId: 'rf-6b4682e1',
photonChatAppId: 'rf-55fae86e',
photonRealtimeAppId: '8f322bdb-2b1f-4c27-a232-01436f43d14e',
photonVoiceAppId: '6b4682e1-a1a9-4e04-b44a-6db0049a4df3',
photonChatAppId: '55fae86e-0459-4f97-bf6c-39c9341da6ef',
photonRegion: 'us',
} as const
@@ -159,10 +160,22 @@ const QOS_REGIONS = [
* 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.
@@ -190,6 +203,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.
@@ -357,11 +388,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,
@@ -369,7 +405,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,
}
}
@@ -390,7 +429,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', {
@@ -422,7 +461,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,
@@ -616,6 +658,60 @@ function roomInstanceFromRoom(
}
}
/**
* 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'
/**
* 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 body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const key = Object.keys(body).find((k) => k.toLowerCase() === 'correlationid')
const posted = key === undefined ? undefined : body[key]
if (typeof posted === 'string' && posted) return posted
const queried = c.req.query('CorrelationId') ?? c.req.query('correlationId')
return queried || EMPTY_CORRELATION_ID
}
/**
* 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.
*/
async function matchmakeResult(
c: Context<App>,
errorCode: MatchmakingErrorCode | number,
roomInstance: RoomInstance | null
) {
return c.json({
errorCode,
result: errorCode,
roomInstance,
correlationId: await readCorrelationId(c),
})
}
/** 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>)
@@ -903,7 +999,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()
@@ -950,7 +1046,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,
})
}
@@ -1119,6 +1215,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.
@@ -1135,16 +1237,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))
}
)
@@ -1244,6 +1353,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
@@ -1287,9 +1400,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)
@@ -1299,9 +1412,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)
}
)
@@ -1355,7 +1468,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 ||
@@ -1369,7 +1482,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)
@@ -1380,10 +1493,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)
}
)
@@ -1410,6 +1523,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',
@@ -1435,26 +1549,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)
}
)
@@ -1481,6 +1595,7 @@ const app = new Hono<App>()
'when banned.',
].join(' '),
security: AUTHED,
requestBody: form(CorrelationIdRequest, 'The attempts CorrelationId'),
parameters: [
{
name: 'instanceId',
@@ -1506,16 +1621,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
@@ -1527,7 +1642,7 @@ 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
@@ -1541,7 +1656,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)
}
)
@@ -1590,11 +1705,11 @@ const app = new Hono<App>()
id,
subRoomId
)
if (!instance) return c.json({ errorCode, roomInstance: null })
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 c.json({ errorCode: 0, roomInstance: instance })
return matchmakeResult(c, 0, instance)
}
)
@@ -1630,11 +1745,11 @@ 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)
// 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 })
return matchmakeResult(c, 0, instance)
}
)
// Matchmake with no target. The client posts this when it needs an instance but isn't
@@ -1656,6 +1771,7 @@ const app = new Hono<App>()
'TTL.',
].join(' '),
security: AUTHED,
requestBody: form(CorrelationIdRequest, 'The attempts CorrelationId'),
responses: {
200: json(MatchmakeResponse, 'The callers current instance, or their dorm'),
401: UNAUTHORIZED_RESPONSE,
@@ -1668,7 +1784,7 @@ const app = new Hono<App>()
const presence = await getPresence<RoomInstance>(c.env.DB, id)
const current = presence?.roomInstance ?? (await playerDormInstance(c, id))
await enterRoom(c, id, current)
return c.json({ errorCode: 0, roomInstance: current })
return matchmakeResult(c, 0, current)
}
)
@@ -1684,6 +1800,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,
@@ -1695,7 +1812,7 @@ 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)
}
)
+34 -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'),
})
/**
@@ -258,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'),
})
@@ -270,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()
+151 -40
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',
@@ -687,10 +708,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 +750,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 +758,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 +789,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 () => {
@@ -935,9 +947,9 @@ describe('auth-gated endpoints', () => {
value: {
// A signed JWT, not an opaque id — three base64url segments.
photonAuthToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/),
photonRealtimeAppId: 'rf-8f322bdb',
photonVoiceAppId: 'rf-6b4682e1',
photonChatAppId: 'rf-55fae86e',
photonRealtimeAppId: '8f322bdb-2b1f-4c27-a232-01436f43d14e',
photonVoiceAppId: '6b4682e1-a1a9-4e04-b44a-6db0049a4df3',
photonChatAppId: '55fae86e-0459-4f97-bf6c-39c9341da6ef',
// Matches the region every room instance is stamped with.
photonRegion: 'us',
// The room the client is told to join has to be the one matchmaking placed
@@ -1071,6 +1083,58 @@ 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)
@@ -1209,6 +1273,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> }
@@ -1669,14 +1786,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`, {
@@ -2016,11 +2133,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)
@@ -2033,10 +2150,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'
@@ -2065,12 +2179,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(
@@ -2238,7 +2352,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))
}
})
@@ -2265,10 +2379,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.
@@ -2341,7 +2452,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 () => {
@@ -2349,7 +2460,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
@@ -2359,7 +2470,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)
})
@@ -2387,14 +2498,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
}