1687 lines
54 KiB
TypeScript
1687 lines
54 KiB
TypeScript
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;
|
||
}
|