mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[match] allow friends to join friends in public
This commit is contained in:
+55
-24
@@ -2281,16 +2281,23 @@ const app = new Hono<App>()
|
||||
)
|
||||
|
||||
// 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 `room_invite` table rather than friendship: the caller must hold a standing
|
||||
// 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 v1 follow above — land in the instance the target is standing in — and the newer
|
||||
// client sends it for two different buttons, so it admits the caller two ways:
|
||||
//
|
||||
// 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.
|
||||
// - An INVITE: the caller holds a standing `room_invite` row 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 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
|
||||
// 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)',
|
||||
description: [
|
||||
'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',
|
||||
'FROM the target (as `POST /invite` writes them) — the newer client redeems an invite by',
|
||||
'its sender when the frame carries no usable `RoomInviteId`. The invite is SINGLE-USE:',
|
||||
'a successful join deletes the row, so the same invite can’t be redeemed again into',
|
||||
'wherever that player goes next (a refusal leaves it standing, so a retry still works).',
|
||||
'the target’s stored presence. Two ways in. INVITE: the caller holds a `room_invite`',
|
||||
'row FROM the target (as `POST /invite` writes them) — the newer client redeems an',
|
||||
'invite by its sender when the frame carries no usable `RoomInviteId`. The invite is',
|
||||
'SINGLE-USE: a successful join deletes the row, so the same invite can’t be redeemed',
|
||||
'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',
|
||||
'(RoomInviteExpired) when no invite stands (expiry deletes rows, so “never invited” and',
|
||||
'“expired” are one answer), 2 (PlayerNotOnline) when the target isn’t in a room, 17',
|
||||
'(RoomInviteExpired) when neither holds — not a friend, or a friend whose instance is',
|
||||
'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',
|
||||
'(InsufficientSpace) when it filled up, and 55 (BannedFromRoom) when the caller is',
|
||||
'banned from that room.',
|
||||
@@ -2342,16 +2353,23 @@ const app = new Hono<App>()
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const targetId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
// The gate: a standing invite from the target to the caller. No row means never
|
||||
// invited or already swept — the same answer either way, since expiry deletes
|
||||
// rows. This also refuses joining yourself: nobody holds a self-invite.
|
||||
// The first key: a standing invite from the target to the caller. No row means
|
||||
// never invited or already swept — the same thing, since expiry deletes rows.
|
||||
// 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)
|
||||
if (invite === null) {
|
||||
logger.info('v2 player matchmake refused: no invite from target', { targetId, id })
|
||||
// The second: mutual friendship, which admits the caller only to a PUBLIC instance
|
||||
// (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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
const targetPresence = await getPresence<RoomInstance>(c.env.DB, targetId)
|
||||
const instance = targetPresence?.roomInstance ?? null
|
||||
@@ -2360,6 +2378,18 @@ const app = new Hono<App>()
|
||||
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
|
||||
// re-fire the friend fan-out for a move that didn't happen.
|
||||
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.
|
||||
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
|
||||
// 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
|
||||
// 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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3342,6 +3342,98 @@ describe('auth-gated endpoints', () => {
|
||||
).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 () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
Reference in New Issue
Block a user