mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[match] fix play-and-invite endpoint
This commit is contained in:
+28
-18
@@ -788,14 +788,6 @@ async function gameInviteType(c: Context<App>): Promise<MessageType> {
|
||||
*/
|
||||
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. */
|
||||
@@ -804,7 +796,7 @@ type GameInviteTarget = {
|
||||
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}. */
|
||||
/** The id of the `room_invite` row this came from — what the invitee redeems. */
|
||||
inviteId: number
|
||||
}
|
||||
|
||||
@@ -1184,6 +1176,12 @@ async function readMatchmakeBody(
|
||||
* 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
|
||||
* 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(
|
||||
c: Context<App>,
|
||||
@@ -1191,21 +1189,33 @@ async function inviteParty(
|
||||
playerIds: number[],
|
||||
instance: RoomInstance
|
||||
): 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
|
||||
// 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, 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,
|
||||
})
|
||||
|
||||
// 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
|
||||
// themselves is skipped.
|
||||
await reset()
|
||||
@@ -2929,6 +2937,58 @@ describe('auth-gated endpoints', () => {
|
||||
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 inviter’s instance', async () => {
|
||||
// 8801 invites 8802. The invite row is what POST /invite answers with.
|
||||
const instance = await createRoomInstance(env.DB, {
|
||||
|
||||
Reference in New Issue
Block a user