From 385e10bd55d1b1aa66d9764ca81ddeb1551dda6d Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 12 Aug 2026 17:16:52 -0400 Subject: [PATCH] [match] matchmake into event room --- apps/match/src/match.app.ts | 95 ++++++++++++++++ apps/match/src/test/integration/api.test.ts | 119 ++++++++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 2393071..471325e 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -3,6 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' import { + Accessibility, areFriends, canManageRoom, 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 // same way econ reads api's inventions-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 // deps), so /invite sends a typed MessageReceived frame instead of a magic number. import { NotificationType } from '../../notify/src/notification-types' @@ -415,6 +419,15 @@ const NO_SUCH_ROOM = MatchmakingErrorCode.NoSuchRoom */ 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). */ const HUB_INSTANCE = 'global' @@ -1241,6 +1254,88 @@ const app = new Hono() } ) + // 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 event’s 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 event’s 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 event’s 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 event’s 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 // 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 diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 7bac901..5955ca6 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -21,6 +21,7 @@ import { SUBROOM_SCHEMA_DDL, } from '@repo/domain' +import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db' import { banFromReport, createReport, @@ -150,6 +151,49 @@ beforeAll(async () => { 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) => + 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 // presence update to the player's friends. Seed friendships for player 9700. await env.DB.prepare( @@ -653,6 +697,80 @@ describe('public endpoints', () => { 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 () => { const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, { method: 'POST', @@ -1907,6 +2025,7 @@ describe('auth-gated endpoints', () => { 'POST /invite', 'POST /matchmake/club/{clubId}', 'POST /matchmake/dorm', + 'POST /matchmake/event/{eventId}', 'POST /matchmake/instance/{instanceId}', 'POST /matchmake/player/{playerId}', 'POST /matchmake/room/{roomId}',