mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
256 lines
9.8 KiB
TypeScript
256 lines
9.8 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: [] }]
|
||
|
||
// ---- 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(),
|
||
})
|
||
|
||
/**
|
||
* 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` — 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')
|
||
|
||
/**
|
||
* `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')
|