diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index a63e1e0..fecfa96 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -25,8 +25,10 @@ import { getRoomByName, getRoomInstance, getRoomInstancesByRoom, + getRoomInvite, getRoomInstanceSummariesByRoom, getStoredRoomInstance, + InviteMode, isClubMember, isPlayerBannedFromRoom, MatchmakingErrorCode, @@ -616,11 +618,105 @@ function nextLiveMessageId(): number { return Date.now() } +/** + * The first build that gets a game invite as {@link MessageType.GameInviteV2} rather than + * the original {@link MessageType.GameInvite}. Inclusive — a client reporting exactly this + * build is on v2; only OLDER builds get v1. + */ +const GAME_INVITE_V2_MIN_BUILD = 20230414 + +/** + * The date portion of a client build (`20250718.01` → `20250718`) as a number, or null when + * there's nothing parseable to compare. Builds are `[.]`, so the leading date + * orders them; the point release after it never decides this gate. + */ +function buildNumber(version: string | null): number | null { + const date = Number.parseInt(version?.split('.')[0] ?? '', 10) + return Number.isNaN(date) ? null : date +} + +/** + * Which game-invite message type this caller's client gets, from the `rn.ver` claim on + * their token — v2 from {@link GAME_INVITE_V2_MIN_BUILD} onward, the original below it. + * + * A token that names no build (an older one issued before the claim carried it, or no + * token at all) falls back to v1: that is what every client got before this gate existed, + * so an unknown build is left where it was known to work rather than moved onto a message + * type it may not have. + * + * The claim is the INVITER's, since theirs is the only token in the request — while the + * message is parsed by the INVITEE. The two agree whenever a party is on one build, which + * is the normal case; the invitee's own build is on their presence row (`appVersion`) if + * this ever needs to key off the client that actually reads the frame. + */ +async function gameInviteType(c: Context): Promise { + const build = buildNumber(await callerVersion(c)) + return build !== null && build >= GAME_INVITE_V2_MIN_BUILD + ? MessageType.GameInviteV2 + : MessageType.GameInvite +} + +/** + * The {@link InviteMode} a v2 game invite carries — what the invite is ASKING FOR, as + * distinct from the instance it names. + * + * `PlayTogether` (22) is the plain "come join me", which is what both paths here send: + * `POST /invite` is exactly that, and the party fan-out is the same ask aimed at several + * people at once. `InviteParty` (2) is the other reading of a join-me, and the fan-out is + * where it would go if the two turn out to be distinguishable — they have not been told + * apart on the wire yet. + */ +const GAME_INVITE_V2_INVITE_MODE = InviteMode.PlayTogether + +/** + * The placeholder `InviteId` on a v2 invite that has no `room_invite` row behind it — the + * party fan-out, which invites without recording anything. What the client does with the + * field isn't known yet; `POST /invite` passes the real row id (see {@link RoomInvite}), + * which is the obvious candidate, and this stands in where there is no row to name. + */ +const UNKNOWN_INVITE_ID = 0 + +/** What an invite points at, in the shape both message versions need to describe it. */ +type GameInviteTarget = { + /** The raw roomInstanceId string — the WHOLE `Data` of a v1 invite. */ + instanceId: string + /** The instance's room, or null when it didn't resolve. Rides on the message itself. */ + roomId: number | null + /** The instance's `^`-prefixed wire name, `''` when the instance didn't resolve. */ + name: string + /** The `room_invite` row this came from, or {@link UNKNOWN_INVITE_ID}. */ + inviteId: number +} + +/** + * The `Data` a game invite carries, which is a different thing per message version: + * + * - v1 ({@link MessageType.GameInvite}) is the bare roomInstanceId as a string. + * - v2 ({@link MessageType.GameInviteV2}) is an escaped JSON object, + * `{"InviteId":…,"Name":"^GoldenTrophy","InviteMode":22}` — a STRING holding JSON, not a + * nested object, since `Data` is a string field on the Message either way. + * + * `Name` is the instance's wire name, the same `^`/`@`-prefixed string presence carries. + */ +function gameInviteData(type: MessageType, target: GameInviteTarget): string { + if (type !== MessageType.GameInviteV2) return target.instanceId + return JSON.stringify({ + InviteId: target.inviteId, + Name: target.name, + InviteMode: GAME_INVITE_V2_INVITE_MODE, + }) +} + /** * Deliver a game invite from `fromId` to `toId` for a room instance — a `MessageReceived` - * frame carrying a game-invite `Message` the client renders the join prompt from. `data` - * is the raw roomInstanceId string the message carries; `roomId` (nullable) tells the - * client which room it points at. Best-effort: a hub failure is logged and swallowed. + * frame carrying a game-invite `Message` the client renders the join prompt from. `target` + * describes the instance being invited into; what of it reaches the wire depends on the + * message version (see {@link gameInviteData}), except `RoomId`, which rides on the message + * itself in both. Best-effort: a hub failure is logged and swallowed. + * + * `type` is the caller's game-invite message type from {@link gameInviteType}, resolved by + * the call site rather than here so the party fan-out settles it once for the whole party + * instead of re-reading the same token per member. * * Shared by `POST /invite` (a single explicit invite) and the party fan-out on a room * matchmake (one per `AdditionalPlayerIds` entry), so the two can't drift. @@ -629,17 +725,17 @@ async function sendGameInvite( c: Context, fromId: number, toId: number, - data: string, - roomId: number | null + target: GameInviteTarget, + type: MessageType ): Promise { const message = { Id: nextLiveMessageId(), FromPlayerId: fromId, ToPlayerId: toId, - Type: MessageType.GameInvite, - Data: data, + Type: type, + Data: gameInviteData(type, target), SentTime: new Date().toISOString(), - RoomId: roomId, + RoomId: target.roomId, } try { await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( @@ -868,6 +964,16 @@ function toV2RoomInstance(instance: RoomInstance) { } } +/** + * The matchmake paths whose responses are the PascalCase envelope (`ErrorCode`, + * `CorrelationId`, `RoomInstance`) rather than the lowercase one with its `result` twin. + * + * `/matchmake/v2/` is the 2025 client's rewrite of the older routes. `/matchmake/invite/` + * has no older spelling — it only ever existed on the 2025 client, and so only ever spoke + * this shape. + */ +const PASCAL_CASE_MATCHMAKE_PATHS = ['/matchmake/v2/', '/matchmake/invite/'] + /** * A matchmake response: the join-result code, the instance (null on a refusal), and the * request's correlation id echoed back. Every matchmake route answers through here, so @@ -878,11 +984,13 @@ function toV2RoomInstance(instance: RoomInstance) { * every other consumer (and this worker's own tests) reads. They must never disagree — * that's why nothing builds this envelope by hand. * - * The `/matchmake/v2/*` routes answer a different envelope — PascalCase `ErrorCode`, - * `CorrelationId`, `RoomInstance`, no `result` twin — so the shape is chosen HERE, off - * the request path, rather than in the handlers. That keeps the two spellings from - * drifting and, more importantly, means everything that answers on a v2 path answers in - * v2: the ban gate, a refusal, and the success all go through this one function. + * Some routes answer a different envelope — PascalCase `ErrorCode`, `CorrelationId`, + * `RoomInstance`, no `result` twin — so the shape is chosen HERE, off the request path, + * rather than in the handlers. That keeps the two spellings from drifting and, more + * importantly, means everything answering on such a path answers in that shape: the ban + * gate, a refusal, and the success all go through this one function. + * + * See {@link PASCAL_CASE_MATCHMAKE_PATHS} for which paths those are. */ async function matchmakeResult( c: Context, @@ -890,7 +998,7 @@ async function matchmakeResult( roomInstance: RoomInstance | null ) { const correlationId = await readCorrelationId(c) - if (c.req.path.startsWith('/matchmake/v2/')) { + if (PASCAL_CASE_MATCHMAKE_PATHS.some((prefix) => c.req.path.startsWith(prefix))) { return c.json({ ErrorCode: errorCode, CorrelationId: correlationId, @@ -963,11 +1071,21 @@ async function inviteParty( playerIds: number[], instance: RoomInstance ): Promise { - const data = String(instance.roomInstanceId) + // The leader's own instance, which every member is being pulled into. Nothing records + // a `room_invite` row on this path, so a v2 invite has no real id to name. + const target: GameInviteTarget = { + instanceId: String(instance.roomInstanceId), + roomId: instance.roomId, + name: instance.name, + inviteId: UNKNOWN_INVITE_ID, + } + // One read of the leader's token for the whole party — every member gets the same + // message, so the type can't differ between them. + const type = await gameInviteType(c) await Promise.all( playerIds .filter((pid) => pid !== leaderId) - .map((pid) => sendGameInvite(c, leaderId, pid, data, instance.roomId)) + .map((pid) => sendGameInvite(c, leaderId, pid, target, type)) ) } @@ -1922,6 +2040,153 @@ const app = new Hono() } ) + // 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 + // instance the INVITER is standing in right now. + // + // The row is the authorization. Only the player the invite was addressed to + // (`ToPlayerId`) may redeem it, checked against the caller's own token — an invite id is + // a small integer handed out to somebody else, so an ungated version of this would let + // anyone walk into any private instance by counting upward. It is also single-target: + // the inviter can't redeem their own invite, since they aren't its `ToPlayerId`. + // + // Where the caller goes is read from the inviter's LIVE presence, not from the invite's + // `RoomId`: the row records which room was named when the invite was sent, while the + // invitee needs the instance the inviter is in when they actually click, which may be a + // different one (or none). Registered before `/matchmake/:room` so `invite` isn't read + // as a room name. + .post( + '/matchmake/invite/:inviteId{[0-9]+}', + describeRoute({ + tags: ['Navigation', '2025'], + summary: 'Accept a game invite', + description: [ + 'Places the caller into the room instance the INVITER is currently in, resolved from', + 'the `room_invite` row named by `roomInviteId` and the inviter’s live presence (not the', + 'invite’s stored `RoomId`, which records where they were when they sent it).', + '', + 'ADDRESSEE ONLY: the caller must be the invite’s `ToPlayerId`, or it answers 76', + '(InstanceJoinNotPermitted) — the id is a small integer held by another player, so an', + 'ungated form of this would be a way into any private instance. Answers 40', + '(RoomInviteExpired) for an invite that isn’t there any more, 2 (PlayerNotOnline) when', + 'the inviter isn’t in a room, 17 (AlreadyInTargetInstance) when the caller is already', + 'standing in it, 3 (InsufficientSpace) when it filled up, and 55 (BannedFromRoom) when', + 'the caller is banned from the room they’d be joining.', + '', + '2025-client route: it answers the PascalCase `ErrorCode`/`RoomInstance` envelope, as', + 'the `/matchmake/v2/*` routes do, and has no older lowercase spelling.', + ].join(' '), + security: AUTHED, + requestBody: form(CorrelationIdRequest, 'The attempt’s CorrelationId'), + parameters: [ + { + name: 'inviteId', + in: 'path', + required: true, + description: 'The `RoomInviteId` from `POST /invite` (digits only)', + schema: { type: 'string', pattern: '^[0-9]+$' }, + }, + ], + responses: { + 200: json( + MatchmakeV2Response, + 'The inviter’s instance, or a null instance with the refusal code' + ), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const inviteId = Number.parseInt(c.req.param('inviteId'), 10) + const invite = await getRoomInvite(c.env.DB, inviteId) + // No row means no longer good — expiry deletes rows rather than flagging them, and + // ids are never reused, so "never existed" and "expired" are the same answer here. + // (78 ChatPartyInviteNotFound is for a CHAT party invite, a different object.) + if (invite === null) { + logger.info('invite matchmake refused: no such invite', { inviteId, id }) + return matchmakeResult(c, MatchmakingErrorCode.RoomInviteExpired, null) + } + + // The gate: an invite is redeemable only by the player it was addressed to. Told + // as "not permitted" rather than "no such invite" — the caller is holding an id + // that is real, and pretending otherwise doesn't hide anything they don't have. + if (invite.ToPlayerId !== id) { + logger.info('invite matchmake refused: caller is not the invitee', { + inviteId, + toPlayerId: invite.ToPlayerId, + id, + }) + return matchmakeResult(c, MatchmakingErrorCode.InstanceJoinNotPermitted, null) + } + + // Where the inviter is NOW. An inviter who has left (or whose presence lapsed) has + // nothing to join, which is what PlayerNotOnline says. + const inviterPresence = await getPresence(c.env.DB, invite.FromPlayerId) + const instance = inviterPresence?.roomInstance ?? null + if (!instance) { + logger.info('invite matchmake refused: inviter is not in a room', { + inviteId, + fromPlayerId: invite.FromPlayerId, + 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) + } + + // Like the follow-a-friend path, this hands out real Photon coordinates without + // going through 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('invite matchmake refused: player banned from room', { + roomId: instance.roomId, + id, + }) + return matchmakeResult(c, BANNED_FROM_ROOM, null) + } + + // Nor does it get the build scoping a room matchmake has by construction. Compared + // against the INVITER's presence — they're the person actually standing in there. + const refusal = crossBuildRefusal( + await callerGameVersion(c), + inviterPresence?.appVersion ?? GAME_VERSION + ) + if (refusal !== null) { + logger.info('invite matchmake refused: inviter is on another client build', { + roomInstanceId: instance.roomInstanceId, + fromPlayerId: invite.FromPlayerId, + id, + }) + return matchmakeResult(c, refusal, null) + } + + // Fullness read fresh rather than off the stored flag, which is only as current as + // the last person to move: an invite is usually redeemed seconds after it's sent, + // which is 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('invite 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) + } + ) + // Join one SPECIFIC live instance by id (`/matchmake/instance/{roomInstanceId}`) — // the action behind the owner's instance listing (`GET /room/{roomId}/instances`), // where they pick a session of their room and drop into it. Unlike every other @@ -2399,11 +2664,13 @@ const app = new Hono() // Resolve the instance to stamp the invite's RoomId — the client reads it to know // which room the invite points at. A missing/unknown instance just leaves RoomId // null (buildNotificationPayload drops it from the frame), as the reference does. - let roomId: number | null = null - if (!Number.isNaN(roomInstanceId) && roomInstanceId > 0) { - const instance = await getRoomInstance(c.env.DB, roomInstanceId) - if (instance) roomId = instance.roomId - } + // The whole instance, not just its room: a v2 invite names it by `Name` too, and an + // unresolved one leaves that empty rather than guessing at a name. + const instance = + !Number.isNaN(roomInstanceId) && roomInstanceId > 0 + ? await getRoomInstance(c.env.DB, roomInstanceId) + : null + const roomId: number | null = instance?.roomId ?? null // Record the invite before delivering it: the row is what mints the `RoomInviteId` // the response carries, while the frame itself is fire-and-forget. @@ -2416,7 +2683,20 @@ const app = new Hono() return c.body(null, 500) } - await sendGameInvite(c, id, toPlayerId, roomInstanceIdStr, roomId) + // The `room_invite` row just written is what a v2 invite names as its `InviteId` — + // the only real invite id this server has. + await sendGameInvite( + c, + id, + toPlayerId, + { + instanceId: roomInstanceIdStr, + roomId, + name: instance?.name ?? '', + inviteId: invite.RoomInviteId, + }, + await gameInviteType(c) + ) return c.json(invite) } ) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 2c47c4e..8e13171 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -19,6 +19,7 @@ import { ROOM_INVITE_SCHEMA_DDL, ROOM_SCHEMA_DDL, seedRoomWithSubRooms, + setPresence, SUBROOM_SCHEMA_DDL, } from '@repo/domain' @@ -2426,6 +2427,79 @@ describe('auth-gated endpoints', () => { expect(after[0].data.Data).toBe('999999') }) + test('POST /invite picks the invite message type off the token’s build', async () => { + type Sent = { data: { Type: number; Data: string; RoomId: number | null } } + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + const sent = async (): Promise => + (await (await hub().fetch('http://do/all')).json()) as Sent[] + + const instance = await createRoomInstance(env.DB, { + ownerAccountId: 42, + roomId: 2, + subRoomId: 2, + photonRoomId: crypto.randomUUID(), + name: '^RecCenter', + maxCapacity: 12, + }) + + // The one frame an invite from a client on `version` pushes, with the RoomInviteId + // the call answered — a v2 invite names it in its Data. + const inviteFrom = async ( + version?: string + ): Promise<{ frame: Sent; roomInviteId: number }> => { + await hub().fetch('http://do/all', { method: 'DELETE' }) + const res = await exports.default.fetch(`${ORIGIN}/invite`, { + method: 'POST', + headers: { + ...(await bearer('42', version)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `playerId=153&roomInstanceId=${instance.roomInstanceId}`, + }) + expect(res.status).toBe(200) + const { RoomInviteId } = (await res.json()) as { RoomInviteId: number } + const notes = await sent() + expect(notes).toHaveLength(1) + return { frame: notes[0], roomInviteId: RoomInviteId } + } + const typeFor = async (version?: string): Promise => + (await inviteFrom(version)).frame.data.Type + + // The cutoff build and anything newer → GameInviteV2 (6). The cutoff is INCLUSIVE, and + // the point release after the date is not part of the comparison. + expect(await typeFor('20250718.01')).toBe(6) + expect(await typeFor('20230415')).toBe(6) + expect(await typeFor('20230414')).toBe(6) + expect(await typeFor('20230414.99')).toBe(6) + + // Only builds OLDER than the cutoff get the original (0) — including the day before. + expect(await typeFor('20230413')).toBe(0) + expect(await typeFor('20230413.99')).toBe(0) + expect(await typeFor('20220101')).toBe(0) + + // No `rn.ver` on the token, or one that isn't a build at all: fall back to the + // original rather than move an unknown client onto v2. + expect(await typeFor()).toBe(0) + expect(await typeFor('not-a-build')).toBe(0) + + // v2 carries an escaped JSON object as its Data — a STRING holding JSON, not a + // nested object — naming the instance and the invite row behind it. + const v2 = await inviteFrom('20250718.01') + expect(typeof v2.frame.data.Data).toBe('string') + expect(JSON.parse(v2.frame.data.Data)).toEqual({ + InviteId: v2.roomInviteId, // the row POST /invite just answered with + Name: '^RecCenter', // the instance's `^`-prefixed wire name + InviteMode: 22, // verbatim, as observed off the client + }) + // RoomId still rides on the message itself, in both versions. + expect(v2.frame.data.RoomId).toBe(2) + + // v1 is unchanged: the bare roomInstanceId as a string, no JSON. + const v1 = await inviteFrom('20220101') + expect(v1.frame.data.Data).toBe(String(instance.roomInstanceId)) + expect(v1.frame.data.RoomId).toBe(2) + }) + test('matchmake pushes SubscriptionUpdatePresence to the player’s friends', async () => { // The notify DO is stubbed to record every send (see vitest.config). The friend // fan-out is a single batch call carrying the friend ids. @@ -2751,6 +2825,114 @@ describe('auth-gated endpoints', () => { expect(await sent()).toEqual([]) }) + test('POST /matchmake/invite/:id lands the invitee in the inviter’s instance', async () => { + // 8801 invites 8802. The invite row is what POST /invite answers with. + const instance = await createRoomInstance(env.DB, { + ownerAccountId: 8801, + roomId: 2, + subRoomId: 2, + photonRoomId: crypto.randomUUID(), + name: '^RecCenter', + maxCapacity: 12, + }) + const stand = async (accountId: number, roomInstance: unknown) => + setPresence(env.DB, { + accountId, + roomInstance, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 1, + platform: 0, + appVersion: GAME_VERSION, + }) + await stand(8801, instance) + + const invite = async (sub: string) => + exports.default.fetch(`${ORIGIN}/invite`, { + method: 'POST', + headers: { + ...(await bearer(sub)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `playerId=8802&roomInstanceId=${instance.roomInstanceId}`, + }) + const accept = async (inviteId: number, sub: string) => + exports.default.fetch(`${ORIGIN}/matchmake/invite/${inviteId}`, { + method: 'POST', + headers: { + ...(await bearer(sub)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'CorrelationId=acc97dc2-be74-4722-99c3-36530491f5ff', + }) + + const { RoomInviteId } = (await (await invite('8801')).json()) as { RoomInviteId: number } + + // Anyone who isn't the addressee is refused, the INVITER included — the row is the + // authorization, and an invite id is a small integer somebody else is holding. + for (const stranger of ['8801', '8803']) { + const res = await accept(RoomInviteId, stranger) + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ ErrorCode: 76, RoomInstance: null }) + } + + // The addressee lands in the instance the inviter is standing in, answered in the + // PascalCase envelope with the CorrelationId echoed back. + const ok = await accept(RoomInviteId, '8802') + expect(ok.status).toBe(200) + expect(await ok.json()).toMatchObject({ + ErrorCode: 0, + CorrelationId: 'acc97dc2-be74-4722-99c3-36530491f5ff', + RoomInstance: { + RoomInstanceId: instance.roomInstanceId, + RoomId: 2, + Name: '^RecCenter', + MatchmakingPolicy: 0, + }, + }) + + // ...and it really moved them: presence now names that instance. + const presence = await exports.default.fetch(`${ORIGIN}/player?id=8802`, { + headers: await bearer('8802'), + }) + expect( + ((await presence.json()) as Array<{ roomInstance: { roomInstanceId: number } | null }>)[0] + ?.roomInstance?.roomInstanceId + ).toBe(instance.roomInstanceId) + + // Standing there already is 17, not a second join. + const again = await accept(RoomInviteId, '8802') + expect(await again.json()).toMatchObject({ ErrorCode: 17, RoomInstance: null }) + + // An invite id that isn't there is 40 — expiry deletes rows, so "gone" and "expired" + // are one answer. + expect(await (await accept(99_999_999, '8802')).json()).toMatchObject({ + ErrorCode: 40, + RoomInstance: null, + }) + + // The inviter walking out leaves nothing to join: 2, PlayerNotOnline. (The invitee is + // moved out of the instance first so the AlreadyIn check doesn't answer ahead of it.) + await stand(8802, null) + await env.DB.prepare('DELETE FROM presence WHERE account_id = 8801').run() + const { RoomInviteId: staleId } = (await (await invite('8801')).json()) as { + RoomInviteId: number + } + expect(await (await accept(staleId, '8802')).json()).toMatchObject({ + ErrorCode: 2, + RoomInstance: null, + }) + + // Unauthenticated is a 401, not a refusal code. + expect( + ( + await exports.default.fetch(`${ORIGIN}/matchmake/invite/${RoomInviteId}`, { + 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) @@ -2786,6 +2968,7 @@ describe('auth-gated endpoints', () => { 'POST /matchmake/dorm', 'POST /matchmake/event/{eventId}', 'POST /matchmake/instance/{instanceId}', + 'POST /matchmake/invite/{inviteId}', 'POST /matchmake/none', 'POST /matchmake/player/{playerId}', 'POST /matchmake/room/{roomId}', diff --git a/packages/domain/src/enums.ts b/packages/domain/src/enums.ts index 6464e22..2692c5e 100644 --- a/packages/domain/src/enums.ts +++ b/packages/domain/src/enums.ts @@ -64,6 +64,27 @@ export enum MessageType { VirtualRoomNotification = 100008, } +/** + * What a v2 game invite (`MessageType.GameInviteV2`) is ASKING FOR — the `InviteMode` in + * its `Data` envelope. It's the intent behind the invite, not the room being invited into: + * the same instance is named whether you're pulling a party along or asking one player to + * come play. + * + * The values are sparse and grouped: 0–4 are the party-management modes, 20+ the invite + * ones. A plain "join me" is {@link PlayTogether}; {@link InviteParty} is the other + * reading of it, and the two have not been told apart on the wire yet. + */ +export enum InviteMode { + None = 0, + LeaveParty = 1, + InviteParty = 2, + PartyAutoFollow = 3, + EveryoneAutoFollow = 4, + InviteOnlineFriends = 20, + FullInstanceReinvite = 21, + PlayTogether = 22, +} + /** * A room's (or image's) visibility, matching the client's `RoomAccessibility`. The * client declares the enum without explicit values, so these are its ordinals — and @@ -140,6 +161,7 @@ export enum MatchmakingErrorCode { MetaJuniorAccountRestriction = 83, NotExclusivelyLoggedIn = 84, AccountDoesNotExist = 85, + RoomInstanceBlockedByMatchmakingPolicy = 86, } /** diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 04cc20f..7f91dee 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -1,4 +1,11 @@ -export { RoomInstanceType, Accessibility, Role, MessageType, MatchmakingErrorCode } from './enums' +export { + RoomInstanceType, + Accessibility, + Role, + MessageType, + InviteMode, + MatchmakingErrorCode, +} from './enums' export * from './accounts-db' export * from './clubs-db' export * from './images-db' diff --git a/packages/domain/src/room-invites-db.ts b/packages/domain/src/room-invites-db.ts index ce1eafd..ba35e84 100644 --- a/packages/domain/src/room-invites-db.ts +++ b/packages/domain/src/room-invites-db.ts @@ -60,6 +60,31 @@ const SELECT_COLUMNS = `room_invite_id, from_player_id, to_player_id, room_id` const nowSeconds = () => Math.floor(Date.now() / 1000) +/** + * One invite by its id, or null when there is no such row. + * + * Null covers BOTH "never existed" and "already expired" — the sweep deletes expired rows + * rather than flagging them, and {@link ROOM_INVITE_SCHEMA_DDL} keeps ids from being + * reused, so a lookup that misses is an invite that is no longer good either way. + */ +export async function getRoomInvite( + db: D1Database, + roomInviteId: number +): Promise { + const row = await db + .prepare(`SELECT ${SELECT_COLUMNS} FROM room_invite WHERE room_invite_id = ?1`) + .bind(roomInviteId) + .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.