clean up match code, remove old endpoints

This commit is contained in:
Devin Zuczek
2026-07-24 14:19:08 -04:00
parent cfc29cb175
commit 5ed9e765a5
7 changed files with 239 additions and 450 deletions
+3 -4
View File
@@ -28,14 +28,13 @@ export async function parseFormIds(c: Context<App>): Promise<number[]> {
.filter((n) => !Number.isNaN(n))
}
/** Read integer ids from repeated/comma-separated `id` query params. The 2023
* client passes these to the bulk GET endpoints (e.g. `?id=1&id=2`). */
/** Read integer ids from repeated `id` query params. The 2023 client passes these to
* the bulk GET endpoints as one value per id (`?id=1&id=2`), never comma-separated. */
export function queryIds(c: Context<App>): number[] {
return (
c.req
.queries('id')
?.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
?.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n)) ?? []
)
}
+1 -1
View File
@@ -43,7 +43,7 @@ function defaultReputation(id: number) {
* may itself be a comma-separated list, so `?id=1,2&id=3` is three ids.
*/
const BULK_ID_QUERY = [
intQuery('id', 'Repeatable; each value may be a comma-separated list of account ids'),
intQuery('id', 'Repeated once per account id (`?id=1&id=2`); not comma-separated'),
]
/** The `Ids` form body the bulk POST forms take. */
+1 -1
View File
@@ -258,7 +258,7 @@ describe('public endpoints', () => {
}),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
expect(await res.json()).toEqual([])
})
test('POST /api/playerReputation/v2/bulk returns a reputation per id', async () => {
+129 -276
View File
@@ -40,14 +40,14 @@ import {
EMPTY_OK,
ExclusiveLoginResponse,
form,
HeartbeatRequest as HeartbeatRequestSchema,
InProgressRequest,
InviteRequest,
JoinModeRequest,
json,
jsonBody,
LoginLockRequest,
MatchmakeResponse,
MatchmakeRoomRequest,
NotifyDisconnectRequest,
PlayerDto,
RoomInstanceDto,
StatusVisibilityRequest,
@@ -108,16 +108,6 @@ function playerPayload(playerId: number, presence?: Presence | null) {
}
}
/** Heartbeat body posted by the client (all fields optional). */
interface HeartbeatRequest {
playerId?: number
statusVisibility?: number
deviceClass?: number
vrMovementMode?: number
appVersion?: string | null
platform?: number
}
/**
* Resolve the account id from a Bearer token, mirroring the repeated
* auth-header check. Returns `null` when the header is missing,
@@ -133,7 +123,7 @@ function unauthorized(c: Context<App>) {
}
/** A synthesized room instance (same shape for dorm and other rooms). */
type RoomInstance = ReturnType<typeof dormRoomInstance>
type RoomInstance = ReturnType<typeof roomInstanceFromRoom>
/**
* Stored presence for a player — the room instance they matchmade into plus the
@@ -165,7 +155,7 @@ const DEFAULT_GET_PLAYER = [{ ...playerPayload(1), isOnline: true }]
* `photonRoomId` would let anyone who can read your presence `JoinByName` the Photon
* room directly, bypassing the private-instance invite check — the friend list only
* needs `roomId`/`name`/`isPrivate` to render the row, and joins go back through
* matchmaking (`/goto/player/:id`), which enforces access. `photonRegion` is omitted
* matchmaking (`/matchmake/player/:playerId`), which enforces access. `photonRegion` is omitted
* (not on the presence DTO); `name` is already the `^`-prefixed wire name.
*/
function redactInstanceForPresence(instance: RoomInstance) {
@@ -260,6 +250,9 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
vrMovementMode: prev?.vrMovementMode ?? 1,
platform: prev?.platform ?? account?.platform ?? 0,
appVersion: 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,
})
// Keep the destination instance's is_full flag in sync with live presence (the
// player's own presence, just written, is counted). Then re-evaluate the
@@ -277,15 +270,6 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
await notifyFriendsPresence(c, id)
}
/**
* Fixed Photon room id for the dorm. With no RoomInstance DB we can't persist
* the GUID minted at matchmake time, and the client's presence check compares
* the *whole* instance (photonRoomId included). Every dorm entry point
* (matchmake/goto, matchmake/none, the heartbeat) must therefore return the
* exact same instance, so the id is a constant rather than random/per-account.
*/
const DORM_PHOTON_ROOM_ID = '00000000-0000-4000-8000-000000000001'
/** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */
const NO_SUCH_ROOM = 20
@@ -352,34 +336,6 @@ async function sendGameInvite(
*/
const ORIENTATION_INSTANCE_ID = -2
/**
* The canonical dorm room instance (room 1, instance 1.1). Returned identically
* by every dorm entry point and the presence heartbeat so the client's local
* presence never reads as out-of-sync.
*/
function dormRoomInstance() {
return {
roomInstanceId: 1,
roomId: 1,
subRoomId: 1,
roomInstanceType: RoomInstanceType.Dormroom,
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
dataBlob: '',
eventId: 0,
clubId: 0,
roomCode: '',
photonRegion: 'us',
photonRegionId: 'us',
photonRoomId: DORM_PHOTON_ROOM_ID,
name: '^DormRoom',
maxCapacity: 4,
isFull: false,
isPrivate: true,
isInProgress: false,
EncryptVoiceChat: false,
}
}
/**
* Instance-relevant fields pulled from a stored room (scene, name, capacity, …).
* The `location` is the SubRoom's real `UnitySceneId` — an empty/unknown location
@@ -426,7 +382,7 @@ function roomInstanceFromRoom(
instanceId: number,
photonRoomId: string,
subRoomId?: number
): RoomInstance {
) {
const f = instanceFieldsFromRoom(room, subRoomId)
return {
roomInstanceId: instanceId,
@@ -450,6 +406,12 @@ 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
}
/** 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>)
@@ -460,9 +422,9 @@ async function readJoinMode(c: Context<App>): Promise<number> {
* 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` may repeat and/or be comma-separated, and ids are parsed
* defensively, de-duplicated, and non-positive/garbage entries dropped. Parsed with
* `{ all: true }` in one pass so repeated fields survive.
* `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.
*/
async function readMatchmakeBody(
c: Context<App>
@@ -482,7 +444,6 @@ async function readMatchmakeBody(
...new Set(
values
.filter((v): v is string => typeof v === 'string')
.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n) && n > 0)
),
@@ -613,29 +574,62 @@ const app = new Hono<App>()
.notFound(withNotFound())
// ---- Player presence -----------------------------------------------------
// login/exclusivelogin are no-op acks and MUST NOT touch presence: the client
// fires exclusivelogin when going online, and clearing presence there would bounce
// the player to the dorm. Presence is overwritten by matchmake/goto and expires on
// its own TTL.
// login records the session's `LoginLock` in presence so the heartbeat can verify
// each beat belongs to this login; it must otherwise leave presence intact (clearing
// the room instance here would bounce the player to the dorm). Presence is overwritten
// by matchmake — which carries the lock forward — and expires on its own TTL.
.post(
'/player/login',
describeRoute({
tags: ['Presence'],
summary: 'Login ack (no-op)',
summary: 'Record the session login lock',
description: [
'A no-op ack. Must NOT touch presence — the client fires this going online, and',
'clearing presence here would bounce the player to the dorm.',
'Records the posted `LoginLock` in the players presence so later heartbeats can',
'verify they still own the session. Updates the live presence row if there is one,',
'otherwise seeds a lobby presence (no room) carrying the lock. Empty ack.',
].join(' '),
requestBody: form(LoginLockRequest, 'The session LoginLock GUID'),
responses: { 200: EMPTY_OK },
}),
(c) => c.body(null, 200)
async (c) => {
const id = await authedId(c)
if (id !== null) {
const loginLock = await readLoginLock(c)
if (loginLock !== undefined) {
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence) {
presence.loginLock = loginLock
await setPresence(c.env.DB, presence)
} else {
// No live presence yet — seed a lobby row (roomInstance null) holding the
// lock, so it survives to the first matchmake (enterRoom carries it forward).
const account = await getAccount(c.env.DB, id)
await setPresence(c.env.DB, {
accountId: id,
roomInstance: null,
statusVisibility: 0,
deviceClass: account?.deviceClass ?? 0,
vrMovementMode: 1,
platform: account?.platform ?? 0,
appVersion: GAME_VERSION,
loginLock,
})
}
}
}
return c.body(null, 200)
}
)
.post(
'/player/exclusivelogin',
describeRoute({
tags: ['Presence'],
summary: 'Exclusive-login ack (no-op)',
description: 'A no-op ack returning a zero error code. Like login, must not touch presence.',
description: [
'Player exclusive login. Carries the session `LoginLock` (as every presence',
'lifecycle call does) but is currently a no-op ack. @todo implement login locking.',
].join(' '),
requestBody: form(LoginLockRequest, 'The session LoginLock GUID'),
responses: { 200: json(ExclusiveLoginResponse, 'errorCode 0') },
}),
(c) => c.json({ errorCode: 0 })
@@ -649,7 +643,7 @@ const app = new Hono<App>()
// worker writes that presence with instance id -2). Clearing presence there wipes
// the seed and bounces the new player to the dorm — so a logout that still points
// at Orientation is left as a no-op ack. An unauthenticated logout is also a no-op
// (no player to clear).
// (no player to clear). @kludge probably a better solution for this.
.post(
'/player/logout',
describeRoute({
@@ -657,10 +651,12 @@ const app = new Hono<App>()
summary: 'Clear presence on logout',
description: [
'Clears the players presence so they read offline immediately and the instance',
'they were in frees up. EXCEPTION: a logout whose presence still points at the',
'they were in frees up. Carries the session `LoginLock` (as every presence',
'lifecycle call does). EXCEPTION: a logout whose presence still points at the',
'Orientation seed (instance -2) is left as a no-op, so the account-creation',
'bootstrap isnt wiped. An unauthenticated logout is also a no-op.',
].join(' '),
requestBody: form(LoginLockRequest, 'The session LoginLock GUID'),
responses: { 200: EMPTY_OK },
}),
async (c) => {
@@ -683,21 +679,35 @@ const app = new Hono<App>()
}
)
// Fire-and-forget disconnect notification (form body `PlayerId`/`RoomInstanceId`).
// The client posts this when it drops a room; we don't act on it presence is
// cleared by logout and otherwise expires on its own TTL — so just ack with 200.
// Photon disconnect notification (form body `PlayerId`/`RoomInstanceId`) — posted when
// Photon sees a player drop a room instance. We don't act on it yet (presence is cleared
// by logout and otherwise expires on its TTL), but the fields are parsed and logged so
// the hook is in place for a future background reconciliation check.
.post(
'/player/notifydisconnect',
describeRoute({
tags: ['Presence'],
summary: 'Disconnect notification (no-op ack)',
summary: 'Photon disconnect notification',
description: [
'Posted when the client drops a room. Not acted on — presence is cleared by logout',
'and otherwise expires on its TTL.',
'Posted by Photon when it sees a player drop a room instance (form body',
'`PlayerId`/`RoomInstanceId`). Currently just logged and acked — presence is cleared',
'by logout and otherwise expires on its TTL — but the hook is here for a future check.',
].join(' '),
requestBody: form(NotifyDisconnectRequest, 'The disconnecting player and the instance they left'),
responses: { 200: EMPTY_OK },
}),
(c) => c.body(null, 200)
async (c) => {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const parseId = (v: unknown): number | null => {
const n = typeof v === 'string' ? Number.parseInt(v, 10) : NaN
return Number.isNaN(n) ? null : n
}
logger.info('player disconnect notification', {
playerId: parseId(body.PlayerId),
roomInstanceId: parseId(body.RoomInstanceId),
})
return c.body(null, 200)
}
)
.get(
@@ -706,27 +716,27 @@ const app = new Hono<App>()
tags: ['Presence'],
summary: 'Batch player presence lookup',
description: [
'Returns each requested players presence. `id` is repeatable and each value may',
'be a comma-separated list. With no ids, serves a single default (online) player.',
'Returns each requested players presence. `id` is a repeated query param',
'(`?id=2&id=155&id=153`) — one value each, not comma-separated. With no ids, serves',
'a single default (online) player.',
].join(' '),
parameters: [
{
name: 'id',
in: 'query',
required: false,
description: 'Repeatable; each value may be a comma-separated list of player ids',
description: 'Repeated once per player id (`?id=2&id=155`); not comma-separated',
schema: { type: 'array', items: { type: 'string' } },
},
],
responses: { 200: json(PlayerDto.array(), 'One entry per requested player') },
}),
async (c) => {
// Returns each requested player's presence. Reads the `id` query param(s);
// with none it serves the static getplayer.json default.
// Returns each requested player's presence. Reads the repeated `id` query
// param(s); with none it serves the static getplayer.json default.
const ids = c.req
.queries('id')
?.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
?.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n))
if (!ids || ids.length === 0) return c.json(DEFAULT_GET_PLAYER)
@@ -743,16 +753,15 @@ const app = new Hono<App>()
tags: ['Presence'],
summary: 'Presence heartbeat',
description: [
'Merges the posted status fields into stored presence and echoes back the player',
'payload. Re-writes the row (refreshing its TTL) only when something changed or the',
'TTL is close to lapsing, so a still player isnt written on every beat. With no',
'stored presence the player isnt in a room yet (roomInstance null, isOnline false).',
'Returns the players current presence payload without mutating any stored fields —',
'the only side effect is refreshing the rows TTL, and even that only when the TTL',
'is close to lapsing so a still player isnt written on every beat. The posted',
'`LoginLock` is verified against the one recorded at login: a heartbeat carrying a',
'different lock is a superseded session and gets an empty body. With no stored',
'presence the player isnt in a room yet (roomInstance null, isOnline false).',
].join(' '),
security: AUTHED,
requestBody: jsonBody(
HeartbeatRequestSchema,
'JSON status fields. A non-JSON (LoginLock) body is accepted and ignored.'
),
requestBody: form(LoginLockRequest, 'The session LoginLock GUID (verified, not stored)'),
responses: {
200: json(PlayerDto, 'The players current presence payload'),
401: UNAUTHORIZED_RESPONSE,
@@ -762,80 +771,38 @@ const app = new Hono<App>()
const id = await authedId(c)
if (id === null) return unauthorized(c)
// Body may be a JSON HeartbeatRequest or a form post (LoginLock); only JSON
// carries presence/status fields.
const raw = await c.req.text().catch(() => '')
let hb: HeartbeatRequest = {}
if (raw.trimStart().startsWith('{')) {
try {
hb = JSON.parse(raw) as HeartbeatRequest
} catch {
hb = {}
}
}
// The body is either a JSON status blob (no longer read — presence is returned
// verbatim) or a form carrying the session `LoginLock`. We only read the
// LoginLock, to verify this beat still owns the session; a JSON body simply
// yields no lock (parseBody fails and is swallowed).
const postedLock = await readLoginLock(c)
// Return the player's stored presence (set by matchmake/goto), mirroring the
// 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. Posted status
// fields are merged back; the row is re-written (refreshing the TTL) only when
// something changed or its TTL is close to lapsing — see below.
// isn't in a room yet, so roomInstance=null / isOnline=false.
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence) {
// Merge the posted status fields, tracking whether any actually changed.
let changed = false
const apply = <K extends keyof Presence>(key: K, value: Presence[K]) => {
if (presence[key] !== value) {
presence[key] = value
changed = true
}
// A heartbeat whose LoginLock disagrees with the one recorded at login belongs
// to a superseded session — return nothing so that stale client stops acting as
// the live one. (No posted lock, or none recorded yet, skips the check.)
if (
postedLock !== undefined &&
presence.loginLock !== undefined &&
presence.loginLock !== postedLock
) {
return c.body(null, 200)
}
if (hb.statusVisibility !== undefined) apply('statusVisibility', hb.statusVisibility)
if (hb.deviceClass !== undefined) apply('deviceClass', hb.deviceClass)
if (hb.vrMovementMode !== undefined) apply('vrMovementMode', hb.vrMovementMode)
if (hb.platform !== undefined) apply('platform', hb.platform)
if (hb.appVersion) apply('appVersion', hb.appVersion)
if (!presence.appVersion) apply('appVersion', GAME_VERSION)
// Extending the TTL means re-writing the row, so skip the write on an
// unchanged heartbeat until the TTL is within PRESENCE_REFRESH_THRESHOLD
// (s) of lapsing — a still player is refreshed periodically rather than on
// every beat. `expiresAt` is epoch seconds (set by setPresence).
// 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)
const dueForRefresh = presence.expiresAt - nowSeconds <= PRESENCE_REFRESH_THRESHOLD
if (changed || dueForRefresh) {
if (presence.expiresAt - nowSeconds <= PRESENCE_REFRESH_THRESHOLD) {
await setPresence(c.env.DB, presence)
}
}
// The heartbeat echoes the same player payload `/player` serves; with no stored
// presence it falls back to what the client just posted.
const payload = {
...playerPayload(hb.playerId ? hb.playerId : id, presence),
statusVisibility: presence?.statusVisibility ?? hb.statusVisibility ?? 0,
deviceClass: presence?.deviceClass ?? hb.deviceClass ?? 0,
vrMovementMode: presence?.vrMovementMode ?? (hb.vrMovementMode ? hb.vrMovementMode : 1),
appVersion: presence?.appVersion || hb.appVersion || GAME_VERSION,
platform: presence?.platform ?? hb.platform ?? 0,
}
// Also push the presence over the websocket as a PresenceHeartbeatResponse,
// mirroring the reference's ReturnHeartbeat. Sent ephemerally (never queued) —
// a heartbeat fires on every beat, and a queued copy would pile up and arrive
// stale. Best-effort: the HTTP body carries the same payload regardless.
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayerEphemeral(
id,
NotificationType.PresenceHeartbeatResponse,
payload
)
} catch (err) {
logger.error('failed to push PresenceHeartbeatResponse notification', {
playerId: id,
error: err instanceof Error ? err.message : String(err),
})
}
return c.json(payload)
return c.json(playerPayload(id, presence))
}
)
@@ -870,85 +837,9 @@ const app = new Hono<App>()
)
// ---- Room navigation -----------------------------------------------------
// Each matchmake/goto persists the resulting instance as the player's presence
// so the heartbeat can replay it (keeping client presence in sync).
.post(
'/goto/room/:room',
describeRoute({
tags: ['Navigation'],
summary: 'Go to a room',
description: [
'Resolves the room (numeric id or name; `dormroom` → the players personal dorm),',
'finds or creates an instance, and stores it as presence. `JoinMode=2` requests a',
'private instance.',
].join(' '),
security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
parameters: [
{
name: 'room',
in: 'path',
required: true,
description: 'Room id, room name, or `dormroom`',
schema: { type: 'string' },
},
],
responses: {
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const room = c.req.param('room')
const joinMode = await readJoinMode(c)
const instance =
room.toLowerCase() === 'dormroom'
? await playerDormInstance(c, id)
: await resolveRoomInstance(c, room, joinMode === 2, id)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Register the static `none` route before the `:room` param route so it
// isn't swallowed by the auth-gated matchmake handler.
.post(
'/matchmake/none',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake with no target (preserve or dorm)',
description: [
'Returns the players current instance if they have one (so Orientation isnt warped',
'away), else their personal dorm when authed, or the shared offline dorm when not.',
'Not auth-gated.',
].join(' '),
responses: {
200: json(MatchmakeResponse, 'Current, personal-dorm, or offline-dorm instance'),
},
}),
async (c) => {
const id = await authedId(c)
// Return the player's *current* heartbeat here rather than forcing the dorm.
// Orientation is a solo room the client establishes via matchmake/none; if we
// force the dorm, the new player is warped out of Orientation within seconds.
// So: preserve existing presence; only fall back to the offline dorm when the
// player has none (e.g. the title screen before they've entered any room).
if (id !== null) {
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence?.roomInstance) {
return c.json({ errorCode: 0, roomInstance: presence.roomInstance })
}
}
// Authed but no presence → their personal dorm; unauthenticated → offline dorm.
const instance = id !== null ? await playerDormInstance(c, id) : dormRoomInstance()
if (id !== null) await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Each matchmake persists the resulting instance as the player's presence so the
// heartbeat can replay it (keeping client presence in sync).
//
// 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
@@ -1152,28 +1043,18 @@ const app = new Hono<App>()
}
)
.post(
'/matchmake/:room',
'/matchmake/dorm',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake into a room by id or name',
summary: 'Matchmake into the players dorm',
description: [
'Single-segment matchmake. `dorm` → the players personal dorm; otherwise resolves',
'the room by id or name. (Note: this dorm keyword is `dorm`, while goto/room uses',
'`dormroom`.)',
'Single-segment matchmake into the callers personal dorm, stored as presence. The',
'client only ever calls this with the `dorm` keyword — real rooms go through',
'`/matchmake/room/:roomId`.',
].join(' '),
security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
parameters: [
{
name: 'room',
in: 'path',
required: true,
description: 'Room id, room name, or `dorm`',
schema: { type: 'string' },
},
],
responses: {
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
200: json(MatchmakeResponse, 'The players personal dorm instance'),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -1181,40 +1062,12 @@ const app = new Hono<App>()
const id = await authedId(c)
if (id === null) return unauthorized(c)
const room = c.req.param('room')
const joinMode = await readJoinMode(c)
// The dorm check here is "dorm" (goto/room uses "dormroom").
const instance =
room.toLowerCase() === 'dorm'
? await playerDormInstance(c, id)
: await resolveRoomInstance(c, room, joinMode === 2, id)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
const instance = await playerDormInstance(c, id)
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Offline dorm — also persisted as presence so the heartbeat stays in sync.
.post(
'/goto/none',
describeRoute({
tags: ['Navigation'],
summary: 'Go to the dorm',
description: [
'Authed → the players personal dorm (persisted as presence); unauthenticated → the',
'shared offline dorm. Unlike matchmake/none, this always goes to the dorm.',
].join(' '),
responses: { 200: json(MatchmakeResponse, 'The dorm instance') },
}),
async (c) => {
const id = await authedId(c)
// Authed → their personal dorm; unauthenticated → the offline dorm.
const instance = id !== null ? await playerDormInstance(c, id) : dormRoomInstance()
if (id !== null) await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Region ping reports — accept-and-ack (the reference returns Ok()).
.put(
'/player/photonregionpings',
+22 -16
View File
@@ -124,7 +124,7 @@ export const PlayerDto = z.object({
})
/**
* The matchmake/goto result envelope. `errorCode` 0 with a `roomInstance` is success;
* The matchmake result envelope. `errorCode` 0 with a `roomInstance` is success;
* a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
*/
export const MatchmakeResponse = z.object({
@@ -136,17 +136,13 @@ export const MatchmakeResponse = z.object({
export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') })
/**
* `POST /player/heartbeat` JSON body. All fields optional — the client also posts a
* non-JSON (LoginLock form) body here, in which case none of these are read and stored
* presence is echoed back unchanged.
* 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 HeartbeatRequest = z.object({
playerId: z.int().optional(),
statusVisibility: z.int().optional(),
deviceClass: z.int().optional(),
vrMovementMode: z.int().optional(),
appVersion: z.string().nullable().optional(),
platform: z.int().optional(),
export const LoginLockRequest = z.object({
LoginLock: z.string().describe('The session login-lock GUID (always sent)'),
})
/** `PUT /roominstance/:id/inprogress` form body. */
@@ -160,7 +156,16 @@ export const StatusVisibilityRequest = z.object({
})
/**
* The `JoinMode` form field the matchmake/goto routes read (`2` = a private instance;
* `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 `JoinMode` form field the matchmake routes read (`2` = a private instance;
* anything else = public). Posted as a urlencoded/multipart body.
*/
export const JoinModeRequest = z.object({
@@ -170,16 +175,17 @@ export const JoinModeRequest = z.object({
/**
* The room-matchmake form body (`/matchmake/room/:roomId[/:subRoomId]`). Beyond
* `JoinMode` the 2023 client posts `AdditionalPlayerIds` — the caller's party — so each
* of them is invited (a game invite) into the instance the leader lands in. May repeat
* and/or be comma-separated. Other fields the client sends (`LoginLock`,
* `MaxPersistenceVersion`, `BypassMovementModeRestriction`) are accepted and ignored.
* of them is invited (a game invite) into the instance the leader lands in. It's a
* repeated field (one id each, not comma-separated). Other fields the client sends
* (`LoginLock`, `MaxPersistenceVersion`, `BypassMovementModeRestriction`) are accepted
* and ignored.
*/
export const MatchmakeRoomRequest = z.object({
JoinMode: z.string().optional().describe('"2" requests a private instance'),
AdditionalPlayerIds: z
.string()
.optional()
.describe('Party members to invite into the room; repeatable and/or comma-separated'),
.describe('Party members to invite into the room; repeated once per id'),
})
/** `POST /invite` form body — invite a player into the caller's room instance. */
+76 -152
View File
@@ -258,22 +258,6 @@ describe('public endpoints', () => {
expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: GAME_VERSION })
})
test('POST /goto/none returns the offline dorm', async () => {
const res = await exports.default.fetch(`${ORIGIN}/goto/none`, { method: 'POST' })
expect(res.status).toBe(200)
const body = (await res.json()) as {
errorCode: number
roomInstance: { name: string; location: string; photonRoomId: string }
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({
name: '^DormRoom',
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
isPrivate: true,
})
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
})
test('POST /matchmake/room/:roomId resolves the room scene from D1', async () => {
const headers = await bearer('88')
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
@@ -425,45 +409,6 @@ describe('public endpoints', () => {
expect(await res.json()).toEqual({ errorCode: 20, roomInstance: null })
})
test('POST /matchmake/none returns the offline dorm when the player has no presence', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, { method: 'POST' })
expect(res.status).toBe(200)
const body = (await res.json()) as {
errorCode: number
roomInstance: { name: string; location: string; isPrivate: boolean; photonRoomId: string }
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({
name: '^DormRoom',
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
isPrivate: true,
})
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
})
test('POST /matchmake/none preserves an existing presence (does not warp to the dorm)', async () => {
const auth = await bearer('314')
// Put the player in a room first (RecCenter), establishing presence.
await exports.default.fetch(`${ORIGIN}/matchmake/2`, { method: 'POST', headers: auth })
// matchmake/none must return that same room, not force the dorm — this is
// what keeps a new player in the solo Orientation room.
const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, {
method: 'POST',
headers: auth,
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
errorCode: number
roomInstance: { roomId: number; name: string; location: string }
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({
roomId: 2,
name: '^RecCenter',
location: RECCENTER_SCENE,
})
})
test('PUT /player/statusvisibility returns 200', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' })
expect(res.status).toBe(200)
@@ -483,80 +428,10 @@ describe('public endpoints', () => {
})
describe('auth-gated endpoints', () => {
test('POST /goto/room/:room 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/goto/room/dormroom`, { method: 'POST' })
expect(res.status).toBe(401)
})
test('POST /goto/room/dormroom creates and returns the players personal dorm', async () => {
const res = await exports.default.fetch(`${ORIGIN}/goto/room/dormroom`, {
method: 'POST',
headers: await bearer('42'),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
errorCode: number
roomInstance: {
name: string
location: string
isPrivate: boolean
roomId: number
photonRoomId: string
}
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({
// Named after the owner: `@<username>'s Dorm` (no `^` — the `@` is its prefix).
name: "@Tester's Dorm",
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
isPrivate: true,
})
// The dorm gets its own unique Photon room id (isolated from other dorms).
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
// A personal dorm room was created (not the seeded template RoomId 1)…
const roomId = body.roomInstance.roomId
expect(roomId).toBeGreaterThan(2)
// …owned by the player and flagged IsDorm so they can save it.
const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
.bind(roomId)
.first<{ data: string }>()
expect(JSON.parse(row!.data)).toMatchObject({ CreatorAccountId: 42, IsDorm: true })
})
test('POST /goto/room/:id resolves a real room scene from D1', async () => {
const res = await exports.default.fetch(`${ORIGIN}/goto/room/2`, {
method: 'POST',
headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ JoinMode: '2' }).toString(),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
roomInstance: {
roomId: number
roomInstanceId: number
isPrivate: boolean
name: string
location: string
photonRoomId: string
}
}
expect(body.roomInstance).toMatchObject({
roomId: 2,
name: '^RecCenter',
location: RECCENTER_SCENE,
isPrivate: true,
})
// The instance id is the room_instance table id (high-based, so it never
// collides with the dorm's fixed instance id of 1).
expect(body.roomInstance.roomInstanceId).toBeGreaterThan(1)
// Every non-dorm instance gets a fresh random Photon room id (a bare UUID).
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
})
test('POST /matchmake/:room reuses a public instance across players; a private one is fresh', async () => {
test('POST /matchmake/room/:roomId reuses a public instance across players; a private one is fresh', async () => {
const matchmake = async (sub: string, joinMode?: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/2`, {
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: {
...(await bearer(sub)),
@@ -590,7 +465,7 @@ describe('auth-gated endpoints', () => {
expect(second).not.toBe(first)
})
test('POST /matchmake/:room 401s without a token', async () => {
test('POST /matchmake/dorm 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST' })
expect(res.status).toBe(401)
})
@@ -633,8 +508,8 @@ describe('auth-gated endpoints', () => {
})
})
test('POST /matchmake/:room resolves a room by name from D1', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/RecCenter`, {
test('POST /matchmake/room/:roomId resolves a room by name from D1', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/RecCenter`, {
method: 'POST',
headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ JoinMode: '2' }).toString(),
@@ -688,7 +563,7 @@ describe('auth-gated endpoints', () => {
expect(hb.roomInstance).toEqual(mm.roomInstance)
})
test('heartbeat merges posted status fields into stored presence', async () => {
test('heartbeat ignores posted status fields stored presence is returned unchanged', async () => {
const headers = await bearer('8')
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
const hb = (await (
@@ -703,37 +578,86 @@ describe('auth-gated endpoints', () => {
appVersion: string
isOnline: boolean
}
// Posted fields are NOT merged — the stored dorm presence (its defaults) is returned.
expect(hb).toMatchObject({
statusVisibility: 2,
platform: 5,
appVersion: '20210129',
statusVisibility: 0,
platform: 0,
appVersion: GAME_VERSION,
isOnline: true,
})
})
test('heartbeat pushes a PresenceHeartbeatResponse over the websocket', async () => {
test('heartbeat pushes no websocket frame', async () => {
// The notify DO is stubbed to record every send (see vitest.config).
type Sent = { playerId: number; notificationType: number; data: Record<string, unknown> }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
const headers = await bearer('9600')
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
// Clear whatever the matchmake fan-out recorded so we observe only the heartbeat.
await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ statusVisibility: 2 }),
headers,
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[]
// Exactly one frame: PresenceHeartbeatResponse (4) to the beating player, whose
// payload is the very presence the HTTP body carried.
expect(sent).toHaveLength(1)
expect(sent[0].playerId).toBe(9600)
expect(sent[0].notificationType).toBe(4) // NotificationType.PresenceHeartbeatResponse
expect(sent[0].data).toEqual(body)
// The heartbeat no longer echoes itself back over the websocket.
expect(sent).toHaveLength(0)
})
test('login records the LoginLock; a superseded heartbeat gets an empty body', async () => {
const headers = await bearer('8100')
// Enter a room so there's live presence, then record this session's lock at login.
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
await exports.default.fetch(`${ORIGIN}/player/login`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'LoginLock=session-one',
})
// A heartbeat carrying the recorded lock gets the presence back.
const ok = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'LoginLock=session-one',
})
expect(ok.status).toBe(200)
expect(((await ok.json()) as { isOnline: boolean }).isOnline).toBe(true)
// A heartbeat from a superseded session (different lock) gets nothing.
const stale = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'LoginLock=session-two',
})
expect(stale.status).toBe(200)
expect(await stale.text()).toBe('')
})
test('login with no live presence seeds a lobby row carrying the lock', async () => {
const headers = await bearer('8200')
await exports.default.fetch(`${ORIGIN}/player/login`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'LoginLock=lobby-lock',
})
// Online in the lobby (no room), and a mismatched heartbeat is rejected on the lock
// recorded at login even though no matchmake ever ran.
const hb = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'LoginLock=lobby-lock',
})
expect(((await hb.json()) as { isOnline: boolean; roomInstance: unknown }).isOnline).toBe(true)
const stale = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'LoginLock=other',
})
expect(await stale.text()).toBe('')
})
// Seed presence directly into D1 with a chosen `expiresAt` (epoch seconds) so the
@@ -822,7 +746,7 @@ describe('auth-gated endpoints', () => {
// Matchmake into a room, returning the resulting instance id.
const matchmakeInto = async (room: string, sub: string): Promise<number> => {
const res = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/${room}`, {
await exports.default.fetch(`${ORIGIN}/matchmake/room/${room}`, {
method: 'POST',
headers: await bearer(sub),
})
@@ -1296,9 +1220,12 @@ describe('auth-gated endpoints', () => {
RoomId: 2,
})
// Multiple ids (comma-separated), de-duplicated, and the leader themselves is skipped.
// Multiple ids (repeated fields, not comma-separated), de-duplicated, and the leader
// themselves is skipped.
await reset()
await matchmake('AdditionalPlayerIds=153,154,153,9850&JoinMode=0')
await matchmake(
'AdditionalPlayerIds=153&AdditionalPlayerIds=154&AdditionalPlayerIds=153&AdditionalPlayerIds=9850&JoinMode=0'
)
const many = await sent()
expect(many.map((i) => i.playerId).sort((a, b) => a - b)).toEqual([153, 154])
@@ -1333,15 +1260,12 @@ describe('auth-gated endpoints', () => {
'GET /room/{roomId}/instances',
'GET /rooms/requiring/developer',
'GET /rooms/requiring/rrplus',
'POST /goto/none',
'POST /goto/room/{room}',
'POST /invite',
'POST /matchmake/club/{clubId}',
'POST /matchmake/none',
'POST /matchmake/dorm',
'POST /matchmake/player/{playerId}',
'POST /matchmake/room/{roomId}',
'POST /matchmake/room/{roomId}/{subRoomId}',
'POST /matchmake/{room}',
'POST /player/exclusivelogin',
'POST /player/heartbeat',
'POST /player/login',
+7
View File
@@ -59,6 +59,13 @@ export interface PresenceInput<TRoomInstance = unknown> {
vrMovementMode: number
platform: number
appVersion: string
/**
* The session's `LoginLock` GUID, bound from the form body at matchmake time. The
* heartbeat posts it back purely to verify it still owns the session — a heartbeat
* carrying a different lock is a superseded session and is rejected. Absent until a
* matchmake supplies one.
*/
loginLock?: string
}
/** A stored presence row — the input plus its absolute expiry (epoch seconds). */