mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[match] fix 2025 game invites
This commit is contained in:
+302
-22
@@ -25,8 +25,10 @@ import {
|
||||
getRoomByName,
|
||||
getRoomInstance,
|
||||
getRoomInstancesByRoom,
|
||||
getRoomInvite,
|
||||
getRoomInstanceSummariesByRoom,
|
||||
getStoredRoomInstance,
|
||||
InviteMode,
|
||||
isClubMember,
|
||||
isPlayerBannedFromRoom,
|
||||
MatchmakingErrorCode,
|
||||
@@ -616,11 +618,105 @@ function nextLiveMessageId(): number {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* The first build that gets a game invite as {@link MessageType.GameInviteV2} rather than
|
||||
* the original {@link MessageType.GameInvite}. Inclusive — a client reporting exactly this
|
||||
* build is on v2; only OLDER builds get v1.
|
||||
*/
|
||||
const GAME_INVITE_V2_MIN_BUILD = 20230414
|
||||
|
||||
/**
|
||||
* The date portion of a client build (`20250718.01` → `20250718`) as a number, or null when
|
||||
* there's nothing parseable to compare. Builds are `<yyyymmdd>[.<n>]`, so the leading date
|
||||
* orders them; the point release after it never decides this gate.
|
||||
*/
|
||||
function buildNumber(version: string | null): number | null {
|
||||
const date = Number.parseInt(version?.split('.')[0] ?? '', 10)
|
||||
return Number.isNaN(date) ? null : date
|
||||
}
|
||||
|
||||
/**
|
||||
* Which game-invite message type this caller's client gets, from the `rn.ver` claim on
|
||||
* their token — v2 from {@link GAME_INVITE_V2_MIN_BUILD} onward, the original below it.
|
||||
*
|
||||
* A token that names no build (an older one issued before the claim carried it, or no
|
||||
* token at all) falls back to v1: that is what every client got before this gate existed,
|
||||
* so an unknown build is left where it was known to work rather than moved onto a message
|
||||
* type it may not have.
|
||||
*
|
||||
* The claim is the INVITER's, since theirs is the only token in the request — while the
|
||||
* message is parsed by the INVITEE. The two agree whenever a party is on one build, which
|
||||
* is the normal case; the invitee's own build is on their presence row (`appVersion`) if
|
||||
* this ever needs to key off the client that actually reads the frame.
|
||||
*/
|
||||
async function gameInviteType(c: Context<App>): Promise<MessageType> {
|
||||
const build = buildNumber(await callerVersion(c))
|
||||
return build !== null && build >= GAME_INVITE_V2_MIN_BUILD
|
||||
? MessageType.GameInviteV2
|
||||
: MessageType.GameInvite
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link InviteMode} a v2 game invite carries — what the invite is ASKING FOR, as
|
||||
* distinct from the instance it names.
|
||||
*
|
||||
* `PlayTogether` (22) is the plain "come join me", which is what both paths here send:
|
||||
* `POST /invite` is exactly that, and the party fan-out is the same ask aimed at several
|
||||
* people at once. `InviteParty` (2) is the other reading of a join-me, and the fan-out is
|
||||
* where it would go if the two turn out to be distinguishable — they have not been told
|
||||
* apart on the wire yet.
|
||||
*/
|
||||
const GAME_INVITE_V2_INVITE_MODE = InviteMode.PlayTogether
|
||||
|
||||
/**
|
||||
* The placeholder `InviteId` on a v2 invite that has no `room_invite` row behind it — the
|
||||
* party fan-out, which invites without recording anything. What the client does with the
|
||||
* field isn't known yet; `POST /invite` passes the real row id (see {@link RoomInvite}),
|
||||
* which is the obvious candidate, and this stands in where there is no row to name.
|
||||
*/
|
||||
const UNKNOWN_INVITE_ID = 0
|
||||
|
||||
/** What an invite points at, in the shape both message versions need to describe it. */
|
||||
type GameInviteTarget = {
|
||||
/** The raw roomInstanceId string — the WHOLE `Data` of a v1 invite. */
|
||||
instanceId: string
|
||||
/** The instance's room, or null when it didn't resolve. Rides on the message itself. */
|
||||
roomId: number | null
|
||||
/** The instance's `^`-prefixed wire name, `''` when the instance didn't resolve. */
|
||||
name: string
|
||||
/** The `room_invite` row this came from, or {@link UNKNOWN_INVITE_ID}. */
|
||||
inviteId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Data` a game invite carries, which is a different thing per message version:
|
||||
*
|
||||
* - v1 ({@link MessageType.GameInvite}) is the bare roomInstanceId as a string.
|
||||
* - v2 ({@link MessageType.GameInviteV2}) is an escaped JSON object,
|
||||
* `{"InviteId":…,"Name":"^GoldenTrophy","InviteMode":22}` — a STRING holding JSON, not a
|
||||
* nested object, since `Data` is a string field on the Message either way.
|
||||
*
|
||||
* `Name` is the instance's wire name, the same `^`/`@`-prefixed string presence carries.
|
||||
*/
|
||||
function gameInviteData(type: MessageType, target: GameInviteTarget): string {
|
||||
if (type !== MessageType.GameInviteV2) return target.instanceId
|
||||
return JSON.stringify({
|
||||
InviteId: target.inviteId,
|
||||
Name: target.name,
|
||||
InviteMode: GAME_INVITE_V2_INVITE_MODE,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* frame carrying a game-invite `Message` the client renders the join prompt from. `target`
|
||||
* describes the instance being invited into; what of it reaches the wire depends on the
|
||||
* message version (see {@link gameInviteData}), except `RoomId`, which rides on the message
|
||||
* itself in both. Best-effort: a hub failure is logged and swallowed.
|
||||
*
|
||||
* `type` is the caller's game-invite message type from {@link gameInviteType}, resolved by
|
||||
* the call site rather than here so the party fan-out settles it once for the whole party
|
||||
* instead of re-reading the same token per member.
|
||||
*
|
||||
* 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.
|
||||
@@ -629,17 +725,17 @@ async function sendGameInvite(
|
||||
c: Context<App>,
|
||||
fromId: number,
|
||||
toId: number,
|
||||
data: string,
|
||||
roomId: number | null
|
||||
target: GameInviteTarget,
|
||||
type: MessageType
|
||||
): Promise<void> {
|
||||
const message = {
|
||||
Id: nextLiveMessageId(),
|
||||
FromPlayerId: fromId,
|
||||
ToPlayerId: toId,
|
||||
Type: MessageType.GameInvite,
|
||||
Data: data,
|
||||
Type: type,
|
||||
Data: gameInviteData(type, target),
|
||||
SentTime: new Date().toISOString(),
|
||||
RoomId: roomId,
|
||||
RoomId: target.roomId,
|
||||
}
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
@@ -868,6 +964,16 @@ function toV2RoomInstance(instance: RoomInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The matchmake paths whose responses are the PascalCase envelope (`ErrorCode`,
|
||||
* `CorrelationId`, `RoomInstance`) rather than the lowercase one with its `result` twin.
|
||||
*
|
||||
* `/matchmake/v2/` is the 2025 client's rewrite of the older routes. `/matchmake/invite/`
|
||||
* has no older spelling — it only ever existed on the 2025 client, and so only ever spoke
|
||||
* this shape.
|
||||
*/
|
||||
const PASCAL_CASE_MATCHMAKE_PATHS = ['/matchmake/v2/', '/matchmake/invite/']
|
||||
|
||||
/**
|
||||
* A matchmake response: the join-result code, the instance (null on a refusal), and the
|
||||
* request's correlation id echoed back. Every matchmake route answers through here, so
|
||||
@@ -878,11 +984,13 @@ function toV2RoomInstance(instance: RoomInstance) {
|
||||
* every other consumer (and this worker's own tests) reads. They must never disagree —
|
||||
* that's why nothing builds this envelope by hand.
|
||||
*
|
||||
* The `/matchmake/v2/*` routes answer a different envelope — PascalCase `ErrorCode`,
|
||||
* `CorrelationId`, `RoomInstance`, no `result` twin — so the shape is chosen HERE, off
|
||||
* the request path, rather than in the handlers. That keeps the two spellings from
|
||||
* drifting and, more importantly, means everything that answers on a v2 path answers in
|
||||
* v2: the ban gate, a refusal, and the success all go through this one function.
|
||||
* Some routes answer a different envelope — PascalCase `ErrorCode`, `CorrelationId`,
|
||||
* `RoomInstance`, no `result` twin — so the shape is chosen HERE, off the request path,
|
||||
* rather than in the handlers. That keeps the two spellings from drifting and, more
|
||||
* importantly, means everything answering on such a path answers in that shape: the ban
|
||||
* gate, a refusal, and the success all go through this one function.
|
||||
*
|
||||
* See {@link PASCAL_CASE_MATCHMAKE_PATHS} for which paths those are.
|
||||
*/
|
||||
async function matchmakeResult(
|
||||
c: Context<App>,
|
||||
@@ -890,7 +998,7 @@ async function matchmakeResult(
|
||||
roomInstance: RoomInstance | null
|
||||
) {
|
||||
const correlationId = await readCorrelationId(c)
|
||||
if (c.req.path.startsWith('/matchmake/v2/')) {
|
||||
if (PASCAL_CASE_MATCHMAKE_PATHS.some((prefix) => c.req.path.startsWith(prefix))) {
|
||||
return c.json({
|
||||
ErrorCode: errorCode,
|
||||
CorrelationId: correlationId,
|
||||
@@ -963,11 +1071,21 @@ async function inviteParty(
|
||||
playerIds: number[],
|
||||
instance: RoomInstance
|
||||
): Promise<void> {
|
||||
const data = String(instance.roomInstanceId)
|
||||
// The leader's own instance, which every member is being pulled into. Nothing records
|
||||
// a `room_invite` row on this path, so a v2 invite has no real id to name.
|
||||
const target: GameInviteTarget = {
|
||||
instanceId: String(instance.roomInstanceId),
|
||||
roomId: instance.roomId,
|
||||
name: instance.name,
|
||||
inviteId: UNKNOWN_INVITE_ID,
|
||||
}
|
||||
// One read of the leader's token for the whole party — every member gets the same
|
||||
// message, so the type can't differ between them.
|
||||
const type = await gameInviteType(c)
|
||||
await Promise.all(
|
||||
playerIds
|
||||
.filter((pid) => pid !== leaderId)
|
||||
.map((pid) => sendGameInvite(c, leaderId, pid, data, instance.roomId))
|
||||
.map((pid) => sendGameInvite(c, leaderId, pid, target, type))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1922,6 +2040,153 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Accept a game invite and land in the inviter's instance
|
||||
// (`/matchmake/invite/{roomInviteId}`). The 2025 client's join button on an invite: it
|
||||
// carries the `RoomInviteId` minted by `POST /invite`, and this resolves that row to the
|
||||
// instance the INVITER is standing in right now.
|
||||
//
|
||||
// The row is the authorization. Only the player the invite was addressed to
|
||||
// (`ToPlayerId`) may redeem it, checked against the caller's own token — an invite id is
|
||||
// a small integer handed out to somebody else, so an ungated version of this would let
|
||||
// anyone walk into any private instance by counting upward. It is also single-target:
|
||||
// the inviter can't redeem their own invite, since they aren't its `ToPlayerId`.
|
||||
//
|
||||
// Where the caller goes is read from the inviter's LIVE presence, not from the invite's
|
||||
// `RoomId`: the row records which room was named when the invite was sent, while the
|
||||
// invitee needs the instance the inviter is in when they actually click, which may be a
|
||||
// different one (or none). Registered before `/matchmake/:room` so `invite` isn't read
|
||||
// as a room name.
|
||||
.post(
|
||||
'/matchmake/invite/:inviteId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Navigation', '2025'],
|
||||
summary: 'Accept a game invite',
|
||||
description: [
|
||||
'Places the caller into the room instance the INVITER is currently in, resolved from',
|
||||
'the `room_invite` row named by `roomInviteId` and the inviter’s live presence (not the',
|
||||
'invite’s stored `RoomId`, which records where they were when they sent it).',
|
||||
'',
|
||||
'ADDRESSEE ONLY: the caller must be the invite’s `ToPlayerId`, or it answers 76',
|
||||
'(InstanceJoinNotPermitted) — the id is a small integer held by another player, so an',
|
||||
'ungated form of this would be a way into any private instance. Answers 40',
|
||||
'(RoomInviteExpired) for an invite that isn’t there any more, 2 (PlayerNotOnline) when',
|
||||
'the inviter isn’t in a room, 17 (AlreadyInTargetInstance) when the caller is already',
|
||||
'standing in it, 3 (InsufficientSpace) when it filled up, and 55 (BannedFromRoom) when',
|
||||
'the caller is banned from the room they’d be joining.',
|
||||
'',
|
||||
'2025-client route: it answers the PascalCase `ErrorCode`/`RoomInstance` envelope, as',
|
||||
'the `/matchmake/v2/*` routes do, and has no older lowercase spelling.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(CorrelationIdRequest, 'The attempt’s CorrelationId'),
|
||||
parameters: [
|
||||
{
|
||||
name: 'inviteId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The `RoomInviteId` from `POST /invite` (digits only)',
|
||||
schema: { type: 'string', pattern: '^[0-9]+$' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(
|
||||
MatchmakeV2Response,
|
||||
'The inviter’s instance, or a null instance with the refusal code'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const inviteId = Number.parseInt(c.req.param('inviteId'), 10)
|
||||
const invite = await getRoomInvite(c.env.DB, inviteId)
|
||||
// No row means no longer good — expiry deletes rows rather than flagging them, and
|
||||
// ids are never reused, so "never existed" and "expired" are the same answer here.
|
||||
// (78 ChatPartyInviteNotFound is for a CHAT party invite, a different object.)
|
||||
if (invite === null) {
|
||||
logger.info('invite matchmake refused: no such invite', { inviteId, id })
|
||||
return matchmakeResult(c, MatchmakingErrorCode.RoomInviteExpired, null)
|
||||
}
|
||||
|
||||
// The gate: an invite is redeemable only by the player it was addressed to. Told
|
||||
// as "not permitted" rather than "no such invite" — the caller is holding an id
|
||||
// that is real, and pretending otherwise doesn't hide anything they don't have.
|
||||
if (invite.ToPlayerId !== id) {
|
||||
logger.info('invite matchmake refused: caller is not the invitee', {
|
||||
inviteId,
|
||||
toPlayerId: invite.ToPlayerId,
|
||||
id,
|
||||
})
|
||||
return matchmakeResult(c, MatchmakingErrorCode.InstanceJoinNotPermitted, null)
|
||||
}
|
||||
|
||||
// Where the inviter is NOW. An inviter who has left (or whose presence lapsed) has
|
||||
// nothing to join, which is what PlayerNotOnline says.
|
||||
const inviterPresence = await getPresence<RoomInstance>(c.env.DB, invite.FromPlayerId)
|
||||
const instance = inviterPresence?.roomInstance ?? null
|
||||
if (!instance) {
|
||||
logger.info('invite matchmake refused: inviter is not in a room', {
|
||||
inviteId,
|
||||
fromPlayerId: invite.FromPlayerId,
|
||||
id,
|
||||
})
|
||||
return matchmakeResult(c, MatchmakingErrorCode.PlayerNotOnline, null)
|
||||
}
|
||||
|
||||
// Already standing there: nothing to do, and re-entering would churn presence and
|
||||
// re-fire the friend fan-out for a move that didn't happen.
|
||||
const own = await getPresence<RoomInstance>(c.env.DB, id)
|
||||
if (own?.roomInstance?.roomInstanceId === instance.roomInstanceId) {
|
||||
return matchmakeResult(c, MatchmakingErrorCode.AlreadyInTargetInstance, null)
|
||||
}
|
||||
|
||||
// Like the follow-a-friend path, this hands out real Photon coordinates without
|
||||
// going through resolveRoomInstance, so the room's bans have to be checked here —
|
||||
// otherwise an invite is a way around one.
|
||||
if (await isPlayerBannedFromRoom(c.env.DB, instance.roomId, id)) {
|
||||
logger.info('invite matchmake refused: player banned from room', {
|
||||
roomId: instance.roomId,
|
||||
id,
|
||||
})
|
||||
return matchmakeResult(c, BANNED_FROM_ROOM, null)
|
||||
}
|
||||
|
||||
// Nor does it get the build scoping a room matchmake has by construction. Compared
|
||||
// against the INVITER's presence — they're the person actually standing in there.
|
||||
const refusal = crossBuildRefusal(
|
||||
await callerGameVersion(c),
|
||||
inviterPresence?.appVersion ?? GAME_VERSION
|
||||
)
|
||||
if (refusal !== null) {
|
||||
logger.info('invite matchmake refused: inviter is on another client build', {
|
||||
roomInstanceId: instance.roomInstanceId,
|
||||
fromPlayerId: invite.FromPlayerId,
|
||||
id,
|
||||
})
|
||||
return matchmakeResult(c, refusal, null)
|
||||
}
|
||||
|
||||
// Fullness read fresh rather than off the stored flag, which is only as current as
|
||||
// the last person to move: an invite is usually redeemed seconds after it's sent,
|
||||
// which is exactly when a nearly-full instance is still filling. Null means a
|
||||
// synthetic instance with no row (a dorm), which has no head-count to check.
|
||||
if ((await refreshInstanceFullness(c.env.DB, instance.roomInstanceId)) === true) {
|
||||
logger.info('invite matchmake refused: instance is full', {
|
||||
roomInstanceId: instance.roomInstanceId,
|
||||
id,
|
||||
})
|
||||
return matchmakeResult(c, MatchmakingErrorCode.InsufficientSpace, null)
|
||||
}
|
||||
|
||||
// Same instance, same Photon room, stored as the caller's presence so their
|
||||
// heartbeat replays it and their own friend fan-out fires.
|
||||
await enterRoom(c, id, instance)
|
||||
return matchmakeResult(c, MatchmakingErrorCode.Success, instance)
|
||||
}
|
||||
)
|
||||
|
||||
// Join one SPECIFIC live instance by id (`/matchmake/instance/{roomInstanceId}`) —
|
||||
// the action behind the owner's instance listing (`GET /room/{roomId}/instances`),
|
||||
// where they pick a session of their room and drop into it. Unlike every other
|
||||
@@ -2399,11 +2664,13 @@ const app = new Hono<App>()
|
||||
// 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
|
||||
}
|
||||
// The whole instance, not just its room: a v2 invite names it by `Name` too, and an
|
||||
// unresolved one leaves that empty rather than guessing at a name.
|
||||
const instance =
|
||||
!Number.isNaN(roomInstanceId) && roomInstanceId > 0
|
||||
? await getRoomInstance(c.env.DB, roomInstanceId)
|
||||
: null
|
||||
const roomId: number | null = instance?.roomId ?? null
|
||||
|
||||
// Record the invite before delivering it: the row is what mints the `RoomInviteId`
|
||||
// the response carries, while the frame itself is fire-and-forget.
|
||||
@@ -2416,7 +2683,20 @@ const app = new Hono<App>()
|
||||
return c.body(null, 500)
|
||||
}
|
||||
|
||||
await sendGameInvite(c, id, toPlayerId, roomInstanceIdStr, roomId)
|
||||
// The `room_invite` row just written is what a v2 invite names as its `InviteId` —
|
||||
// the only real invite id this server has.
|
||||
await sendGameInvite(
|
||||
c,
|
||||
id,
|
||||
toPlayerId,
|
||||
{
|
||||
instanceId: roomInstanceIdStr,
|
||||
roomId,
|
||||
name: instance?.name ?? '',
|
||||
inviteId: invite.RoomInviteId,
|
||||
},
|
||||
await gameInviteType(c)
|
||||
)
|
||||
return c.json(invite)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ROOM_INVITE_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
setPresence,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
@@ -2426,6 +2427,79 @@ describe('auth-gated endpoints', () => {
|
||||
expect(after[0].data.Data).toBe('999999')
|
||||
})
|
||||
|
||||
test('POST /invite picks the invite message type off the token’s build', async () => {
|
||||
type Sent = { data: { Type: number; Data: string; RoomId: number | null } }
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
const sent = async (): Promise<Sent[]> =>
|
||||
(await (await hub().fetch('http://do/all')).json()) as Sent[]
|
||||
|
||||
const instance = await createRoomInstance(env.DB, {
|
||||
ownerAccountId: 42,
|
||||
roomId: 2,
|
||||
subRoomId: 2,
|
||||
photonRoomId: crypto.randomUUID(),
|
||||
name: '^RecCenter',
|
||||
maxCapacity: 12,
|
||||
})
|
||||
|
||||
// The one frame an invite from a client on `version` pushes, with the RoomInviteId
|
||||
// the call answered — a v2 invite names it in its Data.
|
||||
const inviteFrom = async (
|
||||
version?: string
|
||||
): Promise<{ frame: Sent; roomInviteId: number }> => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
const res = await exports.default.fetch(`${ORIGIN}/invite`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer('42', version)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `playerId=153&roomInstanceId=${instance.roomInstanceId}`,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const { RoomInviteId } = (await res.json()) as { RoomInviteId: number }
|
||||
const notes = await sent()
|
||||
expect(notes).toHaveLength(1)
|
||||
return { frame: notes[0], roomInviteId: RoomInviteId }
|
||||
}
|
||||
const typeFor = async (version?: string): Promise<number> =>
|
||||
(await inviteFrom(version)).frame.data.Type
|
||||
|
||||
// The cutoff build and anything newer → GameInviteV2 (6). The cutoff is INCLUSIVE, and
|
||||
// the point release after the date is not part of the comparison.
|
||||
expect(await typeFor('20250718.01')).toBe(6)
|
||||
expect(await typeFor('20230415')).toBe(6)
|
||||
expect(await typeFor('20230414')).toBe(6)
|
||||
expect(await typeFor('20230414.99')).toBe(6)
|
||||
|
||||
// Only builds OLDER than the cutoff get the original (0) — including the day before.
|
||||
expect(await typeFor('20230413')).toBe(0)
|
||||
expect(await typeFor('20230413.99')).toBe(0)
|
||||
expect(await typeFor('20220101')).toBe(0)
|
||||
|
||||
// No `rn.ver` on the token, or one that isn't a build at all: fall back to the
|
||||
// original rather than move an unknown client onto v2.
|
||||
expect(await typeFor()).toBe(0)
|
||||
expect(await typeFor('not-a-build')).toBe(0)
|
||||
|
||||
// v2 carries an escaped JSON object as its Data — a STRING holding JSON, not a
|
||||
// nested object — naming the instance and the invite row behind it.
|
||||
const v2 = await inviteFrom('20250718.01')
|
||||
expect(typeof v2.frame.data.Data).toBe('string')
|
||||
expect(JSON.parse(v2.frame.data.Data)).toEqual({
|
||||
InviteId: v2.roomInviteId, // the row POST /invite just answered with
|
||||
Name: '^RecCenter', // the instance's `^`-prefixed wire name
|
||||
InviteMode: 22, // verbatim, as observed off the client
|
||||
})
|
||||
// RoomId still rides on the message itself, in both versions.
|
||||
expect(v2.frame.data.RoomId).toBe(2)
|
||||
|
||||
// v1 is unchanged: the bare roomInstanceId as a string, no JSON.
|
||||
const v1 = await inviteFrom('20220101')
|
||||
expect(v1.frame.data.Data).toBe(String(instance.roomInstanceId))
|
||||
expect(v1.frame.data.RoomId).toBe(2)
|
||||
})
|
||||
|
||||
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.
|
||||
@@ -2751,6 +2825,114 @@ describe('auth-gated endpoints', () => {
|
||||
expect(await sent()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /matchmake/invite/:id lands the invitee in the inviter’s instance', async () => {
|
||||
// 8801 invites 8802. The invite row is what POST /invite answers with.
|
||||
const instance = await createRoomInstance(env.DB, {
|
||||
ownerAccountId: 8801,
|
||||
roomId: 2,
|
||||
subRoomId: 2,
|
||||
photonRoomId: crypto.randomUUID(),
|
||||
name: '^RecCenter',
|
||||
maxCapacity: 12,
|
||||
})
|
||||
const stand = async (accountId: number, roomInstance: unknown) =>
|
||||
setPresence(env.DB, {
|
||||
accountId,
|
||||
roomInstance,
|
||||
statusVisibility: 0,
|
||||
deviceClass: 0,
|
||||
vrMovementMode: 1,
|
||||
platform: 0,
|
||||
appVersion: GAME_VERSION,
|
||||
})
|
||||
await stand(8801, instance)
|
||||
|
||||
const invite = async (sub: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/invite`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `playerId=8802&roomInstanceId=${instance.roomInstanceId}`,
|
||||
})
|
||||
const accept = async (inviteId: number, sub: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/matchmake/invite/${inviteId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'CorrelationId=acc97dc2-be74-4722-99c3-36530491f5ff',
|
||||
})
|
||||
|
||||
const { RoomInviteId } = (await (await invite('8801')).json()) as { RoomInviteId: number }
|
||||
|
||||
// Anyone who isn't the addressee is refused, the INVITER included — the row is the
|
||||
// authorization, and an invite id is a small integer somebody else is holding.
|
||||
for (const stranger of ['8801', '8803']) {
|
||||
const res = await accept(RoomInviteId, stranger)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({ ErrorCode: 76, RoomInstance: null })
|
||||
}
|
||||
|
||||
// The addressee lands in the instance the inviter is standing in, answered in the
|
||||
// PascalCase envelope with the CorrelationId echoed back.
|
||||
const ok = await accept(RoomInviteId, '8802')
|
||||
expect(ok.status).toBe(200)
|
||||
expect(await ok.json()).toMatchObject({
|
||||
ErrorCode: 0,
|
||||
CorrelationId: 'acc97dc2-be74-4722-99c3-36530491f5ff',
|
||||
RoomInstance: {
|
||||
RoomInstanceId: instance.roomInstanceId,
|
||||
RoomId: 2,
|
||||
Name: '^RecCenter',
|
||||
MatchmakingPolicy: 0,
|
||||
},
|
||||
})
|
||||
|
||||
// ...and it really moved them: presence now names that instance.
|
||||
const presence = await exports.default.fetch(`${ORIGIN}/player?id=8802`, {
|
||||
headers: await bearer('8802'),
|
||||
})
|
||||
expect(
|
||||
((await presence.json()) as Array<{ roomInstance: { roomInstanceId: number } | null }>)[0]
|
||||
?.roomInstance?.roomInstanceId
|
||||
).toBe(instance.roomInstanceId)
|
||||
|
||||
// Standing there already is 17, not a second join.
|
||||
const again = await accept(RoomInviteId, '8802')
|
||||
expect(await again.json()).toMatchObject({ ErrorCode: 17, RoomInstance: null })
|
||||
|
||||
// An invite id that isn't there is 40 — expiry deletes rows, so "gone" and "expired"
|
||||
// are one answer.
|
||||
expect(await (await accept(99_999_999, '8802')).json()).toMatchObject({
|
||||
ErrorCode: 40,
|
||||
RoomInstance: null,
|
||||
})
|
||||
|
||||
// The inviter walking out leaves nothing to join: 2, PlayerNotOnline. (The invitee is
|
||||
// moved out of the instance first so the AlreadyIn check doesn't answer ahead of it.)
|
||||
await stand(8802, null)
|
||||
await env.DB.prepare('DELETE FROM presence WHERE account_id = 8801').run()
|
||||
const { RoomInviteId: staleId } = (await (await invite('8801')).json()) as {
|
||||
RoomInviteId: number
|
||||
}
|
||||
expect(await (await accept(staleId, '8802')).json()).toMatchObject({
|
||||
ErrorCode: 2,
|
||||
RoomInstance: null,
|
||||
})
|
||||
|
||||
// Unauthenticated is a 401, not a refusal code.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/invite/${RoomInviteId}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -2786,6 +2968,7 @@ describe('auth-gated endpoints', () => {
|
||||
'POST /matchmake/dorm',
|
||||
'POST /matchmake/event/{eventId}',
|
||||
'POST /matchmake/instance/{instanceId}',
|
||||
'POST /matchmake/invite/{inviteId}',
|
||||
'POST /matchmake/none',
|
||||
'POST /matchmake/player/{playerId}',
|
||||
'POST /matchmake/room/{roomId}',
|
||||
|
||||
Reference in New Issue
Block a user