mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
match docs, tweak for monowrapper
This commit is contained in:
@@ -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
|
* account, with private fields (email, birthday) excluded. Fields the client parses
|
||||||
* as enums are numbers here.
|
* as enums are numbers here.
|
||||||
*/
|
*/
|
||||||
export const AccountDto = z
|
export const AccountDto = z.object({
|
||||||
.object({
|
accountId: z.int(),
|
||||||
accountId: z.int(),
|
username: z.string(),
|
||||||
username: z.string(),
|
displayName: z.string(),
|
||||||
displayName: z.string(),
|
profileImage: z.string().describe('Avatar object key'),
|
||||||
profileImage: z.string().describe('Avatar object key'),
|
isJunior: z.boolean(),
|
||||||
isJunior: z.boolean(),
|
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
|
||||||
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
|
personalPronouns: z.int().describe('Pronoun flags bitmask'),
|
||||||
personalPronouns: z.int().describe('Pronoun flags bitmask'),
|
identityFlags: z.int().describe('Identity flags bitmask'),
|
||||||
identityFlags: z.int().describe('Identity flags bitmask'),
|
createdAt: z.iso.datetime(),
|
||||||
createdAt: z.iso.datetime(),
|
})
|
||||||
})
|
|
||||||
.meta({ id: 'AccountDto' })
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The private self DTO (`toSelfAccountDto`, the `/account/me` shape) — the public DTO
|
* 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(),
|
email: z.string().nullable(),
|
||||||
birthday: z.null().describe('Always null — birthday is not stored'),
|
birthday: z.null().describe('Always null — birthday is not stored'),
|
||||||
availableUsernameChanges: z.int().describe('Remaining username changes'),
|
availableUsernameChanges: z.int().describe('Remaining username changes'),
|
||||||
}).meta({ id: 'SelfAccountDto' })
|
})
|
||||||
|
|
||||||
/** Player bio, from `GET /account/:id/bio`. */
|
/** Player bio, from `GET /account/:id/bio`. */
|
||||||
export const BioResponse = z
|
export const BioResponse = z.object({
|
||||||
.object({ accountId: z.int(), bio: z.string().describe('"" when unset') })
|
accountId: z.int(),
|
||||||
.meta({ id: 'BioResponse' })
|
bio: z.string().describe('"" when unset'),
|
||||||
|
})
|
||||||
|
|
||||||
/** A bare `{ success: true }` ack, returned by most profile mutations. */
|
/** A bare `{ success: true }` ack, returned by most profile mutations. */
|
||||||
export const SuccessResponse = z
|
export const SuccessResponse = z.object({ success: z.literal(true) })
|
||||||
.object({ success: z.literal(true) })
|
|
||||||
.meta({ id: 'SuccessResponse' })
|
|
||||||
|
|
||||||
/** The RecNet result envelope `{ success, value }` used by create + username change. */
|
/** The RecNet result envelope `{ success, value }` used by create + username change. */
|
||||||
export function envelope(value: z.ZodType, id: string) {
|
export function envelope(value: z.ZodType) {
|
||||||
return z
|
return z.object({
|
||||||
.object({
|
success: z.boolean(),
|
||||||
success: z.boolean(),
|
value,
|
||||||
value,
|
error: z.string().optional().describe('Present (with success:false) on failure'),
|
||||||
error: z.string().optional().describe('Present (with success:false) on failure'),
|
})
|
||||||
})
|
|
||||||
.meta({ id })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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
|
* a message in `error` and `value` an empty string; on success `value` is the updated
|
||||||
* public account.
|
* public account.
|
||||||
*/
|
*/
|
||||||
export const UsernameResult = envelope(
|
export const UsernameResult = envelope(z.union([AccountDto, z.literal('')])).describe(
|
||||||
z.union([AccountDto, z.literal('')]),
|
'value is the updated account on success, "" on failure'
|
||||||
'UsernameResult'
|
)
|
||||||
).describe('value is the updated account on success, "" on failure')
|
|
||||||
|
|
||||||
/** `POST /account/create` response. */
|
/** `POST /account/create` response. */
|
||||||
export const CreateAccountResult = envelope(AccountDto, 'CreateAccountResult')
|
export const CreateAccountResult = envelope(AccountDto)
|
||||||
|
|
||||||
/** `GET /parentalcontrol/me` response. */
|
/** `GET /parentalcontrol/me` response. */
|
||||||
export const ParentalControl = z
|
export const ParentalControl = z.object({ accountId: z.int(), disallowInAppPurchases: z.boolean() })
|
||||||
.object({ accountId: z.int(), disallowInAppPurchases: z.boolean() })
|
|
||||||
.meta({ id: 'ParentalControl' })
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET /accountprivacysettings/:id` response. A bare `{}` fails the client's
|
* `GET /accountprivacysettings/:id` response. A bare `{}` fails the client's
|
||||||
* deserializer, so the id is echoed back and recent history reported visible; nothing
|
* deserializer, so the id is echoed back and recent history reported visible; nothing
|
||||||
* stores per-player privacy yet.
|
* stores per-player privacy yet.
|
||||||
*/
|
*/
|
||||||
export const PrivacySettings = z
|
export const PrivacySettings = z.object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() })
|
||||||
.object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() })
|
|
||||||
.meta({ id: 'PrivacySettings' })
|
|
||||||
|
|
||||||
/** Root health check. */
|
/** Root health check. */
|
||||||
export const HealthResponse = z
|
export const HealthResponse = z.object({ service: z.literal('accounts'), status: z.literal('ok') })
|
||||||
.object({ service: z.literal('accounts'), status: z.literal('ok') })
|
|
||||||
.meta({ id: 'HealthResponse' })
|
|
||||||
|
|
||||||
// ---- Request bodies --------------------------------------------------------
|
// ---- Request bodies --------------------------------------------------------
|
||||||
|
|
||||||
/** `POST /account/create` form body. Both fields are parsed but not yet persisted. */
|
/** `POST /account/create` form body. Both fields are parsed but not yet persisted. */
|
||||||
export const CreateAccountRequest = z
|
export const CreateAccountRequest = z.object({
|
||||||
.object({
|
platform: z.string().optional().describe('PlatformType integer string; defaults to 0'),
|
||||||
platform: z.string().optional().describe('PlatformType integer string; defaults to 0'),
|
platformId: z.string().optional().describe('Parsed for fidelity; currently unused'),
|
||||||
platformId: z.string().optional().describe('Parsed for fidelity; currently unused'),
|
})
|
||||||
})
|
|
||||||
.meta({ id: 'CreateAccountRequest' })
|
|
||||||
|
|
||||||
/** Single-string form bodies, one per profile mutation. */
|
/** Single-string form bodies, one per profile mutation. */
|
||||||
export const DisplayNameRequest = z
|
export const DisplayNameRequest = z.object({
|
||||||
.object({ displayName: z.string().describe('Trimmed; empty is rejected (400)') })
|
displayName: z.string().describe('Trimmed; empty is rejected (400)'),
|
||||||
.meta({ id: 'DisplayNameRequest' })
|
})
|
||||||
|
|
||||||
export const UsernameRequest = z
|
export const UsernameRequest = z.object({
|
||||||
.object({ username: z.string().describe('Trimmed; must be unique and changes must remain') })
|
username: z.string().describe('Trimmed; must be unique and changes must remain'),
|
||||||
.meta({ id: 'UsernameRequest' })
|
})
|
||||||
|
|
||||||
export const EmailRequest = z
|
export const EmailRequest = z.object({
|
||||||
.object({ email: z.string().describe('Must contain "@"; otherwise 400') })
|
email: z.string().describe('Must contain "@"; otherwise 400'),
|
||||||
.meta({ id: 'EmailRequest' })
|
})
|
||||||
|
|
||||||
export const PhoneRequest = z
|
export const PhoneRequest = z.object({
|
||||||
.object({ phone: z.string().describe('Trimmed; empty is rejected (400)') })
|
phone: z.string().describe('Trimmed; empty is rejected (400)'),
|
||||||
.meta({ id: 'PhoneRequest' })
|
})
|
||||||
|
|
||||||
export const IdentityFlagsRequest = z
|
export const IdentityFlagsRequest = z.object({
|
||||||
.object({ identityFlags: z.string().describe('Integer string bitmask; non-numeric is 400') })
|
identityFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
|
||||||
.meta({ id: 'IdentityFlagsRequest' })
|
})
|
||||||
|
|
||||||
export const PronounsRequest = z
|
export const PronounsRequest = z.object({
|
||||||
.object({ pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400') })
|
pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
|
||||||
.meta({ id: 'PronounsRequest' })
|
})
|
||||||
|
|
||||||
export const BioRequest = z
|
export const BioRequest = z.object({ bio: z.string().describe('Free text; empty is allowed') })
|
||||||
.object({ bio: z.string().describe('Free text; empty is allowed') })
|
|
||||||
.meta({ id: 'BioRequest' })
|
|
||||||
|
|
||||||
export const ProfileImageRequest = z
|
export const ProfileImageRequest = z.object({
|
||||||
.object({ imageName: z.string().describe('Avatar object key; empty is rejected (400)') })
|
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
|
||||||
.meta({ id: 'ProfileImageRequest' })
|
})
|
||||||
|
|||||||
+66
-77
@@ -60,94 +60,83 @@ export const PlatformType = z
|
|||||||
)
|
)
|
||||||
|
|
||||||
/** One entry on the client's login screen, from `toCachedLogin`. */
|
/** One entry on the client's login screen, from `toCachedLogin`. */
|
||||||
export const CachedLogin = z
|
export const CachedLogin = z.object({
|
||||||
.object({
|
platform: PlatformType,
|
||||||
platform: PlatformType,
|
platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'),
|
||||||
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'),
|
||||||
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"),
|
||||||
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
|
requirePassword: z
|
||||||
requirePassword: z
|
.literal(false)
|
||||||
.literal(false)
|
.describe('Always false — platform ownership is the credential for a cached login'),
|
||||||
.describe('Always false — platform ownership is the credential for a cached login'),
|
})
|
||||||
})
|
|
||||||
.meta({ id: 'CachedLogin' })
|
|
||||||
|
|
||||||
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
|
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
|
||||||
export const OAuthError = z
|
export const OAuthError = z.object({
|
||||||
.object({
|
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
|
||||||
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
|
error_description: z.string(),
|
||||||
error_description: z.string(),
|
})
|
||||||
})
|
|
||||||
.meta({ id: 'OAuthError' })
|
|
||||||
|
|
||||||
/** Successful `POST /connect/token` body. */
|
/** Successful `POST /connect/token` body. */
|
||||||
export const TokenResponse = z
|
export const TokenResponse = z.object({
|
||||||
.object({
|
access_token: z.string().describe('Signed JWT; `sub` is the account id'),
|
||||||
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)'),
|
||||||
expires_in: z.int().describe('Access-token lifetime in seconds (TOKEN_TTL_SECONDS)'),
|
token_type: z.literal('Bearer'),
|
||||||
token_type: z.literal('Bearer'),
|
refresh_token: z
|
||||||
refresh_token: z
|
.string()
|
||||||
.string()
|
.describe('Single-use; redeem via grant_type=refresh_token, which rotates it'),
|
||||||
.describe('Single-use; redeem via grant_type=refresh_token, which rotates it'),
|
scope: z.string().describe('Space-separated granted scopes'),
|
||||||
scope: z.string().describe('Space-separated granted scopes'),
|
key: z.string().describe('@kludge Constant the client appears to require. Purpose unknown.'),
|
||||||
key: z.string().describe('@kludge Constant the client appears to require. Purpose unknown.'),
|
})
|
||||||
})
|
|
||||||
.meta({ id: 'TokenResponse' })
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `POST /connect/token` form body — the union of every grant's fields, since
|
* `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
|
* OpenAPI cannot express "these fields iff grant_type=X" without splitting the
|
||||||
* endpoint. Per-grant requirements are spelled out in the route description.
|
* endpoint. Per-grant requirements are spelled out in the route description.
|
||||||
*/
|
*/
|
||||||
export const TokenRequest = z
|
export const TokenRequest = z.object({
|
||||||
.object({
|
grant_type: z
|
||||||
grant_type: z
|
.enum(['create_account', 'cached_login', 'refresh_token', 'password'])
|
||||||
.enum(['create_account', 'cached_login', 'refresh_token', 'password'])
|
.describe('Anything unrecognised (including absent) is treated as a password grant'),
|
||||||
.describe('Anything unrecognised (including absent) is treated as a password grant'),
|
account_id: z.string().optional().describe('Numeric account id, as a string'),
|
||||||
account_id: z.string().optional().describe('Numeric account id, as a string'),
|
username: z
|
||||||
username: z
|
.string()
|
||||||
.string()
|
.optional()
|
||||||
.optional()
|
.describe('Password grant alternative to account_id; case-insensitive, trimmed'),
|
||||||
.describe('Password grant alternative to account_id; case-insensitive, trimmed'),
|
password: z
|
||||||
password: z
|
.string()
|
||||||
.string()
|
.optional()
|
||||||
.optional()
|
.describe('Required on a password grant. On create_account, sets the initial password'),
|
||||||
.describe('Required on a password grant. On create_account, sets the initial password'),
|
platform: z.string().optional().describe('PlatformType as an integer string'),
|
||||||
platform: z.string().optional().describe('PlatformType as an integer string'),
|
platform_id: z
|
||||||
platform_id: z
|
.string()
|
||||||
.string()
|
.optional()
|
||||||
.optional()
|
.describe('Unverified; ignored in favour of the Steam-verified id where a ticket is required'),
|
||||||
.describe(
|
platform_auth: z
|
||||||
'Unverified; ignored in favour of the Steam-verified id where a ticket is required'
|
.string()
|
||||||
),
|
.optional()
|
||||||
platform_auth: z
|
.describe('Steam session ticket. Required for cached_login and platform create_account'),
|
||||||
.string()
|
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
|
||||||
.optional()
|
device_id: z
|
||||||
.describe('Steam session ticket. Required for cached_login and platform create_account'),
|
.string()
|
||||||
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
|
.optional()
|
||||||
device_id: z
|
.describe('Client-chosen, unverified. Recorded on the account, never trusted'),
|
||||||
.string()
|
device_class: z.string().optional().describe('Integer string; defaults to 0'),
|
||||||
.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' })
|
|
||||||
|
|
||||||
/** `POST /account/me/changepassword` form body. */
|
/** `POST /account/me/changepassword` form body. */
|
||||||
export const ChangePasswordRequest = z
|
export const ChangePasswordRequest = z.object({
|
||||||
.object({
|
newPassword: z.string().describe('Required; empty is rejected'),
|
||||||
newPassword: z.string().describe('Required; empty is rejected'),
|
oldPassword: z
|
||||||
oldPassword: z
|
.string()
|
||||||
.string()
|
.optional()
|
||||||
.optional()
|
.describe('Must match when the account already has a password; empty when first setting it'),
|
||||||
.describe('Must match when the account already has a password; empty when first setting it'),
|
})
|
||||||
})
|
|
||||||
.meta({ id: 'ChangePasswordRequest' })
|
|
||||||
|
|
||||||
/** `POST /account/me/changepassword` response body. */
|
/** `POST /account/me/changepassword` response body. */
|
||||||
export const ChangePasswordResponse = z
|
export const ChangePasswordResponse = z.object({
|
||||||
.object({ success: z.boolean(), error: z.string().optional() })
|
success: z.boolean(),
|
||||||
.meta({ id: 'ChangePasswordResponse' })
|
error: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spec for the `/role/:role/:id` lookups, which are identical apart from the role.
|
* 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. */
|
/** Bulk cached-login lookup form body: repeated `id=` fields. */
|
||||||
export const PlatformIdsRequest = z
|
export const PlatformIdsRequest = z.object({
|
||||||
.object({ id: z.union([z.string(), z.array(z.string())]).describe('Repeated `id=` form fields') })
|
id: z.union([z.string(), z.array(z.string())]).describe('Repeated `id=` form fields'),
|
||||||
.meta({ id: 'PlatformIdsRequest' })
|
})
|
||||||
|
|||||||
+101
-22
@@ -1,28 +1,107 @@
|
|||||||
# match
|
# match
|
||||||
|
|
||||||
Matchmaking Worker served on the `match` subdomain. A Hono app for matchmaking.
|
Matchmaking Worker served on the `match` subdomain (`match.recflare.net`) — a Hono app
|
||||||
Database queries are stubbed for now — no real bindings yet.
|
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
|
| Method | Path | Auth | Description |
|
||||||
Bearer JWT issued by the `auth` worker (same dev secret, see `src/jwt.ts`) and
|
| ------ | ------------------------------------ | ---- | ------------------------------------------------ |
|
||||||
401 when it's missing/invalid.
|
| POST | `/player/login` | | Login ack (no-op; must not touch presence) |
|
||||||
- **`GET /player`** always returns the inlined `JSON/getplayer.json` default
|
| POST | `/player/exclusivelogin` | | Exclusive-login ack (no-op) → `{ errorCode: 0 }` |
|
||||||
(falls back to that file when the account/room instance isn't found).
|
| POST | `/player/logout` | ✓\* | Clear presence (except the Orientation seed) |
|
||||||
- **`POST /goto/none`** returns the static offline-dorm instance with a fresh
|
| POST | `/player/notifydisconnect` | | Disconnect notification (no-op ack) |
|
||||||
`photonRoomId`.
|
| GET | `/player?id=1&id=2,3` | | Batch player presence lookup |
|
||||||
- **`POST /goto/room/:room`** synthesizes the room-instance response (no Rooms
|
| POST | `/player/heartbeat` | ✓ | Presence heartbeat (JSON body) |
|
||||||
binding yet). The dorm gets its known scene id and a private instance; other
|
| PUT | `/player/statusvisibility` | ✓\* | Set status visibility |
|
||||||
rooms get an empty `location` and respect the posted `JoinMode` (2 = private).
|
| POST | `/goto/room/:room` | ✓ | Go to a room (`dormroom` → personal dorm) |
|
||||||
- **`POST /player/heartbeat`** echoes the posted heartbeat fields; `roomInstance`
|
| POST | `/matchmake/none` | | Preserve current instance, else dorm |
|
||||||
is always null and `isOnline` false until there's a DB binding.
|
| POST | `/matchmake/room/:roomId/:subRoomId` | ✓ | Matchmake into a specific subroom |
|
||||||
- **`/player/login`, `/player/statusvisibility`, `/roominstance/:id/reportjoinresult`**
|
| POST | `/matchmake/room/:roomId` | ✓ | Matchmake into a room (default subroom) |
|
||||||
return empty 200s.
|
| 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`.
|
## API documentation
|
||||||
- Implement `/goto/room/:room` (resolve room, upsert instance) and the
|
|
||||||
per-account `/player` + `/player/heartbeat` room-instance lookups.
|
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
|
||||||
- Move the JWT secret to a shared secret binding (shared with `auth`).
|
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.
|
||||||
|
|||||||
@@ -18,8 +18,13 @@
|
|||||||
"@repo/domain": "workspace:*",
|
"@repo/domain": "workspace:*",
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
|
"@standard-community/standard-json": "0.3.5",
|
||||||
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"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": {
|
"devDependencies": {
|
||||||
"@cloudflare/vitest-pool-workers": "0.16.20",
|
"@cloudflare/vitest-pool-workers": "0.16.20",
|
||||||
|
|||||||
+579
-206
@@ -1,4 +1,5 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,23 @@ import {
|
|||||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
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 { Context } from 'hono'
|
||||||
import type { Room, StoredPresence } from '@repo/domain'
|
import type { Room, StoredPresence } from '@repo/domain'
|
||||||
import type { App, Env } from './context'
|
import type { App, Env } from './context'
|
||||||
@@ -399,8 +417,28 @@ const app = new Hono<App>()
|
|||||||
// fires exclusivelogin when going online, and clearing presence there would bounce
|
// 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
|
// the player to the dorm. Presence is overwritten by matchmake/goto and expires on
|
||||||
// its own TTL.
|
// its own TTL.
|
||||||
.post('/player/login', (c) => c.body(null, 200))
|
.post(
|
||||||
.post('/player/exclusivelogin', (c) => c.json({ errorCode: 0 }))
|
'/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
|
// 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).
|
// instance they were in frees up (rather than waiting out the presence TTL).
|
||||||
@@ -411,262 +449,551 @@ const app = new Hono<App>()
|
|||||||
// the seed and bounces the new player to the dorm — so a logout that still points
|
// 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
|
// at Orientation is left as a no-op ack. An unauthenticated logout is also a no-op
|
||||||
// (no player to clear).
|
// (no player to clear).
|
||||||
.post('/player/logout', async (c) => {
|
.post(
|
||||||
const id = await authedId(c)
|
'/player/logout',
|
||||||
if (id !== null) {
|
describeRoute({
|
||||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
tags: ['Presence'],
|
||||||
const instanceId = presence?.roomInstance?.roomInstanceId
|
summary: 'Clear presence on logout',
|
||||||
if (presence && instanceId !== ORIENTATION_INSTANCE_ID) {
|
description:
|
||||||
await deletePresence(c.env.DB, id)
|
'Clears the player’s presence so they read offline immediately and the instance ' +
|
||||||
// The instance they were in lost a player — recompute its fullness so a
|
'they were in frees up. EXCEPTION: a logout whose presence still points at the ' +
|
||||||
// full room frees up. No-op for the synthetic dorm/orientation instances.
|
'Orientation seed (instance -2) is left as a no-op, so the account-creation ' +
|
||||||
if (instanceId != null) await refreshInstanceFullness(c.env.DB, instanceId)
|
'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<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)
|
||||||
}
|
}
|
||||||
return c.body(null, 200)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
// Fire-and-forget disconnect notification (form body `PlayerId`/`RoomInstanceId`).
|
// 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
|
// 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.
|
// 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) => {
|
.get(
|
||||||
// Returns each requested player's presence. Reads the `id` query param(s);
|
'/player',
|
||||||
// with none it serves the static getplayer.json default.
|
describeRoute({
|
||||||
const ids = c.req
|
tags: ['Presence'],
|
||||||
.queries('id')
|
summary: 'Batch player presence lookup',
|
||||||
?.flatMap((v) => v.split(','))
|
description:
|
||||||
.map((s) => Number.parseInt(s.trim(), 10))
|
'Returns each requested player’s presence. `id` is repeatable and each value may ' +
|
||||||
.filter((n) => !Number.isNaN(n))
|
'be a comma-separated list. With no ids, serves a single default (online) player.',
|
||||||
if (!ids || ids.length === 0) return c.json(DEFAULT_GET_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
|
// One query for the whole batch (D1 `WHERE account_id IN (…)`), rather than a
|
||||||
// point read per id as the KV store required.
|
// point read per id as the KV store required.
|
||||||
const presences = await getPresences<RoomInstance>(c.env.DB, ids)
|
const presences = await getPresences<RoomInstance>(c.env.DB, ids)
|
||||||
return c.json(ids.map((playerId) => playerPayload(playerId, presences.get(playerId))))
|
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 = {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Return the player's stored presence (set by matchmake/goto), mirroring the
|
.post(
|
||||||
// reference server's HeartbeatDB.GetPlayerHeartbeat. No presence → the player
|
'/player/heartbeat',
|
||||||
// isn't in a room yet, so roomInstance=null / isOnline=false. Posted status
|
describeRoute({
|
||||||
// fields are merged back; the row is re-written (refreshing the TTL) only when
|
tags: ['Presence'],
|
||||||
// something changed or its TTL is close to lapsing — see below.
|
summary: 'Presence heartbeat',
|
||||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
description:
|
||||||
if (presence) {
|
'Merges the posted status fields into stored presence and echoes back the player ' +
|
||||||
// Merge the posted status fields, tracking whether any actually changed.
|
'payload. Re-writes the row (refreshing its TTL) only when something changed or the ' +
|
||||||
let changed = false
|
'TTL is close to lapsing, so a still player isn’t written on every beat. With no ' +
|
||||||
const apply = <K extends keyof Presence>(key: K, value: Presence[K]) => {
|
'stored presence the player isn’t in a room yet (roomInstance null, isOnline false).',
|
||||||
if (presence[key] !== value) {
|
security: AUTHED,
|
||||||
presence[key] = value
|
requestBody: jsonBody(
|
||||||
changed = true
|
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
|
// Return the player's stored presence (set by matchmake/goto), mirroring the
|
||||||
// unchanged heartbeat until the TTL is within PRESENCE_REFRESH_THRESHOLD
|
// reference server's HeartbeatDB.GetPlayerHeartbeat. No presence → the player
|
||||||
// (s) of lapsing — a still player is refreshed periodically rather than on
|
// isn't in a room yet, so roomInstance=null / isOnline=false. Posted status
|
||||||
// every beat. `expiresAt` is epoch seconds (set by setPresence).
|
// fields are merged back; the row is re-written (refreshing the TTL) only when
|
||||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
// something changed or its TTL is close to lapsing — see below.
|
||||||
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<string, unknown>)
|
|
||||||
const sv =
|
|
||||||
typeof body.statusVisibility === 'string' ? Number.parseInt(body.statusVisibility, 10) : NaN
|
|
||||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
||||||
if (presence && !Number.isNaN(sv)) {
|
if (presence) {
|
||||||
presence.statusVisibility = sv
|
// Merge the posted status fields, tracking whether any actually changed.
|
||||||
await setPresence(c.env.DB, presence)
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
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<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 -----------------------------------------------------
|
// ---- Room navigation -----------------------------------------------------
|
||||||
// Each matchmake/goto persists the resulting instance as the player's presence
|
// Each matchmake/goto persists the resulting instance as the player's presence
|
||||||
// so the heartbeat can replay it (keeping client presence in sync).
|
// so the heartbeat can replay it (keeping client presence in sync).
|
||||||
.post('/goto/room/:room', async (c) => {
|
.post(
|
||||||
const id = await authedId(c)
|
'/goto/room/:room',
|
||||||
if (id === null) return unauthorized(c)
|
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 room = c.req.param('room')
|
||||||
const joinMode = await readJoinMode(c)
|
const joinMode = await readJoinMode(c)
|
||||||
const instance =
|
const instance =
|
||||||
room.toLowerCase() === 'dormroom'
|
room.toLowerCase() === 'dormroom'
|
||||||
? await playerDormInstance(c, id)
|
? await playerDormInstance(c, id)
|
||||||
: await resolveRoomInstance(c, room, joinMode === 2, id)
|
: await resolveRoomInstance(c, room, joinMode === 2, id)
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
return c.json({ errorCode: 0, roomInstance: instance })
|
return c.json({ errorCode: 0, roomInstance: instance })
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Register the static `none` route before the `:room` param route so it
|
// Register the static `none` route before the `:room` param route so it
|
||||||
// isn't swallowed by the auth-gated matchmake handler.
|
// isn't swallowed by the auth-gated matchmake handler.
|
||||||
.post('/matchmake/none', async (c) => {
|
.post(
|
||||||
const id = await authedId(c)
|
'/matchmake/none',
|
||||||
// Return the player's *current* heartbeat here rather than forcing the dorm.
|
describeRoute({
|
||||||
// Orientation is a solo room the client establishes via matchmake/none; if we
|
tags: ['Navigation'],
|
||||||
// force the dorm, the new player is warped out of Orientation within seconds.
|
summary: 'Matchmake with no target (preserve or dorm)',
|
||||||
// So: preserve existing presence; only fall back to the offline dorm when the
|
description:
|
||||||
// player has none (e.g. the title screen before they've entered any room).
|
'Returns the player’s current instance if they have one (so Orientation isn’t warped ' +
|
||||||
if (id !== null) {
|
'away), else their personal dorm when authed, or the shared offline dorm when not. ' +
|
||||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
'Not auth-gated.',
|
||||||
if (presence?.roomInstance) {
|
responses: {
|
||||||
return c.json({ errorCode: 0, roomInstance: presence.roomInstance })
|
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 })
|
||||||
}
|
}
|
||||||
// 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}`
|
// 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
|
// — 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
|
// 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.
|
// through; an unknown subroom falls back to the room's first.
|
||||||
.post('/matchmake/room/:roomId/:subRoomId{[0-9]+}', async (c) => {
|
.post(
|
||||||
const id = await authedId(c)
|
'/matchmake/room/:roomId/:subRoomId{[0-9]+}',
|
||||||
if (id === null) return unauthorized(c)
|
describeRoute({
|
||||||
const joinMode = await readJoinMode(c)
|
tags: ['Navigation'],
|
||||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
summary: 'Matchmake into a specific subroom',
|
||||||
const instance = await resolveRoomInstance(
|
description:
|
||||||
c,
|
'Enters a specific subroom (scene) of a room. The subroom decides the scene loaded ' +
|
||||||
c.req.param('roomId'),
|
'and which instances are joinable; an unknown subroom falls back to the room’s first.',
|
||||||
joinMode === 2,
|
security: AUTHED,
|
||||||
id,
|
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
|
||||||
subRoomId
|
parameters: [
|
||||||
)
|
{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
{
|
||||||
await enterRoom(c, id, instance)
|
name: 'subRoomId',
|
||||||
return c.json({ errorCode: 0, roomInstance: instance })
|
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
|
// 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.
|
// in D1 so the instance carries its real scene, and store it as presence.
|
||||||
.post('/matchmake/room/:roomId', async (c) => {
|
.post(
|
||||||
const id = await authedId(c)
|
'/matchmake/room/:roomId',
|
||||||
if (id === null) return unauthorized(c)
|
describeRoute({
|
||||||
const joinMode = await readJoinMode(c)
|
tags: ['Navigation'],
|
||||||
const instance = await resolveRoomInstance(c, c.req.param('roomId'), joinMode === 2, id)
|
summary: 'Matchmake into a room (default subroom)',
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
description:
|
||||||
await enterRoom(c, id, instance)
|
'The 2023 client’s two-segment matchmake. Resolves the room from D1 so the instance ' +
|
||||||
return c.json({ errorCode: 0, roomInstance: instance })
|
'carries its real scene, and stores it as presence.',
|
||||||
})
|
security: AUTHED,
|
||||||
.post('/matchmake/:room', async (c) => {
|
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
|
||||||
const id = await authedId(c)
|
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
|
||||||
if (id === null) return unauthorized(c)
|
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 room = c.req.param('room')
|
||||||
const joinMode = await readJoinMode(c)
|
const joinMode = await readJoinMode(c)
|
||||||
// The dorm check here is "dorm" (goto/room uses "dormroom").
|
// The dorm check here is "dorm" (goto/room uses "dormroom").
|
||||||
const instance =
|
const instance =
|
||||||
room.toLowerCase() === 'dorm'
|
room.toLowerCase() === 'dorm'
|
||||||
? await playerDormInstance(c, id)
|
? await playerDormInstance(c, id)
|
||||||
: await resolveRoomInstance(c, room, joinMode === 2, id)
|
: await resolveRoomInstance(c, room, joinMode === 2, id)
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
return c.json({ errorCode: 0, roomInstance: instance })
|
return c.json({ errorCode: 0, roomInstance: instance })
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Offline dorm — also persisted as presence so the heartbeat stays in sync.
|
// Offline dorm — also persisted as presence so the heartbeat stays in sync.
|
||||||
.post('/goto/none', async (c) => {
|
.post(
|
||||||
const id = await authedId(c)
|
'/goto/none',
|
||||||
// Authed → their personal dorm; unauthenticated → the offline dorm.
|
describeRoute({
|
||||||
const instance = id !== null ? await playerDormInstance(c, id) : dormRoomInstance()
|
tags: ['Navigation'],
|
||||||
if (id !== null) await enterRoom(c, id, instance)
|
summary: 'Go to the dorm',
|
||||||
return c.json({ errorCode: 0, roomInstance: instance })
|
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()).
|
// Region ping reports — accept-and-ack (the reference returns Ok()).
|
||||||
.put('/player/photonregionpings', (c) => c.body(null, 200))
|
.put(
|
||||||
.put('/player/gameserverregionpings', (c) => c.body(null, 200))
|
'/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 -------------------------------------------------------
|
// ---- 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
|
// 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`.
|
// (e.g. a game round begins). Body is a form post: `inProgress=True|False`.
|
||||||
.put('/roominstance/:id/inprogress', async (c) => {
|
.put(
|
||||||
const id = await authedId(c)
|
'/roominstance/:id/inprogress',
|
||||||
if (id === null) return unauthorized(c)
|
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)
|
const instanceId = Number.parseInt(c.req.param('id'), 10)
|
||||||
if (Number.isNaN(instanceId)) return c.body(null, 404)
|
if (Number.isNaN(instanceId)) return c.body(null, 404)
|
||||||
|
|
||||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||||
const inProgress =
|
const inProgress =
|
||||||
typeof body.inProgress === 'string' && body.inProgress.toLowerCase() === 'true'
|
typeof body.inProgress === 'string' && body.inProgress.toLowerCase() === 'true'
|
||||||
|
|
||||||
const instance = await setRoomInstanceInProgress(c.env.DB, instanceId, inProgress)
|
const instance = await setRoomInstanceInProgress(c.env.DB, instanceId, inProgress)
|
||||||
if (!instance) return c.body(null, 404)
|
if (!instance) return c.body(null, 404)
|
||||||
return c.body(null, 200)
|
return c.body(null, 200)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// The room's live instances — the owner's view of active sessions of their room.
|
// 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
|
// 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
|
// 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).
|
// bare RoomInstance DTO array (empty when the room has no live instances).
|
||||||
.get('/room/:roomId{[0-9]+}/instances', async (c) => {
|
.get(
|
||||||
const id = await authedId(c)
|
'/room/:roomId{[0-9]+}/instances',
|
||||||
if (id === null) return unauthorized(c)
|
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 roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) return c.body(null, 404)
|
if (!room) return c.body(null, 404)
|
||||||
// The room's creator *or* a co-owner (Role 30) may see its live instances —
|
// 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.
|
// same owner-or-co-owner gate the rooms worker uses for room-admin actions.
|
||||||
if (!canManageRoom(room, id)) return c.body(null, 403)
|
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
|
// Rooms flagged as needing a developer/moderator to spawn in. No such queue
|
||||||
// yet → empty list.
|
// 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.
|
// 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,
|
* Cron: sweep presence that has aged past its TTL. Reads already ignore expired rows,
|
||||||
@@ -690,9 +1017,55 @@ async function sweepExpiredPresence(env: Env): Promise<void> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
// The generated spec. Documentation only — no request is validated against it (see
|
||||||
fetch: app.fetch,
|
// openapi.ts). `hide: true` keeps this route out of its own output. Registered on
|
||||||
scheduled: async (_controller, env, ctx) => {
|
// `app` before it's wrapped in the exported handler below.
|
||||||
ctx.waitUntil(sweepExpiredPresence(env))
|
app.get(
|
||||||
},
|
'/openapi.json',
|
||||||
} satisfies ExportedHandler<Env>
|
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<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>
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
})
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
ROOM_INSTANCE_SCHEMA_DDL,
|
ROOM_INSTANCE_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
import worker from '../../match.app'
|
import { scheduled } from '../../match.app'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
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
|
// Driven through the module's own export rather than the `exports` proxy — a
|
||||||
// ScheduledController can't cross the isolate boundary the proxy serializes over.
|
// ScheduledController can't cross the isolate boundary the proxy serializes over.
|
||||||
const ctx = createExecutionContext()
|
const ctx = createExecutionContext()
|
||||||
await worker.scheduled(createScheduledController(), env, ctx)
|
await scheduled(createScheduledController(), env, ctx)
|
||||||
await waitOnExecutionContext(ctx)
|
await waitOnExecutionContext(ctx)
|
||||||
|
|
||||||
// Expired row gone, and the instance is joinable again.
|
// Expired row gone, and the instance is joinable again.
|
||||||
@@ -868,4 +868,54 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(coOwner.status).toBe(200)
|
expect(coOwner.status).toBe(200)
|
||||||
expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length)
|
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<string, Record<string, { summary?: string }>>
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+15
@@ -404,12 +404,27 @@ importers:
|
|||||||
'@repo/jwt':
|
'@repo/jwt':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/jwt
|
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:
|
hono:
|
||||||
specifier: 4.12.27
|
specifier: 4.12.27
|
||||||
version: 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:
|
workers-tagged-logger:
|
||||||
specifier: 1.0.1
|
specifier: 1.0.1
|
||||||
version: 1.0.1
|
version: 1.0.1
|
||||||
|
zod:
|
||||||
|
specifier: 4.4.3
|
||||||
|
version: 4.4.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@cloudflare/vitest-pool-workers':
|
'@cloudflare/vitest-pool-workers':
|
||||||
specifier: 0.16.20
|
specifier: 0.16.20
|
||||||
|
|||||||
Reference in New Issue
Block a user