Files
recflare/apps/match/src/match.app.ts
T
2026-07-22 10:12:50 -04:00

1135 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
canManageRoom,
createRoomInstance,
deleteExpiredPresence,
deletePresence,
getAccount,
getClubSummary,
getExpiredPresenceInstanceIds,
getJoinableInstance,
getOrCreateDormRoom,
getPresence,
getPresences,
getRoomById,
getRoomByName,
getRoomInstancesByRoom,
isClubMember,
refreshInstanceFullness,
RoomInstanceType,
setPresence,
setRoomInstanceInProgress,
} from '@repo/domain'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
AUTHED,
EMPTY_OK,
ExclusiveLoginResponse,
form,
HeartbeatRequest as HeartbeatRequestSchema,
InProgressRequest,
JoinModeRequest,
json,
jsonBody,
MatchmakeResponse,
PlayerDto,
RoomInstanceDto,
StatusVisibilityRequest,
UNAUTHORIZED_RESPONSE,
} from './openapi'
import type { Context } from 'hono'
import type { Room, StoredPresence } from '@repo/domain'
import type { App, Env } from './context'
/**
* The matchmaking surface. Rooms and room instances are D1-backed (matchmaking
* finds/creates a `room_instance` row per session); player lookups still fall back
* to default values when nothing is found. Presence is D1-backed too (the
* `presence` table; see @repo/domain's presence-db).
*
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
*/
/**
* The connection fields the client expects on a player payload but that only ever
* carry a value in a matchmaking response — the photon/voice credentials for the
* instance you were just placed into. Reading someone else's presence never hands
* out credentials, so they're always null here; the client needs the keys present.
*/
const NULL_CONNECTION_INFO = {
photonAuthToken: null,
photonRealtimeAppId: null,
photonVoiceAppId: null,
photonChatAppId: null,
photonRegion: null,
photonRoomId: null,
voiceConnectionInfo: null,
voiceServerId: null,
experiments: null,
} 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.
*/
function playerPayload(playerId: number, presence?: Presence | null) {
return {
appVersion: presence?.appVersion || GAME_VERSION,
deviceClass: presence?.deviceClass ?? 0,
errorCode: 0,
// `getPresence` yields null and the batch map yields undefined — neither is online.
isOnline: presence != null,
playerId,
roomInstance: presence?.roomInstance ?? null,
statusVisibility: presence?.statusVisibility ?? 0,
vrMovementMode: presence?.vrMovementMode ?? 1,
platform: presence?.platform ?? 0,
...NULL_CONNECTION_INFO,
}
}
/** 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,
* the token is invalid, or the `sub` claim isn't an integer.
*/
async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Results.Unauthorized() equivalent — 401 with empty body. */
function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/** A synthesized room instance (same shape for dorm and other rooms). */
type RoomInstance = ReturnType<typeof dormRoomInstance>
/**
* Stored presence for a player — the room instance they matchmade into plus the
* status fields the heartbeat echoes back. The generic StoredPresence lives in
* @repo/domain; here it's specialized to the match worker's RoomInstance shape.
*/
type Presence = StoredPresence<RoomInstance>
/**
* A heartbeat that changes nothing re-writes presence only once its TTL drops
* within this window of expiring (s), instead of on every beat. So a player who's
* sitting still is refreshed at most once per (PRESENCE_TTL_SECONDS this) rather
* than on every heartbeat — far fewer D1 writes, while still staying comfortably
* ahead of expiry (the client heartbeats many times inside this window).
*/
const PRESENCE_REFRESH_THRESHOLD = 300
/**
* Game build version reported in presence. This is a server-side constant — the
* client doesn't supply it, and an empty value breaks the client's
* presence/version handling. Matches our target 2023 client build.
*/
const GAME_VERSION = '20230302'
/**
* Default `/player` payload, served whenever the `id` is missing/invalid or the
* account isn't found. Inlined here (Workers have no filesystem). The stub player
* reads as online — it's a placeholder for a real, present player.
*/
const DEFAULT_GET_PLAYER = [{ ...playerPayload(1), isOnline: true }]
/**
* Store the room instance the player just matchmade into, preserving status.
*
* With no live presence to carry forward (the player's first matchmake after login,
* or one after their presence lapsed) the device fields would otherwise default —
* writing a screen player into the instance as deviceClass 0 until their next
* heartbeat corrects it. Everyone already in the room sees that stale class in the
* meantime, so fall back to what the account reported at login (auth stores
* `deviceClass`/`platform` from the token request) instead of to 0. The account read
* only happens on that no-presence path; a normal matchmake carries `prev` forward.
*/
async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance): Promise<void> {
const prev = await getPresence<RoomInstance>(c.env.DB, id)
const account = prev ? null : await getAccount(c.env.DB, id)
await setPresence(c.env.DB, {
accountId: id,
roomInstance,
statusVisibility: prev?.statusVisibility ?? 0,
deviceClass: prev?.deviceClass ?? account?.deviceClass ?? 0,
vrMovementMode: prev?.vrMovementMode ?? 1,
platform: prev?.platform ?? account?.platform ?? 0,
appVersion: prev?.appVersion || GAME_VERSION,
})
// 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
// instance they left — its head-count dropped — so a full room frees up when
// players move on. Both no-op for the synthetic dorm/orientation instances.
await refreshInstanceFullness(c.env.DB, roomInstance.roomInstanceId)
const leftId = prev?.roomInstance?.roomInstanceId
if (leftId != null && leftId !== roomInstance.roomInstanceId) {
await refreshInstanceFullness(c.env.DB, leftId)
}
}
/**
* 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
/**
* The sentinel room-instance id the `auth` worker seeds a brand-new player's
* Orientation presence with (see auth's `placeNewPlayerInOrientation`). The client
* fires a spurious `player/logout` right after that seed, so logout must NOT clear
* presence while it still points at Orientation — doing so wipes the seed and
* bounces the new player to the dorm.
*/
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
* makes the client reject the session with "unknown scene location ID".
*
* `subRoomId` picks which of the room's subrooms to enter (the client matchmakes
* into one with `/matchmake/room/{roomId}/{subRoomId}`); an unknown or unspecified
* subroom falls back to the room's first, which is its default entrance.
*/
function instanceFieldsFromRoom(room: Room, subRoomId?: number) {
const subRooms = (Array.isArray(room.SubRooms) ? room.SubRooms : []) as Array<
Record<string, unknown>
>
const sub =
(subRoomId === undefined ? undefined : subRooms.find((s) => s.SubRoomId === subRoomId)) ??
subRooms[0]
const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback)
const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback)
// Room instance names are prefixed with `^` so the client resolves the instance
// (without it the new scene won't load). Personal dorms are the exception: they
// carry the owner prefix `@<user>'s Dorm` and must NOT also get a `^`.
const rawName = str(room.Name, 'Room')
const name = rawName.startsWith('^') || rawName.startsWith('@') ? rawName : `^${rawName}`
return {
roomId: num(room.RoomId, 1),
subRoomId: num(sub?.SubRoomId, 1),
location: str(sub?.UnitySceneId),
dataBlob: str(sub?.DataBlob),
name,
maxCapacity: num(sub?.MaxPlayers, 4),
roomInstanceType: room.IsDorm === true ? RoomInstanceType.Dormroom : RoomInstanceType.Public,
isDorm: room.IsDorm === true,
}
}
/**
* Build the client instance wire shape from a stored room plus the live instance's
* id + Photon room id (both come from the `room_instance` table so joiners of the
* same instance share them).
*/
function roomInstanceFromRoom(
room: Room,
isPrivate: boolean,
instanceId: number,
photonRoomId: string,
subRoomId?: number
): RoomInstance {
const f = instanceFieldsFromRoom(room, subRoomId)
return {
roomInstanceId: instanceId,
roomId: f.roomId,
subRoomId: f.subRoomId,
roomInstanceType: f.roomInstanceType,
location: f.location,
dataBlob: f.dataBlob,
eventId: 0,
clubId: 0,
roomCode: '',
photonRegion: 'us',
photonRegionId: 'us',
photonRoomId,
name: f.name,
maxCapacity: f.maxCapacity,
isFull: false,
isPrivate: isPrivate || f.isDorm,
isInProgress: false,
EncryptVoiceChat: false,
}
}
/** 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
}
/**
* Resolve a room by `:room` path segment (numeric id or name) from D1, then find a
* joinable instance of it (public matchmakes reuse one via the `room_instance`
* table) or create a new one. Returns null when the room isn't found.
*/
async function resolveRoomInstance(
c: Context<App>,
roomKey: string,
isPrivate: boolean,
ownerId: number,
subRoomId?: number
): Promise<RoomInstance | null> {
const id = Number.parseInt(roomKey, 10)
const room = Number.isNaN(id)
? await getRoomByName(c.env.DB, roomKey)
: await getRoomById(c.env.DB, id)
if (!room) return null
const f = instanceFieldsFromRoom(room, subRoomId)
// Never place the player back into the instance they're already in: the client
// keys the room transition off a changing `roomInstanceId`, so re-matchmaking into
// your current instance (e.g. the only public instance of a room you're already in)
// returns the same id and hangs the client mid-join. Exclude it from the join
// search, which pushes them to another live instance if one exists or forces a
// fresh one below. (Only the public path reuses instances, so only it needs the
// read; a private matchmake always gets a fresh instance.)
const currentInstanceId = isPrivate
? undefined
: (await getPresence<RoomInstance>(c.env.DB, ownerId))?.roomInstance?.roomInstanceId
// Reuse an existing joinable public instance *of the same subroom* — subrooms are
// separate places, so joining one must never land you in another. Private
// matchmakes always get a fresh instance. Create one when there's nothing to join.
let instance = isPrivate
? null
: await getJoinableInstance(c.env.DB, f.roomId, f.subRoomId, currentInstanceId)
if (!instance) {
instance = await createRoomInstance(c.env.DB, {
ownerAccountId: ownerId,
roomId: f.roomId,
subRoomId: f.subRoomId,
location: f.location,
dataBlob: f.dataBlob,
photonRoomId: crypto.randomUUID(),
name: f.name,
maxCapacity: f.maxCapacity,
isPrivate: isPrivate || f.isDorm,
roomInstanceType: f.roomInstanceType,
})
}
return roomInstanceFromRoom(
room,
isPrivate,
instance.roomInstanceId,
instance.photonRoomId,
f.subRoomId
)
}
/**
* 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
* a stable, unique Photon room id (dorms are isolated from each other) that
* survives re-entry. The room's current scene/saved data is re-read each time, so
* edits show up on the next visit.
*/
async function playerDormInstance(c: Context<App>, accountId: number): Promise<RoomInstance> {
const room = await getOrCreateDormRoom(c.env.DB, accountId)
const f = instanceFieldsFromRoom(room)
// Reuse the dorm's one instance (private, so getJoinableInstance won't find it).
let instance = (await getRoomInstancesByRoom(c.env.DB, f.roomId))[0]
if (!instance) {
instance = await createRoomInstance(c.env.DB, {
ownerAccountId: accountId,
roomId: f.roomId,
subRoomId: f.subRoomId,
location: f.location,
dataBlob: f.dataBlob,
photonRoomId: crypto.randomUUID(),
name: f.name,
maxCapacity: f.maxCapacity,
isPrivate: true,
roomInstanceType: f.roomInstanceType,
})
}
return roomInstanceFromRoom(room, true, instance.roomInstanceId, instance.photonRoomId)
}
const app = new Hono<App>()
.use(
'*',
// middleware
(c, next) =>
useWorkersLogger(c.env.NAME, {
environment: c.env.ENVIRONMENT,
release: c.env.SENTRY_RELEASE,
})(c, next)
)
.onError(withOnError())
.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.
.post(
'/player/login',
describeRoute({
tags: ['Presence'],
summary: 'Login ack (no-op)',
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.',
responses: { 200: EMPTY_OK },
}),
(c) => 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.',
responses: { 200: json(ExclusiveLoginResponse, 'errorCode 0') },
}),
(c) => c.json({ errorCode: 0 })
)
// Logout clears the player's presence so they read offline immediately and the
// instance they were in frees up (rather than waiting out the presence TTL).
//
// EXCEPTION: the account-creation bootstrap. The client fires a spurious
// `player/logout` right after a new player is seeded into Orientation (the auth
// 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).
.post(
'/player/logout',
describeRoute({
tags: ['Presence'],
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 ' +
'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.',
responses: { 200: EMPTY_OK },
}),
async (c) => {
const id = await authedId(c)
if (id !== null) {
const presence = await getPresence<RoomInstance>(c.env.DB, id)
const instanceId = presence?.roomInstance?.roomInstanceId
if (presence && instanceId !== ORIENTATION_INSTANCE_ID) {
await deletePresence(c.env.DB, id)
// The instance they were in lost a player — recompute its fullness so a
// full room frees up. No-op for the synthetic dorm/orientation instances.
if (instanceId != null) await refreshInstanceFullness(c.env.DB, instanceId)
}
}
return c.body(null, 200)
}
)
// 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.
.post(
'/player/notifydisconnect',
describeRoute({
tags: ['Presence'],
summary: 'Disconnect notification (no-op ack)',
description:
'Posted when the client drops a room. Not acted on — presence is cleared by logout ' +
'and otherwise expires on its TTL.',
responses: { 200: EMPTY_OK },
}),
(c) => c.body(null, 200)
)
.get(
'/player',
describeRoute({
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.',
parameters: [
{
name: 'id',
in: 'query',
required: false,
description: 'Repeatable; each value may be a comma-separated list of player ids',
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.
const ids = c.req
.queries('id')
?.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n))
if (!ids || ids.length === 0) return c.json(DEFAULT_GET_PLAYER)
// One query for the whole batch (D1 `WHERE account_id IN (…)`), rather than a
// point read per id as the KV store required.
const presences = await getPresences<RoomInstance>(c.env.DB, ids)
return c.json(ids.map((playerId) => playerPayload(playerId, presences.get(playerId))))
}
)
.post(
'/player/heartbeat',
describeRoute({
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).',
security: AUTHED,
requestBody: jsonBody(
HeartbeatRequestSchema,
'JSON status fields. A non-JSON (LoginLock) body is accepted and ignored.'
),
responses: {
200: json(PlayerDto, 'The players current presence payload'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
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 = {}
}
}
// Return the player's stored presence (set by matchmake/goto), 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.
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
}
}
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).
const nowSeconds = Math.floor(Date.now() / 1000)
const dueForRefresh = presence.expiresAt - nowSeconds <= PRESENCE_REFRESH_THRESHOLD
if (changed || dueForRefresh) {
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.
return c.json({
...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,
})
}
)
.put(
'/player/statusvisibility',
describeRoute({
tags: ['Presence'],
summary: 'Set status visibility',
description:
'Updates the stored presences status visibility. No-op when the player has no live ' +
'presence or an unauthenticated/invalid token — always acks 200.',
requestBody: form(StatusVisibilityRequest, 'The statusVisibility value'),
responses: { 200: EMPTY_OK },
}),
async (c) => {
const id = await authedId(c)
if (id !== null) {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const sv =
typeof body.statusVisibility === 'string'
? Number.parseInt(body.statusVisibility, 10)
: NaN
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence && !Number.isNaN(sv)) {
presence.statusVisibility = sv
await setPresence(c.env.DB, presence)
}
}
return c.body(null, 200)
}
)
// ---- 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.',
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.',
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 })
}
)
// 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
// someone with a pending request, or banned) is refused rather than let in.
.post(
'/matchmake/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake into a clubs clubhouse',
description:
'Looks the club up, checks the caller is a member of it, and places them into an ' +
'instance of its clubhouse room. Returns errorCode 20 with a null instance when the ' +
'club is unknown, has no clubhouse set, or the caller isnt a member.',
security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
parameters: [
{
name: 'clubId',
in: 'path',
required: true,
description: 'Club id (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(
MatchmakeResponse,
'The clubhouse instance (or errorCode 20 with null when it cant be entered)'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClubSummary(c.env.DB, clubId)
// 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 (!(await isClubMember(c.env.DB, clubId, id))) {
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
}
const joinMode = await readJoinMode(c)
const instance = await resolveRoomInstance(
c,
String(club.clubhouseRoomId),
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 })
}
)
// Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}`
// — the client uses this to enter a room's other scenes). The subroom decides the
// scene the client loads and which instances are joinable, so it must be carried
// through; an unknown subroom falls back to the room's first.
.post(
'/matchmake/room/:roomId/:subRoomId{[0-9]+}',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake into a specific subroom',
description:
'Enters a specific subroom (scene) of a room. The subroom decides the scene loaded ' +
'and which instances are joinable; an unknown subroom falls back to the rooms first.',
security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
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(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 joinMode = await readJoinMode(c)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const instance = await resolveRoomInstance(
c,
c.req.param('roomId'),
joinMode === 2,
id,
subRoomId
)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// The 2023 client uses a two-segment matchmake/room/{roomId}. Look the room up
// in D1 so the instance carries its real scene, and store it as presence.
.post(
'/matchmake/room/:roomId',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake into a room (default subroom)',
description:
'The 2023 clients two-segment matchmake. Resolves the room from D1 so the instance ' +
'carries its real scene, and stores it as presence.',
security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
parameters: [{ name: 'roomId', in: 'path', required: true, 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 joinMode = await readJoinMode(c)
const instance = await resolveRoomInstance(c, c.req.param('roomId'), 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 })
}
)
.post(
'/matchmake/:room',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake into a room by id or name',
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`.)',
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)'),
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)
// 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 })
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.',
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',
describeRoute({
tags: ['Presence'],
summary: 'Photon region pings (no-op ack)',
description: 'Region latency report; accepted and ignored.',
responses: { 200: EMPTY_OK },
}),
(c) => c.body(null, 200)
)
.put(
'/player/gameserverregionpings',
describeRoute({
tags: ['Presence'],
summary: 'Game-server region pings (no-op ack)',
description: 'Region latency report; accepted and ignored.',
responses: { 200: EMPTY_OK },
}),
(c) => c.body(null, 200)
)
// ---- Room instance -------------------------------------------------------
.post(
'/roominstance/:id/reportjoinresult',
describeRoute({
tags: ['Room instance'],
summary: 'Report join result (no-op ack)',
description: 'The client reports how a join went; accepted and ignored.',
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: { 200: EMPTY_OK },
}),
(c) => c.body(null, 200)
)
// The room owner flips the instance's in-progress flag once the session starts
// (e.g. a game round begins). Body is a form post: `inProgress=True|False`.
.put(
'/roominstance/:id/inprogress',
describeRoute({
tags: ['Room instance'],
summary: 'Set instance in-progress flag',
description:
'The room owner flips the instances in-progress flag when a session starts (e.g. a ' +
'round begins). Body is `inProgress=True|False`.',
security: AUTHED,
requestBody: form(InProgressRequest, 'The inProgress flag'),
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
200: EMPTY_OK,
401: UNAUTHORIZED_RESPONSE,
404: { description: 'Non-numeric id or no such instance (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const instanceId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(instanceId)) return c.body(null, 404)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const inProgress =
typeof body.inProgress === 'string' && body.inProgress.toLowerCase() === 'true'
const instance = await setRoomInstanceInProgress(c.env.DB, instanceId, inProgress)
if (!instance) return c.body(null, 404)
return c.body(null, 200)
}
)
// The room's live instances — the owner's view of active sessions of their room.
// Auth-gated (401) and owner/co-owner-only (403): the caller must be the room's
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns the
// bare RoomInstance DTO array (empty when the room has no live instances).
.get(
'/room/:roomId{[0-9]+}/instances',
describeRoute({
tags: ['Room instance'],
summary: 'A rooms live instances',
description:
'The owners view of active sessions of their room. Auth-gated and gated to the ' +
'rooms creator or a co-owner (403 otherwise). Unknown room → 404.',
security: AUTHED,
parameters: [
{
name: 'roomId',
in: 'path',
required: true,
description: 'Room id (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(RoomInstanceDto.array(), 'Live instances (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the rooms creator or a co-owner (empty body)' },
404: { description: 'No such room (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return c.body(null, 404)
// The room's creator *or* a co-owner (Role 30) may see its live instances —
// same owner-or-co-owner gate the rooms worker uses for room-admin actions.
if (!canManageRoom(room, id)) return c.body(null, 403)
return c.json(await getRoomInstancesByRoom(c.env.DB, roomId))
}
)
// Rooms flagged as needing a developer/moderator to spawn in. No such queue
// yet → empty list.
.get(
'/rooms/requiring/developer',
describeRoute({
tags: ['Room instance'],
summary: 'Rooms requiring a developer',
description: 'Rooms flagged as needing a developer/moderator to spawn in. No queue yet → [].',
responses: { 200: json(RoomInstanceDto.array(), 'Always empty for now') },
}),
(c) => c.json([])
)
// Rooms flagged as requiring an RR+ subscription. No such queue yet → empty list.
.get(
'/rooms/requiring/rrplus',
describeRoute({
tags: ['Room instance'],
summary: 'Rooms requiring RR+',
description: 'Rooms flagged as requiring an RR+ subscription. No queue yet → [].',
responses: { 200: json(RoomInstanceDto.array(), 'Always empty for now') },
}),
(c) => c.json([])
)
/**
* Cron: sweep presence that has aged past its TTL. Reads already ignore expired rows,
* so this isn't about correctness of `/player` — it's that a player who crashed or
* hard-quit never matchmakes out of their instance, so nothing recomputes that
* instance's fullness and it can stay flagged full (and unjoinable) with nobody in it.
* Recompute the instances the expiring rows point at, *then* delete: the sweep is the
* only thing that notices those departures. Fullness is recomputed after the delete so
* the head-count no longer sees them.
*/
async function sweepExpiredPresence(env: Env): Promise<void> {
const staleInstanceIds = await getExpiredPresenceInstanceIds(env.DB)
const removed = await deleteExpiredPresence(env.DB)
for (const instanceId of staleInstanceIds) {
await refreshInstanceFullness(env.DB, instanceId)
}
// The tagged logger is request-scoped (its middleware never runs for a cron), so
// log plainly here — Workers observability picks it up either way.
console.log(
`presence sweep: removed ${removed} expired rows, refreshed ${staleInstanceIds.length} instances`
)
}
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output. Registered on
// `app` before it's wrapped in the exported handler below.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare match',
version: '1.0.0',
description: [
'Matchmaking and presence for recflare, a private-server reimplementation of the Rec',
'Room backend. Rooms and room instances are D1-backed (matchmaking finds or creates a',
'`room_instance` per session); presence — the instance each player is currently in —',
'lives in the shared `presence` table and expires on a TTL. A cron sweep clears',
'expired presence and frees up instances a crashed player never left.',
'',
'The shapes here are **reverse-engineered from the game client**, which is the only',
'real consumer. They record observed behaviour, not a designed contract; the handlers',
'are lenient and parse bodies defensively. Nothing in this spec is enforced at',
'runtime — treat a field marked required as "the client always sends it", not "the',
'server rejects it if absent".',
].join('\n'),
},
servers: [{ url: 'https://match.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
// The HTTP surface is a standard Hono app, exported by name so it can be mounted
// uniformly like every other worker (e.g. by a combined/facade worker). The cron
// that sweeps expired presence is exported alongside it.
export { app }
export const scheduled: ExportedHandlerScheduledHandler<Env> = (_controller, env, ctx) => {
ctx.waitUntil(sweepExpiredPresence(env))
}
// Standalone entry: a Worker only runs `scheduled` when it's on the default export,
// so match keeps the object form the runtime requires to fire its `*/5 * * * *` cron.
export default { fetch: app.fetch, scheduled } satisfies ExportedHandler<Env>