From e06eba692979d615237c47ee197ec60e034dd0e3 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 12 Oct 2026 22:17:51 -0400 Subject: [PATCH] [match] missing endpoint for 2026 that apparently breaks game --- apps/match/README.md | 53 +++++++------ apps/match/src/match.app.ts | 88 +++++++++++++++++---- apps/match/src/openapi.ts | 10 +++ apps/match/src/test/integration/api.test.ts | 59 ++++++++++++++ 4 files changed, 168 insertions(+), 42 deletions(-) diff --git a/apps/match/README.md b/apps/match/README.md index b1fda6c..5a60c86 100644 --- a/apps/match/README.md +++ b/apps/match/README.md @@ -6,33 +6,34 @@ instances and presence all live in the shared `recflare` D1 database. ## Routes -| 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 | -| GET | `/player/avoidjuniors` | ✓ | The player's "avoid juniors" setting → `true`/`false` | -| PUT | `/player/avoidjuniors` | ✓ | Set it (`avoidJuniors=True`) → the resulting value | -| 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) | +| 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 | +| PUT | `/player/vrmovementmode` | ✓\* | Set VR movement mode (`vrMovementMode=1`; 0 teleport, 1 walk) | +| GET | `/player/avoidjuniors` | ✓ | The player's "avoid juniors" setting → `true`/`false` | +| PUT | `/player/avoidjuniors` | ✓ | Set it (`avoidJuniors=True`) → the resulting value | +| 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) | -\* `logout` and `statusvisibility` read the token when present but never 401 — an +\* `logout`, `statusvisibility` and `vrmovementmode` 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. diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 7b15841..a8e69ea 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -101,6 +101,7 @@ import { RoomInstanceSummaryDto, StatusVisibilityRequest, UNAUTHORIZED_RESPONSE, + VrMovementModeRequest, } from './openapi' import type { Context } from 'hono' @@ -504,6 +505,30 @@ async function readAvoidJuniorsBody(c: Context): Promise)[key]) } +/** + * A non-negative integer the client posted under `field` — the shape of every presence + * status setter (`PUT /player/statusvisibility`, `PUT /player/vrmovementmode`). The + * client sends a form (`statusVisibility=2`); a query string, a JSON object keyed either + * way (`statusVisibility`/`StatusVisibility`), and a bare JSON number are accepted too. + * `NaN` when nothing readable was posted, so the caller can leave the row alone. + */ +async function readPostedInt(c: Context, field: string): Promise { + const body: unknown = c.req.header('content-type')?.includes('application/json') + ? await c.req.json().catch(() => null) + : await c.req.parseBody().catch(() => null) + const pascal = field[0].toUpperCase() + field.slice(1) + const value = + c.req.query(field) ?? + (body && typeof body === 'object' + ? ((body as Record)[field] ?? (body as Record)[pascal]) + : body) + return typeof value === 'number' + ? value + : typeof value === 'string' && /^\d+$/.test(value) + ? Number(value) + : NaN +} + /** A synthesized room instance (same shape for dorm and other rooms). */ type RoomInstance = ReturnType @@ -2115,22 +2140,7 @@ const app = new Hono() async (c) => { const id = await authedId(c) if (id !== null) { - // Accept native form/query enum values and JSON integrations. - const body: unknown = c.req.header('content-type')?.includes('application/json') - ? await c.req.json().catch(() => null) - : await c.req.parseBody().catch(() => null) - const value = - c.req.query('statusVisibility') ?? - (body && typeof body === 'object' - ? ((body as Record).statusVisibility ?? - (body as Record).StatusVisibility) - : body) - const sv = - typeof value === 'number' - ? value - : typeof value === 'string' && /^\d+$/.test(value) - ? Number(value) - : NaN + const sv = await readPostedInt(c, 'statusVisibility') const presence = await getPresence(c.env.DB, id) if (presence && Number.isInteger(sv) && sv >= 0 && sv <= 3) { presence.statusVisibility = sv @@ -2156,6 +2166,52 @@ const app = new Hono() } ) + // The player's VR locomotion mode. It is PRESENCE state, not a `playersettings` key: + // every presence payload already carries `vrMovementMode` (the reference keeps it on + // its player-state row beside `statusVisibility`), and matchmaking reads it to refuse a + // room whose owner doesn't support the mode (result 32). The 2026 client posts it here + // when the player changes locomotion: `vrMovementMode=1` is walk, 0 teleport. Friends aren't told — the + // field is blanked to 0 in the presence frame they get anyway. + .put( + '/player/vrmovementmode', + describeRoute({ + tags: ['Presence'], + summary: 'Set VR movement mode', + description: [ + 'Updates the stored presence’s `vrMovementMode`, which the heartbeat and the', + '`/player` lookups then echo. No-op when the player has no live presence, the value', + 'isn’t a non-negative integer, or the token is missing/invalid — always acks 200.', + ].join(' '), + requestBody: form(VrMovementModeRequest, 'The vrMovementMode value'), + responses: { 200: EMPTY_OK }, + }), + async (c) => { + const id = await authedId(c) + if (id !== null) { + const mode = await readPostedInt(c, 'vrMovementMode') + const presence = await getPresence(c.env.DB, id) + if (presence && Number.isInteger(mode) && mode >= 0 && presence.vrMovementMode !== mode) { + presence.vrMovementMode = mode + await setPresence(c.env.DB, presence) + // Refresh the client's cached own-player presence, as statusvisibility does. + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral( + [id], + NotificationType.SubscriptionUpdatePresence, + playerPayload(id, presence) + ) + } catch (error) { + logger.error('failed to push own movement mode update', { + playerId: id, + error: String(error), + }) + } + } + } + return c.body(null, 200) + } + ) + // The caller's "avoid juniors" preference. It's asked of this worker because it's a // matchmaking question, but it isn't matchmaking state: the setting is written by the // client through the `playersettings` worker, so this reads that worker's KV map diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index 8554db3..6b08b16 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -288,6 +288,16 @@ export const StatusVisibilityRequest = z.object({ statusVisibility: z.string().describe('Integer string; non-numeric is ignored'), }) +/** + * `PUT /player/vrmovementmode` form body — `vrMovementMode=1`, posted by the 2026 client + * when the player changes their VR locomotion. The value is the client's `VRMovementMode` + * enum ordinal: 0 teleport, 1 walk (the only one seen on the wire). The server stores any + * non-negative integer rather than betting on the enum's size. + */ +export const VrMovementModeRequest = z.object({ + vrMovementMode: z.string().describe('Integer string: 0 teleport, 1 walk; non-numeric is ignored'), +}) + /** * `POST /player/notifydisconnect` form body — posted by Photon when it sees a player * drop a room instance. Both fields are integer strings. diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 128acc1..7aac313 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -1284,6 +1284,64 @@ describe('public endpoints', () => { expect(res.status).toBe(200) }) + // The 2026 client posts its VR locomotion here. It's presence state (the heartbeat + // echoes it), not a playersettings key. + test('PUT /player/vrmovementmode stores the mode on presence', async () => { + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + await hub().fetch('http://do/all', { method: 'DELETE' }) + const headers = await bearer('9210', '20260323.14') + await setPresence(env.DB, { + accountId: 9210, + roomInstance: null, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 1, + platform: 0, + appVersion: '20260323.14', + }) + const update = async (body: string, contentType: string) => + exports.default.fetch(`${ORIGIN}/player/vrmovementmode`, { + method: 'PUT', + headers: { ...headers, 'Content-Type': contentType }, + body, + }) + const heartbeat = async () => + ( + await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers }) + ).json() + + expect((await update('vrMovementMode=2', 'application/x-www-form-urlencoded')).status).toBe(200) + expect(await heartbeat()).toMatchObject({ vrMovementMode: 2 }) + + // JSON forms too, and the other presence fields are left alone. + await update(JSON.stringify({ VrMovementMode: 0 }), 'application/json') + expect(await heartbeat()).toMatchObject({ vrMovementMode: 0, statusVisibility: 0 }) + + // Junk and negatives are ignored rather than stored. + await update('vrMovementMode=walk', 'application/x-www-form-urlencoded') + await update('vrMovementMode=-1', 'application/x-www-form-urlencoded') + expect(await heartbeat()).toMatchObject({ vrMovementMode: 0 }) + + // The player's own cached presence is refreshed; nobody else is told. + const sent = await ( + await hub().fetch('http://do/all') + ).json>() + expect(sent).toHaveLength(2) + expect(sent[0]).toMatchObject({ playerIds: [9210], data: { vrMovementMode: 2 } }) + expect(sent[1]).toMatchObject({ playerIds: [9210], data: { vrMovementMode: 0 } }) + }) + + test('PUT /player/vrmovementmode without a token or presence returns 200', async () => { + const res = await exports.default.fetch(`${ORIGIN}/player/vrmovementmode`, { method: 'PUT' }) + expect(res.status).toBe(200) + const authed = await exports.default.fetch(`${ORIGIN}/player/vrmovementmode`, { + method: 'PUT', + headers: { ...(await bearer('9211')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'vrMovementMode=1', + }) + expect(authed.status).toBe(200) + }) + test('GET /player/connection-info 401s without a token', async () => { const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`) expect(res.status).toBe(401) @@ -4062,6 +4120,7 @@ describe('auth-gated endpoints', () => { 'PUT /player/gameserverregionpings', 'PUT /player/photonregionpings', 'PUT /player/statusvisibility', + 'PUT /player/vrmovementmode', 'PUT /roominstance/{id}/inprogress', 'PUT /roominstance/{id}/matchpolicy', ])