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