mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -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)
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user