mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[api] quest asset split on unity target (#108)
* [api] quest asset split * [api] unity swap + hash removal
This commit is contained in:
@@ -13,8 +13,8 @@
|
||||
* `just admin cai-load` instead, which runs the same statements straight against a database.
|
||||
* The statements come from src/custom-avatar-items-load.ts either way: each record stored as
|
||||
* the row's JSON minus `PurchaseInfo`, with `CreatorAccountId` forced to the Coach account (1)
|
||||
* and each save's `ThumbnailFileName` put under `avatar/`, an existing id REPLACED, nothing
|
||||
* deleted.
|
||||
* and each save's `ThumbnailFileName` put under `avatar/` and its assetbundle hashes blanked,
|
||||
* an existing id REPLACED, nothing deleted.
|
||||
*
|
||||
* The export's `Price` is mostly 0; run `price-custom-avatar-items.ts` against it first to fill
|
||||
* in prices from the storefront dump.
|
||||
|
||||
@@ -296,6 +296,59 @@ export async function getCustomAvatarItems(
|
||||
return ids.flatMap((id) => byId.get(id) ?? [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the QUEST builds of the assetbundles live, relative to the PC ones. An assetbundle is
|
||||
* built per Unity target, and a save names its bundle by bare filename for the client to fetch
|
||||
* from the cdn's `/avatar/`; the Android build of the same bundle keeps the same filename one
|
||||
* folder down, so a caller asking for the Quest target is served `quest/<name>.assetbundle` and asks the cdn for
|
||||
* `/avatar/quest/<name>.assetbundle`.
|
||||
*/
|
||||
export const QUEST_ASSET_PREFIX = 'quest/'
|
||||
|
||||
/**
|
||||
* The `unityAssetTarget` a Quest asks with — the Unity build target the caller wants its
|
||||
* assetbundles built for, which the client names on every custom-avatar-item read. 0 is PC
|
||||
* (Windows), and is what the saves' bare names are; 2 has been SEEN from the Android/Oculus
|
||||
* client and is read as that. The rest of the enum has not been observed.
|
||||
*/
|
||||
export const UNITY_ASSET_TARGET_QUEST = 2
|
||||
|
||||
/**
|
||||
* Whether a request's `unityAssetTarget` asks for the Quest builds. Anything else — 0, a
|
||||
* target not seen yet, or none at all — is served the PC names, which is what was served
|
||||
* before targets were read.
|
||||
*/
|
||||
export function isQuestAssetTarget(target: string | null | undefined): boolean {
|
||||
return target?.trim() === String(UNITY_ASSET_TARGET_QUEST)
|
||||
}
|
||||
|
||||
/**
|
||||
* An assetbundle name under {@link QUEST_ASSET_PREFIX}. Only `.assetbundle` names are touched,
|
||||
* and one that already carries the prefix is left alone; null (an absent `UnityAsset2`) is
|
||||
* returned as it came.
|
||||
*/
|
||||
function questAsset<T extends string | null>(name: T): T | string {
|
||||
if (name === null || !name.endsWith('.assetbundle')) return name
|
||||
return name.startsWith(QUEST_ASSET_PREFIX) ? name : QUEST_ASSET_PREFIX + name
|
||||
}
|
||||
|
||||
/**
|
||||
* An item as a caller asking for the Quest target is served it: every save's `UnityAsset`/`UnityAsset2` pointed at
|
||||
* the Quest build (see {@link QUEST_ASSET_PREFIX}). Per-response, never stored — the row keeps
|
||||
* the bare names, which are what every other target is served. The hashes are left as
|
||||
* stored. A player-made shirt has no saves and comes back unchanged.
|
||||
*/
|
||||
export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarItem {
|
||||
return {
|
||||
...item,
|
||||
CurrentSaves: item.CurrentSaves.map((save) => ({
|
||||
...save,
|
||||
UnityAsset: questAsset(save.UnityAsset),
|
||||
UnityAsset2: questAsset(save.UnityAsset2),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The featured feed (`GET /api/customAvatarItems/v1/featured`): items flagged
|
||||
* `is_featured` that are also published — `Accessibility` 0 is the unpublished state, so
|
||||
|
||||
@@ -78,8 +78,21 @@ export function prefixSaveThumbnail(name: unknown): unknown {
|
||||
}
|
||||
|
||||
/**
|
||||
* One record as the SQL string literal its row holds: the JSON, minus `PurchaseInfo`, with two
|
||||
* fields rewritten.
|
||||
* A save's assetbundle hash as it is stored: BLANK. The export's `UnityAssetHash` /
|
||||
* `UnityAsset2Hash` are the hashes of the PC builds, and one stored save is served to every
|
||||
* build target — a Quest asking with `unityAssetTarget=2` is pointed at the `quest/` build of
|
||||
* the same bundle, whose bytes (and so whose hash) differ. The client checks a download against
|
||||
* a hash it is given and refuses the mismatch, so the Quest rendered nothing until these were
|
||||
* emptied by hand; given `""` it skips the check. A hash that is already null — the
|
||||
* `UnityAsset2Hash` of a save with no second bundle — stays null, so the pair keeps agreeing.
|
||||
*/
|
||||
export function blankAssetHash(hash: unknown): unknown {
|
||||
return typeof hash === 'string' ? '' : hash
|
||||
}
|
||||
|
||||
/**
|
||||
* One record as the SQL string literal its row holds: the JSON, minus `PurchaseInfo`, with
|
||||
* three things rewritten.
|
||||
*
|
||||
* `CreatorAccountId` is overridden to {@link COACH_ACCOUNT_ID} whatever the export said. An
|
||||
* imported item is first-party content by definition, and the creator id is what makes it so
|
||||
@@ -90,22 +103,28 @@ export function prefixSaveThumbnail(name: unknown): unknown {
|
||||
* account happened to share the number.
|
||||
*
|
||||
* Each save's `ThumbnailFileName` is prefixed with {@link SAVE_THUMBNAIL_PREFIX}, which is where
|
||||
* this server serves those images from. The rest of the save — the assetbundle names above
|
||||
* all — is untouched.
|
||||
* this server serves those images from, and its `UnityAssetHash`/`UnityAsset2Hash` are blanked
|
||||
* (see {@link blankAssetHash}). The rest of the save — the assetbundle names above all — is
|
||||
* untouched.
|
||||
*/
|
||||
export function customAvatarItemRowLiteral(record: CustomAvatarItemExportRecord): string {
|
||||
const { PurchaseInfo: _purchaseInfo, ...stored } = record
|
||||
const saves = Array.isArray(stored.CurrentSaves)
|
||||
? stored.CurrentSaves.map((save: unknown) =>
|
||||
save && typeof save === 'object' && !Array.isArray(save)
|
||||
? {
|
||||
...(save as Record<string, unknown>),
|
||||
ThumbnailFileName: prefixSaveThumbnail(
|
||||
(save as { ThumbnailFileName?: unknown }).ThumbnailFileName
|
||||
),
|
||||
}
|
||||
: save
|
||||
)
|
||||
? stored.CurrentSaves.map((save: unknown) => {
|
||||
if (!save || typeof save !== 'object' || Array.isArray(save)) return save
|
||||
const fields = save as Record<string, unknown>
|
||||
return {
|
||||
...fields,
|
||||
ThumbnailFileName: prefixSaveThumbnail(fields.ThumbnailFileName),
|
||||
// Only where the export carries the key, so a save is never GIVEN a field.
|
||||
...('UnityAssetHash' in fields && {
|
||||
UnityAssetHash: blankAssetHash(fields.UnityAssetHash),
|
||||
}),
|
||||
...('UnityAsset2Hash' in fields && {
|
||||
UnityAsset2Hash: blankAssetHash(fields.UnityAsset2Hash),
|
||||
}),
|
||||
}
|
||||
})
|
||||
: stored.CurrentSaves
|
||||
const row = { ...stored, CreatorAccountId: COACH_ACCOUNT_ID, CurrentSaves: saves }
|
||||
return `'${JSON.stringify(row).replaceAll("'", "''")}'`
|
||||
|
||||
@@ -879,6 +879,13 @@ export const BulkCustomAvatarItemsRequest = z.object({
|
||||
customAvatarItemIds: z
|
||||
.array(z.string())
|
||||
.describe('The ids to resolve; repeat the field once per id'),
|
||||
unityAssetTarget: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe(
|
||||
'The Unity build target the assetbundles are wanted for: 0 PC (Windows), 2 ' +
|
||||
'Android/Oculus — which is served the `quest/` builds. Read off the query string too'
|
||||
),
|
||||
})
|
||||
|
||||
/** A paginated custom-avatar-item page, out of the `custom_avatar_item` table. */
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
||||
importCustomAvatarItem,
|
||||
} from '../../custom-avatar-items-db'
|
||||
import { customAvatarItemRowLiteral } from '../../custom-avatar-items-load'
|
||||
import {
|
||||
countGoing,
|
||||
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
|
||||
@@ -1340,6 +1341,56 @@ describe('public endpoints', () => {
|
||||
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
||||
})
|
||||
|
||||
test('the first-party load blanks a save’s assetbundle hashes and leaves the names alone', () => {
|
||||
// The export's hashes are the PC builds'. The same stored save is served to a Quest
|
||||
// pointed at the `quest/` build, which fails the client's check against a PC hash — so
|
||||
// the load stores them blank. A null hash (no second bundle) stays null.
|
||||
const literal = customAvatarItemRowLiteral({
|
||||
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||
CreatorAccountId: 9001,
|
||||
CurrentSaves: [
|
||||
{
|
||||
BodyType: 1,
|
||||
ThumbnailFileName: 'f2y1ndzuvm5ke2hjmn4cwfwfl.png',
|
||||
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
|
||||
UnityAssetHash: 'b1946ac92492d2347c6235b4d2611184',
|
||||
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
|
||||
UnityAsset2Hash: '591785b794601e212b260e25925636fd',
|
||||
},
|
||||
{
|
||||
BodyType: 2,
|
||||
ThumbnailFileName: 'div4cdxc5b7ameui0d5h8ickl.png',
|
||||
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
|
||||
UnityAssetHash: '',
|
||||
UnityAsset2: null,
|
||||
UnityAsset2Hash: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(JSON.parse(literal.slice(1, -1))).toEqual({
|
||||
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||
CreatorAccountId: 1,
|
||||
CurrentSaves: [
|
||||
{
|
||||
BodyType: 1,
|
||||
ThumbnailFileName: 'avatar/f2y1ndzuvm5ke2hjmn4cwfwfl.png',
|
||||
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
|
||||
UnityAssetHash: '',
|
||||
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
|
||||
UnityAsset2Hash: '',
|
||||
},
|
||||
{
|
||||
BodyType: 2,
|
||||
ThumbnailFileName: 'avatar/div4cdxc5b7ameui0d5h8ickl.png',
|
||||
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
|
||||
UnityAssetHash: '',
|
||||
UnityAsset2: null,
|
||||
UnityAsset2Hash: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v1/search serves an imported first-party item as exported', async () => {
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
|
||||
@@ -1441,6 +1492,24 @@ describe('public endpoints', () => {
|
||||
)
|
||||
expect(store).toEqual([{ ...wings, PurchaseInfo: null }])
|
||||
|
||||
// The same query from a Quest (`unityAssetTarget=2`, Android/Oculus) serves the same
|
||||
// record with its assetbundles pointed at the `quest/` builds, and nothing else moved.
|
||||
const questStore = await search(
|
||||
'?outfitTypes=100&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
|
||||
'&take=100&unityAssetTarget=2&unityAssetVersion=3'
|
||||
)
|
||||
expect(questStore).toEqual([
|
||||
{
|
||||
...wings,
|
||||
PurchaseInfo: null,
|
||||
CurrentSaves: wings.CurrentSaves.map((save) => ({
|
||||
...save,
|
||||
UnityAsset: `quest/${save.UnityAsset}`,
|
||||
UnityAsset2: `quest/${save.UnityAsset2}`,
|
||||
})),
|
||||
},
|
||||
])
|
||||
|
||||
// Text search matches it by name and by description, and by its id typed whole.
|
||||
expect((await search('?searchQuery=skeletal')).map((i) => i.Name)).toEqual(['Skeletal Wings'])
|
||||
expect((await search('?searchQuery=connection%20point')).map((i) => i.Name)).toEqual([
|
||||
@@ -1484,6 +1553,31 @@ describe('public endpoints', () => {
|
||||
const [got] = (await bulk.json()) as Array<{ CurrentSaves: unknown[]; Price: number }>
|
||||
expect(got.CurrentSaves).toHaveLength(2)
|
||||
expect(got.Price).toBe(5000)
|
||||
|
||||
// A caller asking for the Quest build (`unityAssetTarget` 2, Android/Oculus) is pointed at
|
||||
// the Quest builds of the same assetbundles, under `quest/` — whether the target rides on
|
||||
// the query string or in the form. Target 0 (PC) and no target get the names as stored.
|
||||
const bulkAssets = async (query: string, form: string[][] = []) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk${query}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
...(await bearer('42')),
|
||||
},
|
||||
body: new URLSearchParams([['customAvatarItemIds', wings.CustomAvatarItemId], ...form]),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const [item] = (await res.json()) as Array<{
|
||||
CurrentSaves: Array<{ UnityAsset: string; UnityAsset2: string | null }>
|
||||
}>
|
||||
return item.CurrentSaves.map((save) => [save.UnityAsset, save.UnityAsset2])
|
||||
}
|
||||
const stored = wings.CurrentSaves.map((save) => [save.UnityAsset, save.UnityAsset2])
|
||||
const quest = stored.map((names) => names.map((name) => `quest/${name}`))
|
||||
expect(await bulkAssets('')).toEqual(stored)
|
||||
expect(await bulkAssets('?unityAssetTarget=0')).toEqual(stored)
|
||||
expect(await bulkAssets('?unityAssetTarget=2')).toEqual(quest)
|
||||
expect(await bulkAssets('', [['unityAssetTarget', '2']])).toEqual(quest)
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v2/fromCreator/:id shows unpublished items only to the creator', async () => {
|
||||
|
||||
Reference in New Issue
Block a user