mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[match] gate 2025 rooms
This commit is contained in:
@@ -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<string, unknown> | undefined): number | null {
|
||||
const save = sub?.CurrentSave
|
||||
if (save && typeof save === 'object') {
|
||||
const v = (save as Record<string, unknown>).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<string, unknown>
|
||||
>
|
||||
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<RoomInstance>(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.
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
// 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<string, unknown>)
|
||||
})
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user