[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
+41 -12
View File
@@ -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). */
+23 -10
View File
@@ -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
View File
@@ -1,5 +1,6 @@
export {
validateAndGetAccountId,
validateAndGetBuild,
validateAndGetPlus,
validateAndGetRoles,
validateAndGetVersion,
+15 -1
View File
@@ -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',