[match] fix 2025 game invites

This commit is contained in:
Devin Zuczek
2026-08-26 17:37:09 -04:00
parent e2ced3e797
commit 062ed452a5
5 changed files with 540 additions and 23 deletions
+302 -22
View File
@@ -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 inviters live presence (not the',
'invites stored `RoomId`, which records where they were when they sent it).',
'',
'ADDRESSEE ONLY: the caller must be the invites `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 isnt there any more, 2 (PlayerNotOnline) when',
'the inviter isnt 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 theyd 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 attempts 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 inviters 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)
}
)