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 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 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 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 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 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 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 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 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 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 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 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 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 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 export const StorySummarySchema = z.object({ summary: z.string().min(20).max(6000), }) export type StorySummary = z.infer 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}` }