From c13f62bb91601ddccd37d6f5e9a46f2ff8b18be4 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 28 Aug 2026 01:56:14 -0400 Subject: [PATCH] [store] wip --- apps/api/static/gameconfigs-v1-all-2025.json | 2 +- apps/cdn/static/loading-screen-tip-data.json | 8 +- apps/discovery/static/StoreCategories.json | 74 ++- apps/discovery/static/StoreClothing.json | 106 ---- apps/discovery/static/StoreConsumables.json | 81 +-- apps/discovery/static/StoreFeatured.json | 120 +---- apps/discovery/static/StoreSkins.json | 10 + apps/econ/src/econ.app.ts | 22 +- apps/econ/src/test/integration/api.test.ts | 68 +++ apps/lists/src/curated-lists.ts | 64 ++- apps/lists/src/lists.app.ts | 285 ++++++++-- apps/lists/src/openapi.ts | 9 +- apps/lists/src/test/integration/api.test.ts | 527 +++++++++++++++++-- 13 files changed, 988 insertions(+), 388 deletions(-) create mode 100644 apps/discovery/static/StoreSkins.json diff --git a/apps/api/static/gameconfigs-v1-all-2025.json b/apps/api/static/gameconfigs-v1-all-2025.json index 8a629cd..395f142 100644 --- a/apps/api/static/gameconfigs-v1-all-2025.json +++ b/apps/api/static/gameconfigs-v1-all-2025.json @@ -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 }, diff --git a/apps/cdn/static/loading-screen-tip-data.json b/apps/cdn/static/loading-screen-tip-data.json index 7fbc09c..572b546 100644 --- a/apps/cdn/static/loading-screen-tip-data.json +++ b/apps/cdn/static/loading-screen-tip-data.json @@ -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", @@ -139,4 +139,4 @@ "PlatformMask": 239, "CreatedAt": "2019-02-28T18:21:25Z" } -] \ No newline at end of file +] diff --git a/apps/discovery/static/StoreCategories.json b/apps/discovery/static/StoreCategories.json index d95c80b..3e18e1c 100644 --- a/apps/discovery/static/StoreCategories.json +++ b/apps/discovery/static/StoreCategories.json @@ -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}" } ] diff --git a/apps/discovery/static/StoreClothing.json b/apps/discovery/static/StoreClothing.json index 923ac08..e69de29 100644 --- a/apps/discovery/static/StoreClothing.json +++ b/apps/discovery/static/StoreClothing.json @@ -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}" - } -] \ No newline at end of file diff --git a/apps/discovery/static/StoreConsumables.json b/apps/discovery/static/StoreConsumables.json index 5370f52..edc357f 100644 --- a/apps/discovery/static/StoreConsumables.json +++ b/apps/discovery/static/StoreConsumables.json @@ -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}" - } -] \ No newline at end of file + { + "Id": "store-consumables-all", + "SectionType": 4, + "Source": "UnifiedAlgorithmicList", + "SourceMetadata": "consumableitems", + "DisplayMetadata": "{\"DisplayTitle\":\"Consumables\",\"numRows\":2,\"itemCount\":20,\"minItemsToShowSection\":1,\"horizontalScrollEnabled\":true}" + } +] diff --git a/apps/discovery/static/StoreFeatured.json b/apps/discovery/static/StoreFeatured.json index d6198ff..adfe3eb 100644 --- a/apps/discovery/static/StoreFeatured.json +++ b/apps/discovery/static/StoreFeatured.json @@ -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}" - } -] \ No newline at end of file + { + "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}" + } +] diff --git a/apps/discovery/static/StoreSkins.json b/apps/discovery/static/StoreSkins.json new file mode 100644 index 0000000..ccb04a3 --- /dev/null +++ b/apps/discovery/static/StoreSkins.json @@ -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}" +} +] diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index fac74bc..e6d595b 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -643,10 +643,11 @@ interface GiftRequest { * 404s as "no such storefront". */ const STOREFRONT_ALIASES: Record = { - // 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 | 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. * diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index ba1b477..10c3633 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -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), + '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), + '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. diff --git a/apps/lists/src/curated-lists.ts b/apps/lists/src/curated-lists.ts index 294b606..d506a6f 100644 --- a/apps/lists/src/curated-lists.ts +++ b/apps/lists/src/curated-lists.ts @@ -81,8 +81,8 @@ const BY_NAME = new Map() /** * 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() @@ -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`, diff --git a/apps/lists/src/lists.app.ts b/apps/lists/src/lists.app.ts index 453e259..4e0874d 100644 --- a/apps/lists/src/lists.app.ts +++ b/apps/lists/src/lists.app.ts @@ -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 } /** - * 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. + * + * `.`, 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.` → a PurchasableItem, the store item of that id + * - `1.` → 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 = { + // 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, rule: StoreRowRule): Promise { + const binds: Array = [] + /** Bind a value and get its placeholder, so the numbering can't drift as clauses are added. */ + const bind = (v: string | number): string => `?${binds.push(v)}` + + const where = [`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): Promise { + 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, key: string): Promise { + 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 = { - // 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() // 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() '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() 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() // 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 `.` 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() '', '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 `.` composites — exactly one dot, `0.` a purchasable', + 'item and `1.` a custom avatar item — that the client resolves one at a time.', + 'It serves a random draw of sellable purchasable items from the `catalog` table, since', + 'nothing here ranks store items yet; the number in each id is the `catalog_id`, which', + 'is the same `PurchasableItemId` the generated storefront carries.', + '`?type=4` (PurchasableItems) is answered by the type too, from the same draw, but', + 'with BARE ids: a typed row’s `Type` already says what its ids are, so only a Generic', + '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() 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 diff --git a/apps/lists/src/openapi.ts b/apps/lists/src/openapi.ts index 0239ef4..f1ad2d9 100644 --- a/apps/lists/src/openapi.ts +++ b/apps/lists/src/openapi.ts @@ -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 `.` composite with EXACTLY ONE dot — `0.` a purchasable item, `1.` a custom avatar item — since such a row can name things of more than one sort. The integer after `0.` is a `catalog_id`, which is exactly what the generated storefront lists the item under as its `PurchasableItemId` — catalog ids start at 10000 so they cannot collide with a captured storefront’s own numbering.' + ), Context: z.string().nullable().describe('Ranking attribution; always null here'), }) diff --git a/apps/lists/src/test/integration/api.test.ts b/apps/lists/src/test/integration/api.test.ts index ef1a63c..7b32baa 100644 --- a/apps/lists/src/test/integration/api.test.ts +++ b/apps/lists/src/test/integration/api.test.ts @@ -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 `.` composites: the client splits each on `.` and + // needs EXACTLY two halves, reading `0` as a purchasable item and `1` as a custom avatar + // item. A bare `257` has no dot and is dropped, which is why these are not plain numbers + // like every other row's. + const read = async (slug: string): Promise => { + const res = await SELF.fetch(`${ORIGIN}/algorithmiclists/${slug}?type=5`) + expect(res.status, slug).toBe(200) + const body = (await res.json()) as { + Type: number + Entities: Array<{ Id: string; Context: null }> + } + expect(body.Type, slug).toBe(5) + // Bare object, no `{ success, error, value }` envelope — the client parses the list itself. + expect(Object.keys(body).sort(), slug).toEqual(['Entities', 'Type']) + expect( + body.Entities.every((e) => e.Context === null), + slug + ).toBe(true) + return body.Entities.map((e) => e.Id) } - 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.` — 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 => { + 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 => { + 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 => { + 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