Files
recflare/apps/econ/src/openapi.ts
T
2026-10-05 16:22:55 -04:00

1342 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the econ worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/match workers: a reverse-engineered protocol, lenient
* handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** The empty-body 401 the auth-gated routes return. */
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/**
* Optional bearer JWT — the empty requirement object makes "no credentials" a valid
* alternative. For routes that serve public data but personalise it for a known caller
* (the weekly challenge's per-player `Complete`) instead of 401ing.
*/
export const OPTIONAL_AUTHED: OpenAPIV3_1.SecurityRequirementObject[] = [{}, { bearerAuth: [] }]
// ---- Loose shapes ----------------------------------------------------------
// Several routes serve opaque static catalogs (avatar items, the weekly challenge) or
// empty-list stubs. Modelling every catalog field adds noise without value, so these
// use deliberately loose schemas.
/** An opaque JSON object (a catalog entry, an avatar blob, …). */
export const JsonObject = z.record(z.string(), z.unknown())
/** An opaque JSON array (a static catalog served verbatim). */
export const JsonArray = z.array(z.unknown())
// ---- Response schemas ------------------------------------------------------
/**
* The public avatar render subset (`GET /api/avatar/v2/:id`) — the fields needed to
* draw another player's avatar. The stored blob also holds OutfitSelectionsV2 /
* CustomAvatarItems, which this view omits.
*/
export const AvatarV2Dto = z.object({
OutfitSelections: z.unknown(),
FaceFeatures: z.unknown(),
SkinColor: z.unknown(),
HairColor: z.unknown(),
})
/**
* The `{ error, success, value }` envelope both consume routes return. Always HTTP 200,
* even for a missing/already-gone target — the client parses this to finish the action,
* so a bare 200 reads as a failure.
*/
export const ConsumeEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.null(),
})
/** One currency balance entry (`GET /api/storefronts/v4/balance/:currencyType`). */
export const BalanceEntry = z.object({
CurrencyType: z.int(),
Platform: z.int().describe('-2 = all platforms (account-wide)'),
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` — the caller's bought custom items, each the full
* `CustomAvatarItem` record as the `api` worker serves it (see its `CustomAvatarItemDto`).
*/
export const CustomAvatarItemsResponse = z.object({
Results: z.array(JsonObject),
TotalResults: z.int(),
})
/**
* A Rec Room Plus subscription (the client calls it a `CampusCard`). Nothing here sells one,
* so this is the complimentary subscription reported by a caller whose token carries
* `rn.plus` — stamped from `account.hasPlus`, which the website's Discord benefits claim
* sets. See `plusSubscription` in econ.app.ts for why each field reads the way it does.
*/
export const SubscriptionDto = z.object({
SubscriptionId: z.int().describe('Placeholder — no subscription is stored'),
RecNetPlayerId: z.int().describe('The subscribed player: the caller'),
PlatformType: z
.int()
.nullable()
.describe(
'Which store sold it: -1 All, 0 Steam, 1 Oculus, 2 PlayStation, 3 Xbox, 4 RecNet, ' +
'5 IOS, 6 GooglePlay, 7 Standalone, 8 Pico. -1 here — no store did'
),
PlatformId: z.string().describe('Empty — no store account behind it'),
PlatformPurchaseId: z.string().describe('Empty — nothing was purchased'),
Level: z.int().describe('0 Gold, 1 Platinum'),
Period: z.int().describe('0 Month, 1 Year, 2 ThreeMonth, 3 SixMonth'),
ExpirationDate: z.string().describe('ISO 8601 UTC; a year out, recomputed per call'),
IsAutoRenewing: z.boolean(),
CreatedAt: z.string(),
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
* `Subscription: null` envelope: an absent key is how the client reads "not subscribed".
*/
export const SubscriptionResponse = z.union([
z.object({
Subscription: SubscriptionDto,
PlatformAccountSubscribedPlayerId: z
.null()
.describe('The platform account holding the sub, when it is shared. Never set here'),
}),
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/influencerpartnerprogram/influencer` and `…/myinfluencer` — one account's
* standing in the partner program.
*
* A BARE NUMBER, not an object: the body is the literal `0`, which is the "not an
* influencer" tier. Nobody on this server is one, so 0 is the answer for every account, the
* caller's own included.
*/
export const InfluencerTierResponse = z
.literal(0)
.describe('The account’s partner tier. Always 0 — nobody here is an influencer')
/**
* `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(),
ChallengeId: z.int(),
Config: z
.string()
.describe('The STORED rule tree — a report carrying none keeps (and echoes) the last one'),
Complete: z
.boolean()
.describe('The STORED completion — latches true within a rotation, so it may differ'),
})
/**
* `POST /api/objectives/v1/updateobjective` — the group the objective belongs to, after
* the update. camelCase, unlike the PascalCase body the client posts and the PascalCase
* `ObjectiveGroups` entries `myprogress` serves — three spellings of the same group.
*/
export const UpdateObjectiveResponse = z.object({
group: z.int().describe('Echoed back from the request'),
isCompleted: z.boolean().describe('Always false — no objectives store yet'),
clearedAt: z.string().describe('When the group was cleared — now, since nothing persists'),
})
/**
* `POST /api/storefronts/v2/buyItem` — the purchase result. `Balance` is the CHANGE
* applied (the negated price), not the resulting total; the client reads its new total
* from `GET /balance/:type`. `BalanceType` -2 is account-wide. Each `Data` entry is the
* gift-drop the recipient received.
*/
export const BuyItemResponse = z.object({
BalanceUpdates: z.array(
z.object({
UpdateResponse: z.int(),
Data: z.array(JsonObject).describe('The gift-drop(s) granted'),
})
),
Balance: z.int().describe('The change applied (negated price), not the new total'),
CurrencyType: z.int(),
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 (`Type` 1, a custom avatar item's `CustomAvatarItemId` under `Guid`),
* which resolve against the `custom_avatar_item` table and are paid to their creator.
*/
export const ItemPurchaseMethodId = z.object({
Type: z.int().describe('0 = NumberId (a storefront item); 1 = Guid (a custom avatar item)'),
NumberId: z.int().nullable().optional().describe('The storefront PurchasableItemId'),
Guid: z
.string()
.nullable()
.optional()
.describe('The `CustomAvatarItemId` of a custom avatar item, for Type 1'),
})
/**
* `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; a custom item’s names it by ' +
'`CustomAvatarItemId`). 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: JsonObject.nullable().describe(
'The whole `CustomAvatarItem` record on a guid-keyed line that sold; null otherwise'
),
}),
})
)
.describe('One entry per REQUESTED item, in request order — failures included'),
})
.nullable()
.describe('Null when nothing was bought'),
})
/**
* The JSON body `POST /api/storefronts/v3/buyInvention` takes. The same two values the v2
* GET reads off the query string (`inventionId`/`requestedPrice`), PascalCase in a body —
* that is the only difference between the two routes.
*/
export const BuyInventionRequest = z.object({
InventionId: z.int().describe('The invention to buy; missing or non-integer is 400'),
RequestedPrice: z
.int()
.optional()
.describe('The price the client rendered; a mismatch is 409. Absent reads as 0'),
})
/**
* `GET /api/storefronts/v2/buyInvention` and `POST /api/storefronts/v3/buyInvention` — the
* purchase result, identical for both. Two envelopes side by side: the balance update
* (shaped like buyItem's, except `Balance` is the RESULTING total, not the change, and
* `Data` is a single invention rather than a gift-drop list) and the invention envelope the
* invention endpoints already serve.
*/
export const BuyInventionResponse = z.object({
BalanceUpdateResponse: z.object({
Balance: z.int().describe('The resulting balance — NOT the change, unlike buyItem'),
BalanceType: z.int().describe('-2 = account-wide'),
CurrencyType: z.int().describe('2 = RecCenterTokens'),
BalanceUpdates: z.array(
z.object({
UpdateResponse: z.int(),
Data: JsonObject.describe('The bought invention (`RRInvention`)'),
})
),
}),
InventionResponse: z
.object({
Status: z.int(),
Invention: JsonObject,
InventionVersion: JsonObject,
})
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
})
/**
* `GET /api/storefronts/v1/buyRoomKey` — the client's `RoomKeyPurchaseResponseDTO`, recovered
* from its decoder: the room-key envelope `/api/roomkeys/v1/create` answers in, beside a
* BalanceUpdateResponse whose bucket key is `Platform` (NOT the `BalanceType` the invention
* buys echo) and whose entry `Data` is the key again.
*
* Every enum here reads an OMITTED key as its 0 member — `Status` 0 is success,
* `UpdateResponse` 0 is OK, `Platform` 0 is SteamPurchased — so all three are always sent.
*/
export const BuyRoomKeyResponse = z.object({
RoomKeyResponse: z.object({
Status: z.int().describe('0 on success; 1 on a refusal'),
RoomKey: JsonObject.nullable().describe(
'The bought key (the 10-key room key); null on a refusal'
),
}),
BalanceUpdateResponse: z.object({
BalanceUpdates: z.array(
z.object({
UpdateResponse: z
.int()
.describe(
'0 OK, 2 NotEnoughCredit, 3 AlreadyOwned, 4 NoItemAvailable, ' +
'6 RequestedPriceDoesNotMatch'
),
Data: JsonObject.nullable().describe('The key the request named; null when it named none'),
})
),
Balance: z.int().describe('The buyer’s RESULTING token total, not the change'),
CurrencyType: z.int().describe('2 = RecCenterTokens'),
Platform: z.int().describe('-2 — the one account-wide bucket every balance surface names'),
}),
})
/** `POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency` — form-encoded. */
export const PurchaseRoomKeyWithCurrencyRequest = z.object({
RoomKeyId: z.string().describe('The key to buy — a `room_key` id'),
RequestedPrice: z.string().describe('The price the client rendered; must equal the key’s'),
RequestedPurchaseCurrencyId: z
.string()
.describe('The room currency to pay in; must be the key’s own `PurchaseCurrencyId`'),
})
/**
* `POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency` — the client's
* `RoomKeyPurchaseWithCurrencyResponseDTO`, recovered from its decoder. A BARE object, no
* envelope, and NOT buyRoomKey's shape: the outer `Balance` is an OBJECT (the 4-key
* room-currency balance) holding a numeric `Balance`, and there is no `Platform` anywhere.
*/
export const PurchaseRoomKeyWithCurrencyResponse = z.object({
Balance: z.object({
AccountId: z.int().describe('The buyer'),
CurrencyId: z.string().describe('The room currency paid in'),
Balance: z.int().describe('What the buyer holds of it afterwards — the RESULTING total'),
ModifiedAt: z.string().describe('ISO-8601 UTC'),
}),
RoomKeyResponse: z.object({
Status: z.int().describe('0 on success; 1 on a refusal. Always sent — omitted reads as 0'),
RoomKey: JsonObject.nullable().describe('The bought key; null on a refusal'),
}),
})
/**
* `POST /api/storefronts/v3/buyInvention` — the purchase result the 2025 client wants,
* which is NOT v2's despite settling the identical purchase. Two differences, both
* recovered from a capture of the real response:
*
* - `InventionResponse` is the v9 SAVE envelope (`{ Value, Success, Error, error_id }`)
* rather than v6's bare `{ Status, Invention, InventionVersion }`, and its `Invention`
* is the client's 28-key `RRInvention`. A buy mints no version and takes no tags, so
* `InventionVersion` and `TagsResponse` are present and null.
* - The balance half is `BalanceResponseDTO`, so the bucket key is `Platform` — the
* client's `BalanceType` member under a [DataMember] rename, the same one the bulk
* purchase answers in. v2 spells it `BalanceType`; do not unify them.
*/
export const BuyInventionV3Response = z.object({
InventionResponse: z
.object({
Value: z
.object({
Status: z.int().describe('0 on success'),
Invention: JsonObject.describe('The bought invention as the 28-key `RRInvention`'),
InventionVersion: z.null().describe('Always null — a buy mints no version'),
TagsResponse: z.null().describe('Always null — a buy takes no tags'),
})
.describe('Never null under `Success: true` — the client dereferences it unguarded'),
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success — not `""`'),
error_id: z.string().nullable().describe('Always null — no error-id catalog here'),
})
.describe('The same envelope `POST /api/inventions/v9/save` returns'),
BalanceUpdateResponse: z.object({
BalanceUpdates: z.array(
z.object({
UpdateResponse: z.int(),
Data: JsonObject.describe('The bought invention, the same `RRInvention` as above'),
})
),
Balance: z.int().describe('The resulting balance — NOT the change, unlike buyItem'),
CurrencyType: z.int().describe('2 = RecCenterTokens'),
Platform: z
.int()
.describe(
'The balance bucket — the client’s `BalanceType` under a [DataMember] rename. -2, ' +
'account-wide: the capture said 0 (SteamPurchased) because the reference server ' +
'kept a wallet per platform; this one keeps a single bucket, and the client SUMS them'
),
}),
})
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
/** The JSON body `POST /api/ugcPurchasables/v1/items/bulk` takes. */
export const UgcPurchasableBulkRequest = z.object({
RoomId: z.number().int().describe('Echoed back on each item; not otherwise used'),
Ids: z.array(
z.object({
itemType: z.number().int().describe('3 = custom avatar item (the only type served)'),
itemId: z.string().describe('The `CustomAvatarItemId`'),
})
),
})
/** The client's `UgcPurchasableItem` — a store-facing view of a custom avatar item. */
export const UgcPurchasableItemDto = z.object({
ItemType: z.number().int(),
ItemId: z.string(),
Name: z.string(),
Description: z.string(),
ImageName: z.string(),
RoomId: z.number().int(),
Price: z.number().int(),
PurchaseCurrencyId: z.string().nullable(),
CreatedAt: z.string(),
ModifiedAt: z.string(),
})
/** What the bulk lookup answers: the resolved items, unknown ids omitted. */
export const UgcPurchasableItemList = z.array(UgcPurchasableItemDto)
/**
* `GET /api/ugcPurchasables/v1/items/room/{roomId}` — one line of a room's shop. The same
* 10 keys as the bulk lookup's item, with `ItemType` naming the kind (0 room key, 1 room
* consumable, 3 currency purchase offer) and `ImageName` nullable, as a key or consumable
* without art is served everywhere else.
*/
export const RoomPurchasableDto = UgcPurchasableItemDto.extend({
ItemType: z
.number()
.int()
.describe('0 RoomKey, 1 RoomConsumable, 3 RoomCurrencyItem (a currency pack for tokens)'),
ItemId: z
.string()
.describe('A bare GUID — the key’s ReplicationId, the consumable’s or offer’s id'),
ImageName: z.string().nullable().describe('Null for a listing with no art'),
PurchaseCurrencyId: z
.string()
.nullable()
.describe('The room currency the line is charged in; null for tokens'),
})
/** The room’s shop — a bare array, empty for a room that sells nothing. */
export const RoomPurchasableList = z.array(RoomPurchasableDto)
/**
* `POST /api/items/purchaseInfos` JSON body — the same `{ itemType, itemId }` reference
* shape the UGC bulk lookup takes, minus the room. camelCase INSIDE the reference, which is
* the client's own inconsistency: the response wraps this very object under a PascalCase
* `ItemId` key without renaming its members.
*/
export const ItemPurchaseInfosRequest = z.object({
Ids: z.array(
z.object({
itemType: z.number().int().describe('3 = custom avatar item (the only type served)'),
itemId: z.string().describe('The `CustomAvatarItemId`'),
})
),
})
/** One `Prices[]` entry: what the item costs in one currency, and any sale on top. */
export const ItemPriceDto = z.object({
CurrencyType: z.number().int().describe('2 = RecCenterTokens — what UGC items are priced in'),
Price: z.number().int(),
StorefrontSaleData: z
.object({
SalePercent: z.number().int(),
SaleStartDate: z.string().nullable(),
SaleEndDate: z.string().nullable(),
})
.nullable()
.describe('Always a zero-percent sale here; nothing discounts UGC items yet'),
})
/** The client's `ItemPurchaseInfo` — how one item may be bought. */
export const ItemPurchaseInfoDto = z.object({
ItemId: z.object({ itemType: z.number().int(), itemId: z.string() }),
PurchaseMethodId: z.object({
Type: z.number().int(),
NumberId: z.number().int().nullable(),
Guid: z.string().nullable(),
}),
Prices: z.array(ItemPriceDto),
NewUntil: z.string().nullable(),
AvailableAt: z.string().nullable(),
AvailableUntil: z.string().nullable(),
CanBeGifted: z.boolean(),
CanApplySubscriberDiscount: z.boolean(),
SubscribersOnly: z.boolean(),
IsFeatured: z.boolean(),
})
/** What the purchase-info lookup answers: one entry per RESOLVED id, unknown ids omitted. */
export const ItemPurchaseInfoList = z.array(ItemPurchaseInfoDto)
/**
* `POST /api/avatar/v1/lockeditems/bulk` JSON body — the descs the client wants the locked
* state for. Currently accepted and not read; see the route.
*/
export const LockedItemsBulkRequest = z.object({
AvatarItemDescriptions: z
.array(z.string())
.describe('The `AvatarItemDesc` of each item the client is about to draw'),
})
export const ErrorResponse = z.object({ error: z.string() })
/**
* A room currency — a custom currency minted for use inside one room. The client's own model,
* member for member and in its order (see the `notify` worker's `RoomCurrencyPayload`, which
* the `RoomCurrencyCreated`/`Modified` frames carry: the create response and those frames are
* the same object). `CurrencyType` is always 300 (RoomCurrency) — a room currency is told
* apart from the others by its `CurrencyId`, not by its type.
*/
export const RoomCurrencyDto = z.object({
CurrencyId: z.string().describe('GUID — how the client names this currency afterwards'),
RoomId: z.int(),
Name: z.string(),
Description: z.string(),
CurrencyType: z
.literal(300)
.describe('Always 300 (RoomCurrency) — the same enum `buyInvention` prices in'),
Limit: z
.int()
.describe(
'The most the room may award PER DAY — a faucet rate, not a holding cap. Stored and ' +
'served, but not enforced: nothing tracks a day’s awards yet'
),
Shape: z.int().describe('A byte — the client’s index into its coin shapes; uninterpreted'),
Color: z
.int()
.describe('The client’s index into its coin colours, signed — it sends -1 for none'),
ImageName: z.string().nullable().describe('Null — custom coin art is not uploadable yet'),
CreatedAt: z.string().describe('ISO-8601 UTC'),
ModifiedAt: z.string().describe('ISO-8601 UTC; equal to `CreatedAt` until an edit'),
})
/**
* The envelope `POST /api/roomcurrencies/v1/createCurrency` answers in. The client unwraps it
* and hands its callers `Value` alone, so the envelope exists on the wire but never reaches
* the code that uses the currency.
*
* PascalCase `Value`/`Success`/`Error` beside a lowercase `error_id` — the same mixed casing
* the client's other envelopes use (see the `rooms` worker's isBanned and publish-state
* envelopes). NOT the lowercase `{ success, error, value }` the room mutations answer with;
* the two live in different workers and must not be unified.
*/
export const RoomCurrencyEnvelope = z.object({
Value: RoomCurrencyDto.nullable().describe('The currency, or null on a failure'),
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success; the failure message otherwise'),
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
})
/** `POST /api/roomcurrencies/v1/createCurrency` — form-encoded. */
export const CreateRoomCurrencyRequest = z.object({
RoomId: z.string().describe('The room the currency belongs to'),
Name: z.string().describe('Shown to players; profanity-masked like every typed string'),
Description: z.string().optional().describe('Defaults to empty'),
Limit: z
.string()
.optional()
.describe('The most the room may award per day; defaults to 0. Not enforced yet'),
Shape: z.string().optional().describe('Coin shape index; defaults to 0'),
Color: z.string().optional().describe('Coin colour index; defaults to 0'),
})
/**
* A room key — a named, priced admission ticket a room lists. The shape is the `RoomKey` the
* live client was observed reading out of the create response, member for member and in its
* order; the `LocalRoomKeyCreated` frame (the `notify` worker's `LocalRoomKeyPayload`) carries
* the same object.
*
* `RoomKeyId` is a NUMBER, unlike a room currency's or consumable's GUID — the client's model
* says so — with the `ReplicationId` GUID beside it. The create body's `Type` arrives as an
* enum NAME (`Key`) and is served as the ORDINAL (0). `ImageName` is null, not '', until a key
* can carry art.
*/
export const RoomKeyDto = z.object({
RoomKeyId: z.int().describe('Autoincrement — how the client names this key afterwards'),
ReplicationId: z.string().describe('GUID, minted alongside the id'),
RoomId: z.int(),
Name: z.string(),
Description: z.string(),
Price: z.int(),
PurchaseCurrencyId: z
.string()
.nullable()
.describe('A `room_currency` id, or null for tokens — which a new key is priced in'),
CreatedAt: z.string().describe('ISO-8601 UTC'),
ImageName: z.string().nullable().describe('The key’s art; null until an edit sets one'),
Type: z.int().describe('The key type’s ordinal: 0 `Key`, the only one seen'),
})
/**
* The envelope `POST /api/roomkeys/v1/create` answers in: `{ Status, RoomKey }`, observed from
* the live client — NOT the `{ Value, Success, Error, error_id }` the room-currency and
* consumable writes use, though they live in the same worker. `Status` is 0 on success. A
* refusal answers a non-zero `Status` with a null `RoomKey`; only the success shape has been
* observed, so the refusal code (1) is an assumption.
*/
export const RoomKeyEnvelope = z.object({
Status: z.int().describe('0 on success; 1 on a refusal (the refusal code is an assumption)'),
RoomKey: RoomKeyDto.nullable().describe('The key, or null on a refusal'),
})
/** `POST /api/roomkeys/v1/create` — form-encoded. */
export const CreateRoomKeyRequest = z.object({
Type: z
.string()
.optional()
.describe('The key type, as an enum NAME. Only `Key` has been seen; defaults to it'),
RoomId: z.string().describe('The room the key opens'),
Name: z.string().describe('Shown to players; profanity-masked like every typed string'),
Description: z.string().optional().describe('Defaults to empty'),
Price: z
.string()
.optional()
.describe('What the key costs, in tokens; defaults to 0. At most 1000'),
})
/**
* `PUT /api/roomkeys/v1/updateAll` — form-encoded. "All" is the FIELDS, not the keys: the body
* names one key and carries every editable field of it
* (`RoomKeyId=43&Name=key1&Description=…&Price=1000&PurchaseCurrencyId=`). For the two
* fields that can be emptied — `PurchaseCurrencyId` and `ImageName` — a BLANK value is an
* instruction to clear it; only a field absent from the body is left alone.
*/
export const UpdateRoomKeyRequest = z.object({
RoomKeyId: z.string().describe('The key to edit'),
Name: z.string().optional().describe('Left alone when absent; profanity-masked'),
Description: z.string().optional().describe('Left alone when absent; profanity-masked'),
Price: z
.string()
.optional()
.describe('Left alone when absent. At most 1000 in tokens, 1000000000 in a room currency'),
PurchaseCurrencyId: z
.string()
.optional()
.describe(
'EMPTY means tokens; otherwise a `room_currency` id of the key’s own room. Left alone when absent'
),
ImageName: z
.string()
.optional()
.describe('The key’s art. EMPTY clears it (served as null). Left alone when absent'),
})
/**
* `POST /api/roomkeys/v1/awardbulk` — a BARE JSON ARRAY of the keys to award, as the client
* posts it. `AccountId` is accepted and IGNORED: the token says who is awarded.
*/
export const AwardRoomKeysRequest = z.array(
z.object({
RoomKeyId: z.int().describe('The key to award — a `room_key` id'),
AccountId: z
.int()
.optional()
.describe('Accepted and IGNORED — the caller is who is awarded the key'),
})
)
/**
* `POST /api/roomkeys/v1/awardbulk` answers an ARRAY OF ENVELOPES — every element its own
* `{ Value, Success, Error, error_id }` around one award (recovered from the client's
* decoder). Not a bare array of awards, and not one envelope wrapping a list. Only the
* success form has been observed; a failed entry's null `Value` is an assumption.
*/
export const AwardRoomKeyResultList = z.array(
z.object({
Value: z
.object({
RoomKeyId: z.int(),
AccountId: z.int().describe('Who now holds the key — the caller'),
})
.nullable()
.describe('The award, or null on a failure'),
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success; the failure message otherwise'),
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
})
)
/**
* `POST /api/roomkeys/v1/owns/bulk` — a BARE JSON ARRAY of (player, key) pairs to check.
* Unlike the award's, this `AccountId` is READ: it names whose keys are being asked about.
*/
export const OwnsRoomKeysRequest = z.array(
z.object({
AccountId: z.int().describe('The player being asked about'),
RoomKeyId: z.int().describe('The key — a `room_key` id'),
})
)
/** `POST /api/roomkeys/v1/owns/bulk` — a bare array, one answer per pair in the order asked. */
export const OwnsRoomKeysResponse = z.array(
z.object({
AccountId: z.int(),
RoomKeyId: z.int(),
DoesPlayerOwnRoomKey: z.boolean(),
})
)
/**
* One wishlist entry — the client's five-key DTO, in its order. An element of the GET's bare
* array and the `Value` of the add/remove envelope.
*
* `PurchasableItemId` is a plain int on the client, so an entry for a custom avatar item
* carries 0 there and the item's GUID in `CustomAvatarItemId`; a storefront-item entry has
* the id and a null GUID. Exactly one of the two names the item.
*/
export const WishlistItemDto = z.object({
WishlistItemId: z.string().describe('GUID — the client’s `Guid`, so never an int'),
AccountId: z.int().describe('Whose list the entry is on'),
PurchasableItemId: z
.int()
.describe('The storefront item wished for, or 0 when the entry names a custom avatar item'),
CustomAvatarItemId: z
.string()
.nullable()
.describe('The custom avatar item wished for, or null when the entry names a storefront item'),
CreatedAt: z.string().describe('ISO-8601 UTC'),
})
/** `GET /api/itemWishlists/v1/wishlist/me|{accountId}` — a bare array, newest wish first. */
export const WishlistItemList = z.array(WishlistItemDto)
/**
* The envelope `wishlist/add` and `wishlist/remove` answer in — the same `{ Value, Success,
* Error, error_id }` the room-currency writes use. The client reads `add`’s `Value` and
* discards `remove`’s, but parses both as this shape: a bare entry or a 204 fails the same
* way `Success: false` does. `Error` is shown to the player verbatim behind “Wishlist error: ”.
*/
export const WishlistEnvelope = z.object({
Value: WishlistItemDto.nullable().describe(
'The entry added or removed; null on a refusal, or on removing an item that wasn’t listed'
),
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success; the failure message otherwise'),
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
})
/**
* `wishlist/add` and `wishlist/remove` — form-encoded. The client posts BOTH fields every
* time with the unused one empty (`purchasableItemId=1534&customAvatarItemId=`); either may
* also be omitted. Exactly one has to name an item.
*/
export const WishlistItemRequest = z.object({
purchasableItemId: z
.string()
.optional()
.describe('The storefront item, as a positive integer; empty or absent for a custom item'),
customAvatarItemId: z
.string()
.optional()
.describe('The custom avatar item’s GUID; empty or absent for a storefront item'),
})
/**
* `POST /api/itemWishlists/v1/isonwishlist/bulk` — form-encoded. `accountIds` is REPEATED,
* once per account (`accountIds=205&accountIds=207`); a comma-separated single value is
* accepted too. The item is named as on add/remove.
*/
export const IsOnWishlistBulkRequest = WishlistItemRequest.extend({
accountIds: z
.array(z.string())
.describe('The players to ask about, repeated once per id — answered in this order'),
})
/**
* `isonwishlist/bulk`’s answer: a bare POSITIONAL array of booleans, one per `accountIds`
* entry in the order posted. A short array leaves the tail reading as “not wished for”, so
* every id gets an element — an unknown player’s is false.
*/
export const IsOnWishlistBulkResponse = z.array(z.boolean())
/**
* One purchase offer on a room currency — a way to BUY that currency, priced in another
* ("5 SuperTokens for 500 Rec Center Tokens"). The client's own model, member for member and
* in its order.
*/
export const RoomCurrencyPurchaseOfferDto = z.object({
CurrencyPurchaseOfferId: z.string().describe('GUID — names this offer'),
CurrencyId: z.string().describe('GUID of the currency being SOLD'),
Order: z.int().describe('Where it sits in the currency’s shop; the list is sorted by it'),
Name: z.string(),
CurrencyAmount: z.int().describe('How much of the currency the offer hands over'),
Price: z.int().describe('What it costs'),
ModifiedAt: z.string().describe('ISO-8601 UTC'),
})
/**
* One room consumable — a thing a room sells, priced in the room's own currency. The client's
* own model, member for member and in its order.
*
* Note `Price`/`PurchaseCurrencyId` here against the nested `PriceAndCurrency` the WRITE
* takes: the client sends them nested and reads them flat, so the two shapes genuinely differ.
*/
export const RoomConsumableDto = z.object({
RoomConsumableId: z.string().describe('GUID — how the client names this listing afterwards'),
RoomId: z.int(),
Name: z.string(),
Description: z.string(),
ImageName: z.string().nullable().describe('Null for a listing with no picture'),
Price: z.int(),
PurchaseCurrencyId: z
.string()
.nullable()
.describe('A `room_currency` id — the room’s own money. Not resolved or validated yet'),
ModifiedAt: z.string().describe('ISO-8601 UTC'),
MaximumCountPerPurchase: z
.int()
.describe('How many may be bought at once. Stored and served; nothing buys one yet'),
})
/**
* `PUT /api/roomconsumables/v1/roomConsumable` — JSON, creating a listing or replacing one.
*
* `RoomConsumableId` null (or absent) creates; naming an existing listing replaces it. An edit
* REPLACES rather than merges — the client sends the whole form back — which is the opposite
* of the currency edit, whose body is genuinely partial.
*/
export const UpsertRoomConsumableRequest = z.object({
RoomConsumableId: z.string().nullable().optional().describe('Null to create; an id to replace'),
RoomId: z.int().describe('The room whose shop this is. Ignored when editing — see the route'),
Name: z.string(),
Description: z.string().optional().describe('Defaults to empty'),
ImageName: z.string().nullable().optional(),
PriceAndCurrency: z
.object({
Price: z.int(),
CurrencyId: z.string().nullable().optional().describe('A `room_currency` id'),
})
.optional()
.describe('Collapsed into `Price`/`PurchaseCurrencyId` on the stored listing'),
MaximumCountPerPurchase: z
.int()
.optional()
.describe('The client has not been seen sending this; defaults to 0'),
})
/**
* `POST /api/roomconsumables/v1/roomConsumable/awardBulk` — the consumables to give the
* CALLER. `Requests` is a MAP keyed by consumable id, not a list: the id is the key, and each
* value is what to do with it.
*/
export const AwardRoomConsumablesRequest = z.object({
Requests: z
.record(
z.string(),
z.object({
Quantity: z.int().describe('How many to give; negative takes them away'),
ConcurrencyCodes: z
.object({
CurrentConcurrencyCode: z.string().nullable().optional(),
NewConcurrencyCode: z.string().nullable().optional(),
})
.optional()
.describe('Accepted and IGNORED — no optimistic concurrency is implemented yet'),
})
)
.describe('Keyed by consumable id'),
})
/**
* One entry's result from the consumable award, in the order its key appeared in `Requests`.
*
* Shaped after the room-currency bulk award, which is the one response in this family the
* client has been seen reading: success is PER ENTRY, so one bad id neither fails the others
* nor the call, and `Response` is null when the entry failed.
*/
export const AwardRoomConsumableResult = z.object({
ConsumableId: z.string(),
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success'),
Response: z
.object({
PlayerId: z.int(),
ConsumableId: z.string(),
Quantity: z.int().describe('The RESULTING total owned, never the change'),
AwardedAt: z.string().describe('ISO-8601 UTC'),
})
.nullable(),
})
export const AwardRoomConsumableResultList = z.array(AwardRoomConsumableResult)
/**
* The envelope the consumable write answers in — the same
* `{ Value, Success, Error, error_id }` the room-currency writes use.
*/
export const RoomConsumableEnvelope = z.object({
Value: RoomConsumableDto.nullable(),
Success: z.boolean(),
Error: z.string().nullable(),
error_id: z.null(),
})
/**
* One currency's shop, as `GET /api/roomcurrencies/v1/getPurchaseOffersBatch` groups them —
* one entry per currency asked for, the whole answer an array of these.
*
* Grouped rather than flat even though every offer already carries its own `CurrencyId`: the
* group is what says a currency was ASKED ABOUT, which a flat list cannot. A currency with no
* shop still gets a group with an empty `PurchaseOffers`.
*/
export const RoomCurrencyPurchaseOffersDto = z.object({
CurrencyId: z.string(),
PurchaseOffers: z
.array(RoomCurrencyPurchaseOfferDto)
.describe('Sorted by `Order`; empty for a currency that sells nothing yet'),
})
/**
* `POST /api/roomcurrencies/v1/createPurchaseOffer` — form-encoded.
*
* Note `Amount` here against `CurrencyAmount` on the offer it becomes: the body and the model
* spell the same number differently, and the route maps between them rather than serving the
* body’s spelling back.
*/
export const CreatePurchaseOfferRequest = z.object({
CurrencyId: z.string().describe('The currency this offer sells'),
Name: z.string().describe('The offer’s name; the client has been seen sending a GUID'),
Amount: z.string().describe('How much of the currency the offer hands over (`CurrencyAmount`)'),
Price: z.string().describe('What it costs'),
Order: z.string().optional().describe('Where it sits in the shop; defaults to 0'),
})
/**
* The envelope the create-offer endpoint answers in — the same
* `{ Value, Success, Error, error_id }` the currency writes use, with the offer in `Value`.
*/
export const RoomCurrencyPurchaseOfferEnvelope = z.object({
Value: RoomCurrencyPurchaseOfferDto.nullable(),
Success: z.boolean(),
Error: z.string().nullable(),
error_id: z.null(),
})
/**
* One entry of `POST /api/roomcurrencies/v1/awardCurrency/bulk` — a JSON ARRAY of these,
* not an object wrapping one. Each names its own currency and recipient, so a single call can
* pay several players, or one player in several currencies.
*/
export const AwardRoomCurrencyRequest = z.object({
CurrencyId: z.string().describe('The room currency being awarded'),
RecipientId: z.int().describe('The player receiving it'),
Amount: z.int().describe('How much to add; negative deducts, and a balance floors at 0'),
TransactionId: z
.string()
.optional()
.describe(
'The client’s id for this award, so a retry is the same award. NOT YET DEDUPLICATED — ' +
'accepted and validated, but a replay currently awards twice'
),
})
/** What one award actually did, on the result that reports it. */
export const AwardRoomCurrencyResponse = z.object({
AccountId: z.int().describe('The player paid'),
CurrencyId: z.string(),
Balance: z.int().describe('The RESULTING total, never the change'),
AmountAwarded: z
.int()
.describe(
'What actually landed — less than asked for when the `Limit` or the zero floor clamped it'
),
AwardedAt: z.string().describe('ISO-8601 UTC'),
})
/**
* One entry's result from `POST /api/roomcurrencies/v1/awardCurrency/bulk`, in the order sent.
*
* Success is PER ENTRY: `Success`/`Error` are this entry's alone and `Response` is null when
* it failed, so one bad entry neither fails the others nor the call. That includes an entry
* whose currency belongs to a room the caller cannot manage — a rejection here rather than a
* 403 over everything. `AccountId` and `CurrencyId` are repeated outside `Response` so a
* caller can line a failure up with what it sent, where there is no `Response` to read them
* from.
*/
export const AwardRoomCurrencyResult = z.object({
AccountId: z.int(),
CurrencyId: z.string(),
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success'),
Response: AwardRoomCurrencyResponse.nullable().describe('Null when this entry failed'),
})
export const AwardRoomCurrencyResultList = z.array(AwardRoomCurrencyResult)
/**
* `POST /api/roomCurrencies/v2/purchase` — form-encoded
* (`PurchaseOfferId=ffab4157-…&RequestedAmount=444&RequestedPrice=555`). The offer alone says
* which currency is being bought; the two numbers are what the client rendered, and must match
* the offer.
*/
export const PurchaseRoomCurrencyRequest = z.object({
PurchaseOfferId: z.string().describe('The offer — a `CurrencyPurchaseOfferId`'),
RequestedAmount: z.string().describe('How much room currency; must equal the offer’s'),
RequestedPrice: z.string().describe('What it costs in tokens; must equal the offer’s'),
})
/**
* What `POST /api/roomCurrencies/v2/purchase` answers: both balances the purchase moved,
* inside the `{ Value, Success, Error, error_id }` envelope the room-currency writes use.
* Each `Balance` is a RESULTING total. Only the success form has been observed; a refusal's
* null `Value` is an assumption.
*/
export const PurchaseRoomCurrencyEnvelope = z.object({
Value: z
.object({
CurrencyBalanceResponse: z.object({
AccountId: z.int().describe('The buyer'),
CurrencyId: z.string().describe('The room currency bought'),
Balance: z.int().describe('What the buyer now holds of it'),
ModifiedAt: z.string().describe('ISO-8601 UTC'),
}),
TokenBalanceResponse: z.object({
Balance: z.int().describe('The buyer’s resulting token total'),
CurrencyType: z.int().describe('2 = RecCenterTokens'),
Platform: z.int().describe('-2 — the one account-wide bucket every balance surface names'),
}),
})
.nullable()
.describe('Null on a refusal'),
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success; the refusal otherwise'),
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
})
/**
* `POST /api/roomcurrencies/v1/updateCurrency` — form-encoded. Names the currency by id
* rather than by room: the room it belongs to is whichever one minted it, and an edit cannot
* move it. Every field but `CurrencyId` is optional and an absent one is left as it was.
*/
export const UpdateRoomCurrencyRequest = z.object({
CurrencyId: z.string().describe('The currency to edit, as the create response named it'),
Name: z.string().optional().describe('Profanity-masked like every typed string'),
Description: z.string().optional(),
Limit: z.string().optional().describe('The most the room may award per day. Not enforced yet'),
Shape: z.string().optional().describe('Coin shape index'),
Color: z.string().optional().describe('Coin colour index, signed'),
})
// ---- 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: 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('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. */
export const ConsumeConsumableRequest = z.object({
Id: z.int().describe('The consumable row id to spend from'),
DeltaCount: z.int().optional().describe('How many to spend; defaults to 1'),
})
/** `POST /api/avatar/v2/gifts/consume` form body (posted with a trailing slash). */
export const ConsumeGiftRequest = z.object({
Id: z.string().describe('The gift-box id to open'),
UnlockedLevel: z.string().optional().describe('Consumable-level hint; unused'),
})
/** `POST /api/challenge/v2/updateProgress` JSON body. */
export const ChallengeProgressRequest = z.object({
ChallengeMapId: z.union([z.string(), z.int()]).optional(),
ChallengeId: z.union([z.string(), z.int()]).optional(),
Config: z
.string()
.optional()
.describe(
'The client-evaluated rule tree, with its running count in `cc`; stored as the player’s progress'
),
Complete: z
.union([z.string(), z.boolean()])
.optional()
.describe('The client’s verdict — sent as .NET’s `"True"`/`"False"`'),
})
/** `POST /api/gamerewards/v1/request` form body. */
export const GameRewardRequest = z.object({
rewardType: z
.string()
.describe('The reward being asked for, e.g. `FirstActivityOfDay`, `PostGameActivity`'),
Message: z.string().optional().describe('The message to show for the reward'),
giftContext: z
.string()
.optional()
.describe(
'The activity it came from, e.g. `Soccer` — part of the cooldown key. A key of `quest-rewards.json` (`Dodgeball`, `Quest_Goblin_S`, …) also picks the prize from that activity’s table'
),
})
/**
* `POST /api/objectives/v1/updateobjective` JSON body — one objective's state as the
* client now sees it. `Index`/`Group` identify it within `myprogress`; the rest is the
* progress it wants persisted.
*/
export const UpdateObjectiveRequest = z.object({
Index: z.int().describe('Which objective within the group'),
Group: z.int().describe('Which objective group'),
Progress: z.int().optional(),
VisualProgress: z.int().optional().describe('What the client animates towards'),
IsCompleted: z.boolean().optional(),
HasClaimedReward: z.boolean().optional(),
})
/** `POST /api/avatar/v3/saved/set` JSON body — an outfit with a target `Slot`. */
export const SaveOutfitRequest = z
.object({ Slot: z.int().describe('Which slot to overwrite; a non-integer is 400') })
.catchall(z.unknown())
.describe('Plus opaque outfit fields (OutfitSelectionsV2, FaceFeatures, …) stored verbatim')
/**
* `POST /api/avatar/v4/saved/set` response — a lean acknowledgement. Unlike v3 (which
* echoes the whole outfit), v4 answers just the success flag and the slot it wrote.
*/
export const SaveOutfitV4Response = z.object({
Success: z.boolean(),
Slot: z.int().describe('The slot that was written'),
})
/**
* `PUT /api/equipment/v1/update` JSON body — the client's favourite toggles. It echoes
* back the whole entry it was served, but only `Favorited` is written; the rest is
* ignored (as on the reference server).
*/
export const EquipmentUpdateRequest = z.array(
z
.object({
ModificationGuid: z.string().describe('Identifies the owned equipment row'),
Favorited: z.boolean(),
})
.catchall(z.unknown())
.describe('Plus the echoed-back PrefabName / FriendlyName / Tooltip / Rarity, all ignored')
)
/** An opaque JSON body stored verbatim (the avatar blob for `POST /api/avatar/v2/set`). */
export const OpaqueJsonBody = JsonObject.describe('Stored verbatim and echoed back')