mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
fix invites
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
import type { HonoApp } from '@repo/hono-helpers'
|
import type { HonoApp } from '@repo/hono-helpers'
|
||||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
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 & {
|
export type Env = SharedHonoEnv & {
|
||||||
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
|
// 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
|
// read by the heartbeat and the batch `/player` lookup). See @repo/domain's
|
||||||
// presence-db (table owned/migrated by the `rooms` worker).
|
// presence-db (table owned/migrated by the `rooms` worker).
|
||||||
DB: D1Database
|
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<NotificationsHub>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Variables can be extended */
|
/** Variables can be extended */
|
||||||
|
|||||||
+349
-6
@@ -3,6 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
|||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
areFriends,
|
||||||
canManageRoom,
|
canManageRoom,
|
||||||
createRoomInstance,
|
createRoomInstance,
|
||||||
deleteExpiredPresence,
|
deleteExpiredPresence,
|
||||||
@@ -11,22 +12,29 @@ import {
|
|||||||
getAccount,
|
getAccount,
|
||||||
getClubSummary,
|
getClubSummary,
|
||||||
getExpiredPresenceInstanceIds,
|
getExpiredPresenceInstanceIds,
|
||||||
|
getFriendIds,
|
||||||
getJoinableInstance,
|
getJoinableInstance,
|
||||||
getOrCreateDormRoom,
|
getOrCreateDormRoom,
|
||||||
getPresence,
|
getPresence,
|
||||||
getPresences,
|
getPresences,
|
||||||
getRoomById,
|
getRoomById,
|
||||||
getRoomByName,
|
getRoomByName,
|
||||||
|
getRoomInstance,
|
||||||
getRoomInstancesByRoom,
|
getRoomInstancesByRoom,
|
||||||
isClubMember,
|
isClubMember,
|
||||||
|
MessageType,
|
||||||
refreshInstanceFullness,
|
refreshInstanceFullness,
|
||||||
RoomInstanceType,
|
RoomInstanceType,
|
||||||
setPresence,
|
setPresence,
|
||||||
setRoomInstanceInProgress,
|
setRoomInstanceInProgress,
|
||||||
} from '@repo/domain'
|
} 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'
|
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 {
|
import {
|
||||||
AUTHED,
|
AUTHED,
|
||||||
EMPTY_OK,
|
EMPTY_OK,
|
||||||
@@ -34,10 +42,12 @@ import {
|
|||||||
form,
|
form,
|
||||||
HeartbeatRequest as HeartbeatRequestSchema,
|
HeartbeatRequest as HeartbeatRequestSchema,
|
||||||
InProgressRequest,
|
InProgressRequest,
|
||||||
|
InviteRequest,
|
||||||
JoinModeRequest,
|
JoinModeRequest,
|
||||||
json,
|
json,
|
||||||
jsonBody,
|
jsonBody,
|
||||||
MatchmakeResponse,
|
MatchmakeResponse,
|
||||||
|
MatchmakeRoomRequest,
|
||||||
PlayerDto,
|
PlayerDto,
|
||||||
RoomInstanceDto,
|
RoomInstanceDto,
|
||||||
StatusVisibilityRequest,
|
StatusVisibilityRequest,
|
||||||
@@ -148,6 +158,86 @@ const PRESENCE_REFRESH_THRESHOLD = 300
|
|||||||
*/
|
*/
|
||||||
const DEFAULT_GET_PLAYER = [{ ...playerPayload(1), isOnline: true }]
|
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<App>, playerId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const friendIds = await getFriendIds(c.env.DB, playerId)
|
||||||
|
if (friendIds.length === 0) return
|
||||||
|
const presence = await getPresence<RoomInstance>(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.
|
* Store the room instance the player just matchmade into, preserving status.
|
||||||
*
|
*
|
||||||
@@ -180,6 +270,11 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
|
|||||||
if (leftId != null && leftId !== roomInstance.roomInstanceId) {
|
if (leftId != null && leftId !== roomInstance.roomInstanceId) {
|
||||||
await refreshInstanceFullness(c.env.DB, leftId)
|
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. */
|
/** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */
|
||||||
const NO_SUCH_ROOM = 20
|
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<App>,
|
||||||
|
fromId: number,
|
||||||
|
toId: number,
|
||||||
|
data: string,
|
||||||
|
roomId: number | null
|
||||||
|
): Promise<void> {
|
||||||
|
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
|
* The sentinel room-instance id the `auth` worker seeds a brand-new player's
|
||||||
* Orientation presence with (see auth's `placeNewPlayerInOrientation`). The client
|
* Orientation presence with (see auth's `placeNewPlayerInOrientation`). The client
|
||||||
@@ -307,6 +456,61 @@ async function readJoinMode(c: Context<App>): Promise<number> {
|
|||||||
return typeof body.JoinMode === 'string' ? Number.parseInt(body.JoinMode, 10) || 0 : 0
|
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<App>
|
||||||
|
): Promise<{ joinMode: number; additionalPlayerIds: number[] }> {
|
||||||
|
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||||
|
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<App>,
|
||||||
|
leaderId: number,
|
||||||
|
playerIds: number[],
|
||||||
|
instance: RoomInstance
|
||||||
|
): Promise<void> {
|
||||||
|
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
|
* 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`
|
* joinable instance of it (public matchmakes reuse one via the `room_instance`
|
||||||
@@ -601,15 +805,34 @@ const app = new Hono<App>()
|
|||||||
|
|
||||||
// The heartbeat echoes the same player payload `/player` serves; with no stored
|
// The heartbeat echoes the same player payload `/player` serves; with no stored
|
||||||
// presence it falls back to what the client just posted.
|
// presence it falls back to what the client just posted.
|
||||||
return c.json({
|
const payload = {
|
||||||
...playerPayload(hb.playerId ? hb.playerId : id, presence),
|
...playerPayload(hb.playerId ? hb.playerId : id, presence),
|
||||||
statusVisibility: presence?.statusVisibility ?? hb.statusVisibility ?? 0,
|
statusVisibility: presence?.statusVisibility ?? hb.statusVisibility ?? 0,
|
||||||
deviceClass: presence?.deviceClass ?? hb.deviceClass ?? 0,
|
deviceClass: presence?.deviceClass ?? hb.deviceClass ?? 0,
|
||||||
vrMovementMode: presence?.vrMovementMode ?? (hb.vrMovementMode ? hb.vrMovementMode : 1),
|
vrMovementMode: presence?.vrMovementMode ?? (hb.vrMovementMode ? hb.vrMovementMode : 1),
|
||||||
appVersion: presence?.appVersion || hb.appVersion || GAME_VERSION,
|
appVersion: presence?.appVersion || hb.appVersion || GAME_VERSION,
|
||||||
platform: presence?.platform ?? hb.platform ?? 0,
|
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)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
.put(
|
.put(
|
||||||
@@ -782,6 +1005,67 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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<RoomInstance>(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}`
|
// 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
|
// — 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
|
// scene the client loads and which instances are joinable, so it must be carried
|
||||||
@@ -796,7 +1080,7 @@ const app = new Hono<App>()
|
|||||||
'and which instances are joinable; an unknown subroom falls back to the room’s first.',
|
'and which instances are joinable; an unknown subroom falls back to the room’s first.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
|
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } },
|
{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
{
|
{
|
||||||
@@ -815,7 +1099,7 @@ const app = new Hono<App>()
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(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 subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||||
const instance = await resolveRoomInstance(
|
const instance = await resolveRoomInstance(
|
||||||
c,
|
c,
|
||||||
@@ -826,6 +1110,8 @@ const app = new Hono<App>()
|
|||||||
)
|
)
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
await enterRoom(c, id, instance)
|
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 })
|
return c.json({ errorCode: 0, roomInstance: instance })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -842,7 +1128,7 @@ const app = new Hono<App>()
|
|||||||
'carries its real scene, and stores it as presence.',
|
'carries its real scene, and stores it as presence.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
|
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
|
||||||
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
|
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
|
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
|
||||||
@@ -852,10 +1138,12 @@ const app = new Hono<App>()
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(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)
|
const instance = await resolveRoomInstance(c, c.req.param('roomId'), joinMode === 2, id)
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
await enterRoom(c, id, instance)
|
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 })
|
return c.json({ errorCode: 0, roomInstance: instance })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -945,6 +1233,61 @@ const app = new Hono<App>()
|
|||||||
(c) => c.body(null, 200)
|
(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<string, unknown>)
|
||||||
|
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 -------------------------------------------------------
|
// ---- Room instance -------------------------------------------------------
|
||||||
.post(
|
.post(
|
||||||
'/roominstance/:id/reportjoinresult',
|
'/roominstance/:id/reportjoinresult',
|
||||||
|
|||||||
@@ -166,3 +166,27 @@ export const StatusVisibilityRequest = z.object({
|
|||||||
export const JoinModeRequest = z.object({
|
export const JoinModeRequest = z.object({
|
||||||
JoinMode: z.string().optional().describe('"2" requests a private instance'),
|
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'),
|
||||||
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
countPlayersInInstance,
|
countPlayersInInstance,
|
||||||
|
createRoomInstance,
|
||||||
GAME_VERSION,
|
GAME_VERSION,
|
||||||
getRoomInstance,
|
getRoomInstance,
|
||||||
PRESENCE_SCHEMA_DDL,
|
PRESENCE_SCHEMA_DDL,
|
||||||
@@ -143,6 +144,25 @@ beforeAll(async () => {
|
|||||||
insertMember.bind(4, 123, -1), // banned
|
insertMember.bind(4, 123, -1), // banned
|
||||||
insertMember.bind(5, 120, 100),
|
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
|
// 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<string, unknown> }
|
||||||
|
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<string, unknown>
|
||||||
|
|
||||||
|
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
|
// Seed presence directly into D1 with a chosen `expiresAt` (epoch seconds) so the
|
||||||
// TTL-refresh branch can be exercised deterministically (independent of timing).
|
// TTL-refresh branch can be exercised deterministically (independent of timing).
|
||||||
const seedPresence = (id: number, expiresAt: number) =>
|
const seedPresence = (id: number, expiresAt: number) =>
|
||||||
@@ -956,6 +1001,262 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length)
|
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<Sent[]> =>
|
||||||
|
(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<Response> =>
|
||||||
|
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<string, unknown> | 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<Response> =>
|
||||||
|
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<Invite[]> =>
|
||||||
|
(await (await hub().fetch('http://do/all')).json()) as Invite[]
|
||||||
|
|
||||||
|
const matchmake = async (body: string, sub = '9850'): Promise<Response> =>
|
||||||
|
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 () => {
|
test('GET /openapi.json documents every route', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -983,8 +1284,10 @@ describe('auth-gated endpoints', () => {
|
|||||||
'GET /rooms/requiring/rrplus',
|
'GET /rooms/requiring/rrplus',
|
||||||
'POST /goto/none',
|
'POST /goto/none',
|
||||||
'POST /goto/room/{room}',
|
'POST /goto/room/{room}',
|
||||||
|
'POST /invite',
|
||||||
'POST /matchmake/club/{clubId}',
|
'POST /matchmake/club/{clubId}',
|
||||||
'POST /matchmake/none',
|
'POST /matchmake/none',
|
||||||
|
'POST /matchmake/player/{playerId}',
|
||||||
'POST /matchmake/room/{roomId}',
|
'POST /matchmake/room/{roomId}',
|
||||||
'POST /matchmake/room/{roomId}/{subRoomId}',
|
'POST /matchmake/room/{roomId}/{subRoomId}',
|
||||||
'POST /matchmake/{room}',
|
'POST /matchmake/{room}',
|
||||||
|
|||||||
@@ -9,6 +9,49 @@ export default defineConfig({
|
|||||||
bindings: {
|
bindings: {
|
||||||
ENVIRONMENT: 'VITEST',
|
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') } }
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -33,6 +33,17 @@
|
|||||||
"secret_name": "JWT_SECRET"
|
"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,
|
"upload_source_maps": true,
|
||||||
"observability": {
|
"observability": {
|
||||||
"logs": {
|
"logs": {
|
||||||
|
|||||||
@@ -394,6 +394,43 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
return { delivered, queued: false }
|
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<string, unknown>
|
||||||
|
): 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<string, unknown>
|
||||||
|
): 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
|
* Send a "coach" message to every connected client (mirrors the reference
|
||||||
* `SendCoachMessageAll`, using the hub's live connections as the online set): each
|
* `SendCoachMessageAll`, using the hub's live connections as the online set): each
|
||||||
|
|||||||
@@ -15,6 +15,55 @@ export enum RoomInstanceType {
|
|||||||
Clubhouse = 5,
|
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`. */
|
/** A room's (or image's) visibility, matching the client's `RoomAccessibility`. */
|
||||||
export enum Accessibility {
|
export enum Accessibility {
|
||||||
Private = 0,
|
Private = 0,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { RoomInstanceType, Accessibility, Role } from './enums'
|
export { RoomInstanceType, Accessibility, Role, MessageType } from './enums'
|
||||||
export * from './accounts-db'
|
export * from './accounts-db'
|
||||||
export * from './clubs-db'
|
export * from './clubs-db'
|
||||||
export * from './images-db'
|
export * from './images-db'
|
||||||
@@ -7,3 +7,4 @@ export * from './rooms-db'
|
|||||||
export * from './room-instance-db'
|
export * from './room-instance-db'
|
||||||
export * from './presence-db'
|
export * from './presence-db'
|
||||||
export * from './gifts-db'
|
export * from './gifts-db'
|
||||||
|
export * from './relationships-db'
|
||||||
|
|||||||
@@ -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<number[]> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user