[match] missing endpoint for 2026 that apparently breaks game

This commit is contained in:
Devin Zuczek
2026-10-12 22:17:51 -04:00
parent 1dd5fd409e
commit e06eba6929
4 changed files with 168 additions and 42 deletions
+27 -26
View File
@@ -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.
+72 -16
View File
@@ -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<App>): Promise<boolean | undefine
return key === undefined ? undefined : parseSettingBool((body as Record<string, unknown>)[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<App>, field: string): Promise<number> {
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<string, unknown>)[field] ?? (body as Record<string, unknown>)[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<typeof roomInstanceFromRoom>
@@ -2115,22 +2140,7 @@ const app = new Hono<App>()
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<string, unknown>).statusVisibility ??
(body as Record<string, unknown>).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<RoomInstance>(c.env.DB, id)
if (presence && Number.isInteger(sv) && sv >= 0 && sv <= 3) {
presence.statusVisibility = sv
@@ -2156,6 +2166,52 @@ const app = new Hono<App>()
}
)
// 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<RoomInstance>(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
+10
View File
@@ -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.
@@ -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<Array<{ playerIds: number[]; data: { vrMovementMode: number } }>>()
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',
])