646 lines
32 KiB
TypeScript
646 lines
32 KiB
TypeScript
import type {
|
||
CustomWorldGenerationFramework,
|
||
CustomWorldGenerationLandmarkOutline,
|
||
CustomWorldGenerationRoleBatchStage,
|
||
CustomWorldGenerationRoleBatchType,
|
||
CustomWorldGenerationRoleOutline,
|
||
CustomWorldLandmark,
|
||
CustomWorldProfile,
|
||
} from '../modules/custom-world/runtimeTypes.js';
|
||
|
||
const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10;
|
||
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES = [15, 30, 60, 90] as const;
|
||
const CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS = [
|
||
'forward',
|
||
'back',
|
||
'left',
|
||
'right',
|
||
'north',
|
||
'south',
|
||
'east',
|
||
'west',
|
||
'up',
|
||
'down',
|
||
'inside',
|
||
'outside',
|
||
'portal',
|
||
] as const;
|
||
|
||
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<string, string[]>();
|
||
|
||
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<string, string[]>();
|
||
|
||
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 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 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.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] ?? '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 所表达的角色定位,不要改名,不要改阵营。',
|
||
'- backstory 必须写出角色和当前世界的具体关系,至少落到一个势力、一个地点、一个正在发生的局势变化,不要只写抽象气质或泛泛成长史。',
|
||
'- personality 不能只写单个形容词,要体现角色在这个世界里的处事习惯、应对压力的方式和与人相处的锋面。',
|
||
'- motivation 必须是“此刻正在推动角色行动”的现实目标,而不是空泛理想;它要和玩家目标、核心冲突或开局处境形成直接拉扯。',
|
||
'- combatStyle 要体现角色为什么会这样战斗,它最好能反映其身份、经历、所属势力或长期栖身的场景环境。',
|
||
roleType === 'story'
|
||
? '- 怪物型场景角色要在 backstory 或 combatStyle 中直接写出怪物特征、栖息环境或攻击方式。'
|
||
: '- 可扮演角色要体现成长空间、协作价值和明确的入队理由。',
|
||
'- 所有生成文本都必须使用中文。',
|
||
'- 每个字符串尽量简洁但不能空泛:backstory/personality/motivation/combatStyle 控制在 18 到 56 个汉字内。',
|
||
'- 返回前自检:必须是一个能被 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 的 4 章必须形成明显递进:第 1 章写表层来意与第一印象,第 2 章写旧伤或代价,第 3 章写角色真正隐瞒的线索,第 4 章写最终底牌或不可回避的真相。',
|
||
'- 每一章都必须紧贴当前世界设定,至少落到具体势力、地点、事件、制度、禁忌或关系链中的一项,不要写成可套用到任何世界的空泛心情。',
|
||
'- teaser 必须像“继续相处后能戳到的钩子”,content 必须像“真正解锁后得到的新信息”,contextSnippet 必须可直接被后续剧情复用,三者不要只是同一句话改写。',
|
||
'- skills 不只是职业标签,要体现角色的个人经历、所属阵营、地理环境或禁忌系统影响,尽量写出这个世界独有的招式语感。',
|
||
'- initialItems 不只是常规装备清单,至少要有一件能反映角色背景、关系或任务压力的私人物件。',
|
||
`- 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 控制在 14 到 36 个汉字内,backstoryReveal.teaser 控制在 12 到 28 个汉字内,backstoryReveal.content 控制在 20 到 64 个汉字内,contextSnippet 控制在 12 到 36 个汉字内,skills.summary 和 initialItems.description 控制在 12 到 32 个汉字内。',
|
||
'- 返回前自检:必须是一个能被 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');
|
||
}
|
||
|
||
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<CustomWorldLandmark, 'name' | 'description' | 'dangerLevel'>,
|
||
userPrompt = '',
|
||
options: {
|
||
hasReferenceImage?: boolean;
|
||
} = {},
|
||
) {
|
||
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 requestedVisual = clampSceneImageText(userPrompt, 120);
|
||
const dangerMood = describeDangerLevel(landmark.dangerLevel);
|
||
|
||
return [
|
||
'为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。',
|
||
'画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。',
|
||
'下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。',
|
||
'下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。',
|
||
'下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。',
|
||
options.hasReferenceImage
|
||
? '已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。'
|
||
: '',
|
||
`世界:${worldName}${worldSubtitle ? `,${worldSubtitle}` : ''}。`,
|
||
worldSetting ? `玩家设定:${worldSetting}。` : '',
|
||
worldSummary ? `世界概述:${worldSummary}。` : '',
|
||
worldTone ? `整体基调:${worldTone}。` : '',
|
||
worldGoal ? `玩家目标关联:${worldGoal}。` : '',
|
||
`场景名称:${landmarkName}。`,
|
||
landmarkDescription ? `场景描述:${landmarkDescription}。` : '',
|
||
requestedVisual ? `本次想要生成的画面内容:${requestedVisual}。` : '',
|
||
`${dangerMood}。`,
|
||
'不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。',
|
||
]
|
||
.filter(Boolean)
|
||
.join('');
|
||
}
|