[2026] relax versioncheck to major versions

This commit is contained in:
Devin Zuczek
2026-10-12 21:28:29 -04:00
parent bd15e30bfc
commit 1dd5fd409e
19 changed files with 304 additions and 88 deletions
+25 -17
View File
@@ -14,6 +14,7 @@ import {
deletePresence,
deleteRoomInvite,
GAME_VERSION,
gameBuild,
getAccount,
getClubSummary,
getExpiredPresenceAccountIds,
@@ -387,16 +388,18 @@ async function callerVersion(c: Context<App>): Promise<string | null> {
}
/**
* 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).
* The BUILD to matchmake the caller as — {@link callerVersion} reduced to its date
* ({@link gameBuild}: `20250718.01` → `20250718`), 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). A point release is the same build: a
* `20250718.2` client lands in the `20250718` session, beside the `.01` who opened it.
*
* 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
return gameBuild(await callerVersion(c)) ?? GAME_VERSION
}
/**
@@ -711,16 +714,20 @@ const BANNED_FROM_ROOM = MatchmakingErrorCode.BannedFromRoom
* `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
* Both sides are compared as BUILDS ({@link gameBuild}) — either may arrive as a full
* client version (a presence row's `appVersion` is verbatim) and a point release is the
* same build. Builds are date-stamped (`20230414`, `20250718`), 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
const caller = gameBuild(callerVersion) ?? ''
const instance = gameBuild(instanceVersion) ?? ''
if (caller === instance) return null
return instance > caller ? MatchmakingErrorCode.UpdateRequired : NO_SUCH_ROOM
}
/**
@@ -854,12 +861,12 @@ function nextLiveMessageId(): number {
const GAME_INVITE_V2_MIN_BUILD = 20230414
/**
* The date portion of a client build (`20250718.01` → `20250718`) as a number, or null when
* there's nothing parseable to compare. Builds are `<yyyymmdd>[.<n>]`, so the leading date
* orders them; the point release after it never decides this gate.
* A client build ({@link gameBuild}: `20250718.01` → `20250718`) as a number, or null when
* there's nothing parseable to compare. The leading date orders builds; the point release
* after it never decides a gate.
*/
function buildNumber(version: string | null): number | null {
const date = Number.parseInt(version?.split('.')[0] ?? '', 10)
const date = Number.parseInt(gameBuild(version) ?? '', 10)
return Number.isNaN(date) ? null : date
}
@@ -1563,10 +1570,11 @@ async function resolveRoomInstance(
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
// The same BUILD (the date, with the client's point release dropped), 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
// build — and lets every point release of that build share it.
const gameVersion = gameBuild(tokenVersion) ?? GAME_VERSION
// 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
@@ -3114,7 +3122,7 @@ const app = new Hono<App>()
'…). Both are empty when the pool is unset or the caller is in no instance. A',
'sandbox account is picked out of the `TACHYON_HOST_PORT_SANDBOX` pool instead, the',
'same way, with ids `dev-1`, `dev-2`, …; a caller whose token’s `rn.ver` is a build',
'under staged rollout (`ROLLOUT_GAME_VERSIONS`, currently `20260323.14`) out of',
'under staged rollout (`ROLLOUT_GAME_VERSIONS`, currently `20260323`) out of',
'`TACHYON_HOST_PORT_ROLLOUT`, with ids `rollout-1`, `rollout-2`, …. Sandbox wins when',
'both apply, and either special pool left unset falls back to the live one.',
'`experiments` carries the client’s networking flags.',
+35 -1
View File
@@ -1351,6 +1351,38 @@ describe('auth-gated endpoints', () => {
expect(priv2.roomInstance.roomInstanceId).not.toBe(priv.roomInstance.roomInstanceId)
})
test('POST /matchmake/room/:roomId pools every point release of a build into one instance', 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 } }
// The `.NN` after the date is a same-day rebuild of the same build, so a `.01` client,
// a `.2` client and one reporting the bare date all belong in one session.
const first = await matchmake('9170', '20250718.01')
const point = await matchmake('9171', '20250718.2')
const bare = await matchmake('9172', '20250718')
expect(point.roomInstance.roomInstanceId).toBe(first.roomInstance.roomInstanceId)
expect(bare.roomInstance.roomInstanceId).toBe(first.roomInstance.roomInstanceId)
// The instance is stamped with the BUILD, not the version that opened it — the key
// the lookups match on. (The same shape migrations/0028 backfills older rows into.)
const row = await env.DB.prepare('SELECT game_version FROM room_instance WHERE id = ?1')
.bind(first.roomInstance.roomInstanceId)
.first<{ game_version: string }>()
expect(row?.game_version).toBe('20250718')
// The point release doesn't leak into presence: that still reports the version the
// client posted, which is what describes the player to their friends.
const presence = await env.DB.prepare(
'SELECT data FROM presence WHERE account_id = 9171'
).first<{ data: string }>()
expect(JSON.parse(presence?.data ?? '{}').appVersion).toBe('20250718.2')
})
test('POST /matchmake/room/:roomId only pools players on the same client build', async () => {
const matchmake = async (sub: string, version: string) =>
(await (
@@ -2651,9 +2683,11 @@ describe('auth-gated endpoints', () => {
const older = await spawn('915', '20250424.01')
expect(await join(older, '20250718.01')).toEqual(refused(20))
// Same build → in they go.
// Same build → in they go — and a point release IS the same build: the instance holds
// the bare date, and the caller's `.2` reduces to it.
const same = await spawn('916', '20250718.01')
expect(await join(same, '20250718.01')).toMatchObject({ errorCode: 0 })
expect(await join(same, '20250718.2')).toMatchObject({ errorCode: 0 })
})
test('POST /matchmake/instance/:id joins that exact instance, owner-only', async () => {