mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[store] update lists for CAI
This commit is contained in:
@@ -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}"
|
||||
}
|
||||
|
||||
+112
-15
@@ -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<App>, rule: StoreRowRule): Promise<nu
|
||||
}
|
||||
|
||||
/**
|
||||
* A GENERIC row's entities: the same store items, under the `0.` PurchasableItem prefix that
|
||||
* a Generic row's composite ids require.
|
||||
* `RecRoom.Avatars.OutfitType`, the slot a custom avatar item is worn in — the members a store
|
||||
* row filters on. The full enum is documented beside the `custom_avatar_item` schema in `api`
|
||||
* (`custom-avatar-items-db.ts`); the Roomie slots start at 500.
|
||||
*/
|
||||
async function genericRowEntities(c: Context<App>): Promise<ListEntity[]> {
|
||||
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<string, number[]> = {
|
||||
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<App>, key: string): Promise<string[]> {
|
||||
const binds: Array<string | number> = []
|
||||
/** 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.<catalog_id>` purchasable item beside a `1.<guid>` — so the prefix goes on each id rather
|
||||
* than being implied by the row.
|
||||
*/
|
||||
async function genericRowEntities(c: Context<App>, key: string): Promise<ListEntity[]> {
|
||||
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<string, ListEntity[]> = {
|
||||
// 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<App>()
|
||||
'`?type=5` (Generic) is answered by the TYPE rather than by the slug, because a Generic',
|
||||
'row’s ids are `<prefix>.<id>` composites — exactly one dot, `0.<int>` a purchasable',
|
||||
'item and `1.<guid>` 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.<guid>` 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<App>()
|
||||
// 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 —
|
||||
|
||||
@@ -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 `<prefix>.<id>` composite with EXACTLY ONE dot — `0.<int>` a purchasable item, `1.<guid>` 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 `<prefix>.<id>` composite with EXACTLY ONE dot — `0.<int>` a purchasable item, `1.<guid>` a custom avatar item — since such a row can name things of more than one sort. The store rows serve `1.<guid>`, 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'),
|
||||
})
|
||||
|
||||
@@ -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,17 +878,8 @@ 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 row's ids are `<prefix>.<id>` 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<string[]> => {
|
||||
/** A GENERIC (type=5) row's ids, checking the envelope every such row shares. */
|
||||
async function readGenericRow(slug: string): Promise<string[]> {
|
||||
const res = await SELF.fetch(`${ORIGIN}/algorithmiclists/${slug}?type=5`)
|
||||
expect(res.status, slug).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
@@ -834,62 +894,95 @@ it('fills a GENERIC (type=5) row with random purchasable items from the catalog'
|
||||
slug
|
||||
).toBe(true)
|
||||
return body.Entities.map((e) => e.Id)
|
||||
}
|
||||
}
|
||||
|
||||
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.<PurchasableItemId>` — exactly one dot, an integer after it. `0.1.2` or a
|
||||
// bare `257` resolves to nothing.
|
||||
const numbers = ids.map((id) => {
|
||||
/** A GENERIC row's ids with the `1.` prefix checked and stripped: the GUIDs it names. */
|
||||
async function readGenericGuids(slug: string): Promise<string[]> {
|
||||
return (await readGenericRow(slug)).map((id) => {
|
||||
// `1.<guid>` — 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('0')
|
||||
expect(parts[1], id).toMatch(/^\d+$/)
|
||||
return Number(parts[1])
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
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 `<prefix>.<id>` 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 GUID has no dot and is dropped.
|
||||
const guids = await readGenericGuids('summerpartycarousel')
|
||||
|
||||
// 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<string[]> => {
|
||||
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.<guid>` 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 () => {
|
||||
|
||||
Reference in New Issue
Block a user