[match] better invites

This commit is contained in:
Devin Zuczek
2026-08-22 12:23:48 -04:00
parent 1a2643240b
commit be24e62f1f
9 changed files with 204 additions and 12 deletions
+24 -6
View File
@@ -7,6 +7,7 @@ import {
areFriends,
canManageRoom,
createRoomInstance,
createRoomInvite,
deleteEmptyRoomInstances,
deleteExpiredPresence,
deletePresence,
@@ -61,6 +62,7 @@ import {
form,
InProgressRequest,
InviteRequest,
InviteResponse,
JoinModeRequest,
json,
jsonBody,
@@ -610,6 +612,7 @@ function nextLiveMessageId(): number {
return Date.now()
}
/**
* 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`
@@ -2262,9 +2265,11 @@ const app = new Hono<App>()
// `roomInstanceId` they're being invited into. Delivers a game-invite Message to the
// target over the notify hub as a MessageReceived frame — the client renders the
// join prompt from it. When the room instance resolves, its RoomId rides along on the
// message so the client knows which room the invite points at. Always acks 200 (a bad
// playerId is a 400, a missing token a 401); hub delivery is best-effort, so a target
// who's offline simply has the frame queued (or dropped) without failing the invite.
// message so the client knows which room the invite points at. The invite is also
// recorded as a `room_invite` row, and that row IS the response —
// `{ RoomInviteId, FromPlayerId, ToPlayerId, RoomId }` (a bad playerId is a 400, a
// missing token a 401). Hub delivery is best-effort, so a target who's offline simply
// has the frame queued (or dropped) without failing the invite.
.post(
'/invite',
describeRoute({
@@ -2274,14 +2279,16 @@ const app = new Hono<App>()
'Sends a game invite from the caller (the Bearer token) to `playerId` for',
'`roomInstanceId`. Delivered to the target over the notify hub as a `MessageReceived`',
'notification carrying a game-invite `Message`; the resolved instances `RoomId` rides',
'on the message. Acks 200 (bad `playerId` → 400); hub delivery is best-effort.',
'on the message. The invite is recorded as a `room_invite` row and that row is the',
'response (bad `playerId` → 400); hub delivery is best-effort.',
].join(' '),
security: AUTHED,
requestBody: form(InviteRequest, 'The target player and the room instance'),
responses: {
200: EMPTY_OK,
200: json(InviteResponse, 'The invite that was sent'),
400: { description: 'Missing, non-numeric, or zero playerId (empty body)' },
401: UNAUTHORIZED_RESPONSE,
500: { description: 'The invite could not be recorded; nothing was sent (empty body)' },
},
}),
async (c) => {
@@ -2306,8 +2313,19 @@ const app = new Hono<App>()
if (instance) roomId = instance.roomId
}
// Record the invite before delivering it: the row is what mints the `RoomInviteId`
// the response carries, while the frame itself is fire-and-forget.
const invite = await createRoomInvite(c.env.DB, id, toPlayerId, roomId)
// No row, no id to answer with — and nothing for a later lookup or expiry to find.
// A server fault rather than the caller's, so don't push the frame either: an
// invite the response can't name is worse than no invite.
if (invite === null) {
logger.error('failed to record room invite', { fromPlayerId: id, toPlayerId, roomId })
return c.body(null, 500)
}
await sendGameInvite(c, id, toPlayerId, roomInstanceIdStr, roomId)
return c.body(null, 200)
return c.json(invite)
}
)
+14
View File
@@ -384,3 +384,17 @@ export const InviteRequest = z.object({
.optional()
.describe('The callers room instance to invite them into; resolves the invites RoomId'),
})
/**
* `POST /invite` response — the `room_invite` row the invite just created. The frame the
* invitee receives is ephemeral; the row is what gives the invite an id of its own.
*/
export const InviteResponse = z.object({
RoomInviteId: z.int().describe('Id of the new `room_invite` row'),
FromPlayerId: z.int().describe('The caller (the Bearer token)'),
ToPlayerId: z.int().describe('The invited account'),
RoomId: z
.int()
.nullable()
.describe('The room the invite points at; null when the room instance didnt resolve'),
})
@@ -16,6 +16,7 @@ import {
getRoomInstance,
PRESENCE_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_INVITE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
@@ -120,6 +121,8 @@ beforeAll(async () => {
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (owned by the rooms worker) — written/read by matchmake + heartbeat.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Room invites (owned by this worker) — POST /invite mints a row per invite.
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Accounts table (owned by the auth worker) — dorm creation reads the username
// to name the room. Seed the players the dorm tests authenticate as.
@@ -2242,6 +2245,25 @@ describe('auth-gated endpoints', () => {
// The client's exact request: player 42 invites 153 into their instance.
const res = await invite(`playerId=153&roomInstanceId=${instance.roomInstanceId}`, '42')
expect(res.status).toBe(200)
// The response is the `room_invite` row the invite just created.
const created = (await res.json()) as Record<string, unknown>
expect(created).toMatchObject({ FromPlayerId: 42, ToPlayerId: 153, RoomId: 2 })
expect(created.RoomInviteId).toBeGreaterThan(0)
// ...and the row is really there, `created_at` stamped in epoch seconds so the
// eventual expiry sweep can compare it.
const row = await env.DB.prepare(
'SELECT from_player_id, to_player_id, room_id, created_at FROM room_invite WHERE room_invite_id = ?1'
)
.bind(created.RoomInviteId)
.first<{
from_player_id: number
to_player_id: number
room_id: number | null
created_at: number
}>()
expect(row).toMatchObject({ from_player_id: 42, to_player_id: 153, room_id: 2 })
expect(row!.created_at).toBeGreaterThan(1_700_000_000)
const notes = await sent()
expect(notes).toHaveLength(1)
@@ -2269,6 +2291,10 @@ describe('auth-gated endpoints', () => {
// RoomId (which the real hub drops from the frame).
const noRoom = await invite('playerId=153&roomInstanceId=999999', '42')
expect(noRoom.status).toBe(200)
const noRoomInvite = (await noRoom.json()) as Record<string, unknown>
expect(noRoomInvite).toMatchObject({ RoomId: null })
// Each invite gets its own id.
expect(noRoomInvite.RoomInviteId).not.toBe(created.RoomInviteId)
const after = await sent()
expect(after).toHaveLength(1)
expect(after[0].data.RoomId).toBeNull()