mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
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:
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user