support for 202507 endpoints (#37)

* [auth][api] accept the 20250424.01 client

Version check now answers "current" for a set of builds rather than one:
SUPPORTED_GAME_VERSIONS carries 20230414 and 20250424.01. GAME_VERSION is
unchanged and still what the server reports for itself (presence, rn.ver).

Adds GET /api/versioncheck/islandedversions, always [] — we never island a
build off into its own matchmaking pool.

The 2025 build POSTs /cachedlogin/forplatformid/:platform/:id with a
deviceId/platformAuth/time form body where the 2023 build GETs it, so that
route now takes both methods. The body is accepted and ignored for now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* [2025] unstable

* 20250718.0

* correct one this time

* stubs

* more stubs

* more stubs

* [lists] add worker

* [ai] route stubs

* [api] player photo setting

* [econ] add roomEconConfig route

* [infra] update worker generators

* [worker] add cards/moderation/platformnotification workers

* [lists] updates to some endpoints

* [clubs] stub out announcement endpoint, for now

* [econ] stub out season endpoints for now

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

* [api] stub out statsig and lockeditems

* [doc] new services

* [lists] stub the bulk endpoint

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

* [api] set gifting to lvl5

* update lock

* [cdn] enable cache

* [match] matchmake v2

* [lists] stub some lists

* [ai] stubs

* [rooms] new subroom save endpoint

* [econ] add bulk purchase endpoint

* [discovery] update featured creator to 1 for fun

* [api] add photo settings flag

* [chat] fixup chat permissions (sorta)

* [auth] restrictions endpoint

* [rooms] contributed endpoint

* [api] fix outfit endpoint

* [discovery] attempt to fix store

* [chat] privacy endpoints

* [api] cheered images

* [rooms] add xp endpoint (disbaled)

* [rooms] add xp endpoint (disabled)

* update images-db for cheers

* [rooms] add autocomplete endpoint

* [cdn/img] increase cache ttl for statics

* [api] bulk route for images

* [accounts] add banner image

* [api] add misc missing endpoints

* [discovery] remove AI tab

* [platformnotifications] stub some endpoints

* [lists] add some more lists

* [rooms] additional endpoints

* [chat] stub a few privacy endpoints

* [econ] stub some endpoints

* misc db fixes

* [api] tweak shape for images v6

* [rooms] dont show trending RROs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
devin
2026-08-18 23:07:24 -04:00
committed by GitHub
parent 66c09806f9
commit 34171417e3
162 changed files with 114930 additions and 469 deletions
+80
View File
@@ -42,6 +42,7 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
| GET | `/api/storefronts/v4/balance/:currencyType` | ✓ | Currency balance |
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
| POST | `/api/items/bulkpurchase` | ✓ | Buy a bag of storefront items |
| GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) |
| GET | `/api/challenge/v2/getCurrent` | ~ | Weekly rotation + the caller's progress |
| POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress |
@@ -87,6 +88,85 @@ Two things are easy to get wrong:
A `Gift` block routes the item (and box) to another player, but the caller always pays.
A self-buy or anonymous gift is attributed to the "Coach" system account (id 1).
### The shopping bag (`POST /api/items/bulkpurchase`)
The same purchase, many items at once. The client posts every line in the bag — an item id,
`DuplicateItemCount` copies, the unit `RequestedPrice` it rendered, an optional `Gift` — plus
the one `StorefrontType` and one `CurrencyType` they all share.
**The response is not buyItem's.** It is `{ Success, Error, error_id, Value }``error_id`
lowercase because the client renames that one member, the other three PascalCase — wrapping a
BalanceUpdateResponse:
```jsonc
{
"Success": true,
"Error": null,
"error_id": null,
"Value": {
"Balance": 9500, // the RESULTING total, not buyItem's change
"CurrencyType": 2,
"Platform": -2, // the bucket that total belongs to — a renamed BalanceType
"BalanceUpdates": [
// one entry per REQUESTED item, in request order
{
"UpdateResponse": 0,
"Data": { "GiftPackage": { }, "PurchasableItemId": 2182, "CustomAvatarItem": null },
},
],
},
}
```
What is deliberate here:
- **`Value.Balance` is the resulting total**, unlike buyItem's change — `{ Balance,
CurrencyType, Platform }` is the BalanceResponse triple a `StorefrontBalance*` frame also
carries, and the one frame this pushes reports the identical number, so body and frame
agree instead of compounding.
- **`Platform` is `-2`, not the capture's `4`.** It is the client's `BalanceType` under a
`[DataMember]` rename — the bucket, not a store. The reference server said `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
— the bug that showed a player 34,100 tokens.
- **One entry per REQUESTED item, and `UpdateResponse` is where it reports.** 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. Codes: 0 OK,
1 TooManyRequests, 2 NotEnoughCredit, 3 AlreadyOwned, 4 NoItemAvailable,
5 CouponNotApplicable, 6 RequestedPriceDoesNotMatch, 7 RequestedAmountNotAllowed,
8 PlayerNotEligible, 9 RequestCannotBeRefunded, 10 PlayerNotApproved. This server can
produce 0/2/4/5/6/7; the rest are rate limiting, entitlements and refunds, none of which
exist here. `AlreadyOwned` is left unused on purpose — buyItem lets a player re-buy what
they own, and one purchase path refusing what the other allows would be worse than either.
- **One entry per item means one box per item.** So `DuplicateItemCount` above 1 is only
allowed for a consumable (they stack, and the count rides on the one box);
anything owned once is refused with `RequestedAmountNotAllowed` rather than charged twice
for a grant that would collapse into itself.
- **One catalog read and one debit for the whole bag.** Lines resolve in memory against a
single `sf{N}.json` read (sf3 is 1161 items; the cap is 200 copies), and the total is spent
in ONE atomic `spendCurrency`. Charging line by line would let a bag half-succeed against a
concurrent spend and would push a balance frame per line.
- **Nothing sold ⇒ a refusal, not an empty success.** `Success: false`, the first failure's
reason in `Error`, and a null `Value` — legal here, since the client's validator only
cascades into a non-null one (unlike the matchmaking DTO, which throws on a null payload).
Without `AllowPartialSuccess`, one bad line refuses the bag the same way and nothing is
charged. `400` is reserved for a request that can't be evaluated at all (no lines, a
non-spendable currency, more than `Econ.BulkPurchaseCap` — the same 200 the client reads
from its game config), and even those answer the envelope.
- **`GiftPackage` is its own 20-key DTO**, not the stored box: buyItem's `Data` entry plus
`PlayerId` / `CustomAvatarItemId` / `Signature` / `IsSignatureValid`, minus its `Level`.
Its `Platform`/`PlatformsToSpawnOn` are the platform MASK (-1) — the balance bucket is the
`BalanceType` beside them, which is the opposite arrangement from `Value.Platform` above.
`Signature` is null and `IsSignatureValid` false: a box the server minted was never signed
for peer-to-peer transfer.
- **`BypassGiftPackages` skips the boxes, not the grant.** Ownership never depended on the
box, so the items are granted either way; `GiftPackage` is then null — exactly what the
captured response shows.
- **Guid-keyed ids and coupons are refused.** `ItemPurchaseMethodId` is a discriminated id
(`Type` 0 = a storefront `PurchasableItemId` in `NumberId`; a `Guid` names a UGC item no
catalog here sells, and `Data.PurchasableItemId` is null for it), and nothing issues the
coupon a `CouponConsumablePlayerMappingId` would spend.
## Query drops — the loot boxes (`IsQuery`)
A gift-drop with `IsQuery: true` is not an item, it is a **roll**: all of its item fields
+953 -56
View File
@@ -7,11 +7,13 @@ import {
consumeGift,
createGift,
getGift,
getOutfits,
getPendingGifts,
grantInvention,
levelReward,
levelsReached,
ownsInvention,
setOutfit,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
@@ -29,6 +31,7 @@ import { NotificationType } from '../../notify/src/notification-types'
import adCarouselItems from '../static/ad-carousel-items.json'
import defaultAvatarItems from '../static/default-avatar-items.json'
import defaultAvatar from '../static/default-avatar.json'
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
import myProgress from '../static/my-progress.json'
import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db'
@@ -54,16 +57,22 @@ import {
grantConsumable,
} from './consumables-db'
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
import { getInventory, grantItem } from './inventory-db'
import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db'
import {
AUTHED,
AvatarItemV4Dto,
AvatarV2Dto,
BalanceEntry,
BulkPurchaseRequest,
BulkPurchaseResponse,
BuyInventionResponse,
BuyItemRequest,
BuyItemResponse,
ChallengeProgressRequest,
ChallengeProgressResponse,
ChecklistCompleteResponse,
ChecklistEntry,
CompleteChecklistRequest,
ConsumeConsumableRequest,
ConsumeEnvelope,
ConsumeGiftRequest,
@@ -72,12 +81,17 @@ import {
ErrorResponse,
form,
GameRewardRequest,
InfluencerIdsResponse,
json,
JsonArray,
jsonBody,
JsonObject,
MakerAiFreeTrialEligibilityResponse,
OpaqueJsonBody,
OPTIONAL_AUTHED,
ReferralProgressResponse,
RoomEconConfig,
RRPlusSignUpBonus,
SaveOutfitRequest,
SaveOutfitV4Response,
SubscriptionResponse,
@@ -85,11 +99,10 @@ import {
UpdateObjectiveRequest,
UpdateObjectiveResponse,
} from './openapi'
import { getOutfits, setOutfit } from './outfit-db'
import { claimReward } from './reward-db'
import type { Context } from 'hono'
import type { GiftContent, Progression, StoredGift, XpGrant } from '@repo/domain'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
import type {
BalanceResponsePayload,
PurchaseBalanceModificationPayload,
@@ -99,7 +112,6 @@ import type { ConsumeResult } from './consumables-db'
import type { App } from './context'
import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db'
import type { Outfit } from './outfit-db'
/**
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
@@ -474,6 +486,20 @@ interface GiftRequest {
GiftContext?: number
}
/**
* Read a storefront catalog (`sf{type}.json`) from the ASSETS binding. Null when there is
* no such storefront.
*
* Separate from {@link findStoreItem} so a caller resolving SEVERAL items from one
* storefront reads (and parses) it once: sf3 alone is over a thousand items, and a bulk
* purchase carries up to `BULK_PURCHASE_CAP` lines.
*/
async function loadStorefront(c: Context<App>, storefrontType: number): Promise<Storefront | null> {
const res = await c.env.ASSETS.fetch(new URL(`/sf${storefrontType}.json`, c.req.url))
if (!res.ok) return null
return (await res.json()) as Storefront
}
/**
* Look up a store item by (storefront type, purchasable item id), reading the catalog
* from the ASSETS binding (`sf{type}.json`). Returns null when there is no such
@@ -484,9 +510,8 @@ async function findStoreItem(
storefrontType: number,
purchasableItemId: number
): Promise<StoreItem | null> {
const res = await c.env.ASSETS.fetch(new URL(`/sf${storefrontType}.json`, c.req.url))
if (!res.ok) return null
const storefront = (await res.json()) as Storefront
const storefront = await loadStorefront(c, storefrontType)
if (storefront === null) return null
return storefront.StoreItems.find((it) => it.PurchasableItemId === purchasableItemId) ?? null
}
@@ -647,10 +672,8 @@ const ROLL_STOREFRONT_TYPE = 3
/** Every item in the roll catalog, or `[]` if it can't be read (a roll then yields nothing). */
async function loadRollCatalog(c: Context<App>): Promise<StoreItem[]> {
const res = await c.env.ASSETS.fetch(new URL(`/sf${ROLL_STOREFRONT_TYPE}.json`, c.req.url))
if (!res.ok) return []
const storefront = (await res.json()) as Storefront
return storefront.StoreItems
const storefront = await loadStorefront(c, ROLL_STOREFRONT_TYPE)
return storefront?.StoreItems ?? []
}
/**
@@ -747,12 +770,31 @@ async function rollQueryDrop(
* A gift box that was just created, and the drop it ended up holding. The drop is the
* RESOLVED one — what a query drop rolled, not the box that promised it — so a caller
* announcing the gift names the item the player actually won.
*
* `id` is 0 when no box was created (`skipGiftBox`) — the drop was still granted.
*/
interface GrantedGift {
id: number
drop: StoreGiftDrop
}
/** How a drop is handed over: how a query one rolls, plus how it is wrapped. */
interface GrantOptions extends RollOptions {
/**
* How many of the drop to hand over in ONE box — a bulk line's `DuplicateItemCount`.
* Only a consumable stacks, so this multiplies the consumable count and nothing else;
* callers must refuse a count above 1 for anything owned once. Defaults to 1.
*/
copies?: number
/**
* Grant the drop without creating the gift box that renders it — what a bulk purchase's
* `BypassGiftPackages` asks for. Ownership never depended on the box (it is granted here,
* not when the box is opened), so this only skips the "open it" moment; a caller setting
* it is saying its own UI announces the items.
*/
skipGiftBox?: boolean
}
/**
* Pick a random consumable from the roll catalog — the reward the published level table
* hands out for the early levels.
@@ -791,7 +833,7 @@ async function grantGiftDrop(
accountId: number,
drop: StoreGiftDrop,
message: string,
options: RollOptions = {}
options: GrantOptions = {}
): Promise<GrantedGift> {
let giftDrop = drop
if (drop.IsQuery === true) {
@@ -819,7 +861,7 @@ async function grantGiftDrop(
}
const isConsumable =
typeof giftDrop.ConsumableItemDesc === 'string' && giftDrop.ConsumableItemDesc !== ''
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0
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
@@ -833,6 +875,7 @@ async function grantGiftDrop(
consumableCount
)
}
if (options.skipGiftBox === true) return { id: 0, drop: giftDrop }
const { id } = await createGift(
db,
accountId,
@@ -841,6 +884,282 @@ async function grantGiftDrop(
return { id, drop: giftDrop }
}
/**
* One `BalanceUpdates[].Data` entry: the gift-drop a player RECEIVED, as both purchase
* endpoints report it. `granted.drop` is the resolved drop — the rolled prize for a query
* box, not the box that promised it — or a query purchase answers with every item field
* empty and the client draws an empty box.
*
* It carries no FriendlyName or consumable count (the count is a getUnlocked concept; each
* box is one instance). `giftContext` is the requesting `Gift` block's, when it named one;
* otherwise the drop's own.
*/
function toBalanceUpdateData(
granted: GrantedGift,
fromPlayerId: number,
message: string,
giftContext: number | null
): Record<string, unknown> {
const drop = granted.drop
return {
Id: granted.id,
FromPlayerId: fromPlayerId,
ConsumableItemDesc: drop.ConsumableItemDesc,
AvatarItemDesc: drop.AvatarItemDesc,
AvatarItemType: drop.AvatarItemType ?? 0,
EquipmentPrefabName: drop.EquipmentPrefabName,
EquipmentModificationGuid: drop.EquipmentModificationGuid,
CurrencyType: drop.CurrencyType,
Currency: drop.Currency,
Xp: drop.Xp ?? 0,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
GiftContext: giftContext ?? drop.Context,
GiftRarity: drop.Rarity,
Message: message,
}
}
/**
* `Econ.BulkPurchaseCap` — the most copies one bulk purchase may carry. The client reads
* the same 200 out of its game config (apps/api/static/gameconfigs-v1-all.json) and caps
* the bag with it, so this is the server side of a limit the client already knows; a
* request over it is a client that ignored its own config, not a bigger shopping trip.
*/
const BULK_PURCHASE_CAP = 200
/**
* `UpdateResponse` — the outcome of ONE `BalanceUpdates` entry, from the client's own enum.
* This is where a bulk purchase reports per line: the bag answers one entry per REQUESTED
* item, and `AllowPartialSuccess` is what lets some of them come back non-OK while the
* envelope's `Success` stays true. (buyItem's single `UpdateResponse: 0` is this same OK.)
*
* The members this server can produce are the ones a catalog purchase can fail on;
* `TooManyRequests`, `PlayerNotEligible`, `RequestCannotBeRefunded` and `PlayerNotApproved`
* belong to rate limiting, entitlements and refunds, none of which exist here. `AlreadyOwned`
* is deliberately unused too: buyItem lets a player re-buy an item they own (the grant
* upserts), and one purchase path refusing what the other allows would be worse than either.
*/
const UpdateResponse = {
OK: 0,
TooManyRequests: 1,
NotEnoughCredit: 2,
AlreadyOwned: 3,
NoItemAvailable: 4,
CouponNotApplicable: 5,
RequestedPriceDoesNotMatch: 6,
RequestedAmountNotAllowed: 7,
PlayerNotEligible: 8,
RequestCannotBeRefunded: 9,
PlayerNotApproved: 10,
} as const
/** The discriminated item id a bulk-purchase line names its item by. */
interface PurchaseMethodId {
Type: number
NumberId: number | null
Guid: string | null
}
/** One line of a `POST /api/items/bulkpurchase` body. */
interface PurchaseItemRequest {
ItemPurchaseMethodId?: Partial<PurchaseMethodId> | null
RequestedPrice?: number
Gift?: GiftRequest | null
CouponConsumablePlayerMappingId?: number | null
DuplicateItemCount?: number
}
/**
* A line that could not be bought: the `UpdateResponse` its entry carries, and the message
* that fills the envelope's single `Error` when the bag as a whole is refused.
*/
interface BulkLineFailure {
method: PurchaseMethodId
code: number
error: string
}
/** A line that resolved to something buyable, with the catalog's own price. */
interface BulkPurchaseLine {
method: PurchaseMethodId
item: StoreItem
/** The UNIT price from the catalog — `count` copies cost `price * count`. */
price: number
count: number
gift: GiftRequest | null
}
/**
* One `BalanceUpdates[].Data` — what a single requested item turned into. Unlike buyItem's,
* it NAMES the purchase rather than describing the drop: the client already has the catalog
* entry for `PurchasableItemId`, so the only thing it can't reconstruct is the box.
*
* `CustomAvatarItem` is the UGC counterpart of `PurchasableItemId`, and both it and
* `GiftPackage` are null on a line that didn't sell. Nothing here fills `CustomAvatarItem`:
* no catalog we serve sells guid-keyed items.
*/
interface BulkPurchaseData {
GiftPackage: Record<string, unknown> | null
PurchasableItemId: number | null
CustomAvatarItem: null
}
/** `ItemPurchaseMethodId.Type` for a numeric (storefront `PurchasableItemId`) id. */
const PURCHASE_METHOD_NUMBER_ID = 0
/**
* The gift box as `GiftPackage` carries it — the same DTO family as buyItem's
* `BalanceUpdates[].Data` entry, but with the four keys that shape doesn't carry
* (`PlayerId`, `CustomAvatarItemId`, `Signature`, `IsSignatureValid`) and without its
* `Level`. Twenty keys, in the order the client's own member list names them.
*
* `Platform`/`PlatformsToSpawnOn` are the platform MASK (-1, all) — the balance bucket is
* the separate `BalanceType` beside them, unlike the envelope's `Value.Platform`, which IS
* a renamed `BalanceType`. `Signature` is null and `IsSignatureValid` false: a box the
* server minted was never signed for peer-to-peer transfer.
*/
function toGiftPackage(
granted: GrantedGift,
playerId: number,
fromPlayerId: number,
message: string,
giftContext: number | null
): Record<string, unknown> {
const drop = granted.drop
return {
Id: granted.id,
PlayerId: playerId,
FromPlayerId: fromPlayerId,
ConsumableItemDesc: drop.ConsumableItemDesc,
AvatarItemType: drop.AvatarItemType ?? 0,
AvatarItemDesc: drop.AvatarItemDesc,
CustomAvatarItemId: null,
EquipmentPrefabName: drop.EquipmentPrefabName,
EquipmentModificationGuid: drop.EquipmentModificationGuid,
CurrencyType: drop.CurrencyType,
Currency: drop.Currency,
Xp: drop.Xp ?? 0,
GiftContext: giftContext ?? drop.Context,
GiftRarity: drop.Rarity,
Message: message,
Signature: null,
IsSignatureValid: false,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
}
}
/**
* Normalise the id a line named its item by. A line that sent no id at all still resolves
* to something, so this never returns null — the checks in {@link resolveBulkLine} are what
* reject it.
*/
function toPurchaseMethodId(raw: Partial<PurchaseMethodId> | null | undefined): PurchaseMethodId {
const id = typeof raw === 'object' && raw !== null ? raw : {}
return {
Type: Number.isInteger(id.Type) ? (id.Type as number) : PURCHASE_METHOD_NUMBER_ID,
NumberId: Number.isInteger(id.NumberId) ? (id.NumberId as number) : null,
Guid: typeof id.Guid === 'string' ? id.Guid : null,
}
}
/**
* Resolve one line against the bag's catalog: what it wants, how many, and at what price.
* Returns the failure — with the `UpdateResponse` its entry will carry — instead when the
* line can't be bought.
*
* Pure — the catalog is passed in — so the whole bag resolves from ONE storefront read.
* The price check is buyItem's, per line: `RequestedPrice` is the UNIT price the client
* rendered, and a mismatch means the catalog moved under a stale client rather than that
* the player agreed to today's price.
*/
function resolveBulkLine(
line: PurchaseItemRequest,
storefront: Storefront | null,
currencyType: number
): BulkPurchaseLine | BulkLineFailure {
const method = toPurchaseMethodId(line.ItemPurchaseMethodId)
// Guid-keyed ids name UGC / custom avatar items, which no catalog here sells. Failing the
// line (rather than the request) is what lets a bag of ordinary items still go through.
if (method.Type !== PURCHASE_METHOD_NUMBER_ID || method.NumberId === null) {
return {
method,
code: UpdateResponse.NoItemAvailable,
error: 'Only numeric storefront item ids can be bought',
}
}
// Nothing issues coupons, so a line claiming one would otherwise be charged full price
// for a discount it thinks it applied.
if (
line.CouponConsumablePlayerMappingId !== null &&
line.CouponConsumablePlayerMappingId !== undefined
) {
return {
method,
code: UpdateResponse.CouponNotApplicable,
error: 'Coupons are not supported',
}
}
const count = line.DuplicateItemCount ?? 1
if (!Number.isInteger(count) || count < 1) {
return {
method,
code: UpdateResponse.RequestedAmountNotAllowed,
error: 'DuplicateItemCount must be a positive integer',
}
}
if (storefront === null) {
return { method, code: UpdateResponse.NoItemAvailable, error: 'No such storefront' }
}
const item = storefront.StoreItems.find((it) => it.PurchasableItemId === method.NumberId)
if (item === undefined) {
return { method, code: UpdateResponse.NoItemAvailable, error: 'Item not found' }
}
// Only a consumable stacks. An avatar item or an equipment skin is owned once, so a
// second copy would grant nothing while charging for it — and the bag answers ONE entry
// (one box) per requested item, which is the same statement from the wire's side.
if (count > 1 && item.GiftDrop.ConsumableItemDesc === '') {
return {
method,
code: UpdateResponse.RequestedAmountNotAllowed,
error: 'This item can only be bought once per line',
}
}
const price = item.Prices.find((p) => p.CurrencyType === currencyType)
if (price === undefined) {
return {
method,
code: UpdateResponse.NoItemAvailable,
error: 'Currency type not available for this item',
}
}
if (!Number.isInteger(line.RequestedPrice)) {
return {
method,
code: UpdateResponse.RequestedPriceDoesNotMatch,
error: 'RequestedPrice is required',
}
}
if (line.RequestedPrice !== price.Price) {
return {
method,
code: UpdateResponse.RequestedPriceDoesNotMatch,
error: 'Price has changed',
}
}
const gift = typeof line.Gift === 'object' && line.Gift !== null ? line.Gift : null
return { method, item, price: price.Price, count, gift }
}
/** Whether a resolved line is buyable or is already a failure. */
function isBulkLine(resolved: BulkPurchaseLine | BulkLineFailure): resolved is BulkPurchaseLine {
return 'item' in resolved
}
/**
* XP paid for a claimed game reward. One flat amount for every reward type, matching the
* one flat cooldown they share — "First Game of the Day" and "Activity completed!" are the
@@ -1160,6 +1479,22 @@ async function awardChallengeGift(c: Context<App>, accountId: number): Promise<v
}
}
/**
* 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
@@ -1201,11 +1536,12 @@ const app = new Hono<App>({ strict: false })
(c) => c.json(defaultAvatarItems)
)
// Default base avatar items — empty stub for now. No auth.
// 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', 'Empty stub for now'),
(c) => c.json([])
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
@@ -1219,10 +1555,12 @@ const app = new Hono<App>({ strict: false })
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(JsonArray, 'Owned items followed by the default catalog'),
200: json(AvatarItemV4Dto.array(), 'Owned items followed by the default catalog'),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -1230,7 +1568,7 @@ const app = new Hono<App>({ strict: false })
const id = await authedId(c)
if (id === null) return unauthorized(c)
const owned = await getInventory(c.env.DB, id)
return c.json([...owned, ...defaultAvatarItems])
return c.json([...owned, ...defaultAvatarItems].map(toAvatarItemV4))
}
)
@@ -1376,11 +1714,76 @@ const app = new Hono<App>({ strict: false })
}
)
// NUX checklist — the client fetches this on the econ host during load. []
// with no DB. A 404 here can abort the load orchestration before matchmake.
// NUX checklist — the client fetches this on the econ host during load, on either
// version path. A 404 here can abort the load orchestration before matchmake. We
// serve the default brand-new-account list to everyone: nothing records per-player
// checklist progress yet, so it never shrinks as steps are done.
.on(
'GET',
['/api/checklist/v1/current', '/api/checklist/v2/current'],
describeRoute({
tags: ['Econ'],
summary: 'NUX checklist',
description:
'The new-user checklist, as the default brand-new-account list — nothing records ' +
'per-player progress yet, so the same rows come back however much the player has ' +
'done. `Objective` is an `ObjectiveType` ordinal the client matches its own ' +
'progress events against. v1 and v2 serve the same list.',
security: AUTHED,
responses: {
200: json(ChecklistEntry.array(), 'The checklist rows, in `Order`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(DEFAULT_CHECKLIST)
}
)
// Mark a checklist row done. [Authorize]. Stubbed: there is no objective-progress
// table to record the completion in, and no reward ledger to make the 25-token grant
// once-only — without one, re-posting the same row would mint tokens indefinitely, so
// we grant nothing and report a change of 0. The envelope is still the balance-update
// shape the client parses, so the flow completes instead of erroring.
.on(
'POST',
['/api/checklist/v1/complete', '/api/checklist/v2/complete'],
describeRoute({
tags: ['Econ'],
summary: 'Complete a checklist row (stub)',
description:
'Marks a NUX checklist row done. Stubbed: nothing records the completion (no ' +
'objective-progress table) and nothing is granted — a reward is worth 25 XP and 25 ' +
'tokens, but making that once-only needs a ledger we do not have, and without one ' +
're-posting the same row would mint tokens indefinitely. The response is still the ' +
'balance-update envelope, with `Balance` (the change) 0. v1 and v2 behave alike.',
security: AUTHED,
requestBody: jsonBody(CompleteChecklistRequest, 'Which row was completed — `{ ItemIndex }`'),
responses: {
200: json(ChecklistCompleteResponse, 'The balance-update envelope, granting nothing'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// The body names the row (`{ ItemIndex: 1 }`, or `Id` as a fallback) — read only
// once there is somewhere to record it.
return c.json({
BalanceUpdates: [{ UpdateResponse: CHECKLIST_REWARD_CONTEXT, Data: [] }],
Balance: 0,
CurrencyType: CurrencyType.RecCenterTokens,
BalanceType: -2,
})
}
)
// The caller's item wishlist. [Authorize]; empty — nothing stores wishlists yet.
.get(
'/api/checklist/v1/current',
listRoute('NUX checklist', 'The new-user checklist; [] for now. A 404 can abort load.', true),
'/api/itemWishlists/v1/wishlist/me',
listRoute('The players item wishlist', 'Empty for now', true),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
@@ -1388,10 +1791,39 @@ const app = new Hono<App>({ strict: false })
}
)
// The player's item wishlist. [Authorize]; empty without a DB binding.
// Another player's item wishlist, by account id — what the client reads to show what
// somebody else is hoping for (and to mark items in the store as already wished for).
// Empty like `/me`: nothing stores wishlists, so there is nothing to show for anyone.
//
// Registered AFTER `/me` so that path stays its own route rather than being read as an
// account id — the pattern here is digits-only, so it could not swallow `me`, but the
// order also says which is the special case.
.get(
'/api/itemWishlists/v1/wishlist/me',
listRoute('The players item wishlist', 'Empty for now', true),
'/api/itemWishlists/v1/wishlist/:accountId{[0-9]+}',
describeRoute({
tags: ['Econ'],
summary: 'Another players item wishlist',
description: [
'The wishlist of the account named in the path, as a bare array. Empty for now —',
'nothing on this server stores wishlists, so every players is empty, and an empty',
'list is what the client renders as “nothing wished for” where a 404 would read as a',
'failed load.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'The account whose wishlist to read',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(JsonArray, 'That players wishlist — empty for now'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
@@ -1674,6 +2106,84 @@ const app = new Hono<App>({ strict: false })
c.json([])
)
// The room-economy surface the client asks for on entering a room: the room's own
// inventory/offers/gift-drop shops and the caller's slice of them. Nothing here is
// stored yet, so every one is an empty list — the client reads that as "this room
// sells nothing" and renders no shop, where a 404 stalls the room load instead.
//
// The `/player` and `purchaseCounts` variants are caller-scoped but deliberately
// unauthed, matching the `roomConsumable/.../me` stub above: an empty list is the
// same answer for every caller, so there's nothing to protect until something
// writes here. Gate them when they start returning real data.
.get(
'/econ/roomInventory/room/:roomId',
listRoute('A rooms inventory', 'Empty stub so the client doesnt 404'),
(c) => c.json([])
)
.get(
'/econ/roomInventory/room/:roomId/player',
listRoute('The callers inventory in a room', 'Empty stub'),
(c) => c.json([])
)
.get(
'/econ/roomInventoryItemTags/room/:roomId',
listRoute('A rooms inventory item tags', 'Empty stub'),
(c) => c.json([])
)
.get('/econ/roomOffer/room/:roomId', listRoute('A rooms offers', 'Empty stub'), (c) =>
c.json([])
)
.get(
'/econ/roomOffer/room/:roomId/purchaseCounts',
listRoute('Per-offer purchase counts for a room', 'Empty stub'),
(c) => c.json([])
)
.get(
'/econ/roomGiftDropShops/room/:roomId',
listRoute('A rooms gift-drop shops', 'Empty stub'),
(c) => c.json([])
)
// A room's economy config, asked for alongside the room-economy lists above. The only
// setting is whether the room's shop UI groups its offers into sorting tabs; nothing
// stores per-room config yet, so every room answers false and the client renders one
// flat list. [Authorize] — unlike the empty-list stubs above this is a real answer the
// client acts on, so it takes the same token the rest of the econ surface does.
.get(
'/econ/roomEconConfig/:roomId',
describeRoute({
tags: ['Econ'],
summary: 'A rooms economy config',
description: [
'Whether the rooms shop groups offers into sorting tabs. No per-room config is',
'stored, so this is always false; the `RoomId` is echoed from the path.',
].join(' '),
security: AUTHED,
responses: {
200: json(RoomEconConfig, 'The rooms economy config'),
400: { description: 'Non-numeric roomId (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
if (Number.isNaN(roomId)) return c.body(null, 400)
return c.json({ RoomId: roomId, EnableSortingTabs: false })
}
)
// The UGC items a room sells (the creator-made things on sale inside it). Same empty
// stub as the room-economy routes above and asked for on the same room load: nothing
// stores room UGC purchasables yet, and an empty list reads as "this room sells
// nothing" where a 404 stalls the load.
.get(
'/api/ugcPurchasables/v1/items/room/:roomId',
listRoute('A rooms UGC purchasables', 'Empty stub so the client doesnt 404'),
(c) => c.json([])
)
// Unlocked consumables. [Authorize]. The consumables the player has bought (from
// `buyItem`, stored in the `consumable` table), grouped by item into the client's
// unlocked-consumable DTO. A player who has bought none gets an empty list.
@@ -1923,12 +2433,7 @@ const app = new Hono<App>({ strict: false })
// `granted.drop` is what the roll landed on — the response has to describe THAT, not
// the box, or a query purchase answers with every item field empty and the client
// draws an empty box.
const { id: giftId, drop: granted } = await grantGiftDrop(
c,
receiverId,
item.GiftDrop,
message
)
const granted = await grantGiftDrop(c, receiverId, item.GiftDrop, message)
// Push the spend to the buyer (`id` — the caller is who was charged) so their client
// updates without waiting for a `GET /balance` re-fetch. StorefrontBalancePurchase
@@ -1940,36 +2445,18 @@ const app = new Hono<App>({ strict: false })
// The response mirrors a captured real buyItem: `Balance` is the change applied (the
// negated price), not the resulting balance (the client reads its new total from
// `GET /balance/:type`); `BalanceType` is -2 (account-wide, all platforms). The Data
// entry is the gift-drop the client RECEIVED — the rolled item for a query box, the
// bought drop otherwise — and it carries no FriendlyName or consumable count (the
// count is a getUnlocked concept; each box is one instance).
// `GET /balance/:type`); `BalanceType` is -2 (account-wide, all platforms).
return c.json({
BalanceUpdates: [
{
UpdateResponse: 0,
Data: [
{
Id: giftId,
FromPlayerId: fromPlayerId,
ConsumableItemDesc: granted.ConsumableItemDesc,
AvatarItemDesc: granted.AvatarItemDesc,
AvatarItemType: granted.AvatarItemType ?? 0,
EquipmentPrefabName: granted.EquipmentPrefabName,
EquipmentModificationGuid: granted.EquipmentModificationGuid,
CurrencyType: granted.CurrencyType,
Currency: granted.Currency,
Xp: granted.Xp ?? 0,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
GiftContext: Number.isInteger(gift?.GiftContext)
? (gift?.GiftContext as number)
: granted.Context,
GiftRarity: granted.Rarity,
Message: message,
},
toBalanceUpdateData(
granted,
fromPlayerId,
message,
Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
),
],
},
],
@@ -1980,6 +2467,229 @@ const app = new Hono<App>({ strict: false })
}
)
// Check out a whole shopping bag. [Authorize]. The client posts every line it has in the
// bag — an item id, `DuplicateItemCount` copies, the unit price it rendered and an
// optional Gift — plus the ONE storefront and the ONE currency they all share.
//
// The whole bag is debited in ONE `spendCurrency` call. Charging line by line would let a
// bag half-succeed on a race with another spend, and would push a balance frame per line.
//
// The response is NOT buyItem's envelope. It is `{ Success, Error, error_id, Value }`
// (`error_id` lowercase — the client renames that one member; the other three are
// PascalCase), and `Value` is a BalanceUpdateResponse: the RESULTING `{ Balance,
// CurrencyType, Platform }` — `Platform` there being a renamed `BalanceType`, i.e. the
// bucket, not a store — plus ONE `BalanceUpdates` entry per REQUESTED item.
//
// Per-line reporting is that entry's `UpdateResponse`: a line that didn't sell comes back
// non-OK with a null `GiftPackage`, and `AllowPartialSuccess` is what lets those sit
// beside successful ones while `Success` stays true. Without it, one bad line refuses the
// whole bag — `Success: false`, the reason in `Error`, a null `Value`, nothing charged.
.post(
'/api/items/bulkpurchase',
describeRoute({
tags: ['Storefront'],
summary: 'Buy a bag of storefront items',
description: [
'Resolves every line against the bags storefront catalog (one read for the whole',
'bag), confirms each lines `RequestedPrice` still matches, debits the total in ONE',
'atomic spend, grants what sold, and answers the `{ Success, Error, error_id, Value }`',
'envelope. `Value.Balance` is the RESULTING total (not buyItems change) in the',
'`Platform` bucket named beside it, and `BalanceUpdates` carries one entry per',
'REQUESTED item, each with its own `UpdateResponse`. `AllowPartialSuccess` lets some',
'of those be non-OK while `Success` stays true; without it a single bad line refuses',
'the bag and nothing is charged.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(BulkPurchaseRequest, 'The bag: its lines, storefront and currency'),
responses: {
200: json(BulkPurchaseResponse, 'The bags result, or `Success: false` if nothing sold'),
400: json(BulkPurchaseResponse, 'A request that could not be evaluated at all'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// Every refusal answers the same envelope, so a client that only knows how to parse
// this shape never has to special-case one. A null `Value` is legal here (the client's
// validator only cascades into a non-null one), and it is the honest answer: nothing
// was bought, so there is no balance to report and nothing to render.
const refuse = (error: string, status: 200 | 400 = 200) =>
c.json({ Success: false, Error: error, error_id: null, Value: null }, status)
const body = (await c.req.json().catch(() => null)) as {
PurchaseItemRequests?: PurchaseItemRequest[]
StorefrontType?: number
CurrencyType?: number
BypassGiftPackages?: boolean
AllowPartialSuccess?: boolean
ShoppingBagId?: string | number | null
} | null
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return refuse('Invalid request body', 400)
}
const lines = body.PurchaseItemRequests
if (!Array.isArray(lines) || lines.length === 0) {
return refuse('PurchaseItemRequests must be a non-empty array', 400)
}
const storefrontType = body.StorefrontType
const currencyType = body.CurrencyType
if (!Number.isInteger(storefrontType) || !Number.isInteger(currencyType)) {
return refuse('StorefrontType and CurrencyType are required', 400)
}
// The bag's currency must be an account balance we can debit, exactly as buyItem's.
if (!isSpendable(currencyType as number)) {
return refuse('Currency type is not spendable', 400)
}
const allowPartial = body.AllowPartialSuccess === true
const skipGiftBox = body.BypassGiftPackages === true
// One catalog read for the bag; every line resolves against it in memory.
const storefront = await loadStorefront(c, storefrontType as number)
const resolved = lines.map((line) =>
resolveBulkLine(line, storefront, currencyType as number)
)
const buyable = resolved.filter(isBulkLine)
const copies = buyable.reduce((n, line) => n + line.count, 0)
if (copies > BULK_PURCHASE_CAP) {
return refuse(`A bulk purchase is capped at ${BULK_PURCHASE_CAP} items`, 400)
}
// All-or-nothing: one unbuyable line stops the bag before anything is charged, and the
// client is told why by the first thing that was wrong with it.
const firstFailure = resolved.find((line): line is BulkLineFailure => !isBulkLine(line))
if (!allowPartial && firstFailure !== undefined) return refuse(firstFailure.error)
// Decide what the balance covers BEFORE spending: lines are taken in request order
// while they fit, so a bag that overruns still buys the items the player put in first.
// The read is only for choosing; the single spend below is what actually settles, and
// its `amount >= ?` guard is what makes that safe against a concurrent spend.
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
const balance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
const affordable: BulkPurchaseLine[] = []
let total = 0
for (const line of buyable) {
const cost = line.price * line.count
if (total + cost > balance) continue
total += cost
affordable.push(line)
}
const bought = new Set(affordable)
// Without partial success an unaffordable line fails the whole bag — including the
// lines that would have fitted, since the player asked for all of it or none.
if (!allowPartial && affordable.length !== buyable.length) {
return refuse('Insufficient balance')
}
// Nothing sold at all: there is no purchase to report, so this is a refusal rather
// than a `Success: true` bag full of non-OK entries.
if (affordable.length === 0) {
return refuse(firstFailure?.error ?? 'Insufficient balance')
}
// One atomic debit for the whole bag. A false return means another request spent the
// tokens between the read above and here, so nothing is granted and nothing changed.
if (
total > 0 &&
!(await spendCurrency(c.env.DB, id, currencyType as number, total, startingTokens))
) {
return refuse('Insufficient balance')
}
// A query drop (a loot box) rolls against sf3, the big catalog. Read it ONCE for the
// whole bag and only when a line actually holds one — a bag of ordinary items should
// not pull a thousand-item catalog in to grant them.
const rollCatalog = affordable.some((line) => line.item.GiftDrop.IsQuery === true)
? await loadRollCatalog(c)
: undefined
// Grant what sold, keeping each line's box so the entry built below can carry it.
const packages = new Map<BulkPurchaseLine, Record<string, unknown> | null>()
for (const line of affordable) {
// Same routing as buyItem: a Gift block sends the item (and its box) to another
// player while the caller pays, a named gift shows the sender, and a self-buy or an
// anonymous gift is attributed to the "Coach" system account.
const gift = line.gift
const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
// One box per requested item, holding all `count` copies — the wire has one
// `GiftPackage` per entry, and only a consumable can be asked for more than once
// (`resolveBulkLine` refuses a bigger count on anything owned once).
const granted = await grantGiftDrop(c, receiverId, line.item.GiftDrop, message, {
rollCatalog,
skipGiftBox,
copies: line.count,
})
packages.set(
line,
// Null under `BypassGiftPackages`, which is the flag asking for exactly that —
// the item is granted either way.
skipGiftBox
? null
: toGiftPackage(
granted,
receiverId,
fromPlayerId,
message,
Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
)
)
}
// One entry per REQUESTED item, in request order — the failures included, which is
// where a partial bag says what it left behind.
const updates = resolved.map((line) => {
if (!isBulkLine(line)) {
return {
UpdateResponse: line.code,
Data: {
GiftPackage: null,
PurchasableItemId: line.method.NumberId,
CustomAvatarItem: null,
} satisfies BulkPurchaseData,
}
}
return {
UpdateResponse: bought.has(line) ? UpdateResponse.OK : UpdateResponse.NotEnoughCredit,
Data: {
GiftPackage: packages.get(line) ?? null,
PurchasableItemId: line.method.NumberId,
CustomAvatarItem: null,
} satisfies BulkPurchaseData,
}
})
// One frame for the whole bag, not one per line: it SETS the account-wide bucket to the
// resulting total read back from D1, so it agrees with the `Value.Balance` below and
// with a `GET /balance` re-fetch instead of compounding — see the frame rule above
// pushBalanceUpdate. Nothing moved on a free bag, so nothing is sent and the balance
// read for the affordability check above still stands.
let newBalance = balance
if (total > 0) {
newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
await pushBalancePurchase(c, id, currencyType as number, -total, newBalance)
}
return c.json({
Success: true,
Error: null,
error_id: null,
Value: {
// The RESULTING total, unlike buyItem's change — and the bucket it belongs to.
// `Platform` here is the client's `BalanceType` under a [DataMember] rename. A
// capture from the reference server says 4 (RecNetPurchased) because it kept a
// wallet per store; this server keeps ONE account-wide bucket, and the client SUMS
// its buckets, so naming any other platform invents a second balance beside the
// real one. See the frame rule above pushBalanceUpdate.
Balance: newBalance,
CurrencyType: currencyType,
Platform: ALL_PLATFORMS,
BalanceUpdates: updates,
},
})
}
)
// Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends
// `?inventionId=…&requestedPrice=…` with no body, so that's what we answer.
//
@@ -2342,6 +3052,34 @@ const app = new Hono<App>({ strict: false })
c.json([])
)
// The Rec Room Plus sign-up bonus: which bonus is running and the token price window
// the free items are drawn from. Fixed numbers, the same for every caller.
//
// Unauthenticated, like the subscription lookup below and for the same reason: the
// client reads this while putting the RR+ page together, nothing in the answer is
// per-account, and a 401 would only be a way for that load to stall. (The `api` copy of
// this path does validate a token, mirroring the reference server.)
.get(
'/api/CampusCard/v1/SignUpBonus',
describeRoute({
tags: ['Econ'],
summary: 'Rec Room Plus sign-up bonus',
description: [
'The bonus a player gets for taking out Rec Room Plus: which bonus is running',
'(`RRPlusSignUpBonusId`) and the token price window the free items are picked from.',
'Fixed values — nothing here is per-account or stored, so no auth is required and',
'every caller gets the same three numbers.',
].join(' '),
responses: { 200: json(RRPlusSignUpBonus, 'The running sign-up bonus') },
}),
(c) =>
c.json({
RRPlusSignUpBonusId: 3,
MinFreeItemsPrice: 6000,
MaxFreeItemsPrice: 10000,
})
)
// Subscription lookup (Rec Room Plus, the client's `CampusCard`). There is no store to
// buy one from, so the `developer` role stands in for a paid subscription: a developer
// reports an active Gold year, everyone else reports none. Nothing is stored — see
@@ -2380,6 +3118,165 @@ const app = new Hono<App>({ strict: false })
}
)
// The subscription seasons running right now (the RR+ seasonal reward tracks). Nothing
// here runs a season, so this is an empty-list stub — the client reads it as "no season
// in progress" and skips the seasonal UI, where a 404 stalls the RR+ page load.
.get(
'/api/subscriptionseasons/v1/seasons/current',
listRoute('Current subscription seasons', 'Empty stub — no RR+ season is running'),
(c) => c.json([])
)
// Whether the caller can start a Maker AI free trial. Always false, mirroring the
// reference server: nothing here runs trials, and false is the answer that leaves the
// client's creation UI in its normal state rather than offering a trial that can't
// start. The body is a BARE JSON `false` — not an envelope, not `{ value: false }`.
//
// Auth-gated (401 on a missing or invalid token) even though the answer is the same for
// everyone, because the reference validates the token before answering and eligibility
// is a per-account question the moment anything does run trials.
.get(
'/api/makerai/checkfreetrialeligibility',
describeRoute({
tags: ['Econ'],
summary: 'Maker AI free-trial eligibility',
description: [
'Whether the caller can start a Maker AI free trial. Always `false` — nothing here',
'runs trials. The body is a bare JSON boolean, not an envelope.',
].join(' '),
security: AUTHED,
responses: {
200: json(MakerAiFreeTrialEligibilityResponse, 'Always `false`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(false)
}
)
// The caller's progress through the refer-a-friend rewards: how many of their referrals
// have been verified, and which rewards they have taken from that track.
//
// Nothing here runs a referral programme, so nobody has referred anybody: the count is 0
// and the reward list is empty. That is a real answer rather than a stub — it is what a
// player who has referred nobody sees — so the client draws an untouched track, which is
// exactly the state this server is in.
//
// The payload is NESTED under `value`, unlike econ's flat balance bodies.
.get(
'/api/incentivizedreferrals/progress',
describeRoute({
tags: ['Econ'],
summary: 'The callers referral-reward progress',
description: [
'How many of the callers referrals have been verified and which referral rewards they',
'have claimed, under a `{ success, value }` envelope. Always 0 and empty — no referral',
'programme runs here — which the client renders as an untouched reward track.',
].join(' '),
security: AUTHED,
responses: {
200: json(ReferralProgressResponse, 'The callers progress — always zero'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
success: true,
value: { ReferralsVerifiedCount: 0, PlayerReferralRewards: [] },
})
}
)
// Everyone in the influencer partner program, by account id — the list the client keeps
// so it can badge an influencer wherever they turn up, rather than asking per player.
//
// Empty: no programme runs here, so there is nobody to list. Note this is the LIST
// counterpart of the single-account check below, and the two answer very differently —
// that one 404s to say "not an influencer", this one is a 200 carrying an empty list,
// because "nobody is" is a complete answer to "who is?".
//
// `take` is accepted and ignored; there is nothing to page through.
.get(
'/api/influencerpartnerprogram/influencers',
describeRoute({
tags: ['Econ'],
summary: 'Every influencer in the partner program',
description: [
'The account ids in the influencer partner program, as `{ InfluencerIds }` — an object',
'around the list, not a bare array. Always empty here: no programme runs on this',
'server. `take` is accepted and ignored, there being nothing to page.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'take',
in: 'query',
required: false,
description: 'How many ids to return. Accepted and ignored.',
schema: { type: 'integer' },
},
],
responses: {
200: json(InfluencerIdsResponse, 'The influencer ids — always empty'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ InfluencerIds: [] })
}
)
// Whether the caller is in the influencer partner program. NOBODY is: this server runs
// no such program, and "not an influencer" is a 404 rather than a body saying so — the
// reference answers 404 with an EMPTY body typed `application/json`, which is what the
// client branches on. A 200 carrying null or `{}` is a different answer to it.
//
// Deliberately built by hand rather than through `c.notFound()`: the worker's not-found
// handler answers its own body, and this has to be empty with that content type.
//
// `accountId` is accepted and ignored — the reference binds it and never reads it, the
// answer being the same for everyone. The token is still validated first, so an
// unauthenticated caller gets 401 rather than the 404.
.get(
'/api/influencerpartnerprogram/influencer',
describeRoute({
tags: ['Econ'],
summary: 'The callers influencer partner program status',
description: [
'Always 404 with an EMPTY body typed `application/json` — this server runs no partner',
'program, and 404 is how the reference says “not an influencer”. `accountId` is',
'accepted and ignored; the answer is the same for every caller. Auth is checked first,',
'so a missing or invalid token is 401, not 404.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'accountId',
in: 'query',
required: false,
description: 'The account being asked about. Accepted and ignored.',
schema: { type: 'integer' },
},
],
responses: {
404: { description: 'Not in the partner program — always. Empty body' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.body('', 404, { 'Content-Type': 'application/json' })
}
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
+37
View File
@@ -40,6 +40,43 @@ export interface AvatarItem extends Record<string, unknown> {
Rarity: number
}
/**
* The camelCase DTO `GET /api/avatar/v4/items` serves. Distinct from the PascalCase
* `AvatarItem` we store and from what the sibling item endpoints (`defaultunlocked`,
* `defaultbaseavataritems`) serve — those hand back their stored/bundled records raw.
*/
export interface AvatarItemV4 {
avatarItemId: number
avatarItemDesc: string
friendlyName: string
tooltip: string
tagList: string
avatarItemType: number
rarity: number
isBaseAvatarItem: boolean
}
/**
* Project a stored or bundled avatar item into the v4 DTO. Neither source carries an
* `AvatarItemId`, a `TagList` or an `IsBaseAvatarItem` flag — the storefront gift-drops
* we grant from have none and the default catalog has none either — so those default to
* 0 / "" / false rather than being invented.
*/
export function toAvatarItemV4(item: Record<string, unknown>): AvatarItemV4 {
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
return {
avatarItemId: num(item.AvatarItemId),
avatarItemDesc: str(item.AvatarItemDesc),
friendlyName: str(item.FriendlyName),
tooltip: str(item.Tooltip),
tagList: str(item.TagList),
avatarItemType: num(item.AvatarItemType),
rarity: num(item.Rarity),
isBaseAvatarItem: item.IsBaseAvatarItem === true,
}
}
/**
* Grant an item into a player's inventory. Upserts on (account_id, avatar_item_desc):
* owning an item is boolean, so re-buying it refreshes the stored DTO rather than
+228 -8
View File
@@ -99,6 +99,15 @@ export const BalanceEntry = z.object({
Balance: z.int(),
})
/**
* `GET /econ/roomEconConfig/:roomId` — a room's economy configuration. Only the
* shop's sorting-tabs toggle is configurable, and nothing stores it yet.
*/
export const RoomEconConfig = z.object({
RoomId: z.int().describe('Echoed back from the path'),
EnableSortingTabs: z.boolean().describe('Always false — no per-room config is stored'),
})
/** `GET /econ/customAvatarItems/v1/owned` — paginated owned custom items. */
export const CustomAvatarItemsResponse = z.object({
Results: JsonArray,
@@ -130,6 +139,56 @@ export const SubscriptionDto = z.object({
ModifiedAt: z.string(),
})
/**
* One item as `GET /api/avatar/v4/items` serves it — camelCase, unlike the PascalCase
* records the sibling item endpoints hand back. `avatarItemId` is 0 and `tagList` empty
* for every item we have: neither the default catalog nor a storefront gift-drop carries
* them.
*/
export const AvatarItemV4Dto = z.object({
avatarItemId: z.int(),
avatarItemDesc: z.string().describe('The comma-delimited item descriptor, commas and all'),
friendlyName: z.string(),
tooltip: z.string(),
tagList: z.string(),
avatarItemType: z.int(),
rarity: z.int(),
isBaseAvatarItem: z.boolean(),
})
/**
* `POST /api/checklist/v1|v2/complete` JSON body — which checklist row was finished.
* The client posts just `{ "ItemIndex": 1 }`; `Id` is the fallback key read when
* `ItemIndex` is absent or 0.
*/
export const CompleteChecklistRequest = z.object({
ItemIndex: z.int().describe('The rows index — what the client actually sends'),
Id: z.int().optional().describe('Fallback row id, read when ItemIndex is absent or 0'),
})
/**
* `POST /api/checklist/v1|v2/complete` — the balance-update envelope, the same shape
* buyItem answers with. `Balance` is the CHANGE applied, so a stubbed (ungranted)
* completion reports 0. `UpdateResponse` 303 is the checklist-reward context.
*/
export const ChecklistCompleteResponse = z.object({
BalanceUpdates: z.array(z.object({ UpdateResponse: z.int(), Data: z.array(JsonObject) })),
Balance: z.int().describe('The change applied — 0 while completion is stubbed'),
CurrencyType: z.int(),
BalanceType: z.int().describe('-2 = account-wide'),
})
/**
* One row of the new-user checklist (`GET /api/checklist/v1|v2/current`). `Objective` is
* an `ObjectiveType` ordinal the client matches its own progress events against.
*/
export const ChecklistEntry = z.object({
Order: z.int().describe('Position in the list, from 0'),
Objective: z.int().describe('ObjectiveType ordinal, e.g. 38 = SaveOutfitSlot'),
Count: z.int().describe('How many times the objective must happen'),
CreditAmount: z.int().describe('Tokens awarded on completion'),
})
/**
* `POST /api/CampusCard/v1/UpdateAndGetSubscription` — the caller's subscription, or `{}`
* when they have none (which is everyone without the `developer` role). `{}` rather than a
@@ -145,6 +204,55 @@ export const SubscriptionResponse = z.union([
z.object({}).describe('`{}` — no subscription'),
])
/**
* `GET /api/CampusCard/v1/SignUpBonus` — the Rec Room Plus sign-up bonus. Fixed values,
* not per-account: `RRPlusSignUpBonusId` names the bonus that is running and the two
* prices are the token window the free items are drawn from.
*/
export const RRPlusSignUpBonus = z.object({
RRPlusSignUpBonusId: z.int().describe('Which sign-up bonus is running'),
MinFreeItemsPrice: z.int().describe('Lowest token price a free item may have'),
MaxFreeItemsPrice: z.int().describe('Highest token price a free item may have'),
})
/**
* `GET /api/influencerpartnerprogram/influencers` — the ids of every influencer in the
* partner program, which the client uses to badge them wherever they appear. An object
* around the list, not a bare array.
*/
export const InfluencerIdsResponse = z.object({
InfluencerIds: z
.array(z.int())
.describe('Account ids in the partner program. Empty — no programme runs here'),
})
/**
* `GET /api/incentivizedreferrals/progress` — how far the caller has got with the
* refer-a-friend rewards: how many referrals have been verified, and which rewards they
* have taken from that track.
*
* A `{ success, value }` envelope with the payload nested — not the flat bodies the balance
* routes answer with. Nothing here runs a referral programme, so the count is 0 and the
* reward list is empty: a player who has referred nobody, which is everybody.
*/
export const ReferralProgressResponse = z.object({
success: z.boolean(),
value: z.object({
ReferralsVerifiedCount: z.int().describe('Referrals that have been verified. Always 0'),
PlayerReferralRewards: z
.array(z.unknown())
.describe('Rewards claimed off the referral track. Always empty'),
}),
})
/**
* `GET /api/makerai/checkfreetrialeligibility` — a BARE JSON boolean (`false`), not an
* envelope and not a `{ value }` wrapper. The whole body is the answer.
*/
export const MakerAiFreeTrialEligibilityResponse = z
.boolean()
.describe('Whether the caller can start a Maker AI free trial; always false')
/** `POST /api/challenge/v2/updateProgress` — the identifying fields echoed back. */
export const ChallengeProgressResponse = z.object({
ChallengeMapId: z.int(),
@@ -184,6 +292,73 @@ export const BuyItemResponse = z.object({
BalanceType: z.int().describe('-2 = account-wide'),
})
/**
* How a bulk-purchase line names its item. A discriminated id: the client buys both
* catalog items (a storefront `PurchasableItemId`, under `NumberId`, `Type` 0) and
* guid-keyed ones (UGC / custom avatar items). Only the numeric form resolves here —
* nothing sells guid-keyed items yet, so a `Guid` id fails its line.
*/
export const ItemPurchaseMethodId = z.object({
Type: z.int().describe('0 = NumberId. Anything else names a guid-keyed item we cant sell'),
NumberId: z.int().nullable().optional().describe('The storefront PurchasableItemId'),
Guid: z.string().nullable().optional().describe('The guid-keyed item id; always null here'),
})
/**
* `POST /api/items/bulkpurchase` — the whole bag's result.
*
* NOT buyItem's envelope. The wrapper is `{ Success, Error, error_id, Value }` — `error_id`
* lowercase because the client renames that one member, the other three PascalCase — and
* `Value` is a BalanceUpdateResponse: the RESULTING `{ Balance, CurrencyType, Platform }`
* (buyItem reports the change instead) plus one `BalanceUpdates` entry per REQUESTED item.
* `Value` is null whenever nothing was bought; the client's validator only cascades into a
* non-null one, so that parses.
*
* Per-line reporting is each entry's `UpdateResponse`. `AllowPartialSuccess` is what lets
* some of them come back non-OK while `Success` stays true.
*/
export const BulkPurchaseResponse = z.object({
Success: z.boolean().describe('False only when the bag bought nothing at all'),
Error: z.string().nullable().describe('Why nothing was bought; null on success'),
error_id: z.string().nullable().describe('Always null — no error-id catalog here'),
Value: z
.object({
Balance: z.int().describe('The RESULTING total in the bucket below, NOT buyItems change'),
CurrencyType: z.int(),
Platform: z
.int()
.describe(
'The balance bucket — the clients `BalanceType` under a [DataMember] rename. -2, ' +
'account-wide: the reference server said 4 (RecNetPurchased) because it kept a ' +
'wallet per store; this one keeps a single bucket, and the client SUMS its buckets'
),
BalanceUpdates: z
.array(
z.object({
UpdateResponse: z
.int()
.describe(
'This lines outcome: 0 OK, 1 TooManyRequests, 2 NotEnoughCredit, ' +
'3 AlreadyOwned, 4 NoItemAvailable, 5 CouponNotApplicable, ' +
'6 RequestedPriceDoesNotMatch, 7 RequestedAmountNotAllowed, ' +
'8 PlayerNotEligible, 9 RequestCannotBeRefunded, 10 PlayerNotApproved'
),
Data: z.object({
GiftPackage: JsonObject.nullable().describe(
'The box created for this line (20 keys). Null on a line that didnt sell, ' +
'and under `BypassGiftPackages` — the item is granted either way'
),
PurchasableItemId: z.int().nullable().describe('The catalog item this line named'),
CustomAvatarItem: z.null().describe('The UGC counterpart; never sold here'),
}),
})
)
.describe('One entry per REQUESTED item, in request order — failures included'),
})
.nullable()
.describe('Null when nothing was bought'),
})
/**
* `GET /api/storefronts/v2/buyInvention` — the purchase result. Two envelopes side by
* side: the balance update (shaped like buyItem's, except `Balance` is the RESULTING
@@ -216,21 +391,66 @@ export const ErrorResponse = z.object({ error: z.string() })
// ---- Request schemas -------------------------------------------------------
/**
* The `Gift` block both purchase bodies carry — present when buying an item for another
* player. The caller is still the one debited.
*/
export const GiftBlock = z
.object({
ToPlayerId: z.int().optional(),
Anonymous: z.boolean().optional(),
Message: z.string().optional(),
GiftContext: z.int().optional(),
})
.describe('Present when buying for another player; the caller still pays')
/** `POST /api/storefronts/v2/buyItem` JSON body. */
export const BuyItemRequest = z.object({
StorefrontType: z.int().describe('Which storefront catalog (sf{N}.json)'),
PurchasableItemId: z.int(),
CurrencyType: z.int().describe('Must be a spendable account currency'),
RequestedPrice: z.int().describe('The price the client rendered; a mismatch is 409'),
Gift: z
.object({
ToPlayerId: z.int().optional(),
Anonymous: z.boolean().optional(),
Message: z.string().optional(),
GiftContext: z.int().optional(),
})
Gift: GiftBlock.optional(),
})
/**
* `POST /api/items/bulkpurchase` JSON body — the shopping bag, checked out in one call.
* `StorefrontType` and `CurrencyType` are the bag's, not per line: every line is bought
* from one catalog with one currency.
*/
export const BulkPurchaseRequest = z.object({
PurchaseItemRequests: z
.array(
z.object({
ItemPurchaseMethodId,
RequestedPrice: z
.int()
.describe('The UNIT price the client rendered; a mismatch fails the line'),
Gift: GiftBlock.nullable().optional(),
CouponConsumablePlayerMappingId: z
.int()
.nullable()
.optional()
.describe('Unsupported — nothing issues coupons, so a non-null one fails the line'),
DuplicateItemCount: z.int().optional().describe('Copies of this item; defaults to 1'),
})
)
.describe('One line per item in the bag; at most Econ.BulkPurchaseCap (200) copies in total'),
StorefrontType: z.int().describe('Which storefront catalog (sf{N}.json) every line comes from'),
CurrencyType: z.int().describe('Must be a spendable account currency'),
BypassGiftPackages: z
.boolean()
.optional()
.describe('Present when buying for another player; the caller still pays'),
.describe('Grant the items without wrapping them in gift boxes'),
AllowPartialSuccess: z
.boolean()
.optional()
.describe('Buy the lines that work and report the rest; false is all-or-nothing'),
ShoppingBagId: z
.union([z.string(), z.int()])
.nullable()
.optional()
.describe('The clients bag id, echoed back untouched'),
})
/** `POST /api/consumables/v1/consume` JSON body. */
-59
View File
@@ -1,59 +0,0 @@
/**
* Saved outfits on the shared `recflare` D1 database — the outfit slots a player
* saves from the avatar screen (`POST /api/avatar/v3/saved/set`) and loads back from
* `GET /api/avatar/v3/saved`.
*
* One row per (account, slot). The outfit itself is stored as the opaque JSON payload
* the client posted: we never query inside it, and its fields (OutfitSelectionsV2,
* FaceFeatures, …) are themselves JSON-in-a-string produced by the client's own
* serializer. Round-tripping it verbatim is both the simplest and the safest thing —
* re-encoding risks changing a payload the client has to parse back.
*
* The `econ` worker owns this table and its migration (apps/econ/migrations/
* 0002_outfit.sql).
*/
/** Schema DDL (mirror of migrations 0002_outfit.sql) — also used to build the table in tests. */
export const OUTFIT_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS outfit (
account_id INTEGER NOT NULL,
set_id INTEGER NOT NULL,
avatar TEXT NOT NULL,
PRIMARY KEY (account_id, set_id)
)`,
]
/**
* A saved outfit, as the client posts it. `Slot` is the outfit slot it occupies (the
* `set_id` column) — saving to a slot the player already used overwrites it, which is
* exactly what the avatar screen's "save over this outfit" does. The rest of the
* payload (PreviewImageName, OutfitSelections, FaceFeatures, SkinColor, HairColor,
* CustomAvatarItems, …) is stored and served back untouched.
*/
export interface Outfit extends Record<string, unknown> {
Slot: number
}
/** Every outfit a player has saved, ordered by slot. */
export async function getOutfits(db: D1Database, accountId: number): Promise<Outfit[]> {
const { results } = await db
.prepare('SELECT avatar FROM outfit WHERE account_id = ?1 ORDER BY set_id')
.bind(accountId)
.all<{ avatar: string }>()
return results.map((r) => JSON.parse(r.avatar) as Outfit)
}
/**
* Save an outfit into one of the player's slots, replacing whatever was there. The
* upsert is keyed on (account_id, set_id), so re-saving a slot overwrites rather than
* accumulating duplicate rows for it.
*/
export async function setOutfit(db: D1Database, accountId: number, outfit: Outfit): Promise<void> {
await db
.prepare(
`INSERT INTO outfit (account_id, set_id, avatar) VALUES (?1, ?2, ?3)
ON CONFLICT (account_id, set_id) DO UPDATE SET avatar = ?3`
)
.bind(accountId, outfit.Slot, JSON.stringify(outfit))
.run()
}
+605 -30
View File
@@ -8,6 +8,7 @@ import {
getOwnedInventionIds,
getProgression,
INVENTORY_INVENTION_SCHEMA_DDL,
OUTFIT_SCHEMA_DDL,
PROGRESSION_SCHEMA_DDL,
RECEIVED_GIFT_SCHEMA_DDL,
} from '@repo/domain'
@@ -33,7 +34,6 @@ import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../ch
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
import type { Env } from '../../context'
@@ -195,10 +195,15 @@ describe('econ endpoints', () => {
expect(body[0]).toHaveProperty('AvatarItemDesc')
})
test('GET /api/avatar/v1/defaultbaseavataritems is an empty stub (no auth)', async () => {
test('GET /api/avatar/v1/defaultbaseavataritems returns the base items (no auth)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/defaultbaseavataritems`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
const body = (await res.json()) as Array<Record<string, unknown>>
expect(body.map((i) => i.AvatarItemId)).toEqual([2184, 2918])
// The client keys these off IsBaseAvatarItem, and the trailing comma in the desc
// is part of the item descriptor — both are served verbatim.
expect(body.every((i) => i.IsBaseAvatarItem === true)).toBe(true)
expect(body[0]?.AvatarItemDesc).toBe('c5d70cb4-71dd-4fe4-b719-34fe2073c611,')
})
test('GET /api/avatar/v4/items 401s without a token', async () => {
@@ -206,16 +211,34 @@ describe('econ endpoints', () => {
expect(res.status).toBe(401)
})
test('GET /api/avatar/v4/items returns the item catalog with a valid token', async () => {
test('GET /api/avatar/v4/items serves the catalog in the camelCase v4 shape', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
const body = (await res.json()) as unknown[]
expect(Array.isArray(body)).toBe(true)
const body = (await res.json()) as Array<Record<string, unknown>>
expect(body.length).toBeGreaterThan(0)
expect(body[0]).toHaveProperty('AvatarItemDesc')
expect(body[0]).toHaveProperty('FriendlyName')
// Every key of the DTO is present on every item, and nothing PascalCase leaks
// through from the stored/bundled records.
for (const item of body) {
expect(Object.keys(item).sort()).toEqual([
'avatarItemDesc',
'avatarItemId',
'avatarItemType',
'friendlyName',
'isBaseAvatarItem',
'rarity',
'tagList',
'tooltip',
])
}
expect(typeof body[0]?.avatarItemDesc).toBe('string')
expect(typeof body[0]?.friendlyName).toBe('string')
// The catalog carries no ids, tags or base flag — those default rather than
// being invented.
expect(body[0]?.avatarItemId).toBe(0)
expect(body[0]?.tagList).toBe('')
expect(body[0]?.isBaseAvatarItem).toBe(false)
})
test('GET /api/avatar/v2 401s without a token', async () => {
@@ -397,14 +420,53 @@ describe('econ endpoints', () => {
expect(body.isCompleted).toBe(false)
})
test('GET /api/checklist/v1/current 401s without a token, returns [] with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`, {
headers: await bearer(),
test('GET /api/checklist/v1|v2/current 401s without a token, serves the NUX list with one', async () => {
const expected = [
{ Order: 0, Objective: 38, Count: 1, CreditAmount: 25 },
{ Order: 1, Objective: 32, Count: 1, CreditAmount: 25 },
{ Order: 2, Objective: 2, Count: 1, CreditAmount: 25 },
{ Order: 3, Objective: 30, Count: 1, CreditAmount: 25 },
{ Order: 4, Objective: 6, Count: 1, CreditAmount: 25 },
]
// Both version paths are live and serve the same list.
for (const path of ['/api/checklist/v1/current', '/api/checklist/v2/current']) {
const anon = await exports.default.fetch(`${ORIGIN}${path}`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}${path}`, { headers: await bearer() })
expect(res.status).toBe(200)
expect(await res.json()).toEqual(expected)
}
})
test('POST /api/checklist/v1|v2/complete 401s without a token, grants nothing with one', async () => {
for (const path of ['/api/checklist/v1/complete', '/api/checklist/v2/complete']) {
const anon = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ItemIndex: 1 }),
})
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer('33')), 'Content-Type': 'application/json' },
body: JSON.stringify({ ItemIndex: 1 }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
BalanceUpdates: [{ UpdateResponse: 303, Data: [] }],
Balance: 0,
CurrencyType: 2,
BalanceType: -2,
})
}
// Stubbed, so completing rows does not move the balance — re-posting cannot farm
// tokens, and the checklist still lists every row.
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('33'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 }])
})
test('GET /api/itemWishlists/v1/wishlist/me 401s without a token, returns [] with one', async () => {
@@ -417,6 +479,23 @@ describe('econ endpoints', () => {
expect(await res.json()).toEqual([])
})
test('GET /api/itemWishlists/v1/wishlist/:accountId 401s without a token, returns []', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/itemWishlists/v1/wishlist/207`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/itemWishlists/v1/wishlist/207`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
// `me` is still its own route, not read as an account id.
const mine = await exports.default.fetch(`${ORIGIN}/api/itemWishlists/v1/wishlist/me`, {
headers: await bearer(),
})
expect(mine.status).toBe(200)
})
test('GET /api/avatar/v3/saved 401s without a token, returns [] with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`)
expect(anon.status).toBe(401)
@@ -573,6 +652,41 @@ describe('econ endpoints', () => {
expect(await res.json()).toEqual([])
})
// The room-economy stubs. One table-driven test: they're the same empty-list answer,
// and what's worth pinning is that every path the client asks for on room entry is
// registered — an unregistered one 404s and stalls the room load.
test('the room-economy endpoints all return []', async () => {
for (const path of [
'/econ/roomInventory/room/92',
'/econ/roomInventory/room/92/player',
'/econ/roomInventoryItemTags/room/92',
'/econ/roomOffer/room/92',
'/econ/roomOffer/room/92/purchaseCounts',
'/econ/roomGiftDropShops/room/92',
'/api/ugcPurchasables/v1/items/room/92',
]) {
const res = await exports.default.fetch(`${ORIGIN}${path}`)
expect(res.status, path).toBe(200)
expect(await res.json(), path).toEqual([])
}
})
test('GET /econ/roomEconConfig/:roomId echoes the room and disables sorting tabs', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/econ/roomEconConfig/92`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/econ/roomEconConfig/92`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ RoomId: 92, EnableSortingTabs: false })
const bad = await exports.default.fetch(`${ORIGIN}/econ/roomEconConfig/nope`, {
headers: await bearer(),
})
expect(bad.status).toBe(400)
})
test('GET /api/consumables/v2/getUnlocked 401s without a token, returns []', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`)
expect(anon.status).toBe(401)
@@ -816,9 +930,9 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('20'),
})
const list = (await items.json()) as Array<{ AvatarItemDesc: string; FriendlyName: string }>
expect(list[0].FriendlyName).toBe('Bowtie (White)')
expect(list[0].AvatarItemDesc).toBe(gift.AvatarItemDesc)
const list = (await items.json()) as Array<{ avatarItemDesc: string; friendlyName: string }>
expect(list[0].friendlyName).toBe('Bowtie (White)')
expect(list[0].avatarItemDesc).toBe(gift.AvatarItemDesc)
// And a pending gift box is waiting to be opened.
const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
@@ -899,8 +1013,8 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('25'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.every((i) => i.FriendlyName !== 'Supreme Pizza')).toBe(true)
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Supreme Pizza')).toBe(true)
// Buying it again stacks: a second instance, count summed to 2.
expect((await buy()).status).toBe(200)
@@ -966,8 +1080,8 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('31'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.every((i) => i.FriendlyName !== 'Disc Skin (Coop)')).toBe(true)
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Disc Skin (Coop)')).toBe(true)
expect(first[0].Favorited).toBe(false)
@@ -1066,8 +1180,359 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('23'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true)
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Bowtie (White)')).toBe(true)
})
// ---- POST /api/items/bulkpurchase ------------------------------------------------
// The shopping bag: many lines, one storefront, one currency, one debit. Its response is
// NOT buyItem's — it is the `{ Success, Error, error_id, Value }` envelope, `Value.Balance`
// is the RESULTING total rather than the change, and each `BalanceUpdates` entry carries
// its own `UpdateResponse` (0 OK, 2 NotEnoughCredit, 4 NoItemAvailable, 5
// CouponNotApplicable, 6 RequestedPriceDoesNotMatch, 7 RequestedAmountNotAllowed).
/** The shape every bulk-purchase response answers with. */
type BulkBody = {
Success: boolean
Error: string | null
error_id: string | null
Value: {
Balance: number
CurrencyType: number
Platform: number
BalanceUpdates: Array<{
UpdateResponse: number
Data: {
GiftPackage: Record<string, unknown> | null
PurchasableItemId: number | null
CustomAvatarItem: null
}
}>
} | null
}
/** A bag line, in the shape the client posts one. */
const line = (numberId: number, requestedPrice: number, extra: Record<string, unknown> = {}) => ({
ItemPurchaseMethodId: { Type: 0, NumberId: numberId, Guid: null },
RequestedPrice: requestedPrice,
Gift: null,
CouponConsumablePlayerMappingId: null,
DuplicateItemCount: 1,
...extra,
})
const bulkPurchase = async (sub: string, body: Record<string, unknown>) =>
exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
CurrencyType: 2,
BypassGiftPackages: false,
AllowPartialSuccess: true,
ShoppingBagId: null,
...body,
}),
})
/** The `UpdateResponse` of every entry, in request order. */
const codes = (body: BulkBody) => body.Value!.BalanceUpdates.map((u) => u.UpdateResponse)
test('POST /api/items/bulkpurchase 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
PurchaseItemRequests: [line(10, 200)],
StorefrontType: 3,
CurrencyType: 2,
}),
})
expect(res.status).toBe(401)
})
test('POST /api/items/bulkpurchase debits the bag once and grants every line', async () => {
// Account 90: fresh, so its first balance touch grants the 10000 default. Three donuts
// (a consumable, 100 each — consumables are the only thing that stacks) and one dress
// (an avatar item, 200) — 500 in total.
await drainFrames()
const res = await bulkPurchase('90', {
PurchaseItemRequests: [line(2182, 100, { DuplicateItemCount: 3 }), line(10, 200)],
ShoppingBagId: 'bag-1',
})
expect(res.status).toBe(200)
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(true)
expect(body.Error).toBe(null)
expect(body.error_id).toBe(null)
const value = body.Value!
// `Balance` here is the RESULTING total (10000 - 500), unlike buyItem's change. The
// bucket is -2, the one `GET /balance` reports — the reference server's 4
// (RecNetPurchased) would read as a second balance the client adds to the real one.
expect(value.Balance).toBe(9500)
expect(value.CurrencyType).toBe(2)
expect(value.Platform).toBe(-2)
// ONE entry per REQUESTED item — three donuts are one line, so one entry — in order.
expect(value.BalanceUpdates).toHaveLength(2)
expect(codes(body)).toEqual([0, 0])
expect(value.BalanceUpdates.map((u) => u.Data.PurchasableItemId)).toEqual([2182, 10])
expect(value.BalanceUpdates.every((u) => u.Data.CustomAvatarItem === null)).toBe(true)
// The box each line produced, as `GiftPackage` carries it: 20 keys, the receiver in
// `PlayerId`, a self-buy attributed to the "Coach" account (1), and the platform MASK in
// `Platform` — the balance bucket is the `BalanceType` beside it.
const box = value.BalanceUpdates[0].Data.GiftPackage!
expect(Object.keys(box)).toEqual([
'Id',
'PlayerId',
'FromPlayerId',
'ConsumableItemDesc',
'AvatarItemType',
'AvatarItemDesc',
'CustomAvatarItemId',
'EquipmentPrefabName',
'EquipmentModificationGuid',
'CurrencyType',
'Currency',
'Xp',
'GiftContext',
'GiftRarity',
'Message',
'Signature',
'IsSignatureValid',
'Platform',
'PlatformsToSpawnOn',
'BalanceType',
])
expect(box.Id).toBeGreaterThan(0)
expect(box.PlayerId).toBe(90)
expect(box.FromPlayerId).toBe(1)
expect(box.ConsumableItemDesc).not.toBe('')
expect(box.Platform).toBe(-1)
expect(box.BalanceType).toBe(-2)
expect(value.BalanceUpdates[1].Data.GiftPackage!.AvatarItemDesc).not.toBe('')
// ONE frame for the whole bag, setting the account-wide bucket to the resulting total —
// the same 9500 the body reports, so the two agree instead of compounding.
expect(await drainFrames()).toEqual([
{
accountId: 90,
notificationType: NotificationType.StorefrontBalancePurchase,
payload: {
BalanceAddType: 1400,
Delta: -500,
Balance: 9500,
Platform: -2,
CurrencyType: 2,
},
},
])
expect(
await getBalance(env.DB, 90, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(9500)
// Everything landed: the dress is owned, all three donuts stacked into the one box's
// grant, and each LINE left one gift box.
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('90'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list[0].friendlyName).toBe('Babydoll Dress (Blue)')
const unlocked = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
headers: await bearer('90'),
})
const consumables = (await unlocked.json()) as Array<{ Count: number }>
expect(consumables[0].Count).toBe(3)
const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
headers: await bearer('90'),
})
const pending = (await gifts.json()) as Array<{ Id: number }>
expect(pending.map((g) => g.Id)).toEqual(
value.BalanceUpdates.map((u) => u.Data.GiftPackage!.Id)
)
})
test('POST /api/items/bulkpurchase buys the good lines when partial success is allowed', async () => {
// The second line's price no longer matches the catalog (200, not 1). The bag still
// succeeds — that entry just comes back non-OK, which is what AllowPartialSuccess means.
const res = await bulkPurchase('91', {
PurchaseItemRequests: [line(10, 200), line(80, 1)],
})
expect(res.status).toBe(200)
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(true)
expect(body.Error).toBe(null)
expect(body.Value!.Balance).toBe(9800)
// 6 = RequestedPriceDoesNotMatch. The failed line still names the item it asked for.
expect(codes(body)).toEqual([0, 6])
expect(body.Value!.BalanceUpdates[1].Data).toEqual({
GiftPackage: null,
PurchasableItemId: 80,
CustomAvatarItem: null,
})
expect(
await getBalance(env.DB, 91, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(9800)
})
test('POST /api/items/bulkpurchase charges nothing when a line fails and partial success is off', async () => {
await drainFrames()
const res = await bulkPurchase('92', {
AllowPartialSuccess: false,
PurchaseItemRequests: [line(10, 200), line(80, 1)],
})
expect(res.status).toBe(200)
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(false)
expect(body.Error).toBe('Price has changed')
expect(body.Value).toBe(null)
// Untouched: no debit, no item, and no frame for a purchase that did not happen.
expect(
await getBalance(env.DB, 92, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(10000)
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('92'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Babydoll Dress (Blue)')).toBe(true)
expect(await drainFrames()).toEqual([])
})
test('POST /api/items/bulkpurchase takes the lines that fit, in request order', async () => {
// Leave account 93 with 250 tokens: enough for the first 200-token line, not both.
await getBalance(env.DB, 93, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
expect(
await spendCurrency(env.DB, 93, CurrencyType.RecCenterTokens, 9750, DEFAULT_STARTING_TOKENS)
).toBe(true)
const res = await bulkPurchase('93', {
PurchaseItemRequests: [line(10, 200), line(80, 200)],
})
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(true)
expect(body.Value!.Balance).toBe(50)
// 2 = NotEnoughCredit for the line the balance no longer covered.
expect(codes(body)).toEqual([0, 2])
expect(body.Value!.BalanceUpdates[1].Data.GiftPackage).toBe(null)
expect(
await getBalance(env.DB, 93, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(50)
})
test('POST /api/items/bulkpurchase fails the whole bag it cannot afford when partial success is off', async () => {
await getBalance(env.DB, 94, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
expect(
await spendCurrency(env.DB, 94, CurrencyType.RecCenterTokens, 9750, DEFAULT_STARTING_TOKENS)
).toBe(true)
const res = await bulkPurchase('94', {
AllowPartialSuccess: false,
PurchaseItemRequests: [line(10, 200), line(80, 200)],
})
const body = (await res.json()) as BulkBody
// Even the line that would have fitted is refused: all of it or none.
expect(body.Success).toBe(false)
expect(body.Error).toBe('Insufficient balance')
expect(body.Value).toBe(null)
expect(
await getBalance(env.DB, 94, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(250)
})
test('POST /api/items/bulkpurchase grants without gift boxes when BypassGiftPackages is set', async () => {
const res = await bulkPurchase('95', {
BypassGiftPackages: true,
PurchaseItemRequests: [line(10, 200)],
})
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(true)
expect(body.Value!.Balance).toBe(9800)
// No box was created, so there is none to hand back — the capture's null GiftPackage.
expect(body.Value!.BalanceUpdates[0]).toEqual({
UpdateResponse: 0,
Data: { GiftPackage: null, PurchasableItemId: 10, CustomAvatarItem: null },
})
const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
headers: await bearer('95'),
})
expect((await gifts.json()) as unknown[]).toEqual([])
// Ownership never depended on the box: the item is owned all the same.
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('95'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list[0].friendlyName).toBe('Babydoll Dress (Blue)')
})
test('POST /api/items/bulkpurchase reports per line what it cannot sell', async () => {
await drainFrames()
const res = await bulkPurchase('96', {
PurchaseItemRequests: [
// A guid-keyed (UGC) item — nothing here sells one, and it has no NumberId to echo.
line(0, 200, {
ItemPurchaseMethodId: { Type: 1, NumberId: null, Guid: 'a3f1-not-a-catalog-item' },
}),
// Nothing issues coupons, so a line claiming one is refused rather than charged full
// price for a discount it thinks it applied.
line(10, 200, { CouponConsumablePlayerMappingId: 4242 }),
line(999999, 200),
line(10, 200, { DuplicateItemCount: 0 }),
// An avatar item is owned once — a second copy would grant nothing and charge for it.
line(80, 200, { DuplicateItemCount: 2 }),
// The catalog prices this item in RecCenterTokens only.
line(2182, 100),
// …and one that works, so the bag is a partial success rather than a refusal.
line(10, 200),
],
CurrencyType: 2,
})
expect(res.status).toBe(200)
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(true)
// 4 NoItemAvailable, 5 CouponNotApplicable, 4 NoItemAvailable, 7/7
// RequestedAmountNotAllowed, 0 OK (the donuts do price in tokens), 0 OK.
expect(codes(body)).toEqual([4, 5, 4, 7, 7, 0, 0])
expect(body.Value!.BalanceUpdates[0].Data.PurchasableItemId).toBe(null)
// Only the two OK lines were charged (100 + 200).
expect(body.Value!.Balance).toBe(9700)
expect(await drainFrames()).toHaveLength(1)
})
test('POST /api/items/bulkpurchase refuses a bag where nothing sells', async () => {
const res = await bulkPurchase('97', {
CurrencyType: CurrencyType.LaserTagTickets,
PurchaseItemRequests: [line(10, 200)],
})
expect(res.status).toBe(200)
const body = (await res.json()) as BulkBody
// Nothing was bought, so this is a refusal rather than a bag of non-OK entries.
expect(body.Success).toBe(false)
expect(body.Error).toBe('Currency type not available for this item')
expect(body.Value).toBe(null)
})
test('POST /api/items/bulkpurchase 400s on a request it cannot evaluate', async () => {
// Same envelope on a 400, so a client that only parses this shape still reads the error.
const empty = await bulkPurchase('98', { PurchaseItemRequests: [] })
expect(empty.status).toBe(400)
const emptyBody = (await empty.json()) as BulkBody
expect(emptyBody).toMatchObject({ Success: false, error_id: null, Value: null })
expect(emptyBody.Error).toBe('PurchaseItemRequests must be a non-empty array')
// A room-scoped currency is not an account balance we can debit.
const roomCurrency = await bulkPurchase('98', {
CurrencyType: CurrencyType.RoomCurrency,
PurchaseItemRequests: [line(10, 200)],
})
expect(roomCurrency.status).toBe(400)
expect(((await roomCurrency.json()) as BulkBody).Error).toBe('Currency type is not spendable')
// Over `Econ.BulkPurchaseCap` (200 copies) — the same cap the client reads from its
// game config. Consumables are what can be asked for in that quantity.
const over = await bulkPurchase('98', {
PurchaseItemRequests: [line(2182, 100, { DuplicateItemCount: 201 })],
})
expect(over.status).toBe(400)
expect(((await over.json()) as BulkBody).Error).toBe('A bulk purchase is capped at 200 items')
expect(
await getBalance(env.DB, 98, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(10000)
})
/**
@@ -1263,8 +1728,8 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('24'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.some((i) => i.FriendlyName === 'Bowtie (White)')).toBe(true)
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.some((i) => i.friendlyName === 'Bowtie (White)')).toBe(true)
// Opening it again is a harmless no-op — still 200.
const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, {
@@ -1670,9 +2135,10 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('76'),
})
const owned = (await items.json()) as Array<{ AvatarItemDesc: string }>
// v4 serves the camelCase DTO, unlike the PascalCase records on the gift box.
const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
if ((boxes[0]?.AvatarItemDesc ?? '') !== '') {
expect(owned.map((i) => i.AvatarItemDesc)).toContain(boxes[0]?.AvatarItemDesc)
expect(owned.map((i) => i.avatarItemDesc)).toContain(boxes[0]?.AvatarItemDesc)
}
// A second box can't roll the same prize: "an item that you don't have" excludes what
@@ -1882,8 +2348,9 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('82'),
})
const owned = (await items.json()) as Array<{ AvatarItemDesc: string }>
expect(owned.map((i) => i.AvatarItemDesc)).toContain(clothingBox?.AvatarItemDesc)
// v4 serves the camelCase DTO, unlike the PascalCase records on the gift box.
const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
expect(owned.map((i) => i.avatarItemDesc)).toContain(clothingBox?.AvatarItemDesc)
expect((await drainFrames()).map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate,
@@ -1943,12 +2410,101 @@ describe('econ endpoints', () => {
expect(await res.json()).toEqual([])
})
test('GET /api/subscriptionseasons/v1/seasons/current returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/subscriptionseasons/v1/seasons/current`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
const getSubscription = async (headers: Record<string, string> = {}) =>
exports.default.fetch(`${ORIGIN}/api/CampusCard/v1/UpdateAndGetSubscription`, {
method: 'POST',
headers,
})
// Fixed values, and no auth: the client reads this while assembling the RR+ page, so a
// 401 would only be a way for that load to stall.
test('GET /api/incentivizedreferrals/progress reports an untouched referral track', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/incentivizedreferrals/progress`, {
headers: await bearer('207'),
})
expect(res.status).toBe(200)
// The payload is nested under `value`, unlike econ's flat balance bodies.
expect(await res.json()).toEqual({
success: true,
value: { ReferralsVerifiedCount: 0, PlayerReferralRewards: [] },
})
})
test('GET /api/incentivizedreferrals/progress 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/incentivizedreferrals/progress`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
test('GET /api/influencerpartnerprogram/influencers lists nobody', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/influencerpartnerprogram/influencers?take=1000`,
{ headers: await bearer('207') }
)
expect(res.status).toBe(200)
// An object around the list, not a bare array. Note this is a 200 while its
// single-account sibling below answers 404 — "nobody is" is a complete answer to
// "who is?", where "are you?" is answered by the 404 itself.
expect(await res.json()).toEqual({ InfluencerIds: [] })
})
test('GET /api/influencerpartnerprogram/influencers 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/influencers`)
expect(res.status).toBe(401)
})
test('GET /api/influencerpartnerprogram/influencer 404s with an empty JSON body', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=206`,
{ headers: await bearer('206') }
)
// 404 IS the answer — "not an influencer" — and the body is empty, not `{}` or null,
// with the content type the reference sends.
expect(res.status).toBe(404)
expect(res.headers.get('content-type')).toContain('application/json')
expect(await res.text()).toBe('')
})
test('GET /api/influencerpartnerprogram/influencer 401s without a bearer token', async () => {
// Auth is checked before the 404, so an unauthenticated caller is told that, not that
// they aren't an influencer.
const res = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/influencer`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
test('GET /api/makerai/checkfreetrialeligibility answers a bare false', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/makerai/checkfreetrialeligibility`, {
headers: await bearer('206'),
})
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
// The whole body is the boolean — not `{ value: false }`, not an envelope.
expect(await res.text()).toBe('false')
})
test('GET /api/makerai/checkfreetrialeligibility 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/makerai/checkfreetrialeligibility`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
test('GET /api/CampusCard/v1/SignUpBonus returns the running bonus, unauthenticated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/CampusCard/v1/SignUpBonus`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
RRPlusSignUpBonusId: 3,
MinFreeItemsPrice: 6000,
MaxFreeItemsPrice: 10000,
})
})
test('POST /api/CampusCard/v1/UpdateAndGetSubscription gives a developer a Gold year', async () => {
const res = await getSubscription(await bearer('205', ['gameClient', 'developer']))
expect(res.status).toBe(200)
@@ -2019,6 +2575,7 @@ describe('econ endpoints', () => {
)
)
expect([...documented].sort()).toEqual([
'GET /api/CampusCard/v1/SignUpBonus',
'GET /api/avatar/v1/defaultbaseavataritems',
'GET /api/avatar/v1/defaultunlocked',
'GET /api/avatar/v2',
@@ -2028,10 +2585,16 @@ describe('econ endpoints', () => {
'GET /api/avatar/v4/items',
'GET /api/challenge/v2/getCurrent',
'GET /api/checklist/v1/current',
'GET /api/checklist/v2/current',
'GET /api/consumables/v2/getUnlocked',
'GET /api/equipment/v2/getUnlocked',
'GET /api/gamerewards/v1/pending',
'GET /api/incentivizedreferrals/progress',
'GET /api/influencerpartnerprogram/influencer',
'GET /api/influencerpartnerprogram/influencers',
'GET /api/itemWishlists/v1/wishlist/me',
'GET /api/itemWishlists/v1/wishlist/{accountId}',
'GET /api/makerai/checkfreetrialeligibility',
'GET /api/objectives/v1/cleargroup',
'GET /api/objectives/v1/myprogress',
'GET /api/roomconsumables/v1/roomConsumable/room/{roomId}',
@@ -2044,15 +2607,27 @@ describe('econ endpoints', () => {
'GET /api/storefronts/v2/buyInvention',
'GET /api/storefronts/v3/giftdropstore/{id}',
'GET /api/storefronts/v4/balance/{currencyType}',
'GET /api/subscriptionseasons/v1/seasons/current',
'GET /api/ugcPurchasables/v1/items/room/{roomId}',
'GET /econ/customAvatarItems/v1/owned',
'GET /econ/roomEconConfig/{roomId}',
'GET /econ/roomGiftDropShops/room/{roomId}',
'GET /econ/roomInventory/room/{roomId}',
'GET /econ/roomInventory/room/{roomId}/player',
'GET /econ/roomInventoryItemTags/room/{roomId}',
'GET /econ/roomOffer/room/{roomId}',
'GET /econ/roomOffer/room/{roomId}/purchaseCounts',
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
'POST /api/avatar/v2/gifts/consume',
'POST /api/avatar/v2/set',
'POST /api/avatar/v3/saved/set',
'POST /api/avatar/v4/saved/set',
'POST /api/challenge/v2/updateProgress',
'POST /api/checklist/v1/complete',
'POST /api/checklist/v2/complete',
'POST /api/consumables/v1/consume',
'POST /api/gamerewards/v1/request',
'POST /api/items/bulkpurchase',
'POST /api/objectives/v1/cleargroup',
'POST /api/objectives/v1/updateobjective',
'POST /api/storefronts/v2/buyItem',
@@ -0,0 +1,28 @@
[
{
"AvatarItemDesc": "c5d70cb4-71dd-4fe4-b719-34fe2073c611,",
"AvatarItemType": 0,
"PlatformMask": -1,
"FriendlyName": "(UGCTee_Shirt) ",
"Tooltip": "",
"Rarity": -1,
"TagList": "",
"AvatarItemId": 2184,
"IsBaseAvatarItem": true,
"CreatedAt": "2022-04-19T23:40:30.807Z",
"ThumbnailImage": "KXfytDhXzES2yco-rwqDSA.png"
},
{
"AvatarItemDesc": "95a519de-f2cb-429c-b014-508477f20d42,",
"AvatarItemType": 0,
"PlatformMask": -1,
"FriendlyName": "(UGCPulloverHoodie_Shirt) ",
"Tooltip": "",
"Rarity": -1,
"TagList": "",
"AvatarItemId": 2918,
"IsBaseAvatarItem": true,
"CreatedAt": "2023-04-07T17:07:07.04Z",
"ThumbnailImage": "m4UIuZjNzEWsCP1gpZBgjg.png"
}
]