diff --git a/apps/api/src/default-avatar-items.ts b/apps/api/src/default-avatar-items.ts deleted file mode 100644 index 6b0a71e..0000000 --- a/apps/api/src/default-avatar-items.ts +++ /dev/null @@ -1,430 +0,0 @@ -/** - * Default avatar items for `GET /api/avatar/v4/items`. - * Stored as `[AvatarItemDesc, FriendlyName, Rarity?]` - * tuples — every entry shares `AvatarItemType: 0`, `PlatformMask: -1`, `Tooltip: ""`, - * and `Rarity` defaults to `0`. - */ -export interface AvatarItem { - AvatarItemType: number - AvatarItemDesc: string - PlatformMask: number - FriendlyName: string - Tooltip: string - Rarity: number -} - -type Entry = readonly [desc: string, friendlyName: string, rarity?: number] - -const ENTRIES: readonly Entry[] = [ - ['5d13a7a2-8213-40e6-90a6-efdd76a3fdcb,,,', 'Flowing Hair'], - ['1d27b674-f9e2-4ffc-9d8c-a58a1be06457,,,', 'Afro Hair'], - ['d84c0ff9-8fbe-4ed8-abf3-7996e81888ab,,,', 'Large Afro Hair'], - ['e5b83dfc-b2e1-4dcb-a4ab-9d3a4c8a34ae,,,', 'Long Wavy Hair'], - ['7dd6f7b0-7ba0-429f-a04f-e32d3a79ee61,,,', 'Short Wavy Hair'], - ['eb9611c6-bb50-41a2-93e9-7f959815a846,,,', 'Dreads Long Hair'], - ['1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,,', 'Ponytail Hair'], - ['a12f724f-4a73-4ab8-aad4-6bfc662b4dd6,,,', 'Undercut Long Hair'], - ['0753d7a4-8247-4fca-a6fc-359c26086140,,,', 'Fonzie Hair'], - ['77d3c585-4928-4471-a425-89036efe7299,,,', 'Spiky Hair'], - ['92302d9d-c527-418c-ac5d-1fa869727505,,,', 'Part Hair'], - ['f9dd08f8-16d3-4c39-af4f-89f7bb6e80d3,,,', 'Undercut Short Hair'], - ['b148cb1e-df81-442f-aea6-ab1727aad00e,,,', 'Chunky Afro Hair'], - ['e36bcd98-7e85-43fa-89f8-57e4ec33823a,,,', 'Bob with Bangs Hair'], - ['880a3cc0-7407-4b61-b759-f9dd890fe9e5,,,', 'Bob Hair'], - ['21599b51-c50f-43d8-ac5f-62c30cd02ca5,,,', 'Lori Hair'], - ['193a3bf9-abc0-4d78-8d63-92046908b1c5,,,', 'Emo Hair'], - ['79b90274-6eec-4664-acfb-4a123334661e,,,', 'Pig Tails Hair'], - ['da4e7b34-2095-4a9e-801e-4f409039e0dd,,,', 'Buzz Cut Hair'], - ['9d9fadb6-97eb-480e-a224-4e0179082071,,,', 'Meatball Buns Hair'], - ['d8280c0c-d803-4513-be10-a0ba96d8821e,,,', 'Flowhawk Hair'], - ['e286863c-2967-4d00-b837-b49487b9484a,,,', 'Fauxhawk Hair'], - ['2cb4f372-3372-4583-8b57-c4e3988e3c28,,,', 'Punky Hair'], - ['06306723-ca20-4aa6-b7b3-917113f41ac3,,,', 'Cat-Eye Glasses (Red)'], - ['c70005d5-6276-4a98-acb3-6a77bc19379a,,,', 'Glasses (Teal)'], - ['8d10cc78-6b00-45f3-affb-205e9cc5b03f,,,', 'Beard (Close)'], - ['cc96f8a5-bc5b-4f89-83b7-ecd53905ada7,,,', 'Beard (Thick)'], - ['c6c08eb5-381a-4193-9722-80da95d62abe,,,', 'Business Tie (Black)'], - ['4d507dfa-4a99-4ac0-8537-229e9dc0eb4a,,,', 'Rec Room Tank Top (Orange)'], - ['d0a9262f-5504-46a7-bb10-7507503db58e,,,', 'Rec Room Shirt (Crew Neck, White)'], - [ - 'd0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,', - 'Rec Room T-Shirt (Crew Neck, Orange)', - ], - ['2e59d8d0-91a0-4449-bfdc-a5d663fd9343,,,', 'Collared Shirt (Plaid, Blue)'], - ['7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,,,', 'T-Shirt'], - ['8aa79563-ace1-4ba7-ad0c-f3210a78142f,,,', 'Rec Room Shirt (V-Neck, White)'], - [ - '8aa79563-ace1-4ba7-ad0c-f3210a78142f,95e4cc30-cb68-473d-a395-feadf5b51512,05f0ee6e-c824-470e-9178-5ed576c6fe0c,', - 'Rec Room T-Shirt (V-Neck, Orange)', - ], - ['21caa68e-c3fa-474c-af5e-af1e742b7a60,,,', 'Tennis Skirt (Blue)'], - [ - '21caa68e-c3fa-474c-af5e-af1e742b7a60,c5deba2a-6e35-4b13-8e94-8ba5457f39df,b75ef67d-00c3-4ac1-9b72-212032460294,', - 'Tennis Skirt (Yellow)', - ], - [ - '21caa68e-c3fa-474c-af5e-af1e742b7a60,758752bd-db2f-43d2-b580-55b3e1efffd5,b75ef67d-00c3-4ac1-9b72-212032460294,', - 'Tennis Skirt (Red)', - ], - ['2296ed0d-df56-4d46-b33a-aae9230a47fc,,,', 'Zipper Dress (Yellow)'], - ['ecc1dbe6-ca06-4564-b2a6-30956194d1e9,,,', 'Wristbands (White)'], - ['71921831-ba6f-408b-a00e-2fd97663636f,,,', 'Wrist Tape (White)'], - [ - '2e59d8d0-91a0-4449-bfdc-a5d663fd9343,55901f12-d5b5-4fa8-b4c8-e479689ee39d,f600037d-c9c0-43fa-b45b-02f456f9dd5f,', - 'Collared Shirt (Denim)', - ], - [ - '2e59d8d0-91a0-4449-bfdc-a5d663fd9343,bf82f2f6-9af8-431e-a296-0890dea48ba7,d015cae7-a905-49e4-8823-6dec069689a6,', - 'Collared Shirt (Argyle)', - ], - [ - '2e59d8d0-91a0-4449-bfdc-a5d663fd9343,EfdMcnfHt0mr0PQ_maaYOg,DRJcNhkqvkKFEaZpOguR6w,', - 'Collared Shirt (Flowers, Green)', - ], - [ - '7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,6d703981-2734-4c45-8983-cdd5f328902f,a0271cd0-e172-4d3f-aa2f-9806f21a82d2,', - 'Tank Top (Camo)', - ], - [ - '7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,9c03f381-7357-4d0f-8cda-8737d4c43d25,', - 'Tank Top (Rainbow)', - ], - [ - '7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,d2a692e6-e1a9-4cfe-8154-10b52be7f8c8,', - 'Jersey (Orange)', - 10, - ], - [ - '7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,b292eb4b-07e3-4a48-99b5-3c6587a1e02e,', - 'Tank Top (Dots)', - ], - [ - '7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,48abd952-214f-48b2-a8f1-1146f6f69aa2,b78008e8-abbd-4ece-be34-9a911f721fcc,', - 'Tank Top (Zebra)', - ], - [ - '14ef6b00-debf-4a85-9755-b4d37df496d3,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Baseball Cap (Red)', - ], - [ - '14ef6b00-debf-4a85-9755-b4d37df496d3,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Baseball Cap (Blue)', - ], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Headband (Cream)', - ], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Headband (Black)', - ], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,018a5c07-e956-457d-a540-a5e2cd68da09,', - 'Headband (Orange, White)', - ], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,018a5c07-e956-457d-a540-a5e2cd68da09,', - 'Headband (Pink, White)', - ], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,018a5c07-e956-457d-a540-a5e2cd68da09,', - 'Headband (Blue, White)', - ], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,6dd95046-acf8-42fe-ab78-80a334096a9d,56a92c8d-af53-413e-929e-4a9a3cfad780,', - 'Headband (Red, White, Blue)', - ], - [ - 'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Wristbands (Blue)', - ], - [ - 'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Wristbands (Black)', - ], - [ - 'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Wristbands (Orange)', - ], - [ - 'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Wristbands (Pink)', - ], - [ - 'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Wristbands (Red)', - ], - ['fcfcaf63-deb4-45f7-b711-c051c9ea45cb,,,', 'Top Bun Hair'], - ['de0ac50d-2adb-4114-bd2e-68953b13d706,,,', 'Blazer (Blue, White)'], - [ - 'de0ac50d-2adb-4114-bd2e-68953b13d706,6f2e74bf-1e95-463d-97db-d5d1a53b2c28,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,', - 'Blazer (Black, White)', - ], - [ - 'de0ac50d-2adb-4114-bd2e-68953b13d706,9374bf66-2ee5-493b-8439-efce4b201904,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,', - 'Blazer (Grey, Black)', - ], - [ - 'de0ac50d-2adb-4114-bd2e-68953b13d706,272fe8eb-5061-4729-a7a8-414ff667a82f,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,', - 'Blazer (Grey, White)', - ], - [ - 'de0ac50d-2adb-4114-bd2e-68953b13d706,0ffad843-d6c9-425a-8686-7217009c867e,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,', - 'Blazer (Green, Black)', - ], - [ - '9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,e0397982-c2c2-4733-9a40-46e18675b5af,dafa658e-753b-46cb-bd85-85c1de5e6ea7,', - 'Button Top (Orange)', - ], - ['9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,,,', 'Button Top (Pink)'], - [ - '9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,49f5864f-9d40-497c-88c8-e87f64d41d74,dafa658e-753b-46cb-bd85-85c1de5e6ea7,', - 'Button Top (Tan)', - ], - [ - '9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,c5deba2a-6e35-4b13-8e94-8ba5457f39df,dafa658e-753b-46cb-bd85-85c1de5e6ea7,', - 'Button Top (Yellow)', - ], - [ - '6d815b35-6f68-4ed4-817d-70f141e1a571,f750de46-3758-4f7d-9709-0a84b1027009,2c8924aa-68f8-4912-9759-18992f72f08a,', - 'Collared Dress (Blue)', - ], - [ - '6d815b35-6f68-4ed4-817d-70f141e1a571,d66aa400-aa5a-4539-a25d-5f8ce94dc281,2c8924aa-68f8-4912-9759-18992f72f08a,', - 'Collared Dress (Green)', - ], - [ - '6d815b35-6f68-4ed4-817d-70f141e1a571,6564acf1-4d70-4f92-92ac-08e2b76dbb6b,2c8924aa-68f8-4912-9759-18992f72f08a,', - 'Collared Dress (Purple)', - ], - ['6d815b35-6f68-4ed4-817d-70f141e1a571,,,', 'Collared Dress (Red)'], - ['241506f6-bf88-4b46-b5fe-513a225421f4,,,', 'Half Up Hair'], - [ - '6b9e022c-0b68-48fd-8eca-da8573c18900,d6edbc00-3c1d-4f49-8412-3ef8c7c5f4c2,cf119781-5bd9-4b85-9a0b-12e82e988c23,', - 'Long Scarf (Blue)', - ], - [ - '2296ed0d-df56-4d46-b33a-aae9230a47fc,6d703981-2734-4c45-8983-cdd5f328902f,cfabdefe-0890-436e-b2a3-b5c712e22955,', - 'Zipper Dress (Green)', - ], - [ - '2296ed0d-df56-4d46-b33a-aae9230a47fc,830be2fa-60a5-48cc-931f-34b670eae4bd,cfabdefe-0890-436e-b2a3-b5c712e22955,', - 'Zipper Dress (Purple)', - ], - [ - '2296ed0d-df56-4d46-b33a-aae9230a47fc,bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a,cfabdefe-0890-436e-b2a3-b5c712e22955,', - 'Zipper Dress (White)', - ], - [ - 'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Wristbands (Light Blue)', - ], - [ - 'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Wristbands (Green)', - ], - [ - 'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Wristbands (Gold)', - ], - [ - '7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Tank Top (Black)', - ], - [ - '7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Tank Top (Blue)', - ], - [ - '7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Tank Top (Orange)', - ], - [ - '7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Tank Top (Red)', - ], - [ - '6b9e022c-0b68-48fd-8eca-da8573c18900,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,cf119781-5bd9-4b85-9a0b-12e82e988c23,', - 'Long Scarf (Purple)', - ], - [ - '6b9e022c-0b68-48fd-8eca-da8573c18900,6dd95046-acf8-42fe-ab78-80a334096a9d,cf119781-5bd9-4b85-9a0b-12e82e988c23,', - 'Long Scarf (White)', - ], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Headband (Blue)', - ], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Headband (Green)', - ], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Headband (Red)', - ], - ['40528de7-38a3-4a7c-8f93-6d3bfa5573f2,,,', 'Headband (White)'], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Headband (Yellow)', - ], - ['24a240f4-1574-420b-b898-a7e91f170759,,,', 'Back Bun Hair'], - ['c45ed7b8-99bd-4a4b-a9ff-e16edf5d7a18,,,', 'High Pony Hair'], - [ - '14ef6b00-debf-4a85-9755-b4d37df496d3,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Baseball Cap (Light Blue)', - ], - [ - '14ef6b00-debf-4a85-9755-b4d37df496d3,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Baseball Cap (Black)', - ], - [ - '14ef6b00-debf-4a85-9755-b4d37df496d3,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Baseball Cap (Orange)', - ], - ['14ef6b00-debf-4a85-9755-b4d37df496d3,,,', 'Baseball Cap (White)'], - [ - '14ef6b00-debf-4a85-9755-b4d37df496d3,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Baseball Cap (Yellow)', - ], - ['896c2491-2f96-4986-9cbd-b3b31ef5d8c5,,,', 'Equestrian Coat (Black)'], - [ - '896c2491-2f96-4986-9cbd-b3b31ef5d8c5,55901f12-d5b5-4fa8-b4c8-e479689ee39d,d344b8cc-85a8-4ace-9f92-38c84f396e99,', - 'Equestrian Coat (Blue)', - ], - [ - '896c2491-2f96-4986-9cbd-b3b31ef5d8c5,4828b50c-95b6-466a-bb25-514891d78202,d344b8cc-85a8-4ace-9f92-38c84f396e99,', - 'Equestrian Coat (Grey)', - ], - [ - '896c2491-2f96-4986-9cbd-b3b31ef5d8c5,d6823e01-69f0-4f85-b94a-74894356a2cf,d344b8cc-85a8-4ace-9f92-38c84f396e99,', - 'Equestrian Coat (Maroon)', - ], - ['09177621-9ecd-4f6a-b6a5-64490139141d,,,', 'Flat Top Hair'], - ['95ab7a7c-c35d-4da5-9955-0921064470b6,,,', 'Gekko Hair'], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Headband (Grey)', - ], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Headband (Light Blue)', - ], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Headband (Orange)', - ], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Headband (Pink)', - ], - [ - '40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,018a5c07-e956-457d-a540-a5e2cd68da09,', - 'Headband (Red, White)', - ], - ['62ce4109-8dee-4895-bf1b-bfa143db4c7e,,,', 'Slim Blazer (Teal)'], - [ - '62ce4109-8dee-4895-bf1b-bfa143db4c7e,cd5d7285-202d-42d0-b93f-04245875793e,0f36bb97-c61b-4281-929f-ff1d0d11be86,', - 'Slim Blazer (Green)', - ], - [ - '62ce4109-8dee-4895-bf1b-bfa143db4c7e,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,0f36bb97-c61b-4281-929f-ff1d0d11be86,', - 'Slim Blazer (Blue)', - ], - ['8b9f1413-e786-4a30-946c-9292f207875a,,,', 'Pulp Hair'], - ['5cd08cfb-c729-4c30-96d9-6a99bb934d91,,,', 'Rec Room Sash'], - ['1a71064b-794f-40fa-9109-8ad36602b6e1,,,', 'Shagg Hair'], - [ - '84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,761a3193-60f0-4190-80c7-285b8192e794,91a451c1-b285-4c48-b14d-59ded8cc006f,', - 'Stoll Dress (Blue)', - ], - [ - '84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,a819f49b-6c7a-49d3-9e6a-d9d79ef5019f,91a451c1-b285-4c48-b14d-59ded8cc006f,', - 'Stoll Dress (Green)', - ], - [ - '84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,64850553-cdfe-455a-ac00-dafbe63d613e,91a451c1-b285-4c48-b14d-59ded8cc006f,', - 'Stoll Dress (Orange)', - ], - ['84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,,,', 'Stoll Dress (Pink)'], - [ - '71921831-ba6f-408b-a00e-2fd97663636f,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Wrist Tape (Black)', - ], - [ - '71921831-ba6f-408b-a00e-2fd97663636f,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Wrist Tape (Cream)', - ], - [ - 'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Wristbands (Grey)', - ], - [ - 'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,', - 'Wristbands (Cream)', - ], - ['6b9e022c-0b68-48fd-8eca-da8573c18900,,,', 'Long Scarf (Red)'], - [ - '2e59d8d0-91a0-4449-bfdc-a5d663fd9343,0iSsaY-HgkmLaRHCn5vEdw,PioQ0o3yP0a6szPZ4EKs2A,', - 'Collared Shirt (Blue)', - ], - [ - '2e59d8d0-91a0-4449-bfdc-a5d663fd9343,jGj28vhq8EGwP2RuM074aQ,PioQ0o3yP0a6szPZ4EKs2A,', - 'Collared Shirt (Yellow)', - ], - [ - '2e59d8d0-91a0-4449-bfdc-a5d663fd9343,kmj5zOjcwku_WWKroCeiVQ,PioQ0o3yP0a6szPZ4EKs2A,', - 'Collared Shirt (Pink)', - ], - [ - '2e59d8d0-91a0-4449-bfdc-a5d663fd9343,FAviMCQ_EE2Mpt6QPo5OEw,PioQ0o3yP0a6szPZ4EKs2A,', - 'Collared Shirt (Red)', - ], - [ - '2e59d8d0-91a0-4449-bfdc-a5d663fd9343,MFrcSQ1DYUm8imvy4ypgvw,PioQ0o3yP0a6szPZ4EKs2A,', - 'Collared Shirt (White)', - ], - [ - 'de0ac50d-2adb-4114-bd2e-68953b13d706,05ac07e1-67f0-486c-abf5-a62866475abb,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,', - 'Blazer (Black, Cream)', - ], - ['0088603e-ec3b-4478-8694-e6fb1989b3f2,,,', 'Angled Bob Hair'], - ['ffea7a65-613f-4835-921e-6dd15f357b7e,,,', 'Long Bangs Hair'], - ['45f5e714-8a5f-4385-a97f-675066167011,,,', 'Seventies Stache'], - ['9bf5d259-7774-4cbe-a90f-7f188cc0dce7,,,', 'Thick Goatee'], - ['a6cbfe76-534a-4655-a8a8-3fed13d001c7,,,', 'Bald Top Hair'], - ['CTcrvbo3OEepIV4oW8bx4w,,,', 'Receding Hair'], - ['-twtjyBdQ02EAdOfBGTiEw,,,', 'Van Dyke Beard'], - ['45eaab67-19c2-4601-8f80-3565a4dceba4,,,', 'Pompadour Hair'], - ['c855dcc3-96cb-470d-b159-d37a025a47d1,,,', 'Dutch Braid Hair'], - ['d7730a9e-78a1-4356-bc09-6b066615850b,,,', 'Afro Updo Hair'], - ['8c35c804-e8d5-49d2-8d5a-ea19fb70bfa6,,,', 'Pencil Bun Hair'], - ['5beeb4c4-f276-4eae-87aa-9302e45b05b7,,,', 'Cornrows Hair'], - [ - 'b6rLwzD4NkKV7xKn9ZYVkA,sxUE0iOSZEmezm54T7xI3Q,tlpa7195x0CkmSjpR1RArQ,', - 'Rec Room Hoodie - Pride (Rainbow Pride)', - ], - ['fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,,,', 'Winged Hat - Pride (Rainbow Pride)'], - [ - 'b6rLwzD4NkKV7xKn9ZYVkA,D_Xmo0rOzkS-kgq1CYXt3g,tnCJp2eDI0SwjVfJMhk3LQ,', - 'Rec Room Hoodie - Pride (Trans Pride)', - ], - [ - 'fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,knXPidb-Rkayfc3kSHfZeQ,1yMyo6oTjU-VAygoeWaohQ,', - 'Winged Hat - Pride (Trans Pride)', - ], - ['88b6ddeb-a455-460d-91d9-a4569ef6903c,,,', 'Square Earrings '], - ['0abb6b08-20ce-444f-879e-0d1344df096c,,,', 'Round Earrings'], - ['9b5bde11-7408-4798-9fcb-c7ec175444df,,,', 'Hoop Earrings'], -] - -export const DEFAULT_AVATAR_ITEMS: readonly AvatarItem[] = ENTRIES.map( - ([AvatarItemDesc, FriendlyName, rarity]) => ({ - AvatarItemType: 0, - AvatarItemDesc, - PlatformMask: -1, - FriendlyName, - Tooltip: '', - Rarity: rarity ?? 0, - }) -) diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index 2cae650..d43656a 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -1,5 +1,7 @@ import { Hono } from 'hono' +import { consumeGift } from '@repo/domain' + import { authedId, unauthorized } from '../http' import { createInvention, @@ -89,13 +91,16 @@ export const avatarRoutes = new Hono({ strict: false }) }) .post('/api/avatar/v2/gifts/consume', async (c) => { const id = await authedId(c) - if (id === null) return unauthorized(c) - const body = await c.req.parseBody().catch(() => ({}) as Record) const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0 - if (giftId === 0) return c.json({ success: false, error: 'Invalid gift ID' }, 400) - // No DB → gift can never be found. - return c.json({ success: false, error: 'Gift not found' }, 404) + // Opening a box just deletes it — the item was granted into the player's inventory + // when they bought it (see the `econ` worker's buyItem), so there's nothing to grant. + // Answers the `{ error, success, value }` envelope a captured real consume returns + // (not an empty body — the client parses it to finish opening the box). A missing/zero + // id, no token, or a box that's already gone (or isn't theirs) is a scoped no-op, not + // an error. Mirrors the econ worker's consume route (the client may call either host). + if (id !== null && giftId !== 0) await consumeGift(c.env.DB, id, giftId) + return c.json({ error: '', success: true, value: null }) }) // Custom avatar item gates — real Rec Room client endpoints with no backing @@ -108,6 +113,9 @@ export const avatarRoutes = new Hono({ strict: false }) .get('/api/customAvatarItems/v1/isCreationEnabled', (c) => c.json(true)) .get('/api/customAvatarItems/v1/isRenderingEnabled', (c) => c.json(true)) + // The featured custom-avatar-item feed. No curated items yet → an empty list. + .get('/api/customAvatarItems/v1/featured', (c) => c.json([])) + // Custom avatar items created by a given account. No storage yet → an empty // paginated result (matches the econ `customAvatarItems/v1/owned` shape). .get('/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}', (c) => diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index e8d67e6..afd88d0 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -4,6 +4,8 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../api.app' +import { createGift, getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain' + import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db' import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db' import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db' @@ -77,6 +79,10 @@ beforeAll(async () => { // Inventions table (owned by the api worker) — invention save/mine use it. for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run() + + // Received-gift boxes (schema owned by the `econ` worker, on the shared DB) — the + // gift consume endpoint deletes from it. + for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) // Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the @@ -275,6 +281,72 @@ describe('public endpoints', () => { expect(await res.json()).toEqual([]) }) + test('POST /api/avatar/v2/gifts/consume deletes the player’s gift box', async () => { + // Seed a box for account 42 directly, then consume it. + const { id: giftId } = await createGift(env.DB, 42, { + ConsumableItemDesc: '', + ConsumableCount: 0, + AvatarItemDesc: 'd0a9262f-5504-46a7-bb10-7507503db58e,,,', + AvatarItemType: 0, + CurrencyType: 0, + Currency: 0, + Xp: 0, + PackageType: 0, + Message: 'A gift for you <3', + EquipmentPrefabName: '', + EquipmentModificationGuid: '', + GiftRarity: 50, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: null, + }) + // Consume is fire-and-forget: always 200 with the success envelope. The box is gone after. + const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, { + method: 'POST', + headers: await bearer('42'), + body: new URLSearchParams({ Id: String(giftId), UnlockedLevel: '0' }), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ error: '', success: true, value: null }) + expect(await getPendingGifts(env.DB, 42)).toHaveLength(0) + + // Consuming it again is a no-op — still 200, nothing changes. + const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, { + method: 'POST', + headers: await bearer('42'), + body: new URLSearchParams({ Id: String(giftId) }), + }) + expect(again.status).toBe(200) + }) + + test('POST /api/avatar/v2/gifts/consume leaves another player’s box untouched', async () => { + const { id: giftId } = await createGift(env.DB, 99, { + ConsumableItemDesc: '', + ConsumableCount: 0, + AvatarItemDesc: 'a,,,', + AvatarItemType: 0, + CurrencyType: 0, + Currency: 0, + Xp: 0, + PackageType: 0, + Message: '', + EquipmentPrefabName: '', + EquipmentModificationGuid: '', + GiftRarity: 0, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: null, + }) + // Account 42 consuming account 99's box is a scoped no-op (still 200), and 99 keeps it. + const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, { + method: 'POST', + headers: await bearer('42'), + body: new URLSearchParams({ Id: String(giftId) }), + }) + expect(res.status).toBe(200) + expect((await getPendingGifts(env.DB, 99)).some((g) => g.Id === giftId)).toBe(true) + }) + test('GET /api/customAvatarItems/v1/isCreationAllowedForAccount returns a success envelope', async () => { const res = await exports.default.fetch( `${ORIGIN}/api/customAvatarItems/v1/isCreationAllowedForAccount` @@ -295,6 +367,12 @@ describe('public endpoints', () => { expect(await res.json()).toBe(true) }) + test('GET /api/customAvatarItems/v1/featured returns []', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/featured`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + test('GET /api/customAvatarItems/v2/fromCreator/:id returns an empty paginated result', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`) expect(res.status).toBe(200) diff --git a/apps/cdn/static/loading-screen-tip-data.json b/apps/cdn/static/loading-screen-tip-data.json index 674f04b..f06050c 100644 --- a/apps/cdn/static/loading-screen-tip-data.json +++ b/apps/cdn/static/loading-screen-tip-data.json @@ -1,16 +1,142 @@ [ - { - "Name": "bf5d8d41a7184e779399ff45a9e8785a", - "Title": "Welcome!", - "Message": "Thanks for trying RecFlare alpha!", - "RoomNames": [], - "Context": 0, - "InputType": 0, - "Visibility": 0, - "AllowCycling": true, - "RestrictToNewUsers": false, - "ImageName": "tip.jpg", - "PlatformMask": 175, - "CreatedAt": "2026-07-11T18:27:25Z" - } -] + { + "Name": "bf5d8d41a7184e779399ff45a9e8785a", + "Title": "Rec Room Tokens", + "Message": "Redeem your Rec Room Tokens for all kinds of fun rewards! You can shop at the Rec Center Merch Booth or the Store section of your Watch Menu.", + "RoomNames": [], + "Context": 0, + "InputType": 0, + "Visibility": 0, + "AllowCycling": true, + "RestrictToNewUsers": false, + "ImageName": "gay", + "PlatformMask": 175, + "CreatedAt": "2019-02-28T18:27:25Z" + }, + { + "Name": "3801fdd940334b85a7460ffdda73f9de", + "Title": "Become a Star!", + "Message": "Use #RecRoom on your Instagram and Twitter posts for a chance to make it onto our Community Board!", + "RoomNames": [], + "Context": 0, + "InputType": 0, + "Visibility": 0, + "AllowCycling": true, + "RestrictToNewUsers": false, + "ImageName": "gay", + "PlatformMask": 167, + "CreatedAt": "2019-02-28T18:15:33Z" + }, + { + "Name": "d517b46a3f734db39d77f2a15b105495", + "Title": "Find Your Style", + "Message": "Personalize your outfit and appearance in your Dorm Room.", + "RoomNames": [], + "Context": 0, + "InputType": 0, + "Visibility": 0, + "AllowCycling": true, + "RestrictToNewUsers": false, + "ImageName": "tip.jpg", + "PlatformMask": 175, + "CreatedAt": "2019-03-02T00:12:59Z" + }, + { + "Name": "bc8569de432a4cc4b0e69d1b619921cb", + "Title": "Room Cheers", + "Message": "Cheer and Favorite any room in the This Room section of your Watch Menu.", + "RoomNames": [], + "Context": 0, + "InputType": 0, + "Visibility": 0, + "AllowCycling": true, + "RestrictToNewUsers": false, + "ImageName": "tip.jpg", + "PlatformMask": 175, + "CreatedAt": "2019-02-28T18:08:56Z" + }, + { + "Name": "4a9bec133a26457280eb30887e9442d9", + "Title": "Daily Challenges", + "Message": "Check out the Challenges section in your watch for fun ways to earn in-game rewards.", + "RoomNames": [], + "Context": 0, + "InputType": 0, + "Visibility": 0, + "AllowCycling": true, + "RestrictToNewUsers": false, + "ImageName": "tip.jpg", + "PlatformMask": 175, + "CreatedAt": "2019-02-28T18:22:44Z" + }, + { + "Name": "511adb340964437cbbe4ad382a658b52", + "Title": "Welcome to Rec Room", + "Message": "Rec Room is a virtual social club where you create and play games with friends. It's a fun and welcoming environment for people from all walks of life!", + "RoomNames": [], + "Context": 0, + "InputType": 0, + "Visibility": 0, + "AllowCycling": true, + "RestrictToNewUsers": false, + "ImageName": "tip.jpg", + "PlatformMask": 175, + "CreatedAt": "2019-02-28T18:28:32Z" + }, + { + "Name": "0ec7868f309843158bfb7fd94fd2a80a", + "Title": "Join the Rec Room Community!", + "Message": "See recroom.com/community for links to Rec Room's YouTube, Instagram, Discord, and MORE!", + "RoomNames": [], + "Context": 0, + "InputType": 0, + "Visibility": 0, + "AllowCycling": true, + "RestrictToNewUsers": false, + "ImageName": "tip.jpg", + "PlatformMask": 167, + "CreatedAt": "2019-02-28T18:24:33Z" + }, + { + "Name": "f0c49e1d5ce146638f1cb53f5d4f0bb2", + "Title": "Play on the go!", + "Message": "Download Rec Room on your iOS device and play anywhere!", + "RoomNames": [], + "Context": 0, + "InputType": 0, + "Visibility": 0, + "AllowCycling": true, + "RestrictToNewUsers": false, + "ImageName": "tip.jpg", + "PlatformMask": 131, + "CreatedAt": "2019-12-21T01:24:23Z" + }, + { + "Name": "ae71ae948ce041bba799ee8d6ee9a0ba", + "Title": "Laser Tag Merch", + "Message": "You earn tickets for every game of Laser Tag. Redeem them for awesome Laser Tag gear!", + "RoomNames": [], + "Context": 0, + "InputType": 0, + "Visibility": 0, + "AllowCycling": true, + "RestrictToNewUsers": false, + "ImageName": "tip.jpg", + "PlatformMask": 175, + "CreatedAt": "2019-02-28T18:07:50Z" + }, + { + "Name": "169b34a66ed046ba84cb2dbf43562a26", + "Title": "We're all on Rec.Net!", + "Message": "Log into your Rec.Net profile to stay in touch with your friends any time!", + "RoomNames": [], + "Context": 0, + "InputType": 0, + "Visibility": 1, + "AllowCycling": true, + "RestrictToNewUsers": false, + "ImageName": "tip.jpg", + "PlatformMask": 239, + "CreatedAt": "2019-02-28T18:21:25Z" + } +] \ No newline at end of file diff --git a/apps/commerce/README.md b/apps/commerce/README.md index 1df0051..96f618e 100644 --- a/apps/commerce/README.md +++ b/apps/commerce/README.md @@ -2,6 +2,17 @@ A Cloudflare Workers application using Hono +## Endpoints + +- `GET /purchase/v1/hasspentmoney` — whether the player has ever spent money; + `false`. +- `GET /api/catalog/v1/all` — the purchasable SKU catalog (token packs, special + offers), served from the bundled `static/catalog-v1-all.json`. The client's + `?onlyAvailableSkus=true` is accepted and ignored: the bundled catalog already + contains only available SKUs. +- `GET /purchasecampaign/allcurrent/v2` — current purchase campaigns + (limited-time offers/promos); `[]` (none active). + ## Development ### Run in dev mode diff --git a/apps/commerce/src/commerce.app.ts b/apps/commerce/src/commerce.app.ts index 5515095..da63307 100644 --- a/apps/commerce/src/commerce.app.ts +++ b/apps/commerce/src/commerce.app.ts @@ -3,6 +3,8 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' +import catalog from '../static/catalog-v1-all.json' + import type { App } from './context' /** @@ -29,4 +31,14 @@ const app = new Hono() // it as an error, so we return `false` (no purchases). .get('/purchase/v1/hasspentmoney', (c) => c.json(false)) + // The purchasable SKU catalog (token packs, special offers), served from the + // bundled static JSON. The client passes `?onlyAvailableSkus=true`; the bundled + // catalog is already only the available SKUs, so the param doesn't change the + // response. + .get('/api/catalog/v1/all', (c) => c.json(catalog)) + + // Current purchase campaigns (limited-time offers/promos). None exist, and + // an empty list is the client's "no active campaigns" state. + .get('/purchasecampaign/allcurrent/v2', (c) => c.json([])) + export default app diff --git a/apps/commerce/src/test/integration/api.test.ts b/apps/commerce/src/test/integration/api.test.ts index 2be65ef..593e95d 100644 --- a/apps/commerce/src/test/integration/api.test.ts +++ b/apps/commerce/src/test/integration/api.test.ts @@ -17,4 +17,19 @@ describe('commerce endpoints', () => { expect(res.status).toBe(200) expect(await res.json()).toBe(false) }) + + it('GET /api/catalog/v1/all serves the SKU catalog', async () => { + const res = await SELF.fetch(`${ORIGIN}/api/catalog/v1/all?onlyAvailableSkus=true`) + expect(res.status).toBe(200) + const skus = (await res.json()) as Array<{ skuId: number }> + expect(Array.isArray(skus)).toBe(true) + expect(skus.length).toBeGreaterThan(0) + expect(skus[0]).toHaveProperty('skuId') + }) + + it('GET /purchasecampaign/allcurrent/v2 returns []', async () => { + const res = await SELF.fetch(`${ORIGIN}/purchasecampaign/allcurrent/v2`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) }) diff --git a/apps/commerce/static/catalog-v1-all.json b/apps/commerce/static/catalog-v1-all.json new file mode 100644 index 0000000..3f7f297 --- /dev/null +++ b/apps/commerce/static/catalog-v1-all.json @@ -0,0 +1,647 @@ +[ + { + "skuId": 178, + "name": "500 Tokens", + "description": "", + "imageName": "0j3qprcooizhw1fw37vbcxj5b.png", + "price": 99, + "oculusSkuId": "TK0007", + "appleProductId": "iTK007", + "googlePlaySkuId": "tk0007", + "picoSkuId": "TK007", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [ + 14248 + ], + "message": "500 Tokens" + } + }, + { + "skuId": 184, + "name": "Special Offer 9", + "description": "", + "imageName": "6lp54v2yy3te4xd4ilbl9gq1f.png", + "price": 99, + "oculusSkuId": "CO1009", + "xboxProductId": "43474e39-5143-3035-c04c-5a47484c5b00", + "xboxStoreId": "9NGCCQ5LZGHL", + "appleProductId": "CO0009", + "googlePlaySkuId": "co0009", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 9" + } + }, + { + "skuId": 182, + "name": "Techwear Samurai Bundle", + "description": "Techwear Samurai Bundle", + "imageName": "dcsac8vyi7dewkwwh9uobi422.png", + "price": 99, + "oculusSkuId": "TK0007", + "appleProductId": "iTK007", + "googlePlaySkuId": "tk0007", + "picoSkuId": "TK007", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Techwear Samurai Bundle" + } + }, + { + "skuId": 183, + "name": "Maker AI Timed Access One Day", + "description": "Grants one day of Maker AI access, timer starts after purchase completion", + "imageName": "0b8qpn5ghc8wtwqnnb7fpmcwz.png", + "price": 199, + "oculusSkuId": "MD0001", + "xboxProductId": "4a464e39-5835-3053-c046-475a53528300", + "xboxStoreId": "9NFJ5XSFGZSR", + "appleProductId": "MD0001", + "googlePlaySkuId": "md0001", + "picoSkuId": "MD0001", + "isSingleUse": false, + "shouldAppearInTokenStore": true, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "One day of Maker AI access granted!" + } + }, + { + "skuId": 185, + "name": "Special Offer 10", + "description": "", + "imageName": "d8sqdpzxny5sgoebk3wfl976c.png", + "price": 199, + "oculusSkuId": "CO0010", + "xboxProductId": "48544e39-4647-304e-c033-325642524d00", + "xboxStoreId": "9NTHGFN32VBR", + "appleProductId": "CO0010", + "googlePlaySkuId": "co0010", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 10" + } + }, + { + "skuId": 179, + "name": "1,000 Tokens", + "description": "", + "imageName": "535sxig13awyltiiu1tpc28em.png", + "price": 199, + "oculusSkuId": "TK0008", + "appleProductId": "iTK008", + "googlePlaySkuId": "tk0008", + "picoSkuId": "TK008", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [ + 14249 + ], + "message": "1,000 Tokens" + } + }, + { + "skuId": 23, + "name": "Special Offer 1", + "description": "", + "imageName": "33q0sd57vofyqyd8559zud6s5.png", + "price": 299, + "oculusSkuId": "CO0001", + "xboxProductId": "57434e39-5137-3051-c04e-3631384e3500", + "xboxStoreId": "9NCW7QQN618N", + "appleProductId": "CO0001", + "googlePlaySkuId": "co0001", + "picoSkuId": "CO001", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 1" + } + }, + { + "skuId": 177, + "name": "1,500 Tokens", + "description": "", + "imageName": "bsvmr0bvc8koxe19a731sy2jt.png", + "price": 299, + "oculusSkuId": "TK0005", + "xboxProductId": "56334e39-5348-3050-c035-533647373700", + "xboxStoreId": "9N3VHSP5S6G7", + "appleProductId": "iTK005", + "googlePlaySkuId": "tk0005", + "picoSkuId": "TK005", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [ + 4463 + ], + "message": "1,500 Tokens" + } + }, + { + "skuId": 186, + "name": "Special Offer 11", + "description": "", + "imageName": "5msy6rgvumgv64hi25bpe9zef.png", + "price": 399, + "oculusSkuId": "CO0011", + "xboxProductId": "504c4e39-5051-304a-c058-44525054a000", + "xboxStoreId": "9NLPQPJXDRPT", + "appleProductId": "CO0011", + "googlePlaySkuId": "co0011", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 11" + } + }, + { + "skuId": 180, + "name": "2,000 Tokens", + "description": "", + "imageName": "89brexfrkyxx7a1fqtb7vimtp.png", + "price": 399, + "oculusSkuId": "TK0009", + "appleProductId": "iTK009", + "googlePlaySkuId": "tk0009", + "picoSkuId": "TK009", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [ + 14250 + ], + "message": "2,000 Tokens" + } + }, + { + "skuId": 181, + "name": "Techwear Samurai Bundle", + "description": "Techwear Samurai Bundle", + "imageName": "dmrxioogofer7d7djcvvt6l6o.png", + "price": 499, + "oculusSkuId": "TK0001", + "psnProductLabel": "TK00010000000000", + "psnEntitlementLabel": "TK0001", + "xboxProductId": "4e444e39-5651-3046-c044-33484b374700", + "xboxStoreId": "9NDNQVFD3HK7", + "appleProductId": "iTK001", + "googlePlaySkuId": "tk0001", + "picoSkuId": "TK001", + "nintendoSkuId": "TK0001", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Techwear Samurai Bundle" + } + }, + { + "skuId": 2, + "name": "2,500 Tokens", + "description": "", + "imageName": "c8nb2o470fwyyiccr4fuiv4k9.png", + "price": 499, + "oculusSkuId": "TK0001", + "psnProductLabel": "TK00010000000000", + "psnEntitlementLabel": "TK0001", + "xboxProductId": "4e444e39-5651-3046-c044-33484b374700", + "xboxStoreId": "9NDNQVFD3HK7", + "appleProductId": "iTK001", + "googlePlaySkuId": "tk0001", + "picoSkuId": "TK001", + "nintendoSkuId": "TK0001", + "isSingleUse": false, + "shouldAppearInTokenStore": true, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [ + 2632 + ], + "message": "2,500 Tokens" + } + }, + { + "skuId": 24, + "name": "Special Offer 2", + "description": "", + "imageName": "0snwnkn7l94klxot4hkha93ot.png", + "price": 499, + "oculusSkuId": "CO0002", + "xboxProductId": "32574e39-3250-304d-c048-445242363500", + "xboxStoreId": "9NW2P2MHDRB6", + "appleProductId": "CO0002", + "googlePlaySkuId": "co0002", + "picoSkuId": "CO002", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 2" + } + }, + { + "skuId": 10, + "name": "Starter Pack", + "description": "5,500 Tokens\r\nPizza, Pop, & a Potion\r\nExclusive White Hoodie", + "imageName": "7jeywaubvdew56wlq2bazbeof.png", + "price": 499, + "oculusSkuId": "SP0001", + "psnProductLabel": "SP00010000000000", + "psnEntitlementLabel": "SP0001", + "xboxProductId": "57395039-5332-304b-c032-4239434b2400", + "xboxStoreId": "9P9W2SK2B9CK", + "appleProductId": "iSP001", + "googlePlaySkuId": "sp0001", + "isSingleUse": true, + "shouldAppearInTokenStore": true, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [ + 2636, + 2011, + 2025, + 2010, + 2548 + ], + "message": "Starter Pack" + } + }, + { + "skuId": 187, + "name": "Special Offer 12", + "description": "", + "imageName": "a5owgnqsbvpw0wm6im1rdtsp3.png", + "price": 599, + "oculusSkuId": "CO0012", + "xboxProductId": "4d474e39-3046-3047-c031-544742311700", + "xboxStoreId": "9NGMF0G1TGB1", + "appleProductId": "CO0012", + "googlePlaySkuId": "co0012", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 12" + } + }, + { + "skuId": 188, + "name": "Special Offer 13", + "description": "", + "imageName": "dn4gs1ie40kfhoruy7jmkfey4.png", + "price": 699, + "oculusSkuId": "CO0013", + "xboxProductId": "364e4e39-4334-3044-c04a-385257352600", + "xboxStoreId": "9NN64CDJ8RW5", + "appleProductId": "CO0013", + "googlePlaySkuId": "co0013", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 13" + } + }, + { + "skuId": 189, + "name": "Special Offer 14", + "description": "", + "imageName": "3fbwxtc1flb1ngd71l2vudklx.png", + "price": 799, + "oculusSkuId": "CO0014", + "xboxProductId": "43314e39-3235-3039-c050-573158562100", + "xboxStoreId": "9N1C529PW1XV", + "appleProductId": "CO0014", + "googlePlaySkuId": "co0014", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 14" + } + }, + { + "skuId": 11, + "name": "Rec Room Plus Membership", + "description": "Rec Room Plus Membership", + "imageName": "304tyaxvb6d8s3dcef8s3y4pb.png", + "price": 799, + "oculusSkuId": "PM0001", + "psnProductLabel": "PM00010000000000", + "psnEntitlementLabel": "PM0001", + "xboxProductId": "54465039-534b-3044-c04a-564e32517600", + "xboxStoreId": "9PFTKSDJVN2Q", + "appleProductId": "iCC001", + "googlePlaySkuId": "pm0001", + "nintendoSkuId": "a26f976e026d86e1", + "isSingleUse": false, + "shouldAppearInTokenStore": true, + "dataSchemaVersion": 2, + "data": { + "giftDropIds": [ + 14258 + ], + "message": "Rec Room Plus Membership", + "subscriptionPurchase": { + "type": 0, + "level": 0, + "period": 0, + "isAutoRenewing": true + } + } + }, + { + "skuId": 190, + "name": "Special Offer 15", + "description": "", + "imageName": "du9cs8uv5buib1ajgwbdcibtp.png", + "price": 899, + "oculusSkuId": "CO0015", + "xboxProductId": "36534e39-5435-3047-c031-585a57435d00", + "xboxStoreId": "9NS65TG1XZWC", + "appleProductId": "CO0015", + "googlePlaySkuId": "co0015", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 15" + } + }, + { + "skuId": 25, + "name": "Special Offer 3", + "description": "", + "imageName": "7yr1khevvss841xrwahj6hfn9.png", + "price": 999, + "oculusSkuId": "CO0003", + "xboxProductId": "36344e39-4747-3051-c044-4d3634350000", + "xboxStoreId": "9N46GGQDM645", + "appleProductId": "CO0003", + "googlePlaySkuId": "co0003", + "picoSkuId": "CO003", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 3" + } + }, + { + "skuId": 5, + "name": "5,500 Tokens", + "description": "", + "imageName": "0k5tpvs9p5tp50dfn4tq1j72l.png", + "price": 999, + "oculusSkuId": "TK0002", + "psnProductLabel": "TK00020000000000", + "psnEntitlementLabel": "TK0002", + "xboxProductId": "44544d39-5153-3030-c044-4a5734576200", + "xboxStoreId": "9MTDSQ0DJW4W", + "appleProductId": "iTK002", + "googlePlaySkuId": "tk0002", + "picoSkuId": "TK002", + "nintendoSkuId": "TK0002", + "isSingleUse": false, + "shouldAppearInTokenStore": true, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [ + 2633 + ], + "message": "5,500 Tokens" + } + }, + { + "skuId": 26, + "name": "Special Offer 4", + "description": "", + "imageName": "ev9ynntwhgonosztxuk6r75vq.png", + "price": 1499, + "oculusSkuId": "CO0004", + "xboxProductId": "52465039-4342-3056-c04e-465847538200", + "xboxStoreId": "9PFRBCVNFXGS", + "appleProductId": "CO0004", + "googlePlaySkuId": "co0004", + "picoSkuId": "CO004", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 4" + } + }, + { + "skuId": 27, + "name": "Special Offer 5", + "description": "", + "imageName": "3exoho63d3xtnx6z8eiudm3yl.png", + "price": 1999, + "oculusSkuId": "CO0005", + "xboxProductId": "5a514e39-3544-3043-c035-5434324a2700", + "xboxStoreId": "9NQZD5C5T42J", + "appleProductId": "CO0005", + "googlePlaySkuId": "co0005", + "picoSkuId": "CO005", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 5" + } + }, + { + "skuId": 8, + "name": "12,000 Tokens", + "description": "", + "imageName": "2cxy6mghew6po1qyl2hjjp1dz.png", + "price": 1999, + "oculusSkuId": "TK0003", + "psnProductLabel": "TK00030000000000", + "psnEntitlementLabel": "TK0003", + "xboxProductId": "38434e39-5447-3032-c054-503747433400", + "xboxStoreId": "9NC8GT2TP7GC", + "appleProductId": "iTK003", + "googlePlaySkuId": "tk0003", + "picoSkuId": "TK003", + "nintendoSkuId": "TK0003", + "isSingleUse": false, + "shouldAppearInTokenStore": true, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [ + 2634 + ], + "message": "12,000 Tokens" + } + }, + { + "skuId": 191, + "name": "Special Offer 16", + "description": "", + "imageName": "de614ysh9h08jgt6rm7xtl8g1.png", + "price": 2499, + "oculusSkuId": "CO0016", + "xboxProductId": "47314e39-3557-3043-c052-345233573000", + "xboxStoreId": "9N1GW5CR4R3W", + "appleProductId": "CO0016", + "googlePlaySkuId": "co0016", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 16" + } + }, + { + "skuId": 192, + "name": "Special Offer 17", + "description": "", + "imageName": "e2pwk2sqieip5vcbd5bmf5qek.png", + "price": 2999, + "oculusSkuId": "CO0017", + "xboxProductId": "4e465039-464e-3047-c04d-374351334300", + "xboxStoreId": "9PFNNFGM7CQ3", + "appleProductId": "CO0017", + "googlePlaySkuId": "co0017", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 17" + } + }, + { + "skuId": 9, + "name": "35,000 Tokens", + "description": "", + "imageName": "8okk17qjqs25tavj04fzqzski.png", + "price": 4999, + "oculusSkuId": "TK0004", + "psnProductLabel": "TK00040000000000", + "psnEntitlementLabel": "TK0004", + "xboxProductId": "36524e39-314d-3034-c039-314b5230f800", + "xboxStoreId": "9NR6M1491KR0", + "appleProductId": "iTK004", + "googlePlaySkuId": "tk0004", + "isSingleUse": false, + "shouldAppearInTokenStore": true, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [ + 2635 + ], + "message": "35,000 Tokens!" + } + }, + { + "skuId": 28, + "name": "Special Offer 6", + "description": "", + "imageName": "9xtn0r4ol3cszw6n3nmdtjqpc.png", + "price": 4999, + "oculusSkuId": "CO0006", + "xboxProductId": "4a395039-4437-3054-c042-4d3847432c00", + "xboxStoreId": "9P9J7DTBM8GC", + "appleProductId": "CO0006", + "googlePlaySkuId": "co0006", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 6" + } + }, + { + "skuId": 29, + "name": "Special Offer 7", + "description": "", + "imageName": "brrnvugan0vtdz5ha58xsydsk.png", + "price": 6999, + "oculusSkuId": "CO0007", + "xboxProductId": "42534e39-4230-3048-c047-46504d373700", + "xboxStoreId": "9NSB0BHGFPM7", + "appleProductId": "CO0007", + "googlePlaySkuId": "co0007", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 7" + } + }, + { + "skuId": 193, + "name": "Special Offer 18", + "description": "", + "imageName": "ct4fpwoznnerrdjwuii3vmlxk.png", + "price": 7499, + "oculusSkuId": "CO0018", + "xboxProductId": "5a485039-4e56-304e-c039-5a3735324e00", + "xboxStoreId": "9PHZVNN9Z752", + "appleProductId": "CO0018", + "googlePlaySkuId": "co0018", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "Special Offer 18" + } + }, + { + "skuId": 30, + "name": "99.99 Special Offer", + "description": "", + "imageName": "5gv9upz5ndauzgwgn8x4lohg6.png", + "price": 9999, + "oculusSkuId": "CO0008", + "xboxProductId": "4b4a4e39-4d51-304e-c054-5a444d569d00", + "xboxStoreId": "9NJKQMNTZDMV", + "appleProductId": "CO0008", + "googlePlaySkuId": "co0008", + "isSingleUse": false, + "shouldAppearInTokenStore": false, + "dataSchemaVersion": 1, + "data": { + "giftDropIds": [], + "message": "$99.99 Special Offer" + } + } +] diff --git a/apps/econ/README.md b/apps/econ/README.md index afeed55..a72f32c 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -11,9 +11,9 @@ endpoints the game client calls on the `econ` service (distinct from the main - `GET /api/avatar/v1/defaultbaseavataritems` — default base avatar items. Reads the same source file as `defaultunlocked`, so it returns the identical catalog. -- `GET /api/avatar/v4/items` — `[Authorize]`. The player's avatar items: owned - items concatenated with the default catalog. No DB binding yet, so owned is - empty and this returns just the catalog. +- `GET /api/avatar/v4/items` — `[Authorize]`. The player's avatar items: the + items they've bought (from `buyItem`, in the `inventory` table) prepended to + the default catalog. A player who has bought nothing gets just the catalog. - `GET /api/avatar/v2` — `[Authorize]`. The player's avatar. No DB binding yet, so it returns the default `{ OutfitSelections, FaceFeatures, SkinColor, HairColor }` seeded for a new player. @@ -25,13 +25,36 @@ HairColor }` seeded for a new player. static JSON file verbatim); returns the bundled `static/my-progress.json` default for all players until a DB binding exists. - `GET /api/avatar/v3/saved` — `[Authorize]`. Saved outfits; `[]` without a DB. -- `GET /api/avatar/v2/gifts` — `[Authorize]`. Pending gifts; `[]` without a DB. +- `GET /api/avatar/v2/gifts` — `[Authorize]`. The player's unopened gift boxes + (from their purchases), out of the shared `received_gift` table; `[]` when + they have none. +- `POST /api/avatar/v2/gifts/consume` — open a box (form body `Id=&UnlockedLevel=`, + posted with a trailing slash). Deletes the box scoped to the caller; the item was + already granted at purchase, so this is cosmetic. Always answers the success envelope + `{ error: "", success: true, value: null }` (a captured real consume returns this, not + an empty body — the client parses it to finish opening the box), even for a + missing/already-opened box, so a fire-and-forget re-open never errors. Also served by + the `api` worker (the client may call either host). +- `POST /api/storefronts/v2/buyItem` — `[Authorize]`. Buy a storefront item. + Looks the item up in `static/storefronts/sf{StorefrontType}.json`, confirms the + client's `RequestedPrice` still matches, debits the buyer atomically, grants the + item, and returns a gift box. An avatar-item drop goes into the `inventory` table + (own-once); a consumable drop goes into the `consumable` table (each buy stacks a + new instance). The response's `Balance` is the change applied (the negated price), + not the resulting total — the client reads its new total from `GET /balance/:type`. + `409` on a stale price, `404` on an unknown item, `400` on insufficient balance. - `GET /api/equipment/v2/getUnlocked` — unlocked equipment; `[]` (no auth). - `POST /api/settings/v2/set` — `[Authorize]`. Persist settings; 200 ack only. -- `GET /api/consumables/v2/getUnlocked` — `[Authorize]`. `[]` without a DB. +- `GET /api/consumables/v2/getUnlocked` — `[Authorize]`. The consumables the + player has bought (from `buyItem`, in the `consumable` table), grouped by item + into the unlocked-consumable DTO (`Ids`/`CreatedAts` per instance, `Count` their + sum); `[]` when they've bought none. - `GET /api/storefronts/v4/balance/2` — `[Authorize]`. Token balance; `[]`. - `GET /api/storefronts/v3/giftdropstore/3` — gift-drop storefront, served from the bundled `static/storefronts-v3-giftdropstore-3.json`. +- `GET /api/storefronts/v1/adcarouselitems` — storefront ad-carousel items, + served from the bundled `static/ad-carousel-items.json` (one placeholder + banner until real promo data exists). - `GET /api/challenge/v2/getCurrent` — current weekly challenge, served from the bundled `static/weekly-challenge.json`. - `GET /api/gamerewards/v1/pending` — pending rewards; `[]`. @@ -46,5 +69,9 @@ duplicated here because the client calls them on the `econ` host. ## TODO before production -- Wire a DB binding and prepend each player's owned `AvatarItems` to - `/api/avatar/v4/items`. +- Gifting to another player (`buyItem` with a `Gift` block) grants the item and + box to the recipient, but there's no notification. `buyItem` grants avatar-item + and consumable drops; currency/xp drops aren't granted yet. +- Consumables are granted and listed but never spent — nothing consumes them, so + `Count` only ever grows (each purchase grants `1`; catalogs don't specify a + per-item quantity). diff --git a/apps/econ/migrations/0003_received_gift.sql b/apps/econ/migrations/0003_received_gift.sql new file mode 100644 index 0000000..080fabc --- /dev/null +++ b/apps/econ/migrations/0003_received_gift.sql @@ -0,0 +1,15 @@ +-- Received gift boxes, owned by the `econ` worker. One row per box: a box is created +-- when a player buys a storefront item (`/api/storefronts/v2/buyItem`) and deleted when +-- the client opens it (`/api/avatar/v2/gifts/consume`, on the `api` worker). Opening is +-- cosmetic — the item is granted into the `inventory` table at purchase time, so a box +-- carries only its rendered content (`data`) for the gift list. Kept in sync with +-- RECEIVED_GIFT_SCHEMA_DDL in @repo/domain's gifts-db.ts. + +CREATE TABLE IF NOT EXISTS received_gift ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + data TEXT NOT NULL, + created_at TEXT NOT NULL + ); + +CREATE INDEX IF NOT EXISTS idx_received_gift_account ON received_gift (account_id); diff --git a/apps/econ/migrations/0004_inventory.sql b/apps/econ/migrations/0004_inventory.sql new file mode 100644 index 0000000..bd0f559 --- /dev/null +++ b/apps/econ/migrations/0004_inventory.sql @@ -0,0 +1,14 @@ +-- Owned avatar items, owned by the `econ` worker. One row per (account, item): the +-- items a player has bought from a storefront. Granted at purchase time by +-- `/api/storefronts/v2/buyItem` and read back by `/api/avatar/v4/items`, where they are +-- concatenated with the default catalog. The item is keyed by its `AvatarItemDesc` (the +-- gift-drop's item guid string) so re-buying the same item is a no-op rather than a +-- duplicate row; `data` is the rendered avatar-item DTO. Kept in sync with +-- INVENTORY_SCHEMA_DDL in src/inventory-db.ts. + +CREATE TABLE IF NOT EXISTS inventory ( + account_id INTEGER NOT NULL, + avatar_item_desc TEXT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (account_id, avatar_item_desc) + ); diff --git a/apps/econ/migrations/0005_consumable.sql b/apps/econ/migrations/0005_consumable.sql new file mode 100644 index 0000000..63ce8ba --- /dev/null +++ b/apps/econ/migrations/0005_consumable.sql @@ -0,0 +1,18 @@ +-- Owned consumables, owned by the `econ` worker. Unlike avatar items (own-once, one +-- row per (account, item)), consumables stack: each purchase inserts a fresh instance +-- row carrying its own id, count and created_at. Granted at purchase time by +-- `/api/storefronts/v2/buyItem` (when the gift-drop carries a `ConsumableItemDesc`) and +-- read back by `/api/consumables/v2/getUnlocked`, which groups a player's rows by +-- `consumable_item_desc` into the client's unlocked-consumable DTO (its `Ids`/`CreatedAts` +-- are these per-instance columns; `Count` their sum). Kept in sync with +-- CONSUMABLE_SCHEMA_DDL in src/consumables-db.ts. + +CREATE TABLE IF NOT EXISTS consumable ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + consumable_item_desc TEXT NOT NULL, + count INTEGER NOT NULL, + created_at TEXT NOT NULL + ); + +CREATE INDEX IF NOT EXISTS idx_consumable_account ON consumable (account_id); diff --git a/apps/econ/package.json b/apps/econ/package.json index d9b4ba0..f90ed35 100644 --- a/apps/econ/package.json +++ b/apps/econ/package.json @@ -16,6 +16,7 @@ "test": "run-vitest" }, "dependencies": { + "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", "hono": "4.12.27", diff --git a/apps/econ/src/consumables-db.ts b/apps/econ/src/consumables-db.ts new file mode 100644 index 0000000..b2e1e6d --- /dev/null +++ b/apps/econ/src/consumables-db.ts @@ -0,0 +1,108 @@ +/** + * Owned consumables on the shared `recflare` D1 database — the consumable items a + * player has bought from a storefront (e.g. a "Supreme Pizza"). One row per granted + * instance: unlike avatar items (own-once, keyed by their desc), consumables stack, so + * each purchase inserts a fresh row carrying its own id, count and created_at. + * + * Granted at purchase time (`POST /api/storefronts/v2/buyItem`, when the gift-drop + * carries a `ConsumableItemDesc`) and read back by `GET /api/consumables/v2/getUnlocked`, + * which groups a player's rows by `consumable_item_desc` into the client's unlocked- + * consumable DTO — its `Ids`/`CreatedAts` are these per-instance columns and `Count` + * their sum. + * + * This worker (`econ`) owns the table and its migration — see apps/econ/migrations/ + * 0005_consumable.sql. + */ + +/** Schema DDL (mirror of migrations 0005_consumable.sql) — also builds the table in tests. */ +export const CONSUMABLE_SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS consumable ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + consumable_item_desc TEXT NOT NULL, + count INTEGER NOT NULL, + created_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_consumable_account ON consumable (account_id)`, +] + +/** + * An unlocked consumable as `/api/consumables/v2/getUnlocked` serves it: one entry per + * distinct `ConsumableItemDesc`, aggregating every instance the player owns. `Ids` and + * `CreatedAts` line up per instance; `Count`/`InitialCount` are the summed quantity (no + * consumption is tracked yet, so they stay equal). The activation fields are inert + * defaults until timed consumables exist. + */ +export interface UnlockedConsumable { + Ids: number[] + CreatedAts: string[] + ConsumableItemDesc: string + Count: number + InitialCount: number + IsActive: boolean + ActiveDurationMinutes: number + IsTransferable: boolean +} + +/** Grant `count` of a consumable to a player as a new owned instance (they stack). */ +export async function grantConsumable( + db: D1Database, + accountId: number, + consumableItemDesc: string, + count: number +): Promise { + await db + .prepare( + `INSERT INTO consumable (account_id, consumable_item_desc, count, created_at) + VALUES (?1, ?2, ?3, ?4)` + ) + .bind(accountId, consumableItemDesc, count, new Date().toISOString()) + .run() +} + +interface ConsumableRow { + id: number + consumable_item_desc: string + count: number + created_at: string +} + +/** + * Every consumable a player owns, grouped by item into the unlocked-consumable DTO. + * Rows are read oldest-first so each group's `Ids`/`CreatedAts` are in purchase order. + */ +export async function getConsumables( + db: D1Database, + accountId: number +): Promise { + const { results } = await db + .prepare( + `SELECT id, consumable_item_desc, count, created_at + FROM consumable WHERE account_id = ?1 ORDER BY id` + ) + .bind(accountId) + .all() + + const byDesc = new Map() + for (const r of results) { + const existing = byDesc.get(r.consumable_item_desc) + if (existing === undefined) { + byDesc.set(r.consumable_item_desc, { + Ids: [r.id], + CreatedAts: [r.created_at], + ConsumableItemDesc: r.consumable_item_desc, + Count: r.count, + InitialCount: r.count, + IsActive: false, + ActiveDurationMinutes: 0, + IsTransferable: false, + }) + } else { + existing.Ids.push(r.id) + existing.CreatedAts.push(r.created_at) + existing.Count += r.count + existing.InitialCount += r.count + } + } + return [...byDesc.values()] +} diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index b949599..44ae20b 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -1,20 +1,32 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' +import { consumeGift, createGift, getPendingGifts } from '@repo/domain' import { intVar, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' +import adCarouselItems from '../static/ad-carousel-items.json' import defaultAvatarItems from '../static/default-avatar-items.json' import defaultAvatar from '../static/default-avatar.json' import myProgress from '../static/my-progress.json' import weeklyChallenge from '../static/weekly-challenge.json' import { getAvatar, setAvatar } from './avatar-db' -import { ALL_PLATFORMS, DEFAULT_STARTING_TOKENS, getBalance, isSpendable } from './balance-db' +import { + ALL_PLATFORMS, + DEFAULT_STARTING_TOKENS, + getBalance, + isSpendable, + spendCurrency, +} from './balance-db' +import { getConsumables, grantConsumable } from './consumables-db' +import { getInventory, grantItem } from './inventory-db' import { getOutfits, setOutfit } from './outfit-db' import type { Context } from 'hono' +import type { GiftContent } from '@repo/domain' import type { Avatar } from './avatar-db' import type { App } from './context' +import type { AvatarItem } from './inventory-db' import type { Outfit } from './outfit-db' /** @@ -53,7 +65,108 @@ function toAvatarV2Dto(avatar: Avatar) { } } -const app = new Hono() +/** + * The subset of a storefront catalog (`static/storefronts/sf{N}.json`) that `buyItem` + * reads: each store item carries the `GiftDrop` describing what you get and a list of + * `Prices` per currency. The catalogs hold more fields (SubscriberPrices, IsFeatured, + * …) that the purchase path doesn't need. + */ +interface StoreGiftDrop { + FriendlyName: string + Tooltip: string + ConsumableItemDesc: string + AvatarItemDesc: string + AvatarItemType: number | null + EquipmentPrefabName: string + EquipmentModificationGuid: string + Rarity: number + Context: number + Currency: number + CurrencyType: number +} +interface StorePrice { + CurrencyType: number + Price: number +} +interface StoreItem { + GiftDrop: StoreGiftDrop + Prices: StorePrice[] + PurchasableItemId: number +} +interface Storefront { + StoreItems: StoreItem[] +} + +/** The `Gift` block of a buyItem body — present when buying an item for another player. */ +interface GiftRequest { + ToPlayerId?: number + Anonymous?: boolean + Message?: string + GiftContext?: number +} + +/** + * Look up a store item by (storefront type, purchasable item id), reading the catalog + * from the ASSETS binding (`sf{type}.json`). Returns null when there is no such + * storefront or no item with that id in it. + */ +async function findStoreItem( + c: Context, + storefrontType: number, + purchasableItemId: number +): Promise { + const res = await c.env.ASSETS.fetch(new URL(`/sf${storefrontType}.json`, c.req.url)) + if (!res.ok) return null + const storefront = (await res.json()) as Storefront + return storefront.StoreItems.find((it) => it.PurchasableItemId === purchasableItemId) ?? null +} + +/** Build the owned avatar-item DTO granted into the buyer's inventory from a gift-drop. */ +function toAvatarItem(giftDrop: StoreGiftDrop): AvatarItem { + return { + AvatarItemType: giftDrop.AvatarItemType, + AvatarItemDesc: giftDrop.AvatarItemDesc, + PlatformMask: -1, + FriendlyName: giftDrop.FriendlyName, + Tooltip: giftDrop.Tooltip, + Rarity: giftDrop.Rarity, + } +} + +/** Quantity of a consumable granted per purchase — our storefront catalogs don't specify one. */ +const CONSUMABLE_GRANT_COUNT = 1 + +/** The "Coach" system account — the sender a self-buy or anonymous gift is attributed to. */ +const COACH_ACCOUNT_ID = 1 + +/** Build the stored gift-box content (the client's rendered "gift box") from a gift-drop. */ +function toGiftContent( + giftDrop: StoreGiftDrop, + message: string, + consumableCount: number +): GiftContent { + return { + ConsumableItemDesc: giftDrop.ConsumableItemDesc, + ConsumableCount: consumableCount, + AvatarItemDesc: giftDrop.AvatarItemDesc, + AvatarItemType: giftDrop.AvatarItemType, + CurrencyType: giftDrop.CurrencyType, + Currency: giftDrop.Currency, + Xp: 0, + PackageType: 0, + Message: message, + EquipmentPrefabName: giftDrop.EquipmentPrefabName, + EquipmentModificationGuid: giftDrop.EquipmentModificationGuid, + GiftRarity: giftDrop.Rarity, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: null, + } +} + +// strict: false so trailing-slash routes (e.g. `/gifts/consume/`, which the client +// posts with a trailing slash) match either form. Mirrors the `api` worker. +const app = new Hono({ strict: false }) .use( '*', // middleware @@ -73,13 +186,14 @@ const app = new Hono() // Default base avatar items — empty stub for now. No auth. .get('/api/avatar/v1/defaultbaseavataritems', (c) => c.json([])) - // The player's avatar items — owned items concatenated with the default - // catalog. No DB binding yet, so owned is empty and this is just the catalog. + // The player's avatar items — the items they've bought (from `buyItem`, stored in + // the inventory table) prepended to the default catalog. A player who has bought + // nothing gets just the catalog. .get('/api/avatar/v4/items', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - // TODO: prepend the player's owned AvatarItems once a DB binding exists. - return c.json(defaultAvatarItems) + const owned = await getInventory(c.env.DB, id) + return c.json([...owned, ...defaultAvatarItems]) }) // The player's owned custom avatar items. [Authorize]; paginated. Empty stub for @@ -163,12 +277,35 @@ const app = new Hono() return c.json(outfit) }) - // Pending avatar gifts for the player. [Authorize]; empty without a DB binding. + // Pending avatar gifts for the player — the unopened gift boxes from their purchases + // (and, once gifting lands, from other players). [Authorize]. The client opens each + // box and consumes it via the consume route below; the item itself was already + // granted at purchase, so an unopened box is cosmetic. .get('/api/avatar/v2/gifts', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - // TODO: query pending ReceivedGifts once a DB binding exists. - return c.json([]) + return c.json(await getPendingGifts(c.env.DB, id)) + }) + + // Open (consume) a gift box. [Authorize]. The client posts this on the econ host after + // the box animation, form-encoded as `Id=&UnlockedLevel=`. Opening just + // deletes the box — the item was granted into the inventory at purchase, so there's + // nothing to grant here — an avatar-item drop was granted into the inventory table and a + // consumable drop into the consumable table, both at purchase. (`UnlockedLevel`, a + // consumable-level hint, is unused.) + // + // Always answers 200 with the `{ error, success, value }` envelope — even with no token, + // a zero id, or a box that is already gone. A captured real consume returns this envelope, + // not an empty body: the client parses it to finish opening the box, so a bare 200 reads + // as a failure and the consumable never finishes unlocking. The delete is scoped to the + // caller's account, so an unauthenticated or mismatched call is simply a no-op. Mirrors + // the same route on the `api` worker (the client may call either host). + .post('/api/avatar/v2/gifts/consume', async (c) => { + const id = await authedId(c) + const body = await c.req.parseBody().catch(() => ({}) as Record) + const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0 + if (id !== null && giftId !== 0) await consumeGift(c.env.DB, id, giftId) + return c.json({ error: '', success: true, value: null }) }) // A player's avatar by account id, projected to the public render subset (used @@ -200,12 +337,13 @@ const app = new Hono() return c.body(null, 200) }) - // Unlocked consumables. [Authorize]; empty without a DB binding. + // Unlocked consumables. [Authorize]. The consumables the player has bought (from + // `buyItem`, stored in the `consumable` table), grouped by item into the client's + // unlocked-consumable DTO. A player who has bought none gets an empty list. .get('/api/consumables/v2/getUnlocked', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - // TODO: query ConsumableItems once a DB binding exists. - return c.json([]) + return c.json(await getConsumables(c.env.DB, id)) }) // Currency balance. [Authorize]. The trailing int is a CurrencyType — the client @@ -240,6 +378,148 @@ const app = new Hono() return c.json(await res.json()) }) + // Buy a storefront item. [Authorize]. The client posts the storefront/item ids, the + // currency and the price it sees; we look the item up in that storefront's catalog, + // confirm the price the client sent still matches, debit the buyer atomically, grant + // the item into the recipient's inventory, and hand back a gift box. + // + // The buyer is always the caller; a `Gift` block routes the item (and box) to another + // player, but the caller pays. Ownership is persisted at purchase — the gift box is + // only the cosmetic "open it" moment, so the grant does not wait for the box to be + // opened (see /api/avatar/v2/gifts/consume on the `api` worker, which just deletes it). + // + // `RequestedPrice` is the price the client rendered; rejecting a mismatch stops a stale + // client (or a tampered request) from buying at a price the catalog no longer offers. + .post('/api/storefronts/v2/buyItem', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = (await c.req.json().catch(() => null)) as Record | null + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return c.json({ error: 'Invalid request body' }, 400) + } + const storefrontType = body.StorefrontType + const purchasableItemId = body.PurchasableItemId + const currencyType = body.CurrencyType + const requestedPrice = body.RequestedPrice + if ( + !Number.isInteger(storefrontType) || + !Number.isInteger(purchasableItemId) || + !Number.isInteger(currencyType) || + !Number.isInteger(requestedPrice) + ) { + return c.json( + { + error: 'StorefrontType, PurchasableItemId, CurrencyType and RequestedPrice are required', + }, + 400 + ) + } + + const item = await findStoreItem(c, storefrontType as number, purchasableItemId as number) + if (item === null) return c.json({ error: 'Item not found' }, 404) + + const price = item.Prices.find((p) => p.CurrencyType === currencyType) + if (price === undefined) { + return c.json({ error: 'Currency type not available for this item' }, 400) + } + if (price.Price !== requestedPrice) { + return c.json({ error: 'Price has changed' }, 409) + } + // The item's currency must be an account balance we can debit (RecCenterTokens et al), + // not a room-scoped or non-spendable currency. + if (!isSpendable(currencyType as number)) { + return c.json({ error: 'Currency type is not spendable' }, 400) + } + + const gift = ( + typeof body.Gift === 'object' && body.Gift !== null ? body.Gift : null + ) as GiftRequest | null + const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id + // A named (non-anonymous) gift shows the sender; a self-purchase or an anonymous gift + // is attributed to the "Coach" system account (id 1), never a null/0 sender. + const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID + const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3' + + const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) + // Debit the buyer atomically; a false return means they couldn't afford it and + // nothing changed, so no item is granted. + const paid = await spendCurrency( + c.env.DB, + id, + currencyType as number, + price.Price, + startingTokens + ) + if (!paid) return c.json({ error: 'Insufficient balance' }, 400) + + // Grant the item to the recipient. A gift-drop carries an avatar item, a consumable, + // or neither (currency/xp drops aren't granted yet); grant whichever it actually has. + if (typeof item.GiftDrop.AvatarItemDesc === 'string' && item.GiftDrop.AvatarItemDesc !== '') { + await grantItem(c.env.DB, receiverId, toAvatarItem(item.GiftDrop)) + } + const isConsumable = + typeof item.GiftDrop.ConsumableItemDesc === 'string' && + item.GiftDrop.ConsumableItemDesc !== '' + const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0 + if (isConsumable) { + await grantConsumable( + c.env.DB, + receiverId, + item.GiftDrop.ConsumableItemDesc, + consumableCount + ) + } + const { id: giftId } = await createGift( + c.env.DB, + receiverId, + toGiftContent(item.GiftDrop, message, consumableCount) + ) + + // The response mirrors a captured real buyItem: `Balance` is the change applied (the + // negated price), not the resulting balance (the client reads its new total from + // `GET /balance/:type`); `BalanceType` is -2 (account-wide, all platforms). The Data + // entry is the gift-drop the client received — it carries no FriendlyName or + // consumable count (the count is a getUnlocked concept; each box is one instance). + return c.json({ + BalanceUpdates: [ + { + UpdateResponse: 0, + Data: [ + { + Id: giftId, + FromPlayerId: fromPlayerId, + ConsumableItemDesc: item.GiftDrop.ConsumableItemDesc, + AvatarItemDesc: item.GiftDrop.AvatarItemDesc, + AvatarItemType: item.GiftDrop.AvatarItemType ?? 0, + EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName, + EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid, + CurrencyType: item.GiftDrop.CurrencyType, + Currency: item.GiftDrop.Currency, + Xp: 0, + Level: 0, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: ALL_PLATFORMS, + GiftContext: Number.isInteger(gift?.GiftContext) + ? (gift?.GiftContext as number) + : item.GiftDrop.Context, + GiftRarity: item.GiftDrop.Rarity, + Message: message, + }, + ], + }, + ], + Balance: -price.Price, + CurrencyType: currencyType, + BalanceType: ALL_PLATFORMS, + }) + }) + + // Storefront ad-carousel items. Served from the bundled static JSON — one + // placeholder banner with no purchasable items until real promo data exists. + .get('/api/storefronts/v1/adcarouselitems', (c) => c.json(adCarouselItems)) + // Current weekly challenge. Served from the bundled static JSON until // per-rotation challenge data is wired up. .get('/api/challenge/v2/getCurrent', (c) => c.json(weeklyChallenge)) diff --git a/apps/econ/src/inventory-db.ts b/apps/econ/src/inventory-db.ts new file mode 100644 index 0000000..3ae594b --- /dev/null +++ b/apps/econ/src/inventory-db.ts @@ -0,0 +1,70 @@ +/** + * Owned avatar items on the shared `recflare` D1 database — the items a player has + * bought from a storefront. One row per (account, item): the item is granted at + * purchase time (`POST /api/storefronts/v2/buyItem`) and read back by + * `GET /api/avatar/v4/items`, which concatenates it with the default catalog. + * + * The item is keyed by its full `AvatarItemDesc` — the comma-delimited descriptor exactly + * as sent, trailing `,,,` and all — so re-buying the same item upserts rather than piling + * up duplicate rows. The descriptor is stored verbatim (not normalized): the client expects + * the commas back and fails without them. `data` is the rendered avatar-item DTO, stored + * opaquely and served back untouched; it matches the shape of the entries in + * default-avatar-items.json. + * + * This worker (`econ`) owns the table and its migration — see apps/econ/migrations/ + * 0004_inventory.sql. The gift box the purchase also creates lives in a separate table + * (@repo/domain's received_gift); ownership does not depend on the box being opened. + */ + +/** Schema DDL (mirror of migrations 0004_inventory.sql) — also builds the table in tests. */ +export const INVENTORY_SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS inventory ( + account_id INTEGER NOT NULL, + avatar_item_desc TEXT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (account_id, avatar_item_desc) + )`, +] + +/** + * A rendered avatar item, as `/api/avatar/v4/items` serves it (same shape as the + * entries in default-avatar-items.json). `AvatarItemDesc` is the item's guid string + * and the row's key. + */ +export interface AvatarItem extends Record { + AvatarItemType: number | null + AvatarItemDesc: string + PlatformMask: number + FriendlyName: string + Tooltip: string + Rarity: number +} + +/** + * Grant an item into a player's inventory. Upserts on (account_id, avatar_item_desc): + * owning an item is boolean, so re-buying it refreshes the stored DTO rather than + * adding a second copy. The descriptor is stored verbatim, commas included — the client + * expects the full comma-delimited form back. + */ +export async function grantItem( + db: D1Database, + accountId: number, + item: AvatarItem +): Promise { + await db + .prepare( + `INSERT INTO inventory (account_id, avatar_item_desc, data) VALUES (?1, ?2, ?3) + ON CONFLICT (account_id, avatar_item_desc) DO UPDATE SET data = ?3` + ) + .bind(accountId, item.AvatarItemDesc, JSON.stringify(item)) + .run() +} + +/** Every avatar item a player owns, ordered by item guid for a stable listing. */ +export async function getInventory(db: D1Database, accountId: number): Promise { + const { results } = await db + .prepare('SELECT data FROM inventory WHERE account_id = ?1 ORDER BY avatar_item_desc') + .bind(accountId) + .all<{ data: string }>() + return results.map((r) => JSON.parse(r.data) as AvatarItem) +} diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 8a756fc..3abff31 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -4,6 +4,8 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../econ.app' +import { RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain' + import { SCHEMA_DDL } from '../../avatar-db' import { BALANCE_SCHEMA_DDL, @@ -12,6 +14,8 @@ import { getBalance, spendCurrency, } from '../../balance-db' +import { CONSUMABLE_SCHEMA_DDL } from '../../consumables-db' +import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' import { OUTFIT_SCHEMA_DDL } from '../../outfit-db' import type { Env } from '../../context' @@ -30,6 +34,9 @@ beforeAll(async () => { for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run() await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') .bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' })) .run() @@ -497,6 +504,265 @@ describe('econ endpoints', () => { expect(await res.json()).toBeTruthy() }) + // Item 73 in sf3.json — "Class of 2016", 4500 RecCenterTokens (CurrencyType 2). + test('POST /api/storefronts/v2/buyItem 401s without a token', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 3, + PurchasableItemId: 73, + CurrencyType: 2, + RequestedPrice: 4500, + }), + }) + expect(res.status).toBe(401) + }) + + test('POST /api/storefronts/v2/buyItem debits, grants the item, and hands back a gift box', async () => { + // Account 20: fresh, so its first balance touch grants the 10000 default. + const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { ...(await bearer('20')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 3, + PurchasableItemId: 73, + CurrencyType: 2, + RequestedPrice: 4500, + }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { + Balance: number + CurrencyType: number + BalanceType: number + BalanceUpdates: Array<{ + Data: Array<{ Id: number; AvatarItemDesc: string }> + }> + } + // `Balance` is the change applied (the negated price), not the resulting total. + expect(body.Balance).toBe(-4500) + expect(body.CurrencyType).toBe(2) + expect(body.BalanceType).toBe(-2) + const gift = body.BalanceUpdates[0].Data[0] + expect(gift.AvatarItemDesc).not.toBe('') + expect(gift.Id).toBeGreaterThan(0) + + // The balance endpoint reflects the debit (this is the resulting total, 10000 - 4500). + const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, { + headers: await bearer('20'), + }) + expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 5500 }]) + + // The item is now owned — it leads the v4/items list (owned items prepend the catalog). + const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { + headers: await bearer('20'), + }) + const list = (await items.json()) as Array<{ AvatarItemDesc: string; FriendlyName: string }> + expect(list[0].FriendlyName).toBe('Class of 2016') + expect(list[0].AvatarItemDesc).toBe(gift.AvatarItemDesc) + + // And a pending gift box is waiting to be opened. + const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { + headers: await bearer('20'), + }) + const pending = (await gifts.json()) as Array<{ Id: number; AvatarItemDesc: string }> + expect(pending).toHaveLength(1) + expect(pending[0].Id).toBe(gift.Id) + expect(pending[0].AvatarItemDesc).toBe(gift.AvatarItemDesc) + }) + + test('POST /api/storefronts/v2/buyItem grants a consumable and stacks on re-buy', async () => { + // Item 2266 (Supreme Pizza) in storefront 300 is a consumable — its gift-drop + // carries a ConsumableItemDesc, not an AvatarItemDesc. + const consumableDesc = 'wUCIKdJSvEmiQHYMyx4X4w' + const buy = async () => + exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { ...(await bearer('25')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 300, + PurchasableItemId: 2266, + CurrencyType: 2, + RequestedPrice: 95, + }), + }) + + const res = await buy() + expect(res.status).toBe(200) + const body = (await res.json()) as { + Balance: number + BalanceUpdates: Array<{ + Data: Array<{ + ConsumableItemDesc: string + AvatarItemDesc: string + AvatarItemType: number + FromPlayerId: number + }> + }> + } + // `Balance` is the change applied (the negated price), not the resulting total. + expect(body.Balance).toBe(-95) + const drop = body.BalanceUpdates[0].Data[0] + expect(drop.ConsumableItemDesc).toBe(consumableDesc) + expect(drop.AvatarItemDesc).toBe('') + // A consumable's AvatarItemType is null in the catalog; the response coalesces it to 0. + expect(drop.AvatarItemType).toBe(0) + // A self-buy is attributed to the "Coach" system account (id 1). + expect(drop.FromPlayerId).toBe(1) + + // It's owned as an unlocked consumable — one instance, count 1. + const unlocked = async () => { + const r = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, { + headers: await bearer('25'), + }) + expect(r.status).toBe(200) + return (await r.json()) as Array<{ + Ids: number[] + CreatedAts: string[] + ConsumableItemDesc: string + Count: number + InitialCount: number + IsActive: boolean + IsTransferable: boolean + }> + } + const first = await unlocked() + expect(first).toHaveLength(1) + expect(first[0].ConsumableItemDesc).toBe(consumableDesc) + expect(first[0].Count).toBe(1) + expect(first[0].InitialCount).toBe(1) + expect(first[0].Ids).toHaveLength(1) + expect(first[0].CreatedAts).toHaveLength(1) + expect(first[0].IsActive).toBe(false) + expect(first[0].IsTransferable).toBe(false) + + // A consumable is not an avatar item — it does not show up in v4/items. + const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { + headers: await bearer('25'), + }) + const list = (await items.json()) as Array<{ FriendlyName: string }> + expect(list.every((i) => i.FriendlyName !== 'Supreme Pizza')).toBe(true) + + // Buying it again stacks: a second instance, count summed to 2. + expect((await buy()).status).toBe(200) + const second = await unlocked() + expect(second).toHaveLength(1) + expect(second[0].Count).toBe(2) + expect(second[0].InitialCount).toBe(2) + expect(second[0].Ids).toHaveLength(2) + expect(second[0].CreatedAts).toHaveLength(2) + }) + + test('POST /api/storefronts/v2/buyItem 409s when the sent price no longer matches', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { ...(await bearer('21')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 3, + PurchasableItemId: 73, + CurrencyType: 2, + RequestedPrice: 1, + }), + }) + expect(res.status).toBe(409) + // Nothing was charged. + const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, { + headers: await bearer('21'), + }) + expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 }]) + }) + + test('POST /api/storefronts/v2/buyItem 404s for an unknown item', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { ...(await bearer('22')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 3, + PurchasableItemId: 9999999, + CurrencyType: 2, + RequestedPrice: 4500, + }), + }) + expect(res.status).toBe(404) + }) + + test('POST /api/storefronts/v2/buyItem 400s when the player cannot afford it', async () => { + // Drain account 23 to 0 first, then try to buy. + expect( + await spendCurrency(env.DB, 23, CurrencyType.RecCenterTokens, 10_000, DEFAULT_STARTING_TOKENS) + ).toBe(true) + const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { ...(await bearer('23')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 3, + PurchasableItemId: 73, + CurrencyType: 2, + RequestedPrice: 4500, + }), + }) + expect(res.status).toBe(400) + // Still owns nothing (only the default catalog in v4/items). + const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { + headers: await bearer('23'), + }) + const list = (await items.json()) as Array<{ FriendlyName: string }> + expect(list.every((i) => i.FriendlyName !== 'Class of 2016')).toBe(true) + }) + + test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => { + // Buy an item for account 24, then consume the box the way the client does: on the + // econ host, with a form body (`Id=..&UnlockedLevel=..`). + const buy = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { ...(await bearer('24')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 3, + PurchasableItemId: 73, + CurrencyType: 2, + RequestedPrice: 4500, + }), + }) + const bought = (await buy.json()) as { + BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> + } + const giftId = bought.BalanceUpdates[0].Data[0].Id + + const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, { + method: 'POST', + headers: { + ...(await bearer('24')), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ Id: String(giftId), UnlockedLevel: '0' }), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ error: '', success: true, value: null }) + + // The box is gone; the item stays owned (it was granted at purchase, not on open). + const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { + headers: await bearer('24'), + }) + expect(await gifts.json()).toEqual([]) + const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { + headers: await bearer('24'), + }) + const list = (await items.json()) as Array<{ FriendlyName: string }> + expect(list.some((i) => i.FriendlyName === 'Class of 2016')).toBe(true) + + // Opening it again is a harmless no-op — still 200. + const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, { + method: 'POST', + headers: { + ...(await bearer('24')), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ Id: String(giftId) }), + }) + expect(again.status).toBe(200) + }) + test('GET /api/challenge/v2/getCurrent returns the weekly challenge', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`) expect(res.status).toBe(200) @@ -505,6 +771,14 @@ describe('econ endpoints', () => { expect(Array.isArray(body.Challenges)).toBe(true) }) + test('GET /api/storefronts/v1/adcarouselitems returns the carousel items', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v1/adcarouselitems`) + expect(res.status).toBe(200) + const body = (await res.json()) as Array<{ AdCarouselItemId: number }> + expect(Array.isArray(body)).toBe(true) + expect(body[0]).toHaveProperty('AdCarouselItemId') + }) + test('GET /api/gamerewards/v1/pending returns []', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/pending`) expect(res.status).toBe(200) diff --git a/apps/econ/static/ad-carousel-items.json b/apps/econ/static/ad-carousel-items.json new file mode 100644 index 0000000..df1839b --- /dev/null +++ b/apps/econ/static/ad-carousel-items.json @@ -0,0 +1,9 @@ +[ + { + "AdCarouselItemId": 1, + "Description": "No description.", + "ImageName": "AdCarouselItem.png", + "PurchasableItemIds": [], + "Title": "Lorem ipsum" + } +] diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 21af501..9f1c314 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -2,8 +2,10 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' import { + canManageRoom, createRoomInstance, deleteExpiredPresence, + deletePresence, getAccount, getExpiredPresenceInstanceIds, getJoinableInstance, @@ -178,6 +180,15 @@ const DORM_PHOTON_ROOM_ID = '00000000-0000-4000-8000-000000000001' /** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */ const NO_SUCH_ROOM = 20 +/** + * The sentinel room-instance id the `auth` worker seeds a brand-new player's + * Orientation presence with (see auth's `placeNewPlayerInOrientation`). The client + * fires a spurious `player/logout` right after that seed, so logout must NOT clear + * presence while it still points at Orientation — doing so wipes the seed and + * bounces the new player to the dorm. + */ +const ORIENTATION_INSTANCE_ID = -2 + /** * The canonical dorm room instance (room 1, instance 1.1). Returned identically * by every dorm entry point and the presence heartbeat so the client's local @@ -301,10 +312,22 @@ async function resolveRoomInstance( if (!room) return null const f = instanceFieldsFromRoom(room, subRoomId) + // Never place the player back into the instance they're already in: the client + // keys the room transition off a changing `roomInstanceId`, so re-matchmaking into + // your current instance (e.g. the only public instance of a room you're already in) + // returns the same id and hangs the client mid-join. Exclude it from the join + // search, which pushes them to another live instance if one exists or forces a + // fresh one below. (Only the public path reuses instances, so only it needs the + // read; a private matchmake always gets a fresh instance.) + const currentInstanceId = isPrivate + ? undefined + : (await getPresence(c.env.DB, ownerId))?.roomInstance?.roomInstanceId // Reuse an existing joinable public instance *of the same subroom* — subrooms are // separate places, so joining one must never land you in another. Private // matchmakes always get a fresh instance. Create one when there's nothing to join. - let instance = isPrivate ? null : await getJoinableInstance(c.env.DB, f.roomId, f.subRoomId) + let instance = isPrivate + ? null + : await getJoinableInstance(c.env.DB, f.roomId, f.subRoomId, currentInstanceId) if (!instance) { instance = await createRoomInstance(c.env.DB, { ownerAccountId: ownerId, @@ -372,15 +395,36 @@ const app = new Hono() .notFound(withNotFound()) // ---- Player presence ----------------------------------------------------- - // login/exclusivelogin/logout are all no-op acks and MUST NOT touch presence. - // The client fires a spurious `player/logout` during the account-creation - // bootstrap (right after create_account seeds the new player into Orientation); - // deleting presence here wiped that seed and bounced the player to the dorm. - // Presence is overwritten by matchmake/goto and expires on its own TTL, so we - // don't need to clear it on these lifecycle calls. + // login/exclusivelogin are no-op acks and MUST NOT touch presence: the client + // fires exclusivelogin when going online, and clearing presence there would bounce + // the player to the dorm. Presence is overwritten by matchmake/goto and expires on + // its own TTL. .post('/player/login', (c) => c.body(null, 200)) .post('/player/exclusivelogin', (c) => c.json({ errorCode: 0 })) - .post('/player/logout', (c) => c.body(null, 200)) + + // Logout clears the player's presence so they read offline immediately and the + // instance they were in frees up (rather than waiting out the presence TTL). + // + // EXCEPTION: the account-creation bootstrap. The client fires a spurious + // `player/logout` right after a new player is seeded into Orientation (the auth + // worker writes that presence with instance id -2). Clearing presence there wipes + // the seed and bounces the new player to the dorm — so a logout that still points + // at Orientation is left as a no-op ack. An unauthenticated logout is also a no-op + // (no player to clear). + .post('/player/logout', async (c) => { + const id = await authedId(c) + if (id !== null) { + const presence = await getPresence(c.env.DB, id) + const instanceId = presence?.roomInstance?.roomInstanceId + if (presence && instanceId !== ORIENTATION_INSTANCE_ID) { + await deletePresence(c.env.DB, id) + // The instance they were in lost a player — recompute its fullness so a + // full room frees up. No-op for the synthetic dorm/orientation instances. + if (instanceId != null) await refreshInstanceFullness(c.env.DB, instanceId) + } + } + return c.body(null, 200) + }) .get('/player', async (c) => { // Returns each requested player's presence. Reads the `id` query param(s); @@ -595,9 +639,9 @@ const app = new Hono() }) // The room's live instances — the owner's view of active sessions of their room. - // Auth-gated (401) and owner-only (403): the caller must be the room's creator. - // Unknown room → 404. Returns the bare RoomInstance DTO array (empty when the - // room has no live instances). + // Auth-gated (401) and owner/co-owner-only (403): the caller must be the room's + // creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns the + // bare RoomInstance DTO array (empty when the room has no live instances). .get('/room/:roomId{[0-9]+}/instances', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) @@ -605,7 +649,9 @@ const app = new Hono() const roomId = Number.parseInt(c.req.param('roomId'), 10) const room = await getRoomById(c.env.DB, roomId) if (!room) return c.body(null, 404) - if (room.CreatorAccountId !== id) return c.body(null, 403) + // The room's creator *or* a co-owner (Role 30) may see its live instances — + // same owner-or-co-owner gate the rooms worker uses for room-admin actions. + if (!canManageRoom(room, id)) return c.body(null, 403) return c.json(await getRoomInstancesByRoom(c.env.DB, roomId)) }) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 4e26f30..3f04d47 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -50,6 +50,8 @@ const TEST_ROOMS = [ IsDorm: false, Accessibility: 1, CreatorAccountId: 42, + // Account 43 is a co-owner (Role 30) — it may view the room's instances too. + Roles: [{ AccountId: 43, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }], SubRooms: [{ SubRoomId: 3, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }], }, { @@ -435,31 +437,43 @@ describe('auth-gated endpoints', () => { expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/) }) - test('POST /matchmake/:room reuses a public instance; a private one is fresh', async () => { - const matchmake = async (joinMode?: string) => + test('POST /matchmake/:room reuses a public instance across players; a private one is fresh', async () => { + const matchmake = async (sub: string, joinMode?: string) => (await ( await exports.default.fetch(`${ORIGIN}/matchmake/2`, { method: 'POST', headers: { - ...(await bearer('900')), + ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded', }, body: joinMode ? new URLSearchParams({ JoinMode: joinMode }).toString() : undefined, }) ).json()) as { roomInstance: { photonRoomId: string; roomInstanceId: number } } - // Two public matchmakes into the same room share the (reused) instance. - const a = await matchmake() - const b = await matchmake() + // Two *different* players matchmaking into the same room share the reused + // instance (population grouping). Distinct accounts here, since re-matchmaking as + // the *same* player deliberately moves them to a fresh instance — see below. + const a = await matchmake('900') + const b = await matchmake('901') expect(a.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/) expect(b.roomInstance.photonRoomId).toBe(a.roomInstance.photonRoomId) expect(b.roomInstance.roomInstanceId).toBe(a.roomInstance.roomInstanceId) // A private matchmake (JoinMode 2) gets its own distinct instance. - const priv = await matchmake('2') + const priv = await matchmake('902', '2') expect(priv.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId) }) + test('re-matchmaking into your current room returns a different instance (id must change)', async () => { + // The client keys the room transition off a changing roomInstanceId; handing back + // the instance the player is already in hangs their join. RecCenter (cap 12) so + // the instance isn't full — the naive "reuse the oldest joinable" would otherwise + // return the same id the player already has. + const first = await matchmakeInto('2', '950') + const second = await matchmakeInto('2', '950') + expect(second).not.toBe(first) + }) + test('POST /matchmake/:room 401s without a token', async () => { const res = await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST' }) expect(res.status).toBe(401) @@ -722,13 +736,11 @@ describe('auth-gated endpoints', () => { expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false) }) - test('player/login, exclusivelogin and logout all preserve presence', async () => { + test('player/login and exclusivelogin preserve presence', async () => { const headers = await bearer('9') await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers }) - // None of these lifecycle calls may wipe presence — the client fires a - // spurious logout during the account-creation bootstrap, and exclusivelogin - // when going online. Clearing here would bounce the player to the dorm. - await exports.default.fetch(`${ORIGIN}/player/logout`, { method: 'POST', headers }) + // These acks must not wipe presence — the client fires exclusivelogin when going + // online, and clearing here would bounce the player to the dorm. await exports.default.fetch(`${ORIGIN}/player/exclusivelogin`, { method: 'POST', headers }) await exports.default.fetch(`${ORIGIN}/player/login`, { method: 'POST', headers }) const hb = (await ( @@ -740,6 +752,59 @@ describe('auth-gated endpoints', () => { expect(hb.roomInstance?.name).toBe("@Player9's Dorm") }) + test('player/logout clears presence and frees the instance the player was in', async () => { + // Fill SoloRoom (cap 1) so its instance is full, then log out. + const solo = await matchmakeInto('5', '960') + expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(true) + + const headers = await bearer('960') + await exports.default.fetch(`${ORIGIN}/player/logout`, { method: 'POST', headers }) + + // Presence is gone → the heartbeat reports offline with no room. + const hb = (await ( + await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers }) + ).json()) as { roomInstance: unknown; isOnline: boolean } + expect(hb.isOnline).toBe(false) + expect(hb.roomInstance).toBeNull() + expect(await countPresenceRows(960)).toBe(0) + // The instance they left is no longer full. + expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false) + }) + + test('player/logout preserves a new player still in Orientation (account-creation bootstrap)', async () => { + // Mirror the auth worker's Orientation seed: presence pointing at instance -2. + // The client's spurious bootstrap logout must NOT wipe it, or the new player is + // bounced out of Orientation to the dorm. + await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId: 961, + roomInstance: { roomInstanceId: -2, roomId: 13, name: '^Orientation' }, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 1, + platform: 0, + appVersion: '20230302', + expiresAt: nowSeconds() + 800, + }) + ) + .run() + + await exports.default.fetch(`${ORIGIN}/player/logout`, { + method: 'POST', + headers: await bearer('961'), + }) + + const hb = (await ( + await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { + method: 'POST', + headers: await bearer('961'), + }) + ).json()) as { roomInstance: { roomInstanceId: number } | null; isOnline: boolean } + expect(hb.isOnline).toBe(true) + expect(hb.roomInstance?.roomInstanceId).toBe(-2) + }) + test('GET /player?id reports stored presence per id', async () => { await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', @@ -751,11 +816,12 @@ describe('auth-gated endpoints', () => { expect(players[0]).toMatchObject({ playerId: 55, isOnline: true }) }) - test('GET /room/:id/instances is auth-gated, owner-only, and lists the room’s instances', async () => { + test('GET /room/:id/instances is auth-gated, owner/co-owner-only, and lists the room’s instances', async () => { // No token → 401. expect((await exports.default.fetch(`${ORIGIN}/room/3/instances`)).status).toBe(401) - // Not the owner (room 3 is owned by account 42) → 403. + // A valid token but no role on the room (room 3 is owned by account 42, with + // account 43 as co-owner) → 403. expect( ( await exports.default.fetch(`${ORIGIN}/room/3/instances`, { @@ -785,5 +851,12 @@ describe('auth-gated endpoints', () => { const instances = (await res.json()) as Array<{ roomId: number; roomInstanceId: number }> expect(instances.length).toBeGreaterThanOrEqual(1) expect(instances.every((i) => i.roomId === 3)).toBe(true) + + // The co-owner (account 43, Role 30) may view the instances too. + const coOwner = await exports.default.fetch(`${ORIGIN}/room/3/instances`, { + headers: await bearer('43'), + }) + expect(coOwner.status).toBe(200) + expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length) }) }) diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index dfd7fb0..5160523 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' import { + canManageRoom, cloneRoom, cloneSubRoom, findSubRoom, @@ -27,6 +28,7 @@ import { setRoomDescription, setRoomImage, setRoomName, + setRoomRole, toggleCheer, toggleFavorite, toggleRoomTag, @@ -134,25 +136,6 @@ function unauthorized(c: Context) { return c.json({ error: 'Unauthorized' }, 401) } -/** - * Room role values the reference treats as edit-capable: Creator (255) and - * CoOwner. (CoOwner's numeric value is a best guess from the seed data — base - * rooms give the co-owner account Role 30.) - */ -const EDIT_ROLES = new Set([255, 30]) - -/** - * Whether an account may edit a room's data — its creator, or a holder of a - * Creator/CoOwner role. Mirrors the reference's SetRoomData permission check. - */ -function canEditRoomData(room: Record, accountId: number): boolean { - if (room.CreatorAccountId === accountId) return true - const roles = Array.isArray(room.Roles) ? (room.Roles as Array>) : [] - return roles.some( - (r) => r.AccountId === accountId && typeof r.Role === 'number' && EDIT_ROLES.has(r.Role) - ) -} - /** The notifications hub is a single global DO instance (see the `notify` worker). */ const HUB_INSTANCE = 'global' @@ -584,6 +567,51 @@ const app = new Hono() return roomResult(c, { Success: true }) }) + // Set a member's role in a room (`Roles[].Role`). Auth-gated (401) and gated to + // the room creator or a co-owner — the same owner/co-owner check the other + // room-admin actions use. Body is the `role` form field (an integer role tier). + // Updates the target account's existing role entry or adds one, notifies the + // affected member so their client refreshes permissions, and returns the + // `{ Success, Value, ErrorId, Error }` envelope at HTTP 200. + .put('/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const targetAccountId = Number.parseInt(c.req.param('accountId'), 10) + const room = await getRoomById(c.env.DB, roomId) + if (!room) { + return roomResult(c, { + Success: false, + ErrorId: 'Rooms.DoesntExist', + Error: 'This room does not exist!', + }) + } + if (!canManageRoom(room, accountId)) { + return roomResult(c, { + Success: false, + ErrorId: 'Rooms.PermissionDenied', + Error: 'You are not the owner of this room!', + }) + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const role = typeof body.role === 'string' ? Number.parseInt(body.role, 10) : Number.NaN + if (Number.isNaN(role)) { + return roomResult(c, { + Success: false, + ErrorId: 'Rooms.InvalidRole', + Error: 'You must provide a valid role!', + }) + } + + const updated = await setRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room) + // Notify the member whose role changed so their client refreshes the room + // (and the permissions it grants them). + await pushRoomUpdate(c, targetAccountId, updated) + return roomResult(c, { Success: true }) + }) + // A subroom's data descriptor (the SubRoom object from the room's SubRooms // array). Public — the client fetches it while loading the room. 404 when the // room or subroom is unknown. @@ -615,7 +643,7 @@ const app = new Hono() Error: 'This room does not exist!', }) } - if (!canEditRoomData(room, accountId)) { + if (!canManageRoom(room, accountId)) { return roomResult(c, { Success: false, ErrorId: 'Rooms.PermissionDenied', diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index d98f3aa..9267c27 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -572,6 +572,50 @@ describe('rooms endpoints', () => { expect(room.ImageName).toBe(imageName) }) + it('PUT /rooms/:id/roles/:accountId is auth-gated, owner/co-owner-only, and persists', async () => { + const rolesOf = async (): Promise> => { + const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { + Roles?: Array<{ AccountId: number; Role: number }> + } + return room.Roles ?? [] + } + + // No token → 401 (auth gate). + expect((await putForm('/rooms/2/roles/5', { role: '20' })).status).toBe(401) + // A valid token but no role on the room (RecCenter is owned by account 1, with + // account 2 as co-owner) → Success:false. + expect(await bodyOf(await putForm('/rooms/2/roles/5', { role: '20' }, '999'))).toMatchObject({ + Success: false, + ErrorId: 'Rooms.PermissionDenied', + }) + // Unknown room → Rooms.DoesntExist envelope. + expect(await bodyOf(await putForm('/rooms/99999/roles/5', { role: '20' }, '1'))).toMatchObject({ + Success: false, + ErrorId: 'Rooms.DoesntExist', + }) + // Non-numeric role → Success:false. + expect(await bodyOf(await putForm('/rooms/2/roles/5', { role: 'nope' }, '1'))).toMatchObject({ + Success: false, + ErrorId: 'Rooms.InvalidRole', + }) + + // Owner sets account 5's role to 20, adding a new Roles entry that persists. + const ok = await putForm('/rooms/2/roles/5', { role: '20' }, '1') + expect(ok.status).toBe(200) + expect(await bodyOf(ok)).toMatchObject({ Success: true }) + expect(await rolesOf()).toContainEqual(expect.objectContaining({ AccountId: 5, Role: 20 })) + + // The co-owner (account 2, Role 30) may also change it — updating the existing + // entry in place rather than adding a duplicate. + const byCoOwner = await putForm('/rooms/2/roles/5', { role: '10' }, '2') + expect(byCoOwner.status).toBe(200) + const roles = await rolesOf() + expect(roles.filter((r) => r.AccountId === 5)).toHaveLength(1) + expect(roles).toContainEqual(expect.objectContaining({ AccountId: 5, Role: 10 })) + // The seeded co-owner (account 2) is left intact. + expect(roles).toContainEqual(expect.objectContaining({ AccountId: 2, Role: 30 })) + }) + it('GET /rooms/:id/subrooms/:sid/data returns the subroom descriptor (404 when unknown)', async () => { // Room 2 has SubRoomId 2 in the seed. const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`) diff --git a/packages/domain/src/enums.ts b/packages/domain/src/enums.ts index 679fc4b..14f64c3 100644 --- a/packages/domain/src/enums.ts +++ b/packages/domain/src/enums.ts @@ -23,10 +23,15 @@ export enum Accessibility { } /** - * A room-role tier (the `Role` byte on a room's `Roles` entries). Named tiers we - * reference by value today — the owner (max byte) and co-owner. + * A room-role tier (the `Role` byte on a room's `Roles` entries), matching the + * client's values. Host and Moderator are limited-permission helper tiers; CoOwner + * and Creator are the owner-level tiers that may manage the room (see + * {@link canManageRoom}). Creator is the room's owner (its `CreatorAccountId`) — + * the max byte. */ export enum Role { + Host = 10, + Moderator = 20, CoOwner = 30, - Owner = 255, + Creator = 255, } diff --git a/packages/domain/src/gifts-db.ts b/packages/domain/src/gifts-db.ts new file mode 100644 index 0000000..2e8c9b0 --- /dev/null +++ b/packages/domain/src/gifts-db.ts @@ -0,0 +1,114 @@ +/** + * Received gifts — the "gift boxes" a player is handed on the shared `recflare` D1. + * A box is created when a player buys a storefront item (for themselves or as a + * gift) and lingers until the client opens it. Opening is purely cosmetic: the item + * itself is granted into the player's inventory at purchase time (see the `econ` + * worker's inventory-db.ts), so consuming a box just deletes the row — there is + * nothing left to grant. + * + * The `econ` worker owns the schema/migration (apps/econ/migrations/ + * 0003_received_gift.sql) and is the only writer: `POST /api/storefronts/v2/buyItem` + * inserts a box and `GET /api/avatar/v2/gifts` lists a player's pending boxes. The + * `api` worker only deletes, from `POST /api/avatar/v2/gifts/consume`. Both import + * these helpers so the table name and row shape live in one place. + * + * One row per gift box. `data` is the box's rendered content as an opaque JSON blob + * (the currency/avatar-item fields the client draws); `id` and `created_at` are + * columns so a box can be listed and deleted by id without parsing the blob. + */ + +/** Schema DDL (mirror of apps/econ/migrations/0003_received_gift.sql). */ +export const RECEIVED_GIFT_SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS received_gift ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + data TEXT NOT NULL, + created_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_received_gift_account ON received_gift (account_id)`, +] + +/** + * The rendered content of a gift box, as the client draws it. Written verbatim by + * `buyItem` from the storefront item's `GiftDrop`; never queried on. `Id` and + * `CreatedAt` are NOT part of this — they come from the row (see {@link StoredGift}). + */ +export interface GiftContent extends Record { + ConsumableItemDesc: string + ConsumableCount: number + AvatarItemDesc: string + AvatarItemType: number | null + CurrencyType: number + Currency: number + Xp: number + PackageType: number + Message: string + EquipmentPrefabName: string + EquipmentModificationGuid: string + GiftRarity: number + Platform: number + PlatformsToSpawnOn: number + BalanceType: number | null +} + +/** A stored gift box: its content plus the row's identity (`Id`, `CreatedAt`). */ +export interface StoredGift extends GiftContent { + Id: number + CreatedAt: string +} + +interface GiftRow { + id: number + data: string + created_at: string +} + +/** + * Create a gift box for `accountId`, returning its assigned id and creation time so + * the caller can echo the box back in the purchase response. + */ +export async function createGift( + db: D1Database, + accountId: number, + content: GiftContent +): Promise<{ id: number; createdAt: string }> { + const createdAt = new Date().toISOString() + const row = await db + .prepare( + 'INSERT INTO received_gift (account_id, data, created_at) VALUES (?1, ?2, ?3) RETURNING id' + ) + .bind(accountId, JSON.stringify(content), createdAt) + .first<{ id: number }>() + // RETURNING always yields a row on a successful insert; the guard is for the types. + return { id: row?.id ?? 0, createdAt } +} + +/** A player's pending gift boxes, oldest first, with `Id`/`CreatedAt` merged in. */ +export async function getPendingGifts(db: D1Database, accountId: number): Promise { + const { results } = await db + .prepare('SELECT id, data, created_at FROM received_gift WHERE account_id = ?1 ORDER BY id') + .bind(accountId) + .all() + return results.map((r) => ({ + ...(JSON.parse(r.data) as GiftContent), + Id: r.id, + CreatedAt: r.created_at, + })) +} + +/** + * Delete (consume) a player's gift box by id. Returns false — changing nothing — + * when the box doesn't exist or isn't theirs. The item was already granted at + * purchase, so this only dismisses the box. + */ +export async function consumeGift( + db: D1Database, + accountId: number, + giftId: number +): Promise { + const { meta } = await db + .prepare('DELETE FROM received_gift WHERE id = ?1 AND account_id = ?2') + .bind(giftId, accountId) + .run() + return meta.changes > 0 +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 5c27478..fbfcc81 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -3,3 +3,4 @@ export * from './accounts-db' export * from './rooms-db' export * from './room-instance-db' export * from './presence-db' +export * from './gifts-db' diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index b8cde48..3c652bb 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -167,3 +167,14 @@ export async function deleteExpiredPresence(db: D1Database, now = nowSeconds()): const res = await db.prepare('DELETE FROM presence WHERE expires_at <= ?1').bind(now).run() return res.meta.changes ?? 0 } + +/** + * Delete a single player's presence row — the player goes offline immediately + * (rather than waiting out the TTL). Used on logout. Returns rows removed (0 when + * they had no live presence). The caller is responsible for recomputing the + * fullness of the instance they were in. + */ +export async function deletePresence(db: D1Database, accountId: number): Promise { + const res = await db.prepare('DELETE FROM presence WHERE account_id = ?1').bind(accountId).run() + return res.meta.changes ?? 0 +} diff --git a/packages/domain/src/room-instance-db.ts b/packages/domain/src/room-instance-db.ts index 79a9f31..d9a4d05 100644 --- a/packages/domain/src/room-instance-db.ts +++ b/packages/domain/src/room-instance-db.ts @@ -245,21 +245,38 @@ export async function refreshInstanceFullness( * A room's subrooms are separate places, so `subRoomId` scopes the search: joining * subroom 35 must never drop you into a live instance of subroom 1. Omitting it * matches any subroom. + * + * `excludeInstanceId` drops one instance from the search — the one the player is + * already in. Matchmaking must land them in a *different* instance (the client keys + * the room transition off a changing `roomInstanceId`), so re-matchmaking into the + * only instance of a room they're already in must skip it and fall through to a + * fresh instance rather than hand back the same id. A no-op when they're not in this + * room; instance ids are globally unique. */ export async function getJoinableInstance( db: D1Database, roomId: number, - subRoomId?: number + subRoomId?: number, + excludeInstanceId?: number ): Promise { - const bySubRoom = subRoomId === undefined ? '' : 'AND sub_room_id = ?2' + const binds: number[] = [roomId] + const filters: string[] = [] + if (subRoomId !== undefined) { + binds.push(subRoomId) + filters.push(`AND sub_room_id = ?${binds.length}`) + } + if (excludeInstanceId !== undefined) { + binds.push(excludeInstanceId) + filters.push(`AND id != ?${binds.length}`) + } const row = await db .prepare( `SELECT data FROM room_instance WHERE room_id = ?1 AND is_private = 0 AND is_full = 0 AND join_disabled = 0 - AND is_in_progress = 0 ${bySubRoom} + AND is_in_progress = 0 ${filters.join(' ')} ORDER BY id LIMIT 1` ) - .bind(...(subRoomId === undefined ? [roomId] : [roomId, subRoomId])) + .bind(...binds) .first<{ data: string }>() return row ? toDto(parse(row.data)) : null } diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index cd131ac..ff7d176 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -55,6 +55,26 @@ interface RoomRole { InvitedRole: number } +/** + * Room roles that confer owner-level management of a room: Creator (255) and + * CoOwner (30). The reference gates its room-admin actions on this set. (Host and + * Moderator are lower tiers and are deliberately excluded.) + */ +const MANAGE_ROLES: ReadonlySet = new Set([Role.Creator, Role.CoOwner]) + +/** + * Whether an account may manage a room — its creator, or the holder of a + * Creator/CoOwner role on the room's `Roles`. This is the owner-or-co-owner gate + * the reference applies to room-admin actions (editing room data, viewing a room's + * live instances). Shared so the `rooms` and `match` workers apply the same check + * rather than each re-deriving the role set. + */ +export function canManageRoom(room: Room, accountId: number): boolean { + if (room.CreatorAccountId === accountId) return true + const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : [] + return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role)) +} + /** * Clone an existing room into a new one owned by `accountId`. Copies the source * room's content (scene/subrooms/settings), assigning a fresh RoomId, the given @@ -86,7 +106,7 @@ export async function cloneRoom( // any co-owners, e.g. the seeded base-room roles for accounts 1/2) must NOT // carry over, or the clone would still list the template's owner as owner. const roles: RoomRole[] = [ - { AccountId: accountId, Role: Role.Owner, LastChangedByAccountId: null, InvitedRole: 0 }, + { AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 }, ] const cloned: Room = { @@ -132,6 +152,41 @@ export async function setRoomImage(db: D1Database, roomId: number, imageName: st .run() } +/** + * Set a target account's room `Role` — updating their existing `Roles` entry or + * appending a new one — and stamp `LastChangedByAccountId` with the editor. The + * caller supplies the already-loaded room (after its owner/co-owner check) to avoid + * a re-read; the whole room JSON is rewritten. Returns the updated room. + */ +export async function setRoomRole( + db: D1Database, + roomId: number, + targetAccountId: number, + role: number, + changedByAccountId: number, + room: Room +): Promise { + const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : [] + const existing = roles.find((r) => r.AccountId === targetAccountId) + if (existing) { + existing.Role = role + existing.LastChangedByAccountId = changedByAccountId + } else { + roles.push({ + AccountId: targetAccountId, + Role: role, + LastChangedByAccountId: changedByAccountId, + InvitedRole: 0, + }) + } + const updated: Room = { ...room, Roles: roles } + await db + .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') + .bind(roomId, JSON.stringify(updated)) + .run() + return updated +} + /** * Mutually-exclusive "main" room tags. The UI presents these as radio buttons, so * setting one clears any other main tag. Compared case-insensitively. @@ -810,7 +865,7 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr Name: `@${username}'s Dorm`, CreatorAccountId: accountId, IsDorm: true, - Roles: [{ AccountId: accountId, Role: Role.Owner, LastChangedByAccountId: null, InvitedRole: 0 }], + Roles: [{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 }], SubRooms: [{ ...templateSub, CreatorAccountId: accountId }], CreatedAt: new Date().toISOString(), } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10ae746..904e5b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,6 +294,9 @@ importers: apps/econ: dependencies: + '@repo/domain': + specifier: workspace:* + version: link:../../packages/domain '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers