mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -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:
+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',
|
||||
|
||||
Reference in New Issue
Block a user