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
+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))