[store] wip

This commit is contained in:
Devin Zuczek
2026-08-28 01:56:14 -04:00
parent f8ca01dd4d
commit c13f62bb91
13 changed files with 988 additions and 388 deletions
+1 -1
View File
@@ -3992,7 +3992,7 @@
{
"Key": "Econ.Store.Categories",
"Value": "{\"Categories\":[{\"Name\":\"Featured\",\"Depth\":0,\"Icon\":1,\"DiscoveryPageSource\":\"StoreFeatured\",\"ClickAction\":1},{\"Name\":\"Appearance\",\"Depth\":0,\"Icon\":2,\"DiscoveryPageSource\":\"StoreClothing\",\"ClickAction\":1},{\"Name\":\"Clothing\",\"Depth\":1,\"DiscoveryPageSource\":\"StoreAppearance\",\"ClickAction\":1},{\"Name\":\"Tops\",\"Depth\":2,\"ClickAction\":3,\"Filters\":512,\"StoreCarouselType\":8,\"SortCriteria\":1},{\"Name\":\"Bottoms\",\"Depth\":2,\"ClickAction\":3,\"Filters\":8388608,\"StoreCarouselType\":17,\"SortCriteria\":1},{\"Name\":\"Footwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":16777216,\"StoreCarouselType\":18,\"SortCriteria\":1},{\"Name\":\"Accessories\",\"Depth\":1,\"DiscoveryPageSource\":\"StoreAccessories\",\"ClickAction\":1},{\"Name\":\"Headwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":64,\"StoreCarouselType\":7,\"SortCriteria\":1},{\"Name\":\"Eyewear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":256,\"StoreCarouselType\":19,\"SortCriteria\":1},{\"Name\":\"Earwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":33554432,\"StoreCarouselType\":20,\"SortCriteria\":1},{\"Name\":\"Neckwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":67108864,\"StoreCarouselType\":21,\"SortCriteria\":1},{\"Name\":\"Shoulders & Back\",\"Depth\":2,\"ClickAction\":3,\"Filters\":134217728,\"StoreCarouselType\":22,\"SortCriteria\":1},{\"Name\":\"Waist\",\"Depth\":2,\"ClickAction\":3,\"Filters\":268435456,\"StoreCarouselType\":23,\"SortCriteria\":1},{\"Name\":\"Hands\",\"Depth\":2,\"ClickAction\":3,\"Filters\":1024,\"StoreCarouselType\":10,\"SortCriteria\":1},{\"Name\":\"Styling\",\"Depth\":1,\"DiscoveryPageSource\":\"StoreStyling\",\"ClickAction\":1},{\"Name\":\"Hairstyles\",\"Depth\":2,\"ClickAction\":3,\"Filters\":128,\"StoreCarouselType\":24,\"SortCriteria\":1},{\"Name\":\"Facial Hair\",\"Depth\":2,\"ClickAction\":3,\"Filters\":536870912,\"StoreCarouselType\":25,\"SortCriteria\":1},{\"Name\":\"Hair Dye\",\"Depth\":2,\"ClickAction\":3,\"Filters\":131072,\"StoreCarouselType\":6,\"SortCriteria\":1},{\"Name\":\"Equipment Skins\",\"Depth\":2,\"ClickAction\":3,\"Filters\":262144,\"StoreCarouselType\":5,\"SortCriteria\":1},{\"Name\":\"Inventions\",\"Depth\":0,\"Icon\":3,\"DiscoveryPageSource\":\"StoreInventions\",\"ClickAction\":1,\"Filters\":0,\"StoreCarouselType\":0},{\"Name\":\"Consumables\",\"Depth\":0,\"Icon\":4,\"DiscoveryPageSource\":\"StoreConsumables\",\"ClickAction\":1,\"Filters\":0,\"StoreCarouselType\":0}]}",
"ActiveExperiments": "revenue_2025q1_appearance_improve_conversion\r\nrevenue_2025q3_trending_by_payersegment",
"ActiveExperiments": "null",
"StartTime": null,
"EndTime": null
},
+3 -3
View File
@@ -6,7 +6,7 @@
"RoomNames": [],
"Context": 0,
"InputType": 0,
"Visibility": 0,
"Visibility": 1,
"AllowCycling": true,
"RestrictToNewUsers": false,
"ImageName": "tip.jpg",
@@ -34,7 +34,7 @@
"RoomNames": [],
"Context": 0,
"InputType": 0,
"Visibility": 0,
"Visibility": 1,
"AllowCycling": true,
"RestrictToNewUsers": false,
"ImageName": "tip.jpg",
@@ -48,7 +48,7 @@
"RoomNames": [],
"Context": 0,
"InputType": 0,
"Visibility": 0,
"Visibility": 1,
"AllowCycling": true,
"RestrictToNewUsers": false,
"ImageName": "tip.jpg",
+69 -5
View File
@@ -1,10 +1,74 @@
[
{
"id": "store-featured",
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Headwear",
"sectionType": 4,
"sectionSubType": "StoreCategory_Featured",
"source": "CuratedList",
"sourceMetadata": "17859340",
"displayMetadata": "{\"DisplayTitle\":\"Featured\",\"itemCount\":20,\"cardStyle\":\"wide\",\"categoryUriNames\":\"featured,new\"}"
"sectionSubType": "Items_Headwear",
"source": "AlgorithmicList",
"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}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Tops",
"sectionType": 4,
"sectionSubType": "Items_Tops",
"source": "AlgorithmicList",
"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}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Bottoms",
"sectionType": 4,
"sectionSubType": "Items_Bottoms",
"source": "AlgorithmicList",
"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}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Footwear",
"sectionType": 4,
"sectionSubType": "Items_Footwear",
"source": "AlgorithmicList",
"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}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Waist",
"sectionType": 4,
"sectionSubType": "Items_Waist",
"source": "AlgorithmicList",
"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}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Hands",
"sectionType": 4,
"sectionSubType": "Items_Hands",
"source": "AlgorithmicList",
"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}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Shoulder",
"sectionType": 4,
"sectionSubType": "Items_Shoulder",
"source": "AlgorithmicList",
"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}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Hairstyles",
"sectionType": 4,
"sectionSubType": "Items_Hairstyles",
"source": "AlgorithmicList",
"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}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_FacialHair",
"sectionType": 4,
"sectionSubType": "Items_FacialHair",
"source": "AlgorithmicList",
"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}"
}
]
-106
View File
@@ -1,106 +0,0 @@
[
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_New",
"sectionType": 4,
"sectionSubType": "Items_NewlyReleased",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "newitems",
"displayMetadata": "{\r\n\t\"DisplayTitle\":\"New\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Headwear",
"sectionType": 4,
"sectionSubType": "Items_Headwear",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "headwearitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Headwear\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 7,\r\n \"categoryUriNames\": \"HatsItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Tops",
"sectionType": 4,
"sectionSubType": "Items_Tops",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "topsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Tops\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 8,\r\n \"categoryUriNames\": \"TorsoItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Bottoms",
"sectionType": 4,
"sectionSubType": "Items_Bottoms",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "bottomsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Bottoms\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 17,\r\n \"categoryUriNames\": \"BottomsItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Footwear",
"sectionType": 4,
"sectionSubType": "Items_Footwear",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "footwearitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Footwear\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 18,\r\n \"categoryUriNames\": \"ShoesItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Waist",
"sectionType": 4,
"sectionSubType": "Items_Waist",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "waistitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Waist\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 23,\r\n \"categoryUriNames\": \"WaistItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Hands",
"sectionType": 4,
"sectionSubType": "Items_Hands",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "handsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Hands\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 10,\r\n \"categoryUriNames\": \"GlovesItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Shoulder",
"sectionType": 4,
"sectionSubType": "Items_Shoulder",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "shoulderitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Shoulders & Back\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 22,\r\n \"categoryUriNames\": \"ShoulderItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Skins",
"sectionType": 4,
"sectionSubType": "Items_Skins",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "skinsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Skins\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 5,\r\n \"categoryUriNames\": \"SkinsItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Accessories",
"sectionType": 4,
"sectionSubType": "Items_Accessories",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "accessoriesitems",
"displayMetadata": "{\r\n\t\"DisplayTitle\":\"Accessories\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Hairstyles",
"sectionType": 4,
"sectionSubType": "Items_Hairstyles",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "hairitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Hairstyles\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 24,\r\n \"categoryUriNames\": \"HeadHairItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_FacialHair",
"sectionType": 4,
"sectionSubType": "Items_FacialHair",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "facialhairitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Facial Hair\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 25,\r\n \"categoryUriNames\": \"FacialHairItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "CustomAvatarItemCarousel_AlgorithmicList_Trending",
"sectionType": 8,
"sectionSubType": "CustomAvatarItems_Trending",
"source": "AlgorithmicList",
"sourceMetadata": "Trending",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Trending Custom Shirts\",\r\n \"itemCount\": 5\r\n}"
}
]
+5 -70
View File
@@ -1,74 +1,9 @@
[
{
"id": "StoreItemCarousel_Storefront_PopUpShop_18",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_18",
"source": "Storefront",
"sourceMetadata": "PopUpShop_18",
"displayMetadata": null
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_14",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_14",
"source": "Storefront",
"sourceMetadata": "PopUpShop_14",
"displayMetadata": "{\r\n \"itemCount\": 5, \r\n \"numRows\":\"2\"\r\n}"
},
{
"id": "StoreItemCarousel_Store_FoodItems",
"sectionType": 4,
"sectionSubType": "Store_FoodItems",
"source": "Store",
"sourceMetadata": "FoodItems",
"displayMetadata": "{\r\n \"itemCount\": 5, \r\n \"numRows\":\"2\"\r\n}"
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_12",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_12",
"source": "Storefront",
"sourceMetadata": "PopUpShop_12",
"displayMetadata": null
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_16",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_16",
"source": "Storefront",
"sourceMetadata": "PopUpShop_16",
"displayMetadata": null
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_17",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_17",
"source": "Storefront",
"sourceMetadata": "PopUpShop_17",
"displayMetadata": null
},
{
"id": "StoreItemCarousel_Store_Potions",
"sectionType": 4,
"sectionSubType": "Store_Potions",
"source": "UnifiedCuratedList",
"sourceMetadata": "StoreItemList_Potions",
"displayMetadata": "{\r\n \"itemCount\": 5,\r\n \"DisplayTitle\": \"Potions\"\r\n}"
},
{
"id": "StoreItemCarousel_Store_RandomBoxesItems",
"sectionType": 4,
"sectionSubType": "Store_RandomBoxesItems",
"source": "Store",
"sourceMetadata": "RandomBoxesItems",
"displayMetadata": "{\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_Store_KOIconsItems",
"sectionType": 4,
"sectionSubType": "Store_KOIconsItems",
"source": "Store",
"sourceMetadata": "KOIconsItems",
"displayMetadata": "{\r\n \"itemCount\": 5\r\n}"
"Id": "store-consumables-all",
"SectionType": 4,
"Source": "UnifiedAlgorithmicList",
"SourceMetadata": "consumableitems",
"DisplayMetadata": "{\"DisplayTitle\":\"Consumables\",\"numRows\":2,\"itemCount\":20,\"minItemsToShowSection\":1,\"horizontalScrollEnabled\":true}"
}
]
+10 -100
View File
@@ -1,106 +1,16 @@
[
{
"id": "StoreItemCarousel_UnifiedCuratedList_InternalMedieval",
"sectionType": 4,
"sectionSubType": "MedeivalItems",
"source": "UnifiedCuratedList",
"sourceMetadata": "Internal_Medieval_Items",
"displayMetadata": "{\r\n\"DisplayTitle\":\"FairyTale Fashions\"\r\n}"
"Id": "store-featured-hot",
"SectionType": 4,
"Source": "UnifiedAlgorithmicList",
"SourceMetadata": "hotitems",
"DisplayMetadata": "{\"DisplayTitle\":\"Hot Right Now\",\"numRows\":1,\"itemCount\":12,\"minItemsToShowSection\":1,\"horizontalScrollEnabled\":true}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_UGCMedievalCarousel",
"sectionType": 4,
"sectionSubType": "Generic_MedievalCarousel",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "summerpartycarousel",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Medieval Masterpieces from the Community\"\r\n}"
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_5",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop5",
"source": "Storefront",
"sourceMetadata": "PopUpShop_5",
"displayMetadata": "{\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Trending2Row",
"sectionType": 4,
"sectionSubType": "StoreTrending",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "trendingitems",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Trending\",\r\n\"numRows\":\"2\"\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_NewItemsFromFeaturedCreators",
"sectionType": 4,
"sectionSubType": "StoreNewItemsFromFeaturedCreators",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "newitemsfromcreators",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Featured Creators\",\"numRows\":\"2\"\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_ItemsForYou2Row",
"sectionType": 4,
"sectionSubType": "Items_ForYou",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "itemsforyou",
"displayMetadata": "{\r\n\t\"DisplayTitle\":\"Recommended For You\",\"numRows\":\"2\"\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_BestSellers",
"sectionType": 4,
"sectionSubType": "BestSellingItems",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "bestsellingitems",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Best Sellers\",\"numRows\":\"2\"\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedCuratedList_RoomieItems",
"sectionType": 4,
"sectionSubType": "RoomieItems",
"source": "UnifiedCuratedList",
"sourceMetadata": "StoreItems_Roomie_Collection",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Stuff for Roomie\"\r\n}"
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_17",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_17",
"source": "Storefront",
"sourceMetadata": "PopUpShop_17",
"displayMetadata": null
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_WishlistCarousel",
"sectionType": 4,
"sectionSubType": "StoreWishlistCarousel",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "wishlistcarousel",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Most Wishlisted\"\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_NewSetsForDisplay",
"sectionType": 4,
"sectionSubType": "NewSetsFromCreators",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "featuredsetscarousel",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Featured Sets\"\r\n}"
},
{
"id": "CustomAvatarItemCarousel_CuratedList_StoreItemList_WeeklyMashup_FantasyBackpack",
"sectionType": 8,
"sectionSubType": "CuratedList_StoreItemList_WeeklyMashup_FantasyBackpack",
"source": "CuratedList",
"sourceMetadata": "CustomAvatarItemCarousel_CuratedList_StoreItemList_WeeklyMashup_FantasyBackpack",
"displayMetadata": "{\r\n\t\"DisplayTitle\":\"Fantasy Backpack\"\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_MostGiftedItems",
"sectionType": 4,
"sectionSubType": "StoreMostGiftedItems",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "mostgifteditems",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Good for Gifts\"\r\n}"
"Id": "store-featured-new",
"SectionType": 4,
"Source": "UnifiedAlgorithmicList",
"SourceMetadata": "newitems",
"DisplayMetadata": "{\"DisplayTitle\":\"New This Week\",\"numRows\":1,\"itemCount\":12,\"minItemsToShowSection\":1,\"horizontalScrollEnabled\":true}"
}
]
+10
View File
@@ -0,0 +1,10 @@
[
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Skins",
"sectionType": 4,
"sectionSubType": "Items_Skins",
"source": "AlgorithmicList",
"sourceMetadata": "skinsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Skins\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 5,\r\n \"categoryUriNames\": \"SkinsItems\",\r\n \"itemCount\": 5\r\n}"
}
]
+13 -9
View File
@@ -643,10 +643,11 @@ interface GiftRequest {
* 404s as "no such storefront".
*/
const STOREFRONT_ALIASES: Record<string, string> = {
// Empty. 1704 was here, served sf3's catalog, until `static/storefronts/sf1704.json` was
// generated — see `runx storefront build`. Its line came out the moment the file appeared,
// exactly as the note above says it must: an alias silently beats a real file of that name,
// so leaving it would have kept serving sf3 from a storefront that now has its own catalog.
// Empty. 1704 was here for a while, standing in for a 2025 gift-drop storefront nobody had
// captured; the items it was meant to sell turned out to belong in the general store, so
// they are in `sf3-2025.json` and served as storefront 3 — see {@link STOREFRONT_BY_BUILD}.
// That is a per-BUILD variant of one storefront rather than an alias between two ids, which
// is why nothing is listed here.
}
/**
@@ -1470,12 +1471,15 @@ function toPurchaseMethodId(raw: Partial<PurchaseMethodId> | null | undefined):
* Catalog rows as STORE ITEMS, so a bag can be resolved against the `catalog` table the same
* way it is resolved against an `sf{N}.json` file.
*
* The generated storefront (sf1704) is built from these very rows with this very pricing, so an
* item bought here costs exactly what that file lists it at. That is not a nicety: `priceCheck`
* refuses a line whose posted `RequestedPrice` doesn't match, so two pricings would 409 every
* purchase the client made from the page it was shown.
* The generated storefront (`sf3-2025.json`) is built from these very rows with this very
* pricing, so an item bought here costs exactly what that file lists it at. That is not a
* nicety: `priceCheck` refuses a line whose posted `RequestedPrice` doesn't match, so two
* pricings would 409 every purchase the client made from the page it was shown.
*
* SKINS come through too, keyed the way a gift-drop keys equipment (`EquipmentPrefabName` +
* Mostly redundant now that the merged store carries every sellable AVATAR ITEM — a newer
* build's bag resolves those straight out of the file. What it still reaches that the file does
* not is SKINS, which the generator leaves out, keyed the way a gift-drop keys equipment
* (`EquipmentPrefabName` +
* `EquipmentModificationGuid`) rather than as an avatar item — which is what lets a skin be
* bought at all, since no generated storefront file lists one.
*
@@ -785,6 +785,74 @@ describe('econ endpoints', () => {
expect(anon.status).toBe(401)
})
test('bulkpurchase buys a merged-store catalog item from storefront 3', async () => {
// The exact request the client sends, verbatim: a catalog id under storefront 3, which is
// what the merged sf3-2025 lists it as. It resolves because `loadStorefront` picks the file
// by the caller's build, so what the store page offered is what the purchase is checked
// against.
const res = await exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
method: 'POST',
headers: {
...((await bearer('4801', undefined, '20250718.01')) as Record<string, string>),
'Content-Type': 'application/json',
},
body: JSON.stringify({
PurchaseItemRequests: [
{
ItemPurchaseMethodId: { Type: 0, NumberId: 10222, Guid: null },
RequestedPrice: 3000,
Gift: null,
CouponConsumablePlayerMappingId: null,
DuplicateItemCount: 1,
},
],
StorefrontType: 3,
CurrencyType: 2,
BypassGiftPackages: false,
AllowPartialSuccess: true,
ShoppingBagId: null,
}),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
Success: boolean
Error: string
Value: { Balance: number } | null
}
// It used to answer `{ Success: false, Error: "Item not found" }` — storefront 3 resolved
// to the captured sf3, which has no id in the catalog range.
expect(body.Error).not.toBe('Item not found')
expect(body.Success).toBe(true)
// 10222 is "Maker Pen Shirt", rarity 50, which the shared pricing puts at 3000 — the same
// number the client posted, because the file it browsed was generated from that pricing.
const item = sf32025.StoreItems.find((i) => i.PurchasableItemId === 10222)
expect(item?.Prices[0]?.Price).toBe(3000)
// The SAME request from an old build still fails: its storefront 3 is the captured sf3,
// which does not list the item, and nothing offers it that id anyway.
const legacy = await exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
method: 'POST',
headers: {
...((await bearer('4802', undefined, '20230414')) as Record<string, string>),
'Content-Type': 'application/json',
},
body: JSON.stringify({
PurchaseItemRequests: [
{
ItemPurchaseMethodId: { Type: 0, NumberId: 10222, Guid: null },
RequestedPrice: 3000,
DuplicateItemCount: 1,
},
],
StorefrontType: 3,
CurrencyType: 2,
AllowPartialSuccess: true,
}),
})
expect(((await legacy.json()) as { Success: boolean }).Success).toBe(false)
})
test('bulkpurchase resolves catalog ids for newer builds, at the storefronts price', async () => {
// A catalog row the generated storefront would list at 600 (rarity 10) and a skin the
// storefront lists nowhere at all — both bought straight off the `catalog` table.
+59 -5
View File
@@ -81,8 +81,8 @@ const BY_NAME = new Map<string, CuratedList>()
/**
* By type alone: the page's DEFAULT list, which answers a request that names NO list — only
* a request that names none. A request that DOES name one and matches nothing is a miss and
* 404s; handing it the page default answers a question nobody asked, under a heading that
* belongs to another list.
* answers an empty placeholder; handing it the page default answers a question nobody asked,
* under a heading that belongs to another list.
*/
const BY_TYPE = new Map<number, CuratedList>()
@@ -129,12 +129,66 @@ function emptyReservedList(creatorAccountId: string | undefined, type: number, n
} satisfies CuratedList
}
/**
* The image a placeholder list carries.
*
* NOT `DEFAULT_LIST_IMAGE` (`DefaultRoomImage.jpg`), which is what a stored list and the
* reserved-playlist answer use. This name came off the client-facing shape for the
* placeholder, and the two are deliberately left as they were found rather than unified on a
* guess: the reserved path is known to work with the room image, and this one is what the
* placeholder was specified with. If a capture ever settles which the reference sends, they
* should converge on that.
*/
const PLACEHOLDER_LIST_IMAGE = 'DefaultListImage.jpg'
/**
* The `CreatedAt` a placeholder carries. Fixed rather than `new Date()` so the same missing
* list reads the same on every request — a placeholder that appears to have been created
* moments ago, differently each time, is worse than one that plainly never was.
*/
const PLACEHOLDER_CREATED_AT = '2026-08-24T18:44:52.438Z'
/**
* The list served for a name this server has nothing under.
*
* A well-formed but EMPTY list, echoing the name and type asked for. It must be a whole list
* object: the client BREAKS on a bare `{}`, and it broke differently on the 404 this used to
* send, so "there is no such list" has to be expressed as a list with nothing in it. `ItemIds`
* empty is what stops it rendering as content — a page whose rows this server has no capture
* of shows as an empty row rather than as another page's rows under its heading.
*
* Distinct from {@link emptyReservedList} despite the near-identical shape, and the two must
* not be merged: that one is a REAL list a player simply has not saved into yet (`ListId` 0,
* `Accessibility` 1, the shared default image), while this is a stand-in for a list that does
* not exist. Every field they differ in was arrived at against the live client.
*/
export function placeholderCuratedList(
creatorAccountId: string | undefined,
type: string | undefined,
name: string | undefined
) {
const parsedType = Number.parseInt(type ?? '', 10)
return {
// A string here and a NUMBER on the wire — see `serializeCuratedList`, which is why every
// id in this file is carried as one.
ListId: '1',
CreatorAccountId: Number.parseInt(creatorAccountId ?? '', 10) || 0,
Name: name ?? '',
Description: null,
ImageName: PLACEHOLDER_LIST_IMAGE,
Type: Number.isInteger(parsedType) ? parsedType : 0,
ItemIds: [],
Accessibility: 0,
CreatedAt: PLACEHOLDER_CREATED_AT,
} satisfies CuratedList
}
/**
* The list behind `GET /curatedlists?creatorAccountId=&type=&name=` once D1 has been asked
* and had nothing — the static captures, resolved most-specific first. UNDEFINED when
* nothing matches, which the route turns into a 404: a name this server has nothing under
* is a list that does not exist, and answering it with an unrelated capture puts one page's
* rows under another page's heading.
* nothing matches, which the route turns into a {@link placeholderCuratedList}: a name this
* server has nothing under is a list that does not exist, and answering it with an unrelated
* capture puts one page's rows under another page's heading.
*
* `type` is the `ListEntityType`: what the `ItemIds` ARE. (The client asks for
* `__SavedForLater_Rooms` with `type=1`, Rooms, and every capture here is `type=7`,
+254 -31
View File
@@ -14,7 +14,8 @@ import {
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { resolveCuratedList, serializeCuratedList } from './curated-lists'
import { CatalogKind, UNSELLABLE_RARITIES } from '../../econ/src/catalog-load'
import { placeholderCuratedList, resolveCuratedList, serializeCuratedList } from './curated-lists'
import {
ALGORITHMIC_LIST_PARAM,
ALGORITHMIC_TYPE_PARAM,
@@ -38,8 +39,12 @@ import {
import type { Context } from 'hono'
import type { CuratedList, Room } from '@repo/domain'
import type { CatalogKindValue } from '../../econ/src/catalog-load'
import type { App } from './context'
// The item catalog's sellable-rarity rule, from the worker that owns the table. Imported
// rather than restated so a row can never offer an id the storefront omits.
/**
* Resolve the account id from a Bearer token. Returns `null` when the header is missing,
* the token is invalid, or the `sub` claim isn't an integer.
@@ -219,13 +224,179 @@ const PERSONAL_ROW_FEEDS: Record<string, (db: D1Database, accountId: number) =>
}
/**
* The placeholder contents of a STORE carousel: four purchasable items out of storefront 3,
* held here because nothing on this server ranks store items yet and what the reference
* actually served these rows is not known. Every store row shares the one list rather than
* each carrying its own copy — they are all the same placeholder, and a row that gets a real
* answer should stop pointing at it rather than have its ids edited in place.
* What a GENERIC row's ids look like. A `Generic` row is the one kind that can name things
* of MORE THAN ONE sort in a single list, so the sort travels in the id itself: the client
* splits each `Id` on `.` and reads the half in front as which service resolves the half
* behind it.
*
* `<prefix>.<id>`, with EXACTLY ONE dot — the split is checked for two halves, so a bare
* `257` and a `0.1.2` are both dropped rather than resolved:
*
* - `0.<int>` → a PurchasableItem, the store item of that id
* - `1.<guid>` → a CustomAvatarItem, the UGC item of that guid
*
* Which is why these ids are written out as the strings they go on the wire as: the prefix
* is part of the id here, not decoration this server adds, and `Type` (5) says only that
* the ids are self-describing — never what any one of them is.
*/
const STORE_PLACEHOLDER_ITEMS = entities(['257', '192', '641', '657'])
const GENERIC_ID_PREFIX = {
PurchasableItem: '0',
CustomAvatarItem: '1',
} as const
/**
* How many entities a GENERIC row serves. The reference's store carousels are a screenful, and
* the client draws what fits and scrolls the rest, so this is a "plenty to look at" number
* rather than one it must match.
*/
const GENERIC_ROW_SIZE = 50
/** What a store row draws from the catalog. */
interface StoreRowRule {
/** Avatar items (worn) or skins (held). */
kind: CatalogKindValue
/**
* Substrings, ANY of which the item's name may contain, matched case-insensitively. Absent
* means the whole kind.
*
* A crude stand-in for a category: the catalog records no slot or category for an item, so a
* row that wants "headwear" has nothing to filter on but the name. Several are ORed because
* one category is several words — a "top" is a shirt or a jacket — while the rule as a whole
* is ANDed with the kind and rarity clauses.
*
* It matches ANYWHERE in the name, so `hat` takes "Top Hat" and "Hatchet" alike. That is the
* known cost of having nothing better to ask; narrow these when the catalog grows a real
* category to filter on.
*/
nameContains?: string[]
}
/**
* What each store row draws. A slug not named here draws the whole avatar-item catalog, which
* is what every store row did before any of them had a rule of its own.
*
* The slug is the only thing that distinguishes these: they are all asked for as `type=4`
* (PurchasableItems) and all answer bare `catalog_id`s, so nothing in the request but the row
* key says which items the row is about.
*/
const STORE_ROW_RULES: Record<string, StoreRowRule> = {
// Equipment skins — the Maker Pen, the sword, the disc golf disc and so on.
skinsitems: { kind: CatalogKind.Skin },
// Wearable categories, by name for want of anything better to filter on.
headwearitems: { kind: CatalogKind.AvatarItem, nameContains: ['hat'] },
topsitems: { kind: CatalogKind.AvatarItem, nameContains: ['shirt', 'jacket', 'dress'] },
handsitems: { kind: CatalogKind.AvatarItem, nameContains: ['glove', 'hand', 'wrist'] },
hairitems: { kind: CatalogKind.AvatarItem, nameContains: ['hair'] },
facialhairitems: {
kind: CatalogKind.AvatarItem,
nameContains: ['beard', 'mustache'],
},
waistitems: { kind: CatalogKind.AvatarItem, nameContains: ['belt'] },
accessoriesitems: {
kind: CatalogKind.AvatarItem,
nameContains: ['earrings', 'hearing aids'],
},
footwearitems: {
kind: CatalogKind.AvatarItem,
nameContains: ['shoes', 'sneakers', 'sandals', 'boots'],
},
bottomsitems: { kind: CatalogKind.AvatarItem, nameContains: ['pants', 'shorts'] },
shoulderitems: {
kind: CatalogKind.AvatarItem,
nameContains: ['quiver', 'backpack', 'sword'],
},
}
/** What a slug with no rule of its own draws: the whole avatar-item catalog. */
const DEFAULT_STORE_ROW_RULE: StoreRowRule = { kind: CatalogKind.AvatarItem }
/**
* A random handful of catalog ids matching one {@link StoreRowRule}, read live from the
* `catalog` table (owned by the `econ` worker, on this same `recflare` database). Backs the
* GENERIC row and every flavour of the PURCHASABLE ITEMS row, which differ only in what they
* ask for and how they spell what they get.
*
* Random because nothing here RANKS store items yet — there is no popularity, no recency, no
* personalisation to sort by — and a random draw is at least honestly arbitrary where a fixed
* hand-picked list pretended to be a ranking. It also exercises the whole catalog instead of
* the same ten ids forever, which is the point of pointing it at the table.
*
* {@link isSellableRarity} is applied whatever the kind: the generated storefront omits the
* developer/unreleased tier — `catalog_id` 10002 is in the table and NOT in sf1704 — and an id
* no storefront sells renders as nothing, indistinguishable from an id the client failed to
* parse. No skin carries that rarity today, so the clause costs them nothing and stops a future
* capture that does from leaking one into a row.
*
* The number in each id is the `catalog_id`, which is exactly what the generated storefront
* lists an avatar item under as its `PurchasableItemId` — one number resolves a row entity and a
* store item, with no second numbering between them. It is clear of every captured storefront's
* own ids by construction (see `CATALOG_ID_BASE`). It is a LOAD-ORDER surrogate, reassigned by
* every catalog load, which is fine for a row built and consumed within one request but is why
* nothing may store one.
*
* WORTH KNOWING for skins: no generated storefront lists them — sf1704 is avatar items only —
* so a client handed a skin's id has nothing to resolve it against yet, and the row will draw
* as nothing until a storefront carries them.
*
* An empty result is served as an empty row rather than falling back to canned ids: the table
* being empty means the catalog was never loaded (`runx catalog load`), and a fallback would
* hide that behind a row that looks fine.
*/
async function randomCatalogIds(c: Context<App>, rule: StoreRowRule): Promise<number[]> {
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 = [`kind = ${bind(rule.kind)}`, 'catalog_id IS NOT NULL']
where.push(`rarity NOT IN (${UNSELLABLE_RARITIES.map((r) => bind(r)).join(', ')})`)
if (rule.nameContains !== undefined && rule.nameContains.length > 0) {
// Lowered on both sides rather than leaning on LIKE, which folds case for ASCII only —
// and item names are not all ASCII. `%` and `_` are escaped so a rule containing one
// matches it literally instead of everything. The needles are ORed with each other and
// the group ANDed with the rest, so a multi-word category widens what it takes without
// escaping the kind and rarity filters.
const any = rule.nameContains
.map((needle) => {
const escaped = needle.toLowerCase().replace(/[\\%_]/g, (ch) => `\\${ch}`)
return `lower(friendly_name) LIKE ${bind(`%${escaped}%`)} ESCAPE '\\'`
})
.join(' OR ')
where.push(`(${any})`)
}
const { results } = await c.env.DB.prepare(
`SELECT catalog_id FROM catalog WHERE ${where.join(' AND ')}
ORDER BY RANDOM() LIMIT ${bind(GENERIC_ROW_SIZE)}`
)
.bind(...binds)
.all<{ catalog_id: number }>()
return results.map((r) => r.catalog_id)
}
/**
* A GENERIC row's entities: the same store items, under the `0.` PurchasableItem prefix that
* a Generic row's composite ids require.
*/
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}`))
}
/**
* A PURCHASABLE ITEMS row's entities: the same store items as the Generic row, but with BARE
* ids and no prefix.
*
* That difference is the whole distinction between the two types and must not be tidied away.
* A typed row's `Type` is what tells the client which service to resolve its ids against, so
* the ids themselves are plain; only a Generic row, which can name things of more than one
* sort, carries the sort inside each id. Serving `0.10000` here would have the client look up
* a purchasable item literally called "0.10000".
*/
async function purchasableItemRowEntities(c: Context<App>, key: string): Promise<ListEntity[]> {
const ids = await randomCatalogIds(c, STORE_ROW_RULES[key] ?? DEFAULT_STORE_ROW_RULE)
return entities(ids.map((id) => String(id)))
}
/**
* Rows served from a FIXED id list — a carousel somebody picked by hand, with no ranking
@@ -243,16 +414,14 @@ const STORE_PLACEHOLDER_ITEMS = entities(['257', '192', '641', '657'])
* would leave that section pointing at nothing.
*/
const STATIC_ROW_ENTITIES: Record<string, ListEntity[]> = {
// The store Featured page's "Medieval Masterpieces from the Community" carousel
// `StoreItemCarousel_UnifiedAlgorithmicList_UGCMedievalCarousel` in the `discovery`
// worker's `StoreFeatured` page. Asked for with `?type=5`, Generic.
summerpartycarousel: STORE_PLACEHOLDER_ITEMS,
// The store Clothing page's "New" carousel —
// `StoreItemCarousel_UnifiedAlgorithmicList_New` in `StoreClothing`, and the `newitems`
// category the client's own store-category game config lists. Same placeholder items as
// the row above until something here actually knows which items are new.
newitems: STORE_PLACEHOLDER_ITEMS,
// 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}.
//
// 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.
}
/**
@@ -399,11 +568,14 @@ const app = new Hono<App>()
// name — the captures are this server's fixtures and a player's list is their data.
// Nothing else distinguishes the two requests: both are the same three parameters.
//
// A name that matches NEITHER 404s. There is no list to serve, and the fallbacks this
// once had answered with an unrelated capture instead — a page's rows under another
// page's heading, which reads as real content rather than as a missing list. The
// exceptions are the client's own reserved playlists and a request naming no list at all;
// both are real answers, not misses (see `resolveCuratedList`).
// A name that matches NEITHER answers an EMPTY PLACEHOLDER list, 200 — a whole list object
// echoing the name and type asked for, with no `ItemIds`. The client breaks on anything that
// is not a list: a bare `{}` breaks it, and so did the 404 this sent before that. The
// fallbacks it had originally were worse still — an unrelated capture, putting one page's
// rows under another page's heading, which reads as real content. Empty `ItemIds` is what
// keeps the placeholder from reading as content of its own. The exceptions are the client's
// own reserved playlists and a request naming no list at all; both are real answers, not
// misses (see `resolveCuratedList`).
.get(
'/curatedlists',
describeRoute({
@@ -427,16 +599,17 @@ const app = new Hono<App>()
'property of the list rather than of the reader, and the answer is only ever ids the',
'client then resolves itself.',
'',
'A name matching NEITHER 404s: answering it with an unrelated capture puts one pages',
'rows under another pages heading, which reads as real content rather than as a missing',
'list. The two exceptions are real answers rather than misses — a reserved `__` playlist',
'nobody owns yet comes back EMPTY, and a request naming no list at all gets the page',
'default for its `type`.',
'A name matching NEITHER answers an EMPTY PLACEHOLDER: a whole list object echoing the',
'name and `type` asked for, with no `ItemIds`. The client breaks on anything that is',
'not a list, so a miss cannot be `{}` or a 404; and answering it with an unrelated',
'capture would put one pages rows under another pages heading, which reads as real',
'content. The two exceptions resolve to real lists rather than placeholders — a',
'reserved `__` playlist nobody owns yet, and a request naming no list at all, which',
'gets the page default for its `type`.',
].join('\n'),
parameters: [CREATOR_ACCOUNT_ID_PARAM, LIST_TYPE_PARAM, LIST_NAME_PARAM],
responses: {
200: json(CuratedListRead, 'The list'),
404: { description: 'No list of that name, and it is not a reserved playlist' },
200: json(CuratedListRead, 'The list, or an empty placeholder when there is no such list'),
},
}),
async (c) => {
@@ -444,10 +617,14 @@ const app = new Hono<App>()
const type = c.req.query('type')
const name = c.req.query('name')
// No such list: a well-formed but EMPTY list echoing what was asked for. The client
// BREAKS on a bare `{}`, and broke differently on the 404 this sent before it, so the
// answer has to be a list — "there is no such list" expressed as a list with nothing in
// it. Empty `ItemIds` is what keeps it from reading as content.
const list =
(await ownedList(c, creatorAccountId, type, name)) ??
resolveCuratedList(creatorAccountId, type, name)
if (list === undefined) return c.notFound()
resolveCuratedList(creatorAccountId, type, name) ??
placeholderCuratedList(creatorAccountId, type, name)
// Serialized by hand rather than through `c.json`: the reference's `ListId`s are
// 64-bit and are carried as strings so their digits survive being parsed — see
@@ -544,6 +721,10 @@ const app = new Hono<App>()
// instead of one it hides. `Type` is echoed back from the query: it
// tells the client what the `Id`s ARE (rooms, players, …), so answering with a type the
// caller didn't ask for would have it resolve the ids against the wrong service.
//
// `?type=5` (Generic) is the exception that is answered by the TYPE and not by the slug:
// its ids are `<prefix>.<id>` composites the client resolves per entity (see
// `genericRowEntities`), so no ranking of bare room ids can fill such a row.
.get(
'/algorithmiclists/:list',
describeRoute({
@@ -564,6 +745,26 @@ const app = new Hono<App>()
'',
'Every other row — an unknown key included — answers an EMPTY 200 rather than a 404,',
'which the client renders as a row that failed to load instead of one it hides.',
'',
'`?type=5` (Generic) is answered by the TYPE rather than by the slug, because a Generic',
'rows 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 rows `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` /',
'`bottomsitems` / `shoulderitems` return avatar items whose names contain one of that rows words —',
'“hat”, “shirt”/“jacket”/“dress”, “glove”/“hand”/“wrist”, “hair”,',
'“beard”/“mustache”, “belt”,',
'“earrings”/“hearing aids”, “shoes”/“sneakers”/“sandals”/“boots”, “pants”/“shorts”,',
'“quiver”/“backpack”/“sword”. Every other row returns the whole avatar-item catalog.',
'The name match is a stand-in — the catalog records no',
'category — so it takes anything the word appears in. No generated storefront lists',
'skins yet, so a client has nothing to resolve those ids against.',
].join('\n'),
parameters: [ALGORITHMIC_LIST_PARAM, ALGORITHMIC_TYPE_PARAM],
responses: { 200: json(AlgorithmicList, 'The rows entities, possibly none') },
@@ -578,6 +779,28 @@ const app = new Hono<App>()
const key = c.req.param('list').toLowerCase()
// GENERIC is answered by the TYPE rather than by the row. Every other kind of row
// 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.
if (echoed === ListEntityType.Generic) {
return c.json({ Type: echoed, Entities: await genericRowEntities(c) })
}
// PURCHASABLE ITEMS is answered by the type as well, and from the same catalogue draw —
// but with BARE ids. A typed row's `Type` is what tells the client which service to
// resolve its ids against, so the ids are plain; the `0.` prefix belongs to Generic
// alone, where it is the only thing saying what each id IS.
// The SLUG picks what is drawn here, unlike Generic: `skinsitems` draws equipment skins,
// the `headwearitems`-style rows draw avatar items whose names say so, everything else
// draws the whole avatar-item catalog. They are all `type=4` and all answer bare ids, so
// the row key is the only thing in the request that says which.
if (echoed === ListEntityType.PurchasableItems) {
return c.json({ Type: echoed, Entities: await purchasableItemRowEntities(c, key) })
}
// A per-caller row needs to know who is asking, so it is the one kind of row that
// reads the token. No token — or one that doesn't resolve — answers an EMPTY row
// rather than 401ing or falling through to the canned entities: this is a row about
+7 -2
View File
@@ -176,7 +176,8 @@ export const ALGORITHMIC_TYPE_PARAM: OpenAPIV3_1.ParameterObject = {
'The `ListEntityType` the caller wants the rows ids read as, ECHOED back on the',
'response — it tells the client which service to resolve the ids against. A BYTE on',
'the client, so a value outside 0255 (or none at all) is answered with 1, Rooms,',
'which is what the client always asks for.',
'which is what the client always asks for. 4 (PurchasableItems) and 5 (Generic) are',
'answered by the type rather than by the row — see the endpoints description.',
].join(' '),
schema: { type: 'integer', minimum: 0, maximum: 255, example: 1 },
}
@@ -241,7 +242,11 @@ export const CuratedListsBulk = CuratedListRead.array()
* entity rather than a made-up context the client would carry into telemetry.
*/
export const ListEntityDto = z.object({
Id: z.string().describe('The room/item id the client resolves itself'),
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 storefronts own numbering.'
),
Context: z.string().nullable().describe('Ranking attribution; always null here'),
})
+480 -47
View File
@@ -9,6 +9,8 @@ import {
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
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'
import type { Env } from '../../context'
@@ -19,6 +21,74 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
/**
* Sellable avatar items seeded into the catalog — more than a GENERIC row holds, so the row's
* LIMIT and its randomness are both exercised rather than trivially satisfied.
*/
const CATALOG_SEEDED = 120
/** The row size the worker serves — see `GENERIC_ROW_SIZE`. */
const GENERIC_ROW_SIZE = 50
/** A rarity -1 row: in the catalog, absent from the storefront, never offered by a row. */
const CATALOG_UNSELLABLE_ID = 90_001
/**
* Named avatar items for the category rows, which filter on the NAME. One or two per category
* plus a pair that must NOT match, so a rule that took everything fails rather than passes.
*/
const NAMED_SEEDS = [
'Wizard Hat (Blue)',
'Top Hat',
'Artist Shirt (Gray)',
'Captain Jacket (Blue)',
'Royal Dress (Blue)',
'Vampire Hunter Gloves (Red)',
'Zombie Hands',
'Karate Wrist Wrap',
'Angled Bob Hair',
'Wizard Beard',
'Curly Mustache',
'Treasure Hunter Belt (Brown)',
'Round Earrings',
'Hearing Aids (BTE Earmold - Red)',
'Carnival Clown Shoes',
'Pink Pop Idol Sneakers',
'Flower Sandals',
'Destiny Hunter Boots',
'Barista Pants',
'Silly Shorts (Donut Dreams)',
'Archer Quiver (Green)',
'Plush Bunny Backpack',
'Backsword (Wolf Steel)',
// In BOTH `waistitems` and `shoulderitems` — the categories are not mutually exclusive, and
// nothing stops an item appearing in two rows.
'Samurai Belt Sword (Jade)',
// Matches none of the rules — the control. Renamed from "Ordinary Boots" when `footwearitems`
// arrived and took it: a control has to be checked against every rule, not just the ones that
// existed when it was written.
'Plain Trousers',
'Ordinary Socks',
] as const
/** Where the named seeds' ids start — clear of the numbered avatar items above. */
const CATALOG_NAMED_ID = 80_001
/**
* Whether an id is one of the seeded AVATAR ITEMS — the bulk numbered ones or the named ones.
* The unfiltered store rows draw from both, so a row asserting "avatar items only" has to
* accept either range while still rejecting the unsellable row and every skin.
*/
const isSeededAvatarItem = (n: number): boolean =>
(n >= CATALOG_ID_BASE && n < CATALOG_ID_BASE + CATALOG_SEEDED) ||
(n >= CATALOG_NAMED_ID && n < CATALOG_NAMED_ID + NAMED_SEEDS.length)
/** Where the seeded skins' ids start — clear of the avatar items above. */
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
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')
@@ -31,6 +101,50 @@ beforeAll(async () => {
// The player-owned curated lists, which this worker owns.
for (const stmt of CURATED_LIST_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// The item catalog (owned by `econ`, on this same database), which the GENERIC rows draw
// from. Seeded with more rows than a row can hold so the LIMIT and the randomness are both
// exercised, and with one unsellable row the draw must never offer.
for (const stmt of CATALOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (let i = 0; i < CATALOG_SEEDED; i++) {
await env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, rarity, platform_mask)
VALUES (?1, ?2, 'avatar_item', ?3, 0, -1)`
)
// Numbered from the base, exactly as a real load numbers them.
.bind(`seed-desc-${i},,,`, CATALOG_ID_BASE + i, `Seeded Item ${i}`)
.run()
}
// Named items for the category rows, which filter on the NAME for want of anything better.
// Deliberately mixed-case and mid-word, so a rule that only matched whole lowercase words
// would miss them.
for (const [i, name] of NAMED_SEEDS.entries()) {
await env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, rarity, platform_mask)
VALUES (?1, ?2, 'avatar_item', ?3, 0, -1)`
)
.bind(`seed-named-${i},,,`, CATALOG_NAMED_ID + i, name)
.run()
}
// Rarity -1 is the developer tier: it is in the catalog and NOT in the storefront, so a row
// offering it would hand the client an id no storefront sells.
await env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, rarity, platform_mask)
VALUES ('seed-unsellable,,,', ?1, 'avatar_item', 'Developer Item', -1, -1)`
)
.bind(CATALOG_UNSELLABLE_ID)
.run()
// Skins. They are in the catalog but are NOT avatar items, so an avatar-item row must never
// offer one — and `skinsitems` must offer nothing else. More than a row holds, so its LIMIT
// and randomness are exercised the same way the avatar-item rows are.
for (let i = 0; i < CATALOG_SKINS_SEEDED; i++) {
await env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, rarity, platform_mask, prefab_name)
VALUES (?1, ?2, 'skin', ?3, 0, -1, '[MakerPen]')`
)
.bind(`seed-skin-guid-${i}`, CATALOG_SKIN_ID + i, `Seeded Skin ${i}`)
.run()
}
// 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.
//
@@ -310,30 +424,61 @@ it('matches the name case-insensitively and prefers it over the type', async ()
expect(((await explore.json()) as { Name: string }).Name).toBe('Discovery.PageSource.PlayExplore')
})
it('404s for a name it has nothing under', async () => {
// A name nothing matches is a list that does not exist. It used to answer with the
// default capture, which put one page's rows under another page's heading — content that
// looks real, where a 404 says plainly there is no such list. `17859340` is the store
// Featured page's own lookup (by the reference's numeric list id) and gets the same
// answer: nothing here is called that.
for (const query of [
'?creatorAccountId=1&type=5&name=Internal_Medieval_Items',
'?creatorAccountId=1&type=4&name=17859340',
'?type=99&name=Nope',
'?type=7&name=Discovery.PageSource.NotAPage',
]) {
it('answers an empty placeholder list for a name it has nothing under', async () => {
// A name nothing matches is a list that does not exist — but the answer still has to BE a
// list: the client breaks on a bare `{}`, and broke differently on the 404 this sent before
// that. So it gets a well-formed list echoing what was asked for, with nothing in it. Empty
// `ItemIds` is what keeps it from reading as content, the way an unrelated capture did when
// this endpoint used to fall back to one.
//
// `17859340` is the store Featured page's own lookup (by the reference's numeric list id)
// and gets the same answer: nothing here is called that.
for (const [query, name, type] of [
['?creatorAccountId=1&type=5&name=Internal_Medieval_Items', 'Internal_Medieval_Items', 5],
['?creatorAccountId=1&type=4&name=17859340', '17859340', 4],
['?type=99&name=Nope', 'Nope', 99],
['?type=7&name=Discovery.PageSource.NotAPage', 'Discovery.PageSource.NotAPage', 7],
] as Array<[string, string, number]>) {
const res = await SELF.fetch(`${ORIGIN}/curatedlists${query}`)
expect(res.status).toBe(404)
expect(res.status, query).toBe(200)
expect(await res.json(), query).toEqual({
ListId: 1,
CreatorAccountId: query.includes('creatorAccountId=1') ? 1 : 0,
// The NAME and TYPE asked for are echoed: the client is looking at the list it
// requested, not a stand-in labelled something else.
Name: name,
Description: null,
ImageName: 'DefaultListImage.jpg',
Type: type,
ItemIds: [],
Accessibility: 0,
CreatedAt: '2026-08-24T18:44:52.438Z',
})
}
// `ListId` is a NUMBER on the wire, not a string — the client's parser wants one, which is
// why every id in the curated-list code is carried as a string and serialized by hand.
const raw = await (
await SELF.fetch(`${ORIGIN}/curatedlists?creatorAccountId=1&type=5&name=Nothing`)
).text()
expect(raw).toContain('"ListId":1')
expect(raw).not.toContain('"ListId":"1"')
// Naming NO list is not a miss — it asks for the page default, and only the type says
// which page. Without a type there is no page either, so that 404s too.
// which page.
const byType = await SELF.fetch(`${ORIGIN}/curatedlists?creatorAccountId=1&type=7`)
expect(byType.status).toBe(200)
expect(((await byType.json()) as { Name: string }).Name).toBe('Discovery.PageSource.PlayExplore')
// Without a type there is no page either, so those fall through to the placeholder like any
// other miss rather than erroring.
for (const query of ['?creatorAccountId=7&type=&name=', '']) {
expect((await SELF.fetch(`${ORIGIN}/curatedlists${query}`)).status).toBe(404)
const res = await SELF.fetch(`${ORIGIN}/curatedlists${query}`)
expect(res.status, query).toBe(200)
const list = (await res.json()) as { Name: string; ItemIds: string[]; Type: number }
expect(list.Name, query).toBe('')
expect(list.ItemIds, query).toEqual([])
expect(list.Type, query).toBe(0)
}
})
@@ -473,14 +618,39 @@ it('answers an unowned reserved list EMPTY rather than with the default page', a
expect(typeof list.ImageName).toBe('string')
})
it('404s for a NON-reserved unknown name', async () => {
// The empty-list answer is the reserved prefix's alone: a name a player did not reserve
// and this server has nothing under is missing, not empty. An empty list would have the
// client render a real but blank row for a page that does not exist here.
const featured = await SELF.fetch(
`${ORIGIN}/curatedlists?creatorAccountId=1&type=4&name=17859340`
it('keeps the placeholder and the reserved empty list distinct', async () => {
// Both are "a list with nothing in it", and they are NOT the same answer. Every field they
// differ in was arrived at against the live client, so they must not be merged on the
// grounds that they look alike.
//
// A reserved name is a REAL list the player simply has not saved into yet.
const reserved = await SELF.fetch(
`${ORIGIN}/curatedlists?creatorAccountId=99&type=1&name=__SavedForLater_Rooms`
)
expect(featured.status).toBe(404)
expect(reserved.status).toBe(200)
expect(await reserved.json()).toMatchObject({
ListId: 0,
CreatorAccountId: 99,
Name: '__SavedForLater_Rooms',
ItemIds: [],
Accessibility: 1,
ImageName: 'DefaultRoomImage.jpg',
})
// A name nobody reserved and this server has nothing under is not a list at all, and its
// stand-in says so: a different id, accessibility and image.
const missing = await SELF.fetch(
`${ORIGIN}/curatedlists?creatorAccountId=99&type=1&name=NotReserved`
)
expect(missing.status).toBe(200)
expect(await missing.json()).toMatchObject({
ListId: 1,
CreatorAccountId: 99,
Name: 'NotReserved',
ItemIds: [],
Accessibility: 0,
ImageName: 'DefaultListImage.jpg',
})
})
it('prefers a players stored list over a capture of the same name', async () => {
@@ -639,33 +809,294 @@ it('serves a discovery row from /algorithmiclists', async () => {
expect(await res.json()).toEqual({ Type: 1, Entities: [] })
})
it('serves the hand-picked summerpartycarousel row', async () => {
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 ranks
// store items here, so the row is a fixed id list — same entity shape as any other row,
// ids as STRINGS and `Context` null.
const res = await SELF.fetch(`${ORIGIN}/algorithmiclists/summerpartycarousel?type=5`)
expect(res.status).toBe(200)
const expected = {
Type: 5,
Entities: [
{ Id: '257', Context: null },
{ Id: '192', Context: null },
{ Id: '641', Context: null },
{ Id: '657', Context: null },
],
// 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[]> => {
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)
}
expect(await res.json()).toEqual(expected)
// Looked up folded, like every other row key: the casing is the reference's, not ours.
const cased = await SELF.fetch(`${ORIGIN}/algorithmiclists/SummerPartyCarousel?type=5`)
expect(await cased.json()).toEqual(expected)
const ids = await read('summerpartycarousel')
// The store Clothing page's "New" carousel serves the same placeholder items — both are
// store rows nothing ranks yet, so they share one id list rather than drifting apart.
const newItems = await SELF.fetch(`${ORIGIN}/algorithmiclists/newitems?type=5`)
expect(newItems.status).toBe(200)
expect(await newItems.json()).toEqual(expected)
// 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) => {
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)
// No duplicates: one draw must not offer the same item twice.
expect(new Set(numbers).size).toBe(numbers.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)
// Actually RANDOM, not a fixed slice: two reads of 50 from 120 rows agree only by a
// vanishing coincidence, so identical draws mean the ORDER BY RANDOM() was lost.
expect(await read('summerpartycarousel')).not.toEqual(ids)
// 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.
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)
}
})
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
// PLAIN. That difference is the whole distinction between the two types: a typed row's
// `Type` is what tells the client which service to resolve its ids against, so its ids need
// say nothing about themselves, where a Generic row can name things of more than one sort
// and carries the sort inside each id.
const read = async (slug: string): Promise<string[]> => {
const res = await SELF.fetch(`${ORIGIN}/algorithmiclists/${slug}?type=4`)
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(4)
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)
}
const ids = await read('clothingitems')
expect(ids).toHaveLength(GENERIC_ROW_SIZE)
// Bare integers — NO `0.` prefix. Serving `0.10000` here would have the client look up a
// purchasable item literally called "0.10000".
for (const id of ids) {
expect(id, id).toMatch(/^\d+$/)
expect(id, id).not.toContain('.')
}
// The same pool the Generic row draws from: catalog ids, so sellable avatar items only.
const numbers = ids.map(Number)
expect(new Set(numbers).size).toBe(numbers.length)
expect(numbers).not.toContain(CATALOG_UNSELLABLE_ID)
expect(numbers.some((n) => n >= CATALOG_SKIN_ID)).toBe(false)
expect(numbers.every(isSeededAvatarItem)).toBe(true)
// Random, and answered by the TYPE rather than the slug — like Generic.
expect(await read('clothingitems')).not.toEqual(ids)
for (const slug of ['ClothingItems', 'newitems', 'HotList', 'NoSuchRowAnywhere']) {
const other = await read(slug)
expect(other, slug).toHaveLength(GENERIC_ROW_SIZE)
expect(
other.every((id) => /^\d+$/.test(id)),
slug
).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)
})
it('fills the skinsitems (type=4) row with equipment skins, not avatar items', async () => {
// `/algorithmiclists/skinsitems?type=4` — the equipment row. Same type and same bare-id
// shape as every other store row, so the SLUG is the only thing in the request saying it
// wants something held rather than something worn.
const read = async (slug: string): Promise<number[]> => {
const res = await SELF.fetch(`${ORIGIN}/algorithmiclists/${slug}?type=4`)
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(4)
expect(
body.Entities.every((e) => e.Context === null && /^\d+$/.test(e.Id)),
slug
).toBe(true)
return body.Entities.map((e) => Number(e.Id))
}
const skins = await read('skinsitems')
expect(skins).toHaveLength(GENERIC_ROW_SIZE)
expect(new Set(skins).size).toBe(skins.length)
// Skins ONLY. Every id is in the seeded skin range, and none is an avatar item — a row that
// quietly mixed the two would still look plausible.
expect(
skins.every((n) => n >= CATALOG_SKIN_ID && n < CATALOG_SKIN_ID + CATALOG_SKINS_SEEDED)
).toBe(true)
expect(skins.some((n) => n < CATALOG_SKIN_ID)).toBe(false)
// Still random, like the other store rows.
expect(await read('skinsitems')).not.toEqual(skins)
// Case-folded like every row key.
const cased = await read('SkinsItems')
expect(cased.every((n) => n >= CATALOG_SKIN_ID)).toBe(true)
// And the slug is what decides: every OTHER type=4 row still draws avatar items, so this is
// a row-specific answer rather than the type changing meaning.
for (const slug of ['clothingitems', 'newitems', 'HotList', 'NoSuchRowAnywhere']) {
const others = await read(slug)
expect(others, slug).toHaveLength(GENERIC_ROW_SIZE)
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)
})
it('filters the category rows by name', async () => {
// `headwearitems`, `topsitems`, `hairitems` and `waistitems` all draw avatar items, narrowed
// by a substring of the NAME — the catalog records no slot or category, so the name is the
// only thing there is to filter on.
const names = async (slug: string): Promise<string[]> => {
const res = await SELF.fetch(`${ORIGIN}/algorithmiclists/${slug}?type=4`)
expect(res.status, slug).toBe(200)
const ids = ((await res.json()) as { Entities: Array<{ Id: string }> }).Entities.map((e) =>
Number(e.Id)
)
// Resolve each id back to the row it names, so the assertions read as item names.
const rows = await env.DB.prepare(
`SELECT friendly_name FROM catalog WHERE catalog_id IN (${ids.map(() => '?').join(', ')})`
)
.bind(...ids)
.all<{ friendly_name: string }>()
return rows.results.map((r) => r.friendly_name).sort()
}
expect(await names('headwearitems')).toEqual(['Top Hat', 'Wizard Hat (Blue)'])
expect(await names('hairitems')).toEqual(['Angled Bob Hair'])
// Facial hair is its OWN row, and the two do not overlap: no real beard or mustache has
// "hair" in its name, so `hairitems` does not sweep them up.
expect(await names('facialhairitems')).toEqual(['Curly Mustache', 'Wizard Beard'])
expect(await names('hairitems')).not.toContain('Wizard Beard')
expect(await names('waistitems')).toEqual([
'Samurai Belt Sword (Jade)',
'Treasure Hunter Belt (Brown)',
])
// A needle may be a PHRASE, not just a word — "hearing aids" has a space in it, and the
// match is a plain substring, so nothing splits it.
expect(await names('accessoriesitems')).toEqual([
'Hearing Aids (BTE Earmold - Red)',
'Round Earrings',
])
expect(await names('footwearitems')).toEqual([
'Carnival Clown Shoes',
'Destiny Hunter Boots',
'Flower Sandals',
'Pink Pop Idol Sneakers',
])
expect(await names('bottomsitems')).toEqual(['Barista Pants', 'Silly Shorts (Donut Dreams)'])
expect(await names('shoulderitems')).toEqual([
'Archer Quiver (Green)',
'Backsword (Wolf Steel)',
'Plush Bunny Backpack',
'Samurai Belt Sword (Jade)',
])
// The rows OVERLAP, deliberately: a "Samurai Belt Sword" is a belt and a sword, so it is in
// both rows rather than being claimed by whichever rule ran first. Nothing here assigns an
// item to one category — the name is all there is to go on.
expect(await names('waistitems')).toContain('Samurai Belt Sword (Jade)')
// Several needles are ORed: a "top" is a shirt, a jacket OR a dress, and hands take gloves,
// hands or wrists. The group is still ANDed with the kind and rarity filters rather than
// escaping them.
expect(await names('topsitems')).toEqual([
'Artist Shirt (Gray)',
'Captain Jacket (Blue)',
'Royal Dress (Blue)',
])
expect(await names('handsitems')).toEqual([
'Karate Wrist Wrap',
'Vampire Hunter Gloves (Red)',
'Zombie Hands',
])
// Case-insensitive on both sides, and matched mid-word: "Wizard Hat" is found by `hat`
// though the name capitalises it, and the row key folds like every other.
expect(await names('HeadwearItems')).toEqual(['Top Hat', 'Wizard Hat (Blue)'])
// The control: nothing in the seeds matches every rule, so a filter that quietly did nothing
// would show up here as the unrelated items coming back too.
for (const slug of ['headwearitems', 'topsitems', 'handsitems', 'hairitems', 'waistitems']) {
const got = await names(slug)
expect(got, slug).not.toContain('Plain Trousers')
expect(got, slug).not.toContain('Ordinary Socks')
// And none of the bulk `Seeded Item N` rows, which no category names.
expect(
got.every((n) => !n.startsWith('Seeded Item')),
slug
).toBe(true)
}
// A row with no rule of its own is unfiltered — it still draws the whole avatar-item catalog.
const unfiltered = await SELF.fetch(`${ORIGIN}/algorithmiclists/clothingitems?type=4`)
expect(((await unfiltered.json()) as { Entities: unknown[] }).Entities.length).toBe(
GENERIC_ROW_SIZE
)
})
it('serves the live hot-room ranking for /algorithmiclists/HotList', async () => {
@@ -864,12 +1295,14 @@ it.each([
})
it('echoes the requested type and answers an unknown row', async () => {
const other = await SELF.fetch(`${ORIGIN}/algorithmiclists/Nothing_Ranks_This_Row?type=4`)
// Type 2 (Inventions), deliberately: 4 and 5 are answered BY THE TYPE from the item
// catalogue, so neither can show what an unranked row does.
const other = await SELF.fetch(`${ORIGIN}/algorithmiclists/Nothing_Ranks_This_Row?type=2`)
expect(other.status).toBe(200)
const body = (await other.json()) as { Type: number; Entities: unknown[] }
// An unknown row key answers 200 with no entities rather than 404ing: a failed request
// renders as a row that failed to load, an empty one as a row the client hides.
expect(body.Type).toBe(4)
expect(body.Type).toBe(2)
expect(body.Entities).toEqual([])
// No `type` at all falls back to Rooms (1), the only one the client asks for — falling