[match] matchmake into event room

This commit is contained in:
Devin Zuczek
2026-08-12 17:16:52 -04:00
parent 8e0e92b449
commit 385e10bd55
2 changed files with 214 additions and 0 deletions
+95
View File
@@ -3,6 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { import {
Accessibility,
areFriends, areFriends,
canManageRoom, canManageRoom,
createRoomInstance, createRoomInstance,
@@ -42,6 +43,9 @@ import { validateAndGetAccountId } from '@repo/jwt'
// db module is plain D1 queries with no runtime deps, so it imports cleanly here (the // db module is plain D1 queries with no runtime deps, so it imports cleanly here (the
// same way econ reads api's inventions-db). // same way econ reads api's inventions-db).
import { banEvasionMatch, resolveBan } from '../../api/src/bans-db' import { banEvasionMatch, resolveBan } from '../../api/src/bans-db'
// The player-event tables are the api worker's too (same plain-D1 shape as bans-db):
// `/matchmake/event` needs the event's room and the caller's invite row.
import { getEventById, getEventResponse } from '../../api/src/events-db'
// Value import of the notify worker's NotificationType enum (its bundle has no runtime // Value import of the notify worker's NotificationType enum (its bundle has no runtime
// deps), so /invite sends a typed MessageReceived frame instead of a magic number. // deps), so /invite sends a typed MessageReceived frame instead of a magic number.
import { NotificationType } from '../../notify/src/notification-types' import { NotificationType } from '../../notify/src/notification-types'
@@ -415,6 +419,15 @@ const NO_SUCH_ROOM = MatchmakingErrorCode.NoSuchRoom
*/ */
const BANNED_FROM_ROOM = MatchmakingErrorCode.BannedFromRoom const BANNED_FROM_ROOM = MatchmakingErrorCode.BannedFromRoom
/**
* "This event isn't open to you" — the refusal on a private event the caller wasn't
* invited to. Told plainly rather than hidden behind the opaque NoSuchRoom: a player
* reaching this already holds the event id from somewhere that showed it to them, so
* the only thing withholding the reason buys is a room that fails to load for no
* visible reason.
*/
const EVENT_IS_PRIVATE = MatchmakingErrorCode.EventIsPrivate
/** The notifications hub is a single global DO instance (see the `notify` worker). */ /** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global' const HUB_INSTANCE = 'global'
@@ -1241,6 +1254,88 @@ const app = new Hono<App>()
} }
) )
// Matchmake into a player event (`/matchmake/event/{playerEventId}`) — the "join" on
// an event. The event names the room (and optionally the subroom) to enter, so this
// is a room matchmake behind an access check on the EVENT.
.post(
'/matchmake/event/:eventId{[0-9]+}',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake into a player event',
description: [
'Places the caller into an instance of the events room — its subroom too, when the',
'event pins one. Who may join: anyone, if the event is Public (1) or Unlisted (2),',
'since unlisted only keeps an event out of the listings rather than closing it; and',
'otherwise only the events creator or a player who has been invited to it (any',
'`event_attendee` row, whatever their answer — being able to decline and change your',
'mind is the point). Everyone else gets errorCode 35 (EventIsPrivate) with a null',
'instance; an unknown event is the opaque errorCode 20, and 55 when the caller is',
'banned from the room the event runs in.',
'',
'The events start and end times are NOT enforced — the reference has codes for',
'both (4 EventNotStarted, 5 EventAlreadyFinished) but nothing here has been observed',
'sending them, and locking a creator out of their own room before the hour would be',
'worse than letting people in early.',
].join(' '),
security: AUTHED,
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
parameters: [
{
name: 'eventId',
in: 'path',
required: true,
description: 'Player event id (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(
MatchmakeResponse,
'The events instance (or a null instance with errorCode 20 / 35 / 55)'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const event = await getEventById(c.env.DB, eventId)
// Opaque, like the club path: an unknown event and one the caller can't see
// shouldn't be distinguishable by probing ids.
if (event === null) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
const open =
event.Accessibility === Accessibility.Public ||
event.Accessibility === Accessibility.Unlisted
// An `event_attendee` row is the invite: bulkInvite writes one, and so does
// responding, so anyone who was invited or answered passes. The creator is checked
// separately so an event whose creator deleted their own response still lets them in.
if (
!open &&
event.CreatorPlayerId !== id &&
(await getEventResponse(c.env.DB, eventId, id)) === null
) {
logger.info('matchmake refused: not invited to private event', { eventId, id })
return c.json({ errorCode: EVENT_IS_PRIVATE, roomInstance: null })
}
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
const { instance, errorCode } = await resolveRoomInstance(
c,
String(event.RoomId),
joinMode === 2,
id,
event.SubRoomId ?? undefined
)
if (!instance) return c.json({ errorCode, roomInstance: null })
await enterRoom(c, id, instance)
await inviteParty(c, id, additionalPlayerIds, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Follow a friend into the room they're in (`/matchmake/player/{playerId}`). Friends // Follow a friend into the room they're in (`/matchmake/player/{playerId}`). Friends
// ONLY — the caller must be a mutual friend of the target, or it's refused; otherwise // ONLY — the caller must be a mutual friend of the target, or it's refused; otherwise
// anyone could read a player's presence and warp to them. Reads the friend's current // anyone could read a player's presence and warp to them. Reads the friend's current
+119
View File
@@ -21,6 +21,7 @@ import {
SUBROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL,
} from '@repo/domain' } from '@repo/domain'
import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db'
import { import {
banFromReport, banFromReport,
createReport, createReport,
@@ -150,6 +151,49 @@ beforeAll(async () => {
insertMember.bind(5, 120, 100), insertMember.bind(5, 120, 100),
]) ])
// Player-event tables (owned by the api worker) — matchmake/event reads the event
// for its room and the caller's invite row for access.
for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
const insertEvent = env.DB.prepare('INSERT OR IGNORE INTO event (data) VALUES (?1)')
const event = (id: number, accessibility: number, extra?: Record<string, unknown>) =>
JSON.stringify({
PlayerEventId: id,
CreatorPlayerId: 300,
ImageName: null,
RoomId: 2,
SubRoomId: null,
ClubId: null,
Name: `Event ${id}`,
Description: '',
StartTime: '2020-11-29T22:00:00Z',
EndTime: '2020-11-29T23:00:00Z',
AttendeeCount: 1,
State: 0,
Accessibility: accessibility,
IsMultiInstance: false,
SupportMultiInstanceRoomChat: false,
DefaultBroadcastPermissions: 0,
CanRequestBroadcastPermissions: 0,
...extra,
})
await env.DB.batch([
insertEvent.bind(event(8, 0)), // private
insertEvent.bind(event(9, 1)), // public
insertEvent.bind(event(10, 2)), // unlisted — listings only, still joinable
// A private one in the two-subroom room, pinning the SECOND subroom.
insertEvent.bind(event(11, 0, { RoomId: 77, SubRoomId: 35 })),
])
const insertAttendee = env.DB.prepare(
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
VALUES (?1, ?2, ?3, '2020-11-29T21:00:00Z')`
)
await env.DB.batch([
insertAttendee.bind(8, 300, 0), // the creator, Going from create
insertAttendee.bind(8, 301, 0), // invited
insertAttendee.bind(8, 302, 2), // invited, but declined — still allowed in
insertAttendee.bind(11, 301, 0),
])
// Relationship table (owned by the api worker) — matchmake reads it to push a // Relationship table (owned by the api worker) — matchmake reads it to push a
// presence update to the player's friends. Seed friendships for player 9700. // presence update to the player's friends. Seed friendships for player 9700.
await env.DB.prepare( await env.DB.prepare(
@@ -653,6 +697,80 @@ describe('public endpoints', () => {
expect((await matchmake('/matchmake/club/4')).status).toBe(401) expect((await matchmake('/matchmake/club/4')).status).toBe(401)
}) })
test('POST /matchmake/event/:eventId gates a private event on the invite list', async () => {
const matchmake = async (path: string, sub?: string) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
...(sub === undefined ? {} : await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'JoinMode=0',
})
type Body = {
errorCode: number
roomInstance: { roomId: number; location: string; roomInstanceId: number } | null
}
const join = async (path: string, sub?: string) =>
(await (await matchmake(path, sub)).json()) as Body
// An invited player lands in an instance of the event's room (2)...
const invited = await join('/matchmake/event/8', '301')
expect(invited.errorCode).toBe(0)
expect(invited.roomInstance).toMatchObject({ roomId: 2, location: RECCENTER_SCENE })
// ...recorded as their presence, like any other matchmake.
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
.bind(301)
.first<{ data: string }>()
const presence = JSON.parse(row!.data) as { roomInstance: { roomInstanceId: number } }
expect(presence.roomInstance.roomInstanceId).toBe(invited.roomInstance!.roomInstanceId)
// The creator gets in, and so does someone who was invited and DECLINED — the row
// is the invite, whatever the answer.
expect((await join('/matchmake/event/8', '300')).errorCode).toBe(0)
expect((await join('/matchmake/event/8', '302')).errorCode).toBe(0)
// A stranger doesn't — and is told why (35 EventIsPrivate), not fobbed off with 20.
expect(await join('/matchmake/event/8', '399')).toEqual({
errorCode: 35,
roomInstance: null,
})
// Public and unlisted are open to anyone: unlisted only keeps an event out of the
// listings, it doesn't close it.
expect((await join('/matchmake/event/9', '399')).errorCode).toBe(0)
expect((await join('/matchmake/event/10', '399')).errorCode).toBe(0)
// An unknown event is the opaque NoSuchRoom, so ids can't be probed.
expect(await join('/matchmake/event/9999', '399')).toEqual({
errorCode: 20,
roomInstance: null,
})
// Signed out is a 401, not a matchmaking error.
expect((await matchmake('/matchmake/event/9')).status).toBe(401)
})
test('POST /matchmake/event/:eventId enters the subroom the event pins', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/event/11`, {
method: 'POST',
headers: { ...(await bearer('301')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'JoinMode=0',
})
const body = (await res.json()) as {
errorCode: number
roomInstance: { roomId: number; subRoomId: number; location: string } | null
}
// Room 77's SECOND subroom (35), not its first — the event pins the scene.
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({
roomId: 77,
subRoomId: 35,
location: SECOND_SUBROOM_SCENE,
})
})
test('POST /matchmake/room/:roomId returns NoSuchRoom for an unknown room', async () => { test('POST /matchmake/room/:roomId returns NoSuchRoom for an unknown room', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, { const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, {
method: 'POST', method: 'POST',
@@ -1907,6 +2025,7 @@ describe('auth-gated endpoints', () => {
'POST /invite', 'POST /invite',
'POST /matchmake/club/{clubId}', 'POST /matchmake/club/{clubId}',
'POST /matchmake/dorm', 'POST /matchmake/dorm',
'POST /matchmake/event/{eventId}',
'POST /matchmake/instance/{instanceId}', 'POST /matchmake/instance/{instanceId}',
'POST /matchmake/player/{playerId}', 'POST /matchmake/player/{playerId}',
'POST /matchmake/room/{roomId}', 'POST /matchmake/room/{roomId}',