mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
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:
@@ -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
@@ -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 attempt’s 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 attempt’s 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 room’s 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 client’s `/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 client’s `/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 attempt’s CorrelationId'),
|
||||
responses: {
|
||||
200: json(MatchmakeResponse, 'The caller’s 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 attempt’s 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',
|
||||
'client’s 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 Room’s 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
@@ -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 request’s 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 instance’s `photonRegion`'),
|
||||
photonRoomId: z.string().describe('The caller’s current instance; empty when they’re 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 request’s 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)'),
|
||||
|
||||
@@ -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 operator’s 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 caller’s 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 request’s 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 player’s 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 caller’s 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 account’s 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user