214 lines
8.0 KiB
TypeScript
214 lines
8.0 KiB
TypeScript
import { z } from 'zod'
|
|
export * from './moderation'
|
|
|
|
// PostgreSQL/PostgREST serializes timestamptz values with an explicit offset
|
|
// (for example `+00:00`). Browser-created timestamps normally use `Z`.
|
|
// Both are valid ISO-8601 timestamps and must be accepted at the API boundary.
|
|
const IsoDatetimeSchema = z.string().datetime({ offset: true })
|
|
|
|
export const abilityKeys = ['str', 'dex', 'con', 'int', 'wis', 'cha'] as const
|
|
export const AbilityKeySchema = z.enum(abilityKeys)
|
|
export type AbilityKey = z.infer<typeof AbilityKeySchema>
|
|
|
|
export const AbilityScoresSchema = z.object({
|
|
str: z.number().int().min(1).max(30),
|
|
dex: z.number().int().min(1).max(30),
|
|
con: z.number().int().min(1).max(30),
|
|
int: z.number().int().min(1).max(30),
|
|
wis: z.number().int().min(1).max(30),
|
|
cha: z.number().int().min(1).max(30),
|
|
})
|
|
export type AbilityScores = z.infer<typeof AbilityScoresSchema>
|
|
|
|
export const WorldEntitySchema = z.object({
|
|
id: z.string().min(1),
|
|
kind: z.enum(['location', 'npc', 'faction', 'quest']),
|
|
name: z.string().min(1).max(120),
|
|
summary: z.string().min(1).max(1200),
|
|
tags: z.array(z.string().max(40)).max(12).default([]),
|
|
secrets: z.array(z.string().max(500)).max(6).default([]),
|
|
})
|
|
export type WorldEntity = z.infer<typeof WorldEntitySchema>
|
|
|
|
export const CharacterPersonaSchema = z.object({
|
|
voice: z.string().trim().min(1).max(240),
|
|
motivation: z.string().trim().min(1).max(300),
|
|
flaw: z.string().trim().min(1).max(300),
|
|
bond: z.string().trim().min(1).max(300),
|
|
})
|
|
export type CharacterPersona = z.infer<typeof CharacterPersonaSchema>
|
|
|
|
export const CharacterDraftSchema = z.object({
|
|
name: z.string().trim().min(1).max(80),
|
|
concept: z.string().trim().min(3).max(600),
|
|
abilities: AbilityScoresSchema,
|
|
hp: z.number().int().min(1).max(100),
|
|
maxHp: z.number().int().min(1).max(100),
|
|
defense: z.number().int().min(1).max(40),
|
|
proficiency: z.number().int().min(1).max(10),
|
|
inventory: z.array(z.string().trim().min(1).max(120)).max(12),
|
|
persona: CharacterPersonaSchema,
|
|
}).strict().refine(value => value.hp <= value.maxHp, {
|
|
message: 'hp must not exceed maxHp',
|
|
path: ['hp'],
|
|
})
|
|
export type CharacterDraft = z.infer<typeof CharacterDraftSchema>
|
|
|
|
export const WorldStarterSchema = z.object({
|
|
title: z.string().min(3).max(100),
|
|
genre: z.string().min(2).max(80),
|
|
tone: z.string().min(2).max(160),
|
|
premise: z.string().min(20).max(1800),
|
|
contentBoundaries: z.array(z.string().max(120)).min(1).max(8),
|
|
startingLocation: WorldEntitySchema.extend({ kind: z.literal('location') }),
|
|
npcs: z.array(WorldEntitySchema.extend({ kind: z.literal('npc') })).length(3),
|
|
factions: z.array(WorldEntitySchema.extend({ kind: z.literal('faction') })).length(2),
|
|
companions: z.array(CharacterDraftSchema).length(3),
|
|
hook: z.string().min(20).max(1200),
|
|
hiddenThreat: z.string().min(10).max(1000),
|
|
openingScene: z.string().min(40).max(2400),
|
|
})
|
|
export type WorldStarter = z.infer<typeof WorldStarterSchema>
|
|
|
|
export const CharacterSchema = z.object({
|
|
id: z.string().min(1),
|
|
name: z.string().min(1).max(80),
|
|
concept: z.string().min(3).max(600),
|
|
controller: z.enum(['human', 'ai', 'delegated']),
|
|
userId: z.string().nullable().default(null),
|
|
abilities: AbilityScoresSchema,
|
|
hp: z.number().int().min(0),
|
|
maxHp: z.number().int().min(1),
|
|
defense: z.number().int().min(1).max(40),
|
|
proficiency: z.number().int().min(1).max(10),
|
|
inventory: z.array(z.string().max(120)).max(50).default([]),
|
|
statuses: z.array(z.string().max(80)).max(12).default([]),
|
|
persona: z.record(z.string(), z.unknown()).optional(),
|
|
})
|
|
export type Character = z.infer<typeof CharacterSchema>
|
|
|
|
export const PlayerIntentSchema = z.object({
|
|
id: z.string().min(1),
|
|
roundId: z.string().min(1),
|
|
memberId: z.string().min(1),
|
|
characterId: z.string().min(1),
|
|
action: z.string().trim().min(1).max(2000),
|
|
ready: z.boolean().default(false),
|
|
createdAt: IsoDatetimeSchema,
|
|
updatedAt: IsoDatetimeSchema,
|
|
})
|
|
export type PlayerIntent = z.infer<typeof PlayerIntentSchema>
|
|
|
|
export const RoundContextSchema = z.object({
|
|
scene: z.string().max(6000),
|
|
storySummary: z.string().max(6000).nullable(),
|
|
recentRounds: z.array(z.object({
|
|
number: z.number().int().positive(),
|
|
narration: z.string().max(6000),
|
|
nextPrompt: z.string().max(500).nullable().optional(),
|
|
})).max(2),
|
|
humanIntents: z.array(PlayerIntentSchema).max(8),
|
|
aiCharacters: z.array(CharacterSchema).max(8),
|
|
activeCharacters: z.array(CharacterSchema).max(16),
|
|
memories: z.array(z.object({
|
|
summary: z.string().min(1).max(1200),
|
|
importance: z.number().int().min(1).max(5).optional(),
|
|
tags: z.array(z.string().max(40)).max(12).optional(),
|
|
entityIds: z.array(z.string()).max(20).optional(),
|
|
})).max(8),
|
|
})
|
|
export type RoundContext = z.infer<typeof RoundContextSchema>
|
|
|
|
export const CheckRequestSchema = z.object({
|
|
actorId: z.string().min(1),
|
|
kind: z.enum(['ability', 'attack', 'initiative', 'damage', 'healing']),
|
|
ability: AbilityKeySchema.optional(),
|
|
difficulty: z.number().int().min(1).max(40).optional(),
|
|
targetId: z.string().optional(),
|
|
mode: z.enum(['normal', 'advantage', 'disadvantage']).default('normal'),
|
|
dice: z.string().regex(/^\d+d\d+(?:[+-]\d+)?$/).optional(),
|
|
reason: z.string().min(1).max(500),
|
|
})
|
|
export type CheckRequest = z.infer<typeof CheckRequestSchema>
|
|
|
|
export const DiceRollSchema = z.object({
|
|
id: z.string().uuid(),
|
|
checkKind: CheckRequestSchema.shape.kind,
|
|
formula: z.string(),
|
|
rolls: z.array(z.number().int()),
|
|
kept: z.array(z.number().int()),
|
|
modifier: z.number().int(),
|
|
total: z.number().int(),
|
|
difficulty: z.number().int().nullable(),
|
|
success: z.boolean().nullable(),
|
|
actorId: z.string(),
|
|
targetId: z.string().nullable(),
|
|
createdAt: IsoDatetimeSchema,
|
|
})
|
|
export type DiceRoll = z.infer<typeof DiceRollSchema>
|
|
|
|
export const ProposedEventSchema = z.object({
|
|
type: z.enum(['narrative', 'relationship', 'quest', 'inventory', 'status', 'damage', 'healing']),
|
|
actorId: z.string().nullable().default(null),
|
|
targetId: z.string().nullable().default(null),
|
|
value: z.number().int().nullable().default(null),
|
|
item: z.string().max(120).nullable().default(null),
|
|
description: z.string().min(1).max(1000),
|
|
})
|
|
export type ProposedEvent = z.infer<typeof ProposedEventSchema>
|
|
|
|
export const RoundPlanSchema = z.object({
|
|
checks: z.array(CheckRequestSchema).max(20),
|
|
aiActions: z.array(z.object({
|
|
characterId: z.string(),
|
|
action: z.string().min(1).max(800),
|
|
})).max(8),
|
|
proposedEvents: z.array(ProposedEventSchema).max(30),
|
|
relevantMemoryQueries: z.array(z.string().max(120)).max(8),
|
|
})
|
|
export type RoundPlan = z.infer<typeof RoundPlanSchema>
|
|
|
|
export const RoundResolutionSchema = z.object({
|
|
narration: z.string().min(20).max(6000),
|
|
events: z.array(ProposedEventSchema).max(30),
|
|
memory: z.object({
|
|
summary: z.string().min(10).max(1200),
|
|
importance: z.number().int().min(1).max(5),
|
|
tags: z.array(z.string().max(40)).max(12),
|
|
entityIds: z.array(z.string()).max(20),
|
|
}).nullable(),
|
|
nextPrompt: z.string().min(3).max(500),
|
|
})
|
|
export type RoundResolution = z.infer<typeof RoundResolutionSchema>
|
|
|
|
export const StorySummarySchema = z.object({
|
|
summary: z.string().min(20).max(6000),
|
|
})
|
|
export type StorySummary = z.infer<typeof StorySummarySchema>
|
|
|
|
export const CoauthorMessageSchema = z.object({
|
|
role: z.enum(['user', 'assistant']),
|
|
content: z.string().trim().min(1).max(5000),
|
|
})
|
|
|
|
export const CreateWorldRequestSchema = z.object({
|
|
messages: z.array(CoauthorMessageSchema).min(1).max(12),
|
|
})
|
|
|
|
export const SubmitIntentRequestSchema = z.object({
|
|
campaignId: z.string().min(1),
|
|
characterId: z.string().min(1),
|
|
action: z.string().trim().min(1).max(2000),
|
|
ready: z.boolean().default(true),
|
|
})
|
|
|
|
export type ID = string
|
|
|
|
export function makeId(prefix: string): string {
|
|
const randomUuid = globalThis.crypto?.randomUUID?.()
|
|
// IDs are identifiers, not authentication secrets. The fallback keeps the
|
|
// shared browser/server contract usable in Node 18 VM contexts without Web Crypto.
|
|
const id = randomUuid ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`
|
|
return `${prefix}_${id}`
|
|
}
|