clean up match code, remove old endpoints

This commit is contained in:
Devin Zuczek
2026-07-24 14:19:08 -04:00
parent cfc29cb175
commit 5ed9e765a5
7 changed files with 239 additions and 450 deletions
+3 -4
View File
@@ -28,14 +28,13 @@ export async function parseFormIds(c: Context<App>): Promise<number[]> {
.filter((n) => !Number.isNaN(n)) .filter((n) => !Number.isNaN(n))
} }
/** Read integer ids from repeated/comma-separated `id` query params. The 2023 /** Read integer ids from repeated `id` query params. The 2023 client passes these to
* client passes these to the bulk GET endpoints (e.g. `?id=1&id=2`). */ * the bulk GET endpoints as one value per id (`?id=1&id=2`), never comma-separated. */
export function queryIds(c: Context<App>): number[] { export function queryIds(c: Context<App>): number[] {
return ( return (
c.req c.req
.queries('id') .queries('id')
?.flatMap((v) => v.split(',')) ?.map((s) => Number.parseInt(s.trim(), 10))
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n)) ?? [] .filter((n) => !Number.isNaN(n)) ?? []
) )
} }
+1 -1
View File
@@ -43,7 +43,7 @@ function defaultReputation(id: number) {
* may itself be a comma-separated list, so `?id=1,2&id=3` is three ids. * may itself be a comma-separated list, so `?id=1,2&id=3` is three ids.
*/ */
const BULK_ID_QUERY = [ const BULK_ID_QUERY = [
intQuery('id', 'Repeatable; each value may be a comma-separated list of account ids'), intQuery('id', 'Repeated once per account id (`?id=1&id=2`); not comma-separated'),
] ]
/** The `Ids` form body the bulk POST forms take. */ /** The `Ids` form body the bulk POST forms take. */
+1 -1
View File
@@ -258,7 +258,7 @@ describe('public endpoints', () => {
}), }),
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' }) expect(await res.json()).toEqual([])
}) })
test('POST /api/playerReputation/v2/bulk returns a reputation per id', async () => { test('POST /api/playerReputation/v2/bulk returns a reputation per id', async () => {
+129 -276
View File
@@ -40,14 +40,14 @@ import {
EMPTY_OK, EMPTY_OK,
ExclusiveLoginResponse, ExclusiveLoginResponse,
form, form,
HeartbeatRequest as HeartbeatRequestSchema,
InProgressRequest, InProgressRequest,
InviteRequest, InviteRequest,
JoinModeRequest, JoinModeRequest,
json, json,
jsonBody, LoginLockRequest,
MatchmakeResponse, MatchmakeResponse,
MatchmakeRoomRequest, MatchmakeRoomRequest,
NotifyDisconnectRequest,
PlayerDto, PlayerDto,
RoomInstanceDto, RoomInstanceDto,
StatusVisibilityRequest, StatusVisibilityRequest,
@@ -108,16 +108,6 @@ function playerPayload(playerId: number, presence?: Presence | null) {
} }
} }
/** Heartbeat body posted by the client (all fields optional). */
interface HeartbeatRequest {
playerId?: number
statusVisibility?: number
deviceClass?: number
vrMovementMode?: number
appVersion?: string | null
platform?: number
}
/** /**
* Resolve the account id from a Bearer token, mirroring the repeated * Resolve the account id from a Bearer token, mirroring the repeated
* auth-header check. Returns `null` when the header is missing, * auth-header check. Returns `null` when the header is missing,
@@ -133,7 +123,7 @@ function unauthorized(c: Context<App>) {
} }
/** A synthesized room instance (same shape for dorm and other rooms). */ /** A synthesized room instance (same shape for dorm and other rooms). */
type RoomInstance = ReturnType<typeof dormRoomInstance> type RoomInstance = ReturnType<typeof roomInstanceFromRoom>
/** /**
* Stored presence for a player — the room instance they matchmade into plus the * Stored presence for a player — the room instance they matchmade into plus the
@@ -165,7 +155,7 @@ const DEFAULT_GET_PLAYER = [{ ...playerPayload(1), isOnline: true }]
* `photonRoomId` would let anyone who can read your presence `JoinByName` the Photon * `photonRoomId` would let anyone who can read your presence `JoinByName` the Photon
* room directly, bypassing the private-instance invite check — the friend list only * room directly, bypassing the private-instance invite check — the friend list only
* needs `roomId`/`name`/`isPrivate` to render the row, and joins go back through * needs `roomId`/`name`/`isPrivate` to render the row, and joins go back through
* matchmaking (`/goto/player/:id`), which enforces access. `photonRegion` is omitted * matchmaking (`/matchmake/player/:playerId`), which enforces access. `photonRegion` is omitted
* (not on the presence DTO); `name` is already the `^`-prefixed wire name. * (not on the presence DTO); `name` is already the `^`-prefixed wire name.
*/ */
function redactInstanceForPresence(instance: RoomInstance) { function redactInstanceForPresence(instance: RoomInstance) {
@@ -260,6 +250,9 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
vrMovementMode: prev?.vrMovementMode ?? 1, vrMovementMode: prev?.vrMovementMode ?? 1,
platform: prev?.platform ?? account?.platform ?? 0, platform: prev?.platform ?? account?.platform ?? 0,
appVersion: prev?.appVersion || GAME_VERSION, appVersion: prev?.appVersion || GAME_VERSION,
// Carry the session lock recorded at login forward, so matchmake doesn't wipe it
// and the heartbeat can keep verifying against it.
loginLock: prev?.loginLock,
}) })
// Keep the destination instance's is_full flag in sync with live presence (the // Keep the destination instance's is_full flag in sync with live presence (the
// player's own presence, just written, is counted). Then re-evaluate the // player's own presence, just written, is counted). Then re-evaluate the
@@ -277,15 +270,6 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
await notifyFriendsPresence(c, id) await notifyFriendsPresence(c, id)
} }
/**
* Fixed Photon room id for the dorm. With no RoomInstance DB we can't persist
* the GUID minted at matchmake time, and the client's presence check compares
* the *whole* instance (photonRoomId included). Every dorm entry point
* (matchmake/goto, matchmake/none, the heartbeat) must therefore return the
* exact same instance, so the id is a constant rather than random/per-account.
*/
const DORM_PHOTON_ROOM_ID = '00000000-0000-4000-8000-000000000001'
/** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */ /** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */
const NO_SUCH_ROOM = 20 const NO_SUCH_ROOM = 20
@@ -352,34 +336,6 @@ async function sendGameInvite(
*/ */
const ORIENTATION_INSTANCE_ID = -2 const ORIENTATION_INSTANCE_ID = -2
/**
* The canonical dorm room instance (room 1, instance 1.1). Returned identically
* by every dorm entry point and the presence heartbeat so the client's local
* presence never reads as out-of-sync.
*/
function dormRoomInstance() {
return {
roomInstanceId: 1,
roomId: 1,
subRoomId: 1,
roomInstanceType: RoomInstanceType.Dormroom,
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
dataBlob: '',
eventId: 0,
clubId: 0,
roomCode: '',
photonRegion: 'us',
photonRegionId: 'us',
photonRoomId: DORM_PHOTON_ROOM_ID,
name: '^DormRoom',
maxCapacity: 4,
isFull: false,
isPrivate: true,
isInProgress: false,
EncryptVoiceChat: false,
}
}
/** /**
* Instance-relevant fields pulled from a stored room (scene, name, capacity, …). * Instance-relevant fields pulled from a stored room (scene, name, capacity, …).
* The `location` is the SubRoom's real `UnitySceneId` — an empty/unknown location * The `location` is the SubRoom's real `UnitySceneId` — an empty/unknown location
@@ -426,7 +382,7 @@ function roomInstanceFromRoom(
instanceId: number, instanceId: number,
photonRoomId: string, photonRoomId: string,
subRoomId?: number subRoomId?: number
): RoomInstance { ) {
const f = instanceFieldsFromRoom(room, subRoomId) const f = instanceFieldsFromRoom(room, subRoomId)
return { return {
roomInstanceId: instanceId, roomInstanceId: instanceId,
@@ -450,6 +406,12 @@ function roomInstanceFromRoom(
} }
} }
/** Read the session's `LoginLock` GUID from a form body (undefined when absent/empty). */
async function readLoginLock(c: Context<App>): Promise<string | undefined> {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
return typeof body.LoginLock === 'string' && body.LoginLock ? body.LoginLock : undefined
}
/** Read the `JoinMode` form field (2 = private instance). */ /** Read the `JoinMode` form field (2 = private instance). */
async function readJoinMode(c: Context<App>): Promise<number> { async function readJoinMode(c: Context<App>): Promise<number> {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>) const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
@@ -460,9 +422,9 @@ async function readJoinMode(c: Context<App>): Promise<number> {
* Read the room-matchmake form body once: `JoinMode` (2 = private) plus the party members * Read the room-matchmake form body once: `JoinMode` (2 = private) plus the party members
* to pull along (`AdditionalPlayerIds`). The 2023 client posts its party on a room * to pull along (`AdditionalPlayerIds`). The 2023 client posts its party on a room
* matchmake so they can be invited into the instance the leader lands in; * matchmake so they can be invited into the instance the leader lands in;
* `AdditionalPlayerIds` may repeat and/or be comma-separated, and ids are parsed * `AdditionalPlayerIds` is a repeated field (one id each, never comma-separated), and
* defensively, de-duplicated, and non-positive/garbage entries dropped. Parsed with * ids are parsed defensively, de-duplicated, and non-positive/garbage entries dropped.
* `{ all: true }` in one pass so repeated fields survive. * Parsed with `{ all: true }` in one pass so the repeated fields survive.
*/ */
async function readMatchmakeBody( async function readMatchmakeBody(
c: Context<App> c: Context<App>
@@ -482,7 +444,6 @@ async function readMatchmakeBody(
...new Set( ...new Set(
values values
.filter((v): v is string => typeof v === 'string') .filter((v): v is string => typeof v === 'string')
.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10)) .map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n) && n > 0) .filter((n) => !Number.isNaN(n) && n > 0)
), ),
@@ -613,29 +574,62 @@ const app = new Hono<App>()
.notFound(withNotFound()) .notFound(withNotFound())
// ---- Player presence ----------------------------------------------------- // ---- Player presence -----------------------------------------------------
// login/exclusivelogin are no-op acks and MUST NOT touch presence: the client // login records the session's `LoginLock` in presence so the heartbeat can verify
// fires exclusivelogin when going online, and clearing presence there would bounce // each beat belongs to this login; it must otherwise leave presence intact (clearing
// the player to the dorm. Presence is overwritten by matchmake/goto and expires on // the room instance here would bounce the player to the dorm). Presence is overwritten
// its own TTL. // by matchmake — which carries the lock forward — and expires on its own TTL.
.post( .post(
'/player/login', '/player/login',
describeRoute({ describeRoute({
tags: ['Presence'], tags: ['Presence'],
summary: 'Login ack (no-op)', summary: 'Record the session login lock',
description: [ description: [
'A no-op ack. Must NOT touch presence — the client fires this going online, and', 'Records the posted `LoginLock` in the players presence so later heartbeats can',
'clearing presence here would bounce the player to the dorm.', 'verify they still own the session. Updates the live presence row if there is one,',
'otherwise seeds a lobby presence (no room) carrying the lock. Empty ack.',
].join(' '), ].join(' '),
requestBody: form(LoginLockRequest, 'The session LoginLock GUID'),
responses: { 200: EMPTY_OK }, responses: { 200: EMPTY_OK },
}), }),
(c) => c.body(null, 200) async (c) => {
const id = await authedId(c)
if (id !== null) {
const loginLock = await readLoginLock(c)
if (loginLock !== undefined) {
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence) {
presence.loginLock = loginLock
await setPresence(c.env.DB, presence)
} else {
// No live presence yet — seed a lobby row (roomInstance null) holding the
// lock, so it survives to the first matchmake (enterRoom carries it forward).
const account = await getAccount(c.env.DB, id)
await setPresence(c.env.DB, {
accountId: id,
roomInstance: null,
statusVisibility: 0,
deviceClass: account?.deviceClass ?? 0,
vrMovementMode: 1,
platform: account?.platform ?? 0,
appVersion: GAME_VERSION,
loginLock,
})
}
}
}
return c.body(null, 200)
}
) )
.post( .post(
'/player/exclusivelogin', '/player/exclusivelogin',
describeRoute({ describeRoute({
tags: ['Presence'], tags: ['Presence'],
summary: 'Exclusive-login ack (no-op)', summary: 'Exclusive-login ack (no-op)',
description: 'A no-op ack returning a zero error code. Like login, must not touch presence.', description: [
'Player exclusive login. Carries the session `LoginLock` (as every presence',
'lifecycle call does) but is currently a no-op ack. @todo implement login locking.',
].join(' '),
requestBody: form(LoginLockRequest, 'The session LoginLock GUID'),
responses: { 200: json(ExclusiveLoginResponse, 'errorCode 0') }, responses: { 200: json(ExclusiveLoginResponse, 'errorCode 0') },
}), }),
(c) => c.json({ errorCode: 0 }) (c) => c.json({ errorCode: 0 })
@@ -649,7 +643,7 @@ const app = new Hono<App>()
// worker writes that presence with instance id -2). Clearing presence there wipes // worker writes that presence with instance id -2). Clearing presence there wipes
// 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). @kludge probably a better solution for this.
.post( .post(
'/player/logout', '/player/logout',
describeRoute({ describeRoute({
@@ -657,10 +651,12 @@ const app = new Hono<App>()
summary: 'Clear presence on logout', summary: 'Clear presence on logout',
description: [ description: [
'Clears the players presence so they read offline immediately and the instance', 'Clears the players presence so they read offline immediately and the instance',
'they were in frees up. EXCEPTION: a logout whose presence still points at the', 'they were in frees up. Carries the session `LoginLock` (as every presence',
'lifecycle call does). EXCEPTION: a logout whose presence still points at the',
'Orientation seed (instance -2) is left as a no-op, so the account-creation', 'Orientation seed (instance -2) is left as a no-op, so the account-creation',
'bootstrap isnt wiped. An unauthenticated logout is also a no-op.', 'bootstrap isnt wiped. An unauthenticated logout is also a no-op.',
].join(' '), ].join(' '),
requestBody: form(LoginLockRequest, 'The session LoginLock GUID'),
responses: { 200: EMPTY_OK }, responses: { 200: EMPTY_OK },
}), }),
async (c) => { async (c) => {
@@ -683,21 +679,35 @@ const app = new Hono<App>()
} }
) )
// Fire-and-forget disconnect notification (form body `PlayerId`/`RoomInstanceId`). // Photon disconnect notification (form body `PlayerId`/`RoomInstanceId`) — posted when
// The client posts this when it drops a room; we don't act on it presence is // Photon sees a player drop a room instance. We don't act on it yet (presence is cleared
// cleared by logout and otherwise expires on its own TTL — so just ack with 200. // by logout and otherwise expires on its TTL), but the fields are parsed and logged so
// the hook is in place for a future background reconciliation check.
.post( .post(
'/player/notifydisconnect', '/player/notifydisconnect',
describeRoute({ describeRoute({
tags: ['Presence'], tags: ['Presence'],
summary: 'Disconnect notification (no-op ack)', summary: 'Photon disconnect notification',
description: [ description: [
'Posted when the client drops a room. Not acted on — presence is cleared by logout', 'Posted by Photon when it sees a player drop a room instance (form body',
'and otherwise expires on its TTL.', '`PlayerId`/`RoomInstanceId`). Currently just logged and acked — presence is cleared',
'by logout and otherwise expires on its TTL — but the hook is here for a future check.',
].join(' '), ].join(' '),
requestBody: form(NotifyDisconnectRequest, 'The disconnecting player and the instance they left'),
responses: { 200: EMPTY_OK }, responses: { 200: EMPTY_OK },
}), }),
(c) => c.body(null, 200) async (c) => {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const parseId = (v: unknown): number | null => {
const n = typeof v === 'string' ? Number.parseInt(v, 10) : NaN
return Number.isNaN(n) ? null : n
}
logger.info('player disconnect notification', {
playerId: parseId(body.PlayerId),
roomInstanceId: parseId(body.RoomInstanceId),
})
return c.body(null, 200)
}
) )
.get( .get(
@@ -706,27 +716,27 @@ const app = new Hono<App>()
tags: ['Presence'], tags: ['Presence'],
summary: 'Batch player presence lookup', summary: 'Batch player presence lookup',
description: [ description: [
'Returns each requested players presence. `id` is repeatable and each value may', 'Returns each requested players presence. `id` is a repeated query param',
'be a comma-separated list. With no ids, serves a single default (online) player.', '(`?id=2&id=155&id=153`) — one value each, not comma-separated. With no ids, serves',
'a single default (online) player.',
].join(' '), ].join(' '),
parameters: [ parameters: [
{ {
name: 'id', name: 'id',
in: 'query', in: 'query',
required: false, required: false,
description: 'Repeatable; each value may be a comma-separated list of player ids', description: 'Repeated once per player id (`?id=2&id=155`); not comma-separated',
schema: { type: 'array', items: { type: 'string' } }, schema: { type: 'array', items: { type: 'string' } },
}, },
], ],
responses: { 200: json(PlayerDto.array(), 'One entry per requested player') }, responses: { 200: json(PlayerDto.array(), 'One entry per requested player') },
}), }),
async (c) => { async (c) => {
// Returns each requested player's presence. Reads the `id` query param(s); // Returns each requested player's presence. Reads the repeated `id` query
// with none it serves the static getplayer.json default. // param(s); with none it serves the static getplayer.json default.
const ids = c.req const ids = c.req
.queries('id') .queries('id')
?.flatMap((v) => v.split(',')) ?.map((s) => Number.parseInt(s.trim(), 10))
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n)) .filter((n) => !Number.isNaN(n))
if (!ids || ids.length === 0) return c.json(DEFAULT_GET_PLAYER) if (!ids || ids.length === 0) return c.json(DEFAULT_GET_PLAYER)
@@ -743,16 +753,15 @@ const app = new Hono<App>()
tags: ['Presence'], tags: ['Presence'],
summary: 'Presence heartbeat', summary: 'Presence heartbeat',
description: [ description: [
'Merges the posted status fields into stored presence and echoes back the player', 'Returns the players current presence payload without mutating any stored fields —',
'payload. Re-writes the row (refreshing its TTL) only when something changed or the', 'the only side effect is refreshing the rows TTL, and even that only when the TTL',
'TTL is close to lapsing, so a still player isnt written on every beat. With no', 'is close to lapsing so a still player isnt written on every beat. The posted',
'stored presence the player isnt in a room yet (roomInstance null, isOnline false).', '`LoginLock` is verified against the one recorded at login: a heartbeat carrying a',
'different lock is a superseded session and gets an empty body. With no stored',
'presence the player isnt in a room yet (roomInstance null, isOnline false).',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
requestBody: jsonBody( requestBody: form(LoginLockRequest, 'The session LoginLock GUID (verified, not stored)'),
HeartbeatRequestSchema,
'JSON status fields. A non-JSON (LoginLock) body is accepted and ignored.'
),
responses: { responses: {
200: json(PlayerDto, 'The players current presence payload'), 200: json(PlayerDto, 'The players current presence payload'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
@@ -762,80 +771,38 @@ const app = new Hono<App>()
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
// Body may be a JSON HeartbeatRequest or a form post (LoginLock); only JSON // The body is either a JSON status blob (no longer read — presence is returned
// carries presence/status fields. // verbatim) or a form carrying the session `LoginLock`. We only read the
const raw = await c.req.text().catch(() => '') // LoginLock, to verify this beat still owns the session; a JSON body simply
let hb: HeartbeatRequest = {} // yields no lock (parseBody fails and is swallowed).
if (raw.trimStart().startsWith('{')) { const postedLock = await readLoginLock(c)
try {
hb = JSON.parse(raw) as HeartbeatRequest
} catch {
hb = {}
}
}
// Return the player's stored presence (set by matchmake/goto), mirroring the // Return the player's stored presence (set at login/matchmake), mirroring the
// reference server's HeartbeatDB.GetPlayerHeartbeat. No presence → the player // reference server's HeartbeatDB.GetPlayerHeartbeat. No presence → the player
// isn't in a room yet, so roomInstance=null / isOnline=false. Posted status // isn't in a room yet, so roomInstance=null / isOnline=false.
// fields are merged back; the row is re-written (refreshing the TTL) only when
// something changed or its TTL is close to lapsing — see below.
const presence = await getPresence<RoomInstance>(c.env.DB, id) const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence) { if (presence) {
// Merge the posted status fields, tracking whether any actually changed. // A heartbeat whose LoginLock disagrees with the one recorded at login belongs
let changed = false // to a superseded session — return nothing so that stale client stops acting as
const apply = <K extends keyof Presence>(key: K, value: Presence[K]) => { // the live one. (No posted lock, or none recorded yet, skips the check.)
if (presence[key] !== value) { if (
presence[key] = value postedLock !== undefined &&
changed = true presence.loginLock !== undefined &&
} presence.loginLock !== postedLock
) {
return c.body(null, 200)
} }
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 // The heartbeat's only side effect is refreshing the TTL, and only once it's
// unchanged heartbeat until the TTL is within PRESENCE_REFRESH_THRESHOLD // within PRESENCE_REFRESH_THRESHOLD (s) of lapsing — a still player is refreshed
// (s) of lapsing — a still player is refreshed periodically rather than on // periodically rather than re-written on every beat. `expiresAt` is epoch seconds.
// every beat. `expiresAt` is epoch seconds (set by setPresence).
const nowSeconds = Math.floor(Date.now() / 1000) const nowSeconds = Math.floor(Date.now() / 1000)
const dueForRefresh = presence.expiresAt - nowSeconds <= PRESENCE_REFRESH_THRESHOLD if (presence.expiresAt - nowSeconds <= PRESENCE_REFRESH_THRESHOLD) {
if (changed || dueForRefresh) {
await setPresence(c.env.DB, presence) await setPresence(c.env.DB, presence)
} }
} }
// The heartbeat echoes the same player payload `/player` serves; with no stored return c.json(playerPayload(id, presence))
// presence it falls back to what the client just posted.
const payload = {
...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,
}
// Also push the presence over the websocket as a PresenceHeartbeatResponse,
// mirroring the reference's ReturnHeartbeat. Sent ephemerally (never queued) —
// a heartbeat fires on every beat, and a queued copy would pile up and arrive
// stale. Best-effort: the HTTP body carries the same payload regardless.
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayerEphemeral(
id,
NotificationType.PresenceHeartbeatResponse,
payload
)
} catch (err) {
logger.error('failed to push PresenceHeartbeatResponse notification', {
playerId: id,
error: err instanceof Error ? err.message : String(err),
})
}
return c.json(payload)
} }
) )
@@ -870,85 +837,9 @@ const app = new Hono<App>()
) )
// ---- Room navigation ----------------------------------------------------- // ---- Room navigation -----------------------------------------------------
// Each matchmake/goto persists the resulting instance as the player's presence // Each matchmake persists the resulting instance as the player's presence so the
// so the heartbeat can replay it (keeping client presence in sync). // heartbeat can replay it (keeping client presence in sync).
.post( //
'/goto/room/:room',
describeRoute({
tags: ['Navigation'],
summary: 'Go to a room',
description: [
'Resolves the room (numeric id or name; `dormroom` → the players personal dorm),',
'finds or creates an instance, and stores it as presence. `JoinMode=2` requests a',
'private instance.',
].join(' '),
security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
parameters: [
{
name: 'room',
in: 'path',
required: true,
description: 'Room id, room name, or `dormroom`',
schema: { type: 'string' },
},
],
responses: {
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const room = c.req.param('room')
const joinMode = await readJoinMode(c)
const instance =
room.toLowerCase() === 'dormroom'
? await playerDormInstance(c, id)
: await resolveRoomInstance(c, room, joinMode === 2, id)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Register the static `none` route before the `:room` param route so it
// isn't swallowed by the auth-gated matchmake handler.
.post(
'/matchmake/none',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake with no target (preserve or dorm)',
description: [
'Returns the players current instance if they have one (so Orientation isnt warped',
'away), else their personal dorm when authed, or the shared offline dorm when not.',
'Not auth-gated.',
].join(' '),
responses: {
200: json(MatchmakeResponse, 'Current, personal-dorm, or offline-dorm instance'),
},
}),
async (c) => {
const id = await authedId(c)
// Return the player's *current* heartbeat here rather than forcing the dorm.
// Orientation is a solo room the client establishes via matchmake/none; if we
// force the dorm, the new player is warped out of Orientation within seconds.
// So: preserve existing presence; only fall back to the offline dorm when the
// player has none (e.g. the title screen before they've entered any room).
if (id !== null) {
const presence = await getPresence<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 })
}
)
// Matchmake into a club's clubhouse (`/matchmake/club/{clubId}`). Registered before // Matchmake into a club's clubhouse (`/matchmake/club/{clubId}`). Registered before
// the single-segment `/matchmake/:room` route so `club` isn't read as a room name. // the single-segment `/matchmake/:room` route so `club` isn't read as a room name.
// Members only: the clubhouse is the club's private space, so a non-member (or // Members only: the clubhouse is the club's private space, so a non-member (or
@@ -1152,28 +1043,18 @@ const app = new Hono<App>()
} }
) )
.post( .post(
'/matchmake/:room', '/matchmake/dorm',
describeRoute({ describeRoute({
tags: ['Navigation'], tags: ['Navigation'],
summary: 'Matchmake into a room by id or name', summary: 'Matchmake into the players dorm',
description: [ description: [
'Single-segment matchmake. `dorm` → the players personal dorm; otherwise resolves', 'Single-segment matchmake into the callers personal dorm, stored as presence. The',
'the room by id or name. (Note: this dorm keyword is `dorm`, while goto/room uses', 'client only ever calls this with the `dorm` keyword — real rooms go through',
'`dormroom`.)', '`/matchmake/room/:roomId`.',
].join(' '), ].join(' '),
security: AUTHED, 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: { responses: {
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'), 200: json(MatchmakeResponse, 'The players personal dorm instance'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
}, },
}), }),
@@ -1181,40 +1062,12 @@ const app = new Hono<App>()
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
const room = c.req.param('room') const instance = await playerDormInstance(c, id)
const joinMode = await readJoinMode(c)
// The dorm check here is "dorm" (goto/room uses "dormroom").
const instance =
room.toLowerCase() === 'dorm'
? await playerDormInstance(c, id)
: await resolveRoomInstance(c, room, joinMode === 2, id)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
await enterRoom(c, id, instance) 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.
.post(
'/goto/none',
describeRoute({
tags: ['Navigation'],
summary: 'Go to the dorm',
description: [
'Authed → the players personal dorm (persisted as presence); unauthenticated → the',
'shared offline dorm. Unlike matchmake/none, this always goes to the dorm.',
].join(' '),
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( .put(
'/player/photonregionpings', '/player/photonregionpings',
+22 -16
View File
@@ -124,7 +124,7 @@ export const PlayerDto = z.object({
}) })
/** /**
* The matchmake/goto result envelope. `errorCode` 0 with a `roomInstance` is success; * The matchmake result envelope. `errorCode` 0 with a `roomInstance` is success;
* a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`. * a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
*/ */
export const MatchmakeResponse = z.object({ export const MatchmakeResponse = z.object({
@@ -136,17 +136,13 @@ export const MatchmakeResponse = z.object({
export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') }) export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') })
/** /**
* `POST /player/heartbeat` JSON body. All fields optional — the client also posts a * The session `LoginLock` GUID form field. The client posts it on every presence
* non-JSON (LoginLock form) body here, in which case none of these are read and stored * lifecycle call — `POST /player/login`, `/player/exclusivelogin`, `/player/logout`,
* presence is echoed back unchanged. * and `/player/heartbeat` — so it's always present, not optional. Recorded in presence
* at login and verified on each heartbeat (a mismatched lock is a superseded session).
*/ */
export const HeartbeatRequest = z.object({ export const LoginLockRequest = z.object({
playerId: z.int().optional(), LoginLock: z.string().describe('The session login-lock GUID (always sent)'),
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. */ /** `PUT /roominstance/:id/inprogress` form body. */
@@ -160,7 +156,16 @@ export const StatusVisibilityRequest = z.object({
}) })
/** /**
* The `JoinMode` form field the matchmake/goto routes read (`2` = a private instance; * `POST /player/notifydisconnect` form body — posted by Photon when it sees a player
* drop a room instance. Both fields are integer strings.
*/
export const NotifyDisconnectRequest = z.object({
PlayerId: z.string().describe('The account that disconnected'),
RoomInstanceId: z.string().describe('The room instance they dropped'),
})
/**
* The `JoinMode` form field the matchmake routes read (`2` = a private instance;
* anything else = public). Posted as a urlencoded/multipart body. * anything else = public). Posted as a urlencoded/multipart body.
*/ */
export const JoinModeRequest = z.object({ export const JoinModeRequest = z.object({
@@ -170,16 +175,17 @@ export const JoinModeRequest = z.object({
/** /**
* The room-matchmake form body (`/matchmake/room/:roomId[/:subRoomId]`). Beyond * The room-matchmake form body (`/matchmake/room/:roomId[/:subRoomId]`). Beyond
* `JoinMode` the 2023 client posts `AdditionalPlayerIds` — the caller's party — so each * `JoinMode` the 2023 client posts `AdditionalPlayerIds` — the caller's party — so each
* of them is invited (a game invite) into the instance the leader lands in. May repeat * of them is invited (a game invite) into the instance the leader lands in. It's a
* and/or be comma-separated. Other fields the client sends (`LoginLock`, * repeated field (one id each, not comma-separated). Other fields the client sends
* `MaxPersistenceVersion`, `BypassMovementModeRestriction`) are accepted and ignored. * (`LoginLock`, `MaxPersistenceVersion`, `BypassMovementModeRestriction`) are accepted
* and ignored.
*/ */
export const MatchmakeRoomRequest = z.object({ export const MatchmakeRoomRequest = z.object({
JoinMode: z.string().optional().describe('"2" requests a private instance'), JoinMode: z.string().optional().describe('"2" requests a private instance'),
AdditionalPlayerIds: z AdditionalPlayerIds: z
.string() .string()
.optional() .optional()
.describe('Party members to invite into the room; repeatable and/or comma-separated'), .describe('Party members to invite into the room; repeated once per id'),
}) })
/** `POST /invite` form body — invite a player into the caller's room instance. */ /** `POST /invite` form body — invite a player into the caller's room instance. */
+76 -152
View File
@@ -258,22 +258,6 @@ describe('public endpoints', () => {
expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: GAME_VERSION }) expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: GAME_VERSION })
}) })
test('POST /goto/none returns the offline dorm', async () => {
const res = await exports.default.fetch(`${ORIGIN}/goto/none`, { method: 'POST' })
expect(res.status).toBe(200)
const body = (await res.json()) as {
errorCode: number
roomInstance: { name: string; location: string; photonRoomId: string }
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({
name: '^DormRoom',
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
isPrivate: true,
})
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
})
test('POST /matchmake/room/:roomId resolves the room scene from D1', async () => { test('POST /matchmake/room/:roomId resolves the room scene from D1', async () => {
const headers = await bearer('88') const headers = await bearer('88')
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
@@ -425,45 +409,6 @@ describe('public endpoints', () => {
expect(await res.json()).toEqual({ errorCode: 20, roomInstance: null }) expect(await res.json()).toEqual({ errorCode: 20, roomInstance: null })
}) })
test('POST /matchmake/none returns the offline dorm when the player has no presence', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, { method: 'POST' })
expect(res.status).toBe(200)
const body = (await res.json()) as {
errorCode: number
roomInstance: { name: string; location: string; isPrivate: boolean; photonRoomId: string }
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({
name: '^DormRoom',
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
isPrivate: true,
})
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
})
test('POST /matchmake/none preserves an existing presence (does not warp to the dorm)', async () => {
const auth = await bearer('314')
// Put the player in a room first (RecCenter), establishing presence.
await exports.default.fetch(`${ORIGIN}/matchmake/2`, { method: 'POST', headers: auth })
// matchmake/none must return that same room, not force the dorm — this is
// what keeps a new player in the solo Orientation room.
const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, {
method: 'POST',
headers: auth,
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
errorCode: number
roomInstance: { roomId: number; name: string; location: string }
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({
roomId: 2,
name: '^RecCenter',
location: RECCENTER_SCENE,
})
})
test('PUT /player/statusvisibility returns 200', async () => { test('PUT /player/statusvisibility returns 200', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' }) const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' })
expect(res.status).toBe(200) expect(res.status).toBe(200)
@@ -483,80 +428,10 @@ describe('public endpoints', () => {
}) })
describe('auth-gated endpoints', () => { describe('auth-gated endpoints', () => {
test('POST /goto/room/:room 401s without a token', async () => { test('POST /matchmake/room/:roomId reuses a public instance across players; a private one is fresh', async () => {
const res = await exports.default.fetch(`${ORIGIN}/goto/room/dormroom`, { method: 'POST' })
expect(res.status).toBe(401)
})
test('POST /goto/room/dormroom creates and returns the players personal dorm', async () => {
const res = await exports.default.fetch(`${ORIGIN}/goto/room/dormroom`, {
method: 'POST',
headers: await bearer('42'),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
errorCode: number
roomInstance: {
name: string
location: string
isPrivate: boolean
roomId: number
photonRoomId: string
}
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({
// Named after the owner: `@<username>'s Dorm` (no `^` — the `@` is its prefix).
name: "@Tester's Dorm",
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
isPrivate: true,
})
// The dorm gets its own unique Photon room id (isolated from other dorms).
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
// A personal dorm room was created (not the seeded template RoomId 1)…
const roomId = body.roomInstance.roomId
expect(roomId).toBeGreaterThan(2)
// …owned by the player and flagged IsDorm so they can save it.
const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
.bind(roomId)
.first<{ data: string }>()
expect(JSON.parse(row!.data)).toMatchObject({ CreatorAccountId: 42, IsDorm: true })
})
test('POST /goto/room/:id resolves a real room scene from D1', async () => {
const res = await exports.default.fetch(`${ORIGIN}/goto/room/2`, {
method: 'POST',
headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ JoinMode: '2' }).toString(),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
roomInstance: {
roomId: number
roomInstanceId: number
isPrivate: boolean
name: string
location: string
photonRoomId: string
}
}
expect(body.roomInstance).toMatchObject({
roomId: 2,
name: '^RecCenter',
location: RECCENTER_SCENE,
isPrivate: true,
})
// The instance id is the room_instance table id (high-based, so it never
// collides with the dorm's fixed instance id of 1).
expect(body.roomInstance.roomInstanceId).toBeGreaterThan(1)
// Every non-dorm instance gets a fresh random Photon room id (a bare UUID).
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
})
test('POST /matchmake/:room reuses a public instance across players; a private one is fresh', async () => {
const matchmake = async (sub: string, joinMode?: string) => const matchmake = async (sub: string, joinMode?: string) =>
(await ( (await (
await exports.default.fetch(`${ORIGIN}/matchmake/2`, { await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST', method: 'POST',
headers: { headers: {
...(await bearer(sub)), ...(await bearer(sub)),
@@ -590,7 +465,7 @@ describe('auth-gated endpoints', () => {
expect(second).not.toBe(first) expect(second).not.toBe(first)
}) })
test('POST /matchmake/:room 401s without a token', async () => { test('POST /matchmake/dorm 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST' }) const res = await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST' })
expect(res.status).toBe(401) expect(res.status).toBe(401)
}) })
@@ -633,8 +508,8 @@ describe('auth-gated endpoints', () => {
}) })
}) })
test('POST /matchmake/:room resolves a room by name from D1', async () => { test('POST /matchmake/room/:roomId resolves a room by name from D1', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/RecCenter`, { const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/RecCenter`, {
method: 'POST', method: 'POST',
headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ JoinMode: '2' }).toString(), body: new URLSearchParams({ JoinMode: '2' }).toString(),
@@ -688,7 +563,7 @@ describe('auth-gated endpoints', () => {
expect(hb.roomInstance).toEqual(mm.roomInstance) expect(hb.roomInstance).toEqual(mm.roomInstance)
}) })
test('heartbeat merges posted status fields into stored presence', async () => { test('heartbeat ignores posted status fields stored presence is returned unchanged', async () => {
const headers = await bearer('8') const headers = await bearer('8')
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers }) await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
const hb = (await ( const hb = (await (
@@ -703,37 +578,86 @@ describe('auth-gated endpoints', () => {
appVersion: string appVersion: string
isOnline: boolean isOnline: boolean
} }
// Posted fields are NOT merged — the stored dorm presence (its defaults) is returned.
expect(hb).toMatchObject({ expect(hb).toMatchObject({
statusVisibility: 2, statusVisibility: 0,
platform: 5, platform: 0,
appVersion: '20210129', appVersion: GAME_VERSION,
isOnline: true, isOnline: true,
}) })
}) })
test('heartbeat pushes a PresenceHeartbeatResponse over the websocket', async () => { test('heartbeat pushes no websocket frame', async () => {
// The notify DO is stubbed to record every send (see vitest.config). // The notify DO is stubbed to record every send (see vitest.config).
type Sent = { playerId: number; notificationType: number; data: Record<string, unknown> } type Sent = { playerId: number; notificationType: number; data: Record<string, unknown> }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
const headers = await bearer('9600') const headers = await bearer('9600')
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers }) await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
// Clear whatever the matchmake fan-out recorded so we observe only the heartbeat.
await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { const res = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST', method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' }, headers,
body: JSON.stringify({ statusVisibility: 2 }),
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[] const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[]
// Exactly one frame: PresenceHeartbeatResponse (4) to the beating player, whose // The heartbeat no longer echoes itself back over the websocket.
// payload is the very presence the HTTP body carried. expect(sent).toHaveLength(0)
expect(sent).toHaveLength(1) })
expect(sent[0].playerId).toBe(9600)
expect(sent[0].notificationType).toBe(4) // NotificationType.PresenceHeartbeatResponse test('login records the LoginLock; a superseded heartbeat gets an empty body', async () => {
expect(sent[0].data).toEqual(body) const headers = await bearer('8100')
// Enter a room so there's live presence, then record this session's lock at login.
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
await exports.default.fetch(`${ORIGIN}/player/login`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'LoginLock=session-one',
})
// A heartbeat carrying the recorded lock gets the presence back.
const ok = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'LoginLock=session-one',
})
expect(ok.status).toBe(200)
expect(((await ok.json()) as { isOnline: boolean }).isOnline).toBe(true)
// A heartbeat from a superseded session (different lock) gets nothing.
const stale = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'LoginLock=session-two',
})
expect(stale.status).toBe(200)
expect(await stale.text()).toBe('')
})
test('login with no live presence seeds a lobby row carrying the lock', async () => {
const headers = await bearer('8200')
await exports.default.fetch(`${ORIGIN}/player/login`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'LoginLock=lobby-lock',
})
// Online in the lobby (no room), and a mismatched heartbeat is rejected on the lock
// recorded at login even though no matchmake ever ran.
const hb = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'LoginLock=lobby-lock',
})
expect(((await hb.json()) as { isOnline: boolean; roomInstance: unknown }).isOnline).toBe(true)
const stale = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'LoginLock=other',
})
expect(await stale.text()).toBe('')
}) })
// Seed presence directly into D1 with a chosen `expiresAt` (epoch seconds) so the // Seed presence directly into D1 with a chosen `expiresAt` (epoch seconds) so the
@@ -822,7 +746,7 @@ describe('auth-gated endpoints', () => {
// Matchmake into a room, returning the resulting instance id. // Matchmake into a room, returning the resulting instance id.
const matchmakeInto = async (room: string, sub: string): Promise<number> => { const matchmakeInto = async (room: string, sub: string): Promise<number> => {
const res = (await ( const res = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/${room}`, { await exports.default.fetch(`${ORIGIN}/matchmake/room/${room}`, {
method: 'POST', method: 'POST',
headers: await bearer(sub), headers: await bearer(sub),
}) })
@@ -1296,9 +1220,12 @@ describe('auth-gated endpoints', () => {
RoomId: 2, RoomId: 2,
}) })
// Multiple ids (comma-separated), de-duplicated, and the leader themselves is skipped. // Multiple ids (repeated fields, not comma-separated), de-duplicated, and the leader
// themselves is skipped.
await reset() await reset()
await matchmake('AdditionalPlayerIds=153,154,153,9850&JoinMode=0') await matchmake(
'AdditionalPlayerIds=153&AdditionalPlayerIds=154&AdditionalPlayerIds=153&AdditionalPlayerIds=9850&JoinMode=0'
)
const many = await sent() const many = await sent()
expect(many.map((i) => i.playerId).sort((a, b) => a - b)).toEqual([153, 154]) expect(many.map((i) => i.playerId).sort((a, b) => a - b)).toEqual([153, 154])
@@ -1333,15 +1260,12 @@ describe('auth-gated endpoints', () => {
'GET /room/{roomId}/instances', 'GET /room/{roomId}/instances',
'GET /rooms/requiring/developer', 'GET /rooms/requiring/developer',
'GET /rooms/requiring/rrplus', 'GET /rooms/requiring/rrplus',
'POST /goto/none',
'POST /goto/room/{room}',
'POST /invite', 'POST /invite',
'POST /matchmake/club/{clubId}', 'POST /matchmake/club/{clubId}',
'POST /matchmake/none', 'POST /matchmake/dorm',
'POST /matchmake/player/{playerId}', 'POST /matchmake/player/{playerId}',
'POST /matchmake/room/{roomId}', 'POST /matchmake/room/{roomId}',
'POST /matchmake/room/{roomId}/{subRoomId}', 'POST /matchmake/room/{roomId}/{subRoomId}',
'POST /matchmake/{room}',
'POST /player/exclusivelogin', 'POST /player/exclusivelogin',
'POST /player/heartbeat', 'POST /player/heartbeat',
'POST /player/login', 'POST /player/login',
+7
View File
@@ -59,6 +59,13 @@ export interface PresenceInput<TRoomInstance = unknown> {
vrMovementMode: number vrMovementMode: number
platform: number platform: number
appVersion: string appVersion: string
/**
* The session's `LoginLock` GUID, bound from the form body at matchmake time. The
* heartbeat posts it back purely to verify it still owns the session — a heartbeat
* carrying a different lock is a superseded session and is rejected. Absent until a
* matchmake supplies one.
*/
loginLock?: string
} }
/** A stored presence row — the input plus its absolute expiry (epoch seconds). */ /** A stored presence row — the input plus its absolute expiry (epoch seconds). */