mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
185 lines
8.1 KiB
TypeScript
185 lines
8.1 KiB
TypeScript
import { resolver } from 'hono-openapi'
|
||
import { z } from 'zod'
|
||
|
||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||
|
||
/**
|
||
* OpenAPI schemas for the ai 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/econ/match/playersettings 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) } } }
|
||
}
|
||
|
||
/** 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: [] }]
|
||
|
||
/** An optional integer query parameter. */
|
||
export function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||
return { name, in: 'query', required: false, description, schema: { type: 'integer' } }
|
||
}
|
||
|
||
/**
|
||
* An optional boolean query parameter. The client spells these .NET-style (`False`, not
|
||
* `false`), which is worth recording even where the value is ignored.
|
||
*/
|
||
export function boolQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||
return { name, in: 'query', required: false, description, schema: { type: 'boolean' } }
|
||
}
|
||
|
||
/** An integer path parameter (ids are constrained to `[0-9]+` by the route pattern). */
|
||
export function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||
return { name, in: 'path', required: true, description, schema: { type: 'integer' } }
|
||
}
|
||
|
||
/**
|
||
* An `application/json` request body.
|
||
*
|
||
* The schema is emitted directly rather than through `resolver()` — zod's `$schema` key
|
||
* and `additionalProperties: false` are dropped, since the handler reads the fields it
|
||
* knows and ignores the rest, so a closed object would misreport it as stricter than it is.
|
||
*/
|
||
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||
return {
|
||
description,
|
||
content: { 'application/json': { schema: jsonSchema as OpenAPIV3_1.SchemaObject } },
|
||
}
|
||
}
|
||
|
||
// ---- Response schemas ------------------------------------------------------
|
||
|
||
/** `GET /` — the root health check. */
|
||
export const HealthResponse = z.object({
|
||
service: z.literal('ai'),
|
||
status: z.literal('ok'),
|
||
})
|
||
|
||
/**
|
||
* The Roomie AI access envelope — the `{ success, error_id, error, value }` shape, unlike
|
||
* the flat Game AI refusal above. Roomie is granted here, with its energy budget pinned at
|
||
* the maximum a signed 32-bit int holds (see INT32_MAX).
|
||
*/
|
||
export const RoomieAiAccess = z.object({
|
||
success: z.literal(true),
|
||
error_id: z.null(),
|
||
error: z.null(),
|
||
value: z.object({
|
||
MaxEnergyFromSubscriptions: z
|
||
.int()
|
||
.describe('The energy ceiling a subscription buys — pinned to int32 max'),
|
||
EnergyLeft: z.int().describe('Energy remaining. Never spent here, so also int32 max'),
|
||
NextSubscriptionEnergyRechargeAt: z
|
||
.string()
|
||
.nullable()
|
||
.describe('When the budget refills. Null — nothing depletes, so nothing recharges'),
|
||
OutputAudioEnabled: z.boolean().describe('Whether Roomie may speak its replies'),
|
||
}),
|
||
})
|
||
|
||
/**
|
||
* The refusal every Game AI read answers with. It is a 200 carrying `success: false`, not
|
||
* an HTTP error — the client branches on the body, and an error status would surface as a
|
||
* failed request rather than the "not available here" state it is meant to show.
|
||
*/
|
||
export const GameAiAccessDenied = z.object({
|
||
success: z.literal(false),
|
||
error_id: z.string().describe('Machine-readable reason, e.g. `AI.RoomDoesNotSupportGameAI`'),
|
||
error: z.string().describe('The message shown to the player'),
|
||
})
|
||
|
||
/**
|
||
* The same refusal, plus an explicit `value: null`. The spend summary carries the key
|
||
* where the access check omits it — the access check answers a yes/no and has nothing to
|
||
* carry, while this one's payload slot exists and is simply empty. Reproduced as the
|
||
* reference server sends it; don't unify the two.
|
||
*/
|
||
export const GameAiSpendSummaryDenied = GameAiAccessDenied.extend({
|
||
value: z.null().describe('The spend summary. Null — there is no Game AI spend to report'),
|
||
})
|
||
|
||
/**
|
||
* `GET /makerai/user/access` — always granted, in an envelope that belongs to this endpoint
|
||
* alone: PascalCase `Success`/`Error` beside a snake_case `error_id`, and no `value` slot.
|
||
* It is neither the Game AI refusal's all-lowercase body nor the Roomie check's
|
||
* `{ success, error_id, error, value }`. Reproduced as the reference sends it — the casing
|
||
* mix is not a typo to normalise.
|
||
*/
|
||
export const MakerAiAccessResponse = z.object({
|
||
Success: z.boolean().describe('Whether the caller may use Maker AI. Always true'),
|
||
Error: z.null().describe('The failure message. Null — the check always passes'),
|
||
error_id: z.null().describe('The failure code. Null — the check always passes'),
|
||
})
|
||
|
||
/**
|
||
* Maker AI's dollar balances. A FLAT body — no `{ success, error, value }` envelope — and
|
||
* every figure zero, since nothing here bills for model usage.
|
||
*/
|
||
export const MakerAiBalances = z.object({
|
||
UsageDollars: z.number().describe('Dollars of model usage spent this period. Always 0'),
|
||
UsersMaxUsageDollars: z.number().describe('The caller’s usage ceiling. Always 0'),
|
||
RRPlusUsageDollars: z.number().describe('Usage spent against the RR+ allowance. Always 0'),
|
||
UsersMaxRRPlusUsageDollars: z.number().describe('The RR+ allowance ceiling. Always 0'),
|
||
TimeBalanceStatus: z.string().describe('Time-balance bucket state, e.g. `Empty`'),
|
||
TimeExpiresAt: z
|
||
.string()
|
||
.describe('When the time balance lapses. `DateTime.MinValue` — there is none'),
|
||
UsageBalanceStatus: z.string().describe('Usage-balance bucket state, e.g. `Good`'),
|
||
UsagePercent: z.number().describe('Share of the usage ceiling consumed. Always 0'),
|
||
RRPlusUsageBalanceStatus: z.string().describe('RR+ usage bucket state, e.g. `Good`'),
|
||
RRPlusUsagePercent: z.number().describe('Share of the RR+ allowance consumed. Always 0'),
|
||
})
|
||
|
||
/** The body the client posts to open a realtime session. Read for documentation only. */
|
||
export const RealtimeSessionCreateBody = z.object({
|
||
AIType: z
|
||
.string()
|
||
.optional()
|
||
.describe('Which assistant the client is opening a session for, e.g. `Roomie`'),
|
||
})
|
||
|
||
/**
|
||
* The realtime-session refusal. `{ success, error, error_id, value }` — note `error_id` is
|
||
* an empty string rather than a machine-readable code, and `value` (which would carry the
|
||
* session id and its client secret) is null.
|
||
*/
|
||
export const RealtimeSessionDenied = z.object({
|
||
success: z.literal(false),
|
||
error: z.string().describe('The message shown to the player'),
|
||
error_id: z.string().describe('Empty — the reference server sends no code for this refusal'),
|
||
value: z.null().describe('The session credentials. Null: no session is created'),
|
||
})
|
||
|
||
/**
|
||
* What Roomie knows about the caller: a prose profile it is primed with, and the discrete
|
||
* facts behind it. Both empty here — nothing observes the player to build them.
|
||
*/
|
||
export const RoomieUserFacts = z.object({
|
||
UserContext: z.string().describe('A prose profile Roomie is primed with. Empty'),
|
||
UserFacts: z
|
||
.array(
|
||
z.object({
|
||
Id: z.string().describe('GUID identifying the fact'),
|
||
CreatedAt: z.string().describe('When the fact was recorded'),
|
||
Emotion: z.string().describe('Sentiment attached to the fact, e.g. `neutral`'),
|
||
Predicate: z.string().describe('The relation, e.g. `identifies as`'),
|
||
Object: z.string().describe('The value the predicate points at'),
|
||
})
|
||
)
|
||
.describe('The recorded facts. Always empty — nothing here observes the player'),
|
||
})
|