From a4ddef2cf73a77b44f34b8f92a448af9df4acdf6 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 20 Jul 2026 17:57:48 -0400 Subject: [PATCH] match docs, tweak for monowrapper --- apps/accounts/src/openapi.ts | 128 ++-- apps/auth/src/openapi.ts | 143 ++-- apps/match/README.md | 123 ++- apps/match/package.json | 7 +- apps/match/src/match.app.ts | 785 +++++++++++++++----- apps/match/src/openapi.ts | 168 +++++ apps/match/src/test/integration/api.test.ts | 54 +- pnpm-lock.yaml | 15 + 8 files changed, 1043 insertions(+), 380 deletions(-) create mode 100644 apps/match/src/openapi.ts diff --git a/apps/accounts/src/openapi.ts b/apps/accounts/src/openapi.ts index 1f0ae08..c93a50c 100644 --- a/apps/accounts/src/openapi.ts +++ b/apps/accounts/src/openapi.ts @@ -47,19 +47,17 @@ export function form(schema: z.ZodType, description: string): OpenAPIV3_1.Reques * account, with private fields (email, birthday) excluded. Fields the client parses * as enums are numbers here. */ -export const AccountDto = z - .object({ - accountId: z.int(), - username: z.string(), - displayName: z.string(), - profileImage: z.string().describe('Avatar object key'), - isJunior: z.boolean(), - platforms: z.int().describe('PlatformType bitmask of linked platforms'), - personalPronouns: z.int().describe('Pronoun flags bitmask'), - identityFlags: z.int().describe('Identity flags bitmask'), - createdAt: z.iso.datetime(), - }) - .meta({ id: 'AccountDto' }) +export const AccountDto = z.object({ + accountId: z.int(), + username: z.string(), + displayName: z.string(), + profileImage: z.string().describe('Avatar object key'), + isJunior: z.boolean(), + platforms: z.int().describe('PlatformType bitmask of linked platforms'), + personalPronouns: z.int().describe('Pronoun flags bitmask'), + identityFlags: z.int().describe('Identity flags bitmask'), + createdAt: z.iso.datetime(), +}) /** * The private self DTO (`toSelfAccountDto`, the `/account/me` shape) — the public DTO @@ -71,27 +69,24 @@ export const SelfAccountDto = AccountDto.extend({ email: z.string().nullable(), birthday: z.null().describe('Always null — birthday is not stored'), availableUsernameChanges: z.int().describe('Remaining username changes'), -}).meta({ id: 'SelfAccountDto' }) +}) /** Player bio, from `GET /account/:id/bio`. */ -export const BioResponse = z - .object({ accountId: z.int(), bio: z.string().describe('"" when unset') }) - .meta({ id: 'BioResponse' }) +export const BioResponse = z.object({ + accountId: z.int(), + bio: z.string().describe('"" when unset'), +}) /** A bare `{ success: true }` ack, returned by most profile mutations. */ -export const SuccessResponse = z - .object({ success: z.literal(true) }) - .meta({ id: 'SuccessResponse' }) +export const SuccessResponse = z.object({ success: z.literal(true) }) /** The RecNet result envelope `{ success, value }` used by create + username change. */ -export function envelope(value: z.ZodType, id: string) { - return z - .object({ - success: z.boolean(), - value, - error: z.string().optional().describe('Present (with success:false) on failure'), - }) - .meta({ id }) +export function envelope(value: z.ZodType) { + return z.object({ + success: z.boolean(), + value, + error: z.string().optional().describe('Present (with success:false) on failure'), + }) } /** @@ -99,72 +94,61 @@ export function envelope(value: z.ZodType, id: string) { * a message in `error` and `value` an empty string; on success `value` is the updated * public account. */ -export const UsernameResult = envelope( - z.union([AccountDto, z.literal('')]), - 'UsernameResult' -).describe('value is the updated account on success, "" on failure') +export const UsernameResult = envelope(z.union([AccountDto, z.literal('')])).describe( + 'value is the updated account on success, "" on failure' +) /** `POST /account/create` response. */ -export const CreateAccountResult = envelope(AccountDto, 'CreateAccountResult') +export const CreateAccountResult = envelope(AccountDto) /** `GET /parentalcontrol/me` response. */ -export const ParentalControl = z - .object({ accountId: z.int(), disallowInAppPurchases: z.boolean() }) - .meta({ id: 'ParentalControl' }) +export const ParentalControl = z.object({ accountId: z.int(), disallowInAppPurchases: z.boolean() }) /** * `GET /accountprivacysettings/:id` response. A bare `{}` fails the client's * deserializer, so the id is echoed back and recent history reported visible; nothing * stores per-player privacy yet. */ -export const PrivacySettings = z - .object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() }) - .meta({ id: 'PrivacySettings' }) +export const PrivacySettings = z.object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() }) /** Root health check. */ -export const HealthResponse = z - .object({ service: z.literal('accounts'), status: z.literal('ok') }) - .meta({ id: 'HealthResponse' }) +export const HealthResponse = z.object({ service: z.literal('accounts'), status: z.literal('ok') }) // ---- Request bodies -------------------------------------------------------- /** `POST /account/create` form body. Both fields are parsed but not yet persisted. */ -export const CreateAccountRequest = z - .object({ - platform: z.string().optional().describe('PlatformType integer string; defaults to 0'), - platformId: z.string().optional().describe('Parsed for fidelity; currently unused'), - }) - .meta({ id: 'CreateAccountRequest' }) +export const CreateAccountRequest = z.object({ + platform: z.string().optional().describe('PlatformType integer string; defaults to 0'), + platformId: z.string().optional().describe('Parsed for fidelity; currently unused'), +}) /** Single-string form bodies, one per profile mutation. */ -export const DisplayNameRequest = z - .object({ displayName: z.string().describe('Trimmed; empty is rejected (400)') }) - .meta({ id: 'DisplayNameRequest' }) +export const DisplayNameRequest = z.object({ + displayName: z.string().describe('Trimmed; empty is rejected (400)'), +}) -export const UsernameRequest = z - .object({ username: z.string().describe('Trimmed; must be unique and changes must remain') }) - .meta({ id: 'UsernameRequest' }) +export const UsernameRequest = z.object({ + username: z.string().describe('Trimmed; must be unique and changes must remain'), +}) -export const EmailRequest = z - .object({ email: z.string().describe('Must contain "@"; otherwise 400') }) - .meta({ id: 'EmailRequest' }) +export const EmailRequest = z.object({ + email: z.string().describe('Must contain "@"; otherwise 400'), +}) -export const PhoneRequest = z - .object({ phone: z.string().describe('Trimmed; empty is rejected (400)') }) - .meta({ id: 'PhoneRequest' }) +export const PhoneRequest = z.object({ + phone: z.string().describe('Trimmed; empty is rejected (400)'), +}) -export const IdentityFlagsRequest = z - .object({ identityFlags: z.string().describe('Integer string bitmask; non-numeric is 400') }) - .meta({ id: 'IdentityFlagsRequest' }) +export const IdentityFlagsRequest = z.object({ + identityFlags: z.string().describe('Integer string bitmask; non-numeric is 400'), +}) -export const PronounsRequest = z - .object({ pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400') }) - .meta({ id: 'PronounsRequest' }) +export const PronounsRequest = z.object({ + pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400'), +}) -export const BioRequest = z - .object({ bio: z.string().describe('Free text; empty is allowed') }) - .meta({ id: 'BioRequest' }) +export const BioRequest = z.object({ bio: z.string().describe('Free text; empty is allowed') }) -export const ProfileImageRequest = z - .object({ imageName: z.string().describe('Avatar object key; empty is rejected (400)') }) - .meta({ id: 'ProfileImageRequest' }) +export const ProfileImageRequest = z.object({ + imageName: z.string().describe('Avatar object key; empty is rejected (400)'), +}) diff --git a/apps/auth/src/openapi.ts b/apps/auth/src/openapi.ts index 7d29f12..35af84b 100644 --- a/apps/auth/src/openapi.ts +++ b/apps/auth/src/openapi.ts @@ -60,94 +60,83 @@ export const PlatformType = z ) /** One entry on the client's login screen, from `toCachedLogin`. */ -export const CachedLogin = z - .object({ - platform: PlatformType, - platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'), - accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'), - lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"), - requirePassword: z - .literal(false) - .describe('Always false — platform ownership is the credential for a cached login'), - }) - .meta({ id: 'CachedLogin' }) +export const CachedLogin = z.object({ + platform: PlatformType, + platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'), + accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'), + lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"), + requirePassword: z + .literal(false) + .describe('Always false — platform ownership is the credential for a cached login'), +}) /** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */ -export const OAuthError = z - .object({ - error: z.enum(['invalid_grant', 'invalid_request', 'server_error']), - error_description: z.string(), - }) - .meta({ id: 'OAuthError' }) +export const OAuthError = z.object({ + error: z.enum(['invalid_grant', 'invalid_request', 'server_error']), + error_description: z.string(), +}) /** Successful `POST /connect/token` body. */ -export const TokenResponse = z - .object({ - access_token: z.string().describe('Signed JWT; `sub` is the account id'), - expires_in: z.int().describe('Access-token lifetime in seconds (TOKEN_TTL_SECONDS)'), - token_type: z.literal('Bearer'), - refresh_token: z - .string() - .describe('Single-use; redeem via grant_type=refresh_token, which rotates it'), - scope: z.string().describe('Space-separated granted scopes'), - key: z.string().describe('@kludge Constant the client appears to require. Purpose unknown.'), - }) - .meta({ id: 'TokenResponse' }) +export const TokenResponse = z.object({ + access_token: z.string().describe('Signed JWT; `sub` is the account id'), + expires_in: z.int().describe('Access-token lifetime in seconds (TOKEN_TTL_SECONDS)'), + token_type: z.literal('Bearer'), + refresh_token: z + .string() + .describe('Single-use; redeem via grant_type=refresh_token, which rotates it'), + scope: z.string().describe('Space-separated granted scopes'), + key: z.string().describe('@kludge Constant the client appears to require. Purpose unknown.'), +}) /** * `POST /connect/token` form body — the union of every grant's fields, since * OpenAPI cannot express "these fields iff grant_type=X" without splitting the * endpoint. Per-grant requirements are spelled out in the route description. */ -export const TokenRequest = z - .object({ - grant_type: z - .enum(['create_account', 'cached_login', 'refresh_token', 'password']) - .describe('Anything unrecognised (including absent) is treated as a password grant'), - account_id: z.string().optional().describe('Numeric account id, as a string'), - username: z - .string() - .optional() - .describe('Password grant alternative to account_id; case-insensitive, trimmed'), - password: z - .string() - .optional() - .describe('Required on a password grant. On create_account, sets the initial password'), - platform: z.string().optional().describe('PlatformType as an integer string'), - platform_id: z - .string() - .optional() - .describe( - 'Unverified; ignored in favour of the Steam-verified id where a ticket is required' - ), - platform_auth: z - .string() - .optional() - .describe('Steam session ticket. Required for cached_login and platform create_account'), - refresh_token: z.string().optional().describe('Required on a refresh_token grant'), - device_id: z - .string() - .optional() - .describe('Client-chosen, unverified. Recorded on the account, never trusted'), - device_class: z.string().optional().describe('Integer string; defaults to 0'), - }) - .meta({ id: 'TokenRequest' }) +export const TokenRequest = z.object({ + grant_type: z + .enum(['create_account', 'cached_login', 'refresh_token', 'password']) + .describe('Anything unrecognised (including absent) is treated as a password grant'), + account_id: z.string().optional().describe('Numeric account id, as a string'), + username: z + .string() + .optional() + .describe('Password grant alternative to account_id; case-insensitive, trimmed'), + password: z + .string() + .optional() + .describe('Required on a password grant. On create_account, sets the initial password'), + platform: z.string().optional().describe('PlatformType as an integer string'), + platform_id: z + .string() + .optional() + .describe('Unverified; ignored in favour of the Steam-verified id where a ticket is required'), + platform_auth: z + .string() + .optional() + .describe('Steam session ticket. Required for cached_login and platform create_account'), + refresh_token: z.string().optional().describe('Required on a refresh_token grant'), + device_id: z + .string() + .optional() + .describe('Client-chosen, unverified. Recorded on the account, never trusted'), + device_class: z.string().optional().describe('Integer string; defaults to 0'), +}) /** `POST /account/me/changepassword` form body. */ -export const ChangePasswordRequest = z - .object({ - newPassword: z.string().describe('Required; empty is rejected'), - oldPassword: z - .string() - .optional() - .describe('Must match when the account already has a password; empty when first setting it'), - }) - .meta({ id: 'ChangePasswordRequest' }) +export const ChangePasswordRequest = z.object({ + newPassword: z.string().describe('Required; empty is rejected'), + oldPassword: z + .string() + .optional() + .describe('Must match when the account already has a password; empty when first setting it'), +}) /** `POST /account/me/changepassword` response body. */ -export const ChangePasswordResponse = z - .object({ success: z.boolean(), error: z.string().optional() }) - .meta({ id: 'ChangePasswordResponse' }) +export const ChangePasswordResponse = z.object({ + success: z.boolean(), + error: z.string().optional(), +}) /** * Spec for the `/role/:role/:id` lookups, which are identical apart from the role. @@ -179,6 +168,6 @@ export function roleLookup(role: 'developer' | 'moderator') { } /** Bulk cached-login lookup form body: repeated `id=` fields. */ -export const PlatformIdsRequest = z - .object({ id: z.union([z.string(), z.array(z.string())]).describe('Repeated `id=` form fields') }) - .meta({ id: 'PlatformIdsRequest' }) +export const PlatformIdsRequest = z.object({ + id: z.union([z.string(), z.array(z.string())]).describe('Repeated `id=` form fields'), +}) diff --git a/apps/match/README.md b/apps/match/README.md index c27ee29..cb03c87 100644 --- a/apps/match/README.md +++ b/apps/match/README.md @@ -1,28 +1,107 @@ # match -Matchmaking Worker served on the `match` subdomain. A Hono app for matchmaking. -Database queries are stubbed for now — no real bindings yet. +Matchmaking Worker served on the `match` subdomain (`match.recflare.net`) — a Hono app +that handles player presence and places players into room instances. Rooms, room +instances and presence all live in the shared `recflare` D1 database. -## Behavior +## Routes -- **Auth-gated routes** (`/player/heartbeat`, `/goto/room/:room`) validate the - Bearer JWT issued by the `auth` worker (same dev secret, see `src/jwt.ts`) and - 401 when it's missing/invalid. -- **`GET /player`** always returns the inlined `JSON/getplayer.json` default - (falls back to that file when the account/room instance isn't found). -- **`POST /goto/none`** returns the static offline-dorm instance with a fresh - `photonRoomId`. -- **`POST /goto/room/:room`** synthesizes the room-instance response (no Rooms - binding yet). The dorm gets its known scene id and a private instance; other - rooms get an empty `location` and respect the posted `JoinMode` (2 = private). -- **`POST /player/heartbeat`** echoes the posted heartbeat fields; `roomInstance` - is always null and `isOnline` false until there's a DB binding. -- **`/player/login`, `/player/statusvisibility`, `/roominstance/:id/reportjoinresult`** - return empty 200s. +| Method | Path | Auth | Description | +| ------ | ------------------------------------ | ---- | ------------------------------------------------ | +| POST | `/player/login` | | Login ack (no-op; must not touch presence) | +| POST | `/player/exclusivelogin` | | Exclusive-login ack (no-op) → `{ errorCode: 0 }` | +| POST | `/player/logout` | ✓\* | Clear presence (except the Orientation seed) | +| POST | `/player/notifydisconnect` | | Disconnect notification (no-op ack) | +| GET | `/player?id=1&id=2,3` | | Batch player presence lookup | +| POST | `/player/heartbeat` | ✓ | Presence heartbeat (JSON body) | +| PUT | `/player/statusvisibility` | ✓\* | Set status visibility | +| POST | `/goto/room/:room` | ✓ | Go to a room (`dormroom` → personal dorm) | +| POST | `/matchmake/none` | | Preserve current instance, else dorm | +| POST | `/matchmake/room/:roomId/:subRoomId` | ✓ | Matchmake into a specific subroom | +| POST | `/matchmake/room/:roomId` | ✓ | Matchmake into a room (default subroom) | +| POST | `/matchmake/:room` | ✓ | Matchmake by id or name (`dorm` → personal dorm) | +| POST | `/goto/none` | | Go to the dorm | +| PUT | `/player/photonregionpings` | | Region ping report (no-op ack) | +| PUT | `/player/gameserverregionpings` | | Region ping report (no-op ack) | +| POST | `/roominstance/:id/reportjoinresult` | | Report join result (no-op ack) | +| PUT | `/roominstance/:id/inprogress` | ✓ | Set the instance's in-progress flag | +| GET | `/room/:roomId/instances` | ✓ | A room's live instances (owner/co-owner only) | +| GET | `/rooms/requiring/developer` | | Rooms requiring a developer → `[]` | +| GET | `/rooms/requiring/rrplus` | | Rooms requiring RR+ → `[]` | +| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) | -## TODO before production +\* `logout` and `statusvisibility` read the token when present but never 401 — an +unauthenticated call is a no-op ack. The other ✓ routes return an empty-body 401 when +the Bearer JWT (issued by the `auth` worker) is missing or invalid. -- Wire a DB binding (D1/DO) for `Accounts`, `Rooms`/`SubRooms`, `RoomInstances`. -- Implement `/goto/room/:room` (resolve room, upsert instance) and the - per-account `/player` + `/player/heartbeat` room-instance lookups. -- Move the JWT secret to a shared secret binding (shared with `auth`). +## API documentation + +`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit +alongside each handler, with the schemas in `src/openapi.ts`. + +**The spec is descriptive, not enforced** — same rationale as the `auth`/`accounts` +workers: a reverse-engineered protocol, lenient handlers, no runtime validation. A test +asserts every route appears in the spec, so adding one without documenting it fails. + +## Presence + +Presence is a per-player row in the shared `presence` table recording the room instance +that player is currently in, plus status fields (visibility, device class, VR movement +mode, platform, app version). It's written by matchmake/goto and refreshed by the +heartbeat, and read by the heartbeat and the batch `GET /player`. + +- **`isOnline` means "has a live presence row"**, not "is in a room". Rows expire on a + TTL, so a player who stops heartbeating drops offline; a player can be online in the + lobby with `roomInstance` null. +- **The heartbeat is write-thrifty.** An unchanged heartbeat re-writes the row (to + extend its TTL) only once the TTL is within `PRESENCE_REFRESH_THRESHOLD` seconds of + lapsing — a still player is refreshed periodically rather than on every beat. +- **A cron sweep** (`scheduled`) clears presence past its TTL and, crucially, + recomputes the fullness of the instances those rows pointed at. Nothing else notices + a player who crashed or hard-quit, so without the sweep their instance can stay + flagged full — and unjoinable — with nobody in it. + +The heartbeat also accepts a non-JSON (LoginLock form) body, which it reads and ignores; +only a JSON body carries status fields. + +## Matchmaking and room instances + +A matchmake resolves the room (by numeric id or name) from D1, then finds a joinable +public instance of the requested subroom or creates a new `room_instance`. The result +is persisted as the player's presence so the heartbeat can replay it — keeping the +client's local presence in sync. `errorCode` 0 with a `roomInstance` is success; an +unknown room returns `errorCode` 20 (NoSuchRoom) with `roomInstance: null`. + +Several behaviours are load-bearing and reverse-engineered from the client: + +- **Instance names are `^`-prefixed** so the client resolves the new scene; personal + dorms are the exception (`@owner's Dorm`, no `^`). An empty `location` (the SubRoom's + Unity scene id) makes the client reject the session. +- **Never re-place a player into their current instance.** The client keys the room + transition off a _changing_ `roomInstanceId`; returning the same id hangs it mid-join, + so the join search excludes the caller's current instance. +- **Subrooms are separate places.** Joining one must never land you in an instance of + another, so instance reuse is scoped to the exact `(roomId, subRoomId)`. +- **The dorm is a single stable instance** with a constant Photon room id, returned + identically by every dorm entry point and the heartbeat, so the client's whole-instance + presence check never reads out-of-sync. +- **Two dorm keywords:** `goto/room/dormroom` and `matchmake/dorm` — different spellings + the 2023 client uses for the same destination. +- **`matchmake/none` preserves existing presence** (it's how the client establishes the + solo Orientation room) and only falls back to the dorm when the player has none. + `goto/none` always goes to the dorm. + +## Bindings + +| Binding | Type | Notes | +| ------------ | ------------- | ------------------------------------------------------------ | +| `DB` | D1 | Shared `recflare` database — rooms, room instances, presence | +| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) | + +The `presence` and `room_instance` tables are owned/migrated by the `rooms` worker; +this worker has no migrations of its own. + +## Known gaps + +- `/rooms/requiring/developer` and `/rooms/requiring/rrplus` always return `[]` — no + such gating queue exists yet. diff --git a/apps/match/package.json b/apps/match/package.json index 5ec3236..9d81c8b 100644 --- a/apps/match/package.json +++ b/apps/match/package.json @@ -18,8 +18,13 @@ "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", + "@standard-community/standard-json": "0.3.5", + "@standard-community/standard-openapi": "0.2.9", "hono": "4.12.27", - "workers-tagged-logger": "1.0.1" + "hono-openapi": "1.3.1", + "openapi-types": "12.1.3", + "workers-tagged-logger": "1.0.1", + "zod": "4.4.3" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "0.16.20", diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 6fc5fc6..a5ea0a9 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono' +import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' import { @@ -23,6 +24,23 @@ import { import { 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' @@ -399,8 +417,28 @@ const app = new Hono() // 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', (c) => c.body(null, 200)) - .post('/player/exclusivelogin', (c) => c.json({ errorCode: 0 })) + .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). @@ -411,262 +449,551 @@ const app = new Hono() // 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', async (c) => { - const id = await authedId(c) - if (id !== null) { - const presence = await getPresence(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) + .post( + '/player/logout', + describeRoute({ + tags: ['Presence'], + summary: 'Clear presence on logout', + description: + 'Clears the player’s 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 isn’t 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(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) } - 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', (c) => c.body(null, 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', 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) + .get( + '/player', + describeRoute({ + tags: ['Presence'], + summary: 'Batch player presence lookup', + description: + 'Returns each requested player’s 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(c.env.DB, ids) - return c.json(ids.map((playerId) => playerPayload(playerId, presences.get(playerId)))) - }) - - .post('/player/heartbeat', 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 = {} - } + // 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(c.env.DB, ids) + return c.json(ids.map((playerId) => playerPayload(playerId, presences.get(playerId)))) } + ) - // 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(c.env.DB, id) - if (presence) { - // Merge the posted status fields, tracking whether any actually changed. - let changed = false - const apply = (key: K, value: Presence[K]) => { - if (presence[key] !== value) { - presence[key] = value - changed = true + .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 isn’t written on every beat. With no ' + + 'stored presence the player isn’t 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 player’s 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 = {} } } - 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', async (c) => { - const id = await authedId(c) - if (id !== null) { - const body = await c.req.parseBody().catch(() => ({}) as Record) - const sv = - typeof body.statusVisibility === 'string' ? Number.parseInt(body.statusVisibility, 10) : NaN + // 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(c.env.DB, id) - if (presence && !Number.isNaN(sv)) { - presence.statusVisibility = sv - await setPresence(c.env.DB, presence) + if (presence) { + // Merge the posted status fields, tracking whether any actually changed. + let changed = false + const apply = (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, + }) } - return c.body(null, 200) - }) + ) + + .put( + '/player/statusvisibility', + describeRoute({ + tags: ['Presence'], + summary: 'Set status visibility', + description: + 'Updates the stored presence’s 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) + const sv = + typeof body.statusVisibility === 'string' + ? Number.parseInt(body.statusVisibility, 10) + : NaN + const presence = await getPresence(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', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) + .post( + '/goto/room/:room', + describeRoute({ + tags: ['Navigation'], + summary: 'Go to a room', + description: + 'Resolves the room (numeric id or name; `dormroom` → the player’s 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 }) - }) + 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', 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(c.env.DB, id) - if (presence?.roomInstance) { - return c.json({ errorCode: 0, roomInstance: presence.roomInstance }) + .post( + '/matchmake/none', + describeRoute({ + tags: ['Navigation'], + summary: 'Matchmake with no target (preserve or dorm)', + description: + 'Returns the player’s current instance if they have one (so Orientation isn’t 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(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 }) } - // 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 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]+}', 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 }) - }) + .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 room’s 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', 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', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) + .post( + '/matchmake/room/:roomId', + describeRoute({ + tags: ['Navigation'], + summary: 'Matchmake into a room (default subroom)', + description: + 'The 2023 client’s 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 player’s 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 }) - }) + 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', 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 }) - }) + .post( + '/goto/none', + describeRoute({ + tags: ['Navigation'], + summary: 'Go to the dorm', + description: + 'Authed → the player’s 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', (c) => c.body(null, 200)) - .put('/player/gameserverregionpings', (c) => c.body(null, 200)) + .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', (c) => c.body(null, 200)) + .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', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) + .put( + '/roominstance/:id/inprogress', + describeRoute({ + tags: ['Room instance'], + summary: 'Set instance in-progress flag', + description: + 'The room owner flips the instance’s 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 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) - const inProgress = - typeof body.inProgress === 'string' && body.inProgress.toLowerCase() === 'true' + const body = await c.req.parseBody().catch(() => ({}) as Record) + 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) - }) + 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', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) + .get( + '/room/:roomId{[0-9]+}/instances', + describeRoute({ + tags: ['Room instance'], + summary: 'A room’s live instances', + description: + 'The owner’s view of active sessions of their room. Auth-gated and gated to the ' + + 'room’s 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 room’s 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) + 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)) - }) + 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', (c) => c.json([])) + .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', (c) => c.json([])) + .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, @@ -690,9 +1017,55 @@ async function sweepExpiredPresence(env: Env): Promise { ) } -export default { - fetch: app.fetch, - scheduled: async (_controller, env, ctx) => { - ctx.waitUntil(sweepExpiredPresence(env)) - }, -} satisfies ExportedHandler +// 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 }), + 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 worker’s `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 = (_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 diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts new file mode 100644 index 0000000..09c844c --- /dev/null +++ b/apps/match/src/openapi.ts @@ -0,0 +1,168 @@ +import { resolver } from 'hono-openapi' +import { z } from 'zod' + +import type { OpenAPIV3_1 } from 'openapi-types' + +/** + * OpenAPI schemas for the match worker. + * + * IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to + * generate the spec and are never wired into `hono-openapi`'s `validator()`. + * + * As with the auth/accounts workers, this is deliberate: the Rec Room client is the + * only real consumer, the handlers are lenient (bodies are parsed defensively and + * missing fields fall through to sensible defaults), and the exact request/response + * shapes are reverse-engineered. These schemas record observed behaviour; to enforce + * one, do it per-route and land a test with it. + */ + +/** Emit a zod schema as an `application/json` response body. */ +export function json(schema: z.ZodType, description: string) { + return { description, content: { 'application/json': { schema: resolver(schema) } } } +} + +/** + * Convert a zod schema to a plain OpenAPI schema for a request body. `describeRoute`'s + * `requestBody` takes an OpenAPI schema (not a `resolver()`). zod's `$schema` key and + * `additionalProperties: false` are dropped — the handlers read the fields they know + * and ignore the rest, so a closed object would misreport them as stricter than they + * are. + */ +function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject { + const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema) + return jsonSchema as OpenAPIV3_1.SchemaObject +} + +/** A form-urlencoded / multipart request body (the client posts both). */ +export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject { + const s = toOpenApiSchema(schema) + return { + description, + content: { + 'application/x-www-form-urlencoded': { schema: s }, + 'multipart/form-data': { schema: s }, + }, + } +} + +/** An `application/json` request body (the heartbeat posts one). */ +export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject { + return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } } +} + +/** An empty-body `200 OK` ack — the response many match routes return. */ +export const EMPTY_OK = { description: 'Acknowledged (empty body)' } + +/** The empty-body 401 the auth-gated routes return. */ +export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' } + +/** Bearer-JWT security requirement, for the auth-gated routes. */ +export const AUTHED = [{ bearerAuth: [] }] + +/** + * RoomInstanceType enum, by value. `Dormroom` instances are private; `Public` are the + * shared, joinable ones matchmaking reuses. + */ +export const RoomInstanceType = z + .int() + .describe('RoomInstanceType: 0 Public, 1 Dormroom, … (see @repo/domain)') + +/** + * A room instance — the session the client connects to (scene + Photon coordinates). + * Joiners of the same public instance share `roomInstanceId` and `photonRoomId`. Names + * are `^`-prefixed so the client resolves the scene (personal dorms use `@owner's Dorm` + * instead). `location` is the SubRoom's Unity scene id; an empty one makes the client + * reject the session. + */ +export const RoomInstanceDto = z.object({ + roomInstanceId: z.int(), + roomId: z.int(), + subRoomId: z.int().describe('Which subroom (scene) of the room this instance is'), + roomInstanceType: RoomInstanceType, + location: z.string().describe('SubRoom Unity scene id; empty is rejected by the client'), + dataBlob: z.string(), + eventId: z.int(), + clubId: z.int(), + roomCode: z.string(), + photonRegion: z.string(), + photonRegionId: z.string(), + photonRoomId: z.string().describe('Shared by joiners of the same instance'), + name: z.string().describe('`^`-prefixed (or `@owner’s Dorm` for personal dorms)'), + maxCapacity: z.int(), + isFull: z.boolean(), + isPrivate: z.boolean(), + isInProgress: z.boolean().describe('Set by the owner via PUT /roominstance/:id/inprogress'), + EncryptVoiceChat: z.boolean(), +}) + +/** + * A player's presence as the client reads it (`GET /player`, `POST /player/heartbeat`). + * `isOnline` means "has a live (unexpired) presence row", NOT "is in a room" — a player + * can be online in the lobby with `roomInstance` null. The `photon*`/`voice*` + * connection fields are only populated in a matchmaking response, never here, but the + * client needs the keys present, so they're always null. + */ +export const PlayerDto = z.object({ + playerId: z.int(), + isOnline: z.boolean().describe('Has a live presence row (presence expires on a TTL)'), + errorCode: z.int().describe('0 = no error; non-zero only on a failed matchmake'), + roomInstance: RoomInstanceDto.nullable().describe('null when not in a room'), + appVersion: z.string(), + deviceClass: z.int(), + statusVisibility: z.int(), + vrMovementMode: z.int(), + platform: z.int(), + photonAuthToken: z.null(), + photonRealtimeAppId: z.null(), + photonVoiceAppId: z.null(), + photonChatAppId: z.null(), + photonRegion: z.null(), + photonRoomId: z.null(), + voiceConnectionInfo: z.null(), + voiceServerId: z.null(), + experiments: z.null(), +}) + +/** + * The matchmake/goto 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({ + errorCode: z.int().describe('0 = success; 20 = NoSuchRoom'), + roomInstance: RoomInstanceDto.nullable(), +}) + +/** `POST /player/exclusivelogin` — a bare error code. */ +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. + */ +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(), +}) + +/** `PUT /roominstance/:id/inprogress` form body. */ +export const InProgressRequest = z.object({ + inProgress: z.string().describe('"True" | "False" (case-insensitive)'), +}) + +/** `PUT /player/statusvisibility` form body. */ +export const StatusVisibilityRequest = z.object({ + statusVisibility: z.string().describe('Integer string; non-numeric is ignored'), +}) + +/** + * The `JoinMode` form field the matchmake/goto routes read (`2` = a private instance; + * anything else = public). Posted as a urlencoded/multipart body. + */ +export const JoinModeRequest = z.object({ + JoinMode: z.string().optional().describe('"2" requests a private instance'), +}) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 0671ede..e724239 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -15,7 +15,7 @@ import { ROOM_INSTANCE_SCHEMA_DDL, } from '@repo/domain' -import worker from '../../match.app' +import { scheduled } from '../../match.app' import type { Env } from '../../context' @@ -737,7 +737,7 @@ describe('auth-gated endpoints', () => { // Driven through the module's own export rather than the `exports` proxy — a // ScheduledController can't cross the isolate boundary the proxy serializes over. const ctx = createExecutionContext() - await worker.scheduled(createScheduledController(), env, ctx) + await scheduled(createScheduledController(), env, ctx) await waitOnExecutionContext(ctx) // Expired row gone, and the instance is joinable again. @@ -868,4 +868,54 @@ describe('auth-gated endpoints', () => { expect(coOwner.status).toBe(200) expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length) }) + + test('GET /openapi.json documents every route', async () => { + const res = await exports.default.fetch(`${ORIGIN}/openapi.json`) + expect(res.status).toBe(200) + const spec = (await res.json()) as { + openapi: string + paths: Record> + } + expect(spec.openapi).toMatch(/^3\.1/) + + // The spec route hides itself. + expect(spec.paths['/openapi.json']).toBeUndefined() + + // Every route the worker serves is described. This is the drift guard: adding a + // route without a describeRoute() block fails here rather than silently shipping + // an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`. + const documented = new Set( + Object.entries(spec.paths).flatMap(([path, ops]) => + Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`) + ) + ) + expect([...documented].sort()).toEqual([ + 'GET /player', + 'GET /room/{roomId}/instances', + 'GET /rooms/requiring/developer', + 'GET /rooms/requiring/rrplus', + 'POST /goto/none', + 'POST /goto/room/{room}', + 'POST /matchmake/none', + 'POST /matchmake/room/{roomId}', + 'POST /matchmake/room/{roomId}/{subRoomId}', + 'POST /matchmake/{room}', + 'POST /player/exclusivelogin', + 'POST /player/heartbeat', + 'POST /player/login', + 'POST /player/logout', + 'POST /player/notifydisconnect', + 'POST /roominstance/{id}/reportjoinresult', + 'PUT /player/gameserverregionpings', + 'PUT /player/photonregionpings', + 'PUT /player/statusvisibility', + 'PUT /roominstance/{id}/inprogress', + ]) + + // Every operation carries a summary — a path present but undescribed is not + // documentation. + for (const ops of Object.values(spec.paths)) { + for (const op of Object.values(ops)) expect(op.summary).toBeTruthy() + } + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae2ac49..07b4082 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -404,12 +404,27 @@ importers: '@repo/jwt': specifier: workspace:* version: link:../../packages/jwt + '@standard-community/standard-json': + specifier: 0.3.5 + version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3) + '@standard-community/standard-openapi': + specifier: 0.2.9 + version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3) hono: specifier: 4.12.27 version: 4.12.27 + hono-openapi: + specifier: 1.3.1 + version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3) + openapi-types: + specifier: 12.1.3 + version: 12.1.3 workers-tagged-logger: specifier: 1.0.1 version: 1.0.1 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@cloudflare/vitest-pool-workers': specifier: 0.16.20