import { publicProcedure, protectedProcedure, projectProcedure } from './router'; import { z } from 'zod'; import type { Project, Character, Scene, CharacterRelationship, CharacterStats } from '../types/project'; // In-memory storage (replace with database later) const projects: Map = new Map(); const characters: Map = new Map(); const characterRelationships: Map = new Map(); const scenes: Map = new Map(); // Helpers function slugify(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); } function findCharacterByProject(characterId: string, projectId: string): Character | undefined { const character = characters.get(characterId); return character?.projectId === projectId ? character : undefined; } function getCharacterStats(characterId: string): CharacterStats { const characterScenes = Array.from(scenes.values()) .filter(s => s.projectId === characters.get(characterId)?.projectId); const characterRels = Array.from(characterRelationships.values()) .filter(r => r.characterIdA === characterId || r.characterIdB === characterId); let totalDialogueLines = 0; let totalScreenTime = 0; let sceneCount = 0; for (const scene of characterScenes) { if (scene.content.toLowerCase().includes(characters.get(characterId)?.name.toLowerCase() || '')) { sceneCount++; const dialogueMatches = scene.content.match(/^[A-Z][A-Z\s,.-]+:/gm); if (dialogueMatches) { totalDialogueLines += dialogueMatches.length; } totalScreenTime += Math.ceil(scene.content.split('\n').length / 15); } } return { characterId, totalScreenTime, totalDialogueLines, sceneCount, relationshipCount: characterRels.length, }; } // Character search with filtering function searchCharacters(projectId: string, query?: string, role?: string, arcType?: string): Character[] { let results = Array.from(characters.values()) .filter(c => c.projectId === projectId); if (query) { const q = query.toLowerCase(); results = results.filter(c => c.name.toLowerCase().includes(q) || c.description?.toLowerCase().includes(q) || c.bio?.toLowerCase().includes(q) || c.traits?.toLowerCase().includes(q) || c.motivation?.toLowerCase().includes(q) ); } if (role) { results = results.filter(c => c.role === role); } if (arcType) { results = results.filter(c => c.arcType === arcType); } return results; } export const projectRouter = { // Project procedures listProjects: publicProcedure.query(async ({ ctx }) => { return Array.from(projects.values()); }), getProject: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ input }) => { const project = projects.get(input.id); if (!project) { throw new Error(`Project ${input.id} not found`); } return project; }), createProject: protectedProcedure .input(z.object({ name: z.string().min(1).max(255), description: z.string().optional(), })) .mutation(async ({ input, ctx }) => { const project: Project = { id: crypto.randomUUID(), name: input.name, description: input.description, userId: ctx.userId!, createdAt: new Date(), updatedAt: new Date(), }; projects.set(project.id, project); return project; }), updateProject: protectedProcedure .input(z.object({ id: z.string().uuid(), name: z.string().min(1).max(255).optional(), description: z.string().optional(), })) .mutation(async ({ input }) => { const project = projects.get(input.id); if (!project) { throw new Error(`Project ${input.id} not found`); } const updated: Project = { ...project, ...(input.name && { name: input.name }), ...(input.description !== undefined && { description: input.description }), updatedAt: new Date(), }; projects.set(updated.id, updated); return updated; }), deleteProject: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ input }) => { const deleted = projects.delete(input.id); if (!deleted) { throw new Error(`Project ${input.id} not found`); } return { success: true }; }), // Character CRUD procedures listCharacters: projectProcedure.query(async ({ ctx }) => { return Array.from(characters.values()) .filter(char => char.projectId === ctx.projectId); }), getCharacter: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ input }) => { const character = characters.get(input.id); if (!character) { throw new Error(`Character ${input.id} not found`); } return character; }), createCharacter: protectedProcedure .input(z.object({ name: z.string().min(1).max(100), description: z.string().optional(), bio: z.string().optional(), role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(), arc: z.string().optional(), arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(), age: z.number().int().optional(), gender: z.string().optional(), voice: z.string().optional(), traits: z.string().optional(), motivation: z.string().optional(), conflict: z.string().optional(), secret: z.string().optional(), imageUrl: z.string().url().optional(), projectId: z.string().uuid(), })) .mutation(async ({ input }) => { const slug = slugify(input.name); const character: Character = { id: crypto.randomUUID(), name: input.name, slug, description: input.description, bio: input.bio, role: input.role || 'supporting', arc: input.arc, arcType: input.arcType, age: input.age, gender: input.gender, voice: input.voice, traits: input.traits, motivation: input.motivation, conflict: input.conflict, secret: input.secret, imageUrl: input.imageUrl, projectId: input.projectId, createdAt: new Date(), updatedAt: new Date(), }; characters.set(character.id, character); return character; }), updateCharacter: protectedProcedure .input(z.object({ id: z.string().uuid(), name: z.string().min(1).max(100).optional(), description: z.string().optional(), bio: z.string().optional(), role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(), arc: z.string().optional(), arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(), age: z.number().int().optional(), gender: z.string().optional(), voice: z.string().optional(), traits: z.string().optional(), motivation: z.string().optional(), conflict: z.string().optional(), secret: z.string().optional(), imageUrl: z.string().url().optional(), projectId: z.string().uuid().optional(), })) .mutation(async ({ input }) => { const character = characters.get(input.id); if (!character) { throw new Error(`Character ${input.id} not found`); } const updatedName = input.name || character.name; const updated: Character = { ...character, name: updatedName, slug: slugify(updatedName), ...(input.description !== undefined && { description: input.description }), ...(input.bio !== undefined && { bio: input.bio }), ...(input.role && { role: input.role }), ...(input.arc !== undefined && { arc: input.arc }), ...(input.arcType && { arcType: input.arcType }), ...(input.age !== undefined && { age: input.age }), ...(input.gender !== undefined && { gender: input.gender }), ...(input.voice !== undefined && { voice: input.voice }), ...(input.traits !== undefined && { traits: input.traits }), ...(input.motivation !== undefined && { motivation: input.motivation }), ...(input.conflict !== undefined && { conflict: input.conflict }), ...(input.secret !== undefined && { secret: input.secret }), ...(input.imageUrl !== undefined && { imageUrl: input.imageUrl }), ...(input.projectId && { projectId: input.projectId }), updatedAt: new Date(), }; characters.set(updated.id, updated); return updated; }), deleteCharacter: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ input }) => { const character = characters.get(input.id); if (!character) { throw new Error(`Character ${input.id} not found`); } // Remove associated relationships for (const [relId, rel] of characterRelationships) { if (rel.characterIdA === input.id || rel.characterIdB === input.id) { characterRelationships.delete(relId); } } const deleted = characters.delete(input.id); if (!deleted) { throw new Error(`Character ${input.id} not found`); } return { success: true }; }), searchCharacters: protectedProcedure .input(z.object({ projectId: z.string().uuid(), query: z.string().optional(), role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(), arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(), })) .query(async ({ input }) => { return searchCharacters(input.projectId, input.query, input.role, input.arcType); }), getCharacterStats: protectedProcedure .input(z.object({ characterId: z.string().uuid() })) .query(async ({ input }) => { if (!characters.has(input.characterId)) { throw new Error(`Character ${input.characterId} not found`); } return getCharacterStats(input.characterId); }), getProjectCharacterStats: projectProcedure .query(async ({ ctx }) => { const projectCharacters = Array.from(characters.values()) .filter(c => c.projectId === ctx.projectId); return projectCharacters.map(c => getCharacterStats(c.id)); }), // Relationship procedures listRelationships: projectProcedure .query(async ({ ctx }) => { const projectCharacterIds = new Set( Array.from(characters.values()) .filter(c => c.projectId === ctx.projectId) .map(c => c.id) ); return Array.from(characterRelationships.values()) .filter(r => projectCharacterIds.has(r.characterIdA) && projectCharacterIds.has(r.characterIdB)); }), getRelationshipsForCharacter: protectedProcedure .input(z.object({ characterId: z.string().uuid() })) .query(async ({ input }) => { const character = characters.get(input.characterId); if (!character) { throw new Error(`Character ${input.characterId} not found`); } return Array.from(characterRelationships.values()) .filter(r => r.characterIdA === input.characterId || r.characterIdB === input.characterId); }), createRelationship: protectedProcedure .input(z.object({ characterIdA: z.string().uuid(), characterIdB: z.string().uuid(), relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']), description: z.string().optional(), strength: z.number().int().min(0).max(100).optional(), isAntagonistic: z.boolean().optional(), })) .mutation(async ({ input }) => { if (input.characterIdA === input.characterIdB) { throw new Error('Cannot create a relationship with the same character'); } const charA = characters.get(input.characterIdA); const charB = characters.get(input.characterIdB); if (!charA || !charB) { throw new Error('Both characters must exist'); } // Check for duplicate relationship const existing = Array.from(characterRelationships.values()).find( r => (r.characterIdA === input.characterIdA && r.characterIdB === input.characterIdB) || (r.characterIdA === input.characterIdB && r.characterIdB === input.characterIdA) ); if (existing) { throw new Error('Relationship already exists between these characters'); } const relationship: CharacterRelationship = { id: crypto.randomUUID(), characterIdA: input.characterIdA, characterIdB: input.characterIdB, relationshipType: input.relationshipType, description: input.description, strength: input.strength ?? 50, isAntagonistic: input.isAntagonistic ?? false, createdAt: new Date(), updatedAt: new Date(), }; characterRelationships.set(relationship.id, relationship); return relationship; }), updateRelationship: protectedProcedure .input(z.object({ id: z.string().uuid(), relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']).optional(), description: z.string().optional(), strength: z.number().int().min(0).max(100).optional(), isAntagonistic: z.boolean().optional(), })) .mutation(async ({ input }) => { const relationship = characterRelationships.get(input.id); if (!relationship) { throw new Error(`Relationship ${input.id} not found`); } const updated: CharacterRelationship = { ...relationship, ...(input.relationshipType && { relationshipType: input.relationshipType }), ...(input.description !== undefined && { description: input.description }), ...(input.strength !== undefined && { strength: input.strength }), ...(input.isAntagonistic !== undefined && { isAntagonistic: input.isAntagonistic }), updatedAt: new Date(), }; characterRelationships.set(updated.id, updated); return updated; }), deleteRelationship: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ input }) => { const deleted = characterRelationships.delete(input.id); if (!deleted) { throw new Error(`Relationship ${input.id} not found`); } return { success: true }; }), // Scene procedures listScenes: projectProcedure.query(async ({ ctx }) => { return Array.from(scenes.values()) .filter(scene => scene.projectId === ctx.projectId) .sort((a, b) => a.order - b.order); }), getScene: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ input }) => { const scene = scenes.get(input.id); if (!scene) { throw new Error(`Scene ${input.id} not found`); } return scene; }), createScene: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional(), projectId: z.string().uuid(), order: z.number().int().nonnegative(), })) .mutation(async ({ input }) => { const scene: Scene = { id: crypto.randomUUID(), title: input.title, content: input.content || '', projectId: input.projectId, order: input.order, createdAt: new Date(), updatedAt: new Date(), }; scenes.set(scene.id, scene); return scene; }), updateScene: protectedProcedure .input(z.object({ id: z.string().uuid(), title: z.string().min(1).optional(), content: z.string().optional(), order: z.number().int().nonnegative().optional(), })) .mutation(async ({ input }) => { const scene = scenes.get(input.id); if (!scene) { throw new Error(`Scene ${input.id} not found`); } const updated: Scene = { ...scene, ...(input.title && { title: input.title }), ...(input.content !== undefined && { content: input.content }), ...(input.order !== undefined && { order: input.order }), updatedAt: new Date(), }; scenes.set(updated.id, updated); return updated; }), deleteScene: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ input }) => { const deleted = scenes.delete(input.id); if (!deleted) { throw new Error(`Scene ${input.id} not found`); } return { success: true }; }), };