1902 lines
61 KiB
TypeScript
1902 lines
61 KiB
TypeScript
import type {
|
||
CustomWorldFoundationDraftCamp,
|
||
CustomWorldFoundationDraftCharacter,
|
||
CustomWorldFoundationDraftFaction,
|
||
CustomWorldFoundationDraftLandmark,
|
||
CustomWorldFoundationDraftProfile,
|
||
CustomWorldFoundationDraftThread,
|
||
EightAnchorContent,
|
||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
|
||
import {
|
||
FOUNDATION_JSON_ONLY_SYSTEM_PROMPT,
|
||
FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT,
|
||
} from '../prompts/customWorldAgentPrompts.js';
|
||
import {
|
||
buildCustomWorldFrameworkJsonRepairPrompt,
|
||
buildCustomWorldFrameworkPrompt,
|
||
buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt,
|
||
buildCustomWorldLandmarkNetworkBatchPrompt,
|
||
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt,
|
||
buildCustomWorldLandmarkSeedBatchPrompt,
|
||
buildCustomWorldRoleBatchJsonRepairPrompt,
|
||
buildCustomWorldRoleBatchPrompt,
|
||
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
|
||
buildCustomWorldRoleOutlineBatchPrompt,
|
||
} from '../prompts/customWorldPrompts.js';
|
||
import {
|
||
buildCompiledCustomWorldProfile,
|
||
buildCustomWorldRawProfileFromFramework,
|
||
type CustomWorldGenerationFramework,
|
||
type CustomWorldGenerationLandmarkOutline,
|
||
type CustomWorldGenerationRoleBatchStage,
|
||
type CustomWorldGenerationRoleBatchType,
|
||
type CustomWorldGenerationRoleOutline,
|
||
normalizeCustomWorldGenerationFramework,
|
||
normalizeCustomWorldGenerationLandmarkOutlineBatch,
|
||
normalizeCustomWorldGenerationRoleOutlineBatch,
|
||
} from '../modules/custom-world/runtimeProfile.js';
|
||
import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js';
|
||
import {
|
||
buildDraftSummaryFromIntent,
|
||
type CreatorCharacterSeedRecord,
|
||
type CustomWorldCreatorIntentRecord,
|
||
normalizeCreatorIntentRecord,
|
||
} from './customWorldAgentIntentExtractionService.js';
|
||
import {
|
||
buildCreatorIntentFromEightAnchorContent,
|
||
buildDraftSummaryFromEightAnchorContent,
|
||
buildDraftTitleFromEightAnchorContent,
|
||
buildEightAnchorFoundationText,
|
||
normalizeEightAnchorContent,
|
||
} from './eightAnchorCompatibilityService.js';
|
||
import type { UpstreamLlmClient } from './llmClient.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 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,
|
||
),
|
||
};
|
||
}
|
||
|
||
const FOUNDATION_DRAFT_PLAYABLE_COUNT = 3;
|
||
const FOUNDATION_DRAFT_STORY_COUNT = 6;
|
||
const FOUNDATION_DRAFT_LANDMARK_COUNT = 4;
|
||
const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE = 2;
|
||
const FOUNDATION_LANDMARK_BATCH_SIZE = 2;
|
||
const FOUNDATION_ROLE_DETAIL_BATCH_SIZE = 2;
|
||
const FOUNDATION_LLM_TIMEOUT_MS = 90000;
|
||
|
||
type DraftProgressPayload = {
|
||
phaseLabel: string;
|
||
phaseDetail: string;
|
||
progress: number;
|
||
};
|
||
|
||
type DraftProgressCallback = (
|
||
payload: DraftProgressPayload,
|
||
) => void | Promise<void>;
|
||
|
||
type MergeableNamedRecord = {
|
||
name: string;
|
||
};
|
||
|
||
function getNamedRecordKey(value: unknown) {
|
||
return toText(value).replace(/\s+/gu, '');
|
||
}
|
||
|
||
function chunkArray<T>(items: T[], size: number) {
|
||
if (size <= 0 || items.length === 0) {
|
||
return items.length === 0 ? [] : [items];
|
||
}
|
||
|
||
const chunks: T[][] = [];
|
||
for (let index = 0; index < items.length; index += size) {
|
||
chunks.push(items.slice(index, index + size));
|
||
}
|
||
return chunks;
|
||
}
|
||
|
||
function mergeRoleBatchDetails<T extends MergeableNamedRecord>(
|
||
baseEntries: T[],
|
||
detailEntries: Array<Record<string, unknown>>,
|
||
) {
|
||
const nextEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
|
||
const availableIndexes = new Set(nextEntries.map((_, index) => index));
|
||
const indexByName = new Map<string, number>();
|
||
|
||
nextEntries.forEach((entry, index) => {
|
||
const name = getNamedRecordKey(entry.name);
|
||
if (name) {
|
||
indexByName.set(name, index);
|
||
}
|
||
});
|
||
|
||
detailEntries.forEach((detail) => {
|
||
const detailName = getNamedRecordKey(detail.name);
|
||
let targetIndex =
|
||
detailName && indexByName.has(detailName)
|
||
? indexByName.get(detailName)
|
||
: undefined;
|
||
|
||
if (targetIndex === undefined) {
|
||
for (const index of availableIndexes) {
|
||
targetIndex = index;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (targetIndex === undefined) {
|
||
return;
|
||
}
|
||
|
||
const baseEntry = nextEntries[targetIndex];
|
||
if (!baseEntry) {
|
||
return;
|
||
}
|
||
|
||
nextEntries[targetIndex] = {
|
||
...baseEntry,
|
||
...detail,
|
||
name: getNamedRecordKey(baseEntry.name) || detailName || baseEntry.name,
|
||
} as T;
|
||
availableIndexes.delete(targetIndex);
|
||
});
|
||
|
||
return nextEntries;
|
||
}
|
||
|
||
function appendUniqueNamedEntries<T extends MergeableNamedRecord>(
|
||
baseEntries: T[],
|
||
nextEntries: T[],
|
||
maxCount: number,
|
||
) {
|
||
const merged = baseEntries.map((entry) => ({ ...entry })) as T[];
|
||
const existingNames = new Set(
|
||
merged.map((entry) => getNamedRecordKey(entry.name)).filter(Boolean),
|
||
);
|
||
|
||
nextEntries.forEach((entry) => {
|
||
if (merged.length >= maxCount) {
|
||
return;
|
||
}
|
||
|
||
const name = getNamedRecordKey(entry.name);
|
||
if (!name || existingNames.has(name)) {
|
||
return;
|
||
}
|
||
|
||
merged.push({ ...entry, name } as T);
|
||
existingNames.add(name);
|
||
});
|
||
|
||
return merged;
|
||
}
|
||
|
||
function extractJsonPayload(text: string) {
|
||
const trimmed = text.trim();
|
||
if (!trimmed) {
|
||
return '';
|
||
}
|
||
|
||
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu);
|
||
const unfenced = fencedMatch?.[1]?.trim() || trimmed;
|
||
const firstBrace = unfenced.indexOf('{');
|
||
const firstBracket = unfenced.indexOf('[');
|
||
const starts = [firstBrace, firstBracket].filter((value) => value >= 0);
|
||
const start = starts.length > 0 ? Math.min(...starts) : -1;
|
||
|
||
if (start < 0) {
|
||
return unfenced;
|
||
}
|
||
|
||
const opener = unfenced[start];
|
||
const closer = opener === '[' ? ']' : '}';
|
||
const end = unfenced.lastIndexOf(closer);
|
||
if (end > start) {
|
||
return unfenced.slice(start, end + 1);
|
||
}
|
||
|
||
return unfenced.slice(start);
|
||
}
|
||
|
||
function sanitizeJsonLikeText(text: string) {
|
||
return extractJsonPayload(text)
|
||
.replace(/^\uFEFF/u, '')
|
||
.replace(/[\u201C\u201D]/gu, '"')
|
||
.replace(/[\u2018\u2019]/gu, "'")
|
||
.replace(/\u00A0/gu, ' ')
|
||
.replace(/,\s*([}\]])/gu, '$1')
|
||
.trim();
|
||
}
|
||
|
||
function buildFoundationGenerationSeedText(params: {
|
||
intent: CustomWorldCreatorIntentRecord;
|
||
anchorPack: unknown;
|
||
anchorContent?: EightAnchorContent | null;
|
||
}) {
|
||
const anchorText = params.anchorContent
|
||
? buildEightAnchorFoundationText(params.anchorContent)
|
||
: '';
|
||
if (anchorText) {
|
||
return anchorText;
|
||
}
|
||
|
||
const anchorRecord = toRecord(params.anchorPack);
|
||
const anchorSummary = toText(anchorRecord?.creatorIntentSummary);
|
||
if (anchorSummary) {
|
||
return anchorSummary;
|
||
}
|
||
|
||
const sections = [
|
||
params.intent.worldHook ? `世界核心:${params.intent.worldHook}` : '',
|
||
params.intent.playerPremise
|
||
? `玩家身份:${params.intent.playerPremise}`
|
||
: '',
|
||
params.intent.openingSituation
|
||
? `开局处境:${params.intent.openingSituation}`
|
||
: '',
|
||
params.intent.coreConflicts.length > 0
|
||
? `核心冲突:${params.intent.coreConflicts.join('、')}`
|
||
: '',
|
||
params.intent.iconicElements.length > 0
|
||
? `标志元素:${params.intent.iconicElements.join('、')}`
|
||
: '',
|
||
].filter(Boolean);
|
||
|
||
return sections.join('\n') || buildDraftSummaryFromIntent(params.intent);
|
||
}
|
||
|
||
async function emitDraftProgress(
|
||
onProgress: DraftProgressCallback | undefined,
|
||
payload: DraftProgressPayload,
|
||
) {
|
||
if (!onProgress) {
|
||
return;
|
||
}
|
||
|
||
await onProgress({
|
||
...payload,
|
||
progress: Math.max(0, Math.min(100, Math.round(payload.progress))),
|
||
});
|
||
}
|
||
|
||
function toBatchProgress(
|
||
start: number,
|
||
end: number,
|
||
completed: number,
|
||
total: number,
|
||
) {
|
||
if (total <= 0) {
|
||
return end;
|
||
}
|
||
|
||
const ratio = Math.max(0, Math.min(1, completed / total));
|
||
return start + (end - start) * ratio;
|
||
}
|
||
|
||
async function requestFoundationJsonStage(params: {
|
||
llmClient: UpstreamLlmClient;
|
||
userPrompt: string;
|
||
debugLabel: string;
|
||
repairPromptBuilder: (responseText: string) => string;
|
||
repairDebugLabel: string;
|
||
emptyResponseMessage: string;
|
||
signal?: AbortSignal;
|
||
}) {
|
||
const responseText = await params.llmClient.requestMessageContent({
|
||
systemPrompt: FOUNDATION_JSON_ONLY_SYSTEM_PROMPT,
|
||
userPrompt: params.userPrompt,
|
||
signal: params.signal,
|
||
timeoutMs: FOUNDATION_LLM_TIMEOUT_MS,
|
||
debugLabel: params.debugLabel,
|
||
});
|
||
|
||
const text = typeof responseText === 'string' ? responseText.trim() : '';
|
||
if (!text) {
|
||
throw new Error(params.emptyResponseMessage);
|
||
}
|
||
|
||
try {
|
||
return parseJsonResponseText(text);
|
||
} catch {
|
||
const sanitized = sanitizeJsonLikeText(text);
|
||
if (sanitized && sanitized !== text) {
|
||
try {
|
||
return parseJsonResponseText(sanitized);
|
||
} catch {
|
||
// Fall through to model-assisted repair.
|
||
}
|
||
}
|
||
|
||
const repairedText = await params.llmClient.requestMessageContent({
|
||
systemPrompt: FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT,
|
||
userPrompt: params.repairPromptBuilder(text),
|
||
signal: params.signal,
|
||
timeoutMs: Math.min(FOUNDATION_LLM_TIMEOUT_MS, 60000),
|
||
debugLabel: params.repairDebugLabel,
|
||
});
|
||
|
||
return parseJsonResponseText(
|
||
sanitizeJsonLikeText(repairedText) || repairedText,
|
||
);
|
||
}
|
||
}
|
||
|
||
async function generateFoundationRoleOutlineEntries(params: {
|
||
llmClient: UpstreamLlmClient;
|
||
framework: CustomWorldGenerationFramework;
|
||
roleType: CustomWorldGenerationRoleBatchType;
|
||
totalCount: number;
|
||
batchSize: number;
|
||
signal?: AbortSignal;
|
||
onProgress?: DraftProgressCallback;
|
||
progressRange: [number, number];
|
||
}) {
|
||
const plannedBatchCount = Math.max(
|
||
1,
|
||
Math.ceil(params.totalCount / params.batchSize),
|
||
);
|
||
const roleLabel = params.roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||
let mergedEntries: CustomWorldGenerationRoleOutline[] = [];
|
||
|
||
for (
|
||
let batchIndex = 0;
|
||
batchIndex < plannedBatchCount && mergedEntries.length < params.totalCount;
|
||
batchIndex += 1
|
||
) {
|
||
const batchCount = Math.min(
|
||
params.batchSize,
|
||
params.totalCount - mergedEntries.length,
|
||
);
|
||
await emitDraftProgress(params.onProgress, {
|
||
phaseLabel: `生成${roleLabel}`,
|
||
phaseDetail: `正在生成${roleLabel}第 ${batchIndex + 1} / ${plannedBatchCount} 批,当前已完成 ${mergedEntries.length}/${params.totalCount}。`,
|
||
progress: toBatchProgress(
|
||
params.progressRange[0],
|
||
params.progressRange[1],
|
||
mergedEntries.length,
|
||
params.totalCount,
|
||
),
|
||
});
|
||
|
||
const batchRaw = await requestFoundationJsonStage({
|
||
llmClient: params.llmClient,
|
||
userPrompt: buildCustomWorldRoleOutlineBatchPrompt({
|
||
framework: params.framework,
|
||
roleType: params.roleType,
|
||
batchCount,
|
||
forbiddenNames: mergedEntries.map((entry) => entry.name),
|
||
}),
|
||
debugLabel: `agent-foundation-${params.roleType}-outline-batch-${batchIndex + 1}`,
|
||
repairPromptBuilder: (responseText) =>
|
||
buildCustomWorldRoleOutlineBatchJsonRepairPrompt({
|
||
responseText,
|
||
roleType: params.roleType,
|
||
expectedCount: batchCount,
|
||
forbiddenNames: mergedEntries.map((entry) => entry.name),
|
||
}),
|
||
repairDebugLabel: `agent-foundation-${params.roleType}-outline-batch-${batchIndex + 1}-json-repair`,
|
||
emptyResponseMessage: `${roleLabel}第 ${batchIndex + 1} 批没有返回有效内容。`,
|
||
signal: params.signal,
|
||
});
|
||
|
||
mergedEntries = appendUniqueNamedEntries(
|
||
mergedEntries,
|
||
normalizeCustomWorldGenerationRoleOutlineBatch(
|
||
batchRaw,
|
||
params.roleType,
|
||
) as MergeableNamedRecord[] as CustomWorldGenerationRoleOutline[],
|
||
params.totalCount,
|
||
);
|
||
}
|
||
|
||
await emitDraftProgress(params.onProgress, {
|
||
phaseLabel: `生成${roleLabel}`,
|
||
phaseDetail: `${roleLabel}名单已整理完成,共 ${mergedEntries.length} 个。`,
|
||
progress: params.progressRange[1],
|
||
});
|
||
|
||
return mergedEntries;
|
||
}
|
||
|
||
async function generateFoundationLandmarkSeedEntries(params: {
|
||
llmClient: UpstreamLlmClient;
|
||
framework: CustomWorldGenerationFramework;
|
||
totalCount: number;
|
||
batchSize: number;
|
||
signal?: AbortSignal;
|
||
onProgress?: DraftProgressCallback;
|
||
progressRange: [number, number];
|
||
}) {
|
||
const plannedBatchCount = Math.max(
|
||
1,
|
||
Math.ceil(params.totalCount / params.batchSize),
|
||
);
|
||
let mergedEntries: CustomWorldGenerationLandmarkOutline[] = [];
|
||
|
||
for (
|
||
let batchIndex = 0;
|
||
batchIndex < plannedBatchCount && mergedEntries.length < params.totalCount;
|
||
batchIndex += 1
|
||
) {
|
||
const batchCount = Math.min(
|
||
params.batchSize,
|
||
params.totalCount - mergedEntries.length,
|
||
);
|
||
await emitDraftProgress(params.onProgress, {
|
||
phaseLabel: '生成关键场景',
|
||
phaseDetail: `正在生成关键场景第 ${batchIndex + 1} / ${plannedBatchCount} 批,当前已完成 ${mergedEntries.length}/${params.totalCount}。`,
|
||
progress: toBatchProgress(
|
||
params.progressRange[0],
|
||
params.progressRange[1],
|
||
mergedEntries.length,
|
||
params.totalCount,
|
||
),
|
||
});
|
||
|
||
const batchRaw = await requestFoundationJsonStage({
|
||
llmClient: params.llmClient,
|
||
userPrompt: buildCustomWorldLandmarkSeedBatchPrompt({
|
||
framework: params.framework,
|
||
batchCount,
|
||
forbiddenNames: mergedEntries.map((entry) => entry.name),
|
||
}),
|
||
debugLabel: `agent-foundation-landmark-seed-batch-${batchIndex + 1}`,
|
||
repairPromptBuilder: (responseText) =>
|
||
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt({
|
||
responseText,
|
||
expectedCount: batchCount,
|
||
forbiddenNames: mergedEntries.map((entry) => entry.name),
|
||
}),
|
||
repairDebugLabel: `agent-foundation-landmark-seed-batch-${batchIndex + 1}-json-repair`,
|
||
emptyResponseMessage: `关键场景第 ${batchIndex + 1} 批没有返回有效内容。`,
|
||
signal: params.signal,
|
||
});
|
||
|
||
mergedEntries = appendUniqueNamedEntries(
|
||
mergedEntries,
|
||
normalizeCustomWorldGenerationLandmarkOutlineBatch(
|
||
batchRaw,
|
||
) as MergeableNamedRecord[] as CustomWorldGenerationLandmarkOutline[],
|
||
params.totalCount,
|
||
);
|
||
}
|
||
|
||
await emitDraftProgress(params.onProgress, {
|
||
phaseLabel: '生成关键场景',
|
||
phaseDetail: `关键场景骨架已整理完成,共 ${mergedEntries.length} 个。`,
|
||
progress: params.progressRange[1],
|
||
});
|
||
|
||
return mergedEntries;
|
||
}
|
||
|
||
async function expandFoundationLandmarkNetworkEntries(params: {
|
||
llmClient: UpstreamLlmClient;
|
||
framework: CustomWorldGenerationFramework;
|
||
storyNpcs: CustomWorldGenerationFramework['storyNpcs'];
|
||
baseEntries: CustomWorldGenerationLandmarkOutline[];
|
||
batchSize: number;
|
||
signal?: AbortSignal;
|
||
onProgress?: DraftProgressCallback;
|
||
progressRange: [number, number];
|
||
}) {
|
||
let mergedEntries = params.baseEntries.map((entry) => ({ ...entry }));
|
||
const batches = chunkArray(params.framework.landmarks, params.batchSize);
|
||
let processedCount = 0;
|
||
|
||
for (const [batchIndex, landmarkBatch] of batches.entries()) {
|
||
await emitDraftProgress(params.onProgress, {
|
||
phaseLabel: '建立场景连接',
|
||
phaseDetail: `正在补全场景连接第 ${batchIndex + 1} / ${batches.length} 批,当前已完成 ${processedCount}/${params.framework.landmarks.length}。`,
|
||
progress: toBatchProgress(
|
||
params.progressRange[0],
|
||
params.progressRange[1],
|
||
processedCount,
|
||
params.framework.landmarks.length,
|
||
),
|
||
});
|
||
|
||
const batchRaw = await requestFoundationJsonStage({
|
||
llmClient: params.llmClient,
|
||
userPrompt: buildCustomWorldLandmarkNetworkBatchPrompt({
|
||
framework: params.framework,
|
||
landmarkBatch,
|
||
storyNpcs: params.storyNpcs,
|
||
}),
|
||
debugLabel: `agent-foundation-landmark-network-batch-${batchIndex + 1}`,
|
||
repairPromptBuilder: (responseText) =>
|
||
buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt({
|
||
responseText,
|
||
expectedNames: landmarkBatch.map((landmark) => landmark.name),
|
||
}),
|
||
repairDebugLabel: `agent-foundation-landmark-network-batch-${batchIndex + 1}-json-repair`,
|
||
emptyResponseMessage: `场景连接第 ${batchIndex + 1} 批没有返回有效内容。`,
|
||
signal: params.signal,
|
||
});
|
||
|
||
mergedEntries = mergeRoleBatchDetails(
|
||
mergedEntries as MergeableNamedRecord[],
|
||
normalizeCustomWorldGenerationLandmarkOutlineBatch(batchRaw),
|
||
) as CustomWorldGenerationLandmarkOutline[];
|
||
processedCount = Math.min(
|
||
params.framework.landmarks.length,
|
||
processedCount + landmarkBatch.length,
|
||
);
|
||
}
|
||
|
||
await emitDraftProgress(params.onProgress, {
|
||
phaseLabel: '建立场景连接',
|
||
phaseDetail: '关键场景的角色分布与路径连接已经整理完成。',
|
||
progress: params.progressRange[1],
|
||
});
|
||
|
||
return mergedEntries;
|
||
}
|
||
|
||
async function expandFoundationRoleEntries(params: {
|
||
llmClient: UpstreamLlmClient;
|
||
framework: CustomWorldGenerationFramework;
|
||
roleType: CustomWorldGenerationRoleBatchType;
|
||
baseEntries: CustomWorldGenerationRoleOutline[];
|
||
stage: CustomWorldGenerationRoleBatchStage;
|
||
batchSize: number;
|
||
signal?: AbortSignal;
|
||
onProgress?: DraftProgressCallback;
|
||
progressRange: [number, number];
|
||
}) {
|
||
const roleLabel = params.roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||
const stageLabel = params.stage === 'narrative' ? '叙事基础' : '档案细节';
|
||
const batches = chunkArray(params.baseEntries, params.batchSize);
|
||
let mergedEntries = params.baseEntries.map((entry) => ({ ...entry }));
|
||
let processedCount = 0;
|
||
|
||
for (const [batchIndex, roleBatch] of batches.entries()) {
|
||
await emitDraftProgress(params.onProgress, {
|
||
phaseLabel: `补全${roleLabel}${stageLabel}`,
|
||
phaseDetail: `正在补全${roleLabel}${stageLabel}第 ${batchIndex + 1} / ${batches.length} 批,当前已完成 ${processedCount}/${params.baseEntries.length}。`,
|
||
progress: toBatchProgress(
|
||
params.progressRange[0],
|
||
params.progressRange[1],
|
||
processedCount,
|
||
params.baseEntries.length,
|
||
),
|
||
});
|
||
|
||
const stageRaw = await requestFoundationJsonStage({
|
||
llmClient: params.llmClient,
|
||
userPrompt: buildCustomWorldRoleBatchPrompt({
|
||
framework: params.framework,
|
||
roleType: params.roleType,
|
||
roleBatch,
|
||
stage: params.stage,
|
||
}),
|
||
debugLabel: `agent-foundation-${params.roleType}-${params.stage}-batch-${batchIndex + 1}`,
|
||
repairPromptBuilder: (responseText) =>
|
||
buildCustomWorldRoleBatchJsonRepairPrompt({
|
||
responseText,
|
||
roleType: params.roleType,
|
||
expectedNames: roleBatch.map((role) => getNamedRecordKey(role.name)),
|
||
stage: params.stage,
|
||
}),
|
||
repairDebugLabel: `agent-foundation-${params.roleType}-${params.stage}-batch-${batchIndex + 1}-json-repair`,
|
||
emptyResponseMessage: `${roleLabel}${stageLabel}第 ${batchIndex + 1} 批没有返回有效内容。`,
|
||
signal: params.signal,
|
||
});
|
||
|
||
const detailEntries = Array.isArray(
|
||
stageRaw && typeof stageRaw === 'object'
|
||
? (stageRaw as Record<string, unknown>)[
|
||
params.roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'
|
||
]
|
||
: [],
|
||
)
|
||
? (((stageRaw as Record<string, unknown>)[
|
||
params.roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'
|
||
] as Array<Record<string, unknown>>) ?? [])
|
||
: [];
|
||
|
||
mergedEntries = mergeRoleBatchDetails(
|
||
mergedEntries as MergeableNamedRecord[],
|
||
detailEntries,
|
||
) as CustomWorldGenerationRoleOutline[];
|
||
processedCount = Math.min(
|
||
params.baseEntries.length,
|
||
processedCount + roleBatch.length,
|
||
);
|
||
}
|
||
|
||
await emitDraftProgress(params.onProgress, {
|
||
phaseLabel: `补全${roleLabel}${stageLabel}`,
|
||
phaseDetail: `${roleLabel}${stageLabel}已经整理完成。`,
|
||
progress: params.progressRange[1],
|
||
});
|
||
|
||
return mergedEntries;
|
||
}
|
||
|
||
function buildDraftFactionsFromRuntimeProfile(profile: CustomWorldProfile) {
|
||
const factionNames = dedupeStrings(profile.majorFactions, 4);
|
||
const firstConflict = profile.coreConflicts[0] || profile.summary;
|
||
|
||
return factionNames.slice(0, 4).map((name, index) => {
|
||
const relatedConflict =
|
||
profile.coreConflicts[
|
||
index % Math.max(1, profile.coreConflicts.length)
|
||
] || firstConflict;
|
||
return {
|
||
id: createId('faction', name, index),
|
||
name,
|
||
title: name,
|
||
publicGoal: clampText(
|
||
extractConflictTarget(relatedConflict)
|
||
? `拿下${extractConflictTarget(relatedConflict)}的主导权`
|
||
: '在失衡局势里先抢到主动权',
|
||
28,
|
||
),
|
||
relatedConflict,
|
||
tension: clampText(relatedConflict, 48),
|
||
playerRelation: clampText(
|
||
index === 0
|
||
? '它会主动影响玩家的第一步站位'
|
||
: '玩家迟早要和它发生直接交集',
|
||
32,
|
||
),
|
||
summary: clampText(
|
||
`${name}围绕“${buildCompactLabel(relatedConflict, '核心冲突', 16)}”持续施压,也会直接影响玩家的开局判断。`,
|
||
120,
|
||
),
|
||
} satisfies CustomWorldFoundationDraftFaction;
|
||
});
|
||
}
|
||
|
||
function buildDraftThreadsFromRuntimeProfile(
|
||
profile: CustomWorldProfile,
|
||
): CustomWorldFoundationDraftThread[] {
|
||
const graphThreads = [
|
||
...(profile.storyGraph?.visibleThreads ?? []).slice(0, 2),
|
||
...(profile.storyGraph?.hiddenThreads ?? []).slice(0, 2),
|
||
];
|
||
|
||
if (graphThreads.length > 0) {
|
||
return graphThreads.map(
|
||
(thread, index) =>
|
||
({
|
||
id: thread.id || createId('thread', thread.title, index),
|
||
title: clampText(thread.title, 18),
|
||
type: thread.visibility === 'hidden' ? 'hidden' : 'main',
|
||
conflictType: clampText(thread.conflictType, 18),
|
||
conflict: clampText(thread.summary || thread.stakes, 72),
|
||
stakes: clampText(thread.stakes, 48),
|
||
characterIds: thread.involvedActorIds.slice(0, 4),
|
||
landmarkIds: thread.relatedLocationIds.slice(0, 4),
|
||
summary: clampText(thread.summary, 120),
|
||
}) satisfies CustomWorldFoundationDraftThread,
|
||
);
|
||
}
|
||
|
||
return profile.coreConflicts.slice(0, 3).map((conflict, index) => ({
|
||
id: createId('thread', conflict, index),
|
||
title: buildCompactLabel(conflict, `主线${index + 1}`, 16),
|
||
type: index === 1 ? 'hidden' : 'main',
|
||
conflict,
|
||
characterIds: [],
|
||
landmarkIds: [],
|
||
summary: clampText(`这条线围绕“${conflict}”持续推进。`, 80),
|
||
}));
|
||
}
|
||
|
||
function buildDraftCharactersFromRuntimeProfile(
|
||
roles: CustomWorldProfile['playableNpcs'] | CustomWorldProfile['storyNpcs'],
|
||
fallbackThreadIds: string[],
|
||
) {
|
||
return roles.map((role) => ({
|
||
id: role.id,
|
||
name: role.name,
|
||
title: clampText(role.title || role.role, 18) || '关键角色',
|
||
role: clampText(role.role || role.title, 28) || '关键角色',
|
||
publicIdentity:
|
||
clampText(
|
||
role.narrativeProfile?.publicMask ||
|
||
role.backstoryReveal.publicSummary ||
|
||
role.description,
|
||
36,
|
||
) || '站在局势前台的人',
|
||
publicMask:
|
||
clampText(
|
||
role.narrativeProfile?.firstContactMask || role.personality,
|
||
36,
|
||
) || undefined,
|
||
currentPressure:
|
||
clampText(
|
||
role.narrativeProfile?.immediatePressure ||
|
||
role.motivation ||
|
||
role.backstory,
|
||
48,
|
||
) || '正在被当前局势不断加压',
|
||
hiddenHook:
|
||
clampText(
|
||
role.narrativeProfile?.hiddenLine ||
|
||
role.backstoryReveal.chapters[2]?.content ||
|
||
role.backstory,
|
||
48,
|
||
) || undefined,
|
||
relationToPlayer:
|
||
clampText(
|
||
role.relationshipHooks[0] ||
|
||
role.narrativeProfile?.visibleLine ||
|
||
role.motivation,
|
||
36,
|
||
) || '会直接改变玩家的下一步选择',
|
||
threadIds:
|
||
role.narrativeProfile?.relatedThreadIds?.slice(0, 3) ??
|
||
fallbackThreadIds.slice(0, 3),
|
||
summary:
|
||
clampText(role.description || role.backstoryReveal.publicSummary, 120) ||
|
||
'这个角色会持续推动当前世界底稿继续展开。',
|
||
})) satisfies CustomWorldFoundationDraftCharacter[];
|
||
}
|
||
|
||
function buildDraftLandmarksFromRuntimeProfile(
|
||
profile: CustomWorldProfile,
|
||
threads: CustomWorldFoundationDraftThread[],
|
||
) {
|
||
return profile.landmarks
|
||
.slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT)
|
||
.map((landmark) => {
|
||
const relatedThreadIds = threads
|
||
.filter((thread) => thread.landmarkIds.includes(landmark.id))
|
||
.map((thread) => thread.id)
|
||
.slice(0, 3);
|
||
|
||
return {
|
||
id: landmark.id,
|
||
name: landmark.name,
|
||
description: clampText(landmark.description, 48) || undefined,
|
||
purpose: clampText(landmark.description, 28) || '承接关键剧情推进',
|
||
mood:
|
||
clampText(
|
||
landmark.narrativeResidues?.[0]?.summary ||
|
||
landmark.dangerLevel ||
|
||
'带着明显风险的关键地点',
|
||
24,
|
||
) || '带着明显风险的关键地点',
|
||
importance: clampText(
|
||
landmark.narrativeResidues?.[0]?.changeHint ||
|
||
landmark.description ||
|
||
'和当前主线冲突直接勾连的关键地点',
|
||
60,
|
||
),
|
||
secret:
|
||
clampText(
|
||
landmark.narrativeResidues?.[0]?.hiddenTruth ||
|
||
landmark.connections[0]?.summary ||
|
||
'',
|
||
36,
|
||
) || undefined,
|
||
dangerLevel: landmark.dangerLevel,
|
||
characterIds: landmark.sceneNpcIds.slice(0, 4),
|
||
threadIds: relatedThreadIds,
|
||
summary: clampText(
|
||
landmark.description ||
|
||
landmark.narrativeResidues?.[0]?.summary ||
|
||
'',
|
||
120,
|
||
),
|
||
} satisfies CustomWorldFoundationDraftLandmark;
|
||
});
|
||
}
|
||
|
||
function convertRuntimeProfileToFoundationDraft(params: {
|
||
profile: CustomWorldProfile;
|
||
intent: CustomWorldCreatorIntentRecord;
|
||
anchorPack: unknown;
|
||
}) {
|
||
const factions = buildDraftFactionsFromRuntimeProfile(params.profile);
|
||
const threads = buildDraftThreadsFromRuntimeProfile(params.profile);
|
||
const playableNpcs = buildDraftCharactersFromRuntimeProfile(
|
||
params.profile.playableNpcs.slice(0, FOUNDATION_DRAFT_PLAYABLE_COUNT),
|
||
threads.slice(0, 2).map((entry) => entry.id),
|
||
);
|
||
const storyNpcs = buildDraftCharactersFromRuntimeProfile(
|
||
params.profile.storyNpcs.slice(0, FOUNDATION_DRAFT_STORY_COUNT),
|
||
threads.slice(1, 3).map((entry) => entry.id),
|
||
);
|
||
const landmarks = buildDraftLandmarksFromRuntimeProfile(
|
||
params.profile,
|
||
threads,
|
||
);
|
||
const chapter = buildChapter({
|
||
worldName: params.profile.name,
|
||
openingSituation:
|
||
clampText(params.intent.openingSituation, 60) || params.profile.summary,
|
||
playerGoal: params.profile.playerGoal,
|
||
characters: [...playableNpcs, ...storyNpcs],
|
||
landmarks,
|
||
threads,
|
||
});
|
||
const anchorRecord = toRecord(params.anchorPack);
|
||
|
||
return {
|
||
name: params.profile.name,
|
||
subtitle: params.profile.subtitle,
|
||
summary: params.profile.summary,
|
||
tone: params.profile.tone,
|
||
playerGoal: params.profile.playerGoal,
|
||
majorFactions:
|
||
params.profile.majorFactions.length > 0
|
||
? params.profile.majorFactions
|
||
: factions.map((entry) => entry.name),
|
||
coreConflicts:
|
||
params.profile.coreConflicts.length > 0
|
||
? params.profile.coreConflicts
|
||
: [params.profile.summary],
|
||
playableNpcs,
|
||
storyNpcs,
|
||
landmarks,
|
||
camp: params.profile.camp
|
||
? ({
|
||
id: 'camp-home',
|
||
name: params.profile.camp.name,
|
||
description: params.profile.camp.description,
|
||
mood: clampText(params.profile.tone, 36) || '紧绷但还可暂时收住局势',
|
||
dangerLevel: params.profile.camp.dangerLevel,
|
||
summary: clampText(params.profile.camp.description, 88),
|
||
} satisfies CustomWorldFoundationDraftCamp)
|
||
: null,
|
||
themePack:
|
||
(params.profile.themePack as unknown as Record<string, unknown> | null) ??
|
||
null,
|
||
storyGraph:
|
||
(params.profile.storyGraph as unknown as Record<string, unknown> | null) ??
|
||
null,
|
||
factions,
|
||
threads,
|
||
chapters: [chapter],
|
||
worldHook:
|
||
clampText(params.intent.worldHook || params.profile.summary, 72) ||
|
||
params.profile.summary,
|
||
playerPremise:
|
||
clampText(params.intent.playerPremise, 72) ||
|
||
'玩家是一名被卷进局势中心的行动者',
|
||
openingSituation:
|
||
clampText(params.intent.openingSituation, 72) ||
|
||
'故事开局时,玩家已经站在必须立刻选边的位置上',
|
||
iconicElements: dedupeStrings(params.intent.iconicElements, 6),
|
||
sourceAnchorSummary:
|
||
toText(anchorRecord?.creatorIntentSummary) ||
|
||
buildDraftSummaryFromIntent(params.intent) ||
|
||
params.profile.summary,
|
||
legacyResultProfile: params.profile as unknown as Record<string, unknown>,
|
||
} satisfies CustomWorldFoundationDraftProfile & {
|
||
legacyResultProfile: Record<string, unknown>;
|
||
};
|
||
}
|
||
|
||
async function buildFoundationDraftProfileWithLlm(params: {
|
||
llmClient: UpstreamLlmClient;
|
||
creatorIntent: CustomWorldCreatorIntentRecord;
|
||
anchorPack: unknown;
|
||
anchorContent?: EightAnchorContent | null;
|
||
signal?: AbortSignal;
|
||
onProgress?: DraftProgressCallback;
|
||
}) {
|
||
const settingText = buildFoundationGenerationSeedText({
|
||
intent: params.creatorIntent,
|
||
anchorPack: params.anchorPack,
|
||
anchorContent: params.anchorContent,
|
||
});
|
||
|
||
await emitDraftProgress(params.onProgress, {
|
||
phaseLabel: '整理世界骨架',
|
||
phaseDetail: '正在根据创作者锚点生成第一版世界框架。',
|
||
progress: 12,
|
||
});
|
||
const frameworkRaw = await requestFoundationJsonStage({
|
||
llmClient: params.llmClient,
|
||
userPrompt: buildCustomWorldFrameworkPrompt(settingText),
|
||
debugLabel: 'agent-foundation-framework',
|
||
repairPromptBuilder: (responseText) =>
|
||
buildCustomWorldFrameworkJsonRepairPrompt(responseText),
|
||
repairDebugLabel: 'agent-foundation-framework-json-repair',
|
||
emptyResponseMessage: '世界框架阶段没有返回有效内容。',
|
||
signal: params.signal,
|
||
});
|
||
const framework = normalizeCustomWorldGenerationFramework(
|
||
frameworkRaw,
|
||
settingText,
|
||
);
|
||
|
||
framework.playableNpcs = await generateFoundationRoleOutlineEntries({
|
||
llmClient: params.llmClient,
|
||
framework,
|
||
roleType: 'playable',
|
||
totalCount: FOUNDATION_DRAFT_PLAYABLE_COUNT,
|
||
batchSize: FOUNDATION_ROLE_OUTLINE_BATCH_SIZE,
|
||
signal: params.signal,
|
||
onProgress: params.onProgress,
|
||
progressRange: [16, 30],
|
||
});
|
||
|
||
framework.storyNpcs = await generateFoundationRoleOutlineEntries({
|
||
llmClient: params.llmClient,
|
||
framework,
|
||
roleType: 'story',
|
||
totalCount: FOUNDATION_DRAFT_STORY_COUNT,
|
||
batchSize: FOUNDATION_ROLE_OUTLINE_BATCH_SIZE,
|
||
signal: params.signal,
|
||
onProgress: params.onProgress,
|
||
progressRange: [30, 44],
|
||
});
|
||
|
||
framework.landmarks = await generateFoundationLandmarkSeedEntries({
|
||
llmClient: params.llmClient,
|
||
framework,
|
||
totalCount: FOUNDATION_DRAFT_LANDMARK_COUNT,
|
||
batchSize: FOUNDATION_LANDMARK_BATCH_SIZE,
|
||
signal: params.signal,
|
||
onProgress: params.onProgress,
|
||
progressRange: [44, 56],
|
||
});
|
||
|
||
framework.landmarks = await expandFoundationLandmarkNetworkEntries({
|
||
llmClient: params.llmClient,
|
||
framework,
|
||
storyNpcs: framework.storyNpcs,
|
||
baseEntries: framework.landmarks,
|
||
batchSize: FOUNDATION_LANDMARK_BATCH_SIZE,
|
||
signal: params.signal,
|
||
onProgress: params.onProgress,
|
||
progressRange: [56, 66],
|
||
});
|
||
|
||
const playableNarrative = await expandFoundationRoleEntries({
|
||
llmClient: params.llmClient,
|
||
framework,
|
||
roleType: 'playable',
|
||
baseEntries: framework.playableNpcs,
|
||
stage: 'narrative',
|
||
batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE,
|
||
signal: params.signal,
|
||
onProgress: params.onProgress,
|
||
progressRange: [66, 76],
|
||
});
|
||
const playableDetailed = await expandFoundationRoleEntries({
|
||
llmClient: params.llmClient,
|
||
framework,
|
||
roleType: 'playable',
|
||
baseEntries: playableNarrative,
|
||
stage: 'dossier',
|
||
batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE,
|
||
signal: params.signal,
|
||
onProgress: params.onProgress,
|
||
progressRange: [76, 84],
|
||
});
|
||
const storyNarrative = await expandFoundationRoleEntries({
|
||
llmClient: params.llmClient,
|
||
framework,
|
||
roleType: 'story',
|
||
baseEntries: framework.storyNpcs,
|
||
stage: 'narrative',
|
||
batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE,
|
||
signal: params.signal,
|
||
onProgress: params.onProgress,
|
||
progressRange: [84, 92],
|
||
});
|
||
const storyDetailed = await expandFoundationRoleEntries({
|
||
llmClient: params.llmClient,
|
||
framework,
|
||
roleType: 'story',
|
||
baseEntries: storyNarrative,
|
||
stage: 'dossier',
|
||
batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE,
|
||
signal: params.signal,
|
||
onProgress: params.onProgress,
|
||
progressRange: [92, 96],
|
||
});
|
||
|
||
await emitDraftProgress(params.onProgress, {
|
||
phaseLabel: '编译世界底稿',
|
||
phaseDetail: '正在把分批生成结果整理成旧版世界结果结构,再编成草稿卡底稿。',
|
||
progress: 97,
|
||
});
|
||
|
||
const rawProfile = buildCustomWorldRawProfileFromFramework(
|
||
framework,
|
||
) as Record<string, unknown>;
|
||
rawProfile.playableNpcs = playableDetailed;
|
||
rawProfile.storyNpcs = storyDetailed;
|
||
rawProfile.landmarks = framework.landmarks;
|
||
|
||
const runtimeProfile = buildCompiledCustomWorldProfile(
|
||
rawProfile,
|
||
settingText,
|
||
);
|
||
return convertRuntimeProfileToFoundationDraft({
|
||
profile: runtimeProfile,
|
||
intent: params.creatorIntent,
|
||
anchorPack: params.anchorPack,
|
||
});
|
||
}
|
||
|
||
export class CustomWorldAgentFoundationDraftService {
|
||
constructor(private readonly llmClient: UpstreamLlmClient | null = null) {}
|
||
|
||
private generateFallbackDraft(params: {
|
||
creatorIntent: unknown;
|
||
anchorPack: unknown;
|
||
anchorContent?: EightAnchorContent | null;
|
||
}): CustomWorldFoundationDraftProfile {
|
||
const normalizedAnchorContent = normalizeEightAnchorContent(
|
||
params.anchorContent,
|
||
);
|
||
const intent =
|
||
normalizeCreatorIntentRecord(params.creatorIntent) ??
|
||
buildCreatorIntentFromEightAnchorContent(normalizedAnchorContent);
|
||
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 anchorDraftTitle =
|
||
buildDraftTitleFromEightAnchorContent(normalizedAnchorContent);
|
||
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:
|
||
anchorDraftTitle && anchorDraftTitle !== '未命名草稿'
|
||
? anchorDraftTitle
|
||
: 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:
|
||
buildDraftSummaryFromEightAnchorContent(normalizedAnchorContent) ||
|
||
toText(anchorPack?.creatorIntentSummary) ||
|
||
buildDraftSummaryFromIntent(intent) ||
|
||
summary,
|
||
};
|
||
}
|
||
|
||
async generate(params: {
|
||
creatorIntent: unknown;
|
||
anchorPack: unknown;
|
||
anchorContent?: EightAnchorContent | null;
|
||
signal?: AbortSignal;
|
||
onProgress?: DraftProgressCallback;
|
||
}): Promise<CustomWorldFoundationDraftProfile> {
|
||
const intent =
|
||
normalizeCreatorIntentRecord(params.creatorIntent) ??
|
||
buildCreatorIntentFromEightAnchorContent(
|
||
normalizeEightAnchorContent(params.anchorContent),
|
||
);
|
||
|
||
if (!this.llmClient || !intent) {
|
||
return this.generateFallbackDraft(params);
|
||
}
|
||
|
||
return buildFoundationDraftProfileWithLlm({
|
||
llmClient: this.llmClient,
|
||
creatorIntent: intent,
|
||
anchorPack: params.anchorPack,
|
||
anchorContent: params.anchorContent,
|
||
signal: params.signal,
|
||
onProgress: params.onProgress,
|
||
});
|
||
}
|
||
}
|