import type { CustomWorldFoundationDraftCamp, CustomWorldFoundationDraftCharacter, CustomWorldFoundationDraftFaction, CustomWorldFoundationDraftLandmark, CustomWorldFoundationDraftProfile, CustomWorldFoundationDraftThread, EightAnchorContent, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { FOUNDATION_JSON_ONLY_SYSTEM_PROMPT, FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT, } from '../prompts/customWorldAgentPrompts.js'; import { buildCustomWorldFrameworkJsonRepairPrompt, buildCustomWorldFrameworkPrompt, buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt, buildCustomWorldLandmarkNetworkBatchPrompt, buildCustomWorldLandmarkSeedBatchJsonRepairPrompt, buildCustomWorldLandmarkSeedBatchPrompt, buildCustomWorldRoleBatchJsonRepairPrompt, buildCustomWorldRoleBatchPrompt, buildCustomWorldRoleOutlineBatchJsonRepairPrompt, buildCustomWorldRoleOutlineBatchPrompt, } from '../prompts/customWorldPrompts.js'; import { buildCompiledCustomWorldProfile, buildCustomWorldRawProfileFromFramework, type CustomWorldGenerationFramework, type CustomWorldGenerationLandmarkOutline, type CustomWorldGenerationRoleBatchStage, type CustomWorldGenerationRoleBatchType, type CustomWorldGenerationRoleOutline, normalizeCustomWorldGenerationFramework, normalizeCustomWorldGenerationLandmarkOutlineBatch, normalizeCustomWorldGenerationRoleOutlineBatch, } from '../modules/custom-world/runtimeProfile.js'; import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js'; import { buildDraftSummaryFromIntent, type CreatorCharacterSeedRecord, type CustomWorldCreatorIntentRecord, normalizeCreatorIntentRecord, } from './customWorldAgentIntentExtractionService.js'; import { buildCreatorIntentFromEightAnchorContent, buildDraftSummaryFromEightAnchorContent, buildDraftTitleFromEightAnchorContent, buildEightAnchorFoundationText, normalizeEightAnchorContent, } from './eightAnchorCompatibilityService.js'; import type { UpstreamLlmClient } from './llmClient.js'; function toText(value: unknown) { return typeof value === 'string' ? value.trim() : ''; } function toRecord(value: unknown) { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } function clampText(value: string, maxLength: number) { const normalized = value.replace(/\s+/gu, ' ').trim(); if (!normalized) { return ''; } if (normalized.length <= maxLength) { return normalized; } return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; } function slugify(value: string) { const normalized = value .trim() .toLowerCase() .replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-') .replace(/^-+|-+$/gu, ''); return normalized || 'entry'; } function createId(prefix: string, label: string, index: number) { return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; } function dedupeStrings(items: string[], maxCount = 8) { return [...new Set(items.map((item) => item.trim()).filter(Boolean))].slice( 0, maxCount, ); } function sanitizeEntityName(value: string) { return value .replace(/^(一个|一种|一名|一位|被迫|正在|眼下|此刻|这个|这座|这片)/u, '') .replace(/[。!?;,,]/gu, '') .trim(); } function buildCompactLabel(text: string, fallback: string, maxLength = 14) { const normalized = sanitizeEntityName(text) .replace(/^(玩家是|主角是|玩家身份是|故事开场时|故事开场|开局时|开局)/u, '') .trim(); return clampText(normalized || fallback, maxLength) || fallback; } function extractConflictSides(conflict: string) { const relationMatch = conflict.match( /([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}?)(?:与|和|及)([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}?)(?:争夺|对抗|角力|围绕|拉扯|较量|冲突)/u, ); if (relationMatch?.[1] && relationMatch?.[2]) { return [relationMatch[1].trim(), relationMatch[2].trim()]; } return [ ...conflict.matchAll( /([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}(?:会|盟|门|宗|阁|府|庭|院|司|营|局|军|团|殿|邦|教|社|帮|署))/gu, ), ] .map((entry) => entry[1]?.trim() || '') .filter(Boolean) .slice(0, 3); } function extractConflictTarget(conflict: string) { const matched = conflict.match( /(?:争夺|抢夺|围绕|对抗|角力|争取)([^,。;]{2,20})/u, ); return clampText(toText(matched?.[1]), 18); } function extractPlaceLikePhrase(text: string) { const patterns = [ /在([^,。;]{2,18}?(?:塔|港|湾|岛|城|宫|桥|门|站|镇|村|院|殿|阁|馆|楼|海|岸|街|巷|林|谷|原|坑|窟|池|湖|河))(?:上|里|中|内|前|旁|边)?/u, /正站在([^,。;]{2,18}?(?:塔|港|湾|岛|城|宫|桥|门|站|镇|村|院|殿|阁|馆|楼|海|岸|街|巷|林|谷|原|坑|窟|池|湖|河))(?:上|里|中|内)?/u, ]; for (const pattern of patterns) { const matched = text.match(pattern); const candidate = sanitizeEntityName(toText(matched?.[1])); if (candidate) { return clampText(candidate, 16); } } return ''; } function looksLikePlaceName(value: string) { return /(塔|港|湾|岛|城|宫|桥|门|站|镇|村|院|殿|阁|馆|楼|海|岸|街|巷|林|谷|原|坑|窟|池|湖|河|道|渡口|码头)/u.test( value, ); } function convertElementToLandmarkName(element: string) { const normalized = sanitizeEntityName(element); if (!normalized) { return ''; } if (looksLikePlaceName(normalized)) { return clampText(normalized, 16); } if (normalized.endsWith('钟声')) { return clampText(normalized.replace(/钟声$/u, '钟塔'), 16); } if (normalized.endsWith('盟约') || normalized.endsWith('残片')) { return clampText(`${normalized}档库`, 16); } if (normalized.endsWith('火')) { return clampText(`${normalized}哨点`, 16); } return clampText(`${normalized}回响区`, 16); } function buildWorldName(intent: CustomWorldCreatorIntentRecord) { const worldHook = sanitizeEntityName( intent.worldHook || intent.rawSettingText, ); const namedMatch = worldHook.match( /([A-Za-z0-9\u4e00-\u9fa5·-]{2,16}(?:列岛|群岛|王朝|帝国|海域|边境|疆域|之城|之境|之域|城邦|废都|王庭|海岸|高地))/u, ); return ( clampText( namedMatch?.[1] || worldHook || intent.iconicElements[0] || '', 18, ) || '未命名世界底稿' ); } function buildTone(intent: CustomWorldCreatorIntentRecord) { return ( dedupeStrings( [ ...intent.themeKeywords, ...intent.toneDirectives, ...intent.iconicElements, ], 8, ).join('、') || '紧绷、未明、带着继续展开的空间' ); } function buildPlayerGoal(params: { playerPremise: string; openingSituation: string; coreConflict: string; }) { const conflictTarget = extractConflictTarget(params.coreConflict); const location = extractPlaceLikePhrase(params.openingSituation); const lead = location ? `先在${location}站稳` : params.openingSituation ? `先扛过“${buildCompactLabel(params.openingSituation, '开局风暴', 12)}”` : '先稳住眼前的局势'; const tail = conflictTarget ? `,再查清谁在主导“${conflictTarget}”` : params.coreConflict ? `,再判断自己在“${buildCompactLabel(params.coreConflict, '核心冲突', 12)}”里的站位` : ''; return clampText(`${lead}${tail}`, 60); } function buildFactions(params: { intent: CustomWorldCreatorIntentRecord; coreConflicts: string[]; playerPremise: string; iconicElements: string[]; }): CustomWorldFoundationDraftFaction[] { const explicitFactions = params.intent.keyFactions.map((entry) => ({ name: sanitizeEntityName(entry.name), publicGoal: clampText(entry.publicGoal, 28), relatedConflict: clampText(entry.tension, 48) || params.coreConflicts[0] || '局势正在升温', playerRelation: '玩家很难绕开它的影响', })); const conflictSideNames = params.coreConflicts.flatMap((entry) => extractConflictSides(entry), ); const fallbackPrefixes = dedupeStrings( [ ...params.iconicElements.map((entry) => buildCompactLabel(entry, '', 6)), buildCompactLabel(params.intent.worldHook, '', 6), ], 4, ).filter(Boolean); const fallbackNames = [ fallbackPrefixes[0] ? `${fallbackPrefixes[0]}守望会` : '', fallbackPrefixes[1] ? `${fallbackPrefixes[1]}商盟` : '', '旧约议庭', '灰区中间人', ].filter(Boolean); const names = dedupeStrings( [ ...explicitFactions.map((entry) => entry.name), ...conflictSideNames, ...fallbackNames, ], 4, ).slice(0, 3); return names.map((name, index) => { const explicit = explicitFactions.find((entry) => entry.name === name); const relatedConflict = explicit?.relatedConflict || params.coreConflicts.find((entry) => entry.includes(name)) || params.coreConflicts[index % Math.max(1, params.coreConflicts.length)] || '局势仍在快速失衡'; const conflictTarget = extractConflictTarget(relatedConflict); const publicGoal = explicit?.publicGoal || clampText( conflictTarget ? `拿下${conflictTarget}的主动解释权` : '在变局里先一步拿到主动权', 28, ); const playerRelation = explicit?.playerRelation || clampText( index === 0 ? '它会把玩家当成必须争取的关键变量' : index === 1 ? '它迟早会逼玩家在立场上做选择' : '它可能提供入口,也可能直接加码风险', 36, ); return { id: createId('faction', name, index), name, publicGoal, relatedConflict, playerRelation, summary: clampText( `${name}正在围绕“${buildCompactLabel(relatedConflict, '核心冲突', 16)}”抢先手,公开目标是${publicGoal},并且${playerRelation}。`, 140, ), }; }); } function buildBaseThreads(params: { intent: CustomWorldCreatorIntentRecord; coreConflicts: string[]; playerPremise: string; openingSituation: string; iconicElements: string[]; }): CustomWorldFoundationDraftThread[] { const firstConflict = params.coreConflicts[0] || '旧秩序与新力量正在拉扯世界走向'; const hiddenSeed = params.intent.keyCharacters.find((entry) => entry.hiddenHook.trim()) ?.hiddenHook || params.iconicElements[0] || '表面冲突背后还有更深的一层'; const relationshipSeed = params.intent.keyCharacters.find((entry) => entry.relationToPlayer.trim()) ?.relationToPlayer || params.playerPremise || params.openingSituation; const extraSeed = params.coreConflicts[1] || params.iconicElements[1] || ''; const seeds = [ { title: buildCompactLabel(firstConflict, '主线推进', 16), type: 'main' as const, conflict: firstConflict, summary: clampText( `明线围绕“${firstConflict}”推进,玩家一入局就会被迫参与。`, 90, ), }, { title: buildCompactLabel(hiddenSeed, '暗线回潮', 16), type: 'hidden' as const, conflict: hiddenSeed, summary: clampText( `暗线真正牵动的不是表面立场,而是“${buildCompactLabel(hiddenSeed, '暗线真相', 18)}”。`, 90, ), }, { title: buildCompactLabel(relationshipSeed, '关系裂口', 16), type: 'main' as const, conflict: relationshipSeed, summary: clampText( `玩家身边的关系与身份会决定这条线最先从哪里裂开。`, 90, ), }, ...(extraSeed ? [ { title: buildCompactLabel(extraSeed, '余波扩散', 16), type: 'hidden' as const, conflict: extraSeed, summary: clampText(`这条线负责把世界里更深的余波慢慢带出来。`, 90), }, ] : []), ]; return seeds.slice(0, 4).map((entry, index) => ({ id: createId('thread', entry.title, index), title: entry.title, type: entry.type, conflict: clampText(entry.conflict, 72), characterIds: [], landmarkIds: [], summary: entry.summary, })); } function buildPlayerProxyCharacter( intent: CustomWorldCreatorIntentRecord, threads: CustomWorldFoundationDraftThread[], coreConflict: string, ): CustomWorldFoundationDraftCharacter | null { const playerPremise = sanitizeEntityName(intent.playerPremise); if (!playerPremise) { return null; } const mainThreadId = threads[0]?.id ?? null; const relationThreadId = threads[2]?.id ?? threads[1]?.id ?? null; const name = buildCompactLabel(playerPremise, '玩家前线身份', 10); return { id: createId('character', name, 0), name, title: '玩家前线身份', role: playerPremise, publicIdentity: playerPremise, currentPressure: clampText(intent.openingSituation || coreConflict, 48) || '必须先扛过眼前的局势压力', relationToPlayer: '这是玩家当前最贴近世界的切入口', threadIds: [mainThreadId, relationThreadId].filter( (entry): entry is string => Boolean(entry), ), summary: clampText( `${playerPremise}被直接推到台前,眼下压力是“${buildCompactLabel(intent.openingSituation || coreConflict, '开局压力', 18)}”。`, 120, ), }; } function buildCharacterFromSeed(params: { seed: CreatorCharacterSeedRecord; index: number; threads: CustomWorldFoundationDraftThread[]; coreConflict: string; }): CustomWorldFoundationDraftCharacter { const hiddenThreadId = params.threads.find( (entry) => entry.type === 'hidden', )?.id; const mainThreadId = params.threads[0]?.id ?? null; const relationThreadId = params.threads[2]?.id ?? hiddenThreadId ?? null; return { id: params.seed.id || createId('character', params.seed.name || params.seed.role, params.index), name: sanitizeEntityName(params.seed.name) || buildCompactLabel( params.seed.role || params.seed.relationToPlayer, '关键角色', 10, ), title: clampText(params.seed.role || '关键人物', 18) || '关键人物', role: clampText(params.seed.role || '关键人物', 28) || '关键人物', publicIdentity: clampText( params.seed.publicMask || params.seed.role || '站在当前局势前台的人', 36, ) || '站在当前局势前台的人', currentPressure: clampText(params.seed.hiddenHook || params.coreConflict, 48) || '正在被当前局势不断加压', relationToPlayer: clampText( params.seed.relationToPlayer || '会直接改变玩家的第一步选择', 36, ) || '会直接改变玩家的第一步选择', threadIds: dedupeStrings( [ params.seed.hiddenHook ? (hiddenThreadId ?? '') : '', params.seed.relationToPlayer ? (relationThreadId ?? '') : '', mainThreadId ?? '', ], 3, ), summary: clampText( `${params.seed.publicMask || params.seed.role || '表面上像是立场前台的人'};当前压力是${params.seed.hiddenHook || '必须在明暗两条线上同时做选择'};与玩家关系是${params.seed.relationToPlayer || '会直接左右玩家的站位'}`, 130, ), }; } function buildGeneratedCharacters(params: { existingNames: string[]; factions: CustomWorldFoundationDraftFaction[]; threads: CustomWorldFoundationDraftThread[]; iconicElements: string[]; coreConflict: string; }): CustomWorldFoundationDraftCharacter[] { const suffixes = ['联络人', '记录官', '引路人', '修补匠', '代言人']; const generated: CustomWorldFoundationDraftCharacter[] = []; const mainThreadId = params.threads[0]?.id ?? null; const hiddenThreadId = params.threads.find( (entry) => entry.type === 'hidden', )?.id; const relationThreadId = params.threads[2]?.id ?? mainThreadId; params.factions.forEach((faction, index) => { const prefix = buildCompactLabel( faction.name.replace(/(会|盟|庭|局|司|府|团|营|帮)$/u, ''), '关键', 6, ) || buildCompactLabel(params.iconicElements[index] || '', '关键', 6); const name = `${prefix}${suffixes[index % suffixes.length]}`; if (params.existingNames.includes(name)) { return; } generated.push({ id: createId('character', name, generated.length + 1), name, title: '关键阵营接口人', role: `${faction.name}在前台推动局势的人`, publicIdentity: `${faction.name}的前台接口人`, currentPressure: faction.relatedConflict || params.coreConflict, relationToPlayer: index === 0 ? '会主动把玩家拉进局势中心' : '对玩家既有利用价值也有试探意图', threadIds: dedupeStrings( [ mainThreadId ?? '', index % 2 === 0 ? (relationThreadId ?? '') : (hiddenThreadId ?? ''), ], 3, ), summary: clampText( `${name}代表${faction.name}在前台出手,眼下压力直指“${buildCompactLabel(faction.relatedConflict || params.coreConflict, '局势升级', 18)}”,同时会主动试探玩家的站位。`, 130, ), }); }); return generated; } function buildCharacters(params: { intent: CustomWorldCreatorIntentRecord; factions: CustomWorldFoundationDraftFaction[]; threads: CustomWorldFoundationDraftThread[]; coreConflicts: string[]; iconicElements: string[]; }) { const firstConflict = params.coreConflicts[0] || '旧秩序与新力量正在拉扯世界走向'; const characters: CustomWorldFoundationDraftCharacter[] = []; const playerProxy = buildPlayerProxyCharacter( params.intent, params.threads, firstConflict, ); if (playerProxy) { characters.push(playerProxy); } params.intent.keyCharacters.forEach((seed, index) => { characters.push( buildCharacterFromSeed({ seed, index: index + 1, threads: params.threads, coreConflict: firstConflict, }), ); }); const generated = buildGeneratedCharacters({ existingNames: characters.map((entry) => entry.name), factions: params.factions, threads: params.threads, iconicElements: params.iconicElements, coreConflict: firstConflict, }); generated.forEach((entry) => { if (characters.some((item) => item.name === entry.name)) { return; } characters.push(entry); }); return dedupeStrings( characters.map((entry) => entry.name), 5, ).map((name) => characters.find((entry) => entry.name === name)!); } function buildCamp(params: { openingSituation: string; worldHook: string; iconicElements: string[]; }): CustomWorldFoundationDraftCamp { const openingPlace = extractPlaceLikePhrase(params.openingSituation); const prefix = openingPlace || buildCompactLabel(params.iconicElements[0] || params.worldHook, '归返', 6); const name = looksLikePlaceName(prefix) ? `${prefix}守望舍` : `${prefix}前哨`; return { id: 'camp-home', name: clampText(name, 16), description: clampText( openingPlace ? `贴着${openingPlace}搭起来的临时落脚处,玩家还能在这里喘口气和整理线索。` : '玩家暂时还能整顿情报、换口气并决定下一步站位的落脚处。', 72, ), mood: '克制、紧绷,但还有一点能重新收住局势的余地', summary: clampText( `${clampText(name, 12)}不是安全区,而是玩家在风暴边缘还能勉强站稳的一块地方。`, 88, ), }; } function buildLandmarks(params: { intent: CustomWorldCreatorIntentRecord; camp: CustomWorldFoundationDraftCamp; factions: CustomWorldFoundationDraftFaction[]; characters: CustomWorldFoundationDraftCharacter[]; threads: CustomWorldFoundationDraftThread[]; coreConflicts: string[]; iconicElements: string[]; openingSituation: string; }): CustomWorldFoundationDraftLandmark[] { const explicit = params.intent.keyLandmarks.map((entry) => ({ name: clampText(sanitizeEntityName(entry.name), 16), purpose: clampText(entry.purpose, 24) || '承接关键剧情推进', mood: clampText(entry.mood, 24) || '带着明显的情绪指向', importance: clampText(entry.secret, 36) || '和当前主线冲突直接勾连的关键地点', })); const openingPlace = extractPlaceLikePhrase(params.openingSituation); const conflictTarget = extractConflictTarget(params.coreConflicts[0] || ''); const derivedNames = dedupeStrings( [ ...explicit.map((entry) => entry.name), openingPlace, ...params.iconicElements.map((entry) => convertElementToLandmarkName(entry), ), conflictTarget ? looksLikePlaceName(conflictTarget) ? conflictTarget : `${conflictTarget}争议带` : '', `${buildCompactLabel(params.factions[0]?.name || params.camp.name, '前线', 8)}前场`, '旧档案库', '灰雾渡口', ], 6, ).slice(0, 5); return derivedNames.map((name, index) => { const explicitEntry = explicit.find((entry) => entry.name === name); const threadIds = dedupeStrings( [ params.threads[index % Math.max(1, params.threads.length)]?.id ?? '', params.threads[(index + 1) % Math.max(1, params.threads.length)]?.id ?? '', ], 3, ); const characterIds = dedupeStrings( [ params.characters[index % Math.max(1, params.characters.length)]?.id ?? '', params.characters[(index + 1) % Math.max(1, params.characters.length)] ?.id ?? '', ], 3, ); return { id: createId('landmark', name, index), name, purpose: explicitEntry?.purpose || clampText( index === 0 ? '玩家最先被推到局势前台的位置' : index === 1 ? '不同立场开始交锋和试探的地方' : '把世界气质、冲突和人物同时挂住的关键地标', 28, ), mood: explicitEntry?.mood || clampText( index === 0 ? '第一眼就能感到风暴逼近' : index === 1 ? '压迫里带着可探索的缝隙' : '既有吸引力,也有明显风险感', 24, ), importance: explicitEntry?.importance || clampText( `${name}和“${buildCompactLabel(params.coreConflicts[0] || params.threads[0]?.title || '主线推进', '主线', 16)}”直接勾连,玩家第一次抵达时就会意识到它不只是背景。`, 60, ), characterIds, threadIds, summary: clampText( `${name}承担${explicitEntry?.purpose || '主线推进'},会把${characterIds.length > 0 ? '关键人物' : '局势压力'}直接挂到玩家面前。`, 120, ), }; }); } function finalizeThreads(params: { threads: CustomWorldFoundationDraftThread[]; characters: CustomWorldFoundationDraftCharacter[]; landmarks: CustomWorldFoundationDraftLandmark[]; }) { return params.threads.map((thread) => { const characterIds = params.characters .filter((entry) => entry.threadIds.includes(thread.id)) .map((entry) => entry.id) .slice(0, 4); const landmarkIds = params.landmarks .filter((entry) => entry.threadIds.includes(thread.id)) .map((entry) => entry.id) .slice(0, 4); return { ...thread, characterIds, landmarkIds, summary: clampText( `${thread.type === 'hidden' ? '暗线' : '明线'}围绕“${buildCompactLabel(thread.conflict, thread.title, 18)}”推进,相关对象包括${ [ characterIds.length > 0 ? `${characterIds.length} 名关键角色` : '', landmarkIds.length > 0 ? `${landmarkIds.length} 个关键地点` : '', ] .filter(Boolean) .join('、') || '当前第一批底稿对象' }。`, 120, ), }; }); } function buildChapter(params: { worldName: string; openingSituation: string; playerGoal: string; characters: CustomWorldFoundationDraftCharacter[]; landmarks: CustomWorldFoundationDraftLandmark[]; threads: CustomWorldFoundationDraftThread[]; }) { const openingEvent = clampText(params.openingSituation, 60) || `玩家被迫卷入“${buildCompactLabel(params.threads[0]?.conflict || '', '主线冲突', 18)}”。`; const characterIds = params.characters.slice(0, 3).map((entry) => entry.id); const landmarkIds = params.landmarks.slice(0, 3).map((entry) => entry.id); const hiddenThread = params.threads.find((entry) => entry.type === 'hidden'); return { id: 'chapter-first-act', title: clampText( `第一幕:${buildCompactLabel(params.worldName, '世界开幕', 12)}`, 18, ), openingEvent, playerGoal: params.playerGoal, characterIds, landmarkIds, understandingShift: clampText( hiddenThread ? `第一幕结束时,玩家会意识到“${buildCompactLabel(hiddenThread.conflict, hiddenThread.title, 18)}”并不是背景噪音,而是会反过来改写主线走向。` : '第一幕结束时,玩家会意识到这场冲突远不止表面那一层。', 72, ), summary: clampText( `${openingEvent} 玩家第一步要做的不是立刻解决一切,而是先在${params.landmarks[0]?.name || '关键地点'}站稳,并看清${params.characters[0]?.name || '关键角色'}等人分别在推什么。`, 140, ), }; } const FOUNDATION_DRAFT_PLAYABLE_COUNT = 3; const FOUNDATION_DRAFT_STORY_COUNT = 6; const FOUNDATION_DRAFT_LANDMARK_COUNT = 4; const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE = 2; const FOUNDATION_LANDMARK_BATCH_SIZE = 2; const FOUNDATION_ROLE_DETAIL_BATCH_SIZE = 2; const FOUNDATION_LLM_TIMEOUT_MS = 90000; type DraftProgressPayload = { phaseLabel: string; phaseDetail: string; progress: number; }; type DraftProgressCallback = ( payload: DraftProgressPayload, ) => void | Promise; type MergeableNamedRecord = { name: string; }; function getNamedRecordKey(value: unknown) { return toText(value).replace(/\s+/gu, ''); } function chunkArray(items: T[], size: number) { if (size <= 0 || items.length === 0) { return items.length === 0 ? [] : [items]; } const chunks: T[][] = []; for (let index = 0; index < items.length; index += size) { chunks.push(items.slice(index, index + size)); } return chunks; } function mergeRoleBatchDetails( baseEntries: T[], detailEntries: Array>, ) { const nextEntries = baseEntries.map((entry) => ({ ...entry })) as T[]; const availableIndexes = new Set(nextEntries.map((_, index) => index)); const indexByName = new Map(); nextEntries.forEach((entry, index) => { const name = getNamedRecordKey(entry.name); if (name) { indexByName.set(name, index); } }); detailEntries.forEach((detail) => { const detailName = getNamedRecordKey(detail.name); let targetIndex = detailName && indexByName.has(detailName) ? indexByName.get(detailName) : undefined; if (targetIndex === undefined) { for (const index of availableIndexes) { targetIndex = index; break; } } if (targetIndex === undefined) { return; } const baseEntry = nextEntries[targetIndex]; if (!baseEntry) { return; } nextEntries[targetIndex] = { ...baseEntry, ...detail, name: getNamedRecordKey(baseEntry.name) || detailName || baseEntry.name, } as T; availableIndexes.delete(targetIndex); }); return nextEntries; } function appendUniqueNamedEntries( baseEntries: T[], nextEntries: T[], maxCount: number, ) { const merged = baseEntries.map((entry) => ({ ...entry })) as T[]; const existingNames = new Set( merged.map((entry) => getNamedRecordKey(entry.name)).filter(Boolean), ); nextEntries.forEach((entry) => { if (merged.length >= maxCount) { return; } const name = getNamedRecordKey(entry.name); if (!name || existingNames.has(name)) { return; } merged.push({ ...entry, name } as T); existingNames.add(name); }); return merged; } function extractJsonPayload(text: string) { const trimmed = text.trim(); if (!trimmed) { return ''; } const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu); const unfenced = fencedMatch?.[1]?.trim() || trimmed; const firstBrace = unfenced.indexOf('{'); const firstBracket = unfenced.indexOf('['); const starts = [firstBrace, firstBracket].filter((value) => value >= 0); const start = starts.length > 0 ? Math.min(...starts) : -1; if (start < 0) { return unfenced; } const opener = unfenced[start]; const closer = opener === '[' ? ']' : '}'; const end = unfenced.lastIndexOf(closer); if (end > start) { return unfenced.slice(start, end + 1); } return unfenced.slice(start); } function sanitizeJsonLikeText(text: string) { return extractJsonPayload(text) .replace(/^\uFEFF/u, '') .replace(/[\u201C\u201D]/gu, '"') .replace(/[\u2018\u2019]/gu, "'") .replace(/\u00A0/gu, ' ') .replace(/,\s*([}\]])/gu, '$1') .trim(); } function buildFoundationGenerationSeedText(params: { intent: CustomWorldCreatorIntentRecord; anchorPack: unknown; anchorContent?: EightAnchorContent | null; }) { const anchorText = params.anchorContent ? buildEightAnchorFoundationText(params.anchorContent) : ''; if (anchorText) { return anchorText; } const anchorRecord = toRecord(params.anchorPack); const anchorSummary = toText(anchorRecord?.creatorIntentSummary); if (anchorSummary) { return anchorSummary; } const sections = [ params.intent.worldHook ? `世界核心:${params.intent.worldHook}` : '', params.intent.playerPremise ? `玩家身份:${params.intent.playerPremise}` : '', params.intent.openingSituation ? `开局处境:${params.intent.openingSituation}` : '', params.intent.coreConflicts.length > 0 ? `核心冲突:${params.intent.coreConflicts.join('、')}` : '', params.intent.iconicElements.length > 0 ? `标志元素:${params.intent.iconicElements.join('、')}` : '', ].filter(Boolean); return sections.join('\n') || buildDraftSummaryFromIntent(params.intent); } async function emitDraftProgress( onProgress: DraftProgressCallback | undefined, payload: DraftProgressPayload, ) { if (!onProgress) { return; } await onProgress({ ...payload, progress: Math.max(0, Math.min(100, Math.round(payload.progress))), }); } function toBatchProgress( start: number, end: number, completed: number, total: number, ) { if (total <= 0) { return end; } const ratio = Math.max(0, Math.min(1, completed / total)); return start + (end - start) * ratio; } async function requestFoundationJsonStage(params: { llmClient: UpstreamLlmClient; userPrompt: string; debugLabel: string; repairPromptBuilder: (responseText: string) => string; repairDebugLabel: string; emptyResponseMessage: string; signal?: AbortSignal; }) { const responseText = await params.llmClient.requestMessageContent({ systemPrompt: FOUNDATION_JSON_ONLY_SYSTEM_PROMPT, userPrompt: params.userPrompt, signal: params.signal, timeoutMs: FOUNDATION_LLM_TIMEOUT_MS, debugLabel: params.debugLabel, }); const text = typeof responseText === 'string' ? responseText.trim() : ''; if (!text) { throw new Error(params.emptyResponseMessage); } try { return parseJsonResponseText(text); } catch { const sanitized = sanitizeJsonLikeText(text); if (sanitized && sanitized !== text) { try { return parseJsonResponseText(sanitized); } catch { // Fall through to model-assisted repair. } } const repairedText = await params.llmClient.requestMessageContent({ systemPrompt: FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT, userPrompt: params.repairPromptBuilder(text), signal: params.signal, timeoutMs: Math.min(FOUNDATION_LLM_TIMEOUT_MS, 60000), debugLabel: params.repairDebugLabel, }); return parseJsonResponseText( sanitizeJsonLikeText(repairedText) || repairedText, ); } } async function generateFoundationRoleOutlineEntries(params: { llmClient: UpstreamLlmClient; framework: CustomWorldGenerationFramework; roleType: CustomWorldGenerationRoleBatchType; totalCount: number; batchSize: number; signal?: AbortSignal; onProgress?: DraftProgressCallback; progressRange: [number, number]; }) { const plannedBatchCount = Math.max( 1, Math.ceil(params.totalCount / params.batchSize), ); const roleLabel = params.roleType === 'playable' ? '可扮演角色' : '场景角色'; let mergedEntries: CustomWorldGenerationRoleOutline[] = []; for ( let batchIndex = 0; batchIndex < plannedBatchCount && mergedEntries.length < params.totalCount; batchIndex += 1 ) { const batchCount = Math.min( params.batchSize, params.totalCount - mergedEntries.length, ); await emitDraftProgress(params.onProgress, { phaseLabel: `生成${roleLabel}`, phaseDetail: `正在生成${roleLabel}第 ${batchIndex + 1} / ${plannedBatchCount} 批,当前已完成 ${mergedEntries.length}/${params.totalCount}。`, progress: toBatchProgress( params.progressRange[0], params.progressRange[1], mergedEntries.length, params.totalCount, ), }); const batchRaw = await requestFoundationJsonStage({ llmClient: params.llmClient, userPrompt: buildCustomWorldRoleOutlineBatchPrompt({ framework: params.framework, roleType: params.roleType, batchCount, forbiddenNames: mergedEntries.map((entry) => entry.name), }), debugLabel: `agent-foundation-${params.roleType}-outline-batch-${batchIndex + 1}`, repairPromptBuilder: (responseText) => buildCustomWorldRoleOutlineBatchJsonRepairPrompt({ responseText, roleType: params.roleType, expectedCount: batchCount, forbiddenNames: mergedEntries.map((entry) => entry.name), }), repairDebugLabel: `agent-foundation-${params.roleType}-outline-batch-${batchIndex + 1}-json-repair`, emptyResponseMessage: `${roleLabel}第 ${batchIndex + 1} 批没有返回有效内容。`, signal: params.signal, }); mergedEntries = appendUniqueNamedEntries( mergedEntries, normalizeCustomWorldGenerationRoleOutlineBatch( batchRaw, params.roleType, ) as MergeableNamedRecord[] as CustomWorldGenerationRoleOutline[], params.totalCount, ); } await emitDraftProgress(params.onProgress, { phaseLabel: `生成${roleLabel}`, phaseDetail: `${roleLabel}名单已整理完成,共 ${mergedEntries.length} 个。`, progress: params.progressRange[1], }); return mergedEntries; } async function generateFoundationLandmarkSeedEntries(params: { llmClient: UpstreamLlmClient; framework: CustomWorldGenerationFramework; totalCount: number; batchSize: number; signal?: AbortSignal; onProgress?: DraftProgressCallback; progressRange: [number, number]; }) { const plannedBatchCount = Math.max( 1, Math.ceil(params.totalCount / params.batchSize), ); let mergedEntries: CustomWorldGenerationLandmarkOutline[] = []; for ( let batchIndex = 0; batchIndex < plannedBatchCount && mergedEntries.length < params.totalCount; batchIndex += 1 ) { const batchCount = Math.min( params.batchSize, params.totalCount - mergedEntries.length, ); await emitDraftProgress(params.onProgress, { phaseLabel: '生成关键场景', phaseDetail: `正在生成关键场景第 ${batchIndex + 1} / ${plannedBatchCount} 批,当前已完成 ${mergedEntries.length}/${params.totalCount}。`, progress: toBatchProgress( params.progressRange[0], params.progressRange[1], mergedEntries.length, params.totalCount, ), }); const batchRaw = await requestFoundationJsonStage({ llmClient: params.llmClient, userPrompt: buildCustomWorldLandmarkSeedBatchPrompt({ framework: params.framework, batchCount, forbiddenNames: mergedEntries.map((entry) => entry.name), }), debugLabel: `agent-foundation-landmark-seed-batch-${batchIndex + 1}`, repairPromptBuilder: (responseText) => buildCustomWorldLandmarkSeedBatchJsonRepairPrompt({ responseText, expectedCount: batchCount, forbiddenNames: mergedEntries.map((entry) => entry.name), }), repairDebugLabel: `agent-foundation-landmark-seed-batch-${batchIndex + 1}-json-repair`, emptyResponseMessage: `关键场景第 ${batchIndex + 1} 批没有返回有效内容。`, signal: params.signal, }); mergedEntries = appendUniqueNamedEntries( mergedEntries, normalizeCustomWorldGenerationLandmarkOutlineBatch( batchRaw, ) as MergeableNamedRecord[] as CustomWorldGenerationLandmarkOutline[], params.totalCount, ); } await emitDraftProgress(params.onProgress, { phaseLabel: '生成关键场景', phaseDetail: `关键场景骨架已整理完成,共 ${mergedEntries.length} 个。`, progress: params.progressRange[1], }); return mergedEntries; } async function expandFoundationLandmarkNetworkEntries(params: { llmClient: UpstreamLlmClient; framework: CustomWorldGenerationFramework; storyNpcs: CustomWorldGenerationFramework['storyNpcs']; baseEntries: CustomWorldGenerationLandmarkOutline[]; batchSize: number; signal?: AbortSignal; onProgress?: DraftProgressCallback; progressRange: [number, number]; }) { let mergedEntries = params.baseEntries.map((entry) => ({ ...entry })); const batches = chunkArray(params.framework.landmarks, params.batchSize); let processedCount = 0; for (const [batchIndex, landmarkBatch] of batches.entries()) { await emitDraftProgress(params.onProgress, { phaseLabel: '建立场景连接', phaseDetail: `正在补全场景连接第 ${batchIndex + 1} / ${batches.length} 批,当前已完成 ${processedCount}/${params.framework.landmarks.length}。`, progress: toBatchProgress( params.progressRange[0], params.progressRange[1], processedCount, params.framework.landmarks.length, ), }); const batchRaw = await requestFoundationJsonStage({ llmClient: params.llmClient, userPrompt: buildCustomWorldLandmarkNetworkBatchPrompt({ framework: params.framework, landmarkBatch, storyNpcs: params.storyNpcs, }), debugLabel: `agent-foundation-landmark-network-batch-${batchIndex + 1}`, repairPromptBuilder: (responseText) => buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt({ responseText, expectedNames: landmarkBatch.map((landmark) => landmark.name), }), repairDebugLabel: `agent-foundation-landmark-network-batch-${batchIndex + 1}-json-repair`, emptyResponseMessage: `场景连接第 ${batchIndex + 1} 批没有返回有效内容。`, signal: params.signal, }); mergedEntries = mergeRoleBatchDetails( mergedEntries as MergeableNamedRecord[], normalizeCustomWorldGenerationLandmarkOutlineBatch(batchRaw), ) as CustomWorldGenerationLandmarkOutline[]; processedCount = Math.min( params.framework.landmarks.length, processedCount + landmarkBatch.length, ); } await emitDraftProgress(params.onProgress, { phaseLabel: '建立场景连接', phaseDetail: '关键场景的角色分布与路径连接已经整理完成。', progress: params.progressRange[1], }); return mergedEntries; } async function expandFoundationRoleEntries(params: { llmClient: UpstreamLlmClient; framework: CustomWorldGenerationFramework; roleType: CustomWorldGenerationRoleBatchType; baseEntries: CustomWorldGenerationRoleOutline[]; stage: CustomWorldGenerationRoleBatchStage; batchSize: number; signal?: AbortSignal; onProgress?: DraftProgressCallback; progressRange: [number, number]; }) { const roleLabel = params.roleType === 'playable' ? '可扮演角色' : '场景角色'; const stageLabel = params.stage === 'narrative' ? '叙事基础' : '档案细节'; const batches = chunkArray(params.baseEntries, params.batchSize); let mergedEntries = params.baseEntries.map((entry) => ({ ...entry })); let processedCount = 0; for (const [batchIndex, roleBatch] of batches.entries()) { await emitDraftProgress(params.onProgress, { phaseLabel: `补全${roleLabel}${stageLabel}`, phaseDetail: `正在补全${roleLabel}${stageLabel}第 ${batchIndex + 1} / ${batches.length} 批,当前已完成 ${processedCount}/${params.baseEntries.length}。`, progress: toBatchProgress( params.progressRange[0], params.progressRange[1], processedCount, params.baseEntries.length, ), }); const stageRaw = await requestFoundationJsonStage({ llmClient: params.llmClient, userPrompt: buildCustomWorldRoleBatchPrompt({ framework: params.framework, roleType: params.roleType, roleBatch, stage: params.stage, }), debugLabel: `agent-foundation-${params.roleType}-${params.stage}-batch-${batchIndex + 1}`, repairPromptBuilder: (responseText) => buildCustomWorldRoleBatchJsonRepairPrompt({ responseText, roleType: params.roleType, expectedNames: roleBatch.map((role) => getNamedRecordKey(role.name)), stage: params.stage, }), repairDebugLabel: `agent-foundation-${params.roleType}-${params.stage}-batch-${batchIndex + 1}-json-repair`, emptyResponseMessage: `${roleLabel}${stageLabel}第 ${batchIndex + 1} 批没有返回有效内容。`, signal: params.signal, }); const detailEntries = Array.isArray( stageRaw && typeof stageRaw === 'object' ? (stageRaw as Record)[ params.roleType === 'playable' ? 'playableNpcs' : 'storyNpcs' ] : [], ) ? (((stageRaw as Record)[ params.roleType === 'playable' ? 'playableNpcs' : 'storyNpcs' ] as Array>) ?? []) : []; mergedEntries = mergeRoleBatchDetails( mergedEntries as MergeableNamedRecord[], detailEntries, ) as CustomWorldGenerationRoleOutline[]; processedCount = Math.min( params.baseEntries.length, processedCount + roleBatch.length, ); } await emitDraftProgress(params.onProgress, { phaseLabel: `补全${roleLabel}${stageLabel}`, phaseDetail: `${roleLabel}${stageLabel}已经整理完成。`, progress: params.progressRange[1], }); return mergedEntries; } function buildDraftFactionsFromRuntimeProfile(profile: CustomWorldProfile) { const factionNames = dedupeStrings(profile.majorFactions, 4); const firstConflict = profile.coreConflicts[0] || profile.summary; return factionNames.slice(0, 4).map((name, index) => { const relatedConflict = profile.coreConflicts[ index % Math.max(1, profile.coreConflicts.length) ] || firstConflict; return { id: createId('faction', name, index), name, title: name, publicGoal: clampText( extractConflictTarget(relatedConflict) ? `拿下${extractConflictTarget(relatedConflict)}的主导权` : '在失衡局势里先抢到主动权', 28, ), relatedConflict, tension: clampText(relatedConflict, 48), playerRelation: clampText( index === 0 ? '它会主动影响玩家的第一步站位' : '玩家迟早要和它发生直接交集', 32, ), summary: clampText( `${name}围绕“${buildCompactLabel(relatedConflict, '核心冲突', 16)}”持续施压,也会直接影响玩家的开局判断。`, 120, ), } satisfies CustomWorldFoundationDraftFaction; }); } function buildDraftThreadsFromRuntimeProfile( profile: CustomWorldProfile, ): CustomWorldFoundationDraftThread[] { const graphThreads = [ ...(profile.storyGraph?.visibleThreads ?? []).slice(0, 2), ...(profile.storyGraph?.hiddenThreads ?? []).slice(0, 2), ]; if (graphThreads.length > 0) { return graphThreads.map( (thread, index) => ({ id: thread.id || createId('thread', thread.title, index), title: clampText(thread.title, 18), type: thread.visibility === 'hidden' ? 'hidden' : 'main', conflictType: clampText(thread.conflictType, 18), conflict: clampText(thread.summary || thread.stakes, 72), stakes: clampText(thread.stakes, 48), characterIds: thread.involvedActorIds.slice(0, 4), landmarkIds: thread.relatedLocationIds.slice(0, 4), summary: clampText(thread.summary, 120), }) satisfies CustomWorldFoundationDraftThread, ); } return profile.coreConflicts.slice(0, 3).map((conflict, index) => ({ id: createId('thread', conflict, index), title: buildCompactLabel(conflict, `主线${index + 1}`, 16), type: index === 1 ? 'hidden' : 'main', conflict, characterIds: [], landmarkIds: [], summary: clampText(`这条线围绕“${conflict}”持续推进。`, 80), })); } function buildDraftCharactersFromRuntimeProfile( roles: CustomWorldProfile['playableNpcs'] | CustomWorldProfile['storyNpcs'], fallbackThreadIds: string[], ) { return roles.map((role) => ({ id: role.id, name: role.name, title: clampText(role.title || role.role, 18) || '关键角色', role: clampText(role.role || role.title, 28) || '关键角色', publicIdentity: clampText( role.narrativeProfile?.publicMask || role.backstoryReveal.publicSummary || role.description, 36, ) || '站在局势前台的人', publicMask: clampText( role.narrativeProfile?.firstContactMask || role.personality, 36, ) || undefined, currentPressure: clampText( role.narrativeProfile?.immediatePressure || role.motivation || role.backstory, 48, ) || '正在被当前局势不断加压', hiddenHook: clampText( role.narrativeProfile?.hiddenLine || role.backstoryReveal.chapters[2]?.content || role.backstory, 48, ) || undefined, relationToPlayer: clampText( role.relationshipHooks[0] || role.narrativeProfile?.visibleLine || role.motivation, 36, ) || '会直接改变玩家的下一步选择', threadIds: role.narrativeProfile?.relatedThreadIds?.slice(0, 3) ?? fallbackThreadIds.slice(0, 3), summary: clampText(role.description || role.backstoryReveal.publicSummary, 120) || '这个角色会持续推动当前世界底稿继续展开。', })) satisfies CustomWorldFoundationDraftCharacter[]; } function buildDraftLandmarksFromRuntimeProfile( profile: CustomWorldProfile, threads: CustomWorldFoundationDraftThread[], ) { return profile.landmarks .slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT) .map((landmark) => { const relatedThreadIds = threads .filter((thread) => thread.landmarkIds.includes(landmark.id)) .map((thread) => thread.id) .slice(0, 3); return { id: landmark.id, name: landmark.name, description: clampText(landmark.description, 48) || undefined, purpose: clampText(landmark.description, 28) || '承接关键剧情推进', mood: clampText( landmark.narrativeResidues?.[0]?.summary || landmark.dangerLevel || '带着明显风险的关键地点', 24, ) || '带着明显风险的关键地点', importance: clampText( landmark.narrativeResidues?.[0]?.changeHint || landmark.description || '和当前主线冲突直接勾连的关键地点', 60, ), secret: clampText( landmark.narrativeResidues?.[0]?.hiddenTruth || landmark.connections[0]?.summary || '', 36, ) || undefined, dangerLevel: landmark.dangerLevel, characterIds: landmark.sceneNpcIds.slice(0, 4), threadIds: relatedThreadIds, summary: clampText( landmark.description || landmark.narrativeResidues?.[0]?.summary || '', 120, ), } satisfies CustomWorldFoundationDraftLandmark; }); } function convertRuntimeProfileToFoundationDraft(params: { profile: CustomWorldProfile; intent: CustomWorldCreatorIntentRecord; anchorPack: unknown; }) { const factions = buildDraftFactionsFromRuntimeProfile(params.profile); const threads = buildDraftThreadsFromRuntimeProfile(params.profile); const playableNpcs = buildDraftCharactersFromRuntimeProfile( params.profile.playableNpcs.slice(0, FOUNDATION_DRAFT_PLAYABLE_COUNT), threads.slice(0, 2).map((entry) => entry.id), ); const storyNpcs = buildDraftCharactersFromRuntimeProfile( params.profile.storyNpcs.slice(0, FOUNDATION_DRAFT_STORY_COUNT), threads.slice(1, 3).map((entry) => entry.id), ); const landmarks = buildDraftLandmarksFromRuntimeProfile( params.profile, threads, ); const chapter = buildChapter({ worldName: params.profile.name, openingSituation: clampText(params.intent.openingSituation, 60) || params.profile.summary, playerGoal: params.profile.playerGoal, characters: [...playableNpcs, ...storyNpcs], landmarks, threads, }); const anchorRecord = toRecord(params.anchorPack); return { name: params.profile.name, subtitle: params.profile.subtitle, summary: params.profile.summary, tone: params.profile.tone, playerGoal: params.profile.playerGoal, majorFactions: params.profile.majorFactions.length > 0 ? params.profile.majorFactions : factions.map((entry) => entry.name), coreConflicts: params.profile.coreConflicts.length > 0 ? params.profile.coreConflicts : [params.profile.summary], playableNpcs, storyNpcs, landmarks, camp: params.profile.camp ? ({ id: 'camp-home', name: params.profile.camp.name, description: params.profile.camp.description, mood: clampText(params.profile.tone, 36) || '紧绷但还可暂时收住局势', dangerLevel: params.profile.camp.dangerLevel, summary: clampText(params.profile.camp.description, 88), } satisfies CustomWorldFoundationDraftCamp) : null, themePack: (params.profile.themePack as unknown as Record | null) ?? null, storyGraph: (params.profile.storyGraph as unknown as Record | null) ?? null, factions, threads, chapters: [chapter], worldHook: clampText(params.intent.worldHook || params.profile.summary, 72) || params.profile.summary, playerPremise: clampText(params.intent.playerPremise, 72) || '玩家是一名被卷进局势中心的行动者', openingSituation: clampText(params.intent.openingSituation, 72) || '故事开局时,玩家已经站在必须立刻选边的位置上', iconicElements: dedupeStrings(params.intent.iconicElements, 6), sourceAnchorSummary: toText(anchorRecord?.creatorIntentSummary) || buildDraftSummaryFromIntent(params.intent) || params.profile.summary, legacyResultProfile: params.profile as unknown as Record, } satisfies CustomWorldFoundationDraftProfile & { legacyResultProfile: Record; }; } async function buildFoundationDraftProfileWithLlm(params: { llmClient: UpstreamLlmClient; creatorIntent: CustomWorldCreatorIntentRecord; anchorPack: unknown; anchorContent?: EightAnchorContent | null; signal?: AbortSignal; onProgress?: DraftProgressCallback; }) { const settingText = buildFoundationGenerationSeedText({ intent: params.creatorIntent, anchorPack: params.anchorPack, anchorContent: params.anchorContent, }); await emitDraftProgress(params.onProgress, { phaseLabel: '整理世界骨架', phaseDetail: '正在根据创作者锚点生成第一版世界框架。', progress: 12, }); const frameworkRaw = await requestFoundationJsonStage({ llmClient: params.llmClient, userPrompt: buildCustomWorldFrameworkPrompt(settingText), debugLabel: 'agent-foundation-framework', repairPromptBuilder: (responseText) => buildCustomWorldFrameworkJsonRepairPrompt(responseText), repairDebugLabel: 'agent-foundation-framework-json-repair', emptyResponseMessage: '世界框架阶段没有返回有效内容。', signal: params.signal, }); const framework = normalizeCustomWorldGenerationFramework( frameworkRaw, settingText, ); framework.playableNpcs = await generateFoundationRoleOutlineEntries({ llmClient: params.llmClient, framework, roleType: 'playable', totalCount: FOUNDATION_DRAFT_PLAYABLE_COUNT, batchSize: FOUNDATION_ROLE_OUTLINE_BATCH_SIZE, signal: params.signal, onProgress: params.onProgress, progressRange: [16, 30], }); framework.storyNpcs = await generateFoundationRoleOutlineEntries({ llmClient: params.llmClient, framework, roleType: 'story', totalCount: FOUNDATION_DRAFT_STORY_COUNT, batchSize: FOUNDATION_ROLE_OUTLINE_BATCH_SIZE, signal: params.signal, onProgress: params.onProgress, progressRange: [30, 44], }); framework.landmarks = await generateFoundationLandmarkSeedEntries({ llmClient: params.llmClient, framework, totalCount: FOUNDATION_DRAFT_LANDMARK_COUNT, batchSize: FOUNDATION_LANDMARK_BATCH_SIZE, signal: params.signal, onProgress: params.onProgress, progressRange: [44, 56], }); framework.landmarks = await expandFoundationLandmarkNetworkEntries({ llmClient: params.llmClient, framework, storyNpcs: framework.storyNpcs, baseEntries: framework.landmarks, batchSize: FOUNDATION_LANDMARK_BATCH_SIZE, signal: params.signal, onProgress: params.onProgress, progressRange: [56, 66], }); const playableNarrative = await expandFoundationRoleEntries({ llmClient: params.llmClient, framework, roleType: 'playable', baseEntries: framework.playableNpcs, stage: 'narrative', batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE, signal: params.signal, onProgress: params.onProgress, progressRange: [66, 76], }); const playableDetailed = await expandFoundationRoleEntries({ llmClient: params.llmClient, framework, roleType: 'playable', baseEntries: playableNarrative, stage: 'dossier', batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE, signal: params.signal, onProgress: params.onProgress, progressRange: [76, 84], }); const storyNarrative = await expandFoundationRoleEntries({ llmClient: params.llmClient, framework, roleType: 'story', baseEntries: framework.storyNpcs, stage: 'narrative', batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE, signal: params.signal, onProgress: params.onProgress, progressRange: [84, 92], }); const storyDetailed = await expandFoundationRoleEntries({ llmClient: params.llmClient, framework, roleType: 'story', baseEntries: storyNarrative, stage: 'dossier', batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE, signal: params.signal, onProgress: params.onProgress, progressRange: [92, 96], }); await emitDraftProgress(params.onProgress, { phaseLabel: '编译世界底稿', phaseDetail: '正在把分批生成结果整理成旧版世界结果结构,再编成草稿卡底稿。', progress: 97, }); const rawProfile = buildCustomWorldRawProfileFromFramework( framework, ) as Record; rawProfile.playableNpcs = playableDetailed; rawProfile.storyNpcs = storyDetailed; rawProfile.landmarks = framework.landmarks; const runtimeProfile = buildCompiledCustomWorldProfile( rawProfile, settingText, ); return convertRuntimeProfileToFoundationDraft({ profile: runtimeProfile, intent: params.creatorIntent, anchorPack: params.anchorPack, }); } export class CustomWorldAgentFoundationDraftService { constructor(private readonly llmClient: UpstreamLlmClient | null = null) {} private generateFallbackDraft(params: { creatorIntent: unknown; anchorPack: unknown; anchorContent?: EightAnchorContent | null; }): CustomWorldFoundationDraftProfile { const normalizedAnchorContent = normalizeEightAnchorContent( params.anchorContent, ); const intent = normalizeCreatorIntentRecord(params.creatorIntent) ?? buildCreatorIntentFromEightAnchorContent(normalizedAnchorContent); const anchorPack = toRecord(params.anchorPack); const worldHook = clampText(intent.worldHook || intent.rawSettingText, 72) || '一个仍在失衡边缘不断扩张的世界'; const playerPremise = clampText(intent.playerPremise, 72) || '玩家是一名被卷进局势中心的行动者'; const openingSituation = clampText(intent.openingSituation, 72) || '故事开局时,玩家已经站在必须立刻选边的位置上'; const coreConflicts = dedupeStrings(intent.coreConflicts, 4).length > 0 ? dedupeStrings(intent.coreConflicts, 4) : ['旧秩序与新力量正在争夺这个世界的解释权']; const iconicElements = dedupeStrings(intent.iconicElements, 6); const tone = buildTone(intent); const worldName = buildWorldName(intent); const playerGoal = buildPlayerGoal({ playerPremise, openingSituation, coreConflict: coreConflicts[0] || '', }); const anchorDraftTitle = buildDraftTitleFromEightAnchorContent(normalizedAnchorContent); const factions = buildFactions({ intent, coreConflicts, playerPremise, iconicElements, }); const baseThreads = buildBaseThreads({ intent, coreConflicts, playerPremise, openingSituation, iconicElements, }); const characters = buildCharacters({ intent, factions, threads: baseThreads, coreConflicts, iconicElements, }).slice(0, 5); const camp = buildCamp({ openingSituation, worldHook, iconicElements, }); const landmarks = buildLandmarks({ intent, camp, factions, characters, threads: baseThreads, coreConflicts, iconicElements, openingSituation, }).slice(0, 6); const threads = finalizeThreads({ threads: baseThreads.slice(0, 4), characters, landmarks, }); const chapter = buildChapter({ worldName, openingSituation, playerGoal, characters, landmarks, threads, }); const uniquePoint = iconicElements.length > 0 ? `最抓人的记忆点是${iconicElements.slice(0, 2).join('、')}` : '这个世界的吸引力来自它正在失衡中的人和秩序'; const summary = clampText( `${worldHook} 玩家会以“${playerPremise}”切入这个世界,眼下最直接的冲突是“${coreConflicts[0]}”。${uniquePoint}。`, 180, ); return { name: anchorDraftTitle && anchorDraftTitle !== '未命名草稿' ? anchorDraftTitle : worldName, subtitle: clampText( [ buildCompactLabel(playerPremise, '玩家视角', 12), buildCompactLabel(coreConflicts[0] || '', '核心冲突', 16), ] .filter(Boolean) .join(' · '), 40, ) || '第一版世界底稿', summary, tone, playerGoal, majorFactions: factions.map((entry) => entry.name), coreConflicts, playableNpcs: characters, storyNpcs: [], landmarks, camp, themePack: null, storyGraph: null, factions, threads, chapters: [chapter], worldHook, playerPremise, openingSituation, iconicElements, sourceAnchorSummary: buildDraftSummaryFromEightAnchorContent(normalizedAnchorContent) || toText(anchorPack?.creatorIntentSummary) || buildDraftSummaryFromIntent(intent) || summary, }; } async generate(params: { creatorIntent: unknown; anchorPack: unknown; anchorContent?: EightAnchorContent | null; signal?: AbortSignal; onProgress?: DraftProgressCallback; }): Promise { const intent = normalizeCreatorIntentRecord(params.creatorIntent) ?? buildCreatorIntentFromEightAnchorContent( normalizeEightAnchorContent(params.anchorContent), ); if (!this.llmClient || !intent) { return this.generateFallbackDraft(params); } return buildFoundationDraftProfileWithLlm({ llmClient: this.llmClient, creatorIntent: intent, anchorPack: params.anchorPack, anchorContent: params.anchorContent, signal: params.signal, onProgress: params.onProgress, }); } }