Integrate role asset studio into custom world agent flow

This commit is contained in:
2026-04-14 20:16:41 +08:00
parent 0981d6ee1b
commit bc2999ffb9
118 changed files with 31211 additions and 1232 deletions

View File

@@ -0,0 +1,821 @@
import type {
CustomWorldFoundationDraftCamp,
CustomWorldFoundationDraftCharacter,
CustomWorldFoundationDraftFaction,
CustomWorldFoundationDraftLandmark,
CustomWorldFoundationDraftProfile,
CustomWorldFoundationDraftThread,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import {
buildDraftSummaryFromIntent,
normalizeCreatorIntentRecord,
type CreatorCharacterSeedRecord,
type CustomWorldCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
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 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 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 dedupeStrings(items: string[], maxCount = 8) {
return [...new Set(items.map((item) => item.trim()).filter(Boolean))].slice(
0,
maxCount,
);
}
function sanitizeEntityName(value: string) {
return value
.replace(/^(||||||||||)/u, '')
.replace(/[,]/gu, '')
.trim();
}
function buildCompactLabel(text: string, fallback: string, maxLength = 14) {
const normalized = sanitizeEntityName(text)
.replace(/^(||||||)/u, '')
.trim();
return clampText(normalized || fallback, maxLength) || fallback;
}
function splitSentences(text: string) {
return text
.split(/[\n]/u)
.map((entry) => entry.trim())
.filter(Boolean);
}
function extractConflictSides(conflict: string) {
const relationMatch = conflict.match(
/([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}?)(?:||)([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}?)(?:||||||)/u,
);
if (relationMatch?.[1] && relationMatch?.[2]) {
return [relationMatch[1].trim(), relationMatch[2].trim()];
}
return [...conflict.matchAll(/([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}(?:|||||||||||||殿|||||))/gu)]
.map((entry) => entry[1]?.trim() || '')
.filter(Boolean)
.slice(0, 3);
}
function extractConflictTarget(conflict: string) {
const matched = conflict.match(
/(?:|||||)([^]{2,20})/u,
);
return clampText(toText(matched?.[1]), 18);
}
function extractPlaceLikePhrase(text: string) {
const patterns = [
/([^]{2,18}?(?:||||||||||||殿|||||||||||||||))(?:||||||)?/u,
/([^]{2,18}?(?:||||||||||||殿|||||||||||||||))(?:|||)?/u,
];
for (const pattern of patterns) {
const matched = text.match(pattern);
const candidate = sanitizeEntityName(toText(matched?.[1]));
if (candidate) {
return clampText(candidate, 16);
}
}
return '';
}
function looksLikePlaceName(value: string) {
return /(||||||||||||殿||||||||||||||||||)/u.test(
value,
);
}
function convertElementToLandmarkName(element: string) {
const normalized = sanitizeEntityName(element);
if (!normalized) {
return '';
}
if (looksLikePlaceName(normalized)) {
return clampText(normalized, 16);
}
if (normalized.endsWith('钟声')) {
return clampText(normalized.replace(/$/u, ''), 16);
}
if (normalized.endsWith('盟约') || normalized.endsWith('残片')) {
return clampText(`${normalized}档库`, 16);
}
if (normalized.endsWith('火')) {
return clampText(`${normalized}哨点`, 16);
}
return clampText(`${normalized}回响区`, 16);
}
function buildWorldName(intent: CustomWorldCreatorIntentRecord) {
const worldHook = sanitizeEntityName(intent.worldHook || intent.rawSettingText);
const namedMatch = worldHook.match(
/([A-Za-z0-9\u4e00-\u9fa5·-]{2,16}(?:||||||||||||||))/u,
);
return (
clampText(namedMatch?.[1] || worldHook || intent.iconicElements[0] || '', 18) ||
'未命名世界底稿'
);
}
function buildTone(intent: CustomWorldCreatorIntentRecord) {
return (
dedupeStrings(
[...intent.themeKeywords, ...intent.toneDirectives, ...intent.iconicElements],
8,
).join('、') || '紧绷、未明、带着继续展开的空间'
);
}
function buildPlayerGoal(params: {
playerPremise: string;
openingSituation: string;
coreConflict: string;
}) {
const conflictTarget = extractConflictTarget(params.coreConflict);
const location = extractPlaceLikePhrase(params.openingSituation);
const lead = location
? `先在${location}站稳`
: params.openingSituation
? `先扛过“${buildCompactLabel(params.openingSituation, '开局风暴', 12)}`
: '先稳住眼前的局势';
const tail = conflictTarget
? `,再查清谁在主导“${conflictTarget}`
: params.coreConflict
? `,再判断自己在“${buildCompactLabel(params.coreConflict, '核心冲突', 12)}”里的站位`
: '';
return clampText(`${lead}${tail}`, 60);
}
function buildFactions(params: {
intent: CustomWorldCreatorIntentRecord;
coreConflicts: string[];
playerPremise: string;
iconicElements: string[];
}): CustomWorldFoundationDraftFaction[] {
const explicitFactions = params.intent.keyFactions.map((entry) => ({
name: sanitizeEntityName(entry.name),
publicGoal: clampText(entry.publicGoal, 28),
relatedConflict:
clampText(entry.tension, 48) || params.coreConflicts[0] || '局势正在升温',
playerRelation: '玩家很难绕开它的影响',
}));
const conflictSideNames = params.coreConflicts.flatMap((entry) =>
extractConflictSides(entry),
);
const fallbackPrefixes = dedupeStrings(
[
...params.iconicElements.map((entry) => buildCompactLabel(entry, '', 6)),
buildCompactLabel(params.intent.worldHook, '', 6),
],
4,
).filter(Boolean);
const fallbackNames = [
fallbackPrefixes[0] ? `${fallbackPrefixes[0]}守望会` : '',
fallbackPrefixes[1] ? `${fallbackPrefixes[1]}商盟` : '',
'旧约议庭',
'灰区中间人',
].filter(Boolean);
const names = dedupeStrings(
[
...explicitFactions.map((entry) => entry.name),
...conflictSideNames,
...fallbackNames,
],
4,
).slice(0, 3);
return names.map((name, index) => {
const explicit = explicitFactions.find((entry) => entry.name === name);
const relatedConflict =
explicit?.relatedConflict ||
params.coreConflicts.find((entry) => entry.includes(name)) ||
params.coreConflicts[index % Math.max(1, params.coreConflicts.length)] ||
'局势仍在快速失衡';
const conflictTarget = extractConflictTarget(relatedConflict);
const publicGoal =
explicit?.publicGoal ||
clampText(
conflictTarget
? `拿下${conflictTarget}的主动解释权`
: '在变局里先一步拿到主动权',
28,
);
const playerRelation =
explicit?.playerRelation ||
clampText(
index === 0
? '它会把玩家当成必须争取的关键变量'
: index === 1
? '它迟早会逼玩家在立场上做选择'
: '它可能提供入口,也可能直接加码风险',
36,
);
return {
id: createId('faction', name, index),
name,
publicGoal,
relatedConflict,
playerRelation,
summary: clampText(
`${name}正在围绕“${buildCompactLabel(relatedConflict, '核心冲突', 16)}”抢先手,公开目标是${publicGoal},并且${playerRelation}`,
140,
),
};
});
}
function buildBaseThreads(params: {
intent: CustomWorldCreatorIntentRecord;
coreConflicts: string[];
playerPremise: string;
openingSituation: string;
iconicElements: string[];
}): CustomWorldFoundationDraftThread[] {
const firstConflict = params.coreConflicts[0] || '旧秩序与新力量正在拉扯世界走向';
const hiddenSeed =
params.intent.keyCharacters.find((entry) => entry.hiddenHook.trim())?.hiddenHook ||
params.iconicElements[0] ||
'表面冲突背后还有更深的一层';
const relationshipSeed =
params.intent.keyCharacters.find((entry) => entry.relationToPlayer.trim())
?.relationToPlayer ||
params.playerPremise ||
params.openingSituation;
const extraSeed = params.coreConflicts[1] || params.iconicElements[1] || '';
const seeds = [
{
title: buildCompactLabel(firstConflict, '主线推进', 16),
type: 'main' as const,
conflict: firstConflict,
summary: clampText(`明线围绕“${firstConflict}”推进,玩家一入局就会被迫参与。`, 90),
},
{
title: buildCompactLabel(hiddenSeed, '暗线回潮', 16),
type: 'hidden' as const,
conflict: hiddenSeed,
summary: clampText(`暗线真正牵动的不是表面立场,而是“${buildCompactLabel(hiddenSeed, '暗线真相', 18)}”。`, 90),
},
{
title: buildCompactLabel(relationshipSeed, '关系裂口', 16),
type: 'main' as const,
conflict: relationshipSeed,
summary: clampText(`玩家身边的关系与身份会决定这条线最先从哪里裂开。`, 90),
},
...(extraSeed
? [
{
title: buildCompactLabel(extraSeed, '余波扩散', 16),
type: 'hidden' as const,
conflict: extraSeed,
summary: clampText(`这条线负责把世界里更深的余波慢慢带出来。`, 90),
},
]
: []),
];
return seeds.slice(0, 4).map((entry, index) => ({
id: createId('thread', entry.title, index),
title: entry.title,
type: entry.type,
conflict: clampText(entry.conflict, 72),
characterIds: [],
landmarkIds: [],
summary: entry.summary,
}));
}
function buildPlayerProxyCharacter(
intent: CustomWorldCreatorIntentRecord,
threads: CustomWorldFoundationDraftThread[],
coreConflict: string,
): CustomWorldFoundationDraftCharacter | null {
const playerPremise = sanitizeEntityName(intent.playerPremise);
if (!playerPremise) {
return null;
}
const mainThreadId = threads[0]?.id ?? null;
const relationThreadId = threads[2]?.id ?? threads[1]?.id ?? null;
const name = buildCompactLabel(playerPremise, '玩家前线身份', 10);
return {
id: createId('character', name, 0),
name,
title: '玩家前线身份',
role: playerPremise,
publicIdentity: playerPremise,
currentPressure:
clampText(intent.openingSituation || coreConflict, 48) ||
'必须先扛过眼前的局势压力',
relationToPlayer: '这是玩家当前最贴近世界的切入口',
threadIds: [mainThreadId, relationThreadId].filter(
(entry): entry is string => Boolean(entry),
),
summary: clampText(
`${playerPremise}被直接推到台前,眼下压力是“${buildCompactLabel(intent.openingSituation || coreConflict, '开局压力', 18)}”。`,
120,
),
};
}
function buildCharacterFromSeed(params: {
seed: CreatorCharacterSeedRecord;
index: number;
threads: CustomWorldFoundationDraftThread[];
coreConflict: string;
}): CustomWorldFoundationDraftCharacter {
const hiddenThreadId = params.threads.find((entry) => entry.type === 'hidden')?.id;
const mainThreadId = params.threads[0]?.id ?? null;
const relationThreadId = params.threads[2]?.id ?? hiddenThreadId ?? null;
return {
id: params.seed.id || createId('character', params.seed.name || params.seed.role, params.index),
name:
sanitizeEntityName(params.seed.name) ||
buildCompactLabel(params.seed.role || params.seed.relationToPlayer, '关键角色', 10),
title: clampText(params.seed.role || '关键人物', 18) || '关键人物',
role: clampText(params.seed.role || '关键人物', 28) || '关键人物',
publicIdentity:
clampText(params.seed.publicMask || params.seed.role || '站在当前局势前台的人', 36) ||
'站在当前局势前台的人',
currentPressure:
clampText(params.seed.hiddenHook || params.coreConflict, 48) ||
'正在被当前局势不断加压',
relationToPlayer:
clampText(params.seed.relationToPlayer || '会直接改变玩家的第一步选择', 36) ||
'会直接改变玩家的第一步选择',
threadIds: dedupeStrings(
[
params.seed.hiddenHook ? hiddenThreadId ?? '' : '',
params.seed.relationToPlayer ? relationThreadId ?? '' : '',
mainThreadId ?? '',
],
3,
),
summary: clampText(
`${params.seed.publicMask || params.seed.role || '表面上像是立场前台的人'};当前压力是${params.seed.hiddenHook || '必须在明暗两条线上同时做选择'};与玩家关系是${params.seed.relationToPlayer || '会直接左右玩家的站位'}`,
130,
),
};
}
function buildGeneratedCharacters(params: {
existingNames: string[];
factions: CustomWorldFoundationDraftFaction[];
threads: CustomWorldFoundationDraftThread[];
iconicElements: string[];
coreConflict: string;
}): CustomWorldFoundationDraftCharacter[] {
const suffixes = ['联络人', '记录官', '引路人', '修补匠', '代言人'];
const generated: CustomWorldFoundationDraftCharacter[] = [];
const mainThreadId = params.threads[0]?.id ?? null;
const hiddenThreadId = params.threads.find((entry) => entry.type === 'hidden')?.id;
const relationThreadId = params.threads[2]?.id ?? mainThreadId;
params.factions.forEach((faction, index) => {
const prefix =
buildCompactLabel(faction.name.replace(/(||||||||)$/u, ''), '', 6) ||
buildCompactLabel(params.iconicElements[index] || '', '关键', 6);
const name = `${prefix}${suffixes[index % suffixes.length]}`;
if (params.existingNames.includes(name)) {
return;
}
generated.push({
id: createId('character', name, generated.length + 1),
name,
title: '关键阵营接口人',
role: `${faction.name}在前台推动局势的人`,
publicIdentity: `${faction.name}的前台接口人`,
currentPressure: faction.relatedConflict || params.coreConflict,
relationToPlayer:
index === 0 ? '会主动把玩家拉进局势中心' : '对玩家既有利用价值也有试探意图',
threadIds: dedupeStrings(
[mainThreadId ?? '', index % 2 === 0 ? relationThreadId ?? '' : hiddenThreadId ?? ''],
3,
),
summary: clampText(
`${name}代表${faction.name}在前台出手,眼下压力直指“${buildCompactLabel(faction.relatedConflict || params.coreConflict, '局势升级', 18)}”,同时会主动试探玩家的站位。`,
130,
),
});
});
return generated;
}
function buildCharacters(params: {
intent: CustomWorldCreatorIntentRecord;
factions: CustomWorldFoundationDraftFaction[];
threads: CustomWorldFoundationDraftThread[];
coreConflicts: string[];
iconicElements: string[];
}) {
const firstConflict = params.coreConflicts[0] || '旧秩序与新力量正在拉扯世界走向';
const characters: CustomWorldFoundationDraftCharacter[] = [];
const playerProxy = buildPlayerProxyCharacter(
params.intent,
params.threads,
firstConflict,
);
if (playerProxy) {
characters.push(playerProxy);
}
params.intent.keyCharacters.forEach((seed, index) => {
characters.push(
buildCharacterFromSeed({
seed,
index: index + 1,
threads: params.threads,
coreConflict: firstConflict,
}),
);
});
const generated = buildGeneratedCharacters({
existingNames: characters.map((entry) => entry.name),
factions: params.factions,
threads: params.threads,
iconicElements: params.iconicElements,
coreConflict: firstConflict,
});
generated.forEach((entry) => {
if (characters.some((item) => item.name === entry.name)) {
return;
}
characters.push(entry);
});
return dedupeStrings(characters.map((entry) => entry.name), 5).map(
(name) => characters.find((entry) => entry.name === name)!,
);
}
function buildCamp(params: {
openingSituation: string;
worldHook: string;
iconicElements: string[];
}): CustomWorldFoundationDraftCamp {
const openingPlace = extractPlaceLikePhrase(params.openingSituation);
const prefix =
openingPlace ||
buildCompactLabel(params.iconicElements[0] || params.worldHook, '归返', 6);
const name = looksLikePlaceName(prefix) ? `${prefix}守望舍` : `${prefix}前哨`;
return {
id: 'camp-home',
name: clampText(name, 16),
description: clampText(
openingPlace
? `贴着${openingPlace}搭起来的临时落脚处,玩家还能在这里喘口气和整理线索。`
: '玩家暂时还能整顿情报、换口气并决定下一步站位的落脚处。',
72,
),
mood: '克制、紧绷,但还有一点能重新收住局势的余地',
summary: clampText(
`${clampText(name, 12)}不是安全区,而是玩家在风暴边缘还能勉强站稳的一块地方。`,
88,
),
};
}
function buildLandmarks(params: {
intent: CustomWorldCreatorIntentRecord;
camp: CustomWorldFoundationDraftCamp;
factions: CustomWorldFoundationDraftFaction[];
characters: CustomWorldFoundationDraftCharacter[];
threads: CustomWorldFoundationDraftThread[];
coreConflicts: string[];
iconicElements: string[];
openingSituation: string;
}): CustomWorldFoundationDraftLandmark[] {
const explicit = params.intent.keyLandmarks.map((entry) => ({
name: clampText(sanitizeEntityName(entry.name), 16),
purpose: clampText(entry.purpose, 24) || '承接关键剧情推进',
mood: clampText(entry.mood, 24) || '带着明显的情绪指向',
importance:
clampText(entry.secret, 36) || '和当前主线冲突直接勾连的关键地点',
}));
const openingPlace = extractPlaceLikePhrase(params.openingSituation);
const conflictTarget = extractConflictTarget(params.coreConflicts[0] || '');
const derivedNames = dedupeStrings(
[
...explicit.map((entry) => entry.name),
openingPlace,
...params.iconicElements.map((entry) => convertElementToLandmarkName(entry)),
conflictTarget
? looksLikePlaceName(conflictTarget)
? conflictTarget
: `${conflictTarget}争议带`
: '',
`${buildCompactLabel(params.factions[0]?.name || params.camp.name, '前线', 8)}前场`,
'旧档案库',
'灰雾渡口',
],
6,
).slice(0, 5);
return derivedNames.map((name, index) => {
const explicitEntry = explicit.find((entry) => entry.name === name);
const threadIds = dedupeStrings(
[
params.threads[index % Math.max(1, params.threads.length)]?.id ?? '',
params.threads[(index + 1) % Math.max(1, params.threads.length)]?.id ?? '',
],
3,
);
const characterIds = dedupeStrings(
[
params.characters[index % Math.max(1, params.characters.length)]?.id ?? '',
params.characters[(index + 1) % Math.max(1, params.characters.length)]?.id ?? '',
],
3,
);
return {
id: createId('landmark', name, index),
name,
purpose:
explicitEntry?.purpose ||
clampText(
index === 0
? '玩家最先被推到局势前台的位置'
: index === 1
? '不同立场开始交锋和试探的地方'
: '把世界气质、冲突和人物同时挂住的关键地标',
28,
),
mood:
explicitEntry?.mood ||
clampText(
index === 0
? '第一眼就能感到风暴逼近'
: index === 1
? '压迫里带着可探索的缝隙'
: '既有吸引力,也有明显风险感',
24,
),
importance:
explicitEntry?.importance ||
clampText(
`${name}和“${buildCompactLabel(params.coreConflicts[0] || params.threads[0]?.title || '主线推进', '主线', 16)}”直接勾连,玩家第一次抵达时就会意识到它不只是背景。`,
60,
),
characterIds,
threadIds,
summary: clampText(
`${name}承担${explicitEntry?.purpose || '主线推进'},会把${characterIds.length > 0 ? '关键人物' : '局势压力'}直接挂到玩家面前。`,
120,
),
};
});
}
function finalizeThreads(params: {
threads: CustomWorldFoundationDraftThread[];
characters: CustomWorldFoundationDraftCharacter[];
landmarks: CustomWorldFoundationDraftLandmark[];
}) {
return params.threads.map((thread) => {
const characterIds = params.characters
.filter((entry) => entry.threadIds.includes(thread.id))
.map((entry) => entry.id)
.slice(0, 4);
const landmarkIds = params.landmarks
.filter((entry) => entry.threadIds.includes(thread.id))
.map((entry) => entry.id)
.slice(0, 4);
return {
...thread,
characterIds,
landmarkIds,
summary: clampText(
`${thread.type === 'hidden' ? '暗线' : '明线'}围绕“${buildCompactLabel(thread.conflict, thread.title, 18)}”推进,相关对象包括${[
characterIds.length > 0 ? `${characterIds.length} 名关键角色` : '',
landmarkIds.length > 0 ? `${landmarkIds.length} 个关键地点` : '',
]
.filter(Boolean)
.join('、') || '当前第一批底稿对象'}`,
120,
),
};
});
}
function buildChapter(params: {
worldName: string;
openingSituation: string;
playerGoal: string;
characters: CustomWorldFoundationDraftCharacter[];
landmarks: CustomWorldFoundationDraftLandmark[];
threads: CustomWorldFoundationDraftThread[];
}) {
const openingEvent =
clampText(params.openingSituation, 60) ||
`玩家被迫卷入“${buildCompactLabel(params.threads[0]?.conflict || '', '主线冲突', 18)}”。`;
const characterIds = params.characters.slice(0, 3).map((entry) => entry.id);
const landmarkIds = params.landmarks.slice(0, 3).map((entry) => entry.id);
const hiddenThread = params.threads.find((entry) => entry.type === 'hidden');
return {
id: 'chapter-first-act',
title: clampText(`第一幕:${buildCompactLabel(params.worldName, '世界开幕', 12)}`, 18),
openingEvent,
playerGoal: params.playerGoal,
characterIds,
landmarkIds,
understandingShift: clampText(
hiddenThread
? `第一幕结束时,玩家会意识到“${buildCompactLabel(hiddenThread.conflict, hiddenThread.title, 18)}”并不是背景噪音,而是会反过来改写主线走向。`
: '第一幕结束时,玩家会意识到这场冲突远不止表面那一层。',
72,
),
summary: clampText(
`${openingEvent} 玩家第一步要做的不是立刻解决一切,而是先在${params.landmarks[0]?.name || '关键地点'}站稳,并看清${params.characters[0]?.name || '关键角色'}等人分别在推什么。`,
140,
),
};
}
export class CustomWorldAgentFoundationDraftService {
generate(params: {
creatorIntent: unknown;
anchorPack: unknown;
}): CustomWorldFoundationDraftProfile {
const intent = normalizeCreatorIntentRecord(params.creatorIntent) ?? {
sourceMode: 'freeform' as const,
rawSettingText: '',
worldHook: '',
themeKeywords: [],
toneDirectives: [],
playerPremise: '',
openingSituation: '',
coreConflicts: [],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: [],
forbiddenDirectives: [],
};
const anchorPack = toRecord(params.anchorPack);
const worldHook =
clampText(intent.worldHook || intent.rawSettingText, 72) ||
'一个仍在失衡边缘不断扩张的世界';
const playerPremise =
clampText(intent.playerPremise, 72) || '玩家是一名被卷进局势中心的行动者';
const openingSituation =
clampText(intent.openingSituation, 72) ||
'故事开局时,玩家已经站在必须立刻选边的位置上';
const coreConflicts =
dedupeStrings(intent.coreConflicts, 4).length > 0
? dedupeStrings(intent.coreConflicts, 4)
: ['旧秩序与新力量正在争夺这个世界的解释权'];
const iconicElements = dedupeStrings(intent.iconicElements, 6);
const tone = buildTone(intent);
const worldName = buildWorldName(intent);
const playerGoal = buildPlayerGoal({
playerPremise,
openingSituation,
coreConflict: coreConflicts[0] || '',
});
const factions = buildFactions({
intent,
coreConflicts,
playerPremise,
iconicElements,
});
const baseThreads = buildBaseThreads({
intent,
coreConflicts,
playerPremise,
openingSituation,
iconicElements,
});
const characters = buildCharacters({
intent,
factions,
threads: baseThreads,
coreConflicts,
iconicElements,
}).slice(0, 5);
const camp = buildCamp({
openingSituation,
worldHook,
iconicElements,
});
const landmarks = buildLandmarks({
intent,
camp,
factions,
characters,
threads: baseThreads,
coreConflicts,
iconicElements,
openingSituation,
}).slice(0, 6);
const threads = finalizeThreads({
threads: baseThreads.slice(0, 4),
characters,
landmarks,
});
const chapter = buildChapter({
worldName,
openingSituation,
playerGoal,
characters,
landmarks,
threads,
});
const uniquePoint =
iconicElements.length > 0
? `最抓人的记忆点是${iconicElements.slice(0, 2).join('、')}`
: '这个世界的吸引力来自它正在失衡中的人和秩序';
const summary = clampText(
`${worldHook} 玩家会以“${playerPremise}”切入这个世界,眼下最直接的冲突是“${coreConflicts[0]}”。${uniquePoint}`,
180,
);
return {
name: worldName,
subtitle:
clampText(
[buildCompactLabel(playerPremise, '玩家视角', 12), buildCompactLabel(coreConflicts[0] || '', '核心冲突', 16)]
.filter(Boolean)
.join(' · '),
40,
) || '第一版世界底稿',
summary,
tone,
playerGoal,
majorFactions: factions.map((entry) => entry.name),
coreConflicts,
playableNpcs: characters,
storyNpcs: [],
landmarks,
camp,
themePack: null,
storyGraph: null,
factions,
threads,
chapters: [chapter],
worldHook,
playerPremise,
openingSituation,
iconicElements,
sourceAnchorSummary:
toText(anchorPack?.creatorIntentSummary) ||
buildDraftSummaryFromIntent(intent) ||
summary,
};
}
}