mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
rooms openapi
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the rooms 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/econ/clubs 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) } } }
|
||||
}
|
||||
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
export const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
/**
|
||||
* The 401 the auth-gated routes return. Most answer `{ error: 'Unauthorized' }`; the
|
||||
* subroom writes answer an empty body (see UNAUTHORIZED_EMPTY / UNAUTHORIZED_ENVELOPE).
|
||||
*/
|
||||
export const UNAUTHORIZED_RESPONSE = json(
|
||||
z.object({ error: z.literal('Unauthorized') }),
|
||||
'Missing or invalid bearer token'
|
||||
)
|
||||
|
||||
/** The empty-body 401 the subroom-save route returns. */
|
||||
export const UNAUTHORIZED_EMPTY = { description: 'Missing or invalid bearer token (empty body)' }
|
||||
|
||||
/** The 403 the owner/co-owner-gated routes return (empty body). */
|
||||
export const FORBIDDEN_RESPONSE = {
|
||||
description: 'A valid token, but not the room’s creator or a co-owner (empty body)',
|
||||
}
|
||||
|
||||
// ---- Parameters ------------------------------------------------------------
|
||||
|
||||
/** A digits-only id path parameter (the route patterns constrain these to `[0-9]+`). */
|
||||
function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return {
|
||||
name,
|
||||
in: 'path',
|
||||
required: true,
|
||||
description,
|
||||
schema: { type: 'string', pattern: '^[0-9]+$' },
|
||||
}
|
||||
}
|
||||
|
||||
/** The `:roomId` path parameter. */
|
||||
export const roomIdParam = idParam('roomId', 'Room id')
|
||||
|
||||
/** The `:subRoomId` path parameter. */
|
||||
export const subRoomIdParam = idParam('subRoomId', 'Subroom id (globally unique, not per-room)')
|
||||
|
||||
/** An optional string query parameter. */
|
||||
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
|
||||
}
|
||||
|
||||
/** An optional integer query parameter. */
|
||||
function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'query', required: false, description, schema: { type: 'integer' } }
|
||||
}
|
||||
|
||||
/** The `skip`/`take` pair every paginated room list accepts. */
|
||||
export function pageParams(defaultTake: number): OpenAPIV3_1.ParameterObject[] {
|
||||
return [
|
||||
intQuery('skip', 'How many rooms to skip (default 0)'),
|
||||
intQuery('take', `How many rooms to return (default ${defaultTake})`),
|
||||
]
|
||||
}
|
||||
|
||||
// ---- Core entities ---------------------------------------------------------
|
||||
|
||||
/** `GET /` — the liveness probe body. */
|
||||
export const ServiceStatus = z.object({
|
||||
service: z.literal('rooms'),
|
||||
status: z.literal('ok'),
|
||||
})
|
||||
|
||||
/** A room-role assignment. `Role`: 10 Host, 20 Moderator, 30 CoOwner, 255 Creator. */
|
||||
export const RoomRoleDto = z.object({
|
||||
AccountId: z.int(),
|
||||
Role: z.int().describe('10 = Host, 20 = Moderator, 30 = CoOwner, 255 = Creator'),
|
||||
LastChangedByAccountId: z.int().nullable(),
|
||||
InvitedRole: z.int(),
|
||||
})
|
||||
|
||||
/** A tag on a room. `Type` 0 = set by the owner, 2 = auto-derived (e.g. `rro`). */
|
||||
export const RoomTagDto = z.object({
|
||||
Tag: z.string(),
|
||||
Type: z.int().describe('0 = owner-set, 2 = auto'),
|
||||
})
|
||||
|
||||
/** A room's engagement counters. Nothing increments these yet, so they stay at 0. */
|
||||
export const RoomStatsDto = z.object({
|
||||
CheerCount: z.int(),
|
||||
FavoriteCount: z.int(),
|
||||
VisitorCount: z.int(),
|
||||
VisitCount: z.int(),
|
||||
})
|
||||
|
||||
/** One of the images shown while the room loads. */
|
||||
export const LoadScreenDto = z.object({
|
||||
ImageName: z.string().describe('A CDN bucket key under `room/`'),
|
||||
Title: z.string(),
|
||||
Subtitle: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* A subroom — a room's individual scene. Subrooms are their own table with a globally
|
||||
* unique, autoincrementing `SubRoomId` (the original game mints them from a single
|
||||
* sequence, not per-room); a room's `SubRooms` array is reconstructed on read.
|
||||
*
|
||||
* `CreatorAccountId` starts null on the seeded rooms and is filled in on the first save —
|
||||
* the client NREs on a null one. The `DataBlob`/`RoomDataBlob`/`DataSavedAt` fields only
|
||||
* appear once the subroom has been saved at least once.
|
||||
*/
|
||||
export const SubRoomDto = z.object({
|
||||
SubRoomId: z.int(),
|
||||
RoomId: z.int(),
|
||||
CreatorAccountId: z.int().nullable().describe('Null until the subroom’s first save'),
|
||||
UnitySceneId: z.string().describe('The Unity scene the client loads'),
|
||||
Name: z.string(),
|
||||
LastModeratedSaveModerationState: z.int(),
|
||||
IsSandbox: z.boolean(),
|
||||
MaxPlayers: z.int(),
|
||||
Accessibility: z.int().describe('0 = Private, 1 = Public, 2 = Unlisted'),
|
||||
ShouldAutoStageSaves: z.boolean(),
|
||||
StagedSubRoomDataSaveId: z.int().nullable(),
|
||||
DataBlob: z.string().optional().describe('Uploaded scene-data key; absent until first save'),
|
||||
RoomDataBlob: z.string().optional().describe('Uploaded room-data key; absent until first save'),
|
||||
DataSavedAt: z.string().optional().describe('ISO timestamp of the last save'),
|
||||
PersistenceVersion: z.int().optional(),
|
||||
})
|
||||
|
||||
/** A room's localization settings — carried through verbatim; nothing localizes yet. */
|
||||
export const LocalizationContextDto = z.object({
|
||||
TargetLocale: z.string().nullable(),
|
||||
Scope: z.string().nullable(),
|
||||
LocalizedFields: z.array(z.string()),
|
||||
})
|
||||
|
||||
/**
|
||||
* A room, exactly as stored: each room is a single JSON blob in D1 and every read
|
||||
* serves it verbatim (PascalCase, the client-facing shape), with `SubRooms` re-attached
|
||||
* from the subroom table. The seed data comes from `static/ImportRooms.json`.
|
||||
*/
|
||||
export const RoomDto = z.object({
|
||||
RoomId: z.int(),
|
||||
Name: z.string().describe('Unique, case-insensitively'),
|
||||
Description: z.string(),
|
||||
ImageName: z.string().describe('A CDN bucket key served back under `room/`'),
|
||||
WarningMask: z.int().describe('Content-warning bit flags'),
|
||||
CustomWarning: z.string().nullable(),
|
||||
CreatorAccountId: z.int().describe('The room’s owner — always passes the role checks'),
|
||||
State: z.int(),
|
||||
Accessibility: z
|
||||
.int()
|
||||
.describe('0 = Private, 1 = Public, 2 = Unlisted. The room-browse feeds serve Public only'),
|
||||
PublishState: z.int(),
|
||||
SupportsLevelVoting: z.boolean(),
|
||||
IsRRO: z.boolean().describe('A Rec Room Original — the client renders a virtual `rro` tag'),
|
||||
IsRecRoomApproved: z.boolean(),
|
||||
ExcludeFromLists: z.boolean(),
|
||||
ExcludeFromSearch: z.boolean(),
|
||||
SupportsScreens: z.boolean(),
|
||||
SupportsWalkVR: z.boolean(),
|
||||
SupportsTeleportVR: z.boolean(),
|
||||
SupportsVRLow: z.boolean(),
|
||||
SupportsQuest2: z.boolean(),
|
||||
SupportsMobile: z.boolean(),
|
||||
SupportsJuniors: z.boolean(),
|
||||
MinLevel: z.int(),
|
||||
AgeRating: z.int(),
|
||||
CreatedAt: z.string(),
|
||||
PublishedAt: z.string(),
|
||||
BecameRRStudioRoomAt: z.string().nullable(),
|
||||
Stats: RoomStatsDto,
|
||||
RankingContext: z.unknown().nullable(),
|
||||
IsDorm: z.boolean().describe('Auto-provisioned personal room; excluded from every feed'),
|
||||
IsPlacePlay: z.boolean(),
|
||||
MaxPlayerCalculationMode: z.int(),
|
||||
MaxPlayers: z.int(),
|
||||
CloningAllowed: z.boolean().describe('False blocks `POST /rooms/{roomId}/clone`'),
|
||||
DisableMicAutoMute: z.boolean(),
|
||||
DisableRoomComments: z.boolean(),
|
||||
EncryptVoiceChat: z.boolean(),
|
||||
ToxmodEnabled: z.boolean(),
|
||||
LoadScreenLocked: z.boolean(),
|
||||
UgcVersion: z.int(),
|
||||
PersistenceVersion: z.int(),
|
||||
UgcSubVersion: z.int().nullable(),
|
||||
MinUgcSubVersion: z.int().nullable(),
|
||||
AutoLocalizeRoom: z.boolean(),
|
||||
LocalizationContext: LocalizationContextDto,
|
||||
IsDeveloperOwned: z.boolean(),
|
||||
RankedEntityId: z.string(),
|
||||
SubRooms: z.array(SubRoomDto).describe('Re-attached from the subroom table on every read'),
|
||||
Roles: z.array(RoomRoleDto),
|
||||
IsJuniorCreated: z.boolean(),
|
||||
Tags: z.array(RoomTagDto),
|
||||
PromoImages: z.array(z.unknown()),
|
||||
PromoExternalContent: z.array(z.unknown()),
|
||||
LoadScreens: z.array(LoadScreenDto),
|
||||
RestrictedCircuitsAllowListNames: z.array(z.string()),
|
||||
InventionUsage: z.string().optional().describe('Recorded by a room save; absent until then'),
|
||||
})
|
||||
|
||||
/** A paged room list (`PagedResultsDTO<RoomDTO>`) — search, hot, similar. */
|
||||
export const PagedRooms = z.object({
|
||||
Results: z.array(RoomDto),
|
||||
TotalResults: z.int().describe('The full match count, not the page size'),
|
||||
})
|
||||
|
||||
/**
|
||||
* A room lookup result: the room, or `{}` when nothing matched. The by-id/by-name
|
||||
* lookups answer an empty object rather than a 404 — the client reads that as "no room".
|
||||
*/
|
||||
export const RoomLookup = z.union([RoomDto, z.object({})])
|
||||
|
||||
/** The bare JSON string the lookup routes answer with when neither `id` nor `name` is given. */
|
||||
export const MissingLookupParam = z
|
||||
.string()
|
||||
.describe("`\"Either 'id' or 'name' query parameter is required\"`")
|
||||
|
||||
// ---- Interaction -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A player's own state on a room: whether they've cheered/favorited it, plus the last
|
||||
* visit. `LastVisitedAt` is stamped with "now" on every read rather than served from the
|
||||
* stored value — the client only uses it to order the recently-visited list.
|
||||
*/
|
||||
export const InteractionDto = z.object({
|
||||
Cheered: z.boolean(),
|
||||
Favorited: z.boolean(),
|
||||
LastVisitedAt: z.string().describe('Always "now" — not the stored visit time'),
|
||||
})
|
||||
|
||||
// ---- Featured rooms --------------------------------------------------------
|
||||
|
||||
/** The compact room projection a featured-room group carries. */
|
||||
export const FeaturedRoomDto = z.object({
|
||||
RoomId: z.int(),
|
||||
RoomName: z.string(),
|
||||
ImageName: z.string(),
|
||||
IsRecRoomApproved: z.boolean(),
|
||||
ExcludeFromLists: z.boolean(),
|
||||
ExcludeFromSearch: z.boolean(),
|
||||
})
|
||||
|
||||
/** A time-boxed group of featured rooms. There's one, and it's always active. */
|
||||
export const FeaturedRoomGroupDto = z.object({
|
||||
FeaturedRoomGroupId: z.int(),
|
||||
name: z.string(),
|
||||
StartAt: z.string(),
|
||||
EndAt: z.string(),
|
||||
Rooms: z.array(FeaturedRoomDto).describe('Randomly ordered — no editorial curation yet'),
|
||||
})
|
||||
|
||||
// ---- Envelopes -------------------------------------------------------------
|
||||
//
|
||||
// The room writes answer one of two envelopes, both at HTTP 200 — the client reads the
|
||||
// success flag, not the status. Which one a route uses is not ours to choose: it's what
|
||||
// the client's deserializer for that call expects, so the two live side by side.
|
||||
|
||||
/**
|
||||
* The PascalCase result envelope (`Results.Ok(new RoomResult{...})`): a bare
|
||||
* success/failure with a message, carrying no entity. `ErrorId` is a stable code the
|
||||
* client may branch on; `Error` is the text it shows.
|
||||
*/
|
||||
export const RoomResultEnvelope = z.object({
|
||||
Success: z.boolean(),
|
||||
Value: z.unknown().nullable().describe('Always null — these routes carry no entity'),
|
||||
ErrorId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('e.g. `Rooms.DoesntExist`, `Rooms.NotOwner`; null on success'),
|
||||
Error: z.string().nullable().describe('The message shown to the player; null on success'),
|
||||
})
|
||||
|
||||
/** The lowercase envelope carrying the updated room — the client re-renders from `value`. */
|
||||
export const RoomEnvelope = z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().describe('Empty on success'),
|
||||
value: RoomDto.nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* What a room save answers: the saved subroom on success (no envelope — the client
|
||||
* deserializes the body directly as the subroom), or the PascalCase result envelope when
|
||||
* the room or subroom doesn't exist. Both at HTTP 200.
|
||||
*/
|
||||
export const SubRoomSaveResult = z.union([SubRoomDto, RoomResultEnvelope])
|
||||
|
||||
/** The same envelope carrying a subroom (`POST …/subrooms/{subRoomId}/clone`). */
|
||||
export const SubRoomEnvelope = z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().describe('Empty on success'),
|
||||
value: SubRoomDto.nullable(),
|
||||
})
|
||||
|
||||
/** The 401 the envelope-returning routes answer with — the only one that isn’t HTTP 200. */
|
||||
export const UNAUTHORIZED_ENVELOPE = json(
|
||||
z.object({ success: z.literal(false), error: z.literal('Unauthorized'), value: z.null() }),
|
||||
'Missing or invalid bearer token'
|
||||
)
|
||||
|
||||
// ---- Request bodies --------------------------------------------------------
|
||||
|
||||
/** `POST /rooms/{roomId}/clone` — also accepted as a `?name=` query param. */
|
||||
export const CloneRoomRequest = z.object({
|
||||
name: z.string().describe('The new room’s name; must be unique'),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/description`. */
|
||||
export const DescriptionRequest = z.object({
|
||||
description: z.string().describe('An absent field clears the description'),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/name`. */
|
||||
export const NameRequest = z.object({
|
||||
name: z.string().describe('Non-empty, and not already taken by another room'),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/tags` — a toggle, not a set. */
|
||||
export const TagRequest = z.object({
|
||||
tag: z.string().describe('Added when absent, removed when present'),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/image`. */
|
||||
export const ImageRequest = z.object({
|
||||
imageName: z.string().describe('A key from the storage upload, stored un-prefixed'),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/roles/{accountId}`. */
|
||||
export const RoleRequest = z.object({
|
||||
role: z.string().describe('The role tier: 10 Host, 20 Moderator, 30 CoOwner, 255 Creator'),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/warning`. */
|
||||
export const WarningRequest = z.object({
|
||||
warningMask: z.string().describe('Content-warning bit flags, as an integer'),
|
||||
customWarning: z.string().optional().describe('Set when present; an empty value clears it'),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/cloning`. */
|
||||
export const CloningRequest = z.object({
|
||||
cloningAllowed: z.string().describe('`True` / `False`'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /rooms/{roomId}/restrictions` — the room's platform/movement support flags. Only
|
||||
* the fields actually posted are changed, and the names are matched case-insensitively.
|
||||
*/
|
||||
export const RestrictionsRequest = z.object({
|
||||
supportsScreens: z.string().optional().describe('`True` / `False`'),
|
||||
supportsWalkVR: z.string().optional().describe('`True` / `False`'),
|
||||
supportsTeleportVR: z.string().optional().describe('`True` / `False`'),
|
||||
supportsVRLow: z.string().optional().describe('`True` / `False`'),
|
||||
supportsQuest2: z.string().optional().describe('`True` / `False`'),
|
||||
supportsMobile: z.string().optional().describe('`True` / `False`'),
|
||||
supportsJuniors: z.string().optional().describe('`True` / `False`'),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/loadscreen` — appends one screen to the list. */
|
||||
export const LoadScreenRequest = z.object({
|
||||
imageName: z.string().describe('A key from the storage upload'),
|
||||
title: z.string().optional(),
|
||||
subtitle: z.string().optional(),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/accessibility`. */
|
||||
export const AccessibilityRequest = z.object({
|
||||
accessibility: z.string().describe('0 = Private, 1 = Public, 2 = Unlisted'),
|
||||
})
|
||||
|
||||
/** `POST /rooms/{roomId}/subrooms`. */
|
||||
export const CreateSubRoomRequest = z.object({
|
||||
name: z.string().describe('The new subroom’s name'),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/subrooms/{subRoomId}/modify`. */
|
||||
export const ModifySubRoomRequest = z.object({
|
||||
name: z.string().describe('Required — an empty name is rejected'),
|
||||
accessibility: z.string().optional().describe('0 = Private, 1 = Public, 2 = Unlisted'),
|
||||
maxPlayers: z.string().optional().describe('Ignored when not a positive integer'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /rooms/{roomId}/subrooms/{subRoomId}/data` — the room save. The blobs are
|
||||
* uploaded to the CDN through the `storage` worker first; this call points the subroom
|
||||
* at them. Every field is optional: a save that carries only `SubRoomData` still stamps
|
||||
* the save time.
|
||||
*/
|
||||
export const SaveSubRoomDataRequest = z.object({
|
||||
SubRoomData: z
|
||||
.object({ Filename: z.string() })
|
||||
.optional()
|
||||
.describe('The uploaded scene-data blob — becomes the subroom’s `DataBlob`'),
|
||||
RoomData: z
|
||||
.object({ Filename: z.string() })
|
||||
.optional()
|
||||
.describe('The uploaded room-level data blob — becomes `RoomDataBlob`'),
|
||||
Description: z.string().optional().describe('Written to the ROOM, not the subroom'),
|
||||
PersistenceVersion: z.int().optional(),
|
||||
InventionUsage: z.string().optional().describe('Written to the room'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /rooms/{roomId}/subrooms/{subRoomId}/saves` — the room-history page. We keep no
|
||||
* save history (a save overwrites the subroom's blob inline), so it's always empty.
|
||||
*/
|
||||
export const SubRoomSavesPage = z.object({
|
||||
Results: z.array(z.unknown()).describe('Always empty — no save history is kept'),
|
||||
TotalResults: z.int(),
|
||||
})
|
||||
|
||||
// ---- Session ---------------------------------------------------------------
|
||||
|
||||
/** One entry of the permission table the client applies when it spawns into a room. */
|
||||
export const RoomPermissionDto = z.object({
|
||||
Override: z.boolean(),
|
||||
Permission: z.string().describe('e.g. `CAN_USE_MAKER_PEN`, `CAN_SAVE_INVENTIONS`'),
|
||||
Role: z.int().describe('The role tier the permission applies to (0 = everyone)'),
|
||||
Type: z.int(),
|
||||
Value: z.string().describe('Always `True` — a permission is present or absent'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The permissions + Photon credentials the client needs to spawn into a room.
|
||||
*
|
||||
* `PhotonAccessToken` is deliberately empty: the reference server signs it with a
|
||||
* secret/algorithm we don't have, and our Photon setup accepts an empty token. The
|
||||
* global (Role 0) maker pen is granted only to the hardcoded dev accounts.
|
||||
*/
|
||||
export const PhotonAccessTokenDto = z.object({
|
||||
Permissions: z.array(RoomPermissionDto),
|
||||
PhotonAccessToken: z.string().describe('Always empty — see above'),
|
||||
RoomInstanceId: z
|
||||
.int()
|
||||
.nullable()
|
||||
.describe('The caller’s current instance, from presence; null when they’re in none'),
|
||||
})
|
||||
|
||||
/** `GET /rooms/{roomId}/playerdata/me` — per-room player data. Nothing stores any yet. */
|
||||
export const PlayerDataDto = z.object({
|
||||
Data: z.string().describe('Always empty — no per-room player data is stored'),
|
||||
})
|
||||
Reference in New Issue
Block a user