diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 7d280da..0991fa4 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -597,6 +597,63 @@ function crossBuildRefusal( return instanceVersion > callerVersion ? MatchmakingErrorCode.UpdateRequired : NO_SUCH_ROOM } +/** + * The 2023 client build, as its token's `rn.ver` date stamps it (`20230414`, or a point + * release of it). See {@link persistenceVersionRefusal}. + */ +const BUILD_2023 = 20230414 + +/** + * The first scene persistence version the 2023 client cannot load. A room whose published + * scene was saved at this version or later was built on a newer client; the old one fails + * to deserialize it. + */ +const MIN_UNLOADABLE_PERSISTENCE_VERSION_2023 = 227 + +/** + * The persistence version of the scene a subroom LOADS — the published `CurrentSave`'s, + * the same save {@link subRoomDataBlob} serves, falling back to the flat legacy field. + * `null` when nothing recorded one (a fresh subroom, or one saved before the field + * existed): unknown is not "new". + */ +function subRoomPersistenceVersion(sub: Record | undefined): number | null { + const save = sub?.CurrentSave + if (save && typeof save === 'object') { + const v = (save as Record).PersistenceVersion + if (typeof v === 'number') return v + } + return typeof sub?.PersistenceVersion === 'number' ? sub.PersistenceVersion : null +} + +/** + * Whether a player on `callerVersion` may enter `room` at all — `null` when they may, + * otherwise the code to refuse with. + * + * A caller on the 2023 build ({@link BUILD_2023}) is refused any room with a subroom whose + * published scene is at persistence version {@link MIN_UNLOADABLE_PERSISTENCE_VERSION_2023} + * or above: that scene was saved by a newer client and the 2023 one can't load it, so + * the honest answer is `UpdateRequired` — "your client can't go there" — rather than + * handing out an instance that never finishes loading. The WHOLE room is gated, not just + * the requested subroom, since the client walks between subrooms without re-matchmaking. + * + * Every other build, and a token that names none, passes: the gate is about one known + * client, not a general ordering. + */ +function persistenceVersionRefusal( + room: Room, + callerVersion: string | null +): MatchmakingErrorCode | null { + if (buildNumber(callerVersion) !== BUILD_2023) return null + const subRooms = (Array.isArray(room.SubRooms) ? room.SubRooms : []) as Array< + Record + > + const tooNew = subRooms.some((sub) => { + const v = subRoomPersistenceVersion(sub) + return v !== null && v >= MIN_UNLOADABLE_PERSISTENCE_VERSION_2023 + }) + return tooNew ? MatchmakingErrorCode.UpdateRequired : null +} + /** * "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 @@ -1205,6 +1262,20 @@ async function resolveRoomInstance( return { instance: null, errorCode: BANNED_FROM_ROOM } } + // 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 + // persistenceVersionRefusal) before any instance is created or reused. + const tokenVersion = await callerVersion(c) + const tooNew = persistenceVersionRefusal(room, tokenVersion) + if (tooNew !== null) { + logger.info('matchmake refused: room persistence version too new for client build', { + roomId: f.roomId, + ownerId, + gameVersion: tokenVersion, + }) + return { instance: null, errorCode: tooNew } + } + // Never place the player back into the instance they're already in: the client // keys the room transition off a changing `roomInstanceId`, so re-matchmaking into // your current instance (e.g. the only public instance of a room you're already in) @@ -1215,10 +1286,10 @@ async function resolveRoomInstance( const currentInstanceId = isPrivate ? undefined : (await getPresence(c.env.DB, ownerId))?.roomInstance?.roomInstanceId - // 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) + // 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. diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index ebc2cd3..5fd6e8f 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -3111,6 +3111,85 @@ describe('account bans', () => { }) }) +// A 2023 client (`rn.ver` 20230414) can't load a scene saved at persistence version 227 or +// later, so any room with such a subroom refuses it with UpdateRequired. Every other build +// gets in as usual. +describe('persistence version gate for the 2023 client', () => { + const matchmake = async (roomId: number, sub: string, version?: string) => { + const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/${roomId}`, { + method: 'POST', + headers: { + ...(await bearer(sub, version)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ JoinMode: '0' }).toString(), + }) + expect(res.status).toBe(200) + return (await res.json()) as { + errorCode: number + result: number + roomInstance: { roomId: number } | null + } + } + + beforeAll(async () => { + // Published scene at 227 — the first version the 2023 client can't load. + await seedRoomWithSubRooms(env.DB, { + RoomId: 7227, + Name: 'NewFormatRoom', + IsDorm: false, + Accessibility: 1, + CreatorAccountId: 7300, + SubRooms: [ + { SubRoomId: 7227, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 10 }, + { + SubRoomId: 7228, + UnitySceneId: SECOND_SUBROOM_SCENE, + MaxPlayers: 10, + CurrentSave: { DataBlob: 'new.room', PersistenceVersion: 227 }, + }, + ], + } as unknown as Record) + // Published at 226 — still loadable. + await seedRoomWithSubRooms(env.DB, { + RoomId: 7226, + Name: 'OldFormatRoom', + IsDorm: false, + Accessibility: 1, + CreatorAccountId: 7300, + SubRooms: [ + { + SubRoomId: 7226, + UnitySceneId: RECCENTER_SCENE, + MaxPlayers: 10, + CurrentSave: { DataBlob: 'old.room', PersistenceVersion: 226 }, + }, + ], + } as unknown as Record) + }) + + test('the 2023 build is refused a room with a subroom at 227+', async () => { + const res = await matchmake(7227, '7301', '20230414') + expect(res.errorCode).toBe(16) // UpdateRequired + expect(res.result).toBe(16) + expect(res.roomInstance).toBeNull() + + // A point release of the same build is the same client. + expect((await matchmake(7227, '7302', '20230414.02')).errorCode).toBe(16) + }) + + test('the 2023 build still enters a room saved below 227', async () => { + const res = await matchmake(7226, '7303', '20230414') + expect(res.errorCode).toBe(0) + expect(res.roomInstance?.roomId).toBe(7226) + }) + + test('newer builds and unversioned tokens are not gated', async () => { + expect((await matchmake(7227, '7304', '20250718.01')).errorCode).toBe(0) + expect((await matchmake(7227, '7305')).errorCode).toBe(0) + }) +}) + // The ban follows the player past the account it was written on: a new account sharing a // proven platform identity or an IP with a banned one is refused the same way. See // bans-db.ts in the api worker for the arms and the BAN_EVASION_MATCH knob.