mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[match] missing endpoint for 2026 that apparently breaks game
This commit is contained in:
+27
-26
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user