Files
Genarrative/server-node/src/services/customWorldAgentFoundationDraftService.ts
高物 50759f3c1e
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-20 09:54:17 +08:00

1902 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
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,
});
}
}