@@ -10,6 +10,8 @@ import type {
|
||||
CustomWorldFoundationDraftFaction,
|
||||
CustomWorldFoundationDraftLandmark,
|
||||
CustomWorldFoundationDraftProfile,
|
||||
CustomWorldFoundationDraftSceneAct,
|
||||
CustomWorldFoundationDraftSceneChapter,
|
||||
CustomWorldFoundationDraftThread,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
@@ -74,6 +76,39 @@ const EDITABLE_CAMP_SECTION_IDS = [
|
||||
'dangerLevel',
|
||||
] as const;
|
||||
|
||||
const EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS = [
|
||||
'title',
|
||||
'summary',
|
||||
] as const;
|
||||
|
||||
const SCENE_ACT_STAGE_ORDER = [
|
||||
'opening',
|
||||
'expansion',
|
||||
'turning_point',
|
||||
'climax',
|
||||
'aftermath',
|
||||
] as const;
|
||||
|
||||
const SCENE_ACT_STAGE_LABELS: Record<
|
||||
CustomWorldFoundationDraftSceneAct['stageCoverage'][number],
|
||||
string
|
||||
> = {
|
||||
opening: '开场',
|
||||
expansion: '铺展',
|
||||
turning_point: '转折',
|
||||
climax: '高潮',
|
||||
aftermath: '余波',
|
||||
};
|
||||
|
||||
const SCENE_ACT_ADVANCE_RULE_LABELS: Record<
|
||||
CustomWorldFoundationDraftSceneAct['advanceRule'],
|
||||
string
|
||||
> = {
|
||||
after_primary_contact: '主角色首次有效接触后推进',
|
||||
after_active_step_complete: '当前主动步骤完成后推进',
|
||||
after_chapter_resolution: '章节进入收束后推进',
|
||||
};
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
@@ -101,6 +136,28 @@ function toStringArray(value: unknown, maxCount = 8) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCharacterSkills(value: unknown, fallbackName: string) {
|
||||
const skills = toRecordArray(value)
|
||||
.map((item, index) => ({
|
||||
id: toText(item.id) || `skill-${index + 1}`,
|
||||
name: toText(item.name) || `技能${index + 1}`,
|
||||
actionPreviewConfig: toRecord(item.actionPreviewConfig),
|
||||
}))
|
||||
.filter((item) => Boolean(item.id));
|
||||
|
||||
if (skills.length > 0) {
|
||||
return skills;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: `${clampText(fallbackName, 10) || '角色'}招牌动作`,
|
||||
actionPreviewConfig: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
@@ -149,9 +206,40 @@ function resolveEditableSectionIds(kind: CustomWorldDraftCardKind) {
|
||||
if (kind === 'thread') return [...EDITABLE_THREAD_SECTION_IDS];
|
||||
if (kind === 'chapter') return [...EDITABLE_CHAPTER_SECTION_IDS];
|
||||
if (kind === 'camp') return [...EDITABLE_CAMP_SECTION_IDS];
|
||||
if (kind === 'scene_chapter') return [...EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS];
|
||||
return [];
|
||||
}
|
||||
|
||||
function resolveSceneChapterEditableSectionIds(
|
||||
sceneChapter: CustomWorldFoundationDraftSceneChapter,
|
||||
) {
|
||||
return [
|
||||
...EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS,
|
||||
...sceneChapter.acts.flatMap((act) => [
|
||||
`act:${act.id}:title`,
|
||||
`act:${act.id}:summary`,
|
||||
`act:${act.id}:backgroundImageSrc`,
|
||||
`act:${act.id}:encounterNpcIds`,
|
||||
`act:${act.id}:actGoal`,
|
||||
`act:${act.id}:transitionHook`,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveSceneActStageCoverageLabel(
|
||||
stageCoverage: CustomWorldFoundationDraftSceneAct['stageCoverage'],
|
||||
) {
|
||||
return stageCoverage
|
||||
.map((stage) => SCENE_ACT_STAGE_LABELS[stage] || stage)
|
||||
.join('、');
|
||||
}
|
||||
|
||||
function resolveSceneActAdvanceRuleLabel(
|
||||
advanceRule: CustomWorldFoundationDraftSceneAct['advanceRule'],
|
||||
) {
|
||||
return SCENE_ACT_ADVANCE_RULE_LABELS[advanceRule] || advanceRule;
|
||||
}
|
||||
|
||||
function normalizeFaction(
|
||||
value: unknown,
|
||||
index: number,
|
||||
@@ -243,6 +331,7 @@ function normalizeCharacter(
|
||||
].join(';'),
|
||||
120,
|
||||
),
|
||||
skills: normalizeCharacterSkills(record.skills, role || title || name || '角色'),
|
||||
imageSrc: toText(record.imageSrc) || null,
|
||||
generatedVisualAssetId: toText(record.generatedVisualAssetId) || null,
|
||||
generatedAnimationSetId: toText(record.generatedAnimationSetId) || null,
|
||||
@@ -287,6 +376,7 @@ function normalizeLandmark(
|
||||
importance: secret || '玩家第一次抵达就会意识到它不只是背景',
|
||||
secret: secret || '玩家第一次抵达就会意识到它不只是背景',
|
||||
dangerLevel: dangerLevel || '中',
|
||||
imageSrc: toText(record.imageSrc) || null,
|
||||
characterIds: toStringArray(record.characterIds, 8),
|
||||
threadIds: toStringArray(record.threadIds, 8),
|
||||
summary:
|
||||
@@ -410,6 +500,7 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null {
|
||||
description: description || '玩家暂时还能整顿情报和喘口气的地方',
|
||||
mood: dangerLevel || '克制、紧绷,但还能暂时收拢局势',
|
||||
dangerLevel: dangerLevel || '克制、紧绷,但还能暂时收拢局势',
|
||||
imageSrc: toText(record.imageSrc) || null,
|
||||
summary:
|
||||
summary ||
|
||||
clampText(
|
||||
@@ -422,6 +513,342 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStageCoverage(value: unknown) {
|
||||
const stageCoverage = Array.isArray(value)
|
||||
? value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is CustomWorldFoundationDraftSceneAct['stageCoverage'][number] =>
|
||||
SCENE_ACT_STAGE_ORDER.includes(
|
||||
entry as (typeof SCENE_ACT_STAGE_ORDER)[number],
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
return [...new Set(stageCoverage)];
|
||||
}
|
||||
|
||||
function buildFallbackSceneActStageCoverage(index: number, actCount: number) {
|
||||
if (actCount <= 2) {
|
||||
return index === 0
|
||||
? (['opening', 'expansion'] as CustomWorldFoundationDraftSceneAct['stageCoverage'])
|
||||
: (['turning_point', 'climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage']);
|
||||
}
|
||||
|
||||
if (actCount === 3) {
|
||||
if (index === 0) {
|
||||
return ['opening'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
|
||||
}
|
||||
if (index === 1) {
|
||||
return ['expansion', 'turning_point'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
|
||||
}
|
||||
return ['climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
|
||||
}
|
||||
|
||||
if (actCount === 4) {
|
||||
if (index === 0) return ['opening'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
|
||||
if (index === 1) return ['expansion'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
|
||||
if (index === 2) return ['turning_point'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
|
||||
return ['climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
|
||||
}
|
||||
|
||||
return [SCENE_ACT_STAGE_ORDER[Math.min(index, SCENE_ACT_STAGE_ORDER.length - 1)]];
|
||||
}
|
||||
|
||||
function normalizeSceneAct(
|
||||
value: unknown,
|
||||
index: number,
|
||||
fallback: {
|
||||
sceneId: string;
|
||||
sceneName: string;
|
||||
backgroundImageSrc?: string | null;
|
||||
encounterNpcIds: string[];
|
||||
linkedThreadIds: string[];
|
||||
actCount: number;
|
||||
},
|
||||
): CustomWorldFoundationDraftSceneAct | null {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = toText(record.title);
|
||||
const summary = toText(record.summary);
|
||||
const encounterNpcIds = toStringArray(
|
||||
record.encounterNpcIds,
|
||||
Math.max(1, fallback.encounterNpcIds.length || 8),
|
||||
);
|
||||
const stageCoverage = normalizeStageCoverage(record.stageCoverage);
|
||||
|
||||
if (!title && !summary && encounterNpcIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedEncounterNpcIds =
|
||||
encounterNpcIds.length > 0 ? encounterNpcIds : fallback.encounterNpcIds;
|
||||
const primaryNpcId = toText(record.primaryNpcId) || resolvedEncounterNpcIds[0] || '';
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(record.id) ||
|
||||
createId(`scene-act-${fallback.sceneId}`, title || fallback.sceneName, index),
|
||||
title: title || `第 ${index + 1} 幕`,
|
||||
summary:
|
||||
summary ||
|
||||
clampText(
|
||||
[
|
||||
title || `第 ${index + 1} 幕`,
|
||||
toText(record.actGoal) || '这一幕仍需继续精修',
|
||||
].join(';'),
|
||||
120,
|
||||
),
|
||||
stageCoverage:
|
||||
stageCoverage.length > 0
|
||||
? stageCoverage
|
||||
: buildFallbackSceneActStageCoverage(index, fallback.actCount),
|
||||
backgroundImageSrc:
|
||||
toText(record.backgroundImageSrc) || fallback.backgroundImageSrc || null,
|
||||
backgroundAssetId: toText(record.backgroundAssetId) || null,
|
||||
encounterNpcIds: resolvedEncounterNpcIds,
|
||||
primaryNpcId,
|
||||
linkedThreadIds:
|
||||
toStringArray(record.linkedThreadIds, 8).length > 0
|
||||
? toStringArray(record.linkedThreadIds, 8)
|
||||
: fallback.linkedThreadIds,
|
||||
actGoal:
|
||||
toText(record.actGoal) ||
|
||||
(index === 0
|
||||
? `先在${fallback.sceneName}接住开场 lead`
|
||||
: index === fallback.actCount - 1
|
||||
? `把${fallback.sceneName}这一章收住`
|
||||
: `继续逼近${fallback.sceneName}的核心压力`),
|
||||
transitionHook:
|
||||
toText(record.transitionHook) ||
|
||||
(index === fallback.actCount - 1
|
||||
? '这一幕结束后会把问题推向下一跳。'
|
||||
: '完成当前推进后,局势会进入下一幕。'),
|
||||
advanceRule:
|
||||
toText(record.advanceRule) === 'after_primary_contact' ||
|
||||
toText(record.advanceRule) === 'after_active_step_complete' ||
|
||||
toText(record.advanceRule) === 'after_chapter_resolution'
|
||||
? (toText(record.advanceRule) as CustomWorldFoundationDraftSceneAct['advanceRule'])
|
||||
: index === 0
|
||||
? 'after_primary_contact'
|
||||
: index === fallback.actCount - 1
|
||||
? 'after_chapter_resolution'
|
||||
: 'after_active_step_complete',
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackSceneActs(params: {
|
||||
sceneId: string;
|
||||
sceneName: string;
|
||||
sceneSummary: string;
|
||||
backgroundImageSrc?: string | null;
|
||||
encounterNpcIds: string[];
|
||||
linkedThreadIds: string[];
|
||||
}) {
|
||||
const actCount = 3;
|
||||
|
||||
return [
|
||||
{
|
||||
id: `${params.sceneId}-act-1`,
|
||||
title: `初见 ${params.sceneName}`,
|
||||
summary: clampText(
|
||||
`玩家第一次真正接住${params.sceneName}这一章的入口。${params.sceneSummary}`,
|
||||
120,
|
||||
),
|
||||
stageCoverage: buildFallbackSceneActStageCoverage(0, actCount),
|
||||
backgroundImageSrc: params.backgroundImageSrc || null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: params.encounterNpcIds,
|
||||
primaryNpcId: params.encounterNpcIds[0] || '',
|
||||
linkedThreadIds: params.linkedThreadIds,
|
||||
actGoal: `先在${params.sceneName}接住开场 lead`,
|
||||
transitionHook: '和主角色完成首次有效接触后,局势会继续加压。',
|
||||
advanceRule: 'after_primary_contact',
|
||||
},
|
||||
{
|
||||
id: `${params.sceneId}-act-2`,
|
||||
title: `${params.sceneName}承压`,
|
||||
summary: clampText(
|
||||
`玩家开始确认${params.sceneName}不只是背景,而是这一章真正承压的地方。`,
|
||||
120,
|
||||
),
|
||||
stageCoverage: buildFallbackSceneActStageCoverage(1, actCount),
|
||||
backgroundImageSrc: params.backgroundImageSrc || null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: params.encounterNpcIds,
|
||||
primaryNpcId: params.encounterNpcIds[0] || '',
|
||||
linkedThreadIds: params.linkedThreadIds,
|
||||
actGoal: `继续逼近${params.sceneName}的核心压力`,
|
||||
transitionHook: '完成当前主动 step 后,这一章会转向收束。',
|
||||
advanceRule: 'after_active_step_complete',
|
||||
},
|
||||
{
|
||||
id: `${params.sceneId}-act-3`,
|
||||
title: `${params.sceneName}收束`,
|
||||
summary: clampText(
|
||||
`这一幕承担${params.sceneName}的局部收束和下一跳 handoff。`,
|
||||
120,
|
||||
),
|
||||
stageCoverage: buildFallbackSceneActStageCoverage(2, actCount),
|
||||
backgroundImageSrc: params.backgroundImageSrc || null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: params.encounterNpcIds,
|
||||
primaryNpcId: params.encounterNpcIds[0] || '',
|
||||
linkedThreadIds: params.linkedThreadIds,
|
||||
actGoal: `把${params.sceneName}这一章收住`,
|
||||
transitionHook: '这一幕结束后需要把后续方向明确抛给玩家。',
|
||||
advanceRule: 'after_chapter_resolution',
|
||||
},
|
||||
] satisfies CustomWorldFoundationDraftSceneAct[];
|
||||
}
|
||||
|
||||
function normalizeSceneChapter(
|
||||
value: unknown,
|
||||
index: number,
|
||||
fallback: {
|
||||
sceneId: string;
|
||||
sceneName: string;
|
||||
sceneSummary: string;
|
||||
linkedThreadIds: string[];
|
||||
linkedLandmarkIds: string[];
|
||||
backgroundImageSrc?: string | null;
|
||||
encounterNpcIds: string[];
|
||||
},
|
||||
): CustomWorldFoundationDraftSceneChapter | null {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sceneId = toText(record.sceneId) || fallback.sceneId;
|
||||
const sceneName = toText(record.sceneName) || fallback.sceneName;
|
||||
const title = toText(record.title);
|
||||
const summary = toText(record.summary);
|
||||
const actsInput = Array.isArray(record.acts) ? record.acts : [];
|
||||
const actCount = Math.min(5, Math.max(2, actsInput.length || 3));
|
||||
const linkedThreadIds =
|
||||
toStringArray(record.linkedThreadIds, 8).length > 0
|
||||
? toStringArray(record.linkedThreadIds, 8)
|
||||
: fallback.linkedThreadIds;
|
||||
const linkedLandmarkIds =
|
||||
toStringArray(record.linkedLandmarkIds, 8).length > 0
|
||||
? toStringArray(record.linkedLandmarkIds, 8)
|
||||
: fallback.linkedLandmarkIds;
|
||||
|
||||
const acts = actsInput
|
||||
.map((entry, actIndex) =>
|
||||
normalizeSceneAct(entry, actIndex, {
|
||||
sceneId,
|
||||
sceneName,
|
||||
backgroundImageSrc: fallback.backgroundImageSrc,
|
||||
encounterNpcIds: fallback.encounterNpcIds,
|
||||
linkedThreadIds,
|
||||
actCount,
|
||||
}),
|
||||
)
|
||||
.filter((entry): entry is CustomWorldFoundationDraftSceneAct => Boolean(entry))
|
||||
.slice(0, 5);
|
||||
|
||||
return {
|
||||
id: toText(record.id) || createId('scene-chapter', sceneName || title, index),
|
||||
sceneId,
|
||||
sceneName,
|
||||
title: title || `${sceneName}章节`,
|
||||
summary:
|
||||
summary ||
|
||||
clampText(
|
||||
[
|
||||
sceneName,
|
||||
fallback.sceneSummary || '这一章的场景节拍仍可继续收紧',
|
||||
].join(':'),
|
||||
140,
|
||||
),
|
||||
linkedThreadIds,
|
||||
linkedLandmarkIds,
|
||||
acts: acts.length >= 2 ? acts : buildFallbackSceneActs({
|
||||
sceneId,
|
||||
sceneName,
|
||||
sceneSummary: fallback.sceneSummary,
|
||||
backgroundImageSrc: fallback.backgroundImageSrc,
|
||||
encounterNpcIds: fallback.encounterNpcIds,
|
||||
linkedThreadIds,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackSceneChapters(params: {
|
||||
landmarks: CustomWorldFoundationDraftLandmark[];
|
||||
characters: CustomWorldFoundationDraftCharacter[];
|
||||
threads: CustomWorldFoundationDraftThread[];
|
||||
chapters: CustomWorldFoundationDraftChapter[];
|
||||
}) {
|
||||
const fallbackCharacterIds = params.characters.slice(0, 3).map((entry) => entry.id);
|
||||
|
||||
return params.landmarks.map((landmark, index) => {
|
||||
const matchingChapter =
|
||||
params.chapters.find((chapter) => chapter.landmarkIds.includes(landmark.id)) ?? null;
|
||||
const encounterNpcIds =
|
||||
landmark.characterIds.length > 0 ? landmark.characterIds : fallbackCharacterIds;
|
||||
const linkedThreadIds =
|
||||
landmark.threadIds.length > 0
|
||||
? landmark.threadIds
|
||||
: params.threads
|
||||
.filter((thread) => thread.landmarkIds.includes(landmark.id))
|
||||
.map((thread) => thread.id)
|
||||
.slice(0, 4);
|
||||
|
||||
return {
|
||||
id: `scene-chapter-${landmark.id}`,
|
||||
sceneId: landmark.id,
|
||||
sceneName: landmark.name,
|
||||
title: matchingChapter?.title || `${landmark.name}章节`,
|
||||
summary:
|
||||
matchingChapter?.summary ||
|
||||
clampText(
|
||||
[landmark.summary, matchingChapter?.openingEvent || '这一章会从这里真正展开']
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
140,
|
||||
),
|
||||
linkedThreadIds,
|
||||
linkedLandmarkIds: [landmark.id],
|
||||
acts: buildFallbackSceneActs({
|
||||
sceneId: landmark.id,
|
||||
sceneName: landmark.name,
|
||||
sceneSummary: landmark.summary,
|
||||
backgroundImageSrc: landmark.imageSrc || null,
|
||||
encounterNpcIds,
|
||||
linkedThreadIds,
|
||||
}),
|
||||
} satisfies CustomWorldFoundationDraftSceneChapter;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSceneChapterFallbackFromRecord(item: unknown, index: number) {
|
||||
const record = toRecord(item);
|
||||
const linkedLandmarkIds = toStringArray(record?.linkedLandmarkIds, 8);
|
||||
return {
|
||||
sceneId: toText(record?.sceneId) || linkedLandmarkIds[0] || `scene-${index + 1}`,
|
||||
sceneName:
|
||||
toText(record?.sceneName) ||
|
||||
toText(record?.title) ||
|
||||
`场景章节 ${index + 1}`,
|
||||
sceneSummary:
|
||||
toText(record?.summary) ||
|
||||
'这一章仍可继续精修场景幕结构。',
|
||||
linkedThreadIds: toStringArray(record?.linkedThreadIds, 8),
|
||||
linkedLandmarkIds,
|
||||
backgroundImageSrc: toText(record?.backgroundImageSrc) || null,
|
||||
encounterNpcIds: toStringArray(record?.encounterNpcIds, 8),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeFoundationDraftProfile(
|
||||
value: unknown,
|
||||
): CustomWorldFoundationDraftProfile | null {
|
||||
@@ -474,6 +901,28 @@ export function normalizeFoundationDraftProfile(
|
||||
Boolean(item),
|
||||
),
|
||||
);
|
||||
const mergedCharacters = dedupeById([...playableNpcs, ...storyNpcs]);
|
||||
const explicitSceneChapters = toRecordArray(record.sceneChapters)
|
||||
.map((item, index) =>
|
||||
normalizeSceneChapter(
|
||||
item,
|
||||
index,
|
||||
resolveSceneChapterFallbackFromRecord(item, index),
|
||||
),
|
||||
)
|
||||
.filter((item): item is CustomWorldFoundationDraftSceneChapter =>
|
||||
Boolean(item),
|
||||
);
|
||||
const sceneChapters = dedupeById(
|
||||
explicitSceneChapters.length > 0
|
||||
? explicitSceneChapters
|
||||
: buildFallbackSceneChapters({
|
||||
landmarks,
|
||||
characters: mergedCharacters,
|
||||
threads,
|
||||
chapters,
|
||||
})
|
||||
);
|
||||
const camp = normalizeCamp(record.camp);
|
||||
const hasStructuredFoundationContent =
|
||||
playableNpcs.length > 0 ||
|
||||
@@ -482,13 +931,12 @@ export function normalizeFoundationDraftProfile(
|
||||
factions.length > 0 ||
|
||||
threads.length > 0 ||
|
||||
chapters.length > 0 ||
|
||||
sceneChapters.length > 0 ||
|
||||
Boolean(camp);
|
||||
|
||||
if (!hasStructuredFoundationContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mergedCharacters = dedupeById([...playableNpcs, ...storyNpcs]);
|
||||
const coreConflicts = toStringArray(record.coreConflicts, 6);
|
||||
|
||||
return {
|
||||
@@ -539,6 +987,7 @@ export function normalizeFoundationDraftProfile(
|
||||
factions,
|
||||
threads,
|
||||
chapters,
|
||||
sceneChapters,
|
||||
worldHook: toText(record.worldHook) || name || summary,
|
||||
playerPremise: toText(record.playerPremise),
|
||||
openingSituation: toText(record.openingSituation),
|
||||
@@ -636,6 +1085,84 @@ function buildChapterWarnings(chapter: CustomWorldFoundationDraftChapter) {
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function buildSceneChapterWarnings(params: {
|
||||
sceneChapter: CustomWorldFoundationDraftSceneChapter;
|
||||
characterById: Map<string, CustomWorldFoundationDraftCharacter>;
|
||||
threadById: Map<string, CustomWorldFoundationDraftThread>;
|
||||
landmarkById: Map<string, CustomWorldFoundationDraftLandmark>;
|
||||
}) {
|
||||
const { sceneChapter, characterById, threadById, landmarkById } = params;
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (sceneChapter.acts.length < 2) {
|
||||
warnings.push('这个场景章节至少需要 2 幕。');
|
||||
}
|
||||
if (sceneChapter.acts.length > 5) {
|
||||
warnings.push('这个场景章节当前超过 5 幕,建议先收束到 5 幕以内。');
|
||||
}
|
||||
|
||||
const linkedLandmarks = sceneChapter.linkedLandmarkIds
|
||||
.map((id) => landmarkById.get(id))
|
||||
.filter((entry): entry is CustomWorldFoundationDraftLandmark => Boolean(entry));
|
||||
|
||||
sceneChapter.acts.forEach((act, index) => {
|
||||
const actLabel = `第 ${index + 1} 幕`;
|
||||
const primaryNpcId = act.encounterNpcIds[0] || act.primaryNpcId;
|
||||
const actThreadIds =
|
||||
act.linkedThreadIds.length > 0
|
||||
? act.linkedThreadIds
|
||||
: sceneChapter.linkedThreadIds;
|
||||
|
||||
if (!act.backgroundImageSrc && !act.backgroundAssetId) {
|
||||
warnings.push(`${actLabel}还没有绑定背景图。`);
|
||||
}
|
||||
if (act.encounterNpcIds.length === 0) {
|
||||
warnings.push(`${actLabel}还没有配置相遇 NPC。`);
|
||||
}
|
||||
if (!primaryNpcId) {
|
||||
warnings.push(`${actLabel}缺少主角色。`);
|
||||
}
|
||||
if (act.primaryNpcId && act.primaryNpcId !== (act.encounterNpcIds[0] ?? '')) {
|
||||
warnings.push(`${actLabel}的主角色必须放在相遇 NPC 的第一位。`);
|
||||
}
|
||||
if (actThreadIds.length === 0) {
|
||||
warnings.push(`${actLabel}还没有挂到明确线程。`);
|
||||
}
|
||||
|
||||
const unresolvedNpcIds = act.encounterNpcIds.filter((id) => !characterById.has(id));
|
||||
if (unresolvedNpcIds.length > 0) {
|
||||
warnings.push(
|
||||
`${actLabel}存在未进入当前世界角色池的 NPC:${unresolvedNpcIds
|
||||
.slice(0, 3)
|
||||
.join('、')}。`,
|
||||
);
|
||||
}
|
||||
|
||||
const unresolvedThreadIds = actThreadIds.filter((id) => !threadById.has(id));
|
||||
if (unresolvedThreadIds.length > 0) {
|
||||
warnings.push(
|
||||
`${actLabel}存在未绑定的线程引用:${unresolvedThreadIds
|
||||
.slice(0, 3)
|
||||
.join('、')}。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (primaryNpcId && characterById.has(primaryNpcId)) {
|
||||
const linkedToLandmark = linkedLandmarks.some((landmark) =>
|
||||
landmark.characterIds.includes(primaryNpcId),
|
||||
);
|
||||
const linkedToThread = actThreadIds.some((threadId) =>
|
||||
threadById.get(threadId)?.characterIds.includes(primaryNpcId),
|
||||
);
|
||||
if (!linkedToLandmark && !linkedToThread) {
|
||||
warnings.push(`${actLabel}的主角色和当前场景/线程的关联还不够明确。`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function buildCampWarnings() {
|
||||
return [] as string[];
|
||||
}
|
||||
@@ -650,6 +1177,7 @@ function buildCharacterAssetHeadline(character: CustomWorldFoundationDraftCharac
|
||||
generatedVisualAssetId: character.generatedVisualAssetId,
|
||||
generatedAnimationSetId: character.generatedAnimationSetId,
|
||||
animationMap: character.animationMap,
|
||||
skills: character.skills ?? [],
|
||||
},
|
||||
roleKind: 'story',
|
||||
});
|
||||
@@ -773,6 +1301,7 @@ export class CustomWorldAgentDraftCompiler {
|
||||
...profile.landmarks.map((entry) => entry.id),
|
||||
...profile.threads.map((entry) => entry.id),
|
||||
...profile.chapters.map((entry) => entry.id),
|
||||
...profile.sceneChapters.map((entry) => entry.id),
|
||||
].slice(0, 12),
|
||||
sections: [
|
||||
buildSection('title', '标题', profile.name),
|
||||
@@ -1025,6 +1554,129 @@ export class CustomWorldAgentDraftCompiler {
|
||||
});
|
||||
});
|
||||
|
||||
profile.sceneChapters.forEach((sceneChapter) => {
|
||||
const uniqueNpcIds = [...new Set(sceneChapter.acts.flatMap((act) => act.encounterNpcIds))];
|
||||
const readyBackgroundCount = sceneChapter.acts.filter(
|
||||
(act) => Boolean(act.backgroundImageSrc || act.backgroundAssetId),
|
||||
).length;
|
||||
const warnings = buildSceneChapterWarnings({
|
||||
sceneChapter,
|
||||
characterById,
|
||||
threadById,
|
||||
landmarkById,
|
||||
});
|
||||
|
||||
pushCard({
|
||||
id: sceneChapter.id,
|
||||
kind: 'scene_chapter',
|
||||
title: sceneChapter.title,
|
||||
subtitle: clampText(
|
||||
`${sceneChapter.sceneName} · ${sceneChapter.acts.length} 幕 · 背景 ${readyBackgroundCount}/${sceneChapter.acts.length}`,
|
||||
40,
|
||||
),
|
||||
summary: sceneChapter.summary,
|
||||
linkedIds: [
|
||||
...sceneChapter.linkedLandmarkIds,
|
||||
...sceneChapter.linkedThreadIds,
|
||||
...uniqueNpcIds,
|
||||
].slice(0, 12),
|
||||
sections: [
|
||||
buildSection('sceneName', '所属场景', sceneChapter.sceneName),
|
||||
buildSection('title', '场景章节标题', sceneChapter.title),
|
||||
buildSection('summary', '场景章节摘要', sceneChapter.summary),
|
||||
buildSection(
|
||||
'actOverview',
|
||||
'幕结构总览',
|
||||
sceneChapter.acts
|
||||
.map((act, index) => {
|
||||
const primaryNpcName =
|
||||
resolveCharacterNames([act.encounterNpcIds[0] || act.primaryNpcId]) ||
|
||||
'待补主角色';
|
||||
const supportNpcNames =
|
||||
resolveCharacterNames(act.encounterNpcIds.slice(1)) || '当前没有辅助 NPC';
|
||||
return [
|
||||
`第 ${index + 1} 幕|${act.title}`,
|
||||
`主角色:${primaryNpcName}`,
|
||||
`辅助 NPC:${supportNpcNames}`,
|
||||
`目标:${act.actGoal}`,
|
||||
`过渡:${act.transitionHook}`,
|
||||
].join('\n');
|
||||
})
|
||||
.join('\n\n'),
|
||||
),
|
||||
buildSection(
|
||||
'linkedLandmarkIds',
|
||||
'关联地点',
|
||||
resolveLandmarkNames(sceneChapter.linkedLandmarkIds),
|
||||
),
|
||||
buildSection(
|
||||
'linkedThreadIds',
|
||||
'关联线程',
|
||||
resolveThreadTitles(sceneChapter.linkedThreadIds),
|
||||
),
|
||||
...sceneChapter.acts.flatMap((act, index) => {
|
||||
const actLabel = `第 ${index + 1} 幕`;
|
||||
const encounterNpcValue =
|
||||
resolveCharacterNames(act.encounterNpcIds) ||
|
||||
act.encounterNpcIds.join('、');
|
||||
const primaryNpcValue =
|
||||
resolveCharacterNames([act.encounterNpcIds[0] || act.primaryNpcId]) ||
|
||||
act.encounterNpcIds[0] ||
|
||||
act.primaryNpcId;
|
||||
const actThreadTitles =
|
||||
resolveThreadTitles(
|
||||
act.linkedThreadIds.length > 0
|
||||
? act.linkedThreadIds
|
||||
: sceneChapter.linkedThreadIds,
|
||||
) || '待补线程挂钩';
|
||||
|
||||
return [
|
||||
buildSection(`act:${act.id}:title`, `${actLabel}标题`, act.title),
|
||||
buildSection(`act:${act.id}:summary`, `${actLabel}摘要`, act.summary),
|
||||
buildSection(
|
||||
`act:${act.id}:backgroundImageSrc`,
|
||||
`${actLabel}背景图`,
|
||||
act.backgroundImageSrc || act.backgroundAssetId || '',
|
||||
),
|
||||
buildSection(
|
||||
`act:${act.id}:encounterNpcIds`,
|
||||
`${actLabel}相遇 NPC`,
|
||||
encounterNpcValue,
|
||||
),
|
||||
buildSection(
|
||||
`act:${act.id}:primaryNpcId`,
|
||||
`${actLabel}主角色`,
|
||||
primaryNpcValue,
|
||||
),
|
||||
buildSection(
|
||||
`act:${act.id}:stageCoverage`,
|
||||
`${actLabel}阶段覆盖`,
|
||||
resolveSceneActStageCoverageLabel(act.stageCoverage),
|
||||
),
|
||||
buildSection(`act:${act.id}:actGoal`, `${actLabel}目标`, act.actGoal),
|
||||
buildSection(
|
||||
`act:${act.id}:transitionHook`,
|
||||
`${actLabel}过渡钩子`,
|
||||
act.transitionHook,
|
||||
),
|
||||
buildSection(
|
||||
`act:${act.id}:linkedThreadIds`,
|
||||
`${actLabel}关联线程`,
|
||||
actThreadTitles,
|
||||
),
|
||||
buildSection(
|
||||
`act:${act.id}:advanceRule`,
|
||||
`${actLabel}推进规则`,
|
||||
resolveSceneActAdvanceRuleLabel(act.advanceRule),
|
||||
),
|
||||
];
|
||||
}),
|
||||
],
|
||||
editableSectionIds: resolveSceneChapterEditableSectionIds(sceneChapter),
|
||||
warningMessages: warnings,
|
||||
});
|
||||
});
|
||||
|
||||
return cards;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user