Integrate role asset studio into custom world agent flow
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user