mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
34171417e3
* [auth][api] accept the 20250424.01 client Version check now answers "current" for a set of builds rather than one: SUPPORTED_GAME_VERSIONS carries 20230414 and 20250424.01. GAME_VERSION is unchanged and still what the server reports for itself (presence, rn.ver). Adds GET /api/versioncheck/islandedversions, always [] — we never island a build off into its own matchmaking pool. The 2025 build POSTs /cachedlogin/forplatformid/:platform/:id with a deviceId/platformAuth/time form body where the 2023 build GETs it, so that route now takes both methods. The body is accepted and ignored for now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * [2025] unstable * 20250718.0 * correct one this time * stubs * more stubs * more stubs * [lists] add worker * [ai] route stubs * [api] player photo setting * [econ] add roomEconConfig route * [infra] update worker generators * [worker] add cards/moderation/platformnotification workers * [lists] updates to some endpoints * [clubs] stub out announcement endpoint, for now * [econ] stub out season endpoints for now * [chat] apps/chat stub out party endpoint not sure the shape yet * [api] stub out statsig and lockeditems * [doc] new services * [lists] stub the bulk endpoint * [datacollection] add placeholder service until we can kill it * [api] set gifting to lvl5 * update lock * [cdn] enable cache * [match] matchmake v2 * [lists] stub some lists * [ai] stubs * [rooms] new subroom save endpoint * [econ] add bulk purchase endpoint * [discovery] update featured creator to 1 for fun * [api] add photo settings flag * [chat] fixup chat permissions (sorta) * [auth] restrictions endpoint * [rooms] contributed endpoint * [api] fix outfit endpoint * [discovery] attempt to fix store * [chat] privacy endpoints * [api] cheered images * [rooms] add xp endpoint (disbaled) * [rooms] add xp endpoint (disabled) * update images-db for cheers * [rooms] add autocomplete endpoint * [cdn/img] increase cache ttl for statics * [api] bulk route for images * [accounts] add banner image * [api] add misc missing endpoints * [discovery] remove AI tab * [platformnotifications] stub some endpoints * [lists] add some more lists * [rooms] additional endpoints * [chat] stub a few privacy endpoints * [econ] stub some endpoints * misc db fixes * [api] tweak shape for images v6 * [rooms] dont show trending RROs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
540 lines
21 KiB
TypeScript
540 lines
21 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` — paginated owned custom items. */
|
||
export const CustomAvatarItemsResponse = z.object({
|
||
Results: JsonArray,
|
||
TotalResults: z.int(),
|
||
})
|
||
|
||
/**
|
||
* A Rec Room Plus subscription (the client calls it a `CampusCard`). Nothing here sells one,
|
||
* so this is the complimentary subscription a `developer` account reports — see
|
||
* `developerSubscription` 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/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('Echoed back verbatim; not stored'),
|
||
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 (UGC / custom avatar items). Only the numeric form resolves here —
|
||
* nothing sells guid-keyed items yet, so a `Guid` id fails its line.
|
||
*/
|
||
export const ItemPurchaseMethodId = z.object({
|
||
Type: z.int().describe('0 = NumberId. Anything else names a guid-keyed item we can’t sell'),
|
||
NumberId: z.int().nullable().optional().describe('The storefront PurchasableItemId'),
|
||
Guid: z.string().nullable().optional().describe('The guid-keyed item id; always null here'),
|
||
})
|
||
|
||
/**
|
||
* `POST /api/items/bulkpurchase` — the whole bag's result.
|
||
*
|
||
* NOT buyItem's envelope. The wrapper is `{ Success, Error, error_id, Value }` — `error_id`
|
||
* lowercase because the client renames that one member, the other three PascalCase — and
|
||
* `Value` is a BalanceUpdateResponse: the RESULTING `{ Balance, CurrencyType, Platform }`
|
||
* (buyItem reports the change instead) plus one `BalanceUpdates` entry per REQUESTED item.
|
||
* `Value` is null whenever nothing was bought; the client's validator only cascades into a
|
||
* non-null one, so that parses.
|
||
*
|
||
* Per-line reporting is each entry's `UpdateResponse`. `AllowPartialSuccess` is what lets
|
||
* some of them come back non-OK while `Success` stays true.
|
||
*/
|
||
export const BulkPurchaseResponse = z.object({
|
||
Success: z.boolean().describe('False only when the bag bought nothing at all'),
|
||
Error: z.string().nullable().describe('Why nothing was bought; null on success'),
|
||
error_id: z.string().nullable().describe('Always null — no error-id catalog here'),
|
||
Value: z
|
||
.object({
|
||
Balance: z.int().describe('The RESULTING total in the bucket below, NOT buyItem’s change'),
|
||
CurrencyType: z.int(),
|
||
Platform: z
|
||
.int()
|
||
.describe(
|
||
'The balance bucket — the client’s `BalanceType` under a [DataMember] rename. -2, ' +
|
||
'account-wide: the reference server said 4 (RecNetPurchased) because it kept a ' +
|
||
'wallet per store; this one keeps a single bucket, and the client SUMS its buckets'
|
||
),
|
||
BalanceUpdates: z
|
||
.array(
|
||
z.object({
|
||
UpdateResponse: z
|
||
.int()
|
||
.describe(
|
||
'This line’s outcome: 0 OK, 1 TooManyRequests, 2 NotEnoughCredit, ' +
|
||
'3 AlreadyOwned, 4 NoItemAvailable, 5 CouponNotApplicable, ' +
|
||
'6 RequestedPriceDoesNotMatch, 7 RequestedAmountNotAllowed, ' +
|
||
'8 PlayerNotEligible, 9 RequestCannotBeRefunded, 10 PlayerNotApproved'
|
||
),
|
||
Data: z.object({
|
||
GiftPackage: JsonObject.nullable().describe(
|
||
'The box created for this line (20 keys). Null on a line that didn’t sell, ' +
|
||
'and under `BypassGiftPackages` — the item is granted either way'
|
||
),
|
||
PurchasableItemId: z.int().nullable().describe('The catalog item this line named'),
|
||
CustomAvatarItem: z.null().describe('The UGC counterpart; never sold here'),
|
||
}),
|
||
})
|
||
)
|
||
.describe('One entry per REQUESTED item, in request order — failures included'),
|
||
})
|
||
.nullable()
|
||
.describe('Null when nothing was bought'),
|
||
})
|
||
|
||
/**
|
||
* `GET /api/storefronts/v2/buyInvention` — the purchase result. Two envelopes side by
|
||
* side: the balance update (shaped like buyItem's, except `Balance` is the RESULTING
|
||
* 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'),
|
||
})
|
||
|
||
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
|
||
export const ErrorResponse = z.object({ error: z.string() })
|
||
|
||
// ---- Request schemas -------------------------------------------------------
|
||
|
||
/**
|
||
* The `Gift` block both purchase bodies carry — present when buying an item for another
|
||
* player. The caller is still the one debited.
|
||
*/
|
||
export const GiftBlock = z
|
||
.object({
|
||
ToPlayerId: z.int().optional(),
|
||
Anonymous: z.boolean().optional(),
|
||
Message: z.string().optional(),
|
||
GiftContext: z.int().optional(),
|
||
})
|
||
.describe('Present when buying for another player; the caller still pays')
|
||
|
||
/** `POST /api/storefronts/v2/buyItem` JSON body. */
|
||
export const BuyItemRequest = z.object({
|
||
StorefrontType: z.int().describe('Which storefront catalog (sf{N}.json)'),
|
||
PurchasableItemId: z.int(),
|
||
CurrencyType: z.int().describe('Must be a spendable account currency'),
|
||
RequestedPrice: z.int().describe('The price the client rendered; a mismatch is 409'),
|
||
Gift: 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`; not stored'),
|
||
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'),
|
||
})
|
||
|
||
/**
|
||
* `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')
|