From 1e26082f2e63934b3037ce5cd8bf52ab6177ae49 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 1 Sep 2026 01:17:37 -0400 Subject: [PATCH] [match] the other v2 invite endpoint --- apps/match/src/match.app.ts | 124 ++++++++++++++++++++ apps/match/src/openapi.ts | 9 +- apps/match/src/test/integration/api.test.ts | 122 +++++++++++++++++++ packages/domain/src/room-invites-db.ts | 32 +++++ 4 files changed, 283 insertions(+), 4 deletions(-) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 0991fa4..535fa92 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -18,6 +18,7 @@ import { getExpiredPresenceInstanceIds, getFriendIds, getJoinableInstance, + getLatestRoomInviteBetween, getMostActiveClubhouses, getOrCreateDormRoom, getPresence, @@ -2113,6 +2114,129 @@ const app = new Hono() } ) + // The newer client's join-by-player (`/matchmake/v2/player/{playerId}`). Same move as + // the v1 follow above — land in the instance the target is standing in — but gated on + // the `room_invite` table rather than friendship: the caller must hold a standing + // invite FROM the target (the newer client's invite frame doesn't always carry a + // redeemable `InviteId` — the party fan-out sends 0 — so it redeems by player instead + // of by row id, and this is that path). Everything the target sent stays checkable: + // the newest row is enough, since any live row is authorization. + // + // Like the follow and invite paths, this hands out real Photon coordinates without + // going through resolveRoomInstance, so it carries its own ban and build checks. + // `/matchmake/v2/` answers the PascalCase envelope via `matchmakeResult`, as the v2 + // room routes do. + .post( + '/matchmake/v2/player/:playerId{[0-9]+}', + describeRoute({ + tags: ['Navigation', '2025'], + summary: 'Join the player who invited you (v2)', + description: [ + 'Places the caller into the room instance the target player is currently in, read from', + 'the target’s stored presence. INVITEES ONLY: the caller must hold a `room_invite` row', + 'FROM the target (as `POST /invite` writes them) — the newer client redeems an invite by', + 'its sender when the frame carries no usable `RoomInviteId`. Answers 40', + '(RoomInviteExpired) when no invite stands (expiry deletes rows, so “never invited” and', + '“expired” are one answer), 2 (PlayerNotOnline) when the target isn’t in a room, 17', + '(AlreadyInTargetInstance) when the caller is already standing there, 3', + '(InsufficientSpace) when it filled up, and 55 (BannedFromRoom) when the caller is', + 'banned from that room.', + '', + '2025-client route: it answers the PascalCase `ErrorCode`/`RoomInstance` envelope, as', + 'the other `/matchmake/v2/*` routes do.', + ].join(' '), + security: AUTHED, + requestBody: form(CorrelationIdRequest, 'The attempt’s CorrelationId'), + parameters: [ + { + name: 'playerId', + in: 'path', + required: true, + description: 'The player to join (digits only)', + schema: { type: 'string', pattern: '^[0-9]+$' }, + }, + ], + responses: { + 200: json( + MatchmakeV2Response, + 'The target’s instance, or a null RoomInstance with the refusal code' + ), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const targetId = Number.parseInt(c.req.param('playerId'), 10) + // The gate: a standing invite from the target to the caller. No row means never + // invited or already swept — the same answer either way, since expiry deletes + // rows. This also refuses joining yourself: nobody holds a self-invite. + const invite = await getLatestRoomInviteBetween(c.env.DB, targetId, id) + if (invite === null) { + logger.info('v2 player matchmake refused: no invite from target', { targetId, id }) + return matchmakeResult(c, MatchmakingErrorCode.RoomInviteExpired, null) + } + + // Where the inviter is NOW, straight off their presence row — not the invite's + // stored RoomId, which records where they were when they sent it. + const targetPresence = await getPresence(c.env.DB, targetId) + const instance = targetPresence?.roomInstance ?? null + if (!instance) { + logger.info('v2 player matchmake refused: target is not in a room', { targetId, id }) + return matchmakeResult(c, MatchmakingErrorCode.PlayerNotOnline, null) + } + + // Already standing there: nothing to do, and re-entering would churn presence and + // re-fire the friend fan-out for a move that didn't happen. + const own = await getPresence(c.env.DB, id) + if (own?.roomInstance?.roomInstanceId === instance.roomInstanceId) { + return matchmakeResult(c, MatchmakingErrorCode.AlreadyInTargetInstance, null) + } + + // Real Photon coordinates without resolveRoomInstance, so the room's bans have to + // be checked here — otherwise an invite is a way around one. + if (await isPlayerBannedFromRoom(c.env.DB, instance.roomId, id)) { + logger.info('v2 player matchmake refused: player banned from room', { + roomId: instance.roomId, + id, + }) + return matchmakeResult(c, BANNED_FROM_ROOM, null) + } + + // Nor the build scoping a room matchmake has by construction. Compared against the + // TARGET's presence — they're the person actually standing in there. + const refusal = crossBuildRefusal( + await callerGameVersion(c), + targetPresence?.appVersion ?? GAME_VERSION + ) + if (refusal !== null) { + logger.info('v2 player matchmake refused: target is on another client build', { + roomInstanceId: instance.roomInstanceId, + targetId, + id, + }) + return matchmakeResult(c, refusal, null) + } + + // Fullness read fresh, like the invite path: joins off an invite cluster exactly + // when a nearly-full instance is still filling. Null means a synthetic instance + // with no row (a dorm), which has no head-count to check. + if ((await refreshInstanceFullness(c.env.DB, instance.roomInstanceId)) === true) { + logger.info('v2 player matchmake refused: instance is full', { + roomInstanceId: instance.roomInstanceId, + id, + }) + return matchmakeResult(c, MatchmakingErrorCode.InsufficientSpace, null) + } + + // Same instance, same Photon room, stored as the caller's presence so their + // heartbeat replays it and their own friend fan-out fires. + await enterRoom(c, id, instance) + return matchmakeResult(c, MatchmakingErrorCode.Success, instance) + } + ) + // Accept a game invite and land in the inviter's instance // (`/matchmake/invite/{roomInviteId}`). The 2025 client's join button on an invite: it // carries the `RoomInviteId` minted by `POST /invite`, and this resolves that row to the diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index ceb7c38..2885583 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -278,10 +278,11 @@ export const NotifyDisconnectRequest = z.object({ * matchmakes that post no body at all. * * This is the whole body of the target-less matchmakes (`/matchmake/dorm`, - * `/matchmake/none`, `/matchmake/player/:id`, `/matchmake/instance/:id`), which is why - * it's a schema of its own; the room matchmakes extend it. Other fields the client sends - * (`LoginLock`, `MaxPersistenceVersion`, `VoiceServerVersion`, - * `BypassMovementModeRestriction`) are accepted and ignored. + * `/matchmake/none`, `/matchmake/player/:id`, `/matchmake/v2/player/:id`, + * `/matchmake/instance/:id`), which is why it's a schema of its own; the room matchmakes + * extend it. Other fields the client sends (`LoginLock`, `MaxPersistenceVersion`, + * `VoiceServerVersion`, `BypassMovementModeRestriction`, `PlayerIsPartyMember`) are + * accepted and ignored. */ export const CorrelationIdRequest = z.object({ CorrelationId: z.string().optional().describe('Per-attempt GUID; echoed on the response'), diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 5fd6e8f..191bb5a 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -2958,6 +2958,127 @@ describe('auth-gated endpoints', () => { ).toBe(401) }) + test('POST /matchmake/v2/player/:id joins the inviter, invite row required', async () => { + // 8811 stands in an instance and invites 8812 (writing the room_invite row). + const instance = await createRoomInstance(env.DB, { + ownerAccountId: 8811, + roomId: 2, + subRoomId: 2, + photonRoomId: crypto.randomUUID(), + name: '^RecCenter', + maxCapacity: 12, + }) + await setPresence(env.DB, { + accountId: 8811, + roomInstance: instance, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 1, + platform: 0, + appVersion: GAME_VERSION, + }) + + const join = async (targetId: number, sub: string) => + exports.default.fetch(`${ORIGIN}/matchmake/v2/player/${targetId}`, { + method: 'POST', + headers: { + ...(await bearer(sub)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'BypassMovementModeRestriction=False&LoginLock=40bacd8f-7c60-4d49-93f9-462b096602de&VoiceServerVersion=gameserver-2&CorrelationId=82c12c19-a3fc-4734-9abc-e912aeb1f351&MaxPersistenceVersion=227&PlayerIsPartyMember=False', + }) + + // No invite from the target yet → 40, and nothing about their state leaks. + expect(await (await join(8811, '8812')).json()).toMatchObject({ + ErrorCode: 40, + RoomInstance: null, + }) + + await exports.default.fetch(`${ORIGIN}/invite`, { + method: 'POST', + headers: { + ...(await bearer('8811')), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `playerId=8812&roomInstanceId=${instance.roomInstanceId}`, + }) + + // An invite from a DIFFERENT player doesn't authorize this target: 8813 holds no + // invite from 8811. + expect(await (await join(8811, '8813')).json()).toMatchObject({ + ErrorCode: 40, + RoomInstance: null, + }) + + // The invitee lands in the inviter's instance, in the PascalCase v2 envelope with + // the CorrelationId echoed back. + const ok = await join(8811, '8812') + expect(ok.status).toBe(200) + const okBody = (await ok.json()) as Record + expect(okBody).toMatchObject({ + ErrorCode: 0, + CorrelationId: '82c12c19-a3fc-4734-9abc-e912aeb1f351', + RoomInstance: { + RoomInstanceId: instance.roomInstanceId, + RoomId: 2, + Name: '^RecCenter', + MatchmakingPolicy: 0, + }, + }) + // The exact wire shape, confirmed against the live client: the three-key envelope + // and the 15-key v2 instance, nothing extra (no Photon coordinates, no DataBlob). + expect(Object.keys(okBody).sort()).toEqual(['CorrelationId', 'ErrorCode', 'RoomInstance']) + expect(Object.keys(okBody.RoomInstance as object).sort()).toEqual( + [ + 'RoomInstanceId', + 'RoomId', + 'SubRoomId', + 'Location', + 'EventId', + 'ClubId', + 'RoomCode', + 'Name', + 'MaxCapacity', + 'IsFull', + 'IsPrivate', + 'IsInProgress', + 'EncryptVoiceChat', + 'RoomInstanceType', + 'MatchmakingPolicy', + ].sort() + ) + + // Standing there already is 17, not a second join. + expect(await (await join(8811, '8812')).json()).toMatchObject({ + ErrorCode: 17, + RoomInstance: null, + }) + + // The inviter walking out leaves nothing to join: 2, PlayerNotOnline. (The invitee + // is moved out first so the AlreadyIn check doesn't answer ahead of it.) + await setPresence(env.DB, { + accountId: 8812, + roomInstance: null, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 1, + platform: 0, + appVersion: GAME_VERSION, + }) + await env.DB.prepare('DELETE FROM presence WHERE account_id = 8811').run() + expect(await (await join(8811, '8812')).json()).toMatchObject({ + ErrorCode: 2, + RoomInstance: null, + }) + + // Unauthenticated is a 401, not a refusal code. + expect( + ( + await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/8811`, { method: 'POST' }) + ).status + ).toBe(401) + }) + test('GET /openapi.json documents every route', async () => { const res = await exports.default.fetch(`${ORIGIN}/openapi.json`) expect(res.status).toBe(200) @@ -2998,6 +3119,7 @@ describe('auth-gated endpoints', () => { 'POST /matchmake/player/{playerId}', 'POST /matchmake/room/{roomId}', 'POST /matchmake/room/{roomId}/{subRoomId}', + 'POST /matchmake/v2/player/{playerId}', 'POST /matchmake/v2/room/{roomId}', 'POST /matchmake/v2/room/{roomId}/{subRoomId}', 'POST /player/exclusivelogin', diff --git a/packages/domain/src/room-invites-db.ts b/packages/domain/src/room-invites-db.ts index ba35e84..b24e6f7 100644 --- a/packages/domain/src/room-invites-db.ts +++ b/packages/domain/src/room-invites-db.ts @@ -85,6 +85,38 @@ export async function getRoomInvite( } } +/** + * The newest live invite from `fromPlayerId` to `toPlayerId`, or null when none stands. + * + * This is the by-PLAYER-pair lookup behind `POST /matchmake/v2/player/:playerId`, where + * the caller redeems "an invite from that player" without holding a `RoomInviteId` (the + * newer client's invite frame doesn't always carry a usable one). Newest by id — ids are + * AUTOINCREMENT, so the largest is the most recently sent — and, like + * {@link getRoomInvite}, a miss covers both "never invited" and "already swept". + */ +export async function getLatestRoomInviteBetween( + db: D1Database, + fromPlayerId: number, + toPlayerId: number +): Promise { + const row = await db + .prepare( + `SELECT ${SELECT_COLUMNS} FROM room_invite + WHERE from_player_id = ?1 AND to_player_id = ?2 + ORDER BY room_invite_id DESC LIMIT 1` + ) + .bind(fromPlayerId, toPlayerId) + .first() + + if (!row) return null + return { + RoomInviteId: row.room_invite_id, + FromPlayerId: row.from_player_id, + ToPlayerId: row.to_player_id, + RoomId: row.room_id, + } +} + /** * Record an invite from `fromPlayerId` to `toPlayerId` for a room, returning it as the * client reads it back. `roomId` is null when the caller's room instance didn't resolve.