1035 lines
32 KiB
TypeScript
1035 lines
32 KiB
TypeScript
import type {
|
||
CustomWorldDraftCardDetail,
|
||
CustomWorldDraftCardDetailSection,
|
||
CustomWorldDraftCardKind,
|
||
CustomWorldDraftCardSummary,
|
||
CustomWorldRoleAssetStatus,
|
||
CustomWorldFoundationDraftCamp,
|
||
CustomWorldFoundationDraftChapter,
|
||
CustomWorldFoundationDraftCharacter,
|
||
CustomWorldFoundationDraftFaction,
|
||
CustomWorldFoundationDraftLandmark,
|
||
CustomWorldFoundationDraftProfile,
|
||
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;
|
||
|
||
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 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];
|
||
return [];
|
||
}
|
||
|
||
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,
|
||
),
|
||
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 || '中',
|
||
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 || '克制、紧绷,但还能暂时收拢局势',
|
||
summary:
|
||
summary ||
|
||
clampText(
|
||
[
|
||
description || '这是玩家当前最稳的回气点',
|
||
dangerLevel || '它承担落脚与整理线索的功能',
|
||
].join(';'),
|
||
120,
|
||
),
|
||
};
|
||
}
|
||
|
||
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 camp = normalizeCamp(record.camp);
|
||
const hasStructuredFoundationContent =
|
||
playableNpcs.length > 0 ||
|
||
storyNpcs.length > 0 ||
|
||
landmarks.length > 0 ||
|
||
factions.length > 0 ||
|
||
threads.length > 0 ||
|
||
chapters.length > 0 ||
|
||
Boolean(camp);
|
||
|
||
if (!hasStructuredFoundationContent) {
|
||
return null;
|
||
}
|
||
|
||
const mergedCharacters = dedupeById([...playableNpcs, ...storyNpcs]);
|
||
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,
|
||
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 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,
|
||
},
|
||
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),
|
||
].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,
|
||
});
|
||
});
|
||
|
||
return cards;
|
||
}
|
||
}
|
||
|
||
export function getWorldFoundationCardId() {
|
||
return WORLD_CARD_ID;
|
||
}
|