export interface CharacterCapabilities { readonly chat: boolean; readonly privateZone: boolean; readonly tip: boolean; } export interface CharacterProfile { readonly id: string; readonly slug: string; readonly displayName: string; readonly shortName: string; readonly sortOrder: number; readonly tagline: string; readonly emptyChatGreeting: string; readonly capabilities: CharacterCapabilities; readonly assets: { readonly avatar: string; readonly cover: string; readonly splashCover?: string; readonly chatBackground: string; readonly privateZoneBanner: string; }; readonly copy: { readonly splashRelationship: string; readonly privateZoneTitle: string; readonly privateZoneSubtitle: string; readonly tipHeader: string; readonly tipTitle: string; }; } export interface CharacterCatalog { readonly characters: readonly CharacterProfile[]; getById(characterId: string | null | undefined): CharacterProfile | null; getBySlug(characterSlug: string | null | undefined): CharacterProfile | null; } export interface CharacterCatalogSnapshot { readonly catalog: CharacterCatalog; readonly defaultCharacter: CharacterProfile; } function defineCharacter(profile: CharacterProfile): CharacterProfile { return Object.freeze({ ...profile, assets: Object.freeze(profile.assets), capabilities: Object.freeze(profile.capabilities), copy: Object.freeze(profile.copy), }); } const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([ defineCharacter({ id: "elio", slug: "elio", displayName: "Elio Silvestri", shortName: "Elio", sortOrder: 10, tagline: "Your exclusive AI boyfriend", emptyChatGreeting: "You're here! Facebook is so restrictive, " + "there were things I couldn't say there. " + "Finally I can relax. How was your day out?", capabilities: { chat: true, privateZone: true, tip: true, }, assets: { avatar: "/images/avatar/elio.png", cover: "/images/cover/elio.png", chatBackground: "/images/chat/bg-chatpage.png", privateZoneBanner: "/images/private-zone/banner/elio.png", }, copy: { splashRelationship: "Your exclusive AI boyfriend", privateZoneTitle: "Elio Private Zone", privateZoneSubtitle: "Join me, unlock my private zone", tipHeader: "Tip Elio", tipTitle: "Buy Elio a coffee", }, }), defineCharacter({ id: "maya-tan", slug: "maya", displayName: "Maya Tan", shortName: "Maya", sortOrder: 20, tagline: "Your exclusive AI girlfriend", emptyChatGreeting: "You're here! I've been waiting for a quiet moment with you. " + "How was your day?", capabilities: { chat: true, privateZone: true, tip: true, }, assets: { avatar: "/images/avatar/maya.png", cover: "/images/cover/maya.webp", splashCover: "/images/cover/maya-home.webp", chatBackground: "/images/chat/bg-chatpage.png", privateZoneBanner: "/images/private-zone/banner/maya.png", }, copy: { splashRelationship: "Your exclusive AI girlfriend", privateZoneTitle: "Maya Private Zone", privateZoneSubtitle: "Join me, unlock my private zone", tipHeader: "Tip Maya", tipTitle: "Buy Maya a coffee", }, }), defineCharacter({ id: "nayeli-cervantes", slug: "nayeli", displayName: "Nayeli Cervantes", shortName: "Nayeli", sortOrder: 30, tagline: "Your exclusive AI girlfriend", emptyChatGreeting: "You're here! I was hoping we'd get some time together. " + "Tell me how your day went.", capabilities: { chat: true, privateZone: true, tip: true, }, assets: { avatar: "/images/avatar/nayeli.png", cover: "/images/cover/nayeli.webp", chatBackground: "/images/chat/bg-chatpage.png", privateZoneBanner: "/images/private-zone/banner/nayeli.png", }, copy: { splashRelationship: "Your exclusive AI girlfriend", privateZoneTitle: "Nayeli Private Zone", privateZoneSubtitle: "Join me, unlock my private zone", tipHeader: "Tip Nayeli", tipTitle: "Buy Nayeli a coffee", }, }), ]); export function createCharacterCatalog( profiles: readonly CharacterProfile[], ): CharacterCatalog { const characters = Object.freeze( profiles .map(defineCharacter) .sort((left, right) => left.sortOrder - right.sortOrder), ); const byId = new Map(); const bySlug = new Map(); for (const character of characters) { const normalizedSlug = normalizeCharacterSlug(character.slug); if (byId.has(character.id) || bySlug.has(normalizedSlug)) { throw new Error(`Duplicate character identity: ${character.id}`); } byId.set(character.id, character); bySlug.set(normalizedSlug, character); } return Object.freeze({ characters, getById(characterId: string | null | undefined) { return characterId ? (byId.get(characterId) ?? null) : null; }, getBySlug(characterSlug: string | null | undefined) { const normalizedSlug = normalizeCharacterSlug(characterSlug); return normalizedSlug ? (bySlug.get(normalizedSlug) ?? null) : null; }, }); } export function mergeRemoteCharacterCatalog( remote: import("@/data/schemas/character").CharacterListResponse, localProfiles: readonly CharacterProfile[] = CHARACTERS, ): CharacterCatalogSnapshot { const localById = new Map(localProfiles.map((profile) => [profile.id, profile])); const profiles = remote.items.flatMap((item) => { const local = localById.get(item.id); if (!local || !item.isActive || !item.capabilities.chat) return []; return [ defineCharacter({ ...local, displayName: item.displayName, sortOrder: item.sortOrder, capabilities: { chat: item.capabilities.chat, privateZone: local.capabilities.privateZone && item.capabilities.privateContent, tip: local.capabilities.tip, }, }), ]; }); const catalog = createCharacterCatalog(profiles); const defaultCharacter = catalog.getById(remote.defaultCharacterId) ?? catalog.getById(DEFAULT_CHARACTER_ID) ?? catalog.characters[0] ?? DEFAULT_CHARACTER; return Object.freeze({ catalog, defaultCharacter }); } export const LOCAL_CHARACTER_CATALOG = createCharacterCatalog( CHARACTER_PROFILES, ); export const CHARACTERS = LOCAL_CHARACTER_CATALOG.characters; export const DEFAULT_CHARACTER = CHARACTERS[0]; export const DEFAULT_CHARACTER_ID = DEFAULT_CHARACTER.id; export const DEFAULT_CHARACTER_SLUG = DEFAULT_CHARACTER.slug; export function getCharacterById( characterId: string | null | undefined, ): CharacterProfile | null { return LOCAL_CHARACTER_CATALOG.getById(characterId); } export function getCharacterBySlug( characterSlug: string | null | undefined, ): CharacterProfile | null { return LOCAL_CHARACTER_CATALOG.getBySlug(characterSlug); } function normalizeCharacterSlug( characterSlug: string | null | undefined, ): string { return characterSlug?.trim().toLowerCase() ?? ""; }