mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
2239 lines
86 KiB
TypeScript
2239 lines
86 KiB
TypeScript
import { Hono } from 'hono'
|
||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||
|
||
import {
|
||
addXp,
|
||
consumeGift,
|
||
createGift,
|
||
getGift,
|
||
getPendingGifts,
|
||
grantInvention,
|
||
levelReward,
|
||
levelsReached,
|
||
ownsInvention,
|
||
} from '@repo/domain'
|
||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||
import { validateAndGetAccountId } from '@repo/jwt'
|
||
|
||
// Invention storage (owned by the `api` worker, on this same `recflare` database).
|
||
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
|
||
// their own, and buyInvention has to read the very rows `api` writes.
|
||
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
|
||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
||
// as a value — the enum has no runtime dependencies.
|
||
import { NotificationType } from '../../notify/src/notification-types'
|
||
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,
|
||
creditCurrency,
|
||
CurrencyType,
|
||
DEFAULT_STARTING_TOKENS,
|
||
ensureStartingBalances,
|
||
getBalance,
|
||
isSpendable,
|
||
spendCurrency,
|
||
} from './balance-db'
|
||
import {
|
||
claimChallengeGift,
|
||
getCompletedChallengeIds,
|
||
recordChallengeProgress,
|
||
} from './challenge-db'
|
||
import {
|
||
consumeConsumable,
|
||
countConsumable,
|
||
getConsumables,
|
||
grantConsumable,
|
||
} from './consumables-db'
|
||
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
|
||
import { getInventory, grantItem } from './inventory-db'
|
||
import {
|
||
AUTHED,
|
||
AvatarV2Dto,
|
||
BalanceEntry,
|
||
BuyInventionResponse,
|
||
BuyItemRequest,
|
||
BuyItemResponse,
|
||
ChallengeProgressRequest,
|
||
ChallengeProgressResponse,
|
||
ConsumeConsumableRequest,
|
||
ConsumeEnvelope,
|
||
ConsumeGiftRequest,
|
||
CustomAvatarItemsResponse,
|
||
EquipmentUpdateRequest,
|
||
ErrorResponse,
|
||
form,
|
||
GameRewardRequest,
|
||
json,
|
||
JsonArray,
|
||
jsonBody,
|
||
JsonObject,
|
||
OpaqueJsonBody,
|
||
OPTIONAL_AUTHED,
|
||
SaveOutfitRequest,
|
||
SaveOutfitV4Response,
|
||
SubscriptionResponse,
|
||
UNAUTHORIZED_RESPONSE,
|
||
UpdateObjectiveRequest,
|
||
UpdateObjectiveResponse,
|
||
} from './openapi'
|
||
import { getOutfits, setOutfit } from './outfit-db'
|
||
import { claimReward } from './reward-db'
|
||
|
||
import type { Context } from 'hono'
|
||
import type { GiftContent, Progression, StoredGift, XpGrant } from '@repo/domain'
|
||
import type { Avatar } from './avatar-db'
|
||
import type { ConsumeResult } from './consumables-db'
|
||
import type { App } from './context'
|
||
import type { Equipment } from './equipment-db'
|
||
import type { AvatarItem } from './inventory-db'
|
||
import type { Outfit } from './outfit-db'
|
||
|
||
/**
|
||
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
|
||
* the `econ` service (these are separate from the main `api` worker). Balances,
|
||
* inventory (avatar items, equipment, bought inventions), consumables, saved outfits,
|
||
* avatars, gift boxes, weekly-challenge progress and game-reward eligibility are D1-backed;
|
||
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
|
||
* binding. Some routes are still empty-list stubs (room keys, wishlist, …).
|
||
*
|
||
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
|
||
*/
|
||
|
||
/**
|
||
* Resolve the account id from a Bearer token. Returns `null` when the header is
|
||
* missing, the token is invalid, or the `sub` claim isn't an integer.
|
||
*/
|
||
async function authedId(c: Context<App>): Promise<number | null> {
|
||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||
}
|
||
|
||
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
||
function unauthorized(c: Context<App>) {
|
||
return c.body(null, 401)
|
||
}
|
||
|
||
/**
|
||
* A boolean the client may send either as a JSON `true` or as .NET's `bool.ToString()`
|
||
* output — `"True"`/`"False"`, capitalized. `Boolean(value)` is a trap here: the string
|
||
* `"False"` is truthy, so a client reporting "not complete" would read as complete.
|
||
* Anything unrecognised (missing, `null`, `""`) is false.
|
||
*/
|
||
function parseBool(value: string | boolean | undefined): boolean {
|
||
return typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true'
|
||
}
|
||
|
||
/**
|
||
* Shared parse/validate/store for the save-outfit routes (v3 and v4). Persists the
|
||
* posted outfit into its `Slot` verbatim and returns the stored `Outfit`; on the
|
||
* unauth or bad-body path it returns the Response to send directly (401, or 400 for a
|
||
* non-object body or missing/non-integer `Slot`). Callers format the success body — v3
|
||
* echoes the whole outfit, v4 answers a lean `{ Success, Slot }` ack.
|
||
*/
|
||
async function persistPostedOutfit(c: Context<App>): Promise<Outfit | Response> {
|
||
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.body(null, 400)
|
||
}
|
||
if (!Number.isInteger(body.Slot)) return c.body(null, 400)
|
||
const outfit = body as Outfit
|
||
await setOutfit(c.env.DB, id, outfit)
|
||
return outfit
|
||
}
|
||
|
||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||
const HUB_INSTANCE = 'global'
|
||
|
||
/**
|
||
* Push a ConsumableMappingRemoved notification to a player after they consume a
|
||
* consumable, mirroring the reference's
|
||
* `HubSendToPlayer(accountID, NotifFrame(ConsumableMappingRemoved, {...}))` — the
|
||
* client uses it to update/remove the item from inventory. Best-effort: a hub failure
|
||
* is logged and swallowed, since the consume has already committed.
|
||
*/
|
||
async function pushConsumableRemoved(
|
||
c: Context<App>,
|
||
accountId: number,
|
||
consumed: ConsumeResult
|
||
): Promise<void> {
|
||
try {
|
||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||
accountId,
|
||
NotificationType.ConsumableMappingRemoved,
|
||
{
|
||
Id: consumed.id,
|
||
ConsumableItemDesc: consumed.consumableItemDesc,
|
||
CreatedAt: consumed.createdAt,
|
||
Count: consumed.remaining,
|
||
InitialCount: consumed.previousCount,
|
||
IsActive: false,
|
||
ActiveDurationMinutes: 0,
|
||
IsTransferable: false,
|
||
}
|
||
)
|
||
} catch (err) {
|
||
logger.error('failed to push ConsumableMappingRemoved notification', {
|
||
accountId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Push a ConsumableMappingAdded notification to a player after they open a gift box
|
||
* that carried a consumable, mirroring the reference's
|
||
* `HubSendToPlayer(accountID, NotifFrame(ConsumableMappingAdded, {...}))` — the client
|
||
* uses it to show the newly-unlocked consumable. The mapping id and pre-existing count
|
||
* were stamped onto the box at purchase (see toGiftContent). Best-effort like the
|
||
* removed push.
|
||
*/
|
||
async function pushConsumableAdded(
|
||
c: Context<App>,
|
||
accountId: number,
|
||
gift: StoredGift
|
||
): Promise<void> {
|
||
try {
|
||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||
accountId,
|
||
NotificationType.ConsumableMappingAdded,
|
||
{
|
||
Id: gift.ConsumableMappingId ?? 0,
|
||
ConsumableItemDesc: gift.ConsumableItemDesc,
|
||
CreatedAt: new Date().toISOString(),
|
||
Count: gift.ConsumableCount,
|
||
InitialCount: gift.ConsumablePreExistingCount ?? 0,
|
||
IsActive: false,
|
||
ActiveDurationMinutes: 0,
|
||
IsTransferable: false,
|
||
}
|
||
)
|
||
} catch (err) {
|
||
logger.error('failed to push ConsumableMappingAdded notification', {
|
||
accountId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Push a StorefrontBalanceUpdate to a player after their balance changes, mirroring the
|
||
* reference's
|
||
* `HubSendToPlayer(accountID, NotifFrame(StorefrontBalanceUpdate, {Balance, CurrencyType, BalanceType}))`.
|
||
* The client applies it to the shown balance so a purchase reflects immediately, without
|
||
* waiting for a `GET /balance` re-fetch.
|
||
*
|
||
* `Balance` is the CHANGE — negative for a debit, positive for a payout — not the
|
||
* resulting total. The client ADDS what it receives to the balance it is already showing,
|
||
* so sending the total made a 10,000-token player who earned 250 read 20,250: their own
|
||
* balance plus the new total. That also makes this frame non-idempotent, so push exactly
|
||
* once per change and never re-send it as a "refresh".
|
||
*
|
||
* `BalanceType` is -2 (account-wide, all platforms). Best-effort: a hub failure is logged
|
||
* and swallowed, since the balance change has already committed.
|
||
*/
|
||
async function pushBalanceUpdate(
|
||
c: Context<App>,
|
||
accountId: number,
|
||
currencyType: number,
|
||
change: number
|
||
): Promise<void> {
|
||
try {
|
||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||
accountId,
|
||
NotificationType.StorefrontBalanceUpdate,
|
||
{
|
||
Balance: change,
|
||
CurrencyType: currencyType,
|
||
BalanceType: ALL_PLATFORMS,
|
||
}
|
||
)
|
||
} catch (err) {
|
||
logger.error('failed to push StorefrontBalanceUpdate notification', {
|
||
accountId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Project a stored avatar into the public render subset returned by
|
||
* `GET /api/avatar/v2/:id` — the fields needed to draw another player's avatar
|
||
* (the full blob also holds `OutfitSelectionsV2`/`CustomAvatarItems`, which this
|
||
* view omits).
|
||
*/
|
||
function toAvatarV2Dto(avatar: Avatar) {
|
||
return {
|
||
OutfitSelections: avatar.OutfitSelections,
|
||
FaceFeatures: avatar.FaceFeatures,
|
||
SkinColor: avatar.SkinColor,
|
||
HairColor: avatar.HairColor,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
/**
|
||
* A QUERY drop — a loot box rather than an item. Its item fields are all empty on
|
||
* purpose: what the player gets is rolled at grant time from everything of the target
|
||
* rarity they don't already own (see {@link rollQueryDrop}). sf2's "Star Boxes" set and
|
||
* sf3's "Random box" family are the two that ship; sf2's tooltip says it outright — "A
|
||
* random 4-star item that you don't have."
|
||
*/
|
||
IsQuery?: boolean
|
||
/**
|
||
* The rarity a query drop rolls at, when it differs from the box's own `Rarity`. The
|
||
* sf2 boxes carry both and they agree; sf3's don't carry it at all, hence the fallback
|
||
* to `Rarity`.
|
||
*/
|
||
QueryRedirectRarity?: number
|
||
/**
|
||
* XP the drop pays out. No storefront catalog sets it — a bought item is an item — but a
|
||
* game reward is XP in a gift box, so the box and its notification carry the amount from
|
||
* here. The XP itself is banked in `progression`, not read back off the box.
|
||
*/
|
||
Xp?: 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,
|
||
}
|
||
}
|
||
|
||
/** Build the owned equipment DTO granted into the buyer's inventory from a gift-drop. */
|
||
function toEquipment(giftDrop: StoreGiftDrop): Equipment {
|
||
return {
|
||
ModificationGuid: giftDrop.EquipmentModificationGuid,
|
||
PrefabName: giftDrop.EquipmentPrefabName,
|
||
FriendlyName: giftDrop.FriendlyName,
|
||
Tooltip: giftDrop.Tooltip,
|
||
Rarity: giftDrop.Rarity,
|
||
PlatformMask: -1,
|
||
Favorited: false,
|
||
}
|
||
}
|
||
|
||
/** 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,
|
||
consumableMappingId = 0,
|
||
consumablePreExistingCount = 0
|
||
): GiftContent {
|
||
return {
|
||
ConsumableItemDesc: giftDrop.ConsumableItemDesc,
|
||
ConsumableCount: consumableCount,
|
||
ConsumableMappingId: consumableMappingId,
|
||
ConsumablePreExistingCount: consumablePreExistingCount,
|
||
AvatarItemDesc: giftDrop.AvatarItemDesc,
|
||
AvatarItemType: giftDrop.AvatarItemType,
|
||
CurrencyType: giftDrop.CurrencyType,
|
||
Currency: giftDrop.Currency,
|
||
Xp: giftDrop.Xp ?? 0,
|
||
PackageType: 0,
|
||
Message: message,
|
||
EquipmentPrefabName: giftDrop.EquipmentPrefabName,
|
||
EquipmentModificationGuid: giftDrop.EquipmentModificationGuid,
|
||
GiftRarity: giftDrop.Rarity,
|
||
Platform: -1,
|
||
PlatformsToSpawnOn: -1,
|
||
BalanceType: null,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Push a GiftPackageReceivedImmediate notification for a gift box the player didn't ask
|
||
* for, mirroring the reference's
|
||
* `HubSendToPlayer(accountID, NotifFrame(GiftPackageReceivedImmediate, {...}))` — the
|
||
* client pops the "you got something" panel from it instead of waiting for the next read of
|
||
* `GET /api/avatar/v2/gifts`.
|
||
*
|
||
* The payload is the reference's field-for-field: the stored box's contents plus its `Id`,
|
||
* a `FromGiftDropId` of 0 (the reference never populates it either) and the
|
||
* platform/balance constants. `Xp` is the drop's, so a game reward's box announces the XP it
|
||
* paid; `Level` is 0, since nothing levels a player up yet.
|
||
*
|
||
* "Immediate" (31) rather than GiftPackageReceived (30) is what the reference sends for a
|
||
* box handed over by the server: a purchase gifted to another player, an admin token grant,
|
||
* a report reward. This is the same case — the player is being handed a box they never
|
||
* clicked for. Best-effort: a hub failure is logged and swallowed, since the gift itself is
|
||
* already granted and stored.
|
||
*/
|
||
async function pushGiftReceived(
|
||
c: Context<App>,
|
||
accountId: number,
|
||
gift: GrantedGift,
|
||
message: string,
|
||
fromPlayerId: number
|
||
): Promise<void> {
|
||
try {
|
||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||
accountId,
|
||
NotificationType.GiftPackageReceivedImmediate,
|
||
{
|
||
Id: gift.id,
|
||
FromGiftDropId: 0,
|
||
FromPlayerId: fromPlayerId,
|
||
ConsumableItemDesc: gift.drop.ConsumableItemDesc,
|
||
AvatarItemDesc: gift.drop.AvatarItemDesc,
|
||
AvatarItemType: gift.drop.AvatarItemType ?? 0,
|
||
EquipmentPrefabName: gift.drop.EquipmentPrefabName,
|
||
EquipmentModificationGuid: gift.drop.EquipmentModificationGuid,
|
||
CurrencyType: gift.drop.CurrencyType,
|
||
Currency: gift.drop.Currency,
|
||
Xp: gift.drop.Xp ?? 0,
|
||
Level: 0,
|
||
Platform: -1,
|
||
PlatformsToSpawnOn: -1,
|
||
BalanceType: ALL_PLATFORMS,
|
||
GiftContext: gift.drop.Context,
|
||
GiftRarity: gift.drop.Rarity,
|
||
Message: message,
|
||
}
|
||
)
|
||
} catch (err) {
|
||
logger.error('failed to push GiftPackageReceivedImmediate notification', {
|
||
accountId,
|
||
giftId: gift.id,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Push a PlayerProgressionLevelUpdate so the client's level bar moves when XP lands, instead
|
||
* of waiting for its next progression read. `XP` is the progress into the current level (the
|
||
* ladder spends the rest on the level-ups), which is what the bar draws against the
|
||
* `LevelProgressionMaps` the client is served.
|
||
*
|
||
* Best-effort: the XP is already banked, so a hub failure costs a bar animation, not the
|
||
* reward.
|
||
*/
|
||
async function pushProgressionUpdate(
|
||
c: Context<App>,
|
||
accountId: number,
|
||
progression: Progression
|
||
): Promise<void> {
|
||
try {
|
||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||
accountId,
|
||
NotificationType.PlayerProgressionLevelUpdate,
|
||
{ PlayerId: progression.PlayerId, Level: progression.Level, XP: progression.XP }
|
||
)
|
||
} catch (err) {
|
||
logger.error('failed to push PlayerProgressionLevelUpdate notification', {
|
||
accountId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The catalog a query drop rolls from: sf3, the general store. It is the only catalog with
|
||
* a real pool at every rarity (1161 items against 8–40 in the themed ones), it's where the
|
||
* "Random box" family itself sells, and a box promising "a random 4-star item" plainly
|
||
* means the whole item universe rather than whichever seasonal shelf it was bought from.
|
||
*/
|
||
const ROLL_STOREFRONT_TYPE = 3
|
||
|
||
/** Every item in the roll catalog, or `[]` if it can't be read (a roll then yields nothing). */
|
||
async function loadRollCatalog(c: Context<App>): Promise<StoreItem[]> {
|
||
const res = await c.env.ASSETS.fetch(new URL(`/sf${ROLL_STOREFRONT_TYPE}.json`, c.req.url))
|
||
if (!res.ok) return []
|
||
const storefront = (await res.json()) as Storefront
|
||
return storefront.StoreItems
|
||
}
|
||
|
||
/**
|
||
* Whether the player already owns what a drop carries — the question a query drop's "an
|
||
* item that you don't have" turns on, and the one that decides whether the weekly gift
|
||
* hands over its item or rolls the fallback box instead.
|
||
*
|
||
* Ownership is boolean for avatar items and equipment, which is what makes "already have
|
||
* it" meaningful. A drop carrying neither (a consumable, a currency drop, an empty query
|
||
* box) counts as owned: there is nothing ownable to hand over, so callers offering a
|
||
* fallback should take it.
|
||
*/
|
||
async function ownsGiftDrop(
|
||
db: D1Database,
|
||
accountId: number,
|
||
giftDrop: StoreGiftDrop
|
||
): Promise<boolean> {
|
||
if (typeof giftDrop.AvatarItemDesc === 'string' && giftDrop.AvatarItemDesc !== '') {
|
||
const owned = await getInventory(db, accountId)
|
||
return owned.some((item) => item.AvatarItemDesc === giftDrop.AvatarItemDesc)
|
||
}
|
||
if (
|
||
typeof giftDrop.EquipmentModificationGuid === 'string' &&
|
||
giftDrop.EquipmentModificationGuid !== ''
|
||
) {
|
||
const owned = await getEquipment(db, accountId)
|
||
return owned.some((eq) => eq.ModificationGuid === giftDrop.EquipmentModificationGuid)
|
||
}
|
||
return true
|
||
}
|
||
|
||
/** How a query drop is rolled — what it may land on, and whose catalog copy to use. */
|
||
interface RollOptions {
|
||
/**
|
||
* Restrict the roll to avatar items, leaving equipment skins out of the pool. Off by
|
||
* default: a bought box says "a random item", and the catalog's own boxes mean both.
|
||
*/
|
||
avatarItemsOnly?: boolean
|
||
/**
|
||
* The roll catalog, when the caller has already read it — it's the big one (sf3), and a
|
||
* caller granting several boxes at once shouldn't re-read it per box.
|
||
*/
|
||
rollCatalog?: StoreItem[]
|
||
}
|
||
|
||
/**
|
||
* Roll a query drop: pick, uniformly at random, one item of `rarity` from the roll catalog
|
||
* that the player doesn't already own. Returns null when the pool is empty — an unreadable
|
||
* catalog, a rarity nothing is published at, or a player who owns every item of that tier.
|
||
*
|
||
* The pool is deliberately narrow. Other query drops are excluded (a box that rolls a box
|
||
* would either loop or hand over an unopenable one), and so is everything that isn't an
|
||
* avatar item or a piece of equipment: "an item you don't have" only means anything for
|
||
* things owned once, and consumables stack, so a consumable would be rollable forever and
|
||
* would crowd out the real prizes.
|
||
*
|
||
* `avatarItemsOnly` narrows it further to things worn on the avatar, leaving equipment
|
||
* skins out — a level-up prize should be something the player can see on themselves, not a
|
||
* skin for a weapon they may not own. It also skips the equipment read entirely, since
|
||
* nothing in the pool can match it.
|
||
*/
|
||
async function rollQueryDrop(
|
||
c: Context<App>,
|
||
accountId: number,
|
||
rarity: number,
|
||
options: RollOptions = {}
|
||
): Promise<StoreGiftDrop | null> {
|
||
const [catalog, ownedItems, ownedEquipment] = await Promise.all([
|
||
options.rollCatalog ?? loadRollCatalog(c),
|
||
getInventory(c.env.DB, accountId),
|
||
options.avatarItemsOnly === true ? [] : getEquipment(c.env.DB, accountId),
|
||
])
|
||
const haveItem = new Set(ownedItems.map((item) => item.AvatarItemDesc))
|
||
const haveEquipment = new Set(ownedEquipment.map((eq) => eq.ModificationGuid))
|
||
const pool = catalog.filter(({ GiftDrop: drop }) => {
|
||
if (drop.IsQuery === true || drop.Rarity !== rarity) return false
|
||
if (typeof drop.AvatarItemDesc === 'string' && drop.AvatarItemDesc !== '') {
|
||
return !haveItem.has(drop.AvatarItemDesc)
|
||
}
|
||
if (options.avatarItemsOnly === true) return false
|
||
if (
|
||
typeof drop.EquipmentModificationGuid === 'string' &&
|
||
drop.EquipmentModificationGuid !== ''
|
||
) {
|
||
return !haveEquipment.has(drop.EquipmentModificationGuid)
|
||
}
|
||
return false
|
||
})
|
||
const rolled = pool[Math.floor(Math.random() * pool.length)]
|
||
return rolled?.GiftDrop ?? null
|
||
}
|
||
|
||
/**
|
||
* A gift box that was just created, and the drop it ended up holding. The drop is the
|
||
* RESOLVED one — what a query drop rolled, not the box that promised it — so a caller
|
||
* announcing the gift names the item the player actually won.
|
||
*/
|
||
interface GrantedGift {
|
||
id: number
|
||
drop: StoreGiftDrop
|
||
}
|
||
|
||
/**
|
||
* Pick a random consumable from the roll catalog — the reward the published level table
|
||
* hands out for the early levels.
|
||
*
|
||
* Unlike a clothing roll this one has no rarity and no ownership filter: the table names no
|
||
* star tier for a consumable, and consumables STACK, so "one you don't have" is meaningless
|
||
* (a second Confetti Cannon is a fine prize). Returns a concrete drop rather than a query
|
||
* one, so the grant path just grants it.
|
||
*/
|
||
function rollConsumableDrop(catalog: StoreItem[]): StoreGiftDrop | null {
|
||
const pool = catalog.filter(
|
||
({ GiftDrop: drop }) =>
|
||
drop.IsQuery !== true &&
|
||
typeof drop.ConsumableItemDesc === 'string' &&
|
||
drop.ConsumableItemDesc !== ''
|
||
)
|
||
return pool[Math.floor(Math.random() * pool.length)]?.GiftDrop ?? null
|
||
}
|
||
|
||
/**
|
||
* Hand a gift-drop to a player: grant whatever it turns out to carry (an avatar item, an
|
||
* equipment skin, a consumable, or none of these — currency/xp drops aren't granted yet)
|
||
* and create the gift box that renders it.
|
||
*
|
||
* A query drop is ROLLED here first, so what gets granted — and what the box shows — is the
|
||
* item the player actually won, not the box that promised it. A roll with nothing left to
|
||
* give falls through with the box itself, which grants nothing: no worse than not rolling,
|
||
* and the warning says which rarity ran dry.
|
||
*
|
||
* Both faucets share this — a storefront purchase and the weekly-challenge reward — so a
|
||
* drop lands in a player's inventory the same way whichever one it came from. The item is
|
||
* granted here, not when the box is opened: consuming a box only deletes the row.
|
||
*/
|
||
async function grantGiftDrop(
|
||
c: Context<App>,
|
||
accountId: number,
|
||
drop: StoreGiftDrop,
|
||
message: string,
|
||
options: RollOptions = {}
|
||
): Promise<GrantedGift> {
|
||
let giftDrop = drop
|
||
if (drop.IsQuery === true) {
|
||
const rarity = drop.QueryRedirectRarity ?? drop.Rarity
|
||
const rolled = await rollQueryDrop(c, accountId, rarity, options)
|
||
if (rolled === null) {
|
||
logger.warn('query gift-drop rolled nothing', {
|
||
accountId,
|
||
rarity,
|
||
friendlyName: drop.FriendlyName,
|
||
})
|
||
} else {
|
||
giftDrop = rolled
|
||
}
|
||
}
|
||
const db = c.env.DB
|
||
if (typeof giftDrop.AvatarItemDesc === 'string' && giftDrop.AvatarItemDesc !== '') {
|
||
await grantItem(db, accountId, toAvatarItem(giftDrop))
|
||
}
|
||
if (
|
||
typeof giftDrop.EquipmentModificationGuid === 'string' &&
|
||
giftDrop.EquipmentModificationGuid !== ''
|
||
) {
|
||
await grantEquipment(db, accountId, toEquipment(giftDrop))
|
||
}
|
||
const isConsumable =
|
||
typeof giftDrop.ConsumableItemDesc === 'string' && giftDrop.ConsumableItemDesc !== ''
|
||
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0
|
||
// Capture the granted consumable's row id and the player's pre-existing count so the
|
||
// gift box can carry them — gift-consume fires ConsumableMappingAdded from these.
|
||
let consumableMappingId = 0
|
||
let consumablePreExisting = 0
|
||
if (isConsumable) {
|
||
consumablePreExisting = await countConsumable(db, accountId, giftDrop.ConsumableItemDesc)
|
||
consumableMappingId = await grantConsumable(
|
||
db,
|
||
accountId,
|
||
giftDrop.ConsumableItemDesc,
|
||
consumableCount
|
||
)
|
||
}
|
||
const { id } = await createGift(
|
||
db,
|
||
accountId,
|
||
toGiftContent(giftDrop, message, consumableCount, consumableMappingId, consumablePreExisting)
|
||
)
|
||
return { id, drop: giftDrop }
|
||
}
|
||
|
||
/**
|
||
* XP paid for a claimed game reward. One flat amount for every reward type, matching the
|
||
* one flat cooldown they share — "First Game of the Day" and "Activity completed!" are the
|
||
* same size of pat on the back until there's reason to price them apart.
|
||
*
|
||
* Deliberately smaller than the 10 XP the first level costs: a single action shouldn't be a
|
||
* level-up, let alone two of them. At 5 it takes two rewards to reach level 2, and the early
|
||
* levels are paced by the hourly cooldown rather than cleared in one match.
|
||
*/
|
||
const GAME_REWARD_XP = 5
|
||
|
||
/**
|
||
* `GiftContext.GameRewards` — what the box says it came from, so the client files it under
|
||
* gameplay rewards rather than a purchase or a player's gift. (`51` is the tokens variant,
|
||
* for when a reward pays currency instead of XP.)
|
||
*/
|
||
const GIFT_CONTEXT_GAME_REWARDS = 50
|
||
|
||
/** Shown on the box when the client asks for a reward without saying what to call it. */
|
||
const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!'
|
||
|
||
/**
|
||
* The gift-drop a claimed game reward hands over: XP in a box, no item. Every item field is
|
||
* empty on purpose — this is not a purchase and not a roll, so `grantGiftDrop` grants
|
||
* nothing into the inventory and only creates the box. The XP is banked in `progression`;
|
||
* the copy here is what the box and its notification display.
|
||
*/
|
||
function toGameRewardDrop(): StoreGiftDrop {
|
||
return {
|
||
FriendlyName: '',
|
||
Tooltip: '',
|
||
ConsumableItemDesc: '',
|
||
AvatarItemDesc: '',
|
||
AvatarItemType: null,
|
||
EquipmentPrefabName: '',
|
||
EquipmentModificationGuid: '',
|
||
Rarity: 0,
|
||
Context: GIFT_CONTEXT_GAME_REWARDS,
|
||
Currency: 0,
|
||
CurrencyType: 0,
|
||
Xp: GAME_REWARD_XP,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The box a CLOTHING level-up hands over: a query drop at the level's own tier, rolled from
|
||
* AVATAR ITEMS only. The published table calls these levels "N-Star Clothing", so the prize
|
||
* has to be something the player can wear and be seen in — never an equipment skin for a
|
||
* weapon they may not own. This is the one roll that narrows the pool that far.
|
||
*/
|
||
function toLevelUpDrop(rarity: number): StoreGiftDrop {
|
||
return {
|
||
FriendlyName: '',
|
||
Tooltip: '',
|
||
ConsumableItemDesc: '',
|
||
AvatarItemDesc: '',
|
||
AvatarItemType: null,
|
||
EquipmentPrefabName: '',
|
||
EquipmentModificationGuid: '',
|
||
Rarity: rarity,
|
||
Context: GIFT_CONTEXT_GAME_REWARDS,
|
||
Currency: 0,
|
||
CurrencyType: 0,
|
||
IsQuery: true,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Hand over the rewards a run of level-ups earned — ONE PER LEVEL crossed, since the
|
||
* published table names a reward for every level and a single grant can cross several (a
|
||
* large enough grant could clear the first three levels at 10 XP each). Each arrives as a
|
||
* gift box, announced like any other unasked-for gift.
|
||
*
|
||
* Which reward is per level, not per tier: the early levels pay CONSUMABLES and the rest pay
|
||
* clothing at a rising star rating. The catalog is read once and shared across the boxes.
|
||
* Best-effort as a whole: the XP is banked and the levels are already stored, so a failed
|
||
* roll costs a prize, not the level.
|
||
*/
|
||
async function grantLevelUpGifts(
|
||
c: Context<App>,
|
||
accountId: number,
|
||
grant: XpGrant
|
||
): Promise<void> {
|
||
const levels = levelsReached(grant)
|
||
if (levels.length === 0) return
|
||
try {
|
||
const rollCatalog = await loadRollCatalog(c)
|
||
for (const level of levels) {
|
||
const reward = levelReward(level)
|
||
if (reward === null) continue
|
||
const message = `Level ${level}!`
|
||
// A consumable is rolled to a concrete drop up front; clothing rides the query path,
|
||
// which rolls it against what the player already owns.
|
||
const drop =
|
||
reward.kind === 'consumable'
|
||
? rollConsumableDrop(rollCatalog)
|
||
: toLevelUpDrop(reward.rarity)
|
||
if (drop === null) {
|
||
logger.warn('level up reward rolled nothing', { accountId, level, kind: reward.kind })
|
||
continue
|
||
}
|
||
const granted = await grantGiftDrop(c, accountId, drop, message, {
|
||
avatarItemsOnly: reward.kind === 'clothing',
|
||
rollCatalog,
|
||
})
|
||
await pushGiftReceived(c, accountId, granted, message, COACH_ACCOUNT_ID)
|
||
logger.info('level up gift granted', {
|
||
accountId,
|
||
level,
|
||
kind: reward.kind,
|
||
rarity: reward.kind === 'clothing' ? reward.rarity : null,
|
||
giftId: granted.id,
|
||
avatarItemDesc: granted.drop.AvatarItemDesc,
|
||
consumableItemDesc: granted.drop.ConsumableItemDesc,
|
||
})
|
||
}
|
||
} catch (err) {
|
||
logger.error('failed to grant level up gift', {
|
||
accountId,
|
||
levels,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The rotation's reward, as static/weekly-challenge.json writes it. Same item vocabulary as
|
||
* a storefront `GiftDrop` but with `Context`/`Rarity` spelled `GiftContext`/`GiftRarity`,
|
||
* so it has to be translated before the grant path can read it (see
|
||
* {@link toChallengeGiftDrop}).
|
||
*
|
||
* `FriendlyName`/`Tooltip` are OPTIONAL because the captured rotation has neither — the
|
||
* client resolves the reward's name from the item itself, falling back to
|
||
* `FallbackGiftName`. A rotation we publish can carry them to name the granted item
|
||
* properly without a code change.
|
||
*/
|
||
interface ChallengeGift {
|
||
AvatarItemDesc: string
|
||
AvatarItemType: number
|
||
ConsumableItemDesc: string
|
||
EquipmentPrefabName: string
|
||
EquipmentModificationGuid: string
|
||
GiftContext: number
|
||
GiftRarity: number
|
||
Xp: number
|
||
FriendlyName?: string
|
||
Tooltip?: string
|
||
}
|
||
|
||
/** The message on the gift box the weekly reward arrives in. */
|
||
const CHALLENGE_GIFT_MESSAGE = 'Weekly challenge complete!'
|
||
|
||
/**
|
||
* The star rating → `Rarity` ladder, indexed by stars - 1. Pinned by sf2's "Star Boxes"
|
||
* item set, whose three members name their own tier and carry the rarity they roll at:
|
||
* 2-Star → 10, 3-Star → 20, 4-Star → 30. The ends are extrapolated from sf3's parallel
|
||
* "Random box" family (Common 0, Uncommon 10, Rare 20, Epic 30, Legendary 50), which is the
|
||
* same ladder under the other naming.
|
||
*/
|
||
const STAR_RARITY = [0, 10, 20, 30, 50]
|
||
|
||
/** The tier a "4-Star Box" rolls at, used when a rotation's fallback name doesn't parse. */
|
||
const DEFAULT_FALLBACK_STARS = 4
|
||
|
||
/**
|
||
* The rarity the rotation's `FallbackGiftName` promises, read off the leading star count
|
||
* ("4-Star Box" → 30). That string is the whole specification of the consolation prize —
|
||
* it is what the client renders when the gift resolves to a box rather than a named item —
|
||
* so a rotation can retune the tier by renaming it, with no code change.
|
||
*/
|
||
function fallbackGiftRarity(): number {
|
||
const stars = Number(/^(\d+)-star/i.exec(weeklyChallenge.FallbackGiftName)?.[1])
|
||
return STAR_RARITY[stars - 1] ?? STAR_RARITY[DEFAULT_FALLBACK_STARS - 1] ?? 0
|
||
}
|
||
|
||
/**
|
||
* Translate the rotation's `Gift` block into the storefront gift-drop shape the grant path
|
||
* reads. The renamed fields are the whole point — feeding one shape to the other's reader
|
||
* silently drops the rarity and context.
|
||
*
|
||
* The reward carries no price, so `Currency`/`CurrencyType` are zero: the box shows an
|
||
* item, not a payout. Display strings come from the block when it carries them; a block
|
||
* that doesn't (the captured rotation names neither) borrows them from the catalog entry
|
||
* selling the same item, so the granted item reads as itself — "Camera Skin (Comic)" rather
|
||
* than the name of the box it might have arrived in.
|
||
*/
|
||
function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop {
|
||
const gift = weeklyChallenge.Gift as ChallengeGift
|
||
const sold = catalog.find(
|
||
({ GiftDrop: drop }) =>
|
||
(gift.EquipmentModificationGuid !== '' &&
|
||
drop.EquipmentModificationGuid === gift.EquipmentModificationGuid) ||
|
||
(gift.AvatarItemDesc !== '' && drop.AvatarItemDesc === gift.AvatarItemDesc)
|
||
)?.GiftDrop
|
||
return {
|
||
FriendlyName: gift.FriendlyName ?? sold?.FriendlyName ?? weeklyChallenge.FallbackGiftName,
|
||
Tooltip: gift.Tooltip ?? sold?.Tooltip ?? '',
|
||
ConsumableItemDesc: gift.ConsumableItemDesc,
|
||
AvatarItemDesc: gift.AvatarItemDesc,
|
||
AvatarItemType: gift.AvatarItemType,
|
||
EquipmentPrefabName: gift.EquipmentPrefabName,
|
||
EquipmentModificationGuid: gift.EquipmentModificationGuid,
|
||
// The block's own `GiftRarity` is 0 in the captured rotation even though the item it
|
||
// names sells at rarity 5, so the catalog's rarity wins where there is one.
|
||
Rarity: sold?.Rarity ?? gift.GiftRarity,
|
||
Context: gift.GiftContext,
|
||
Currency: 0,
|
||
CurrencyType: 0,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The consolation box: a query drop at the rarity `FallbackGiftName` promises, named after
|
||
* it. Handed over instead of the rotation's item when that item would be a duplicate, which
|
||
* is what the fallback name is for — the reward reads "the Camera Skin, or a 4-Star Box".
|
||
*/
|
||
function toChallengeFallbackDrop(): StoreGiftDrop {
|
||
return {
|
||
FriendlyName: weeklyChallenge.FallbackGiftName,
|
||
Tooltip: '',
|
||
ConsumableItemDesc: '',
|
||
AvatarItemDesc: '',
|
||
AvatarItemType: null,
|
||
EquipmentPrefabName: '',
|
||
EquipmentModificationGuid: '',
|
||
Rarity: fallbackGiftRarity(),
|
||
Context: (weeklyChallenge.Gift as ChallengeGift).GiftContext,
|
||
Currency: 0,
|
||
CurrencyType: 0,
|
||
IsQuery: true,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* How many of a rotation's challenges earn its gift. A week presents five and asks for
|
||
* three: the reward is for playing most of the week's set, not for clearing all of it, so
|
||
* the two a player can't reach (a quest they don't own, a mode they don't like) don't sink
|
||
* the whole week.
|
||
*/
|
||
const CHALLENGES_REQUIRED_FOR_GIFT = 3
|
||
|
||
/**
|
||
* How many completions this rotation's gift needs. `CompletedRequired` makes the set
|
||
* all-or-nothing when it's true — the reading its name and the partial default suggest —
|
||
* and a rotation shorter than the threshold can only ever ask for what it publishes.
|
||
*/
|
||
function challengesRequiredForGift(): number {
|
||
const published = weeklyChallenge.Challenges.length
|
||
return weeklyChallenge.CompletedRequired
|
||
? published
|
||
: Math.min(CHALLENGES_REQUIRED_FOR_GIFT, published)
|
||
}
|
||
|
||
/**
|
||
* Award the rotation's `Gift` if this player has just earned it, doing nothing otherwise.
|
||
* Called after each completing progress report, since `updateProgress` is the only place a
|
||
* challenge is ever finished — there is no separate claim endpoint, and the client never
|
||
* asks for this reward.
|
||
*
|
||
* Earning it takes {@link challengesRequiredForGift} of the rotation's challenges, counted
|
||
* from `challenge_status`. Only challenges the rotation still publishes count: a report can
|
||
* carry an id this week's set no longer lists (an edited rotation under a live client), and
|
||
* three of those shouldn't buy a gift the player never worked for.
|
||
*
|
||
* What lands is the `Gift` block's item — or, if the player already owns it, the box named
|
||
* by `FallbackGiftName`, which rolls something they don't have at that tier. Finishing the
|
||
* week can't be worth nothing, and the rotation's reward is one fixed item that plenty of
|
||
* players will have bought already.
|
||
*
|
||
* A grant that throws is swallowed: the client is reporting gameplay progress, and failing
|
||
* that report (which it would then retry with the same completion) is worse than missing
|
||
* the reward — the claim row is already taken, so the miss is permanent but visible in the
|
||
* logs. An empty rotation earns nothing: its threshold clamps to zero, which every player
|
||
* would otherwise meet without playing.
|
||
*/
|
||
async function awardChallengeGift(c: Context<App>, accountId: number): Promise<void> {
|
||
try {
|
||
if (weeklyChallenge.Challenges.length === 0) return
|
||
const complete = await getCompletedChallengeIds(
|
||
c.env.DB,
|
||
accountId,
|
||
weeklyChallenge.ChallengeMapId
|
||
)
|
||
const done = weeklyChallenge.Challenges.filter((ch) => complete.has(ch.ChallengeId)).length
|
||
if (done < challengesRequiredForGift()) return
|
||
// Claim first: this is what stops the next report paying out a second time.
|
||
const claimed = await claimChallengeGift(c.env.DB, accountId, weeklyChallenge.ChallengeMapId)
|
||
if (!claimed) return
|
||
const catalog = await loadRollCatalog(c)
|
||
const reward = toChallengeGiftDrop(catalog)
|
||
const duplicate = await ownsGiftDrop(c.env.DB, accountId, reward)
|
||
const granted = await grantGiftDrop(
|
||
c,
|
||
accountId,
|
||
duplicate ? toChallengeFallbackDrop() : reward,
|
||
CHALLENGE_GIFT_MESSAGE,
|
||
{ rollCatalog: catalog }
|
||
)
|
||
// Nobody asked for this box, so the client has no reason to re-read the gifts list:
|
||
// the notification is what makes the reward show up at the moment the set is finished.
|
||
// From "Coach", the same system sender a self-buy is attributed to — the rotation is
|
||
// the server handing something over, not another player.
|
||
await pushGiftReceived(c, accountId, granted, CHALLENGE_GIFT_MESSAGE, COACH_ACCOUNT_ID)
|
||
logger.info('weekly challenge gift granted', {
|
||
accountId,
|
||
challengeMapId: weeklyChallenge.ChallengeMapId,
|
||
giftId: granted.id,
|
||
fallbackRoll: duplicate,
|
||
challengesComplete: done,
|
||
})
|
||
} catch (err) {
|
||
logger.error('failed to grant weekly challenge gift', {
|
||
accountId,
|
||
challengeMapId: weeklyChallenge.ChallengeMapId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A concise `describeRoute` spec for a route that serves an opaque JSON array — either
|
||
* a static catalog served verbatim or an empty-list stub. `auth` adds the bearer
|
||
* requirement + a 401 response.
|
||
*/
|
||
function listRoute(summary: string, description: string, auth = false) {
|
||
return describeRoute({
|
||
tags: ['Econ'],
|
||
summary,
|
||
description,
|
||
...(auth ? { security: AUTHED } : {}),
|
||
responses: {
|
||
200: json(JsonArray, description),
|
||
...(auth ? { 401: UNAUTHORIZED_RESPONSE } : {}),
|
||
},
|
||
})
|
||
}
|
||
|
||
// 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
|
||
(c, next) =>
|
||
useWorkersLogger(c.env.NAME, {
|
||
environment: c.env.ENVIRONMENT,
|
||
release: c.env.SENTRY_RELEASE,
|
||
})(c, next)
|
||
)
|
||
|
||
.onError(withOnError())
|
||
.notFound(withNotFound())
|
||
|
||
// Default-unlocked avatar items, served from the bundled static JSON.
|
||
.get(
|
||
'/api/avatar/v1/defaultunlocked',
|
||
listRoute('Default-unlocked avatar items', 'The bundled default avatar-item catalog'),
|
||
(c) => c.json(defaultAvatarItems)
|
||
)
|
||
|
||
// Default base avatar items — empty stub for now. No auth.
|
||
.get(
|
||
'/api/avatar/v1/defaultbaseavataritems',
|
||
listRoute('Default base avatar items', 'Empty stub for now'),
|
||
(c) => c.json([])
|
||
)
|
||
|
||
// 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',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'The player’s avatar items',
|
||
description: [
|
||
'The items the player has bought (from buyItem, in the inventory table) prepended',
|
||
'to the default catalog. A player who has bought nothing gets just the catalog.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(JsonArray, 'Owned items followed by the default catalog'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
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
|
||
// now (no DB binding). The client downloads these when custom-item creation is
|
||
// allowed; a 404 here surfaces as "Failed to download unlocked avatar items".
|
||
.get(
|
||
'/econ/customAvatarItems/v1/owned',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Owned custom avatar items',
|
||
description: [
|
||
'Paginated owned custom items. Empty stub for now. The client requests this when',
|
||
'custom-item creation is allowed; a 404 shows as “Failed to download unlocked',
|
||
'avatar items”.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(CustomAvatarItemsResponse, 'Paginated results (empty for now)'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json({ Results: [], TotalResults: 0 })
|
||
}
|
||
)
|
||
|
||
// The player's objectives progress. Serves a static JSON file verbatim with
|
||
// no auth — same default for everyone until there's a DB binding to track
|
||
// per-player progress.
|
||
.get(
|
||
'/api/objectives/v1/myprogress',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Objectives progress',
|
||
description:
|
||
'Serves the bundled static progress verbatim (no per-player store yet). No auth.',
|
||
responses: { 200: json(JsonObject, 'The bundled objectives-progress default') },
|
||
}),
|
||
(c) => c.json(myProgress)
|
||
)
|
||
|
||
// Clears a group of objectives. No per-player progress to clear yet, so this
|
||
// is a no-op that returns an empty array (a 404 here breaks the client). Accepts
|
||
// GET or POST since the client may use either.
|
||
.on(
|
||
['GET', 'POST'],
|
||
'/api/objectives/v1/cleargroup',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Clear an objectives group (no-op)',
|
||
description: 'No per-player progress to clear yet → []. Accepts GET or POST.',
|
||
responses: { 200: json(JsonArray, 'Always empty for now') },
|
||
}),
|
||
(c) => c.json([])
|
||
)
|
||
|
||
// Report one objective's progress. The client posts the whole objective as it now
|
||
// sees it (Index/Group identify it within `myprogress`) and reads back the state of
|
||
// the GROUP that objective belongs to — camelCase here, unlike the PascalCase body it
|
||
// posted. Stubbed: with no objectives store yet we persist nothing, echo the group
|
||
// back and never complete it, so the reward-claim flow isn't triggered. `clearedAt`
|
||
// is the clear time, which for a group we didn't clear is just now.
|
||
.post(
|
||
'/api/objectives/v1/updateobjective',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Report objective progress',
|
||
description: [
|
||
'Stubbed: with no objectives store we persist nothing and never complete a group.',
|
||
'Echoes `Group` back as camelCase `group` with `isCompleted: false` so the client',
|
||
'gets a well-formed body.',
|
||
].join(' '),
|
||
requestBody: jsonBody(UpdateObjectiveRequest, 'The objective as the client now sees it'),
|
||
responses: { 200: json(UpdateObjectiveResponse, 'The echoed group, never completed') },
|
||
}),
|
||
async (c) => {
|
||
const body = await c.req
|
||
.json<{ Group?: string | number }>()
|
||
.catch(() => ({}) as Record<string, never>)
|
||
return c.json({
|
||
group: Number(body.Group) || 0,
|
||
isCompleted: false,
|
||
clearedAt: new Date().toISOString(),
|
||
})
|
||
}
|
||
)
|
||
|
||
// The player's avatar, stored as a JSON blob on their account row. Falls back
|
||
// to the default outfit when they haven't saved one — the client's parser NREs
|
||
// on an empty OutfitSelections (real RecNet never returns one).
|
||
.get(
|
||
'/api/avatar/v2',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'The player’s own avatar',
|
||
description: [
|
||
'The avatar JSON blob stored on the account row, or the default outfit when none is',
|
||
'saved (the client NREs on an empty OutfitSelections).',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(JsonObject, 'The stored avatar blob (or the default)'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json((await getAvatar(c.env.DB, id)) ?? defaultAvatar)
|
||
}
|
||
)
|
||
|
||
// Save the player's avatar. [Authorize]. Stores the posted JSON payload verbatim
|
||
// on the account row and echoes it back. 400 on a non-object body; 404 when the
|
||
// caller has no account row to attach it to.
|
||
.post(
|
||
'/api/avatar/v2/set',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Save the player’s avatar',
|
||
description: 'Stores the posted JSON blob verbatim on the account row and echoes it back.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(OpaqueJsonBody, 'The avatar blob'),
|
||
responses: {
|
||
200: json(JsonObject, 'The saved avatar (echoed back)'),
|
||
400: { description: 'Body was not a JSON object (empty body)' },
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
404: { description: 'No account row to attach it to (empty body)' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const avatar = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||
if (avatar === null || typeof avatar !== 'object' || Array.isArray(avatar)) {
|
||
return c.body(null, 400)
|
||
}
|
||
if (!(await setAvatar(c.env.DB, id, avatar))) return c.body(null, 404)
|
||
return c.json(avatar)
|
||
}
|
||
)
|
||
|
||
// NUX checklist — the client fetches this on the econ host during load. []
|
||
// with no DB. A 404 here can abort the load orchestration before matchmake.
|
||
.get(
|
||
'/api/checklist/v1/current',
|
||
listRoute('NUX checklist', 'The new-user checklist; [] for now. A 404 can abort load.', true),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json([])
|
||
}
|
||
)
|
||
|
||
// The player's item wishlist. [Authorize]; empty without a DB binding.
|
||
.get(
|
||
'/api/itemWishlists/v1/wishlist/me',
|
||
listRoute('The player’s item wishlist', 'Empty for now', true),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json([])
|
||
}
|
||
)
|
||
|
||
// The player's saved outfits. [Authorize]. Served back as the client posted them
|
||
// (see /saved/set); a player who has saved none gets [].
|
||
.get(
|
||
'/api/avatar/v3/saved',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'The player’s saved outfits',
|
||
description: 'Served back as the client posted them (see /saved/set); [] when none.',
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(JsonArray, 'Saved outfits (empty when none)'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json(await getOutfits(c.env.DB, id))
|
||
}
|
||
)
|
||
|
||
// Save an outfit into one of the player's slots. [Authorize]. The posted `Slot` is
|
||
// the slot to write, and re-saving a slot overwrites it — that's the avatar screen's
|
||
// "save over this outfit". The payload is stored verbatim and echoed back: its inner
|
||
// fields (OutfitSelectionsV2, FaceFeatures, …) are JSON-in-a-string from the client's
|
||
// own serializer, so re-encoding them risks handing back something it can't parse.
|
||
//
|
||
// A missing/non-integer `Slot` is a 400 rather than a default slot — guessing would
|
||
// silently overwrite an outfit the player didn't mean to touch.
|
||
//
|
||
// v3 and v4 share this handler: newer clients POST to /v4/saved/set with the same
|
||
// payload shape (Slot, PreviewImageName, OutfitSelections(V2), FaceFeatures, Skin/HairColor,
|
||
// CustomAvatarItems) and expect the same slot-overwrite semantics, so they store into the
|
||
// same outfit table and read back through /api/avatar/v3/saved.
|
||
.post(
|
||
'/api/avatar/v3/saved/set',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Save an outfit into a slot',
|
||
description: [
|
||
'Writes the posted outfit into the given `Slot` (overwriting it) and echoes it back.',
|
||
'The payload is stored verbatim — its inner fields are JSON-in-a-string from the',
|
||
'client’s own serializer. A missing/non-integer `Slot` is a 400 (guessing would',
|
||
'silently overwrite another outfit).',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'),
|
||
responses: {
|
||
200: json(JsonObject, 'The saved outfit (echoed back)'),
|
||
400: { description: 'Non-object body or missing/non-integer Slot (empty body)' },
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const result = await persistPostedOutfit(c)
|
||
if (result instanceof Response) return result
|
||
return c.json(result)
|
||
}
|
||
)
|
||
|
||
// v4 of the save-outfit route. Same payload, table and slot-overwrite semantics as v3
|
||
// (see above) — newer clients moved to /v4/saved/set. The one difference is the response:
|
||
// v4 answers a lean `{ Success, Slot }` acknowledgement rather than echoing the whole
|
||
// outfit back. The outfit is read back through /api/avatar/v3/saved either way.
|
||
.post(
|
||
'/api/avatar/v4/saved/set',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Save an outfit into a slot (v4)',
|
||
description: [
|
||
'Writes the posted outfit into the given `Slot` (overwriting it), same as',
|
||
'`POST /api/avatar/v3/saved/set`, but answers a lean `{ Success, Slot }` ack instead',
|
||
'of echoing the outfit. A missing/non-integer `Slot` is a 400.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'),
|
||
responses: {
|
||
200: json(SaveOutfitV4Response, 'Save acknowledgement'),
|
||
400: { description: 'Non-object body or missing/non-integer Slot (empty body)' },
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const result = await persistPostedOutfit(c)
|
||
if (result instanceof Response) return result
|
||
return c.json({ Success: true, Slot: result.Slot })
|
||
}
|
||
)
|
||
|
||
// 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',
|
||
describeRoute({
|
||
tags: ['Gifts'],
|
||
summary: 'Pending gift boxes',
|
||
description: [
|
||
'The player’s unopened gift boxes from their purchases (and, later, from other',
|
||
'players). The item was already granted at purchase, so an unopened box is cosmetic.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(JsonArray, 'Unopened gift boxes (empty when none)'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
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',
|
||
describeRoute({
|
||
tags: ['Gifts'],
|
||
summary: 'Open (consume) a gift box',
|
||
description: [
|
||
'Deletes the box (the item was already granted at purchase). Always answers the',
|
||
'`{ error, success, value }` envelope with HTTP 200 — even with no token, a zero id,',
|
||
'or a box already gone — because the client parses it to finish opening the box. The',
|
||
'delete is scoped to the caller; opening someone else’s box is 403. Also served by',
|
||
'the `api` worker.',
|
||
].join(' '),
|
||
requestBody: form(ConsumeGiftRequest, 'The gift-box id'),
|
||
responses: {
|
||
200: json(ConsumeEnvelope, 'Success envelope'),
|
||
403: { description: 'The box belongs to another player (empty body)' },
|
||
},
|
||
}),
|
||
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) {
|
||
// Scoped delete: only the box's owner deletes it. A returned box means it was
|
||
// theirs and is now consumed.
|
||
const gift = await consumeGift(c.env.DB, id, giftId)
|
||
if (gift !== null) {
|
||
// If the box carried a consumable, tell the client it now has it (so it shows
|
||
// up in inventory without a refetch). Avatar-item boxes carry no ConsumableItemDesc.
|
||
if (gift.ConsumableItemDesc !== '') await pushConsumableAdded(c, id, gift)
|
||
} else {
|
||
// Nothing was consumed: either the box is already gone (a harmless no-op —
|
||
// re-opening your own consumed box still succeeds) or it belongs to another
|
||
// player, which is forbidden.
|
||
const other = await getGift(c.env.DB, giftId)
|
||
if (other !== null && other.accountId !== id) return c.body(null, 403)
|
||
}
|
||
}
|
||
return c.json({ error: '', success: true, value: null })
|
||
}
|
||
)
|
||
|
||
// A player's avatar by account id, projected to the public render subset (used
|
||
// to draw other players' avatars). No auth — like the accounts `/account/:id`
|
||
// lookup. Falls back to the default outfit when the player hasn't saved one.
|
||
// Registered after the static `/api/avatar/v2/*` routes so `:id` can't shadow them.
|
||
.get(
|
||
'/api/avatar/v2/:id',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Another player’s avatar (render subset)',
|
||
description: [
|
||
'The public render subset used to draw another player’s avatar. No auth. Falls back',
|
||
'to the default outfit when the player hasn’t saved one.',
|
||
].join(' '),
|
||
parameters: [
|
||
{
|
||
name: 'id',
|
||
in: 'path',
|
||
required: true,
|
||
description: 'Account id; non-numeric is 400',
|
||
schema: { type: 'string' },
|
||
},
|
||
],
|
||
responses: {
|
||
200: json(AvatarV2Dto, 'The render subset'),
|
||
400: { description: 'Non-numeric id (empty body)' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const accountId = Number.parseInt(c.req.param('id'), 10)
|
||
if (Number.isNaN(accountId)) return c.body(null, 400)
|
||
return c.json(toAvatarV2Dto((await getAvatar(c.env.DB, accountId)) ?? defaultAvatar))
|
||
}
|
||
)
|
||
|
||
// Unlocked equipment. [Authorize]. The equipment skins the player has bought (from
|
||
// `buyItem`, stored in the `equipment` table). A player who has bought none gets an
|
||
// empty list.
|
||
.get(
|
||
'/api/equipment/v2/getUnlocked',
|
||
listRoute('Unlocked equipment', 'The equipment skins the player has bought', true),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json(await getEquipment(c.env.DB, id))
|
||
}
|
||
)
|
||
|
||
// Favourite/un-favourite owned equipment. [Authorize]. The client PUTs the entries
|
||
// it wants changed (one request can carry several) and reads nothing back. Only
|
||
// `Favorited` is written — the rest of each entry is the client echoing what it was
|
||
// served, and a guid the caller doesn't own matches no row and is dropped.
|
||
.put(
|
||
'/api/equipment/v1/update',
|
||
describeRoute({
|
||
tags: ['Equipment'],
|
||
summary: 'Update owned equipment',
|
||
description: [
|
||
'Applies the posted `Favorited` flags to the caller’s owned equipment, matched by',
|
||
'`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller',
|
||
'doesn’t own is silently skipped. Empty body on success.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'),
|
||
responses: {
|
||
200: { description: 'Applied (empty body)' },
|
||
400: { description: 'Body isn’t a JSON array (empty body)' },
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const body = (await c.req.json().catch(() => null)) as unknown
|
||
if (!Array.isArray(body)) return c.body(null, 400)
|
||
const updates = body
|
||
.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null)
|
||
.filter((e) => typeof e.ModificationGuid === 'string' && e.ModificationGuid !== '')
|
||
.map((e) => ({
|
||
ModificationGuid: e.ModificationGuid as string,
|
||
Favorited: e.Favorited === true,
|
||
}))
|
||
await setEquipmentFavorited(c.env.DB, id, updates)
|
||
return c.body(null, 200)
|
||
}
|
||
)
|
||
|
||
// Room consumables/currencies for a given room. Stubbed as empty lists so the
|
||
// client doesn't 404.
|
||
.get(
|
||
'/api/roomconsumables/v1/roomConsumable/room/:roomId',
|
||
listRoute('Room consumables', 'Empty stub so the client doesn’t 404'),
|
||
(c) => c.json([])
|
||
)
|
||
.get(
|
||
'/api/roomconsumables/v1/roomConsumable/room/:roomId/me',
|
||
listRoute('The caller’s room consumables', 'Empty stub'),
|
||
(c) => c.json([])
|
||
)
|
||
.get('/api/roomcurrencies/v1/currencies', listRoute('Room currencies', 'Empty stub'), (c) =>
|
||
c.json([])
|
||
)
|
||
.get('/api/roomcurrencies/v1/getAllBalances', listRoute('Room balances', 'Empty stub'), (c) =>
|
||
c.json([])
|
||
)
|
||
|
||
// 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',
|
||
describeRoute({
|
||
tags: ['Consumables'],
|
||
summary: 'Unlocked consumables',
|
||
description: [
|
||
'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.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(JsonArray, 'Grouped unlocked consumables (empty when none)'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json(await getConsumables(c.env.DB, id))
|
||
}
|
||
)
|
||
|
||
// Consume a quantity of an owned consumable instance. [Authorize]. Body is JSON
|
||
// `{ Id, DeltaCount }` where `Id` is the consumable row id. Reduces that instance's
|
||
// count by DeltaCount, deleting the row once it hits zero. Scoped to the caller so
|
||
// they can only consume their own. Envelope mirrors the gift-consume ack.
|
||
.post(
|
||
'/api/consumables/v1/consume',
|
||
describeRoute({
|
||
tags: ['Consumables'],
|
||
summary: 'Consume a quantity of an owned consumable',
|
||
description: [
|
||
'Reduces the given consumable instance’s count by `DeltaCount` (default 1), deleting',
|
||
'the row at zero. Scoped to the caller. Pushes a ConsumableMappingRemoved socket',
|
||
'notification. Envelope mirrors the gift-consume ack.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(ConsumeConsumableRequest, 'The consumable id and delta'),
|
||
responses: {
|
||
200: json(ConsumeEnvelope, 'Success envelope'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const body = await c.req
|
||
.json<{ Id?: unknown; DeltaCount?: unknown }>()
|
||
.catch(() => ({}) as { Id?: unknown; DeltaCount?: unknown })
|
||
const consumableId = typeof body.Id === 'number' ? body.Id : Number.NaN
|
||
const delta = typeof body.DeltaCount === 'number' ? body.DeltaCount : 1
|
||
if (!Number.isNaN(consumableId) && delta > 0) {
|
||
const consumed = await consumeConsumable(c.env.DB, id, consumableId, delta)
|
||
// Notify the player so their client removes/updates the item in inventory.
|
||
if (consumed !== null) await pushConsumableRemoved(c, id, consumed)
|
||
}
|
||
return c.json({ error: '', success: true, value: null })
|
||
}
|
||
)
|
||
|
||
// Currency balance. [Authorize]. The trailing int is a CurrencyType — the client
|
||
// fetches `/balance/2` (RecCenterTokens) on load. Backed by the `balance` table; a
|
||
// player who has never been granted gets their starting balance on this first read.
|
||
//
|
||
// An unknown or non-account-scoped currency (a room currency, ProgressionEvent,
|
||
// Invalid) returns a 0 balance rather than 404: the client treats a failed balance
|
||
// fetch as a load error, and "you have none of that" is the honest answer anyway.
|
||
.get(
|
||
'/api/storefronts/v4/balance/:currencyType',
|
||
describeRoute({
|
||
tags: ['Storefront'],
|
||
summary: 'Currency balance',
|
||
description: [
|
||
'The player’s balance in a CurrencyType (the client fetches `/balance/2`,',
|
||
'RecCenterTokens, on load). A first read seeds their starting balance. An unknown or',
|
||
'non-account currency returns a 0 balance rather than 404.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
parameters: [
|
||
{
|
||
name: 'currencyType',
|
||
in: 'path',
|
||
required: true,
|
||
description: 'CurrencyType integer; non-numeric is 400',
|
||
schema: { type: 'string' },
|
||
},
|
||
],
|
||
responses: {
|
||
200: json(BalanceEntry.array(), 'A single-entry balance array'),
|
||
400: { description: 'Non-numeric currencyType (empty body)' },
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const currencyType = Number.parseInt(c.req.param('currencyType'), 10)
|
||
if (Number.isNaN(currencyType)) return c.body(null, 400)
|
||
const amount = isSpendable(currencyType)
|
||
? await getBalance(
|
||
c.env.DB,
|
||
id,
|
||
currencyType,
|
||
intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||
)
|
||
: 0
|
||
return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }])
|
||
}
|
||
)
|
||
|
||
// Gift-drop storefront. Serves `static/storefronts/sf{id}.json` for the requested
|
||
// storefront id via the ASSETS binding; 404s when no such catalog exists.
|
||
.get(
|
||
'/api/storefronts/v3/giftdropstore/:id',
|
||
describeRoute({
|
||
tags: ['Storefront'],
|
||
summary: 'Gift-drop storefront catalog',
|
||
description: 'Serves the `sf{id}.json` catalog via the ASSETS binding. 404 when none exists.',
|
||
parameters: [
|
||
{
|
||
name: 'id',
|
||
in: 'path',
|
||
required: true,
|
||
description: 'Storefront id (selects sf{id}.json)',
|
||
schema: { type: 'string' },
|
||
},
|
||
],
|
||
responses: {
|
||
200: json(JsonObject, 'The storefront catalog'),
|
||
404: { description: 'No such storefront catalog' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = c.req.param('id')
|
||
const res = await c.env.ASSETS.fetch(new URL(`/sf${id}.json`, c.req.url))
|
||
if (!res.ok) return c.notFound()
|
||
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',
|
||
describeRoute({
|
||
tags: ['Storefront'],
|
||
summary: 'Buy a storefront item',
|
||
description: [
|
||
'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice`',
|
||
'still matches, debits the buyer atomically, grants the item (into the inventory or',
|
||
'consumable table), and returns a gift box. A `Gift` block routes the item to another',
|
||
'player, but the caller always pays. `Balance` in the response is the CHANGE (negated',
|
||
'price), not the new total. Pushes a StorefrontBalanceUpdate socket frame carrying the',
|
||
'same change, which the client ADDS to the balance it is showing.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'),
|
||
responses: {
|
||
200: json(BuyItemResponse, 'The purchase result (gift box + balance change)'),
|
||
400: json(ErrorResponse, 'Invalid body, unavailable currency, or insufficient balance'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
404: json(ErrorResponse, 'No such item'),
|
||
409: json(ErrorResponse, 'The price has changed since the client rendered it'),
|
||
},
|
||
}),
|
||
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, with the gift box that renders it. A box (an
|
||
// `IsQuery` drop, e.g. sf2's "4-Star Unique Box") rolls its prize in here, and
|
||
// `granted.drop` is what the roll landed on — the response has to describe THAT, not
|
||
// the box, or a query purchase answers with every item field empty and the client
|
||
// draws an empty box.
|
||
const { id: giftId, drop: granted } = await grantGiftDrop(
|
||
c,
|
||
receiverId,
|
||
item.GiftDrop,
|
||
message
|
||
)
|
||
|
||
// Push the debit over the socket so the buyer's client updates the shown total
|
||
// immediately — the buyer (`id`) is who was charged, in the currency they spent. The
|
||
// frame carries the CHANGE, so a purchase is negative. Best-effort; the HTTP response
|
||
// carries the same change either way.
|
||
await pushBalanceUpdate(c, id, currencyType as number, -price.Price)
|
||
|
||
// 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 — the rolled item for a query box, the
|
||
// bought drop otherwise — and 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: granted.ConsumableItemDesc,
|
||
AvatarItemDesc: granted.AvatarItemDesc,
|
||
AvatarItemType: granted.AvatarItemType ?? 0,
|
||
EquipmentPrefabName: granted.EquipmentPrefabName,
|
||
EquipmentModificationGuid: granted.EquipmentModificationGuid,
|
||
CurrencyType: granted.CurrencyType,
|
||
Currency: granted.Currency,
|
||
Xp: granted.Xp ?? 0,
|
||
Level: 0,
|
||
Platform: -1,
|
||
PlatformsToSpawnOn: -1,
|
||
BalanceType: ALL_PLATFORMS,
|
||
GiftContext: Number.isInteger(gift?.GiftContext)
|
||
? (gift?.GiftContext as number)
|
||
: granted.Context,
|
||
GiftRarity: granted.Rarity,
|
||
Message: message,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
Balance: -price.Price,
|
||
CurrencyType: currencyType,
|
||
BalanceType: ALL_PLATFORMS,
|
||
})
|
||
}
|
||
)
|
||
|
||
// Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends
|
||
// `?inventionId=…&requestedPrice=…` with no body, so that's what we answer.
|
||
//
|
||
// A priced invention is settled player-to-player: the buyer is debited its `Price` in
|
||
// RecCenterTokens and the CREATOR is credited the same amount — no house cut, so the
|
||
// tokens are moved rather than minted or burned. A free invention (`Price` 0) skips the
|
||
// money entirely: nothing is debited and nobody is paid. The stored price is confirmed
|
||
// against the price the client rendered first, so a stale or tampered client can't buy
|
||
// at a price the creator no longer offers (409), and an unaffordable one is a 400 —
|
||
// the same "Insufficient balance" buyItem answers with.
|
||
//
|
||
// Ownership is recorded in `inventory_invention`; the creator is not sold their own
|
||
// invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
|
||
// than a second row. The invention's `NumDownloads` counter is deliberately NOT
|
||
// bumped: that column lives on the `invention` table the `api` worker owns, and this
|
||
// worker only reads it.
|
||
.get(
|
||
'/api/storefronts/v2/buyInvention',
|
||
describeRoute({
|
||
tags: ['Storefront'],
|
||
summary: 'Buy an invention',
|
||
description: [
|
||
'Looks the invention up by id, confirms the client’s `requestedPrice` still matches',
|
||
'its stored `Price`, debits the buyer and pays the creator that price in',
|
||
'RecCenterTokens (a free invention moves nothing), records ownership in',
|
||
'`inventory_invention`, and returns the invention alongside the buyer’s resulting',
|
||
'balance. When tokens moved, both players get a StorefrontBalanceUpdate push carrying',
|
||
'their CHANGE (the buyer’s negative, the creator’s positive), which the client adds to',
|
||
'the balance it is showing — unlike this response body, which replaces it.',
|
||
'A GET because that is how the client sends it.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
parameters: [
|
||
{
|
||
name: 'inventionId',
|
||
in: 'query',
|
||
required: true,
|
||
description: 'Invention id; missing or non-numeric is 400',
|
||
schema: { type: 'integer' },
|
||
},
|
||
{
|
||
name: 'requestedPrice',
|
||
in: 'query',
|
||
required: false,
|
||
description: 'The price the client rendered; a mismatch is 409. Defaults to 0',
|
||
schema: { type: 'integer' },
|
||
},
|
||
],
|
||
responses: {
|
||
200: json(BuyInventionResponse, 'The purchase result (invention + balance)'),
|
||
400: json(
|
||
ErrorResponse,
|
||
'Missing/non-numeric inventionId, buying your own, or insufficient balance'
|
||
),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: json(ErrorResponse, 'The invention is not published, so it is not for sale'),
|
||
404: json(ErrorResponse, 'No such invention'),
|
||
409: json(ErrorResponse, 'Already owned, or the price has changed'),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
|
||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||
// Absent/non-numeric requestedPrice reads as 0, which only matches a free invention —
|
||
// a priced one then fails the confirmation below rather than selling for nothing.
|
||
const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0
|
||
|
||
const invention = await getInventionById(c.env.DB, inventionId)
|
||
if (invention === null) return c.json({ error: 'Invention not found' }, 404)
|
||
// An unpublished invention is a draft: it isn't on sale, not even for free.
|
||
if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403)
|
||
if (invention.CreatorPlayerId === id) {
|
||
return c.json({ error: 'Cannot buy your own invention' }, 400)
|
||
}
|
||
if (await ownsInvention(c.env.DB, id, inventionId)) {
|
||
return c.json({ error: 'Already owned' }, 409)
|
||
}
|
||
|
||
// The price the client rendered must still be the stored one: a mismatch is a stale
|
||
// catalog or a tampered request, never a sale.
|
||
if (invention.Price !== requestedPrice) {
|
||
return c.json({ error: 'Price has changed' }, 409)
|
||
}
|
||
|
||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||
// Inventions are priced in RecCenterTokens only — the store shows no other currency
|
||
// for them, and `Price` carries no currency of its own to pick a different one from.
|
||
const price = invention.Price
|
||
if (price > 0) {
|
||
// Debit the buyer atomically; false means they couldn't afford it and nothing
|
||
// changed, so no ownership is recorded and the creator is not paid.
|
||
const paid = await spendCurrency(
|
||
c.env.DB,
|
||
id,
|
||
CurrencyType.RecCenterTokens,
|
||
price,
|
||
startingTokens
|
||
)
|
||
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
|
||
}
|
||
|
||
// Grant before paying out: these are three separate D1 writes with no transaction
|
||
// around them, so order them by what a failure costs. A buyer who paid and got the
|
||
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
|
||
// is not.
|
||
await grantInvention(c.env.DB, id, inventionId)
|
||
|
||
if (price > 0) {
|
||
// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
|
||
// the balance row, and `ensureStartingBalances` is an INSERT OR IGNORE, so a
|
||
// creator who had never touched their balance would otherwise have the row created
|
||
// here and lose their starting tokens forever.
|
||
await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
|
||
await creditCurrency(
|
||
c.env.DB,
|
||
invention.CreatorPlayerId,
|
||
CurrencyType.RecCenterTokens,
|
||
price,
|
||
startingTokens
|
||
)
|
||
// The creator is a different, probably-online player: push the payout so a sale
|
||
// lands on their shown balance without a re-fetch. Positive, because the frame
|
||
// carries the change. Best-effort, as everywhere.
|
||
await pushBalanceUpdate(c, invention.CreatorPlayerId, CurrencyType.RecCenterTokens, price)
|
||
}
|
||
|
||
// Unlike buyItem — whose `Balance` is the change applied — the reference server
|
||
// answers this one with the RESULTING total (a first read seeds the buyer's starting
|
||
// grant, as everywhere else). The socket frame below is the other way round: the HTTP
|
||
// body REPLACES the shown balance, the push ADDS to it.
|
||
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
|
||
// A free invention moved nothing, so there is no change to push for it.
|
||
if (price > 0) {
|
||
await pushBalanceUpdate(c, id, CurrencyType.RecCenterTokens, -price)
|
||
}
|
||
return c.json({
|
||
BalanceUpdateResponse: {
|
||
Balance: balance,
|
||
BalanceType: ALL_PLATFORMS,
|
||
CurrencyType: CurrencyType.RecCenterTokens,
|
||
BalanceUpdates: [{ UpdateResponse: 0, Data: invention }],
|
||
},
|
||
// The same `{ Status, Invention, InventionVersion }` envelope the invention
|
||
// save/read endpoints serve — the client re-renders the invention from it.
|
||
InventionResponse: toSaveResult(invention),
|
||
})
|
||
}
|
||
)
|
||
|
||
// 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',
|
||
listRoute('Storefront ad-carousel items', 'The bundled carousel (one placeholder banner)'),
|
||
(c) => c.json(adCarouselItems)
|
||
)
|
||
|
||
// Current weekly challenge. The rotation itself is the bundled static JSON (its format
|
||
// is documented in the README) but each challenge's `Complete` is per-player, so the
|
||
// caller's rows from `challenge_status` are stamped over the static `false`s.
|
||
// Auth is OPTIONAL: without a valid bearer the static catalog is served unchanged
|
||
// rather than 401, since the rotation is public information and a 404/401 on this
|
||
// route can stall the client's load orchestration.
|
||
.get(
|
||
'/api/challenge/v2/getCurrent',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Current weekly challenge',
|
||
description: [
|
||
'The bundled static rotation, with each challenge’s `Complete` stamped from the',
|
||
'caller’s progress rows. Auth is optional — unauthenticated callers get the static',
|
||
'catalog with every `Complete` false.',
|
||
].join(' '),
|
||
security: OPTIONAL_AUTHED,
|
||
responses: { 200: json(JsonObject, 'The current weekly challenge') },
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return c.json(weeklyChallenge)
|
||
const complete = await getCompletedChallengeIds(c.env.DB, id, weeklyChallenge.ChallengeMapId)
|
||
if (complete.size === 0) return c.json(weeklyChallenge)
|
||
// Rebuild rather than mutate: the static import is module state shared by every
|
||
// request this isolate serves, so stamping it in place would leak one player's
|
||
// completions to the next caller.
|
||
return c.json({
|
||
...weeklyChallenge,
|
||
Challenges: weeklyChallenge.Challenges.map((challenge) => ({
|
||
...challenge,
|
||
Complete: complete.has(challenge.ChallengeId),
|
||
})),
|
||
})
|
||
}
|
||
)
|
||
|
||
// Report progress on a weekly challenge. [Authorize]. The client evaluates the
|
||
// challenge's rule tree locally and posts ChallengeMapId/ChallengeId, that tree in
|
||
// `Config`, and whether it now considers the challenge `Complete`. Only the
|
||
// completion is persisted (keyed by account + challenge); `Config` is the catalog's
|
||
// own definition plus the client's running count, so storing it would duplicate
|
||
// static data. Echoes the identifying fields back with the completion the row now
|
||
// holds — which is not always what was posted, since completion latches within a
|
||
// rotation.
|
||
.post(
|
||
'/api/challenge/v2/updateProgress',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Report weekly-challenge progress',
|
||
description: [
|
||
'Persists the reported completion into `challenge_status`, keyed by account +',
|
||
'challenge. `Config` is accepted and echoed but not stored. Completion latches within',
|
||
'a rotation, so the echoed `Complete` is the stored value, not the posted one.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'),
|
||
responses: {
|
||
200: json(ChallengeProgressResponse, 'Echoed fields with the stored completion'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const body = await c.req
|
||
.json<{
|
||
ChallengeMapId?: string | number
|
||
ChallengeId?: string | number
|
||
Config?: string
|
||
Complete?: string | boolean
|
||
}>()
|
||
.catch(() => ({}) as Record<string, never>)
|
||
const challengeMapId = Number(body.ChallengeMapId) || 0
|
||
const challengeId = Number(body.ChallengeId) || 0
|
||
// Nothing to key a row on — echo the body back rather than writing a (0, 0) row.
|
||
const complete =
|
||
challengeId === 0
|
||
? parseBool(body.Complete)
|
||
: await recordChallengeProgress(c.env.DB, id, {
|
||
challengeMapId,
|
||
challengeId,
|
||
complete: parseBool(body.Complete),
|
||
})
|
||
// This report may have been the last one of the set. Only a completing report on
|
||
// the LIVE rotation can be — an old rotation's set can no longer be finished, and
|
||
// an unfinished challenge means the set isn't either, so neither is worth a read.
|
||
// The response is unchanged whether or not a gift was won: the client learns about
|
||
// the box from `GET /api/avatar/v2/gifts`, and adding a field here would be
|
||
// inventing response shape the client never sent us.
|
||
if (complete && challengeId !== 0 && challengeMapId === weeklyChallenge.ChallengeMapId) {
|
||
await awardChallengeGift(c, id)
|
||
}
|
||
return c.json({
|
||
ChallengeMapId: challengeMapId,
|
||
ChallengeId: challengeId,
|
||
Config: typeof body.Config === 'string' ? body.Config : '',
|
||
Complete: complete,
|
||
})
|
||
}
|
||
)
|
||
|
||
// Pending game rewards. Returns "[]".
|
||
.get('/api/gamerewards/v1/pending', listRoute('Pending game rewards', 'Empty for now'), (c) =>
|
||
c.json([])
|
||
)
|
||
|
||
// Request a game reward. [Authorize]. The client asks whenever it thinks one is due,
|
||
// posting the type and the message to show for it (`rewardType=FirstActivityOfDay&
|
||
// Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity
|
||
// completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided
|
||
// here, from `reward_status`: one claim per type per hour, atomically.
|
||
//
|
||
// A claim pays GAME_REWARD_XP into `progression` and hands over a gift box carrying that
|
||
// XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses —
|
||
// the client posted the message to show, so the box wears it. An on-cooldown ask changes
|
||
// nothing and pays nothing.
|
||
//
|
||
// The response stays `[]` either way. It is what the client already accepts, and the box
|
||
// is how a reward is delivered, so there is no captured shape to put the payout in — the
|
||
// reference answers its own (different, selection-based) flow with a success envelope,
|
||
// not a list of rewards.
|
||
//
|
||
// `giftContext` (the activity, e.g. `Soccer`) is accepted and ignored: the cooldown is
|
||
// per reward type, shared across activities.
|
||
.post(
|
||
'/api/gamerewards/v1/request',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Request a game reward',
|
||
description: [
|
||
'Claims one reward of `rewardType` per hour per player, recorded in `reward_status`.',
|
||
'The reward payload is still a stub — a claim grants nothing and both a claim and a',
|
||
'rejected (on-cooldown) ask answer `[]`. `giftContext` is accepted and ignored.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: form(GameRewardRequest, 'The reward type and its display message'),
|
||
responses: {
|
||
200: json(JsonArray, 'The rewards granted — always [] while the payload is stubbed'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
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 rewardType = typeof body.rewardType === 'string' ? body.rewardType : ''
|
||
// No type, nothing to gate: don't write a row keyed on an empty string.
|
||
if (rewardType === '') return c.json([])
|
||
const claimed = await claimReward(c.env.DB, id, rewardType)
|
||
// On cooldown: nothing was claimed, so nothing is paid and nothing is announced.
|
||
if (claimed === null) return c.json([])
|
||
const message =
|
||
typeof body.Message === 'string' && body.Message !== ''
|
||
? body.Message
|
||
: DEFAULT_GAME_REWARD_MESSAGE
|
||
// Bank the XP first: it is the reward, and the box is the wrapper the client shows.
|
||
// A failure here must not leave a box promising XP that was never credited.
|
||
const { progression, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP)
|
||
const granted = await grantGiftDrop(c, id, toGameRewardDrop(), message)
|
||
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
|
||
// Every grant moves the bar, whether or not it crossed a level.
|
||
await pushProgressionUpdate(c, id, progression)
|
||
// …and every level crossed is worth a box of its own tier.
|
||
await grantLevelUpGifts(c, id, { progression, levelsGained })
|
||
logger.info('game reward claimed', {
|
||
accountId: id,
|
||
rewardType,
|
||
grantCount: claimed,
|
||
message,
|
||
xp: GAME_REWARD_XP,
|
||
level: progression.Level,
|
||
levelsGained,
|
||
levelXp: progression.XP,
|
||
giftId: granted.id,
|
||
})
|
||
return c.json([])
|
||
}
|
||
)
|
||
|
||
// The player's room keys. Returns "[]".
|
||
.get('/api/roomkeys/v1/mine', listRoute('The player’s room keys', 'Empty for now'), (c) =>
|
||
c.json([])
|
||
)
|
||
// Room keys for a given room (client calls this on the econ host). [] with no DB.
|
||
.get('/api/roomkeys/v1/room', listRoute('Room keys for a room', 'Empty for now'), (c) =>
|
||
c.json([])
|
||
)
|
||
|
||
// Subscription lookup. Returns both fields null with no auth.
|
||
.post(
|
||
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Subscription lookup',
|
||
description: 'No subscriptions yet — both fields null. No auth.',
|
||
responses: { 200: json(SubscriptionResponse, 'Both fields null') },
|
||
}),
|
||
(c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||
)
|
||
|
||
// The generated spec. Documentation only — no request is validated against it (see
|
||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||
app.get(
|
||
'/openapi.json',
|
||
describeRoute({ hide: true }),
|
||
withCleanSpec(
|
||
openAPIRouteHandler(app, {
|
||
documentation: {
|
||
info: {
|
||
title: 'recflare econ',
|
||
version: '1.0.0',
|
||
description: [
|
||
'Avatar and economy endpoints for recflare, a private-server reimplementation of the',
|
||
'Rec Room backend. The client calls these on the `econ` host; many are also served by',
|
||
'the `api` worker. Storefront catalogs are static assets (`sf{N}.json`); balances,',
|
||
'inventory, consumables, saved outfits and gift boxes are D1-backed.',
|
||
].join('\n'),
|
||
},
|
||
servers: [{ url: 'https://econ.recflare.net', description: 'Production' }],
|
||
components: {
|
||
securitySchemes: {
|
||
bearerAuth: {
|
||
type: 'http',
|
||
scheme: 'bearer',
|
||
bearerFormat: 'JWT',
|
||
description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
)
|
||
)
|
||
|
||
export default app
|