From 5ed9e765a5e86bb786f32eae7b5a8f4db1c58147 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 24 Jul 2026 14:19:08 -0400 Subject: [PATCH] clean up match code, remove old endpoints --- apps/api/src/http.ts | 7 +- apps/api/src/routes/progression.ts | 2 +- apps/api/src/test/integration/api.test.ts | 2 +- apps/match/src/match.app.ts | 405 +++++++------------- apps/match/src/openapi.ts | 38 +- apps/match/src/test/integration/api.test.ts | 228 ++++------- packages/domain/src/presence-db.ts | 7 + 7 files changed, 239 insertions(+), 450 deletions(-) diff --git a/apps/api/src/http.ts b/apps/api/src/http.ts index 693a766..f548fae 100644 --- a/apps/api/src/http.ts +++ b/apps/api/src/http.ts @@ -28,14 +28,13 @@ export async function parseFormIds(c: Context): Promise { .filter((n) => !Number.isNaN(n)) } -/** Read integer ids from repeated/comma-separated `id` query params. The 2023 - * client passes these to the bulk GET endpoints (e.g. `?id=1&id=2`). */ +/** Read integer ids from repeated `id` query params. The 2023 client passes these to + * the bulk GET endpoints as one value per id (`?id=1&id=2`), never comma-separated. */ export function queryIds(c: Context): number[] { return ( c.req .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)) ?? [] ) } diff --git a/apps/api/src/routes/progression.ts b/apps/api/src/routes/progression.ts index 25c9bc6..9124a49 100644 --- a/apps/api/src/routes/progression.ts +++ b/apps/api/src/routes/progression.ts @@ -43,7 +43,7 @@ function defaultReputation(id: number) { * may itself be a comma-separated list, so `?id=1,2&id=3` is three ids. */ 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. */ diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index ce2adc3..b541a7b 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -258,7 +258,7 @@ describe('public endpoints', () => { }), }) 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 () => { diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 839bc10..0070cb8 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -40,14 +40,14 @@ import { EMPTY_OK, ExclusiveLoginResponse, form, - HeartbeatRequest as HeartbeatRequestSchema, InProgressRequest, InviteRequest, JoinModeRequest, json, - jsonBody, + LoginLockRequest, MatchmakeResponse, MatchmakeRoomRequest, + NotifyDisconnectRequest, PlayerDto, RoomInstanceDto, 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 * auth-header check. Returns `null` when the header is missing, @@ -133,7 +123,7 @@ function unauthorized(c: Context) { } /** A synthesized room instance (same shape for dorm and other rooms). */ -type RoomInstance = ReturnType +type RoomInstance = ReturnType /** * 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 * 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 - * 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. */ function redactInstanceForPresence(instance: RoomInstance) { @@ -260,6 +250,9 @@ async function enterRoom(c: Context, id: number, roomInstance: RoomInstance vrMovementMode: prev?.vrMovementMode ?? 1, platform: prev?.platform ?? account?.platform ?? 0, 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 // player's own presence, just written, is counted). Then re-evaluate the @@ -277,15 +270,6 @@ async function enterRoom(c: Context, id: number, roomInstance: RoomInstance 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. */ const NO_SUCH_ROOM = 20 @@ -352,34 +336,6 @@ async function sendGameInvite( */ 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, …). * The `location` is the SubRoom's real `UnitySceneId` — an empty/unknown location @@ -426,7 +382,7 @@ function roomInstanceFromRoom( instanceId: number, photonRoomId: string, subRoomId?: number -): RoomInstance { +) { const f = instanceFieldsFromRoom(room, subRoomId) return { 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): Promise { + const body = await c.req.parseBody().catch(() => ({}) as Record) + return typeof body.LoginLock === 'string' && body.LoginLock ? body.LoginLock : undefined +} + /** Read the `JoinMode` form field (2 = private instance). */ async function readJoinMode(c: Context): Promise { const body = await c.req.parseBody().catch(() => ({}) as Record) @@ -460,9 +422,9 @@ async function readJoinMode(c: Context): Promise { * 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 * 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 - * defensively, de-duplicated, and non-positive/garbage entries dropped. Parsed with - * `{ all: true }` in one pass so repeated fields survive. + * `AdditionalPlayerIds` is a repeated field (one id each, never comma-separated), and + * ids are parsed defensively, de-duplicated, and non-positive/garbage entries dropped. + * Parsed with `{ all: true }` in one pass so the repeated fields survive. */ async function readMatchmakeBody( c: Context @@ -482,7 +444,6 @@ async function readMatchmakeBody( ...new Set( values .filter((v): v is string => typeof v === 'string') - .flatMap((v) => v.split(',')) .map((s) => Number.parseInt(s.trim(), 10)) .filter((n) => !Number.isNaN(n) && n > 0) ), @@ -613,29 +574,62 @@ const app = new Hono() .notFound(withNotFound()) // ---- Player presence ----------------------------------------------------- - // login/exclusivelogin are no-op acks and MUST NOT touch presence: the client - // fires exclusivelogin when going online, and clearing presence there would bounce - // the player to the dorm. Presence is overwritten by matchmake/goto and expires on - // its own TTL. + // login records the session's `LoginLock` in presence so the heartbeat can verify + // each beat belongs to this login; it must otherwise leave presence intact (clearing + // the room instance here would bounce the player to the dorm). Presence is overwritten + // by matchmake — which carries the lock forward — and expires on its own TTL. .post( '/player/login', describeRoute({ tags: ['Presence'], - summary: 'Login ack (no-op)', + summary: 'Record the session login lock', description: [ - 'A no-op ack. Must NOT touch presence — the client fires this going online, and', - 'clearing presence here would bounce the player to the dorm.', + 'Records the posted `LoginLock` in the player’s presence so later heartbeats can', + '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(' '), + requestBody: form(LoginLockRequest, 'The session LoginLock GUID'), 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(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( '/player/exclusivelogin', describeRoute({ tags: ['Presence'], summary: 'Exclusive-login ack (no-op)', - description: 'A no-op ack returning a zero error code. Like login, must not touch presence.', + 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') }, }), (c) => c.json({ errorCode: 0 }) @@ -649,7 +643,7 @@ const app = new Hono() // 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 // 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( '/player/logout', describeRoute({ @@ -657,10 +651,12 @@ const app = new Hono() summary: 'Clear presence on logout', description: [ 'Clears the player’s 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', 'bootstrap isn’t wiped. An unauthenticated logout is also a no-op.', ].join(' '), + requestBody: form(LoginLockRequest, 'The session LoginLock GUID'), responses: { 200: EMPTY_OK }, }), async (c) => { @@ -683,21 +679,35 @@ const app = new Hono() } ) - // Fire-and-forget disconnect notification (form body `PlayerId`/`RoomInstanceId`). - // The client posts this when it drops a room; we don't act on it — presence is - // cleared by logout and otherwise expires on its own TTL — so just ack with 200. + // Photon disconnect notification (form body `PlayerId`/`RoomInstanceId`) — posted when + // Photon sees a player drop a room instance. We don't act on it yet (presence is cleared + // 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( '/player/notifydisconnect', describeRoute({ tags: ['Presence'], - summary: 'Disconnect notification (no-op ack)', + summary: 'Photon disconnect notification', description: [ - 'Posted when the client drops a room. Not acted on — presence is cleared by logout', - 'and otherwise expires on its TTL.', + 'Posted by Photon when it sees a player drop a room instance (form body', + '`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(' '), + requestBody: form(NotifyDisconnectRequest, 'The disconnecting player and the instance they left'), responses: { 200: EMPTY_OK }, }), - (c) => c.body(null, 200) + async (c) => { + const body = await c.req.parseBody().catch(() => ({}) as Record) + 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( @@ -706,27 +716,27 @@ const app = new Hono() tags: ['Presence'], summary: 'Batch player presence lookup', description: [ - 'Returns each requested player’s presence. `id` is repeatable and each value may', - 'be a comma-separated list. With no ids, serves a single default (online) player.', + 'Returns each requested player’s presence. `id` is a repeated query param', + '(`?id=2&id=155&id=153`) — one value each, not comma-separated. With no ids, serves', + 'a single default (online) player.', ].join(' '), parameters: [ { name: 'id', in: 'query', 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' } }, }, ], responses: { 200: json(PlayerDto.array(), 'One entry per requested player') }, }), async (c) => { - // Returns each requested player's presence. Reads the `id` query param(s); - // with none it serves the static getplayer.json default. + // Returns each requested player's presence. Reads the repeated `id` query + // param(s); with none it serves the static getplayer.json default. const ids = c.req .queries('id') - ?.flatMap((v) => v.split(',')) - .map((s) => Number.parseInt(s.trim(), 10)) + ?.map((s) => Number.parseInt(s.trim(), 10)) .filter((n) => !Number.isNaN(n)) if (!ids || ids.length === 0) return c.json(DEFAULT_GET_PLAYER) @@ -743,16 +753,15 @@ const app = new Hono() tags: ['Presence'], summary: 'Presence heartbeat', description: [ - 'Merges the posted status fields into stored presence and echoes back the player', - 'payload. Re-writes the row (refreshing its TTL) only when something changed or the', - 'TTL is close to lapsing, so a still player isn’t written on every beat. With no', - 'stored presence the player isn’t in a room yet (roomInstance null, isOnline false).', + 'Returns the player’s current presence payload without mutating any stored fields —', + 'the only side effect is refreshing the row’s TTL, and even that only when the TTL', + 'is close to lapsing so a still player isn’t written on every beat. The posted', + '`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 isn’t in a room yet (roomInstance null, isOnline false).', ].join(' '), security: AUTHED, - requestBody: jsonBody( - HeartbeatRequestSchema, - 'JSON status fields. A non-JSON (LoginLock) body is accepted and ignored.' - ), + requestBody: form(LoginLockRequest, 'The session LoginLock GUID (verified, not stored)'), responses: { 200: json(PlayerDto, 'The player’s current presence payload'), 401: UNAUTHORIZED_RESPONSE, @@ -762,80 +771,38 @@ const app = new Hono() const id = await authedId(c) if (id === null) return unauthorized(c) - // Body may be a JSON HeartbeatRequest or a form post (LoginLock); only JSON - // carries presence/status fields. - const raw = await c.req.text().catch(() => '') - let hb: HeartbeatRequest = {} - if (raw.trimStart().startsWith('{')) { - try { - hb = JSON.parse(raw) as HeartbeatRequest - } catch { - hb = {} - } - } + // The body is either a JSON status blob (no longer read — presence is returned + // verbatim) or a form carrying the session `LoginLock`. We only read the + // LoginLock, to verify this beat still owns the session; a JSON body simply + // yields no lock (parseBody fails and is swallowed). + const postedLock = await readLoginLock(c) - // 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 - // isn't in a room yet, so roomInstance=null / isOnline=false. Posted status - // 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. + // isn't in a room yet, so roomInstance=null / isOnline=false. const presence = await getPresence(c.env.DB, id) if (presence) { - // Merge the posted status fields, tracking whether any actually changed. - let changed = false - const apply = (key: K, value: Presence[K]) => { - if (presence[key] !== value) { - presence[key] = value - changed = true - } + // A heartbeat whose LoginLock disagrees with the one recorded at login belongs + // to a superseded session — return nothing so that stale client stops acting as + // the live one. (No posted lock, or none recorded yet, skips the check.) + if ( + postedLock !== undefined && + 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 - // unchanged heartbeat until the TTL is within PRESENCE_REFRESH_THRESHOLD - // (s) of lapsing — a still player is refreshed periodically rather than on - // every beat. `expiresAt` is epoch seconds (set by setPresence). + // The heartbeat's only side effect is refreshing the TTL, and only once it's + // within PRESENCE_REFRESH_THRESHOLD (s) of lapsing — a still player is refreshed + // periodically rather than re-written on every beat. `expiresAt` is epoch seconds. const nowSeconds = Math.floor(Date.now() / 1000) - const dueForRefresh = presence.expiresAt - nowSeconds <= PRESENCE_REFRESH_THRESHOLD - if (changed || dueForRefresh) { + if (presence.expiresAt - nowSeconds <= PRESENCE_REFRESH_THRESHOLD) { await setPresence(c.env.DB, presence) } } - // The heartbeat echoes the same player payload `/player` serves; with no stored - // presence it falls back to what the client just posted. - 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) + return c.json(playerPayload(id, presence)) } ) @@ -870,85 +837,9 @@ const app = new Hono() ) // ---- Room navigation ----------------------------------------------------- - // Each matchmake/goto persists the resulting instance as the player's presence - // so the 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 player’s 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 player’s current instance if they have one (so Orientation isn’t 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(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 }) - } - ) + // Each matchmake persists the resulting instance as the player's presence so the + // heartbeat can replay it (keeping client presence in sync). + // // 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. // Members only: the clubhouse is the club's private space, so a non-member (or @@ -1152,28 +1043,18 @@ const app = new Hono() } ) .post( - '/matchmake/:room', + '/matchmake/dorm', describeRoute({ tags: ['Navigation'], - summary: 'Matchmake into a room by id or name', + summary: 'Matchmake into the player’s dorm', description: [ - 'Single-segment matchmake. `dorm` → the player’s personal dorm; otherwise resolves', - 'the room by id or name. (Note: this dorm keyword is `dorm`, while goto/room uses', - '`dormroom`.)', + 'Single-segment matchmake into the caller’s personal dorm, stored as presence. The', + 'client only ever calls this with the `dorm` keyword — real rooms go through', + '`/matchmake/room/:roomId`.', ].join(' '), security: AUTHED, - requestBody: form(JoinModeRequest, 'Optional JoinMode'), - parameters: [ - { - name: 'room', - in: 'path', - required: true, - description: 'Room id, room name, or `dorm`', - schema: { type: 'string' }, - }, - ], responses: { - 200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'), + 200: json(MatchmakeResponse, 'The player’s personal dorm instance'), 401: UNAUTHORIZED_RESPONSE, }, }), @@ -1181,40 +1062,12 @@ const app = new Hono() const id = await authedId(c) if (id === null) return unauthorized(c) - const room = c.req.param('room') - 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 }) + const instance = await playerDormInstance(c, id) await enterRoom(c, id, 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 player’s 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()). .put( '/player/photonregionpings', diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index 643ff40..c0c51a5 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -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`. */ 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') }) /** - * `POST /player/heartbeat` JSON body. All fields optional — the client also posts a - * non-JSON (LoginLock form) body here, in which case none of these are read and stored - * presence is echoed back unchanged. + * The session `LoginLock` GUID form field. The client posts it on every presence + * lifecycle call — `POST /player/login`, `/player/exclusivelogin`, `/player/logout`, + * 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({ - playerId: z.int().optional(), - statusVisibility: z.int().optional(), - deviceClass: z.int().optional(), - vrMovementMode: z.int().optional(), - appVersion: z.string().nullable().optional(), - platform: z.int().optional(), +export const LoginLockRequest = z.object({ + LoginLock: z.string().describe('The session login-lock GUID (always sent)'), }) /** `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. */ export const JoinModeRequest = z.object({ @@ -170,16 +175,17 @@ export const JoinModeRequest = z.object({ /** * The room-matchmake form body (`/matchmake/room/:roomId[/:subRoomId]`). Beyond * `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 - * and/or be comma-separated. Other fields the client sends (`LoginLock`, - * `MaxPersistenceVersion`, `BypassMovementModeRestriction`) are accepted and ignored. + * of them is invited (a game invite) into the instance the leader lands in. It's a + * repeated field (one id each, not comma-separated). Other fields the client sends + * (`LoginLock`, `MaxPersistenceVersion`, `BypassMovementModeRestriction`) are accepted + * and ignored. */ export const MatchmakeRoomRequest = z.object({ JoinMode: z.string().optional().describe('"2" requests a private instance'), AdditionalPlayerIds: z .string() .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. */ diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 92fad72..2fa0bbb 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -258,22 +258,6 @@ describe('public endpoints', () => { 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 () => { const headers = await bearer('88') 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 }) }) - 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 () => { const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' }) expect(res.status).toBe(200) @@ -483,80 +428,10 @@ describe('public endpoints', () => { }) describe('auth-gated endpoints', () => { - test('POST /goto/room/:room 401s without a token', 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 player’s 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: `@'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 () => { + test('POST /matchmake/room/:roomId reuses a public instance across players; a private one is fresh', async () => { const matchmake = async (sub: string, joinMode?: string) => (await ( - await exports.default.fetch(`${ORIGIN}/matchmake/2`, { + await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { method: 'POST', headers: { ...(await bearer(sub)), @@ -590,7 +465,7 @@ describe('auth-gated endpoints', () => { 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' }) 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 () => { - const res = await exports.default.fetch(`${ORIGIN}/matchmake/RecCenter`, { + test('POST /matchmake/room/:roomId resolves a room by name from D1', async () => { + const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/RecCenter`, { method: 'POST', headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ JoinMode: '2' }).toString(), @@ -688,7 +563,7 @@ describe('auth-gated endpoints', () => { 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') await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers }) const hb = (await ( @@ -703,37 +578,86 @@ describe('auth-gated endpoints', () => { appVersion: string isOnline: boolean } + // Posted fields are NOT merged — the stored dorm presence (its defaults) is returned. expect(hb).toMatchObject({ - statusVisibility: 2, - platform: 5, - appVersion: '20210129', + statusVisibility: 0, + platform: 0, + appVersion: GAME_VERSION, 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). type Sent = { playerId: number; notificationType: number; data: Record } const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') - await hub().fetch('http://do/all', { method: 'DELETE' }) const headers = await bearer('9600') 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`, { method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ statusVisibility: 2 }), + headers, }) expect(res.status).toBe(200) - const body = (await res.json()) as Record const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[] - // Exactly one frame: PresenceHeartbeatResponse (4) to the beating player, whose - // payload is the very presence the HTTP body carried. - expect(sent).toHaveLength(1) - expect(sent[0].playerId).toBe(9600) - expect(sent[0].notificationType).toBe(4) // NotificationType.PresenceHeartbeatResponse - expect(sent[0].data).toEqual(body) + // The heartbeat no longer echoes itself back over the websocket. + expect(sent).toHaveLength(0) + }) + + test('login records the LoginLock; a superseded heartbeat gets an empty body', async () => { + 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 @@ -822,7 +746,7 @@ describe('auth-gated endpoints', () => { // Matchmake into a room, returning the resulting instance id. const matchmakeInto = async (room: string, sub: string): Promise => { const res = (await ( - await exports.default.fetch(`${ORIGIN}/matchmake/${room}`, { + await exports.default.fetch(`${ORIGIN}/matchmake/room/${room}`, { method: 'POST', headers: await bearer(sub), }) @@ -1296,9 +1220,12 @@ describe('auth-gated endpoints', () => { 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 matchmake('AdditionalPlayerIds=153,154,153,9850&JoinMode=0') + await matchmake( + 'AdditionalPlayerIds=153&AdditionalPlayerIds=154&AdditionalPlayerIds=153&AdditionalPlayerIds=9850&JoinMode=0' + ) const many = await sent() 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 /rooms/requiring/developer', 'GET /rooms/requiring/rrplus', - 'POST /goto/none', - 'POST /goto/room/{room}', 'POST /invite', 'POST /matchmake/club/{clubId}', - 'POST /matchmake/none', + 'POST /matchmake/dorm', 'POST /matchmake/player/{playerId}', 'POST /matchmake/room/{roomId}', 'POST /matchmake/room/{roomId}/{subRoomId}', - 'POST /matchmake/{room}', 'POST /player/exclusivelogin', 'POST /player/heartbeat', 'POST /player/login', diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index a80b7a5..bb71e2c 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -59,6 +59,13 @@ export interface PresenceInput { vrMovementMode: number platform: number 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). */