Files
Genarrative/server-node/src/services/customWorldAgentDraftCompiler.ts

1035 lines
32 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,
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;
}