mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
1342 lines
54 KiB
TypeScript
1342 lines
54 KiB
TypeScript
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')
|