From 1dd5fd409e7660560e066fe90b3f4f0d5dcbe087 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 12 Oct 2026 21:28:29 -0400 Subject: [PATCH] [2026] relax versioncheck to major versions --- CLAUDE.md | 17 ++++-- README.md | 20 ++++--- apps/ai/src/ai.app.ts | 11 ++-- apps/api/src/routes/config.ts | 11 ++-- apps/api/src/routes/events.ts | 11 ++-- apps/api/src/test/integration/api.test.ts | 15 +++++ apps/auth/src/auth.app.ts | 2 +- apps/auth/src/test/integration/api.test.ts | 12 ++++ apps/chat/src/chat.app.ts | 8 +-- apps/econ/src/econ.app.ts | 11 ++-- apps/match/src/match.app.ts | 42 ++++++++------ apps/match/src/test/integration/api.test.ts | 36 +++++++++++- .../migrations/0028_room_instance_build.sql | 18 ++++++ apps/rooms/src/rooms.app.ts | 19 ++++--- apps/rooms/src/test/integration/api.test.ts | 56 +++++++++++++++++++ packages/domain/src/presence-db.ts | 53 ++++++++++++++---- packages/domain/src/room-instance-db.ts | 33 +++++++---- packages/jwt/src/index.ts | 1 + packages/jwt/src/jwt.ts | 16 +++++- 19 files changed, 304 insertions(+), 88 deletions(-) create mode 100644 apps/rooms/migrations/0028_room_instance_build.sql diff --git a/CLAUDE.md b/CLAUDE.md index 82d7145..0ed86ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,13 +49,18 @@ Supported client builds live in `SUPPORTED_GAME_VERSIONS` (`packages/domain/src/presence-db.ts`); `GAME_VERSION` is the default the stack targets. - `20230414` — default, official. Manifest `7859140924515540835` (2023). -- `20250718.01` — beta, official. Manifest `1151455856673601091`; reaches this server via - the [patch-2025](https://github.com/recflare/patch-2025) patch. -- `20260323.14`, `20250424.01`, `20231207`, `20230616` — alpha. +- `20250718` — beta, official (the `20250718.01` client). Manifest `1151455856673601091`; + reaches this server via the [patch-2025](https://github.com/recflare/patch-2025) patch. +- `20260323` (`20260323.14`), `20250424` (`20250424.01`), `20231207`, `20230616` — alpha. -Builds are date-stamped (`YYYYMMDD[.NN]`) so they order as plain strings; several -surfaces gate on "newer than `20230414`" (econ storefront catalog, `api` event tags, -`rooms` featured rooms) rather than on an explicit list. +A client reports `YYYYMMDD[.NN]`; the `.NN` is a same-day point release of the same build +and decides nothing. `gameBuild` strips it, the lists above hold bare dates, every gate +compares builds (`validateAndGetBuild` in `@repo/jwt` is the token form), and the +`room_instance` key is stored as the build so `20250718.01` and `20250718.2` share a +session. The token's `rn.ver` and presence's `appVersion` still echo the client's full +version. Builds order as plain strings; several surfaces gate on "newer than `20230414`" +(econ storefront catalog, `api` event tags, `rooms` featured rooms) rather than on an +explicit list. diff --git a/README.md b/README.md index c5c5dbd..46943e4 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,18 @@ See [RecFlare Client](https://github.com/djdevin/recflare-client) for the offici These game builds are supported: -| Build | Manifest | Support | -| ------------- | --------------------- | --------------------------------------------------------------------------------------- | -| `20230414` | `7859140924515540835` | **Default**, official — the 2023 build the rest of the stack targets | -| `20250718.01` | `1151455856673601091` | **Beta**, official — use the [patch-2025](https://github.com/recflare/patch-2025) patch | -| `20260323.14` | | Alpha | -| `20250424.01` | | Alpha | -| `20231207` | | Alpha | -| `20230616` | | Alpha | +| Build | Client version | Manifest | Support | +| ---------- | -------------- | --------------------- | --------------------------------------------------------------------------------------- | +| `20230414` | `20230414` | `7859140924515540835` | **Default**, official — the 2023 build the rest of the stack targets | +| `20250718` | `20250718.01` | `1151455856673601091` | **Beta**, official — use the [patch-2025](https://github.com/recflare/patch-2025) patch | +| `20260323` | `20260323.14` | | Alpha | +| `20250424` | `20250424.01` | | Alpha | +| `20231207` | `20231207` | | Alpha | +| `20230616` | `20230616` | | Alpha | + +A build is the `YYYYMMDD` date of the client version; the `.NN` after it is a same-day +point release and is ignored everywhere, so `20250718.01`, `20250718.2` and `20250718` +are all the beta build and share game sessions. Alpha builds get past the version check and largely work, but nothing else in the stack targets them, so expect protocol differences. Other client or game versions diff --git a/apps/ai/src/ai.app.ts b/apps/ai/src/ai.app.ts index b55aa7c..176f1e1 100644 --- a/apps/ai/src/ai.app.ts +++ b/apps/ai/src/ai.app.ts @@ -3,7 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' -import { validateAndGetAccountId, validateAndGetVersion } from '@repo/jwt' +import { validateAndGetAccountId, validateAndGetBuild } from '@repo/jwt' import { AUTHED, @@ -163,7 +163,7 @@ const app = new Hono() } ) - // 20260323.14 has native low-energy and no-energy companion support. Advertising + // The 20260323 build (20260323.14) has native low-energy and no-energy companion support. Advertising // full energy selects a conversation backend we do not provide. Grant access with // zero energy and the client's recognized no-credit result instead. A successful // access response still opens a realtime session even when its energy is zero. @@ -174,7 +174,7 @@ const app = new Hono() tags: ['Roomie AI', '2025'], summary: 'The caller’s Roomie AI energy budget', description: [ - '20260323.14 receives the recognized no-credit result and zero energy for its native', + 'The 20260323 build receives the recognized no-credit result and zero energy for its native', 'companion mode. Its DTO has no recharge field. Older clients retain the full', 'int32-max budget and null recharge time. No model session is created.', '', @@ -191,8 +191,9 @@ const app = new Hono() const id = await authedId(c) if (id === null) return unauthorized(c) - const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get()) - if (version === '20260323.14') { + // The build, not the version: every point release of 20260323 has the companion mode. + const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get()) + if (build === '20260323') { return c.json({ success: false, error_id: 'AI.InsufficientRoomieAICreditNoSub', diff --git a/apps/api/src/routes/config.ts b/apps/api/src/routes/config.ts index 795d17c..1d2109c 100644 --- a/apps/api/src/routes/config.ts +++ b/apps/api/src/routes/config.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono' import { describeRoute } from 'hono-openapi' import { GAME_VERSION, isSupportedGameVersion } from '@repo/domain' -import { validateAndGetVersion } from '@repo/jwt' +import { validateAndGetBuild } from '@repo/jwt' import apiConfigV2 from '../../static/api-config-v2.json' import gameConfigsV1All2025 from '../../static/gameconfigs-v1-all-2025.json' @@ -140,8 +140,9 @@ export const configRoutes = new Hono({ strict: false }) // Which one a caller gets is decided by the token's `rn.ver` claim — the build the // client posted at login — since the request itself carries no version. Anything NEWER // than `GAME_VERSION` (20230414) gets the 2025 catalog; that build and anything older - // get the 2023 one. Builds are date-stamped (`20230414`, `20250718.01`), so they order - // as strings, the same comparison `match` makes for cross-build joins. + // get the 2023 one. Builds are date-stamped (`20230414`, `20250718`), so they order as + // strings, the same comparison `match` makes for cross-build joins — compared as BUILDS, + // so a same-day point release of the target (`20230414.01`) stays on the 2023 catalog. // // A request with no readable token version gets the 2023 catalog, the same body this // route has always served: unauthenticated is not evidence of a newer client, and this @@ -163,8 +164,8 @@ export const configRoutes = new Hono({ strict: false }) responses: { 200: json(JsonObject, 'The game config catalog for the caller’s build') }, }), async (c) => { - const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get()) - const newerThanTarget = version !== null && version > GAME_VERSION + const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get()) + const newerThanTarget = build !== null && build > GAME_VERSION return c.json(newerThanTarget ? gameConfigsV1All2025 : gameConfigsV1All) } ) diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index fcbd112..5ebc82f 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -3,7 +3,7 @@ import { describeRoute } from 'hono-openapi' import { Accessibility, createNotification, GAME_VERSION, MessageType } from '@repo/domain' import { logger } from '@repo/hono-helpers' -import { validateAndGetVersion } from '@repo/jwt' +import { validateAndGetBuild } 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. @@ -306,15 +306,16 @@ async function pushEventInvitation( * the split is the one `/api/gameconfigs/v1/all` already makes: anything NEWER than * `GAME_VERSION` (20230414) gets the pairs; that build, anything older, and a request * with no readable token version all get the 2023 names. Builds are date-stamped, so they - * order as strings. `TagModifyResult.Tags` is a name list to every build — only the - * inline field moves. + * order as strings — compared as BUILDS, with the client's point release dropped, so a + * same-day rebuild of the target stays on its side. `TagModifyResult.Tags` is a name list + * to every build — only the inline field moves. * * Like the other version gates here the claim is unverified — a client that lies about its * build only empties its own tag chips. */ async function eventResult(c: Context, event: PlayerEvent, tags: EventTag[]) { - const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get()) - const isModernBuild = version !== null && version > GAME_VERSION + const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get()) + const isModernBuild = build !== null && build > GAME_VERSION return toEventResult(event, tags, isModernBuild) } diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index c01183f..d34c688 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -301,6 +301,18 @@ describe('public endpoints', () => { } }) + test('GET /api/versioncheck/v4 ignores the point release: every .NN of a supported build is current', async () => { + // The list holds bare dates; the client reports `YYYYMMDD.NN`, and the `.NN` is a + // same-day rebuild of the same build. All of these are the beta build. + for (const version of ['20250718.01', '20250718.2', '20250718', '20250718.99']) { + const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${version}`) + expect(await res.json(), version).toMatchObject({ VersionStatus: 0 }) + } + // ...and a point release of a build that ISN'T supported is still not. + const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101.01`) + expect(await res.json()).toMatchObject({ VersionStatus: 1 }) + }) + test('GET /api/versioncheck/v4 flags a mismatched build', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`) expect(await res.json()).toMatchObject({ VersionStatus: 1 }) @@ -331,6 +343,9 @@ describe('public endpoints', () => { test('GET /api/gameconfigs/v1/all serves the 2023 catalog to the target build', async () => { expect(await gameConfigKeys(GAME_VERSION)).not.toContain(KEY_2025_ONLY) + // A same-day point release of the target is the target build: compared as a build, + // not as a string that happens to sort after `20230414`. + expect(await gameConfigKeys(`${GAME_VERSION}.01`)).not.toContain(KEY_2025_ONLY) }) test('GET /api/gameconfigs/v1/all serves the 2023 catalog to an older build', async () => { diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 1487da6..d3e49aa 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -978,7 +978,7 @@ const app = new Hono() 'refused may belong to a housemate of the banned player rather than to them.', '', '**Rollout gate.** A grant whose `ver` names a build under staged rollout', - '(`ROLLOUT_GAME_VERSIONS`, currently `20260323.14`) is refused as `invalid_grant` "this build is limited to', + '(`ROLLOUT_GAME_VERSIONS`, currently `20260323` — every point release of it) is refused as `invalid_grant` "this build is limited to', 'Discord supporters" unless the account’s Discord link (made by the website’s', 'benefits claim) holds one of the roles in the operator’s `DISCORD_ROLLOUT_ROLE_IDS`.', 'With none configured the gate is closed to everyone. `create_account` on such a', diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 0956c48..e0492c1 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -1352,6 +1352,18 @@ describe('auth worker routes', () => { }) }) + test('every point release of the gated build is gated', async () => { + // The gate is on the BUILD (the bare date): the client's `.NN` is a same-day + // rebuild, so `.14`, `.2` and the date alone are all the rollout build. + await withRoleIds(undefined, async () => { + for (const version of ['20260323', '20260323.2', '20260323.14']) { + const res = await login('gate_outsider', version) + expect(res.status, version).toBe(400) + expect(res.json.error_description, version).toBe(REFUSED) + } + }) + }) + test('the credential is checked first, so the gate cannot probe for accounts', async () => { await withRoleIds(undefined, async () => { const res = await postToken( diff --git a/apps/chat/src/chat.app.ts b/apps/chat/src/chat.app.ts index 6092e7e..cf478df 100644 --- a/apps/chat/src/chat.app.ts +++ b/apps/chat/src/chat.app.ts @@ -4,7 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { mergePlayerSettings as mergeStoredPlayerSettings, readPlayerSettings } from '@repo/domain' import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' -import { validateAndGetAccountId, validateAndGetVersion } from '@repo/jwt' +import { validateAndGetAccountId, validateAndGetBuild } from '@repo/jwt' import { censorSwears } from '../../api/src/sanitize' import { NotificationType } from '../../notify/src/notification-types' @@ -41,9 +41,9 @@ import { ChatThreadType, createThread, getOrCreateThreadWithMembers, + getPartyThreadForPlayer, getThreadForPlayer, getThreadMemberIds, - getPartyThreadForPlayer, getThreadMeta, getThreadsForPlayer, isThreadMember, @@ -903,8 +903,8 @@ const app = new Hono() if (id === null) return c.body(null, 401) // Path 1: already in a party. One query, no settings read. - const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get()) - const listResponse = version !== null && version >= '20260323' + const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get()) + const listResponse = build !== null && build >= '20260323' const noParty = () => (listResponse ? c.json([]) : c.json({})) let thread = await getPartyThreadForPlayer(c.env.DB, id) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 3a934b2..cfe31e4 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -22,7 +22,7 @@ import { updateAccount, } from '@repo/domain' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' -import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } from '@repo/jwt' +import { validateAndGetAccountId, validateAndGetBuild, validateAndGetPlus } from '@repo/jwt' import { getCustomAvatarItems, @@ -260,16 +260,17 @@ async function authedId(c: Context): Promise { /** * The client build this request's token was minted for (`rn.ver`), as a comparable NUMBER — * the leading `YYYYMMDD` of e.g. `20250718.01`, whose `.01` is a same-day rebuild and not a - * version to order by. `null` when there is no valid token, when it carries no `rn.ver` (an - * older token, issued before the claim did), or when the claim isn't a build at all. + * version to order by (`validateAndGetBuild` drops it). `null` when there is no valid + * token, when it carries no `rn.ver` (an older token, issued before the claim did), or + * when the claim isn't a build at all. * * Unverified — a client can claim any build — which is fine for what it gates here: a build * lying about itself only changes which storefront its own player is shown. */ async function authedBuild(c: Context): Promise { - const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get()) + const version = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get()) if (version === null) return null - const build = Number.parseInt(version.split('.')[0] ?? '', 10) + const build = Number.parseInt(version, 10) return Number.isInteger(build) ? build : null } diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 6b8114e..7b15841 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -14,6 +14,7 @@ import { deletePresence, deleteRoomInvite, GAME_VERSION, + gameBuild, getAccount, getClubSummary, getExpiredPresenceAccountIds, @@ -387,16 +388,18 @@ async function callerVersion(c: Context): Promise { } /** - * 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): Promise { - 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 `[.]`, 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(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() '…). 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.', diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 95bc08d..128acc1 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -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 () => { diff --git a/apps/rooms/migrations/0028_room_instance_build.sql b/apps/rooms/migrations/0028_room_instance_build.sql new file mode 100644 index 0000000..6e26f69 --- /dev/null +++ b/apps/rooms/migrations/0028_room_instance_build.sql @@ -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 '%.%'; diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index a9b2520..fa28a31 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -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): Promise { } /** - * 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): Promise { - 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): Promise { * * 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 = new Set(['20250718.01']) +const FEATURED_ROOMS_VERSIONS: ReadonlySet = 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() '', '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.', diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index a43303a..0428d4a 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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//` 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 diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index d511f2b..68bf6de 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -34,6 +34,22 @@ export const PRESENCE_TTL_SECONDS = 900 */ export const GAME_VERSION = '20230414' +/** + * The BUILD a client version names — the `YYYYMMDD` date of `20250718.01`, `20250718.2` + * or `20250718` alike — or null when there is nothing to name (no version, or an empty + * one). The `.NN` after the date is a same-day point release: it does not change the + * protocol, the scene format or anything else this server keys on, so every build gate + * compares builds, not versions, and a point release is served exactly as its date is. + * + * Versions are still carried VERBATIM where they describe the client to someone — the + * token's `rn.ver` and presence's `appVersion` echo what the client posted. Only the + * comparisons (and the room-instance key, see `room-instance-db`) go through here. + */ +export function gameBuild(version: string | null | undefined): string | null { + const build = version?.split('.')[0] ?? '' + return build === '' ? null : build +} + /** * Client builds `/api/versioncheck/v4` answers "current" for, in the support tiers the * README publishes. They talk close enough to the same protocol to get past the update @@ -41,25 +57,33 @@ export const GAME_VERSION = '20230414' * * - `GAME_VERSION` (`20230414`, manifest `7859140924515540835`) is the DEFAULT — the * build the rest of the stack targets and the one it reports for itself. - * - `20250718.01` (manifest `1151455856673601091`, reached through the `patch-2025` - * patch) is BETA — supported, and the build the "newer than `20230414`" gates in - * `econ`/`api`/`rooms` are written and tested against. + * - `20250718` (the `20250718.01` client, manifest `1151455856673601091`, reached through + * the `patch-2025` patch) is BETA — supported, and the build the "newer than `20230414`" + * gates in `econ`/`api`/`rooms` are written and tested against. * - The rest are ALPHA: waved past the version check, but nothing else in the stack * targets them, so they can still hit protocol differences this check would otherwise * have caught. Expect breakage rather than treating a bug there as a regression. + * + * Listed WITHOUT the point release: a client reports `20250718.01`, `20250718.2` or + * `20250718` and all three are this build (see {@link gameBuild}). Keep the entries to the + * bare date — one with a `.NN` would match nothing. */ export const SUPPORTED_GAME_VERSIONS: string[] = [ GAME_VERSION, // default - '20250718.01', // beta + '20250718', // beta (20250718.01) '20230616', // alpha '20231207', // alpha - '20250424.01', // alpha - '20260323.14', // alpha + '20250424', // alpha (20250424.01) + '20260323', // alpha (20260323.14) ] -/** Whether a client-supplied build (the version check's `?v=`) is one we serve. */ +/** + * Whether a client-supplied version (the version check's `?v=`) names a build we serve. + * Compared as a build, so the client's point release never decides it. + */ export function isSupportedGameVersion(version: string | null | undefined): boolean { - return version != null && SUPPORTED_GAME_VERSIONS.includes(version) + const build = gameBuild(version) + return build !== null && SUPPORTED_GAME_VERSIONS.includes(build) } /** @@ -73,13 +97,18 @@ export function isSupportedGameVersion(version: string | null | undefined): bool * pool) — the new build runs against its own voice servers, apart from the live pool. * * One list so the two gates can't drift: a build is either rolling out or it isn't. Remove a - * build from here when it ships to everyone. + * build from here when it ships to everyone. Bare dates, like `SUPPORTED_GAME_VERSIONS`: + * every point release of a listed date is under rollout. */ -export const ROLLOUT_GAME_VERSIONS: readonly string[] = ['20260323.14'] +export const ROLLOUT_GAME_VERSIONS: readonly string[] = ['20260323'] -/** Whether a build (a token's `rn.ver`, a grant's posted `ver`) is one under staged rollout. */ +/** + * Whether a version (a token's `rn.ver`, a grant's posted `ver`) names a build under + * staged rollout. + */ export function isRolloutGameVersion(version: string | null | undefined): boolean { - return version != null && ROLLOUT_GAME_VERSIONS.includes(version) + const build = gameBuild(version) + return build !== null && ROLLOUT_GAME_VERSIONS.includes(build) } /** Schema DDL (mirror of migrations/0006_presence.sql). */ diff --git a/packages/domain/src/room-instance-db.ts b/packages/domain/src/room-instance-db.ts index 98f07a5..c54241d 100644 --- a/packages/domain/src/room-instance-db.ts +++ b/packages/domain/src/room-instance-db.ts @@ -11,10 +11,13 @@ * data_blob, allow_new_users, join_disabled) live in the blob but are dropped from * the client DTO (`toDto`) — as does `game_version`, which is this server's own * addition (migrations/0012_room_instance_version.sql) and keys matchmaking so that - * only players on the same client build share an instance. + * only players on the same client build share an instance. It is stored as a BUILD — + * the bare `YYYYMMDD` (`gameBuild`), never the client's `20250718.01` — so point releases + * of one build share a session; migrations/0028_room_instance_build.sql stripped the + * rows written before that held. */ -import { countPlayersInInstance, getPlayerIdsByRoomInstance } from './presence-db' +import { countPlayersInInstance, gameBuild, getPlayerIdsByRoomInstance } from './presence-db' /** Schema DDL (mirror of migrations/0004_room_instance.sql). */ export const ROOM_INSTANCE_SCHEMA_DDL: string[] = [ @@ -131,6 +134,10 @@ export interface NewRoomInstance { * {@link getJoinableInstance}), because two builds in one Photon room disagree about * how the scene and its objects serialize. Omitted (`''`) means "unknown", which * matches nothing: rows written before the field existed are never joined into. + * + * Stored as the BUILD (`gameBuild`): a caller may hand over the client's full + * `20250718.01` and the row carries `20250718`, the same key every lookup here + * normalizes its argument to — a `.2` point release of that date joins this session. */ gameVersion?: string } @@ -198,7 +205,7 @@ export async function createRoomInstance( matchmakingPolicy: input.matchmakingPolicy ?? 0, allowNewUsers: input.allowNewUsers ?? true, joinDisabled: input.joinDisabled ?? false, - gameVersion: input.gameVersion ?? '', + gameVersion: gameBuild(input.gameVersion) ?? '', createdAt: new Date().toISOString(), } await db @@ -235,7 +242,9 @@ export async function getStoredRoomInstance( // A row written before the build stamp existed has no `gameVersion` at all; it reads // as `''` (the "unknown build" the type promises) rather than as undefined, so a // caller comparing builds doesn't have to know the field is younger than the table. - return { ...stored, gameVersion: stored.gameVersion || '' } + // Read as a build, like it is written: a row the backfill hasn't reached yet still + // compares as its date. + return { ...stored, gameVersion: gameBuild(stored.gameVersion) ?? '' } } /** @@ -492,6 +501,9 @@ export async function deleteEmptyRoomInstances( * only instance of a room they're already in must skip it and fall through to a * fresh instance rather than hand back the same id. A no-op when they're not in this * room; instance ids are globally unique. + * + * `gameVersion` is matched as a BUILD ({@link gameBuild}): `20250718.01` and `20250718.2` + * both find the `20250718` session, which is what the rows store. */ export async function getJoinableInstance( db: D1Database, @@ -500,7 +512,7 @@ export async function getJoinableInstance( subRoomId?: number, excludeInstanceId?: number ): Promise { - const binds: Array = [roomId, gameVersion] + const binds: Array = [roomId, gameBuild(gameVersion) ?? ''] const filters: string[] = [] if (subRoomId !== undefined) { binds.push(subRoomId) @@ -555,7 +567,7 @@ export async function getSharedPrivateInstance( excludeInstanceId?: number, now = Math.floor(Date.now() / 1000) ): Promise { - const binds: Array = [roomId, gameVersion, subRoomId, now] + const binds: Array = [roomId, gameBuild(gameVersion) ?? '', subRoomId, now] let exclude = '' if (excludeInstanceId !== undefined) { binds.push(excludeInstanceId) @@ -580,9 +592,10 @@ export async function getSharedPrivateInstance( /** * All instances of a given room — every build's, unless `gameVersion` scopes it to the - * sessions running one. An instance belongs to a single client build (see - * {@link getJoinableInstance}), so a caller looking for one to place a player in wants - * the scoped form; a caller counting or listing what's live wants them all. + * sessions running one (matched as a build, like the other lookups). An instance belongs + * to a single client build (see {@link getJoinableInstance}), so a caller looking for one + * to place a player in wants the scoped form; a caller counting or listing what's live + * wants them all. */ export async function getRoomInstancesByRoom( db: D1Database, @@ -595,7 +608,7 @@ export async function getRoomInstancesByRoom( gameVersion === undefined ? '' : ' AND game_version = ?2' }` ) - .bind(...(gameVersion === undefined ? [roomId] : [roomId, gameVersion])) + .bind(...(gameVersion === undefined ? [roomId] : [roomId, gameBuild(gameVersion) ?? ''])) .all<{ data: string }>() return results.map((r) => toDto(parse(r.data))) } diff --git a/packages/jwt/src/index.ts b/packages/jwt/src/index.ts index 24af9a6..66185c3 100644 --- a/packages/jwt/src/index.ts +++ b/packages/jwt/src/index.ts @@ -1,5 +1,6 @@ export { validateAndGetAccountId, + validateAndGetBuild, validateAndGetPlus, validateAndGetRoles, validateAndGetVersion, diff --git a/packages/jwt/src/jwt.ts b/packages/jwt/src/jwt.ts index 3060b85..c183c2e 100644 --- a/packages/jwt/src/jwt.ts +++ b/packages/jwt/src/jwt.ts @@ -10,7 +10,7 @@ import { sign, verify } from 'hono/jwt' -import { GAME_VERSION } from '@repo/domain' +import { GAME_VERSION, gameBuild } from '@repo/domain' // Token lifetime in seconds (mirrored in the `expires_in` response field). // @todo Allegedly, the game is supposed to refresh tokens every 3600 seconds, but it doesn't. @@ -135,6 +135,20 @@ export async function validateAndGetVersion( } } +/** + * {@link validateAndGetVersion} reduced to the BUILD it names — `gameBuild`'s bare + * `YYYYMMDD`, so `20250718.01` and `20250718.2` both read `20250718`. `null` for the + * same reasons as the version, and when the claim is empty. The form every build gate + * wants: a point release is the same build and must be served the same way, so nothing + * should compare the raw claim against a build. + */ +export async function validateAndGetBuild( + request: Request, + secret: string +): Promise { + return gameBuild(await validateAndGetVersion(request, secret)) +} + /** Scopes stamped onto every token (as a claim array). */ const TOKEN_SCOPES = [ 'profile',