Files
Genarrative/src/services/customWorld.ts
高物 bd9fdcbe31
Some checks failed
CI / verify (push) Has been cancelled
Implement scene-based chapter quest progression
2026-04-08 11:58:47 +08:00

2214 lines
89 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 条 connectionsrelativePosition 只能使用:${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 个 chapterschapters.affinityRequired 固定为 ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.join('、')}`,
'skills 默认补成 3 个对象,每个对象包含 name、summary、styleinitialItems 默认补成 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('');
}