[match] fix play-and-invite endpoint

This commit is contained in:
Devin Zuczek
2026-09-01 14:47:49 -04:00
parent 2af5d5089d
commit 9c63f077b8
2 changed files with 88 additions and 18 deletions
+28 -18
View File
@@ -788,14 +788,6 @@ async function gameInviteType(c: Context<App>): Promise<MessageType> {
*/ */
const GAME_INVITE_V2_INVITE_MODE = InviteMode.PlayTogether 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. */ /** What an invite points at, in the shape both message versions need to describe it. */
type GameInviteTarget = { type GameInviteTarget = {
/** The raw roomInstanceId string — the WHOLE `Data` of a v1 invite. */ /** The raw roomInstanceId string — the WHOLE `Data` of a v1 invite. */
@@ -804,7 +796,7 @@ type GameInviteTarget = {
roomId: number | null roomId: number | null
/** The instance's `^`-prefixed wire name, `''` when the instance didn't resolve. */ /** The instance's `^`-prefixed wire name, `''` when the instance didn't resolve. */
name: string name: string
/** The `room_invite` row this came from, or {@link UNKNOWN_INVITE_ID}. */ /** The id of the `room_invite` row this came from — what the invitee redeems. */
inviteId: number inviteId: number
} }
@@ -1184,6 +1176,12 @@ async function readMatchmakeBody(
* sends, pointing at this instance, so a party matchmake pulls the whole party along. The * sends, pointing at this instance, so a party matchmake pulls the whole party along. The
* leader is skipped (already in). Best-effort per member (sendGameInvite swallows its own * leader is skipped (already in). Best-effort per member (sendGameInvite swallows its own
* failures), and never blocks the matchmake beyond the sends themselves. * failures), and never blocks the matchmake beyond the sends themselves.
*
* Each member's invite is RECORDED, exactly as `POST /invite` records one, because the row
* is what the member redeems the frame against: the 2025 client joins off a party invite
* through `/matchmake/invite/{InviteId}` or `/matchmake/v2/player/{leaderId}`, and both
* resolve a `room_invite` row. A fan-out that only pushed the frame minted no row, so every
* party invite read as expired the moment it arrived while a manual `POST /invite` worked.
*/ */
async function inviteParty( async function inviteParty(
c: Context<App>, c: Context<App>,
@@ -1191,21 +1189,33 @@ async function inviteParty(
playerIds: number[], playerIds: number[],
instance: RoomInstance instance: RoomInstance
): Promise<void> { ): Promise<void> {
// 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 // 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. // message, so the type can't differ between them.
const type = await gameInviteType(c) const type = await gameInviteType(c)
await Promise.all( await Promise.all(
playerIds playerIds
.filter((pid) => pid !== leaderId) .filter((pid) => pid !== leaderId)
.map((pid) => sendGameInvite(c, leaderId, pid, target, type)) .map(async (pid) => {
// The row before the frame, as `POST /invite` does: the frame names the row's id,
// so an invite that couldn't be recorded has nothing to redeem and isn't sent.
const invite = await createRoomInvite(c.env.DB, leaderId, pid, instance.roomId)
if (invite === null) {
logger.error('failed to record party room invite', {
fromPlayerId: leaderId,
toPlayerId: pid,
roomId: instance.roomId,
})
return
}
// The leader's own instance, which every member is being pulled into.
const target: GameInviteTarget = {
instanceId: String(instance.roomInstanceId),
roomId: instance.roomId,
name: instance.name,
inviteId: invite.RoomInviteId,
}
await sendGameInvite(c, leaderId, pid, target, type)
})
) )
} }
@@ -2914,6 +2914,14 @@ describe('auth-gated endpoints', () => {
RoomId: 2, RoomId: 2,
}) })
// The fan-out RECORDS each invite, like POST /invite: the row is what the invitee
// redeems the frame against, so a frame without one is expired on arrival.
const row = await env.DB.prepare(
'SELECT room_invite_id, room_id FROM room_invite WHERE from_player_id = 9850 AND to_player_id = 153'
).first<{ room_invite_id: number; room_id: number }>()
expect(row).not.toBeNull()
expect(row?.room_id).toBe(2)
// Multiple ids (repeated fields, not comma-separated), de-duplicated, and the leader // Multiple ids (repeated fields, not comma-separated), de-duplicated, and the leader
// themselves is skipped. // themselves is skipped.
await reset() await reset()
@@ -2929,6 +2937,58 @@ describe('auth-gated endpoints', () => {
expect(await sent()).toEqual([]) expect(await sent()).toEqual([])
}) })
test('a v2 party matchmake mints invites the members can actually redeem', async () => {
// The reported bug: /matchmake/v2/room/:id fanned the party out as frames with no
// `room_invite` row behind them, so the member's join answered 40 (RoomInviteExpired)
// while a manual POST /invite worked.
type Sent = { playerId: number; data: { Type: number; Data: string } }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await exports.default.fetch(`${ORIGIN}/matchmake/v2/room/2`, {
method: 'POST',
headers: {
...(await bearer('9860', '20250718.01')),
'Content-Type': 'application/json',
},
body: JSON.stringify({
AdditionalPlayerIds: [9861],
CorrelationId: '3c60e657-21c4-46be-815c-57ee51add506',
JoinMode: 2,
InviteMode: 20,
}),
})
expect(res.status).toBe(200)
const leader = (await res.json()) as { RoomInstance: { RoomInstanceId: number } }
// The party member's frame is a v2 invite naming a REAL row id.
const frames = (await (await hub().fetch('http://do/all')).json()) as Sent[]
const invite = frames.find((f) => f.playerId === 9861)
expect(invite?.data.Type).toBe(6) // MessageType.GameInviteV2
const { InviteId } = JSON.parse(invite?.data.Data ?? '{}') as { InviteId: number }
expect(InviteId).toBeGreaterThan(0)
// Redeeming it puts the member in the leader's instance rather than answering 40.
const joined = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/invite/${InviteId}`, {
method: 'POST',
headers: { ...(await bearer('9861', '20250718.01')) },
})
).json()) as { ErrorCode: number; RoomInstance: { RoomInstanceId: number } | null }
expect(joined.ErrorCode).toBe(0)
expect(joined.RoomInstance?.RoomInstanceId).toBe(leader.RoomInstance.RoomInstanceId)
// The by-sender redemption the newer client falls back to works off the same row.
await env.DB.prepare('DELETE FROM presence WHERE account_id = 9861').run()
const bySender = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/9860`, {
method: 'POST',
headers: { ...(await bearer('9861', '20250718.01')) },
})
).json()) as { ErrorCode: number }
expect(bySender.ErrorCode).toBe(0)
})
test('POST /matchmake/invite/:id lands the invitee in the inviters instance', async () => { test('POST /matchmake/invite/:id lands the invitee in the inviters instance', async () => {
// 8801 invites 8802. The invite row is what POST /invite answers with. // 8801 invites 8802. The invite row is what POST /invite answers with.
const instance = await createRoomInstance(env.DB, { const instance = await createRoomInstance(env.DB, {