mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
support for 202507 endpoints (#37)
* [auth][api] accept the 20250424.01 client * [2025] unstable * 20250718.0 * correct one this time * stubs * more stubs * more stubs * [lists] add worker * [ai] route stubs * [api] player photo setting * [econ] add roomEconConfig route * [infra] update worker generators * [worker] add cards/moderation/platformnotification workers * [lists] updates to some endpoints * [clubs] stub out announcement endpoint, for now * [econ] stub out season endpoints for now * [chat] apps/chat stub out party endpoint not sure the shape yet * [api] stub out statsig and lockeditems * [doc] new services * [lists] stub the bulk endpoint * [datacollection] add placeholder service until we can kill it * [api] set gifting to lvl5 * update lock * [cdn] enable cache * [match] matchmake v2 * [lists] stub some lists * [ai] stubs * [rooms] new subroom save endpoint * [econ] add bulk purchase endpoint * [discovery] update featured creator to 1 for fun * [api] add photo settings flag * [chat] fixup chat permissions (sorta) * [auth] restrictions endpoint * [rooms] contributed endpoint * [api] fix outfit endpoint * [discovery] attempt to fix store * [chat] privacy endpoints * [api] cheered images * [rooms] add xp endpoint (disbaled) * [rooms] add xp endpoint (disabled) * update images-db for cheers * [rooms] add autocomplete endpoint * [cdn/img] increase cache ttl for statics * [api] bulk route for images * [accounts] add banner image * [api] add misc missing endpoints * [discovery] remove AI tab * [platformnotifications] stub some endpoints * [lists] add some more lists * [rooms] additional endpoints * [chat] stub a few privacy endpoints * [econ] stub some endpoints * misc db fixes * [api] tweak shape for images v6 * [rooms] dont show trending RROs
This commit is contained in:
+953
-56
@@ -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 player’s 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 player’s item wishlist', 'Empty for now', true),
|
||||
'/api/itemWishlists/v1/wishlist/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Another player’s item wishlist',
|
||||
description: [
|
||||
'The wishlist of the account named in the path, as a bare array. Empty for now —',
|
||||
'nothing on this server stores wishlists, so every player’s 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 player’s 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 room’s inventory', 'Empty stub so the client doesn’t 404'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/econ/roomInventory/room/:roomId/player',
|
||||
listRoute('The caller’s inventory in a room', 'Empty stub'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/econ/roomInventoryItemTags/room/:roomId',
|
||||
listRoute('A room’s inventory item tags', 'Empty stub'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get('/econ/roomOffer/room/:roomId', listRoute('A room’s offers', 'Empty stub'), (c) =>
|
||||
c.json([])
|
||||
)
|
||||
.get(
|
||||
'/econ/roomOffer/room/:roomId/purchaseCounts',
|
||||
listRoute('Per-offer purchase counts for a room', 'Empty stub'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/econ/roomGiftDropShops/room/:roomId',
|
||||
listRoute('A room’s gift-drop shops', 'Empty stub'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// A room's economy config, asked for alongside the room-economy lists above. The only
|
||||
// setting is whether the room's shop UI groups its offers into sorting tabs; nothing
|
||||
// stores per-room config yet, so every room answers false and the client renders one
|
||||
// flat list. [Authorize] — unlike the empty-list stubs above this is a real answer the
|
||||
// client acts on, so it takes the same token the rest of the econ surface does.
|
||||
.get(
|
||||
'/econ/roomEconConfig/:roomId',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'A room’s economy config',
|
||||
description: [
|
||||
'Whether the room’s shop groups offers into sorting tabs. No per-room config is',
|
||||
'stored, so this is always false; the `RoomId` is echoed from the path.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(RoomEconConfig, 'The room’s economy config'),
|
||||
400: { description: 'Non-numeric roomId (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
if (Number.isNaN(roomId)) return c.body(null, 400)
|
||||
return c.json({ RoomId: roomId, EnableSortingTabs: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The 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 room’s UGC purchasables', 'Empty stub so the client doesn’t 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 bag’s storefront catalog (one read for the whole',
|
||||
'bag), confirms each line’s `RequestedPrice` still matches, debits the total in ONE',
|
||||
'atomic spend, grants what sold, and answers the `{ Success, Error, error_id, Value }`',
|
||||
'envelope. `Value.Balance` is the RESULTING total (not buyItem’s change) in the',
|
||||
'`Platform` bucket named beside it, and `BalanceUpdates` carries one entry per',
|
||||
'REQUESTED item, each with its own `UpdateResponse`. `AllowPartialSuccess` lets some',
|
||||
'of those be non-OK while `Success` stays true; without it a single bad line refuses',
|
||||
'the bag and nothing is charged.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(BulkPurchaseRequest, 'The bag: its lines, storefront and currency'),
|
||||
responses: {
|
||||
200: json(BulkPurchaseResponse, 'The bag’s result, or `Success: false` if nothing sold'),
|
||||
400: json(BulkPurchaseResponse, 'A request that could not be evaluated at all'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
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 caller’s referral-reward progress',
|
||||
description: [
|
||||
'How many of the caller’s referrals have been verified and which referral rewards they',
|
||||
'have claimed, under a `{ success, value }` envelope. Always 0 and empty — no referral',
|
||||
'programme runs here — which the client renders as an untouched reward track.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(ReferralProgressResponse, 'The caller’s progress — always zero'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({
|
||||
success: true,
|
||||
value: { ReferralsVerifiedCount: 0, PlayerReferralRewards: [] },
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Everyone in the influencer partner program, by account id — the list the client keeps
|
||||
// so it can badge an influencer wherever they turn up, rather than asking per player.
|
||||
//
|
||||
// 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 caller’s 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(
|
||||
|
||||
@@ -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
@@ -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 row’s 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 can’t 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 buyItem’s change'),
|
||||
CurrencyType: z.int(),
|
||||
Platform: z
|
||||
.int()
|
||||
.describe(
|
||||
'The balance bucket — the client’s `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 line’s 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 didn’t 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 client’s bag id, echoed back untouched'),
|
||||
})
|
||||
|
||||
/** `POST /api/consumables/v1/consume` JSON body. */
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user