import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS, DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, } from '../data/affinityLevels'; import { coerceWorldAttributeSchema } from '../data/attributeValidation'; import { CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS, type CustomWorldLandmarkDraft, getCustomWorldSceneRelativePositionLabel, normalizeCustomWorldLandmarks, } from '../data/customWorldSceneGraph'; import { ActorNarrativeProfile, CharacterBackstoryChapter, CharacterBackstoryRevealConfig, CustomWorldAnchorPack, CustomWorldCampScene, CustomWorldItem, CustomWorldLandmark, CustomWorldNpc, CustomWorldPlayableNpc, CustomWorldProfile, CustomWorldRoleInitialItem, CustomWorldRoleSkill, ItemRarity, ThemePack, WorldStoryGraph, WorldType, } from '../types'; import { generateWorldAttributeSchema } from './attributeSchemaGenerator'; import { buildFallbackCustomWorldCampScene } from './customWorldCamp'; import { buildCustomWorldAnchorPackFromIntent, deriveCustomWorldLockStateFromIntent, normalizeCustomWorldCreatorIntent, normalizeCustomWorldLockState, } from './customWorldCreatorIntent'; import { buildFallbackActorNarrativeProfile, normalizeActorNarrativeProfile, } from './storyEngine/actorNarrativeProfile'; import { buildThemePackFromWorldProfile } from './storyEngine/themePack'; import { buildFallbackWorldStoryGraph } from './storyEngine/worldStoryGraph'; const CUSTOM_WORLD_RARITIES: ItemRarity[] = [ 'common', 'uncommon', 'rare', 'epic', 'legendary', ]; 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 CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [ '表层来意', '旧事裂痕', '隐藏执念', '最终底牌', ] as const; const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = [ '武器', '护甲', '饰品', '消耗品', '材料', '稀有品', '专属物品', '专属物', ] as const; const DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT = 3; const DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT = 3; const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS; export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5; export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30; export const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10; export const MIN_CUSTOM_WORLD_STORY_NPC_COUNT = Math.max( 0, MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, ); export interface CustomWorldGenerationRoleOutline { name: string; title: string; role: string; description: string; initialAffinity: number; relationshipHooks: string[]; tags: string[]; } export interface CustomWorldGenerationLandmarkConnectionOutline { targetLandmarkName: string; relativePosition: string; summary: string; } export interface CustomWorldGenerationLandmarkOutline { name: string; description: string; dangerLevel: string; sceneNpcNames: string[]; connections: CustomWorldGenerationLandmarkConnectionOutline[]; } export interface CustomWorldGenerationCampOutline { name: string; description: string; dangerLevel: string; } export interface CustomWorldGenerationFramework { settingText: string; name: string; subtitle: string; summary: string; tone: string; playerGoal: string; templateWorldType: WorldType; majorFactions: string[]; coreConflicts: string[]; camp: CustomWorldGenerationCampOutline; playableNpcs: CustomWorldGenerationRoleOutline[]; storyNpcs: CustomWorldGenerationRoleOutline[]; landmarks: CustomWorldGenerationLandmarkOutline[]; } export type CustomWorldGenerationRoleBatchType = 'playable' | 'story'; export type CustomWorldGenerationRoleBatchStage = 'narrative' | 'dossier'; function toText(value: unknown) { return typeof value === 'string' ? value.trim() : ''; } function toRecordArray(value: unknown) { return Array.isArray(value) ? (value.filter((item) => item && typeof item === 'object') as Array< Record >) : []; } function normalizeTags(value: unknown, fallbackTags: string[] = []) { const tags = Array.isArray(value) ? value.map((item) => toText(item)).filter(Boolean) : []; return [ ...new Set((tags.length > 0 ? tags : fallbackTags).filter(Boolean)), ].slice(0, 5); } function clampCustomWorldAffinity(value: number) { return Math.max( MIN_CUSTOM_WORLD_AFFINITY, Math.min(MAX_CUSTOM_WORLD_AFFINITY, Math.round(value)), ); } function normalizeInitialAffinity(value: unknown, fallback: number) { return typeof value === 'number' && Number.isFinite(value) ? clampCustomWorldAffinity(value) : fallback; } function normalizeWorldType(value: unknown, sourceText: string) { const worldType = toText(value).toUpperCase(); if (worldType === WorldType.WUXIA || worldType === WorldType.XIANXIA) { return worldType; } return inferWorldTypeFromSetting(sourceText); } function normalizeRarity( value: unknown, fallback: ItemRarity = 'rare', ): ItemRarity { const rarity = toText(value).toLowerCase() as ItemRarity; return CUSTOM_WORLD_RARITIES.includes(rarity) ? rarity : fallback; } function normalizeRoleItemCategory(value: unknown, fallback = '材料') { const category = toText(value); if ( ( CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[] ).includes(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 slugify(value: string) { const ascii = value .toLowerCase() .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') .replace(/^-+|-+$/g, ''); if (ascii) { return ascii.slice(0, 24); } return 'entry'; } function createEntryId(prefix: string, label: string, index: number) { return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; } 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); } type CustomWorldRoleFallbackSource = Pick< CustomWorldPlayableNpc, | 'name' | 'title' | 'role' | 'description' | 'backstory' | 'personality' | 'motivation' | 'combatStyle' | 'relationshipHooks' | 'tags' >; 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: createEntryId( 'backstory-chapter', `${source.name}-${CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? index + 1}`, index, ), 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 (!value || typeof value !== 'object') { return fallback; } const item = value as Record; const rawChapters = toRecordArray(item.chapters); return { publicSummary: toText(item.publicSummary) || fallback.publicSummary, privateChatUnlockAffinity: typeof item.privateChatUnlockAffinity === 'number' && Number.isFinite(item.privateChatUnlockAffinity) ? clampCustomWorldAffinity(item.privateChatUnlockAffinity) : fallback.privateChatUnlockAffinity, chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map( (defaultAffinity, index) => { const fallbackChapter = fallback.chapters[index]; const rawChapter = rawChapters[index]; return { id: (rawChapter && toText(rawChapter.id)) || fallbackChapter?.id || `backstory-chapter-${index + 1}`, title: (rawChapter && toText(rawChapter.title)) || fallbackChapter?.title || `背景片段${index + 1}`, affinityRequired: fallbackChapter?.affinityRequired ?? defaultAffinity, teaser: (rawChapter && toText(rawChapter.teaser)) || fallbackChapter?.teaser || '', content: (rawChapter && toText(rawChapter.content)) || fallbackChapter?.content || '', contextSnippet: (rawChapter && toText(rawChapter.contextSnippet)) || fallbackChapter?.contextSnippet || '', } satisfies CharacterBackstoryChapter; }, ), } satisfies CharacterBackstoryRevealConfig; } function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) { const skillNameSeed = source.title || source.role || source.name || '角色'; const skillSummarySeed = source.combatStyle || source.description || `${source.name}善于把握局势。`; const motivationSeed = source.motivation || source.personality || source.backstory; return [ { id: createEntryId('role-skill', `${skillNameSeed}-起手`, 0), name: `${skillNameSeed}起手`, summary: truncateText(skillSummarySeed, 36), style: '起手压制', }, { id: createEntryId('role-skill', `${skillNameSeed}-机动`, 1), name: `${skillNameSeed}变招`, summary: truncateText( source.personality || `${source.name}习惯在试探中寻找破绽。`, 36, ), style: '机动周旋', }, { id: createEntryId('role-skill', `${skillNameSeed}-底牌`, 2), name: `${skillNameSeed}底牌`, summary: truncateText( motivationSeed || `${source.name}会在关键时刻亮出压箱手段。`, 36, ), style: '爆发终结', }, ] satisfies CustomWorldRoleSkill[]; } function normalizeRoleSkillList( value: unknown, fallbackSource: CustomWorldRoleFallbackSource, ) { const normalized = toRecordArray(value) .map((item, index) => { const name = toText(item.name); const summary = toText(item.summary) || toText(item.description); const style = toText(item.style) || toText(item.category) || '常用'; return { id: createEntryId('role-skill', name || style, index), name, summary, style, } satisfies CustomWorldRoleSkill; }) .filter((entry) => entry.name) .slice(0, DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT); return normalized.length > 0 ? normalized : buildFallbackRoleSkills(fallbackSource); } function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) { const itemNameSeed = source.title || source.role || source.name || '角色'; return [ { id: createEntryId('role-item', `${itemNameSeed}-1`, 0), name: `${itemNameSeed}常备武具`, category: '武器', quantity: 1, rarity: 'rare', description: truncateText( source.combatStyle || `${source.name}随身携带的主要作战物件。`, 36, ), tags: normalizeTags(source.tags, ['战斗', '随身']), }, { id: createEntryId('role-item', `${itemNameSeed}-2`, 1), name: `${itemNameSeed}补给包`, category: '消耗品', quantity: 2, rarity: 'uncommon', description: truncateText( source.personality || `${source.name}为了长期行动准备的基础补给。`, 36, ), tags: normalizeTags(source.relationshipHooks, ['补给', '行动']), }, { id: createEntryId('role-item', `${itemNameSeed}-3`, 2), name: `${itemNameSeed}私人物件`, category: '专属物品', quantity: 1, rarity: 'rare', description: truncateText( source.backstory || source.motivation || `${source.name}不愿随意交出的信物。`, 36, ), tags: normalizeTags( [...source.tags, ...source.relationshipHooks], ['信物', '线索'], ), }, ] satisfies CustomWorldRoleInitialItem[]; } function normalizeRoleInitialItemList( value: unknown, fallbackSource: CustomWorldRoleFallbackSource, ) { const normalized = toRecordArray(value) .map((item, index) => { const name = toText(item.name); return { id: createEntryId('role-item', name, index), name, category: normalizeRoleItemCategory(item.category), quantity: typeof item.quantity === 'number' && Number.isFinite(item.quantity) ? Math.max(1, Math.min(99, Math.round(item.quantity))) : 1, rarity: normalizeRarity(item.rarity, 'rare'), description: toText(item.description), tags: normalizeTags(item.tags), } satisfies CustomWorldRoleInitialItem; }) .filter((entry) => entry.name) .slice(0, DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT); return normalized.length > 0 ? normalized : buildFallbackRoleInitialItems(fallbackSource); } function toStringArray(value: unknown, nestedKey?: string) { if (!Array.isArray(value)) { return []; } return value .map((item) => { if (typeof item === 'string') { return item.trim(); } if (nestedKey && item && typeof item === 'object') { return toText((item as Record)[nestedKey]); } return ''; }) .filter(Boolean); } function inferWorldTypeFromSetting(settingText: string) { if (/[仙灵修真飞升灵脉宗门法器天宫秘境道骨星舰]/u.test(settingText)) { return WorldType.XIANXIA; } return WorldType.WUXIA; } function buildSeedPhrase(settingText: string, fallback: string) { const compact = settingText.replace(/\s+/g, '').trim(); return compact ? compact.slice(0, 10) : fallback; } function buildWorldName(settingText: string, worldType: WorldType) { const seed = buildSeedPhrase(settingText, '新旅'); const suffix = worldType === WorldType.XIANXIA ? '境' : '域'; return `${seed}${suffix}`; } function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile { const templateWorldType = inferWorldTypeFromSetting(settingText); const name = buildWorldName(settingText, templateWorldType); const subtitle = '前路未明'; const summary = settingText.trim() ? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。` : '一个仍待展开的独立世界正在成形。'; const tone = '未知、紧绷、仍在展开'; const playerGoal = '查清眼前局势的关键矛盾,并守住仍值得相信的人与事'; const camp = buildFallbackCustomWorldCampScene({ name, summary, tone, playerGoal, settingText: settingText.trim(), templateWorldType, }); return { id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`, settingText: settingText.trim(), name, subtitle, summary, tone, playerGoal, templateWorldType, majorFactions: [], coreConflicts: [summary], attributeSchema: generateWorldAttributeSchema({ worldType: WorldType.CUSTOM, worldName: name, settingText: settingText.trim(), summary, tone, playerGoal, majorFactions: [], coreConflicts: [summary], }), playableNpcs: [], storyNpcs: [], items: [], camp, landmarks: [], themePack: null, storyGraph: null, creatorIntent: null, anchorPack: null, lockState: normalizeCustomWorldLockState(null), generationMode: 'full', generationStatus: 'complete', }; } export function buildFallbackCustomWorldProfile( settingText: string, ): CustomWorldProfile { return buildBaseCustomWorldProfile(settingText); } export function normalizeCustomWorldGenerationFramework( raw: unknown, settingText: string, ): CustomWorldGenerationFramework { const fallback = buildBaseCustomWorldProfile(settingText); if (!raw || typeof raw !== 'object') { return { settingText: fallback.settingText, name: fallback.name, subtitle: fallback.subtitle, summary: fallback.summary, tone: fallback.tone, playerGoal: fallback.playerGoal, templateWorldType: fallback.templateWorldType, majorFactions: [], coreConflicts: [fallback.summary], camp: { name: fallback.camp?.name ?? '归舍', description: fallback.camp?.description ?? '', dangerLevel: fallback.camp?.dangerLevel ?? 'low', }, playableNpcs: [], storyNpcs: [], landmarks: [], }; } const item = raw as Record; const worldSignalText = [ settingText, toText(item.subtitle), toText(item.summary), toText(item.tone), toText(item.playerGoal), ].join(' '); const templateWorldType = normalizeWorldType( item.templateWorldType, worldSignalText, ); const name = toText(item.name) || buildWorldName(settingText, templateWorldType); return { settingText: settingText.trim(), name, subtitle: toText(item.subtitle) || fallback.subtitle, summary: toText(item.summary) || fallback.summary, tone: toText(item.tone) || fallback.tone, playerGoal: toText(item.playerGoal) || fallback.playerGoal, templateWorldType, majorFactions: normalizeTags(item.majorFactions, []), coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]), camp: normalizeCampOutline(item.camp, { name, summary: toText(item.summary) || fallback.summary, tone: toText(item.tone) || fallback.tone, playerGoal: toText(item.playerGoal) || fallback.playerGoal, settingText: settingText.trim(), templateWorldType, }), playableNpcs: normalizeRoleOutlineList(item.playableNpcs, { titleFallback: '未定称号', defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY, maxCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, }), storyNpcs: normalizeRoleOutlineList(item.storyNpcs, { titleFallback: '未定称号', defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY, maxCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT, }), landmarks: normalizeLandmarkOutlineList(item.landmarks), }; } export function buildCustomWorldRawProfileFromFramework( framework: CustomWorldGenerationFramework, ) { return { name: framework.name, subtitle: framework.subtitle, summary: framework.summary, tone: framework.tone, playerGoal: framework.playerGoal, templateWorldType: framework.templateWorldType, majorFactions: framework.majorFactions, coreConflicts: framework.coreConflicts, camp: { name: framework.camp.name, description: framework.camp.description, dangerLevel: framework.camp.dangerLevel, }, playableNpcs: framework.playableNpcs.map((npc) => ({ name: npc.name, title: npc.title, role: npc.role, description: npc.description, initialAffinity: npc.initialAffinity, relationshipHooks: [...npc.relationshipHooks], tags: [...npc.tags], })), storyNpcs: framework.storyNpcs.map((npc) => ({ name: npc.name, title: npc.title, role: npc.role, description: npc.description, initialAffinity: npc.initialAffinity, relationshipHooks: [...npc.relationshipHooks], tags: [...npc.tags], })), landmarks: framework.landmarks.map((landmark) => ({ name: landmark.name, description: landmark.description, dangerLevel: landmark.dangerLevel, sceneNpcNames: [...landmark.sceneNpcNames], connections: landmark.connections.map((connection) => ({ targetLandmarkName: connection.targetLandmarkName, relativePosition: connection.relativePosition, summary: connection.summary, })), })), }; } function normalizeRoleProfile( item: Record, index: number, options: { idPrefix: 'playable-npc' | 'story-npc'; titleFallback: string; defaultAffinity: number; }, ) { const name = toText(item.name); const title = toText(item.title) || toText(item.role) || options.titleFallback; const role = toText(item.role) || title; const relationshipHooks = normalizeTags( item.relationshipHooks, normalizeTags(item.tags), ); const normalizedRole = { id: toText(item.id) || createEntryId(options.idPrefix, name, index), name, title, role, description: toText(item.description), backstory: toText(item.backstory), personality: toText(item.personality), motivation: toText(item.motivation) || toText(item.description), combatStyle: toText(item.combatStyle), initialAffinity: normalizeInitialAffinity( item.initialAffinity, options.defaultAffinity, ), relationshipHooks, tags: normalizeTags(item.tags, relationshipHooks), }; return { ...normalizedRole, backstoryReveal: normalizeBackstoryReveal(item.backstoryReveal, normalizedRole), skills: normalizeRoleSkillList(item.skills, normalizedRole), initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole), narrativeProfile: item.narrativeProfile && typeof item.narrativeProfile === 'object' ? (item.narrativeProfile as ActorNarrativeProfile) : null, }; } function normalizePlayableNpcList(value: unknown) { return toRecordArray(value) .map((item, index) => ({ ...normalizeRoleProfile(item, index, { idPrefix: 'playable-npc', titleFallback: '未定称号', defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY, }), templateCharacterId: toText(item.templateCharacterId) || undefined, })) .filter((entry) => entry.name) .slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT); } function normalizeStoryNpcList(value: unknown) { return toRecordArray(value) .map((item, index) => ({ ...normalizeRoleProfile(item, index, { idPrefix: 'story-npc', titleFallback: '未定称号', defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY, }), imageSrc: toText(item.imageSrc) || undefined, visual: item.visual && typeof item.visual === 'object' ? (item.visual as CustomWorldNpc['visual']) : undefined, }) satisfies CustomWorldNpc, ) .filter((entry) => entry.name); } function normalizeItemList(value: unknown) { return toRecordArray(value) .map((item, index) => { const name = toText(item.name); const category = toText(item.category); return { id: toText(item.id) || createEntryId('item', name, index), name, category, rarity: normalizeRarity(item.rarity, 'rare'), description: toText(item.description), tags: normalizeTags(item.tags), } satisfies CustomWorldItem; }) .filter((entry) => entry.name && entry.category); } function normalizeRoleOutlineList( value: unknown, options: { titleFallback: string; defaultAffinity: number; maxCount?: number; }, ) { const normalized = toRecordArray(value) .map((item) => { const name = toText(item.name); const title = toText(item.title) || toText(item.role) || options.titleFallback; const role = toText(item.role) || title; const relationshipHooks = normalizeTags( item.relationshipHooks, normalizeTags(item.tags), ); return { name, title, role, description: toText(item.description) || truncateText(`${name || title}在世界中以${role}身份活动。`, 36), initialAffinity: normalizeInitialAffinity( item.initialAffinity, options.defaultAffinity, ), relationshipHooks, tags: normalizeTags(item.tags, relationshipHooks), } satisfies CustomWorldGenerationRoleOutline; }) .filter((entry) => entry.name); if (typeof options.maxCount === 'number') { return normalized.slice(0, options.maxCount); } return normalized; } function normalizeCampOutline( value: unknown, fallbackProfile: Pick< CustomWorldProfile, 'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType' >, ): CustomWorldGenerationCampOutline { const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); const item = value && typeof value === 'object' ? (value as Record) : {}; return { name: toText(item.name) || fallback.name, description: toText(item.description) || fallback.description, dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, }; } function normalizeLandmarkOutlineList(value: unknown) { return toRecordArray(value) .map((item) => { const name = toText(item.name); return { name, description: toText(item.description) || truncateText(`${name}暗藏新的局势变化。`, 40), dangerLevel: toText(item.dangerLevel) || 'medium', sceneNpcNames: [ ...toStringArray(item.sceneNpcNames), ...toStringArray(item.npcs, 'name'), ...toStringArray(item.sceneNpcs, 'name'), ...toStringArray(item.npcNames), ], connections: toRecordArray(item.connections) .map((connection) => ({ targetLandmarkName: toText(connection.targetLandmarkName) || toText(connection.target) || toText(connection.sceneName), relativePosition: toText(connection.relativePosition) || toText(connection.position) || 'forward', summary: toText(connection.summary) || toText(connection.description), })) .filter((connection) => connection.targetLandmarkName), } satisfies CustomWorldGenerationLandmarkOutline; }) .filter((entry) => entry.name) .slice(0, MIN_CUSTOM_WORLD_LANDMARK_COUNT); } export function normalizeCustomWorldGenerationRoleOutlineBatch( raw: unknown, roleType: CustomWorldGenerationRoleBatchType, ) { const item = raw && typeof raw === 'object' ? (raw as Record) : {}; const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; return normalizeRoleOutlineList(item[key], { titleFallback: '未定称号', defaultAffinity: roleType === 'playable' ? DEFAULT_PLAYABLE_INITIAL_AFFINITY : DEFAULT_STORY_NPC_INITIAL_AFFINITY, }); } export function normalizeCustomWorldGenerationLandmarkOutlineBatch( raw: unknown, ) { const item = raw && typeof raw === 'object' ? (raw as Record) : {}; return normalizeLandmarkOutlineList(item.landmarks); } function normalizeLandmarkDraftList(value: unknown) { return toRecordArray(value) .map((item, index) => { const name = toText(item.name); return { id: toText(item.id) || createEntryId('landmark', name, index), name, description: toText(item.description), dangerLevel: toText(item.dangerLevel), imageSrc: toText(item.imageSrc) || undefined, sceneNpcIds: toStringArray(item.sceneNpcIds), sceneNpcNames: [ ...toStringArray(item.sceneNpcNames), ...toStringArray(item.npcs, 'name'), ...toStringArray(item.sceneNpcs, 'name'), ...toStringArray(item.npcNames), ], connections: toRecordArray(item.connections).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), })), } satisfies CustomWorldLandmarkDraft; }) .filter((entry) => entry.name); } function normalizeCampScene( value: unknown, fallbackProfile: Pick< CustomWorldProfile, 'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType' >, ): CustomWorldCampScene { const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); const item = value && typeof value === 'object' ? (value as Record) : {}; return { name: toText(item.name) || fallback.name, description: toText(item.description) || fallback.description, dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, imageSrc: toText(item.imageSrc) || undefined, }; } export function normalizeCustomWorldProfile( raw: unknown, settingText: string, ): CustomWorldProfile { const fallback = buildBaseCustomWorldProfile(settingText); if (!raw || typeof raw !== 'object') { return fallback; } const item = raw as Record; const worldSignalText = [ settingText, toText(item.subtitle), toText(item.summary), toText(item.tone), toText(item.playerGoal), ].join(' '); const templateWorldType = normalizeWorldType( item.templateWorldType, worldSignalText, ); const name = toText(item.name) || buildWorldName(settingText, templateWorldType); const summary = toText(item.summary) || fallback.summary; const tone = toText(item.tone) || fallback.tone; const playerGoal = toText(item.playerGoal) || fallback.playerGoal; const generatedAttributeSchema = generateWorldAttributeSchema({ worldType: WorldType.CUSTOM, worldName: name, settingText: settingText.trim(), summary, tone, playerGoal, majorFactions: normalizeTags(item.majorFactions, []), coreConflicts: normalizeTags(item.coreConflicts, [summary]), }); const playableNpcs = normalizePlayableNpcList(item.playableNpcs); const storyNpcs = normalizeStoryNpcList(item.storyNpcs); const landmarkDrafts = normalizeLandmarkDraftList(item.landmarks); const camp = normalizeCampScene(item.camp, { name, summary, tone, playerGoal, settingText: settingText.trim(), templateWorldType, }); return { id: toText(item.id) || `custom-world-${Date.now().toString(36)}-${slugify(name)}`, settingText: settingText.trim(), name, subtitle: toText(item.subtitle) || fallback.subtitle, summary, tone, playerGoal, templateWorldType, majorFactions: normalizeTags(item.majorFactions, []), coreConflicts: normalizeTags(item.coreConflicts, [summary]), attributeSchema: coerceWorldAttributeSchema( item.attributeSchema, generatedAttributeSchema, ), playableNpcs, storyNpcs, items: normalizeItemList(item.items), camp, landmarks: normalizeCustomWorldLandmarks({ landmarks: landmarkDrafts, storyNpcs, }), themePack: item.themePack && typeof item.themePack === 'object' ? (item.themePack as ThemePack) : null, storyGraph: item.storyGraph && typeof item.storyGraph === 'object' ? (item.storyGraph as WorldStoryGraph) : null, creatorIntent: normalizeCustomWorldCreatorIntent(item.creatorIntent), anchorPack: item.anchorPack && typeof item.anchorPack === 'object' ? (item.anchorPack as CustomWorldAnchorPack) : buildCustomWorldAnchorPackFromIntent( normalizeCustomWorldCreatorIntent(item.creatorIntent), ), lockState: item.lockState && typeof item.lockState === 'object' ? normalizeCustomWorldLockState(item.lockState) : deriveCustomWorldLockStateFromIntent( normalizeCustomWorldCreatorIntent(item.creatorIntent), ), generationMode: item.generationMode === 'fast' || item.generationMode === 'full' ? item.generationMode : fallback.generationMode, generationStatus: item.generationStatus === 'key_only' || item.generationStatus === 'complete' ? item.generationStatus : fallback.generationStatus, }; } function buildFrameworkSummaryText( framework: CustomWorldGenerationFramework, options: { maxLandmarks?: number; } = {}, ) { const maxLandmarks = options.maxLandmarks ?? MIN_CUSTOM_WORLD_LANDMARK_COUNT; const landmarkText = framework.landmarks .slice(0, maxLandmarks) .map( (landmark) => `${landmark.name}(${landmark.dangerLevel},${landmark.description})`, ) .join('、'); return [ `世界:${framework.name}`, `副标题:${framework.subtitle}`, `世界概述:${framework.summary}`, `世界基调:${framework.tone}`, `玩家核心目标:${framework.playerGoal}`, framework.majorFactions.length > 0 ? `主要势力:${framework.majorFactions.join('、')}` : '', framework.coreConflicts.length > 0 ? `核心冲突:${framework.coreConflicts.join('、')}` : '', `开局归处:${framework.camp.name}(${framework.camp.description})`, landmarkText ? `关键场景:${landmarkText}` : '', ] .filter(Boolean) .join('\n'); } function buildLandmarkAppearanceLookup( framework: CustomWorldGenerationFramework, ) { const lookup = new Map(); framework.landmarks.forEach((landmark) => { landmark.sceneNpcNames.forEach((npcName) => { const key = npcName.trim(); if (!key) { return; } const current = lookup.get(key) ?? []; if (!current.includes(landmark.name)) { current.push(landmark.name); } lookup.set(key, current); }); }); return lookup; } function buildRoleOutlinePromptLines( roleBatch: CustomWorldGenerationRoleOutline[], options: { framework: CustomWorldGenerationFramework; roleType: CustomWorldGenerationRoleBatchType; }, ) { const appearanceLookup = options.roleType === 'story' ? buildLandmarkAppearanceLookup(options.framework) : new Map(); return roleBatch .map((role) => { const appearanceText = options.roleType === 'story' ? appearanceLookup.get(role.name)?.join('、') ?? '未指定' : ''; return [ `- ${role.name} / ${role.title}`, `身份:${role.role}`, `框架描述:${role.description}`, `预设好感:${role.initialAffinity}`, role.relationshipHooks.length > 0 ? `关系切入口:${role.relationshipHooks.join('、')}` : '', role.tags.length > 0 ? `标签:${role.tags.join('、')}` : '', appearanceText ? `出现场景:${appearanceText}` : '', ] .filter(Boolean) .join(';'); }) .join('\n'); } export function validateCustomWorldGenerationFramework( framework: CustomWorldGenerationFramework, ) { const playableCount = countUniqueNames(framework.playableNpcs); const landmarkCount = countUniqueNames(framework.landmarks); if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) { throw new Error( `自定义世界框架至少需要 ${MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT} 名可扮演角色。`, ); } if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) { throw new Error( `自定义世界框架至少需要 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅有 ${landmarkCount} 个。`, ); } if (!framework.camp.name.trim() || !framework.camp.description.trim()) { throw new Error('自定义世界框架必须包含一个有效的开局归处场景。'); } } export function buildCustomWorldFrameworkPrompt(settingText: string) { return [ '请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。', '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', '这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物和地图细节。', '玩家设定:', settingText.trim(), '', '输出 JSON 模板:', '{', ' "name": "世界名称",', ' "subtitle": "世界副标题",', ' "summary": "世界概述",', ' "tone": "世界基调",', ' "playerGoal": "玩家核心目标",', ' "templateWorldType": "WUXIA|XIANXIA",', ' "majorFactions": ["势力甲", "势力乙"],', ' "coreConflicts": ["冲突甲", "冲突乙"],', ' "camp": {', ' "name": "开局归处名称",', ' "description": "这是玩家进入世界后的第一处落脚点描述",', ' "dangerLevel": "low|medium|high|extreme"', ' }', '}', '', '要求:', '- 所有生成文本都必须使用中文。', '- 这一步只输出顶层 9 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。', '- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。', '- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。', '- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。', '- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。', '- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。', '- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。', '- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。', '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', ].join('\n'); } export function buildCustomWorldThemePackPrompt(params: { framework: CustomWorldGenerationFramework; }) { const { framework } = params; return [ '请根据下面的世界框架,生成一份题材适配层 ThemePack。', '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', '世界框架摘要:', buildFrameworkSummaryText(framework, { maxLandmarks: 6 }), '', '输出 JSON 模板:', '{', ' "id": "theme-pack-id",', ' "displayName": "题材包名称",', ' "toneRange": ["基调1", "基调2"],', ' "institutionLexicon": ["制度词1", "制度词2", "制度词3"],', ' "tabooLexicon": ["禁忌词1", "禁忌词2", "禁忌词3"],', ' "artifactClasses": ["载体种类1", "载体种类2", "载体种类3"],', ' "actorArchetypes": ["角色原型1", "角色原型2", "角色原型3"],', ' "conflictForms": ["冲突形式1", "冲突形式2", "冲突形式3"],', ' "clueForms": ["线索形态1", "线索形态2", "线索形态3"],', ' "namingPatterns": ["命名范式1", "命名范式2"],', ' "revealStyles": ["揭示方式1", "揭示方式2"]', '}', '', '要求:', '- 所有文本必须使用中文。', '- 输出必须贴合当前世界,不要写泛化奇幻模板。', '- institutionLexicon / tabooLexicon / artifactClasses / conflictForms / clueForms 至少各给 4 项。', '- 命名范式要直接服务后续 NPC、场景、物件、文书的统一词根。', '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', ].join('\n'); } export function buildCustomWorldThemePackJsonRepairPrompt(params: { responseText: string; }) { return [ '下面这段文本本应是自定义世界 ThemePack 的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。', '请只输出修复后的 JSON 对象。', '顶层必须包含:id、displayName、toneRange、institutionLexicon、tabooLexicon、artifactClasses、actorArchetypes、conflictForms、clueForms、namingPatterns、revealStyles。', '如果缺少数组字段,补空数组;如果缺少字符串字段,补空字符串。', '原始文本:', params.responseText.trim(), ].join('\n'); } export function buildCustomWorldStoryGraphPrompt(params: { framework: CustomWorldGenerationFramework; themePack: ThemePack; }) { const { framework, themePack } = params; const roleText = [ ...framework.playableNpcs.slice(0, 5), ...framework.storyNpcs.slice(0, 10), ] .map((role) => `- ${role.name} / ${role.role}:${role.description}`) .join('\n'); const landmarkText = framework.landmarks .slice(0, 10) .map((landmark) => `- ${landmark.name}:${landmark.description}`) .join('\n'); return [ '请根据下面的世界框架和 ThemePack,生成 WorldStoryGraph。', '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', '世界框架摘要:', buildFrameworkSummaryText(framework, { maxLandmarks: 8 }), '', `ThemePack:${themePack.displayName}`, `制度词汇:${themePack.institutionLexicon.join('、')}`, `禁忌词汇:${themePack.tabooLexicon.join('、')}`, `冲突形式:${themePack.conflictForms.join('、')}`, `线索形态:${themePack.clueForms.join('、')}`, '', `角色索引:\n${roleText}`, `场景索引:\n${landmarkText}`, '', '输出 JSON 模板:', '{', ' "visibleThreads": [', ' {', ' "id": "visible-thread-1",', ' "title": "明线标题",', ' "visibility": "visible",', ' "summary": "明线摘要",', ' "conflictType": "冲突形式",', ' "stakes": "代价与利害",', ' "involvedFactionIds": ["势力1"],', ' "involvedActorIds": ["角色id1"],', ' "relatedLocationIds": ["场景id1"]', ' }', ' ],', ' "hiddenThreads": [', ' {', ' "id": "hidden-thread-1",', ' "title": "暗线标题",', ' "visibility": "hidden",', ' "summary": "暗线摘要",', ' "conflictType": "冲突形式",', ' "stakes": "代价与利害",', ' "involvedFactionIds": ["势力1"],', ' "involvedActorIds": ["角色id1"],', ' "relatedLocationIds": ["场景id1"]', ' }', ' ],', ' "scars": [', ' {', ' "id": "scar-1",', ' "title": "旧伤标题",', ' "pastEvent": "过去发生的事件",', ' "publicResidue": "表面残痕",', ' "hiddenTruth": "隐藏真相",', ' "relatedActorIds": ["角色id1"],', ' "relatedLocationIds": ["场景id1"]', ' }', ' ],', ' "motifs": [', ' {', ' "id": "motif-1",', ' "label": "意象词根",', ' "semanticRole": "institution|ritual|technology|taboo|ruin|memory|resource|creature",', ' "lexicalHints": ["提示1", "提示2"]', ' }', ' ]', '}', '', '要求:', '- 至少生成 3 条 visibleThreads、4 条 hiddenThreads、4 条 scars、8 个 motifs。', '- involvedActorIds / relatedLocationIds 优先使用已给出的真实角色与场景 id。', '- 所有文本必须使用中文。', '- 输出要让角色、场景、旧痕之间可互相印证,不要让每条线程彼此无关。', ].join('\n'); } export function buildCustomWorldStoryGraphJsonRepairPrompt(params: { responseText: string; }) { return [ '下面这段文本本应是自定义世界 WorldStoryGraph 的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。', '请只输出修复后的 JSON 对象。', '顶层必须包含:visibleThreads、hiddenThreads、scars、motifs。', '每个线程对象必须包含:id、title、visibility、summary、conflictType、stakes、involvedFactionIds、involvedActorIds、relatedLocationIds。', '每个 scar 必须包含:id、title、pastEvent、publicResidue、hiddenTruth、relatedActorIds、relatedLocationIds。', '每个 motif 必须包含:id、label、semanticRole、lexicalHints。', '原始文本:', params.responseText.trim(), ].join('\n'); } export function buildCustomWorldActorNarrativeProfileBatchPrompt(params: { framework: CustomWorldGenerationFramework; roleType: CustomWorldGenerationRoleBatchType; roleBatch: Array>; themePack: ThemePack; storyGraph: WorldStoryGraph; }) { const { framework, roleType, roleBatch, themePack, storyGraph } = params; const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; const label = roleType === 'playable' ? '可扮演角色' : '场景角色'; const roleText = roleBatch .map((role) => { const roleName = toText(role.name); return `- ${roleName} / ${toText(role.role)}:${toText(role.description)};背景:${toText(role.backstory)};动机:${toText(role.motivation)};关系切口:${normalizeTags(role.relationshipHooks).join('、')}`; }) .join('\n'); const threadText = [...storyGraph.visibleThreads, ...storyGraph.hiddenThreads] .slice(0, 8) .map((thread) => `- ${thread.id} / ${thread.title}:${thread.summary}`) .join('\n'); const scarText = storyGraph.scars .slice(0, 8) .map((scar) => `- ${scar.id} / ${scar.title}:${scar.publicResidue}`) .join('\n'); return [ `请根据世界框架、ThemePack 和 StoryGraph,为这一批${label}生成 ActorNarrativeProfile。`, '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', '世界框架摘要:', buildFrameworkSummaryText(framework, { maxLandmarks: 6 }), '', `ThemePack:${themePack.displayName}`, `揭示方式:${themePack.revealStyles.join('、')}`, `命名范式:${themePack.namingPatterns.join('、')}`, '', `世界线程:\n${threadText}`, `世界旧伤:\n${scarText}`, `本批角色:\n${roleText}`, '', '输出 JSON 模板:', '{', ` "${key}": [`, ' {', ' "name": "角色名称",', ' "narrativeProfile": {', ' "publicMask": "公开面",', ' "firstContactMask": "首遇说辞",', ' "visibleLine": "表层线",', ' "hiddenLine": "隐藏线",', ' "contradiction": "说辞错位",', ' "debtOrBurden": "债务或负担",', ' "taboo": "不愿被提起的禁区",', ' "immediatePressure": "此刻压力",', ' "relatedThreadIds": ["thread-id"],', ' "relatedScarIds": ["scar-id"],', ' "reactionHooks": ["反应钩子1", "反应钩子2"]', ' }', ' }', ' ]', '}', '', '要求:', '- 名称必须与本批角色完全一致,不得改名。', '- 每个角色都必须给出 1 个 publicMask、1 个 firstContactMask、1 个 visibleLine、1 个 hiddenLine、1 个 contradiction、1 个 debtOrBurden、1 个 taboo、1 个 immediatePressure。', '- relatedThreadIds 至少 1 个,relatedScarIds 至少 0 到 2 个,reactionHooks 至少 2 个。', '- 低好感角色必须明显表现“压力、错位、钩子”,不要只写冷淡。', '- 所有文本必须使用中文。', ].join('\n'); } export function buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt(params: { responseText: string; roleType: CustomWorldGenerationRoleBatchType; expectedNames: string[]; }) { const key = params.roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; return [ `下面这段文本本应是自定义世界角色叙事档案批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`, '请只输出修复后的 JSON 对象。', `顶层必须只包含一个 ${key} 数组。`, `数组里只能保留这些名称:${params.expectedNames.join('、')}。`, '每个角色对象必须包含:name、narrativeProfile。', 'narrativeProfile 必须包含:publicMask、firstContactMask、visibleLine、hiddenLine、contradiction、debtOrBurden、taboo、immediatePressure、relatedThreadIds、relatedScarIds、reactionHooks。', '原始文本:', params.responseText.trim(), ].join('\n'); } export function buildCustomWorldFrameworkJsonRepairPrompt( responseText: string, ) { return [ '下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。', '请只输出修复后的 JSON 对象。', '顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。', '不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。', 'majorFactions 与 coreConflicts 必须是字符串数组。', 'camp 必须是对象,且包含:name、description、dangerLevel。', '原始文本:', responseText.trim(), ].join('\n'); } export function buildCustomWorldRoleOutlineBatchPrompt(params: { framework: CustomWorldGenerationFramework; roleType: CustomWorldGenerationRoleBatchType; batchCount: number; forbiddenNames?: string[]; }) { const { framework, roleType, batchCount, forbiddenNames = [] } = params; const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; const label = roleType === 'playable' ? '可扮演角色' : '场景角色'; return [ `请根据下面的世界核心信息,生成一批${label}框架名单。`, '后续我会继续补全人物档案,所以这一步每个角色只保留最少字段。', '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', '世界核心信息:', buildFrameworkSummaryText(framework, { maxLandmarks: 0 }), forbiddenNames.length > 0 ? `这些名字已经生成,禁止重复:${forbiddenNames.join('、')}` : '', '', '输出 JSON 模板:', '{', ` "${key}": [`, ' {', ' "name": "角色名称",', ' "title": "称号",', ' "role": "身份",', ' "description": "极简定位描述",', ' "initialAffinity": 18,', ' "relationshipHooks": ["一个关系切入口"],', ' "tags": ["标签1", "标签2"]', ' }', ' ]', '}', '', '要求:', `- 必须生成恰好 ${batchCount} 个${label}。`, '- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。', '- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。', '- 只保留:name、title、role、description、initialAffinity、relationshipHooks、tags。', '- relationshipHooks 最多 1 条;tags 保持 1 到 2 个。', '- description 控制在 8 到 18 个汉字内,title 和 role 也尽量短。', '- initialAffinity 必须是 -40 到 90 的整数。', roleType === 'playable' ? '- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。' : '- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。', '- 所有生成文本都必须使用中文。', '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', ] .filter(Boolean) .join('\n'); } export function buildCustomWorldRoleOutlineBatchJsonRepairPrompt(params: { responseText: string; roleType: CustomWorldGenerationRoleBatchType; expectedCount: number; forbiddenNames?: string[]; }) { const { responseText, roleType, expectedCount, forbiddenNames = [] } = params; const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; return [ `下面这段文本本应是自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`, '请只输出修复后的 JSON 对象。', `顶层必须只包含一个 ${key} 数组。`, `必须保留恰好 ${expectedCount} 个角色对象。`, forbiddenNames.length > 0 ? `禁止使用这些重复名:${forbiddenNames.join('、')}。` : '', '每个角色只包含:name、title、role、description、initialAffinity、relationshipHooks、tags。', '如果缺少字段:字符串补空字符串,relationshipHooks 和 tags 补空数组,initialAffinity 补默认整数。', '不要输出 backstory、skills、landmarks 或任何其他字段。', '原始文本:', responseText.trim(), ] .filter(Boolean) .join('\n'); } export function buildCustomWorldLandmarkSeedBatchPrompt(params: { framework: CustomWorldGenerationFramework; batchCount: number; forbiddenNames?: string[]; }) { const { framework, batchCount, forbiddenNames = [] } = params; return [ '请根据下面的世界核心信息,生成一批场景地标骨架。', '后续我会继续补全场景角色分布和连接关系,所以这一步只保留最少字段。', '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', '世界核心信息:', buildFrameworkSummaryText(framework, { maxLandmarks: 0 }), forbiddenNames.length > 0 ? `这些场景名已经生成,禁止重复:${forbiddenNames.join('、')}` : '', '', '输出 JSON 模板:', '{', ' "landmarks": [', ' {', ' "name": "场景名称",', ' "description": "极简场景描述",', ' "dangerLevel": "low|medium|high|extreme"', ' }', ' ]', '}', '', '要求:', `- 必须生成恰好 ${batchCount} 个 landmarks。`, '- 这是一个完全独立的自定义世界;不要在场景描述里直接写“武侠世界”“仙侠世界”等现成世界名。', '- 这一步只保留:name、description、dangerLevel。', '- 不要输出 sceneNpcNames、connections、imageSrc 或其他字段。', '- 名称必须具体且互不重复,不要使用 场景1、地点1 之类的占位名。', '- description 控制在 8 到 18 个汉字内。', '- 所有生成文本都必须使用中文。', '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', ] .filter(Boolean) .join('\n'); } export function buildCustomWorldLandmarkSeedBatchJsonRepairPrompt(params: { responseText: string; expectedCount: number; forbiddenNames?: string[]; }) { const { responseText, expectedCount, forbiddenNames = [] } = params; return [ '下面这段文本本应是自定义世界场景地标骨架批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。', '请只输出修复后的 JSON 对象。', '顶层必须只包含一个 landmarks 数组。', `必须保留恰好 ${expectedCount} 个地标对象。`, forbiddenNames.length > 0 ? `禁止使用这些重复场景名:${forbiddenNames.join('、')}。` : '', '每个地标只包含:name、description、dangerLevel。', '不要输出 sceneNpcNames、connections 或其他字段。', '原始文本:', responseText.trim(), ] .filter(Boolean) .join('\n'); } export function buildCustomWorldLandmarkNetworkBatchPrompt(params: { framework: CustomWorldGenerationFramework; landmarkBatch: CustomWorldGenerationLandmarkOutline[]; storyNpcs: CustomWorldGenerationRoleOutline[]; }) { const { framework, landmarkBatch, storyNpcs } = params; const relativePositionValues = CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map( (option) => option.value, ).join('|'); const allLandmarkNames = framework.landmarks.map((landmark) => landmark.name); const storyNpcNames = storyNpcs.map((npc) => npc.name); return [ '请根据下面的世界信息,为这一批场景补全“出现场景角色”和“地图连接关系”。', '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', '世界核心信息:', buildFrameworkSummaryText(framework, { maxLandmarks: 0 }), `全部场景名:${allLandmarkNames.join('、')}`, `可用场景角色名:${storyNpcNames.join('、')}`, '本批次场景骨架:', landmarkBatch .map( (landmark) => `- ${landmark.name} / 危险度:${landmark.dangerLevel} / 描述:${landmark.description}`, ) .join('\n'), '', '输出 JSON 模板:', '{', ' "landmarks": [', ' {', ' "name": "场景名称",', ' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],', ' "connections": [', ' {', ' "targetLandmarkName": "其他场景名称",', ` "relativePosition": "${CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS[0]?.value ?? 'forward'}",`, ' "summary": "极简通路说明"', ' }', ' ]', ' }', ' ]', '}', '', '要求:', `- 只输出这批 ${landmarkBatch.length} 个场景,不要输出其他场景。`, '- 这是一个完全独立的自定义世界;summary 不要带入“武侠”“仙侠”等现成世界名称。', '- 名称必须与本批次场景骨架完全一致,不得改名。', '- 每个场景必须提供恰好 3 个唯一 sceneNpcNames,且只能从可用场景角色名里选择。', `- 每个场景必须提供恰好 2 条 connections;relativePosition 只能使用:${relativePositionValues}。`, '- targetLandmarkName 必须来自全部场景名,且不能连接自己,两个目标场景不能重复。', '- summary 控制在 4 到 10 个汉字内。', '- 不要输出 description、dangerLevel、backstory 或其他字段。', '- 所有生成文本都必须使用中文。', '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', ].join('\n'); } export function buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt(params: { responseText: string; expectedNames: string[]; }) { const { responseText, expectedNames } = params; return [ '下面这段文本本应是自定义世界场景连接补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。', '请只输出修复后的 JSON 对象。', '顶层必须只包含一个 landmarks 数组。', `landmarks 里只能保留这些场景名:${expectedNames.join('、')}。`, '每个场景对象只包含:name、sceneNpcNames、connections。', 'connections 里的每个对象必须包含:targetLandmarkName、relativePosition、summary。', '不要输出 description、dangerLevel 或其他字段。', '原始文本:', responseText.trim(), ].join('\n'); } export function buildCustomWorldRoleBatchPrompt(params: { framework: CustomWorldGenerationFramework; roleType: CustomWorldGenerationRoleBatchType; roleBatch: CustomWorldGenerationRoleOutline[]; stage: CustomWorldGenerationRoleBatchStage; }) { const { framework, roleType, roleBatch, stage } = params; const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; const label = roleType === 'playable' ? '可扮演角色' : '场景角色'; const roleOutlineText = buildRoleOutlinePromptLines(roleBatch, { framework, roleType, }); if (stage === 'narrative') { return [ `请根据下面的世界框架,补全这一批${label}的叙事基础设定。`, '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', '玩家原始设定:', framework.settingText, '', '世界框架摘要:', buildFrameworkSummaryText(framework, { maxLandmarks: 8 }), '', `本批次需要补全的${label}(名称必须原样保留):`, roleOutlineText, '', '输出 JSON 模板:', '{', ` "${key}": [`, ' {', ' "name": "角色名称",', ' "backstory": "背景经历",', ' "personality": "性格特点",', ' "motivation": "当前动机",', ' "combatStyle": "战斗风格"', ' }', ' ]', '}', '', '要求:', `- 只输出这批${label},不要输出其他角色、场景或额外顶层字段。`, '- 这是一个完全独立的自定义世界;不要在角色背景、性格、动机或战斗风格里直接写“武侠世界”“仙侠世界”等现成世界名。', `- ${key} 的数量必须与本批次名单完全一致。`, '- 名称必须与批次名单完全一致,不得增删改名。', '- 只补全 backstory、personality、motivation、combatStyle 这 4 个字段,不要输出 backstoryReveal、skills、initialItems。', '- 必须严格沿用框架中的 title、role、description、initialAffinity、relationshipHooks、tags 所表达的角色定位,不要改名,不要改阵营。', roleType === 'story' ? '- 怪物型场景角色要在 backstory 或 combatStyle 中直接写出怪物特征、栖息环境或攻击方式。' : '- 可扮演角色要体现成长空间、协作价值和明确的入队理由。', '- 所有生成文本都必须使用中文。', '- 每个字符串尽量简洁:backstory/personality/motivation/combatStyle 控制在 10 到 40 个汉字内。', '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', ].join('\n'); } return [ `请根据下面的世界框架,补全这一批${label}的背景章节、技能和初始物品。`, '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', '玩家原始设定:', framework.settingText, '', '世界框架摘要:', buildFrameworkSummaryText(framework, { maxLandmarks: 8 }), '', `本批次需要补全的${label}(名称必须原样保留):`, roleOutlineText, '', '输出 JSON 模板:', '{', ` "${key}": [`, ' {', ' "name": "角色名称",', ' "backstoryReveal": {', ' "publicSummary": "公开可见的背景摘要",', ' "chapters": [', ` { "id": "surface", "title": "表层来意", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, ` { "id": "scar", "title": "旧事裂痕", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, ` { "id": "hidden", "title": "隐藏执念", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, ` { "id": "final", "title": "最终底牌", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" }`, ' ]', ' },', ' "skills": [', ' { "name": "技能1", "summary": "技能说明", "style": "起手压制" },', ' { "name": "技能2", "summary": "技能说明", "style": "机动周旋" },', ' { "name": "技能3", "summary": "技能说明", "style": "爆发终结" }', ' ],', ' "initialItems": [', ' { "name": "初始物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "物品说明", "tags": ["标签1", "标签2"] },', ' { "name": "初始物品2", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "物品说明", "tags": ["标签1", "标签2"] },', ' { "name": "初始物品3", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "物品说明", "tags": ["标签1", "标签2"] }', ' ]', ' }', ' ]', '}', '', '要求:', `- 只输出这批${label},不要输出其他角色、场景或额外顶层字段。`, '- 这是一个完全独立的自定义世界;不要在公开背景、技能名、物品名或说明里直接带入“武侠”“仙侠”等现成世界名。', `- ${key} 的数量必须与本批次名单完全一致。`, '- 名称必须与批次名单完全一致,不得增删改名。', '- 这一阶段只补全 backstoryReveal、skills、initialItems,不要重复输出 title、role、description、backstory、personality、motivation、combatStyle、initialAffinity、relationshipHooks、tags。', '- 背景章节、技能和初始物品必须严格围绕框架中的角色定位来写,不要改变阵营、身份或出现场景。', `- backstoryReveal.chapters 必须恰好 4 章,affinityRequired 固定使用 ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.join('、')}。`, '- 每个角色必须提供恰好 3 个 skills 和恰好 3 个 initialItems。', '- initialItems.category 只能使用:武器、护甲、饰品、消耗品、材料、稀有品、专属物品。', roleType === 'story' ? '- 怪物型角色仍然放进 storyNpcs,并在 role、description、backstory、combatStyle、tags、backstoryReveal、skills 或 initialItems 中明确写出怪物特征、栖息环境、攻击方式或异形外观。' : '- 可扮演角色要保持明确的成长空间、协作价值和入队理由。', '- 所有生成文本都必须使用中文。', '- 每个字符串尽量简洁:backstoryReveal.publicSummary 控制在 10 到 28 个汉字内,backstoryReveal.content 控制在 12 到 36 个汉字内,skills.summary 和 initialItems.description 控制在 8 到 24 个汉字内。', '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', ].join('\n'); } export function buildCustomWorldRoleBatchJsonRepairPrompt(params: { responseText: string; roleType: CustomWorldGenerationRoleBatchType; expectedNames: string[]; stage: CustomWorldGenerationRoleBatchStage; }) { const { responseText, roleType, expectedNames, stage } = params; const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; if (stage === 'narrative') { return [ `下面这段文本本应是自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}叙事设定批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`, '请只输出修复后的 JSON 对象。', `顶层必须只包含一个 ${key} 数组。`, `这个数组里只能保留这些角色名:${expectedNames.join('、')}。`, '名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。', '每个角色都必须包含:name、backstory、personality、motivation、combatStyle。', '如果缺少字段:字符串补空字符串。', '不要输出 backstoryReveal、skills、initialItems,也不要新增名单外的角色。', '原始文本:', responseText.trim(), ].join('\n'); } return [ `下面这段文本本应是自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`, '请只输出修复后的 JSON 对象。', `顶层必须只包含一个 ${key} 数组。`, `这个数组里只能保留这些角色名:${expectedNames.join('、')}。`, '名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。', '每个角色都必须包含:name、backstoryReveal、skills、initialItems。', `backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.join('、')}。`, 'skills 默认补成 3 个对象,每个对象包含 name、summary、style;initialItems 默认补成 3 个对象,每个对象包含 name、category、quantity、rarity、description、tags。', '不要输出 backstory、personality、motivation、combatStyle、landmarks,也不要新增名单外的角色。', '原始文本:', responseText.trim(), ].join('\n'); } export function buildCustomWorldGenerationPrompt(settingText: string) { const relativePositionValues = CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map( (option) => option.value, ).join('|'); return [ '请根据下面的玩家设定创建一份自定义世界档案。', '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', '玩家设定:', settingText.trim(), '', '输出 JSON 模板:', '{', ' "name": "世界名称",', ' "subtitle": "世界副标题",', ' "summary": "世界概述",', ' "tone": "世界基调",', ' "playerGoal": "玩家核心目标",', ' "majorFactions": ["势力甲", "势力乙"],', ' "coreConflicts": ["冲突甲", "冲突乙"],', ' "camp": {', ' "name": "开局归处名称",', ' "description": "玩家进入世界后的第一处落脚点描述",', ' "dangerLevel": "low|medium|high|extreme"', ' },', ' "playableNpcs": [', ' {', ' "name": "角色名称",', ' "title": "称号",', ' "role": "在世界中的身份/职责",', ' "description": "简短描述",', ' "backstory": "背景经历",', ' "personality": "性格特点",', ' "motivation": "当前动机",', ' "combatStyle": "战斗风格",', ' "initialAffinity": 18,', ' "relationshipHooks": ["关系切入口1", "关系切入口2"],', ' "tags": ["标签1", "标签2"],', ' "backstoryReveal": {', ' "publicSummary": "公开可见的背景摘要",', ' "chapters": [', ` { "id": "surface", "title": "表层来意", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, ` { "id": "scar", "title": "旧事裂痕", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, ` { "id": "hidden", "title": "隐藏执念", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, ` { "id": "final", "title": "最终底牌", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" }`, ' ]', ' },', ' "skills": [', ' { "name": "技能1", "summary": "技能说明", "style": "起手压制" },', ' { "name": "技能2", "summary": "技能说明", "style": "机动周旋" },', ' { "name": "技能3", "summary": "技能说明", "style": "爆发终结" }', ' ],', ' "initialItems": [', ' { "name": "初始物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "物品说明", "tags": ["标签1", "标签2"] },', ' { "name": "初始物品2", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "物品说明", "tags": ["标签1", "标签2"] },', ' { "name": "初始物品3", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "物品说明", "tags": ["标签1", "标签2"] }', ' ]', ' }', ' ],', ' "storyNpcs": [', ' {', ' "name": "场景角色名称",', ' "title": "称号",', ' "role": "身份",', ' "description": "简短描述",', ' "backstory": "背景经历",', ' "personality": "性格特点",', ' "motivation": "动机",', ' "combatStyle": "战斗风格",', ' "initialAffinity": 6,', ' "relationshipHooks": ["关系切入口1", "关系切入口2"],', ' "tags": ["标签1", "标签2"],', ' "backstoryReveal": {', ' "publicSummary": "公开可见的背景摘要",', ' "chapters": [', ` { "id": "surface", "title": "表层来意", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, ` { "id": "scar", "title": "旧事裂痕", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, ` { "id": "hidden", "title": "隐藏执念", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, ` { "id": "final", "title": "最终底牌", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" }`, ' ]', ' },', ' "skills": [', ' { "name": "技能1", "summary": "技能说明", "style": "起手压制" },', ' { "name": "技能2", "summary": "技能说明", "style": "机动周旋" },', ' { "name": "技能3", "summary": "技能说明", "style": "爆发终结" }', ' ],', ' "initialItems": [', ' { "name": "初始物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "物品说明", "tags": ["标签1", "标签2"] },', ' { "name": "初始物品2", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "物品说明", "tags": ["标签1", "标签2"] },', ' { "name": "初始物品3", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "物品说明", "tags": ["标签1", "标签2"] }', ' ]', ' }', ' ],', ' "landmarks": [', ' {', ' "name": "场景名称",', ' "description": "场景描述",', ' "dangerLevel": "low|medium|high|extreme",', ' "sceneNpcNames": ["会在这个场景出现的角色1", "会在这个场景出现的角色2", "会在这个场景出现的角色3"],', ' "connections": [', ' {', ' "targetLandmarkName": "相邻场景名称",', ` "relativePosition": "${CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS[0]?.value ?? 'forward'}",`, ' "summary": "两处场景之间的相对位置或通路说明"', ' }', ' ]', ' }', ' ]', '}', '', '要求:', '- 所有生成文本都必须使用中文。', '- camp 必须存在,代表玩家开局时的落脚处;名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。', '- 必须生成恰好 5 个 playableNpcs。', '- 至少生成 25 个 storyNpcs,并保证 playableNpcs + storyNpcs 的唯一名称总数不少于 30。', '- 至少生成 10 个真正可游玩的 landmarks。', '- playableNpcs 与 storyNpcs 必须使用完全相同的字段结构:name、title、role、description、backstory、personality、motivation、combatStyle、initialAffinity、relationshipHooks、tags、backstoryReveal、skills、initialItems。', `- backstoryReveal.chapters 必须恰好 4 章,affinityRequired 固定使用 ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.join('、')}。`, '- 每个 NPC 必须提供恰好 3 个 skills 和恰好 3 个 initialItems。', '- 每个 landmark 必须提供 sceneNpcNames 和 connections 两个字段。', '- 每个 landmark.sceneNpcNames 至少包含 3 个来自 storyNpcs 的唯一角色名,不要引用 playableNpcs。', `- 每个 landmark.connections 至少包含 2 条连接;relativePosition 只能使用:${relativePositionValues}。`, '- landmark.connections.targetLandmarkName 必须指向 landmarks 中真实存在的其他场景,不能引用自己。', '- initialItems.category 只能使用:武器、护甲、饰品、消耗品、材料、稀有品、专属物品。', '- initialAffinity 必须是 -40 到 90 的整数;可扮演角色通常不低于 18,敌对或怪物型 NPC 应使用负数。', '- 每个场景角色和地标都必须直接源自玩家设定。', '- 怪物型角色仍然放进 storyNpcs,并在 role、description、backstory、combatStyle、tags、backstoryReveal、skills 或 initialItems 中明确写出怪物特征、栖息环境、攻击方式或异形外观,方便后续形象解析同时引用 Medieval 和怪物素材。', '- 场景之间的连接关系要形成可遍历的地图网络,并在 summary 中直接写出相对位置、通路或地理关系。', '- 每个字符串尽量简洁:description/backstory/personality/motivation/combatStyle 控制在 10 到 40 个汉字内,backstoryReveal.content 控制在 12 到 36 个汉字内,skills.summary 和 initialItems.description 控制在 8 到 24 个汉字内。', '- 名称必须具体且有辨识度,不要使用 角色1、NPC1、场景1 之类的占位名。', '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', ].join('\n'); } export function buildCustomWorldReferenceText( profile: CustomWorldProfile, options: { activeThreadIds?: string[] | null; highlightNpcNames?: string[] | null; } = {}, ) { const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc])); const landmarkById = new Map( profile.landmarks.map((landmark) => [landmark.id, landmark]), ); const themePack = profile.themePack ?? buildThemePackFromWorldProfile(profile); const storyGraph = profile.storyGraph ?? buildFallbackWorldStoryGraph(profile, themePack); const activeThreadIds = options.activeThreadIds?.filter(Boolean)?.length ? options.activeThreadIds.filter(Boolean) : storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id); const activeThreads = [...storyGraph.visibleThreads, ...storyGraph.hiddenThreads] .filter((thread) => activeThreadIds.includes(thread.id)) .slice(0, 3); const highlightNpcNames = new Set( (options.highlightNpcNames ?? []).map((name) => name.trim()).filter(Boolean), ); const describeNpcReference = ( npc: CustomWorldProfile['storyNpcs'][number] | CustomWorldProfile['playableNpcs'][number], ) => { const narrativeProfile = normalizeActorNarrativeProfile( npc.narrativeProfile, buildFallbackActorNarrativeProfile(npc, storyGraph, themePack), ); return `- ${npc.name} / ${npc.title}:身份 ${npc.role};公开面:${narrativeProfile.publicMask};表层线:${narrativeProfile.visibleLine};当前压力:${narrativeProfile.immediatePressure};相关线程:${ narrativeProfile.relatedThreadIds .map((threadId) => [...storyGraph.visibleThreads, ...storyGraph.hiddenThreads] .find((thread) => thread.id === threadId)?.title ?? threadId, ) .join('、') || '暂无' };反应钩子:${narrativeProfile.reactionHooks.join('、') || '暂无'}`; }; const playableNpcText = profile.playableNpcs .slice(0, 3) .map((npc) => describeNpcReference(npc)) .join('\n'); const storyNpcText = profile.storyNpcs .filter((npc) => highlightNpcNames.size > 0 ? highlightNpcNames.has(npc.name) : true, ) .slice(0, highlightNpcNames.size > 0 ? 3 : 6) .map((npc) => describeNpcReference(npc)) .join('\n'); const landmarkText = profile.landmarks .slice(0, 10) .map( (landmark) => `- ${landmark.name}:${landmark.description};危险度:${landmark.dangerLevel};场景角色:${ landmark.sceneNpcIds .map((npcId) => storyNpcById.get(npcId)?.name) .filter(Boolean) .join('、') || '暂无' };连接:${ landmark.connections .map((connection) => { const targetLandmark = landmarkById.get(connection.targetLandmarkId); if (!targetLandmark) { return ''; } return `${getCustomWorldSceneRelativePositionLabel(connection.relativePosition)} -> ${targetLandmark.name}${connection.summary ? `(${connection.summary})` : ''}`; }) .filter(Boolean) .join('、') || '暂无' }`, ) .join('\n'); return [ `自定义世界:${profile.name}`, `副标题:${profile.subtitle}`, `玩家原始设定:${profile.settingText}`, `世界概述:${profile.summary}`, `世界基调:${profile.tone}`, `玩家核心目标:${profile.playerGoal}`, `开局归处:${profile.camp?.name ?? '未设定'}${profile.camp?.description ? `;${profile.camp.description}` : ''}`, `题材适配层:${themePack.displayName};制度词汇 ${themePack.institutionLexicon.slice(0, 4).join('、')};禁忌词 ${themePack.tabooLexicon.slice(0, 4).join('、')};载体类型 ${themePack.artifactClasses.slice(0, 4).join('、')}`, `当前激活线程:\n${activeThreads.map((thread) => `- ${thread.title}:${thread.summary}`).join('\n') || '- 暂无'}`, `世界属性轴:${profile.attributeSchema.slots.map((slot) => `${slot.name}:${slot.definition}`).join(';')}`, `可扮演角色档案:\n${playableNpcText || '- 暂无'}`, `世界场景角色档案:\n${storyNpcText || '- 暂无'}`, `关键场景档案:\n${landmarkText || '- 暂无'}`, ].join('\n'); } function countUniqueNames(items: Array<{ name: string }>) { return new Set(items.map((item) => item.name.trim()).filter(Boolean)).size; } export function validateGeneratedCustomWorldProfile( profile: CustomWorldProfile, ) { const playableCount = countUniqueNames(profile.playableNpcs); const landmarkCount = countUniqueNames(profile.landmarks); if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) { throw new Error( `自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT} 名可扮演角色。`, ); } if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) { throw new Error( `自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`, ); } const validStoryNpcIds = new Set(profile.storyNpcs.map((npc) => npc.id)); const validLandmarkIds = new Set(profile.landmarks.map((landmark) => landmark.id)); profile.landmarks.forEach((landmark) => { const uniqueSceneNpcIds = [...new Set(landmark.sceneNpcIds)]; if (uniqueSceneNpcIds.length < 3) { throw new Error( `场景「${landmark.name}」至少需要关联 3 个场景角色,当前仅有 ${uniqueSceneNpcIds.length} 个。`, ); } if (uniqueSceneNpcIds.some((npcId) => !validStoryNpcIds.has(npcId))) { throw new Error(`场景「${landmark.name}」引用了不存在的场景角色。`); } if (landmark.connections.length === 0) { throw new Error(`场景「${landmark.name}」缺少可用的场景连接关系。`); } if ( landmark.connections.some( (connection) => connection.targetLandmarkId === landmark.id || !validLandmarkIds.has(connection.targetLandmarkId), ) ) { throw new Error(`场景「${landmark.name}」存在无效的目标场景连接。`); } }); } function clampSceneImageText(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 describeDangerLevel(dangerLevel: string) { const normalized = dangerLevel.trim().toLowerCase(); if (normalized === 'low' || normalized === '低') return '气氛相对平静,但暗藏细节张力'; if (normalized === 'medium' || normalized === '中') return '带有明确的探索压力与潜在威胁'; if (normalized === 'high' || normalized === '高') return '危险感强烈,空间中有明显压迫感'; if (normalized === 'extreme' || normalized === '极高') return '极端危险,环境本身就像会吞没闯入者'; return dangerLevel.trim() ? `危险氛围:${dangerLevel.trim()}` : '危险气质保持克制但不可忽视'; } export const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT = [ '文字', '水印', 'logo', 'UI界面', '对话框', '边框', '人物近景特写', '多人合照', '模糊', '低清晰度', '畸形建筑', '现代车辆', '监控摄像头', ].join(','); export function buildCustomWorldSceneImagePrompt( profile: Pick< CustomWorldProfile, 'name' | 'subtitle' | 'summary' | 'tone' | 'playerGoal' | 'settingText' >, landmark: Pick, ) { const worldName = clampSceneImageText(profile.name, 18) || '未命名世界'; const worldSubtitle = clampSceneImageText(profile.subtitle, 18); const worldTone = clampSceneImageText(profile.tone, 48); const worldGoal = clampSceneImageText(profile.playerGoal, 48); const worldSummary = clampSceneImageText(profile.summary, 72); const worldSetting = clampSceneImageText(profile.settingText, 72); const landmarkName = clampSceneImageText(landmark.name, 18) || '未命名场景'; const landmarkDescription = clampSceneImageText(landmark.description, 96); const dangerMood = describeDangerLevel(landmark.dangerLevel); return [ '横版幻想 RPG 场景背景概念图,适合作为 2D 游戏战斗与探索背景,环境主体清晰,空间层次明确,电影感光影,细节丰富。', `世界:${worldName}${worldSubtitle ? `,${worldSubtitle}` : ''}。`, worldSetting ? `玩家设定:${worldSetting}。` : '', worldSummary ? `世界概述:${worldSummary}。` : '', worldTone ? `整体基调:${worldTone}。` : '', worldGoal ? `玩家目标关联:${worldGoal}。` : '', `场景名称:${landmarkName}。`, landmarkDescription ? `场景描述:${landmarkDescription}。` : '', `${dangerMood}。`, '不要出现 UI、字幕、文字、水印或 logo,人物仅可作为很小的远景剪影,画面重点放在建筑、地貌、光线与氛围。', ] .filter(Boolean) .join(''); }