[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
@@ -0,0 +1,18 @@
-- A room instance's `gameVersion` is now a BUILD — the bare `YYYYMMDD` — rather than the
-- client's full version. The `.NN` after the date is a same-day point release of the
-- same build: it changes nothing a session keys on, so `20250718.01` and `20250718.2`
-- belong in one Photon room, and `SUPPORTED_GAME_VERSIONS` lists dates alone.
--
-- Matchmaking now stamps the build (createRoomInstance runs the value through
-- `gameBuild`) and every lookup normalizes its argument the same way, so a row still
-- carrying `20250718.01` would match NOBODY from here on: a 20250718 player's search is
-- `game_version = '20250718'`. Strip the point release off every live row so the sessions
-- open at the moment of the deploy stay joinable. Rows with no `.` (already bare, or the
-- NULL of a row older than the stamp) are left exactly as they are.
--
-- `game_version` is a virtual column over the `data` blob (0012), so the blob is what's
-- rewritten; the column follows.
UPDATE room_instance
SET data = json_set(data, '$.gameVersion', substr(game_version, 1, instr(game_version, '.') - 1))
WHERE game_version LIKE '%.%';
+11 -8
View File
@@ -93,7 +93,7 @@ import {
withNotFound,
withOnError,
} from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles, validateAndGetVersion } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetBuild, validateAndGetRoles } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
@@ -470,16 +470,17 @@ async function authedAccountId(c: Context<App>): Promise<number | null> {
}
/**
* The client build this request's token was minted for (`rn.ver`), or null when there's no
* valid token. The claim is the build the CLIENT posted at login, not this server's
* GAME_VERSION, so it identifies what the player is actually running.
* The client BUILD this request's token was minted for (`rn.ver` as its bare `YYYYMMDD`,
* so a `20250718.01` client reads `20250718`), or null when there's no valid token. The
* claim is the build the CLIENT posted at login, not this server's GAME_VERSION, so it
* identifies what the player is actually running.
*
* It is unverified — a client can claim any build — which is fine for the one thing it is
* used for here: keeping a payload away from a build it breaks. Lying about your version to
* opt IN to a broken room list only breaks your own client.
*/
async function authedGameVersion(c: Context<App>): Promise<string | null> {
return validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
return validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
@@ -493,9 +494,11 @@ async function authedGameVersion(c: Context<App>): Promise<string | null> {
*
* An allow-list rather than a "this build or newer" comparison, deliberately: the thing
* being asserted is that a build was CHECKED, and a version string that sorts high (a
* debug build, say) must not opt itself in. Add a build here once it's been tried.
* debug build, say) must not opt itself in. Add a build here once it's been tried — as
* the bare date, which is what {@link authedGameVersion} yields; every point release of
* it (`20250718.01`, `20250718.2`) is the same build.
*/
const FEATURED_ROOMS_VERSIONS: ReadonlySet<string> = new Set(['20250718.01'])
const FEATURED_ROOMS_VERSIONS: ReadonlySet<string> = new Set(['20250718'])
/**
* Operator-granted elevated roles — the ones the auth worker stamps from an account's
@@ -1522,7 +1525,7 @@ const app = new Hono<App>()
'',
'Restricted by CLIENT BUILD. Serving this to the 2023 client breaks its other room',
'listings (NREs, apparently from the featured-room load corrupting its room cache), so',
'only the builds known to render it — `20250718.01` today — get the group; anything',
'only the builds known to render it — `20250718` today — get the group; anything',
'else gets a 404, the same answer it got while the route was parked. The build is read',
'from the token’s `rn.ver` claim, which is why this needs a token at all: nothing in',
'the group itself is per-player.',
@@ -1274,6 +1274,15 @@ describe('rooms endpoints', () => {
expect(body.Rooms.every((r) => typeof r.RoomName === 'string')).toBe(true)
// The dorm (RoomId 1) is non-public, so it's never featured.
expect(body.Rooms.some((r) => r.RoomId === 1)).toBe(false)
// The allow-list holds the BUILD (the bare date), so every point release of it is
// served: a `.2` rebuild of the 2025 client renders the group the same as `.01`.
for (const version of ['20250718.2', '20250718']) {
const point = await SELF.fetch(`${ORIGIN}/featuredrooms/current`, {
headers: await bearer('1', undefined, version),
})
expect(point.status, version).toBe(200)
}
})
it('GET /featuredrooms/current serves at most 10 rooms', async () => {
@@ -6336,6 +6345,53 @@ describe('studio room bundles', () => {
UnitySubAssets: [],
})
})
// A FIRST-PARTY room's bundle — captured from the official game and loaded by hand
// under its `unity/<roomId>/` layout — shares `unity_asset` with Studio builds but is
// not UGC, and must not be folded onto the save: the 2025 client handed it inline
// takes it instead of asking the bulk lookup, cannot name it, and loads nothing (every
// imported room broke when the splice moved from the Studio-only table to `unity_asset`).
// The bulk lookup is where that client resolves it, and still serves it.
it('leaves a first-party bundle to the bulk lookup rather than the room read', async () => {
await reset()
const firstParty = 'unity/9901/8iaf2yemao2d1zurblo875cn2.assetbundle'
await putUnityAsset(env.DB, {
UnityAssetId: ASSET,
Target: 0,
Version: 3,
Filename: firstParty,
Hash: winHash,
})
type Loaded = {
SubRooms: Array<{ CurrentSave: { UnitySubAssets: unknown[]; SubRoomDataSaveId: number } }>
}
for (const query of ['', '?unityAssetTarget=0']) {
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/9901${query}`)).json()) as Loaded
expect(room.SubRooms[0]!.CurrentSave.UnitySubAssets, query).toEqual([])
}
const saveId = ((await (await SELF.fetch(`${ORIGIN}/rooms/9901`)).json()) as Loaded)
.SubRooms[0]!.CurrentSave.SubRoomDataSaveId
const detail = await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/saves/${saveId}`, {
headers: await bearer('1'),
})
expect(await detail.json()).toMatchObject({
UnityAsset: null,
UnityAssetHash: null,
UnitySubAssets: [],
})
const bulk = await SELF.fetch(`${ORIGIN}/unity_assets/baked/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ target: '0', version: '3', id: ASSET }).toString(),
})
expect(bulk.status).toBe(200)
expect(await bulk.json()).toEqual([
{ UnityAssetId: ASSET, Target: 0, Version: 3, Filename: firstParty, Hash: winHash },
])
})
})
// Room and subroom names take letters, digits and underscores, at most 32 (see