mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[store] wip
This commit is contained in:
@@ -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
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
]
|
||||
@@ -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}"
|
||||
}
|
||||
]
|
||||
@@ -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": "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-hot",
|
||||
"SectionType": 4,
|
||||
"Source": "UnifiedAlgorithmicList",
|
||||
"SourceMetadata": "hotitems",
|
||||
"DisplayMetadata": "{\"DisplayTitle\":\"Hot Right Now\",\"numRows\":1,\"itemCount\":12,\"minItemsToShowSection\":1,\"horizontalScrollEnabled\":true}"
|
||||
},
|
||||
{
|
||||
"Id": "store-featured-new",
|
||||
"SectionType": 4,
|
||||
"Source": "UnifiedAlgorithmicList",
|
||||
"SourceMetadata": "newitems",
|
||||
"DisplayMetadata": "{\"DisplayTitle\":\"New This Week\",\"numRows\":1,\"itemCount\":12,\"minItemsToShowSection\":1,\"horizontalScrollEnabled\":true}"
|
||||
}
|
||||
]
|
||||
@@ -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}"
|
||||
}
|
||||
]
|
||||
@@ -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 storefront’s 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.
|
||||
|
||||
@@ -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
@@ -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 page’s',
|
||||
'rows under another page’s 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 page’s rows under another page’s 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',
|
||||
'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',
|
||||
'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 row’s 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 row’s 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
|
||||
|
||||
@@ -176,7 +176,8 @@ export const ALGORITHMIC_TYPE_PARAM: OpenAPIV3_1.ParameterObject = {
|
||||
'The `ListEntityType` the caller wants the row’s 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 0–255 (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 endpoint’s 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 storefront’s own numbering.'
|
||||
),
|
||||
Context: z.string().nullable().describe('Ranking attribution; always null here'),
|
||||
})
|
||||
|
||||
|
||||
@@ -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 player’s 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
|
||||
|
||||
Reference in New Issue
Block a user