[match] do not allow crossplay between 2023 and 2025

This commit is contained in:
Devin Zuczek
2026-08-19 11:33:33 -04:00
parent 178d3b5b0e
commit 244b3bca70
4 changed files with 392 additions and 51 deletions
+126 -18
View File
@@ -24,6 +24,7 @@ import {
getRoomInstance,
getRoomInstancesByRoom,
getRoomInstanceSummariesByRoom,
getStoredRoomInstance,
isClubMember,
isPlayerBannedFromRoom,
MatchmakingErrorCode,
@@ -256,6 +257,19 @@ async function callerVersion(c: Context<App>): Promise<string | null> {
return validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* The build to matchmake the caller as — {@link callerVersion} with the server's own
* `GAME_VERSION` standing in when the token doesn't say (the same fallback presence
* uses, so a player's instance and their reported build agree).
*
* Unlike presence, this can never be left unset: it is the key an instance is created
* under and searched by, and an empty one would pool every unknown-build player into a
* shared "" bucket — exactly the mixed session the stamp exists to prevent.
*/
async function callerGameVersion(c: Context<App>): Promise<string> {
return (await callerVersion(c)) ?? GAME_VERSION
}
/**
* The "avoid juniors" preference, spelled the way the client posts it — the key a NEW
* setting is written under, and the one every stored spelling is matched against.
@@ -547,6 +561,32 @@ const NO_SUCH_ROOM = MatchmakingErrorCode.NoSuchRoom
*/
const BANNED_FROM_ROOM = MatchmakingErrorCode.BannedFromRoom
/**
* Whether a player on `callerVersion` may join a session already running
* `instanceVersion` — `null` when they may, otherwise the code to refuse with.
*
* The room matchmakes resolve this by construction: they only ever reuse an instance of
* the caller's own build and create one otherwise. The paths that join a NAMED instance
* can't — following a friend and the owner's instance listing both hand out a Photon
* room id for a session that already exists — so they ask here instead of putting two
* builds in one Photon room, where neither side sees what the other spawns.
*
* Which refusal depends on who is behind: a caller on the older build is told
* `UpdateRequired`, the one code that says "your client can't go there" and the honest
* answer. There is no code for the other direction ("they must update"), so a caller on
* the newer build gets the opaque NoSuchRoom every other unjoinable thing answers.
* Builds are date-stamped (`20230414`, `20250718.01`), so they order as strings; an
* instance carrying no version at all (written before the stamp existed) is nobody's
* build and refuses both ways.
*/
function crossBuildRefusal(
callerVersion: string,
instanceVersion: string
): MatchmakingErrorCode | null {
if (callerVersion === instanceVersion) return null
return instanceVersion > callerVersion ? MatchmakingErrorCode.UpdateRequired : NO_SUCH_ROOM
}
/**
* "This event isn't open to you" — the refusal on a private event the caller wasn't
* invited to. Told plainly rather than hidden behind the opaque NoSuchRoom: a player
@@ -790,8 +830,10 @@ const DEFAULT_MATCHMAKING_POLICY = 0
* because sending fields the reference doesn't send is how you find out the hard way
* that the client reads one of them.
*
* Only the wire shape differs — the instance itself is the same row, so a v1 and a v2
* client asking for the same public room land in the same instance.
* Only the wire shape differs — an instance is the same row whichever spelling asked for
* it. That does NOT mean a v1 and a v2 client asking for the same public room land in the
* same one: instances are scoped to the caller's client build, and these are two builds,
* so each gets its own session of the room (see resolveRoomInstance).
*/
function toV2RoomInstance(instance: RoomInstance) {
return {
@@ -995,6 +1037,14 @@ async function substituteRoom(
* Every matchmake that names a room lands here, so this is also where the operator's
* room substitutions apply (`ROOM_REDIRECTS`) — everything downstream, from the ban
* check to presence and the visit count, sees only the room actually entered.
*
* It is also where a session's client build is decided. An instance is reused only when
* it is running the caller's own build, and a new one is stamped with it, so players
* only ever share a Photon room with others on the same version of the room — two builds
* in one instance disagree about how the scene and its objects serialize, and each side
* simply fails to see what the other spawned. Players on another build therefore don't
* count as somebody to join: a room busy with them reads as empty and the caller gets a
* fresh instance beside them.
*/
async function resolveRoomInstance(
c: Context<App>,
@@ -1032,12 +1082,18 @@ async function resolveRoomInstance(
const currentInstanceId = isPrivate
? undefined
: (await getPresence<RoomInstance>(c.env.DB, ownerId))?.roomInstance?.roomInstanceId
// Reuse an existing joinable public instance *of the same subroom* — subrooms are
// separate places, so joining one must never land you in another. Private
// matchmakes always get a fresh instance. Create one when there's nothing to join.
// The build this player is on, from their token. 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 = await callerGameVersion(c)
// 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 = isPrivate
? null
: await getJoinableInstance(c.env.DB, f.roomId, f.subRoomId, currentInstanceId)
: await getJoinableInstance(c.env.DB, f.roomId, gameVersion, f.subRoomId, currentInstanceId)
if (!instance) {
instance = await createRoomInstance(c.env.DB, {
ownerAccountId: ownerId,
@@ -1050,6 +1106,7 @@ async function resolveRoomInstance(
maxCapacity: f.maxCapacity,
isPrivate: isPrivate || f.isDorm,
roomInstanceType: f.roomInstanceType,
gameVersion,
})
}
return {
@@ -1096,17 +1153,28 @@ async function matchmakeIntoRoom(c: Context<App>) {
}
/**
* The authed player's personal dorm instance. Gets-or-creates their dorm room,
* then backs it with a single persistent private `room_instance` so the dorm has
* a stable, unique Photon room id (dorms are isolated from each other) that
* survives re-entry. The room's current scene/saved data is re-read each time, so
* edits show up on the next visit.
* The authed player's personal dorm instance. Gets-or-creates their dorm room, then backs
* it with a private `room_instance` carrying its own Photon room id (dorms are isolated
* from each other). The room's current scene/saved data is re-read each time, so edits
* show up on the next visit — what persists about a dorm is the ROOM and the scene saved
* in it, not the session.
*
* One instance PER CLIENT BUILD: a dorm isn't private to its owner — they can invite
* people in — so it's an instance like any other and a build gets its own. An owner on a
* new build and a guest still on the old one end up in different sessions of the same
* dorm and can't see each other, which is the point: a mixed instance is a room where
* each side silently fails to see what the other spawned.
*
* A dorm instance is reused while it lasts — the owner leaving and coming back on the
* same build lands in the same session — but it is swept like any other once it sits
* empty past the grace window, and they simply get a fresh one on the next visit.
*/
async function playerDormInstance(c: Context<App>, accountId: number): Promise<RoomInstance> {
const room = await getOrCreateDormRoom(c.env.DB, accountId)
const f = instanceFieldsFromRoom(room)
// Reuse the dorm's one instance (private, so getJoinableInstance won't find it).
let instance = (await getRoomInstancesByRoom(c.env.DB, f.roomId))[0]
const gameVersion = await callerGameVersion(c)
// Reuse this build's dorm instance (private, so getJoinableInstance won't find it).
let instance = (await getRoomInstancesByRoom(c.env.DB, f.roomId, gameVersion))[0]
if (!instance) {
instance = await createRoomInstance(c.env.DB, {
ownerAccountId: accountId,
@@ -1119,6 +1187,7 @@ async function playerDormInstance(c: Context<App>, accountId: number): Promise<R
maxCapacity: f.maxCapacity,
isPrivate: true,
roomInstanceType: f.roomInstanceType,
gameVersion,
})
}
return roomInstanceFromRoom(c.env, room, true, instance.roomInstanceId, instance.photonRoomId)
@@ -1734,6 +1803,25 @@ const app = new Hono<App>()
return matchmakeResult(c, BANNED_FROM_ROOM, null)
}
// Nor does it go through the build scoping the room matchmakes get by
// construction, so a player on another build could otherwise follow their way
// into a session that can't render them. Compared against the FRIEND's presence
// rather than the instance's stamp: they're the person actually standing in
// there, their row is already read, and the two agree anyway (every matchmake
// writes the build it placed them under).
const followed = crossBuildRefusal(
await callerGameVersion(c),
targetPresence?.appVersion ?? GAME_VERSION
)
if (followed !== null) {
logger.info('follow refused: friend is on another client build', {
roomInstanceId: instance.roomInstanceId,
targetId,
id,
})
return matchmakeResult(c, followed, null)
}
// Join that same instance (same id + Photon room) and store it as the caller's
// presence, so the heartbeat replays it and their own friend fan-out fires.
await enterRoom(c, id, instance)
@@ -1787,7 +1875,9 @@ const app = new Hono<App>()
if (id === null) return unauthorized(c)
const instanceId = Number.parseInt(c.req.param('instanceId'), 10)
const stored = await getRoomInstance(c.env.DB, instanceId)
// The stored row rather than the client DTO: this needs the instance's own
// `gameVersion`, which the DTO drops.
const stored = await getStoredRoomInstance(c.env.DB, instanceId)
// One opaque refusal for "no such instance", "no such room" and "not yours":
// a distinct code for the last would confirm which instance ids are live.
if (!stored) return matchmakeResult(c, NO_SUCH_ROOM, null)
@@ -1814,6 +1904,20 @@ const app = new Hono<App>()
return matchmakeResult(c, BANNED_FROM_ROOM, null)
}
// Owning the room doesn't make an older client able to render a session running a
// newer build. This path picks a fixed instance, so unlike a room matchmake there
// is no same-build instance to fall back to — the owner is refused and can enter
// the room normally instead, which gets them a session of their own build.
const crossBuild = crossBuildRefusal(await callerGameVersion(c), stored.gameVersion)
if (crossBuild !== null) {
logger.info('instance matchmake refused: instance is on another client build', {
roomInstanceId: instanceId,
instanceVersion: stored.gameVersion,
accountId: id,
})
return matchmakeResult(c, crossBuild, null)
}
// Rebuild the wire instance from the room (fresh scene + published save) keyed to
// this instance's own id and Photon room, so the owner lands in exactly the
// session they picked rather than a new one alongside it.
@@ -1893,10 +1997,14 @@ const app = new Hono<App>()
// The newer client asks for the same two room matchmakes under a `/v2/` prefix
// (`/matchmake/v2/room/{roomId}` and `/matchmake/v2/room/{roomId}/{subRoomId}`). The
// MATCHMAKING is the same — same rooms, same instances, same refusals, so a v1 and a
// v2 player asking for the same public room stand in the same place — and so these
// share the handler. What differs is the wire on both ends: the request is JSON with
// real types rather than a urlencoded form, and the response is the PascalCase
// MATCHMAKING is the same — same rooms, same instance table, same refusals and so
// these share the handler. They do NOT put the two clients in one session, though: an
// instance is scoped to the caller's build, and a v1 and a v2 player are by definition
// on different ones, so each stands in their own instance of the room. That is the
// point — the two builds can't render each other's scene.
//
// What differs is the wire on both ends: the request is JSON with real types rather
// than a urlencoded form, and the response is the PascalCase
// envelope (`ErrorCode`/`CorrelationId`/`RoomInstance`, no `result` twin, no Photon
// coordinates or DataBlob on the instance, plus `MatchmakingPolicy`). Neither is
// handled here: `readRequestFields` takes either encoding and `matchmakeResult` picks
+159 -15
View File
@@ -1060,6 +1060,33 @@ describe('auth-gated endpoints', () => {
expect(priv.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId)
})
test('POST /matchmake/room/:roomId only pools players on the same client build', async () => {
const matchmake = async (sub: string, version: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer(sub, version),
})
).json()) as { roomInstance: { photonRoomId: string; roomInstanceId: number } }
// Two players on one build share an instance, exactly as before — the build scoping
// groups players, it doesn't stop grouping them.
const oldA = await matchmake('910', '20250424.01')
const oldB = await matchmake('911', '20250424.01')
expect(oldB.roomInstance.roomInstanceId).toBe(oldA.roomInstance.roomInstanceId)
// A player on a different build asking for the same room gets their own instance:
// the live one is running a version of the room their client can't render, so it
// isn't somebody to join.
const next = await matchmake('912', '20250718.01')
expect(next.roomInstance.roomInstanceId).not.toBe(oldA.roomInstance.roomInstanceId)
expect(next.roomInstance.photonRoomId).not.toBe(oldA.roomInstance.photonRoomId)
// ...and they pool with their own build in turn.
const nextB = await matchmake('913', '20250718.01')
expect(nextB.roomInstance.roomInstanceId).toBe(next.roomInstance.roomInstanceId)
})
test('GET /player/connection-info hands back the Photon room the caller matchmade into', async () => {
const matchmaked = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
@@ -1271,6 +1298,34 @@ describe('auth-gated endpoints', () => {
})
})
test('POST /matchmake/dorm gives each client build its own dorm instance', async () => {
const dorm = async (version: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer('43', version),
})
).json()) as {
roomInstance: { roomId: number; photonRoomId: string; roomInstanceId: number }
}
// Same dorm ROOM whichever build the owner is on — it's their one dorm...
const older = await dorm('20250424.01')
const newer = await dorm('20250718.01')
expect(newer.roomInstance.roomId).toBe(older.roomInstance.roomId)
// ...but a separate session per build. A dorm takes guests, so a mixed one is a
// room where neither side can see what the other spawned; the owner on a new build
// and a guest still on the old one are deliberately kept apart.
expect(newer.roomInstance.roomInstanceId).not.toBe(older.roomInstance.roomInstanceId)
expect(newer.roomInstance.photonRoomId).not.toBe(older.roomInstance.photonRoomId)
// Each build's instance is still the stable one it re-enters (id + Photon room),
// and coming back on the older build doesn't hand back the newer session.
expect(await dorm('20250424.01')).toMatchObject({ roomInstance: older.roomInstance })
expect(await dorm('20250718.01')).toMatchObject({ roomInstance: newer.roomInstance })
})
test('a matchmake echoes the requests CorrelationId (and mirrors errorCode as result)', async () => {
// The client tags each attempt with a GUID and won't accept a session whose
// response doesn't carry the same one back ("Unable to connect to game session").
@@ -1606,8 +1661,15 @@ describe('auth-gated endpoints', () => {
)
.run()
// A stand-in instance id for presence rows whose instance is beside the point. Kept
// far above what createRoomInstance hands out (ID_BASE + 1, climbing by one per
// instance) so it can never collide with a real one — a live presence row pointing at
// a real instance makes that instance look occupied, which quietly breaks whichever
// test is watching the empty-instance sweep.
const UNRELATED_INSTANCE_ID = 1_900_042
const seedPresence = (id: number, expiresAt: number) =>
seedPresenceInInstance(id, 1000042, expiresAt)
seedPresenceInInstance(id, UNRELATED_INSTANCE_ID, expiresAt)
const storedExpiresAt = async (id: number): Promise<number> => {
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
@@ -1651,11 +1713,12 @@ describe('auth-gated endpoints', () => {
})
test('countPlayersInInstance counts live players in a room instance (excludes expired)', async () => {
// Three players in instance 1000099 — two live, one expired.
await seedPresenceInInstance(710, 1000099, nowSeconds() + 800)
await seedPresenceInInstance(711, 1000099, nowSeconds() + 800)
await seedPresenceInInstance(712, 1000099, nowSeconds() - 10) // expired → not counted
expect(await countPlayersInInstance(env.DB, 1000099)).toBe(2)
// Three players in instance 1900099 (synthetic, like UNRELATED_INSTANCE_ID above) —
// two live, one expired.
await seedPresenceInInstance(710, 1_900_099, nowSeconds() + 800)
await seedPresenceInInstance(711, 1_900_099, nowSeconds() + 800)
await seedPresenceInInstance(712, 1_900_099, nowSeconds() - 10) // expired → not counted
expect(await countPlayersInInstance(env.DB, 1_900_099)).toBe(2)
expect(await countPlayersInInstance(env.DB, 999999)).toBe(0)
})
@@ -1782,22 +1845,36 @@ describe('auth-gated endpoints', () => {
expect(await getRoomInstance(env.DB, fresh.roomInstanceId)).not.toBeNull()
})
test('the cron sweep spares an empty dorm instance', async () => {
// A dorm is backed by one persistent instance so its Photon room id survives
// re-entry — it sits empty whenever the owner is anywhere else.
test('the cron sweep retires an empty dorm instance like any other', async () => {
// A dorm gets no exemption: once its owner is elsewhere the session is an empty
// Photon room nobody can be pointed at, exactly like a public instance everyone
// left. What persists about a dorm is the ROOM and the scene saved in it.
const headers = await bearer('833')
const dorm = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
).json()) as { roomInstance: { roomInstanceId: number } }
const dormInstanceId = dorm.roomInstance.roomInstanceId
const enterDorm = async () =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
).json()) as {
roomInstance: { roomInstanceId: number; roomId: number; photonRoomId: string }
}
const dorm = await enterDorm()
await expirePresence(833)
await backdateInstance(dormInstanceId)
await backdateInstance(dorm.roomInstance.roomInstanceId)
const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx)
expect(await getRoomInstance(env.DB, dormInstanceId)).not.toBeNull()
expect(await getRoomInstance(env.DB, dorm.roomInstance.roomInstanceId)).toBeNull()
// And the owner walks back into their own dorm regardless — same room, a fresh
// session of it. Freshness is read off the Photon room (a new GUID per instance)
// rather than the id: ids come from MAX(id) + 1, so retiring the newest row hands
// its number straight back to the next instance created.
const again = await enterDorm()
expect(again.roomInstance.roomId).toBe(dorm.roomInstance.roomId)
expect(again.roomInstance.photonRoomId).not.toBe(dorm.roomInstance.photonRoomId)
expect(await getRoomInstance(env.DB, again.roomInstance.roomInstanceId)).not.toBeNull()
})
test('player/login and exclusivelogin preserve presence', async () => {
@@ -1946,6 +2023,42 @@ describe('auth-gated endpoints', () => {
expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length)
})
test('POST /matchmake/instance/:id refuses an instance running another client build', async () => {
const spawn = async (sub: string, version: string) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
method: 'POST',
headers: await bearer(sub, version),
})
).json()) as { roomInstance: { roomInstanceId: number } }
).roomInstance.roomInstanceId
const join = async (instanceId: number, version: string) =>
(
await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
method: 'POST',
headers: await bearer('42', version),
})
).json()
// The owner of room 3 (42) can't drop into a session running a build their own
// client isn't: owning the room doesn't make an older client able to render it.
// Their build is behind the instance's, so they're told to update (16) rather than
// given the opaque refusal.
const newer = await spawn('914', '20250718.01')
expect(await join(newer, '20250424.01')).toEqual(refused(16))
// The other direction has no code of its own — there's no "the people in there must
// update" — so it's the opaque NoSuchRoom every other unjoinable thing answers.
const older = await spawn('915', '20250424.01')
expect(await join(older, '20250718.01')).toEqual(refused(20))
// Same build → in they go.
const same = await spawn('916', '20250718.01')
expect(await join(same, '20250718.01')).toMatchObject({ errorCode: 0 })
})
test('POST /matchmake/instance/:id joins that exact instance, owner-only', async () => {
// A player with no role on room 3 spins up an instance of it, which the room's
// owner should then be able to drop into by id.
@@ -2274,6 +2387,37 @@ describe('auth-gated endpoints', () => {
expect(await sent()).toEqual([])
})
test('POST /matchmake/player/:id refuses a friend on another client build', async () => {
// 9810 is friends with 9811, 9812 with 9813 — one pair per direction of the build gap.
const insertRel = env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
)
await env.DB.batch([insertRel.bind(9810, 9811, 3), insertRel.bind(9812, 9813, 3)])
const enter = async (sub: string, version: string) =>
exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer(sub, version),
})
const follow = async (targetId: number, sub: string, version: string) =>
(
await exports.default.fetch(`${ORIGIN}/matchmake/player/${targetId}`, {
method: 'POST',
headers: await bearer(sub, version),
})
).json()
// The friend is standing in a session of a newer build: following them would put
// two builds in one Photon room, so the follower is told to update (16) instead.
await enter('9811', '20250718.01')
expect(await follow(9811, '9810', '20250424.01')).toEqual(refused(16))
// Following someone on an OLDER build is refused too — opaquely, since there's no
// code for "they're the ones who need to update".
await enter('9813', '20250424.01')
expect(await follow(9813, '9812', '20250718.01')).toEqual(refused(20))
})
test('POST /matchmake/player/:id follows a friend into their room, friends only', async () => {
// 9800 is friends with 9801 (in a room) and 9803 (not in any room); 9802 is not a
// friend.