Files
Genarrative/src/services/customWorld.ts
2026-04-28 20:25:37 +08:00

1539 lines
49 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 type { EightAnchorContent } from '../../packages/shared/src/contracts/customWorldAgent';
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,
normalizeCustomWorldSceneRelativePosition,
normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph';
import {
ActorNarrativeProfile,
AnimationState,
CharacterAnimationConfig,
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldAnchorPack,
CustomWorldCampScene,
CustomWorldCoverProfile,
CustomWorldCoverSourceType,
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 { normalizeCustomWorldOwnedSettingLayers } from './customWorldOwnedSettingLayers';
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;
const CUSTOM_WORLD_ANIMATION_STATES = new Set<AnimationState>(
Object.values(AnimationState),
);
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;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
initialAffinity: number;
relationshipHooks: string[];
tags: string[];
}
export interface CustomWorldGenerationLandmarkConnectionOutline {
targetLandmarkName: string;
relativePosition: string;
summary: string;
}
export interface CustomWorldGenerationLandmarkOutline {
name: string;
description: string;
visualDescription?: string;
actNPCNames: string[];
sceneNpcNames?: string[];
connections: CustomWorldGenerationLandmarkConnectionOutline[];
}
export interface CustomWorldGenerationCampOutline {
id?: string;
name: string;
description: string;
visualDescription?: string;
imageSrc?: string;
sceneNpcIds?: string[];
sceneNpcNames?: string[];
connections?: CustomWorldGenerationLandmarkConnectionOutline[];
}
export interface CustomWorldGenerationFramework {
settingText: string;
name: string;
subtitle: string;
summary: string;
tone: string;
playerGoal: string;
templateWorldType: WorldType;
compatibilityTemplateWorldType: 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 toFiniteInteger(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? Math.round(value)
: undefined;
}
function toFiniteNumber(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
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 normalizeGeneratedAnimationConfig(
value: unknown,
): CharacterAnimationConfig | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const folder = toText(item.folder);
const prefix = toText(item.prefix);
const frames = Math.max(1, toFiniteInteger(item.frames) ?? 0);
if (!folder || !prefix || frames <= 0) {
return null;
}
const startFrame = toFiniteInteger(item.startFrame);
const extension = toText(item.extension);
const file = toText(item.file);
const basePath = toText(item.basePath);
const frameWidth = toFiniteInteger(item.frameWidth);
const frameHeight = toFiniteInteger(item.frameHeight);
const fps = toFiniteNumber(item.fps);
const loop = typeof item.loop === 'boolean' ? item.loop : undefined;
const previewVideoPath = toText(item.previewVideoPath);
return {
folder,
prefix,
frames,
...(startFrame ? { startFrame: Math.max(1, startFrame) } : {}),
...(extension ? { extension } : {}),
...(file ? { file } : {}),
...(basePath ? { basePath } : {}),
...(frameWidth ? { frameWidth: Math.max(1, frameWidth) } : {}),
...(frameHeight ? { frameHeight: Math.max(1, frameHeight) } : {}),
...(fps ? { fps: Math.max(1, fps) } : {}),
...(typeof loop === 'boolean' ? { loop } : {}),
...(previewVideoPath ? { previewVideoPath } : {}),
};
}
function normalizeGeneratedAnimationMap(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const entries = Object.entries(value).flatMap(([key, rawConfig]) => {
if (!CUSTOM_WORLD_ANIMATION_STATES.has(key as AnimationState)) {
return [];
}
const config = normalizeGeneratedAnimationConfig(rawConfig);
return config ? [[key as AnimationState, config] as const] : [];
});
return entries.length > 0
? (Object.fromEntries(entries) as Partial<
Record<AnimationState, CharacterAnimationConfig>
>)
: undefined;
}
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 toUniqueStringArray(value: unknown, nestedKey?: string) {
return [...new Set(toStringArray(value, nestedKey))];
}
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,
});
const baseProfile = {
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
settingText: settingText.trim(),
name,
subtitle,
summary,
tone,
playerGoal,
cover: buildDefaultCustomWorldCover([]),
templateWorldType,
compatibilityTemplateWorldType: 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',
} satisfies CustomWorldProfile;
return {
...baseProfile,
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
null,
baseProfile,
),
};
}
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,
compatibilityTemplateWorldType:
fallback.compatibilityTemplateWorldType ?? fallback.templateWorldType,
majorFactions: [],
coreConflicts: [fallback.summary],
camp: {
name: fallback.camp?.name ?? '归舍',
description: fallback.camp?.description ?? '',
},
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,
compatibilityTemplateWorldType: 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,
compatibilityTemplateWorldType: framework.compatibilityTemplateWorldType,
majorFactions: framework.majorFactions,
coreConflicts: framework.coreConflicts,
camp: {
name: framework.camp.name,
description: framework.camp.description,
},
playableNpcs: framework.playableNpcs.map((npc) => ({
name: npc.name,
title: npc.title,
role: npc.role,
description: npc.description,
visualDescription: npc.visualDescription,
actionDescription: npc.actionDescription,
sceneVisualDescription: npc.sceneVisualDescription,
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,
visualDescription: npc.visualDescription,
actionDescription: npc.actionDescription,
sceneVisualDescription: npc.sceneVisualDescription,
initialAffinity: npc.initialAffinity,
relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags],
})),
landmarks: framework.landmarks.map((landmark) => ({
name: landmark.name,
description: landmark.description,
visualDescription: landmark.visualDescription,
actNPCNames: [...landmark.actNPCNames],
sceneNpcNames: [...landmark.actNPCNames],
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),
visualDescription: toText(item.visualDescription),
actionDescription: toText(item.actionDescription),
sceneVisualDescription: toText(item.sceneVisualDescription),
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,
),
imageSrc: toText(item.imageSrc) || undefined,
generatedVisualAssetId: toText(item.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(item.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(item.animationMap),
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,
}),
}))
.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 normalizeCustomWorldCoverCharacterRoleIds(
value: unknown,
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
) {
const availableIds = new Set(
playableNpcs.map((entry) => entry.id.trim()).filter(Boolean),
);
const selectedIds = Array.isArray(value)
? [
...new Set(
value
.map((entry) => toText(entry))
.filter((entry) => entry && availableIds.has(entry)),
),
].slice(0, 3)
: [];
if (selectedIds.length > 0) {
return selectedIds;
}
return playableNpcs
.map((entry) => entry.id.trim())
.filter(Boolean)
.slice(0, 3);
}
function buildDefaultCustomWorldCover(
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
): CustomWorldCoverProfile {
return {
sourceType: 'default' as const,
imageSrc: null,
characterRoleIds: normalizeCustomWorldCoverCharacterRoleIds(
undefined,
playableNpcs,
),
};
}
function normalizeCustomWorldCover(
value: unknown,
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
): CustomWorldCoverProfile {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return buildDefaultCustomWorldCover(playableNpcs);
}
const item = value as Record<string, unknown>;
const sourceType: CustomWorldCoverSourceType =
item.sourceType === 'uploaded' || item.sourceType === 'generated'
? item.sourceType
: 'default';
const imageSrc = toText(item.imageSrc) || null;
if (sourceType !== 'default' && imageSrc) {
return {
sourceType,
imageSrc,
characterRoleIds: [],
};
}
return buildDefaultCustomWorldCover(playableNpcs);
}
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),
visualDescription: toText(item.visualDescription) || undefined,
actionDescription: toText(item.actionDescription) || undefined,
sceneVisualDescription: toText(item.sceneVisualDescription) || undefined,
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 {
id: toText(item.id) || fallback.id,
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
visualDescription: toText(item.visualDescription) || undefined,
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
sceneNpcNames: toUniqueStringArray([
...toStringArray(item.actNPCNames),
...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),
};
}
function normalizeLandmarkOutlineList(value: unknown) {
return toRecordArray(value)
.map((item) => {
const name = toText(item.name);
return {
name,
description:
toText(item.description) ||
truncateText(`${name}暗藏新的局势变化。`, 40),
visualDescription: toText(item.visualDescription) || undefined,
actNPCNames: toStringArray([
...toStringArray(item.actNPCNames),
...toStringArray(item.sceneNpcNames),
...toStringArray(item.npcs, 'name'),
...toStringArray(item.sceneNpcs, 'name'),
...toStringArray(item.npcNames),
]),
sceneNpcNames: toUniqueStringArray([
...toStringArray(item.actNPCNames),
...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),
visualDescription: toText(item.visualDescription) || undefined,
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
sceneNpcNames: toUniqueStringArray([
...toStringArray(item.actNPCNames),
...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 {
id: toText(item.id) || fallback.id,
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
visualDescription: toText(item.visualDescription) || undefined,
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkId: toText(connection.targetLandmarkId),
relativePosition: normalizeCustomWorldSceneRelativePosition(
toText(connection.relativePosition) || toText(connection.position) || 'forward',
),
summary: toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkId),
narrativeResidues: null,
};
}
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,
});
const normalizedProfile = {
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,
cover: normalizeCustomWorldCover(item.cover, playableNpcs),
templateWorldType,
compatibilityTemplateWorldType: 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,
anchorContent:
item.anchorContent && typeof item.anchorContent === 'object'
? (item.anchorContent as EightAnchorContent)
: 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,
} satisfies CustomWorldProfile;
return {
...normalizedProfile,
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
item.ownedSettingLayers,
normalizedProfile,
),
};
}
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 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.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).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}」存在无效的目标场景连接。`);
}
});
}