[match] allow friends to join friends in public

This commit is contained in:
Devin Zuczek
2026-09-30 11:18:37 -04:00
parent fec3fc5258
commit d543b301e2
2 changed files with 147 additions and 24 deletions
+55 -24
View File
@@ -2281,16 +2281,23 @@ const app = new Hono<App>()
) )
// The newer client's join-by-player (`/matchmake/v2/player/{playerId}`). Same move as // The newer client's join-by-player (`/matchmake/v2/player/{playerId}`). Same move as
// the v1 follow above — land in the instance the target is standing in — but gated on // the v1 follow above — land in the instance the target is standing in — and the newer
// the `room_invite` table rather than friendship: the caller must hold a standing // client sends it for two different buttons, so it admits the caller two ways:
// invite FROM the target (the newer client's invite frame doesn't always carry a
// redeemable `InviteId` — the party fan-out sends 0 — so it redeems by player instead
// of by row id, and this is that path). Everything the target sent stays checkable:
// the newest row is enough, since any live row is authorization.
// //
// The row is consumed on a successful join: an invite authorizes one entry, and since // - An INVITE: the caller holds a standing `room_invite` row FROM the target (the newer
// this path follows the target's LIVE presence rather than the room the invite named, // client's invite frame doesn't always carry a redeemable `InviteId` — the party
// keeping it would leave a standing key into whatever instance they're in later. // fan-out sends 0 — so it redeems by player instead of by row id, and this is that
// path). Everything the target sent stays checkable: the newest row is enough, since
// any live row is authorization. The row is consumed on a successful join: an invite
// authorizes one entry, and since this path follows the target's LIVE presence rather
// than the room the invite named, keeping it would leave a standing key into whatever
// instance they're in later.
// - A FRIEND in a PUBLIC instance: "Join" on a friend's profile or the friends list,
// which sends no invite at all. Gated on mutual friendship like the v1 follow, and —
// unlike it — on the instance being public: a private session admits people through
// the invite branch, which is what an invite is for. Nothing is consumed.
//
// Every refusal is checked with the invite (if any) still standing, so a retry works.
// //
// Like the follow and invite paths, this hands out real Photon coordinates without // Like the follow and invite paths, this hands out real Photon coordinates without
// going through resolveRoomInstance, so it carries its own ban and build checks. // going through resolveRoomInstance, so it carries its own ban and build checks.
@@ -2303,14 +2310,18 @@ const app = new Hono<App>()
summary: 'Join the player who invited you (v2)', summary: 'Join the player who invited you (v2)',
description: [ description: [
'Places the caller into the room instance the target player is currently in, read from', 'Places the caller into the room instance the target player is currently in, read from',
'the target’s stored presence. INVITEES ONLY: the caller must hold a `room_invite` row', 'the target’s stored presence. Two ways in. INVITE: the caller holds a `room_invite`',
'FROM the target (as `POST /invite` writes them) — the newer client redeems an invite by', 'row FROM the target (as `POST /invite` writes them) — the newer client redeems an',
'its sender when the frame carries no usable `RoomInviteId`. The invite is SINGLE-USE:', 'invite by its sender when the frame carries no usable `RoomInviteId`. The invite is',
'a successful join deletes the row, so the same invite can’t be redeemed again into', 'SINGLE-USE: a successful join deletes the row, so the same invite can’t be redeemed',
'wherever that player goes next (a refusal leaves it standing, so a retry still works).', 'again into wherever that player goes next (a refusal leaves it standing, so a retry',
'still works). FRIEND: with no invite, a mutual friend may join the target’s PUBLIC',
'instance (the profile/friends-list “Join”); a private instance still takes an invite.',
'Answers 40', 'Answers 40',
'(RoomInviteExpired) when no invite stands (expiry deletes rows, so “never invited” and', '(RoomInviteExpired) when neither holds — not a friend, or a friend whose instance is',
'“expired” are one answer), 2 (PlayerNotOnline) when the target isn’t in a room, 17', 'private — so nothing about a stranger’s whereabouts leaks (expiry deletes rows, so',
'“never invited” and “expired” are one answer), 2 (PlayerNotOnline) when the target',
'isn’t in a room, 17',
'(AlreadyInTargetInstance) when the caller is already standing there, 3', '(AlreadyInTargetInstance) when the caller is already standing there, 3',
'(InsufficientSpace) when it filled up, and 55 (BannedFromRoom) when the caller is', '(InsufficientSpace) when it filled up, and 55 (BannedFromRoom) when the caller is',
'banned from that room.', 'banned from that room.',
@@ -2342,16 +2353,23 @@ const app = new Hono<App>()
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
const targetId = Number.parseInt(c.req.param('playerId'), 10) const targetId = Number.parseInt(c.req.param('playerId'), 10)
// The gate: a standing invite from the target to the caller. No row means never // The first key: a standing invite from the target to the caller. No row means
// invited or already swept — the same answer either way, since expiry deletes // never invited or already swept — the same thing, since expiry deletes rows.
// rows. This also refuses joining yourself: nobody holds a self-invite. // Nobody holds a self-invite, and nobody is their own friend, so joining yourself
// is refused by both keys.
const invite = await getLatestRoomInviteBetween(c.env.DB, targetId, id) const invite = await getLatestRoomInviteBetween(c.env.DB, targetId, id)
if (invite === null) { // The second: mutual friendship, which admits the caller only to a PUBLIC instance
logger.info('v2 player matchmake refused: no invite from target', { targetId, id }) // (checked below, once the instance is known). Not read when an invite stands.
const friend = invite === null && (await areFriends(c.env.DB, id, targetId))
if (invite === null && !friend) {
logger.info('v2 player matchmake refused: no invite from target, not a friend', {
targetId,
id,
})
return matchmakeResult(c, MatchmakingErrorCode.RoomInviteExpired, null) return matchmakeResult(c, MatchmakingErrorCode.RoomInviteExpired, null)
} }
// Where the inviter is NOW, straight off their presence row — not the invite's // Where the target is NOW, straight off their presence row — not an invite's
// stored RoomId, which records where they were when they sent it. // stored RoomId, which records where they were when they sent it.
const targetPresence = await getPresence<RoomInstance>(c.env.DB, targetId) const targetPresence = await getPresence<RoomInstance>(c.env.DB, targetId)
const instance = targetPresence?.roomInstance ?? null const instance = targetPresence?.roomInstance ?? null
@@ -2360,6 +2378,18 @@ const app = new Hono<App>()
return matchmakeResult(c, MatchmakingErrorCode.PlayerNotOnline, null) return matchmakeResult(c, MatchmakingErrorCode.PlayerNotOnline, null)
} }
// Friendship alone doesn't open a private session: that's what an invite is for.
// The same opaque 40 as a stranger gets — a distinct code would tell a friend the
// target is in a private room, which the target's presence settings may hide.
if (invite === null && instance.isPrivate) {
logger.info('v2 player matchmake refused: friend’s instance is private, no invite', {
roomInstanceId: instance.roomInstanceId,
targetId,
id,
})
return matchmakeResult(c, MatchmakingErrorCode.RoomInviteExpired, null)
}
// Already standing there: nothing to do, and re-entering would churn presence and // 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. // re-fire the friend fan-out for a move that didn't happen.
const own = await getPresence<RoomInstance>(c.env.DB, id) const own = await getPresence<RoomInstance>(c.env.DB, id)
@@ -2407,12 +2437,13 @@ const app = new Hono<App>()
// heartbeat replays it and their own friend fan-out fires. // heartbeat replays it and their own friend fan-out fires.
await enterRoom(c, id, instance) await enterRoom(c, id, instance)
// The invite is spent: it was authorization for THIS join, and leaving the row // An invite is spent: it was authorization for THIS join, and leaving the row
// standing would make it a permanent key into whatever instance the target is in // standing would make it a permanent key into whatever instance the target is in
// later — this path reads their live presence, not the room the invite named. // later — this path reads their live presence, not the room the invite named.
// Dropped only once the caller is actually in, so every refusal above (target not // Dropped only once the caller is actually in, so every refusal above (target not
// in a room, full, banned, wrong build) leaves the invite redeemable for a retry. // in a room, full, banned, wrong build) leaves the invite redeemable for a retry.
await deleteRoomInvite(c.env.DB, invite.RoomInviteId) // A friend's join consumed nothing; there's nothing to drop.
if (invite !== null) await deleteRoomInvite(c.env.DB, invite.RoomInviteId)
return matchmakeResult(c, MatchmakingErrorCode.Success, instance) return matchmakeResult(c, MatchmakingErrorCode.Success, instance)
} }
) )
@@ -3342,6 +3342,98 @@ describe('auth-gated endpoints', () => {
).toBe(401) ).toBe(401)
}) })
test('POST /matchmake/v2/player/:id lets a friend join a public instance without an invite', async () => {
// 8821 stands in a PUBLIC instance; 8822 is a mutual friend (8821 requested), 8823
// has a pending request only, 8824 is a stranger.
const insertRel = env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
)
await env.DB.batch([insertRel.bind(8821, 8822, 3), insertRel.bind(8823, 8821, 1)])
const instance = await createRoomInstance(env.DB, {
ownerAccountId: 8821,
roomId: 2,
subRoomId: 2,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 12,
isPrivate: false,
})
const stand = (accountId: number, roomInstance: typeof instance | null) =>
setPresence(env.DB, {
accountId,
roomInstance,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
})
await stand(8821, instance)
const join = async (targetId: number, sub: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/${targetId}`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'CorrelationId=5d1f0a4e-9a5b-4a3e-8d2b-6f1c2e3a4b5c',
})
).json()) as { ErrorCode: number; RoomInstance: { RoomInstanceId: number } | null }
// Not a friend, no invite: 40, whichever way round the non-friendship is.
expect(await join(8821, '8824')).toMatchObject({ ErrorCode: 40, RoomInstance: null })
expect(await join(8821, '8823')).toMatchObject({ ErrorCode: 40, RoomInstance: null })
// A friend lands in the public instance with no invite row at all…
expect(
await env.DB.prepare('SELECT COUNT(*) AS n FROM room_invite WHERE from_player_id = 8821')
.first<{ n: number }>()
).toMatchObject({ n: 0 })
expect(await join(8821, '8822')).toMatchObject({
ErrorCode: 0,
RoomInstance: { RoomInstanceId: instance.roomInstanceId, IsPrivate: false },
})
// …and is standing there afterwards, so the same call now says so (17).
expect(await join(8821, '8822')).toMatchObject({ ErrorCode: 17, RoomInstance: null })
// A friend's PRIVATE instance still takes an invite: the same opaque 40 a stranger
// gets, so the code doesn't confirm where the friend is.
const closed = await createRoomInstance(env.DB, {
ownerAccountId: 8821,
roomId: 2,
subRoomId: 2,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 12,
isPrivate: true,
})
await stand(8821, closed)
await stand(8822, null)
expect(await join(8821, '8822')).toMatchObject({ ErrorCode: 40, RoomInstance: null })
// With an invite the private instance opens, and the invite (not the friendship)
// is what's spent: the row is gone after the join.
await exports.default.fetch(`${ORIGIN}/invite`, {
method: 'POST',
headers: {
...(await bearer('8821')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `playerId=8822&roomInstanceId=${closed.roomInstanceId}`,
})
expect(await join(8821, '8822')).toMatchObject({
ErrorCode: 0,
RoomInstance: { RoomInstanceId: closed.roomInstanceId, IsPrivate: true },
})
expect(
await env.DB.prepare(
'SELECT COUNT(*) AS n FROM room_invite WHERE from_player_id = 8821 AND to_player_id = 8822'
).first<{ n: number }>()
).toMatchObject({ n: 0 })
})
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)