Currency, storefronts, purchasing (#12)

And a few other minor things, but primarily, the balance table exists and also consumable/inventory table.
This commit is contained in:
devin
2026-07-15 14:10:49 -04:00
committed by GitHub
parent 8314e54439
commit c33919bc67
29 changed files with 2204 additions and 524 deletions
-430
View File
@@ -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,
})
)
+13 -5
View File
@@ -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<App>({ 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<string, unknown>)
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<App>({ 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) =>
+78
View File
@@ -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 players 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 players 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)
+141 -15
View File
@@ -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"
}
]
+11
View File
@@ -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
+12
View File
@@ -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<App>()
// 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
@@ -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([])
})
})
+647
View File
@@ -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"
}
}
]
+34 -7
View File
@@ -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=<n>&UnlockedLevel=<n>`,
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).
@@ -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);
+14
View File
@@ -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)
);
+18
View File
@@ -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);
+1
View File
@@ -16,6 +16,7 @@
"test": "run-vitest"
},
"dependencies": {
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"hono": "4.12.27",
+108
View File
@@ -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<void> {
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<UnlockedConsumable[]> {
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<ConsumableRow>()
const byDesc = new Map<string, UnlockedConsumable>()
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()]
}
+292 -12
View File
@@ -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<App>()
/**
* 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<App>,
storefrontType: number,
purchasableItemId: number
): Promise<StoreItem | null> {
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<App>({ strict: false })
.use(
'*',
// middleware
@@ -73,13 +186,14 @@ const app = new Hono<App>()
// 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<App>()
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=<giftId>&UnlockedLevel=<n>`. 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<string, unknown>)
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<App>()
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<App>()
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<string, unknown> | 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))
+70
View File
@@ -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<string, unknown> {
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<void> {
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<AvatarItem[]> {
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)
}
+274
View File
@@ -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)
+9
View File
@@ -0,0 +1,9 @@
[
{
"AdCarouselItemId": 1,
"Description": "No description.",
"ImageName": "AdCarouselItem.png",
"PurchasableItemIds": [],
"Title": "Lorem ipsum"
}
]
+58 -12
View File
@@ -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<RoomInstance>(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<App>()
.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<RoomInstance>(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<App>()
})
// 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<App>()
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))
})
+87 -14
View File
@@ -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 rooms instances', async () => {
test('GET /room/:id/instances is auth-gated, owner/co-owner-only, and lists the rooms 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)
})
})
+48 -20
View File
@@ -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<App>) {
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<string, unknown>, accountId: number): boolean {
if (room.CreatorAccountId === accountId) return true
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Record<string, unknown>>) : []
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<App>()
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<string, unknown>
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<App>()
Error: 'This room does not exist!',
})
}
if (!canEditRoomData(room, accountId)) {
if (!canManageRoom(room, accountId)) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.PermissionDenied',
@@ -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<Array<{ AccountId: number; Role: number }>> => {
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`)