import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { badRequest } from '../errors.js'; import { buildLandmarkPrompt, buildPlayablePrompt, buildStoryPrompt, CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT, } from '../prompts/customWorldEntityPrompts.js'; import type { UpstreamLlmClient } from './llmClient.js'; type CustomWorldEntityKind = 'playable' | 'story' | 'landmark'; type GenerateCustomWorldEntityInput = { profile: Record; kind: CustomWorldEntityKind; }; type ParsedRole = { id: string; name: string; title: string; role: string; description: string; visualDescription: string; actionDescription: string; sceneVisualDescription: string; backstory: string; personality: string; motivation: string; combatStyle: string; initialAffinity: number; relationshipHooks: string[]; tags: string[]; }; type ParsedLandmarkConnection = { targetLandmarkId: string; summary: string; relativePosition: string; }; type ParsedLandmark = { id: string; name: string; description: string; visualDescription: string; dangerLevel: string; sceneNpcIds: string[]; connections: ParsedLandmarkConnection[]; }; type ParsedProfile = { name: string; settingText: string; summary: string; tone: string; playerGoal: string; playableNpcs: ParsedRole[]; storyNpcs: ParsedRole[]; landmarks: ParsedLandmark[]; }; const BACKSTORY_CHAPTERS = [ { id: 'surface', title: '表层来意', affinityRequired: 6 }, { id: 'scar', title: '旧事裂痕', affinityRequired: 12 }, { id: 'hidden', title: '隐藏执念', affinityRequired: 18 }, { id: 'final', title: '最终底牌', affinityRequired: 24 }, ] as const; const ROLE_SURNAME_POOL = [ '沈', '顾', '裴', '闻', '纪', '苏', '岑', '陆', '白', '商', '温', '严', '黎', '季', ] as const; const ROLE_GIVEN_POOL = [ '砺', '岚', '澄', '栖', '弦', '朔', '遥', '霁', '衡', '铃', '潮', '燧', '宁', '鸢', ] as const; const PLAYABLE_ROLE_POOL = [ '同行策士', '前线斥候', '旧誓护卫', '异闻译者', '禁制解读者', '地脉向导', ] as const; const STORY_ROLE_POOL = [ '守望者', '情报掮客', '巡夜人', '渡口看守', '旧案证人', '异类来客', ] as const; const LANDMARK_PREFIX_POOL = [ '潮碑', '沉钟', '雾湾', '灰塔', '回潮', '旧航', '断潮', '盐火', ] as const; const LANDMARK_SUFFIX_POOL = [ '前哨', '档案楼', '栈桥', '工坊', '集市', '观测台', '驿站', '藏书阁', ] as const; function toRecord(value: unknown) { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } function toText(value: unknown, fallback = '') { return typeof value === 'string' && value.trim() ? value.trim() : fallback; } function toStringArray(value: unknown, maxCount = 12) { if (!Array.isArray(value)) { return []; } return value .map((item) => toText(item)) .filter(Boolean) .slice(0, maxCount); } 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 createStableId(prefix: string, label: string, seed: string) { return `${prefix}-${slugify(label || prefix)}-${seed}`; } function dedupeStrings(values: string[], maxCount = 8) { return [...new Set(values.map((value) => value.trim()).filter(Boolean))].slice( 0, maxCount, ); } function extractJsonPayload(content: string) { const fencedMatch = content.match(/```(?:json)?\s*([\s\S]+?)\s*```/u); if (fencedMatch?.[1]) { return fencedMatch[1].trim(); } const objectStart = content.indexOf('{'); const objectEnd = content.lastIndexOf('}'); if (objectStart >= 0 && objectEnd > objectStart) { return content.slice(objectStart, objectEnd + 1); } return content.trim(); } function normalizeRole(value: unknown): ParsedRole | null { const record = toRecord(value); if (!record) { return null; } const id = toText(record.id); const name = toText(record.name); if (!id || !name) { return null; } const title = toText(record.title); const role = toText(record.role, title || '角色'); return { id, name, title: title || role || '角色', role, description: toText(record.description), visualDescription: toText(record.visualDescription), actionDescription: toText(record.actionDescription), sceneVisualDescription: toText(record.sceneVisualDescription), backstory: toText(record.backstory), personality: toText(record.personality), motivation: toText(record.motivation), combatStyle: toText(record.combatStyle), initialAffinity: typeof record.initialAffinity === 'number' && Number.isFinite(record.initialAffinity) ? Math.round(record.initialAffinity) : 0, relationshipHooks: toStringArray(record.relationshipHooks, 6), tags: toStringArray(record.tags, 8), }; } function normalizeLandmark(value: unknown): ParsedLandmark | null { const record = toRecord(value); if (!record) { return null; } const id = toText(record.id); const name = toText(record.name); if (!id || !name) { return null; } const connections = Array.isArray(record.connections) ? record.connections .map((item) => { const connection = toRecord(item); if (!connection) { return null; } const targetLandmarkId = toText(connection.targetLandmarkId); if (!targetLandmarkId) { return null; } return { targetLandmarkId, summary: toText(connection.summary), relativePosition: toText(connection.relativePosition, 'forward'), } satisfies ParsedLandmarkConnection; }) .filter( (item): item is ParsedLandmarkConnection => item !== null, ) .slice(0, 8) : []; return { id, name, description: toText(record.description), visualDescription: toText(record.visualDescription), dangerLevel: toText(record.dangerLevel, 'medium'), sceneNpcIds: toStringArray(record.sceneNpcIds, 12), connections, }; } function normalizeProfile(value: unknown): ParsedProfile { const record = toRecord(value); if (!record) { throw badRequest('profile is required'); } return { name: toText(record.name, '自定义世界'), settingText: toText(record.settingText), summary: toText(record.summary), tone: toText(record.tone), playerGoal: toText(record.playerGoal), playableNpcs: Array.isArray(record.playableNpcs) ? record.playableNpcs .map(normalizeRole) .filter((item): item is ParsedRole => item !== null) : [], storyNpcs: Array.isArray(record.storyNpcs) ? record.storyNpcs .map(normalizeRole) .filter((item): item is ParsedRole => item !== null) : [], landmarks: Array.isArray(record.landmarks) ? record.landmarks .map(normalizeLandmark) .filter((item): item is ParsedLandmark => item !== null) : [], }; } function buildUniqueRoleName(existingNames: Set, startIndex: number) { for (let attempt = 0; attempt < 120; attempt += 1) { const index = startIndex + attempt; const surname = ROLE_SURNAME_POOL[index % ROLE_SURNAME_POOL.length]; const firstName = ROLE_GIVEN_POOL[ Math.floor(index / ROLE_SURNAME_POOL.length) % ROLE_GIVEN_POOL.length ]; const secondName = ROLE_GIVEN_POOL[(index + 5) % ROLE_GIVEN_POOL.length]; const candidate = `${surname}${firstName}${secondName}`; if (!existingNames.has(candidate)) { existingNames.add(candidate); return candidate; } } const fallback = `新角色${existingNames.size + 1}`; existingNames.add(fallback); return fallback; } function buildUniqueLandmarkName(existingNames: Set, startIndex: number) { for (let attempt = 0; attempt < 120; attempt += 1) { const index = startIndex + attempt; const candidate = `${LANDMARK_PREFIX_POOL[index % LANDMARK_PREFIX_POOL.length]}${ LANDMARK_SUFFIX_POOL[ Math.floor(index / LANDMARK_PREFIX_POOL.length) % LANDMARK_SUFFIX_POOL.length ] }`; if (!existingNames.has(candidate)) { existingNames.add(candidate); return candidate; } } const fallback = `新场景${existingNames.size + 1}`; existingNames.add(fallback); return fallback; } function buildFallbackRoleDraft( profile: ParsedProfile, kind: 'playable' | 'story', ) { const existingNames = new Set( [...profile.playableNpcs, ...profile.storyNpcs].map((role) => role.name), ); const name = buildUniqueRoleName(existingNames, existingNames.size + 1); const roleTitlePool = kind === 'playable' ? PLAYABLE_ROLE_POOL : STORY_ROLE_POOL; const role = roleTitlePool[existingNames.size % roleTitlePool.length]; const relationHook = kind === 'playable' ? `愿意与玩家共同推进“${profile.playerGoal || profile.summary || profile.name}”` : `与“${profile.playerGoal || profile.summary || profile.name}”这条局势线直接相关`; return { name, title: role, role, description: clampText( kind === 'playable' ? `适合与玩家同行,能补足当前队伍短板的关键角色。` : `长期活跃于当前世界暗面,能补足场景视角的关键角色。`, 60, ), visualDescription: '', actionDescription: '', sceneVisualDescription: '', backstory: clampText( `他与${profile.name}当前正在扩张的冲突链紧密相连,知道一些还未公开的内情。`, 80, ), personality: kind === 'playable' ? '克制、敏锐,擅长合作推进。' : '谨慎、耐心,擅长观察局势。', motivation: clampText( kind === 'playable' ? `希望借玩家的选择改变当前世界里已经失衡的局面。` : `想借玩家的介入撬动一条已经僵住的关系链。`, 72, ), combatStyle: kind === 'playable' ? '偏向协作压制与局势调度。' : '偏向试探牵制与环境借势。', initialAffinity: kind === 'playable' ? 22 : 6, relationshipHooks: dedupeStrings( [relationHook, profile.landmarks[0]?.name ? `常在${profile.landmarks[0].name}附近活动` : '', profile.playableNpcs[0]?.name ? `会先试探${profile.playableNpcs[0].name}与玩家的关系` : '会先试探玩家立场'], 3, ), tags: dedupeStrings( [profile.name, profile.tone, kind === 'playable' ? '可同行' : '场景线'], 4, ), publicSummary: kind === 'playable' ? '一名能与玩家形成互补的新同行者。' : '一名能补足当前场景关系网的新角色。', chapterTeasers: [ '他出现得并不偶然。', '他与旧局势之间有一道未说透的裂痕。', '他真正站队的理由比表面更复杂。', '他留着最后一张不会轻易掀开的牌。', ], chapterContents: [ `他在${profile.name}当前局势里早就留下了自己的位置。`, '一段旧事让他无法再把自己完全抽离出去。', '他真正想守住的并不是表面上说出口的东西。', '一旦走到临界点,他会把最关键的底牌押上桌面。', ], skills: [ { name: kind === 'playable' ? '协作先手' : '观察起手', summary: '先稳住局面,再把主动权拉回自己手里。', style: '起手压制', }, { name: kind === 'playable' ? '阵线补位' : '地形借势', summary: '借助环境和站位撕开短暂缺口。', style: '机动周旋', }, { name: kind === 'playable' ? '压轴回合' : '暗线反制', summary: '在关键回合揭出隐藏准备,改变节奏。', style: '爆发终结', }, ], initialItems: [ { name: kind === 'playable' ? '随身兵装' : '私藏器具', category: '武器', quantity: 1, rarity: 'rare' as const, description: '与其身份长期绑定的常备装备。', tags: ['自定义'], }, { name: kind === 'playable' ? '路书残页' : '情报残页', category: '专属物品', quantity: 1, rarity: 'rare' as const, description: '记录着只属于他自己的线索与判断。', tags: ['线索'], }, { name: '应急补给', category: '消耗品', quantity: 2, rarity: 'uncommon' as const, description: '面对突发局势时会先拿出来的保底物资。', tags: ['备用'], }, ], }; } function buildFallbackLandmarkDraft(profile: ParsedProfile) { const existingNames = new Set(profile.landmarks.map((landmark) => landmark.name)); const name = buildUniqueLandmarkName(existingNames, profile.landmarks.length + 1); const sceneNpcNames = profile.storyNpcs.slice(0, 3).map((npc) => npc.name); const targetLandmarkNames = profile.landmarks.slice(0, 2).map((landmark) => landmark.name); return { name, description: clampText( `承接${profile.name}当前主冲突的一处关键新场景,适合继续向外扩张世界关系网。`, 72, ), visualDescription: '', dangerLevel: 'medium', sceneNpcNames, connections: targetLandmarkNames.map((targetLandmarkName, index) => ({ targetLandmarkName, relativePosition: index === 0 ? 'forward' : 'inside', summary: index === 0 ? `沿主路可抵达${targetLandmarkName}` : `可从暗线进入${targetLandmarkName}`, })), }; } function ensureUniqueName(name: string, existingNames: string[], fallbackName: string) { const normalized = name.trim() || fallbackName; if (!existingNames.includes(normalized)) { return normalized; } let index = 2; let nextName = `${normalized}${index}`; while (existingNames.includes(nextName)) { index += 1; nextName = `${normalized}${index}`; } return nextName; } function sanitizeGeneratedRole( rawValue: unknown, profile: ParsedProfile, kind: 'playable' | 'story', ) { const record = toRecord(rawValue); const fallbackDraft = buildFallbackRoleDraft(profile, kind); const existingNames = [...profile.playableNpcs, ...profile.storyNpcs].map( (role) => role.name, ); const seed = Date.now().toString(36); const relationshipHooks = dedupeStrings( toStringArray(record?.relationshipHooks, 6).concat( fallbackDraft.relationshipHooks, ), 4, ); const tags = dedupeStrings( toStringArray(record?.tags, 8).concat(fallbackDraft.tags), 6, ); const chapterTeasers = toStringArray(record?.chapterTeasers, 4); const chapterContents = toStringArray(record?.chapterContents, 4); const skillRecords = Array.isArray(record?.skills) ? record.skills : []; const itemRecords = Array.isArray(record?.initialItems) ? record.initialItems : []; const name = ensureUniqueName( toText(record?.name, fallbackDraft.name), existingNames, fallbackDraft.name, ); return { id: createStableId( kind === 'playable' ? 'playable-npc' : 'story-npc', name, seed, ), name, title: clampText(toText(record?.title, fallbackDraft.title), 20), role: clampText( toText(record?.role, fallbackDraft.role || fallbackDraft.title), 20, ), description: clampText( toText(record?.description, fallbackDraft.description), 120, ), visualDescription: clampText( toText(record?.visualDescription, fallbackDraft.visualDescription), 180, ), actionDescription: clampText( toText(record?.actionDescription, fallbackDraft.actionDescription), 140, ), sceneVisualDescription: clampText( toText( record?.sceneVisualDescription, fallbackDraft.sceneVisualDescription, ), 180, ), backstory: clampText(toText(record?.backstory, fallbackDraft.backstory), 260), personality: clampText( toText(record?.personality, fallbackDraft.personality), 120, ), motivation: clampText( toText(record?.motivation, fallbackDraft.motivation), 120, ), combatStyle: clampText( toText(record?.combatStyle, fallbackDraft.combatStyle), 120, ), initialAffinity: typeof record?.initialAffinity === 'number' && Number.isFinite(record.initialAffinity) ? Math.round( Math.max( kind === 'playable' ? 12 : -40, Math.min(90, record.initialAffinity), ), ) : fallbackDraft.initialAffinity, relationshipHooks, relations: [], tags, backstoryReveal: { publicSummary: clampText( toText(record?.publicSummary, fallbackDraft.publicSummary), 120, ), chapters: BACKSTORY_CHAPTERS.map((chapter, index) => ({ id: chapter.id, title: chapter.title, affinityRequired: chapter.affinityRequired, teaser: chapterTeasers[index] ?? fallbackDraft.chapterTeasers[index] ?? fallbackDraft.chapterTeasers[0], content: chapterContents[index] ?? fallbackDraft.chapterContents[index] ?? fallbackDraft.chapterContents[0], contextSnippet: clampText( chapterContents[index] ?? fallbackDraft.chapterContents[index] ?? fallbackDraft.chapterContents[0], 36, ), })), }, skills: skillRecords.length >= 3 ? skillRecords.slice(0, 3).map((skill, index) => { const skillRecord = toRecord(skill); const fallbackSkill = fallbackDraft.skills[index] ?? fallbackDraft.skills[0]; return { id: createStableId( 'skill', `${name}-${toText(skillRecord?.name, fallbackSkill.name)}`, `${seed}-${index + 1}`, ), name: clampText( toText(skillRecord?.name, fallbackSkill.name), 20, ), summary: clampText( toText(skillRecord?.summary, fallbackSkill.summary), 60, ), style: clampText( toText(skillRecord?.style, fallbackSkill.style), 20, ), }; }) : fallbackDraft.skills.map((skill, index) => ({ id: createStableId('skill', `${name}-${skill.name}`, `${seed}-${index + 1}`), name: skill.name, summary: skill.summary, style: skill.style, })), initialItems: itemRecords.length >= 3 ? itemRecords.slice(0, 3).map((item, index) => { const itemRecord = toRecord(item); const fallbackItem = fallbackDraft.initialItems[index] ?? fallbackDraft.initialItems[0]; const rarity = toText(itemRecord?.rarity, fallbackItem.rarity); return { id: createStableId( 'item', `${name}-${toText(itemRecord?.name, fallbackItem.name)}`, `${seed}-${index + 1}`, ), name: clampText(toText(itemRecord?.name, fallbackItem.name), 20), category: clampText( toText(itemRecord?.category, fallbackItem.category), 16, ), quantity: typeof itemRecord?.quantity === 'number' && Number.isFinite(itemRecord.quantity) ? Math.max(1, Math.min(9, Math.round(itemRecord.quantity))) : fallbackItem.quantity, rarity: rarity === 'common' || rarity === 'uncommon' || rarity === 'rare' || rarity === 'epic' || rarity === 'legendary' ? rarity : fallbackItem.rarity, description: clampText( toText(itemRecord?.description, fallbackItem.description), 80, ), tags: dedupeStrings( toStringArray(itemRecord?.tags, 4).concat(fallbackItem.tags), 4, ), }; }) : fallbackDraft.initialItems.map((item, index) => ({ id: createStableId('item', `${name}-${item.name}`, `${seed}-${index + 1}`), name: item.name, category: item.category, quantity: item.quantity, rarity: item.rarity, description: item.description, tags: item.tags, })), }; } function sanitizeGeneratedLandmark(rawValue: unknown, profile: ParsedProfile) { const record = toRecord(rawValue); const fallbackDraft = buildFallbackLandmarkDraft(profile); const existingNames = profile.landmarks.map((landmark) => landmark.name); const name = ensureUniqueName( toText(record?.name, fallbackDraft.name), existingNames, fallbackDraft.name, ); const seed = Date.now().toString(36); const storyNpcByName = new Map( profile.storyNpcs.map((npc) => [npc.name.trim(), npc.id]), ); const landmarkByName = new Map( profile.landmarks.map((landmark) => [landmark.name.trim(), landmark.id]), ); const rawSceneNpcNames = toStringArray(record?.sceneNpcNames, 12); const rawConnections = Array.isArray(record?.connections) ? record.connections : []; const resolvedSceneNpcIds = dedupeStrings( rawSceneNpcNames .map((npcName) => storyNpcByName.get(npcName.trim()) ?? '') .concat( fallbackDraft.sceneNpcNames .map((npcName) => storyNpcByName.get(npcName.trim()) ?? '') .filter(Boolean), ), 3, ); const fallbackSceneNpcIds = dedupeStrings( profile.storyNpcs.slice(0, 3).map((npc) => npc.id), 3, ); const sceneNpcIds = resolvedSceneNpcIds.length >= 3 ? resolvedSceneNpcIds : fallbackSceneNpcIds; const connections = rawConnections .map((item, index) => { const connection = toRecord(item); if (!connection) { return null; } const targetLandmarkId = landmarkByName.get(toText(connection.targetLandmarkName)) ?? landmarkByName.get(toText(connection.targetLandmarkId)) ?? ''; if (!targetLandmarkId) { return null; } return { targetLandmarkId, relativePosition: toText( connection.relativePosition, index === 0 ? 'forward' : 'inside', ), summary: clampText( toText( connection.summary, fallbackDraft.connections[index]?.summary || '可通往相邻区域', ), 24, ), }; }) .filter((item): item is ParsedLandmarkConnection => item !== null) .filter((item) => item.targetLandmarkId); const fallbackConnections = fallbackDraft.connections .map((connection) => { const targetLandmarkId = landmarkByName.get(connection.targetLandmarkName.trim()) ?? ''; if (!targetLandmarkId) { return null; } return { targetLandmarkId, relativePosition: connection.relativePosition, summary: connection.summary, } satisfies ParsedLandmarkConnection; }) .filter((item): item is ParsedLandmarkConnection => item !== null); return { id: createStableId('landmark', name, seed), name, description: clampText( toText(record?.description, fallbackDraft.description), 140, ), visualDescription: clampText( toText(record?.visualDescription, fallbackDraft.visualDescription), 180, ), dangerLevel: (() => { const level = toText(record?.dangerLevel, fallbackDraft.dangerLevel); return level === 'low' || level === 'medium' || level === 'high' || level === 'extreme' ? level : 'medium'; })(), sceneNpcIds, connections: (connections.length > 0 ? connections : fallbackConnections).slice(0, 3), }; } async function requestGeneratedEntity( llmClient: UpstreamLlmClient, kind: CustomWorldEntityKind, profile: ParsedProfile, ) { const userPrompt = kind === 'playable' ? buildPlayablePrompt(profile) : kind === 'story' ? buildStoryPrompt(profile) : buildLandmarkPrompt(profile); const content = await llmClient.requestMessageContent({ systemPrompt: CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT, userPrompt, timeoutMs: 45000, debugLabel: `custom-world-generate-${kind}`, }); return parseJsonResponseText(extractJsonPayload(content)); } export async function generateCustomWorldEntity( llmClient: UpstreamLlmClient, input: GenerateCustomWorldEntityInput, ) { const profile = normalizeProfile(input.profile); try { const parsed = await requestGeneratedEntity(llmClient, input.kind, profile); const record = toRecord(parsed); if (input.kind === 'playable') { return { kind: 'playable' as const, entity: sanitizeGeneratedRole(record?.playableNpc ?? parsed, profile, 'playable'), }; } if (input.kind === 'story') { return { kind: 'story' as const, entity: sanitizeGeneratedRole(record?.storyNpc ?? parsed, profile, 'story'), }; } return { kind: 'landmark' as const, entity: sanitizeGeneratedLandmark(record?.landmark ?? parsed, profile), }; } catch { if (input.kind === 'playable') { return { kind: 'playable' as const, entity: sanitizeGeneratedRole(null, profile, 'playable'), }; } if (input.kind === 'story') { return { kind: 'story' as const, entity: sanitizeGeneratedRole(null, profile, 'story'), }; } return { kind: 'landmark' as const, entity: sanitizeGeneratedLandmark(null, profile), }; } }