mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -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(
|
||||
|
||||
Reference in New Issue
Block a user