diff --git a/apps/api/migrations/0021_custom_avatar_item_outfit_type.sql b/apps/api/migrations/0021_custom_avatar_item_outfit_type.sql new file mode 100644 index 0000000..70d897d --- /dev/null +++ b/apps/api/migrations/0021_custom_avatar_item_outfit_type.sql @@ -0,0 +1,7 @@ +-- File every custom avatar item under OutfitType 105 (`RecRoom.Avatars.OutfitType.CustomShirt`). +-- Creation left `outfit_type` at the column's default of 0 (Hat), and the store's +-- user-generated-content tab searches `GET /api/customAvatarItems/v1/search?outfitTypes=105` +-- ALONE, so the tab sat empty over a full catalog. The custom shirt is the one kind of custom avatar item the +-- client can make, so every row written before this migration is one. Creation now writes +-- 105 itself (src/custom-avatar-items-db.ts, OUTFIT_TYPE_CUSTOM_SHIRT). +UPDATE custom_avatar_item SET outfit_type = 105 WHERE outfit_type = 0; diff --git a/apps/api/src/custom-avatar-items-db.ts b/apps/api/src/custom-avatar-items-db.ts index 35a4d0a..dcf7b57 100644 --- a/apps/api/src/custom-avatar-items-db.ts +++ b/apps/api/src/custom-avatar-items-db.ts @@ -60,6 +60,25 @@ export interface CustomAvatarItem { PurchaseInfo: null } +/** + * `RecRoom.Avatars.OutfitType.CustomShirt` — the slot of a custom shirt, the one kind of custom + * avatar item the client can make. The enum (unobfuscated in the client) is banded by body + * region: head None -1, Hat 0, Hair 2, Ear 3, Eye 10, Beard 20; torso Shoulder 100, Shirt 101, + * Waist 102, Neck 103, TeamJersey 104, CustomShirt 105; arms Wrist 200, TeamWrist 203; legs + * Legs 300, Feet 301; Roomie_Hat 500, Roomie_Waist 501, Roomie_Eye 502. It reaches the search + * as a bare `IEnumerable` — nothing on the wire ties 105 to the name. + * + * This is what the store's user-generated-content tab searches for, ALONE + * (`GET /api/customAvatarItems/v1/search?outfitTypes=105&includeCoachItems=False`), so it is + * what every created item must be filed under: the table's default of 0 (Hat) matched nothing, + * and the tab sat empty over a full catalog. + * + * Not to be confused with the search's `itemTypes`, a different 3-member enum (All -1, None 0, + * Shirt 1) whose Shirt is 1, not 101 or 105. The client sends `itemTypes=-1` (All) alongside + * `outfitTypes=105`; the outfit type is the filter, and the two are never cross-mapped. + */ +export const OUTFIT_TYPE_CUSTOM_SHIRT = 105 + /** What `POST /api/customAvatarItems/v1` needs to create an item. */ export interface CreateCustomAvatarItemInput { /** The item's id. Chosen by the caller because the upload keys are derived from it. */ @@ -73,6 +92,8 @@ export interface CreateCustomAvatarItemInput { accessibility: number designFilename: string thumbnailImageFilename: string + /** The slot the item is worn in; {@link OUTFIT_TYPE_CUSTOM_SHIRT} when left out. */ + outfitType?: number } interface Row { @@ -132,8 +153,8 @@ export async function createCustomAvatarItem( `INSERT INTO custom_avatar_item ( custom_avatar_item_id, creator_account_id, name, description, price, accessibility, base_avatar_item_id, base_avatar_item_color, design_filename, thumbnail_image_filename, - created_at, modified_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11) + created_at, modified_at, outfit_type + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12) RETURNING *` ) .bind( @@ -147,7 +168,8 @@ export async function createCustomAvatarItem( input.baseAvatarItemColor, input.designFilename, input.thumbnailImageFilename, - ts + ts, + input.outfitType ?? OUTFIT_TYPE_CUSTOM_SHIRT ) .first() if (!row) throw new Error('custom_avatar_item insert returned no row') @@ -264,7 +286,13 @@ export interface CustomAvatarItemSearch { * the full set of types it can render, so an absent parameter is "everything", not "nothing". */ outfitTypes?: number[] - /** Whether items authored by the Coach — this server's stock content — are included. */ + /** + * WHICH SIDE of the catalog to serve, not whether to add one to the other: `true` is the + * Coach's stock content ONLY, `false` is player-made content ONLY, and undefined is both. + * The client browses the store's two tabs with this — its storefront call says `True` + * and its user-generated-content call says `False` — so "include" reads as a toggle + * between them, never as a superset. + */ includeCoachItems?: boolean /** Lowest price to include, inclusive. */ minPrice?: number @@ -295,6 +323,11 @@ export const SEARCH_MAX_TAKE = 200 * how an empty IN () clause reads in SQL: the client sends every type it can render, so treating * an absent parameter as "match nothing" would empty the store. * + * `includeCoachItems` SPLITS the catalog by author rather than widening it: `true` is the Coach's + * items alone, `false` everyone else's alone, absent both. The client's storefront tab sends + * `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. + * * 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 @@ -327,7 +360,9 @@ export async function searchCustomAvatarItems( if (outfitTypes.length > 0) { where.push(`outfit_type IN (${outfitTypes.map((t) => bind(t)).join(', ')})`) } - if (search.includeCoachItems === false) { + if (search.includeCoachItems === true) { + where.push(`creator_account_id = ${bind(COACH_ACCOUNT_ID)}`) + } else if (search.includeCoachItems === false) { where.push(`creator_account_id != ${bind(COACH_ACCOUNT_ID)}`) } if (search.minPrice !== undefined) where.push(`price >= ${bind(search.minPrice)}`) diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index c2cea1a..91a094d 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -160,9 +160,7 @@ async function bulkCustomAvatarItemIds(c: Context): Promise { async function creatorsInventionResult( c: Context, inventionId: number -): Promise< - { invention: SavedInvention } | { rejection: string; status: 400 | 401 | 403 | 404 } -> { +): Promise<{ invention: SavedInvention } | { rejection: string; status: 400 | 401 | 403 | 404 }> { const playerId = await authedId(c) if (playerId === null) return { rejection: 'Unauthorized', status: 401 } if (Number.isNaN(inventionId)) return { rejection: 'inventionId is required', status: 400 } @@ -218,7 +216,10 @@ function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: num }) return rejected ? { tags: [], tagResult: INVENTION_TAG_RESULT.rejected } - : { tags: normalizeInventionTags(autoTags, customTags), tagResult: INVENTION_TAG_RESULT.success } + : { + tags: normalizeInventionTags(autoTags, customTags), + tagResult: INVENTION_TAG_RESULT.success, + } } /** @@ -228,8 +229,7 @@ function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: num * two versions disagree about the shape of a reply, not about what a save is. */ type InventionSaveOutcome = - | { rejection: string } - | { invention: SavedInvention; tags: InventionTag[]; tagResult: number } + { rejection: string } | { invention: SavedInvention; tags: InventionTag[]; tagResult: number } /** * The invention save both `v6/save` and `v9/save` run through. v9 sends everything v6 does @@ -450,7 +450,8 @@ export const avatarRoutes = new Hono({ strict: false }) 'Multipart: a `metadata` JSON text field plus two file parts, `thumbnailImage` ' + '(PNG) and `design` (the design blob). Inserts a `custom_avatar_item` row owned ' + 'by the caller and answers with it in the PascalCase `{ Value, Success, Error, ' + - 'error_id }` envelope.\n\n' + + 'error_id }` envelope. The item is filed under `OutfitType` 105, the custom shirt, ' + + 'which is what the store’s user-generated-content tab searches for.\n\n' + 'The two files go to the shared image bucket (`recflare-img`) under ' + '`avatar-item//-thumb.png` and `avatar-item//-design.png`; those ' + 'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.', @@ -682,10 +683,17 @@ export const avatarRoutes = new Hono({ strict: false }) 'rather than no results, since the client sends every type it can render.', '`minPrice`/`maxPrice` bound the price, inclusive.', '`skip`/`take` page the results, `take` capped at 200.', - '`includeCoachItems=false` leaves out this server’s stock content.', - '`itemTypes`, `ordering`, `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 avatar items are the only item type there is.', + '`includeCoachItems` picks a SIDE of the catalog: `true` serves only the Coach’s', + '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.', + '`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.', '`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,', 'whatever it says.', ].join(' '), @@ -736,7 +744,8 @@ export const avatarRoutes = new Hono({ strict: false }) name: 'includeCoachItems', in: 'query', required: false, - description: 'Include the Coach’s stock items (default true)', + description: + 'true: only the Coach’s stock items; false: only player-made items; absent: both', schema: { type: 'boolean' }, }, ], @@ -751,9 +760,11 @@ export const avatarRoutes = new Hono({ strict: false }) ?.map((v) => Number.parseInt(v, 10)) .filter((n) => Number.isInteger(n)) + // Three-way: `True` is the Coach's side of the store, `False` the players', absent both. // The client capitalises its booleans (`includeCoachItems=True`), so this is folded - // before comparing; anything that isn't recognisably false leaves the default alone. - const includeCoachItems = c.req.query('includeCoachItems')?.toLowerCase() !== 'false' + // before comparing; anything that isn't recognisably true or false is treated as absent. + const coach = c.req.query('includeCoachItems')?.toLowerCase() + const includeCoachItems = coach === 'true' ? true : coach === 'false' ? false : undefined const int = (name: string): number | undefined => { const raw = c.req.query(name) @@ -2027,9 +2038,7 @@ export const avatarRoutes = new Hono({ strict: false }) for (const rejection of [ name === undefined ? null : inventionNameRejection(name), description === undefined ? null : inventionDescriptionRejection(description), - longDescription === undefined - ? null - : inventionLongDescriptionRejection(longDescription), + longDescription === undefined ? null : inventionLongDescriptionRejection(longDescription), ]) { if (rejection !== null) return c.json(inventionSaveV9Failure(rejection)) } diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 20f8a07..c88ce48 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1149,7 +1149,9 @@ describe('public endpoints', () => { new Date(Date.UTC(2026, 7, 1 + i)) ) made[name] = created.CustomAvatarItemId - // Nothing sets outfit_type on creation yet, so set it straight in the table. + // Every created item is a custom shirt (OutfitType 105); this test wants a spread of + // slots to filter across, so set them straight in the table. + expect(created.OutfitType).toBe(105) await env.DB.prepare( 'UPDATE custom_avatar_item SET outfit_type = ?2 WHERE custom_avatar_item_id = ?1' ) @@ -1199,20 +1201,32 @@ describe('public endpoints', () => { expect(await search('?skip=99&take=10')).toEqual([]) expect(await search('?take=0')).toEqual([]) - // The client capitalises its booleans (`includeCoachItems=True`), so the comparison folds - // case; only a recognisable "false" turns the stock content off. - expect((await search('?includeCoachItems=True')).map((i) => i.Name)).toContain('Coach Hat') - expect((await search('?includeCoachItems=false')).map((i) => i.Name)).not.toContain('Coach Hat') + // `includeCoachItems` picks a SIDE of the catalog rather than widening it: `True` is the + // Coach's stock content alone (the storefront tab), `False` the players' alone (the + // user-generated-content tab), and the two never overlap. Absent is both. The client + // capitalises its booleans, so the comparison folds case. + expect((await search('?includeCoachItems=True')).map((i) => i.Name)).toEqual(['Coach Hat']) + expect((await search('?includeCoachItems=false')).map((i) => i.Name)).toEqual([ + 'Trousers A', + 'Shirt B', + 'Shirt A', + 'Hat A', + ]) + expect((await search('?includeCoachItems=False')).map((i) => i.Name)).not.toContain('Coach Hat') + expect((await search('?includeCoachItems=maybe')).map((i) => i.Name)).toEqual( + all.map((i) => i.Name) + ) - // The whole query the client actually sends, unchanged — the parameters that aren't acted - // on yet must be accepted rather than 400 or throw. + // 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. const real = await search( '?outfitTypes=0&outfitTypes=2&outfitTypes=3&outfitTypes=10&outfitTypes=20&outfitTypes=100' + '&outfitTypes=101&outfitTypes=102&outfitTypes=103&outfitTypes=200&outfitTypes=300' + '&outfitTypes=301&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' + '&take=100&unityAssetTarget=0&unityAssetVersion=3' ) - expect(real.map((i) => i.Name)).toEqual(all.map((i) => i.Name)) + expect(real.map((i) => i.Name)).toEqual(['Coach Hat']) // `includePurchaseInfos=True` notwithstanding: nothing prices a custom item here yet, so // the field is null on every item and the parameter changes nothing. expect(real.every((i) => i.PurchaseInfo === null)).toBe(true) @@ -1285,17 +1299,18 @@ describe('public endpoints', () => { // Combined with the text search, since the client sends both together. expect(await search('?searchQuery=room&maxPrice=500')).toEqual(['Cosy Beanie', 'Room Hat']) - // The whole query the client actually sends. `itemTypes` and the unity asset parameters are - // accepted and not acted on; `outfitTypes=105` matches nothing, so this is empty — which is - // the filter working, not the search failing. + // The whole query the client's user-generated-content tab actually sends. `itemTypes=-1` + // ("every item type") and the unity asset parameters are accepted and not acted on; + // `outfitTypes=105` is the custom shirt, which is what every created item is filed under, + // so the tab finds them. (It once found nothing: creation left `outfit_type` at 0.) expect( await search( '?searchQuery=room&itemTypes=-1&outfitTypes=105&minPrice=0&maxPrice=10000' + '&includePurchaseInfos=True&includeCoachItems=False&ordering=0&skip=0&take=1000' + '&unityAssetTarget=0&unityAssetVersion=3' ) - ).toEqual([]) - // Same query with the outfit-type filter dropped: the rest of it does match. + ).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat']) + // Same query with the outfit-type filter dropped matches the same, as it must. expect( await search( '?searchQuery=room&itemTypes=-1&minPrice=0&maxPrice=10000&includePurchaseInfos=True' +