mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 23:21:30 -07:00
[match] the other v2 invite endpoint
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
|||||||
getExpiredPresenceInstanceIds,
|
getExpiredPresenceInstanceIds,
|
||||||
getFriendIds,
|
getFriendIds,
|
||||||
getJoinableInstance,
|
getJoinableInstance,
|
||||||
|
getLatestRoomInviteBetween,
|
||||||
getMostActiveClubhouses,
|
getMostActiveClubhouses,
|
||||||
getOrCreateDormRoom,
|
getOrCreateDormRoom,
|
||||||
getPresence,
|
getPresence,
|
||||||
@@ -2113,6 +2114,129 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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<RoomInstance>(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<RoomInstance>(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
|
// Accept a game invite and land in the inviter's instance
|
||||||
// (`/matchmake/invite/{roomInviteId}`). The 2025 client's join button on an invite: it
|
// (`/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
|
// carries the `RoomInviteId` minted by `POST /invite`, and this resolves that row to the
|
||||||
|
|||||||
@@ -278,10 +278,11 @@ export const NotifyDisconnectRequest = z.object({
|
|||||||
* matchmakes that post no body at all.
|
* matchmakes that post no body at all.
|
||||||
*
|
*
|
||||||
* This is the whole body of the target-less matchmakes (`/matchmake/dorm`,
|
* This is the whole body of the target-less matchmakes (`/matchmake/dorm`,
|
||||||
* `/matchmake/none`, `/matchmake/player/:id`, `/matchmake/instance/:id`), which is why
|
* `/matchmake/none`, `/matchmake/player/:id`, `/matchmake/v2/player/:id`,
|
||||||
* it's a schema of its own; the room matchmakes extend it. Other fields the client sends
|
* `/matchmake/instance/:id`), which is why it's a schema of its own; the room matchmakes
|
||||||
* (`LoginLock`, `MaxPersistenceVersion`, `VoiceServerVersion`,
|
* extend it. Other fields the client sends (`LoginLock`, `MaxPersistenceVersion`,
|
||||||
* `BypassMovementModeRestriction`) are accepted and ignored.
|
* `VoiceServerVersion`, `BypassMovementModeRestriction`, `PlayerIsPartyMember`) are
|
||||||
|
* accepted and ignored.
|
||||||
*/
|
*/
|
||||||
export const CorrelationIdRequest = z.object({
|
export const CorrelationIdRequest = z.object({
|
||||||
CorrelationId: z.string().optional().describe('Per-attempt GUID; echoed on the response'),
|
CorrelationId: z.string().optional().describe('Per-attempt GUID; echoed on the response'),
|
||||||
|
|||||||
@@ -2958,6 +2958,127 @@ describe('auth-gated endpoints', () => {
|
|||||||
).toBe(401)
|
).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<string, unknown>
|
||||||
|
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 () => {
|
test('GET /openapi.json documents every route', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -2998,6 +3119,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'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/v2/player/{playerId}',
|
||||||
'POST /matchmake/v2/room/{roomId}',
|
'POST /matchmake/v2/room/{roomId}',
|
||||||
'POST /matchmake/v2/room/{roomId}/{subRoomId}',
|
'POST /matchmake/v2/room/{roomId}/{subRoomId}',
|
||||||
'POST /player/exclusivelogin',
|
'POST /player/exclusivelogin',
|
||||||
|
|||||||
@@ -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<RoomInvite | null> {
|
||||||
|
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<RoomInviteRow>()
|
||||||
|
|
||||||
|
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
|
* 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.
|
* client reads it back. `roomId` is null when the caller's room instance didn't resolve.
|
||||||
|
|||||||
Reference in New Issue
Block a user