Files
Genarrative/server-node/src/services/customWorldAgentDraftCompiler.ts
高物 8a7bd90458
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-20 11:30:19 +08:00

1687 lines
54 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 {
CustomWorldDraftCardDetail,
CustomWorldDraftCardDetailSection,
CustomWorldDraftCardKind,
CustomWorldDraftCardSummary,
CustomWorldRoleAssetStatus,
CustomWorldFoundationDraftCamp,
CustomWorldFoundationDraftChapter,
CustomWorldFoundationDraftCharacter,
CustomWorldFoundationDraftFaction,
CustomWorldFoundationDraftLandmark,
CustomWorldFoundationDraftProfile,
CustomWorldFoundationDraftSceneAct,
CustomWorldFoundationDraftSceneChapter,
CustomWorldFoundationDraftThread,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import {
buildRoleAssetSummary,
resolveRoleAssetStatusLabel,
} from './customWorldAgentRoleAssetStateService.js';
const WORLD_CARD_ID = 'world-foundation';
const EDITABLE_WORLD_SECTION_IDS = [
'title',
'subtitle',
'summary',
'playerGoal',
'tone',
'coreConflicts',
] as const;
const EDITABLE_FACTION_SECTION_IDS = [
'title',
'subtitle',
'summary',
'publicGoal',
'tension',
] as const;
const EDITABLE_CHARACTER_SECTION_IDS = [
'name',
'role',
'publicMask',
'hiddenHook',
'relationToPlayer',
'summary',
] as const;
const EDITABLE_LANDMARK_SECTION_IDS = [
'name',
'purpose',
'mood',
'secret',
'summary',
] as const;
const EDITABLE_THREAD_SECTION_IDS = [
'title',
'summary',
'conflictType',
'stakes',
] as const;
const EDITABLE_CHAPTER_SECTION_IDS = [
'title',
'summary',
'openingEvent',
'playerGoal',
'understandingShift',
] as const;
const EDITABLE_CAMP_SECTION_IDS = [
'name',
'description',
'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() : '';
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter((item) => item && typeof item === 'object')
: [];
}
function toStringArray(value: unknown, maxCount = 8) {
if (!Array.isArray(value)) {
return [];
}
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
0,
maxCount,
);
}
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()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-')
.replace(/^-+|-+$/gu, '');
return normalized || 'entry';
}
function createId(prefix: string, label: string, index: number) {
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
}
function clampText(value: string, maxLength: number) {
const normalized = value.replace(/\s+/gu, ' ').trim();
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function dedupeById<T extends { id: string }>(items: T[]) {
const seen = new Set<string>();
return items.filter((item) => {
const key = item.id.trim();
if (!key || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function resolveEditableSectionIds(kind: CustomWorldDraftCardKind) {
if (kind === 'world') return [...EDITABLE_WORLD_SECTION_IDS];
if (kind === 'faction') return [...EDITABLE_FACTION_SECTION_IDS];
if (kind === 'character') return [...EDITABLE_CHARACTER_SECTION_IDS];
if (kind === 'landmark') return [...EDITABLE_LANDMARK_SECTION_IDS];
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,
): CustomWorldFoundationDraftFaction | null {
const record = toRecord(value);
if (!record) {
return null;
}
const title = toText(record.title) || toText(record.name);
const subtitle = toText(record.subtitle);
const publicGoal = toText(record.publicGoal);
const tension = toText(record.tension) || toText(record.relatedConflict);
const playerRelation = toText(record.playerRelation);
const summary = toText(record.summary);
if (!title && !publicGoal && !tension && !summary) {
return null;
}
return {
id: toText(record.id) || createId('faction', title || publicGoal, index),
name: title || `关键势力 ${index + 1}`,
title: title || `关键势力 ${index + 1}`,
subtitle:
subtitle ||
clampText(
[publicGoal || '关键势力', tension || '当前张力仍在升级']
.filter(Boolean)
.join(' · '),
40,
),
publicGoal: publicGoal || '稳住自己在当前局势中的位置',
relatedConflict: tension || '局势仍在快速失衡',
tension: tension || '局势仍在快速失衡',
playerRelation: playerRelation || '玩家迟早要和它发生直接关系',
summary:
summary ||
clampText(
[
publicGoal || '正在抢夺当前局势的主动权',
tension || '和主线冲突直接相连',
playerRelation || '会逼玩家选边',
].join(''),
120,
),
};
}
function normalizeCharacter(
value: unknown,
index: number,
): CustomWorldFoundationDraftCharacter | null {
const record = toRecord(value);
if (!record) {
return null;
}
const name = toText(record.name);
const title = toText(record.title);
const role = toText(record.role);
const publicMask = toText(record.publicMask) || toText(record.publicIdentity);
const hiddenHook = toText(record.hiddenHook) || toText(record.currentPressure);
const relationToPlayer = toText(record.relationToPlayer);
const summary = toText(record.summary);
if (!name && !title && !role && !summary) {
return null;
}
return {
id: toText(record.id) || createId('character', name || title || role, index),
name: name || `关键角色 ${index + 1}`,
title: title || role || '关键角色',
role: role || title || '关键角色',
publicIdentity: publicMask || title || role || '正在局势前台行动的人',
publicMask: publicMask || title || role || '正在局势前台行动的人',
currentPressure: hiddenHook || '必须立刻回应眼前的局势压力',
hiddenHook: hiddenHook || '必须立刻回应眼前的局势压力',
relationToPlayer: relationToPlayer || '和玩家存在尚待精修的关系钩子',
threadIds: toStringArray(record.threadIds, 6),
summary:
summary ||
clampText(
[
publicMask || title || role || '处在局势前台',
hiddenHook || '眼下压力仍在加码',
relationToPlayer || '与玩家关系待细化',
].join(''),
120,
),
skills: normalizeCharacterSkills(record.skills, role || title || name || '角色'),
imageSrc: toText(record.imageSrc) || null,
generatedVisualAssetId: toText(record.generatedVisualAssetId) || null,
generatedAnimationSetId: toText(record.generatedAnimationSetId) || null,
animationMap: toRecord(record.animationMap),
};
}
function normalizeLandmark(
value: unknown,
index: number,
): CustomWorldFoundationDraftLandmark | null {
const record = toRecord(value);
if (!record) {
return null;
}
const name = toText(record.name);
const description = toText(record.description);
const purpose = toText(record.purpose);
const mood = toText(record.mood);
const secret = toText(record.secret) || toText(record.importance);
const dangerLevel = toText(record.dangerLevel);
const summary = toText(record.summary);
if (!name && !purpose && !mood && !secret && !summary) {
return null;
}
return {
id: toText(record.id) || createId('landmark', name || purpose, index),
name: name || `关键地点 ${index + 1}`,
description:
description ||
clampText(
[purpose || '承接关键冲突', mood || '整体情绪仍在发酵']
.filter(Boolean)
.join(''),
96,
),
purpose: purpose || '承接主线推进的关键地点',
mood: mood || '带着明显张力与未明感',
importance: secret || '玩家第一次抵达就会意识到它不只是背景',
secret: secret || '玩家第一次抵达就会意识到它不只是背景',
dangerLevel: dangerLevel || '中',
imageSrc: toText(record.imageSrc) || null,
characterIds: toStringArray(record.characterIds, 8),
threadIds: toStringArray(record.threadIds, 8),
summary:
summary ||
clampText(
[
purpose || '承担关键戏剧功能',
secret || '和当前冲突直接相连',
mood || '会立刻形成情绪印象',
].join(''),
120,
),
};
}
function normalizeThread(
value: unknown,
index: number,
): CustomWorldFoundationDraftThread | null {
const record = toRecord(value);
if (!record) {
return null;
}
const title = toText(record.title);
const conflictTypeText = toText(record.conflictType);
const type =
record.type === 'hidden' ||
conflictTypeText.includes('暗') ||
conflictTypeText.toLowerCase() === 'hidden'
? 'hidden'
: 'main';
const stakes = toText(record.stakes) || toText(record.conflict);
const summary = toText(record.summary);
if (!title && !stakes && !summary) {
return null;
}
return {
id: toText(record.id) || createId('thread', title || stakes, index),
title: title || `世界线程 ${index + 1}`,
type,
conflictType:
conflictTypeText || (type === 'hidden' ? '暗线' : '明线'),
conflict: stakes || '这条线仍在等待进一步精修',
stakes: stakes || '这条线仍在等待进一步精修',
characterIds: toStringArray(record.characterIds, 8),
landmarkIds: toStringArray(record.landmarkIds, 8),
summary:
summary ||
clampText(
[
type === 'hidden' ? '暗线' : '明线',
stakes || '主要冲突待细化',
].join(''),
120,
),
};
}
function normalizeChapter(
value: unknown,
index: number,
): CustomWorldFoundationDraftChapter | null {
const record = toRecord(value);
if (!record) {
return null;
}
const title = toText(record.title);
const openingEvent = toText(record.openingEvent);
const playerGoal = toText(record.playerGoal);
const understandingShift = toText(record.understandingShift);
const summary = toText(record.summary);
if (!title && !openingEvent && !playerGoal && !summary) {
return null;
}
return {
id: toText(record.id) || createId('chapter', title || openingEvent, index),
title: title || '第一幕',
openingEvent: openingEvent || '局势在开幕时突然失控',
playerGoal: playerGoal || '先稳住开局并找到下一步目标',
characterIds: toStringArray(record.characterIds, 8),
landmarkIds: toStringArray(record.landmarkIds, 8),
understandingShift:
understandingShift || '玩家会意识到这场冲突远不止表面那一层',
summary:
summary ||
clampText(
[
openingEvent || '开幕事件已逼近',
playerGoal || '玩家需要尽快立住脚跟',
understandingShift || '第一幕会改写玩家对世界的理解',
].join(''),
140,
),
};
}
function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null {
const record = toRecord(value);
if (!record) {
return null;
}
const name = toText(record.name);
const description = toText(record.description);
const dangerLevel = toText(record.dangerLevel) || toText(record.mood);
const summary = toText(record.summary);
if (!name && !description && !summary) {
return null;
}
return {
id: toText(record.id) || 'camp-home',
name: name || '临时落脚处',
description: description || '玩家暂时还能整顿情报和喘口气的地方',
mood: dangerLevel || '克制、紧绷,但还能暂时收拢局势',
dangerLevel: dangerLevel || '克制、紧绷,但还能暂时收拢局势',
imageSrc: toText(record.imageSrc) || null,
summary:
summary ||
clampText(
[
description || '这是玩家当前最稳的回气点',
dangerLevel || '它承担落脚与整理线索的功能',
].join(''),
120,
),
};
}
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 {
const record = toRecord(value);
if (!record) {
return null;
}
const name = toText(record.name) || toText(record.title);
const summary = toText(record.summary);
const playableNpcs = dedupeById(
toRecordArray(record.playableNpcs)
.map((item, index) => normalizeCharacter(item, index))
.filter((item): item is CustomWorldFoundationDraftCharacter =>
Boolean(item),
),
);
const storyNpcs = dedupeById(
toRecordArray(record.storyNpcs)
.map((item, index) => normalizeCharacter(item, index))
.filter((item): item is CustomWorldFoundationDraftCharacter =>
Boolean(item),
),
);
const landmarks = dedupeById(
toRecordArray(record.landmarks)
.map((item, index) => normalizeLandmark(item, index))
.filter((item): item is CustomWorldFoundationDraftLandmark =>
Boolean(item),
),
);
const factions = dedupeById(
toRecordArray(record.factions)
.map((item, index) => normalizeFaction(item, index))
.filter((item): item is CustomWorldFoundationDraftFaction =>
Boolean(item),
),
);
const threads = dedupeById(
toRecordArray(record.threads)
.map((item, index) => normalizeThread(item, index))
.filter((item): item is CustomWorldFoundationDraftThread =>
Boolean(item),
),
);
const chapters = dedupeById(
toRecordArray(record.chapters)
.map((item, index) => normalizeChapter(item, index))
.filter((item): item is CustomWorldFoundationDraftChapter =>
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 ||
storyNpcs.length > 0 ||
landmarks.length > 0 ||
factions.length > 0 ||
threads.length > 0 ||
chapters.length > 0 ||
sceneChapters.length > 0 ||
Boolean(camp);
if (!hasStructuredFoundationContent) {
return null;
}
const coreConflicts = toStringArray(record.coreConflicts, 6);
return {
name: name || '未命名世界底稿',
subtitle:
toText(record.subtitle) ||
clampText(
[toText(record.playerPremise), coreConflicts[0] ?? '核心冲突仍在整理']
.filter(Boolean)
.join(' · '),
40,
) ||
'第一版世界底稿',
summary:
summary ||
clampText(
[
toText(record.worldHook),
toText(record.playerPremise),
coreConflicts[0] ?? '',
]
.filter(Boolean)
.join(' '),
160,
) ||
'第一版世界底稿已经整理完成。',
tone: toText(record.tone) || '整体气质仍可继续精修',
playerGoal: toText(record.playerGoal) || '先站稳开局,再判断下一步',
majorFactions:
toStringArray(record.majorFactions, 6).length > 0
? toStringArray(record.majorFactions, 6)
: factions.map((entry) => entry.name),
coreConflicts,
playableNpcs:
playableNpcs.length > 0
? playableNpcs
: mergedCharacters.slice(0, Math.max(3, mergedCharacters.length)),
storyNpcs:
storyNpcs.length > 0
? storyNpcs
: mergedCharacters.filter(
(entry) => !playableNpcs.some((npc) => npc.id === entry.id),
),
landmarks,
camp,
themePack: toRecord(record.themePack),
storyGraph: toRecord(record.storyGraph),
factions,
threads,
chapters,
sceneChapters,
worldHook: toText(record.worldHook) || name || summary,
playerPremise: toText(record.playerPremise),
openingSituation: toText(record.openingSituation),
iconicElements: toStringArray(record.iconicElements, 8),
sourceAnchorSummary: toText(record.sourceAnchorSummary) || summary,
};
}
function buildSection(
id: string,
label: string,
value: string,
): CustomWorldDraftCardDetailSection {
return {
id,
label,
value: value.trim() || '待继续精修',
};
}
function resolveThreadTypeLabel(type: CustomWorldFoundationDraftThread['type']) {
return type === 'hidden' ? '暗线' : '明线';
}
function buildWorldWarnings(profile: CustomWorldFoundationDraftProfile) {
const warnings: string[] = [];
const totalCharacters = dedupeById([
...profile.playableNpcs,
...profile.storyNpcs,
]).length;
if (profile.iconicElements.length === 0) {
warnings.push('标志性要素还偏少,后续可以补 1 到 2 个记忆点。');
}
if (totalCharacters < 3) {
warnings.push('关键角色数量还偏少,建议继续补角色关系网。');
}
if (profile.landmarks.length < 4) {
warnings.push('关键地点仍然偏少,第一版游历路径还不够饱满。');
}
return warnings;
}
function buildFactionWarnings(faction: CustomWorldFoundationDraftFaction) {
const warnings: string[] = [];
if (!faction.playerRelation.trim()) {
warnings.push('这个势力和玩家的关系仍可更具体。');
}
if (!faction.relatedConflict.trim()) {
warnings.push('这个势力还缺少更明确的冲突挂钩。');
}
return warnings;
}
function buildCharacterWarnings(character: CustomWorldFoundationDraftCharacter) {
const warnings: string[] = [];
if (!character.relationToPlayer.trim()) {
warnings.push('和玩家的关系钩子还不够明确。');
}
if (character.threadIds.length === 0) {
warnings.push('这个角色尚未绑定到明确线程。');
}
return warnings;
}
function buildLandmarkWarnings(landmark: CustomWorldFoundationDraftLandmark) {
const warnings: string[] = [];
if (landmark.characterIds.length === 0) {
warnings.push('这个地点还没有挂住足够明确的角色。');
}
if (landmark.threadIds.length === 0) {
warnings.push('这个地点还缺少更清楚的线程挂钩。');
}
return warnings;
}
function buildThreadWarnings(thread: CustomWorldFoundationDraftThread) {
const warnings: string[] = [];
if (thread.characterIds.length === 0) {
warnings.push('这条线还缺少更明确的角色挂点。');
}
if (thread.landmarkIds.length === 0) {
warnings.push('这条线还缺少更明确的地点挂点。');
}
return warnings;
}
function buildChapterWarnings(chapter: CustomWorldFoundationDraftChapter) {
const warnings: string[] = [];
if (chapter.characterIds.length < 2) {
warnings.push('第一幕涉及的关键角色还偏少。');
}
if (chapter.landmarkIds.length < 2) {
warnings.push('第一幕涉及的关键地点还偏少。');
}
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[];
}
function buildCharacterAssetHeadline(character: CustomWorldFoundationDraftCharacter) {
const assetSummary = buildRoleAssetSummary({
role: {
id: character.id,
name: character.name,
threadIds: character.threadIds,
imageSrc: character.imageSrc,
generatedVisualAssetId: character.generatedVisualAssetId,
generatedAnimationSetId: character.generatedAnimationSetId,
animationMap: character.animationMap,
skills: character.skills ?? [],
},
roleKind: 'story',
});
return {
status: assetSummary.status,
label: resolveRoleAssetStatusLabel(assetSummary.status),
};
}
type CompiledCard = {
summary: CustomWorldDraftCardSummary;
detail: CustomWorldDraftCardDetail;
};
export class CustomWorldAgentDraftCompiler {
compileDraftCards(profileInput: unknown) {
return this.compile(profileInput).map((entry) => entry.summary);
}
getDraftCardDetail(profileInput: unknown, cardId: string) {
return (
this.compile(profileInput).find((entry) => entry.summary.id === cardId)
?.detail ?? null
);
}
private compile(profileInput: unknown): CompiledCard[] {
const profile = normalizeFoundationDraftProfile(profileInput);
if (!profile) {
return [];
}
const characters = dedupeById([
...profile.playableNpcs,
...profile.storyNpcs,
]);
const characterById = new Map(characters.map((entry) => [entry.id, entry]));
const landmarkById = new Map(profile.landmarks.map((entry) => [entry.id, entry]));
const threadById = new Map(profile.threads.map((entry) => [entry.id, entry]));
const resolveCharacterNames = (ids: string[]) =>
ids
.map((id) => characterById.get(id)?.name)
.filter((entry): entry is string => Boolean(entry))
.join('、');
const resolveLandmarkNames = (ids: string[]) =>
ids
.map((id) => landmarkById.get(id)?.name)
.filter((entry): entry is string => Boolean(entry))
.join('、');
const resolveThreadTitles = (ids: string[]) =>
ids
.map((id) => threadById.get(id)?.title)
.filter((entry): entry is string => Boolean(entry))
.join('、');
const cards: CompiledCard[] = [];
const pushCard = (params: {
id: string;
kind: CustomWorldDraftCardKind;
title: string;
subtitle: string;
summary: string;
linkedIds: string[];
sections: CustomWorldDraftCardDetailSection[];
editableSectionIds?: string[];
warningMessages: string[];
assetStatus?: CustomWorldRoleAssetStatus | null;
assetStatusLabel?: string | null;
}) => {
const warningMessages = [...new Set(params.warningMessages.filter(Boolean))];
const editableSectionIds = params.editableSectionIds ?? [];
cards.push({
summary: {
id: params.id,
kind: params.kind,
title: params.title,
subtitle: params.subtitle,
summary: clampText(params.summary, 180),
status: warningMessages.length > 0 ? 'warning' : 'suggested',
linkedIds: [...new Set(params.linkedIds.filter(Boolean))],
warningCount: warningMessages.length,
assetStatus: params.assetStatus ?? null,
assetStatusLabel: params.assetStatusLabel ?? null,
},
detail: {
id: params.id,
kind: params.kind,
title: params.title,
sections: params.sections,
linkedIds: [...new Set(params.linkedIds.filter(Boolean))],
locked: false,
editable: editableSectionIds.length > 0,
editableSectionIds,
warningMessages,
assetStatus: params.assetStatus ?? null,
assetStatusLabel: params.assetStatusLabel ?? null,
},
});
};
const worldWarnings = buildWorldWarnings(profile);
pushCard({
id: WORLD_CARD_ID,
kind: 'world',
title: profile.name,
subtitle:
clampText(
[profile.playerPremise, profile.coreConflicts[0] ?? '核心冲突待继续精修']
.filter(Boolean)
.join(' · '),
40,
) || profile.subtitle,
summary: profile.summary,
linkedIds: [
...(profile.camp ? [profile.camp.id] : []),
...profile.factions.map((entry) => entry.id),
...characters.map((entry) => entry.id),
...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),
buildSection('subtitle', '副标题', profile.subtitle),
buildSection('summary', '摘要', profile.summary),
buildSection('playerGoal', '玩家目标', profile.playerGoal),
buildSection(
'tone',
'世界气质',
[profile.tone, profile.iconicElements.join('、')]
.filter(Boolean)
.join(' / '),
),
buildSection(
'coreConflicts',
'核心冲突',
profile.coreConflicts.join('') || profile.summary,
),
buildSection('worldHook', '世界一句话', profile.worldHook || profile.summary),
buildSection(
'playerPremise',
'玩家是谁',
profile.playerPremise,
),
],
editableSectionIds: resolveEditableSectionIds('world'),
warningMessages: worldWarnings,
});
if (profile.camp) {
const campWarnings = buildCampWarnings();
pushCard({
id: profile.camp.id,
kind: 'camp',
title: profile.camp.name,
subtitle: clampText(profile.camp.mood || '开局落脚处', 28),
summary: profile.camp.summary,
linkedIds: [
...profile.landmarks.slice(0, 2).map((entry) => entry.id),
...characters.slice(0, 2).map((entry) => entry.id),
...profile.chapters.slice(0, 1).map((entry) => entry.id),
],
sections: [
buildSection('name', '营地名称', profile.camp.name),
buildSection('description', '当前定位', profile.camp.description),
buildSection(
'dangerLevel',
'危险等级',
profile.camp.dangerLevel || profile.camp.mood,
),
buildSection(
'linkedObjects',
'关联对象',
[
resolveLandmarkNames(
profile.landmarks.slice(0, 2).map((entry) => entry.id),
),
resolveCharacterNames(
characters.slice(0, 2).map((entry) => entry.id),
),
]
.filter(Boolean)
.join(''),
),
],
editableSectionIds: resolveEditableSectionIds('camp'),
warningMessages: campWarnings,
});
}
profile.threads.forEach((thread) => {
const warnings = buildThreadWarnings(thread);
pushCard({
id: thread.id,
kind: 'thread',
title: thread.title,
subtitle: resolveThreadTypeLabel(thread.type),
summary: thread.summary,
linkedIds: [...thread.characterIds, ...thread.landmarkIds],
sections: [
buildSection('title', '线程标题', thread.title),
buildSection('summary', '线程摘要', thread.summary),
buildSection(
'conflictType',
'冲突类型',
thread.conflictType || resolveThreadTypeLabel(thread.type),
),
buildSection('stakes', '冲突内容', thread.stakes || thread.conflict),
buildSection(
'relatedObjects',
'相关对象',
[
resolveCharacterNames(thread.characterIds),
resolveLandmarkNames(thread.landmarkIds),
]
.filter(Boolean)
.join(''),
),
],
editableSectionIds: resolveEditableSectionIds('thread'),
warningMessages: warnings,
});
});
profile.factions.forEach((faction) => {
const warnings = buildFactionWarnings(faction);
const linkedThreadIds = profile.threads
.filter(
(thread) =>
thread.conflict.includes(faction.name) ||
thread.conflict.includes(faction.relatedConflict) ||
thread.summary.includes(faction.name),
)
.map((entry) => entry.id)
.slice(0, 3);
pushCard({
id: faction.id,
kind: 'faction',
title: faction.title || faction.name,
subtitle: clampText(faction.subtitle || faction.publicGoal, 28),
summary: faction.summary,
linkedIds: linkedThreadIds,
sections: [
buildSection('title', '势力标题', faction.title || faction.name),
buildSection(
'subtitle',
'副标题',
faction.subtitle || clampText(faction.publicGoal, 40),
),
buildSection('summary', '势力摘要', faction.summary),
buildSection('publicGoal', '公开目标', faction.publicGoal),
buildSection('tension', '当前张力', faction.tension || faction.relatedConflict),
buildSection('playerRelation', '玩家关系', faction.playerRelation),
],
editableSectionIds: resolveEditableSectionIds('faction'),
warningMessages: warnings,
});
});
characters.forEach((character) => {
const warnings = buildCharacterWarnings(character);
const assetHeadline = buildCharacterAssetHeadline(character);
const linkedLandmarks = profile.landmarks
.filter((landmark) => landmark.characterIds.includes(character.id))
.map((entry) => entry.id)
.slice(0, 3);
pushCard({
id: character.id,
kind: 'character',
title: character.name,
subtitle: [
clampText(character.publicMask || character.publicIdentity, 18),
assetHeadline.label,
]
.filter(Boolean)
.join(' / '),
summary: clampText(character.summary, 180),
linkedIds: [...character.threadIds, ...linkedLandmarks].slice(0, 6),
sections: [
buildSection('name', '角色名', character.name),
buildSection('role', '角色定位', character.role || character.title),
buildSection(
'publicMask',
'外显身份',
character.publicMask || character.publicIdentity,
),
buildSection(
'hiddenHook',
'隐藏钩子',
character.hiddenHook || character.currentPressure,
),
buildSection('relationToPlayer', '玩家关系', character.relationToPlayer),
buildSection('summary', '角色摘要', character.summary),
buildSection(
'threadIds',
'关联线程',
resolveThreadTitles(character.threadIds),
),
],
editableSectionIds: resolveEditableSectionIds('character'),
warningMessages: warnings,
assetStatus: assetHeadline.status,
assetStatusLabel: assetHeadline.label,
});
});
profile.landmarks.forEach((landmark) => {
const warnings = buildLandmarkWarnings(landmark);
pushCard({
id: landmark.id,
kind: 'landmark',
title: landmark.name,
subtitle: clampText(landmark.purpose || landmark.mood, 28),
summary: landmark.summary,
linkedIds: [...landmark.characterIds, ...landmark.threadIds].slice(0, 8),
sections: [
buildSection('name', '地点名', landmark.name),
buildSection('purpose', '地点定位', landmark.purpose),
buildSection('mood', '场景情绪', landmark.mood),
buildSection('secret', '隐藏秘密', landmark.secret || landmark.importance),
buildSection('summary', '地点摘要', landmark.summary),
buildSection(
'characterIds',
'关联角色',
resolveCharacterNames(landmark.characterIds),
),
buildSection(
'threadIds',
'关联线程',
resolveThreadTitles(landmark.threadIds),
),
],
editableSectionIds: resolveEditableSectionIds('landmark'),
warningMessages: warnings,
});
});
profile.chapters.forEach((chapter) => {
const warnings = buildChapterWarnings(chapter);
pushCard({
id: chapter.id,
kind: 'chapter',
title: chapter.title,
subtitle: clampText(chapter.playerGoal, 28),
summary: chapter.summary,
linkedIds: [...chapter.characterIds, ...chapter.landmarkIds].slice(0, 10),
sections: [
buildSection('title', '章节标题', chapter.title),
buildSection('summary', '章节摘要', chapter.summary),
buildSection('openingEvent', '开幕事件', chapter.openingEvent),
buildSection('playerGoal', '玩家目标', chapter.playerGoal),
buildSection(
'characterIds',
'第一批角色',
resolveCharacterNames(chapter.characterIds),
),
buildSection(
'landmarkIds',
'第一批地点',
resolveLandmarkNames(chapter.landmarkIds),
),
buildSection(
'understandingShift',
'第一幕理解变化',
chapter.understandingShift,
),
],
editableSectionIds: resolveEditableSectionIds('chapter'),
warningMessages: warnings,
});
});
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;
}
}
export function getWorldFoundationCardId() {
return WORLD_CARD_ID;
}