Files
recflare/apps/match/src/openapi.ts
T
2026-09-01 10:55:47 -04:00

433 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the match worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`.
*
* As with the auth/accounts workers, this is deliberate: the Rec Room client is the
* only real consumer, the handlers are lenient (bodies are parsed defensively and
* missing fields fall through to sensible defaults), and the exact request/response
* shapes are reverse-engineered. These schemas record observed behaviour; to enforce
* one, do it per-route and land a test with it.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
/**
* Convert a zod schema to a plain OpenAPI schema for a request body. `describeRoute`'s
* `requestBody` takes an OpenAPI schema (not a `resolver()`). zod's `$schema` key and
* `additionalProperties: false` are dropped — the handlers read the fields they know
* and ignore the rest, so a closed object would misreport them as stricter than they
* are.
*/
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body (the heartbeat posts one). */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** An empty-body `200 OK` ack — the response many match routes return. */
export const EMPTY_OK = { description: 'Acknowledged (empty body)' }
/** The empty-body 401 the auth-gated routes return. */
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/**
* RoomInstanceType enum, by value. `Dormroom` instances are private; `Public` are the
* shared, joinable ones matchmaking reuses.
*/
export const RoomInstanceType = z
.int()
.describe('RoomInstanceType: 0 Public, 1 Dormroom, … (see @repo/domain)')
/**
* A room instance — the session the client connects to (scene + Photon coordinates).
* Joiners of the same public instance share `roomInstanceId` and `photonRoomId`. Names
* are `^`-prefixed so the client resolves the scene (personal dorms use `@owner's Dorm`
* instead). `location` is the SubRoom's Unity scene id; an empty one makes the client
* reject the session.
*/
export const RoomInstanceDto = z.object({
roomInstanceId: z.int(),
roomId: z.int(),
subRoomId: z.int().describe('Which subroom (scene) of the room this instance is'),
roomInstanceType: RoomInstanceType,
location: z.string().describe('SubRoom Unity scene id; empty is rejected by the client'),
dataBlob: z.string(),
eventId: z.int(),
clubId: z.int(),
roomCode: z.string(),
photonRegion: z.string(),
photonRegionId: z.string(),
photonRoomId: z.string().describe('Shared by joiners of the same instance'),
name: z.string().describe('`^`-prefixed (or `@owners Dorm` for personal dorms)'),
maxCapacity: z.int(),
isFull: z.boolean(),
isPrivate: z.boolean(),
isInProgress: z.boolean().describe('Set by the owner via PUT /roominstance/:id/inprogress'),
EncryptVoiceChat: z.boolean(),
})
/**
* One live instance in the owner's management listing (`GET /room/:roomId/instances`).
* Not the client `RoomInstanceDto`: it carries who's in there and drops the connection
* details (photon ids, data blob, room code) of a session the owner isn't in.
*/
export const RoomInstanceSummaryDto = z.object({
roomInstanceId: z.int(),
roomId: z.int(),
subRoomId: z.int().describe('Which subroom (scene) of the room this instance is'),
isFull: z.boolean(),
createdAt: z.string().describe('ISO 8601 UTC, stamped when the instance was created'),
playerIds: z
.array(z.int())
.describe('Accounts currently in the instance (live presence); empty when nobody is'),
})
/**
* A player's presence as the client reads it (`GET /player`, `POST /player/heartbeat`).
* `isOnline` means "has a live (unexpired) presence row", NOT "is in a room" — a player
* can be online in the lobby with `roomInstance` null. The `photon*`/`voice*`
* connection fields are only populated in a matchmaking response, never here, but the
* client needs the keys present, so they're always null.
*/
export const PlayerDto = z.object({
playerId: z.int(),
isOnline: z.boolean().describe('Has a live presence row (presence expires on a TTL)'),
errorCode: z.int().describe('0 = no error; non-zero only on a failed matchmake'),
roomInstance: RoomInstanceDto.nullable().describe('null when not in a room'),
appVersion: z.string(),
deviceClass: z.int(),
statusVisibility: z.int(),
vrMovementMode: z.int(),
platform: z.int(),
photonAuthToken: z.null(),
photonRealtimeAppId: z.null(),
photonVoiceAppId: z.null(),
photonChatAppId: z.null(),
photonRegion: z.null(),
photonRoomId: z.null(),
voiceConnectionInfo: z.null(),
voiceServerId: z.null(),
experiments: z.null(),
})
/**
* The matchmake result envelope. Code 0 with a `roomInstance` is success; a non-zero
* code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
*
* `result` and `errorCode` are the same code under two names: `result` is what the
* client reads, `errorCode` is what this server has always sent (and what its own tests
* read), so both are served and they always agree. `correlationId` echoes the
* `CorrelationId` the request was tagged with — without it the client never matches the
* response to the attempt and fails with "Unable to connect to game session".
*/
export const MatchmakeResponse = z.object({
result: z.int().describe('The join-result code the client checks first; same as errorCode'),
errorCode: z
.int()
.describe('0 = success; 20 = NoSuchRoom; 55 = banned from the room (the one non-opaque code)'),
roomInstance: RoomInstanceDto.nullable(),
correlationId: z
.string()
.describe('Echoes the requests CorrelationId; all-zero GUID when it sent none'),
})
/**
* `GET /player/avoidjuniors` — a BARE JSON boolean (`true`/`false`), not an envelope and
* not a `{ value }` wrapper. The whole body is the preference.
*/
export const AvoidJuniorsResponse = z
.boolean()
.describe('Whether the player asked to be kept away from junior accounts')
/**
* `PUT /player/avoidjuniors` form body. The client posts `avoidJuniors=True`; the field is
* matched case-insensitively and `True`/`false`/`1`/`0`/`yes`/`no` all parse, since neither
* the casing nor the spelling of the boolean is guaranteed across the client's surfaces.
*/
export const AvoidJuniorsRequest = z.object({
avoidJuniors: z.string().describe('`True`/`False` (also `1`/`0`, `yes`/`no`)'),
})
/** `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. The voice fields name
* the Tachyon server that instance was assigned out of the `TACHYON_HOST_PORT` pool —
* likewise the same for everyone in the session — and are empty when the pool is unset
* or the caller is in no instance. `photonRegion` matches the one stamped
* on every room instance, so the two can't disagree.
*/
export const ConnectionInfo = z.object({
photonAuthToken: z.string().describe('Short-lived HS256 token identifying the caller to Photon'),
photonRealtimeAppId: z.string().describe('Photon Realtime application id'),
photonVoiceAppId: z.string().describe('Photon Voice application id'),
photonChatAppId: z.string().describe('Photon Chat application id'),
photonRegion: z.string().describe('Region id, matching a room instances `photonRegion`'),
photonRoomId: z.string().describe('The callers current instance; empty when theyre in none'),
voiceConnectionInfo: z
.string()
.describe('The instances Tachyon server, `host:port`; empty when none is configured'),
voiceServerId: z
.string()
.describe('That servers generated id (`tachyon-1`, …); cosmetic, empty when there is none'),
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`,
* and `/player/heartbeat` — so it's always present, not optional. Recorded in presence
* at login and verified on each heartbeat (a mismatched lock is a superseded session).
*/
export const LoginLockRequest = z.object({
LoginLock: z.string().describe('The session login-lock GUID (always sent)'),
})
/** `PUT /roominstance/:id/inprogress` form body. */
export const InProgressRequest = z.object({
inProgress: z.string().describe('"True" | "False" (case-insensitive)'),
})
/** `PUT /player/statusvisibility` form body. */
export const StatusVisibilityRequest = z.object({
statusVisibility: z.string().describe('Integer string; non-numeric is ignored'),
})
/**
* `POST /player/notifydisconnect` form body — posted by Photon when it sees a player
* drop a room instance. Both fields are integer strings.
*/
export const NotifyDisconnectRequest = z.object({
PlayerId: z.string().describe('The account that disconnected'),
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/v2/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`, `PlayerIsPartyMember`) 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 = CorrelationIdRequest.extend({
JoinMode: z.string().optional().describe('"2" requests a private instance'),
})
/**
* 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). `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 = CorrelationIdRequest.extend({
JoinMode: z.string().optional().describe('"2" requests a private instance'),
AdditionalPlayerIds: z
.string()
.optional()
.describe('Party members to invite into the room; repeated once per id'),
})
/**
* The v2 room-matchmake body (`/matchmake/v2/room/:roomId[/:subRoomId]`). Unlike the 2023
* client's urlencoded form, the newer client posts JSON with real types: `JoinMode` is a
* number, and `AdditionalPlayerIds` is an array — `null`, not `[]`, when the player is
* alone. Only `JoinMode`, `AdditionalPlayerIds` and `CorrelationId` are read; the rest are
* accepted and ignored, and are recorded here because they are what the client actually
* sends.
*/
export const MatchmakeRoomV2Request = z.object({
CorrelationId: z.string().optional().describe('Per-attempt GUID; echoed on the response'),
JoinMode: z.int().optional().describe('2 requests a private instance'),
AdditionalPlayerIds: z
.array(z.int())
.nullable()
.optional()
.describe('Party members to invite into the room; null when the player is alone'),
InviteMode: z.int().optional(),
ShouldKeepPlayerWithParty: z.boolean().optional(),
BypassMovementModeRestriction: z.boolean().optional(),
MaxPersistenceVersion: z.int().optional(),
Ugc1SubVersion: z.int().optional(),
Ugc2SubVersion: z.int().optional(),
VoiceServerVersion: z.string().optional(),
LoginLock: z.string().optional(),
ClientJoinData: z.string().nullable().optional(),
PlayerScores: z.unknown().optional(),
})
/**
* The v2 client's room instance: PascalCase, and a SUBSET of `RoomInstanceDto`. The
* reference server's v2 response carries no `DataBlob` and no Photon coordinates at all,
* and adds `MatchmakingPolicy` — this mirrors it field for field. The instance behind it
* is the same row a v1 matchmake answers with; only the projection differs.
*/
export const RoomInstanceV2Dto = z.object({
RoomInstanceId: z.int(),
RoomId: z.int(),
SubRoomId: z.int(),
Location: z.string().describe('SubRoom Unity scene id; empty is rejected by the client'),
EventId: z.int(),
ClubId: z.int(),
RoomCode: z.string(),
Name: z.string(),
MaxCapacity: z.int(),
IsFull: z.boolean(),
IsPrivate: z.boolean(),
IsInProgress: z.boolean(),
EncryptVoiceChat: z.boolean(),
RoomInstanceType: RoomInstanceType,
MatchmakingPolicy: z.int().describe('Always 0; this server has no policy to express'),
})
/**
* The v2 matchmake envelope. Same codes and the same correlation-id echo as
* `MatchmakeResponse`, but PascalCase and with no `result` twin — the v2 client reads
* `ErrorCode`. Refusals answer `RoomInstance: null` with a non-zero `ErrorCode`.
*/
export const MatchmakeV2Response = z.object({
ErrorCode: z
.int()
.describe('0 = success; 20 = NoSuchRoom; 55 = banned from the room (the one non-opaque code)'),
CorrelationId: z
.string()
.describe('Echoes the requests CorrelationId; all-zero GUID when it sent none'),
RoomInstance: RoomInstanceV2Dto.nullable(),
})
/** `POST /invite` form body — invite a player into the caller's room instance. */
export const InviteRequest = z.object({
playerId: z.string().describe('The account to invite; a non-zero integer (else 400)'),
roomInstanceId: z
.string()
.optional()
.describe('The callers room instance to invite them into; resolves the invites RoomId'),
})
/**
* `POST /invite` response — the `room_invite` row the invite just created. The frame the
* invitee receives is ephemeral; the row is what gives the invite an id of its own.
*/
export const InviteResponse = z.object({
RoomInviteId: z.int().describe('Id of the new `room_invite` row'),
FromPlayerId: z.int().describe('The caller (the Bearer token)'),
ToPlayerId: z.int().describe('The invited account'),
RoomId: z
.int()
.nullable()
.describe('The room the invite points at; null when the room instance didnt resolve'),
})
/**
* `GET /tachyon?id=…` — the room instance a player is in, as a BARE NUMBER: the whole body
* is the id, with no object around it.
*
* 0 means "not in one" — no live presence for that account, an expired row, or no `id`
* given. Presence rows carry synthetic ids too, which are passed through as they stand:
* -2 is the Orientation seed the `auth` worker writes for a brand-new player.
*/
export const InstanceIdResponse = z
.int()
.describe('The players room instance id, or 0 when they are not in one')
/**
* `GET /clubhousesearch/mostactivenow` — one row per clubhouse someone is standing in
* right now, busiest first.
*
* A bare array, and only the clubs with players in them: an empty clubhouse is absent
* rather than listed with a `PlayerCount` of 0, so a quiet server answers `[]`.
*/
export const ActiveClubhouseDto = z.object({
RoomId: z.int().describe('The clubs clubhouse room'),
ClubId: z.int().describe('The club that clubhouse belongs to'),
PlayerCount: z.int().describe('How many players are in the room this second'),
})