diff --git a/CLAUDE.md b/CLAUDE.md index 4616ff4..ccddf17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,6 +220,17 @@ inconsistency here without checking the client first. 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 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/.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`) as a line whose `ItemPurchaseMethodId` is `{ Type: 1, Guid: }`, beside the `Type: 0, NumberId` catalog lines. It is a SALE between players: the price leaves the buyer diff --git a/apps/api/scripts/import-custom-avatar-items.ts b/apps/api/scripts/import-custom-avatar-items.ts index 76180ed..7a8bc9f 100644 --- a/apps/api/scripts/import-custom-avatar-items.ts +++ b/apps/api/scripts/import-custom-avatar-items.ts @@ -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. diff --git a/apps/api/src/custom-avatar-items-db.ts b/apps/api/src/custom-avatar-items-db.ts index eaa848b..d025123 100644 --- a/apps/api/src/custom-avatar-items-db.ts +++ b/apps/api/src/custom-avatar-items-db.ts @@ -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/.assetbundle` and asks the cdn for + * `/avatar/quest/.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(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 diff --git a/apps/api/src/custom-avatar-items-load.ts b/apps/api/src/custom-avatar-items-load.ts index d2a3995..6159f6e 100644 --- a/apps/api/src/custom-avatar-items-load.ts +++ b/apps/api/src/custom-avatar-items-load.ts @@ -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), - 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 + 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("'", "''")}'` diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 292a0a4..2c76c0c 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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. */ diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index 9832653..2fcaaa6 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -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): Promise { .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): Promise { + const queried = c.req.query('unityAssetTarget') + if (queried !== undefined) return queried + const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record) + 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({ 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({ 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({ 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({ 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({ 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 ) } ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index c9e7322..d541c70 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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 () => { diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 7e2c7db..fd43081 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -23,6 +23,8 @@ import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } fr import { getCustomAvatarItems, + isQuestAssetTarget, + toQuestCustomAvatarItem, toUgcPurchasable, UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM, } from '../../api/src/custom-avatar-items-db' @@ -2661,6 +2663,9 @@ const app = new Hono({ strict: false }) '`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', '(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(' '), security: AUTHED, responses: { @@ -2671,7 +2676,13 @@ const app = new Hono({ strict: false }) async (c) => { const id = await authedId(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 }) } ) diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 16682c2..0fd1be9 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -20,6 +20,7 @@ import { import { createCustomAvatarItem, SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL, + importCustomAvatarItem, } from '../../../../api/src/custom-avatar-items-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 @@ -588,6 +589,56 @@ describe('econ endpoints', () => { ], 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 () => { diff --git a/packages/tools/src/cmd/admin.cmd.ts b/packages/tools/src/cmd/admin.cmd.ts index d1a870c..053eeec 100644 --- a/packages/tools/src/cmd/admin.cmd.ts +++ b/packages/tools/src/cmd/admin.cmd.ts @@ -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 * 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 - * each save's `ThumbnailFileName` put under `avatar/` (the statements come from api's - * `custom-avatar-items-load.ts`, the same ones the migration generator writes), 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. + * each save's `ThumbnailFileName` put under `avatar/` and its assetbundle hashes blanked — they + * are the PC builds' hashes, and the same save serves the Quest builds too. (The statements + * come from api's `custom-avatar-items-load.ts`, the same ones the migration generator writes.) + * 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') .description('Load first-party custom avatar items from an export JSON (merges; never deletes)')