import { isRecord, readStoredJson, writeStoredJson, } from '../persistence/storage'; import { generateWorldAttributeSchema } from '../services/attributeSchemaGenerator'; import { buildFallbackCustomWorldCampScene } from '../services/customWorldCamp'; import { buildCustomWorldAnchorPackFromIntent, deriveCustomWorldLockStateFromIntent, normalizeCustomWorldCreatorIntent, normalizeCustomWorldLockState, } from '../services/customWorldCreatorIntent'; import { normalizeCustomWorldOwnedSettingLayers } from '../services/customWorldOwnedSettingLayers'; import { AnimationState, CharacterAnimationConfig, CharacterBackstoryChapter, CharacterBackstoryRevealConfig, CustomWorldAnchorPack, CustomWorldItem, CustomWorldLandmark, CustomWorldNpc, CustomWorldNpcVisual, CustomWorldNpcVisualGear, CustomWorldNpcVisualGearType, CustomWorldNpcVisualRace, CustomWorldPlayableNpc, CustomWorldProfile, CustomWorldRoleInitialItem, CustomWorldRoleSkill, EquipmentSlotId, ItemAttributeResonance, ItemRarity, ItemStatProfile, ItemUseProfile, KnowledgeFact, RoleAttributeProfile, SceneNarrativeResidue, ThemePack, ThreadContract, WorldType, WorldStoryGraph, } from '../types'; import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS, DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, } from './affinityLevels'; import { coerceWorldAttributeSchema } from './attributeValidation'; import { type CustomWorldLandmarkDraft, normalizeCustomWorldLandmarks, } from './customWorldSceneGraph'; const CUSTOM_WORLD_LIBRARY_STORAGE_KEY = 'tavernrealms.custom-world-library.v1'; const CUSTOM_WORLD_LIBRARY_VERSION = 1; const MAX_SAVED_CUSTOM_WORLDS = 12; const MIN_CUSTOM_WORLD_AFFINITY = -40; const MAX_CUSTOM_WORLD_AFFINITY = 90; const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18; const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6; const ITEM_RARITIES = new Set([ 'common', 'uncommon', 'rare', 'epic', 'legendary', ]); const EQUIPMENT_SLOTS = new Set(['weapon', 'armor', 'relic']); const ANIMATION_STATES = new Set(Object.values(AnimationState)); const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set([ 'human', 'elf', 'orc', 'goblin', ]); const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES = new Set([ 'cloth', 'leather', 'metal', 'melee', 'magic', 'ranged', ]); const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([ '武器', '护甲', '饰品', '消耗品', '材料', '稀有品', '专属物品', '专属物', ]); const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS; const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [ '表层来意', '旧事裂痕', '隐藏执念', '最终底牌', ] as const; type CustomWorldRoleFallbackSource = { name: string; title: string; role: string; description: string; backstory: string; personality: string; motivation: string; combatStyle: string; relationshipHooks: string[]; tags: string[]; }; type StoredCustomWorldLibrary = { version: number; profiles: CustomWorldProfile[]; }; function toText(value: unknown, fallback = '') { return typeof value === 'string' ? value.trim() : fallback; } function toStringArray(value: unknown) { return Array.isArray(value) ? value .filter((item): item is string => typeof item === 'string') .map((item) => item.trim()) .filter(Boolean) : []; } function toOptionalNumber(value: unknown) { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } function toOptionalInteger(value: unknown) { return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined; } function preserveStructuredRecord(value: unknown): T | null { return isRecord(value) ? (value as T) : null; } function preserveStructuredRecordArray(value: unknown): T[] | null { return Array.isArray(value) ? (value.filter((entry): entry is Record => isRecord(entry)) as T[]) : null; } function normalizeInitialAffinity(value: unknown, fallback: number) { const resolved = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : fallback; return Math.max( MIN_CUSTOM_WORLD_AFFINITY, Math.min(MAX_CUSTOM_WORLD_AFFINITY, resolved), ); } function truncateText(value: string, maxLength: number) { const normalized = value.trim().replace(/\s+/g, ' '); if (!normalized) return ''; if (normalized.length <= maxLength) return normalized; return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; } function splitNarrativeSentences(text: string) { const normalized = text.replace(/\s+/g, ' ').trim(); if (!normalized) return []; const matches = normalized.match(/[^。!?!?]+[。!?!?]?/gu); return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean); } function normalizeRoleItemCategory(value: unknown, fallback = '材料') { const category = toText(value); if (CUSTOM_WORLD_ROLE_ITEM_CATEGORIES.has(category)) { return category === '专属物' ? '专属物品' : category; } if (/武器|刀|剑|弓|枪|炮|锤/u.test(category)) return '武器'; if (/甲|护|盾|衣|袍/u.test(category)) return '护甲'; if (/饰|符|佩|坠|珠|印/u.test(category)) return '饰品'; if (/药|食|补给|瓶|卷轴/u.test(category)) return '消耗品'; if (/材|矿|木|石|鳞|骨/u.test(category)) return '材料'; if (/秘|钥|图|册|录|卷/u.test(category)) return '稀有品'; if (/信物|遗物|核心|母印|真符/u.test(category)) return '专属物品'; return fallback; } function buildFallbackBackstoryReveal( source: CustomWorldRoleFallbackSource, ): CharacterBackstoryRevealConfig { const normalizedBackstory = source.backstory.trim() || `${source.name}对自己的过去仍有保留。`; const backstorySentences = splitNarrativeSentences(normalizedBackstory); const backstoryLead = backstorySentences[0] ?? normalizedBackstory; const backstoryDetail = backstorySentences.slice(0, 2).join('') || normalizedBackstory; const publicSummary = source.description.trim() || truncateText(normalizedBackstory, 42); const fallbackContents = [ source.description.trim() || backstoryLead, backstoryDetail, source.motivation.trim() ? `${source.name}真正挂念的,是:${source.motivation.trim()}` : `${source.name}的决定与“${truncateText(backstoryLead, 24)}”直接相关。`, source.personality.trim() ? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}` : `${source.name}仍把最深的筹码藏在过去里。`, ]; return { publicSummary, privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map( (affinityRequired, index) => ({ id: `saved-backstory-${index + 1}`, title: CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? `背景片段${index + 1}`, affinityRequired, teaser: truncateText( fallbackContents[index] ?? normalizedBackstory, 22, ), content: truncateText( fallbackContents[index] ?? normalizedBackstory, 72, ), contextSnippet: truncateText( `${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`, 48, ), }) satisfies CharacterBackstoryChapter, ), }; } function normalizeBackstoryReveal( value: unknown, fallbackSource: CustomWorldRoleFallbackSource, ) { const fallback = buildFallbackBackstoryReveal(fallbackSource); if (!isRecord(value)) { return fallback; } const rawChapters = Array.isArray(value.chapters) ? value.chapters.filter(isRecord) : []; return { publicSummary: toText(value.publicSummary, fallback.publicSummary), privateChatUnlockAffinity: typeof value.privateChatUnlockAffinity === 'number' && Number.isFinite(value.privateChatUnlockAffinity) ? normalizeInitialAffinity( value.privateChatUnlockAffinity, DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, ) : fallback.privateChatUnlockAffinity, chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map( (defaultAffinity, index) => { const rawChapter = rawChapters[index]; const fallbackChapter = fallback.chapters[index]; return { id: rawChapter ? toText(rawChapter.id, fallbackChapter?.id) : (fallbackChapter?.id ?? `saved-backstory-${index + 1}`), title: rawChapter ? toText(rawChapter.title, fallbackChapter?.title) : (fallbackChapter?.title ?? `背景片段${index + 1}`), affinityRequired: fallbackChapter?.affinityRequired ?? defaultAffinity, teaser: rawChapter ? toText(rawChapter.teaser, fallbackChapter?.teaser) : (fallbackChapter?.teaser ?? ''), content: rawChapter ? toText(rawChapter.content, fallbackChapter?.content) : (fallbackChapter?.content ?? ''), contextSnippet: rawChapter ? toText(rawChapter.contextSnippet, fallbackChapter?.contextSnippet) : (fallbackChapter?.contextSnippet ?? ''), } satisfies CharacterBackstoryChapter; }, ), } satisfies CharacterBackstoryRevealConfig; } function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) { const nameSeed = source.title || source.role || source.name || '角色'; return [ { id: 'saved-role-skill-1', name: `${nameSeed}起手`, summary: truncateText( source.combatStyle || `${source.name}擅长稳住局面。`, 36, ), style: '起手压制', }, { id: 'saved-role-skill-2', name: `${nameSeed}变招`, summary: truncateText( source.personality || `${source.name}习惯在周旋中找破绽。`, 36, ), style: '机动周旋', }, { id: 'saved-role-skill-3', name: `${nameSeed}底牌`, summary: truncateText( source.motivation || `${source.name}会在关键时刻亮出压箱手段。`, 36, ), style: '爆发终结', }, ] satisfies CustomWorldRoleSkill[]; } function normalizeRoleSkills( value: unknown, fallbackSource: CustomWorldRoleFallbackSource, ) { const normalized = Array.isArray(value) ? value .filter(isRecord) .map( (entry, index) => ({ id: toText(entry.id, `saved-role-skill-${index + 1}`), name: toText(entry.name), summary: toText(entry.summary, toText(entry.description)), style: toText(entry.style, toText(entry.category, '常用')), }) satisfies CustomWorldRoleSkill, ) .filter((entry) => entry.name) .slice(0, 3) : []; return normalized.length > 0 ? normalized : buildFallbackRoleSkills(fallbackSource); } function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) { const itemSeed = source.title || source.role || source.name || '角色'; return [ { id: 'saved-role-item-1', name: `${itemSeed}常备武具`, category: '武器', quantity: 1, rarity: 'rare', description: truncateText( source.combatStyle || `${source.name}随身携带的主要作战物件。`, 36, ), tags: source.tags.slice(0, 2), }, { id: 'saved-role-item-2', name: `${itemSeed}补给包`, category: '消耗品', quantity: 2, rarity: 'uncommon', description: truncateText( source.personality || `${source.name}为长期行动准备的基础补给。`, 36, ), tags: source.relationshipHooks.slice(0, 2), }, { id: 'saved-role-item-3', name: `${itemSeed}私人物件`, category: '专属物品', quantity: 1, rarity: 'rare', description: truncateText( source.backstory || source.motivation || `${source.name}不愿随意交出的信物。`, 36, ), tags: [...source.tags, ...source.relationshipHooks].slice(0, 3), }, ] satisfies CustomWorldRoleInitialItem[]; } function normalizeRoleInitialItems( value: unknown, fallbackSource: CustomWorldRoleFallbackSource, ) { const normalized = Array.isArray(value) ? value .filter(isRecord) .map( (entry, index) => ({ id: toText(entry.id, `saved-role-item-${index + 1}`), name: toText(entry.name), category: normalizeRoleItemCategory(entry.category), quantity: typeof entry.quantity === 'number' && Number.isFinite(entry.quantity) ? Math.max(1, Math.min(99, Math.round(entry.quantity))) : 1, rarity: typeof entry.rarity === 'string' && ITEM_RARITIES.has(entry.rarity as ItemRarity) ? (entry.rarity as ItemRarity) : 'rare', description: toText(entry.description), tags: toStringArray(entry.tags), }) satisfies CustomWorldRoleInitialItem, ) .filter((entry) => entry.name) .slice(0, 3) : []; return normalized.length > 0 ? normalized : buildFallbackRoleInitialItems(fallbackSource); } function normalizeEquipmentSlot(value: unknown) { return typeof value === 'string' && EQUIPMENT_SLOTS.has(value as EquipmentSlotId) ? (value as EquipmentSlotId) : null; } function normalizeCustomWorldNpcVisualGear( value: unknown, ): CustomWorldNpcVisualGear | null { if (!isRecord(value)) return null; const type = typeof value.type === 'string' && CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES.has( value.type as CustomWorldNpcVisualGearType, ) ? (value.type as CustomWorldNpcVisualGearType) : null; const file = toText(value.file); if (!type || !file) return null; return { type, file, frameIndex: toOptionalInteger(value.frameIndex) ?? 0, }; } function normalizeCustomWorldNpcVisual( value: unknown, ): CustomWorldNpcVisual | undefined { if (!isRecord(value)) return undefined; const race = typeof value.race === 'string' && CUSTOM_WORLD_NPC_VISUAL_RACES.has(value.race as CustomWorldNpcVisualRace) ? (value.race as CustomWorldNpcVisualRace) : null; if (!race) return undefined; return { race, bodyColor: toText(value.bodyColor, 'black'), headIndex: Math.max(1, toOptionalInteger(value.headIndex) ?? 1), hairColorIndex: Math.max(1, toOptionalInteger(value.hairColorIndex) ?? 1), hairStyleFrame: Math.max(0, toOptionalInteger(value.hairStyleFrame) ?? 0), facialHairEnabled: Boolean(value.facialHairEnabled), facialHairColorIndex: Math.max( 1, toOptionalInteger(value.facialHairColorIndex) ?? 1, ), facialHairStyleFrame: Math.max( 0, toOptionalInteger(value.facialHairStyleFrame) ?? 0, ), headgear: normalizeCustomWorldNpcVisualGear(value.headgear), mainHand: normalizeCustomWorldNpcVisualGear(value.mainHand), offHand: normalizeCustomWorldNpcVisualGear(value.offHand), }; } function normalizeCharacterAnimationConfig( value: unknown, ): CharacterAnimationConfig | null { if (!isRecord(value)) return null; const folder = toText(value.folder); const prefix = toText(value.prefix); const frames = Math.max(1, toOptionalInteger(value.frames) ?? 0); if (!folder || !prefix || frames <= 0) { return null; } const startFrame = toOptionalInteger(value.startFrame); const extension = toText(value.extension); const file = toText(value.file); const basePath = toText(value.basePath); return { folder, prefix, frames, ...(startFrame ? { startFrame: Math.max(1, startFrame) } : {}), ...(extension ? { extension } : {}), ...(file ? { file } : {}), ...(basePath ? { basePath } : {}), }; } function normalizeGeneratedAnimationMap(value: unknown) { if (!isRecord(value)) return undefined; const entries = Object.entries(value).flatMap(([key, rawConfig]) => { if (!ANIMATION_STATES.has(key as AnimationState)) { return []; } const config = normalizeCharacterAnimationConfig(rawConfig); return config ? [[key as AnimationState, config] as const] : []; }); return entries.length > 0 ? (Object.fromEntries(entries) as Partial< Record >) : undefined; } function normalizeItemStatProfile(value: unknown): ItemStatProfile | null { if (!isRecord(value)) return null; const profile: ItemStatProfile = { maxHpBonus: toOptionalNumber(value.maxHpBonus), maxManaBonus: toOptionalNumber(value.maxManaBonus), outgoingDamageBonus: toOptionalNumber(value.outgoingDamageBonus), incomingDamageMultiplier: toOptionalNumber(value.incomingDamageMultiplier), }; return Object.values(profile).some((entry) => entry !== undefined) ? profile : null; } function normalizeItemUseProfile(value: unknown): ItemUseProfile | null { if (!isRecord(value)) return null; const profile: ItemUseProfile = { hpRestore: toOptionalNumber(value.hpRestore), manaRestore: toOptionalNumber(value.manaRestore), cooldownReduction: toOptionalNumber(value.cooldownReduction), }; return Object.values(profile).some((entry) => entry !== undefined) ? profile : null; } function normalizePlayableNpc( value: unknown, index: number, ): CustomWorldPlayableNpc | null { if (!isRecord(value)) return null; const name = toText(value.name); if (!name) return null; const title = toText(value.title, toText(value.role, '未命名角色')); const role = toText(value.role, title); const relationshipHooks = toStringArray(value.relationshipHooks); const tags = toStringArray(value.tags); const fallbackSource = { name, title, role, description: toText(value.description), backstory: toText(value.backstory), personality: toText(value.personality), motivation: toText(value.motivation, toText(value.description)), combatStyle: toText(value.combatStyle), relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3), tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5), } satisfies CustomWorldRoleFallbackSource; return { id: toText(value.id, `saved-playable-${index + 1}`), name, title, role, description: fallbackSource.description, backstory: fallbackSource.backstory, personality: fallbackSource.personality, motivation: fallbackSource.motivation, combatStyle: fallbackSource.combatStyle, initialAffinity: normalizeInitialAffinity( value.initialAffinity, DEFAULT_PLAYABLE_INITIAL_AFFINITY, ), relationshipHooks: fallbackSource.relationshipHooks, tags: fallbackSource.tags, backstoryReveal: normalizeBackstoryReveal( value.backstoryReveal, fallbackSource, ), skills: normalizeRoleSkills(value.skills, fallbackSource), initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource), imageSrc: toText(value.imageSrc) || undefined, generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined, generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined, animationMap: normalizeGeneratedAnimationMap(value.animationMap), attributeProfile: preserveStructuredRecord(value.attributeProfile) ?? undefined, narrativeProfile: preserveStructuredRecord( value.narrativeProfile, ) ?? undefined, templateCharacterId: toText(value.templateCharacterId) || undefined, }; } function normalizeStoryNpc( value: unknown, index: number, ): CustomWorldNpc | null { if (!isRecord(value)) return null; const name = toText(value.name); if (!name) return null; const title = toText(value.title, toText(value.role, '未命名场景角色')); const role = toText(value.role, title); const relationshipHooks = toStringArray(value.relationshipHooks); const tags = toStringArray(value.tags); const fallbackSource = { name, title, role, description: toText(value.description), backstory: toText(value.backstory), personality: toText(value.personality), motivation: toText(value.motivation), combatStyle: toText(value.combatStyle), relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3), tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5), } satisfies CustomWorldRoleFallbackSource; return { id: toText(value.id, `saved-story-${index + 1}`), name, title, role, description: fallbackSource.description, backstory: fallbackSource.backstory, personality: fallbackSource.personality, motivation: fallbackSource.motivation, combatStyle: fallbackSource.combatStyle, initialAffinity: normalizeInitialAffinity( value.initialAffinity, DEFAULT_STORY_NPC_INITIAL_AFFINITY, ), relationshipHooks: fallbackSource.relationshipHooks, tags: fallbackSource.tags, backstoryReveal: normalizeBackstoryReveal( value.backstoryReveal, fallbackSource, ), skills: normalizeRoleSkills(value.skills, fallbackSource), initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource), imageSrc: toText(value.imageSrc) || undefined, generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined, generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined, animationMap: normalizeGeneratedAnimationMap(value.animationMap), attributeProfile: preserveStructuredRecord(value.attributeProfile) ?? undefined, narrativeProfile: preserveStructuredRecord( value.narrativeProfile, ) ?? undefined, visual: normalizeCustomWorldNpcVisual(value.visual), }; } function normalizeItem(value: unknown, index: number): CustomWorldItem | null { if (!isRecord(value)) return null; const name = toText(value.name); const category = toText(value.category); const rarity = typeof value.rarity === 'string' && ITEM_RARITIES.has(value.rarity as ItemRarity) ? (value.rarity as ItemRarity) : null; if (!name || !category || !rarity) return null; return { id: toText(value.id, `saved-item-${index + 1}`), name, category, rarity, description: toText(value.description), tags: toStringArray(value.tags), iconSrc: toText(value.iconSrc) || undefined, sourcePath: toText(value.sourcePath) || undefined, origin: value.origin === 'generated' || value.origin === 'catalog' ? value.origin : undefined, equipmentSlotId: normalizeEquipmentSlot(value.equipmentSlotId), statProfile: normalizeItemStatProfile(value.statProfile), useProfile: normalizeItemUseProfile(value.useProfile), value: toOptionalNumber(value.value), attributeResonance: preserveStructuredRecord( value.attributeResonance, ) ?? undefined, }; } function normalizeLandmark( value: unknown, index: number, ): CustomWorldLandmark | null { if (!isRecord(value)) return null; const name = toText(value.name); if (!name) return null; return { id: toText(value.id, `saved-landmark-${index + 1}`), name, description: toText(value.description), dangerLevel: toText(value.dangerLevel), imageSrc: toText(value.imageSrc) || undefined, narrativeResidues: preserveStructuredRecordArray( value.narrativeResidues, ) ?? undefined, sceneNpcIds: [], connections: [], }; } function normalizeCampScene( value: unknown, fallbackProfile: Pick< CustomWorldProfile, | 'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType' >, ) { const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); if (!isRecord(value)) { return fallback; } return { name: toText(value.name, fallback.name), description: toText(value.description, fallback.description), dangerLevel: toText(value.dangerLevel, fallback.dangerLevel), imageSrc: toText(value.imageSrc) || undefined, }; } function normalizeLandmarkDraft( value: unknown, index: number, ): CustomWorldLandmarkDraft | null { if (!isRecord(value)) return null; const normalizedLandmark = normalizeLandmark(value, index); if (!normalizedLandmark) { return null; } const rawConnections = Array.isArray(value.connections) ? value.connections.filter(isRecord) : []; return { ...normalizedLandmark, sceneNpcIds: toStringArray(value.sceneNpcIds), sceneNpcNames: [ ...toStringArray(value.sceneNpcNames), ...toStringArray(value.npcNames), ...(Array.isArray(value.npcs) ? value.npcs .filter(isRecord) .map((item) => toText(item.name)) .filter(Boolean) : []), ], connections: rawConnections.map((connection) => ({ targetLandmarkId: toText(connection.targetLandmarkId), targetLandmarkName: toText(connection.targetLandmarkName) || toText(connection.target) || toText(connection.sceneName), relativePosition: toText(connection.relativePosition) || toText(connection.position), summary: toText(connection.summary) || toText(connection.description), })), }; } function normalizeProfile(value: unknown): CustomWorldProfile | null { if (!isRecord(value)) return null; const name = toText(value.name); const settingText = toText(value.settingText, toText(value.summary, name)); if (!name) return null; const compatibilityTemplateWorldType = value.compatibilityTemplateWorldType === WorldType.XIANXIA ? WorldType.XIANXIA : value.compatibilityTemplateWorldType === WorldType.WUXIA ? WorldType.WUXIA : value.templateWorldType === WorldType.XIANXIA ? WorldType.XIANXIA : WorldType.WUXIA; const templateWorldType = compatibilityTemplateWorldType; const subtitle = toText(value.subtitle); const summary = toText(value.summary); const tone = toText(value.tone); const playerGoal = toText(value.playerGoal); const majorFactions = toStringArray(value.majorFactions); const coreConflicts = toStringArray(value.coreConflicts); const resolvedCoreConflicts = coreConflicts.length > 0 ? coreConflicts : [summary || playerGoal || settingText || name]; const camp = normalizeCampScene(value.camp, { name, summary, tone, playerGoal, settingText, templateWorldType, }); const generatedAttributeSchema = generateWorldAttributeSchema({ worldType: WorldType.CUSTOM, worldName: name, settingText, summary, tone, playerGoal, majorFactions, coreConflicts: resolvedCoreConflicts, }); const storyNpcs = Array.isArray(value.storyNpcs) ? value.storyNpcs .map((entry, index) => normalizeStoryNpc(entry, index)) .filter((entry): entry is CustomWorldNpc => Boolean(entry)) : []; const landmarkDrafts = Array.isArray(value.landmarks) ? value.landmarks .map((entry, index) => normalizeLandmarkDraft(entry, index)) .filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry)) : []; const normalizedProfile = { id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`), settingText, name, subtitle, summary, tone, playerGoal, templateWorldType, compatibilityTemplateWorldType, majorFactions, coreConflicts: resolvedCoreConflicts, attributeSchema: coerceWorldAttributeSchema( value.attributeSchema, generatedAttributeSchema, ), playableNpcs: Array.isArray(value.playableNpcs) ? value.playableNpcs .map((entry, index) => normalizePlayableNpc(entry, index)) .filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry)) : [], storyNpcs, items: Array.isArray(value.items) ? value.items .map((entry, index) => normalizeItem(entry, index)) .filter((entry): entry is CustomWorldItem => Boolean(entry)) : [], camp, landmarks: normalizeCustomWorldLandmarks({ landmarks: landmarkDrafts, storyNpcs, }), themePack: preserveStructuredRecord(value.themePack), storyGraph: preserveStructuredRecord(value.storyGraph), knowledgeFacts: preserveStructuredRecordArray(value.knowledgeFacts), threadContracts: preserveStructuredRecordArray(value.threadContracts), creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent), anchorPack: value.anchorPack && typeof value.anchorPack === 'object' ? (value.anchorPack as CustomWorldAnchorPack) : buildCustomWorldAnchorPackFromIntent( normalizeCustomWorldCreatorIntent(value.creatorIntent), ), lockState: value.lockState && isRecord(value.lockState) ? normalizeCustomWorldLockState(value.lockState) : deriveCustomWorldLockStateFromIntent( normalizeCustomWorldCreatorIntent(value.creatorIntent), ), generationMode: value.generationMode === 'fast' || value.generationMode === 'full' ? value.generationMode : 'full', generationStatus: value.generationStatus === 'key_only' || value.generationStatus === 'complete' ? value.generationStatus : 'complete', scenarioPackId: toText(value.scenarioPackId) || null, campaignPackId: toText(value.campaignPackId) || null, } satisfies CustomWorldProfile; return { ...normalizedProfile, ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers( value.ownedSettingLayers, normalizedProfile, ), }; } export function normalizeCustomWorldProfileRecord( value: unknown, ): CustomWorldProfile | null { return normalizeProfile(value); } function writeProfiles(profiles: CustomWorldProfile[]) { const normalizedProfiles = profiles .map((profile) => normalizeProfile(profile)) .filter((profile): profile is CustomWorldProfile => Boolean(profile)) .slice(0, MAX_SAVED_CUSTOM_WORLDS); if (typeof window === 'undefined') { return normalizedProfiles; } const payload: StoredCustomWorldLibrary = { version: CUSTOM_WORLD_LIBRARY_VERSION, profiles: normalizedProfiles, }; writeStoredJson({ key: CUSTOM_WORLD_LIBRARY_STORAGE_KEY, value: payload, }); return normalizedProfiles; } export function readSavedCustomWorldProfiles() { return ( readStoredJson({ key: CUSTOM_WORLD_LIBRARY_STORAGE_KEY, parse: (value) => { if ( !isRecord(value) || value.version !== CUSTOM_WORLD_LIBRARY_VERSION || !Array.isArray(value.profiles) ) { return null; } return value.profiles .map((profile) => normalizeProfile(profile)) .filter((profile): profile is CustomWorldProfile => Boolean(profile)) .slice(0, MAX_SAVED_CUSTOM_WORLDS); }, }) ?? ([] as CustomWorldProfile[]) ); } export function upsertSavedCustomWorldProfile(profile: CustomWorldProfile) { const nextProfiles = [ profile, ...readSavedCustomWorldProfiles().filter( (savedProfile) => savedProfile.id !== profile.id, ), ]; return writeProfiles(nextProfiles); }