mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
econ docs
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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: [] }]
|
||||
|
||||
// ---- 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/customAvatarItems/v1/owned` — paginated owned custom items. */
|
||||
export const CustomAvatarItemsResponse = z.object({
|
||||
Results: JsonArray,
|
||||
TotalResults: z.int(),
|
||||
})
|
||||
|
||||
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both fields null (no subs yet). */
|
||||
export const SubscriptionResponse = z.object({
|
||||
subscription: z.null(),
|
||||
platformAccountSubscribedPlayerId: z.null(),
|
||||
})
|
||||
|
||||
/** `POST /api/challenge/v2/updateProgress` — the identifying fields echoed back. */
|
||||
export const ChallengeProgressResponse = z.object({
|
||||
ChallengeMapId: z.int(),
|
||||
ChallengeId: z.int(),
|
||||
Config: z.string(),
|
||||
Complete: z.boolean().describe('Always false — no challenge-progress store yet'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `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'),
|
||||
})
|
||||
|
||||
/** buyItem error body (`{ error }`), returned on 400/404/409. */
|
||||
export const ErrorResponse = z.object({ error: z.string() })
|
||||
|
||||
// ---- Request schemas -------------------------------------------------------
|
||||
|
||||
/** `POST /api/storefronts/v2/buyItem` JSON body. */
|
||||
export const BuyItemRequest = z.object({
|
||||
StorefrontType: z.int().describe('Which storefront catalog (sf{N}.json)'),
|
||||
PurchasableItemId: z.int(),
|
||||
CurrencyType: z.int().describe('Must be a spendable account currency'),
|
||||
RequestedPrice: z.int().describe('The price the client rendered; a mismatch is 409'),
|
||||
Gift: z
|
||||
.object({
|
||||
ToPlayerId: z.int().optional(),
|
||||
Anonymous: z.boolean().optional(),
|
||||
Message: z.string().optional(),
|
||||
GiftContext: z.int().optional(),
|
||||
})
|
||||
.optional()
|
||||
.describe('Present when buying for another player; the caller still pays'),
|
||||
})
|
||||
|
||||
/** `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'),
|
||||
})
|
||||
|
||||
/** `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')
|
||||
|
||||
/** 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')
|
||||
Reference in New Issue
Block a user