import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage'; import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator'; import { buildCustomWorldAnchorPackFromIntent, deriveCustomWorldLockStateFromIntent, normalizeCustomWorldCreatorIntent, normalizeCustomWorldLockState, } from '../services/customWorldCreatorIntent'; import { CharacterBackstoryChapter, CharacterBackstoryRevealConfig, CustomWorldAnchorPack, CustomWorldItem, CustomWorldLandmark, CustomWorldNpc, CustomWorldNpcVisual, CustomWorldNpcVisualGear, CustomWorldNpcVisualGearType, CustomWorldNpcVisualRace, CustomWorldPlayableNpc, CustomWorldProfile, CustomWorldRoleInitialItem, CustomWorldRoleSkill, EquipmentSlotId, ItemRarity, ItemStatProfile, ItemUseProfile, WorldType, } 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 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 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 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), 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, 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), }; } 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, sceneNpcIds: [], connections: [], }; } 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 templateWorldType = value.templateWorldType === WorldType.XIANXIA ? WorldType.XIANXIA : WorldType.WUXIA; const subtitle = toText(value.subtitle); const summary = toText(value.summary); const tone = toText(value.tone); const playerGoal = toText(value.playerGoal); const generatedAttributeSchema = generateWorldAttributeSchema({ worldType: WorldType.CUSTOM, worldName: name, settingText, summary, tone, playerGoal, majorFactions: [], coreConflicts: [summary || playerGoal || settingText || name], }); 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)) : []; return { id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`), settingText, name, subtitle, summary, tone, playerGoal, templateWorldType, majorFactions: [], coreConflicts: [summary || playerGoal || settingText || name], 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)) : [], landmarks: normalizeCustomWorldLandmarks({ landmarks: landmarkDrafts, storyNpcs, }), themePack: null, storyGraph: null, 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', }; } 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); }