[match] put players with room roles in same private instance when unpublished

This commit is contained in:
Devin Zuczek
2026-10-05 11:28:15 -04:00
parent f7167358d8
commit 8ea72fcdc5
3 changed files with 155 additions and 28 deletions
+38 -18
View File
@@ -30,6 +30,7 @@ import {
getRoomInstancesByRoom,
getRoomInstanceSummariesByRoom,
getRoomInvite,
getSharedPrivateInstance,
getStoredRoomInstance,
hasRoomInviteTo,
InviteMode,
@@ -1486,10 +1487,16 @@ async function resolveRoomInstance(
// it never admitted — a friend follows the owner in, and `IsPrivate: false` tells every
// client the session is open. The owner walking through their own room's subroom door
// posts `JoinMode` 0, so this has to be decided here rather than trusted from the body.
// Private means a fresh instance each time, as `JoinMode` 2 always has: the room's
// people reach each other by invite. Room routes only, like the gate — an event or a
// clubhouse held in a private room is a session its own guest list shares.
const privateInstance = isPrivate || (gateOnAccessibility && !isPublished(room))
// Room routes only, like the gate — an event or a clubhouse held in a private room is a
// session its own guest list shares.
//
// It is also ONE session, not a fresh one per matchmake: an unpublished room has a
// single private instance (per subroom and build), and everyone the gate admits is
// placed in it — that is what "come look at my room" means to a co-owner. Spawning a
// fresh instance each time, as `JoinMode` 2 does on a published room, put the creator
// and each person they let in into separate empty copies of the room.
const unpublished = gateOnAccessibility && !isPublished(room)
const privateInstance = isPrivate || unpublished
// The build this player is on, from their token. A 2023 client can't load a scene
// saved at a newer persistence version, so it is refused the room outright (see
@@ -1510,23 +1517,34 @@ async function resolveRoomInstance(
// your current instance (e.g. the only public instance of a room you're already in)
// returns the same id and hangs the client mid-join. Exclude it from the join
// search, which pushes them to another live instance if one exists or forces a
// fresh one below. (Only the public path reuses instances, so only it needs the
// read; a private matchmake always gets a fresh instance.)
const currentInstanceId = privateInstance
? undefined
: (await getPresence<RoomInstance>(c.env.DB, ownerId))?.roomInstance?.roomInstanceId
// fresh one below. (Only the paths that reuse instances — public, and the shared
// session of an unpublished room — need the read; a private matchmake into a
// published room always gets a fresh instance.)
const reusesInstance = unpublished || !privateInstance
const currentInstanceId = reusesInstance
? (await getPresence<RoomInstance>(c.env.DB, ownerId))?.roomInstance?.roomInstanceId
: undefined
// The same build, with GAME_VERSION standing in for a token that names none. It scopes
// the search below and is stamped on the instance when one is created, which is what
// keeps a session to a single client version.
const gameVersion = tokenVersion ?? GAME_VERSION
// Reuse an existing joinable public instance *of the same subroom and the same
// build* — subrooms are separate places, so joining one must never land you in
// another, and neither must a session running a different version of the room.
// Private matchmakes always get a fresh instance. Create one when there's nothing
// to join.
let instance = privateInstance
? null
: await getJoinableInstance(c.env.DB, f.roomId, gameVersion, f.subRoomId, currentInstanceId)
// Reuse an existing joinable instance *of the same subroom and the same build* —
// subrooms are separate places, so joining one must never land you in another, and
// neither must a session running a different version of the room. A published room
// reuses a public instance; an unpublished one reuses its single private session.
// A private matchmake into a published room always gets a fresh instance. Create
// one when there's nothing to join.
let instance = unpublished
? await getSharedPrivateInstance(
c.env.DB,
f.roomId,
gameVersion,
f.subRoomId,
currentInstanceId
)
: privateInstance
? null
: await getJoinableInstance(c.env.DB, f.roomId, gameVersion, f.subRoomId, currentInstanceId)
if (!instance) {
instance = await createRoomInstance(c.env.DB, {
ownerAccountId: ownerId,
@@ -1566,7 +1584,9 @@ async function resolveRoomInstance(
* {@link canEnterRoom}): a room that isn't published answers `RoomIsPrivate` (25) to anyone
* but its creator, its role holders and its invitees, in either join mode — a private
* instance of an unpublished room is still a way into it. Those it does admit always get
* a PRIVATE instance of it, whatever `JoinMode` they posted.
* a PRIVATE instance of it, whatever `JoinMode` they posted — and the SAME one: an
* unpublished room has a single private instance per subroom and build, shared by
* everyone it admits (see `getSharedPrivateInstance`).
*
* `subRoomId` is optional: absent, `resolveRoomInstance` falls back to the room's first
* subroom (its default entrance).
+65 -10
View File
@@ -1183,9 +1183,10 @@ describe('public endpoints', () => {
['8809', '20230414.01'],
['8810', '20221117'],
]) {
expect((await matchmake('/matchmake/room/2', player, version)).roomInstance).toMatchObject(
{ roomId: 2, name: '^RecCenter' }
)
expect((await matchmake('/matchmake/room/2', player, version)).roomInstance).toMatchObject({
roomId: 2,
name: '^RecCenter',
})
}
expect((await matchmake('/matchmake/room/2', '8811', '')).roomInstance).toMatchObject({
roomId: 2,
@@ -1279,9 +1280,16 @@ describe('auth-gated endpoints', () => {
expect(b.roomInstance.photonRoomId).toBe(a.roomInstance.photonRoomId)
expect(b.roomInstance.roomInstanceId).toBe(a.roomInstance.roomInstanceId)
// A private matchmake (JoinMode 2) gets its own distinct instance.
// A private matchmake (JoinMode 2) gets its own distinct instance — and so does the
// next one: a PUBLISHED room may have any number of private instances. Only an
// unpublished room collapses them into one shared session (see the accessibility
// tests below).
const priv = await matchmake('902', '2')
expect(priv.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId)
const priv2 = await matchmake('903', '2')
expect(priv2.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId)
expect(priv2.roomInstance.photonRoomId).not.toBe(priv.roomInstance.photonRoomId)
expect(priv2.roomInstance.roomInstanceId).not.toBe(priv.roomInstance.roomInstanceId)
})
test('POST /matchmake/room/:roomId only pools players on the same client build', async () => {
@@ -3049,7 +3057,11 @@ describe('auth-gated endpoints', () => {
expect((await v2('/matchmake/v2/room/2', '9880', 2)).RoomInstance?.RoomId).toBe(2)
})
test('an unpublished room’s instances are always PRIVATE, whatever JoinMode asked for', async () => {
test('an unpublished room has ONE private instance, shared by everyone it admits', async () => {
// Start clean: an earlier case may have left room 6 sessions and presence behind,
// and which instance a matchmake lands in is the whole point here.
await env.DB.prepare('DELETE FROM room_instance WHERE room_id = 6').run()
await env.DB.prepare('DELETE FROM presence WHERE account_id IN (42, 44, 9880, 9885)').run()
type V2 = {
ErrorCode: number
RoomInstance: { RoomInstanceId: number; RoomId: number; IsPrivate: boolean } | null
@@ -3076,7 +3088,10 @@ describe('auth-gated endpoints', () => {
expect(own.ErrorCode).toBe(0)
expect(own.RoomInstance).toMatchObject({ RoomId: 6, IsPrivate: true })
expect(await stored(own.RoomInstance?.RoomInstanceId)).toBe(1)
// The 2023 spelling, and JoinMode 1, are the same.
// The 2023 spelling, and JoinMode 1, are the same. The creator is already standing
// in the session above, and a matchmake never hands back the instance the caller is
// in (the client hangs on an unchanged id), so this one is a FRESH private session —
// and the first now sits empty, within the sweep's grace window.
const v1 = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/6`, {
method: 'POST',
@@ -3086,18 +3101,56 @@ describe('auth-gated endpoints', () => {
).json()) as { roomInstance: { roomInstanceId: number; isPrivate: boolean } | null }
expect(v1.roomInstance?.isPrivate).toBe(true)
expect(await stored(v1.roomInstance?.roomInstanceId)).toBe(1)
expect(v1.roomInstance?.roomInstanceId).not.toBe(own.RoomInstance?.RoomInstanceId)
// Nothing public to reuse: a role holder asking for a public session gets a private
// one of their own rather than joining the creator's.
// A role holder joins the creator's session: an unpublished room has ONE private
// instance its people share, so a Host asking for a public session is placed WITH
// the creator — in the instance they're standing in, not the empty one they left,
// and never in a private instance of their own. Nothing public exists to reuse.
const host = await v2('/matchmake/v2/room/6', '44', 0)
expect(host.RoomInstance?.IsPrivate).toBe(true)
expect(host.RoomInstance?.RoomInstanceId).not.toBe(v1.roomInstance?.roomInstanceId)
expect(host.RoomInstance?.RoomInstanceId).toBe(v1.roomInstance?.roomInstanceId)
expect(
await env.DB.prepare(
'SELECT COUNT(*) AS n FROM room_instance WHERE room_id = 6 AND is_private = 0'
).first<{ n: number }>()
).toEqual({ n: 0 })
// JoinMode 2 does not fork an unpublished room either: an invitee asking for a
// private session is put in the same one, in both spellings of the route.
await env.DB.prepare(
'INSERT INTO room_invite (from_player_id, to_player_id, room_id, created_at) VALUES (42, 9880, 6, ?1)'
)
.bind(Math.floor(Date.now() / 1000))
.run()
try {
const invitee = await v2('/matchmake/v2/room/6/6', '9880', 2)
expect(invitee.RoomInstance?.RoomInstanceId).toBe(v1.roomInstance?.roomInstanceId)
// Standing in it now, so the 2023 spelling moves them to the only other
// session — the one the creator abandoned — rather than back into the same id.
const again = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/6`, {
method: 'POST',
headers: {
...(await bearer('9880')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'JoinMode=2',
})
).json()) as { roomInstance: { roomInstanceId: number; isPrivate: boolean } | null }
expect(again.roomInstance?.isPrivate).toBe(true)
expect(again.roomInstance?.roomInstanceId).toBe(own.RoomInstance?.RoomInstanceId)
// Still exactly the two sessions: nothing was minted for anyone it admitted.
expect(
await env.DB.prepare('SELECT COUNT(*) AS n FROM room_instance WHERE room_id = 6').first<{
n: number
}>()
).toEqual({ n: 2 })
} finally {
await env.DB.prepare('DELETE FROM room_invite WHERE to_player_id = 9880').run()
await env.DB.prepare('DELETE FROM presence WHERE account_id = 9880').run()
}
// A FRIEND of the creator is not one of the room's people: neither follow gets them
// in — the 2023 route by the room's gate, the v2 route because the session is private.
await env.DB.prepare(
@@ -3125,10 +3178,12 @@ describe('auth-gated endpoints', () => {
).toBeNull()
// One of the room's own people who is also a friend may follow: the room admits
// them itself. Here the creator follows the Host into the Host's session.
// them itself. Here the creator steps out to a public room, then follows the Host
// back into the shared session.
await env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (42, 44, 3)'
).run()
expect((await v2('/matchmake/v2/room/2', '42', 0)).ErrorCode).toBe(0)
const ownFollow = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/player/44`, {
method: 'POST',
+52
View File
@@ -432,6 +432,58 @@ export async function getJoinableInstance(
return row ? toDto(parse(row.data)) : null
}
/**
* The ONE private session of an unpublished room — the instance of `subRoomId` running
* `gameVersion` that the room's people share — or null when nobody has opened one. The
* complement of {@link getJoinableInstance}: that search only ever reuses PUBLIC instances,
* and an unpublished room has none (every instance of it is private, see the match
* worker's `resolveRoomInstance`), so without this each of the room's people who
* matchmaked in got a private instance of their own and never found each other — a
* co-owner answering the creator's "come look at this" landed in an empty copy of the
* room beside them.
*
* Scoped like the public search: by subroom (separate places) and by build (a session
* belongs to one client version; another build's session reads as nothing to join and the
* caller opens one beside it). Full instances are skipped — there is nothing to put a
* player into — and `excludeInstanceId` drops the one the caller is already standing in,
* because the client keys its room transition off a CHANGING instance id and hangs when
* handed the same one back.
*
* That exclusion is also why the search prefers the instance with the most live players
* in it over the oldest: a creator re-entering the subroom they're in is moved to a fresh
* instance, and the one they left sits empty for the sweep's grace window. A co-owner
* arriving in the meantime must land with the creator, not in the abandoned shell.
*/
export async function getSharedPrivateInstance(
db: D1Database,
roomId: number,
gameVersion: string,
subRoomId: number,
excludeInstanceId?: number,
now = Math.floor(Date.now() / 1000)
): Promise<RoomInstanceDto | null> {
const binds: Array<number | string> = [roomId, gameVersion, subRoomId, now]
let exclude = ''
if (excludeInstanceId !== undefined) {
binds.push(excludeInstanceId)
exclude = `AND id != ?${binds.length}`
}
const row = await db
.prepare(
`SELECT data FROM room_instance
WHERE room_id = ?1 AND game_version = ?2 AND sub_room_id = ?3
AND is_private = 1 AND is_full = 0 ${exclude}
ORDER BY (
SELECT COUNT(*) FROM presence
WHERE presence.room_instance_id = room_instance.id AND presence.expires_at > ?4
) DESC, id
LIMIT 1`
)
.bind(...binds)
.first<{ data: string }>()
return row ? toDto(parse(row.data)) : null
}
/**
* All instances of a given room — every build's, unless `gameVersion` scopes it to the
* sessions running one. An instance belongs to a single client build (see