mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] room storefront purchaseables
This commit is contained in:
@@ -139,6 +139,7 @@ import {
|
|||||||
RoomEconConfig,
|
RoomEconConfig,
|
||||||
RoomKeyDto,
|
RoomKeyDto,
|
||||||
RoomKeyEnvelope,
|
RoomKeyEnvelope,
|
||||||
|
RoomPurchasableList,
|
||||||
RRPlusSignUpBonus,
|
RRPlusSignUpBonus,
|
||||||
SaveOutfitRequest,
|
SaveOutfitRequest,
|
||||||
SaveOutfitV4Response,
|
SaveOutfitV4Response,
|
||||||
@@ -182,6 +183,7 @@ import {
|
|||||||
releaseRoomKey,
|
releaseRoomKey,
|
||||||
updateRoomKey,
|
updateRoomKey,
|
||||||
} from './room-key-db'
|
} from './room-key-db'
|
||||||
|
import { getRoomPurchasables } from './room-purchasables-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
|
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
|
||||||
@@ -4483,14 +4485,37 @@ const app = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// The UGC items a room sells (the creator-made things on sale inside it). Same empty
|
// Everything a room sells, in one list: its keys, its consumables and the purchase offers
|
||||||
// stub as the room-economy routes above and asked for on the same room load: nothing
|
// of its currencies, each as the client's 10-key `UgcPurchasableItem` with `ItemType`
|
||||||
// stores room UGC purchasables yet, and an empty list reads as "this room sells
|
// saying which (0 key, 1 consumable, 3 currency pack). Asked for on every room load. A
|
||||||
// nothing" where a 404 stalls the load.
|
// room that sells nothing is `[]` — the client's decoder throws on `{}` or an empty body.
|
||||||
.get(
|
.get(
|
||||||
'/api/ugcPurchasables/v1/items/room/:roomId',
|
'/api/ugcPurchasables/v1/items/room/:roomId{[0-9]+}',
|
||||||
listRoute('A room’s UGC purchasables', 'Empty stub so the client doesn’t 404'),
|
describeRoute({
|
||||||
(c) => c.json([])
|
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
|
// Bulk lookup of UGC purchasables by `{ itemType, itemId }`. Only custom avatar items
|
||||||
|
|||||||
@@ -564,6 +564,30 @@ export const UgcPurchasableItemDto = z.object({
|
|||||||
/** What the bulk lookup answers: the resolved items, unknown ids omitted. */
|
/** What the bulk lookup answers: the resolved items, unknown ids omitted. */
|
||||||
export const UgcPurchasableItemList = z.array(UgcPurchasableItemDto)
|
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
|
* `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
|
* shape the UGC bulk lookup takes, minus the room. camelCase INSIDE the reference, which is
|
||||||
|
|||||||
@@ -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<RoomPurchasable[]> {
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -104,9 +104,18 @@ import {
|
|||||||
} from '../../inventory-custom-db'
|
} from '../../inventory-custom-db'
|
||||||
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
|
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
|
||||||
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
|
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
|
||||||
import { ROOM_CONSUMABLE_SCHEMA_DDL, ROOM_INVENTORY_SCHEMA_DDL } from '../../room-consumable-db'
|
import {
|
||||||
import { ROOM_BALANCE_SCHEMA_DDL, ROOM_CURRENCY_SCHEMA_DDL } from '../../room-currency-db'
|
ROOM_CONSUMABLE_SCHEMA_DDL,
|
||||||
import { ROOM_KEY_SCHEMA_DDL } from '../../room-key-db'
|
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 { CatalogLoadRow, CatalogRow, CatalogValue, StoreListing } from '../../catalog-db'
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
@@ -2896,7 +2905,6 @@ describe('econ endpoints', () => {
|
|||||||
'/econ/roomOffer/room/92',
|
'/econ/roomOffer/room/92',
|
||||||
'/econ/roomOffer/room/92/purchaseCounts',
|
'/econ/roomOffer/room/92/purchaseCounts',
|
||||||
'/econ/roomGiftDropShops/room/92',
|
'/econ/roomGiftDropShops/room/92',
|
||||||
'/api/ugcPurchasables/v1/items/room/92',
|
|
||||||
]) {
|
]) {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}${path}`)
|
const res = await exports.default.fetch(`${ORIGIN}${path}`)
|
||||||
expect(res.status, path).toBe(200)
|
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<Record<string, unknown>>
|
||||||
|
|
||||||
|
// 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 () => {
|
test('POST /api/ugcPurchasables/v1/items/bulk resolves custom avatar items, echoing RoomId', async () => {
|
||||||
const item = await createCustomAvatarItem(env.DB, {
|
const item = await createCustomAvatarItem(env.DB, {
|
||||||
customAvatarItemId: crypto.randomUUID(),
|
customAvatarItemId: crypto.randomUUID(),
|
||||||
|
|||||||
Reference in New Issue
Block a user