mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
nonworking photon stuff
This commit is contained in:
@@ -135,7 +135,7 @@ export const ApiConfigV2 = JsonObject.describe(
|
|||||||
'The static client config, plus a ShareBaseUrl templated from the deploy domain'
|
'The static client config, plus a ShareBaseUrl templated from the deploy domain'
|
||||||
)
|
)
|
||||||
|
|
||||||
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build matches GAME_VERSION. */
|
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build is one we accept. */
|
||||||
export const VersionCheck = z.object({
|
export const VersionCheck = z.object({
|
||||||
VersionStatus: z.int().describe('0 = current, 1 = client on a different build'),
|
VersionStatus: z.int().describe('0 = current, 1 = client on a different build'),
|
||||||
UpdateNotificationStage: z.int(),
|
UpdateNotificationStage: z.int(),
|
||||||
@@ -392,12 +392,14 @@ export const SubscriptionResponse = z.object({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
|
* `GET /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
|
||||||
* answer (no ban storage yet). `ReportCategory` is -1 (no category) rather than 0,
|
* answer (no ban storage yet), mirroring the reference server's stub
|
||||||
* which is a real category; `Message` is null, not an empty string — the client
|
* `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1) rather than 0,
|
||||||
* distinguishes "no message" from a blank one.
|
* which is a real category, and `Message` is the empty string the reference sends.
|
||||||
|
* `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but unset by that stub, so
|
||||||
|
* they carry their C# defaults (false / null).
|
||||||
*/
|
*/
|
||||||
export const ModerationBlockDetails = z.object({
|
export const ModerationBlockDetails = z.object({
|
||||||
ReportCategory: z.int().describe('-1 = no category (0 is a real one)'),
|
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
|
||||||
Duration: z.int(),
|
Duration: z.int(),
|
||||||
GameSessionId: z.int(),
|
GameSessionId: z.int(),
|
||||||
IsBan: z.boolean(),
|
IsBan: z.boolean(),
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ import {
|
|||||||
|
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client builds the version check answers as current. `GAME_VERSION` is the build the
|
||||||
|
* rest of the stack targets; `20230616` is a later client that talks the same protocol,
|
||||||
|
* so we let it through rather than telling it to update.
|
||||||
|
*/
|
||||||
|
const ACCEPTED_GAME_VERSIONS = new Set([GAME_VERSION, '20230616'])
|
||||||
|
|
||||||
// ---- Config / version ------------------------------------------------------
|
// ---- Config / version ------------------------------------------------------
|
||||||
export const configRoutes = new Hono<App>({ strict: false })
|
export const configRoutes = new Hono<App>({ strict: false })
|
||||||
.get(
|
.get(
|
||||||
@@ -100,13 +107,13 @@ export const configRoutes = new Hono<App>({ strict: false })
|
|||||||
summary: 'Client version check',
|
summary: 'Client version check',
|
||||||
description:
|
description:
|
||||||
'Whether the client build is current. Compares the client’s `?v=` build against ' +
|
'Whether the client build is current. Compares the client’s `?v=` build against ' +
|
||||||
'our target `GAME_VERSION`: `VersionStatus` is 0 when they match, 1 when the ' +
|
'the builds we accept — our target `GAME_VERSION` plus `20230616`: ' +
|
||||||
'client is on a different build.',
|
'`VersionStatus` is 0 for either, 1 for any other build.',
|
||||||
responses: { 200: json(VersionCheck, 'Version status') },
|
responses: { 200: json(VersionCheck, 'Version status') },
|
||||||
}),
|
}),
|
||||||
(c) =>
|
(c) =>
|
||||||
c.json({
|
c.json({
|
||||||
VersionStatus: c.req.query('v') === GAME_VERSION ? 0 : 1,
|
VersionStatus: ACCEPTED_GAME_VERSIONS.has(c.req.query('v') ?? '') ? 0 : 1,
|
||||||
UpdateNotificationStage: 0,
|
UpdateNotificationStage: 0,
|
||||||
IsVersionIslanded: false,
|
IsVersionIslanded: false,
|
||||||
IsCrossPlayDisabled: false,
|
IsCrossPlayDisabled: false,
|
||||||
|
|||||||
@@ -14,21 +14,24 @@ import type { App } from '../context'
|
|||||||
|
|
||||||
// ---- Player reporting ------------------------------------------------------
|
// ---- Player reporting ------------------------------------------------------
|
||||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
|
// Whether the caller is currently blocked (banned / timed out / host-kicked). No ban
|
||||||
// ban storage yet, so this is always the "not blocked" answer. `ReportCategory` is
|
// storage yet, so this is always the "not blocked" answer — the reference server's
|
||||||
// -1 (no category) rather than 0, which is a real category; `Message` is null, not
|
// stub `ReturnModerationBlockDetails()` verbatim. `ReportCategory` is `Unknown` (-1),
|
||||||
// an empty string — the client distinguishes "no message" from a blank one.
|
// not 0, which is a real category, and `Message` is the empty string that stub sends.
|
||||||
.get(
|
// `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but left unset there, so they
|
||||||
|
// go out with their C# defaults.
|
||||||
|
// POST with no body — the client's actual call, despite this being a pure read.
|
||||||
|
.post(
|
||||||
'/api/PlayerReporting/v1/moderationBlockDetails',
|
'/api/PlayerReporting/v1/moderationBlockDetails',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Moderation'],
|
tags: ['Moderation'],
|
||||||
summary: 'Whether the caller is blocked',
|
summary: 'Whether the caller is blocked',
|
||||||
description:
|
description:
|
||||||
'Ban / timeout / host-kick state for the caller. There is no ban storage yet, so ' +
|
'Ban / timeout / host-kick state for the caller. There is no ban storage yet, so ' +
|
||||||
'this is always the “not blocked” answer. Two details matter to the client: ' +
|
'this is always the “not blocked” answer, matching the reference server’s stub: ' +
|
||||||
'`ReportCategory` is -1 (no category) rather than 0, which is a real category, and ' +
|
'`ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and ' +
|
||||||
'`Message` is null rather than an empty string — the client distinguishes “no ' +
|
'`Message` is an empty string. `IsVoiceModAutoban` and `TimeoutStartedAt` are on ' +
|
||||||
'message” from a blank one.',
|
'the DTO but unset by that stub, so they carry their defaults.',
|
||||||
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
|
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
|
||||||
}),
|
}),
|
||||||
(c) =>
|
(c) =>
|
||||||
@@ -39,7 +42,7 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
IsBan: false,
|
IsBan: false,
|
||||||
IsHostKick: false,
|
IsHostKick: false,
|
||||||
IsVoiceModAutoban: false,
|
IsVoiceModAutoban: false,
|
||||||
Message: null,
|
Message: '',
|
||||||
PlayerIdReporter: null,
|
PlayerIdReporter: null,
|
||||||
TimeoutStartedAt: null,
|
TimeoutStartedAt: null,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ describe('public endpoints', () => {
|
|||||||
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
|
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /api/versioncheck/v4 reports current for the 20230616 build', async () => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=20230616`)
|
||||||
|
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
|
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
|
||||||
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
||||||
@@ -228,12 +233,15 @@ describe('public endpoints', () => {
|
|||||||
expect(await res.json()).toEqual([])
|
expect(await res.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
|
test('POST /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
|
||||||
|
// The client POSTs this with no body, despite it being a pure read.
|
||||||
const res = await exports.default.fetch(
|
const res = await exports.default.fetch(
|
||||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
|
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
|
||||||
|
{ method: 'POST' }
|
||||||
)
|
)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
// ReportCategory -1 = no category (0 is a real one), and Message is null.
|
// The reference server's stub verbatim: ReportCategory -1 = Unknown (0 is a real
|
||||||
|
// category) and an empty-string Message.
|
||||||
expect(await res.json()).toEqual({
|
expect(await res.json()).toEqual({
|
||||||
ReportCategory: -1,
|
ReportCategory: -1,
|
||||||
Duration: 0,
|
Duration: 0,
|
||||||
@@ -241,7 +249,7 @@ describe('public endpoints', () => {
|
|||||||
IsBan: false,
|
IsBan: false,
|
||||||
IsHostKick: false,
|
IsHostKick: false,
|
||||||
IsVoiceModAutoban: false,
|
IsVoiceModAutoban: false,
|
||||||
Message: null,
|
Message: '',
|
||||||
PlayerIdReporter: null,
|
PlayerIdReporter: null,
|
||||||
TimeoutStartedAt: null,
|
TimeoutStartedAt: null,
|
||||||
})
|
})
|
||||||
@@ -1891,7 +1899,6 @@ describe('openapi', () => {
|
|||||||
)
|
)
|
||||||
expect([...documented].sort()).toEqual([
|
expect([...documented].sort()).toEqual([
|
||||||
'DELETE /api/images/v1/deletesaved',
|
'DELETE /api/images/v1/deletesaved',
|
||||||
'GET /api/PlayerReporting/v1/moderationBlockDetails',
|
|
||||||
'GET /api/PlayerReporting/v1/voteToKickReasons',
|
'GET /api/PlayerReporting/v1/voteToKickReasons',
|
||||||
'GET /api/activities/charades/v1/words/{activity}',
|
'GET /api/activities/charades/v1/words/{activity}',
|
||||||
'GET /api/announcement/v1/get',
|
'GET /api/announcement/v1/get',
|
||||||
@@ -1964,6 +1971,7 @@ describe('openapi', () => {
|
|||||||
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
||||||
'POST /api/PlayerReporting/v1/deviceId',
|
'POST /api/PlayerReporting/v1/deviceId',
|
||||||
'POST /api/PlayerReporting/v1/hile',
|
'POST /api/PlayerReporting/v1/hile',
|
||||||
|
'POST /api/PlayerReporting/v1/moderationBlockDetails',
|
||||||
'POST /api/avatar/v2/gifts/generate',
|
'POST /api/avatar/v2/gifts/generate',
|
||||||
'POST /api/gamesight/event',
|
'POST /api/gamesight/event',
|
||||||
'POST /api/images/v1/cheer',
|
'POST /api/images/v1/cheer',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"Visibility": 0,
|
"Visibility": 0,
|
||||||
"AllowCycling": true,
|
"AllowCycling": true,
|
||||||
"RestrictToNewUsers": false,
|
"RestrictToNewUsers": false,
|
||||||
"ImageName": "gay",
|
"ImageName": "tip.jpg",
|
||||||
"PlatformMask": 175,
|
"PlatformMask": 175,
|
||||||
"CreatedAt": "2019-02-28T18:27:25Z"
|
"CreatedAt": "2019-02-28T18:27:25Z"
|
||||||
},
|
},
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"Visibility": 0,
|
"Visibility": 0,
|
||||||
"AllowCycling": true,
|
"AllowCycling": true,
|
||||||
"RestrictToNewUsers": false,
|
"RestrictToNewUsers": false,
|
||||||
"ImageName": "gay",
|
"ImageName": "tip.jpg",
|
||||||
"PlatformMask": 167,
|
"PlatformMask": 167,
|
||||||
"CreatedAt": "2019-02-28T18:15:33Z"
|
"CreatedAt": "2019-02-28T18:15:33Z"
|
||||||
},
|
},
|
||||||
|
|||||||
+149
-1
@@ -29,7 +29,7 @@ import {
|
|||||||
setRoomInstanceInProgress,
|
setRoomInstanceInProgress,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { generatePhotonAuthToken, validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
// Value import of the notify worker's NotificationType enum (its bundle has no runtime
|
// Value import of the notify worker's NotificationType enum (its bundle has no runtime
|
||||||
// deps), so /invite sends a typed MessageReceived frame instead of a magic number.
|
// deps), so /invite sends a typed MessageReceived frame instead of a magic number.
|
||||||
@@ -37,6 +37,7 @@ import { NotificationType } from '../../notify/src/notification-types'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AUTHED,
|
AUTHED,
|
||||||
|
ConnectionInfoResponse,
|
||||||
EMPTY_OK,
|
EMPTY_OK,
|
||||||
ExclusiveLoginResponse,
|
ExclusiveLoginResponse,
|
||||||
form,
|
form,
|
||||||
@@ -49,6 +50,7 @@ import {
|
|||||||
MatchmakeRoomRequest,
|
MatchmakeRoomRequest,
|
||||||
NotifyDisconnectRequest,
|
NotifyDisconnectRequest,
|
||||||
PlayerDto,
|
PlayerDto,
|
||||||
|
QosRegion,
|
||||||
RoomInstanceDto,
|
RoomInstanceDto,
|
||||||
StatusVisibilityRequest,
|
StatusVisibilityRequest,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
@@ -85,6 +87,54 @@ const NULL_CONNECTION_INFO = {
|
|||||||
experiments: null,
|
experiments: null,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Photon applications the client connects to (`GET /player/connection-info`).
|
||||||
|
* Temporary placeholders — move them to wrangler vars before they need to differ per
|
||||||
|
* environment. `photonRegion` matches the value `roomInstanceFromRoom` stamps on every
|
||||||
|
* instance, so the two can't disagree ('us' resolves to us-east1 for QoS).
|
||||||
|
*/
|
||||||
|
const PHOTON_APPS = {
|
||||||
|
photonRealtimeAppId: '',
|
||||||
|
photonVoiceAppId: '',
|
||||||
|
photonChatAppId: '',
|
||||||
|
photonRegion: 'us',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `PHOTON_APPS.photonRegion` 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`).
|
* 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
|
* `isOnline` means "has a live presence row" — presence rows expire, so a player who
|
||||||
@@ -1068,6 +1118,104 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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 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: PHOTON_APPS.photonRealtimeAppId,
|
||||||
|
},
|
||||||
|
await c.env.JWT_SECRET.get()
|
||||||
|
)
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
value: {
|
||||||
|
photonAuthToken,
|
||||||
|
...PHOTON_APPS,
|
||||||
|
photonRoomId,
|
||||||
|
voiceConnectionInfo: null,
|
||||||
|
voiceServerId: null,
|
||||||
|
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()).
|
// Region ping reports — accept-and-ack (the reference returns Ok()).
|
||||||
.put(
|
.put(
|
||||||
'/player/photonregionpings',
|
'/player/photonregionpings',
|
||||||
|
|||||||
@@ -135,6 +135,65 @@ export const MatchmakeResponse = z.object({
|
|||||||
/** `POST /player/exclusivelogin` — a bare error code. */
|
/** `POST /player/exclusivelogin` — a bare error code. */
|
||||||
export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') })
|
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.null().describe('Null — no separate voice server'),
|
||||||
|
voiceServerId: z.null().describe('Null — 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
|
* The session `LoginLock` GUID form field. The client posts it on every presence
|
||||||
* lifecycle call — `POST /player/login`, `/player/exclusivelogin`, `/player/logout`,
|
* lifecycle call — `POST /player/login`, `/player/exclusivelogin`, `/player/logout`,
|
||||||
|
|||||||
@@ -417,6 +417,25 @@ describe('public endpoints', () => {
|
|||||||
expect(res.status).toBe(200)
|
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 () => {
|
test('PUT /player/photonregionpings returns 200', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/player/photonregionpings`, { method: 'PUT' })
|
const res = await exports.default.fetch(`${ORIGIN}/player/photonregionpings`, { method: 'PUT' })
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -458,6 +477,99 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(priv.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId)
|
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-]+$/),
|
||||||
|
photonRealtimeAppId: '',
|
||||||
|
photonVoiceAppId: '',
|
||||||
|
photonChatAppId: '',
|
||||||
|
// Matches the region every room instance is stamped with.
|
||||||
|
photonRegion: 'us',
|
||||||
|
// The room the client is told to join has to be the one matchmaking placed
|
||||||
|
// them in, or they end up alone in a room of their own.
|
||||||
|
photonRoomId: matchmaked.roomInstance.photonRoomId,
|
||||||
|
voiceConnectionInfo: null,
|
||||||
|
voiceServerId: null,
|
||||||
|
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('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 } }
|
||||||
|
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, and short-lived.
|
||||||
|
expect(claims.aud).toBe('xx')
|
||||||
|
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 () => {
|
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 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
|
// the instance the player is already in hangs their join. RecCenter (cap 12) so
|
||||||
@@ -1280,6 +1392,8 @@ describe('auth-gated endpoints', () => {
|
|||||||
)
|
)
|
||||||
expect([...documented].sort()).toEqual([
|
expect([...documented].sort()).toEqual([
|
||||||
'GET /player',
|
'GET /player',
|
||||||
|
'GET /player/connection-info',
|
||||||
|
'GET /player/qos',
|
||||||
'GET /room/{roomId}/instances',
|
'GET /room/{roomId}/instances',
|
||||||
'GET /rooms/requiring/developer',
|
'GET /rooms/requiring/developer',
|
||||||
'GET /rooms/requiring/rrplus',
|
'GET /rooms/requiring/rrplus',
|
||||||
|
|||||||
@@ -2,5 +2,7 @@ export {
|
|||||||
validateAndGetAccountId,
|
validateAndGetAccountId,
|
||||||
validateAndGetRoles,
|
validateAndGetRoles,
|
||||||
generateToken,
|
generateToken,
|
||||||
|
generatePhotonAuthToken,
|
||||||
TOKEN_TTL_SECONDS,
|
TOKEN_TTL_SECONDS,
|
||||||
} from './jwt'
|
} from './jwt'
|
||||||
|
export type { PhotonAuthClaims } from './jwt'
|
||||||
|
|||||||
@@ -105,6 +105,55 @@ const TOKEN_SCOPES = [
|
|||||||
*/
|
*/
|
||||||
const BASE_ROLES = ['gameClient']
|
const BASE_ROLES = ['gameClient']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The claims the Photon auth token carries beyond `sub`/`exp`/`aud`, describing who
|
||||||
|
* (and on what) is connecting. All of them go on the wire as STRINGS, including the
|
||||||
|
* numeric ones — that's how the real token encodes them.
|
||||||
|
*/
|
||||||
|
export interface PhotonAuthClaims {
|
||||||
|
/** The platform-native id (e.g. a SteamID64) — `rn.platid`. */
|
||||||
|
platformId: string
|
||||||
|
/** PlatformType int (0 = Steam) — `rn.plat`. */
|
||||||
|
platform: number
|
||||||
|
/** DeviceClass int (2 = PC/standalone) — `rn.deviceclass`. */
|
||||||
|
deviceClass: number
|
||||||
|
/** The Photon application the token is for — the `aud` claim. */
|
||||||
|
audience: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint the short-lived HS256 token the client hands to Photon as its custom auth
|
||||||
|
* credential (`photonAuthToken` on `GET /player/connection-info`). The claim set
|
||||||
|
* mirrors the real one — `sub`, `rn.platid`, `rn.plat`, `rn.deviceclass`, `rn.env`,
|
||||||
|
* `exp`, `aud` — rather than being a second copy of the login token: it identifies
|
||||||
|
* the connecting player to the realtime server and nothing else, so none of the
|
||||||
|
* scopes or roles from {@link generateToken} belong on it.
|
||||||
|
*
|
||||||
|
* Signed with the same shared `JWT_SECRET` as every other token here. A real Photon
|
||||||
|
* Cloud application would verify this against a secret configured in its dashboard;
|
||||||
|
* self-hosted, nothing verifies it yet — so treat it as identifying, not authorizing.
|
||||||
|
* `rn.env` is `prod` because that's what the client is built against, regardless of
|
||||||
|
* which environment this worker is running in.
|
||||||
|
*/
|
||||||
|
export async function generatePhotonAuthToken(
|
||||||
|
accountId: number,
|
||||||
|
claims: PhotonAuthClaims,
|
||||||
|
secret: string
|
||||||
|
): Promise<string> {
|
||||||
|
return sign(
|
||||||
|
{
|
||||||
|
sub: String(accountId),
|
||||||
|
'rn.platid': claims.platformId,
|
||||||
|
'rn.plat': String(claims.platform),
|
||||||
|
'rn.deviceclass': String(claims.deviceClass),
|
||||||
|
'rn.env': 'prod',
|
||||||
|
exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
|
||||||
|
aud: claims.audience,
|
||||||
|
},
|
||||||
|
secret
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateToken(
|
export async function generateToken(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user