mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[2026] relax versioncheck to major versions
This commit is contained in:
@@ -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.
|
||||
</game-clients>
|
||||
|
||||
<code-style>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// 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<App>()
|
||||
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<App>()
|
||||
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',
|
||||
|
||||
@@ -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<App>({ 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<App>({ 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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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<App>, 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -978,7 +978,7 @@ const app = new Hono<App>()
|
||||
'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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<App>()
|
||||
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)
|
||||
|
||||
|
||||
@@ -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<App>): Promise<number | null> {
|
||||
/**
|
||||
* 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<App>): Promise<number | null> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+25
-17
@@ -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.',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 '%.%';
|
||||
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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<RoomInstanceDto | null> {
|
||||
const binds: Array<number | string> = [roomId, gameVersion]
|
||||
const binds: Array<number | string> = [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<RoomInstanceDto | null> {
|
||||
const binds: Array<number | string> = [roomId, gameVersion, subRoomId, now]
|
||||
const binds: Array<number | string> = [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)))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
validateAndGetAccountId,
|
||||
validateAndGetBuild,
|
||||
validateAndGetPlus,
|
||||
validateAndGetRoles,
|
||||
validateAndGetVersion,
|
||||
|
||||
+15
-1
@@ -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<string | null> {
|
||||
return gameBuild(await validateAndGetVersion(request, secret))
|
||||
}
|
||||
|
||||
/** Scopes stamped onto every token (as a claim array). */
|
||||
const TOKEN_SCOPES = [
|
||||
'profile',
|
||||
|
||||
Reference in New Issue
Block a user