From 739176a3ed789ca051be35e84adf8346149bffc6 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sat, 3 Oct 2026 13:57:32 -0400 Subject: [PATCH] [econ] room storefront purchaseables --- apps/econ/src/econ.app.ts | 39 +++++-- apps/econ/src/openapi.ts | 24 +++++ apps/econ/src/room-purchasables-db.ts | 119 +++++++++++++++++++++ apps/econ/src/test/integration/api.test.ts | 101 ++++++++++++++++- 4 files changed, 272 insertions(+), 11 deletions(-) create mode 100644 apps/econ/src/room-purchasables-db.ts diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 76cf000..d27f2f6 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -139,6 +139,7 @@ import { RoomEconConfig, RoomKeyDto, RoomKeyEnvelope, + RoomPurchasableList, RRPlusSignUpBonus, SaveOutfitRequest, SaveOutfitV4Response, @@ -182,6 +183,7 @@ import { releaseRoomKey, updateRoomKey, } from './room-key-db' +import { getRoomPurchasables } from './room-purchasables-db' import type { Context } from 'hono' import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain' @@ -4483,14 +4485,37 @@ const app = new Hono({ strict: false }) } ) - // The UGC items a room sells (the creator-made things on sale inside it). Same empty - // stub as the room-economy routes above and asked for on the same room load: nothing - // stores room UGC purchasables yet, and an empty list reads as "this room sells - // nothing" where a 404 stalls the load. + // Everything a room sells, in one list: its keys, its consumables and the purchase offers + // of its currencies, each as the client's 10-key `UgcPurchasableItem` with `ItemType` + // saying which (0 key, 1 consumable, 3 currency pack). Asked for on every room load. A + // room that sells nothing is `[]` — the client's decoder throws on `{}` or an empty body. .get( - '/api/ugcPurchasables/v1/items/room/:roomId', - listRoute('A room’s UGC purchasables', 'Empty stub so the client doesn’t 404'), - (c) => c.json([]) + '/api/ugcPurchasables/v1/items/room/:roomId{[0-9]+}', + describeRoute({ + tags: ['Rooms'], + summary: 'A room’s purchasables', + description: + 'The room’s shop as one flat list: its room keys (`ItemType` 0), room consumables ' + + '(1) and the purchase offers of each of its currencies (3), assembled from the ' + + '`room_key`, `room_consumable` and `room_currency` tables. `ItemId` is a bare GUID ' + + '— a key’s `ReplicationId`, a consumable’s `RoomConsumableId`, an offer’s ' + + '`CurrencyPurchaseOfferId` — with the kind beside it as `ItemType`, not the ' + + '`{ itemType, itemId }` struct the bulk lookup takes. `PurchaseCurrencyId` is the ' + + 'room currency a line is charged in, or null for tokens (every currency pack). A room ' + + 'that sells nothing is an empty array.', + parameters: [ + { + name: 'roomId', + in: 'path', + required: true, + description: 'The room whose shop to list', + schema: { type: 'string', pattern: '^[0-9]+$' }, + }, + ], + responses: { 200: json(RoomPurchasableList, 'The room’s shop') }, + }), + async (c) => + c.json(await getRoomPurchasables(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))) ) // Bulk lookup of UGC purchasables by `{ itemType, itemId }`. Only custom avatar items diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index b58d482..9f85d7a 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -564,6 +564,30 @@ export const UgcPurchasableItemDto = z.object({ /** What the bulk lookup answers: the resolved items, unknown ids omitted. */ export const UgcPurchasableItemList = z.array(UgcPurchasableItemDto) +/** + * `GET /api/ugcPurchasables/v1/items/room/{roomId}` — one line of a room's shop. The same + * 10 keys as the bulk lookup's item, with `ItemType` naming the kind (0 room key, 1 room + * consumable, 3 currency purchase offer) and `ImageName` nullable, as a key or consumable + * without art is served everywhere else. + */ +export const RoomPurchasableDto = UgcPurchasableItemDto.extend({ + ItemType: z + .number() + .int() + .describe('0 RoomKey, 1 RoomConsumable, 3 RoomCurrencyItem (a currency pack for tokens)'), + ItemId: z + .string() + .describe('A bare GUID — the key’s ReplicationId, the consumable’s or offer’s id'), + ImageName: z.string().nullable().describe('Null for a listing with no art'), + PurchaseCurrencyId: z + .string() + .nullable() + .describe('The room currency the line is charged in; null for tokens'), +}) + +/** The room’s shop — a bare array, empty for a room that sells nothing. */ +export const RoomPurchasableList = z.array(RoomPurchasableDto) + /** * `POST /api/items/purchaseInfos` JSON body — the same `{ itemType, itemId }` reference * shape the UGC bulk lookup takes, minus the room. camelCase INSIDE the reference, which is diff --git a/apps/econ/src/room-purchasables-db.ts b/apps/econ/src/room-purchasables-db.ts new file mode 100644 index 0000000..47c4c49 --- /dev/null +++ b/apps/econ/src/room-purchasables-db.ts @@ -0,0 +1,119 @@ +/** + * Everything a room sells, in one list — what `GET /api/ugcPurchasables/v1/items/room/:roomId` + * answers. There is no table of its own: the list is assembled on read from the three tables + * that hold a room's listings — `room_key` (room-key-db), `room_consumable` + * (room-consumable-db) and the purchase offers inside `room_currency` (room-currency-db) — + * each projected into the client's 10-key `UgcPurchasableItem` with `ItemType` saying which + * it is. One shape for three kinds of thing, which is the point of the route: the client + * draws the room's shop from it without knowing which table a line came from. + */ +import { getRoomConsumables } from './room-consumable-db' +import { getPurchaseOffers, getRoomCurrencies } from './room-currency-db' +import { getRoomKeys } from './room-key-db' + +/** + * The client's `ItemType` enum for a UGC purchasable, numeric on the wire. Recovered from the + * client: RoomKey 0, RoomConsumable 1, RoomCurrencyItem 3, StoreUGCAvatarItem 4, UNDEFINED 5 + * — 2 is unassigned. Note this is NOT the `itemType` inside the `{ itemType, itemId }` + * reference `…/items/bulk` and `/api/items/purchaseInfos` take, where a custom avatar item + * is 3 (`UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM`); the two enums share a name and little else. + */ +export const ROOM_PURCHASABLE_TYPE = { + roomKey: 0, + roomConsumable: 1, + roomCurrencyItem: 3, + storeUgcAvatarItem: 4, + undefined: 5, +} as const + +/** + * One line of a room's shop — the client's `UgcPurchasableItem`, 10 keys. `ItemId` is a bare + * GUID string, with the kind beside it as `ItemType`, not the `{ itemType, itemId }` struct + * the bulk lookups use. `Price` is a `long` in the client's model. `ImageName` is null for a + * listing with no art, as the key and consumable routes already serve it. + */ +export interface RoomPurchasable { + ItemType: number + ItemId: string + Name: string + Description: string + ImageName: string | null + RoomId: number + Price: number + /** A `room_currency` id the line is charged in, or null for tokens. */ + PurchaseCurrencyId: string | null + /** ISO-8601 UTC. */ + CreatedAt: string + /** ISO-8601 UTC. */ + ModifiedAt: string +} + +/** + * A room's shop: its keys, then its consumables, then the purchase offers of each of its + * currencies. Each kind keeps its own module's order. A room that sells nothing is `[]` — + * never `{}` or an empty body, which the client's decoder throws on. + * + * What stands in for what each table lacks: + * - A key is named by its `ReplicationId`, the GUID minted beside its numeric `RoomKeyId` + * (`ItemId` is a Guid in the client's model). Nothing records when a key was last edited, + * so `ModifiedAt` is its `CreatedAt`. + * - A consumable records only `ModifiedAt`, which stands as its `CreatedAt` too. + * - A purchase offer is a pack of the room's currency sold for TOKENS, so its + * `PurchaseCurrencyId` is null; it has no description or art of its own, and the one + * timestamp it keeps serves as both. + */ +export async function getRoomPurchasables( + db: D1Database, + roomId: number +): Promise { + const [keys, consumables, currencies] = await Promise.all([ + getRoomKeys(db, roomId), + getRoomConsumables(db, roomId), + getRoomCurrencies(db, roomId), + ]) + const offers = await getPurchaseOffers( + db, + currencies.map((currency) => currency.CurrencyId) + ) + + return [ + ...keys.map((key): RoomPurchasable => ({ + ItemType: ROOM_PURCHASABLE_TYPE.roomKey, + ItemId: key.ReplicationId, + Name: key.Name, + Description: key.Description, + ImageName: key.ImageName, + RoomId: key.RoomId, + Price: key.Price, + PurchaseCurrencyId: key.PurchaseCurrencyId, + CreatedAt: key.CreatedAt, + ModifiedAt: key.CreatedAt, + })), + ...consumables.map((consumable): RoomPurchasable => ({ + ItemType: ROOM_PURCHASABLE_TYPE.roomConsumable, + ItemId: consumable.RoomConsumableId, + Name: consumable.Name, + Description: consumable.Description, + ImageName: consumable.ImageName, + RoomId: consumable.RoomId, + Price: consumable.Price, + PurchaseCurrencyId: consumable.PurchaseCurrencyId, + CreatedAt: consumable.ModifiedAt, + ModifiedAt: consumable.ModifiedAt, + })), + ...offers.flatMap((shop) => + shop.PurchaseOffers.map((offer): RoomPurchasable => ({ + ItemType: ROOM_PURCHASABLE_TYPE.roomCurrencyItem, + ItemId: offer.CurrencyPurchaseOfferId, + Name: offer.Name, + Description: '', + ImageName: null, + RoomId: roomId, + Price: offer.Price, + PurchaseCurrencyId: null, + CreatedAt: offer.ModifiedAt, + ModifiedAt: offer.ModifiedAt, + })) + ), + ] +} diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 389cb24..c38abdf 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -104,9 +104,18 @@ import { } from '../../inventory-custom-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db' -import { ROOM_CONSUMABLE_SCHEMA_DDL, ROOM_INVENTORY_SCHEMA_DDL } from '../../room-consumable-db' -import { ROOM_BALANCE_SCHEMA_DDL, ROOM_CURRENCY_SCHEMA_DDL } from '../../room-currency-db' -import { ROOM_KEY_SCHEMA_DDL } from '../../room-key-db' +import { + ROOM_CONSUMABLE_SCHEMA_DDL, + ROOM_INVENTORY_SCHEMA_DDL, + upsertRoomConsumable, +} from '../../room-consumable-db' +import { + createPurchaseOffer, + createRoomCurrency, + ROOM_BALANCE_SCHEMA_DDL, + ROOM_CURRENCY_SCHEMA_DDL, +} from '../../room-currency-db' +import { createRoomKey, ROOM_KEY_SCHEMA_DDL } from '../../room-key-db' import type { CatalogLoadRow, CatalogRow, CatalogValue, StoreListing } from '../../catalog-db' import type { Env } from '../../context' @@ -2896,7 +2905,6 @@ describe('econ endpoints', () => { '/econ/roomOffer/room/92', '/econ/roomOffer/room/92/purchaseCounts', '/econ/roomGiftDropShops/room/92', - '/api/ugcPurchasables/v1/items/room/92', ]) { const res = await exports.default.fetch(`${ORIGIN}${path}`) expect(res.status, path).toBe(200) @@ -2904,6 +2912,91 @@ describe('econ endpoints', () => { } }) + test('GET /api/ugcPurchasables/v1/items/room/:roomId lists keys, consumables and currency packs as one shop', async () => { + const shop = async (roomId: number) => + (await ( + await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/room/${roomId}`) + ).json()) as Array> + + // A room that sells nothing is `[]`, never `{}` — the client's decoder throws on that. + expect(await shop(93)).toEqual([]) + + const key = await createRoomKey(env.DB, { + RoomId: 93, + Type: 'Key', + Name: 'VIP Key', + Description: '', + Price: 250, + }) + const consumable = await upsertRoomConsumable(env.DB, null, { + RoomId: 93, + Name: 'Potion', + Description: 'Drink me', + ImageName: 'potion.png', + Price: 5, + PurchaseCurrencyId: 'c0ffee00-0000-4000-8000-000000000093', + MaximumCountPerPurchase: 10, + }) + const currency = await createRoomCurrency(env.DB, { + RoomId: 93, + Name: 'Gems', + Description: '', + Limit: 100, + Shape: 0, + Color: 0, + }) + const offer = await createPurchaseOffer(env.DB, currency.CurrencyId, { + Name: '100 gems', + CurrencyAmount: 100, + Price: 400, + Order: 0, + }) + + // The client's 10-key `UgcPurchasableItem`, `ItemType` saying which table a line is + // from and `ItemId` a bare GUID: a key is named by its ReplicationId, not its number. + expect(await shop(93)).toEqual([ + { + ItemType: 0, + ItemId: key.ReplicationId, + Name: 'VIP Key', + Description: '', + ImageName: null, + RoomId: 93, + Price: 250, + PurchaseCurrencyId: null, + CreatedAt: key.CreatedAt, + ModifiedAt: key.CreatedAt, + }, + { + ItemType: 1, + ItemId: consumable.RoomConsumableId, + Name: 'Potion', + Description: 'Drink me', + ImageName: 'potion.png', + RoomId: 93, + Price: 5, + PurchaseCurrencyId: 'c0ffee00-0000-4000-8000-000000000093', + CreatedAt: consumable.ModifiedAt, + ModifiedAt: consumable.ModifiedAt, + }, + { + ItemType: 3, + ItemId: offer.CurrencyPurchaseOfferId, + Name: '100 gems', + Description: '', + ImageName: null, + RoomId: 93, + Price: 400, + // A currency pack is bought with tokens. + PurchaseCurrencyId: null, + CreatedAt: offer.ModifiedAt, + ModifiedAt: offer.ModifiedAt, + }, + ]) + // Another room's shop is untouched. + expect(await shop(92)).toEqual([]) + }) + test('POST /api/ugcPurchasables/v1/items/bulk resolves custom avatar items, echoing RoomId', async () => { const item = await createCustomAvatarItem(env.DB, { customAvatarItemId: crypto.randomUUID(),