diff --git a/apps/discovery/static/StoreCategories.json b/apps/discovery/static/StoreCategories.json index 3e18e1c..46915b1 100644 --- a/apps/discovery/static/StoreCategories.json +++ b/apps/discovery/static/StoreCategories.json @@ -3,7 +3,7 @@ "id": "StoreItemCarousel_UnifiedAlgorithmicList_Headwear", "sectionType": 4, "sectionSubType": "Items_Headwear", - "source": "AlgorithmicList", + "source": "UnifiedAlgorithmicList", "sourceMetadata": "headwearitems", "displayMetadata": "{\r\n \"DisplayTitle\":\"Headwear\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 7,\r\n \"categoryUriNames\": \"HatsItems\",\r\n \"itemCount\": 5\r\n}" }, @@ -11,7 +11,7 @@ "id": "StoreItemCarousel_UnifiedAlgorithmicList_Tops", "sectionType": 4, "sectionSubType": "Items_Tops", - "source": "AlgorithmicList", + "source": "UnifiedAlgorithmicList", "sourceMetadata": "topsitems", "displayMetadata": "{\r\n \"DisplayTitle\":\"Tops\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 8,\r\n \"categoryUriNames\": \"TorsoItems\",\r\n \"itemCount\": 5\r\n}" }, @@ -19,7 +19,7 @@ "id": "StoreItemCarousel_UnifiedAlgorithmicList_Bottoms", "sectionType": 4, "sectionSubType": "Items_Bottoms", - "source": "AlgorithmicList", + "source": "UnifiedAlgorithmicList", "sourceMetadata": "bottomsitems", "displayMetadata": "{\r\n \"DisplayTitle\":\"Bottoms\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 17,\r\n \"categoryUriNames\": \"BottomsItems\",\r\n \"itemCount\": 5\r\n}" }, @@ -27,7 +27,7 @@ "id": "StoreItemCarousel_UnifiedAlgorithmicList_Footwear", "sectionType": 4, "sectionSubType": "Items_Footwear", - "source": "AlgorithmicList", + "source": "UnifiedAlgorithmicList", "sourceMetadata": "footwearitems", "displayMetadata": "{\r\n \"DisplayTitle\":\"Footwear\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 18,\r\n \"categoryUriNames\": \"ShoesItems\",\r\n \"itemCount\": 5\r\n}" }, @@ -35,7 +35,7 @@ "id": "StoreItemCarousel_UnifiedAlgorithmicList_Waist", "sectionType": 4, "sectionSubType": "Items_Waist", - "source": "AlgorithmicList", + "source": "UnifiedAlgorithmicList", "sourceMetadata": "waistitems", "displayMetadata": "{\r\n \"DisplayTitle\":\"Waist\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 23,\r\n \"categoryUriNames\": \"WaistItems\",\r\n \"itemCount\": 5\r\n}" }, @@ -43,7 +43,7 @@ "id": "StoreItemCarousel_UnifiedAlgorithmicList_Hands", "sectionType": 4, "sectionSubType": "Items_Hands", - "source": "AlgorithmicList", + "source": "UnifiedAlgorithmicList", "sourceMetadata": "handsitems", "displayMetadata": "{\r\n \"DisplayTitle\":\"Hands\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 10,\r\n \"categoryUriNames\": \"GlovesItems\",\r\n \"itemCount\": 5\r\n}" }, @@ -51,7 +51,7 @@ "id": "StoreItemCarousel_UnifiedAlgorithmicList_Shoulder", "sectionType": 4, "sectionSubType": "Items_Shoulder", - "source": "AlgorithmicList", + "source": "UnifiedAlgorithmicList", "sourceMetadata": "shoulderitems", "displayMetadata": "{\r\n \"DisplayTitle\":\"Shoulders & Back\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 22,\r\n \"categoryUriNames\": \"ShoulderItems\",\r\n \"itemCount\": 5\r\n}" }, @@ -59,7 +59,7 @@ "id": "StoreItemCarousel_UnifiedAlgorithmicList_Hairstyles", "sectionType": 4, "sectionSubType": "Items_Hairstyles", - "source": "AlgorithmicList", + "source": "UnifiedAlgorithmicList", "sourceMetadata": "hairitems", "displayMetadata": "{\r\n \"DisplayTitle\":\"Hairstyles\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 24,\r\n \"categoryUriNames\": \"HeadHairItems\",\r\n \"itemCount\": 5\r\n}" }, @@ -67,7 +67,7 @@ "id": "StoreItemCarousel_UnifiedAlgorithmicList_FacialHair", "sectionType": 4, "sectionSubType": "Items_FacialHair", - "source": "AlgorithmicList", + "source": "UnifiedAlgorithmicList", "sourceMetadata": "facialhairitems", "displayMetadata": "{\r\n \"DisplayTitle\":\"Facial Hair\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 25,\r\n \"categoryUriNames\": \"FacialHairItems\",\r\n \"itemCount\": 5\r\n}" } diff --git a/apps/lists/src/lists.app.ts b/apps/lists/src/lists.app.ts index 4e0874d..c3a3bdd 100644 --- a/apps/lists/src/lists.app.ts +++ b/apps/lists/src/lists.app.ts @@ -14,6 +14,7 @@ import { import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' +import { COACH_ACCOUNT_ID } from '../../api/src/custom-avatar-items-load' import { CatalogKind, UNSELLABLE_RARITIES } from '../../econ/src/catalog-load' import { placeholderCuratedList, resolveCuratedList, serializeCuratedList } from './curated-lists' import { @@ -375,12 +376,102 @@ async function randomCatalogIds(c: Context, rule: StoreRowRule): Promise): Promise { - const ids = await randomCatalogIds(c, DEFAULT_STORE_ROW_RULE) - return entities(ids.map((id) => `${GENERIC_ID_PREFIX.PurchasableItem}.${id}`)) +const OutfitType = { + Hat: 0, + Hair: 2, + Ear: 3, + Eye: 10, + Beard: 20, + Shoulder: 100, + Shirt: 101, + Waist: 102, + Neck: 103, + TeamJersey: 104, + Wrist: 200, + TeamWrist: 203, + Legs: 300, + Feet: 301, +} as const + +/** Where the Roomie's slots start. A Roomie's items are not something a player wears. */ +const ROOMIE_OUTFIT_TYPE_BASE = 500 + +/** + * What each GENERIC store row draws from `custom_avatar_item`, as the slots it takes. Keyed by + * the discovery section's `sourceMetadata` slug, like {@link STORE_ROW_RULES}; a slug not named + * here draws every player slot. + * + * Unlike the catalog, a custom avatar item records its slot (`OutfitType`), so these are real + * categories rather than a guess from the name, and no item sits in two rows. + */ +const CUSTOM_ITEM_ROW_OUTFIT_TYPES: Record = { + headwearitems: [OutfitType.Hat], + topsitems: [OutfitType.Shirt, OutfitType.TeamJersey], + handsitems: [OutfitType.Wrist, OutfitType.TeamWrist], + hairitems: [OutfitType.Hair], + facialhairitems: [OutfitType.Beard], + waistitems: [OutfitType.Waist], + accessoriesitems: [OutfitType.Ear, OutfitType.Eye, OutfitType.Neck], + footwearitems: [OutfitType.Feet], + bottomsitems: [OutfitType.Legs], + shoulderitems: [OutfitType.Shoulder], +} + +/** + * A random handful of custom avatar item GUIDs for one store row, read live from the + * `custom_avatar_item` table (owned by the `api` worker, on this same `recflare` database). + * Random for the reason {@link randomCatalogIds} is: nothing here ranks store items yet. + * + * Only what the store can actually SELL: first-party items (the Coach's — a player's own shirt + * is not stock), published (`Accessibility` 0 is a draft, which the bag refuses), and priced. A + * price of 0 would sell the item for nothing, so an unpriced item stays out of the store rather + * than turning up free. + * + * Empty when the table was never loaded (`just cai-load`), for the reason an empty catalog + * draw is: a fallback would hide that behind a row that looks fine. + */ +async function randomCustomAvatarItemIds(c: Context, key: string): Promise { + const binds: Array = [] + /** Bind a value and get its placeholder, so the numbering can't drift as clauses are added. */ + const bind = (v: string | number): string => `?${binds.push(v)}` + + const where = [ + `creator_account_id = ${bind(COACH_ACCOUNT_ID)}`, + 'accessibility <> 0', + 'price > 0', + ] + const slots = CUSTOM_ITEM_ROW_OUTFIT_TYPES[key] + where.push( + slots === undefined + ? `outfit_type < ${bind(ROOMIE_OUTFIT_TYPE_BASE)}` + : `outfit_type IN (${slots.map((t) => bind(t)).join(', ')})` + ) + + const { results } = await c.env.DB.prepare( + `SELECT custom_avatar_item_id FROM custom_avatar_item WHERE ${where.join(' AND ')} + ORDER BY RANDOM() LIMIT ${bind(GENERIC_ROW_SIZE)}` + ) + .bind(...binds) + .all<{ custom_avatar_item_id: string }>() + return results.map((r) => r.custom_avatar_item_id) +} + +/** + * A GENERIC row's entities: first-party custom avatar items under the `1.` CustomAvatarItem + * prefix that a Generic row's composite ids require. The store's `UnifiedAlgorithmicList` + * sections are what ask for these, by slug. + * + * Every entity is a custom avatar item today, but the row is one that CAN mix sorts — a + * `0.` purchasable item beside a `1.` — so the prefix goes on each id rather + * than being implied by the row. + */ +async function genericRowEntities(c: Context, key: string): Promise { + const ids = await randomCustomAvatarItemIds(c, key) + return entities(ids.map((id) => `${GENERIC_ID_PREFIX.CustomAvatarItem}.${id}`)) } /** @@ -417,8 +508,8 @@ const STATIC_ROW_ENTITIES: Record = { // Empty. The store carousels that lived here — // `summerpartycarousel` (the Featured page's "Medieval Masterpieces from the Community") // and `newitems` (the Clothing page's "New") — are asked for with `?type=5`, and Generic is - // answered by the TYPE from the catalog table now, so a static entry for either was already - // unreachable. See {@link genericRowEntities}. + // answered by the TYPE from the custom avatar item table now, so a static entry for either + // was already unreachable. See {@link genericRowEntities}. // // The table stays because it is the right home for a row somebody picks by hand, and the // handler still consults it; nothing is hand-picked at the moment. @@ -749,11 +840,16 @@ const app = new Hono() '`?type=5` (Generic) is answered by the TYPE rather than by the slug, because a Generic', 'row’s ids are `.` composites — exactly one dot, `0.` a purchasable', 'item and `1.` a custom avatar item — that the client resolves one at a time.', - 'It serves a random draw of sellable purchasable items from the `catalog` table, since', - 'nothing here ranks store items yet; the number in each id is the `catalog_id`, which', - 'is the same `PurchasableItemId` the generated storefront carries.', - '`?type=4` (PurchasableItems) is answered by the type too, from the same draw, but', - 'with BARE ids: a typed row’s `Type` already says what its ids are, so only a Generic', + 'It serves a random draw of `1.` first-party custom avatar items from the', + '`custom_avatar_item` table (the Coach’s, published, with a price above 0), since', + 'nothing here ranks store items yet. The store’s `UnifiedAlgorithmicList` sections ask', + 'for these, and the SLUG picks the slot by `OutfitType`: `headwearitems` hats,', + '`topsitems` shirts and team jerseys, `handsitems` wrists, `hairitems` hair,', + '`facialhairitems` beards, `waistitems` waist, `accessoriesitems` ears/eyes/neck,', + '`footwearitems` feet, `bottomsitems` legs, `shoulderitems` shoulders. Any other slug', + 'draws every slot but the Roomie’s.', + '`?type=4` (PurchasableItems) serves the `catalog` table instead, with BARE', + '`catalog_id`s: a typed row’s `Type` already says what its ids are, so only a Generic', 'row needs the prefix. There the SLUG picks what is drawn: `skinsitems` returns', 'equipment skins, and `headwearitems` / `topsitems` / `handsitems` / `hairitems` /', '`facialhairitems` / `waistitems` / `accessoriesitems` / `footwearitems` /', @@ -783,10 +879,11 @@ const app = new Hono() // serves bare ids of one sort — room ids, item ids — and it is `Type` that tells the // client which service to resolve them against; a Generic row instead carries the sort // inside each id (see `GENERIC_ID_PREFIX`), so a caller asking for 5 cannot be served - // a ranking of bare room ids no matter which slug it named. A random draw from the - // item catalog until something here ranks store items. + // a ranking of bare room ids no matter which slug it named. A random draw of + // first-party custom avatar items until something here ranks store items; the slug + // picks the slot, as it picks the category of a PurchasableItems row. if (echoed === ListEntityType.Generic) { - return c.json({ Type: echoed, Entities: await genericRowEntities(c) }) + return c.json({ Type: echoed, Entities: await genericRowEntities(c, key) }) } // PURCHASABLE ITEMS is answered by the type as well, and from the same catalogue draw — diff --git a/apps/lists/src/openapi.ts b/apps/lists/src/openapi.ts index f1ad2d9..8f012eb 100644 --- a/apps/lists/src/openapi.ts +++ b/apps/lists/src/openapi.ts @@ -245,7 +245,7 @@ export const ListEntityDto = z.object({ Id: z .string() .describe( - 'The room/item id the client resolves itself. On a GENERIC row (`type=5`) it is instead a `.` composite with EXACTLY ONE dot — `0.` a purchasable item, `1.` a custom avatar item — since such a row can name things of more than one sort. The integer after `0.` is a `catalog_id`, which is exactly what the generated storefront lists the item under as its `PurchasableItemId` — catalog ids start at 10000 so they cannot collide with a captured storefront’s own numbering.' + 'The room/item id the client resolves itself. On a GENERIC row (`type=5`) it is instead a `.` composite with EXACTLY ONE dot — `0.` a purchasable item, `1.` a custom avatar item — since such a row can name things of more than one sort. The store rows serve `1.`, the `CustomAvatarItemId` of a first-party item. The integer after `0.` is a `catalog_id`, which is exactly what the generated storefront lists the item under as its `PurchasableItemId` — catalog ids start at 10000 so they cannot collide with a captured storefront’s own numbering.' ), Context: z.string().nullable().describe('Ranking attribution; always null here'), }) diff --git a/apps/lists/src/test/integration/api.test.ts b/apps/lists/src/test/integration/api.test.ts index 7b32baa..3acdfe5 100644 --- a/apps/lists/src/test/integration/api.test.ts +++ b/apps/lists/src/test/integration/api.test.ts @@ -9,6 +9,7 @@ import { SUBROOM_SCHEMA_DDL, } from '@repo/domain' +import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db' import { CATALOG_SCHEMA_DDL } from '../../../../econ/src/catalog-db' import { CATALOG_ID_BASE } from '../../../../econ/src/catalog-load' import curatedLists from '../../../static/curated-lists.json' @@ -89,6 +90,45 @@ const CATALOG_SKIN_ID = 90_002 /** Seeded skins: more than a row holds, so its LIMIT and randomness are both exercised. */ const CATALOG_SKINS_SEEDED = 70 +/** + * First-party custom avatar items the GENERIC rows draw, as `[name, OutfitType]`. More shirts + * than a row holds, so the LIMIT and the randomness are exercised on the unfiltered draw; one + * or two in each other slot the category rows take. + */ +const CUSTOM_ITEM_SEEDS: Array<[name: string, outfitType: number]> = [ + ...Array.from({ length: 60 }, (_, i): [string, number] => [`Seeded Shirt ${i}`, 101]), + ['Top Hat', 0], + ['Angled Bob Hair', 2], + ['Round Earrings', 3], + ['3D Glasses', 10], + ['Wizard Beard', 20], + ['Archer Quiver', 100], + ['Treasure Hunter Belt', 102], + ['Bow Tie', 103], + ['Team Jersey', 104], + ['Karate Wrist Wrap', 200], + ['Barista Pants', 300], + ['Flower Sandals', 301], +] + +/** + * Custom avatar items a store row must NEVER offer, each missing exactly one thing the draw + * requires. All are shirts, so the unfiltered and `topsitems` draws would take them otherwise. + */ +const CUSTOM_ITEM_EXCLUDED = { + /** `Accessibility` 0 — a draft, which the bag refuses to sell. */ + draft: 'dddddddd-0000-4000-8000-000000000001', + /** `Price` 0 — would sell for nothing. */ + free: 'dddddddd-0000-4000-8000-000000000002', + /** Made by a player, not the Coach: not stock. */ + playerMade: 'dddddddd-0000-4000-8000-000000000003', + /** A Roomie's slot (500): not something a player wears. Excluded from the unfiltered draw. */ + roomie: 'dddddddd-0000-4000-8000-000000000004', +} as const + +/** The GUID a seeded custom avatar item gets, by its index in {@link CUSTOM_ITEM_SEEDS}. */ +const customItemId = (i: number): string => `cccccccc-0000-4000-8000-${String(i).padStart(12, '0')}` + beforeAll(async () => { // Seed the shared JWT signing key into the local Secrets Store so .get() resolves. await adminSecretsStore(env.JWT_SECRET).create('test-signing-key') @@ -145,6 +185,35 @@ beforeAll(async () => { .run() } + // Custom avatar items (owned by `api`, on this same database), which the GENERIC store rows + // draw. A row is the record as JSON; only the fields the draw filters on matter here. + for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run() + const seedCustomItem = async ( + id: string, + name: string, + outfitType: number, + { creator = 1, accessibility = 1, price = 3000 } = {} + ) => { + const record = { + CustomAvatarItemId: id, + CreatorAccountId: creator, + Name: name, + Accessibility: accessibility, + OutfitType: outfitType, + Price: price, + } + await env.DB.prepare('INSERT INTO custom_avatar_item (data) VALUES (?1)') + .bind(JSON.stringify(record)) + .run() + } + for (const [i, [name, outfitType]] of CUSTOM_ITEM_SEEDS.entries()) { + await seedCustomItem(customItemId(i), name, outfitType) + } + await seedCustomItem(CUSTOM_ITEM_EXCLUDED.draft, 'Draft Shirt', 101, { accessibility: 0 }) + await seedCustomItem(CUSTOM_ITEM_EXCLUDED.free, 'Free Shirt', 101, { price: 0 }) + await seedCustomItem(CUSTOM_ITEM_EXCLUDED.playerMade, 'Player Shirt', 101, { creator: 5 }) + await seedCustomItem(CUSTOM_ITEM_EXCLUDED.roomie, "Roomie's Cape", 501) + // Creation order and publish order are deliberately near-REVERSES of each other, so the // `new` row and the `recentlyupdated` row can't both be passing on the same ordering. // @@ -809,87 +878,111 @@ it('serves a discovery row from /algorithmiclists', async () => { expect(await res.json()).toEqual({ Type: 1, Entities: [] }) }) -it('fills a GENERIC (type=5) row with random purchasable items from the catalog', async () => { - // The store's "Medieval Masterpieces from the Community" carousel, which the client asks - // for by the section's `sourceMetadata` slug and with `?type=5` (Generic). Nothing here - // RANKS store items, so the row is a random draw from the catalog rather than a ranking or - // a hand-picked list pretending to be one. +/** A GENERIC (type=5) row's ids, checking the envelope every such row shares. */ +async function readGenericRow(slug: string): Promise { + const res = await SELF.fetch(`${ORIGIN}/algorithmiclists/${slug}?type=5`) + expect(res.status, slug).toBe(200) + const body = (await res.json()) as { + Type: number + Entities: Array<{ Id: string; Context: null }> + } + expect(body.Type, slug).toBe(5) + // Bare object, no `{ success, error, value }` envelope — the client parses the list itself. + expect(Object.keys(body).sort(), slug).toEqual(['Entities', 'Type']) + expect( + body.Entities.every((e) => e.Context === null), + slug + ).toBe(true) + return body.Entities.map((e) => e.Id) +} + +/** A GENERIC row's ids with the `1.` prefix checked and stripped: the GUIDs it names. */ +async function readGenericGuids(slug: string): Promise { + return (await readGenericRow(slug)).map((id) => { + // `1.` — exactly one dot (a GUID has none), `1` for a custom avatar item. + const parts = id.split('.') + expect(parts, id).toHaveLength(2) + expect(parts[0], id).toBe('1') + expect(parts[1], id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + return parts[1] as string + }) +} + +it('fills a GENERIC (type=5) row with random first-party custom avatar items', async () => { + // The store's `UnifiedAlgorithmicList` sections ask for their rows by `sourceMetadata` slug + // and with `?type=5` (Generic). Nothing here RANKS store items, so the row is a random draw + // from `custom_avatar_item` rather than a ranking or a hand-picked list pretending to be one. // // A Generic row's ids are `.` composites: the client splits each on `.` and // needs EXACTLY two halves, reading `0` as a purchasable item and `1` as a custom avatar - // item. A bare `257` has no dot and is dropped, which is why these are not plain numbers - // like every other row's. - const read = async (slug: string): Promise => { - const res = await SELF.fetch(`${ORIGIN}/algorithmiclists/${slug}?type=5`) - expect(res.status, slug).toBe(200) - const body = (await res.json()) as { - Type: number - Entities: Array<{ Id: string; Context: null }> - } - expect(body.Type, slug).toBe(5) - // Bare object, no `{ success, error, value }` envelope — the client parses the list itself. - expect(Object.keys(body).sort(), slug).toEqual(['Entities', 'Type']) - expect( - body.Entities.every((e) => e.Context === null), - slug - ).toBe(true) - return body.Entities.map((e) => e.Id) - } + // item. A bare GUID has no dot and is dropped. + const guids = await readGenericGuids('summerpartycarousel') - const ids = await read('summerpartycarousel') - - // Capped at the row size even though the catalog holds more, and full rather than a - // handful: a row that quietly served three items would still pass a "not empty" check. - expect(ids).toHaveLength(GENERIC_ROW_SIZE) - - // Every id is `0.` — exactly one dot, an integer after it. `0.1.2` or a - // bare `257` resolves to nothing. - const numbers = ids.map((id) => { - const parts = id.split('.') - expect(parts, id).toHaveLength(2) - expect(parts[0], id).toBe('0') - expect(parts[1], id).toMatch(/^\d+$/) - return Number(parts[1]) - }) - - // The number is the `catalog_id`, which is exactly what the generated storefront lists the - // item under as its `PurchasableItemId` — one number, no arithmetic between them. Catalog ids - // start at 10000 so they cannot be mistaken for a captured storefront's own. - expect(numbers.every((n) => n >= CATALOG_ID_BASE)).toBe(true) + // Capped at the row size even though the table holds more, and full rather than a handful: + // a row that quietly served three items would still pass a "not empty" check. + expect(guids).toHaveLength(GENERIC_ROW_SIZE) // No duplicates: one draw must not offer the same item twice. - expect(new Set(numbers).size).toBe(numbers.length) + expect(new Set(guids).size).toBe(guids.length) - // Only SELLABLE avatar items. The catalog also holds a rarity -1 developer row and a skin, - // and neither is in a storefront — an id no storefront sells renders as nothing, which - // looks exactly like an id the client failed to parse. - expect(numbers).not.toContain(CATALOG_UNSELLABLE_ID) - expect(numbers.some((n) => n >= CATALOG_SKIN_ID)).toBe(false) - expect(numbers.every(isSeededAvatarItem)).toBe(true) + // Only what the store can SELL: none of the draft, the free item, the player's own shirt or + // the Roomie's cape, each of which is otherwise a perfectly good row. + const seeded = new Set(CUSTOM_ITEM_SEEDS.map((_, i) => customItemId(i))) + expect(guids.every((g) => seeded.has(g))).toBe(true) + for (const excluded of Object.values(CUSTOM_ITEM_EXCLUDED)) { + expect(guids).not.toContain(excluded) + } - // Actually RANDOM, not a fixed slice: two reads of 50 from 120 rows agree only by a + // Actually RANDOM, not a fixed slice: two reads of 50 from 72 rows agree only by a // vanishing coincidence, so identical draws mean the ORDER BY RANDOM() was lost. - expect(await read('summerpartycarousel')).not.toEqual(ids) + expect(await readGenericGuids('summerpartycarousel')).not.toEqual(guids) - // Looked up folded, like every other row key: the casing is the reference's, not ours. And - // Generic is answered by the TYPE, not the slug — an unknown row, and a row with a live - // RANKING behind it, both answer store items rather than bare room ids the client can't - // split. All four draw from the same pool, so all four are the same shape. + // Looked up folded, like every other row key. And Generic is answered by the TYPE, not the + // slug — an unknown row, and a row with a live RANKING behind it, both answer store items + // rather than bare room ids the client can't split. for (const slug of [ 'SummerPartyCarousel', 'newitems', 'Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore', 'HotList', ]) { - const other = await read(slug) - expect(other, slug).toHaveLength(GENERIC_ROW_SIZE) - expect( - other.every((id) => /^0\.\d+$/.test(id)), - slug - ).toBe(true) + expect(await readGenericGuids(slug), slug).toHaveLength(GENERIC_ROW_SIZE) } }) +it('filters the GENERIC store category rows by OutfitType', async () => { + // The `StoreCategories` sections. A custom avatar item records its slot, so each row is a + // real category rather than a guess from the name — and no item lands in two rows. + const names = async (slug: string): Promise => { + const guids = await readGenericGuids(slug) + const rows = await env.DB.prepare( + `SELECT json_extract(data, '$.Name') AS name FROM custom_avatar_item + WHERE custom_avatar_item_id IN (${guids.map(() => '?').join(', ')})` + ) + .bind(...guids) + .all<{ name: string }>() + return rows.results.map((r) => r.name).sort() + } + + expect(await names('headwearitems')).toEqual(['Top Hat']) + expect(await names('hairitems')).toEqual(['Angled Bob Hair']) + expect(await names('facialhairitems')).toEqual(['Wizard Beard']) + expect(await names('accessoriesitems')).toEqual(['3D Glasses', 'Bow Tie', 'Round Earrings']) + expect(await names('shoulderitems')).toEqual(['Archer Quiver']) + expect(await names('waistitems')).toEqual(['Treasure Hunter Belt']) + expect(await names('handsitems')).toEqual(['Karate Wrist Wrap']) + expect(await names('bottomsitems')).toEqual(['Barista Pants']) + expect(await names('footwearitems')).toEqual(['Flower Sandals']) + expect(await names('HeadwearItems')).toEqual(['Top Hat']) + + // Tops are shirts AND team jerseys, still under the row size and still without the draft, + // free and player-made shirts. + const tops = await names('topsitems') + expect(tops).toContain('Team Jersey') + expect(tops).toHaveLength(GENERIC_ROW_SIZE) + expect(tops.every((n) => n === 'Team Jersey' || n.startsWith('Seeded Shirt'))).toBe(true) +}) + it('fills a PURCHASABLE ITEMS (type=4) row with the same draw, but BARE ids', async () => { // The store's clothing row (`/algorithmiclists/clothingitems?type=4`). Same random draw from // the catalogue as the Generic row — nothing ranks store items here either — but the ids are @@ -941,12 +1034,10 @@ it('fills a PURCHASABLE ITEMS (type=4) row with the same draw, but BARE ids', as ).toBe(true) } - // The two types stay distinct: asking the SAME slug as 5 gets prefixed ids back. - const generic = await SELF.fetch(`${ORIGIN}/algorithmiclists/clothingitems?type=5`) - const genericIds = ((await generic.json()) as { Entities: Array<{ Id: string }> }).Entities.map( - (e) => e.Id - ) - expect(genericIds.every((id) => /^0\.\d+$/.test(id))).toBe(true) + // The two types stay distinct: asking the SAME slug as 5 gets `1.` custom avatar items. + const genericIds = await readGenericRow('clothingitems') + expect(genericIds.length).toBeGreaterThan(0) + expect(genericIds.every((id) => id.startsWith('1.'))).toBe(true) }) it('fills the skinsitems (type=4) row with equipment skins, not avatar items', async () => { @@ -994,15 +1085,11 @@ it('fills the skinsitems (type=4) row with equipment skins, not avatar items', a expect(others.every(isSeededAvatarItem), slug).toBe(true) } - // `skinsitems` asked for as GENERIC still draws avatar items under the `0.` prefix: the - // slug picks the kind only where the type leaves it open, and a Generic row's ids say what - // they are for themselves. - const generic = await SELF.fetch(`${ORIGIN}/algorithmiclists/skinsitems?type=5`) - const genericIds = ((await generic.json()) as { Entities: Array<{ Id: string }> }).Entities.map( - (e) => e.Id - ) - expect(genericIds.every((id) => /^0\.\d+$/.test(id))).toBe(true) - expect(genericIds.every((id) => Number(id.slice(2)) < CATALOG_SKIN_ID)).toBe(true) + // `skinsitems` asked for as GENERIC draws custom avatar items instead: a skin is not one, + // so the Generic rows have no skins row, and the slug draws every player slot. + const genericIds = await readGenericRow('skinsitems') + expect(genericIds).toHaveLength(GENERIC_ROW_SIZE) + expect(genericIds.every((id) => id.startsWith('1.'))).toBe(true) }) it('filters the category rows by name', async () => {