[api] quest asset split on unity target (#108)

* [api] quest asset split

* [api] unity swap + hash removal
This commit is contained in:
devin
2026-09-20 23:40:08 -04:00
committed by GitHub
parent 2909550cd6
commit 95bca2458a
10 changed files with 327 additions and 35 deletions
+59 -14
View File
@@ -19,10 +19,12 @@ import {
deleteCustomAvatarItem,
getCustomAvatarItem,
getCustomAvatarItems,
isQuestAssetTarget,
listCustomAvatarItemsByCreator,
listFeaturedCustomAvatarItems,
listHotCustomAvatarItems,
searchCustomAvatarItems,
toQuestCustomAvatarItem,
updateCustomAvatarItem,
} from '../custom-avatar-items-db'
import { authedId, unauthorized } from '../http'
@@ -150,6 +152,22 @@ async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
.filter((v) => v !== '')
}
/**
* The `unityAssetTarget` `POST /api/customAvatarItems/v1/bulk` was asked for, read the way
* {@link bulkCustomAvatarItemIds} reads the ids and for the same reason: off the query string
* (where the GET reads carry it) or out of the form, whatever the casing. `parseBody` caches,
* so reading the form a second time costs nothing.
*/
async function bulkUnityAssetTarget(c: Context<App>): Promise<string | undefined> {
const queried = c.req.query('unityAssetTarget')
if (queried !== undefined) return queried
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const key = Object.keys(body).find((k) => k.toLowerCase() === 'unityassettarget')
const posted = key === undefined ? undefined : body[key]
const value = Array.isArray(posted) ? posted[0] : posted
return typeof value === 'string' ? value : undefined
}
/**
* The gate every invention write runs through: the caller must be signed in, the
* invention must exist, and it must be theirs. Yields the loaded invention, or why not —
@@ -695,10 +713,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'`outfitTypes` values are `RecRoom.Avatars.OutfitType` ordinals; the user-generated-',
'content tab sends 105 (`CustomShirt`) alone, the storefront tab a dozen slots.',
'`itemTypes` (a separate enum: All -1, None 0, Shirt 1 — not the outfit type),',
'`ordering` (0 is `SearchScoreDescending`), `unityAssetTarget` and `unityAssetVersion`',
'are accepted and NOT yet acted on — nothing records purchase or wear counts to rank',
'by, no per-target asset variants are stored, and custom shirts are the only item type',
'there is.',
'`ordering` (0 is `SearchScoreDescending`) and `unityAssetVersion` are accepted and NOT',
'yet acted on — nothing records purchase or wear counts to rank by, no per-version',
'asset variants are stored, and custom shirts are the only item type there is.',
'`unityAssetTarget` IS acted on: 2 (Android/Oculus) serves each save’s',
'`UnityAsset`/`UnityAsset2` under `quest/`, the Android build of the same assetbundle;',
'0 (PC), any other value, and none at all get the bare filenames as stored.',
'`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,',
'whatever it says.',
].join(' '),
@@ -753,6 +773,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'true: only the Coach’s stock items; false: only player-made items; absent: both',
schema: { type: 'boolean' },
},
{
name: 'unityAssetTarget',
in: 'query',
required: false,
description:
'The Unity build target the assetbundles are wanted for: 0 PC (Windows), 2 ' +
'Android/Oculus — which is served the `quest/` builds',
schema: { type: 'integer' },
},
],
responses: { 200: json(CustomAvatarItemList, 'The matching items, newest first') },
}),
@@ -778,16 +807,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return Number.isInteger(n) ? n : undefined
}
const items = await searchCustomAvatarItems(c.env.DB, {
searchQuery: c.req.query('searchQuery'),
outfitTypes,
includeCoachItems,
minPrice: int('minPrice'),
maxPrice: int('maxPrice'),
skip: int('skip'),
take: int('take'),
})
// The same switch the bulk lookup makes: a caller asking for the Quest target is
// pointed at the `quest/` builds of the assetbundles the saves name.
return c.json(
await searchCustomAvatarItems(c.env.DB, {
searchQuery: c.req.query('searchQuery'),
outfitTypes,
includeCoachItems,
minPrice: int('minPrice'),
maxPrice: int('maxPrice'),
skip: int('skip'),
take: int('take'),
})
isQuestAssetTarget(c.req.query('unityAssetTarget'))
? items.map(toQuestCustomAvatarItem)
: items
)
}
)
@@ -845,6 +879,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'Ids ride as repeated `customAvatarItemIds` form fields; a comma-separated value ' +
'and the same spelling on the query string are both accepted, since the client’s ' +
'exact encoding here has not been pinned down.\n\n' +
'A caller asking for the Quest build — `unityAssetTarget` 2 (Android/Oculus), on ' +
'the query string or in the form — is served each save’s `UnityAsset`/`UnityAsset2` ' +
'under `quest/`, the Android build of the same assetbundle. Target 0 (PC), any ' +
'other value, and no target at all get the bare filenames as stored.\n\n' +
'A batch of more than 100 ids answers an EMPTY array without reading the table: the ' +
'client has been seen posting more than a screen could draw, and a miss is already ' +
'not an error here.',
@@ -870,8 +908,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
// Unpublished items are held back from everyone but their creator — the same rule
// the featured/hot feeds and the creator shelf apply, so an item can't be surfaced
// through this route that the feeds hide.
const visible = items.filter(
(item) => item.Accessibility !== 0 || item.CreatorAccountId === id
)
// A Quest can't load the PC assetbundles the saves name; its builds sit beside them
// under `quest/`, so the names are pointed there for a caller asking for that target.
return c.json(
items.filter((item) => item.Accessibility !== 0 || item.CreatorAccountId === id)
isQuestAssetTarget(await bulkUnityAssetTarget(c))
? visible.map(toQuestCustomAvatarItem)
: visible
)
}
)