[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
+6 -5
View File
@@ -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',
+6 -5
View File
@@ -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)
}
)
+6 -5
View File
@@ -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)
}
+15
View File
@@ -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 () => {
+1 -1
View File
@@ -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 -4
View File
@@ -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)
+6 -5
View File
@@ -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
View File
@@ -14,6 +14,7 @@ import {
deletePresence,
deleteRoomInvite,
GAME_VERSION,
gameBuild,
getAccount,
getClubSummary,
getExpiredPresenceAccountIds,
@@ -387,16 +388,18 @@ async function callerVersion(c: Context<App>): Promise<string | null> {
}
/**
* The build to matchmake the caller as — {@link callerVersion} with the server's own
* `GAME_VERSION` standing in when the token doesn't say (the same fallback presence
* uses, so a player's instance and their reported build agree).
* The BUILD to matchmake the caller as — {@link callerVersion} reduced to its date
* ({@link gameBuild}: `20250718.01` → `20250718`), with the server's own `GAME_VERSION`
* standing in when the token doesn't say (the same fallback presence uses, so a player's
* instance and their reported build agree). A point release is the same build: a
* `20250718.2` client lands in the `20250718` session, beside the `.01` who opened it.
*
* Unlike presence, this can never be left unset: it is the key an instance is created
* under and searched by, and an empty one would pool every unknown-build player into a
* shared "" bucket — exactly the mixed session the stamp exists to prevent.
*/
async function callerGameVersion(c: Context<App>): Promise<string> {
return (await callerVersion(c)) ?? GAME_VERSION
return gameBuild(await callerVersion(c)) ?? GAME_VERSION
}
/**
@@ -711,16 +714,20 @@ const BANNED_FROM_ROOM = MatchmakingErrorCode.BannedFromRoom
* `UpdateRequired`, the one code that says "your client can't go there" and the honest
* answer. There is no code for the other direction ("they must update"), so a caller on
* the newer build gets the opaque NoSuchRoom every other unjoinable thing answers.
* Builds are date-stamped (`20230414`, `20250718.01`), so they order as strings; an
* instance carrying no version at all (written before the stamp existed) is nobody's
* Both sides are compared as BUILDS ({@link gameBuild}) — either may arrive as a full
* client version (a presence row's `appVersion` is verbatim) and a point release is the
* same build. Builds are date-stamped (`20230414`, `20250718`), so they order as strings;
* an instance carrying no version at all (written before the stamp existed) is nobody's
* build and refuses both ways.
*/
function crossBuildRefusal(
callerVersion: string,
instanceVersion: string
): MatchmakingErrorCode | null {
if (callerVersion === instanceVersion) return null
return instanceVersion > callerVersion ? MatchmakingErrorCode.UpdateRequired : NO_SUCH_ROOM
const caller = gameBuild(callerVersion) ?? ''
const instance = gameBuild(instanceVersion) ?? ''
if (caller === instance) return null
return instance > caller ? MatchmakingErrorCode.UpdateRequired : NO_SUCH_ROOM
}
/**
@@ -854,12 +861,12 @@ function nextLiveMessageId(): number {
const GAME_INVITE_V2_MIN_BUILD = 20230414
/**
* The date portion of a client build (`20250718.01` → `20250718`) as a number, or null when
* there's nothing parseable to compare. Builds are `<yyyymmdd>[.<n>]`, so the leading date
* orders them; the point release after it never decides this gate.
* A client build ({@link gameBuild}: `20250718.01` → `20250718`) as a number, or null when
* there's nothing parseable to compare. The leading date orders builds; the point release
* after it never decides a gate.
*/
function buildNumber(version: string | null): number | null {
const date = Number.parseInt(version?.split('.')[0] ?? '', 10)
const date = Number.parseInt(gameBuild(version) ?? '', 10)
return Number.isNaN(date) ? null : date
}
@@ -1563,10 +1570,11 @@ async function resolveRoomInstance(
const currentInstanceId = reusesInstance
? (await getPresence<RoomInstance>(c.env.DB, ownerId))?.roomInstance?.roomInstanceId
: undefined
// The same build, with GAME_VERSION standing in for a token that names none. It scopes
// the search below and is stamped on the instance when one is created, which is what
// keeps a session to a single client version.
const gameVersion = tokenVersion ?? GAME_VERSION
// The same BUILD (the date, with the client's point release dropped), with GAME_VERSION
// standing in for a token that names none. It scopes the search below and is stamped on
// the instance when one is created, which is what keeps a session to a single client
// build — and lets every point release of that build share it.
const gameVersion = gameBuild(tokenVersion) ?? GAME_VERSION
// Reuse an existing joinable instance *of the same subroom and the same build* —
// subrooms are separate places, so joining one must never land you in another, and
// neither must a session running a different version of the room. A published room
@@ -3114,7 +3122,7 @@ const app = new Hono<App>()
'…). Both are empty when the pool is unset or the caller is in no instance. A',
'sandbox account is picked out of the `TACHYON_HOST_PORT_SANDBOX` pool instead, the',
'same way, with ids `dev-1`, `dev-2`, …; a caller whose token’s `rn.ver` is a build',
'under staged rollout (`ROLLOUT_GAME_VERSIONS`, currently `20260323.14`) out of',
'under staged rollout (`ROLLOUT_GAME_VERSIONS`, currently `20260323`) out of',
'`TACHYON_HOST_PORT_ROLLOUT`, with ids `rollout-1`, `rollout-2`, …. Sandbox wins when',
'both apply, and either special pool left unset falls back to the live one.',
'`experiments` carries the client’s networking flags.',
+35 -1
View File
@@ -1351,6 +1351,38 @@ describe('auth-gated endpoints', () => {
expect(priv2.roomInstance.roomInstanceId).not.toBe(priv.roomInstance.roomInstanceId)
})
test('POST /matchmake/room/:roomId pools every point release of a build into one instance', async () => {
const matchmake = async (sub: string, version: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer(sub, version),
})
).json()) as { roomInstance: { photonRoomId: string; roomInstanceId: number } }
// The `.NN` after the date is a same-day rebuild of the same build, so a `.01` client,
// a `.2` client and one reporting the bare date all belong in one session.
const first = await matchmake('9170', '20250718.01')
const point = await matchmake('9171', '20250718.2')
const bare = await matchmake('9172', '20250718')
expect(point.roomInstance.roomInstanceId).toBe(first.roomInstance.roomInstanceId)
expect(bare.roomInstance.roomInstanceId).toBe(first.roomInstance.roomInstanceId)
// The instance is stamped with the BUILD, not the version that opened it — the key
// the lookups match on. (The same shape migrations/0028 backfills older rows into.)
const row = await env.DB.prepare('SELECT game_version FROM room_instance WHERE id = ?1')
.bind(first.roomInstance.roomInstanceId)
.first<{ game_version: string }>()
expect(row?.game_version).toBe('20250718')
// The point release doesn't leak into presence: that still reports the version the
// client posted, which is what describes the player to their friends.
const presence = await env.DB.prepare(
'SELECT data FROM presence WHERE account_id = 9171'
).first<{ data: string }>()
expect(JSON.parse(presence?.data ?? '{}').appVersion).toBe('20250718.2')
})
test('POST /matchmake/room/:roomId only pools players on the same client build', async () => {
const matchmake = async (sub: string, version: string) =>
(await (
@@ -2651,9 +2683,11 @@ describe('auth-gated endpoints', () => {
const older = await spawn('915', '20250424.01')
expect(await join(older, '20250718.01')).toEqual(refused(20))
// Same build → in they go.
// Same build → in they go — and a point release IS the same build: the instance holds
// the bare date, and the caller's `.2` reduces to it.
const same = await spawn('916', '20250718.01')
expect(await join(same, '20250718.01')).toMatchObject({ errorCode: 0 })
expect(await join(same, '20250718.2')).toMatchObject({ errorCode: 0 })
})
test('POST /matchmake/instance/:id joins that exact instance, owner-only', async () => {
@@ -0,0 +1,18 @@
-- A room instance's `gameVersion` is now a BUILD — the bare `YYYYMMDD` — rather than the
-- client's full version. The `.NN` after the date is a same-day point release of the
-- same build: it changes nothing a session keys on, so `20250718.01` and `20250718.2`
-- belong in one Photon room, and `SUPPORTED_GAME_VERSIONS` lists dates alone.
--
-- Matchmaking now stamps the build (createRoomInstance runs the value through
-- `gameBuild`) and every lookup normalizes its argument the same way, so a row still
-- carrying `20250718.01` would match NOBODY from here on: a 20250718 player's search is
-- `game_version = '20250718'`. Strip the point release off every live row so the sessions
-- open at the moment of the deploy stay joinable. Rows with no `.` (already bare, or the
-- NULL of a row older than the stamp) are left exactly as they are.
--
-- `game_version` is a virtual column over the `data` blob (0012), so the blob is what's
-- rewritten; the column follows.
UPDATE room_instance
SET data = json_set(data, '$.gameVersion', substr(game_version, 1, instr(game_version, '.') - 1))
WHERE game_version LIKE '%.%';
+11 -8
View File
@@ -93,7 +93,7 @@ import {
withNotFound,
withOnError,
} from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles, validateAndGetVersion } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetBuild, validateAndGetRoles } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
@@ -470,16 +470,17 @@ async function authedAccountId(c: Context<App>): Promise<number | null> {
}
/**
* The client build this request's token was minted for (`rn.ver`), or null when there's no
* valid token. The claim is the build the CLIENT posted at login, not this server's
* GAME_VERSION, so it identifies what the player is actually running.
* The client BUILD this request's token was minted for (`rn.ver` as its bare `YYYYMMDD`,
* so a `20250718.01` client reads `20250718`), or null when there's no valid token. The
* claim is the build the CLIENT posted at login, not this server's GAME_VERSION, so it
* identifies what the player is actually running.
*
* It is unverified — a client can claim any build — which is fine for the one thing it is
* used for here: keeping a payload away from a build it breaks. Lying about your version to
* opt IN to a broken room list only breaks your own client.
*/
async function authedGameVersion(c: Context<App>): Promise<string | null> {
return validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
return validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
@@ -493,9 +494,11 @@ async function authedGameVersion(c: Context<App>): Promise<string | null> {
*
* An allow-list rather than a "this build or newer" comparison, deliberately: the thing
* being asserted is that a build was CHECKED, and a version string that sorts high (a
* debug build, say) must not opt itself in. Add a build here once it's been tried.
* debug build, say) must not opt itself in. Add a build here once it's been tried — as
* the bare date, which is what {@link authedGameVersion} yields; every point release of
* it (`20250718.01`, `20250718.2`) is the same build.
*/
const FEATURED_ROOMS_VERSIONS: ReadonlySet<string> = new Set(['20250718.01'])
const FEATURED_ROOMS_VERSIONS: ReadonlySet<string> = new Set(['20250718'])
/**
* Operator-granted elevated roles — the ones the auth worker stamps from an account's
@@ -1522,7 +1525,7 @@ const app = new Hono<App>()
'',
'Restricted by CLIENT BUILD. Serving this to the 2023 client breaks its other room',
'listings (NREs, apparently from the featured-room load corrupting its room cache), so',
'only the builds known to render it — `20250718.01` today — get the group; anything',
'only the builds known to render it — `20250718` today — get the group; anything',
'else gets a 404, the same answer it got while the route was parked. The build is read',
'from the token’s `rn.ver` claim, which is why this needs a token at all: nothing in',
'the group itself is per-player.',
@@ -1274,6 +1274,15 @@ describe('rooms endpoints', () => {
expect(body.Rooms.every((r) => typeof r.RoomName === 'string')).toBe(true)
// The dorm (RoomId 1) is non-public, so it's never featured.
expect(body.Rooms.some((r) => r.RoomId === 1)).toBe(false)
// The allow-list holds the BUILD (the bare date), so every point release of it is
// served: a `.2` rebuild of the 2025 client renders the group the same as `.01`.
for (const version of ['20250718.2', '20250718']) {
const point = await SELF.fetch(`${ORIGIN}/featuredrooms/current`, {
headers: await bearer('1', undefined, version),
})
expect(point.status, version).toBe(200)
}
})
it('GET /featuredrooms/current serves at most 10 rooms', async () => {
@@ -6336,6 +6345,53 @@ describe('studio room bundles', () => {
UnitySubAssets: [],
})
})
// A FIRST-PARTY room's bundle — captured from the official game and loaded by hand
// under its `unity/<roomId>/` layout — shares `unity_asset` with Studio builds but is
// not UGC, and must not be folded onto the save: the 2025 client handed it inline
// takes it instead of asking the bulk lookup, cannot name it, and loads nothing (every
// imported room broke when the splice moved from the Studio-only table to `unity_asset`).
// The bulk lookup is where that client resolves it, and still serves it.
it('leaves a first-party bundle to the bulk lookup rather than the room read', async () => {
await reset()
const firstParty = 'unity/9901/8iaf2yemao2d1zurblo875cn2.assetbundle'
await putUnityAsset(env.DB, {
UnityAssetId: ASSET,
Target: 0,
Version: 3,
Filename: firstParty,
Hash: winHash,
})
type Loaded = {
SubRooms: Array<{ CurrentSave: { UnitySubAssets: unknown[]; SubRoomDataSaveId: number } }>
}
for (const query of ['', '?unityAssetTarget=0']) {
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/9901${query}`)).json()) as Loaded
expect(room.SubRooms[0]!.CurrentSave.UnitySubAssets, query).toEqual([])
}
const saveId = ((await (await SELF.fetch(`${ORIGIN}/rooms/9901`)).json()) as Loaded)
.SubRooms[0]!.CurrentSave.SubRoomDataSaveId
const detail = await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/saves/${saveId}`, {
headers: await bearer('1'),
})
expect(await detail.json()).toMatchObject({
UnityAsset: null,
UnityAssetHash: null,
UnitySubAssets: [],
})
const bulk = await SELF.fetch(`${ORIGIN}/unity_assets/baked/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ target: '0', version: '3', id: ASSET }).toString(),
})
expect(bulk.status).toBe(200)
expect(await bulk.json()).toEqual([
{ UnityAssetId: ASSET, Target: 0, Version: 3, Filename: firstParty, Hash: winHash },
])
})
})
// Room and subroom names take letters, digits and underscores, at most 32 (see