diff --git a/apps/api/src/custom-avatar-items-db.ts b/apps/api/src/custom-avatar-items-db.ts index d025123..dc6d914 100644 --- a/apps/api/src/custom-avatar-items-db.ts +++ b/apps/api/src/custom-avatar-items-db.ts @@ -413,6 +413,13 @@ export interface CustomAvatarItemSearch { * between them, never as a superset. */ includeCoachItems?: boolean + /** + * Only items this account authored — how the client lists what one player has made (a + * profile's creations, `creatorAccountId=&includeCoachItems=False&take=1000`). Combines + * with the rest as one more AND; naming the Coach here with `includeCoachItems=false` + * matches nothing, as the two conditions read. + */ + creatorAccountId?: number /** Lowest price to include, inclusive. */ minPrice?: number /** Highest price to include, inclusive. */ @@ -447,6 +454,9 @@ export const SEARCH_MAX_TAKE = 200 * `True` and its user-generated-content tab sends `False`, and the two must not overlap — read * as "stock plus players'", the storefront would show every player's item too. * + * `creatorAccountId` narrows to one author. It was ignored until it was noticed that a player's + * creations list (which sends it) was serving the whole player-made catalog under every name. + * * Ordered by recency because there is nothing else to order by — no purchase counts, no wear * counts, no ratings are recorded — which is the same stand-in the `hot` feed makes. The * `custom_avatar_item_id` tiebreak is what makes paging stable: without it, two items sharing a @@ -489,6 +499,9 @@ export async function searchCustomAvatarItems( } else if (search.includeCoachItems === false) { where.push(`creator_account_id != ${bind(COACH_ACCOUNT_ID)}`) } + if (search.creatorAccountId !== undefined) { + where.push(`creator_account_id = ${bind(search.creatorAccountId)}`) + } if (search.minPrice !== undefined) where.push(`price >= ${bind(search.minPrice)}`) if (search.maxPrice !== undefined) where.push(`price <= ${bind(search.maxPrice)}`) diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index 2fcaaa6..a5e438f 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -710,6 +710,8 @@ export const avatarRoutes = new Hono({ strict: false }) 'stock content (account 1), `false` only player-made items, and leaving it out serves', 'both. The client’s storefront tab sends `True` and its user-generated-content tab', '`False`, so the two never overlap.', + '`creatorAccountId` narrows to one author’s items — what a player’s creations list', + 'sends (`creatorAccountId=&includeCoachItems=False`); still published items only.', '`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),', @@ -773,6 +775,13 @@ 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: 'creatorAccountId', + in: 'query', + required: false, + description: 'Only items this account made', + schema: { type: 'integer' }, + }, { name: 'unityAssetTarget', in: 'query', @@ -811,6 +820,7 @@ export const avatarRoutes = new Hono({ strict: false }) searchQuery: c.req.query('searchQuery'), outfitTypes, includeCoachItems, + creatorAccountId: int('creatorAccountId'), minPrice: int('minPrice'), maxPrice: int('maxPrice'), skip: int('skip'), diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index ad82682..a1e198e 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1238,6 +1238,41 @@ describe('public endpoints', () => { all.map((i) => i.Name) ) + // `creatorAccountId` is one author's published items — a player's creations list. It + // was once ignored, and every player's shelf showed the whole player-made catalog. + const spec2: Array<[name: string, creator: number]> = [['Hat C', 206]] + for (const [name, creator] of spec2) { + await createCustomAvatarItem( + env.DB, + { + customAvatarItemId: crypto.randomUUID(), + creatorAccountId: creator, + name, + description: '', + price: 0, + baseAvatarItemId: 1, + baseAvatarItemColor: '#fff', + accessibility: 1, + designFilename: 'design_x.bin', + thumbnailImageFilename: 'thumb_x.png', + }, + new Date(Date.UTC(2026, 7, 20)) + ) + } + expect((await search('?creatorAccountId=206')).map((i) => i.Name)).toEqual(['Hat C']) + // The query the client sends for a player's creations: theirs alone, `Hidden` still out. + expect( + ( + await search( + '?creatorAccountId=205&includePurchaseInfos=True&includeCoachItems=False&skip=0' + + '&take=1000&unityAssetTarget=0&unityAssetVersion=3' + ) + ).map((i) => i.Name) + ).toEqual(['Trousers A', 'Shirt B', 'Shirt A', 'Hat A']) + expect(await search('?creatorAccountId=1&includeCoachItems=False')).toEqual([]) + expect(await search('?creatorAccountId=424242')).toEqual([]) + await env.DB.prepare('DELETE FROM custom_avatar_item WHERE creator_account_id = 206').run() + // The whole storefront query the client actually sends, unchanged — the parameters that // aren't acted on yet must be accepted rather than 400 or throw, and `includeCoachItems=True` // narrows it to the stock content.