Add full character management system with enriched profiles (bio, traits, arcs, motivation, conflict, secrets), relationship mapping between characters with types and strength, character search/filter by role and arc type, and character statistics (scene count, dialogue, screen time). Includes database schema, tRPC router procedures, SolidJS components, API hooks, and unit tests. Co-Authored-By: Paperclip <noreply@paperclip.ing>
483 lines
17 KiB
TypeScript
483 lines
17 KiB
TypeScript
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<string, Project> = new Map();
|
|
const characters: Map<string, Character> = new Map();
|
|
const characterRelationships: Map<string, CharacterRelationship> = new Map();
|
|
const scenes: Map<string, Scene> = 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 };
|
|
}),
|
|
};
|