mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
7371 lines
307 KiB
TypeScript
7371 lines
307 KiB
TypeScript
import { Hono } from 'hono'
|
||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||
import { z } from 'zod'
|
||
|
||
import {
|
||
addXp,
|
||
canManageRoomById,
|
||
consumeGift,
|
||
createGift,
|
||
getAccount,
|
||
getGift,
|
||
getOutfits,
|
||
getPendingGifts,
|
||
getPlayerIdsInRoom,
|
||
grantInvention,
|
||
levelReward,
|
||
levelsReached,
|
||
listInfluencerIds,
|
||
ownsInvention,
|
||
setOutfit,
|
||
updateAccount,
|
||
} from '@repo/domain'
|
||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||
import { validateAndGetAccountId, validateAndGetBuild, validateAndGetPlus } from '@repo/jwt'
|
||
|
||
import {
|
||
getCustomAvatarItems,
|
||
isQuestAssetTarget,
|
||
toQuestCustomAvatarItem,
|
||
toUgcPurchasable,
|
||
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
|
||
} from '../../api/src/custom-avatar-items-db'
|
||
import { getInventionById, toInventionV9, toSaveResult } from '../../api/src/inventions-db'
|
||
// The profanity filter behind `api`'s `POST /api/sanitize/v1`, imported rather than copied
|
||
// so a gift note is masked by the very same word list every other player-typed string is.
|
||
import { censorSwears } from '../../api/src/sanitize'
|
||
// 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 consumableDefinitions from '../static/consumables.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 questRewards from '../static/quest-rewards.json'
|
||
import { getAvatar, setAvatar } from './avatar-db'
|
||
import {
|
||
ALL_PLATFORMS,
|
||
creditCurrency,
|
||
CurrencyType,
|
||
DEFAULT_STARTING_TOKENS,
|
||
ensureStartingBalances,
|
||
getBalance,
|
||
isSpendable,
|
||
spendCurrency,
|
||
} from './balance-db'
|
||
import { getCatalogItem, getStoreListings, toCatalogAvatarItem } from './catalog-db'
|
||
// `LEGACY_CLIENT_BUILD` is shared with the storefront generator rather than restated: it picks
|
||
// which store FILE a caller is served here, and is what the generator's help names.
|
||
import { CatalogKind, LEGACY_CLIENT_BUILD } from './catalog-load'
|
||
import { claimChallengeGift, getChallengeStatuses, recordChallengeProgress } from './challenge-db'
|
||
import { buildRotation, rotationMapId, withWeeklyGift } from './challenge-rotation'
|
||
import {
|
||
consumeConsumable,
|
||
countConsumable,
|
||
getConsumables,
|
||
grantConsumable,
|
||
} from './consumables-db'
|
||
import { grantDiscordRoleGifts } from './discord-role-gift'
|
||
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
|
||
import {
|
||
getOwnedCustomAvatarItems,
|
||
grantCustomAvatarItem,
|
||
ownedCustomAvatarItemIds,
|
||
} from './inventory-custom-db'
|
||
import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db'
|
||
import {
|
||
AUTHED,
|
||
AvatarItemV4Dto,
|
||
AvatarV2Dto,
|
||
AwardRoomConsumableResultList,
|
||
AwardRoomConsumablesRequest,
|
||
AwardRoomCurrencyRequest,
|
||
AwardRoomCurrencyResultList,
|
||
AwardRoomKeyResultList,
|
||
AwardRoomKeysRequest,
|
||
BalanceEntry,
|
||
BulkPurchaseRequest,
|
||
BulkPurchaseResponse,
|
||
BuyInventionRequest,
|
||
BuyInventionResponse,
|
||
BuyInventionV3Response,
|
||
BuyItemRequest,
|
||
BuyItemResponse,
|
||
BuyRoomKeyResponse,
|
||
ChallengeProgressRequest,
|
||
ChallengeProgressResponse,
|
||
ChecklistCompleteResponse,
|
||
ChecklistEntry,
|
||
CompleteChecklistRequest,
|
||
ConsumeConsumableRequest,
|
||
ConsumeEnvelope,
|
||
ConsumeGiftRequest,
|
||
CreatePurchaseOfferRequest,
|
||
CreateRoomCurrencyRequest,
|
||
CreateRoomKeyRequest,
|
||
CustomAvatarItemsResponse,
|
||
DeletePurchaseOfferRequest,
|
||
EquipmentUpdateRequest,
|
||
ErrorResponse,
|
||
form,
|
||
GameRewardRequest,
|
||
InfluencerIdsResponse,
|
||
IsOnWishlistBulkRequest,
|
||
IsOnWishlistBulkResponse,
|
||
ItemPurchaseInfoList,
|
||
ItemPurchaseInfosRequest,
|
||
json,
|
||
JsonArray,
|
||
jsonBody,
|
||
JsonObject,
|
||
LockedItemsBulkRequest,
|
||
MakerAiFreeTrialEligibilityResponse,
|
||
OpaqueJsonBody,
|
||
OPTIONAL_AUTHED,
|
||
OwnsRoomKeysRequest,
|
||
OwnsRoomKeysResponse,
|
||
PurchasableItemsRequest,
|
||
PurchaseRoomCurrencyEnvelope,
|
||
PurchaseRoomCurrencyRequest,
|
||
PurchaseRoomKeyWithCurrencyRequest,
|
||
PurchaseRoomKeyWithCurrencyResponse,
|
||
QueryConsumablesRequest,
|
||
ReferralProgressResponse,
|
||
RemoveInfluencerBody,
|
||
RoomConsumableDto,
|
||
RoomConsumableEnvelope,
|
||
RoomCurrencyBalance,
|
||
RoomCurrencyDto,
|
||
RoomCurrencyEnvelope,
|
||
RoomCurrencyPurchaseOfferDto,
|
||
RoomCurrencyPurchaseOfferEnvelope,
|
||
RoomCurrencyPurchaseOffersDto,
|
||
RoomEconConfig,
|
||
RoomKeyDto,
|
||
RoomKeyEnvelope,
|
||
RoomPurchasableList,
|
||
RRPlusSignUpBonus,
|
||
SaveOutfitRequest,
|
||
SaveOutfitV4Response,
|
||
SubscriptionResponse,
|
||
SupportedInfluencerResponse,
|
||
SupportInfluencerBody,
|
||
SupportInfluencerResponse,
|
||
UgcPurchasableBulkRequest,
|
||
UgcPurchasableItemList,
|
||
UNAUTHORIZED_RESPONSE,
|
||
UpdateActiveConsumableRequest,
|
||
UpdateObjectiveRequest,
|
||
UpdateObjectiveResponse,
|
||
UpdateRoomCurrencyRequest,
|
||
UpdateRoomKeyRequest,
|
||
UpsertRoomConsumableRequest,
|
||
WishlistEnvelope,
|
||
WishlistItemList,
|
||
WishlistItemRequest,
|
||
} from './openapi'
|
||
import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
|
||
import {
|
||
awardRoomConsumable,
|
||
getRoomConsumable,
|
||
getRoomConsumables,
|
||
upsertRoomConsumable,
|
||
} from './room-consumable-db'
|
||
import {
|
||
awardRoomCurrency,
|
||
createPurchaseOffer,
|
||
createRoomCurrency,
|
||
deletePurchaseOffer,
|
||
findPurchaseOffer,
|
||
getPurchaseOffers,
|
||
getRoomBalance,
|
||
getRoomCurrencies,
|
||
getRoomCurrency,
|
||
spendRoomCurrency,
|
||
updateRoomCurrency,
|
||
} from './room-currency-db'
|
||
import {
|
||
awardRoomKey,
|
||
claimRoomKey,
|
||
createRoomKey,
|
||
getRoomKey,
|
||
getRoomKeys,
|
||
getRoomOwnerId,
|
||
ownsRoomKeys,
|
||
releaseRoomKey,
|
||
updateRoomKey,
|
||
} from './room-key-db'
|
||
import { getRoomPurchasables } from './room-purchasables-db'
|
||
import {
|
||
addWishlistItem,
|
||
getWishlist,
|
||
isOnWishlists,
|
||
parseWishlistTarget,
|
||
removeWishlistItem,
|
||
} from './wishlist-db'
|
||
|
||
import type { Context } from 'hono'
|
||
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
|
||
import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db'
|
||
import type { SavedInvention } from '../../api/src/inventions-db'
|
||
import type {
|
||
BalanceResponsePayload,
|
||
ConsumableMappingPayload,
|
||
LocalRoomKeyPayload,
|
||
PurchaseBalanceModificationPayload,
|
||
RoomCurrencyPayload,
|
||
} from '../../notify/src/notification-payloads'
|
||
import type { Avatar } from './avatar-db'
|
||
import type { CatalogRow } from './catalog-db'
|
||
import type {
|
||
ChallengeGiftBlock,
|
||
EquipmentGift,
|
||
WeeklyChallengeRotation,
|
||
} from './challenge-rotation'
|
||
import type { ConsumeResult } from './consumables-db'
|
||
import type { App, Env } from './context'
|
||
import type { Equipment } from './equipment-db'
|
||
import type { AvatarItem } from './inventory-db'
|
||
import type { RoomConsumable } from './room-consumable-db'
|
||
import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db'
|
||
import type { RoomKey, RoomKeyHolding } from './room-key-db'
|
||
import type { WishlistItem } from './wishlist-db'
|
||
|
||
// 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.
|
||
// Custom avatar items likewise live in an `api`-owned table; the UGC-purchasable bulk
|
||
// lookup is the store's view of those rows.
|
||
|
||
/**
|
||
* 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 (equipment, room consumables, …).
|
||
*
|
||
* 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 client build this request's token was minted for (`rn.ver`), as a comparable NUMBER —
|
||
* the leading `YYYYMMDD` of e.g. `20250718.01`, whose `.01` is a same-day rebuild and not a
|
||
* version to order by (`validateAndGetBuild` drops it). `null` when there is no valid
|
||
* token, when it carries no `rn.ver` (an older token, issued before the claim did), or
|
||
* when the claim isn't a build at all.
|
||
*
|
||
* Unverified — a client can claim any build — which is fine for what it gates here: a build
|
||
* lying about itself only changes which storefront its own player is shown.
|
||
*/
|
||
async function authedBuild(c: Context<App>): Promise<number | null> {
|
||
const version = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
|
||
if (version === null) return null
|
||
const build = Number.parseInt(version, 10)
|
||
return Number.isInteger(build) ? build : null
|
||
}
|
||
|
||
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
||
function unauthorized(c: Context<App>) {
|
||
return c.body(null, 401)
|
||
}
|
||
|
||
/**
|
||
* The `Error` a wishlist write answers when its body names no item. Shown to the player
|
||
* verbatim behind the client's “Wishlist error: ” prefix, so it is a sentence, not a code.
|
||
*/
|
||
const WISHLIST_NO_ITEM = 'No item was specified.'
|
||
|
||
/**
|
||
* The `{ Value, Success, Error, error_id }` envelope the wishlist writes answer in — the
|
||
* same shape the room-currency writes use. A null `item` with no `error` is still a success
|
||
* (removing something that wasn't listed); an `error` makes it a refusal.
|
||
*/
|
||
function wishlistEnvelope(c: Context<App>, item: WishlistItem | null, error?: string) {
|
||
return c.json({
|
||
Value: item,
|
||
Success: error === undefined,
|
||
Error: error ?? null,
|
||
error_id: null,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* The item a wishlist write's form body names — `purchasableItemId` or `customAvatarItemId`,
|
||
* as the client spells them, with the PascalCase spellings accepted for a hand-written
|
||
* request. Null when the body names nothing usable (see `parseWishlistTarget`).
|
||
*/
|
||
async function wishlistTargetFromBody(c: Context<App>) {
|
||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||
return parseWishlistTarget(
|
||
body.purchasableItemId ?? body.PurchasableItemId,
|
||
body.customAvatarItemId ?? body.CustomAvatarItemId
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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'
|
||
|
||
/**
|
||
* The messages the room-currency failure paths carry. One string per endpoint, not per
|
||
* reason: the client unwraps the envelope and hands its callers `Value`, so a more specific
|
||
* message would reach nobody but a log.
|
||
*
|
||
* The create string is the reference's own. The update one is its obvious counterpart and has
|
||
* not been seen on the wire — if the real client ever shows it and the wording is wrong, this
|
||
* is the line to fix.
|
||
*/
|
||
const CREATE_CURRENCY_FAILED = 'Failed to create currency'
|
||
const UPDATE_CURRENCY_FAILED = 'Failed to update currency'
|
||
const CREATE_OFFER_FAILED = 'Failed to create purchase offer'
|
||
const DELETE_OFFER_FAILED = 'Failed to delete purchase offer'
|
||
const SAVE_CONSUMABLE_FAILED = 'Failed to save consumable'
|
||
/**
|
||
* `Status` on the room-key create's `{ Status, RoomKey }` reply. 0 is the observed success
|
||
* code; the refusal code has not been observed, so 1 is an assumption.
|
||
*/
|
||
const ROOM_KEY_STATUS_OK = 0
|
||
const ROOM_KEY_STATUS_FAILED = 1
|
||
|
||
/**
|
||
* The most keys one room may list — the `RoomKeyConfig.MaxKeysPerRoom` the `api` worker's
|
||
* config (`apps/api/static/api-config-v2.json`) tells the client, restated here so the server
|
||
* refuses what the client already knows not to ask for.
|
||
*/
|
||
const MAX_KEYS_PER_ROOM = 10
|
||
|
||
/**
|
||
* The most a room key may cost, by what it is priced in: tokens (a null `PurchaseCurrencyId`)
|
||
* are real money to the player paying, so they are capped low; a room's own currency is the
|
||
* room's to inflate.
|
||
*/
|
||
const MAX_KEY_PRICE_TOKENS = 1000
|
||
const MAX_KEY_PRICE_ROOM_CURRENCY = 1_000_000_000
|
||
|
||
/**
|
||
* The envelope the create endpoint answers in: `{ Value, Success, Error, error_id }`.
|
||
*
|
||
* PascalCase beside a lowercase `error_id` — the client's own mixed casing, not a typo, and
|
||
* NOT the lowercase `{ success, error, value }` the `rooms` worker's room mutations use.
|
||
* A failure answers 200 with `Success: false` and a null `Value`, the way the reference's
|
||
* failure path does; only the auth gates answer with a status of their own.
|
||
*/
|
||
function roomCurrencyEnvelope(c: Context<App>, value: RoomCurrency | null, error?: string) {
|
||
return c.json({
|
||
Value: value,
|
||
Success: error === undefined,
|
||
Error: error ?? null,
|
||
error_id: null,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* The room-key create's envelope: `{ Status, RoomKey }`, as the live client was observed
|
||
* reading it — NOT the `{ Value, Success, Error, error_id }` the room-currency and consumable
|
||
* writes beside it answer in. `Status` 0 carries the key; a refusal is a non-zero `Status`
|
||
* with a null `RoomKey` (the refusal code is an assumption — only success has been observed).
|
||
*/
|
||
function roomKeyEnvelope(c: Context<App>, key: RoomKey | null) {
|
||
return c.json({
|
||
Status: key === null ? ROOM_KEY_STATUS_FAILED : ROOM_KEY_STATUS_OK,
|
||
RoomKey: key,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* The consumable write's envelope — the same `{ Value, Success, Error, error_id }` shape the
|
||
* room-currency writes answer in, carrying one listing.
|
||
*/
|
||
function roomConsumableEnvelope(c: Context<App>, value: RoomConsumable | null, error?: string) {
|
||
return c.json({
|
||
Value: value,
|
||
Success: error === undefined,
|
||
Error: error ?? null,
|
||
error_id: null,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* The create- and delete-offer envelope — the same `{ Value, Success, Error, error_id }`
|
||
* shape again, carrying one purchase offer.
|
||
*/
|
||
function purchaseOfferEnvelope(
|
||
c: Context<App>,
|
||
value: RoomCurrencyPurchaseOffer | null,
|
||
error?: string
|
||
) {
|
||
return c.json({
|
||
Value: value,
|
||
Success: error === undefined,
|
||
Error: error ?? null,
|
||
error_id: null,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Push `RoomCurrencyCreated` or `RoomCurrencyModified` for a room currency to everyone
|
||
* standing in the room, plus whoever made the change.
|
||
*
|
||
* Room-wide because a room currency is room-wide: every client in there prices the room's
|
||
* shops in it and shows what the player holds of it, so all of them need the change, not just
|
||
* the owner who made it. An EDIT matters to them just as much as a creation — a renamed or
|
||
* re-capped currency that only the editor hears about leaves everyone else spending the old
|
||
* one. Read from live presence across the room's instances. The editor is added separately:
|
||
* the settings UI opens from outside the room, and their own client is the one certain to be
|
||
* showing the currency list right now.
|
||
*
|
||
* The frame is typed as the hub's {@link RoomCurrencyPayload} — the client's own model — so a
|
||
* renamed key fails the build here rather than vanishing on the wire. It is the stored record
|
||
* verbatim: the two are the same object, which is why the HTTP response and this frame cannot
|
||
* drift apart.
|
||
*
|
||
* Best-effort, like the other pushes here — the currency has already committed, so a hub
|
||
* hiccup must not fail the request. One unreachable player doesn't cost the rest theirs.
|
||
*/
|
||
async function pushRoomCurrencyChange(
|
||
c: Context<App>,
|
||
currency: RoomCurrency,
|
||
changedByAccountId: number,
|
||
notificationType:
|
||
typeof NotificationType.RoomCurrencyCreated | typeof NotificationType.RoomCurrencyModified
|
||
): Promise<void> {
|
||
const frame: RoomCurrencyPayload = currency
|
||
|
||
// Presence is another worker's table, and this whole push is best-effort: a currency that
|
||
// has already committed must not fail the request because the audience couldn't be read.
|
||
// The editor still hears about it either way.
|
||
let occupants: number[] = []
|
||
try {
|
||
occupants = await getPlayerIdsInRoom(c.env.DB, currency.RoomId)
|
||
} catch (err) {
|
||
logger.error('failed to read room presence for a room-currency push', {
|
||
notificationType,
|
||
roomId: currency.RoomId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
|
||
const playerIds = new Set(occupants)
|
||
playerIds.add(changedByAccountId)
|
||
|
||
await Promise.all(
|
||
[...playerIds].map(async (playerId) => {
|
||
try {
|
||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||
playerId,
|
||
notificationType,
|
||
// Spread because `notifyPlayer` takes an index-signature record, which an
|
||
// interface doesn't satisfy implicitly.
|
||
{ ...frame }
|
||
)
|
||
} catch (err) {
|
||
logger.error('failed to push a room-currency notification', {
|
||
notificationType,
|
||
playerId,
|
||
roomId: currency.RoomId,
|
||
currencyId: currency.CurrencyId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
})
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Push `LocalRoomKeyCreated` (120) for a new room key to everyone standing in the room, plus
|
||
* whoever listed it — the same audience, for the same reason, as a room currency's push: a
|
||
* key is something every client in the room may offer for sale, and the settings UI that
|
||
* lists one opens from outside the room, so its author is added separately.
|
||
*
|
||
* The frame is typed as the hub's {@link LocalRoomKeyPayload} — the client's own decoder —
|
||
* and is the stored record verbatim, so the HTTP response and the frame cannot drift apart.
|
||
* Best-effort: the key has already committed, and a hub hiccup must not fail the request.
|
||
*/
|
||
async function pushRoomKeyCreated(
|
||
c: Context<App>,
|
||
key: RoomKey,
|
||
createdByAccountId: number
|
||
): Promise<void> {
|
||
const frame: LocalRoomKeyPayload = key
|
||
|
||
let occupants: number[] = []
|
||
try {
|
||
occupants = await getPlayerIdsInRoom(c.env.DB, key.RoomId)
|
||
} catch (err) {
|
||
logger.error('failed to read room presence for a room-key push', {
|
||
roomId: key.RoomId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
|
||
const playerIds = new Set(occupants)
|
||
playerIds.add(createdByAccountId)
|
||
|
||
await Promise.all(
|
||
[...playerIds].map(async (playerId) => {
|
||
try {
|
||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||
playerId,
|
||
NotificationType.LocalRoomKeyCreated,
|
||
{ ...frame }
|
||
)
|
||
} catch (err) {
|
||
logger.error('failed to push a room-key notification', {
|
||
playerId,
|
||
roomId: key.RoomId,
|
||
roomKeyId: key.RoomKeyId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
})
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Whether a consumable is a first-party (Rec Room Original) item, as its definition in
|
||
* `static/consumables.json` says — the value every consumable instance we serve must carry.
|
||
*
|
||
* The 2026 client keys an instance on this flag and checks it against the definition it holds:
|
||
* a mismatch throws (`Key IsRRO=False does not match Definition IsRRO=True!`) and the frame is
|
||
* lost, so the purchase never shows. Every definition in the file says true; a desc with none
|
||
* is still a built-in store item, so it defaults to true too.
|
||
*/
|
||
const RRO_BY_DESC = new Map(
|
||
(consumableDefinitions as Array<{ ConsumableItemDesc: string; IsRRO?: boolean }>).map((d) => [
|
||
d.ConsumableItemDesc,
|
||
d.IsRRO !== false,
|
||
])
|
||
)
|
||
const isRroConsumable = (consumableItemDesc: string): boolean =>
|
||
RRO_BY_DESC.get(consumableItemDesc) ?? true
|
||
|
||
/**
|
||
* 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,
|
||
ActivatedByRoomie: false,
|
||
IsRRO: isRroConsumable(consumed.consumableItemDesc),
|
||
} satisfies ConsumableMappingPayload
|
||
)
|
||
} 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> {
|
||
await pushConsumableMappingAdded(c, accountId, {
|
||
mappingId: gift.ConsumableMappingId ?? 0,
|
||
consumableItemDesc: gift.ConsumableItemDesc,
|
||
count: gift.ConsumableCount,
|
||
preExistingCount: gift.ConsumablePreExistingCount ?? 0,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* The `ConsumableMappingAdded` frame itself — one granted consumable instance (its row id,
|
||
* how many it holds, and the player's count before it). Sent when a box carrying it is
|
||
* opened ({@link pushConsumableAdded}) or, when there IS no box, at grant time: a purchase
|
||
* under `BypassGiftPackages` is never opened, and without this the consumable didn't show
|
||
* until the client refetched its inventory. Never both for one grant — a second frame for
|
||
* the same row id could be counted twice. Best-effort like the removed push.
|
||
*/
|
||
async function pushConsumableMappingAdded(
|
||
c: Context<App>,
|
||
accountId: number,
|
||
granted: {
|
||
mappingId: number
|
||
consumableItemDesc: string
|
||
count: number
|
||
preExistingCount: number
|
||
}
|
||
): Promise<void> {
|
||
try {
|
||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||
accountId,
|
||
NotificationType.ConsumableMappingAdded,
|
||
{
|
||
Id: granted.mappingId,
|
||
ConsumableItemDesc: granted.consumableItemDesc,
|
||
CreatedAt: new Date().toISOString(),
|
||
Count: granted.count,
|
||
InitialCount: granted.preExistingCount,
|
||
IsActive: false,
|
||
ActiveDurationMinutes: 0,
|
||
IsTransferable: false,
|
||
ActivatedByRoomie: false,
|
||
IsRRO: isRroConsumable(granted.consumableItemDesc),
|
||
} satisfies ConsumableMappingPayload
|
||
)
|
||
} 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 influencer an account supports in the partner program, as its reads serve it — the
|
||
* `supportedInfluencerId` on the account, or 0 for nobody (and for an account that does not
|
||
* exist, which supports nobody too). The whole body of `/api/influencerpartnerprogram/influencer`,
|
||
* `…/myinfluencer`, served as a bare number rather than wrapped in anything; the `…/support`
|
||
* and `…/remove` writes answer a status envelope instead. The stored id is served as-is: it was an influencer when picked, and a later
|
||
* revoke of their flag drops them from `…/influencers` but not from their supporters.
|
||
*/
|
||
async function supportedInfluencer(db: D1Database, accountId: number): Promise<number> {
|
||
const account = await getAccount(db, accountId)
|
||
return account?.supportedInfluencerId ?? 0
|
||
}
|
||
|
||
/**
|
||
* Whether the caller holds a Rec Room Plus subscription — the ONE definition, shared by
|
||
* `UpdateAndGetSubscription` (which reports it) and the storefront buys (which price off
|
||
* it via `SubscriberPrices`). Those two must never disagree: a subscriber whose client
|
||
* applied the discount itself and then had the buy refused as a price mismatch is exactly
|
||
* what one definition prevents.
|
||
*
|
||
* Nothing SELLS subscriptions here. Plus is `account.hasPlus`, claimed on the website by
|
||
* proving a qualifying role in the community Discord (`www` `POST /api/benefits/claim`),
|
||
* and it reaches this worker as the token's `rn.plus` claim — stamped by `auth` at login
|
||
* from that flag. So this is a pure token read: no database, no binding, nothing to load.
|
||
*
|
||
* The cost is FRESHNESS, deliberately accepted. The claim is only as current as the token,
|
||
* which lasts a day and is never refreshed (see TOKEN_TTL_SECONDS), so a player who claims
|
||
* on the website has to sign in again — and restart the game — before Plus applies. The
|
||
* website's claim page says so.
|
||
*
|
||
* The `developer` role does NOT grant Plus. It used to, as a stand-in while nothing else
|
||
* could confer it; now that the Discord claim exists, Plus is one thing with one source.
|
||
* An operator who wants a developer to have it sets `hasPlus` on their account like
|
||
* anyone else's.
|
||
*
|
||
* Never read from the body. No token, or an invalid one, is "not subscribed".
|
||
*/
|
||
async function isSubscriber(c: Context<App>): Promise<boolean> {
|
||
return validateAndGetPlus(c.req.raw, await c.env.JWT_SECRET.get())
|
||
}
|
||
|
||
/** `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 subscriber reports — Rec Room Plus, which the client's
|
||
* API calls a `CampusCard`. See `isSubscriber` for who counts as one: a `developer`, or a
|
||
* player who claimed `hasPlus` with a Discord role on the website.
|
||
*
|
||
* Nothing here sells subscriptions, so holding one of those IS the subscription. Every
|
||
* field is computed per call and none of it is persisted, so this is not a record of
|
||
* anything — dropping the role or the flag drops 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 plusSubscription(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,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A storefront catalog entry's `GiftDrop` (`static/storefronts/sf{N}.json`) — what a store
|
||
* item hands over. Field-for-field the client's own `GiftDrop` class, in its declared
|
||
* order, so a name here is a name the client reads.
|
||
*
|
||
* REQUIRED vs OPTIONAL is about what this server produces, not what the client declares:
|
||
* the required eleven are the ones every drop-building helper here sets (a game reward, a
|
||
* level-up box, a challenge gift — see {@link toGameRewardDrop} and friends), and they are
|
||
* the only ones the purchase and roll paths read. The rest are optional because nothing
|
||
* here synthesizes one, whether or not a captured catalog carries it — `GiftDropId`,
|
||
* `Unique`, `SubscribersOnly`, `ItemSetId` and `ItemSetFriendlyName` are on all 5,875
|
||
* captured entries, while `TagList`, `CustomAvatarItemId`, `AvatarItemId`,
|
||
* `EquipmentItemId` and `ThumbnailImageName` are on none of them.
|
||
*
|
||
* The store item around it carries `Prices` per currency and optionally `SubscriberPrices`
|
||
* — the discounted list a Rec Room Plus subscriber is shown and pays. Both hold more
|
||
* fields (IsFeatured, …) the purchase path doesn't need.
|
||
*/
|
||
interface StoreGiftDrop {
|
||
/**
|
||
* The drop's own id. Every captured entry has it equal to the item's
|
||
* `PurchasableItemId`, which is why the paths that need one (a weekly gift, a skin)
|
||
* take it off there instead of from here.
|
||
*/
|
||
GiftDropId?: number
|
||
FriendlyName: string
|
||
/**
|
||
* NULL on 23 captured entries — the client's field is a plain string, but the catalogs
|
||
* keep null and `""` apart, so a reader passing it on has to collapse it (`?? ''`).
|
||
*/
|
||
Tooltip: string | null
|
||
/** Not on any captured entry; nothing here reads or sets one. */
|
||
TagList?: string
|
||
ConsumableItemDesc: string
|
||
AvatarItemDesc: string
|
||
/** A UGC item's guid. Not on any captured entry — the captures predate them. */
|
||
CustomAvatarItemId?: string | null
|
||
AvatarItemType: number | null
|
||
EquipmentPrefabName: string
|
||
EquipmentModificationGuid: string
|
||
/**
|
||
* 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
|
||
/** Whether the player may hold only one. Nothing here enforces it. */
|
||
Unique?: boolean
|
||
/** Whether only a Rec Room Plus subscriber may buy it. Nothing here enforces it. */
|
||
SubscribersOnly?: boolean
|
||
Rarity: number
|
||
CurrencyType: number
|
||
Currency: number
|
||
Context: number
|
||
/** The set the item belongs to; null on 92 captured entries. */
|
||
ItemSetId?: number | null
|
||
ItemSetFriendlyName?: string
|
||
/** Catalog ids for the item the drop carries. Not on any captured entry. */
|
||
AvatarItemId?: number | null
|
||
EquipmentItemId?: number | null
|
||
/** Not on any captured entry; the client falls back to the item's own thumbnail. */
|
||
ThumbnailImageName?: string
|
||
|
||
// ---- Not part of the client's class -------------------------------------
|
||
|
||
/**
|
||
* 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`. The client's `GiftDrop` has no such field — it is the catalog's, and
|
||
* only this server reads it.
|
||
*/
|
||
QueryRedirectRarity?: number
|
||
/**
|
||
* XP the drop pays out. Ours, not the client's and not any catalog's — 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[]
|
||
/**
|
||
* The subscriber price list, where the catalog has one (sf300's item 2263 lists 95 tokens
|
||
* in `Prices` and 85 in here). A subscriber's client renders and posts this as
|
||
* `RequestedPrice`, so checking their buy against `Prices` alone 409s it as "Price has
|
||
* changed". Treated as a FLOOR rather than the price to expect, because the client also
|
||
* posts the FULL price for items whose two lists agree (sf3's 2208, 150/150) — see
|
||
* {@link priceCheck}.
|
||
*/
|
||
SubscriberPrices?: StorePrice[] | null
|
||
PurchasableItemId: number
|
||
}
|
||
|
||
/**
|
||
* The most Rec Room Plus can take off an item, in percent of the regular price.
|
||
*
|
||
* The client applies the discount ITSELF and posts the result as `RequestedPrice`, but it
|
||
* does NOT apply it to everything: sf3's item 2208 is 150 tokens in both catalog lists and a
|
||
* subscriber's client posts 150, while sf300's 2263 is 95/85 and posts 85. Only 144 of the
|
||
* 1382 captured items carry a discounted `SubscriberPrices` at all, and whether the rest are
|
||
* genuinely full price for a subscriber or were merely captured through a non-subscriber's
|
||
* view isn't answerable from here. So the server doesn't predict the number: it accepts
|
||
* anything from the regular price down to this much off (see {@link priceCheck}) and charges
|
||
* what the client asked to pay. Deriving one exact subscriber price instead 409'd every buy
|
||
* the client priced the other way.
|
||
*/
|
||
const SUBSCRIBER_DISCOUNT_PERCENT = 10
|
||
|
||
/** The lowest a subscriber's client can render an item whose regular price is `regular`. */
|
||
function subscriberFloor(regular: number): number {
|
||
return Math.floor((regular * (100 - SUBSCRIBER_DISCOUNT_PERCENT)) / 100)
|
||
}
|
||
|
||
/**
|
||
* The outcome of confirming a client's `RequestedPrice` against the catalog: the price to
|
||
* actually charge, or why the line can't be sold.
|
||
*/
|
||
type PriceCheck =
|
||
| { charge: number }
|
||
/** The item isn't sold in the requested currency at all. */
|
||
| 'no-currency'
|
||
/** The catalog moved under a stale client, or the price was made up. */
|
||
| 'mismatch'
|
||
|
||
/**
|
||
* Confirms what the buyer's client rendered, and answers what to charge them.
|
||
*
|
||
* A non-subscriber pays the `Prices` entry, exactly. A subscriber pays whatever they asked to
|
||
* pay within a BAND: the regular price at the top (their client posts it for items it doesn't
|
||
* discount) down to the catalog's `SubscriberPrices` entry or
|
||
* {@link SUBSCRIBER_DISCOUNT_PERCENT} off, whichever is lower.
|
||
*
|
||
* Charging `RequestedPrice` rather than a server-picked end of the band keeps the debit equal
|
||
* to the price the buyer was shown. The floor is what bounds the discount: a modified client
|
||
* can shave at most {@link SUBSCRIBER_DISCOUNT_PERCENT} off, and only while subscribed.
|
||
*/
|
||
function priceCheck(
|
||
item: StoreItem,
|
||
currencyType: number,
|
||
subscriber: boolean,
|
||
requestedPrice: unknown
|
||
): PriceCheck {
|
||
const regular = item.Prices.find((p) => p.CurrencyType === currencyType)
|
||
if (regular === undefined) return 'no-currency'
|
||
if (!Number.isInteger(requestedPrice)) return 'mismatch'
|
||
const requested = requestedPrice as number
|
||
if (requested === regular.Price) return { charge: requested }
|
||
if (!subscriber) return 'mismatch'
|
||
const listed = item.SubscriberPrices?.find((p) => p.CurrencyType === currencyType)
|
||
const floor = Math.min(subscriberFloor(regular.Price), listed?.Price ?? regular.Price)
|
||
return requested >= floor && requested < regular.Price ? { charge: requested } : 'mismatch'
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
/**
|
||
* Storefront ids that are served ANOTHER storefront's catalog, because no capture of their
|
||
* own exists yet. Placeholder: an alias here is a storefront this server hasn't got, not one
|
||
* it has decided is a duplicate, so a line should come OUT again the moment `static/storefronts`
|
||
* grows the real `sf{id}.json` — the alias silently wins over a file of that name.
|
||
*
|
||
* Resolved in {@link storefrontAssetPath} rather than at the route, so an aliased storefront
|
||
* is aliased for BUYING too. Browsing and purchasing read the same catalog by id, and an
|
||
* alias applied to only the browse side would show a page of items whose every purchase
|
||
* 404s as "no such storefront".
|
||
*/
|
||
const STOREFRONT_ALIASES: Record<string, string> = {
|
||
// Empty. 1704 was here for a while, standing in for a 2025 gift-drop storefront nobody had
|
||
// captured; the 2025 general store has since been dumped and is served as storefront 3 —
|
||
// see {@link STOREFRONT_BY_BUILD}.
|
||
// That is a per-BUILD variant of one storefront rather than an alias between two ids, which
|
||
// is why nothing is listed here.
|
||
}
|
||
|
||
/**
|
||
* Storefronts that have a SECOND file for newer clients, keyed by the id the client asks for
|
||
* and naming the file a build past {@link LEGACY_CLIENT_BUILD} is served instead.
|
||
*
|
||
* `3` is the general store at two points in time. `sf3.json` is the 2023 store, frozen;
|
||
* `sf3-2025.json` is the real 2025 store — a dump of the live game's storefront 3, written by
|
||
* `runx storefront build` with its captured sale stripped (see that command). One storefront
|
||
* id either way: the client asks for 3 in both cases and neither knows there are two files, so
|
||
* nothing about the request changes. The two files number their items differently — each is
|
||
* the store its build actually had — and nothing crosses between them.
|
||
*
|
||
* Resolved in {@link storefrontAssetPath}, which BOTH the listing route and
|
||
* {@link loadStorefront} go through, so browsing and buying always read the same file. That is
|
||
* the whole reason it is not done at the route: a newer client shown the 2025 store and then
|
||
* charged against the 2023 one would have every purchase 404 as "no such storefront".
|
||
*/
|
||
const STOREFRONT_BY_BUILD: Record<string, string> = {
|
||
'3': 'sf3-2025',
|
||
}
|
||
|
||
/**
|
||
* The ASSETS path a storefront id reads from, following any {@link STOREFRONT_ALIASES} entry
|
||
* and any {@link STOREFRONT_BY_BUILD} variant. The id arrives as a path param, so it is a
|
||
* string here rather than a number: both tables are matched on what the client asked for.
|
||
*
|
||
* `build` is the caller's `rn.ver` (see {@link authedBuild}), or null when there is no readable
|
||
* one. Null gets the captured file: an unversioned token is the OLD client, so treating "can't
|
||
* prove its version" as "newer" would swap the store out from under the build that needs it.
|
||
*/
|
||
function storefrontAssetPath(id: string, build: number | null): string {
|
||
const aliased = STOREFRONT_ALIASES[id] ?? id
|
||
const variant = STOREFRONT_BY_BUILD[aliased]
|
||
if (variant !== undefined && build !== null && build > LEGACY_CLIENT_BUILD) {
|
||
return `/${variant}.json`
|
||
}
|
||
return `/sf${aliased}.json`
|
||
}
|
||
|
||
/**
|
||
* Read a storefront catalog from the ASSETS binding — WHICH file depending on the caller's
|
||
* build, see {@link storefrontAssetPath}. 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 two thousand items and sf3-2025
|
||
* over three, and a bulk purchase carries up to `BULK_PURCHASE_CAP` lines.
|
||
*/
|
||
async function loadStorefront(c: Context<App>, storefrontType: number): Promise<Storefront | null> {
|
||
const build = await authedBuild(c)
|
||
const res = await c.env.ASSETS.fetch(
|
||
new URL(storefrontAssetPath(String(storefrontType), build), 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
|
||
}
|
||
|
||
/**
|
||
* How one item may be bought, as `POST /api/items/purchaseInfos` answers it. The store row
|
||
* holds ids only, so everything a price tag needs comes from here.
|
||
*
|
||
* `ItemId` re-uses the request's reference verbatim — camelCase members under a PascalCase
|
||
* key. It reads like a mistake and is not one: the client's decoder names the members that
|
||
* way on both legs, and PascalCasing them here loses the id.
|
||
*
|
||
* `PurchaseMethodId` names WHICH listing sells the item, and is a tagged union of the two
|
||
* kinds of id a listing can have: `Type` 1 carries a `Guid` (a UGC item, keyed by its own
|
||
* guid) and leaves `NumberId` null; a storefront's numbered `PurchasableItemId` would be the
|
||
* other side. Nothing here sells anything under a second listing, so the guid is the item's own.
|
||
*/
|
||
interface ItemPurchaseInfo {
|
||
ItemId: { itemType: number; itemId: string }
|
||
PurchaseMethodId: { Type: number; NumberId: number | null; Guid: string | null }
|
||
Prices: Array<{
|
||
CurrencyType: number
|
||
Price: number
|
||
StorefrontSaleData: {
|
||
SalePercent: number
|
||
SaleStartDate: string | null
|
||
SaleEndDate: string | null
|
||
} | null
|
||
}>
|
||
NewUntil: string | null
|
||
AvailableAt: string | null
|
||
AvailableUntil: string | null
|
||
CanBeGifted: boolean
|
||
CanApplySubscriberDiscount: boolean
|
||
SubscribersOnly: boolean
|
||
IsFeatured: boolean
|
||
}
|
||
|
||
/** The `PurchaseMethodId.Type` that carries a `Guid` rather than a `NumberId`. */
|
||
const PURCHASE_METHOD_TYPE_GUID = 1
|
||
|
||
/**
|
||
* The purchase-info projection of a custom avatar item.
|
||
*
|
||
* The price is in `RecCenterTokens` because that is what a UGC item costs: the creation UI's
|
||
* floor (`api`'s `/api/customAvatarItems/v1/minPriceForPublicItem`) is a token price, and the
|
||
* `price` column it writes is the same number. It must NOT be a room currency — those are
|
||
* scoped to a room this endpoint knows nothing about, and the client holds no balance to pay
|
||
* one with, so the item would draw a price it can never meet.
|
||
*
|
||
* The rest is what the row can honestly say:
|
||
* - `AvailableAt` is the item's creation — the moment it began being sellable. There is no
|
||
* scheduled listing here, so `AvailableUntil` is null: on sale until the creator pulls it.
|
||
* - `NewUntil` is null rather than derived from `CreatedAt`: nothing has ever defined how long
|
||
* “new” lasts here, and guessing draws the pip on items that are not.
|
||
* - `StorefrontSaleData` is a zero-percent sale rather than null, since nothing discounts UGC
|
||
* items yet and a present-but-empty sale is the shape the client always gets to read.
|
||
* - `SubscribersOnly`/`CanApplySubscriberDiscount` are false: subscriber pricing is a
|
||
* storefront-catalog feature (`sf{N}.json`'s `SubscriberPrices`) and no UGC item has one.
|
||
* - `IsFeatured` is the row's own flag, the same one the featured feed reads.
|
||
*
|
||
* `CanBeGifted` is true because the reference let players gift UGC items — but nothing here
|
||
* buys a custom avatar item yet, gift or otherwise, so the button it draws leads nowhere until
|
||
* that exists. It is the flag to flip if a dead gift button is worse than a missing one.
|
||
*/
|
||
function toItemPurchaseInfo(item: CustomAvatarItem): ItemPurchaseInfo {
|
||
return {
|
||
ItemId: { itemType: UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM, itemId: item.CustomAvatarItemId },
|
||
PurchaseMethodId: {
|
||
Type: PURCHASE_METHOD_TYPE_GUID,
|
||
NumberId: null,
|
||
Guid: item.CustomAvatarItemId,
|
||
},
|
||
Prices: [
|
||
{
|
||
CurrencyType: CurrencyType.RecCenterTokens,
|
||
Price: item.Price,
|
||
StorefrontSaleData: { SalePercent: 0, SaleStartDate: null, SaleEndDate: null },
|
||
},
|
||
],
|
||
NewUntil: null,
|
||
AvailableAt: item.CreatedAt,
|
||
AvailableUntil: null,
|
||
CanBeGifted: true,
|
||
CanApplySubscriberDiscount: false,
|
||
SubscribersOnly: false,
|
||
IsFeatured: item.IsFeatured,
|
||
}
|
||
}
|
||
|
||
/** 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
|
||
|
||
/** What a box says when the buyer wrote nothing — a self-purchase, or a gift sent bare. */
|
||
const DEFAULT_GIFT_MESSAGE = 'A gift for you <3'
|
||
|
||
/**
|
||
* The most a gift note may carry — the same 150 the client's own input field stops typing at,
|
||
* so a longer one is a client that ignored its own limit rather than a longer note.
|
||
*/
|
||
const MAX_GIFT_MESSAGE_LENGTH = 150
|
||
|
||
/**
|
||
* The note a gift box carries: capped at {@link MAX_GIFT_MESSAGE_LENGTH}, then masked the way
|
||
* every other string a player typed is.
|
||
*
|
||
* The buyer writes this and someone ELSE reads it — off the box, out of the hub frame, and
|
||
* for as long as the box goes unopened — so a gift is a way to put text in front of a player
|
||
* who never chose to hear from you. That is why both rules are re-applied here: nothing
|
||
* obliges a client to have called `POST /api/sanitize/v1` first, or to have honoured its own
|
||
* character limit, and this is the last point before the note is stored. `chat` censors its
|
||
* messages again for the same reason.
|
||
*
|
||
* Trimming and masking (rather than refusing) matches the rest of this server: the purchase
|
||
* goes through, the swear comes out as asterisks, the overrun is dropped, and the buyer is
|
||
* never told their gift was rejected. Blocked characters are deliberately left alone, as in
|
||
* chat — a note is emoji-carrying text, and stripping format characters would break the
|
||
* joiners inside a multi-person emoji.
|
||
*
|
||
* The cap is applied FIRST so what gets filtered is what gets stored: cutting a word in half
|
||
* can leave a swear where there wasn't one ("assassin" ending as "ass"), and cutting after
|
||
* the mask would leave a half-masked word instead. Both counts are UTF-16 units, as the
|
||
* client's are — a trailing lone surrogate is dropped rather than stored as half a character.
|
||
*/
|
||
function giftMessage(gift: GiftRequest | null): string {
|
||
if (typeof gift?.Message !== 'string') return DEFAULT_GIFT_MESSAGE
|
||
return censorSwears(truncateGiftMessage(gift.Message))
|
||
}
|
||
|
||
/** `message` cut to the cap, never through the middle of a surrogate pair. */
|
||
function truncateGiftMessage(message: string): string {
|
||
if (message.length <= MAX_GIFT_MESSAGE_LENGTH) return message
|
||
const cut = message.slice(0, MAX_GIFT_MESSAGE_LENGTH)
|
||
const last = cut.charCodeAt(cut.length - 1)
|
||
// A high surrogate at the end lost its partner to the cut, and alone it is not a
|
||
// character at all — the client would draw the replacement glyph for it.
|
||
return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut
|
||
}
|
||
|
||
/**
|
||
* Build the stored gift-box content (the client's rendered "gift box") from a gift-drop.
|
||
*
|
||
* `fromPlayerId` and `giftContext` are stamped on because the box outlives the request that
|
||
* made it: a gift's receiver may well be offline and meets it in `GET /api/avatar/v2/gifts`,
|
||
* with nothing but the row to say who sent it or why. They default to Coach and the drop's
|
||
* own context — a box the server handed over on nobody's behalf.
|
||
*/
|
||
/**
|
||
* `AvatarItemType` as a gift box carries it: the drop's own (0 when it names none) for an
|
||
* AVATAR ITEM — a baked one by `AvatarItemDesc` or a custom one by `CustomAvatarItemId` — and
|
||
* NULL for anything else: a consumable, a skin, a box, a token bundle.
|
||
*
|
||
* The client routes a box by this field before it looks at what else the box names: a 0
|
||
* reads as "an avatar item of type 0", and a consumable or skin box sent with 0 fails with
|
||
* "can't find avatar item" because there is no such item to find. The 2025 store lists every
|
||
* non-avatar drop with `AvatarItemType: null` for exactly that reason, and this is what the
|
||
* old `?? 0` coalescing used to erase. A custom item's box is the other way round: it needs
|
||
* the number, as the bag's guid-keyed box ({@link toCustomGiftContent}) sends it. Applied at
|
||
* every surface a box is serialized on — the stored box, both purchase responses and the hub
|
||
* frame — so they cannot disagree.
|
||
*/
|
||
function boxAvatarItemType(drop: StoreGiftDrop): number | null {
|
||
const isAvatarItem =
|
||
drop.AvatarItemDesc !== '' ||
|
||
(typeof drop.CustomAvatarItemId === 'string' && drop.CustomAvatarItemId !== '')
|
||
return isAvatarItem ? (drop.AvatarItemType ?? 0) : null
|
||
}
|
||
|
||
function toGiftContent(
|
||
giftDrop: StoreGiftDrop,
|
||
message: string,
|
||
consumableCount: number,
|
||
consumableMappingId = 0,
|
||
consumablePreExistingCount = 0,
|
||
fromPlayerId = COACH_ACCOUNT_ID,
|
||
giftContext: number | null = null
|
||
): GiftContent {
|
||
return {
|
||
FromPlayerId: fromPlayerId,
|
||
GiftContext: giftContext ?? giftDrop.Context,
|
||
ConsumableItemDesc: giftDrop.ConsumableItemDesc,
|
||
ConsumableCount: consumableCount,
|
||
ConsumableMappingId: consumableMappingId,
|
||
ConsumablePreExistingCount: consumablePreExistingCount,
|
||
AvatarItemDesc: giftDrop.AvatarItemDesc,
|
||
AvatarItemType: boxAvatarItemType(giftDrop),
|
||
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,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The stored gift box for a bought CUSTOM avatar item: the item named by
|
||
* `CustomAvatarItemId` where a catalog box names an `AvatarItemDesc`, every other item field
|
||
* empty. A custom item is no storefront drop, so it has no rarity of its own, and its context
|
||
* is the `Gift` block's or 0. `fromPlayerId` is routed as {@link toGiftContent}'s is.
|
||
*/
|
||
function toCustomGiftContent(
|
||
item: CustomAvatarItem,
|
||
message: string,
|
||
fromPlayerId: number,
|
||
giftContext: number | null
|
||
): GiftContent {
|
||
return {
|
||
FromPlayerId: fromPlayerId,
|
||
GiftContext: giftContext ?? 0,
|
||
ConsumableItemDesc: '',
|
||
ConsumableCount: 0,
|
||
AvatarItemDesc: '',
|
||
AvatarItemType: 0,
|
||
CustomAvatarItemId: item.CustomAvatarItemId,
|
||
CurrencyType: 0,
|
||
Currency: 0,
|
||
Xp: 0,
|
||
PackageType: 0,
|
||
Message: message,
|
||
EquipmentPrefabName: '',
|
||
EquipmentModificationGuid: '',
|
||
GiftRarity: 0,
|
||
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,
|
||
giftContext: number | null = null
|
||
): Promise<void> {
|
||
await sendGiftReceivedFrame(c, accountId, {
|
||
Id: gift.id,
|
||
FromGiftDropId: 0,
|
||
FromPlayerId: fromPlayerId,
|
||
ConsumableItemDesc: gift.drop.ConsumableItemDesc,
|
||
AvatarItemDesc: gift.drop.AvatarItemDesc,
|
||
AvatarItemType: boxAvatarItemType(gift.drop),
|
||
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: giftContext ?? gift.drop.Context,
|
||
GiftRarity: gift.drop.Rarity,
|
||
Message: message,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* {@link pushGiftReceived} for a gifted custom avatar item's box, read off its stored
|
||
* content. The frame carries `CustomAvatarItemId` beside the usual fields: the decoder
|
||
* recovered for this frame names no such member and drops unknown ones silently, so it costs
|
||
* nothing if unread, and it is the only thing on the frame that says what the box holds.
|
||
*/
|
||
async function pushCustomGiftReceived(
|
||
c: Context<App>,
|
||
accountId: number,
|
||
giftId: number,
|
||
content: GiftContent
|
||
): Promise<void> {
|
||
await sendGiftReceivedFrame(c, accountId, {
|
||
Id: giftId,
|
||
FromGiftDropId: 0,
|
||
FromPlayerId: content.FromPlayerId,
|
||
ConsumableItemDesc: content.ConsumableItemDesc,
|
||
AvatarItemDesc: content.AvatarItemDesc,
|
||
AvatarItemType: content.AvatarItemType,
|
||
CustomAvatarItemId: content.CustomAvatarItemId,
|
||
EquipmentPrefabName: content.EquipmentPrefabName,
|
||
EquipmentModificationGuid: content.EquipmentModificationGuid,
|
||
CurrencyType: content.CurrencyType,
|
||
Currency: content.Currency,
|
||
Xp: content.Xp,
|
||
Level: 0,
|
||
Platform: -1,
|
||
PlatformsToSpawnOn: -1,
|
||
BalanceType: ALL_PLATFORMS,
|
||
GiftContext: content.GiftContext,
|
||
GiftRarity: content.GiftRarity,
|
||
Message: content.Message,
|
||
})
|
||
}
|
||
|
||
/** Send one GiftPackageReceivedImmediate frame, best-effort (see {@link pushGiftReceived}). */
|
||
async function sendGiftReceivedFrame(
|
||
c: Context<App>,
|
||
accountId: number,
|
||
payload: Record<string, unknown> & { Id: number }
|
||
): Promise<void> {
|
||
try {
|
||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||
accountId,
|
||
NotificationType.GiftPackageReceivedImmediate,
|
||
payload
|
||
)
|
||
} catch (err) {
|
||
logger.error('failed to push GiftPackageReceivedImmediate notification', {
|
||
accountId,
|
||
giftId: payload.Id,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Push a PlayerProgressionLevelUpdate so the client's level bar moves when XP lands, instead
|
||
* of waiting for its next progression read. `XP` is the progress into the current level (the
|
||
* ladder spends the rest on the level-ups), which is what the bar draws against the
|
||
* `LevelProgressionMaps` the client is served.
|
||
*
|
||
* Best-effort: the XP is already banked, so a hub failure costs a bar animation, not the
|
||
* reward.
|
||
*/
|
||
async function pushProgressionUpdate(
|
||
c: Context<App>,
|
||
accountId: number,
|
||
progression: Progression
|
||
): Promise<void> {
|
||
try {
|
||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||
accountId,
|
||
NotificationType.PlayerProgressionLevelUpdate,
|
||
{ PlayerId: progression.PlayerId, Level: progression.Level, XP: progression.XP }
|
||
)
|
||
} catch (err) {
|
||
logger.error('failed to push PlayerProgressionLevelUpdate notification', {
|
||
accountId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The catalog a query drop rolls from: sf3, the general store. It is the only catalog with
|
||
* a real pool at every rarity (1161 items against 8–40 in the themed ones), it's where the
|
||
* "Random box" family itself sells, and a box promising "a random 4-star item" plainly
|
||
* means the whole item universe rather than whichever seasonal shelf it was bought from.
|
||
*/
|
||
const ROLL_STOREFRONT_TYPE = 3
|
||
|
||
/**
|
||
* Every item a roll or a weekly gift may draw, or `[]` if it can't be read (a roll then yields
|
||
* nothing).
|
||
*
|
||
* The storefront PLUS every equipment skin the storefront does not sell. The 2025 store lists
|
||
* every skin (`runx storefront build` appends the ones the game's own store never sold), the
|
||
* 2023 store none — and the weekly gift pool is exactly the equipment in this list, which for
|
||
* an older build would otherwise be empty. The rest come from the `catalog` table, which is
|
||
* loaded from the 2025 store; one already listed is not added again, or a roll would weight it
|
||
* double.
|
||
*
|
||
* Being in this list does NOT make an item purchasable. `findStoreItem` and the bulk bag resolve
|
||
* a purchase against the storefront file, never against this.
|
||
*/
|
||
async function loadRollCatalog(c: Context<App>): Promise<StoreItem[]> {
|
||
const storefront = await loadStorefront(c, ROLL_STOREFRONT_TYPE)
|
||
const { results } = await c.env.DB.prepare(
|
||
`SELECT * FROM catalog WHERE kind = ?1 AND catalog_id IS NOT NULL`
|
||
)
|
||
.bind(CatalogKind.Skin)
|
||
.all<CatalogRow>()
|
||
const listed = storefront?.StoreItems ?? []
|
||
const sold = new Set(listed.map((item) => item.GiftDrop.EquipmentModificationGuid))
|
||
return [...listed, ...results.filter((row) => !sold.has(row.item_key)).map(toSkinStoreItem)]
|
||
}
|
||
|
||
/**
|
||
* One catalog skin as a STORE ITEM, so the roll catalog and the weekly gift pool can read it the
|
||
* same way they read a storefront entry.
|
||
*
|
||
* Keyed the way a gift-drop keys equipment (`EquipmentPrefabName` + `EquipmentModificationGuid`)
|
||
* rather than as an avatar item — that guid is what marks an entry as equipment, and what the
|
||
* gift pool filters on. Priced at zero: nothing sells these, and a price here would be a number
|
||
* no surface ever shows.
|
||
*/
|
||
function toSkinStoreItem(row: CatalogRow): StoreItem {
|
||
return {
|
||
GiftDrop: {
|
||
FriendlyName: row.friendly_name,
|
||
Tooltip: row.tooltip ?? '',
|
||
ConsumableItemDesc: '',
|
||
AvatarItemDesc: '',
|
||
// A skin is no avatar item: null, as the store lists every skin.
|
||
AvatarItemType: null,
|
||
EquipmentPrefabName: row.prefab_name ?? '',
|
||
EquipmentModificationGuid: row.item_key,
|
||
Rarity: row.rarity,
|
||
Context: 0,
|
||
Currency: 0,
|
||
CurrencyType: 0,
|
||
},
|
||
Prices: [],
|
||
PurchasableItemId: row.catalog_id as number,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Equipment prefabs a weekly gift is never drawn from, matched on the prefix of
|
||
* `EquipmentPrefabName`.
|
||
*
|
||
* `[Sandbox_D4]` … `[Sandbox_D20]` are the sandbox dice — 24 skins across six prefabs, a sixth
|
||
* of the whole pool. Theming a week on "Sandbox D8 (Pewter)" spends the week's headline reward
|
||
* on a die recolour, so they are excluded and the pool is the 248 that remain.
|
||
*/
|
||
const WEEKLY_GIFT_EXCLUDED_PREFABS = ['[Sandbox_']
|
||
|
||
/**
|
||
* The equipment a weekly challenge gift can be drawn from: every roll-catalog item carrying an
|
||
* `EquipmentModificationGuid`, less {@link WEEKLY_GIFT_EXCLUDED_PREFABS}. Weekly rewards are
|
||
* equipment — the captured rotation's is a camera skin — and that guid is exactly what marks an
|
||
* entry as equipment.
|
||
*
|
||
* The pool is the roll catalog's equipment — the skins the caller's store sells plus the
|
||
* catalog's, see {@link loadRollCatalog} — so a week can be themed on a skin the 2023 store
|
||
* never sold.
|
||
*
|
||
* `GiftDropId` comes off `PurchasableItemId`, which is a skin's `catalog_id` as well.
|
||
*/
|
||
function toEquipmentGiftPool(catalog: StoreItem[]): EquipmentGift[] {
|
||
return catalog
|
||
.filter(
|
||
(item) =>
|
||
item.GiftDrop.EquipmentModificationGuid !== '' &&
|
||
!WEEKLY_GIFT_EXCLUDED_PREFABS.some((prefix) =>
|
||
item.GiftDrop.EquipmentPrefabName.startsWith(prefix)
|
||
)
|
||
)
|
||
.map((item) => ({
|
||
GiftDropId: item.PurchasableItemId,
|
||
EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName,
|
||
EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid,
|
||
Rarity: item.GiftDrop.Rarity,
|
||
// Carried so the rotation can theme the week on the item it rolled; the grant path
|
||
// resolves the same name from this entry when it hands the item over.
|
||
FriendlyName: item.GiftDrop.FriendlyName,
|
||
}))
|
||
}
|
||
|
||
/**
|
||
* The same pool, memoised for the life of the isolate. `getCurrent` needs it on every call
|
||
* just to show the week's reward, and sf3 is a megabyte and a half of JSON to fetch and parse
|
||
* — but it is a bundled asset, so it cannot change under a running isolate and a deploy
|
||
* builds new ones. A failed read is deliberately NOT cached: it would pin an empty pool (and
|
||
* so the static fallback gift) until the next deploy.
|
||
*/
|
||
let cachedGiftPool: EquipmentGift[] | null = null
|
||
async function loadEquipmentGiftPool(c: Context<App>): Promise<EquipmentGift[]> {
|
||
if (cachedGiftPool !== null) return cachedGiftPool
|
||
const pool = toEquipmentGiftPool(await loadRollCatalog(c))
|
||
if (pool.length > 0) cachedGiftPool = pool
|
||
return pool
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
/**
|
||
* Who the box says it is from, and why it exists — a purchase gifted to another player
|
||
* carries the buyer (or Coach, when they sent it anonymously) and the `Gift` block's
|
||
* `GiftContext`. Default: Coach and the drop's own context, i.e. a box from the server.
|
||
*/
|
||
fromPlayerId?: number
|
||
giftContext?: number | null
|
||
}
|
||
|
||
/**
|
||
* 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))
|
||
}
|
||
// A first-party custom avatar item: the 2025 store lists five hundred of them, each a plain
|
||
// numbered listing whose drop names the item by guid. Ownership is the same row a bag's
|
||
// guid-keyed line writes; without this the line charges and the box opens on nothing.
|
||
if (typeof giftDrop.CustomAvatarItemId === 'string' && giftDrop.CustomAvatarItemId !== '') {
|
||
await grantCustomAvatarItem(db, accountId, giftDrop.CustomAvatarItemId)
|
||
}
|
||
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) {
|
||
// No box means nothing will ever be opened to announce the consumable, so announce it
|
||
// now — otherwise a bag bought with `BypassGiftPackages` doesn't show it until a refetch.
|
||
if (isConsumable) {
|
||
await pushConsumableMappingAdded(c, accountId, {
|
||
mappingId: consumableMappingId,
|
||
consumableItemDesc: giftDrop.ConsumableItemDesc,
|
||
count: consumableCount,
|
||
preExistingCount: consumablePreExisting,
|
||
})
|
||
}
|
||
return { id: 0, drop: giftDrop }
|
||
}
|
||
const { id } = await createGift(
|
||
db,
|
||
accountId,
|
||
toGiftContent(
|
||
giftDrop,
|
||
message,
|
||
consumableCount,
|
||
consumableMappingId,
|
||
consumablePreExisting,
|
||
options.fromPlayerId,
|
||
options.giftContext
|
||
)
|
||
)
|
||
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: boxAvatarItemType(drop),
|
||
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 a catalog item, with the catalog's own price. */
|
||
interface BulkCatalogLine {
|
||
kind: 'catalog'
|
||
method: PurchaseMethodId
|
||
item: StoreItem
|
||
/** The UNIT price from the catalog — `count` copies cost `price * count`. */
|
||
price: number
|
||
count: number
|
||
gift: GiftRequest | null
|
||
}
|
||
|
||
/**
|
||
* A line that resolved to a CUSTOM avatar item — a guid-keyed line, the `Guid` being the
|
||
* item's `CustomAvatarItemId` — at the item's own `Price`. Owned once, so `count` is one; the
|
||
* price goes to the item's creator rather than nowhere, which is what makes it a sale.
|
||
*/
|
||
interface BulkCustomLine {
|
||
kind: 'custom'
|
||
method: PurchaseMethodId
|
||
custom: CustomAvatarItem
|
||
price: number
|
||
count: 1
|
||
gift: GiftRequest | null
|
||
}
|
||
|
||
/** A line that resolved to something buyable. */
|
||
type BulkPurchaseLine = BulkCatalogLine | BulkCustomLine
|
||
|
||
/**
|
||
* 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`: the whole item record on
|
||
* a guid-keyed line that sold, which the client has nothing else to render it from. Both it
|
||
* and `GiftPackage` are null on a line that didn't sell. A custom item's box names it by
|
||
* `CustomAvatarItemId` rather than `AvatarItemDesc`; ownership is granted straight into
|
||
* `inventory_custom` either way.
|
||
*/
|
||
interface BulkPurchaseData {
|
||
GiftPackage: Record<string, unknown> | null
|
||
PurchasableItemId: number | null
|
||
CustomAvatarItem: 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: boxAvatarItemType(drop),
|
||
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,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* {@link toGiftPackage} for the box a custom avatar item comes in — the same twenty keys,
|
||
* read off the stored {@link toCustomGiftContent} so the response and `GET
|
||
* /api/avatar/v2/gifts` describe one box.
|
||
*/
|
||
function toCustomGiftPackage(
|
||
id: number,
|
||
playerId: number,
|
||
content: GiftContent
|
||
): Record<string, unknown> {
|
||
return {
|
||
Id: id,
|
||
PlayerId: playerId,
|
||
FromPlayerId: content.FromPlayerId,
|
||
ConsumableItemDesc: content.ConsumableItemDesc,
|
||
AvatarItemType: content.AvatarItemType,
|
||
AvatarItemDesc: content.AvatarItemDesc,
|
||
CustomAvatarItemId: content.CustomAvatarItemId,
|
||
EquipmentPrefabName: content.EquipmentPrefabName,
|
||
EquipmentModificationGuid: content.EquipmentModificationGuid,
|
||
CurrencyType: content.CurrencyType,
|
||
Currency: content.Currency,
|
||
Xp: content.Xp,
|
||
GiftContext: content.GiftContext,
|
||
GiftRarity: content.GiftRarity,
|
||
Message: content.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 and the buyer's subscriber status are passed in — so the whole bag
|
||
* resolves from ONE storefront read and ONE token read. The price check is buyItem's, per
|
||
* line: `RequestedPrice` is the UNIT price the client rendered (for a subscriber, anywhere in
|
||
* the discount band — see {@link priceCheck}), 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,
|
||
subscriber: boolean,
|
||
custom: CustomBagContext
|
||
): BulkPurchaseLine | BulkLineFailure {
|
||
const method = toPurchaseMethodId(line.ItemPurchaseMethodId)
|
||
// 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',
|
||
}
|
||
}
|
||
// A guid-keyed line names a CUSTOM avatar item, resolved against the `custom_avatar_item`
|
||
// table rather than the bag's catalog.
|
||
if (method.Type === PURCHASE_METHOD_TYPE_GUID) {
|
||
return resolveCustomLine(line, method, count, currencyType, custom)
|
||
}
|
||
if (method.Type !== PURCHASE_METHOD_NUMBER_ID || method.NumberId === null) {
|
||
return {
|
||
method,
|
||
code: UpdateResponse.NoItemAvailable,
|
||
error: 'Only storefront item ids and custom avatar item guids can be bought',
|
||
}
|
||
}
|
||
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 checked = priceCheck(item, currencyType, subscriber, line.RequestedPrice)
|
||
if (checked === 'no-currency') {
|
||
return {
|
||
method,
|
||
code: UpdateResponse.NoItemAvailable,
|
||
error: 'Currency type not available for this item',
|
||
}
|
||
}
|
||
if (checked === 'mismatch') {
|
||
return {
|
||
method,
|
||
code: UpdateResponse.RequestedPriceDoesNotMatch,
|
||
error: !Number.isInteger(line.RequestedPrice)
|
||
? 'RequestedPrice is required'
|
||
: 'Price has changed',
|
||
}
|
||
}
|
||
const gift = typeof line.Gift === 'object' && line.Gift !== null ? line.Gift : null
|
||
return { kind: 'catalog', method, item, price: checked.charge, count, gift }
|
||
}
|
||
|
||
/**
|
||
* What a bag's guid-keyed lines resolve against, read ONCE for the bag: the custom avatar
|
||
* items it names (keyed by lowercased id — a GUID's case is not part of its identity, and the
|
||
* client is not consistent about it) and which of them the buyer already owns.
|
||
*/
|
||
interface CustomBagContext {
|
||
buyerId: number
|
||
items: Map<string, CustomAvatarItem>
|
||
/** Which of the bag's guids each line's RECEIVER already owns, keyed by receiver. */
|
||
owned: Map<number, Set<string>>
|
||
}
|
||
|
||
/** Who a bag line's item goes to: the `Gift` block's `ToPlayerId`, else the buyer. */
|
||
function lineReceiver(gift: GiftRequest | null | undefined, buyerId: number): number {
|
||
return Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : buyerId
|
||
}
|
||
|
||
/**
|
||
* Resolve a guid-keyed line to the custom avatar item it names, or the failure its entry will
|
||
* carry. The rules are buyInvention's, per line, applied to whoever RECEIVES the item — the
|
||
* buyer, or the player a `Gift` block names: the item must exist and be published
|
||
* (`Accessibility` 0 is a draft, visible to its creator alone), the receiver must not be its
|
||
* creator (who owns it already — and, buying it themself, would be paying themself) nor own
|
||
* it already, and the
|
||
* posted `RequestedPrice` must be the item's `Price` — no subscriber band, since a custom
|
||
* item has no `SubscriberPrices`. Custom items are priced in RecCenterTokens only, as the
|
||
* store lists them, so a bag in another currency cannot buy one. Owned once, so a count above
|
||
* one is refused as it is for an avatar item. A creator may GIFT their own item: they pay the
|
||
* price and are paid it back, so the receiver gets it at no net cost to anyone.
|
||
*/
|
||
function resolveCustomLine(
|
||
line: PurchaseItemRequest,
|
||
method: PurchaseMethodId,
|
||
count: number,
|
||
currencyType: number,
|
||
custom: CustomBagContext
|
||
): BulkCustomLine | BulkLineFailure {
|
||
const guid = method.Guid?.toLowerCase() ?? null
|
||
const item = guid === null ? undefined : custom.items.get(guid)
|
||
if (guid === null || item === undefined || item.Accessibility === 0) {
|
||
return { method, code: UpdateResponse.NoItemAvailable, error: 'Item not found' }
|
||
}
|
||
const gift = typeof line.Gift === 'object' && line.Gift !== null ? line.Gift : null
|
||
const receiverId = lineReceiver(gift, custom.buyerId)
|
||
if (item.CreatorAccountId === receiverId) {
|
||
return {
|
||
method,
|
||
code: UpdateResponse.PlayerNotEligible,
|
||
error:
|
||
receiverId === custom.buyerId
|
||
? 'Cannot buy your own item'
|
||
: 'The recipient created this item',
|
||
}
|
||
}
|
||
if (custom.owned.get(receiverId)?.has(guid) === true) {
|
||
return { method, code: UpdateResponse.AlreadyOwned, error: 'Already owned' }
|
||
}
|
||
if (currencyType !== CurrencyType.RecCenterTokens) {
|
||
return {
|
||
method,
|
||
code: UpdateResponse.NoItemAvailable,
|
||
error: 'Currency type not available for this item',
|
||
}
|
||
}
|
||
if (count !== 1) {
|
||
return {
|
||
method,
|
||
code: UpdateResponse.RequestedAmountNotAllowed,
|
||
error: 'This item can only be bought once per line',
|
||
}
|
||
}
|
||
if (line.RequestedPrice !== item.Price) {
|
||
return {
|
||
method,
|
||
code: UpdateResponse.RequestedPriceDoesNotMatch,
|
||
error: !Number.isInteger(line.RequestedPrice)
|
||
? 'RequestedPrice is required'
|
||
: 'Price has changed',
|
||
}
|
||
}
|
||
return { kind: 'custom', method, custom: item, price: item.Price, count: 1, gift }
|
||
}
|
||
|
||
/** Whether a resolved line is buyable or is already a failure. */
|
||
function isBulkLine(resolved: BulkPurchaseLine | BulkLineFailure): resolved is BulkPurchaseLine {
|
||
return 'kind' in resolved
|
||
}
|
||
|
||
/**
|
||
* XP paid for a claimed game reward. One flat amount for every reward type — "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.
|
||
*
|
||
* Exactly what each of the first three levels costs, so a fresh player's first rewards each
|
||
* level them up; from level 4 (20 XP a level) it takes two or more. The daily
|
||
* {@link GAME_REWARD_DAILY_XP_CAP} paces it from there.
|
||
*/
|
||
const GAME_REWARD_XP = 10
|
||
|
||
/**
|
||
* The most game-reward XP a player earns in one rolling 24h window: fifteen claims' worth,
|
||
* whatever the type or activity — `FirstActivityOfDay` aside, whose XP is a bonus on top, like
|
||
* its tokens. The running total is the player's `$xp` row in `reward_status`
|
||
* (`claimAllowance`). A multiple of {@link GAME_REWARD_XP} — a claim that would cross it pays
|
||
* no XP.
|
||
*/
|
||
const GAME_REWARD_DAILY_XP_CAP = 150
|
||
|
||
/**
|
||
* `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!'
|
||
|
||
/**
|
||
* Tokens paid with each claimed {@link TOKEN_REWARD_TYPES} reward, until the player has had
|
||
* {@link GAME_REWARD_DAILY_TOKEN_CAP} in the current 24h window — whatever the activity.
|
||
* The running total is the player's `$tokens` row in `reward_status` (`claimAllowance`).
|
||
*/
|
||
const GAME_REWARD_TOKENS = 250
|
||
|
||
/**
|
||
* The most game-reward tokens a player earns in one rolling 24h window: five claims' worth.
|
||
* A multiple of {@link GAME_REWARD_TOKENS} — a claim that would cross it pays nothing.
|
||
*/
|
||
const GAME_REWARD_DAILY_TOKEN_CAP = 1250
|
||
|
||
/**
|
||
* The reward types whose tokens draw on the daily allowance: the client's ask after a match
|
||
* and after a quest. {@link FIRST_ACTIVITY_OF_DAY} pays its own on top, outside the cap.
|
||
*/
|
||
const TOKEN_REWARD_TYPES = new Set(['PostGameActivity', 'PostQuestActivity'])
|
||
|
||
/**
|
||
* The once-a-day bonus. Its claim is per PLAYER — keyed on the empty context whatever
|
||
* activity asked — on a 24h cooldown, and it pays {@link GAME_REWARD_TOKENS} outside the
|
||
* daily cap: the claim itself is what limits it, so it needs no allowance row.
|
||
*/
|
||
const FIRST_ACTIVITY_OF_DAY = 'FirstActivityOfDay'
|
||
|
||
/**
|
||
* `GiftContext.GameRewardsTokens` — the tokens variant of {@link GIFT_CONTEXT_GAME_REWARDS},
|
||
* on the box the token payout arrives in.
|
||
*/
|
||
const GIFT_CONTEXT_GAME_REWARDS_TOKENS = 51
|
||
|
||
/** The message on the token payout's box. */
|
||
const GAME_REWARD_TOKENS_MESSAGE = 'Activity bonus!'
|
||
|
||
/**
|
||
* The gift-drop a claimed game reward's token payout hands over: tokens in a box, no item, no XP. Like
|
||
* {@link toGameRewardDrop}, `grantGiftDrop` creates only the box from it — the tokens are
|
||
* credited to the balance separately, and the copy here is what the box displays.
|
||
*/
|
||
function toGameRewardTokensDrop(): StoreGiftDrop {
|
||
return {
|
||
FriendlyName: '',
|
||
Tooltip: '',
|
||
ConsumableItemDesc: '',
|
||
AvatarItemDesc: '',
|
||
AvatarItemType: null,
|
||
EquipmentPrefabName: '',
|
||
EquipmentModificationGuid: '',
|
||
Rarity: 0,
|
||
Context: GIFT_CONTEXT_GAME_REWARDS_TOKENS,
|
||
Currency: GAME_REWARD_TOKENS,
|
||
CurrencyType: CurrencyType.RecCenterTokens,
|
||
Xp: 0,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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(xp: number): StoreGiftDrop {
|
||
return {
|
||
FriendlyName: '',
|
||
Tooltip: '',
|
||
ConsumableItemDesc: '',
|
||
AvatarItemDesc: '',
|
||
AvatarItemType: null,
|
||
EquipmentPrefabName: '',
|
||
EquipmentModificationGuid: '',
|
||
Rarity: 0,
|
||
Context: GIFT_CONTEXT_GAME_REWARDS,
|
||
Currency: 0,
|
||
CurrencyType: 0,
|
||
Xp: xp,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* One row of `static/quest-rewards.json`: the reward table of the live game's activities,
|
||
* keyed by the `giftContext` the client posts with a game-reward ask (`Dodgeball`,
|
||
* `Quest_Goblin_S`, `Paintball_Dam`, …). Each row is the gift-drop as the game's own reward
|
||
* server shaped it — a comma-laden `AvatarItemDesc` (the catalog's `item_key`), or for the
|
||
* Laser Tag entry a currency payout — with `GiftRarity` and the activity's own `Context`
|
||
* (8000 for dodgeball, 4003 for the goblin quest's S rank) spelled the way the client's box
|
||
* reads them. Untyped fields (`Id`, `Level`, `Message`) are carried but unused.
|
||
*/
|
||
interface QuestReward {
|
||
AvatarItemDesc: string
|
||
ConsumableItemDesc: string
|
||
EquipmentPrefabName: string
|
||
EquipmentModificationGuid: string
|
||
CurrencyType: number
|
||
Currency: number
|
||
Xp: number
|
||
GiftRarity: number
|
||
Context: number
|
||
}
|
||
|
||
const QUEST_REWARDS: Record<string, QuestReward[]> = questRewards
|
||
|
||
/**
|
||
* The reward an activity pays, when `giftContext` names an entry in `quest-rewards.json`:
|
||
* one row drawn at random from that key's list, among the rows the player DOESN'T ALREADY
|
||
* OWN — the table is "what this activity can give you", and handing over a duplicate gives
|
||
* nothing (the inventory is a set). A currency row is never "owned", so it always stays in
|
||
* the pool.
|
||
*
|
||
* Null for a context the table doesn't know (or one whose every reward the player already
|
||
* has), which the caller pays as the plain XP box — the cooldown key is the same string
|
||
* either way, so an unknown or exhausted context is still rate-limited.
|
||
*/
|
||
async function pickQuestReward(
|
||
db: D1Database,
|
||
accountId: number,
|
||
giftContext: string
|
||
): Promise<QuestReward | null> {
|
||
if (!Object.hasOwn(QUEST_REWARDS, giftContext)) return null
|
||
const rows = QUEST_REWARDS[giftContext] ?? []
|
||
if (rows.length === 0) return null
|
||
const ownedItems = new Set((await getInventory(db, accountId)).map((i) => i.AvatarItemDesc))
|
||
const ownedGuids = new Set((await getEquipment(db, accountId)).map((e) => e.ModificationGuid))
|
||
const pool = rows.filter(
|
||
(r) =>
|
||
!(r.AvatarItemDesc !== '' && ownedItems.has(r.AvatarItemDesc)) &&
|
||
!(r.EquipmentModificationGuid !== '' && ownedGuids.has(r.EquipmentModificationGuid))
|
||
)
|
||
if (pool.length === 0) {
|
||
logger.info('quest rewards exhausted for player', { accountId, giftContext })
|
||
return null
|
||
}
|
||
return pool[Math.floor(Math.random() * pool.length)] ?? null
|
||
}
|
||
|
||
/**
|
||
* A quest reward as the gift-drop `grantGiftDrop` hands over. The item fields come off the
|
||
* row, so the item IS granted — unlike {@link toGameRewardDrop}'s empty box. The catalog
|
||
* row for the item, when it resolves, supplies what the table doesn't carry (name, tooltip,
|
||
* `AvatarItemType`), so the inventory entry reads like a bought one rather than blank.
|
||
* The XP is the game reward's (`xp`), not the row's (always 0): the reward is the item, and
|
||
* the XP is the same pat on the back every claim gets while the daily cap has room.
|
||
*/
|
||
function toQuestRewardDrop(
|
||
reward: QuestReward,
|
||
catalog: CatalogRow | null,
|
||
xp: number
|
||
): StoreGiftDrop {
|
||
return {
|
||
FriendlyName: catalog?.friendly_name ?? '',
|
||
Tooltip: catalog?.tooltip ?? '',
|
||
ConsumableItemDesc: reward.ConsumableItemDesc,
|
||
AvatarItemDesc: reward.AvatarItemDesc,
|
||
AvatarItemType: catalog?.avatar_item_type ?? null,
|
||
EquipmentPrefabName: reward.EquipmentPrefabName,
|
||
EquipmentModificationGuid: reward.EquipmentModificationGuid,
|
||
Rarity: reward.GiftRarity,
|
||
Context: reward.Context,
|
||
Currency: reward.Currency,
|
||
CurrencyType: reward.CurrencyType,
|
||
Xp: 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 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(rotation: WeeklyChallengeRotation): number {
|
||
const stars = Number(/^(\d+)-star/i.exec(rotation.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(
|
||
rotation: WeeklyChallengeRotation,
|
||
catalog: StoreItem[]
|
||
): StoreGiftDrop {
|
||
const gift: ChallengeGiftBlock = rotation.Gift
|
||
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 ?? rotation.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(rotation: WeeklyChallengeRotation): StoreGiftDrop {
|
||
return {
|
||
FriendlyName: rotation.FallbackGiftName,
|
||
Tooltip: '',
|
||
ConsumableItemDesc: '',
|
||
AvatarItemDesc: '',
|
||
AvatarItemType: null,
|
||
EquipmentPrefabName: '',
|
||
EquipmentModificationGuid: '',
|
||
Rarity: fallbackGiftRarity(rotation),
|
||
Context: rotation.Gift.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(rotation: WeeklyChallengeRotation): number {
|
||
const published = rotation.Challenges.length
|
||
return rotation.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> {
|
||
const rotation = buildRotation(new Date())
|
||
try {
|
||
if (rotation.Challenges.length === 0) return
|
||
const statuses = await getChallengeStatuses(c.env.DB, accountId, rotation.ChallengeMapId)
|
||
const done = rotation.Challenges.filter(
|
||
(ch) => statuses.get(ch.ChallengeId)?.complete === true
|
||
).length
|
||
if (done < challengesRequiredForGift(rotation)) return
|
||
// Claim first: this is what stops the next report paying out a second time.
|
||
const claimed = await claimChallengeGift(c.env.DB, accountId, rotation.ChallengeMapId)
|
||
if (!claimed) return
|
||
// Only now is the catalog worth reading: it names the week's reward and is what the
|
||
// grant path rolls a duplicate's replacement from.
|
||
const catalog = await loadRollCatalog(c)
|
||
const week = withWeeklyGift(rotation, toEquipmentGiftPool(catalog))
|
||
const reward = toChallengeGiftDrop(week, catalog)
|
||
const duplicate = await ownsGiftDrop(c.env.DB, accountId, reward)
|
||
const granted = await grantGiftDrop(
|
||
c,
|
||
accountId,
|
||
duplicate ? toChallengeFallbackDrop(week) : 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: rotation.ChallengeMapId,
|
||
giftId: granted.id,
|
||
fallbackRoll: duplicate,
|
||
challengesComplete: done,
|
||
})
|
||
} catch (err) {
|
||
logger.error('failed to grant weekly challenge gift', {
|
||
accountId,
|
||
challengeMapId: rotation.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 } : {}),
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* A completed invention purchase: the invention that changed hands and the buyer's
|
||
* RESULTING token balance (not the change — see the envelopes both routes build from it).
|
||
*/
|
||
interface SettledInventionPurchase {
|
||
invention: SavedInvention
|
||
balance: number
|
||
}
|
||
|
||
/**
|
||
* Settle an invention purchase — the whole of buyInvention except the envelope it is
|
||
* announced in, shared by the `v2` GET and the `v3` POST. Every refusal is a `Response`
|
||
* (the `{ error }` body both routes answer with); a sale is the bought invention and the
|
||
* buyer's resulting balance, which each route then wraps in ITS OWN shape — the two
|
||
* clients want different ones, so the money is shared and the projection is not.
|
||
*
|
||
* 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 grant also resyncs the invention’s `NumDownloads` — the count
|
||
* of players who have it — and the invention returned carries the new number.
|
||
*/
|
||
async function settleInventionPurchase(
|
||
c: Context<App>,
|
||
id: number,
|
||
inventionId: number,
|
||
requestedPrice: number
|
||
): Promise<SettledInventionPurchase | Response> {
|
||
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.
|
||
const downloads = 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 { invention: { ...invention, NumDownloads: downloads }, balance }
|
||
}
|
||
|
||
// 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())
|
||
|
||
// A batch lookup of LOCKED avatar items — the client posts the descs it wants the locked
|
||
// state for and expects the matching item records back, as a BARE ARRAY.
|
||
//
|
||
// Filtered by exact `AvatarItemDesc`, matching the reference implementation: it walks its
|
||
// own item list and keeps the entries whose desc appears in the posted set. Two consequences
|
||
// of copying that shape rather than the obvious one:
|
||
//
|
||
// - Order is the CATALOGUE's, not the request's, because the filter iterates the catalogue.
|
||
// A caller must not read the response positionally against what it asked for.
|
||
// - The match is the WHOLE desc, not the base asset. `<base>,,,` and `<base>,<colour>,` are
|
||
// different items and only the one asked for comes back.
|
||
//
|
||
// An EMPTY or absent list means everything, again as the reference does — that is its "give
|
||
// me the catalogue" case rather than a degenerate "match nothing".
|
||
//
|
||
// Unknown descs are simply absent from the response; a miss is not an error. Nothing records
|
||
// a LOCK yet, so what comes back is the item records rather than a genuine locked answer.
|
||
//
|
||
// POST only, despite the reference declaring it `[HttpGet]` with a `[FromBody]` parameter —
|
||
// a combination the fetch standard forbids, so a GET could never carry the descs it needs.
|
||
//
|
||
// NOTE: the `api` worker has a route of this same path that answers `[]`. The client asks
|
||
// THIS host, so that one is unreached; they must be reconciled before either is taken for
|
||
// real behaviour.
|
||
.post(
|
||
'/api/avatar/v1/lockeditems/bulk',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Locked avatar items in bulk',
|
||
description: [
|
||
'Resolves `AvatarItemDescriptions` against the item catalogue (the `catalog` table) and',
|
||
'answers the matching records as a bare array. The match is on the WHOLE `AvatarItemDesc`, so a',
|
||
'colourway is not found by its base asset alone.',
|
||
'An empty or absent list answers the WHOLE catalogue, which is the reference’s own',
|
||
'behaviour rather than a degenerate empty match.',
|
||
'Results come back in CATALOGUE order, not request order — the filter walks the',
|
||
'catalogue — so the response must not be read positionally. Unknown descs are simply',
|
||
'absent; a miss is not an error.',
|
||
'Nothing records a LOCK yet, so what comes back is the item records rather than a',
|
||
'genuine locked/unlocked answer.',
|
||
].join(' '),
|
||
requestBody: jsonBody(LockedItemsBulkRequest, 'The descs to resolve'),
|
||
responses: { 200: json(JsonArray, 'The matching items, in catalogue order') },
|
||
}),
|
||
async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as {
|
||
AvatarItemDescriptions?: unknown
|
||
} | null
|
||
const requested = Array.isArray(body?.AvatarItemDescriptions)
|
||
? body.AvatarItemDescriptions.filter((d): d is string => typeof d === 'string')
|
||
: []
|
||
// The whole kind from the `catalog` table, in LOAD order — which is store order, the
|
||
// closest thing to the reference's "catalogue order" there is — then filtered here.
|
||
// One statement whatever was asked: the client posts hundreds of descs at a time, and
|
||
// D1 binds at most a hundred parameters, so an `IN (...)` of the request cannot be.
|
||
const { results } = await c.env.DB.prepare(
|
||
'SELECT * FROM catalog WHERE kind = ?1 ORDER BY rowid'
|
||
)
|
||
.bind(CatalogKind.AvatarItem)
|
||
.all<CatalogRow>()
|
||
const catalogue = results.map(toCatalogAvatarItem)
|
||
if (requested.length === 0) return c.json(catalogue)
|
||
|
||
// A Set rather than `Array.includes` per item: the reference's nested scan is
|
||
// quadratic.
|
||
const wanted = new Set(requested)
|
||
return c.json(catalogue.filter((item) => wanted.has(item.AvatarItemDesc)))
|
||
}
|
||
)
|
||
|
||
// 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 player’s avatar items',
|
||
description: [
|
||
'The items the player has bought (from buyItem, in the inventory table) prepended',
|
||
'to the default catalog. A player who has bought nothing gets just the catalog.',
|
||
'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]; the paginated envelope. What the
|
||
// caller has BOUGHT through bulkpurchase (`inventory_custom`) plus what they CREATED, each
|
||
// the whole `CustomAvatarItem` record out of the item table. 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: [
|
||
'The custom avatar items the caller owns — bought (`POST /api/items/bulkpurchase` with',
|
||
'a guid-keyed line) or created (`CreatorAccountId`, drafts included) — each as its full',
|
||
'`CustomAvatarItem` record out of the `custom_avatar_item` table, oldest first by when',
|
||
'it became theirs, in the `{ Results, TotalResults }` envelope. No paging is applied',
|
||
'(the client sends none), so `TotalResults` is the list’s length.',
|
||
'`unityAssetTarget` 2 (Android/Oculus) serves each save’s `UnityAsset`/`UnityAsset2`',
|
||
'under `quest/`, the Android build of the same assetbundle — the same switch `api`’s',
|
||
'search and bulk reads make; 0 (PC), any other value, and none get the names as stored.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(CustomAvatarItemsResponse, 'The owned items'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const owned = await getOwnedCustomAvatarItems(c.env.DB, id)
|
||
// The client renders what the player is WEARING from this list, and it is read before
|
||
// any `api` lookup — so a Quest served PC names here downloads PC assetbundles however
|
||
// the search and bulk reads answer.
|
||
const Results = isQuestAssetTarget(c.req.query('unityAssetTarget'))
|
||
? owned.map(toQuestCustomAvatarItem)
|
||
: owned
|
||
return c.json({ Results, TotalResults: Results.length })
|
||
}
|
||
)
|
||
|
||
// The player's objectives progress. Serves a static JSON file verbatim with
|
||
// no auth — same default for everyone until there's a DB binding to track
|
||
// per-player progress.
|
||
.get(
|
||
'/api/objectives/v1/myprogress',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Objectives progress',
|
||
description:
|
||
'Serves the bundled static progress verbatim (no per-player store yet). No auth.',
|
||
responses: { 200: json(JsonObject, 'The bundled objectives-progress default') },
|
||
}),
|
||
(c) => c.json(myProgress)
|
||
)
|
||
|
||
// Clears a group of objectives. No per-player progress to clear yet, so this
|
||
// is a no-op that returns an empty array (a 404 here breaks the client). Accepts
|
||
// GET or POST since the client may use either.
|
||
.on(
|
||
['GET', 'POST'],
|
||
'/api/objectives/v1/cleargroup',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Clear an objectives group (no-op)',
|
||
description: 'No per-player progress to clear yet → []. Accepts GET or POST.',
|
||
responses: { 200: json(JsonArray, 'Always empty for now') },
|
||
}),
|
||
(c) => c.json([])
|
||
)
|
||
|
||
// Report one objective's progress. The client posts the whole objective as it now
|
||
// sees it (Index/Group identify it within `myprogress`) and reads back the state of
|
||
// the GROUP that objective belongs to — camelCase here, unlike the PascalCase body it
|
||
// posted. Stubbed: with no objectives store yet we persist nothing, echo the group
|
||
// back and never complete it, so the reward-claim flow isn't triggered. `clearedAt`
|
||
// is the clear time, which for a group we didn't clear is just now.
|
||
.post(
|
||
'/api/objectives/v1/updateobjective',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Report objective progress',
|
||
description: [
|
||
'Stubbed: with no objectives store we persist nothing and never complete a group.',
|
||
'Echoes `Group` back as camelCase `group` with `isCompleted: false` so the client',
|
||
'gets a well-formed body.',
|
||
].join(' '),
|
||
requestBody: jsonBody(UpdateObjectiveRequest, 'The objective as the client now sees it'),
|
||
responses: { 200: json(UpdateObjectiveResponse, 'The echoed group, never completed') },
|
||
}),
|
||
async (c) => {
|
||
const body = await c.req
|
||
.json<{ Group?: string | number }>()
|
||
.catch(() => ({}) as Record<string, never>)
|
||
return c.json({
|
||
group: Number(body.Group) || 0,
|
||
isCompleted: false,
|
||
clearedAt: new Date().toISOString(),
|
||
})
|
||
}
|
||
)
|
||
|
||
// The player's avatar, stored as a JSON blob on their account row. Falls back
|
||
// to the default outfit when they haven't saved one — the client's parser NREs
|
||
// on an empty OutfitSelections (real RecNet never returns one).
|
||
.get(
|
||
'/api/avatar/v2',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'The player’s own avatar',
|
||
description: [
|
||
'The avatar JSON blob stored on the account row, or the default outfit when none is',
|
||
'saved (the client NREs on an empty OutfitSelections).',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(JsonObject, 'The stored avatar blob (or the default)'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json((await getAvatar(c.env.DB, id)) ?? defaultAvatar)
|
||
}
|
||
)
|
||
|
||
// Save the player's avatar. [Authorize]. Stores the posted JSON payload verbatim
|
||
// on the account row and echoes it back. 400 on a non-object body; 404 when the
|
||
// caller has no account row to attach it to.
|
||
.post(
|
||
'/api/avatar/v2/set',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Save the player’s avatar',
|
||
description: 'Stores the posted JSON blob verbatim on the account row and echoes it back.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(OpaqueJsonBody, 'The avatar blob'),
|
||
responses: {
|
||
200: json(JsonObject, 'The saved avatar (echoed back)'),
|
||
400: { description: 'Body was not a JSON object (empty body)' },
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
404: { description: 'No account row to attach it to (empty body)' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const avatar = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||
if (avatar === null || typeof avatar !== 'object' || Array.isArray(avatar)) {
|
||
return c.body(null, 400)
|
||
}
|
||
if (!(await setAvatar(c.env.DB, id, avatar))) return c.body(null, 404)
|
||
return c.json(avatar)
|
||
}
|
||
)
|
||
|
||
// NUX checklist — the client fetches this on the econ host during load, 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]. A bare array of entries, newest wish first;
|
||
// `[]` for a player who has wished for nothing. The client appends a KEYLESS query string
|
||
// (`/me?True`, `/me?False` — a raw `bool.ToString()` after the `?`, no parameter name) that
|
||
// no server can bind by name, so it is ignored: both spellings serve the same list. The
|
||
// client caches this for five seconds and drops the cache when an add succeeds.
|
||
.get(
|
||
'/api/itemWishlists/v1/wishlist/me',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'The player’s item wishlist',
|
||
description: [
|
||
'The caller’s wishlist as a bare array of entries, newest first — `[]` when empty.',
|
||
'Each entry names ONE item: a storefront item by `PurchasableItemId`, or a custom',
|
||
'avatar item by `CustomAvatarItemId` (with `PurchasableItemId` 0, the client’s field',
|
||
'being a plain int).',
|
||
'',
|
||
'The client appends `?True` or `?False` with no parameter name; it cannot be read',
|
||
'and is ignored — both serve the same list.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(WishlistItemList, 'The caller’s wishlist, newest first'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json(await getWishlist(c.env.DB, id))
|
||
}
|
||
)
|
||
|
||
// Another player's item wishlist, by account id — what the client reads to show what
|
||
// somebody else is hoping for. Public to any signed-in player: a wishlist exists to be
|
||
// read by others. An unknown account has simply wished for nothing.
|
||
//
|
||
// 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 player’s item wishlist',
|
||
description: [
|
||
'The wishlist of the account named in the path, as a bare array of entries, newest',
|
||
'first. Readable by any signed-in player — a wishlist is for others to see. An',
|
||
'unknown account answers `[]`, which the client renders as “nothing wished for”',
|
||
'where a 404 would read as a failed load. The keyless `?True`/`?False` the client',
|
||
'appends is ignored, as on `/me`.',
|
||
].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(WishlistItemList, 'That player’s wishlist, newest first'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const accountId = Number.parseInt(c.req.param('accountId'), 10)
|
||
if (!Number.isInteger(accountId)) return c.json([])
|
||
return c.json(await getWishlist(c.env.DB, accountId))
|
||
}
|
||
)
|
||
|
||
// Wish for an item. [Authorize]. Form-encoded: the client posts BOTH `purchasableItemId`
|
||
// and `customAvatarItemId` every time with the unused one empty (its two nullables are
|
||
// boxed without a HasValue guard), so either may be empty or absent; exactly one has to
|
||
// name an item. Answers the `{ Value, Success, Error, error_id }` envelope with the entry.
|
||
// An item already on the list answers its EXISTING entry: the toggle is driven by the
|
||
// client's idea of the current state, and re-adding changes nothing.
|
||
//
|
||
// The row has to persist: on success the client drops its cached list and re-reads it,
|
||
// so a 200 that stored nothing shows the heart un-filling again at once.
|
||
.post(
|
||
'/api/itemWishlists/v1/wishlist/add',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Wish for an item',
|
||
description: [
|
||
'Adds an item to the caller’s wishlist. Form-encoded; the client posts both',
|
||
'`purchasableItemId` and `customAvatarItemId` with the unused one EMPTY',
|
||
'(`purchasableItemId=1534&customAvatarItemId=`), and either may also be absent —',
|
||
'exactly one has to name an item.',
|
||
'',
|
||
'Answers `{ Value, Success, Error, error_id }` with the entry as `Value`. An item',
|
||
'already on the list answers its existing entry rather than a twin or a refusal.',
|
||
'A body naming no item is `Success: false` with a player-readable `Error` — the',
|
||
'client shows it verbatim behind “Wishlist error: ”. A bare entry or a 204 fails the',
|
||
'client the same way `Success: false` does.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: form(WishlistItemRequest, 'The item to wish for'),
|
||
responses: {
|
||
200: json(WishlistEnvelope, 'The entry, or a refusal in the same shape'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const target = await wishlistTargetFromBody(c)
|
||
if (target === null) return wishlistEnvelope(c, null, WISHLIST_NO_ITEM)
|
||
return wishlistEnvelope(c, await addWishlistItem(c.env.DB, id, target))
|
||
}
|
||
)
|
||
|
||
// Take an item off the caller's wishlist. [Authorize]. A POST, not a DELETE, with the same
|
||
// form body as `add`. The client parses the same envelope and discards `Value`, so the
|
||
// removed entry is served anyway; an item that wasn't on the list is still a success —
|
||
// the list ends up as asked — with a null `Value`.
|
||
.post(
|
||
'/api/itemWishlists/v1/wishlist/remove',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Un-wish an item',
|
||
description: [
|
||
'Removes an item from the caller’s wishlist. A POST (not a DELETE) with the same',
|
||
'form body as `add`. Answers the same envelope with the REMOVED entry as `Value`;',
|
||
'the client discards it but parses the shape, so a bare body or 204 fails it. An',
|
||
'item that wasn’t on the list is still `Success: true`, with a null `Value`. A body',
|
||
'naming no item is `Success: false`.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: form(WishlistItemRequest, 'The item to un-wish'),
|
||
responses: {
|
||
200: json(WishlistEnvelope, 'The removed entry, or a refusal in the same shape'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const target = await wishlistTargetFromBody(c)
|
||
if (target === null) return wishlistEnvelope(c, null, WISHLIST_NO_ITEM)
|
||
return wishlistEnvelope(c, await removeWishlistItem(c.env.DB, id, target))
|
||
}
|
||
)
|
||
|
||
// Whether each of several players has an item wished for. [Authorize]. Form-encoded:
|
||
// `accountIds` repeated once per player plus the item named as on add/remove. A BARE
|
||
// positional array of booleans back, one per id in the order posted — a short array would
|
||
// leave the tail reading as unset, so every id is answered, unknown players as false.
|
||
.post(
|
||
'/api/itemWishlists/v1/isonwishlist/bulk',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Whether players have an item wished for',
|
||
description: [
|
||
'For each `accountIds` entry, whether that player’s wishlist has the item named by',
|
||
'`purchasableItemId` or `customAvatarItemId`. Form-encoded, `accountIds` repeated',
|
||
'once per id (a comma-separated single value is accepted too). The answer is a BARE',
|
||
'array of booleans, one per id in the order posted. A body naming no item answers',
|
||
'false for everyone.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: form(IsOnWishlistBulkRequest, 'The players to ask about, and the item'),
|
||
responses: {
|
||
200: json(IsOnWishlistBulkResponse, 'One boolean per `accountIds` entry, in order'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const body = await c.req
|
||
.parseBody({ all: true })
|
||
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
|
||
// `all: true` keeps the repeated field a list; a single value arrives as a string.
|
||
const accountIds = [body.accountIds, body.AccountIds]
|
||
.flat()
|
||
.filter((v): v is string => typeof v === 'string')
|
||
.flatMap((v) => v.split(','))
|
||
.map((v) => Number.parseInt(v.trim(), 10))
|
||
.filter((n) => Number.isInteger(n))
|
||
const first = (v: unknown): unknown => (Array.isArray(v) ? v[0] : v)
|
||
const target = parseWishlistTarget(
|
||
first(body.purchasableItemId ?? body.PurchasableItemId),
|
||
first(body.customAvatarItemId ?? body.CustomAvatarItemId)
|
||
)
|
||
if (target === null) return c.json(accountIds.map(() => false))
|
||
return c.json(await isOnWishlists(c.env.DB, accountIds, target))
|
||
}
|
||
)
|
||
|
||
// The player's saved outfits. [Authorize]. Served back as the client posted them
|
||
// (see /saved/set); a player who has saved none gets [].
|
||
.get(
|
||
'/api/avatar/v3/saved',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'The player’s saved outfits',
|
||
description: 'Served back as the client posted them (see /saved/set); [] when none.',
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(JsonArray, 'Saved outfits (empty when none)'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json(await getOutfits(c.env.DB, id))
|
||
}
|
||
)
|
||
|
||
// Save an outfit into one of the player's slots. [Authorize]. The posted `Slot` is
|
||
// the slot to write, and re-saving a slot overwrites it — that's the avatar screen's
|
||
// "save over this outfit". The payload is stored verbatim and echoed back: its inner
|
||
// fields (OutfitSelectionsV2, FaceFeatures, …) are JSON-in-a-string from the client's
|
||
// own serializer, so re-encoding them risks handing back something it can't parse.
|
||
//
|
||
// A missing/non-integer `Slot` is a 400 rather than a default slot — guessing would
|
||
// silently overwrite an outfit the player didn't mean to touch.
|
||
//
|
||
// v3 and v4 share this handler: newer clients POST to /v4/saved/set with the same
|
||
// payload shape (Slot, PreviewImageName, OutfitSelections(V2), FaceFeatures, Skin/HairColor,
|
||
// CustomAvatarItems) and expect the same slot-overwrite semantics, so they store into the
|
||
// same outfit table and read back through /api/avatar/v3/saved.
|
||
.post(
|
||
'/api/avatar/v3/saved/set',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Save an outfit into a slot',
|
||
description: [
|
||
'Writes the posted outfit into the given `Slot` (overwriting it) and echoes it back.',
|
||
'The payload is stored verbatim — its inner fields are JSON-in-a-string from the',
|
||
'client’s own serializer. A missing/non-integer `Slot` is a 400 (guessing would',
|
||
'silently overwrite another outfit).',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'),
|
||
responses: {
|
||
200: json(JsonObject, 'The saved outfit (echoed back)'),
|
||
400: { description: 'Non-object body or missing/non-integer Slot (empty body)' },
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const result = await persistPostedOutfit(c)
|
||
if (result instanceof Response) return result
|
||
return c.json(result)
|
||
}
|
||
)
|
||
|
||
// v4 of the save-outfit route. Same payload, table and slot-overwrite semantics as v3
|
||
// (see above) — newer clients moved to /v4/saved/set. The one difference is the response:
|
||
// v4 answers a lean `{ Success, Slot }` acknowledgement rather than echoing the whole
|
||
// outfit back. The outfit is read back through /api/avatar/v3/saved either way.
|
||
.post(
|
||
'/api/avatar/v4/saved/set',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Save an outfit into a slot (v4)',
|
||
description: [
|
||
'Writes the posted outfit into the given `Slot` (overwriting it), same as',
|
||
'`POST /api/avatar/v3/saved/set`, but answers a lean `{ Success, Slot }` ack instead',
|
||
'of echoing the outfit. A missing/non-integer `Slot` is a 400.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'),
|
||
responses: {
|
||
200: json(SaveOutfitV4Response, 'Save acknowledgement'),
|
||
400: { description: 'Non-object body or missing/non-integer Slot (empty body)' },
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const result = await persistPostedOutfit(c)
|
||
if (result instanceof Response) return result
|
||
return c.json({ Success: true, Slot: result.Slot })
|
||
}
|
||
)
|
||
|
||
// Pending avatar gifts for the player — the unopened gift boxes from their purchases
|
||
// (and, once gifting lands, from other players). [Authorize]. The client opens each
|
||
// box and consumes it via the consume route below; the item itself was already
|
||
// granted at purchase, so an unopened box is cosmetic.
|
||
.get(
|
||
'/api/avatar/v2/gifts',
|
||
describeRoute({
|
||
tags: ['Gifts'],
|
||
summary: 'Pending gift boxes',
|
||
description: [
|
||
'The player’s unopened gift boxes from their purchases (and, later, from other',
|
||
'players). The item was already granted at purchase, so an unopened box is cosmetic.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(JsonArray, 'Unopened gift boxes (empty when none)'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json(await getPendingGifts(c.env.DB, id))
|
||
}
|
||
)
|
||
|
||
// Open (consume) a gift box. [Authorize]. The client posts this on the econ host after
|
||
// the box animation, form-encoded as `Id=<giftId>&UnlockedLevel=<n>`. Opening just
|
||
// deletes the box — the item was granted into the inventory at purchase, so there's
|
||
// nothing to grant here — an avatar-item drop was granted into the inventory table and a
|
||
// consumable drop into the consumable table, both at purchase. (`UnlockedLevel`, a
|
||
// consumable-level hint, is unused.)
|
||
//
|
||
// Always answers 200 with the `{ error, success, value }` envelope — even with no token,
|
||
// a zero id, or a box that is already gone. A captured real consume returns this envelope,
|
||
// not an empty body: the client parses it to finish opening the box, so a bare 200 reads
|
||
// as a failure and the consumable never finishes unlocking. The delete is scoped to the
|
||
// caller's account, so an unauthenticated or mismatched call is simply a no-op. Mirrors
|
||
// the same route on the `api` worker (the client may call either host).
|
||
.post(
|
||
'/api/avatar/v2/gifts/consume',
|
||
describeRoute({
|
||
tags: ['Gifts'],
|
||
summary: 'Open (consume) a gift box',
|
||
description: [
|
||
'Deletes the box (the item was already granted at purchase). Always answers the',
|
||
'`{ error, success, value }` envelope with HTTP 200 — even with no token, a zero id,',
|
||
'or a box already gone — because the client parses it to finish opening the box. The',
|
||
'delete is scoped to the caller; opening someone else’s box is 403. Also served by',
|
||
'the `api` worker.',
|
||
].join(' '),
|
||
requestBody: form(ConsumeGiftRequest, 'The gift-box id'),
|
||
responses: {
|
||
200: json(ConsumeEnvelope, 'Success envelope'),
|
||
403: { description: 'The box belongs to another player (empty body)' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||
const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0
|
||
if (id !== null && giftId !== 0) {
|
||
// Scoped delete: only the box's owner deletes it. A returned box means it was
|
||
// theirs and is now consumed.
|
||
const gift = await consumeGift(c.env.DB, id, giftId)
|
||
if (gift !== null) {
|
||
// If the box carried a consumable, tell the client it now has it (so it shows
|
||
// up in inventory without a refetch). Avatar-item boxes carry no ConsumableItemDesc.
|
||
if (gift.ConsumableItemDesc !== '') await pushConsumableAdded(c, id, gift)
|
||
} else {
|
||
// Nothing was consumed: either the box is already gone (a harmless no-op —
|
||
// re-opening your own consumed box still succeeds) or it belongs to another
|
||
// player, which is forbidden.
|
||
const other = await getGift(c.env.DB, giftId)
|
||
if (other !== null && other.accountId !== id) return c.body(null, 403)
|
||
}
|
||
}
|
||
return c.json({ error: '', success: true, value: null })
|
||
}
|
||
)
|
||
|
||
// A player's avatar by account id, projected to the public render subset (used
|
||
// to draw other players' avatars). No auth — like the accounts `/account/:id`
|
||
// lookup. Falls back to the default outfit when the player hasn't saved one.
|
||
// Registered after the static `/api/avatar/v2/*` routes so `:id` can't shadow them.
|
||
.get(
|
||
'/api/avatar/v2/:id',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Another player’s avatar (render subset)',
|
||
description: [
|
||
'The public render subset used to draw another player’s avatar. No auth. Falls back',
|
||
'to the default outfit when the player hasn’t saved one.',
|
||
].join(' '),
|
||
parameters: [
|
||
{
|
||
name: 'id',
|
||
in: 'path',
|
||
required: true,
|
||
description: 'Account id; non-numeric is 400',
|
||
schema: { type: 'string' },
|
||
},
|
||
],
|
||
responses: {
|
||
200: json(AvatarV2Dto, 'The render subset'),
|
||
400: { description: 'Non-numeric id (empty body)' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const accountId = Number.parseInt(c.req.param('id'), 10)
|
||
if (Number.isNaN(accountId)) return c.body(null, 400)
|
||
return c.json(toAvatarV2Dto((await getAvatar(c.env.DB, accountId)) ?? defaultAvatar))
|
||
}
|
||
)
|
||
|
||
// Unlocked equipment. [Authorize]. The equipment skins the player has bought (from
|
||
// `buyItem`, stored in the `equipment` table). A player who has bought none gets an
|
||
// empty list.
|
||
.get(
|
||
'/api/equipment/v2/getUnlocked',
|
||
listRoute('Unlocked equipment', 'The equipment skins the player has bought', true),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json(await getEquipment(c.env.DB, id))
|
||
}
|
||
)
|
||
|
||
// Favourite/un-favourite owned equipment. [Authorize]. The client sends 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 or POST: the client uses both spellings for this one call, with an identical body
|
||
// either way, so they are the same route rather than two handlers. A 404 on the POST
|
||
// leaves the star drawn on the item the client already redrew, and the favourite
|
||
// silently doesn't stick.
|
||
.on(
|
||
['PUT', 'POST'],
|
||
'/api/equipment/v1/update',
|
||
describeRoute({
|
||
tags: ['Equipment'],
|
||
summary: 'Update owned equipment',
|
||
description: [
|
||
'Applies the posted `Favorited` flags to the caller’s owned equipment, matched by',
|
||
'`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller',
|
||
'doesn’t own is silently skipped. Empty body on success. Accepts PUT or POST — the',
|
||
'client uses both, with the same body.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'),
|
||
responses: {
|
||
200: { description: 'Applied (empty body)' },
|
||
400: { description: 'Body isn’t a JSON array (empty body)' },
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const body = (await c.req.json().catch(() => null)) as unknown
|
||
if (!Array.isArray(body)) return c.body(null, 400)
|
||
const updates = body
|
||
.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null)
|
||
.filter((e) => typeof e.ModificationGuid === 'string' && e.ModificationGuid !== '')
|
||
.map((e) => ({
|
||
ModificationGuid: e.ModificationGuid as string,
|
||
Favorited: e.Favorited === true,
|
||
}))
|
||
await setEquipmentFavorited(c.env.DB, id, updates)
|
||
return c.body(null, 200)
|
||
}
|
||
)
|
||
|
||
// A room's shop — the things it sells for its own currency. Public, like the room's
|
||
// currencies: a shop is shown to everyone who walks in.
|
||
.get(
|
||
'/api/roomconsumables/v1/roomConsumable/room/:roomId',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'A room’s consumables',
|
||
description: [
|
||
'Everything the room sells — a Health Potion for 25 tokens, a custom shirt for 500 —',
|
||
'oldest first, which is the order its owner built the shop up in.',
|
||
'',
|
||
'`Price` and `PurchaseCurrencyId` are FLAT here, against the nested',
|
||
'`PriceAndCurrency` the write takes: the client sends them nested and reads them',
|
||
'flat, so the two shapes genuinely differ. `PurchaseCurrencyId` names one of the',
|
||
'room’s own currencies and is not resolved or validated — a listing priced in a',
|
||
'currency that has since been deleted still shows, at a price nobody can pay.',
|
||
'',
|
||
'Public, like `GET /api/roomcurrencies/v1/currencies`. A room that sells nothing, and',
|
||
'an unknown room, are both an empty list — which the client reads as "no shop here"',
|
||
'where a 404 would stall the room load.',
|
||
].join('\n'),
|
||
parameters: [
|
||
{
|
||
name: 'roomId',
|
||
in: 'path',
|
||
required: true,
|
||
description: 'The room whose shop to list',
|
||
schema: { type: 'string' },
|
||
},
|
||
],
|
||
responses: { 200: json(RoomConsumableDto.array(), 'The room’s consumables, oldest first') },
|
||
}),
|
||
async (c) => {
|
||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||
if (!Number.isInteger(roomId)) return c.json([])
|
||
return c.json(await getRoomConsumables(c.env.DB, roomId))
|
||
}
|
||
)
|
||
|
||
// Give the CALLER some of a room's consumables. Auth-gated (401); everything else is
|
||
// reported per entry, like the room-currency award.
|
||
.post(
|
||
'/api/roomconsumables/v1/roomConsumable/awardBulk',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Award room consumables to the caller',
|
||
description: [
|
||
'Adds to what the CALLER owns of a room’s consumables — the token says who is being',
|
||
'given them, and there is no recipient in the body.',
|
||
'',
|
||
'`Requests` is a MAP keyed by consumable id, not a list: `{ "<id>": { "Quantity": 1,',
|
||
'"ConcurrencyCodes": { … } } }`. `ConcurrencyCodes` is accepted and IGNORED — no',
|
||
'optimistic concurrency is implemented, so `NewConcurrencyCode` is neither stored nor',
|
||
'echoed and a stale `CurrentConcurrencyCode` does not refuse the write.',
|
||
'',
|
||
'The answer is one result per entry, in the order the keys appeared, each standing',
|
||
'alone: an id naming no listing fails that entry and leaves the rest to land. Shaped',
|
||
'after the room-currency bulk award rather than observed.',
|
||
'',
|
||
'`Quantity` may be negative, which takes items away; a player’s count floors at zero.',
|
||
'`Quantity` on the result is the RESULTING total owned, never the change.',
|
||
'',
|
||
'NOTE: this awards to whoever is asking, and nothing is spent for it — no room',
|
||
'currency is debited and no room membership is checked. It is a self-service faucet',
|
||
'until a purchase flow sits in front of it.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(AwardRoomConsumablesRequest, 'The consumables to give the caller'),
|
||
responses: {
|
||
200: json(
|
||
AwardRoomConsumableResultList,
|
||
'One result per entry, in the order the keys appeared'
|
||
),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const accountId = await authedId(c)
|
||
if (accountId === null) return unauthorized(c)
|
||
|
||
const body = await c.req.json<{ Requests?: unknown }>().catch(() => null)
|
||
const requests = body?.Requests
|
||
// A body carrying no map of requests has no entries to report on, and the answer is
|
||
// a list of entry results — so there is nothing to say but "none".
|
||
if (typeof requests !== 'object' || requests === null || Array.isArray(requests)) {
|
||
return c.json([])
|
||
}
|
||
|
||
const results = []
|
||
for (const [consumableId, request] of Object.entries(requests as Record<string, unknown>)) {
|
||
const quantity = Number(
|
||
(typeof request === 'object' && request !== null
|
||
? (request as { Quantity?: unknown }).Quantity
|
||
: Number.NaN) ?? Number.NaN
|
||
)
|
||
if (!Number.isInteger(quantity)) {
|
||
results.push({
|
||
ConsumableId: consumableId,
|
||
Success: false,
|
||
Error: 'Invalid quantity',
|
||
Response: null,
|
||
})
|
||
continue
|
||
}
|
||
|
||
// Checked before writing: an inventory row naming no listing would be a thing a
|
||
// player owns that cannot be described.
|
||
const consumable = await getRoomConsumable(c.env.DB, consumableId)
|
||
if (!consumable) {
|
||
results.push({
|
||
ConsumableId: consumableId,
|
||
Success: false,
|
||
Error: 'No such consumable',
|
||
Response: null,
|
||
})
|
||
continue
|
||
}
|
||
|
||
const owned = await awardRoomConsumable(c.env.DB, accountId, consumableId, quantity)
|
||
results.push({
|
||
ConsumableId: consumableId,
|
||
Success: true,
|
||
Error: null,
|
||
Response: {
|
||
PlayerId: accountId,
|
||
ConsumableId: consumableId,
|
||
Quantity: owned,
|
||
AwardedAt: new Date().toISOString(),
|
||
},
|
||
})
|
||
}
|
||
|
||
return c.json(results)
|
||
}
|
||
)
|
||
|
||
// Create a consumable, or replace one when the body names an existing id. Auth-gated (401)
|
||
// and gated to the room's creator or a co-owner (403) — a shop is the room's to stock.
|
||
.put(
|
||
'/api/roomconsumables/v1/roomConsumable',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Create or replace a room consumable',
|
||
description: [
|
||
'Puts a thing in the room’s shop. JSON, with the price nested:',
|
||
'`{ "RoomConsumableId": null, "RoomId": 1162, "Name": "…", "Description": "…",',
|
||
'"ImageName": null, "PriceAndCurrency": { "Price": 50, "CurrencyId": "…" } }`.',
|
||
'',
|
||
'`RoomConsumableId` null (or absent) CREATES a listing and mints its id; naming an',
|
||
'existing listing REPLACES it. A replace is a replace, not a merge — the client',
|
||
'sends the whole form back, so an absent field is a cleared one. (The currency edit',
|
||
'is the opposite: its body is genuinely partial.)',
|
||
'',
|
||
'On a create the room is the body’s `RoomId`. On a replace it is the STORED',
|
||
'listing’s, and the body’s is ignored — an edit cannot move a listing into another',
|
||
'room, and the permission check must not be pointed somewhere friendlier than where',
|
||
'the thing actually lives.',
|
||
'',
|
||
'Gated to the room’s creator or a co-owner; a valid token from anyone else is a 403.',
|
||
'`PriceAndCurrency` is collapsed into the flat `Price`/`PurchaseCurrencyId` the read',
|
||
'serves. Answers the `{ Value, Success, Error, error_id }` envelope the room-currency',
|
||
'writes use, or a 200 carrying `Success: false` when the body is unusable or the',
|
||
'listing is unknown.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(UpsertRoomConsumableRequest, 'The listing to create or replace'),
|
||
responses: {
|
||
200: json(
|
||
RoomConsumableEnvelope,
|
||
'The listing as stored, or a rejection with `Success: false`'
|
||
),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const accountId = await authedId(c)
|
||
if (accountId === null) return unauthorized(c)
|
||
|
||
const body = (await c.req.json<Record<string, unknown>>().catch(() => null)) as Record<
|
||
string,
|
||
unknown
|
||
> | null
|
||
if (!body) return roomConsumableEnvelope(c, null, SAVE_CONSUMABLE_FAILED)
|
||
|
||
const str = (v: unknown): string | null => (typeof v === 'string' ? v : null)
|
||
const int = (v: unknown, fallback: number): number =>
|
||
typeof v === 'number' && Number.isInteger(v) ? v : fallback
|
||
|
||
const name = str(body.Name)?.trim() ?? ''
|
||
if (name === '') return roomConsumableEnvelope(c, null, SAVE_CONSUMABLE_FAILED)
|
||
|
||
// On a replace the room comes off the STORED listing, never the body — the same rule
|
||
// the currency edit follows. Otherwise a caller could name a listing in one room and
|
||
// a room they happen to own, and have the check pass against the wrong one.
|
||
const roomConsumableId = str(body.RoomConsumableId)
|
||
let roomId: number
|
||
if (roomConsumableId === null) {
|
||
roomId = int(body.RoomId, Number.NaN)
|
||
if (!Number.isInteger(roomId)) {
|
||
return roomConsumableEnvelope(c, null, SAVE_CONSUMABLE_FAILED)
|
||
}
|
||
} else {
|
||
const existing = await getRoomConsumable(c.env.DB, roomConsumableId)
|
||
if (!existing) return roomConsumableEnvelope(c, null, SAVE_CONSUMABLE_FAILED)
|
||
roomId = existing.RoomId
|
||
}
|
||
|
||
const canManage = await canManageRoomById(c.env.DB, roomId, accountId)
|
||
if (canManage === null) return roomConsumableEnvelope(c, null, SAVE_CONSUMABLE_FAILED)
|
||
if (!canManage) return c.body(null, 403)
|
||
|
||
// One price in one currency, flattened out of the body's nested object.
|
||
const priceAndCurrency = (
|
||
typeof body.PriceAndCurrency === 'object' && body.PriceAndCurrency !== null
|
||
? body.PriceAndCurrency
|
||
: {}
|
||
) as Record<string, unknown>
|
||
|
||
const consumable = await upsertRoomConsumable(c.env.DB, roomConsumableId, {
|
||
RoomId: roomId,
|
||
// Masked like every other player-typed string: a shop's contents are shown to
|
||
// everyone who walks into the room.
|
||
Name: censorSwears(name),
|
||
Description: censorSwears(str(body.Description) ?? ''),
|
||
ImageName: str(body.ImageName),
|
||
Price: int(priceAndCurrency.Price, 0),
|
||
PurchaseCurrencyId: str(priceAndCurrency.CurrencyId),
|
||
// The client has not been seen sending this, so it falls back to the CLR default
|
||
// for an int rather than to a guess at what the shop should allow.
|
||
MaximumCountPerPurchase: int(body.MaximumCountPerPurchase, 0),
|
||
})
|
||
return roomConsumableEnvelope(c, consumable)
|
||
}
|
||
)
|
||
.get(
|
||
'/api/roomconsumables/v1/roomConsumable/room/:roomId/me',
|
||
listRoute('The caller’s room consumables', 'Empty stub'),
|
||
(c) => c.json([])
|
||
)
|
||
// The custom currencies a room has minted. Public, like the room itself: a room's
|
||
// currencies are shown to every player who walks into it, so the list is not a secret
|
||
// the way its ban list is. A room with none — or an unknown room — is an empty list.
|
||
.get(
|
||
'/api/roomcurrencies/v1/currencies',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'A room’s currencies',
|
||
description: [
|
||
'Every custom currency the room named by `roomId` has minted, oldest first — the',
|
||
'order its owner built them up in.',
|
||
'',
|
||
'Public, like the room itself: a room’s currencies are shown to everyone who walks',
|
||
'into it. A room with none, an unknown room, and a missing `roomId` are all the same',
|
||
'empty list, which the client reads as "this room has no currency of its own".',
|
||
'',
|
||
'These are DEFINITIONS, not balances — what a player holds of one is',
|
||
'`getAllBalances`, which is still a stub.',
|
||
].join('\n'),
|
||
parameters: [
|
||
{
|
||
name: 'roomId',
|
||
in: 'query',
|
||
required: false,
|
||
description: 'The room whose currencies to list',
|
||
schema: { type: 'string' },
|
||
},
|
||
],
|
||
responses: { 200: json(RoomCurrencyDto.array(), 'The room’s currencies, oldest first') },
|
||
}),
|
||
async (c) => {
|
||
const roomId = Number.parseInt(c.req.query('roomId') ?? '', 10)
|
||
if (!Number.isInteger(roomId)) return c.json([])
|
||
return c.json(await getRoomCurrencies(c.env.DB, roomId))
|
||
}
|
||
)
|
||
|
||
// Mint a custom currency for a room. Auth-gated (401) and gated to the room's creator or
|
||
// a co-owner (403) — the same owner-level check the `rooms` worker applies to its own
|
||
// room-admin writes, reading the same room blob.
|
||
.post(
|
||
'/api/roomcurrencies/v1/createCurrency',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Create a room currency',
|
||
description: [
|
||
'Mints a custom currency for one room — its name, its description, its coin art and',
|
||
'the most of it the room may award per day. Form-encoded',
|
||
'(`RoomId=2511&Name=Devintokens&Description=…&Limit=100&Shape=0&Color=19`).',
|
||
'',
|
||
'Gated to the room’s CREATOR or a CO-OWNER — minting a currency is room',
|
||
'administration, so it takes the same standing as the room’s other settings. A valid',
|
||
'token from anyone else is a 403.',
|
||
'',
|
||
'`Name` and `Description` are masked by the same word list as every other string a',
|
||
'player types, since a currency’s name is shown to everyone in the room.',
|
||
'',
|
||
'Answers the created currency inside the `{ Value, Success, Error, error_id }`',
|
||
'envelope — PascalCase beside a lowercase `error_id`, which is the client’s own mixed',
|
||
'casing. The client unwraps it and hands its callers `Value` alone. A recoverable',
|
||
'refusal (an unusable `RoomId`, an empty `Name`, an unknown room) is a 200 carrying',
|
||
'`Success: false`, a null `Value` and the reference’s own message; only the auth',
|
||
'gates answer with a status of their own.',
|
||
'',
|
||
'`CurrencyId` is how the client names this currency everywhere afterwards.',
|
||
'`CurrencyType` is always 300 (RoomCurrency) and `ImageName` is null until custom',
|
||
'coin art can be uploaded.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: form(CreateRoomCurrencyRequest, 'The currency to mint'),
|
||
responses: {
|
||
200: json(
|
||
RoomCurrencyEnvelope,
|
||
'The currency as created, or a rejection with `Success: false`'
|
||
),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const accountId = await authedId(c)
|
||
if (accountId === null) return unauthorized(c)
|
||
|
||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||
const int = (v: unknown, fallback: number): number => {
|
||
const parsed = Number.parseInt(str(v), 10)
|
||
return Number.isInteger(parsed) ? parsed : fallback
|
||
}
|
||
|
||
const roomId = int(body.RoomId, Number.NaN)
|
||
if (!Number.isInteger(roomId)) return roomCurrencyEnvelope(c, null, CREATE_CURRENCY_FAILED)
|
||
const name = str(body.Name).trim()
|
||
if (name === '') return roomCurrencyEnvelope(c, null, CREATE_CURRENCY_FAILED)
|
||
|
||
// The room is the `rooms` worker's, read here only to apply its owner check — the
|
||
// same `canManageRoom` that worker gates its own room-admin writes on, so the two
|
||
// can't drift into disagreeing about who runs a room. Null is no such room, which
|
||
// answers differently from a room somebody else runs.
|
||
const canManage = await canManageRoomById(c.env.DB, roomId, accountId)
|
||
if (canManage === null) return roomCurrencyEnvelope(c, null, CREATE_CURRENCY_FAILED)
|
||
if (!canManage) return c.body(null, 403)
|
||
|
||
const currency = await createRoomCurrency(c.env.DB, {
|
||
RoomId: roomId,
|
||
// Masked like every other player-typed string (see the gift note above): a
|
||
// currency's name is shown to everyone who walks into the room.
|
||
Name: censorSwears(name),
|
||
Description: censorSwears(str(body.Description)),
|
||
Limit: int(body.Limit, 0),
|
||
Shape: int(body.Shape, 0),
|
||
Color: int(body.Color, 0),
|
||
})
|
||
|
||
await pushRoomCurrencyChange(c, currency, accountId, NotificationType.RoomCurrencyCreated)
|
||
return roomCurrencyEnvelope(c, currency)
|
||
}
|
||
)
|
||
|
||
// Edit a room currency. Auth-gated (401) and gated to the creator or a co-owner of the room
|
||
// that MINTED it (403) — the currency names the room, so the caller doesn't get to say
|
||
// which room's permissions apply. Answers the same envelope the create does.
|
||
.post(
|
||
'/api/roomcurrencies/v1/updateCurrency',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Edit a room currency',
|
||
description: [
|
||
'Changes a currency’s name, description, daily award limit or coin art. Form-encoded,',
|
||
'naming the',
|
||
'currency by `CurrencyId`',
|
||
'(`CurrencyId=b9f41a7c-\u2026&Name=\u2026&Description=\u2026&Limit=330&Shape=9&Color=5`).',
|
||
'',
|
||
'The room is not in the body and cannot be: it is whichever room minted the currency,',
|
||
'which is also the room whose creator/co-owner check applies. A valid token from',
|
||
'anyone else is a 403.',
|
||
'',
|
||
'Every field but `CurrencyId` is optional, and one left out is left ALONE rather than',
|
||
'reset — the client sends the whole form, so this only bites a partial request, and',
|
||
'blanking a description because it went unmentioned is the worse reading. What can',
|
||
'never change: the id, the room, the `CurrencyType`, and `CreatedAt`. `ModifiedAt`',
|
||
'moves to now, which is what the client’s model carries both for.',
|
||
'',
|
||
'`Name` and `Description` are masked by the same word list as every other string a',
|
||
'player types. Answers the created-currency envelope: the updated currency in',
|
||
'`Value`, or a 200 carrying `Success: false` when there is no such currency or the',
|
||
'body is unusable.',
|
||
'',
|
||
'Everyone standing in the room gets a `RoomCurrencyModified` frame — a renamed or',
|
||
'recapped currency that only the editor hears about leaves the rest of the room',
|
||
'spending the old one.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: form(UpdateRoomCurrencyRequest, 'The currency and the fields to change'),
|
||
responses: {
|
||
200: json(
|
||
RoomCurrencyEnvelope,
|
||
'The currency as it now stands, or a rejection with `Success: false`'
|
||
),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: {
|
||
description: 'Not the creator or a co-owner of the room that minted it (empty body)',
|
||
},
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const accountId = await authedId(c)
|
||
if (accountId === null) return unauthorized(c)
|
||
|
||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||
const int = (v: unknown): number | undefined => {
|
||
const raw = str(v)
|
||
if (raw === undefined) return undefined
|
||
const parsed = Number.parseInt(raw, 10)
|
||
return Number.isInteger(parsed) ? parsed : undefined
|
||
}
|
||
|
||
const currencyId = str(body.CurrencyId)?.trim() ?? ''
|
||
if (currencyId === '') return roomCurrencyEnvelope(c, null, UPDATE_CURRENCY_FAILED)
|
||
|
||
const existing = await getRoomCurrency(c.env.DB, currencyId)
|
||
if (!existing) return roomCurrencyEnvelope(c, null, UPDATE_CURRENCY_FAILED)
|
||
|
||
// The room comes off the CURRENCY, never the request: the caller names what to edit,
|
||
// not whose permissions to check against. Same gate as minting one.
|
||
const canManage = await canManageRoomById(c.env.DB, existing.RoomId, accountId)
|
||
// A currency whose room has since been deleted is unowned and so uneditable by
|
||
// anyone — a rejection rather than a 403, since nobody is being turned away.
|
||
if (canManage === null) return roomCurrencyEnvelope(c, null, UPDATE_CURRENCY_FAILED)
|
||
if (!canManage) return c.body(null, 403)
|
||
|
||
// A name sent but blank is a bad edit, not an instruction to leave it alone: the
|
||
// currency would be left nameless in every list that shows it.
|
||
const name = str(body.Name)?.trim()
|
||
if (name !== undefined && name === '') {
|
||
return roomCurrencyEnvelope(c, null, UPDATE_CURRENCY_FAILED)
|
||
}
|
||
|
||
const updated = await updateRoomCurrency(c.env.DB, existing, {
|
||
Name: name === undefined ? undefined : censorSwears(name),
|
||
Description:
|
||
str(body.Description) === undefined ? undefined : censorSwears(str(body.Description)!),
|
||
Limit: int(body.Limit),
|
||
Shape: int(body.Shape),
|
||
Color: int(body.Color),
|
||
})
|
||
|
||
await pushRoomCurrencyChange(c, updated, accountId, NotificationType.RoomCurrencyModified)
|
||
return roomCurrencyEnvelope(c, updated)
|
||
}
|
||
)
|
||
|
||
// The purchase offers on one or more room currencies — a currency's shop, the ways a
|
||
// player can BUY it. Public, like the currencies themselves: a shop is shown to everyone
|
||
// who walks into the room.
|
||
.get(
|
||
'/api/roomcurrencies/v1/getPurchaseOffersBatch',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Purchase offers for room currencies',
|
||
description: [
|
||
'The offers on each currency named in `ids` — the ways a player can buy that',
|
||
'currency, priced in another ("5 SuperTokens for 500 Rec Center Tokens").',
|
||
'',
|
||
'`ids` takes several currencies: comma-separated, repeated (`ids=a&ids=b`), or both.',
|
||
'The answer is one GROUP per currency — `{ CurrencyId, PurchaseOffers }` — in the',
|
||
'order the ids were given, each shop sorted by its offers’ `Order`. Grouped even',
|
||
'though every offer carries its own `CurrencyId`, because the group is what says a',
|
||
'currency was asked about: a currency with no shop still gets one, with an empty',
|
||
'`PurchaseOffers`.',
|
||
'',
|
||
'An id naming no currency at all is omitted — there is no shop to report, empty or',
|
||
'otherwise — the way the other batch lookups here treat an unknown id, and never an',
|
||
'error for the whole call. No `ids` at all is an empty list.',
|
||
'',
|
||
'Public, like `GET /api/roomcurrencies/v1/currencies`. Nothing writes offers yet, so',
|
||
'every currency answers empty until there is an endpoint that builds a shop.',
|
||
].join('\n'),
|
||
parameters: [
|
||
{
|
||
name: 'ids',
|
||
in: 'query',
|
||
required: false,
|
||
description: 'Currency ids — comma-separated, repeated, or both',
|
||
schema: { type: 'string' },
|
||
},
|
||
],
|
||
responses: {
|
||
200: json(
|
||
RoomCurrencyPurchaseOffersDto.array(),
|
||
'One group per currency asked for, in the order given'
|
||
),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
// Both spellings, since nothing says which the client uses: `ids=a&ids=b` and
|
||
// `ids=a,b` reach the same list, and blanks from a trailing comma are dropped.
|
||
const ids = c.req
|
||
.queries('ids')
|
||
?.flatMap((value) => value.split(','))
|
||
.map((value) => value.trim())
|
||
.filter((value) => value !== '')
|
||
|
||
// De-duplicated: an id sent twice is one shop, not the same shop twice.
|
||
return c.json(await getPurchaseOffers(c.env.DB, [...new Set(ids ?? [])]))
|
||
}
|
||
)
|
||
|
||
// Add a purchase offer to a room currency's shop. Auth-gated (401) and gated to the
|
||
// creator or a co-owner of the room that minted the currency (403) — a shop is the room's
|
||
// to build, and the room comes off the currency rather than the request.
|
||
.post(
|
||
'/api/roomcurrencies/v1/createPurchaseOffer',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Add a purchase offer to a room currency',
|
||
description: [
|
||
'Appends an offer to the currency named by `CurrencyId` — a way for players to buy',
|
||
'that currency ("5 SuperTokens for 500 Rec Center Tokens"). Form-encoded',
|
||
'(`CurrencyId=…&Name=…&Amount=5&Price=55&Order=0`).',
|
||
'',
|
||
'Note `Amount` in the body against `CurrencyAmount` on the offer it becomes: the',
|
||
'body and the client’s model spell the same number differently, and the answer uses',
|
||
'the model’s spelling.',
|
||
'',
|
||
'`CurrencyPurchaseOfferId` and `ModifiedAt` are minted here — the offer’s id is how',
|
||
'the client names it afterwards. `Order` places it in the shop and defaults to 0;',
|
||
'offers are served sorted by it, so several at 0 keep the order they were added in.',
|
||
'',
|
||
'Gated to the creator or a co-owner of the room that minted the currency. A valid',
|
||
'token from anyone else is a 403. Answers the same',
|
||
'`{ Value, Success, Error, error_id }` envelope the currency writes use, with the',
|
||
'offer in `Value`, or a 200 carrying `Success: false` when the currency is unknown',
|
||
'or the body unusable.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: form(CreatePurchaseOfferRequest, 'The offer to add'),
|
||
responses: {
|
||
200: json(
|
||
RoomCurrencyPurchaseOfferEnvelope,
|
||
'The offer as created, or a rejection with `Success: false`'
|
||
),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: { description: 'Not the creator or a co-owner of the minting room (empty body)' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const accountId = await authedId(c)
|
||
if (accountId === null) return unauthorized(c)
|
||
|
||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||
const int = (v: unknown, fallback: number): number => {
|
||
const parsed = Number.parseInt(str(v), 10)
|
||
return Number.isInteger(parsed) ? parsed : fallback
|
||
}
|
||
|
||
const currencyId = str(body.CurrencyId).trim()
|
||
if (currencyId === '') return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED)
|
||
|
||
const amount = int(body.Amount, Number.NaN)
|
||
const price = int(body.Price, Number.NaN)
|
||
if (!Number.isInteger(amount) || !Number.isInteger(price)) {
|
||
return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED)
|
||
}
|
||
|
||
const currency = await getRoomCurrency(c.env.DB, currencyId)
|
||
if (!currency) return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED)
|
||
|
||
// The room comes off the CURRENCY, never the request — the same gate minting and
|
||
// editing one take. A shop is the room's to build.
|
||
const canManage = await canManageRoomById(c.env.DB, currency.RoomId, accountId)
|
||
if (canManage === null) return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED)
|
||
if (!canManage) return c.body(null, 403)
|
||
|
||
const offer = await createPurchaseOffer(c.env.DB, currencyId, {
|
||
// The body's `Amount` is the model's `CurrencyAmount`.
|
||
CurrencyAmount: amount,
|
||
Price: price,
|
||
// Stored verbatim, NOT profanity-masked like a currency's name: the client has
|
||
// been seen sending a GUID here, and the mask works on substrings — it would
|
||
// happily corrupt an identifier that happened to contain a word. If this turns
|
||
// out to be player-typed display text, it wants masking.
|
||
Name: str(body.Name),
|
||
Order: int(body.Order, 0),
|
||
})
|
||
return purchaseOfferEnvelope(c, offer)
|
||
}
|
||
)
|
||
|
||
// Take a purchase offer down. Auth-gated (401) and gated to the creator or a co-owner of
|
||
// the room that minted the currency the offer sells (403). The body names ONLY the offer,
|
||
// so the currency — and through it the room — is found by walking the shops for the id;
|
||
// nothing about the caller's whereabouts is consulted.
|
||
.post(
|
||
'/api/roomcurrencies/v1/deletePurchaseOffer',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Remove a purchase offer from a room currency',
|
||
description: [
|
||
'Removes the offer named by `PurchaseOfferId` (its `CurrencyPurchaseOfferId`) from',
|
||
'whichever currency’s shop lists it. Form-encoded (`PurchaseOfferId=…`). The body',
|
||
'names nothing else: the currency, and the room that gates the delete, are found',
|
||
'from the offer.',
|
||
'',
|
||
'Gated to the creator or a co-owner of the room that minted the currency. A valid',
|
||
'token from anyone else is a 403. Answers the same',
|
||
'`{ Value, Success, Error, error_id }` envelope the create answers in, with the',
|
||
'offer as it stood when removed in `Value`, or a 200 carrying `Success: false` when',
|
||
'no shop lists that id. What the client does with `Value` here is an assumption —',
|
||
'only the create’s reply has been observed.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: form(DeletePurchaseOfferRequest, 'The offer to remove'),
|
||
responses: {
|
||
200: json(
|
||
RoomCurrencyPurchaseOfferEnvelope,
|
||
'The offer as removed, or a rejection with `Success: false`'
|
||
),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: { description: 'Not the creator or a co-owner of the minting room (empty body)' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const accountId = await authedId(c)
|
||
if (accountId === null) return unauthorized(c)
|
||
|
||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||
const purchaseOfferId = (
|
||
typeof body.PurchaseOfferId === 'string' ? body.PurchaseOfferId : ''
|
||
).trim()
|
||
if (purchaseOfferId === '') return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
|
||
|
||
// Found BEFORE it is removed, because the find is what says which room gates the
|
||
// delete: an unknown id is a refusal, and a known one is somebody's to refuse.
|
||
const found = await findPurchaseOffer(c.env.DB, purchaseOfferId)
|
||
if (!found) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
|
||
|
||
const canManage = await canManageRoomById(c.env.DB, found.RoomId, accountId)
|
||
if (canManage === null) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
|
||
if (!canManage) return c.body(null, 403)
|
||
|
||
const removed = await deletePurchaseOffer(c.env.DB, purchaseOfferId)
|
||
if (!removed) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
|
||
return purchaseOfferEnvelope(c, removed.Offer)
|
||
}
|
||
)
|
||
|
||
// Award room currency to players, several awards per call. Auth-gated (401); everything
|
||
// else is reported PER ENTRY, including whether the caller may award that currency at all.
|
||
.post(
|
||
'/api/roomcurrencies/v1/awardCurrency/bulk',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Award room currency in bulk',
|
||
description: [
|
||
'Adds to what players hold of a room currency. The body is a JSON ARRAY, each entry',
|
||
'naming its own `CurrencyId` and `RecipientId`, so one call can pay several players',
|
||
'or one player in several currencies.',
|
||
'',
|
||
'The answer is a BARE ARRAY, one result per entry IN THE ORDER SENT, and each result',
|
||
'stands alone: `Success` and `Error` are that entry’s, and `Response` carries the',
|
||
'outcome or is null. So one entry failing neither fails the others nor the call —',
|
||
'including an entry whose currency belongs to a room the caller cannot manage, which',
|
||
'is that entry’s failure rather than a 403 for everything. Only a missing or invalid',
|
||
'token is answered at the call level, with a 401.',
|
||
'',
|
||
'Each entry is authorised against the room that MINTED its currency: the caller must',
|
||
'be that room’s creator or a co-owner. The room comes off the currency, never the',
|
||
'request — a faucet a caller could point at any room would let anyone print another',
|
||
'room’s money.',
|
||
'',
|
||
'`Amount` may be negative, which deducts. A balance floors at zero in the write',
|
||
'itself — a player cannot owe a room currency — and `AmountAwarded` reports what',
|
||
'actually landed, which is less than was asked for only when a deduction ran out of',
|
||
'balance to take. `Balance` is the RESULTING total.',
|
||
'',
|
||
'The currency’s `Limit` is NOT applied here. It is the most a room may award PER DAY',
|
||
'— a faucet rate, not a ceiling on holdings — and nothing tracks a day’s awards yet,',
|
||
'so no award is refused for it.',
|
||
'',
|
||
'`TransactionId` is the client’s id for an award so that a retry is the same award.',
|
||
'It is accepted and validated, but NOT yet deduplicated — replaying a request',
|
||
'currently awards twice. That needs a table of its own.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(
|
||
AwardRoomCurrencyRequest.array(),
|
||
'The awards to apply, as a bare JSON array'
|
||
),
|
||
responses: {
|
||
200: json(AwardRoomCurrencyResultList, 'One result per entry, in the order sent'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const accountId = await authedId(c)
|
||
if (accountId === null) return unauthorized(c)
|
||
|
||
const parsed = await c.req.json<unknown>().catch(() => null)
|
||
// A body that isn't a list of awards has no entries to report on, and the answer is
|
||
// a list of entry results — so there is nothing to say but "none".
|
||
if (!Array.isArray(parsed)) return c.json([])
|
||
|
||
// Cached across entries, so paying twenty players in one currency is one currency
|
||
// lookup and one permission check rather than twenty of each. `null` caches a
|
||
// currency that doesn't exist; `false` one the caller may not award.
|
||
const currencies = new Map<string, RoomCurrency | null | false>()
|
||
const resolve = async (currencyId: string): Promise<RoomCurrency | null | false> => {
|
||
const cached = currencies.get(currencyId)
|
||
if (cached !== undefined) return cached
|
||
|
||
const found = await getRoomCurrency(c.env.DB, currencyId)
|
||
// The room comes off the CURRENCY, never the request — the same gate minting and
|
||
// editing one take.
|
||
const canManage = found && (await canManageRoomById(c.env.DB, found.RoomId, accountId))
|
||
const resolved = !found || canManage === null ? null : canManage ? found : false
|
||
currencies.set(currencyId, resolved)
|
||
return resolved
|
||
}
|
||
|
||
const results = []
|
||
for (const entry of parsed as Array<Record<string, unknown>>) {
|
||
const currencyId = typeof entry?.CurrencyId === 'string' ? entry.CurrencyId.trim() : ''
|
||
const playerId = Number(entry?.RecipientId)
|
||
const amount = Number(entry?.Amount)
|
||
|
||
// Every result names its entry even when the entry was unusable, so a caller can
|
||
// line the answer up with what it sent.
|
||
const fail = (error: string) => ({
|
||
AccountId: Number.isInteger(playerId) ? playerId : 0,
|
||
CurrencyId: currencyId,
|
||
Success: false,
|
||
Error: error,
|
||
Response: null,
|
||
})
|
||
|
||
if (currencyId === '' || !Number.isInteger(playerId) || !Number.isInteger(amount)) {
|
||
results.push(fail('Invalid award'))
|
||
continue
|
||
}
|
||
|
||
const currency = await resolve(currencyId)
|
||
if (currency === null) {
|
||
results.push(fail('No such currency'))
|
||
continue
|
||
}
|
||
if (currency === false) {
|
||
results.push(fail('Not permitted'))
|
||
continue
|
||
}
|
||
|
||
const { Balance, AmountAwarded } = await awardRoomCurrency(
|
||
c.env.DB,
|
||
currency.CurrencyId,
|
||
playerId,
|
||
amount
|
||
)
|
||
results.push({
|
||
AccountId: playerId,
|
||
CurrencyId: currency.CurrencyId,
|
||
Success: true,
|
||
Error: null,
|
||
Response: {
|
||
AccountId: playerId,
|
||
CurrencyId: currency.CurrencyId,
|
||
Balance,
|
||
AmountAwarded,
|
||
AwardedAt: new Date().toISOString(),
|
||
},
|
||
})
|
||
}
|
||
|
||
return c.json(results)
|
||
}
|
||
)
|
||
|
||
// The purchase offers on one or more room currencies — a currency's shop, the ways a
|
||
// player can BUY it. Public, like the currencies themselves: a shop is shown to everyone
|
||
// who walks into the room.
|
||
.get(
|
||
'/api/roomcurrencies/v1/getPurchaseOffersBatch',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Purchase offers for room currencies',
|
||
description: [
|
||
'The offers on each currency named in `ids` — the ways a player can buy that',
|
||
'currency, priced in another ("5 SuperTokens for 500 Rec Center Tokens").',
|
||
'',
|
||
'`ids` takes several currencies: comma-separated, repeated (`ids=a&ids=b`), or both.',
|
||
'The answer is FLAT rather than grouped, because every offer carries its own',
|
||
'`CurrencyId` — the client groups them itself, and one currency’s answer looks like',
|
||
'twenty’s. Ordered by the ids as given, then by each offer’s `Order`, so a caller',
|
||
'gets its own batch back in its own order with each shop in shop order.',
|
||
'',
|
||
'An id naming no currency contributes nothing, the way the other batch lookups here',
|
||
'treat an unknown id: a shop that does not exist is an empty one, not an error for',
|
||
'the whole call. No `ids` at all is an empty list.',
|
||
'',
|
||
'Public, like `GET /api/roomcurrencies/v1/currencies`. Nothing writes offers yet, so',
|
||
'every currency answers empty until there is an endpoint that builds a shop.',
|
||
].join('\n'),
|
||
parameters: [
|
||
{
|
||
name: 'ids',
|
||
in: 'query',
|
||
required: false,
|
||
description: 'Currency ids — comma-separated, repeated, or both',
|
||
schema: { type: 'string' },
|
||
},
|
||
],
|
||
responses: {
|
||
200: json(
|
||
RoomCurrencyPurchaseOfferDto.array(),
|
||
'The offers, flattened across the currencies asked for'
|
||
),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
// Both spellings, since nothing says which the client uses: `ids=a&ids=b` and
|
||
// `ids=a,b` reach the same list, and blanks from a trailing comma are dropped.
|
||
const ids = c.req
|
||
.queries('ids')
|
||
?.flatMap((value) => value.split(','))
|
||
.map((value) => value.trim())
|
||
.filter((value) => value !== '')
|
||
|
||
// De-duplicated: an id sent twice is one shop, not the same shop twice.
|
||
return c.json(await getPurchaseOffers(c.env.DB, [...new Set(ids ?? [])]))
|
||
}
|
||
)
|
||
|
||
// Add a purchase offer to a room currency's shop. Auth-gated (401) and gated to the
|
||
// creator or a co-owner of the room that minted the currency (403) — a shop is the room's
|
||
// to build, and the room comes off the currency rather than the request.
|
||
.post(
|
||
'/api/roomcurrencies/v1/createPurchaseOffer',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Add a purchase offer to a room currency',
|
||
description: [
|
||
'Appends an offer to the currency named by `CurrencyId` — a way for players to buy',
|
||
'that currency ("5 SuperTokens for 500 Rec Center Tokens"). Form-encoded',
|
||
'(`CurrencyId=…&Name=…&Amount=5&Price=55&Order=0`).',
|
||
'',
|
||
'Note `Amount` in the body against `CurrencyAmount` on the offer it becomes: the',
|
||
'body and the client’s model spell the same number differently, and the answer uses',
|
||
'the model’s spelling.',
|
||
'',
|
||
'`CurrencyPurchaseOfferId` and `ModifiedAt` are minted here — the offer’s id is how',
|
||
'the client names it afterwards. `Order` places it in the shop and defaults to 0;',
|
||
'offers are served sorted by it, so several at 0 keep the order they were added in.',
|
||
'',
|
||
'Gated to the creator or a co-owner of the room that minted the currency. A valid',
|
||
'token from anyone else is a 403. Answers the same',
|
||
'`{ Value, Success, Error, error_id }` envelope the currency writes use, with the',
|
||
'offer in `Value`, or a 200 carrying `Success: false` when the currency is unknown',
|
||
'or the body unusable.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: form(CreatePurchaseOfferRequest, 'The offer to add'),
|
||
responses: {
|
||
200: json(
|
||
RoomCurrencyPurchaseOfferEnvelope,
|
||
'The offer as created, or a rejection with `Success: false`'
|
||
),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: { description: 'Not the creator or a co-owner of the minting room (empty body)' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const accountId = await authedId(c)
|
||
if (accountId === null) return unauthorized(c)
|
||
|
||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||
const int = (v: unknown, fallback: number): number => {
|
||
const parsed = Number.parseInt(str(v), 10)
|
||
return Number.isInteger(parsed) ? parsed : fallback
|
||
}
|
||
|
||
const currencyId = str(body.CurrencyId).trim()
|
||
if (currencyId === '') return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED)
|
||
|
||
const amount = int(body.Amount, Number.NaN)
|
||
const price = int(body.Price, Number.NaN)
|
||
if (!Number.isInteger(amount) || !Number.isInteger(price)) {
|
||
return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED)
|
||
}
|
||
|
||
const currency = await getRoomCurrency(c.env.DB, currencyId)
|
||
if (!currency) return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED)
|
||
|
||
// The room comes off the CURRENCY, never the request — the same gate minting and
|
||
// editing one take. A shop is the room's to build.
|
||
const canManage = await canManageRoomById(c.env.DB, currency.RoomId, accountId)
|
||
if (canManage === null) return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED)
|
||
if (!canManage) return c.body(null, 403)
|
||
|
||
const offer = await createPurchaseOffer(c.env.DB, currencyId, {
|
||
// The body's `Amount` is the model's `CurrencyAmount`.
|
||
CurrencyAmount: amount,
|
||
Price: price,
|
||
// Stored verbatim, NOT profanity-masked like a currency's name: the client has
|
||
// been seen sending a GUID here, and the mask works on substrings — it would
|
||
// happily corrupt an identifier that happened to contain a word. If this turns
|
||
// out to be player-typed display text, it wants masking.
|
||
Name: str(body.Name),
|
||
Order: int(body.Order, 0),
|
||
})
|
||
return purchaseOfferEnvelope(c, offer)
|
||
}
|
||
)
|
||
.get('/api/roomcurrencies/v1/getAllBalances', listRoute('Room balances', 'Empty stub'), (c) =>
|
||
c.json([])
|
||
)
|
||
|
||
// What one player holds of one room currency. Any signed-in caller may read any player's,
|
||
// as the query names the player rather than taking the caller.
|
||
.get(
|
||
'/api/roomcurrencies/v1/getBalance',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'A player’s balance of one room currency',
|
||
description: [
|
||
'What the player named by `accountId` holds of the room currency `currencyId`',
|
||
'(`room_balance`). A player who has never earned any — and a currency that does not',
|
||
'exist — is a `Balance` of 0, not a 404.',
|
||
'',
|
||
'`ModifiedAt` is the time of the response: `room_balance` keeps no modification time.',
|
||
'A missing `currencyId` or a non-integer `accountId` is a 400.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
parameters: [
|
||
{
|
||
name: 'currencyId',
|
||
in: 'query',
|
||
required: true,
|
||
description: 'The room currency',
|
||
schema: { type: 'string' },
|
||
},
|
||
{
|
||
name: 'accountId',
|
||
in: 'query',
|
||
required: true,
|
||
description: 'The player whose balance to read',
|
||
schema: { type: 'integer' },
|
||
},
|
||
],
|
||
responses: {
|
||
200: json(RoomCurrencyBalance, 'The player’s balance'),
|
||
400: { description: 'A missing `currencyId` or a non-integer `accountId`' },
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
if ((await authedId(c)) === null) return unauthorized(c)
|
||
const currencyId = c.req.query('currencyId')?.trim() ?? ''
|
||
const rawAccountId = c.req.query('accountId')?.trim() ?? ''
|
||
if (currencyId === '' || !/^\d+$/.test(rawAccountId)) return c.body(null, 400)
|
||
const accountId = Number(rawAccountId)
|
||
return c.json({
|
||
AccountId: accountId,
|
||
CurrencyId: currencyId,
|
||
Balance: await getRoomBalance(c.env.DB, currencyId, accountId),
|
||
ModifiedAt: new Date().toISOString(),
|
||
})
|
||
}
|
||
)
|
||
|
||
// Buy room currency with tokens, through one of the currency's purchase offers. The body
|
||
// names only the OFFER — which currency it sells is whichever one lists it — and the
|
||
// amount and price the client rendered, both of which must still match the offer.
|
||
.post(
|
||
'/api/roomCurrencies/v2/purchase',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Buy room currency with tokens',
|
||
description: [
|
||
'Buys from a room currency’s shop. Form-encoded',
|
||
'(`PurchaseOfferId=ffab4157-…&RequestedAmount=444&RequestedPrice=555`): the caller',
|
||
'pays the offer’s `Price` in RecCenterTokens and is given its `CurrencyAmount` of the',
|
||
'currency the offer belongs to (`room_balance`).',
|
||
'',
|
||
'`PurchaseOfferId` is a `CurrencyPurchaseOfferId` out of a currency’s purchase offers,',
|
||
'and is all that says which currency is bought. `RequestedAmount` and `RequestedPrice`',
|
||
'must EQUAL the offer’s `CurrencyAmount` and `Price` — an offer edited since the client',
|
||
'drew it is refused rather than sold at numbers the player did not see.',
|
||
'',
|
||
'The tokens are paid to the room’s OWNER (its `CreatorAccountId`), as a room-key sale',
|
||
'is. The room’s owner and co-owners (the `canManageRoom` set) take from their own shop',
|
||
'for free: the client shows them the offer at no cost and posts `RequestedPrice=0`, so',
|
||
'their posted price is not checked and no tokens move. A free offer moves none either.',
|
||
'',
|
||
'Answers the `{ Value, Success, Error, error_id }` envelope the room-currency writes',
|
||
'use. `Value` is BOTH balances the purchase moved, each a RESULTING total:',
|
||
'`CurrencyBalanceResponse` (`AccountId`, `CurrencyId`, `Balance`, `ModifiedAt`) is',
|
||
'what the buyer now holds of the room currency, and `TokenBalanceResponse`',
|
||
'(`Balance`, `CurrencyType`, `Platform`) their tokens. `Platform` is -2, the one',
|
||
'bucket every balance surface here names — any other value shows the buyer a phantom',
|
||
'second token balance. A refusal (no such offer, a mismatch, not enough tokens) is a',
|
||
'200 with `Success: false` and a null `Value` — that form is an assumption — and',
|
||
'nothing moved.',
|
||
'',
|
||
'When tokens moved, both players get a socket push carrying their resulting token',
|
||
'total — the buyer a StorefrontBalancePurchase, the owner a StorefrontBalanceUpdate.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: form(PurchaseRoomCurrencyRequest, 'The offer, and the numbers the client saw'),
|
||
responses: {
|
||
200: json(PurchaseRoomCurrencyEnvelope, 'The buyer’s new holding, or a refusal'),
|
||
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 str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
|
||
const int = (v: unknown): number => (/^-?\d+$/.test(str(v)) ? Number(str(v)) : Number.NaN)
|
||
const refuse = (error: string) =>
|
||
c.json({ Value: null, Success: false, Error: error, error_id: null })
|
||
|
||
const offerId = str(body.PurchaseOfferId)
|
||
const found = offerId === '' ? null : await findPurchaseOffer(c.env.DB, offerId)
|
||
if (found === null) return refuse('No such purchase offer')
|
||
const { Offer: offer, RoomId: roomId } = found
|
||
|
||
// An offer that hands over nothing, or costs less than nothing, is not for sale.
|
||
if (!Number.isInteger(offer.CurrencyAmount) || offer.CurrencyAmount <= 0) {
|
||
return refuse('Purchase offer is not available')
|
||
}
|
||
if (!Number.isInteger(offer.Price) || offer.Price < 0) {
|
||
return refuse('Purchase offer is not available')
|
||
}
|
||
if (int(body.RequestedAmount) !== offer.CurrencyAmount) {
|
||
return refuse('Requested amount does not match')
|
||
}
|
||
|
||
const ownerId = await getRoomOwnerId(c.env.DB, roomId)
|
||
if (ownerId === null) return refuse('Purchase offer is not available')
|
||
|
||
// The room's owner and co-owners take from their own shop for free: the client
|
||
// shows THEM the offer at no cost and posts `RequestedPrice=0`, so holding a
|
||
// contributor to the offer's price refused every purchase they made. Nobody is
|
||
// charged — the owner paying themselves would be a debit and a credit of the same
|
||
// tokens, and a co-owner is building the shop, not buying from it — so the posted
|
||
// price is not checked for them at all; everyone else must have seen the real one.
|
||
const canManage = (await canManageRoomById(c.env.DB, roomId, id)) === true
|
||
if (!canManage && int(body.RequestedPrice) !== offer.Price) {
|
||
return refuse('Requested price does not match')
|
||
}
|
||
|
||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||
const tokens = CurrencyType.RecCenterTokens
|
||
const charged = offer.Price > 0 && !canManage
|
||
// Charged FIRST, and atomically: a buyer who cannot pay is handed nothing.
|
||
if (charged && !(await spendCurrency(c.env.DB, id, tokens, offer.Price, startingTokens))) {
|
||
return refuse('Not enough tokens')
|
||
}
|
||
|
||
const { Balance: currencyBalance } = await awardRoomCurrency(
|
||
c.env.DB,
|
||
offer.CurrencyId,
|
||
id,
|
||
offer.CurrencyAmount
|
||
)
|
||
|
||
if (charged) {
|
||
// Seed the owner's signup grant BEFORE crediting them — `creditCurrency` upserts
|
||
// the row, which would otherwise cost a never-seen owner their starting tokens.
|
||
await ensureStartingBalances(c.env.DB, ownerId, startingTokens)
|
||
const ownerBalance = await creditCurrency(
|
||
c.env.DB,
|
||
ownerId,
|
||
tokens,
|
||
offer.Price,
|
||
startingTokens
|
||
)
|
||
// Both frames carry a RESULTING total — see the frame rule above pushBalanceUpdate.
|
||
await pushBalanceUpdate(c, ownerId, tokens, ownerBalance)
|
||
}
|
||
const tokenBalance = await getBalance(c.env.DB, id, tokens, startingTokens)
|
||
if (charged) await pushBalancePurchase(c, id, tokens, -offer.Price, tokenBalance)
|
||
|
||
// Both balances are RESULTING totals. `Platform` names the one account-wide bucket,
|
||
// like every other token balance this worker reports.
|
||
return c.json({
|
||
Value: {
|
||
CurrencyBalanceResponse: {
|
||
AccountId: id,
|
||
CurrencyId: offer.CurrencyId,
|
||
Balance: currencyBalance,
|
||
ModifiedAt: new Date().toISOString(),
|
||
},
|
||
TokenBalanceResponse: {
|
||
Balance: tokenBalance,
|
||
CurrencyType: tokens,
|
||
Platform: ALL_PLATFORMS,
|
||
},
|
||
},
|
||
Success: true,
|
||
Error: null,
|
||
error_id: null,
|
||
})
|
||
}
|
||
)
|
||
|
||
// 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 room’s inventory', 'Empty stub so the client doesn’t 404'),
|
||
(c) => c.json([])
|
||
)
|
||
.get(
|
||
'/econ/roomInventory/room/:roomId/player',
|
||
listRoute('The caller’s inventory in a room', 'Empty stub'),
|
||
(c) => c.json([])
|
||
)
|
||
.get(
|
||
'/econ/roomInventoryItemTags/room/:roomId',
|
||
listRoute('A room’s inventory item tags', 'Empty stub'),
|
||
(c) => c.json([])
|
||
)
|
||
.get('/econ/roomOffer/room/:roomId', listRoute('A room’s offers', 'Empty stub'), (c) =>
|
||
c.json([])
|
||
)
|
||
.get(
|
||
'/econ/roomOffer/room/:roomId/purchaseCounts',
|
||
describeRoute({
|
||
summary: 'Per-offer purchase counts for a room',
|
||
responses: {
|
||
200: json(
|
||
z.union([
|
||
JsonArray,
|
||
z.object({
|
||
Value: JsonArray,
|
||
Success: z.boolean(),
|
||
Error: z.string().nullable(),
|
||
error_id: z.string().nullable(),
|
||
}),
|
||
]),
|
||
'2026: RecNet result envelope; older builds: a list'
|
||
),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
if (((await authedBuild(c)) ?? 0) >= 20260323) {
|
||
// The 2026 client decodes RecNetResultListObject<RoomOfferPurchaseCountDTO>.
|
||
return c.json({ Value: [], Success: true, Error: null, error_id: null })
|
||
}
|
||
return c.json([])
|
||
}
|
||
)
|
||
.get(
|
||
'/econ/roomGiftDropShops/room/:roomId',
|
||
listRoute('A room’s 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 room’s economy config',
|
||
description: [
|
||
'Whether the room’s 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 room’s 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 paintball battle pass's season config. A stub: nothing stores a season yet, so
|
||
// every caller is served the one example season below, verbatim.
|
||
.get(
|
||
'/paintball-battle-pass/config',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'The paintball battle pass config (stub)',
|
||
description: 'A fixed example season — no battle pass is stored yet.',
|
||
responses: { 200: json(JsonObject, 'The battle pass config') },
|
||
}),
|
||
(c) =>
|
||
c.json({
|
||
BattlePassId: 1,
|
||
Name: 'Season 1',
|
||
ValidRoomIds: [112],
|
||
StartTime: '2026-10-01T00:00:00Z',
|
||
EndTime: '2026-12-01T00:00:00Z',
|
||
Levels: [{ XpRequired: 100, RewardGiftDropIds: [1] }],
|
||
EventXP: { '1': 10, '2': 25, '3': 50, '4': 100 },
|
||
BoostConfig: {
|
||
TemporaryBoostDurationInMinutes: 60,
|
||
TemporaryBoostXpMultiplierPercentage: 50,
|
||
PermanentBoostXpMultiplierPercentage: 25,
|
||
SocialBoostXpMultiplierPercentage: 10,
|
||
MaxBoostXpMultiplierPercentage: 100,
|
||
},
|
||
DailyParticipationBonusLimit: 5,
|
||
MinimumParticipationTimeInSeconds: 120,
|
||
PurchasableSkus: [{ Type: 1, TokenPrice: 1000, BonusLevels: 0, PermanentBoost: false }],
|
||
PermanentBoostTokenPrice: 500,
|
||
LimitedTimeReward: { CompletionDeadline: '2026-11-01T00:00:00Z', GiftDropIds: [9] },
|
||
})
|
||
)
|
||
|
||
// The timed currency grant the client polls. A stub: nothing accrues or is granted yet,
|
||
// so every caller is served the one example grant below, verbatim. Takes no body.
|
||
.post(
|
||
'/api/timedCurrencyGrants/update',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'The timed currency grant (stub)',
|
||
description: 'A fixed example grant — no timed grant is stored or paid out yet.',
|
||
responses: { 200: json(JsonObject, 'The timed currency grant') },
|
||
}),
|
||
(c) =>
|
||
c.json({
|
||
TimedCurrencyGrantId: 1,
|
||
Name: 'Daily Tokens',
|
||
StartTime: '2026-10-01T00:00:00Z',
|
||
EndTime: '2026-12-01T00:00:00Z',
|
||
CurrencyType: 0,
|
||
OfflineRatePerHour: 1.0,
|
||
OnlineRatePerHour: 5.0,
|
||
ActiveExperiment: null,
|
||
})
|
||
)
|
||
|
||
// The client reporting how far the player has moved, towards a distance-moved reward
|
||
// track. A stub: the form body (`distanceMeters=12.5`) is not read, nothing is tracked,
|
||
// and the answer is an empty 200.
|
||
.post(
|
||
'/api/distanceMovedRewardTracks/distance',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Report distance moved (stub)',
|
||
description:
|
||
'Takes a form-encoded `distanceMeters` and stores nothing — no reward track exists yet.',
|
||
responses: { 200: { description: 'Empty body' } },
|
||
}),
|
||
(c) => c.body(null, 200)
|
||
)
|
||
|
||
// Everything a room sells, in one list: its keys, its consumables and the purchase offers
|
||
// of its currencies, each as the client's 10-key `UgcPurchasableItem` with `ItemType`
|
||
// saying which (0 key, 1 consumable, 3 currency pack). Asked for on every room load. A
|
||
// room that sells nothing is `[]` — the client's decoder throws on `{}` or an empty body.
|
||
.get(
|
||
'/api/ugcPurchasables/v1/items/room/:roomId{[0-9]+}',
|
||
describeRoute({
|
||
tags: ['Rooms'],
|
||
summary: 'A room’s purchasables',
|
||
description:
|
||
'The room’s shop as one flat list: its room keys (`ItemType` 0), room consumables ' +
|
||
'(1) and the purchase offers of each of its currencies (3), assembled from the ' +
|
||
'`room_key`, `room_consumable` and `room_currency` tables. `ItemId` is a bare GUID ' +
|
||
'— a key’s `ReplicationId`, a consumable’s `RoomConsumableId`, an offer’s ' +
|
||
'`CurrencyPurchaseOfferId` — with the kind beside it as `ItemType`, not the ' +
|
||
'`{ itemType, itemId }` struct the bulk lookup takes. `PurchaseCurrencyId` is the ' +
|
||
'room currency a line is charged in, or null for tokens (every currency pack). A room ' +
|
||
'that sells nothing is an empty array.',
|
||
parameters: [
|
||
{
|
||
name: 'roomId',
|
||
in: 'path',
|
||
required: true,
|
||
description: 'The room whose shop to list',
|
||
schema: { type: 'string', pattern: '^[0-9]+$' },
|
||
},
|
||
],
|
||
responses: { 200: json(RoomPurchasableList, 'The room’s shop') },
|
||
}),
|
||
async (c) =>
|
||
c.json(await getRoomPurchasables(c.env.DB, Number.parseInt(c.req.param('roomId'), 10)))
|
||
)
|
||
|
||
// Bulk lookup of UGC purchasables by `{ itemType, itemId }`. Only custom avatar items
|
||
// (type 3) exist to resolve; they come off the api-owned `custom_avatar_item` table.
|
||
.post(
|
||
'/api/ugcPurchasables/v1/items/bulk',
|
||
describeRoute({
|
||
tags: ['Rooms'],
|
||
summary: 'Look up UGC purchasables by id',
|
||
description:
|
||
'Resolves `Ids[]` (`{ itemType, itemId }`) against the `custom_avatar_item` table and ' +
|
||
'answers the store-facing `UgcPurchasableItem` view of each, in request order. ' +
|
||
'Only `itemType` 3 (custom avatar item) is served; other types and unknown ids are ' +
|
||
'dropped. `RoomId` is echoed onto every item — what the client wants it for is ' +
|
||
'not yet known. `PurchaseCurrencyId` is null until a currency exists.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(UgcPurchasableBulkRequest, 'The room and the ids to resolve'),
|
||
responses: {
|
||
200: json(UgcPurchasableItemList, 'The resolved items (unknown ids omitted)'),
|
||
400: json(ErrorResponse, 'Malformed 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 Record<string, unknown> | null
|
||
if (!body || !Array.isArray(body.Ids)) return c.json({ error: 'Ids is required' }, 400)
|
||
const roomId = typeof body.RoomId === 'number' ? body.RoomId : 0
|
||
const ids = (body.Ids as unknown[]).flatMap((ref) => {
|
||
if (!ref || typeof ref !== 'object') return []
|
||
const { itemType, itemId } = ref as Record<string, unknown>
|
||
return itemType === UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM && typeof itemId === 'string'
|
||
? [itemId]
|
||
: []
|
||
})
|
||
const items = await getCustomAvatarItems(c.env.DB, ids)
|
||
return c.json(items.map((item) => toUgcPurchasable(item, roomId)))
|
||
}
|
||
)
|
||
|
||
// How the items in a store row may be BOUGHT — the counterpart of the bulk lookup above.
|
||
// The row itself carries only ids; the client asks this for the price tag, the sale
|
||
// banner, the “new” pip and whether the gift button is drawn. It answers one entry per
|
||
// RESOLVED id, in request order, dropping ids it doesn't know exactly as the bulk lookup
|
||
// does — an item with no purchase info renders as not-for-sale rather than at price zero.
|
||
//
|
||
// Two shapes meet in one object here and neither may be tidied into the other: the
|
||
// request's `{ itemType, itemId }` reference is camelCase, and the response nests THAT
|
||
// object, members unchanged, under a PascalCase `ItemId` beside PascalCase siblings.
|
||
.post(
|
||
'/api/items/purchaseInfos',
|
||
describeRoute({
|
||
tags: ['Storefront'],
|
||
summary: 'Purchase info for a bag of items',
|
||
description: [
|
||
'Resolves `Ids[]` (`{ itemType, itemId }`) against the `custom_avatar_item` table and',
|
||
'answers how each may be bought: its price in RecCenterTokens, its availability window',
|
||
'and the flags the store row draws. Only `itemType` 3 (custom avatar item) is served;',
|
||
'other types and unknown ids are dropped, so the response is one entry per RESOLVED',
|
||
'id in request order — never a positional match for `Ids[]`.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(ItemPurchaseInfosRequest, 'The ids to price'),
|
||
responses: {
|
||
200: json(ItemPurchaseInfoList, 'The resolved items’ purchase info (unknown ids omitted)'),
|
||
400: json(ErrorResponse, 'Malformed 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 Record<string, unknown> | null
|
||
if (!body || !Array.isArray(body.Ids)) return c.json({ error: 'Ids is required' }, 400)
|
||
const ids = (body.Ids as unknown[]).flatMap((ref) => {
|
||
if (!ref || typeof ref !== 'object') return []
|
||
const { itemType, itemId } = ref as Record<string, unknown>
|
||
return itemType === UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM && typeof itemId === 'string'
|
||
? [itemId]
|
||
: []
|
||
})
|
||
const items = await getCustomAvatarItems(c.env.DB, ids)
|
||
return c.json(items.map(toItemPurchaseInfo))
|
||
}
|
||
)
|
||
|
||
// Store listings by `PurchasableItemId` — `{ PurchasableItemIds: [2594], GiftDropIds: null }`.
|
||
// Answers a bare array of the listings exactly as the served 2025 store (`sf3-2025.json`)
|
||
// lists them, read from the `catalog` table's `listing` column rather than the 4 MB file
|
||
// (a row's `catalog_id` IS its `PurchasableItemId`). Every item kind resolves; unknown ids
|
||
// are dropped, so the answer is in request order but not positional. `GiftDropIds` has only
|
||
// been seen null and is ignored. No auth: a listing is what the public store shows anyone.
|
||
.post(
|
||
'/api/storefronts/v1/purchasableitems',
|
||
describeRoute({
|
||
tags: ['Storefront'],
|
||
summary: 'Store listings by PurchasableItemId',
|
||
description: [
|
||
'Resolves `PurchasableItemIds[]` against the `catalog` table and answers each listing',
|
||
'verbatim as the served 2025 store (`sf3-2025.json`) lists it — `GiftDrop`, `Prices`,',
|
||
'`SubscriberPrices` and the rest. Avatar items, consumables, skins and custom avatar',
|
||
'items all resolve. Unknown ids (and repeats) are dropped, so the response follows',
|
||
'request order but is never a positional match. `GiftDropIds` is ignored.',
|
||
].join(' '),
|
||
requestBody: jsonBody(PurchasableItemsRequest, 'The listings to resolve'),
|
||
responses: {
|
||
200: json(JsonArray, 'The resolved listings (unknown ids omitted)'),
|
||
400: json(ErrorResponse, 'Malformed body'),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||
if (!body || typeof body !== 'object')
|
||
return c.json({ error: 'A JSON body is required' }, 400)
|
||
const raw = body.PurchasableItemIds
|
||
if (raw !== undefined && raw !== null && !Array.isArray(raw)) {
|
||
return c.json({ error: 'PurchasableItemIds must be an array' }, 400)
|
||
}
|
||
const ids = ((raw as unknown[] | null | undefined) ?? []).filter((id): id is number =>
|
||
Number.isInteger(id)
|
||
)
|
||
return c.json(await getStoreListings(c.env.DB, ids))
|
||
}
|
||
)
|
||
|
||
// The consumable DEFINITIONS the client knows how to use (`?supportedVersion=2`), as
|
||
// opposed to the ones a player owns. Served verbatim from `static/consumables.json`, the
|
||
// live game's list; `supportedVersion` is accepted and unused. A row's `ConfigurationData`
|
||
// is JSON escaped into a STRING (`"{\"MeshConfigurationData\":null,…}"`), or null.
|
||
.get(
|
||
'/api/consumables/v1/all',
|
||
describeRoute({
|
||
tags: ['Consumables'],
|
||
summary: 'All consumable definitions',
|
||
description:
|
||
'Every consumable definition, verbatim from static/consumables.json. supportedVersion is ignored.',
|
||
responses: { 200: json(JsonArray, 'The consumable definitions') },
|
||
}),
|
||
(c) => c.json(consumableDefinitions)
|
||
)
|
||
|
||
// STUB. The client's lookup of consumable definitions by descriptor
|
||
// (`?configurationVersion=2`), form-encoded with one repeated key:
|
||
// `descriptors=xfeDIDboPES-AS90CCn2xQ&descriptors=test_bad`. The answer is an array of
|
||
// definitions — `{ ConsumableItemDesc, InitialCount, CreatedAt, ThumbnailImageName,
|
||
// FriendlyName, RoomieCategory, ConsumableToolName, ConfigurationData }`, with
|
||
// `ConfigurationData` JSON escaped into a string as on `/all` — holding only the
|
||
// descriptors that resolve. Nothing here can resolve one yet, so every query answers [].
|
||
.post(
|
||
'/api/consumables/v1/query/bulk',
|
||
describeRoute({
|
||
tags: ['Consumables'],
|
||
summary: 'Look up consumable definitions by descriptor (stub)',
|
||
description: [
|
||
'STUB. Takes a form body repeating `descriptors` once per consumable descriptor and',
|
||
'answers the definitions that resolve. There is no lookup behind it yet, so the',
|
||
'answer is always []. configurationVersion is ignored.',
|
||
].join(' '),
|
||
requestBody: form(QueryConsumablesRequest, 'The descriptors to look up'),
|
||
responses: { 200: json(JsonArray, 'The matching definitions (always empty for now)') },
|
||
}),
|
||
async (c) => {
|
||
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||
const descriptors = body['descriptors']
|
||
logger.info('consumable query/bulk (stub, nothing resolved)', { descriptors })
|
||
return c.json([])
|
||
}
|
||
)
|
||
|
||
// Unlocked consumables. [Authorize]. The consumables the player has bought (from
|
||
// `buyItem`, stored in the `consumable` table), grouped by item into the client's
|
||
// unlocked-consumable DTO. A player who has bought none gets an empty list.
|
||
.get(
|
||
'/api/consumables/v2/getUnlocked',
|
||
describeRoute({
|
||
tags: ['Consumables'],
|
||
summary: 'Unlocked consumables',
|
||
description: [
|
||
'The consumables the player has bought (from buyItem, in the consumable table),',
|
||
'grouped by item into the unlocked-consumable DTO (Ids/CreatedAts per instance,',
|
||
'Count their sum). [] when they’ve bought none.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(JsonArray, 'Grouped unlocked consumables (empty when none)'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const consumables = await getConsumables(c.env.DB, id)
|
||
// The 2026 client includes this flag in the instance key and verifies it against the
|
||
// definition, so it comes from the definition — see `isRroConsumable`.
|
||
return c.json(
|
||
((await authedBuild(c)) ?? 0) >= 20260323
|
||
? consumables.map((item) => ({
|
||
...item,
|
||
IsRRO: isRroConsumable(item.ConsumableItemDesc),
|
||
}))
|
||
: consumables
|
||
)
|
||
}
|
||
)
|
||
|
||
// Consume a quantity of an owned consumable instance. [Authorize]. Body is JSON
|
||
// `{ Id, DeltaCount }` where `Id` is the consumable row id. Reduces that instance's
|
||
// count by DeltaCount, deleting the row once it hits zero. Scoped to the caller so
|
||
// they can only consume their own. Envelope mirrors the gift-consume ack.
|
||
.post(
|
||
'/api/consumables/v1/consume',
|
||
describeRoute({
|
||
tags: ['Consumables'],
|
||
summary: 'Consume a quantity of an owned consumable',
|
||
description: [
|
||
'Reduces the given consumable instance’s count by `DeltaCount` (default 1), deleting',
|
||
'the row at zero. Scoped to the caller. Pushes a ConsumableMappingRemoved socket',
|
||
'notification. Envelope mirrors the gift-consume ack.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(ConsumeConsumableRequest, 'The consumable id and delta'),
|
||
responses: {
|
||
200: json(ConsumeEnvelope, 'Success envelope'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const body = await c.req
|
||
.json<{ Id?: unknown; DeltaCount?: unknown }>()
|
||
.catch(() => ({}) as { Id?: unknown; DeltaCount?: unknown })
|
||
const consumableId = typeof body.Id === 'number' ? body.Id : Number.NaN
|
||
const delta = typeof body.DeltaCount === 'number' ? body.DeltaCount : 1
|
||
if (!Number.isNaN(consumableId) && delta > 0) {
|
||
const consumed = await consumeConsumable(c.env.DB, id, consumableId, delta)
|
||
// Notify the player so their client removes/updates the item in inventory.
|
||
if (consumed !== null) await pushConsumableRemoved(c, id, consumed)
|
||
}
|
||
return c.json({ error: '', success: true, value: null })
|
||
}
|
||
)
|
||
|
||
// STUB. The client posts `{ Id, IsActive, ActivatedByRoomie }` when a consumable starts
|
||
// or stops being used — `Id` is the consumable row id, as on `/consume`. Nothing is
|
||
// stored: the body is read only to log it, and the client ignores the response entirely,
|
||
// so none is sent.
|
||
//
|
||
// What this most likely means: the consumable is IN USE, and marking it so is what keeps
|
||
// a player from reusing one before it is over (a second Confetti Cannon while the first
|
||
// is still firing), and `ActivatedByRoomie` says a roommate set it off rather than its
|
||
// owner. That is a guess from the field names, not observed behaviour, so no row flips
|
||
// and `/consume` is unchanged. When the client's reading of it is known, this is where
|
||
// an `is_active` flag on the consumable row (and a refusal on `/consume`) would go.
|
||
.post(
|
||
'/api/consumables/v1/updateActive',
|
||
describeRoute({
|
||
tags: ['Consumables'],
|
||
summary: 'Mark a consumable active or inactive (stub)',
|
||
description: [
|
||
'STUB. Accepts `{ Id, IsActive, ActivatedByRoomie }` — the client posting that one',
|
||
'of its consumables has started or stopped being used — and stores nothing. The',
|
||
'client ignores the response entirely, so this answers an empty 200.',
|
||
'',
|
||
'Believed to mark the consumable as IN USE so it cannot be reused before it is over,',
|
||
'with `ActivatedByRoomie` saying a roommate set it off; that is a guess from the field',
|
||
'names, and nothing acts on it yet. `/consume` is unaffected.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(UpdateActiveConsumableRequest, 'The consumable and its new state'),
|
||
responses: {
|
||
200: { description: 'Empty body — the client ignores the response entirely' },
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const body = await c.req
|
||
.json<Record<string, unknown>>()
|
||
.catch(() => ({}) as Record<string, unknown>)
|
||
logger.info('consumable updateActive (stub, not stored)', {
|
||
accountId: id,
|
||
consumableId: body.Id,
|
||
isActive: body.IsActive,
|
||
activatedByRoomie: body.ActivatedByRoomie,
|
||
})
|
||
return c.body(null, 200)
|
||
}
|
||
)
|
||
|
||
// Currency balance. [Authorize]. The trailing int is a CurrencyType — the client
|
||
// fetches `/balance/2` (RecCenterTokens) on load. Backed by the `balance` table; a
|
||
// player who has never been granted gets their starting balance on this first read.
|
||
//
|
||
// An unknown or non-account-scoped currency (a room currency, ProgressionEvent,
|
||
// Invalid) returns a 0 balance rather than 404: the client treats a failed balance
|
||
// fetch as a load error, and "you have none of that" is the honest answer anyway.
|
||
.get(
|
||
'/api/storefronts/v4/balance/:currencyType',
|
||
describeRoute({
|
||
tags: ['Storefront'],
|
||
summary: 'Currency balance',
|
||
description: [
|
||
'The player’s balance in a CurrencyType (the client fetches `/balance/2`,',
|
||
'RecCenterTokens, on load). A first read seeds their starting balance. An unknown or',
|
||
'non-account currency returns a 0 balance rather than 404.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
parameters: [
|
||
{
|
||
name: 'currencyType',
|
||
in: 'path',
|
||
required: true,
|
||
description: 'CurrencyType integer; non-numeric is 400',
|
||
schema: { type: 'string' },
|
||
},
|
||
],
|
||
responses: {
|
||
200: json(BalanceEntry.array(), 'A single-entry balance array'),
|
||
400: { description: 'Non-numeric currencyType (empty body)' },
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const currencyType = Number.parseInt(c.req.param('currencyType'), 10)
|
||
if (Number.isNaN(currencyType)) return c.body(null, 400)
|
||
const amount = isSpendable(currencyType)
|
||
? await getBalance(
|
||
c.env.DB,
|
||
id,
|
||
currencyType,
|
||
intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||
)
|
||
: 0
|
||
return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }])
|
||
}
|
||
)
|
||
|
||
// Gift-drop storefront. Serves `static/storefronts/sf{id}.json` for the requested
|
||
// storefront id via the ASSETS binding; 404s when no such catalog exists. A few ids are
|
||
// stand-ins for another storefront's catalog (see `STOREFRONT_ALIASES`) — resolved through
|
||
// the same helper `buyItem` uses, so an aliased storefront can be bought from as well as
|
||
// browsed.
|
||
.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. An id',
|
||
'with no capture of its own may stand in for another storefront’s catalog (see',
|
||
'`STOREFRONT_ALIASES`, currently empty), and such an alias applies to purchases from',
|
||
'that storefront too, not just to this listing. Which FILE a storefront reads from can',
|
||
'also depend on the caller’s build (`rn.ver`): storefront `3` serves the 2023',
|
||
'`sf3.json` to builds up to 20230414 and the 2025 store dump `sf3-2025.json` to later',
|
||
'ones. The id does not change, and the same resolution applies to purchases, so what',
|
||
'is browsed is what is charged.',
|
||
].join(' '),
|
||
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')
|
||
// The same resolution `loadStorefront` uses, so what is browsed is what a purchase is
|
||
// checked against — see `storefrontAssetPath`.
|
||
const path = storefrontAssetPath(id, await authedBuild(c))
|
||
const res = await c.env.ASSETS.fetch(new URL(path, c.req.url))
|
||
if (!res.ok) return c.notFound()
|
||
return c.json(await res.json())
|
||
}
|
||
)
|
||
|
||
// Buy a storefront item. [Authorize]. The client posts the storefront/item ids, the
|
||
// currency and the price it sees; we look the item up in that storefront's catalog,
|
||
// confirm the price the client sent still matches, debit the buyer atomically, grant
|
||
// the item into the recipient's inventory, and hand back a gift box.
|
||
//
|
||
// The buyer is always the caller; a `Gift` block routes the item (and box) to another
|
||
// player, but the caller pays. Ownership is persisted at purchase — the gift box is
|
||
// only the cosmetic "open it" moment, so the grant does not wait for the box to be
|
||
// opened (see /api/avatar/v2/gifts/consume on the `api` worker, which just deletes it).
|
||
//
|
||
// `RequestedPrice` is the price the client rendered; rejecting a mismatch stops a stale
|
||
// client (or a tampered request) from buying at a price the catalog no longer offers.
|
||
.post(
|
||
'/api/storefronts/v2/buyItem',
|
||
describeRoute({
|
||
tags: ['Storefront'],
|
||
summary: 'Buy a storefront item',
|
||
description: [
|
||
'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice`',
|
||
'still matches the `Prices` entry — a Rec Room Plus subscriber (the same `rn.plus`',
|
||
'check as `UpdateAndGetSubscription`) may pay anywhere from that down to 10% off, since their',
|
||
'client applies the discount itself and not to every item — debits the buyer atomically,',
|
||
'grants the item (into the inventory or',
|
||
'consumable table), and returns a gift box. A `Gift` block routes the item — and its',
|
||
'box — to the player it names, who is handed it over the hub as',
|
||
'`GiftPackageReceivedImmediate`; the caller always pays, and `Anonymous` hides them',
|
||
'from the box rather than withholding it. `Balance` in the response is the CHANGE (negated',
|
||
'price), not the new total. Pushes a StorefrontBalancePurchase socket frame that SETS the',
|
||
'buyer’s 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, or a `Gift` naming a player that does not exist'),
|
||
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)
|
||
|
||
// A subscriber's client prices the item itself and posts the result, so the check is a
|
||
// band rather than one number — see `priceCheck`. `charge` is what they asked to pay.
|
||
const checked = priceCheck(
|
||
item,
|
||
currencyType as number,
|
||
await isSubscriber(c),
|
||
requestedPrice
|
||
)
|
||
if (checked === 'no-currency') {
|
||
return c.json({ error: 'Currency type not available for this item' }, 400)
|
||
}
|
||
if (checked === 'mismatch') {
|
||
return c.json({ error: 'Price has changed' }, 409)
|
||
}
|
||
const price = checked.charge
|
||
// 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 = giftMessage(gift)
|
||
const giftContext = Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
|
||
// A gift is paid for here and granted THERE, so an id that names nobody would take the
|
||
// buyer's tokens and strand the box on an account that will never read it. The client
|
||
// only offers players it just looked up, so this is a tampered or stale id — refuse it
|
||
// before charging rather than after.
|
||
if (receiverId !== id && (await getAccount(c.env.DB, receiverId)) === null) {
|
||
return c.json({ error: 'No such player to gift to' }, 404)
|
||
}
|
||
|
||
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, 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, {
|
||
fromPlayerId,
|
||
giftContext,
|
||
})
|
||
|
||
// The buyer reads their own box out of the response below, but a gift's receiver has
|
||
// no response to read — they may not even be online. Hand them the box the way every
|
||
// other server-handed box arrives, so it pops in front of them instead of waiting for
|
||
// their client's next `GET /api/avatar/v2/gifts`.
|
||
if (receiverId !== id) {
|
||
await pushGiftReceived(c, receiverId, granted, message, fromPlayerId, giftContext)
|
||
}
|
||
|
||
// 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, 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, giftContext)],
|
||
},
|
||
],
|
||
Balance: -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 bag’s storefront catalog (one read for the whole',
|
||
'bag) — or, for a line whose `ItemPurchaseMethodId` is a `Guid` (Type 1), against the',
|
||
'`custom_avatar_item` table, the guid being a `CustomAvatarItemId` — confirms each',
|
||
'line’s `RequestedPrice` still matches, debits the total in ONE atomic spend, grants',
|
||
'what sold, and answers the `{ Success, Error, error_id, Value }` envelope. A custom',
|
||
'item is a sale between players: its price is paid to its creator (pushed to them as',
|
||
'a balance update), ownership lands in `inventory_custom`, its gift box names it by',
|
||
'`CustomAvatarItemId`, and the entry’s `CustomAvatarItem` carries the item itself. A',
|
||
'custom line may be gifted, and fails with AlreadyOwned when its receiver owns it,',
|
||
'PlayerNotEligible when its receiver created it, and',
|
||
'NoItemAvailable for a draft, an unknown id or a currency other than RecCenterTokens.',
|
||
'`Value.Balance` is the RESULTING total (not buyItem’s 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 bag’s result, or `Success: false` if nothing sold'),
|
||
400: json(BulkPurchaseResponse, 'A request that could not be evaluated at all'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
404: json(BulkPurchaseResponse, 'A line gifts to a player that does not exist'),
|
||
},
|
||
}),
|
||
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 | 404 = 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. The file is
|
||
// the whole catalog: the ids the discovery rows hand out (`lists`) are the store's own
|
||
// `PurchasableItemId`s, so a numbered line resolves here or nowhere.
|
||
const bagCatalog = await loadStorefront(c, storefrontType as number)
|
||
// The bag may also name CUSTOM avatar items — a line whose id is a `Guid`, the item's
|
||
// `CustomAvatarItemId` — which resolve against the `custom_avatar_item` table rather than
|
||
// the catalog. One read for the items the bag names and one for which of them the buyer
|
||
// already owns, so each line still resolves in memory.
|
||
// Ownership is checked against each line's RECEIVER, so read it once per distinct
|
||
// receiver of a guid line — the buyer, plus anyone a custom line gifts to.
|
||
const guidLines = lines.flatMap((line) => {
|
||
const method = toPurchaseMethodId(line.ItemPurchaseMethodId)
|
||
return method.Type === PURCHASE_METHOD_TYPE_GUID && method.Guid !== null
|
||
? [{ guid: method.Guid.toLowerCase(), receiverId: lineReceiver(line.Gift, id) }]
|
||
: []
|
||
})
|
||
const guids = guidLines.map((line) => line.guid)
|
||
const owned = new Map<number, Set<string>>()
|
||
for (const receiverId of new Set(guidLines.map((line) => line.receiverId))) {
|
||
owned.set(receiverId, await ownedCustomAvatarItemIds(c.env.DB, receiverId, guids))
|
||
}
|
||
const custom: CustomBagContext = {
|
||
buyerId: id,
|
||
items: new Map(
|
||
(await getCustomAvatarItems(c.env.DB, guids)).map((item) => [
|
||
item.CustomAvatarItemId.toLowerCase(),
|
||
item,
|
||
])
|
||
),
|
||
owned,
|
||
}
|
||
const subscriber = await isSubscriber(c)
|
||
const resolved = lines.map((line) =>
|
||
resolveBulkLine(line, bagCatalog, currencyType as number, subscriber, custom)
|
||
)
|
||
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)
|
||
|
||
// Same as buyItem: a line gifting to an id that names nobody would charge the buyer and
|
||
// strand the box. One lookup per DISTINCT recipient, and the whole bag refuses — a bad
|
||
// recipient is a malformed request, not a line that merely didn't fit.
|
||
const recipients = new Set<number>()
|
||
for (const line of buyable) {
|
||
const to = line.gift?.ToPlayerId
|
||
if (Number.isInteger(to) && to !== id) recipients.add(to as number)
|
||
}
|
||
for (const to of recipients) {
|
||
if ((await getAccount(c.env.DB, to)) === null) {
|
||
return refuse('No such player to gift to', 404)
|
||
}
|
||
}
|
||
|
||
// 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.kind === 'catalog' && 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, for both kinds of line: 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
|
||
// Annotated: without it the inference of this handler's own type runs through the
|
||
// hub call below and back, and tsc gives up on the initializer (TS7022).
|
||
const receiverId: number = lineReceiver(gift, id)
|
||
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
|
||
const message = giftMessage(gift)
|
||
const giftContext = Number.isInteger(gift?.GiftContext)
|
||
? (gift?.GiftContext as number)
|
||
: null
|
||
if (line.kind === 'custom') {
|
||
// A custom item is a SALE between players, settled the way buyInvention settles
|
||
// one: the buyer's share of the bag was debited above, so record ownership first
|
||
// (a buyer who paid and got the item but left the creator unpaid is recoverable;
|
||
// a buyer charged for nothing is not), then pay the creator the item's price.
|
||
// The creator's signup grant is seeded 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. The creator is a
|
||
// different, probably-online player with no response to read, so the sale is
|
||
// pushed as a plain update carrying their RESULTING total (what `creditCurrency`
|
||
// returns) — sending the payout would set their whole balance to it.
|
||
await grantCustomAvatarItem(c.env.DB, receiverId, line.custom.CustomAvatarItemId)
|
||
if (line.price > 0) {
|
||
const creatorId = line.custom.CreatorAccountId
|
||
await ensureStartingBalances(c.env.DB, creatorId, startingTokens)
|
||
const creatorBalance = await creditCurrency(
|
||
c.env.DB,
|
||
creatorId,
|
||
currencyType as number,
|
||
line.price,
|
||
startingTokens
|
||
)
|
||
await pushBalanceUpdate(c, creatorId, currencyType as number, creatorBalance)
|
||
}
|
||
// Wrapped like any purchase: a box naming the item by `CustomAvatarItemId`, which
|
||
// the client opens to show what was bought — in the receiver's gifts, announced to
|
||
// them when that is someone else, as a catalog line's is. Ownership was granted
|
||
// above, so the box is cosmetic and `BypassGiftPackages` skips it exactly as it
|
||
// does a catalog line's. The entry below also carries the item record itself.
|
||
if (skipGiftBox) {
|
||
packages.set(line, null)
|
||
continue
|
||
}
|
||
const content = toCustomGiftContent(line.custom, message, fromPlayerId, giftContext)
|
||
const box = await createGift(c.env.DB, receiverId, content)
|
||
if (receiverId !== id) {
|
||
await pushCustomGiftReceived(c, receiverId, box.id, content)
|
||
}
|
||
packages.set(line, toCustomGiftPackage(box.id, receiverId, content))
|
||
continue
|
||
}
|
||
// 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,
|
||
fromPlayerId,
|
||
giftContext,
|
||
})
|
||
// The bag's own response carries only the buyer's boxes, so a gifted line is
|
||
// announced to its receiver the same way buyItem's is. `BypassGiftPackages` skipped
|
||
// the box entirely, and there is nothing to announce.
|
||
if (receiverId !== id && !skipGiftBox) {
|
||
await pushGiftReceived(c, receiverId, granted, message, fromPlayerId, giftContext)
|
||
}
|
||
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, giftContext)
|
||
)
|
||
}
|
||
|
||
// 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,
|
||
}
|
||
}
|
||
const sold = bought.has(line)
|
||
return {
|
||
UpdateResponse: sold ? UpdateResponse.OK : UpdateResponse.NotEnoughCredit,
|
||
Data: {
|
||
GiftPackage: packages.get(line) ?? null,
|
||
PurchasableItemId: line.method.NumberId,
|
||
CustomAvatarItem: sold && line.kind === 'custom' ? line.custom : 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 2023 client sends
|
||
// `?inventionId=…&requestedPrice=…` with no body, so that’s what we answer, in the v6 save
|
||
// envelope that build reads. The 2025 build posts to `v3/buyInvention` below and wants a
|
||
// different envelope back; the two share {@link settleInventionPurchase}, which is where
|
||
// the money and the rules live, and build their own bodies from what it returns.
|
||
.get(
|
||
'/api/storefronts/v2/buyInvention',
|
||
describeRoute({
|
||
tags: ['Storefront'],
|
||
summary: 'Buy an invention',
|
||
description: [
|
||
'Looks the invention up by id, confirms the client’s `requestedPrice` still matches',
|
||
'its stored `Price`, debits the buyer and pays the creator that price in',
|
||
'RecCenterTokens (a free invention moves nothing), records ownership in',
|
||
'`inventory_invention`, and returns the invention alongside the buyer’s resulting',
|
||
'balance. When tokens moved, both players get a 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 settled = await settleInventionPurchase(c, id, inventionId, requestedPrice)
|
||
if (settled instanceof Response) return settled
|
||
|
||
return c.json({
|
||
BalanceUpdateResponse: {
|
||
Balance: settled.balance,
|
||
BalanceType: ALL_PLATFORMS,
|
||
CurrencyType: CurrencyType.RecCenterTokens,
|
||
BalanceUpdates: [{ UpdateResponse: 0, Data: settled.invention }],
|
||
},
|
||
// The bare `{ Status, Invention, InventionVersion }` the v6 save serves — this
|
||
// build's invention endpoints answer in it, and the client re-renders from it.
|
||
InventionResponse: toSaveResult(settled.invention),
|
||
})
|
||
}
|
||
)
|
||
|
||
// Buy an invention, the way the 2025 client asks for it. [Authorize]. A POST carrying
|
||
// `{ InventionId, RequestedPrice }` as JSON, where the v2 GET takes query params.
|
||
//
|
||
// The PURCHASE is identical — both settle through `settleInventionPurchase` — but the
|
||
// RESPONSE is not, and that is the whole reason this route exists rather than an alias:
|
||
// this build wraps the invention in the v9 save envelope and names its balance bucket
|
||
// `Platform`. See `BuyInventionV3Response`. Both routes stay served: the 2023 build still
|
||
// sends the GET, and it would not parse this body.
|
||
.post(
|
||
'/api/storefronts/v3/buyInvention',
|
||
describeRoute({
|
||
tags: ['Storefront'],
|
||
summary: 'Buy an invention (JSON body)',
|
||
description: [
|
||
'The same purchase as `GET /api/storefronts/v2/buyInvention` — confirms the client’s',
|
||
'`RequestedPrice` still matches the invention’s stored `Price`, debits the buyer and',
|
||
'pays the creator that price in RecCenterTokens (a free invention moves nothing),',
|
||
'records ownership in `inventory_invention`, and pushes both players a socket frame',
|
||
'carrying their RESULTING total — but answered in a DIFFERENT envelope, which is why',
|
||
'the route exists at all: `InventionResponse` is the v9 save’s',
|
||
'`{ Value, Success, Error, error_id }` (its `InventionVersion` and `TagsResponse` null,',
|
||
'since a buy mints neither) and the balance half names its bucket `Platform`, not v2’s',
|
||
'`BalanceType`.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(BuyInventionRequest, 'The invention id and the price rendered'),
|
||
responses: {
|
||
200: json(BuyInventionV3Response, 'The purchase result (invention + balance)'),
|
||
400: json(
|
||
ErrorResponse,
|
||
'Invalid body, missing 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 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 inventionId = body.InventionId
|
||
if (!Number.isInteger(inventionId)) {
|
||
return c.json({ error: 'InventionId is required' }, 400)
|
||
}
|
||
// Read the same way as v2’s query param: an absent or non-integer RequestedPrice is 0,
|
||
// which only matches a free invention — a priced one then fails the confirmation rather
|
||
// than selling for nothing.
|
||
const requestedPrice = Number.isInteger(body.RequestedPrice)
|
||
? (body.RequestedPrice as number)
|
||
: 0
|
||
|
||
const settled = await settleInventionPurchase(c, id, inventionId as number, requestedPrice)
|
||
if (settled instanceof Response) return settled
|
||
|
||
return c.json({
|
||
// The v9 SAVE envelope, not v6's bare `{ Status, Invention, InventionVersion }`:
|
||
// `Value` under `{ Success, Error, error_id }`, with `Invention` the client's 28-key
|
||
// `RRInvention`. A buy mints no version and takes no tags, so both of those keys are
|
||
// present and NULL — which is safe here for the same reason it is on the save: the
|
||
// client reads `Success` and `Value.Invention` and nothing else. `Value` itself must
|
||
// never be null under `Success: true` — that dereference is what crashes it.
|
||
InventionResponse: {
|
||
Value: {
|
||
Status: 0,
|
||
Invention: toInventionV9(settled.invention),
|
||
InventionVersion: null,
|
||
TagsResponse: null,
|
||
},
|
||
Success: true,
|
||
Error: null,
|
||
error_id: null,
|
||
},
|
||
// `BalanceResponseDTO`, the same one the bulk purchase answers in — so the bucket key
|
||
// is `Platform`, NOT the `BalanceType` the v2 body sends. The client's member IS named
|
||
// `BalanceType`, but it carries a [DataMember] rename to `Platform` and its decoder
|
||
// drops what it doesn't know, so spelling it `BalanceType` here would land this balance
|
||
// in bucket 0 beside the real one. `Balance` is the RESULTING total, as in v2.
|
||
BalanceUpdateResponse: {
|
||
BalanceUpdates: [{ UpdateResponse: 0, Data: toInventionV9(settled.invention) }],
|
||
Balance: settled.balance,
|
||
CurrencyType: CurrencyType.RecCenterTokens,
|
||
// The capture says 0 (SteamPurchased) because the reference server kept a wallet per
|
||
// platform. This one keeps ONE bucket and the client SUMS them, so naming 0 here
|
||
// while every socket frame names -2 is exactly the phantom second balance that
|
||
// doubled players' tokens twice before. -2, like every other surface.
|
||
Platform: ALL_PLATFORMS,
|
||
},
|
||
})
|
||
}
|
||
)
|
||
|
||
// Storefront ad-carousel items. Served from the bundled static JSON — one
|
||
// placeholder banner with no purchasable items until real promo data exists.
|
||
.get(
|
||
'/api/storefronts/v1/adcarouselitems',
|
||
listRoute('Storefront ad-carousel items', 'The bundled carousel (one placeholder banner)'),
|
||
(c) => c.json(adCarouselItems)
|
||
)
|
||
|
||
// Current weekly challenge. The rotation is GENERATED from the calendar week (see
|
||
// challenge-rotation.ts — the same five challenges, window and gift for everyone, derived
|
||
// from the week index; static/weekly-challenge.json pins it instead when it carries
|
||
// challenges), but each challenge's state is per-player, so the caller's rows from
|
||
// `challenge_status` are stamped over the week's: `Complete` over the published `false`,
|
||
// and `Config` over the published rule tree — the client evaluates that tree locally and
|
||
// reports it back with its running counts written into it (`cc`/`c`), so serving the
|
||
// pristine tree back is what makes partial progress reset every session.
|
||
// Auth is OPTIONAL: without a valid bearer the week is served unstamped 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: [
|
||
'This week’s rotation — generated from the calendar week — with each challenge’s',
|
||
'`Complete` and `Config` stamped from the caller’s progress rows, the stored `Config`',
|
||
'carrying the client’s running counts. Auth is optional: unauthenticated callers get',
|
||
'the week unstamped, every `Complete` false and every `Config` as published.',
|
||
].join(' '),
|
||
security: OPTIONAL_AUTHED,
|
||
responses: { 200: json(JsonObject, 'The current weekly challenge') },
|
||
}),
|
||
async (c) => {
|
||
const rotation = withWeeklyGift(buildRotation(new Date()), await loadEquipmentGiftPool(c))
|
||
const id = await authedId(c)
|
||
if (id === null) return c.json(rotation)
|
||
const statuses = await getChallengeStatuses(c.env.DB, id, rotation.ChallengeMapId)
|
||
if (statuses.size === 0) return c.json(rotation)
|
||
// Rebuild rather than mutate: the generated rotation is cached module state shared
|
||
// by every request this isolate serves, so stamping it in place would leak one
|
||
// player's progress to the next caller.
|
||
return c.json({
|
||
...rotation,
|
||
Challenges: rotation.Challenges.map((challenge) => {
|
||
const status = statuses.get(challenge.ChallengeId)
|
||
if (status === undefined) return challenge
|
||
// A row with no stored tree (never reported one) keeps the authored `Config`;
|
||
// overwriting it with null would hand the client a challenge it can't evaluate.
|
||
return {
|
||
...challenge,
|
||
Complete: status.complete,
|
||
Config: status.config ?? challenge.Config,
|
||
}
|
||
}),
|
||
})
|
||
}
|
||
)
|
||
|
||
// 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`. Both are persisted
|
||
// (keyed by account + challenge): the posted tree is the catalog's definition with the
|
||
// client's running counts written into it, so it is this player's progress, and
|
||
// `getCurrent` serves it back in place of the authored tree. Echoes the identifying
|
||
// fields back with the state the row now holds — which is not always what was posted,
|
||
// since completion latches within a rotation and a report with no `Config` keeps the
|
||
// stored tree.
|
||
.post(
|
||
'/api/challenge/v2/updateProgress',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Report weekly-challenge progress',
|
||
description: [
|
||
'Persists the reported completion and rule tree into `challenge_status`, keyed by',
|
||
'account + challenge, so `getCurrent` can serve the player’s own progress back.',
|
||
'Completion latches within a rotation and a report carrying no `Config` keeps the',
|
||
'stored tree, so the echoed fields are the stored values, not the posted ones.',
|
||
].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
|
||
const config = typeof body.Config === 'string' ? body.Config : null
|
||
// Nothing to key a row on — echo the body back rather than writing a (0, 0) row.
|
||
const stored =
|
||
challengeId === 0
|
||
? { complete: parseBool(body.Complete), config }
|
||
: await recordChallengeProgress(c.env.DB, id, {
|
||
challengeMapId,
|
||
challengeId,
|
||
complete: parseBool(body.Complete),
|
||
config,
|
||
})
|
||
// 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 (stored.complete && challengeId !== 0 && challengeMapId === rotationMapId(new Date())) {
|
||
await awardChallengeGift(c, id)
|
||
}
|
||
return c.json({
|
||
ChallengeMapId: challengeMapId,
|
||
ChallengeId: challengeId,
|
||
Config: stored.config ?? '',
|
||
Complete: stored.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 —
|
||
// except FirstActivityOfDay, one per player per 24h.
|
||
//
|
||
// A claim pays GAME_REWARD_XP into `progression` (up to GAME_REWARD_DAILY_XP_CAP per player
|
||
// per rolling 24h, FirstActivityOfDay's on top) 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, 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 `''` — as FirstActivityOfDay always
|
||
// does, being the player's first activity of the day, not each activity's.
|
||
//
|
||
// It also picks the PRIZE: a context that is a key of `static/quest-rewards.json`
|
||
// (`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity's rewards — an avatar item
|
||
// granted into the inventory, or Laser Tag's ticket payout — and the box carries it, with
|
||
// the activity's own `GiftContext`. A context the table doesn't know gets the XP-only box.
|
||
//
|
||
// A `PostGameActivity`/`PostQuestActivity` claim also pays GAME_REWARD_TOKENS, up to
|
||
// GAME_REWARD_DAILY_TOKEN_CAP per player per rolling 24h across every activity, and a
|
||
// FirstActivityOfDay claim pays them on top of the cap — credited to the balance and
|
||
// delivered in a second box of its own so the player sees what the tokens were for.
|
||
.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. A `giftContext` that names an activity in `quest-rewards.json`',
|
||
'(`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity’s rewards and grants it;',
|
||
'any other claim pays XP only. A `PostGameActivity` or `PostQuestActivity` claim also',
|
||
'pays 250 tokens, in a box of its own, up to 1250 per player per rolling 24 hours',
|
||
'whatever the activity. `FirstActivityOfDay` is once per player per 24 hours, whatever',
|
||
'the `giftContext`, and pays 250 tokens outside that cap. 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 : ''
|
||
// FirstActivityOfDay is the player's, not the activity's: claimed on the empty context
|
||
// and a day's cooldown. Its prize is still picked from the activity that asked.
|
||
const daily = rewardType === FIRST_ACTIVITY_OF_DAY
|
||
const claimed = daily
|
||
? await claimReward(c.env.DB, id, rewardType, '', new Date(), DAILY_REWARD_COOLDOWN_MS)
|
||
: 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
|
||
// The XP draws on the player's daily allowance — FirstActivityOfDay's is a bonus on top,
|
||
// its once-a-day claim being limit enough. Past the cap the claim pays no XP.
|
||
const xpToday = daily
|
||
? null
|
||
: await claimAllowance(c.env.DB, id, '$xp', GAME_REWARD_XP, GAME_REWARD_DAILY_XP_CAP)
|
||
const xp = daily || xpToday !== null ? GAME_REWARD_XP : 0
|
||
// 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 gained = xp > 0 ? await addXp(c.env.DB, id, xp) : null
|
||
// An activity the reward table knows pays one of ITS rewards the player lacks — the
|
||
// item rides in the box and is granted with it. Anything else gets the plain XP box.
|
||
const questReward = await pickQuestReward(c.env.DB, id, giftContext)
|
||
const itemKey = questReward?.AvatarItemDesc || questReward?.EquipmentModificationGuid
|
||
const drop =
|
||
questReward === null
|
||
? toGameRewardDrop(xp)
|
||
: toQuestRewardDrop(
|
||
questReward,
|
||
itemKey ? await getCatalogItem(c.env.DB, itemKey) : null,
|
||
xp
|
||
)
|
||
// A currency reward (Laser Tag's tickets) is credited here: `grantGiftDrop` grants
|
||
// items, not balances. Seed the signup grant first — `creditCurrency` upserts the
|
||
// row, and a never-touched RecCenterTokens balance would otherwise lose it.
|
||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||
if (drop.Currency > 0 && drop.CurrencyType !== CurrencyType.Invalid) {
|
||
await ensureStartingBalances(c.env.DB, id, startingTokens)
|
||
const balance = await creditCurrency(
|
||
c.env.DB,
|
||
id,
|
||
drop.CurrencyType,
|
||
drop.Currency,
|
||
startingTokens
|
||
)
|
||
await pushBalanceUpdate(c, id, drop.CurrencyType, balance)
|
||
}
|
||
// A box with nothing in it — the XP capped and no item or currency to carry — is not
|
||
// handed over at all.
|
||
let rewardGiftId: number | null = null
|
||
if (questReward !== null || xp > 0) {
|
||
const granted = await grantGiftDrop(c, id, drop, message)
|
||
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
|
||
rewardGiftId = granted.id
|
||
}
|
||
// The tokens ride in a second box — after the reward's own, so the player sees the
|
||
// reward they asked for first — while the player's daily allowance has room. Same
|
||
// order as the currency reward above: balance first, then the box that shows it.
|
||
let tokensGiftId: number | null = null
|
||
let tokensToday: number | null = null
|
||
if (TOKEN_REWARD_TYPES.has(rewardType)) {
|
||
tokensToday = await claimAllowance(
|
||
c.env.DB,
|
||
id,
|
||
'$tokens',
|
||
GAME_REWARD_TOKENS,
|
||
GAME_REWARD_DAILY_TOKEN_CAP
|
||
)
|
||
}
|
||
// FirstActivityOfDay's claim already held it to once a day: it pays without the cap.
|
||
if (daily || tokensToday !== null) {
|
||
const tokensDrop = toGameRewardTokensDrop()
|
||
await ensureStartingBalances(c.env.DB, id, startingTokens)
|
||
const tokenBalance = await creditCurrency(
|
||
c.env.DB,
|
||
id,
|
||
tokensDrop.CurrencyType,
|
||
tokensDrop.Currency,
|
||
startingTokens
|
||
)
|
||
await pushBalanceUpdate(c, id, tokensDrop.CurrencyType, tokenBalance)
|
||
const tokensGift = await grantGiftDrop(c, id, tokensDrop, GAME_REWARD_TOKENS_MESSAGE)
|
||
await pushGiftReceived(c, id, tokensGift, GAME_REWARD_TOKENS_MESSAGE, COACH_ACCOUNT_ID)
|
||
tokensGiftId = tokensGift.id
|
||
}
|
||
if (gained !== null) {
|
||
// Every grant moves the bar, whether or not it crossed a level.
|
||
await pushProgressionUpdate(c, id, gained.progression)
|
||
// …and every level crossed is worth a box of its own tier.
|
||
await grantLevelUpGifts(c, id, gained)
|
||
}
|
||
logger.info('game reward claimed', {
|
||
accountId: id,
|
||
rewardType,
|
||
giftContext,
|
||
grantCount: claimed,
|
||
message,
|
||
xp,
|
||
xpToday,
|
||
level: gained?.progression.Level,
|
||
levelsGained: gained?.levelsGained,
|
||
levelXp: gained?.progression.XP,
|
||
giftId: rewardGiftId,
|
||
tokens: tokensGiftId === null ? 0 : GAME_REWARD_TOKENS,
|
||
tokensToday,
|
||
tokensGiftId,
|
||
})
|
||
return c.json([])
|
||
}
|
||
)
|
||
|
||
// The player's room keys. Returns "[]": nothing sells a key yet, so nobody holds one.
|
||
.get('/api/roomkeys/v1/mine', listRoute('The player’s room keys', 'Empty for now'), (c) =>
|
||
c.json([])
|
||
)
|
||
// The keys a room has listed. Public, like the room's currencies: a key is offered to
|
||
// whoever walks up to the door it opens. A room with none, an unknown room and a missing
|
||
// `roomId` are all the same empty list.
|
||
.get(
|
||
'/api/roomkeys/v1/room',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'A room’s keys',
|
||
description: [
|
||
'Every key the room named by `roomId` has listed, oldest first.',
|
||
'',
|
||
'Public, like `GET /api/roomcurrencies/v1/currencies`. A room with none, an unknown',
|
||
'room and a missing `roomId` are all the same empty list.',
|
||
].join('\n'),
|
||
parameters: [
|
||
{
|
||
name: 'roomId',
|
||
in: 'query',
|
||
required: false,
|
||
description: 'The room whose keys to list',
|
||
schema: { type: 'string' },
|
||
},
|
||
],
|
||
responses: { 200: json(RoomKeyDto.array(), 'The room’s keys, oldest first') },
|
||
}),
|
||
async (c) => {
|
||
const roomId = Number.parseInt(c.req.query('roomId') ?? '', 10)
|
||
if (!Number.isInteger(roomId)) return c.json([])
|
||
return c.json(await getRoomKeys(c.env.DB, roomId))
|
||
}
|
||
)
|
||
|
||
// List a key for a room. Auth-gated (401) and gated to the room's creator or a co-owner
|
||
// (403) — the same owner-level check the room-currency writes apply.
|
||
.post(
|
||
'/api/roomkeys/v1/create',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Create a room key',
|
||
description: [
|
||
'Lists a key for one room — its name, its description and its price. Form-encoded',
|
||
'(`Type=Key&RoomId=1162&Name=my%20key&Description=…&Price=50`).',
|
||
'',
|
||
'Gated to the room’s CREATOR or a CO-OWNER, like minting a currency. A valid token',
|
||
'from anyone else is a 403.',
|
||
'',
|
||
'`Name` and `Description` are masked by the same word list as every other string a',
|
||
'player types. A room may list at most 10 keys — the `RoomKeyConfig.MaxKeysPerRoom`',
|
||
'the `api` config tells the client — and the eleventh is refused. A new key is priced',
|
||
'in tokens (the body names no currency), so a `Price` over 1000 is refused too.',
|
||
'',
|
||
'Answers `{ Status, RoomKey }` — `Status` 0 and the created key, as the live client',
|
||
'reads it — not the `{ Value, Success, Error, error_id }` envelope the room-currency',
|
||
'writes use. A recoverable refusal (an unusable `RoomId`, an empty `Name`, an unknown',
|
||
'room, a full room, a `Price` over the limit) is a 200 carrying a non-zero `Status` and a null `RoomKey`; only',
|
||
'the auth gates answer with an HTTP status of their own.',
|
||
'',
|
||
'Pushes `LocalRoomKeyCreated` (120), carrying the same object, to everyone in the',
|
||
'room and to the caller.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: form(CreateRoomKeyRequest, 'The key to list'),
|
||
responses: {
|
||
200: json(RoomKeyEnvelope, 'The key as created, or a refusal with a non-zero `Status`'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const accountId = await authedId(c)
|
||
if (accountId === null) return unauthorized(c)
|
||
|
||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||
const int = (v: unknown, fallback: number): number => {
|
||
const parsed = Number.parseInt(str(v), 10)
|
||
return Number.isInteger(parsed) ? parsed : fallback
|
||
}
|
||
|
||
const roomId = int(body.RoomId, Number.NaN)
|
||
if (!Number.isInteger(roomId)) return roomKeyEnvelope(c, null)
|
||
const name = str(body.Name).trim()
|
||
if (name === '') return roomKeyEnvelope(c, null)
|
||
// A key cannot cost less than nothing — nor, being priced in tokens until an edit
|
||
// says otherwise, more than the token cap.
|
||
const price = Math.max(0, int(body.Price, 0))
|
||
if (price > MAX_KEY_PRICE_TOKENS) return roomKeyEnvelope(c, null)
|
||
|
||
const canManage = await canManageRoomById(c.env.DB, roomId, accountId)
|
||
if (canManage === null) return roomKeyEnvelope(c, null)
|
||
if (!canManage) return c.body(null, 403)
|
||
|
||
if ((await getRoomKeys(c.env.DB, roomId)).length >= MAX_KEYS_PER_ROOM) {
|
||
return roomKeyEnvelope(c, null)
|
||
}
|
||
|
||
const key = await createRoomKey(c.env.DB, {
|
||
RoomId: roomId,
|
||
Type: str(body.Type).trim() || 'Key',
|
||
Name: censorSwears(name),
|
||
Description: censorSwears(str(body.Description)),
|
||
Price: price,
|
||
})
|
||
|
||
await pushRoomKeyCreated(c, key, accountId)
|
||
return roomKeyEnvelope(c, key)
|
||
}
|
||
)
|
||
|
||
// Buy a room key with tokens. A GET with query params — that is how the client sends it.
|
||
// The key's price leaves the buyer and is paid to the room's owner; holding the key is a
|
||
// `room_key_player` row. Every refusal is a 200 in the same shape, told apart by the
|
||
// entry's `UpdateResponse` — only a missing token answers with a status of its own.
|
||
.get(
|
||
'/api/storefronts/v1/buyRoomKey',
|
||
describeRoute({
|
||
tags: ['Storefront'],
|
||
summary: 'Buy a room key',
|
||
description: [
|
||
'Looks the key up by `RoomKeyId`, confirms the client’s `RequestedPrice` matches its',
|
||
'stored `Price`, debits the buyer that price in RecCenterTokens, pays it to the',
|
||
'room’s OWNER (its `CreatorAccountId`), and records the buyer as holding the key',
|
||
'(`room_key_player`). A free key moves no tokens, and nor does a key bought by the',
|
||
'room’s creator or a CO-OWNER: they get their own room’s keys free, and the client',
|
||
'asks them `RequestedPrice=0` (the stored price is accepted too). A GET because that',
|
||
'is how the client sends it.',
|
||
'',
|
||
'Answers the client’s `RoomKeyPurchaseResponseDTO`: `RoomKeyResponse` is the',
|
||
'`{ Status, RoomKey }` the create answers in, and `BalanceUpdateResponse` carries one',
|
||
'entry (`UpdateResponse`, `Data` = the key) and the buyer’s RESULTING `Balance`.',
|
||
'`Platform` is -2, the one bucket every balance surface here names — any other value',
|
||
'shows the buyer a phantom second balance.',
|
||
'',
|
||
'A refusal is a 200 in the same shape — `Status` 1, a null `RoomKey`, the balance',
|
||
'untouched — with the reason in the entry’s `UpdateResponse`: 6 the price does not',
|
||
'match, 3 already held, 2 not enough tokens, 4 no such key (or no such room, or a key',
|
||
'priced in a ROOM CURRENCY, which this token route does not sell).',
|
||
'',
|
||
'When tokens moved, both players get a socket push carrying their resulting total —',
|
||
'the buyer a StorefrontBalancePurchase, the owner a StorefrontBalanceUpdate.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
parameters: [
|
||
{
|
||
name: 'RoomKeyId',
|
||
in: 'query',
|
||
required: true,
|
||
description: 'The key to buy — a `room_key` id',
|
||
schema: { type: 'integer' },
|
||
},
|
||
{
|
||
name: 'RequestedPrice',
|
||
in: 'query',
|
||
required: false,
|
||
description: 'The price the client rendered; must equal the key’s. Defaults to 0',
|
||
schema: { type: 'integer' },
|
||
},
|
||
],
|
||
responses: {
|
||
200: json(BuyRoomKeyResponse, 'The purchase result, or a refusal in the same shape'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
|
||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||
const currencyType = CurrencyType.RecCenterTokens
|
||
const roomKeyId = Number.parseInt(c.req.query('RoomKeyId') ?? '', 10)
|
||
// Absent/non-numeric reads as 0, which only matches a free key — 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 key = Number.isInteger(roomKeyId) ? await getRoomKey(c.env.DB, roomKeyId) : null
|
||
|
||
// Every key is always sent: the client reads an omitted one as its enum's 0 member,
|
||
// which is success for `Status`/`UpdateResponse` and SteamPurchased for `Platform`.
|
||
const answer = async (code: number) =>
|
||
c.json({
|
||
RoomKeyResponse: {
|
||
Status: code === UpdateResponse.OK ? ROOM_KEY_STATUS_OK : ROOM_KEY_STATUS_FAILED,
|
||
RoomKey: code === UpdateResponse.OK ? key : null,
|
||
},
|
||
BalanceUpdateResponse: {
|
||
BalanceUpdates: [{ UpdateResponse: code, Data: key }],
|
||
Balance: await getBalance(c.env.DB, id, currencyType, startingTokens),
|
||
CurrencyType: currencyType,
|
||
Platform: ALL_PLATFORMS,
|
||
},
|
||
})
|
||
|
||
// A key priced in a room currency is not for sale in tokens.
|
||
if (key === null || key.PurchaseCurrencyId !== null) {
|
||
return answer(UpdateResponse.NoItemAvailable)
|
||
}
|
||
const ownerId = await getRoomOwnerId(c.env.DB, key.RoomId)
|
||
if (ownerId === null) return answer(UpdateResponse.NoItemAvailable)
|
||
// The room's creator and co-owners get their own room's keys free — the client asks
|
||
// them `RequestedPrice=0` — and are never charged, whatever price they name.
|
||
const canManage = (await canManageRoomById(c.env.DB, key.RoomId, id)) === true
|
||
if (requestedPrice !== key.Price && !(canManage && requestedPrice === 0)) {
|
||
return answer(UpdateResponse.RequestedPriceDoesNotMatch)
|
||
}
|
||
|
||
// Claim the key BEFORE charging: the insert is atomic, so of two concurrent buys only
|
||
// one gets past here and only one is charged. A buyer who cannot pay gives it back.
|
||
if (!(await claimRoomKey(c.env.DB, id, key.RoomKeyId))) {
|
||
return answer(UpdateResponse.AlreadyOwned)
|
||
}
|
||
if (key.Price > 0 && !canManage) {
|
||
if (!(await spendCurrency(c.env.DB, id, currencyType, key.Price, startingTokens))) {
|
||
await releaseRoomKey(c.env.DB, id, key.RoomKeyId)
|
||
return answer(UpdateResponse.NotEnoughCredit)
|
||
}
|
||
// Seed the owner's signup grant BEFORE crediting them — `creditCurrency` upserts
|
||
// the row, which would otherwise cost a never-seen owner their starting tokens.
|
||
await ensureStartingBalances(c.env.DB, ownerId, startingTokens)
|
||
const ownerBalance = await creditCurrency(
|
||
c.env.DB,
|
||
ownerId,
|
||
currencyType,
|
||
key.Price,
|
||
startingTokens
|
||
)
|
||
// Both frames carry a RESULTING total — see the frame rule above pushBalanceUpdate.
|
||
await pushBalanceUpdate(c, ownerId, currencyType, ownerBalance)
|
||
await pushBalancePurchase(
|
||
c,
|
||
id,
|
||
currencyType,
|
||
-key.Price,
|
||
await getBalance(c.env.DB, id, currencyType, startingTokens)
|
||
)
|
||
}
|
||
|
||
return answer(UpdateResponse.OK)
|
||
}
|
||
)
|
||
|
||
// Buy a room key with the room currency it is priced in — buyRoomKey's mirror image, and
|
||
// almost nothing shared on the wire: a POST with a form body, a bare `{ Balance,
|
||
// RoomKeyResponse }`, and a `Balance` that is an OBJECT. Nobody is paid: room currency is
|
||
// the room's own play money, so the price simply leaves the buyer.
|
||
.post(
|
||
'/api/storefronts/v1/PurchaseRoomKeyWithCurrency',
|
||
describeRoute({
|
||
tags: ['Storefront'],
|
||
summary: 'Buy a room key with room currency',
|
||
description: [
|
||
'Buys a key priced in a room currency. Form-encoded',
|
||
'(`RoomKeyId=43&RequestedPrice=1000&RequestedPurchaseCurrencyId=b9f41a7c-…`): the',
|
||
'key’s `Price` is subtracted from what the caller holds of that currency',
|
||
'(`room_balance`) and they are recorded as holding the key (`room_key_player`).',
|
||
'NOBODY IS PAID — unlike the token sale, the price does not reach the room’s owner.',
|
||
'',
|
||
'`RequestedPurchaseCurrencyId` must be the key’s own `PurchaseCurrencyId` and',
|
||
'`RequestedPrice` its `Price`. A key priced in TOKENS (a null `PurchaseCurrencyId`)',
|
||
'is not sold here — that is `GET /api/storefronts/v1/buyRoomKey`.',
|
||
'',
|
||
'Answers the client’s `RoomKeyPurchaseWithCurrencyResponseDTO`, a bare object with no',
|
||
'envelope: `Balance` is an OBJECT — `{ AccountId, CurrencyId, Balance, ModifiedAt }`,',
|
||
'the buyer’s RESULTING holding — and `RoomKeyResponse` the `{ Status, RoomKey }` the',
|
||
'create answers in. Not buyRoomKey’s shape; there is no `Platform` here.',
|
||
'',
|
||
'A refusal (no such key, a currency or price that is not the key’s, a key already',
|
||
'held, too little of the currency) is a 200 in the same shape: `Status` 1, a null',
|
||
'`RoomKey`, and the holding as it stands. That form is an assumption — only success',
|
||
'has been observed.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: form(PurchaseRoomKeyWithCurrencyRequest, 'The key, and what the client saw'),
|
||
responses: {
|
||
200: json(
|
||
PurchaseRoomKeyWithCurrencyResponse,
|
||
'The purchase result, or a refusal in the same shape'
|
||
),
|
||
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 str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
|
||
const int = (v: unknown): number => (/^-?\d+$/.test(str(v)) ? Number(str(v)) : Number.NaN)
|
||
|
||
const requestedCurrencyId = str(body.RequestedPurchaseCurrencyId)
|
||
const roomKeyId = int(body.RoomKeyId)
|
||
const key = Number.isInteger(roomKeyId) ? await getRoomKey(c.env.DB, roomKeyId) : null
|
||
// The currency the answer reports on: the key's own once it is known to be the one
|
||
// asked for, the requested one on a refusal that never got that far.
|
||
const currencyId = key?.PurchaseCurrencyId ?? requestedCurrencyId
|
||
|
||
// `Status` is always sent: the client reads an omitted one as 0, which is success.
|
||
const answer = async (sold: boolean) =>
|
||
c.json({
|
||
Balance: {
|
||
AccountId: id,
|
||
CurrencyId: currencyId,
|
||
Balance: await getRoomBalance(c.env.DB, currencyId, id),
|
||
ModifiedAt: new Date().toISOString(),
|
||
},
|
||
RoomKeyResponse: {
|
||
Status: sold ? ROOM_KEY_STATUS_OK : ROOM_KEY_STATUS_FAILED,
|
||
RoomKey: sold ? key : null,
|
||
},
|
||
})
|
||
|
||
// A key priced in tokens is buyRoomKey's to sell, and a key is paid for in its own
|
||
// currency only — never whichever one the request would rather spend.
|
||
if (key === null || key.PurchaseCurrencyId === null) return answer(false)
|
||
if (requestedCurrencyId.toLowerCase() !== key.PurchaseCurrencyId.toLowerCase()) {
|
||
return answer(false)
|
||
}
|
||
if (int(body.RequestedPrice) !== key.Price) return answer(false)
|
||
|
||
// Claim the key BEFORE charging, as the token sale does: the insert is atomic, so of
|
||
// two concurrent buys only one is charged. A buyer who cannot pay gives it back.
|
||
if (!(await claimRoomKey(c.env.DB, id, key.RoomKeyId))) return answer(false)
|
||
if (
|
||
key.Price > 0 &&
|
||
!(await spendRoomCurrency(c.env.DB, key.PurchaseCurrencyId, id, key.Price))
|
||
) {
|
||
await releaseRoomKey(c.env.DB, id, key.RoomKeyId)
|
||
return answer(false)
|
||
}
|
||
|
||
return answer(true)
|
||
}
|
||
)
|
||
|
||
// Edit a room key. "All" is the key's FIELDS, not the room's keys: the body names one
|
||
// `RoomKeyId` and carries everything editable about it. Gated like the create — the room
|
||
// comes off the KEY, never the request.
|
||
.put(
|
||
'/api/roomkeys/v1/updateAll',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Update a room key',
|
||
description: [
|
||
'Edits ONE key — despite the name, "all" is its fields — form-encoded',
|
||
'(`RoomKeyId=43&Name=key1&Description=…&Price=1000&PurchaseCurrencyId=`).',
|
||
'',
|
||
'Gated to the CREATOR or a CO-OWNER of the room that lists the key; a valid token',
|
||
'from anyone else is a 403.',
|
||
'',
|
||
'`PurchaseCurrencyId` sent EMPTY prices the key in tokens (stored and served as',
|
||
'null) — that is how a key on a room currency is taken back off it; otherwise it',
|
||
'must be a `room_currency` id belonging to the key’s own room.',
|
||
'`ImageName` is the key’s art, stored as posted; sent EMPTY it clears the art',
|
||
'(served as null). That `ImageName` rides in this form is an ASSUMPTION.',
|
||
'`Price` is capped by what the key ends up priced in: 1000 in tokens, 1000000000 in',
|
||
'a room currency. A field left out of the body is left alone rather than reset, and',
|
||
'the cap is checked against the resulting pair.',
|
||
'',
|
||
'`Name` and `Description` are masked by the same word list as every other string a',
|
||
'player types. What can never change: the ids, the room, the `Type`, `CreatedAt`.',
|
||
'',
|
||
'Answers the create’s `{ Status, RoomKey }` envelope with the key as it now stands',
|
||
'— an ASSUMPTION: what the client reads from this response has not been observed.',
|
||
'A recoverable refusal (no such key, a blank `Name`, a `Price` over the cap, an',
|
||
'unknown or foreign currency) is a 200 carrying a non-zero `Status` and a null',
|
||
'`RoomKey`.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: form(UpdateRoomKeyRequest, 'The key and its fields'),
|
||
responses: {
|
||
200: json(RoomKeyEnvelope, 'The key as it now stands, or a refusal'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const accountId = await authedId(c)
|
||
if (accountId === null) return unauthorized(c)
|
||
|
||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||
|
||
const roomKeyId = Number.parseInt(str(body.RoomKeyId) ?? '', 10)
|
||
if (!Number.isInteger(roomKeyId)) return roomKeyEnvelope(c, null)
|
||
|
||
const existing = await getRoomKey(c.env.DB, roomKeyId)
|
||
if (!existing) return roomKeyEnvelope(c, null)
|
||
|
||
const canManage = await canManageRoomById(c.env.DB, existing.RoomId, accountId)
|
||
if (canManage === null) return roomKeyEnvelope(c, null)
|
||
if (!canManage) return c.body(null, 403)
|
||
|
||
// A name sent but blank is a bad edit, not an instruction to leave it alone.
|
||
const name = str(body.Name)?.trim()
|
||
if (name === '') return roomKeyEnvelope(c, null)
|
||
|
||
let price = existing.Price
|
||
if (str(body.Price) !== undefined) {
|
||
price = Number.parseInt(str(body.Price)!, 10)
|
||
if (!Number.isInteger(price) || price < 0) return roomKeyEnvelope(c, null)
|
||
}
|
||
|
||
// Present-but-empty is a VALUE here — tokens — unlike the fields above, where only
|
||
// absence means "leave alone".
|
||
let purchaseCurrencyId = existing.PurchaseCurrencyId
|
||
const postedCurrencyId = str(body.PurchaseCurrencyId)?.trim()
|
||
if (postedCurrencyId === '') {
|
||
purchaseCurrencyId = null
|
||
} else if (postedCurrencyId !== undefined && postedCurrencyId !== purchaseCurrencyId) {
|
||
// A key is sold in its own room's money, not another's.
|
||
const currency = await getRoomCurrency(c.env.DB, postedCurrencyId)
|
||
if (!currency || currency.RoomId !== existing.RoomId) return roomKeyEnvelope(c, null)
|
||
purchaseCurrencyId = currency.CurrencyId
|
||
}
|
||
|
||
const maxPrice =
|
||
purchaseCurrencyId === null ? MAX_KEY_PRICE_TOKENS : MAX_KEY_PRICE_ROOM_CURRENCY
|
||
if (price > maxPrice) return roomKeyEnvelope(c, null)
|
||
|
||
// Blank is a value here too: no art. Only absence leaves the stored name alone.
|
||
const postedImageName = str(body.ImageName)?.trim()
|
||
const imageName = postedImageName === undefined ? existing.ImageName : postedImageName || null
|
||
|
||
const description = str(body.Description)
|
||
return roomKeyEnvelope(
|
||
c,
|
||
await updateRoomKey(c.env.DB, roomKeyId, {
|
||
Name: name === undefined ? existing.Name : censorSwears(name),
|
||
Description: description === undefined ? existing.Description : censorSwears(description),
|
||
Price: price,
|
||
PurchaseCurrencyId: purchaseCurrencyId,
|
||
ImageName: imageName,
|
||
})
|
||
)
|
||
}
|
||
)
|
||
|
||
// Award the CALLER some room keys. Auth-gated (401); everything else is reported per
|
||
// entry, each in its OWN `{ Value, Success, Error, error_id }` envelope — the client
|
||
// decodes a list of single-value envelopes here. The body is a bare array of `{ RoomKeyId,
|
||
// AccountId }` — `AccountId` is accepted and unused: the token says who gets the key.
|
||
.post(
|
||
'/api/roomkeys/v1/awardbulk',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Award room keys to the caller',
|
||
description: [
|
||
'Records the CALLER as holding each key the body names (`room_key_player`). The body',
|
||
'is a BARE JSON ARRAY, `[{ "RoomKeyId": 43, "AccountId": 205 }]`; `AccountId` is',
|
||
'accepted and IGNORED — the token says who is awarded.',
|
||
'',
|
||
'A key is held or it isn’t: awarding one the caller already holds succeeds and',
|
||
'changes nothing.',
|
||
'',
|
||
'The answer is an ARRAY OF ENVELOPES — one `{ Value, Success, Error, error_id }` per',
|
||
'entry, in the order posted, as the client’s decoder reads it; not a bare array of',
|
||
'awards, and not one envelope around a list. `Value` is `{ RoomKeyId, AccountId }`,',
|
||
'`AccountId` being the caller. Each entry stands alone: a `RoomKeyId` naming no listed',
|
||
'key answers `Success` false with a null `Value` (the failure form is an assumption —',
|
||
'only success has been observed) and leaves the rest to land.',
|
||
'',
|
||
'NOTE: this awards to whoever is asking, and nothing is spent for it — the key’s',
|
||
'`Price` is not charged and no room membership is checked.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(AwardRoomKeysRequest, 'The keys to give the caller'),
|
||
responses: {
|
||
200: json(AwardRoomKeyResultList, 'One result per entry, in the order posted'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const accountId = await authedId(c)
|
||
if (accountId === null) return unauthorized(c)
|
||
|
||
const body = await c.req.json<unknown>().catch(() => null)
|
||
// A body that is no list has no entries to report on.
|
||
if (!Array.isArray(body)) return c.json([])
|
||
|
||
const failed = (error: string) => ({
|
||
Value: null,
|
||
Success: false,
|
||
Error: error,
|
||
error_id: null,
|
||
})
|
||
const results = []
|
||
for (const entry of body as unknown[]) {
|
||
const roomKeyId =
|
||
typeof entry === 'object' && entry !== null
|
||
? (entry as { RoomKeyId?: unknown }).RoomKeyId
|
||
: undefined
|
||
if (typeof roomKeyId !== 'number' || !Number.isInteger(roomKeyId)) {
|
||
results.push(failed('Invalid room key'))
|
||
continue
|
||
}
|
||
|
||
const awardedAt = await awardRoomKey(c.env.DB, accountId, roomKeyId)
|
||
results.push(
|
||
awardedAt === null
|
||
? failed('No such room key')
|
||
: {
|
||
Value: { RoomKeyId: roomKeyId, AccountId: accountId },
|
||
Success: true,
|
||
Error: null,
|
||
error_id: null,
|
||
}
|
||
)
|
||
}
|
||
|
||
return c.json(results)
|
||
}
|
||
)
|
||
|
||
// Who holds which room key. Auth-gated (401). The body is a bare array of `{ AccountId,
|
||
// RoomKeyId }` pairs and — unlike the award's — `AccountId` is READ here: a room asks
|
||
// about the players standing in it, not only the caller. A bare array back, one answer
|
||
// per pair.
|
||
.post(
|
||
'/api/roomkeys/v1/owns/bulk',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Whether players hold room keys',
|
||
description: [
|
||
'Answers, for each `{ AccountId, RoomKeyId }` pair posted, whether that player holds',
|
||
'that key (`room_key_player`). The body is a BARE JSON ARRAY, and so is the answer:',
|
||
'`[{ "AccountId": 205, "RoomKeyId": 43, "DoesPlayerOwnRoomKey": true }]`, one entry',
|
||
'per pair in the order posted — no envelope, unlike the award beside it.',
|
||
'',
|
||
'`AccountId` is honoured, not replaced by the caller: any signed-in player may ask',
|
||
'about any player. An unknown key or player is simply not held; an entry without two',
|
||
'integer ids is skipped.',
|
||
].join('\n'),
|
||
security: AUTHED,
|
||
requestBody: jsonBody(OwnsRoomKeysRequest, 'The (player, key) pairs to check'),
|
||
responses: {
|
||
200: json(OwnsRoomKeysResponse, 'One answer per pair, in the order posted'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const accountId = await authedId(c)
|
||
if (accountId === null) return unauthorized(c)
|
||
|
||
const body = await c.req.json<unknown>().catch(() => null)
|
||
if (!Array.isArray(body)) return c.json([])
|
||
|
||
const pairs: RoomKeyHolding[] = []
|
||
for (const entry of body as unknown[]) {
|
||
if (typeof entry !== 'object' || entry === null) continue
|
||
const { AccountId, RoomKeyId } = entry as Record<string, unknown>
|
||
if (typeof AccountId !== 'number' || !Number.isInteger(AccountId)) continue
|
||
if (typeof RoomKeyId !== 'number' || !Number.isInteger(RoomKeyId)) continue
|
||
pairs.push({ AccountId, RoomKeyId })
|
||
}
|
||
|
||
const owned = await ownsRoomKeys(c.env.DB, pairs)
|
||
return c.json(pairs.map((pair, i) => ({ ...pair, DoesPlayerOwnRoomKey: owned[i] })))
|
||
}
|
||
)
|
||
|
||
// 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: Plus is claimed on the website by proving a Discord role, and reaches
|
||
// this worker as the token's `rn.plus` claim. A caller carrying it reports an active
|
||
// Gold year; everyone else reports none. Nothing about the subscription itself is
|
||
// stored, and nothing here reads the database — see `isSubscriber` and `plusSubscription`.
|
||
//
|
||
// 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. Never read from the body.
|
||
.post(
|
||
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Subscription lookup',
|
||
description: [
|
||
'The caller’s Rec Room Plus subscription. Nothing sells subscriptions here: Plus is',
|
||
'claimed on the website by proving a qualifying role in the community Discord, and',
|
||
'arrives as the token’s `rn.plus` claim. A token carrying it reports an active Gold',
|
||
'(`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All), expiring a',
|
||
'year from the call; every other caller gets `{}`. The `developer` role does NOT',
|
||
'confer it. Auth is optional — a missing or invalid token reads as “not subscribed”,',
|
||
'not 401. The subscription itself is not persisted, and because the claim is stamped',
|
||
'at login, a player who has just claimed must sign in again before it appears.',
|
||
].join(' '),
|
||
responses: {
|
||
200: json(SubscriptionResponse, 'The subscription, or `{}` for no subscription'),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
if (!(await isSubscriber(c))) return c.json({})
|
||
const id = await authedId(c)
|
||
if (id === null) return c.json({})
|
||
return c.json({
|
||
Subscription: plusSubscription(id),
|
||
PlatformAccountSubscribedPlayerId: null,
|
||
})
|
||
}
|
||
)
|
||
|
||
// The 2026 decoder requires an object; RecNet rejects JSON null as an empty response.
|
||
.get(
|
||
'/api/subscriptionseasons/v1/seasons/current',
|
||
describeRoute({
|
||
summary: 'Current subscription season',
|
||
responses: {
|
||
200: json(
|
||
z.union([
|
||
JsonArray,
|
||
z.object({
|
||
SubscriptionSeasonId: z.string().uuid(),
|
||
Name: z.string(),
|
||
ImageName: z.string(),
|
||
StartAt: z.string(),
|
||
NextSeasonStart: z.string().nullable(),
|
||
Milestones: JsonArray,
|
||
}),
|
||
]),
|
||
'2026: season object without milestones; older builds: []'
|
||
),
|
||
},
|
||
}),
|
||
async (c) =>
|
||
c.json(
|
||
((await authedBuild(c)) ?? 0) >= 20260323
|
||
? {
|
||
SubscriptionSeasonId: '00000000-0000-0000-0000-000000000000',
|
||
Name: '',
|
||
ImageName: '',
|
||
StartAt: '0001-01-01T00:00:00Z',
|
||
NextSeasonStart: null,
|
||
Milestones: [],
|
||
}
|
||
: []
|
||
)
|
||
)
|
||
|
||
// 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 caller’s referral-reward progress',
|
||
description: [
|
||
'How many of the caller’s 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 caller’s 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.
|
||
// Membership is the account's `isInfluencer` flag (`runx admin grant-influencer`), read
|
||
// through the `is_influencer` generated column's partial index so the account table is
|
||
// never scanned.
|
||
//
|
||
// Paged in shape only: the client asks for `take=1000` and this server will not have
|
||
// that many, so the whole list is one page and `ContinuationToken` is always null. `take`
|
||
// is accepted and ignored. Note this is the LIST counterpart of the single-account tier
|
||
// check below, which still answers 0 for everyone.
|
||
.get(
|
||
'/api/influencerpartnerprogram/influencers',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Every influencer in the partner program',
|
||
description: [
|
||
'The account ids in the influencer partner program — every account flagged',
|
||
'`isInfluencer` — as `{ InfluencerIds, ContinuationToken }`: an object around the',
|
||
'list, not a bare array. Served whole: `take` is accepted and ignored, and',
|
||
'`ContinuationToken` is always null.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
parameters: [
|
||
{
|
||
name: 'take',
|
||
in: 'query',
|
||
required: false,
|
||
description: 'How many ids to return. Accepted and ignored — the list is served whole.',
|
||
schema: { type: 'integer' },
|
||
},
|
||
],
|
||
responses: {
|
||
200: json(InfluencerIdsResponse, 'The influencer ids, lowest first'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const InfluencerIds = await listInfluencerIds(c.env.DB)
|
||
return c.json({ InfluencerIds, ContinuationToken: null })
|
||
}
|
||
)
|
||
|
||
// The influencer one account SUPPORTS in the partner program, by account id — every
|
||
// player picks one (`…/support` below), and this is how anyone reads anyone's pick.
|
||
//
|
||
// A BARE NUMBER is the whole body, like `…/makerai/checkfreetrialeligibility`'s bare
|
||
// `false`, not a number wrapped in an object; 0 is "supports nobody", which is also what
|
||
// an account that does not exist answers. The caller's own pick is `…/myinfluencer`.
|
||
.get(
|
||
'/api/influencerpartnerprogram/influencer',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'The influencer an account supports',
|
||
description: [
|
||
'The account id of the influencer the account named by `accountId` supports, as a',
|
||
'BARE NUMBER — the whole body is the id, not an object around it; `0` when they',
|
||
'support nobody (or no such account exists). Auth-gated; a missing or invalid token',
|
||
'is a 401. A missing or non-numeric `accountId` is a 400.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
parameters: [
|
||
{
|
||
name: 'accountId',
|
||
in: 'query',
|
||
required: true,
|
||
description: 'The account being asked about.',
|
||
schema: { type: 'integer' },
|
||
},
|
||
],
|
||
responses: {
|
||
200: json(SupportedInfluencerResponse, 'The supported influencer’s id, or 0'),
|
||
400: { description: '`accountId` missing or not an integer (empty body)' },
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const accountId = Number.parseInt(c.req.query('accountId') ?? '', 10)
|
||
if (!Number.isInteger(accountId)) return c.body(null, 400)
|
||
return c.json(await supportedInfluencer(c.env.DB, accountId))
|
||
}
|
||
)
|
||
|
||
// The same question about the CALLER — the `my` form, which names no account because the
|
||
// token already does. Same bare number, same 0 for nobody.
|
||
//
|
||
// Its own route rather than an alias of the one above, because the two differ in who they
|
||
// are about.
|
||
.get(
|
||
'/api/influencerpartnerprogram/myinfluencer',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'The influencer the caller supports',
|
||
description: [
|
||
'The account id of the influencer the caller supports — the `my` form of the route',
|
||
'above, taking the account from the token rather than a query parameter. A BARE',
|
||
'NUMBER; `0` when they support nobody. Auth-gated; a missing or invalid token is a 401.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(SupportedInfluencerResponse, 'The supported influencer’s id, or 0'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json(await supportedInfluencer(c.env.DB, id))
|
||
}
|
||
)
|
||
|
||
// Pick the influencer the caller supports: `influencerAccountId=<id>`, form-encoded. ONE
|
||
// per player, stored on the account as `supportedInfluencerId`; posting again replaces
|
||
// the pick. The id has to name an account currently flagged `isInfluencer` — anything
|
||
// else, the caller's own id included, is a 400 and changes nothing. The reply is a bare
|
||
// `{ Success, Error, error_id }` status envelope — no `Value`: the client reads the pick
|
||
// back from `…/myinfluencer` rather than from this reply.
|
||
.post(
|
||
'/api/influencerpartnerprogram/support',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Pick the influencer the caller supports',
|
||
description: [
|
||
'Set the ONE influencer the caller supports in the partner program, replacing any',
|
||
'earlier pick. `influencerAccountId` must name an account that is currently an',
|
||
'influencer (listed by `…/influencers`); the caller cannot support themselves. The',
|
||
'reply is a `{ Success, Error, error_id }` status envelope with no `Value`; read the',
|
||
'new pick back from `…/myinfluencer`.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: form(SupportInfluencerBody, 'The influencer to support'),
|
||
responses: {
|
||
200: json(SupportInfluencerResponse, 'The pick was recorded'),
|
||
400: {
|
||
description:
|
||
'`influencerAccountId` missing, not an integer, the caller’s own id, or not an influencer (empty body)',
|
||
},
|
||
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 raw = body.influencerAccountId
|
||
const influencerId = Number.parseInt(typeof raw === 'string' ? raw : '', 10)
|
||
if (!Number.isInteger(influencerId) || influencerId === id) return c.body(null, 400)
|
||
const influencer = await getAccount(c.env.DB, influencerId)
|
||
if (influencer?.isInfluencer !== true) return c.body(null, 400)
|
||
await updateAccount(c.env.DB, id, { supportedInfluencerId: influencerId })
|
||
return c.json({ Success: true, Error: null, error_id: null })
|
||
}
|
||
)
|
||
|
||
// Stop supporting an influencer: `influencerAccountId=<id>`, form-encoded, the same body
|
||
// as `…/support`. The field is UNSET on the account (dropped from the JSON, not written
|
||
// as 0), so `…/myinfluencer` goes back to 0. The id has to be an integer (else 400), but
|
||
// it only has to NAME THE CURRENT PICK to do anything: naming anyone else — a stale pick
|
||
// from before a `…/support` that replaced it, or nobody at all — is a no-op that still
|
||
// answers success, like a wishlist remove of something not listed. Guarding on the match
|
||
// keeps a stale client from clearing a pick it never saw.
|
||
.post(
|
||
'/api/influencerpartnerprogram/remove',
|
||
describeRoute({
|
||
tags: ['Econ'],
|
||
summary: 'Stop supporting an influencer',
|
||
description: [
|
||
'Clear the influencer the caller supports in the partner program. `influencerAccountId`',
|
||
'names the current pick; the field is unset and `…/myinfluencer` answers 0 again.',
|
||
'An id that is not the current pick changes nothing and still answers success. The',
|
||
'reply is the same `{ Success, Error, error_id }` status envelope as `…/support`.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
requestBody: form(RemoveInfluencerBody, 'The influencer to stop supporting'),
|
||
responses: {
|
||
200: json(SupportInfluencerResponse, 'The pick was cleared (or was not this one)'),
|
||
400: { description: '`influencerAccountId` missing or not an integer (empty body)' },
|
||
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 raw = body.influencerAccountId
|
||
const influencerId = Number.parseInt(typeof raw === 'string' ? raw : '', 10)
|
||
if (!Number.isInteger(influencerId)) return c.body(null, 400)
|
||
if ((await supportedInfluencer(c.env.DB, id)) === influencerId) {
|
||
// `undefined` wins the spread in updateAccount and JSON.stringify drops the key.
|
||
await updateAccount(c.env.DB, id, { supportedInfluencerId: undefined })
|
||
}
|
||
return c.json({ Success: true, Error: null, error_id: null })
|
||
}
|
||
)
|
||
|
||
// The generated spec. Documentation only — no request is validated against it (see
|
||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||
app.get(
|
||
'/openapi.json',
|
||
describeRoute({ hide: true }),
|
||
withCleanSpec(
|
||
openAPIRouteHandler(app, {
|
||
documentation: {
|
||
info: {
|
||
title: 'recflare econ',
|
||
version: '1.0.0',
|
||
description: [
|
||
'Avatar and economy endpoints for recflare, a private-server reimplementation of the',
|
||
'Rec Room backend. The client calls these on the `econ` host; many are also served by',
|
||
'the `api` worker. Storefront catalogs are static assets (`sf{N}.json`); balances,',
|
||
'inventory, consumables, saved outfits and gift boxes are D1-backed.',
|
||
].join('\n'),
|
||
},
|
||
servers: [{ url: 'https://econ.recflare.net', description: 'Production' }],
|
||
components: {
|
||
securitySchemes: {
|
||
bearerAuth: {
|
||
type: 'http',
|
||
scheme: 'bearer',
|
||
bearerFormat: 'JWT',
|
||
description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
)
|
||
)
|
||
|
||
export { app }
|
||
|
||
// The cron (wrangler.jsonc `triggers`, which sets how often): box every Discord supporter
|
||
// the tokens their role is mapped to in DISCORD_ROLE_TOKENS. A no-op, logged once, until the
|
||
// operator maps a role — see discord-role-gift.ts. The signup grant is seeded before the credit,
|
||
// as every other faucet here does, so it reads the same knob.
|
||
export const scheduled: ExportedHandlerScheduledHandler<Env> = (_controller, env, ctx) => {
|
||
ctx.waitUntil(grantDiscordRoleGifts(env, intVar(env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)))
|
||
}
|
||
|
||
// A Worker only runs `scheduled` when it's on the default export, so econ takes the object
|
||
// form the runtime requires (as `www` does for its role sweep and `match` for presence).
|
||
export default { fetch: app.fetch, scheduled } satisfies ExportedHandler<Env>
|