Files
recflare/apps/econ/src/econ.app.ts
T
devin 178d3b5b0e support for 202507 endpoints (#37)
* [auth][api] accept the 20250424.01 client

* [2025] unstable

* 20250718.0

* correct one this time

* stubs

* more stubs

* more stubs

* [lists] add worker

* [ai] route stubs

* [api] player photo setting

* [econ] add roomEconConfig route

* [infra] update worker generators

* [worker] add cards/moderation/platformnotification workers

* [lists] updates to some endpoints

* [clubs] stub out announcement endpoint, for now

* [econ] stub out season endpoints for now

* [chat] apps/chat stub out party endpoint not sure the shape yet

* [api] stub out statsig and lockeditems

* [doc] new services

* [lists] stub the bulk endpoint

* [datacollection] add placeholder service until we can kill it

* [api] set gifting to lvl5

* update lock

* [cdn] enable cache

* [match] matchmake v2

* [lists] stub some lists

* [ai] stubs

* [rooms] new subroom save endpoint

* [econ] add bulk purchase endpoint

* [discovery] update featured creator to 1 for fun

* [api] add photo settings flag

* [chat] fixup chat permissions (sorta)

* [auth] restrictions endpoint

* [rooms] contributed endpoint

* [api] fix outfit endpoint

* [discovery] attempt to fix store

* [chat] privacy endpoints

* [api] cheered images

* [rooms] add xp endpoint (disbaled)

* [rooms] add xp endpoint (disabled)

* update images-db for cheers

* [rooms] add autocomplete endpoint

* [cdn/img] increase cache ttl for statics

* [api] bulk route for images

* [accounts] add banner image

* [api] add misc missing endpoints

* [discovery] remove AI tab

* [platformnotifications] stub some endpoints

* [lists] add some more lists

* [rooms] additional endpoints

* [chat] stub a few privacy endpoints

* [econ] stub some endpoints

* misc db fixes

* [api] tweak shape for images v6

* [rooms] dont show trending RROs
2026-08-21 15:15:55 -04:00

3315 lines
131 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
addXp,
consumeGift,
createGift,
getGift,
getOutfits,
getPendingGifts,
grantInvention,
levelReward,
levelsReached,
ownsInvention,
setOutfit,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } 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, and the payload shapes recovered from the
// client's own decoder (both owned by the `notify` worker). Imported rather than copied so
// the frames this worker builds are typed by the shapes the client actually parses — a
// wrong or renamed key (see the `Platform`/`BalanceType` trap) fails the build here.
import { BalanceAddType } from '../../notify/src/notification-payloads'
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 defaultBaseAvatarItems from '../static/default-base-avatar-items.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, toAvatarItemV4 } from './inventory-db'
import {
AUTHED,
AvatarItemV4Dto,
AvatarV2Dto,
BalanceEntry,
BulkPurchaseRequest,
BulkPurchaseResponse,
BuyInventionResponse,
BuyItemRequest,
BuyItemResponse,
ChallengeProgressRequest,
ChallengeProgressResponse,
ChecklistCompleteResponse,
ChecklistEntry,
CompleteChecklistRequest,
ConsumeConsumableRequest,
ConsumeEnvelope,
ConsumeGiftRequest,
CustomAvatarItemsResponse,
EquipmentUpdateRequest,
ErrorResponse,
form,
GameRewardRequest,
InfluencerIdsResponse,
json,
JsonArray,
jsonBody,
JsonObject,
MakerAiFreeTrialEligibilityResponse,
OpaqueJsonBody,
OPTIONAL_AUTHED,
ReferralProgressResponse,
RoomEconConfig,
RRPlusSignUpBonus,
SaveOutfitRequest,
SaveOutfitV4Response,
SubscriptionResponse,
UNAUTHORIZED_RESPONSE,
UpdateObjectiveRequest,
UpdateObjectiveResponse,
} from './openapi'
import { claimReward } from './reward-db'
import type { Context } from 'hono'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
import type {
BalanceResponsePayload,
PurchaseBalanceModificationPayload,
} from '../../notify/src/notification-payloads'
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'
/**
* 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())
}
/**
* The `role` claim from a Bearer token — the operator-granted roles the auth worker stamps
* from the account's flags, so a plain player's token is just `['gameClient']`. `null` when
* the request carries no valid token; an empty array means a valid token with no roles.
* Shaped to mirror {@link authedId}.
*/
async function authedRoles(c: Context<App>): Promise<string[] | null> {
return validateAndGetRoles(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),
})
}
}
/**
* THE BALANCE-FRAME RULE, which both balance bugs came from getting wrong.
*
* The client holds a balance PER `(CurrencyType, Platform)` bucket and shows the SUM of the
* buckets. Every `StorefrontBalance*` frame is an absolute SET of the one bucket it names —
* not a change to apply — so:
*
* 1. `Balance` is the RESULTING TOTAL. Sending the change sets the bucket TO that change.
* 2. The bucket key on the wire is `Platform`. The client's property is called
* `BalanceType` but carries a `[DataMember]` rename, and its decoder drops unknown
* members in silence — so a frame that says `BalanceType` lands in `Platform` 0,
* `SteamPurchased`, and creates a SECOND bucket that is added to the real one forever.
* 3. That bucket must be the same one `GET /api/storefronts/v4/balance/:type` reports,
* `ALL_PLATFORMS`. One account-wide bucket per currency is the whole model here; a
* frame naming any other Platform is a phantom balance, not a per-store nicety.
*
* Both live bugs were rule 2 or 3, and both looked like the frame being "additive":
* - A player who earned 250 on 10,000 read 20,250 — `BalanceType: -2` was dropped, so the
* total landed in a phantom Steam bucket beside the real one.
* - A player who spent 900 of 17,500 read 34,100, then 33,200 once the purchase response's
* -900 reached the real bucket — same phantom bucket, this time from `Platform: RecNet`.
* Neither was additivity: the totals were right, the bucket was wrong. Frames as specified
* here are idempotent, so re-sending one or racing a `GET /balance` cannot drift the total.
*
* See apps/notify/src/notification-payloads.ts for the payload shapes this is recovered
* from — the interfaces there type these calls, so a wrong key is now a build error.
*/
/**
* Push a StorefrontBalanceUpdate (61) — "your balance in this bucket is now X" — after a
* player's balance changes for a reason that is not their own purchase. `balance` is their
* resulting TOTAL in that currency, per the rule above.
*
* A player who is reading the HTTP response for the same change gets this too: it sets the
* bucket to the same total the body reports, so the two agree rather than compound. Pushing
* it is what saves them a `GET /balance` re-fetch.
*
* Best-effort: a hub failure is logged and swallowed, since the change has already committed.
*/
async function pushBalanceUpdate(
c: Context<App>,
accountId: number,
currencyType: number,
balance: number
): Promise<void> {
// `satisfies` rather than a type annotation: the hub takes a Record<string, unknown>, and
// an interface (unlike an inferred object type) has no implicit index signature to match
// it. This still checks every key against the shape the client's decoder parses.
const payload = {
Balance: balance,
CurrencyType: currencyType,
Platform: ALL_PLATFORMS,
} satisfies BalanceResponsePayload
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
accountId,
NotificationType.StorefrontBalanceUpdate,
payload
)
} catch (err) {
logger.error('failed to push StorefrontBalanceUpdate notification', {
accountId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Push a StorefrontBalancePurchase (62) — the frame the reference sends when the balance
* moved because the player BOUGHT something, as opposed to the plain update above. Same
* absolute-set semantics: `balance` is the resulting total.
*
* `Delta` (the negated price) and `BalanceAddType` are display/telemetry only — the client
* logs them and then stores `Balance` outright, so a correct `Delta` beside a stale
* `Balance` still leaves the player's balance wrong. `Platform` is `ALL_PLATFORMS`, NOT
* `RecNetPurchased`: it has to name the bucket `GET /balance` reports, and sending RecNet
* here is exactly what doubled a buyer's tokens on screen. Best-effort, as above.
*/
async function pushBalancePurchase(
c: Context<App>,
accountId: number,
currencyType: number,
delta: number,
balance: number
): Promise<void> {
const payload = {
BalanceAddType: BalanceAddType.CommercePurchase,
Delta: delta,
Balance: balance,
Platform: ALL_PLATFORMS,
CurrencyType: currencyType,
} satisfies PurchaseBalanceModificationPayload
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
accountId,
NotificationType.StorefrontBalancePurchase,
payload
)
} catch (err) {
logger.error('failed to push StorefrontBalancePurchase notification', {
accountId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/** The operator-granted role that comes with a complimentary subscription. */
const DEVELOPER_ROLE = 'developer'
/** `SubscriptionLevel.Gold`. 1 is Platinum. */
const SUBSCRIPTION_LEVEL_GOLD = 0
/** `SubscriptionPeriod.Year`. 0 is Month, 2 ThreeMonth, 3 SixMonth. */
const SUBSCRIPTION_PERIOD_YEAR = 1
/**
* `PlatformType.All` (-1) — the subscription belongs to no single store, which is the honest
* answer when no store sold it. The rest of the enum: 0 Steam, 1 Oculus, 2 PlayStation,
* 3 Xbox, 4 RecNet, 5 IOS, 6 GooglePlay, 7 Standalone, 8 Pico.
*/
const SUBSCRIPTION_PLATFORM_ALL = -1
/** The id every reported subscription carries — a placeholder, since none is stored. */
const STUB_SUBSCRIPTION_ID = 1
/**
* The complimentary subscription a `developer` account reports — Rec Room Plus, which the
* client's API calls a `CampusCard`.
*
* Nothing here sells subscriptions, so holding the role IS the subscription: it's how the
* paid-tier surfaces get exercised without a store. Every field is computed per call and
* none of it is persisted, so this is not a record of anything — revoking the role revokes
* the subscription, and no expiry sweep or renewal exists.
*
* `ExpirationDate` is a year out from THIS call rather than a fixed date: a hard-coded one
* lapses on a day nobody is expecting, and the client would start showing an expired
* subscription with no way to renew it. `IsAutoRenewing` tells the client the same thing.
* The dates are milliseconds-precision ISO like the rest of this worker's timestamps.
*/
function developerSubscription(accountId: number) {
const now = new Date()
// Calendar arithmetic, not now + 365 days: setUTCFullYear lands on the same date next
// year whether or not a leap day falls in between.
const expires = new Date(now)
expires.setUTCFullYear(expires.getUTCFullYear() + 1)
return {
SubscriptionId: STUB_SUBSCRIPTION_ID,
RecNetPlayerId: accountId,
PlatformType: SUBSCRIPTION_PLATFORM_ALL,
PlatformId: '',
PlatformPurchaseId: '',
Level: SUBSCRIPTION_LEVEL_GOLD,
Period: SUBSCRIPTION_PERIOD_YEAR,
ExpirationDate: expires.toISOString(),
IsAutoRenewing: true,
CreatedAt: now.toISOString(),
ModifiedAt: now.toISOString(),
}
}
/**
* 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
}
/**
* Read a storefront catalog (`sf{type}.json`) from the ASSETS binding. Null when there is
* no such storefront.
*
* Separate from {@link findStoreItem} so a caller resolving SEVERAL items from one
* storefront reads (and parses) it once: sf3 alone is over a thousand items, and a bulk
* purchase carries up to `BULK_PURCHASE_CAP` lines.
*/
async function loadStorefront(c: Context<App>, storefrontType: number): Promise<Storefront | null> {
const res = await c.env.ASSETS.fetch(new URL(`/sf${storefrontType}.json`, c.req.url))
if (!res.ok) return null
return (await res.json()) as Storefront
}
/**
* 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 storefront = await loadStorefront(c, storefrontType)
if (storefront === null) return null
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 840 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 storefront = await loadStorefront(c, ROLL_STOREFRONT_TYPE)
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.
*
* `id` is 0 when no box was created (`skipGiftBox`) — the drop was still granted.
*/
interface GrantedGift {
id: number
drop: StoreGiftDrop
}
/** How a drop is handed over: how a query one rolls, plus how it is wrapped. */
interface GrantOptions extends RollOptions {
/**
* How many of the drop to hand over in ONE box — a bulk line's `DuplicateItemCount`.
* Only a consumable stacks, so this multiplies the consumable count and nothing else;
* callers must refuse a count above 1 for anything owned once. Defaults to 1.
*/
copies?: number
/**
* Grant the drop without creating the gift box that renders it — what a bulk purchase's
* `BypassGiftPackages` asks for. Ownership never depended on the box (it is granted here,
* not when the box is opened), so this only skips the "open it" moment; a caller setting
* it is saying its own UI announces the items.
*/
skipGiftBox?: boolean
}
/**
* 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: GrantOptions = {}
): 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 * (options.copies ?? 1) : 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
)
}
if (options.skipGiftBox === true) return { id: 0, drop: giftDrop }
const { id } = await createGift(
db,
accountId,
toGiftContent(giftDrop, message, consumableCount, consumableMappingId, consumablePreExisting)
)
return { id, drop: giftDrop }
}
/**
* One `BalanceUpdates[].Data` entry: the gift-drop a player RECEIVED, as both purchase
* endpoints report it. `granted.drop` is the resolved drop — the rolled prize for a query
* box, not the box that promised it — or a query purchase answers with every item field
* empty and the client draws an empty box.
*
* It carries no FriendlyName or consumable count (the count is a getUnlocked concept; each
* box is one instance). `giftContext` is the requesting `Gift` block's, when it named one;
* otherwise the drop's own.
*/
function toBalanceUpdateData(
granted: GrantedGift,
fromPlayerId: number,
message: string,
giftContext: number | null
): Record<string, unknown> {
const drop = granted.drop
return {
Id: granted.id,
FromPlayerId: fromPlayerId,
ConsumableItemDesc: drop.ConsumableItemDesc,
AvatarItemDesc: drop.AvatarItemDesc,
AvatarItemType: drop.AvatarItemType ?? 0,
EquipmentPrefabName: drop.EquipmentPrefabName,
EquipmentModificationGuid: drop.EquipmentModificationGuid,
CurrencyType: drop.CurrencyType,
Currency: drop.Currency,
Xp: drop.Xp ?? 0,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
GiftContext: giftContext ?? drop.Context,
GiftRarity: drop.Rarity,
Message: message,
}
}
/**
* `Econ.BulkPurchaseCap` — the most copies one bulk purchase may carry. The client reads
* the same 200 out of its game config (apps/api/static/gameconfigs-v1-all.json) and caps
* the bag with it, so this is the server side of a limit the client already knows; a
* request over it is a client that ignored its own config, not a bigger shopping trip.
*/
const BULK_PURCHASE_CAP = 200
/**
* `UpdateResponse` — the outcome of ONE `BalanceUpdates` entry, from the client's own enum.
* This is where a bulk purchase reports per line: the bag answers one entry per REQUESTED
* item, and `AllowPartialSuccess` is what lets some of them come back non-OK while the
* envelope's `Success` stays true. (buyItem's single `UpdateResponse: 0` is this same OK.)
*
* The members this server can produce are the ones a catalog purchase can fail on;
* `TooManyRequests`, `PlayerNotEligible`, `RequestCannotBeRefunded` and `PlayerNotApproved`
* belong to rate limiting, entitlements and refunds, none of which exist here. `AlreadyOwned`
* is deliberately unused too: buyItem lets a player re-buy an item they own (the grant
* upserts), and one purchase path refusing what the other allows would be worse than either.
*/
const UpdateResponse = {
OK: 0,
TooManyRequests: 1,
NotEnoughCredit: 2,
AlreadyOwned: 3,
NoItemAvailable: 4,
CouponNotApplicable: 5,
RequestedPriceDoesNotMatch: 6,
RequestedAmountNotAllowed: 7,
PlayerNotEligible: 8,
RequestCannotBeRefunded: 9,
PlayerNotApproved: 10,
} as const
/** The discriminated item id a bulk-purchase line names its item by. */
interface PurchaseMethodId {
Type: number
NumberId: number | null
Guid: string | null
}
/** One line of a `POST /api/items/bulkpurchase` body. */
interface PurchaseItemRequest {
ItemPurchaseMethodId?: Partial<PurchaseMethodId> | null
RequestedPrice?: number
Gift?: GiftRequest | null
CouponConsumablePlayerMappingId?: number | null
DuplicateItemCount?: number
}
/**
* A line that could not be bought: the `UpdateResponse` its entry carries, and the message
* that fills the envelope's single `Error` when the bag as a whole is refused.
*/
interface BulkLineFailure {
method: PurchaseMethodId
code: number
error: string
}
/** A line that resolved to something buyable, with the catalog's own price. */
interface BulkPurchaseLine {
method: PurchaseMethodId
item: StoreItem
/** The UNIT price from the catalog — `count` copies cost `price * count`. */
price: number
count: number
gift: GiftRequest | null
}
/**
* One `BalanceUpdates[].Data` — what a single requested item turned into. Unlike buyItem's,
* it NAMES the purchase rather than describing the drop: the client already has the catalog
* entry for `PurchasableItemId`, so the only thing it can't reconstruct is the box.
*
* `CustomAvatarItem` is the UGC counterpart of `PurchasableItemId`, and both it and
* `GiftPackage` are null on a line that didn't sell. Nothing here fills `CustomAvatarItem`:
* no catalog we serve sells guid-keyed items.
*/
interface BulkPurchaseData {
GiftPackage: Record<string, unknown> | null
PurchasableItemId: number | null
CustomAvatarItem: null
}
/** `ItemPurchaseMethodId.Type` for a numeric (storefront `PurchasableItemId`) id. */
const PURCHASE_METHOD_NUMBER_ID = 0
/**
* The gift box as `GiftPackage` carries it — the same DTO family as buyItem's
* `BalanceUpdates[].Data` entry, but with the four keys that shape doesn't carry
* (`PlayerId`, `CustomAvatarItemId`, `Signature`, `IsSignatureValid`) and without its
* `Level`. Twenty keys, in the order the client's own member list names them.
*
* `Platform`/`PlatformsToSpawnOn` are the platform MASK (-1, all) — the balance bucket is
* the separate `BalanceType` beside them, unlike the envelope's `Value.Platform`, which IS
* a renamed `BalanceType`. `Signature` is null and `IsSignatureValid` false: a box the
* server minted was never signed for peer-to-peer transfer.
*/
function toGiftPackage(
granted: GrantedGift,
playerId: number,
fromPlayerId: number,
message: string,
giftContext: number | null
): Record<string, unknown> {
const drop = granted.drop
return {
Id: granted.id,
PlayerId: playerId,
FromPlayerId: fromPlayerId,
ConsumableItemDesc: drop.ConsumableItemDesc,
AvatarItemType: drop.AvatarItemType ?? 0,
AvatarItemDesc: drop.AvatarItemDesc,
CustomAvatarItemId: null,
EquipmentPrefabName: drop.EquipmentPrefabName,
EquipmentModificationGuid: drop.EquipmentModificationGuid,
CurrencyType: drop.CurrencyType,
Currency: drop.Currency,
Xp: drop.Xp ?? 0,
GiftContext: giftContext ?? drop.Context,
GiftRarity: drop.Rarity,
Message: message,
Signature: null,
IsSignatureValid: false,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
}
}
/**
* Normalise the id a line named its item by. A line that sent no id at all still resolves
* to something, so this never returns null — the checks in {@link resolveBulkLine} are what
* reject it.
*/
function toPurchaseMethodId(raw: Partial<PurchaseMethodId> | null | undefined): PurchaseMethodId {
const id = typeof raw === 'object' && raw !== null ? raw : {}
return {
Type: Number.isInteger(id.Type) ? (id.Type as number) : PURCHASE_METHOD_NUMBER_ID,
NumberId: Number.isInteger(id.NumberId) ? (id.NumberId as number) : null,
Guid: typeof id.Guid === 'string' ? id.Guid : null,
}
}
/**
* Resolve one line against the bag's catalog: what it wants, how many, and at what price.
* Returns the failure — with the `UpdateResponse` its entry will carry — instead when the
* line can't be bought.
*
* Pure — the catalog is passed in — so the whole bag resolves from ONE storefront read.
* The price check is buyItem's, per line: `RequestedPrice` is the UNIT price the client
* rendered, and a mismatch means the catalog moved under a stale client rather than that
* the player agreed to today's price.
*/
function resolveBulkLine(
line: PurchaseItemRequest,
storefront: Storefront | null,
currencyType: number
): BulkPurchaseLine | BulkLineFailure {
const method = toPurchaseMethodId(line.ItemPurchaseMethodId)
// Guid-keyed ids name UGC / custom avatar items, which no catalog here sells. Failing the
// line (rather than the request) is what lets a bag of ordinary items still go through.
if (method.Type !== PURCHASE_METHOD_NUMBER_ID || method.NumberId === null) {
return {
method,
code: UpdateResponse.NoItemAvailable,
error: 'Only numeric storefront item ids can be bought',
}
}
// Nothing issues coupons, so a line claiming one would otherwise be charged full price
// for a discount it thinks it applied.
if (
line.CouponConsumablePlayerMappingId !== null &&
line.CouponConsumablePlayerMappingId !== undefined
) {
return {
method,
code: UpdateResponse.CouponNotApplicable,
error: 'Coupons are not supported',
}
}
const count = line.DuplicateItemCount ?? 1
if (!Number.isInteger(count) || count < 1) {
return {
method,
code: UpdateResponse.RequestedAmountNotAllowed,
error: 'DuplicateItemCount must be a positive integer',
}
}
if (storefront === null) {
return { method, code: UpdateResponse.NoItemAvailable, error: 'No such storefront' }
}
const item = storefront.StoreItems.find((it) => it.PurchasableItemId === method.NumberId)
if (item === undefined) {
return { method, code: UpdateResponse.NoItemAvailable, error: 'Item not found' }
}
// Only a consumable stacks. An avatar item or an equipment skin is owned once, so a
// second copy would grant nothing while charging for it — and the bag answers ONE entry
// (one box) per requested item, which is the same statement from the wire's side.
if (count > 1 && item.GiftDrop.ConsumableItemDesc === '') {
return {
method,
code: UpdateResponse.RequestedAmountNotAllowed,
error: 'This item can only be bought once per line',
}
}
const price = item.Prices.find((p) => p.CurrencyType === currencyType)
if (price === undefined) {
return {
method,
code: UpdateResponse.NoItemAvailable,
error: 'Currency type not available for this item',
}
}
if (!Number.isInteger(line.RequestedPrice)) {
return {
method,
code: UpdateResponse.RequestedPriceDoesNotMatch,
error: 'RequestedPrice is required',
}
}
if (line.RequestedPrice !== price.Price) {
return {
method,
code: UpdateResponse.RequestedPriceDoesNotMatch,
error: 'Price has changed',
}
}
const gift = typeof line.Gift === 'object' && line.Gift !== null ? line.Gift : null
return { method, item, price: price.Price, count, gift }
}
/** Whether a resolved line is buyable or is already a failure. */
function isBulkLine(resolved: BulkPurchaseLine | BulkLineFailure): resolved is BulkPurchaseLine {
return 'item' in resolved
}
/**
* 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),
})
}
}
/**
* The default NUX checklist for a brand-new account. `Objective` is an `ObjectiveType`
* ordinal (from the client's `ProgressionManager`) that the client matches its own
* progress events against — the names below are what those ordinals mean.
*/
const DEFAULT_CHECKLIST = [
{ Order: 0, Objective: 38, Count: 1, CreditAmount: 25 }, // SaveOutfitSlot
{ Order: 1, Objective: 32, Count: 1, CreditAmount: 25 }, // VisitACustomRoom
{ Order: 2, Objective: 2, Count: 1, CreditAmount: 25 }, // AddAFriend
{ Order: 3, Objective: 30, Count: 1, CreditAmount: 25 }, // GoToRecCenter
{ Order: 4, Objective: 6, Count: 1, CreditAmount: 25 }, // CheerAPlayer
]
/** The `UpdateResponse` context a checklist reward is reported under. */
const CHECKLIST_REWARD_CONTEXT = 303
/**
* 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)
)
// The base items UGC clothing is built on top of — served from bundled static JSON,
// separate from the `defaultunlocked` catalog. No auth.
.get(
'/api/avatar/v1/defaultbaseavataritems',
listRoute('Default base avatar items', 'The bundled base items UGC clothing builds on'),
(c) => c.json(defaultBaseAvatarItems)
)
// 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 players 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.',
'Both sources are projected into the camelCase v4 DTO — the sibling item endpoints',
'(`defaultunlocked`, `defaultbaseavataritems`) serve their records raw instead.',
].join(' '),
security: AUTHED,
responses: {
200: json(AvatarItemV4Dto.array(), '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].map(toAvatarItemV4))
}
)
// 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 players 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 players 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, on either
// version path. A 404 here can abort the load orchestration before matchmake. We
// serve the default brand-new-account list to everyone: nothing records per-player
// checklist progress yet, so it never shrinks as steps are done.
.on(
'GET',
['/api/checklist/v1/current', '/api/checklist/v2/current'],
describeRoute({
tags: ['Econ'],
summary: 'NUX checklist',
description:
'The new-user checklist, as the default brand-new-account list — nothing records ' +
'per-player progress yet, so the same rows come back however much the player has ' +
'done. `Objective` is an `ObjectiveType` ordinal the client matches its own ' +
'progress events against. v1 and v2 serve the same list.',
security: AUTHED,
responses: {
200: json(ChecklistEntry.array(), 'The checklist rows, in `Order`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(DEFAULT_CHECKLIST)
}
)
// Mark a checklist row done. [Authorize]. Stubbed: there is no objective-progress
// table to record the completion in, and no reward ledger to make the 25-token grant
// once-only — without one, re-posting the same row would mint tokens indefinitely, so
// we grant nothing and report a change of 0. The envelope is still the balance-update
// shape the client parses, so the flow completes instead of erroring.
.on(
'POST',
['/api/checklist/v1/complete', '/api/checklist/v2/complete'],
describeRoute({
tags: ['Econ'],
summary: 'Complete a checklist row (stub)',
description:
'Marks a NUX checklist row done. Stubbed: nothing records the completion (no ' +
'objective-progress table) and nothing is granted — a reward is worth 25 XP and 25 ' +
'tokens, but making that once-only needs a ledger we do not have, and without one ' +
're-posting the same row would mint tokens indefinitely. The response is still the ' +
'balance-update envelope, with `Balance` (the change) 0. v1 and v2 behave alike.',
security: AUTHED,
requestBody: jsonBody(CompleteChecklistRequest, 'Which row was completed — `{ ItemIndex }`'),
responses: {
200: json(ChecklistCompleteResponse, 'The balance-update envelope, granting nothing'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// The body names the row (`{ ItemIndex: 1 }`, or `Id` as a fallback) — read only
// once there is somewhere to record it.
return c.json({
BalanceUpdates: [{ UpdateResponse: CHECKLIST_REWARD_CONTEXT, Data: [] }],
Balance: 0,
CurrencyType: CurrencyType.RecCenterTokens,
BalanceType: -2,
})
}
)
// The caller's item wishlist. [Authorize]; empty — nothing stores wishlists yet.
.get(
'/api/itemWishlists/v1/wishlist/me',
listRoute('The players item wishlist', 'Empty for now', true),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
}
)
// Another player's item wishlist, by account id — what the client reads to show what
// somebody else is hoping for (and to mark items in the store as already wished for).
// Empty like `/me`: nothing stores wishlists, so there is nothing to show for anyone.
//
// Registered AFTER `/me` so that path stays its own route rather than being read as an
// account id — the pattern here is digits-only, so it could not swallow `me`, but the
// order also says which is the special case.
.get(
'/api/itemWishlists/v1/wishlist/:accountId{[0-9]+}',
describeRoute({
tags: ['Econ'],
summary: 'Another players item wishlist',
description: [
'The wishlist of the account named in the path, as a bare array. Empty for now —',
'nothing on this server stores wishlists, so every players is empty, and an empty',
'list is what the client renders as “nothing wished for” where a 404 would read as a',
'failed load.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'The account whose wishlist to read',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(JsonArray, 'That players wishlist — empty for now'),
401: UNAUTHORIZED_RESPONSE,
},
}),
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 players 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',
'clients 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 players 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 elses 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 players avatar (render subset)',
description: [
'The public render subset used to draw another players avatar. No auth. Falls back',
'to the default outfit when the player hasnt 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 callers owned equipment, matched by',
'`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller',
'doesnt 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 isnt 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 doesnt 404'),
(c) => c.json([])
)
.get(
'/api/roomconsumables/v1/roomConsumable/room/:roomId/me',
listRoute('The callers 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([])
)
// The room-economy surface the client asks for on entering a room: the room's own
// inventory/offers/gift-drop shops and the caller's slice of them. Nothing here is
// stored yet, so every one is an empty list — the client reads that as "this room
// sells nothing" and renders no shop, where a 404 stalls the room load instead.
//
// The `/player` and `purchaseCounts` variants are caller-scoped but deliberately
// unauthed, matching the `roomConsumable/.../me` stub above: an empty list is the
// same answer for every caller, so there's nothing to protect until something
// writes here. Gate them when they start returning real data.
.get(
'/econ/roomInventory/room/:roomId',
listRoute('A rooms inventory', 'Empty stub so the client doesnt 404'),
(c) => c.json([])
)
.get(
'/econ/roomInventory/room/:roomId/player',
listRoute('The callers inventory in a room', 'Empty stub'),
(c) => c.json([])
)
.get(
'/econ/roomInventoryItemTags/room/:roomId',
listRoute('A rooms inventory item tags', 'Empty stub'),
(c) => c.json([])
)
.get('/econ/roomOffer/room/:roomId', listRoute('A rooms offers', 'Empty stub'), (c) =>
c.json([])
)
.get(
'/econ/roomOffer/room/:roomId/purchaseCounts',
listRoute('Per-offer purchase counts for a room', 'Empty stub'),
(c) => c.json([])
)
.get(
'/econ/roomGiftDropShops/room/:roomId',
listRoute('A rooms gift-drop shops', 'Empty stub'),
(c) => c.json([])
)
// A room's economy config, asked for alongside the room-economy lists above. The only
// setting is whether the room's shop UI groups its offers into sorting tabs; nothing
// stores per-room config yet, so every room answers false and the client renders one
// flat list. [Authorize] — unlike the empty-list stubs above this is a real answer the
// client acts on, so it takes the same token the rest of the econ surface does.
.get(
'/econ/roomEconConfig/:roomId',
describeRoute({
tags: ['Econ'],
summary: 'A rooms economy config',
description: [
'Whether the rooms shop groups offers into sorting tabs. No per-room config is',
'stored, so this is always false; the `RoomId` is echoed from the path.',
].join(' '),
security: AUTHED,
responses: {
200: json(RoomEconConfig, 'The rooms economy config'),
400: { description: 'Non-numeric roomId (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
if (Number.isNaN(roomId)) return c.body(null, 400)
return c.json({ RoomId: roomId, EnableSortingTabs: false })
}
)
// The UGC items a room sells (the creator-made things on sale inside it). Same empty
// stub as the room-economy routes above and asked for on the same room load: nothing
// stores room UGC purchasables yet, and an empty list reads as "this room sells
// nothing" where a 404 stalls the load.
.get(
'/api/ugcPurchasables/v1/items/room/:roomId',
listRoute('A rooms UGC purchasables', 'Empty stub so the client doesnt 404'),
(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 theyve 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 instances 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 players 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 clients `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 StorefrontBalancePurchase socket frame that SETS the',
'buyers account-wide bucket to the RESULTING total, so the frame, this body and a',
'`GET /balance` re-fetch all agree (`Delta` there is display-only).',
].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 granted = await grantGiftDrop(c, receiverId, item.GiftDrop, message)
// Push the spend to the buyer (`id` — the caller is who was charged) so their client
// updates without waiting for a `GET /balance` re-fetch. StorefrontBalancePurchase
// SETS the account-wide bucket to the resulting total read back from D1, so it agrees
// with both the response body below and any re-fetch instead of compounding with them
// — see the frame rule above pushBalanceUpdate. Best-effort.
const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
await pushBalancePurchase(c, id, currencyType as number, -price.Price, newBalance)
// 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).
return c.json({
BalanceUpdates: [
{
UpdateResponse: 0,
Data: [
toBalanceUpdateData(
granted,
fromPlayerId,
message,
Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
),
],
},
],
Balance: -price.Price,
CurrencyType: currencyType,
BalanceType: ALL_PLATFORMS,
})
}
)
// Check out a whole shopping bag. [Authorize]. The client posts every line it has in the
// bag — an item id, `DuplicateItemCount` copies, the unit price it rendered and an
// optional Gift — plus the ONE storefront and the ONE currency they all share.
//
// The whole bag is debited in ONE `spendCurrency` call. Charging line by line would let a
// bag half-succeed on a race with another spend, and would push a balance frame per line.
//
// The response is NOT buyItem's envelope. It is `{ Success, Error, error_id, Value }`
// (`error_id` lowercase — the client renames that one member; the other three are
// PascalCase), and `Value` is a BalanceUpdateResponse: the RESULTING `{ Balance,
// CurrencyType, Platform }` — `Platform` there being a renamed `BalanceType`, i.e. the
// bucket, not a store — plus ONE `BalanceUpdates` entry per REQUESTED item.
//
// Per-line reporting is that entry's `UpdateResponse`: a line that didn't sell comes back
// non-OK with a null `GiftPackage`, and `AllowPartialSuccess` is what lets those sit
// beside successful ones while `Success` stays true. Without it, one bad line refuses the
// whole bag — `Success: false`, the reason in `Error`, a null `Value`, nothing charged.
.post(
'/api/items/bulkpurchase',
describeRoute({
tags: ['Storefront'],
summary: 'Buy a bag of storefront items',
description: [
'Resolves every line against the bags storefront catalog (one read for the whole',
'bag), confirms each lines `RequestedPrice` still matches, debits the total in ONE',
'atomic spend, grants what sold, and answers the `{ Success, Error, error_id, Value }`',
'envelope. `Value.Balance` is the RESULTING total (not buyItems change) in the',
'`Platform` bucket named beside it, and `BalanceUpdates` carries one entry per',
'REQUESTED item, each with its own `UpdateResponse`. `AllowPartialSuccess` lets some',
'of those be non-OK while `Success` stays true; without it a single bad line refuses',
'the bag and nothing is charged.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(BulkPurchaseRequest, 'The bag: its lines, storefront and currency'),
responses: {
200: json(BulkPurchaseResponse, 'The bags result, or `Success: false` if nothing sold'),
400: json(BulkPurchaseResponse, 'A request that could not be evaluated at all'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// Every refusal answers the same envelope, so a client that only knows how to parse
// this shape never has to special-case one. A null `Value` is legal here (the client's
// validator only cascades into a non-null one), and it is the honest answer: nothing
// was bought, so there is no balance to report and nothing to render.
const refuse = (error: string, status: 200 | 400 = 200) =>
c.json({ Success: false, Error: error, error_id: null, Value: null }, status)
const body = (await c.req.json().catch(() => null)) as {
PurchaseItemRequests?: PurchaseItemRequest[]
StorefrontType?: number
CurrencyType?: number
BypassGiftPackages?: boolean
AllowPartialSuccess?: boolean
ShoppingBagId?: string | number | null
} | null
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return refuse('Invalid request body', 400)
}
const lines = body.PurchaseItemRequests
if (!Array.isArray(lines) || lines.length === 0) {
return refuse('PurchaseItemRequests must be a non-empty array', 400)
}
const storefrontType = body.StorefrontType
const currencyType = body.CurrencyType
if (!Number.isInteger(storefrontType) || !Number.isInteger(currencyType)) {
return refuse('StorefrontType and CurrencyType are required', 400)
}
// The bag's currency must be an account balance we can debit, exactly as buyItem's.
if (!isSpendable(currencyType as number)) {
return refuse('Currency type is not spendable', 400)
}
const allowPartial = body.AllowPartialSuccess === true
const skipGiftBox = body.BypassGiftPackages === true
// One catalog read for the bag; every line resolves against it in memory.
const storefront = await loadStorefront(c, storefrontType as number)
const resolved = lines.map((line) =>
resolveBulkLine(line, storefront, currencyType as number)
)
const buyable = resolved.filter(isBulkLine)
const copies = buyable.reduce((n, line) => n + line.count, 0)
if (copies > BULK_PURCHASE_CAP) {
return refuse(`A bulk purchase is capped at ${BULK_PURCHASE_CAP} items`, 400)
}
// All-or-nothing: one unbuyable line stops the bag before anything is charged, and the
// client is told why by the first thing that was wrong with it.
const firstFailure = resolved.find((line): line is BulkLineFailure => !isBulkLine(line))
if (!allowPartial && firstFailure !== undefined) return refuse(firstFailure.error)
// Decide what the balance covers BEFORE spending: lines are taken in request order
// while they fit, so a bag that overruns still buys the items the player put in first.
// The read is only for choosing; the single spend below is what actually settles, and
// its `amount >= ?` guard is what makes that safe against a concurrent spend.
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
const balance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
const affordable: BulkPurchaseLine[] = []
let total = 0
for (const line of buyable) {
const cost = line.price * line.count
if (total + cost > balance) continue
total += cost
affordable.push(line)
}
const bought = new Set(affordable)
// Without partial success an unaffordable line fails the whole bag — including the
// lines that would have fitted, since the player asked for all of it or none.
if (!allowPartial && affordable.length !== buyable.length) {
return refuse('Insufficient balance')
}
// Nothing sold at all: there is no purchase to report, so this is a refusal rather
// than a `Success: true` bag full of non-OK entries.
if (affordable.length === 0) {
return refuse(firstFailure?.error ?? 'Insufficient balance')
}
// One atomic debit for the whole bag. A false return means another request spent the
// tokens between the read above and here, so nothing is granted and nothing changed.
if (
total > 0 &&
!(await spendCurrency(c.env.DB, id, currencyType as number, total, startingTokens))
) {
return refuse('Insufficient balance')
}
// A query drop (a loot box) rolls against sf3, the big catalog. Read it ONCE for the
// whole bag and only when a line actually holds one — a bag of ordinary items should
// not pull a thousand-item catalog in to grant them.
const rollCatalog = affordable.some((line) => line.item.GiftDrop.IsQuery === true)
? await loadRollCatalog(c)
: undefined
// Grant what sold, keeping each line's box so the entry built below can carry it.
const packages = new Map<BulkPurchaseLine, Record<string, unknown> | null>()
for (const line of affordable) {
// Same routing as buyItem: a Gift block sends the item (and its box) to another
// player while the caller pays, a named gift shows the sender, and a self-buy or an
// anonymous gift is attributed to the "Coach" system account.
const gift = line.gift
const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
// One box per requested item, holding all `count` copies — the wire has one
// `GiftPackage` per entry, and only a consumable can be asked for more than once
// (`resolveBulkLine` refuses a bigger count on anything owned once).
const granted = await grantGiftDrop(c, receiverId, line.item.GiftDrop, message, {
rollCatalog,
skipGiftBox,
copies: line.count,
})
packages.set(
line,
// Null under `BypassGiftPackages`, which is the flag asking for exactly that —
// the item is granted either way.
skipGiftBox
? null
: toGiftPackage(
granted,
receiverId,
fromPlayerId,
message,
Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
)
)
}
// One entry per REQUESTED item, in request order — the failures included, which is
// where a partial bag says what it left behind.
const updates = resolved.map((line) => {
if (!isBulkLine(line)) {
return {
UpdateResponse: line.code,
Data: {
GiftPackage: null,
PurchasableItemId: line.method.NumberId,
CustomAvatarItem: null,
} satisfies BulkPurchaseData,
}
}
return {
UpdateResponse: bought.has(line) ? UpdateResponse.OK : UpdateResponse.NotEnoughCredit,
Data: {
GiftPackage: packages.get(line) ?? null,
PurchasableItemId: line.method.NumberId,
CustomAvatarItem: null,
} satisfies BulkPurchaseData,
}
})
// One frame for the whole bag, not one per line: it SETS the account-wide bucket to the
// resulting total read back from D1, so it agrees with the `Value.Balance` below and
// with a `GET /balance` re-fetch instead of compounding — see the frame rule above
// pushBalanceUpdate. Nothing moved on a free bag, so nothing is sent and the balance
// read for the affordability check above still stands.
let newBalance = balance
if (total > 0) {
newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
await pushBalancePurchase(c, id, currencyType as number, -total, newBalance)
}
return c.json({
Success: true,
Error: null,
error_id: null,
Value: {
// The RESULTING total, unlike buyItem's change — and the bucket it belongs to.
// `Platform` here is the client's `BalanceType` under a [DataMember] rename. A
// capture from the reference server says 4 (RecNetPurchased) because it kept a
// wallet per store; this server keeps ONE account-wide bucket, and the client SUMS
// its buckets, so naming any other platform invents a second balance beside the
// real one. See the frame rule above pushBalanceUpdate.
Balance: newBalance,
CurrencyType: currencyType,
Platform: ALL_PLATFORMS,
BalanceUpdates: updates,
},
})
}
)
// 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 clients `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 buyers resulting',
'balance. When tokens moved, both players get a socket push carrying their RESULTING',
'total — the buyer a StorefrontBalancePurchase, the CREATOR a StorefrontBalanceUpdate —',
'which sets the account-wide bucket their client shows, agreeing with this body.',
'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)
const creatorBalance = await creditCurrency(
c.env.DB,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
// The creator is a different, probably-online player with no response to read:
// push the sale so it lands on their shown balance without a re-fetch. The frame
// carries their resulting TOTAL (what `creditCurrency` returns), not the payout —
// sending the payout would set their whole balance to it. A plain update rather
// than a purchase frame: they sold, they didn't buy. Best-effort, as everywhere.
await pushBalanceUpdate(
c,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
creatorBalance
)
}
// 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 buyer's frame carries that same total, so the body
// and the push land the client on one number.
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
// A free invention moved nothing, so there is no purchase to report.
if (price > 0) {
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
}
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 challenges `Complete` stamped from the',
'callers 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 activity 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 part of the cooldown key: the first
// activity of the day is per ACTIVITY, so a player who moves from Soccer to Paintball is
// owed another reward while a second Soccer match inside the hour is not. An ask that
// sends no context keys on `''`.
.post(
'/api/gamerewards/v1/request',
describeRoute({
tags: ['Econ'],
summary: 'Request a game reward',
description: [
'Claims one reward of `rewardType` in `giftContext` per hour per player, recorded in',
'`reward_status`. The cooldown is per (type, activity), so a different activity is',
'owed another reward while the same one is not; an ask with no `giftContext` keys on',
'the empty context. The reward rides in a gift box, so a claim and a rejected',
'(on-cooldown) ask both answer `[]`.',
].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 giftContext = typeof body.giftContext === 'string' ? body.giftContext : ''
const claimed = await claimReward(c.env.DB, id, rewardType, giftContext)
// 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,
giftContext,
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 players 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([])
)
// The Rec Room Plus sign-up bonus: which bonus is running and the token price window
// the free items are drawn from. Fixed numbers, the same for every caller.
//
// Unauthenticated, like the subscription lookup below and for the same reason: the
// client reads this while putting the RR+ page together, nothing in the answer is
// per-account, and a 401 would only be a way for that load to stall. (The `api` copy of
// this path does validate a token, mirroring the reference server.)
.get(
'/api/CampusCard/v1/SignUpBonus',
describeRoute({
tags: ['Econ'],
summary: 'Rec Room Plus sign-up bonus',
description: [
'The bonus a player gets for taking out Rec Room Plus: which bonus is running',
'(`RRPlusSignUpBonusId`) and the token price window the free items are picked from.',
'Fixed values — nothing here is per-account or stored, so no auth is required and',
'every caller gets the same three numbers.',
].join(' '),
responses: { 200: json(RRPlusSignUpBonus, 'The running sign-up bonus') },
}),
(c) =>
c.json({
RRPlusSignUpBonusId: 3,
MinFreeItemsPrice: 6000,
MaxFreeItemsPrice: 10000,
})
)
// Subscription lookup (Rec Room Plus, the client's `CampusCard`). There is no store to
// buy one from, so the `developer` role stands in for a paid subscription: a developer
// reports an active Gold year, everyone else reports none. Nothing is stored — see
// `developerSubscription`.
//
// Auth is OPTIONAL, and a missing or invalid token answers "no subscription" rather than
// 401: the client posts this while loading, so an error here can stall its load
// orchestration, and "you aren't subscribed" is the truthful answer for an anonymous
// caller anyway. The role is read from the token's `role` claim, never from the body.
.post(
'/api/CampusCard/v1/UpdateAndGetSubscription',
describeRoute({
tags: ['Econ'],
summary: 'Subscription lookup',
description: [
'The callers Rec Room Plus subscription. Nothing sells subscriptions here, so the',
'operator-granted `developer` role stands in for one: a developers token reports an',
'active Gold (`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All),',
'expiring a year from the call, and every other caller gets `{}`. Auth is optional —',
'a missing or invalid token reads as “not subscribed”, not 401. Nothing is persisted:',
'the role IS the subscription, so revoking it revokes this.',
].join(' '),
responses: {
200: json(SubscriptionResponse, 'The subscription, or `{}` for no subscription'),
},
}),
async (c) => {
const roles = await authedRoles(c)
if (!roles?.includes(DEVELOPER_ROLE)) return c.json({})
const id = await authedId(c)
if (id === null) return c.json({})
return c.json({
Subscription: developerSubscription(id),
PlatformAccountSubscribedPlayerId: null,
})
}
)
// The subscription seasons running right now (the RR+ seasonal reward tracks). Nothing
// here runs a season, so this is an empty-list stub — the client reads it as "no season
// in progress" and skips the seasonal UI, where a 404 stalls the RR+ page load.
.get(
'/api/subscriptionseasons/v1/seasons/current',
listRoute('Current subscription seasons', 'Empty stub — no RR+ season is running'),
(c) => c.json([])
)
// Whether the caller can start a Maker AI free trial. Always false, mirroring the
// reference server: nothing here runs trials, and false is the answer that leaves the
// client's creation UI in its normal state rather than offering a trial that can't
// start. The body is a BARE JSON `false` — not an envelope, not `{ value: false }`.
//
// Auth-gated (401 on a missing or invalid token) even though the answer is the same for
// everyone, because the reference validates the token before answering and eligibility
// is a per-account question the moment anything does run trials.
.get(
'/api/makerai/checkfreetrialeligibility',
describeRoute({
tags: ['Econ'],
summary: 'Maker AI free-trial eligibility',
description: [
'Whether the caller can start a Maker AI free trial. Always `false` — nothing here',
'runs trials. The body is a bare JSON boolean, not an envelope.',
].join(' '),
security: AUTHED,
responses: {
200: json(MakerAiFreeTrialEligibilityResponse, 'Always `false`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(false)
}
)
// The caller's progress through the refer-a-friend rewards: how many of their referrals
// have been verified, and which rewards they have taken from that track.
//
// Nothing here runs a referral programme, so nobody has referred anybody: the count is 0
// and the reward list is empty. That is a real answer rather than a stub — it is what a
// player who has referred nobody sees — so the client draws an untouched track, which is
// exactly the state this server is in.
//
// The payload is NESTED under `value`, unlike econ's flat balance bodies.
.get(
'/api/incentivizedreferrals/progress',
describeRoute({
tags: ['Econ'],
summary: 'The callers referral-reward progress',
description: [
'How many of the callers referrals have been verified and which referral rewards they',
'have claimed, under a `{ success, value }` envelope. Always 0 and empty — no referral',
'programme runs here — which the client renders as an untouched reward track.',
].join(' '),
security: AUTHED,
responses: {
200: json(ReferralProgressResponse, 'The callers progress — always zero'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
success: true,
value: { ReferralsVerifiedCount: 0, PlayerReferralRewards: [] },
})
}
)
// Everyone in the influencer partner program, by account id — the list the client keeps
// so it can badge an influencer wherever they turn up, rather than asking per player.
//
// Empty: no programme runs here, so there is nobody to list. Note this is the LIST
// counterpart of the single-account check below, and the two answer very differently —
// that one 404s to say "not an influencer", this one is a 200 carrying an empty list,
// because "nobody is" is a complete answer to "who is?".
//
// `take` is accepted and ignored; there is nothing to page through.
.get(
'/api/influencerpartnerprogram/influencers',
describeRoute({
tags: ['Econ'],
summary: 'Every influencer in the partner program',
description: [
'The account ids in the influencer partner program, as `{ InfluencerIds }` — an object',
'around the list, not a bare array. Always empty here: no programme runs on this',
'server. `take` is accepted and ignored, there being nothing to page.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'take',
in: 'query',
required: false,
description: 'How many ids to return. Accepted and ignored.',
schema: { type: 'integer' },
},
],
responses: {
200: json(InfluencerIdsResponse, 'The influencer ids — always empty'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ InfluencerIds: [] })
}
)
// Whether the caller is in the influencer partner program. NOBODY is: this server runs
// no such program, and "not an influencer" is a 404 rather than a body saying so — the
// reference answers 404 with an EMPTY body typed `application/json`, which is what the
// client branches on. A 200 carrying null or `{}` is a different answer to it.
//
// Deliberately built by hand rather than through `c.notFound()`: the worker's not-found
// handler answers its own body, and this has to be empty with that content type.
//
// `accountId` is accepted and ignored — the reference binds it and never reads it, the
// answer being the same for everyone. The token is still validated first, so an
// unauthenticated caller gets 401 rather than the 404.
.get(
'/api/influencerpartnerprogram/influencer',
describeRoute({
tags: ['Econ'],
summary: 'The callers influencer partner program status',
description: [
'Always 404 with an EMPTY body typed `application/json` — this server runs no partner',
'program, and 404 is how the reference says “not an influencer”. `accountId` is',
'accepted and ignored; the answer is the same for every caller. Auth is checked first,',
'so a missing or invalid token is 401, not 404.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'accountId',
in: 'query',
required: false,
description: 'The account being asked about. Accepted and ignored.',
schema: { type: 'integer' },
},
],
responses: {
404: { description: 'Not in the partner program — always. Empty body' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.body('', 404, { 'Content-Type': 'application/json' })
}
)
// 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 workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app