mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
[match] matchmake into event room
This commit is contained in:
@@ -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 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
|
// 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
|
||||||
|
|||||||
@@ -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}',
|
||||||
|
|||||||
Reference in New Issue
Block a user