[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
+11
View File
@@ -220,6 +220,17 @@ inconsistency here without checking the client first.
empty `CurrentSaves`/`Tags` and null `CustomBadgeMetadata`, never a missing key. The saves empty `CurrentSaves`/`Tags` and null `CustomBadgeMetadata`, never a missing key. The saves
name their assetbundles and thumbnails by bare filename; storing the record does nothing name their assetbundles and thumbnails by bare filename; storing the record does nothing
about serving those files. about serving those files.
- An assetbundle is built PER UNITY TARGET, and the client names the one it wants as
`unityAssetTarget` on every custom-avatar-item read (0 PC/Windows, 2 Android/Oculus — the
rest unobserved; a query param on the GETs, a FORM field on `POST …/v1/bulk`). The row stores
the bare PC names; a caller asking for 2 is served `UnityAsset`/`UnityAsset2` under `quest/`
(`toQuestCustomAvatarItem`), fetched from the cdn as `/avatar/quest/<name>.assetbundle`.
Every read a Quest renders from has to make the switch — `api` search and bulk, AND `econ`
`GET /econ/customAvatarItems/v1/owned`, which the client reads FIRST and renders worn items
from: with only `api` switched the Quest kept downloading PC bundles. The load BLANKS each
save's `UnityAssetHash`/`UnityAsset2Hash` (`""`, null stays null): the export's are the PC
builds' hashes, one stored save serves both targets, and the client refuses a download that
fails the hash it was given but skips the check on an empty one.
- Buying a custom avatar item goes through the ordinary bag (`econ`: `POST /api/items/bulkpurchase`) - Buying a custom avatar item goes through the ordinary bag (`econ`: `POST /api/items/bulkpurchase`)
as a line whose `ItemPurchaseMethodId` is `{ Type: 1, Guid: <CustomAvatarItemId> }`, beside as a line whose `ItemPurchaseMethodId` is `{ Type: 1, Guid: <CustomAvatarItemId> }`, beside
the `Type: 0, NumberId` catalog lines. It is a SALE between players: the price leaves the buyer the `Type: 0, NumberId` catalog lines. It is a SALE between players: the price leaves the buyer
@@ -13,8 +13,8 @@
* `just admin cai-load` instead, which runs the same statements straight against a database. * `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 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) * 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 * and each save's `ThumbnailFileName` put under `avatar/` and its assetbundle hashes blanked,
* deleted. * an existing id REPLACED, nothing deleted.
* *
* The export's `Price` is mostly 0; run `price-custom-avatar-items.ts` against it first to fill * The export's `Price` is mostly 0; run `price-custom-avatar-items.ts` against it first to fill
* in prices from the storefront dump. * in prices from the storefront dump.
+53
View File
@@ -296,6 +296,59 @@ export async function getCustomAvatarItems(
return ids.flatMap((id) => byId.get(id) ?? []) 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 * The featured feed (`GET /api/customAvatarItems/v1/featured`): items flagged
* `is_featured` that are also published — `Accessibility` 0 is the unpublished state, so * `is_featured` that are also published — `Accessibility` 0 is the unpublished state, so
+32 -13
View File
@@ -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 * A save's assetbundle hash as it is stored: BLANK. The export's `UnityAssetHash` /
* fields rewritten. * `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 * `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 * 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. * account happened to share the number.
* *
* Each save's `ThumbnailFileName` is prefixed with {@link SAVE_THUMBNAIL_PREFIX}, which is where * 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 * this server serves those images from, and its `UnityAssetHash`/`UnityAsset2Hash` are blanked
* all — is untouched. * (see {@link blankAssetHash}). The rest of the save — the assetbundle names above all — is
* untouched.
*/ */
export function customAvatarItemRowLiteral(record: CustomAvatarItemExportRecord): string { export function customAvatarItemRowLiteral(record: CustomAvatarItemExportRecord): string {
const { PurchaseInfo: _purchaseInfo, ...stored } = record const { PurchaseInfo: _purchaseInfo, ...stored } = record
const saves = Array.isArray(stored.CurrentSaves) const saves = Array.isArray(stored.CurrentSaves)
? stored.CurrentSaves.map((save: unknown) => ? stored.CurrentSaves.map((save: unknown) => {
save && typeof save === 'object' && !Array.isArray(save) if (!save || typeof save !== 'object' || Array.isArray(save)) return save
? { const fields = save as Record<string, unknown>
...(save as Record<string, unknown>), return {
ThumbnailFileName: prefixSaveThumbnail( ...fields,
(save as { ThumbnailFileName?: unknown }).ThumbnailFileName 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),
}),
} }
: save })
)
: stored.CurrentSaves : stored.CurrentSaves
const row = { ...stored, CreatorAccountId: COACH_ACCOUNT_ID, CurrentSaves: saves } const row = { ...stored, CreatorAccountId: COACH_ACCOUNT_ID, CurrentSaves: saves }
return `'${JSON.stringify(row).replaceAll("'", "''")}'` return `'${JSON.stringify(row).replaceAll("'", "''")}'`
+7
View File
@@ -879,6 +879,13 @@ export const BulkCustomAvatarItemsRequest = z.object({
customAvatarItemIds: z customAvatarItemIds: z
.array(z.string()) .array(z.string())
.describe('The ids to resolve; repeat the field once per id'), .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. */ /** A paginated custom-avatar-item page, out of the `custom_avatar_item` table. */
+52 -7
View File
@@ -19,10 +19,12 @@ import {
deleteCustomAvatarItem, deleteCustomAvatarItem,
getCustomAvatarItem, getCustomAvatarItem,
getCustomAvatarItems, getCustomAvatarItems,
isQuestAssetTarget,
listCustomAvatarItemsByCreator, listCustomAvatarItemsByCreator,
listFeaturedCustomAvatarItems, listFeaturedCustomAvatarItems,
listHotCustomAvatarItems, listHotCustomAvatarItems,
searchCustomAvatarItems, searchCustomAvatarItems,
toQuestCustomAvatarItem,
updateCustomAvatarItem, updateCustomAvatarItem,
} from '../custom-avatar-items-db' } from '../custom-avatar-items-db'
import { authedId, unauthorized } from '../http' import { authedId, unauthorized } from '../http'
@@ -150,6 +152,22 @@ async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
.filter((v) => v !== '') .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 * 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 — * 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-', '`outfitTypes` values are `RecRoom.Avatars.OutfitType` ordinals; the user-generated-',
'content tab sends 105 (`CustomShirt`) alone, the storefront tab a dozen slots.', '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),', '`itemTypes` (a separate enum: All -1, None 0, Shirt 1 — not the outfit type),',
'`ordering` (0 is `SearchScoreDescending`), `unityAssetTarget` and `unityAssetVersion`', '`ordering` (0 is `SearchScoreDescending`) and `unityAssetVersion` are accepted and NOT',
'are accepted and NOT yet acted on — nothing records purchase or wear counts to rank', 'yet acted on — nothing records purchase or wear counts to rank by, no per-version',
'by, no per-target asset variants are stored, and custom shirts are the only item type', 'asset variants are stored, and custom shirts are the only item type there is.',
'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,', '`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,',
'whatever it says.', 'whatever it says.',
].join(' '), ].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', 'true: only the Coach’s stock items; false: only player-made items; absent: both',
schema: { type: 'boolean' }, 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') }, responses: { 200: json(CustomAvatarItemList, 'The matching items, newest first') },
}), }),
@@ -778,8 +807,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return Number.isInteger(n) ? n : undefined return Number.isInteger(n) ? n : undefined
} }
return c.json( const items = await searchCustomAvatarItems(c.env.DB, {
await searchCustomAvatarItems(c.env.DB, {
searchQuery: c.req.query('searchQuery'), searchQuery: c.req.query('searchQuery'),
outfitTypes, outfitTypes,
includeCoachItems, includeCoachItems,
@@ -788,6 +816,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
skip: int('skip'), skip: int('skip'),
take: int('take'), 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(
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 ' + '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 ' + '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' + '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 ' + '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 ' + 'client has been seen posting more than a screen could draw, and a miss is already ' +
'not an error here.', '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 // 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 // the featured/hot feeds and the creator shelf apply, so an item can't be surfaced
// through this route that the feeds hide. // 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( return c.json(
items.filter((item) => item.Accessibility !== 0 || item.CreatorAccountId === id) isQuestAssetTarget(await bulkUnityAssetTarget(c))
? visible.map(toQuestCustomAvatarItem)
: visible
) )
} }
) )
+94
View File
@@ -44,6 +44,7 @@ import {
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL, SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
importCustomAvatarItem, importCustomAvatarItem,
} from '../../custom-avatar-items-db' } from '../../custom-avatar-items-db'
import { customAvatarItemRowLiteral } from '../../custom-avatar-items-load'
import { import {
countGoing, countGoing,
SCHEMA_DDL as EVENTS_SCHEMA_DDL, SCHEMA_DDL as EVENTS_SCHEMA_DDL,
@@ -1340,6 +1341,56 @@ describe('public endpoints', () => {
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat']) ).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 () => { 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() await env.DB.prepare('DELETE FROM custom_avatar_item').run()
@@ -1441,6 +1492,24 @@ describe('public endpoints', () => {
) )
expect(store).toEqual([{ ...wings, PurchaseInfo: null }]) 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. // 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=skeletal')).map((i) => i.Name)).toEqual(['Skeletal Wings'])
expect((await search('?searchQuery=connection%20point')).map((i) => i.Name)).toEqual([ 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 }> const [got] = (await bulk.json()) as Array<{ CurrentSaves: unknown[]; Price: number }>
expect(got.CurrentSaves).toHaveLength(2) expect(got.CurrentSaves).toHaveLength(2)
expect(got.Price).toBe(5000) 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 () => { test('GET /api/customAvatarItems/v2/fromCreator/:id shows unpublished items only to the creator', async () => {
+12 -1
View File
@@ -23,6 +23,8 @@ import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } fr
import { import {
getCustomAvatarItems, getCustomAvatarItems,
isQuestAssetTarget,
toQuestCustomAvatarItem,
toUgcPurchasable, toUgcPurchasable,
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM, UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
} from '../../api/src/custom-avatar-items-db' } from '../../api/src/custom-avatar-items-db'
@@ -2661,6 +2663,9 @@ const app = new Hono<App>({ strict: false })
'`CustomAvatarItem` record out of the `custom_avatar_item` table, oldest first by when', '`CustomAvatarItem` record out of the `custom_avatar_item` table, oldest first by when',
'it became theirs, in the `{ Results, TotalResults }` envelope. No paging is applied', 'it became theirs, in the `{ Results, TotalResults }` envelope. No paging is applied',
'(the client sends none), so `TotalResults` is the list’s length.', '(the client sends none), so `TotalResults` is the list’s length.',
'`unityAssetTarget` 2 (Android/Oculus) serves each save’s `UnityAsset`/`UnityAsset2`',
'under `quest/`, the Android build of the same assetbundle — the same switch `api`’s',
'search and bulk reads make; 0 (PC), any other value, and none get the names as stored.',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
responses: { responses: {
@@ -2671,7 +2676,13 @@ const app = new Hono<App>({ strict: false })
async (c) => { async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
const Results = await getOwnedCustomAvatarItems(c.env.DB, id) const owned = await getOwnedCustomAvatarItems(c.env.DB, id)
// The client renders what the player is WEARING from this list, and it is read before
// any `api` lookup — so a Quest served PC names here downloads PC assetbundles however
// the search and bulk reads answer.
const Results = isQuestAssetTarget(c.req.query('unityAssetTarget'))
? owned.map(toQuestCustomAvatarItem)
: owned
return c.json({ Results, TotalResults: Results.length }) return c.json({ Results, TotalResults: Results.length })
} }
) )
@@ -20,6 +20,7 @@ import {
import { import {
createCustomAvatarItem, createCustomAvatarItem,
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL, SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
importCustomAvatarItem,
} from '../../../../api/src/custom-avatar-items-db' } from '../../../../api/src/custom-avatar-items-db'
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db' import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
// The notification-type ids the hub carries, from the worker that owns them — asserting // The notification-type ids the hub carries, from the worker that owns them — asserting
@@ -588,6 +589,56 @@ describe('econ endpoints', () => {
], ],
TotalResults: 4, TotalResults: 4,
}) })
// A first-party item is rendered from its saves' assetbundles. A caller asking for the
// Quest target (`unityAssetTarget=2`, Android/Oculus) is pointed at the `quest/` builds;
// target 0 (PC) gets the names as stored.
const save = {
CustomAvatarItemSaveId: 1,
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
UnityAssetId: crypto.randomUUID(),
BodyType: 2,
OutfitType: 100,
QAState: 0,
CreatedAt: '2024-09-26T21:32:48.523Z',
ModifiedAt: '2024-09-26T21:32:48.523Z',
Description: null,
ThumbnailFileName: 'avatar/f2y1ndzuvm5ke2hjmn4cwfwfl.png',
AdditionalConfiguration: '{}',
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
UnityAssetHash: 'hash-a',
UnityAsset2: null,
UnityAsset2Hash: null,
}
const wings = await importCustomAvatarItem(env.DB, {
...first,
CustomAvatarItemId: save.CustomAvatarItemId,
RankedEntityId: save.CustomAvatarItemId,
CreatorAccountId: 1,
Name: 'Skeletal Wings',
BaseAvatarItemId: null,
BaseAvatarItemColor: null,
DesignFilename: null,
ThumbnailImageFilename: null,
OutfitType: 100,
CurrentSaves: [save],
})
await grantCustomAvatarItem(env.DB, 612, wings.CustomAvatarItemId)
const ownedAssets = async (query: string) => {
const res = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned${query}`, {
headers: await bearer('612'),
})
const { Results } = (await res.json()) as {
Results: Array<{ CurrentSaves: Array<{ UnityAsset: string; UnityAsset2: string | null }> }>
}
return Results[0].CurrentSaves.map((s) => [s.UnityAsset, s.UnityAsset2])
}
expect(await ownedAssets('?skip=0&take=1000&unityAssetTarget=0')).toEqual([
[save.UnityAsset, null],
])
expect(await ownedAssets('?skip=0&take=1000&unityAssetTarget=2&unityAssetVersion=3')).toEqual([
[`quest/${save.UnityAsset}`, null],
])
}) })
test('GET /api/objectives/v1/myprogress returns the default progress (no auth)', async () => { test('GET /api/objectives/v1/myprogress returns the default progress (no auth)', async () => {
+5 -4
View File
@@ -270,10 +270,11 @@ const CUSTOM_AVATAR_ITEM_EXPORT = 'apps/econ/static/db/2025-1-cai.json'
* worker). Like `runx catalog load`: the table's STRUCTURE is a migration, its first-party * worker). Like `runx catalog load`: the table's STRUCTURE is a migration, its first-party
* CONTENTS are not, and this is the reload. Each record lands as the row's JSON with its * CONTENTS are not, and this is the reload. Each record lands as the row's JSON with its
* `CreatorAccountId` forced to the Coach account (1) — what makes it stock content here — and * `CreatorAccountId` forced to the Coach account (1) — what makes it stock content here — and
* each save's `ThumbnailFileName` put under `avatar/` (the statements come from api's * each save's `ThumbnailFileName` put under `avatar/` and its assetbundle hashes blanked — they
* `custom-avatar-items-load.ts`, the same ones the migration generator writes), an id already * are the PC builds' hashes, and the same save serves the Quest builds too. (The statements
* present is REPLACED, and nothing is deleted — the players' own shirts share the table and an * come from api's `custom-avatar-items-load.ts`, the same ones the migration generator writes.)
* export never mentions them. Re-running is always safe. * An id already present is REPLACED, and nothing is deleted — the players' own shirts share the
* table and an export never mentions them. Re-running is always safe.
*/ */
const caiLoad = new Command('cai-load') const caiLoad = new Command('cai-load')
.description('Load first-party custom avatar items from an export JSON (merges; never deletes)') .description('Load first-party custom avatar items from an export JSON (merges; never deletes)')