diff --git a/apps/match/src/context.ts b/apps/match/src/context.ts index 8b09bbf..8038d86 100644 --- a/apps/match/src/context.ts +++ b/apps/match/src/context.ts @@ -1,5 +1,8 @@ import type { HonoApp } from '@repo/hono-helpers' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' +// Type-only import (erased at build) of the DO class owned by the `notify` worker, +// so this worker can push websocket notifications through its RPC surface. +import type { NotificationsHub } from '../../notify/src/notifications-hub' export type Env = SharedHonoEnv & { // Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value @@ -12,6 +15,11 @@ export type Env = SharedHonoEnv & { // read by the heartbeat and the batch `/player` lookup). See @repo/domain's // presence-db (table owned/migrated by the `rooms` worker). DB: D1Database + /** + * The `notify` worker's NotificationsHub DO — pushes websocket notifications to a + * player. Used by `POST /invite` to deliver the game-invite message to the invitee. + */ + RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace } /** Variables can be extended */ diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 89a135d..030b8fb 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 { + areFriends, canManageRoom, createRoomInstance, deleteExpiredPresence, @@ -11,22 +12,29 @@ import { getAccount, getClubSummary, getExpiredPresenceInstanceIds, + getFriendIds, getJoinableInstance, getOrCreateDormRoom, getPresence, getPresences, getRoomById, getRoomByName, + getRoomInstance, getRoomInstancesByRoom, isClubMember, + MessageType, refreshInstanceFullness, RoomInstanceType, setPresence, setRoomInstanceInProgress, } from '@repo/domain' -import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' +import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' +// 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' + import { AUTHED, EMPTY_OK, @@ -34,10 +42,12 @@ import { form, HeartbeatRequest as HeartbeatRequestSchema, InProgressRequest, + InviteRequest, JoinModeRequest, json, jsonBody, MatchmakeResponse, + MatchmakeRoomRequest, PlayerDto, RoomInstanceDto, StatusVisibilityRequest, @@ -148,6 +158,86 @@ const PRESENCE_REFRESH_THRESHOLD = 300 */ const DEFAULT_GET_PLAYER = [{ ...playerPayload(1), isOnline: true }] +/** + * The wire subset of a room instance a friend sees in a presence update — the + * reference's `RoomInstanceDto.Redact` projection. `photonRoomId` and `dataBlob` are + * BLANKED (empty string): they're safe only for the player themselves. A leaked + * `photonRoomId` would let anyone who can read your presence `JoinByName` the Photon + * room directly, bypassing the private-instance invite check — the friend list only + * needs `roomId`/`name`/`isPrivate` to render the row, and joins go back through + * matchmaking (`/goto/player/:id`), which enforces access. `photonRegion` is omitted + * (not on the presence DTO); `name` is already the `^`-prefixed wire name. + */ +function redactInstanceForPresence(instance: RoomInstance) { + return { + roomInstanceId: instance.roomInstanceId, + roomId: instance.roomId, + subRoomId: instance.subRoomId, + roomInstanceType: instance.roomInstanceType, + location: instance.location, + // Blanked — see above: never hand another player the join coordinates. + dataBlob: '', + eventId: instance.eventId, + clubId: instance.clubId, + roomCode: instance.roomCode, + photonRegionId: instance.photonRegionId, + photonRoomId: '', + name: instance.name, + maxCapacity: instance.maxCapacity, + isFull: instance.isFull, + isPrivate: instance.isPrivate, + isInProgress: instance.isInProgress, + EncryptVoiceChat: instance.EncryptVoiceChat, + } +} + +/** + * The SubscriptionUpdatePresence message a friend receives when the player changes rooms: + * a presence snapshot of who, and the redacted instance they're now in (null when in no + * room → `isOnline` false). `statusVisibility` is forced to 0 (Everyone) so the player + * isn't hidden from friends. `appVersion` MUST be a string — the client's presence DTO + * reads it with a string reader, and a numeric value aborts the whole SignalR frame + * ("expected String Begin Token"), dropping the room/presence update. + */ +function presenceUpdateMessage(playerId: number, instance: RoomInstance | null) { + return { + playerId, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 0, + roomInstance: instance ? redactInstanceForPresence(instance) : null, + isOnline: instance != null, + appVersion: GAME_VERSION, + } +} + +/** + * Push a SubscriptionUpdatePresence to every online friend of `playerId` after their + * presence changes (they entered a room). Mirrors the reference's PlayerPresenceChanged: + * only currently-connected friends receive it (an offline friend gets nothing, not a + * queued stale frame), so it's an ephemeral batch send. The room instance the friends + * see is read from the player's stored presence — the authoritative record just written, + * the same one the heartbeat replays. Best-effort: a hub or lookup failure is logged and + * swallowed, so it never fails the matchmake that triggered it. + */ +async function notifyFriendsPresence(c: Context, playerId: number): Promise { + try { + const friendIds = await getFriendIds(c.env.DB, playerId) + if (friendIds.length === 0) return + const presence = await getPresence(c.env.DB, playerId) + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral( + friendIds, + NotificationType.SubscriptionUpdatePresence, + presenceUpdateMessage(playerId, presence?.roomInstance ?? null) + ) + } catch (err) { + logger.error('failed to push SubscriptionUpdatePresence to friends', { + playerId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + /** * Store the room instance the player just matchmade into, preserving status. * @@ -180,6 +270,11 @@ async function enterRoom(c: Context, id: number, roomInstance: RoomInstance if (leftId != null && leftId !== roomInstance.roomInstanceId) { await refreshInstanceFullness(c.env.DB, leftId) } + + // The player's presence changed — tell their online friends where they went, reading + // the instance back from the presence we just stored. Best-effort; never blocks or + // fails the matchmake. + await notifyFriendsPresence(c, id) } /** @@ -194,6 +289,60 @@ const DORM_PHOTON_ROOM_ID = '00000000-0000-4000-8000-000000000001' /** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */ const NO_SUCH_ROOM = 20 +/** The notifications hub is a single global DO instance (see the `notify` worker). */ +const HUB_INSTANCE = 'global' + +/** + * A fresh id for a *live* (non-persisted) message — the reference's + * `NextLiveMessageID`. A game invite is ephemeral (never stored), so there's no + * database sequence to draw from; epoch milliseconds give a monotonically increasing, + * effectively unique id the client can key the invite off. It only has to be distinct + * among a player's in-flight invites, not globally. + */ +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` + * is the raw roomInstanceId string the message carries; `roomId` (nullable) tells the + * client which room it points at. Best-effort: a hub failure is logged and swallowed. + * + * Shared by `POST /invite` (a single explicit invite) and the party fan-out on a room + * matchmake (one per `AdditionalPlayerIds` entry), so the two can't drift. + */ +async function sendGameInvite( + c: Context, + fromId: number, + toId: number, + data: string, + roomId: number | null +): Promise { + const message = { + Id: nextLiveMessageId(), + FromPlayerId: fromId, + ToPlayerId: toId, + Type: MessageType.GameInvite, + Data: data, + SentTime: new Date().toISOString(), + RoomId: roomId, + } + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + toId, + NotificationType.MessageReceived, + message + ) + } catch (err) { + logger.error('failed to push game-invite MessageReceived notification', { + fromPlayerId: fromId, + toPlayerId: toId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + /** * The sentinel room-instance id the `auth` worker seeds a brand-new player's * Orientation presence with (see auth's `placeNewPlayerInOrientation`). The client @@ -307,6 +456,61 @@ async function readJoinMode(c: Context): Promise { return typeof body.JoinMode === 'string' ? Number.parseInt(body.JoinMode, 10) || 0 : 0 } +/** + * Read the room-matchmake form body once: `JoinMode` (2 = private) plus the party members + * to pull along (`AdditionalPlayerIds`). The 2023 client posts its party on a room + * matchmake so they can be invited into the instance the leader lands in; + * `AdditionalPlayerIds` may repeat and/or be comma-separated, and ids are parsed + * defensively, de-duplicated, and non-positive/garbage entries dropped. Parsed with + * `{ all: true }` in one pass so repeated fields survive. + */ +async function readMatchmakeBody( + c: Context +): Promise<{ joinMode: number; additionalPlayerIds: number[] }> { + const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record) + const firstString = (v: unknown): string | undefined => { + const first = Array.isArray(v) ? v[0] : v + return typeof first === 'string' ? first : undefined + } + const joinModeRaw = firstString(body.JoinMode) + const joinMode = joinModeRaw === undefined ? 0 : Number.parseInt(joinModeRaw, 10) || 0 + + const key = Object.keys(body).find((k) => k.toLowerCase() === 'additionalplayerids') + const raw = key === undefined ? [] : body[key] + const values = Array.isArray(raw) ? raw : [raw] + const additionalPlayerIds = [ + ...new Set( + values + .filter((v): v is string => typeof v === 'string') + .flatMap((v) => v.split(',')) + .map((s) => Number.parseInt(s.trim(), 10)) + .filter((n) => !Number.isNaN(n) && n > 0) + ), + ] + return { joinMode, additionalPlayerIds } +} + +/** + * Invite the caller's party members into the instance the caller just matchmade into — + * the `AdditionalPlayerIds` fan-out. Each member gets the same game invite `POST /invite` + * 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. + */ +async function inviteParty( + c: Context, + leaderId: number, + playerIds: number[], + instance: RoomInstance +): Promise { + const data = String(instance.roomInstanceId) + await Promise.all( + playerIds + .filter((pid) => pid !== leaderId) + .map((pid) => sendGameInvite(c, leaderId, pid, data, instance.roomId)) + ) +} + /** * Resolve a room by `:room` path segment (numeric id or name) from D1, then find a * joinable instance of it (public matchmakes reuse one via the `room_instance` @@ -601,14 +805,33 @@ const app = new Hono() // The heartbeat echoes the same player payload `/player` serves; with no stored // presence it falls back to what the client just posted. - return c.json({ + const payload = { ...playerPayload(hb.playerId ? hb.playerId : id, presence), statusVisibility: presence?.statusVisibility ?? hb.statusVisibility ?? 0, deviceClass: presence?.deviceClass ?? hb.deviceClass ?? 0, vrMovementMode: presence?.vrMovementMode ?? (hb.vrMovementMode ? hb.vrMovementMode : 1), appVersion: presence?.appVersion || hb.appVersion || GAME_VERSION, platform: presence?.platform ?? hb.platform ?? 0, - }) + } + + // Also push the presence over the websocket as a PresenceHeartbeatResponse, + // mirroring the reference's ReturnHeartbeat. Sent ephemerally (never queued) — + // a heartbeat fires on every beat, and a queued copy would pile up and arrive + // stale. Best-effort: the HTTP body carries the same payload regardless. + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayerEphemeral( + id, + NotificationType.PresenceHeartbeatResponse, + payload + ) + } catch (err) { + logger.error('failed to push PresenceHeartbeatResponse notification', { + playerId: id, + error: err instanceof Error ? err.message : String(err), + }) + } + + return c.json(payload) } ) @@ -782,6 +1005,67 @@ const app = new Hono() } ) + // 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 + // instance from their stored presence and places the caller into that same instance + // (the real, un-redacted Photon coordinates — the caller is authorized to join). + // Registered before the single-segment `/matchmake/:room` route so `player` isn't read + // as a room name. Returns errorCode 20 with a null instance when the target isn't a + // friend or isn't currently in a room. + .post( + '/matchmake/player/:playerId{[0-9]+}', + describeRoute({ + tags: ['Navigation'], + summary: 'Follow a friend into their room', + description: [ + 'Places the caller into the room instance the target player is currently in, read', + 'from the target’s stored presence. FRIENDS ONLY: the caller must be a mutual friend', + 'of the target (otherwise anyone could read a player’s presence and warp to them).', + 'Returns errorCode 20 with a null instance when the target isn’t a friend, is the', + 'caller themselves, or isn’t currently in a room.', + ].join(' '), + security: AUTHED, + parameters: [ + { + name: 'playerId', + in: 'path', + required: true, + description: 'The friend to follow (digits only)', + schema: { type: 'string', pattern: '^[0-9]+$' }, + }, + ], + responses: { + 200: json( + MatchmakeResponse, + 'The friend’s instance (or errorCode 20 with null when it can’t be joined)' + ), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const targetId = Number.parseInt(c.req.param('playerId'), 10) + // Friends only, and never yourself — otherwise refuse without leaking whether the + // target is even online (same opaque NoSuchRoom the club path uses). + if (targetId === id || !(await areFriends(c.env.DB, id, targetId))) { + return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) + } + + // The instance the friend is currently in, straight off their presence row. + const targetPresence = await getPresence(c.env.DB, targetId) + const instance = targetPresence?.roomInstance ?? null + if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) + + // Join that same instance (same id + Photon room) and store it as the caller's + // presence, so the heartbeat replays it and their own friend fan-out fires. + await enterRoom(c, id, instance) + return c.json({ errorCode: 0, roomInstance: instance }) + } + ) + // Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}` // — the client uses this to enter a room's other scenes). The subroom decides the // scene the client loads and which instances are joinable, so it must be carried @@ -796,7 +1080,7 @@ const app = new Hono() 'and which instances are joinable; an unknown subroom falls back to the room’s first.', ].join(' '), security: AUTHED, - requestBody: form(JoinModeRequest, 'Optional JoinMode'), + requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'), parameters: [ { name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }, { @@ -815,7 +1099,7 @@ const app = new Hono() async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - const joinMode = await readJoinMode(c) + const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c) const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10) const instance = await resolveRoomInstance( c, @@ -826,6 +1110,8 @@ const app = new Hono() ) if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) await enterRoom(c, id, instance) + // Pull the caller's party (AdditionalPlayerIds) into the instance they landed in. + await inviteParty(c, id, additionalPlayerIds, instance) return c.json({ errorCode: 0, roomInstance: instance }) } ) @@ -842,7 +1128,7 @@ const app = new Hono() 'carries its real scene, and stores it as presence.', ].join(' '), security: AUTHED, - requestBody: form(JoinModeRequest, 'Optional JoinMode'), + requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'), parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }], responses: { 200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'), @@ -852,10 +1138,12 @@ const app = new Hono() async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - const joinMode = await readJoinMode(c) + const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c) const instance = await resolveRoomInstance(c, c.req.param('roomId'), joinMode === 2, id) if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) await enterRoom(c, id, instance) + // Pull the caller's party (AdditionalPlayerIds) into the instance they landed in. + await inviteParty(c, id, additionalPlayerIds, instance) return c.json({ errorCode: 0, roomInstance: instance }) } ) @@ -945,6 +1233,61 @@ const app = new Hono() (c) => c.body(null, 200) ) + // ---- Social -------------------------------------------------------------- + // Invite a player to join the caller in their room instance. The caller is the + // inviter (from the Bearer token); the form carries the target `playerId` and the + // `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. + .post( + '/invite', + describeRoute({ + tags: ['Social'], + summary: 'Invite a player into the caller’s room instance', + description: [ + '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 instance’s `RoomId` rides', + 'on the message. Acks 200 (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, + 400: { description: 'Missing, non-numeric, or zero playerId (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = await c.req.parseBody().catch(() => ({}) as Record) + const str = (v: unknown) => (typeof v === 'string' ? v : '') + const toPlayerId = Number.parseInt(str(body.playerId), 10) + // A missing or zero target is a bad request (mirrors the reference's guard). + if (Number.isNaN(toPlayerId) || toPlayerId === 0) return c.body(null, 400) + + const roomInstanceIdStr = str(body.roomInstanceId) + const roomInstanceId = Number.parseInt(roomInstanceIdStr, 10) + + // Resolve the instance to stamp the invite's RoomId — the client reads it to know + // which room the invite points at. A missing/unknown instance just leaves RoomId + // null (buildNotificationPayload drops it from the frame), as the reference does. + let roomId: number | null = null + if (!Number.isNaN(roomInstanceId) && roomInstanceId > 0) { + const instance = await getRoomInstance(c.env.DB, roomInstanceId) + if (instance) roomId = instance.roomId + } + + await sendGameInvite(c, id, toPlayerId, roomInstanceIdStr, roomId) + return c.body(null, 200) + } + ) + // ---- Room instance ------------------------------------------------------- .post( '/roominstance/:id/reportjoinresult', diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index 09c844c..643ff40 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -166,3 +166,27 @@ export const StatusVisibilityRequest = z.object({ export const JoinModeRequest = z.object({ JoinMode: z.string().optional().describe('"2" requests a private instance'), }) + +/** + * The room-matchmake form body (`/matchmake/room/:roomId[/:subRoomId]`). Beyond + * `JoinMode` the 2023 client posts `AdditionalPlayerIds` — the caller's party — so each + * of them is invited (a game invite) into the instance the leader lands in. May repeat + * and/or be comma-separated. Other fields the client sends (`LoginLock`, + * `MaxPersistenceVersion`, `BypassMovementModeRestriction`) are accepted and ignored. + */ +export const MatchmakeRoomRequest = z.object({ + JoinMode: z.string().optional().describe('"2" requests a private instance'), + AdditionalPlayerIds: z + .string() + .optional() + .describe('Party members to invite into the room; repeatable and/or comma-separated'), +}) + +/** `POST /invite` form body — invite a player into the caller's room instance. */ +export const InviteRequest = z.object({ + playerId: z.string().describe('The account to invite; a non-zero integer (else 400)'), + roomInstanceId: z + .string() + .optional() + .describe('The caller’s room instance to invite them into; resolves the invite’s RoomId'), +}) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index a641183..e61bfe4 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -10,6 +10,7 @@ import { beforeAll, describe, expect, test } from 'vitest' import { countPlayersInInstance, + createRoomInstance, GAME_VERSION, getRoomInstance, PRESENCE_SCHEMA_DDL, @@ -143,6 +144,25 @@ beforeAll(async () => { insertMember.bind(4, 123, -1), // banned insertMember.bind(5, 120, 100), ]) + + // 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( + `CREATE TABLE IF NOT EXISTS relationship ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + requester_id INTEGER NOT NULL, + target_id INTEGER NOT NULL, + relationship_type INTEGER NOT NULL DEFAULT 0 + )` + ).run() + const insertRel = env.DB.prepare( + 'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)' + ) + await env.DB.batch([ + insertRel.bind(9700, 9701, 3), // friends (9700 requested) — friend is the target + insertRel.bind(9702, 9700, 3), // friends (9702 requested) — friend is the requester + insertRel.bind(9700, 9703, 1), // pending request out — 9703 is NOT a friend + ]) }) // Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the @@ -691,6 +711,31 @@ describe('auth-gated endpoints', () => { }) }) + test('heartbeat pushes a PresenceHeartbeatResponse over the websocket', async () => { + // The notify DO is stubbed to record every send (see vitest.config). + type Sent = { playerId: number; notificationType: number; data: Record } + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + await hub().fetch('http://do/all', { method: 'DELETE' }) + + const headers = await bearer('9600') + await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers }) + const res = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ statusVisibility: 2 }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + + const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[] + // Exactly one frame: PresenceHeartbeatResponse (4) to the beating player, whose + // payload is the very presence the HTTP body carried. + expect(sent).toHaveLength(1) + expect(sent[0].playerId).toBe(9600) + expect(sent[0].notificationType).toBe(4) // NotificationType.PresenceHeartbeatResponse + expect(sent[0].data).toEqual(body) + }) + // Seed presence directly into D1 with a chosen `expiresAt` (epoch seconds) so the // TTL-refresh branch can be exercised deterministically (independent of timing). const seedPresence = (id: number, expiresAt: number) => @@ -956,6 +1001,262 @@ describe('auth-gated endpoints', () => { expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length) }) + test('POST /invite pushes a game-invite MessageReceived to the target', async () => { + // The notify DO is stubbed to record every notifyPlayer call (see vitest.config). + type Sent = { + playerId: number + notificationType: number + data: { + Id: number + FromPlayerId: number + ToPlayerId: number + Type: number + Data: string + SentTime: string + RoomId: number | null + } + } + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + const reset = () => hub().fetch('http://do/all', { method: 'DELETE' }) + const sent = async (): Promise => + (await (await hub().fetch('http://do/all')).json()) as Sent[] + + await reset() + + // A live instance of room 2 to invite the target into. + const instance = await createRoomInstance(env.DB, { + ownerAccountId: 42, + roomId: 2, + subRoomId: 2, + photonRoomId: crypto.randomUUID(), + name: '^RecCenter', + maxCapacity: 12, + }) + + const invite = async (body: string, sub?: string): Promise => + exports.default.fetch(`${ORIGIN}/invite`, { + method: 'POST', + headers: { + ...(sub === undefined ? {} : await bearer(sub)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }) + + // 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) + + const notes = await sent() + expect(notes).toHaveLength(1) + expect(notes[0].playerId).toBe(153) // delivered to the invitee, not the caller + expect(notes[0].notificationType).toBe(2) // NotificationType.MessageReceived + expect(notes[0].data).toMatchObject({ + FromPlayerId: 42, // the caller + ToPlayerId: 153, + Type: 0, // MessageType.GameInvite + Data: String(instance.roomInstanceId), // raw roomInstanceId string + RoomId: 2, // resolved from the instance + }) + expect(notes[0].data.Id).toBeGreaterThan(0) + expect(typeof notes[0].data.SentTime).toBe('string') + + // A missing token is a 401 and a missing/zero/non-numeric playerId a 400 — and + // none of them push a notification. + await reset() + expect((await invite('playerId=153')).status).toBe(401) + expect((await invite('playerId=0', '42')).status).toBe(400) + expect((await invite('playerId=abc', '42')).status).toBe(400) + expect(await sent()).toHaveLength(0) + + // An unknown (or absent) room instance still delivers the invite — just with a null + // RoomId (which the real hub drops from the frame). + const noRoom = await invite('playerId=153&roomInstanceId=999999', '42') + expect(noRoom.status).toBe(200) + const after = await sent() + expect(after).toHaveLength(1) + expect(after[0].data.RoomId).toBeNull() + expect(after[0].data.Data).toBe('999999') + }) + + test('matchmake pushes SubscriptionUpdatePresence to the player’s friends', async () => { + // The notify DO is stubbed to record every send (see vitest.config). The friend + // fan-out is a single batch call carrying the friend ids. + type Batch = { + playerIds: number[] + notificationType: number + data: { + playerId: number + statusVisibility: number + isOnline: boolean + appVersion: string + roomInstance: Record | null + } + } + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + await hub().fetch('http://do/all', { method: 'DELETE' }) + + // 9700 enters RecCenter (room 2, public). + const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { + method: 'POST', + headers: await bearer('9700'), + }) + expect(res.status).toBe(200) + const mm = (await res.json()) as { roomInstance: { roomInstanceId: number } } + + const sent = (await (await hub().fetch('http://do/all')).json()) as Batch[] + expect(sent).toHaveLength(1) + const batch = sent[0] + // Delivered to the two friends (in both graph directions), not the pending-request + // player (9703). + expect(batch.playerIds.slice().sort((a, b) => a - b)).toEqual([9701, 9702]) + expect(batch.notificationType).toBe(12) // NotificationType.SubscriptionUpdatePresence + expect(batch.data).toMatchObject({ + playerId: 9700, + statusVisibility: 0, // Everyone — not hidden from friends + isOnline: true, + appVersion: GAME_VERSION, // a STRING, matching the client build + }) + expect(typeof batch.data.appVersion).toBe('string') + // The redacted instance the friends see: the room just entered (read back from the + // player's stored presence). photonRoomId and dataBlob are blanked so a friend can't + // use the leaked Photon room id to join a private instance directly; photonRegion is + // dropped entirely. + expect(batch.data.roomInstance).toMatchObject({ + roomId: 2, + roomInstanceId: mm.roomInstance.roomInstanceId, + photonRoomId: '', // blanked + dataBlob: '', // blanked + }) + expect(batch.data.roomInstance).not.toHaveProperty('photonRegion') + expect(batch.data.roomInstance).toHaveProperty('photonRegionId') + + // A player with no friends triggers no fan-out (empty list → no hub call). + await hub().fetch('http://do/all', { method: 'DELETE' }) + await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { + method: 'POST', + headers: await bearer('9999'), + }) + expect(await (await hub().fetch('http://do/all')).json()).toEqual([]) + }) + + test('POST /matchmake/player/:id follows a friend into their room, friends only', async () => { + // 9800 is friends with 9801 (in a room) and 9803 (not in any room); 9802 is not a + // friend. + const insertRel = env.DB.prepare( + 'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)' + ) + await env.DB.batch([insertRel.bind(9800, 9801, 3), insertRel.bind(9803, 9800, 3)]) + + const follow = async (targetId: number, sub?: string): Promise => + exports.default.fetch(`${ORIGIN}/matchmake/player/${targetId}`, { + method: 'POST', + ...(sub === undefined ? {} : { headers: await bearer(sub) }), + }) + type Result = { + errorCode: number + roomInstance: { roomInstanceId: number; photonRoomId: string; roomId: number } | null + } + + // The friend (9801) enters RecCenter → they now have a presence with an instance. + const friendMM = (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { + method: 'POST', + headers: await bearer('9801'), + }) + ).json()) as Result + + // 9800 follows 9801 → placed into the SAME instance, with the real (un-redacted) + // Photon room id, since they're authorized to join. + const res = await follow(9801, '9800') + expect(res.status).toBe(200) + const body = (await res.json()) as Result + expect(body.errorCode).toBe(0) + expect(body.roomInstance?.roomInstanceId).toBe(friendMM.roomInstance?.roomInstanceId) + expect(body.roomInstance?.photonRoomId).toBe(friendMM.roomInstance?.photonRoomId) + expect(body.roomInstance?.photonRoomId).not.toBe('') + + // And 9800's presence now points at that instance (the heartbeat replays it). + const hb = (await ( + await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { + method: 'POST', + headers: { ...(await bearer('9800')), 'Content-Type': 'application/json' }, + body: '{}', + }) + ).json()) as { roomInstance: { roomInstanceId: number } | null } + expect(hb.roomInstance?.roomInstanceId).toBe(friendMM.roomInstance?.roomInstanceId) + + // A non-friend can't be followed → NoSuchRoom, null instance (no leak of their state). + expect(await (await follow(9802, '9800')).json()).toEqual({ errorCode: 20, roomInstance: null }) + // You can't follow yourself. + expect(await (await follow(9800, '9800')).json()).toEqual({ errorCode: 20, roomInstance: null }) + // A friend who isn't in any room → nothing to join. + expect(await (await follow(9803, '9800')).json()).toEqual({ errorCode: 20, roomInstance: null }) + + // No token → 401. + expect((await follow(9801)).status).toBe(401) + }) + + test('POST /matchmake/room/:id invites AdditionalPlayerIds (party) into the instance', async () => { + // Party invites go out as game invites over notifyPlayer (see vitest stub). 9850 has + // no friends, so the only recorded sends are the party invites (no presence fan-out). + type Invite = { + playerId: number + notificationType: number + data: { + FromPlayerId: number + ToPlayerId: number + Type: number + Data: string + RoomId: number | null + } + } + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + const reset = () => hub().fetch('http://do/all', { method: 'DELETE' }) + const sent = async (): Promise => + (await (await hub().fetch('http://do/all')).json()) as Invite[] + + const matchmake = async (body: string, sub = '9850'): Promise => + exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + + // The client's exact request shape (the extra fields are accepted and ignored), one + // party member. + await reset() + const res = await matchmake( + 'BypassMovementModeRestriction=False&LoginLock=abc&AdditionalPlayerIds=153&MaxPersistenceVersion=51&JoinMode=0' + ) + expect(res.status).toBe(200) + const instance = ((await res.json()) as { roomInstance: { roomInstanceId: number } }) + .roomInstance + + const invites = await sent() + expect(invites).toHaveLength(1) + expect(invites[0].playerId).toBe(153) // delivered to the party member + expect(invites[0].notificationType).toBe(2) // NotificationType.MessageReceived + expect(invites[0].data).toMatchObject({ + FromPlayerId: 9850, // the party leader (caller) + ToPlayerId: 153, + Type: 0, // MessageType.GameInvite + Data: String(instance.roomInstanceId), // the instance the leader landed in + RoomId: 2, + }) + + // Multiple ids (comma-separated), de-duplicated, and the leader themselves is skipped. + await reset() + await matchmake('AdditionalPlayerIds=153,154,153,9850&JoinMode=0') + const many = await sent() + expect(many.map((i) => i.playerId).sort((a, b) => a - b)).toEqual([153, 154]) + + // No AdditionalPlayerIds → nobody is invited. + await reset() + await matchmake('JoinMode=0') + expect(await sent()).toEqual([]) + }) + test('GET /openapi.json documents every route', async () => { const res = await exports.default.fetch(`${ORIGIN}/openapi.json`) expect(res.status).toBe(200) @@ -983,8 +1284,10 @@ describe('auth-gated endpoints', () => { 'GET /rooms/requiring/rrplus', 'POST /goto/none', 'POST /goto/room/{room}', + 'POST /invite', 'POST /matchmake/club/{clubId}', 'POST /matchmake/none', + 'POST /matchmake/player/{playerId}', 'POST /matchmake/room/{roomId}', 'POST /matchmake/room/{roomId}/{subRoomId}', 'POST /matchmake/{room}', diff --git a/apps/match/vitest.config.ts b/apps/match/vitest.config.ts index de0d903..6b282fe 100644 --- a/apps/match/vitest.config.ts +++ b/apps/match/vitest.config.ts @@ -9,6 +9,49 @@ export default defineConfig({ bindings: { ENVIRONMENT: 'VITEST', }, + // The worker's RECFLARE_NOTIFICATIONS_HUB binding points at the `notify` + // worker's DO (script_name: "notify"). That worker isn't part of this + // isolated test, so provide a minimal stub exposing the same NotificationsHub + // RPC surface. notifyPlayer records every call so the invite test can assert + // the notification the worker pushed (type + payload): GET the DO for the most + // recent one, GET /all for the whole list, DELETE to reset between assertions. + workers: [ + { + name: 'notify', + modules: true, + compatibilityDate: '2026-06-16', + compatibilityFlags: ['nodejs_compat'], + durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' }, + script: ` + import { DurableObject } from 'cloudflare:workers' + export class NotificationsHub extends DurableObject { + sent = [] + async notifyPlayer(playerId, notificationType, data) { + this.sent.push({ playerId, notificationType, data }) + return { delivered: 0, queued: true } + } + async notifyPlayerEphemeral(playerId, notificationType, data) { + this.sent.push({ playerId, notificationType, data }) + return { delivered: 0 } + } + async notifyPlayersEphemeral(playerIds, notificationType, data) { + this.sent.push({ playerIds, notificationType, data }) + return { delivered: 0 } + } + async broadcast() { return { delivered: 0 } } + async fetch(request) { + if (request.method === 'DELETE') { + this.sent = [] + return new Response(null, { status: 204 }) + } + if (new URL(request.url).pathname === '/all') return Response.json(this.sent) + return Response.json(this.sent.at(-1) ?? null) + } + } + export default { fetch() { return new Response('ok') } } + `, + }, + ], }, }), ], diff --git a/apps/match/wrangler.jsonc b/apps/match/wrangler.jsonc index f72fd5c..1e7f000 100644 --- a/apps/match/wrangler.jsonc +++ b/apps/match/wrangler.jsonc @@ -33,6 +33,17 @@ "secret_name": "JWT_SECRET" } ], + // The `notify` worker's NotificationsHub DO — used by POST /invite to push the + // game-invite message to the invitee's live socket. + "durable_objects": { + "bindings": [ + { + "name": "RECFLARE_NOTIFICATIONS_HUB", + "class_name": "NotificationsHub", + "script_name": "notify" + } + ] + }, "upload_source_maps": true, "observability": { "logs": { diff --git a/apps/notify/src/notifications-hub.ts b/apps/notify/src/notifications-hub.ts index 3abb527..6059e72 100644 --- a/apps/notify/src/notifications-hub.ts +++ b/apps/notify/src/notifications-hub.ts @@ -394,6 +394,43 @@ export class NotificationsHub extends DurableObject { return { delivered, queued: false } } + /** + * Send a notification to a player's live sockets and, unlike {@link notifyPlayer}, + * NEVER queue it when they're offline — the ephemeral "SendWebsocket" send. For + * high-frequency, transient frames whose value is gone by the next reconnect (the + * presence heartbeat response, sent on every beat): queueing those would pile up + * unbounded in `pending` and then deliver a burst of stale frames when the player + * next connects. Returns how many live sockets received it (0 = nobody was + * connected, and it was dropped rather than stored). + */ + async notifyPlayerEphemeral( + playerId: number, + notificationType: string | number, + data?: Record + ): Promise<{ delivered: number }> { + const payload = this.buildNotificationPayload(notificationType, data) + return { delivered: this.deliverToPlayer(playerId, payload) } + } + + /** + * Ephemeral send (see {@link notifyPlayerEphemeral}) to many players at once — the + * batch the presence fan-out needs. When a player changes rooms, every online friend + * gets one SubscriptionUpdatePresence and an offline friend gets nothing (a SignalR + * group send reaches only connected clients), so the same identical payload is built + * once and delivered to each in a single RPC round-trip rather than one call per + * friend. Returns the total live sockets reached across all of them. + */ + async notifyPlayersEphemeral( + playerIds: number[], + notificationType: string | number, + data?: Record + ): Promise<{ delivered: number }> { + const payload = this.buildNotificationPayload(notificationType, data) + let delivered = 0 + for (const playerId of playerIds) delivered += this.deliverToPlayer(playerId, payload) + return { delivered } + } + /** * Send a "coach" message to every connected client (mirrors the reference * `SendCoachMessageAll`, using the hub's live connections as the online set): each diff --git a/packages/domain/src/enums.ts b/packages/domain/src/enums.ts index 14f64c3..6744ce6 100644 --- a/packages/domain/src/enums.ts +++ b/packages/domain/src/enums.ts @@ -15,6 +15,55 @@ export enum RoomInstanceType { Clubhouse = 5, } +/** + * The `Type` byte on a messaging `Message` — how the client dispatches a message it + * receives (a game invite renders the join prompt, a text message the chat bubble, …). + * Distinct from the notify hub's {@link NotificationType}: a message is delivered *as* + * a `MessageReceived` (NotificationType 2) notification whose payload is a `Message`, + * and this enum is that inner `Message.Type`. Mirrors the reference's `MessageType`. + */ +export enum MessageType { + GameInvite = 0, + GameInviteDeclined = 1, + GameJoinFailed = 2, + PartyActivitySwitch = 3, + FriendInvite = 4, + VoteToKick = 5, + GameInviteV2 = 6, + PartyActivitySwitchV2 = 7, + RequestGameInvite = 10, + RequestGameInviteDeclined = 11, + FriendStatusOnline = 20, + TextMessage = 30, + FriendRequestAccepted = 40, + PlayerCheer = 50, + PlayerCheerAnonymous = 51, + RoomCoOwnerAdded = 60, + RoomCoOwnerRemoved = 61, + RoomCoOwnerInvited = 62, + CreatorPublishedNewRoom = 70, + PlayerAttendingEvent = 80, + PlayerEventInvitation = 81, + DeprecatedGroupInvitation = 90, + DeprecatedPlayerJoinedGroup = 91, + CoachMessage = 100, + NewRoomComments = 110, + PartyUpRequest = 120, + FriendIntroduction = 130, + ClubMemberInvited = 200, + ClubModeratorInvited = 201, + ClubCoownerInvited = 202, + VirtualClubAnnouncementRoomPublished = 100000, + VirtualClubAnnouncementInventionPublished = 100001, + VirtualClubAnnouncementGeneric = 100002, + VirtualClubAnnouncementPlayerEventPublished = 100003, + VirtualClubAnnouncementClub = 100004, + VirtualClubAnnouncementPlayer = 100005, + VirtualClubAnnouncementCode = 100006, + VirtualClubAnnouncementPhoto = 100007, + VirtualRoomNotification = 100008, +} + /** A room's (or image's) visibility, matching the client's `RoomAccessibility`. */ export enum Accessibility { Private = 0, diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index f249829..0c4df20 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -1,4 +1,4 @@ -export { RoomInstanceType, Accessibility, Role } from './enums' +export { RoomInstanceType, Accessibility, Role, MessageType } from './enums' export * from './accounts-db' export * from './clubs-db' export * from './images-db' @@ -7,3 +7,4 @@ export * from './rooms-db' export * from './room-instance-db' export * from './presence-db' export * from './gifts-db' +export * from './relationships-db' diff --git a/packages/domain/src/relationships-db.ts b/packages/domain/src/relationships-db.ts new file mode 100644 index 0000000..c727b35 --- /dev/null +++ b/packages/domain/src/relationships-db.ts @@ -0,0 +1,56 @@ +/** + * Read-only access to the friendship graph on the shared `recflare` D1 database. + * + * The `relationship` table's schema and every mutation are owned by the `api` worker + * (apps/api/src/relationships-db.ts, migrations/0001_relationship.sql). This module is + * the shared *reader* other workers need: the `match` worker looks up a player's friends + * to push a presence update to them when the player changes rooms. It only SELECTs the + * three columns that identify a friendship (requester/target/type), so it stays + * decoupled from the favorited/ignored/muted flag columns api layers on top. + */ + +/** + * `relationship_type` for a mutual friendship — mirror of api's `RelationshipType.Friend`. + * Pending requests (1 sent / 2 received) and bare ignore/mute rows (0) are not friends. + */ +const FRIEND_RELATIONSHIP_TYPE = 3 + +/** + * The account ids of a player's mutual friends. Exactly one relationship row exists per + * unordered pair, with the player on either side, so this reads both directions and + * returns whichever id isn't the player. Non-friend rows are excluded; the order is + * unspecified. + */ +export async function getFriendIds(db: D1Database, playerId: number): Promise { + const { results } = await db + .prepare( + `SELECT requester_id, target_id FROM relationship + WHERE relationship_type = ?2 AND (requester_id = ?1 OR target_id = ?1)` + ) + .bind(playerId, FRIEND_RELATIONSHIP_TYPE) + .all<{ requester_id: number; target_id: number }>() + return results.map((r) => (r.requester_id === playerId ? r.target_id : r.requester_id)) +} + +/** + * Whether two players are mutual friends. The single relationship row for the pair sits + * in either direction, so both are checked. A targeted single-row read — cheaper than + * {@link getFriendIds} when all you need is "are these two friends?" (e.g. gating a + * follow-a-friend matchmake). + */ +export async function areFriends( + db: D1Database, + playerId: number, + otherId: number +): Promise { + const row = await db + .prepare( + `SELECT 1 AS ok FROM relationship + WHERE relationship_type = ?3 + AND ((requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)) + LIMIT 1` + ) + .bind(playerId, otherId, FRIEND_RELATIONSHIP_TYPE) + .first<{ ok: number }>() + return row !== null +}