Integrate role asset studio into custom world agent flow

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

File diff suppressed because it is too large Load Diff

View File

@@ -61,6 +61,15 @@ function validateCredentials(username: string, password: string) {
}
}
function isUniqueViolationError(error: unknown) {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === '23505'
);
}
function buildMaskedPhoneDisplay(phoneNumber: string) {
const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneNumber);
return normalizedPhone.maskedNationalNumber;
@@ -935,13 +944,21 @@ export async function entryWithPassword(
validateCredentials(username, password);
let user = await context.userRepository.findByUsername(username);
let shouldVerifyExistingPassword = Boolean(user);
if (!user) {
const passwordHash = await hashPassword(password);
user = await context.userRepository.create(username, passwordHash);
} else {
const isValid = await verifyPassword(user.passwordHash, password);
if (!isValid) {
throw unauthorized('用户名或密码错误');
try {
user = await context.userRepository.create(username, passwordHash);
shouldVerifyExistingPassword = false;
} catch (error) {
if (!isUniqueViolationError(error)) {
throw error;
}
user = await context.userRepository.findByUsername(username);
shouldVerifyExistingPassword = true;
if (!user) {
throw error;
}
}
}
@@ -949,6 +966,13 @@ export async function entryWithPassword(
throw new Error('failed to resolve user after auth entry');
}
if (shouldVerifyExistingPassword) {
const isValid = await verifyPassword(user.passwordHash, password);
if (!isValid) {
throw unauthorized('用户名或密码错误');
}
}
await writeAuthAuditLog(context, {
userId: user.id,
eventType: 'password_login',

View File

@@ -2,15 +2,17 @@ import type { Logger } from 'pino';
import type { AppConfig } from './config.js';
import type { AppDatabase } from './db.js';
import { AuthIdentityRepository } from './repositories/authIdentityRepository.js';
import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js';
import { AuthIdentityRepository } from './repositories/authIdentityRepository.js';
import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js';
import { RuntimeRepository } from './repositories/runtimeRepository.js';
import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js';
import { UserRepository } from './repositories/userRepository.js';
import { UserSessionRepository } from './repositories/userSessionRepository.js';
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
import { UpstreamLlmClient } from './services/llmClient.js';
import type { SmsVerificationService } from './services/smsVerificationService.js';
import type { WechatAuthService } from './services/wechatAuthService.js';
@@ -29,6 +31,8 @@ export type AppContext = {
runtimeRepository: RuntimeRepository;
llmClient: UpstreamLlmClient;
customWorldSessions: CustomWorldSessionStore;
customWorldAgentSessions: CustomWorldAgentSessionStore;
customWorldAgentOrchestrator: CustomWorldAgentOrchestrator;
smsVerificationService: SmsVerificationService;
wechatAuthService: WechatAuthService;
wechatAuthStates: WechatAuthStateStore;

View File

@@ -108,6 +108,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
'20260409_006_auth_audit_logs',
'20260409_007_sms_auth_events',
'20260409_008_auth_risk_blocks',
'20260413_009_custom_world_sessions',
],
);
@@ -123,6 +124,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
'auth_risk_blocks',
'sms_auth_events',
'user_sessions',
'custom_world_sessions',
'save_snapshots',
'runtime_settings',
'custom_world_profiles'
@@ -137,6 +139,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
'auth_identities',
'auth_risk_blocks',
'custom_world_profiles',
'custom_world_sessions',
'runtime_settings',
'save_snapshots',
'schema_migrations',

View File

@@ -189,4 +189,21 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
ON auth_risk_blocks (scope_type, scope_key, expires_at DESC)`,
],
},
{
id: '20260413_009_custom_world_sessions',
name: 'custom world sessions',
statements: [
`CREATE TABLE IF NOT EXISTS custom_world_sessions (
user_id TEXT NOT NULL,
session_id TEXT NOT NULL,
payload_json JSONB NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, session_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS custom_world_sessions_user_updated_idx
ON custom_world_sessions (user_id, updated_at DESC)`,
],
},
];

View File

@@ -1,462 +1,490 @@
import type {
CustomWorldGenerationMode,
CustomWorldGenerationProgress,
GenerateCustomWorldProfileInput,
} from '../../../../packages/shared/src/contracts/runtime.js';
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
import {
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
validateGeneratedCustomWorldProfile,
} from '../../../../src/services/customWorld.js';
import { buildExpandedCustomWorldProfile } from '../../../../src/services/customWorldBuilder.js';
import {
buildCustomWorldAnchorPackFromIntent,
buildCustomWorldCreatorIntentGenerationText,
deriveCustomWorldLockStateFromIntent,
hasMeaningfulCustomWorldCreatorIntent,
normalizeCustomWorldCreatorIntent,
} from '../../../../src/services/customWorldCreatorIntent.js';
import type {
CustomWorldCreatorIntent,
CustomWorldProfile,
} from '../../../../src/types.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
type GeneratedProfile = Record<string, unknown>;
const PLAYABLE_ROLE_TEMPLATES = [
{ title: '断桥行者', role: '游历剑客', style: '快剑追击', tags: ['快剑', '突进', '追击'] },
{ title: '听风客', role: '远行弓手', style: '远射游击', tags: ['远射', '游击', '风行'] },
{ title: '守夜人', role: '前列护卫', style: '守御护体', tags: ['守御', '护体', '先锋'] },
{ title: '观火者', role: '术式使', style: '法修过载', tags: ['法修', '过载', '法力'] },
{ title: '逐潮者', role: '浪客拳师', style: '重击压制', tags: ['重击', '爆发', '压制'] },
const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
你会收到一段本应为单个 JSON 对象的文本。
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
不要输出 Markdown、代码块、解释、注释或额外文字。
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = 180000;
const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3;
const FAST_CUSTOM_WORLD_STORY_COUNT = 8;
const FAST_CUSTOM_WORLD_LANDMARK_COUNT = 4;
const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
{
id: 'prepare',
label: '整理设定',
detail: '整理创作者输入,准备模型推理上下文。',
total: 1,
weight: 1,
},
{
id: 'llm-profile',
label: '大模型推理',
detail: '正在请求模型生成世界档案、角色群像与场景网络。',
total: 1,
weight: 8,
},
{
id: 'normalize',
label: '系统编译',
detail: '正在把模型结果归一成运行时可用结构。',
total: 1,
weight: 2,
},
{
id: 'finalize',
label: '归档世界',
detail: '整理最终世界档案并做完整性校验。',
total: 1,
weight: 1,
},
] as const;
const STORY_ROLE_TEMPLATES = [
{ role: '沿街商贩', danger: 'low', tags: ['交易', '情报'] },
{ role: '巡路探子', danger: 'medium', tags: ['巡守', '警觉'] },
{ role: '旧案见证人', danger: 'medium', tags: ['旧案', '隐情'] },
{ role: '守桥武人', danger: 'high', tags: ['守御', '敌意'] },
{ role: '异变潜伏者', danger: 'high', tags: ['异变', '威胁'] },
] as const;
type CustomWorldGenerationStageId =
(typeof CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS)[number]['id'];
const LANDMARK_TEMPLATES = [
'断桥口',
'旧市桥廊',
'潮痕渡口',
'灰塔前庭',
'沉钟小巷',
'碑下荒庭',
'雾潮栈道',
'封灯码头',
'裂潮前哨',
'残照高台',
] as const;
class CustomWorldGenerationAbortedError extends Error {
constructor(message = '世界生成已中断。') {
super(message);
this.name = 'CustomWorldGenerationAbortedError';
}
}
function nowMs() {
return Date.now();
}
function inferWorldType(settingText: string) {
return /||||||/u.test(settingText)
? 'XIANXIA'
: 'WUXIA';
function throwIfCustomWorldGenerationAborted(signal?: AbortSignal) {
if (!signal?.aborted) {
return;
}
throw signal.reason instanceof Error
? signal.reason
: new CustomWorldGenerationAbortedError();
}
function seedText(input: GenerateCustomWorldProfileInput) {
return input.settingText.trim().replace(/\s+/g, ' ');
function isCustomWorldGenerationAbortLikeError(error: unknown) {
return (
error instanceof CustomWorldGenerationAbortedError ||
(typeof DOMException !== 'undefined' &&
error instanceof DOMException &&
error.name === 'AbortError') ||
(error instanceof Error && error.name === 'AbortError')
);
}
function slugify(value: string) {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '');
function sanitizeJsonLikeText(text: string) {
const trimmed = text.trim();
if (!trimmed) {
return '';
}
return normalized || 'entry';
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu);
const unfenced = fencedMatch?.[1]?.trim() || trimmed;
const firstBrace = unfenced.indexOf('{');
const lastBrace = unfenced.lastIndexOf('}');
const extracted =
firstBrace >= 0 && lastBrace > firstBrace
? unfenced.slice(firstBrace, lastBrace + 1)
: unfenced;
return extracted
.replace(/^\uFEFF/u, '')
.replace(/[\u201C\u201D]/gu, '"')
.replace(/[\u2018\u2019]/gu, "'")
.replace(/\u00A0/gu, ' ')
.replace(/,\s*([}\]])/gu, '$1')
.trim();
}
function buildAttributeSchema(worldType: 'WUXIA' | 'XIANXIA') {
function resolveCustomWorldGenerationInput(
input: GenerateCustomWorldProfileInput,
): {
settingText: string;
generationSeedText: string;
creatorIntent: CustomWorldCreatorIntent | null;
generationMode: CustomWorldGenerationMode;
} {
const settingText = input.settingText.trim();
const creatorIntent = normalizeCustomWorldCreatorIntent(input.creatorIntent);
const generationSeedText =
creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent)
? buildCustomWorldCreatorIntentGenerationText(creatorIntent)
: settingText;
return {
id: `schema:${worldType.toLowerCase()}:default`,
worldId: `world:${worldType.toLowerCase()}`,
schemaVersion: 1,
generatedFrom: {
worldType,
worldName: worldType === 'XIANXIA' ? '云海异境' : '裂潮边城',
settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的高空异境' : '旧桥与边城交错的裂潮地界',
tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、边境余震',
conflictCore: '旧秩序与新威胁正在同时逼近',
},
schemaName: worldType === 'XIANXIA' ? '灵潮六轴' : '边城六轴',
slots: [
{
slotId: 'axis_a',
name: '锋势',
definition: '临战时的主动压迫与破面能力',
positiveSignals: ['先手', '破势'],
negativeSignals: ['迟疑', '退缩'],
combatUseText: '决定压制与追击能力',
socialUseText: '决定发起对峙的胆气',
explorationUseText: '决定冒险前推的强度',
},
{
slotId: 'axis_b',
name: '守意',
definition: '承压、稳住阵脚与保全同伴的能力',
positiveSignals: ['护持', '稳守'],
negativeSignals: ['失衡', '溃散'],
combatUseText: '决定承伤与稳场',
socialUseText: '决定是否可靠',
explorationUseText: '决定穿越危险区的稳定性',
},
{
slotId: 'axis_c',
name: '灵运',
definition: '资源调度、法力回转与术式适配能力',
positiveSignals: ['回转', '灵感'],
negativeSignals: ['枯竭', '滞涩'],
combatUseText: '决定灵力和术式运转',
socialUseText: '决定理解复杂信息的能力',
explorationUseText: '决定破解机关与异象',
},
{
slotId: 'axis_d',
name: '机变',
definition: '借势应变、换位与局势判断能力',
positiveSignals: ['借势', '换位'],
negativeSignals: ['僵硬', '迟钝'],
combatUseText: '决定机动与变招',
socialUseText: '决定读懂弦外之音',
explorationUseText: '决定追踪与绕险',
},
{
slotId: 'axis_e',
name: '因缘',
definition: '人与人之间的牵连、信任与旧债张力',
positiveSignals: ['信任', '牵连'],
negativeSignals: ['隔阂', '背离'],
combatUseText: '决定协同与互援',
socialUseText: '决定关系推进',
explorationUseText: '决定是否能得到帮助',
},
{
slotId: 'axis_f',
name: '秘痕',
definition: '旧案、禁忌与隐秘线索的承载程度',
positiveSignals: ['旧痕', '秘线'],
negativeSignals: ['空白', '浅表'],
combatUseText: '决定异象与特殊效果',
socialUseText: '决定话题深度',
explorationUseText: '决定发现隐藏真相的能力',
},
],
settingText,
generationSeedText: generationSeedText.trim() || settingText,
creatorIntent,
generationMode: input.generationMode === 'fast' ? 'fast' : 'full',
};
}
function buildBackstoryReveal(name: string) {
function getCustomWorldGenerationTargets(
generationMode: CustomWorldGenerationMode,
) {
if (generationMode === 'fast') {
return {
playableCount: FAST_CUSTOM_WORLD_PLAYABLE_COUNT,
storyCount: FAST_CUSTOM_WORLD_STORY_COUNT,
landmarkCount: FAST_CUSTOM_WORLD_LANDMARK_COUNT,
generationStatus: 'key_only' as const,
};
}
return {
publicSummary: `${name}在表面上只露出一层足以自保的说辞。`,
privateChatUnlockAffinity: 60,
chapters: [
{
id: `${slugify(name)}-surface`,
title: '表层来意',
affinityRequired: 15,
teaser: `${name}对你仍留着一层试探。`,
content: `${name}先承认自己并非偶然出现在这里,而是被同一场异动推到了前线。`,
contextSnippet: `${name}的真正来意还没有完全摊开。`,
},
{
id: `${slugify(name)}-scar`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `${name}提到过一次不愿重说的旧伤。`,
content: `${name}曾在上一轮风暴里失去过重要的人,因此对类似局势格外警觉。`,
contextSnippet: `${name}和旧案之间存在未平的裂痕。`,
},
{
id: `${slugify(name)}-hidden`,
title: '隐藏执念',
affinityRequired: 60,
teaser: `${name}其实一直在盯着更深一层的线索。`,
content: `${name}真正想追索的不是眼前纷乱本身,而是它背后那只一直没露面的手。`,
contextSnippet: `${name}的行动始终绕着一条更深的暗线。`,
},
{
id: `${slugify(name)}-final`,
title: '最终底牌',
affinityRequired: 90,
teaser: `${name}手里一直留着最后一道底牌。`,
content: `${name}早就为最坏结局准备了最后的应对,但不到绝境绝不会轻易亮出。`,
contextSnippet: `${name}仍保留着能改写局面的最后筹码。`,
},
],
playableCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
storyCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
landmarkCount: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
generationStatus: 'complete' as const,
};
}
function buildSkills(name: string) {
return [
{
id: `${slugify(name)}-skill-1`,
name: `${name}起手`,
summary: '先用短促动作压住眼前节奏。',
style: '起手压制',
},
{
id: `${slugify(name)}-skill-2`,
name: `${name}变招`,
summary: '在试探后迅速换位改势。',
style: '机动周旋',
},
{
id: `${slugify(name)}-skill-3`,
name: `${name}底牌`,
summary: '在局势逼紧时打出保留手段。',
style: '爆发终结',
},
];
}
function buildInitialItems(name: string) {
return [
{
id: `${slugify(name)}-item-1`,
name: `${name}常备武具`,
category: '武器',
quantity: 1,
rarity: 'rare',
description: '随身不离手的主战物件。',
tags: ['战斗', '随身'],
},
{
id: `${slugify(name)}-item-2`,
name: `${name}补给包`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '为了久战和撤离准备的基础补给。',
tags: ['补给', '行动'],
},
{
id: `${slugify(name)}-item-3`,
name: `${name}私人物件`,
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '不愿轻易交出的旧信物。',
tags: ['信物', '线索'],
},
];
}
function buildPlayableNpcs(seed: string) {
return PLAYABLE_ROLE_TEMPLATES.map((template, index) => {
const name = `${seed.slice(0, 2) || '裂潮'}${['岚', '砺', '遥', '烛', '澜'][index]}`;
return {
id: `playable-npc-${index + 1}`,
name,
title: template.title,
role: template.role,
description: `${name}习惯先观察再出手,对局势变化反应极快。`,
backstory: `${name}长期在风暴边缘活动,对眼前这场失衡局势并不陌生。`,
personality: '谨慎、沉稳、保留余地',
motivation: '想先查清是谁把局势推到这一步。',
combatStyle: template.style,
initialAffinity: 18 + index * 4,
relationshipHooks: ['共同求生', '交换情报'],
tags: [...template.tags],
backstoryReveal: buildBackstoryReveal(name),
skills: buildSkills(name),
initialItems: buildInitialItems(name),
templateCharacterId: ['sword-princess', 'archer-hero', 'girl-hero', 'punch-hero', 'fighter-4'][index],
};
});
}
function buildStoryNpcs(seed: string) {
return Array.from({ length: 25 }, (_, index) => {
const template = STORY_ROLE_TEMPLATES[index % STORY_ROLE_TEMPLATES.length]!;
const name = `${seed.slice(0, 2) || '裂潮'}${['青', '玄', '沉', '洛', '霁'][index % 5]}${index + 1}`;
return {
id: `story-npc-${index + 1}`,
name,
title: `${index + 1}位见证者`,
role: template.role,
description: `${name}始终在观察这场异动会把谁先逼到台前。`,
backstory: `${name}和这片地界的旧事牵连很深,只是还没有把来历说透。`,
personality: '警觉、克制、善于藏话',
motivation: '想确认这轮动荡背后真正的引线。',
combatStyle: template.danger === 'high' ? '先压后断' : '先试后动',
initialAffinity: template.danger === 'high' ? -12 : 6 + (index % 3) * 6,
relationshipHooks: ['旧案牵连', '局势试探'],
tags: [...template.tags],
backstoryReveal: buildBackstoryReveal(name),
skills: buildSkills(name),
initialItems: buildInitialItems(name),
};
});
}
function buildLandmarks(seed: string, storyNpcIds: string[]) {
return LANDMARK_TEMPLATES.map((baseName, index, all) => {
const name = `${seed.slice(0, 2) || '裂潮'}${baseName}`;
return {
id: `landmark-${index + 1}`,
name,
description: `${name}附近同时压着旧痕、异动与尚未收束的危险。`,
dangerLevel: index < 3 ? 'medium' : index < 7 ? 'high' : 'extreme',
sceneNpcIds: [
storyNpcIds[index % storyNpcIds.length],
storyNpcIds[(index + 7) % storyNpcIds.length],
storyNpcIds[(index + 13) % storyNpcIds.length],
],
connections: [
{
targetLandmarkId: `landmark-${((index + 1) % all.length) + 1}`,
relativePosition: 'forward',
summary: '沿着当前道路继续前推就能抵达。',
},
{
targetLandmarkId: `landmark-${((index + all.length - 1) % all.length) + 1}`,
relativePosition: 'back',
summary: '沿原路回撤可以折返到上一处节点。',
},
],
};
});
}
function buildProgress(
phaseId: string,
phaseLabel: string,
phaseDetail: string,
overallProgress: number,
activeStepIndex: number,
startedAt: number,
): CustomWorldGenerationProgress {
const steps = [
{ id: 'framework', label: '世界框架', detail: '整理世界基础骨架。', status: overallProgress >= 0.25 ? 'completed' : phaseId === 'framework' ? 'active' : 'pending', completed: overallProgress >= 0.25 ? 1 : 0, total: 1 },
{ id: 'roles', label: '角色群像', detail: '生成可玩与场景角色。', status: overallProgress >= 0.6 ? 'completed' : phaseId === 'roles' ? 'active' : 'pending', completed: overallProgress >= 0.6 ? 1 : 0, total: 1 },
{ id: 'landmarks', label: '场景网络', detail: '生成地标与连接关系。', status: overallProgress >= 0.85 ? 'completed' : phaseId === 'landmarks' ? 'active' : 'pending', completed: overallProgress >= 0.85 ? 1 : 0, total: 1 },
{ id: 'finalize', label: '最终归档', detail: '整理最终世界资料。', status: overallProgress >= 1 ? 'completed' : phaseId === 'finalize' ? 'active' : 'pending', completed: overallProgress >= 1 ? 1 : 0, total: 1 },
] as CustomWorldGenerationProgress['steps'];
return {
phaseId,
phaseLabel,
phaseDetail,
overallProgress,
completedWeight: Math.round(overallProgress * 100),
totalWeight: 100,
elapsedMs: nowMs() - startedAt,
estimatedRemainingMs: overallProgress >= 1 ? 0 : Math.max(1000, Math.round((1 - overallProgress) * 4000)),
activeStepIndex,
steps,
};
}
function inferMajorFactions(seed: string) {
return [
`${seed.slice(0, 2) || '裂潮'}守桥司`,
`${seed.slice(0, 2) || '裂潮'}旧案会`,
`${seed.slice(0, 2) || '裂潮'}商旅盟`,
];
}
function inferCoreConflicts(seedText: string) {
const core = seedText.slice(0, 24) || '旧秩序与新威胁的失衡';
return [
`围绕“${core}”的旧秩序正在松动。`,
'各方都在争夺谁来解释眼前的异变。',
'真正推动局势的人始终没有完全现身。',
];
}
function buildDeterministicProfile(input: GenerateCustomWorldProfileInput) {
const setting = seedText(input);
const worldType = inferWorldType(setting);
const seed = setting.replace(/\s+/g, '').slice(0, 6) || (worldType === 'XIANXIA' ? '云潮' : '裂潮');
const playableNpcs = buildPlayableNpcs(seed);
const storyNpcs = buildStoryNpcs(seed);
const landmarks = buildLandmarks(
seed,
storyNpcs.map((npc) => npc.id),
function createCustomWorldGenerationReporter(
onProgress?: (progress: CustomWorldGenerationProgress) => void,
) {
const startedAt = nowMs();
const completedByStage = Object.fromEntries(
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((stage) => [stage.id, 0]),
) as Record<CustomWorldGenerationStageId, number>;
const totalWeight = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
(sum, stage) => sum + stage.weight,
0,
);
const emit = (
stageId: CustomWorldGenerationStageId,
options: Partial<{
completed: number;
phaseDetail: string;
}> = {},
) => {
const stage = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.find(
(item) => item.id === stageId,
);
if (!stage) {
return;
}
if (typeof options.completed === 'number') {
completedByStage[stageId] = Math.max(
0,
Math.min(stage.total, options.completed),
);
}
const steps = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((item) => {
const completed = Math.max(
0,
Math.min(item.total, completedByStage[item.id]),
);
return {
id: item.id,
label: item.label,
detail: item.detail,
completed,
total: item.total,
status:
completed >= item.total
? 'completed'
: item.id === stageId
? 'active'
: 'pending',
} satisfies CustomWorldGenerationProgress['steps'][number];
});
const completedWeight = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
(sum, item) =>
sum + (completedByStage[item.id] / item.total || 0) * item.weight,
0,
);
const progressFraction = totalWeight > 0 ? completedWeight / totalWeight : 0;
const elapsedMs = Math.max(0, nowMs() - startedAt);
const estimatedRemainingMs =
progressFraction > 0 && progressFraction < 1
? Math.max(0, Math.round(elapsedMs / progressFraction - elapsedMs))
: progressFraction >= 1
? 0
: null;
onProgress?.({
phaseId: stage.id,
phaseLabel: stage.label,
phaseDetail: options.phaseDetail ?? stage.detail,
overallProgress: Math.max(
0,
Math.min(100, Math.round(progressFraction * 100)),
),
completedWeight,
totalWeight,
elapsedMs,
estimatedRemainingMs,
activeStepIndex: CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.findIndex(
(item) => item.id === stage.id,
),
steps,
});
};
return {
id: `custom-world-${Date.now().toString(36)}-${slugify(seed)}`,
settingText: setting,
name: worldType === 'XIANXIA' ? `${seed}灵境` : `${seed}边城`,
subtitle: '前路未明',
summary: `这个世界围绕“${setting.slice(0, 28)}”展开,旧秩序与新威胁正在同时逼近。`,
tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、边境余震',
playerGoal: '查清眼前局势的关键矛盾,并守住仍值得相信的人与事',
templateWorldType: worldType,
compatibilityTemplateWorldType: worldType,
majorFactions: inferMajorFactions(seed),
coreConflicts: inferCoreConflicts(setting),
attributeSchema: buildAttributeSchema(worldType),
playableNpcs,
storyNpcs,
items: [],
camp: {
name: worldType === 'XIANXIA' ? `${seed}归云舍` : `${seed}归桥居`,
description: '这是玩家开局时暂时安身、整理情报与调整队伍的位置。',
dangerLevel: 'low',
begin(stageId: CustomWorldGenerationStageId, phaseDetail?: string) {
emit(stageId, {
completed: completedByStage[stageId],
phaseDetail,
});
},
landmarks,
themePack: null,
storyGraph: null,
knowledgeFacts: [],
threadContracts: [],
creatorIntent: input.creatorIntent ?? null,
anchorPack: null,
lockState: null,
ownedSettingLayers: null,
generationMode: input.generationMode ?? 'full',
generationStatus: input.generationMode === 'fast' ? 'key_only' : 'complete',
scenarioPackId: null,
campaignPackId: null,
} satisfies GeneratedProfile;
complete(stageId: CustomWorldGenerationStageId, phaseDetail?: string) {
const stage = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.find(
(item) => item.id === stageId,
);
if (!stage) {
return;
}
emit(stageId, {
completed: stage.total,
phaseDetail,
});
},
};
}
function buildCustomWorldProfilePrompt(params: {
settingText: string;
generationSeedText: string;
creatorIntent: CustomWorldCreatorIntent | null;
generationMode: CustomWorldGenerationMode;
}) {
const targets = getCustomWorldGenerationTargets(params.generationMode);
const creatorIntentText =
params.creatorIntent && hasMeaningfulCustomWorldCreatorIntent(params.creatorIntent)
? buildCustomWorldCreatorIntentGenerationText(params.creatorIntent)
: '';
return [
'请根据创作者输入,生成一个可直接进入游戏的自定义世界档案 JSON。',
'必须严格输出单个 JSON 对象,不要 Markdown不要解释。',
'',
`生成模式:${params.generationMode}`,
`可扮演角色数量:${targets.playableCount}`,
`场景角色数量:${targets.storyCount}`,
`关键场景数量:${targets.landmarkCount}`,
'',
'创作者输入:',
params.generationSeedText,
creatorIntentText ? `\n结构化创作锚点\n${creatorIntentText}` : '',
'',
'输出 JSON 字段要求:',
'- name, subtitle, summary, tone, playerGoal, templateWorldType',
'- majorFactions: string[]coreConflicts: string[]',
'- camp: { name, description, dangerLevel }',
'- playableNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
'- storyNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
'- landmarks: 每项包含 name,description,dangerLevel,sceneNpcNames,connections',
'- connections 每项包含 targetLandmarkName, relativePosition, summarytargetLandmarkName 必须指向本次输出的其他场景名',
'',
'约束:',
'- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。',
'- 角色名字、势力名、场景名必须互相区分,避免重复。',
'- sceneNpcNames 只能引用 storyNpcs 中已经输出的 name。',
'- templateWorldType 只能是 WUXIA 或 XIANXIA。',
'- dangerLevel 使用 low、medium、high、extreme 之一。',
'- relativePosition 使用 forward、back、left、right、up、down、inside、outside 之一。',
'- 不要预生成物品档案items 如需输出,必须为空数组。',
].filter(Boolean).join('\n');
}
function buildCustomWorldProfileRepairPrompt(responseText: string) {
return [
'请修复下面的自定义世界 JSON。',
'只输出能被 JSON.parse 直接解析的单个 JSON 对象,不要解释。',
responseText,
].join('\n\n');
}
async function parseCustomWorldJsonStage(params: {
llmClient: UpstreamLlmClient;
responseText: string;
signal?: AbortSignal;
}) {
throwIfCustomWorldGenerationAborted(params.signal);
try {
return parseJsonResponseText(params.responseText);
} catch {
const sanitized = sanitizeJsonLikeText(params.responseText);
if (sanitized && sanitized !== params.responseText.trim()) {
try {
return parseJsonResponseText(sanitized);
} catch {
// Fall through to model-assisted repair.
}
}
const repairedText = await params.llmClient.requestMessageContent({
systemPrompt: CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT,
userPrompt: buildCustomWorldProfileRepairPrompt(params.responseText),
signal: params.signal,
timeoutMs: 90000,
debugLabel: 'custom-world-profile-json-repair',
});
throwIfCustomWorldGenerationAborted(params.signal);
return parseJsonResponseText(sanitizeJsonLikeText(repairedText) || repairedText);
}
}
async function requestCustomWorldProfileJson(params: {
llmClient: UpstreamLlmClient;
userPrompt: string;
signal?: AbortSignal;
}) {
const responseText = await params.llmClient.requestMessageContent({
systemPrompt: CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT,
userPrompt: params.userPrompt,
signal: params.signal,
timeoutMs: CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
debugLabel: 'custom-world-profile',
});
if (!responseText.trim()) {
throw new Error('自定义世界生成失败:模型没有返回有效内容。');
}
return parseCustomWorldJsonStage({
llmClient: params.llmClient,
responseText,
signal: params.signal,
});
}
function attachRuntimeGenerationMetadata(params: {
profile: CustomWorldProfile;
settingText: string;
creatorIntent: CustomWorldCreatorIntent | null;
generationMode: CustomWorldGenerationMode;
}) {
const targets = getCustomWorldGenerationTargets(params.generationMode);
return {
...params.profile,
settingText: params.settingText || params.profile.settingText,
creatorIntent: params.creatorIntent,
anchorPack:
params.profile.anchorPack ??
buildCustomWorldAnchorPackFromIntent(params.creatorIntent),
lockState:
params.profile.lockState ??
deriveCustomWorldLockStateFromIntent(params.creatorIntent),
generationMode: params.generationMode,
generationStatus: targets.generationStatus,
items: [],
} satisfies CustomWorldProfile;
}
export async function generateCustomWorldProfileFromOrchestrator(
llmClient: UpstreamLlmClient,
input: GenerateCustomWorldProfileInput,
options: {
onProgress?: (progress: CustomWorldGenerationProgress) => void;
signal?: AbortSignal;
} = {},
) {
if (options.signal?.aborted) {
throw new Error('世界生成已中断。');
const {
settingText,
generationSeedText,
creatorIntent,
generationMode,
} = resolveCustomWorldGenerationInput(input);
const reporter = createCustomWorldGenerationReporter(options.onProgress);
try {
throwIfCustomWorldGenerationAborted(options.signal);
reporter.begin('prepare', '正在整理创作者输入与结构化锚点。');
const userPrompt = buildCustomWorldProfilePrompt({
settingText,
generationSeedText,
creatorIntent,
generationMode,
});
reporter.complete('prepare', '设定上下文已整理,开始请求大模型推理。');
reporter.begin('llm-profile', '正在请求模型生成世界档案、角色群像与场景网络。');
const rawProfile = await requestCustomWorldProfileJson({
llmClient,
userPrompt,
signal: options.signal,
});
reporter.complete('llm-profile', '模型已返回世界档案,开始系统归一与运行时编译。');
reporter.begin('normalize', '正在把模型 JSON 归一为可运行的自定义世界结构。');
const expandedProfile = buildExpandedCustomWorldProfile(
{
...(rawProfile as GeneratedProfile),
settingText,
creatorIntent,
generationMode,
generationStatus: getCustomWorldGenerationTargets(generationMode)
.generationStatus,
},
generationSeedText,
);
const profile = attachRuntimeGenerationMetadata({
profile: expandedProfile,
settingText,
creatorIntent,
generationMode,
});
reporter.complete('normalize', '模型结果已完成运行时结构编译。');
reporter.begin('finalize', '正在做最终完整性校验。');
if (generationMode === 'full') {
validateGeneratedCustomWorldProfile(profile);
}
reporter.complete('finalize', `世界“${profile.name}”已完成归档。`);
return profile as unknown as GeneratedProfile;
} catch (error) {
if (isCustomWorldGenerationAbortLikeError(error) || options.signal?.aborted) {
throw error instanceof Error
? error
: new CustomWorldGenerationAbortedError();
}
if (error instanceof SyntaxError) {
throw new Error(
'自定义世界生成失败:模型返回了非严格 JSON且自动修复仍未成功请稍后重试。',
);
}
throw error;
}
const startedAt = nowMs();
options.onProgress?.(
buildProgress(
'framework',
'世界框架',
'正在整理世界基础设定与主矛盾。',
0.2,
0,
startedAt,
),
);
options.onProgress?.(
buildProgress(
'roles',
'角色群像',
'正在生成可扮演角色与场景角色骨架。',
0.55,
1,
startedAt,
),
);
options.onProgress?.(
buildProgress(
'landmarks',
'场景网络',
'正在生成地标与场景连接关系。',
0.82,
2,
startedAt,
),
);
const profile = buildDeterministicProfile(input);
options.onProgress?.(
buildProgress(
'finalize',
'最终归档',
`世界“${String(profile.name)}”已完成归档。`,
1,
3,
startedAt,
),
);
return profile;
}

View File

@@ -5,12 +5,15 @@ import type {
CharacterChatSuggestionsRequest,
} from '../../../../packages/shared/src/contracts/story.js';
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
import { CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT } from './chatPromptBuilders.js';
import { SYSTEM_PROMPT } from './storyPromptBuilders.js';
import {
generateCharacterChatSuggestionsFromOrchestrator,
} from './chatOrchestrator.js';
import { CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT } from './chatPromptBuilders.js';
import {
generateCustomWorldProfileFromOrchestrator,
} from './customWorldOrchestrator.js';
import { generateInitialStoryFromOrchestrator } from './storyOrchestrator.js';
import { SYSTEM_PROMPT } from './storyPromptBuilders.js';
type TestStoryContext = Parameters<typeof generateInitialStoryFromOrchestrator>[4];
type TestStoryOption = Awaited<
@@ -191,3 +194,105 @@ test('chat orchestrator builds character suggestion prompts on the server side',
assert.match(capturedPrompts[0]?.userPrompt ?? '', //u);
assert.match(capturedPrompts[0]?.userPrompt ?? '', new RegExp(payload.targetCharacter.name, 'u'));
});
test('custom world orchestrator requests LLM content before compiling the profile', async () => {
const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = [];
const storyNpcNames = Array.from(
{ length: 8 },
(_, index) => `潮灯见证者${index + 1}`,
);
const llmClient = {
requestMessageContent: async ({
systemPrompt,
userPrompt,
}: {
systemPrompt: string;
userPrompt: string;
}) => {
capturedPrompts.push({ systemPrompt, userPrompt });
return JSON.stringify({
name: '潮灯列岛',
subtitle: '雾潮之下',
summary: '旧灯塔、潮雾与沉船盟约纠缠出的列岛冒险。',
tone: '潮湿、悬疑、克制',
playerGoal: '查明潮雾为何吞掉守灯人的名字',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '沉船商盟', '潮雾祭司'],
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
camp: {
name: '旧灯塔下层',
description: '潮水退去时才露出的临时据点。',
dangerLevel: 'low',
},
playableNpcs: Array.from({ length: 3 }, (_, index) => ({
name: `守灯旅人${index + 1}`,
title: `${index + 1}盏灯`,
role: '守灯同行者',
description: '在潮雾边缘辨认灯火与人声。',
backstory: '曾经守过一座被除名的灯塔。',
personality: '谨慎、沉静、记仇',
motivation: '找回被潮雾吞掉的名字。',
combatStyle: '短刃牵制后借灯火逼退敌人。',
initialAffinity: 18,
relationshipHooks: ['守灯', '旧名'],
tags: ['潮雾', '灯塔'],
})),
storyNpcs: storyNpcNames.map((name, index) => ({
name,
title: `${index + 1}位见证者`,
role: '潮雾见证者',
description: '知道一段被潮水洗掉的航线传闻。',
backstory: '在沉船夜里听见过不该出现的钟声。',
personality: '警觉、克制',
motivation: '确认下一次潮雾会带走谁。',
combatStyle: '先试探再撤入雾中。',
initialAffinity: 6,
relationshipHooks: ['沉船夜', '钟声'],
tags: ['潮雾', '线索'],
})),
landmarks: Array.from({ length: 4 }, (_, index) => ({
name: `潮灯地标${index + 1}`,
description: '潮雾会在这里折回,留下盐痕和旧灯影。',
dangerLevel: index === 0 ? 'medium' : 'high',
sceneNpcNames: storyNpcNames.slice(index, index + 3),
connections: [
{
targetLandmarkName: `潮灯地标${(index + 1) % 4 + 1}`,
relativePosition: 'forward',
summary: '沿潮痕继续前行即可抵达下一处灯影。',
},
],
})),
items: [],
});
},
} as const;
const progressEvents: Array<{ phaseId: string; overallProgress: number }> = [];
const profile = await generateCustomWorldProfileFromOrchestrator(
llmClient as never,
{
settingText: '一个被潮雾与失落列岛切碎的边境世界。',
generationMode: 'fast',
},
{
onProgress: (progress) => {
progressEvents.push({
phaseId: progress.phaseId,
overallProgress: progress.overallProgress,
});
},
},
);
assert.equal(capturedPrompts.length, 1);
assert.match(capturedPrompts[0]?.systemPrompt ?? '', /JSON /u);
assert.match(capturedPrompts[0]?.userPrompt ?? '', /fast/u);
assert.match(capturedPrompts[0]?.userPrompt ?? '', //u);
assert.equal(profile.name, '潮灯列岛');
assert.equal(profile.generationMode, 'fast');
assert.equal(profile.generationStatus, 'key_only');
assert.equal((profile.playableNpcs as unknown[]).length, 3);
assert.ok(progressEvents.some((event) => event.phaseId === 'llm-profile'));
assert.equal(progressEvents.at(-1)?.overallProgress, 100);
});

View File

@@ -1,14 +1,15 @@
import type { QueryResultRow } from 'pg';
import {
DEFAULT_MUSIC_VOLUME,
SAVE_SNAPSHOT_VERSION,
} from '../../../packages/shared/src/contracts/runtime.js';
import type {
CustomWorldProfileRecord,
RuntimeSettings,
SavedGameSnapshot,
} from '../../../packages/shared/src/contracts/runtime.js';
import {
type CustomWorldSessionRecord,
DEFAULT_MUSIC_VOLUME,
SAVE_SNAPSHOT_VERSION,
} from '../../../packages/shared/src/contracts/runtime.js';
import type { AppDatabase } from '../db.js';
const MAX_CUSTOM_WORLD_PROFILES = 12;
@@ -29,6 +30,13 @@ type SettingsRow = QueryResultRow & {
type ProfileRow = QueryResultRow & {
payload: CustomWorldProfileRecord;
updatedAt: string;
};
type SessionRow = QueryResultRow & {
payload: CustomWorldSessionRecord;
createdAt: string;
updatedAt: string;
};
export type RuntimeRepositoryPort = {
@@ -53,6 +61,16 @@ export type RuntimeRepositoryPort = {
userId: string,
profileId: string,
): Promise<CustomWorldProfileRecord[]>;
listCustomWorldSessions(userId: string): Promise<CustomWorldSessionRecord[]>;
getCustomWorldSession(
userId: string,
sessionId: string,
): Promise<CustomWorldSessionRecord | null>;
upsertCustomWorldSession(
userId: string,
sessionId: string,
session: CustomWorldSessionRecord,
): Promise<CustomWorldSessionRecord>;
};
export class RuntimeRepository implements RuntimeRepositoryPort {
@@ -175,7 +193,8 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
async listCustomWorldProfiles(userId: string) {
const result = await this.db.query<ProfileRow>(
`SELECT payload_json AS payload
`SELECT payload_json AS payload,
updated_at AS "updatedAt"
FROM custom_world_profiles
WHERE user_id = $1
ORDER BY updated_at DESC
@@ -183,7 +202,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
[userId, MAX_CUSTOM_WORLD_PROFILES],
);
return result.rows.map((row: ProfileRow) => row.payload);
return result.rows.map((row: ProfileRow) => ({
...row.payload,
updatedAt: row.updatedAt,
}));
}
async upsertCustomWorldProfile(
@@ -217,4 +239,75 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
return this.listCustomWorldProfiles(userId);
}
async listCustomWorldSessions(userId: string) {
const result = await this.db.query<SessionRow>(
`SELECT payload_json AS payload,
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM custom_world_sessions
WHERE user_id = $1
ORDER BY updated_at DESC`,
[userId],
);
return result.rows.map((row) => ({
...row.payload,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
}
async getCustomWorldSession(userId: string, sessionId: string) {
const result = await this.db.query<SessionRow>(
`SELECT payload_json AS payload,
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM custom_world_sessions
WHERE user_id = $1 AND session_id = $2`,
[userId, sessionId],
);
const row = result.rows[0];
if (!row) {
return null;
}
return {
...row.payload,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
async upsertCustomWorldSession(
userId: string,
sessionId: string,
session: CustomWorldSessionRecord,
) {
const payload = {
...session,
sessionId,
} satisfies CustomWorldSessionRecord;
await this.db.query(
`INSERT INTO custom_world_sessions (
user_id,
session_id,
payload_json,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, session_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at`,
[userId, sessionId, payload, session.createdAt, session.updatedAt],
);
return {
...payload,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
}
}

View File

@@ -0,0 +1,221 @@
import { Router } from 'express';
import { z } from 'zod';
import type {
CreateCustomWorldAgentSessionRequest,
CustomWorldAgentActionRequest,
SendCustomWorldAgentMessageRequest,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { AppContext } from '../context.js';
import { badRequest, notFound } from '../errors.js';
import { asyncHandler, prepareApiResponse, sendApiResponse } from '../http.js';
import { routeMeta } from '../middleware/routeMeta.js';
const createSessionSchema = z.object({
seedText: z.string().trim().optional().default(''),
});
const sendMessageSchema = z.object({
clientMessageId: z.string().trim().min(1),
text: z.string().trim().min(1),
focusCardId: z.string().trim().nullable().optional().default(null),
selectedCardIds: z.array(z.string().trim().min(1)).optional().default([]),
});
const actionSchema = z.discriminatedUnion('action', [
z.object({
action: z.literal('draft_foundation'),
}),
z.object({
action: z.literal('update_draft_card'),
cardId: z.string().trim().min(1),
sections: z
.array(
z.object({
sectionId: z.string().trim().min(1),
value: z.string(),
}),
)
.min(1),
}),
z.object({
action: z.literal('generate_characters'),
count: z.number().int().min(1).max(3),
promptText: z.string().trim().nullable().optional().default(null),
anchorCardIds: z.array(z.string().trim().min(1)).optional().default([]),
}),
z.object({
action: z.literal('generate_landmarks'),
count: z.number().int().min(1).max(3),
promptText: z.string().trim().nullable().optional().default(null),
anchorCardIds: z.array(z.string().trim().min(1)).optional().default([]),
}),
z.object({
action: z.literal('generate_role_assets'),
roleIds: z.array(z.string().trim().min(1)).min(1),
}),
z.object({
action: z.literal('sync_role_assets'),
roleId: z.string().trim().min(1),
portraitPath: z.string().trim().min(1),
generatedVisualAssetId: z.string().trim().min(1),
generatedAnimationSetId: z.string().trim().nullable().optional(),
animationMap: z.record(z.string(), z.unknown()).nullable().optional(),
}),
z.object({
action: z.literal('publish_world'),
}),
]);
function readParam(param: string | string[] | undefined) {
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
}
export function createCustomWorldAgentRoutes(context: AppContext) {
const router = Router();
router.post(
'/sessions',
routeMeta({ operation: 'runtime.customWorldAgent.createSession' }),
asyncHandler(async (request, response) => {
const payload = createSessionSchema.parse(
request.body,
) as CreateCustomWorldAgentSessionRequest;
sendApiResponse(response, {
session: await context.customWorldAgentOrchestrator.createSession(
request.userId!,
payload,
),
});
}),
);
router.get(
'/sessions/:sessionId',
routeMeta({ operation: 'runtime.customWorldAgent.getSession' }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
const session = await context.customWorldAgentOrchestrator.getSessionSnapshot(
request.userId!,
sessionId,
);
if (!session) {
throw notFound('custom world agent session not found');
}
sendApiResponse(response, session);
}),
);
router.post(
'/sessions/:sessionId/messages',
routeMeta({ operation: 'runtime.customWorldAgent.sendMessage' }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
const payload = sendMessageSchema.parse(
request.body,
) as SendCustomWorldAgentMessageRequest;
sendApiResponse(
response,
await context.customWorldAgentOrchestrator.submitMessage(
request.userId!,
sessionId,
payload,
),
);
}),
);
router.post(
'/sessions/:sessionId/actions',
routeMeta({ operation: 'runtime.customWorldAgent.executeAction' }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
const payload = actionSchema.parse(
request.body,
) as CustomWorldAgentActionRequest;
sendApiResponse(
response,
await context.customWorldAgentOrchestrator.executeAction(
request.userId!,
sessionId,
payload,
),
);
}),
);
router.get(
'/sessions/:sessionId/operations/:operationId',
routeMeta({ operation: 'runtime.customWorldAgent.getOperation' }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
const operationId = readParam(request.params.operationId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
if (!operationId) {
throw badRequest('operationId is required');
}
const operation = await context.customWorldAgentOrchestrator.getOperation(
request.userId!,
sessionId,
operationId,
);
if (!operation) {
throw notFound('custom world agent operation not found');
}
prepareApiResponse(request, response, {
statusCode: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
});
response.end(JSON.stringify({ operation }));
}),
);
router.get(
'/sessions/:sessionId/cards/:cardId',
routeMeta({ operation: 'runtime.customWorldAgent.getCardDetail' }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
const cardId = readParam(request.params.cardId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
if (!cardId) {
throw badRequest('cardId is required');
}
const card = await context.customWorldAgentOrchestrator.getCardDetail(
request.userId!,
sessionId,
cardId,
);
if (!card) {
throw notFound('custom world agent card not found');
}
sendApiResponse(response, {
card,
});
}),
);
return router;
}

View File

@@ -1,6 +1,7 @@
import { Router } from 'express';
import { z } from 'zod';
import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type {
AnswerCustomWorldSessionQuestionRequest,
CreateCustomWorldSessionRequest,
@@ -27,6 +28,8 @@ import {
prepareEventStreamResponse,
sendApiResponse,
} from '../http.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { routeMeta } from '../middleware/routeMeta.js';
import {
generateCharacterChatSuggestionsFromOrchestrator,
generateCharacterChatSummaryFromOrchestrator,
@@ -34,8 +37,6 @@ import {
streamNpcChatDialogueFromOrchestrator,
streamNpcRecruitDialogueFromOrchestrator,
} from '../modules/ai/chatOrchestrator.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { routeMeta } from '../middleware/routeMeta.js';
import {
hydrateSavedSnapshot,
normalizeSavedSnapshotPayload,
@@ -48,6 +49,9 @@ import {
npcRecruitDialogueRequestSchema,
} from '../services/chatService.js';
import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js';
import {
listCustomWorldWorkSummaries,
} from '../services/customWorldWorkSummaryService.js';
import { generateQuestForNpcEncounter } from '../services/questService.js';
import { generateRuntimeItemIntents } from '../services/runtimeItemService.js';
import {
@@ -59,6 +63,7 @@ import {
generateHighQualityNextStory,
parseStoryRequest,
} from '../services/storyService.js';
import { createCustomWorldAgentRoutes } from './customWorldAgent.js';
const jsonObjectSchema = z.record(z.string(), z.unknown());
@@ -109,6 +114,10 @@ export function createRuntimeRoutes(context: AppContext) {
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.use(requireAuth);
router.use(
'/runtime/custom-world/agent',
createCustomWorldAgentRoutes(context),
);
router.post(
'/llm/chat/completions',
@@ -198,6 +207,19 @@ export function createRuntimeRoutes(context: AppContext) {
}),
);
router.get(
'/runtime/custom-world/works',
routeMeta({ operation: 'runtime.customWorldWorks.list' }),
asyncHandler(async (request, response) => {
sendApiResponse<ListCustomWorldWorksResponse>(response, {
items: await listCustomWorldWorkSummaries(request.userId!, {
runtimeRepository: context.runtimeRepository,
customWorldAgentSessions: context.customWorldAgentSessions,
}),
});
}),
);
router.get(
'/runtime/custom-world-library',
routeMeta({ operation: 'runtime.customWorldLibrary.list' }),
@@ -356,7 +378,7 @@ export function createRuntimeRoutes(context: AppContext) {
) as CreateCustomWorldSessionRequest;
sendApiResponse(
response,
context.customWorldSessions.create(
await context.customWorldSessions.create(
request.userId!,
payload.settingText,
payload.creatorIntent,
@@ -370,7 +392,7 @@ export function createRuntimeRoutes(context: AppContext) {
'/runtime/custom-world/sessions/:sessionId',
routeMeta({ operation: 'runtime.customWorldSession.get' }),
asyncHandler(async (request, response) => {
const session = context.customWorldSessions.get(
const session = await context.customWorldSessions.get(
request.userId!,
readParam(request.params.sessionId),
);
@@ -388,7 +410,7 @@ export function createRuntimeRoutes(context: AppContext) {
const payload = customWorldAnswerSchema.parse(
request.body,
) as AnswerCustomWorldSessionQuestionRequest;
const session = context.customWorldSessions.answer(
const session = await context.customWorldSessions.answer(
request.userId!,
readParam(request.params.sessionId),
payload.questionId,
@@ -405,7 +427,7 @@ export function createRuntimeRoutes(context: AppContext) {
'/runtime/custom-world/sessions/:sessionId/generate/stream',
routeMeta({ operation: 'runtime.customWorldSession.generateStream' }),
asyncHandler(async (request, response) => {
const session = context.customWorldSessions.get(
const session = await context.customWorldSessions.get(
request.userId!,
readParam(request.params.sessionId),
);
@@ -426,7 +448,7 @@ export function createRuntimeRoutes(context: AppContext) {
};
writeEvent('progress', { phase: 'preparing', progress: 10 });
context.customWorldSessions.updateStatus(
await context.customWorldSessions.updateStatus(
request.userId!,
readParam(request.params.sessionId),
'generating',
@@ -443,7 +465,7 @@ export function createRuntimeRoutes(context: AppContext) {
);
},
});
context.customWorldSessions.setResult(
await context.customWorldSessions.setResult(
request.userId!,
readParam(request.params.sessionId),
profile,
@@ -456,7 +478,7 @@ export function createRuntimeRoutes(context: AppContext) {
error instanceof Error
? error.message
: 'custom world generation failed';
context.customWorldSessions.updateStatus(
await context.customWorldSessions.updateStatus(
request.userId!,
readParam(request.params.sessionId),
'generation_error',

View File

@@ -5,16 +5,18 @@ import { type AppConfig, loadConfig } from './config.js';
import type { AppContext } from './context.js';
import { createDatabase } from './db.js';
import { createLogger } from './logging.js';
import { AuthIdentityRepository } from './repositories/authIdentityRepository.js';
import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js';
import { AuthIdentityRepository } from './repositories/authIdentityRepository.js';
import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js';
import { RuntimeRepository } from './repositories/runtimeRepository.js';
import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js';
import { UserRepository } from './repositories/userRepository.js';
import { UserSessionRepository } from './repositories/userSessionRepository.js';
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
import { UpstreamLlmClient } from './services/llmClient.js';
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
import { createSmsVerificationService } from './services/smsVerificationService.js';
import { createWechatAuthService } from './services/wechatAuthService.js';
import { WechatAuthStateStore } from './services/wechatAuthStateStore.js';
@@ -77,6 +79,10 @@ function describeDatabase(databaseUrl: string) {
export async function createAppContext(config: AppConfig = loadConfig()) {
const logger = createLogger(config);
const db = await createDatabase(config);
const runtimeRepository = new RuntimeRepository(db);
const customWorldAgentSessions = new CustomWorldAgentSessionStore(
runtimeRepository,
);
const context: AppContext = {
config,
logger,
@@ -87,9 +93,16 @@ export async function createAppContext(config: AppConfig = loadConfig()) {
authRiskBlockRepository: new AuthRiskBlockRepository(db),
smsAuthEventRepository: new SmsAuthEventRepository(db),
userSessionRepository: new UserSessionRepository(db),
runtimeRepository: new RuntimeRepository(db),
runtimeRepository,
llmClient: new UpstreamLlmClient(config, logger),
customWorldSessions: new CustomWorldSessionStore(),
customWorldSessions: new CustomWorldSessionStore(runtimeRepository),
customWorldAgentSessions,
customWorldAgentOrchestrator: new CustomWorldAgentOrchestrator(
customWorldAgentSessions,
config.llm.apiKey.trim()
? new UpstreamLlmClient(config, logger)
: null,
),
smsVerificationService: createSmsVerificationService(config, logger),
wechatAuthService: createWechatAuthService(config, logger),
wechatAuthStates: new WechatAuthStateStore(),

View File

@@ -0,0 +1,99 @@
import type { CustomWorldRoleAssetSummary } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import {
getRoleAssetSummaryById,
mergeRoleAssetIntoDraftProfile,
} from './customWorldAgentRoleAssetStateService.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 toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
)
: [];
}
type SyncRoleAssetsPayload = {
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
};
export type SyncRoleAssetsResult = {
roleId: string;
updatedRole: Record<string, unknown>;
updatedAssetSummary: CustomWorldRoleAssetSummary;
draftProfile: Record<string, unknown>;
};
export class CustomWorldAgentAssetBridgeService {
buildRoleAssetStudioContext(snapshot: unknown, roleId: string) {
const profile = toRecord(snapshot);
if (!profile) {
throw new Error('当前世界草稿为空,无法打开角色资产工坊。');
}
const playableRole = toRecordArray(profile.playableNpcs).find(
(item) => toText(item.id) === roleId,
);
const storyRole = toRecordArray(profile.storyNpcs).find(
(item) => toText(item.id) === roleId,
);
const role = playableRole ?? storyRole;
if (!role) {
throw new Error('未找到目标角色,无法进入角色资产工坊。');
}
const assetSummary = getRoleAssetSummaryById(profile, roleId);
if (!assetSummary) {
throw new Error('未找到目标角色的资产摘要。');
}
return {
roleId,
roleName: toText(role.name) || assetSummary.roleName,
roleKind: playableRole ? ('playable' as const) : ('story' as const),
startFrom:
assetSummary.status === 'missing' ? ('visual' as const) : ('animation' as const),
assetSummary,
};
}
applyRoleAssetPublishResult(
snapshot: unknown,
payload: SyncRoleAssetsPayload,
): SyncRoleAssetsResult {
const profile = toRecord(snapshot);
if (!profile) {
throw new Error('当前世界草稿为空,无法同步角色资产。');
}
const { draftProfile, updatedRole } = mergeRoleAssetIntoDraftProfile(
profile,
payload,
);
const assetSummary = getRoleAssetSummaryById(draftProfile, payload.roleId);
if (!assetSummary) {
throw new Error('角色资产同步后未能生成新的资产摘要。');
}
return {
roleId: payload.roleId,
updatedRole,
updatedAssetSummary: assetSummary,
draftProfile,
};
}
}

View File

@@ -0,0 +1,91 @@
import {
getWorldFoundationCardId,
normalizeFoundationDraftProfile,
} from './customWorldAgentDraftCompiler.js';
type BuildDraftChangeSummaryParams =
| {
action: 'update_draft_card';
cardId: string;
changedLabels: string[];
draftProfile: Record<string, unknown>;
}
| {
action: 'generate_characters';
names: string[];
draftProfile: Record<string, unknown>;
}
| {
action: 'generate_landmarks';
names: string[];
draftProfile: Record<string, unknown>;
};
function resolveTotalCharacterCount(
profile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
) {
return [...new Set([...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id))]
.length;
}
function resolveCardTitle(
draftProfile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
cardId: string,
) {
if (cardId === getWorldFoundationCardId()) {
return draftProfile.name;
}
return (
draftProfile.factions.find((entry) => entry.id === cardId)?.title ||
draftProfile.factions.find((entry) => entry.id === cardId)?.name ||
[...draftProfile.playableNpcs, ...draftProfile.storyNpcs].find(
(entry) => entry.id === cardId,
)?.name ||
draftProfile.landmarks.find((entry) => entry.id === cardId)?.name ||
draftProfile.threads.find((entry) => entry.id === cardId)?.title ||
draftProfile.chapters.find((entry) => entry.id === cardId)?.title ||
(draftProfile.camp?.id === cardId ? draftProfile.camp.name : '') ||
'当前卡片'
);
}
export class CustomWorldAgentChangeSummaryService {
buildSummary(params: BuildDraftChangeSummaryParams) {
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
if (!draftProfile) {
return '这次改动已经写回草稿。';
}
const characterCount = resolveTotalCharacterCount(draftProfile);
const landmarkCount = draftProfile.landmarks.length;
if (params.action === 'update_draft_card') {
const title = resolveCardTitle(draftProfile, params.cardId);
const changedLabelText =
params.changedLabels.length > 0
? params.changedLabels.slice(0, 4).join('、')
: '核心字段';
return [
`已更新「${title}」的 ${changedLabelText}`,
`当前底稿里共有 ${characterCount} 个角色、${landmarkCount} 个地点。`,
'下一步建议顺着这张卡直接检查它牵动的线程或地点。',
].join('\n');
}
if (params.action === 'generate_characters') {
return [
`已补出 ${params.names.length} 个新角色:${params.names.join('、')}`,
`当前底稿里共有 ${characterCount} 个角色、${landmarkCount} 个地点。`,
'下一步建议先点开新角色卡,把玩家关系和关联线程收紧一轮。',
].join('\n');
}
return [
`已补出 ${params.names.length} 个新地点:${params.names.join('、')}`,
`当前底稿里共有 ${characterCount} 个角色、${landmarkCount} 个地点。`,
'下一步建议先点开新地点卡,把线程挂钩和场景气质收紧一轮。',
].join('\n');
}
}

View File

@@ -0,0 +1,161 @@
import type {
CreatorIntentReadiness,
CustomWorldPendingClarification,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { CustomWorldAgentStage } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { CustomWorldCreatorIntentRecord } from './customWorldAgentIntentExtractionService.js';
type CreatorIntentReadinessKey =
| 'world_hook'
| 'player_premise'
| 'theme_and_tone'
| 'core_conflict'
| 'relationship_seed'
| 'iconic_element';
const CLARIFICATION_DEFINITIONS: Array<{
targetKey: CreatorIntentReadinessKey;
priority: number;
label: string;
question: string;
}> = [
{
targetKey: 'world_hook',
priority: 1,
label: '世界一句话',
question: '先用一句话收住这个世界最独特的核心幻想,我会据此继续往下补。',
},
{
targetKey: 'player_premise',
priority: 2,
label: '玩家身份与开局',
question:
'玩家是谁,故事开场时卡在什么处境里?你可以把身份和开局困境一起告诉我。',
},
{
targetKey: 'core_conflict',
priority: 3,
label: '核心冲突',
question:
'现在推动这个世界往前走的主要冲突是什么?最好是能立刻形成剧情压力的那种。',
},
{
targetKey: 'theme_and_tone',
priority: 4,
label: '主题气质',
question:
'它整体更偏什么主题和气质?比如冷峻、压迫、浪漫、潮湿,也可以顺手告诉我不要什么。',
},
{
targetKey: 'relationship_seed',
priority: 5,
label: '关键关系钩子',
question:
'给我一个关键人物种子就行,他和玩家是什么关系,或者他藏着什么暗线?',
},
{
targetKey: 'iconic_element',
priority: 6,
label: '标志性要素',
question: '这个世界至少给我 1 个一眼能认出来的标志性元素、机制或意象。',
},
];
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
export function evaluateCreatorIntentReadiness(
intent: CustomWorldCreatorIntentRecord | null | undefined,
): CreatorIntentReadiness {
const completedKeys: CreatorIntentReadinessKey[] = [];
const missingKeys: CreatorIntentReadinessKey[] = [];
const relationshipReady =
intent?.keyCharacters.some(
(entry) =>
Boolean(toText(entry.name)) &&
Boolean(toText(entry.relationToPlayer) || toText(entry.hiddenHook)),
) ?? false;
const keyChecks: Array<{
key: CreatorIntentReadinessKey;
ready: boolean;
}> = [
{
key: 'world_hook',
ready:
(intent?.worldHook.trim().length ?? 0) >= 8 ||
(intent?.rawSettingText.trim().length ?? 0) >= 24,
},
{
key: 'player_premise',
ready: Boolean(
intent?.playerPremise.trim() && intent?.openingSituation.trim(),
),
},
{
key: 'theme_and_tone',
ready:
(intent?.themeKeywords.length ?? 0) >= 1 &&
(intent?.toneDirectives.length ?? 0) >= 1,
},
{
key: 'core_conflict',
ready: (intent?.coreConflicts.length ?? 0) >= 1,
},
{
key: 'relationship_seed',
ready: (intent?.keyCharacters.length ?? 0) >= 1 && relationshipReady,
},
{
key: 'iconic_element',
ready: (intent?.iconicElements.length ?? 0) >= 1,
},
];
keyChecks.forEach((entry) => {
if (entry.ready) {
completedKeys.push(entry.key);
return;
}
missingKeys.push(entry.key);
});
return {
isReady: missingKeys.length === 0,
completedKeys,
missingKeys,
};
}
export function buildPendingClarifications(
intent: CustomWorldCreatorIntentRecord | null | undefined,
readiness = evaluateCreatorIntentReadiness(intent),
) {
return CLARIFICATION_DEFINITIONS.filter((entry) =>
readiness.missingKeys.includes(entry.targetKey),
)
.sort((left, right) => left.priority - right.priority)
.slice(0, 1)
.map(
(entry): CustomWorldPendingClarification => ({
id: entry.targetKey,
label: entry.label,
question: entry.question,
targetKey: entry.targetKey,
priority: entry.priority,
}),
);
}
export function resolveCreatorIntentStage(params: {
hasUserInput: boolean;
readiness: CreatorIntentReadiness;
}): CustomWorldAgentStage {
if (params.readiness.isReady) {
return 'foundation_review';
}
return params.hasUserInput ? 'clarifying' : 'collecting_intent';
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,322 @@
import { badRequest, notFound } from '../errors.js';
import {
getWorldFoundationCardId,
normalizeFoundationDraftProfile,
} from './customWorldAgentDraftCompiler.js';
type DraftSectionPatch = {
sectionId: string;
value: string;
};
export type UpdateDraftCardSectionsParams = {
draftProfile: Record<string, unknown>;
cardId: string;
sections: DraftSectionPatch[];
};
const EDITABLE_SECTION_IDS = {
world: new Set(['title', 'subtitle', 'summary', 'playerGoal', 'tone', 'coreConflicts']),
faction: new Set(['title', 'subtitle', 'summary', 'publicGoal', 'tension']),
character: new Set(['name', 'role', 'publicMask', 'hiddenHook', 'relationToPlayer', 'summary']),
landmark: new Set(['name', 'purpose', 'mood', 'secret', 'summary']),
thread: new Set(['title', 'summary', 'conflictType', 'stakes']),
chapter: new Set(['title', 'summary', 'openingEvent', 'playerGoal', 'understandingShift']),
camp: new Set(['name', 'description', 'dangerLevel']),
} as const;
function normalizePatches(sections: DraftSectionPatch[]) {
const normalized = sections
.map((section) => ({
sectionId: section.sectionId.trim(),
value: section.value.trim(),
}))
.filter((section) => section.sectionId);
if (normalized.length === 0) {
throw badRequest('update_draft_card requires at least one section patch');
}
const deduped = new Map<string, string>();
normalized.forEach((section) => {
deduped.set(section.sectionId, section.value);
});
return [...deduped.entries()].map(([sectionId, value]) => ({
sectionId,
value,
}));
}
function parseStringList(value: string) {
return [...new Set(value.split(/[\n;]+/u).map((item) => item.trim()).filter(Boolean))];
}
function resolveThreadType(value: string) {
if (value.includes('暗') || value.toLowerCase() === 'hidden') {
return 'hidden' as const;
}
return 'main' as const;
}
export function updateDraftCardSections(params: UpdateDraftCardSectionsParams) {
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
if (!draftProfile) {
throw badRequest('draftProfile is empty');
}
const patches = normalizePatches(params.sections);
const worldCardId = getWorldFoundationCardId();
if (params.cardId === worldCardId) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.world.has(sectionId as never)) {
throw badRequest(`section ${sectionId} is not editable for world`);
}
if (sectionId === 'title') {
draftProfile.name = value;
return;
}
if (sectionId === 'subtitle') {
draftProfile.subtitle = value;
return;
}
if (sectionId === 'summary') {
draftProfile.summary = value;
return;
}
if (sectionId === 'playerGoal') {
draftProfile.playerGoal = value;
return;
}
if (sectionId === 'tone') {
draftProfile.tone = value;
return;
}
if (sectionId === 'coreConflicts') {
draftProfile.coreConflicts = parseStringList(value);
}
});
return draftProfile as unknown as Record<string, unknown>;
}
const faction = draftProfile.factions.find((entry) => entry.id === params.cardId);
if (faction) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.faction.has(sectionId as never)) {
throw badRequest(`section ${sectionId} is not editable for faction`);
}
if (sectionId === 'title') {
faction.name = value;
faction.title = value;
return;
}
if (sectionId === 'subtitle') {
faction.subtitle = value;
return;
}
if (sectionId === 'summary') {
faction.summary = value;
return;
}
if (sectionId === 'publicGoal') {
faction.publicGoal = value;
return;
}
if (sectionId === 'tension') {
faction.tension = value;
faction.relatedConflict = value;
}
});
return draftProfile as unknown as Record<string, unknown>;
}
const character = [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].find(
(entry) => entry.id === params.cardId,
);
if (character) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.character.has(sectionId as never)) {
throw badRequest(`section ${sectionId} is not editable for character`);
}
if (sectionId === 'name') {
character.name = value;
return;
}
if (sectionId === 'role') {
character.role = value;
character.title = value;
return;
}
if (sectionId === 'publicMask') {
character.publicMask = value;
character.publicIdentity = value;
return;
}
if (sectionId === 'hiddenHook') {
character.hiddenHook = value;
character.currentPressure = value;
return;
}
if (sectionId === 'relationToPlayer') {
character.relationToPlayer = value;
return;
}
if (sectionId === 'summary') {
character.summary = value;
}
});
return draftProfile as unknown as Record<string, unknown>;
}
const landmark = draftProfile.landmarks.find((entry) => entry.id === params.cardId);
if (landmark) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.landmark.has(sectionId as never)) {
throw badRequest(`section ${sectionId} is not editable for landmark`);
}
if (sectionId === 'name') {
landmark.name = value;
return;
}
if (sectionId === 'purpose') {
landmark.purpose = value;
return;
}
if (sectionId === 'mood') {
landmark.mood = value;
return;
}
if (sectionId === 'secret') {
landmark.secret = value;
landmark.importance = value;
return;
}
if (sectionId === 'summary') {
landmark.summary = value;
}
});
return draftProfile as unknown as Record<string, unknown>;
}
const thread = draftProfile.threads.find((entry) => entry.id === params.cardId);
if (thread) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.thread.has(sectionId as never)) {
throw badRequest(`section ${sectionId} is not editable for thread`);
}
if (sectionId === 'title') {
thread.title = value;
return;
}
if (sectionId === 'summary') {
thread.summary = value;
return;
}
if (sectionId === 'conflictType') {
thread.conflictType = value;
thread.type = resolveThreadType(value);
return;
}
if (sectionId === 'stakes') {
thread.stakes = value;
thread.conflict = value;
}
});
return draftProfile as unknown as Record<string, unknown>;
}
const chapter = draftProfile.chapters.find((entry) => entry.id === params.cardId);
if (chapter) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.chapter.has(sectionId as never)) {
throw badRequest(`section ${sectionId} is not editable for chapter`);
}
if (sectionId === 'title') {
chapter.title = value;
return;
}
if (sectionId === 'summary') {
chapter.summary = value;
return;
}
if (sectionId === 'openingEvent') {
chapter.openingEvent = value;
return;
}
if (sectionId === 'playerGoal') {
chapter.playerGoal = value;
return;
}
if (sectionId === 'understandingShift') {
chapter.understandingShift = value;
}
});
return draftProfile as unknown as Record<string, unknown>;
}
if (draftProfile.camp?.id === params.cardId) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.camp.has(sectionId as never)) {
throw badRequest(`section ${sectionId} is not editable for camp`);
}
if (sectionId === 'name') {
draftProfile.camp!.name = value;
return;
}
if (sectionId === 'description') {
draftProfile.camp!.description = value;
return;
}
if (sectionId === 'dangerLevel') {
draftProfile.camp!.dangerLevel = value;
draftProfile.camp!.mood = value;
}
});
return draftProfile as unknown as Record<string, unknown>;
}
throw notFound('draft card not found');
}

View File

@@ -0,0 +1,651 @@
import type {
CustomWorldFoundationDraftCharacter,
CustomWorldFoundationDraftLandmark,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import { badRequest } from '../errors.js';
import {
getWorldFoundationCardId,
normalizeFoundationDraftProfile,
} from './customWorldAgentDraftCompiler.js';
import type { UpstreamLlmClient } from './llmClient.js';
type GenerateEntitiesParams = {
creatorIntent: unknown;
anchorPack: unknown;
draftProfile: Record<string, unknown>;
count: number;
promptText?: string | null;
anchorCardIds?: string[];
llmClient?: UpstreamLlmClient | null;
};
const CHARACTER_SURNAME_POOL = [
'沈',
'顾',
'裴',
'闻',
'纪',
'苏',
'岑',
'陆',
'白',
'商',
'温',
'严',
'黎',
'季',
] as const;
const CHARACTER_GIVEN_POOL = [
'砺',
'岚',
'澄',
'栖',
'弦',
'朔',
'遥',
'霁',
'衡',
'铃',
'潮',
'燧',
'宁',
'鸢',
] as const;
const CHARACTER_ROLE_POOL = [
'线人',
'调停者',
'巡查官',
'记录员',
'司钥人',
'护送者',
] as const;
const LANDMARK_PREFIX_POOL = [
'盐火',
'潮碑',
'雾湾',
'沉钟',
'旧航',
'灰塔',
'回潮',
'断潮',
] as const;
const LANDMARK_SUFFIX_POOL = [
'观测台',
'栈桥',
'档案楼',
'前哨站',
'藏书库',
'工坊',
'集市',
'驿站',
] as const;
const DANGER_LEVEL_POOL = ['中', '中高', '高'] as const;
type AnchorContext = {
anchorLabels: string[];
threadIds: string[];
characterIds: string[];
landmarkIds: string[];
factionNames: string[];
};
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 createStableId(prefix: string, label: string, index: number) {
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
}
function ensureCount(count: number) {
const normalized = Number.isFinite(count) ? Math.round(count) : 0;
if (normalized < 1 || normalized > 3) {
throw badRequest('count must be between 1 and 3');
}
return normalized;
}
function getAllCharacters(
profile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
) {
return [...profile.playableNpcs, ...profile.storyNpcs];
}
function dedupeStrings(values: string[]) {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
}
function extractJsonPayload(content: string) {
const fencedMatch = content.match(/```(?:json)?\s*([\s\S]+?)\s*```/u);
if (fencedMatch?.[1]) {
return fencedMatch[1].trim();
}
const arrayStart = content.indexOf('[');
const arrayEnd = content.lastIndexOf(']');
if (arrayStart >= 0 && arrayEnd > arrayStart) {
return content.slice(arrayStart, arrayEnd + 1);
}
return content.trim();
}
function buildAnchorContext(
profile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
anchorCardIds: string[],
): AnchorContext {
const worldCardId = getWorldFoundationCardId();
const labels: string[] = [];
const threadIds: string[] = [];
const characterIds: string[] = [];
const landmarkIds: string[] = [];
const factionNames: string[] = [];
const characters = getAllCharacters(profile);
anchorCardIds.forEach((cardId) => {
if (cardId === worldCardId) {
labels.push(profile.name);
if (profile.threads[0]) {
threadIds.push(profile.threads[0].id);
}
return;
}
const faction = profile.factions.find((entry) => entry.id === cardId);
if (faction) {
labels.push(faction.title || faction.name);
factionNames.push(faction.title || faction.name);
profile.threads
.filter(
(thread) =>
thread.summary.includes(faction.name) ||
thread.conflict.includes(faction.name) ||
thread.conflict.includes(faction.relatedConflict),
)
.slice(0, 2)
.forEach((thread) => {
threadIds.push(thread.id);
});
return;
}
const character = characters.find((entry) => entry.id === cardId);
if (character) {
labels.push(character.name);
characterIds.push(character.id);
threadIds.push(...character.threadIds);
return;
}
const landmark = profile.landmarks.find((entry) => entry.id === cardId);
if (landmark) {
labels.push(landmark.name);
landmarkIds.push(landmark.id);
characterIds.push(...landmark.characterIds);
threadIds.push(...landmark.threadIds);
return;
}
const thread = profile.threads.find((entry) => entry.id === cardId);
if (thread) {
labels.push(thread.title);
threadIds.push(thread.id);
characterIds.push(...thread.characterIds);
landmarkIds.push(...thread.landmarkIds);
return;
}
const chapter = profile.chapters.find((entry) => entry.id === cardId);
if (chapter) {
labels.push(chapter.title);
characterIds.push(...chapter.characterIds);
landmarkIds.push(...chapter.landmarkIds);
return;
}
if (profile.camp?.id === cardId) {
labels.push(profile.camp.name);
landmarkIds.push(...profile.landmarks.slice(0, 2).map((entry) => entry.id));
}
});
if (labels.length === 0) {
labels.push(profile.name);
}
if (threadIds.length === 0 && profile.threads[0]) {
threadIds.push(profile.threads[0].id);
}
if (characterIds.length === 0 && characters[0]) {
characterIds.push(characters[0].id);
}
return {
anchorLabels: dedupeStrings(labels),
threadIds: dedupeStrings(threadIds).slice(0, 3),
characterIds: dedupeStrings(characterIds).slice(0, 4),
landmarkIds: dedupeStrings(landmarkIds).slice(0, 4),
factionNames: dedupeStrings(factionNames).slice(0, 3),
};
}
function buildUniqueCharacterName(existingNames: Set<string>, startIndex: number) {
for (let attempt = 0; attempt < 120; attempt += 1) {
const index = startIndex + attempt;
const surname =
CHARACTER_SURNAME_POOL[index % CHARACTER_SURNAME_POOL.length];
const firstName =
CHARACTER_GIVEN_POOL[
Math.floor(index / CHARACTER_SURNAME_POOL.length) %
CHARACTER_GIVEN_POOL.length
];
const secondName =
CHARACTER_GIVEN_POOL[
(index + 5) % CHARACTER_GIVEN_POOL.length
];
const candidate = `${surname}${firstName}${secondName}`;
if (!existingNames.has(candidate)) {
existingNames.add(candidate);
return candidate;
}
}
const fallback = `新角色${existingNames.size + 1}`;
existingNames.add(fallback);
return fallback;
}
function buildUniqueLandmarkName(existingNames: Set<string>, startIndex: number) {
for (let attempt = 0; attempt < 120; attempt += 1) {
const index = startIndex + attempt;
const candidate = `${LANDMARK_PREFIX_POOL[index % LANDMARK_PREFIX_POOL.length]}${
LANDMARK_SUFFIX_POOL[
Math.floor(index / LANDMARK_PREFIX_POOL.length) %
LANDMARK_SUFFIX_POOL.length
]
}`;
if (!existingNames.has(candidate)) {
existingNames.add(candidate);
return candidate;
}
}
const fallback = `新地点${existingNames.size + 1}`;
existingNames.add(fallback);
return fallback;
}
function buildPromptSeed(promptText?: string | null) {
return clampText(promptText || '', 28);
}
function buildAnchorSummary(anchorContext: AnchorContext) {
return anchorContext.anchorLabels[0] || '当前底稿';
}
function buildCharacterFallback(
profile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
anchorContext: AnchorContext,
promptSeed: string,
index: number,
existingNames: Set<string>,
): CustomWorldFoundationDraftCharacter {
const name = buildUniqueCharacterName(existingNames, getAllCharacters(profile).length + index);
const role = CHARACTER_ROLE_POOL[
(getAllCharacters(profile).length + index) % CHARACTER_ROLE_POOL.length
];
const anchorSummary = buildAnchorSummary(anchorContext);
const publicMask = clampText(
[
`表面上以${role}身份靠近${anchorSummary}`,
promptSeed ? `对外总把话题往“${promptSeed}”上带` : '',
]
.filter(Boolean)
.join(''),
72,
);
const hiddenHook = clampText(
[
`暗中握着和${anchorSummary}有关的旧线索`,
anchorContext.factionNames[0]
? `并持续替${anchorContext.factionNames[0]}观察局势变化`
: '一直在等一个足以翻盘的时机',
].join(''),
72,
);
const relationToPlayer = clampText(
anchorContext.characterIds[0]
? `会先借熟人网络试探玩家愿不愿意卷入${anchorSummary}`
: `会先试探玩家是否愿意站到${anchorSummary}这一侧。`,
72,
);
const summary = clampText(
`${publicMask}${hiddenHook}${relationToPlayer}`,
140,
);
return {
id: createStableId('character', name, getAllCharacters(profile).length + index),
name,
title: role,
role,
publicIdentity: publicMask,
publicMask,
currentPressure: hiddenHook,
hiddenHook,
relationToPlayer,
threadIds: anchorContext.threadIds.slice(0, 2),
summary,
};
}
function buildLandmarkFallback(
profile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
anchorContext: AnchorContext,
promptSeed: string,
index: number,
existingNames: Set<string>,
): CustomWorldFoundationDraftLandmark {
const name = buildUniqueLandmarkName(existingNames, profile.landmarks.length + index);
const anchorSummary = buildAnchorSummary(anchorContext);
const purpose = clampText(
promptSeed
? `承接“${promptSeed}”这条补充要求的关键场景`
: `承接${anchorSummary}这条线的关键场景`,
72,
);
const mood = clampText(
buildPromptSeed(profile.tone) || '压迫、克制、带着未明感',
28,
);
const dangerLevel =
DANGER_LEVEL_POOL[(profile.landmarks.length + index) % DANGER_LEVEL_POOL.length];
const secret = clampText(
anchorContext.characterIds[0]
? `埋着与现有角色有关的旧痕和反转线索`
: `埋着足以改写${anchorSummary}解释权的旧线索`,
72,
);
const summary = clampText(
`${purpose},整体气质${mood}${secret}`,
140,
);
return {
id: createStableId('landmark', name, profile.landmarks.length + index),
name,
description: summary,
purpose,
mood,
importance: secret,
secret,
dangerLevel,
characterIds: anchorContext.characterIds.slice(0, 3),
threadIds: anchorContext.threadIds.slice(0, 2),
summary,
};
}
async function requestCharacterSuggestionsFromLlm(params: {
llmClient: UpstreamLlmClient;
profile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>;
anchorContext: AnchorContext;
count: number;
promptSeed: string;
creatorIntent: unknown;
anchorPack: unknown;
}) {
const anchorSummary = buildAnchorSummary(params.anchorContext);
const creatorIntentSummary =
toText(toRecord(params.anchorPack)?.creatorIntentSummary) ||
toText(toRecord(params.creatorIntent)?.worldHook) ||
params.profile.summary;
const content = await params.llmClient.requestMessageContent({
systemPrompt:
'你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。',
userPrompt: [
`当前世界:${params.profile.name}`,
`世界摘要:${params.profile.summary}`,
`创作意图摘要:${creatorIntentSummary}`,
`参考锚点:${anchorSummary}`,
`已有角色:${getAllCharacters(params.profile)
.slice(0, 10)
.map((entry) => entry.name)
.join('、') || '暂无'}`,
`数量:${params.count}`,
`补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`,
'返回 JSON 数组。每个对象字段只允许包含name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。',
'threadIds 必须优先引用现有线程 id。',
].join('\n'),
timeoutMs: 45000,
debugLabel: 'custom-world-agent-generate-characters',
});
const parsed = JSON.parse(extractJsonPayload(content)) as Array<Record<string, unknown>>;
return Array.isArray(parsed) ? parsed : [];
}
async function requestLandmarkSuggestionsFromLlm(params: {
llmClient: UpstreamLlmClient;
profile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>;
anchorContext: AnchorContext;
count: number;
promptSeed: string;
creatorIntent: unknown;
anchorPack: unknown;
}) {
const anchorSummary = buildAnchorSummary(params.anchorContext);
const creatorIntentSummary =
toText(toRecord(params.anchorPack)?.creatorIntentSummary) ||
toText(toRecord(params.creatorIntent)?.worldHook) ||
params.profile.summary;
const content = await params.llmClient.requestMessageContent({
systemPrompt:
'你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。',
userPrompt: [
`当前世界:${params.profile.name}`,
`世界摘要:${params.profile.summary}`,
`创作意图摘要:${creatorIntentSummary}`,
`参考锚点:${anchorSummary}`,
`已有地点:${params.profile.landmarks
.slice(0, 10)
.map((entry) => entry.name)
.join('、') || '暂无'}`,
`数量:${params.count}`,
`补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`,
'返回 JSON 数组。每个对象字段只允许包含name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。',
'threadIds / characterIds 必须优先引用现有对象 id。',
].join('\n'),
timeoutMs: 45000,
debugLabel: 'custom-world-agent-generate-landmarks',
});
const parsed = JSON.parse(extractJsonPayload(content)) as Array<Record<string, unknown>>;
return Array.isArray(parsed) ? parsed : [];
}
export class CustomWorldAgentEntityGenerationService {
constructor(private readonly llmClient: UpstreamLlmClient | null = null) {}
async generateAdditionalCharacters(params: GenerateEntitiesParams) {
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
if (!draftProfile) {
throw badRequest('draftProfile is empty');
}
const count = ensureCount(params.count);
const promptSeed = buildPromptSeed(params.promptText);
const anchorContext = buildAnchorContext(draftProfile, params.anchorCardIds ?? []);
const existingNames = new Set(
getAllCharacters(draftProfile).map((entry) => entry.name),
);
let llmDrafts: Array<Record<string, unknown>> = [];
if (this.llmClient) {
try {
llmDrafts = await requestCharacterSuggestionsFromLlm({
llmClient: this.llmClient,
profile: draftProfile,
anchorContext,
count,
promptSeed,
creatorIntent: params.creatorIntent,
anchorPack: params.anchorPack,
});
} catch {
llmDrafts = [];
}
}
const generatedCharacters = Array.from({ length: count }, (_, index) => {
const fallback = buildCharacterFallback(
draftProfile,
anchorContext,
promptSeed,
index,
existingNames,
);
const llmDraft = toRecord(llmDrafts[index]);
if (!llmDraft) {
return fallback;
}
const name = toText(llmDraft.name) || fallback.name;
return {
...fallback,
id: createStableId('character', name, getAllCharacters(draftProfile).length + index),
name,
title: toText(llmDraft.role) || fallback.title,
role: toText(llmDraft.role) || fallback.role,
publicIdentity: toText(llmDraft.publicMask) || fallback.publicIdentity,
publicMask: toText(llmDraft.publicMask) || fallback.publicMask,
currentPressure: toText(llmDraft.hiddenHook) || fallback.currentPressure,
hiddenHook: toText(llmDraft.hiddenHook) || fallback.hiddenHook,
relationToPlayer:
toText(llmDraft.relationToPlayer) || fallback.relationToPlayer,
threadIds:
Array.isArray(llmDraft.threadIds) && llmDraft.threadIds.length > 0
? dedupeStrings(llmDraft.threadIds.map((entry) => toText(entry))).slice(0, 2)
: fallback.threadIds,
summary: toText(llmDraft.summary) || fallback.summary,
} satisfies CustomWorldFoundationDraftCharacter;
});
draftProfile.storyNpcs = [...draftProfile.storyNpcs, ...generatedCharacters];
return {
draftProfile: draftProfile as unknown as Record<string, unknown>,
generatedCharacters,
};
}
async generateAdditionalLandmarks(params: GenerateEntitiesParams) {
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
if (!draftProfile) {
throw badRequest('draftProfile is empty');
}
const count = ensureCount(params.count);
const promptSeed = buildPromptSeed(params.promptText);
const anchorContext = buildAnchorContext(draftProfile, params.anchorCardIds ?? []);
const existingNames = new Set(draftProfile.landmarks.map((entry) => entry.name));
let llmDrafts: Array<Record<string, unknown>> = [];
if (this.llmClient) {
try {
llmDrafts = await requestLandmarkSuggestionsFromLlm({
llmClient: this.llmClient,
profile: draftProfile,
anchorContext,
count,
promptSeed,
creatorIntent: params.creatorIntent,
anchorPack: params.anchorPack,
});
} catch {
llmDrafts = [];
}
}
const generatedLandmarks = Array.from({ length: count }, (_, index) => {
const fallback = buildLandmarkFallback(
draftProfile,
anchorContext,
promptSeed,
index,
existingNames,
);
const llmDraft = toRecord(llmDrafts[index]);
if (!llmDraft) {
return fallback;
}
const name = toText(llmDraft.name) || fallback.name;
return {
...fallback,
id: createStableId('landmark', name, draftProfile.landmarks.length + index),
name,
description: toText(llmDraft.description) || toText(llmDraft.summary) || fallback.description,
purpose: toText(llmDraft.purpose) || fallback.purpose,
mood: toText(llmDraft.mood) || fallback.mood,
importance: toText(llmDraft.secret) || fallback.importance,
secret: toText(llmDraft.secret) || fallback.secret,
dangerLevel: toText(llmDraft.dangerLevel) || fallback.dangerLevel,
characterIds:
Array.isArray(llmDraft.characterIds) && llmDraft.characterIds.length > 0
? dedupeStrings(llmDraft.characterIds.map((entry) => toText(entry))).slice(0, 3)
: fallback.characterIds,
threadIds:
Array.isArray(llmDraft.threadIds) && llmDraft.threadIds.length > 0
? dedupeStrings(llmDraft.threadIds.map((entry) => toText(entry))).slice(0, 2)
: fallback.threadIds,
summary: toText(llmDraft.summary) || fallback.summary,
} satisfies CustomWorldFoundationDraftLandmark;
});
draftProfile.landmarks = [...draftProfile.landmarks, ...generatedLandmarks];
return {
draftProfile: draftProfile as unknown as Record<string, unknown>,
generatedLandmarks,
};
}
}

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,351 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import {
buildPendingClarifications,
evaluateCreatorIntentReadiness,
} from './customWorldAgentClarificationService.js';
import {
extractCreatorIntentPatch,
mergeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
const sessionsByUser = new Map<
string,
Map<string, CustomWorldSessionRecord>
>();
const profilesByUser = new Map<string, Record<string, unknown>[]>();
const getSessionBucket = (userId: string) => {
const existing = sessionsByUser.get(userId);
if (existing) {
return existing;
}
const nextBucket = new Map<string, CustomWorldSessionRecord>();
sessionsByUser.set(userId, nextBucket);
return nextBucket;
};
return {
async getSnapshot(_userId) {
return null;
},
async putSnapshot(_userId, _payload) {
throw new Error('not implemented');
},
async deleteSnapshot(_userId) {
return undefined;
},
async getSettings() {
return {
musicVolume: 0.42,
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles(userId) {
return [...(profilesByUser.get(userId) ?? [])];
},
async upsertCustomWorldProfile(userId, profileId, profile) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
current.unshift({
...profile,
id: profileId,
});
profilesByUser.set(userId, current);
return current;
},
async deleteCustomWorldProfile(userId, profileId) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
profilesByUser.set(userId, current);
return current;
},
async listCustomWorldSessions(userId) {
return [...getSessionBucket(userId).values()];
},
async getCustomWorldSession(userId, sessionId) {
return getSessionBucket(userId).get(sessionId) ?? null;
},
async upsertCustomWorldSession(userId, sessionId, session) {
getSessionBucket(userId).set(
sessionId,
JSON.parse(JSON.stringify(session)),
);
return JSON.parse(JSON.stringify(session));
},
};
}
async function waitForOperation(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
sessionId: string,
operationId: string,
) {
for (let attempt = 0; attempt < 40; attempt += 1) {
const operation = await orchestrator.getOperation(
userId,
sessionId,
operationId,
);
if (operation?.status === 'completed' || operation?.status === 'failed') {
return operation;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error('operation did not finish in time');
}
test('phase2 extractor can pull multiple creator intent anchors from natural language', () => {
const patch = extractCreatorIntentPatch({
currentIntent: null,
latestUserMessage:
'玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。标志性元素是潮雾钟声、盐火灯塔。',
});
assert.match(patch.playerPremise ?? '', //u);
assert.match(patch.openingSituation ?? '', //u);
assert.ok(patch.themeKeywords?.some((entry) => /|/u.test(entry)));
assert.ok(patch.toneDirectives?.some((entry) => /|/u.test(entry)));
assert.ok(patch.coreConflicts?.[0]?.includes('争夺航道解释权'));
assert.deepEqual(patch.iconicElements, ['潮雾钟声', '盐火灯塔']);
});
test('phase2 extractor marks explicit rewrite fields for merge replacement', () => {
const patch = extractCreatorIntentPatch({
currentIntent: {
sourceMode: 'freeform',
rawSettingText: '',
worldHook: '一个被潮雾切开的列岛世界。',
themeKeywords: ['海岛'],
toneDirectives: ['冷峻'],
playerPremise: '',
openingSituation: '',
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: [],
forbiddenDirectives: [],
},
latestUserMessage:
'主题改成宫廷悬疑,核心冲突改为王庭继承人与旧灯塔盟约对抗。',
});
assert.ok(patch.replaceFields?.includes('themeKeywords'));
assert.ok(patch.replaceFields?.includes('coreConflicts'));
assert.ok(patch.themeKeywords?.some((entry) => /|/u.test(entry)));
assert.ok(patch.coreConflicts?.some((entry) => //u.test(entry)));
});
test('phase2 clarification service only keeps the top highest leverage gap', () => {
const readiness = evaluateCreatorIntentReadiness({
sourceMode: 'freeform',
rawSettingText: '',
worldHook: '',
themeKeywords: [],
toneDirectives: [],
playerPremise: '',
openingSituation: '',
coreConflicts: [],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: [],
forbiddenDirectives: [],
});
const clarifications = buildPendingClarifications(null, readiness);
assert.equal(clarifications.length, 1);
assert.equal(clarifications[0]?.targetKey, 'world_hook');
});
test('phase2 orchestrator advances session to foundation_review when minimal anchors are complete', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase2-ready';
const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。',
});
assert.equal(createdSession.stage, 'clarifying');
assert.match(
String(
(createdSession.creatorIntent as Record<string, unknown>)?.worldHook ??
'',
),
//u,
);
const message1 = await orchestrator.submitMessage(
userId,
createdSession.sessionId,
{
clientMessageId: 'client-1',
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
focusCardId: null,
selectedCardIds: [],
},
);
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message1.operation.operationId,
);
const message2 = await orchestrator.submitMessage(
userId,
createdSession.sessionId,
{
clientMessageId: 'client-2',
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
focusCardId: null,
selectedCardIds: [],
},
);
const operation = await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message2.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(
userId,
createdSession.sessionId,
);
assert.equal(operation?.status, 'completed');
assert.equal(snapshot?.stage, 'foundation_review');
assert.equal(snapshot?.creatorIntentReadiness.isReady, true);
assert.deepEqual(snapshot?.pendingClarifications, []);
assert.match(
String(
(snapshot?.creatorIntent as Record<string, unknown>)?.worldHook ?? '',
),
//u,
);
assert.ok(
snapshot?.messages.some(
(message) =>
message.role === 'assistant' &&
message.text.includes('最小锚点已经齐备'),
),
);
});
test('phase2 work summaries compile draft title and summary from creator intent', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase2-summary';
const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。',
});
const update = await orchestrator.submitMessage(
userId,
createdSession.sessionId,
{
clientMessageId: 'client-summary',
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。核心冲突是守灯会与沉船商盟争夺航道解释权。',
focusCardId: null,
selectedCardIds: [],
},
);
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
update.operation.operationId,
);
const items = await listCustomWorldWorkSummaries(userId, {
runtimeRepository,
customWorldAgentSessions: sessionStore,
});
const draft = items.find(
(item) => item.sessionId === createdSession.sessionId,
);
assert.ok(draft);
assert.match(draft?.title ?? '', //u);
assert.match(draft?.summary ?? '', //u);
assert.match(draft?.summary ?? '', //u);
});
test('phase2 merge keeps existing anchors while applying new patch', () => {
const merged = mergeCreatorIntentRecord(
{
sourceMode: 'freeform',
rawSettingText: '一个被潮雾切开的列岛世界。',
worldHook: '一个被潮雾切开的列岛世界。',
themeKeywords: [],
toneDirectives: [],
playerPremise: '玩家是失职返乡的守灯人。',
openingSituation: '',
coreConflicts: [],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: [],
forbiddenDirectives: [],
},
{
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
toneDirectives: ['冷峻'],
},
);
assert.equal(merged.playerPremise, '玩家是失职返乡的守灯人。');
assert.equal(merged.worldHook, '一个被潮雾切开的列岛世界。');
assert.deepEqual(merged.coreConflicts, ['守灯会与沉船商盟争夺航道解释权']);
assert.deepEqual(merged.toneDirectives, ['冷峻']);
});
test('phase2 merge replaces explicit rewrite arrays instead of appending them', () => {
const merged = mergeCreatorIntentRecord(
{
sourceMode: 'freeform',
rawSettingText: '',
worldHook: '一个被潮雾切开的列岛世界。',
themeKeywords: ['海岛', '旧案'],
toneDirectives: ['冷峻'],
playerPremise: '',
openingSituation: '',
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: [],
forbiddenDirectives: [],
},
{
themeKeywords: ['宫廷', '悬疑'],
coreConflicts: ['王庭继承人与旧灯塔盟约对抗'],
replaceFields: ['themeKeywords', 'coreConflicts'],
},
);
assert.deepEqual(merged.themeKeywords, ['宫廷', '悬疑']);
assert.deepEqual(merged.coreConflicts, ['王庭继承人与旧灯塔盟约对抗']);
assert.deepEqual(merged.toneDirectives, ['冷峻']);
});

View File

@@ -0,0 +1,259 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
const sessionsByUser = new Map<
string,
Map<string, CustomWorldSessionRecord>
>();
const profilesByUser = new Map<string, Record<string, unknown>[]>();
const getSessionBucket = (userId: string) => {
const existing = sessionsByUser.get(userId);
if (existing) {
return existing;
}
const nextBucket = new Map<string, CustomWorldSessionRecord>();
sessionsByUser.set(userId, nextBucket);
return nextBucket;
};
return {
async getSnapshot(_userId) {
return null;
},
async putSnapshot(_userId, _payload) {
throw new Error('not implemented');
},
async deleteSnapshot(_userId) {
return undefined;
},
async getSettings() {
return {
musicVolume: 0.42,
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles(userId) {
return [...(profilesByUser.get(userId) ?? [])];
},
async upsertCustomWorldProfile(userId, profileId, profile) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
current.unshift({
...profile,
id: profileId,
});
profilesByUser.set(userId, current);
return current;
},
async deleteCustomWorldProfile(userId, profileId) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
profilesByUser.set(userId, current);
return current;
},
async listCustomWorldSessions(userId) {
return [...getSessionBucket(userId).values()];
},
async getCustomWorldSession(userId, sessionId) {
return getSessionBucket(userId).get(sessionId) ?? null;
},
async upsertCustomWorldSession(userId, sessionId, session) {
getSessionBucket(userId).set(
sessionId,
JSON.parse(JSON.stringify(session)),
);
return JSON.parse(JSON.stringify(session));
},
};
}
async function waitForOperation(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
sessionId: string,
operationId: string,
) {
for (let attempt = 0; attempt < 50; attempt += 1) {
const operation = await orchestrator.getOperation(
userId,
sessionId,
operationId,
);
if (operation?.status === 'completed' || operation?.status === 'failed') {
return operation;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error('operation did not finish in time');
}
async function createReadySession(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
) {
const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。',
});
const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase3-ready-1',
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
focusCardId: null,
selectedCardIds: [],
});
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message1.operation.operationId,
);
const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase3-ready-2',
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
focusCardId: null,
selectedCardIds: [],
});
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message2.operation.operationId,
);
const readySession = await orchestrator.getSessionSnapshot(
userId,
createdSession.sessionId,
);
assert.equal(readySession?.stage, 'foundation_review');
assert.equal(readySession?.creatorIntentReadiness.isReady, true);
return readySession!;
}
test('phase3 ready session can execute draft_foundation and expose card detail', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase3-draft';
const readySession = await createReadySession(orchestrator, userId);
const response = await orchestrator.executeAction(
userId,
readySession.sessionId,
{
action: 'draft_foundation',
},
);
const operation = await waitForOperation(
orchestrator,
userId,
readySession.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId);
assert.equal(operation?.status, 'completed');
assert.equal(snapshot?.stage, 'object_refining');
assert.ok(snapshot?.draftCards.length);
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'world'));
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'faction'));
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'character'));
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'landmark'));
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'thread'));
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'chapter'));
assert.equal(
typeof (snapshot?.draftProfile as Record<string, unknown>)?.name,
'string',
);
assert.ok(
snapshot?.messages.some(
(message) =>
message.role === 'assistant' &&
message.text.includes('第一版世界底稿整理出来了'),
),
);
const worldCard = snapshot?.draftCards.find((card) => card.kind === 'world');
assert.ok(worldCard);
const detail = await orchestrator.getCardDetail(
userId,
readySession.sessionId,
worldCard!.id,
);
assert.ok(detail);
assert.equal(detail?.kind, 'world');
assert.ok(detail?.sections.length);
assert.ok(detail?.sections.some((section) => section.label === '世界一句话'));
});
test('phase3 draft_foundation rejects not-ready session', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase3-not-ready';
const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。',
});
await assert.rejects(
() =>
orchestrator.executeAction(userId, createdSession.sessionId, {
action: 'draft_foundation',
}),
/ready session|foundation_review/u,
);
});
test('phase3 work summaries prefer compiled foundation draft fields', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase3-summary';
const readySession = await createReadySession(orchestrator, userId);
const response = await orchestrator.executeAction(
userId,
readySession.sessionId,
{
action: 'draft_foundation',
},
);
await waitForOperation(
orchestrator,
userId,
readySession.sessionId,
response.operation.operationId,
);
const items = await listCustomWorldWorkSummaries(userId, {
runtimeRepository,
customWorldAgentSessions: sessionStore,
});
const draft = items.find((item) => item.sessionId === readySession.sessionId);
assert.ok(draft);
assert.ok((draft?.playableNpcCount ?? 0) >= 3);
assert.ok((draft?.landmarkCount ?? 0) >= 4);
assert.match(draft?.summary ?? '', /||/u);
assert.match(draft?.subtitle ?? '', /||/u);
});

View File

@@ -0,0 +1,311 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
const sessionsByUser = new Map<
string,
Map<string, CustomWorldSessionRecord>
>();
const profilesByUser = new Map<string, Record<string, unknown>[]>();
const getSessionBucket = (userId: string) => {
const existing = sessionsByUser.get(userId);
if (existing) {
return existing;
}
const nextBucket = new Map<string, CustomWorldSessionRecord>();
sessionsByUser.set(userId, nextBucket);
return nextBucket;
};
return {
async getSnapshot(_userId) {
return null;
},
async putSnapshot(_userId, _payload) {
throw new Error('not implemented');
},
async deleteSnapshot(_userId) {
return undefined;
},
async getSettings() {
return {
musicVolume: 0.42,
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles(userId) {
return [...(profilesByUser.get(userId) ?? [])];
},
async upsertCustomWorldProfile(userId, profileId, profile) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
current.unshift({
...profile,
id: profileId,
});
profilesByUser.set(userId, current);
return current;
},
async deleteCustomWorldProfile(userId, profileId) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
profilesByUser.set(userId, current);
return current;
},
async listCustomWorldSessions(userId) {
return [...getSessionBucket(userId).values()];
},
async getCustomWorldSession(userId, sessionId) {
return getSessionBucket(userId).get(sessionId) ?? null;
},
async upsertCustomWorldSession(userId, sessionId, session) {
getSessionBucket(userId).set(
sessionId,
JSON.parse(JSON.stringify(session)),
);
return JSON.parse(JSON.stringify(session));
},
};
}
async function waitForOperation(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
sessionId: string,
operationId: string,
) {
for (let attempt = 0; attempt < 60; attempt += 1) {
const operation = await orchestrator.getOperation(
userId,
sessionId,
operationId,
);
if (operation?.status === 'completed' || operation?.status === 'failed') {
return operation;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error('operation did not finish in time');
}
async function createObjectRefiningSession(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
) {
const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。',
});
const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase4-ready-1',
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
focusCardId: null,
selectedCardIds: [],
});
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message1.operation.operationId,
);
const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase4-ready-2',
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
focusCardId: null,
selectedCardIds: [],
});
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message2.operation.operationId,
);
const foundationOperation = await orchestrator.executeAction(
userId,
createdSession.sessionId,
{
action: 'draft_foundation',
},
);
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
foundationOperation.operation.operationId,
);
return (await orchestrator.getSessionSnapshot(
userId,
createdSession.sessionId,
))!;
}
test('phase4 update_draft_card writes back draft profile and recompiles summaries', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase4-edit';
const session = await createObjectRefiningSession(orchestrator, userId);
const characterCard = session.draftCards.find((card) => card.kind === 'character');
assert.ok(characterCard);
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'update_draft_card',
cardId: characterCard!.id,
sections: [
{
sectionId: 'publicMask',
value: '表面上仍是守灯会里最懂旧航道的人。',
},
{
sectionId: 'relationToPlayer',
value: '和玩家共享一段无法轻易翻篇的旧灯塔往事。',
},
{
sectionId: 'summary',
value: '他像旧友,也像最早知道航道秘密的人。',
},
],
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
const editedCharacter = [...(profile?.playableNpcs ?? []), ...(profile?.storyNpcs ?? [])].find(
(entry) => entry.id === characterCard!.id,
);
const editedCard = snapshot?.draftCards.find((card) => card.id === characterCard!.id);
assert.equal(operation?.status, 'completed');
assert.equal(
editedCharacter?.publicMask,
'表面上仍是守灯会里最懂旧航道的人。',
);
assert.equal(
editedCharacter?.relationToPlayer,
'和玩家共享一段无法轻易翻篇的旧灯塔往事。',
);
assert.equal(editedCard?.summary, '他像旧友,也像最早知道航道秘密的人。');
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' && message.text.includes('已更新'),
),
);
});
test('phase4 generate_characters appends story npcs and updates work summary counts', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase4-characters';
const session = await createObjectRefiningSession(orchestrator, userId);
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;
const baselineCharacterCount = [
...new Set(
[...baselineProfile.playableNpcs, ...baselineProfile.storyNpcs].map(
(entry) => entry.id,
),
),
].length;
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'generate_characters',
count: 2,
promptText: '补两位更贴近旧航道线的边缘角色。',
anchorCardIds: [session.draftCards.find((card) => card.kind === 'thread')!.id],
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!;
const nextCharacterCount = [
...new Set(
[...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id),
),
].length;
const workItems = await listCustomWorldWorkSummaries(userId, {
runtimeRepository,
customWorldAgentSessions: sessionStore,
});
const draftItem = workItems.find((item) => item.sessionId === session.sessionId);
assert.equal(operation?.status, 'completed');
assert.ok(profile.storyNpcs.length >= 2);
assert.ok(nextCharacterCount >= baselineCharacterCount + 2);
assert.ok(snapshot?.draftCards.filter((card) => card.kind === 'character').length);
assert.ok(snapshot?.focusCardId);
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' && message.text.includes('新角色'),
),
);
assert.ok((draftItem?.playableNpcCount ?? 0) >= baselineCharacterCount + 2);
});
test('phase4 generate_landmarks appends new landmark cards and checkpoints', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase4-landmarks';
const session = await createObjectRefiningSession(orchestrator, userId);
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;
const baselineLandmarkCount = baselineProfile.landmarks.length;
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'generate_landmarks',
count: 2,
promptText: '补两个适合藏旧航道秘密的地点。',
anchorCardIds: [session.draftCards.find((card) => card.kind === 'character')!.id],
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!;
const latestSessionRecord = await sessionStore.get(userId, session.sessionId);
assert.equal(operation?.status, 'completed');
assert.ok(profile.landmarks.length >= baselineLandmarkCount + 2);
assert.ok(
snapshot?.draftCards.filter((card) => card.kind === 'landmark').length,
);
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' && message.text.includes('新地点'),
),
);
assert.ok((latestSessionRecord?.checkpoints.length ?? 0) >= 2);
});

View File

@@ -0,0 +1,276 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
const sessionsByUser = new Map<
string,
Map<string, CustomWorldSessionRecord>
>();
const profilesByUser = new Map<string, Record<string, unknown>[]>();
const getSessionBucket = (userId: string) => {
const existing = sessionsByUser.get(userId);
if (existing) {
return existing;
}
const nextBucket = new Map<string, CustomWorldSessionRecord>();
sessionsByUser.set(userId, nextBucket);
return nextBucket;
};
return {
async getSnapshot() {
return null;
},
async putSnapshot(_userId, payload) {
return payload;
},
async deleteSnapshot() {
return undefined;
},
async getSettings() {
return {
musicVolume: 0.42,
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles(userId) {
return [...(profilesByUser.get(userId) ?? [])];
},
async upsertCustomWorldProfile(userId, profileId, profile) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
current.unshift({
...profile,
id: profileId,
});
profilesByUser.set(userId, current);
return current;
},
async deleteCustomWorldProfile(userId, profileId) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
profilesByUser.set(userId, current);
return current;
},
async listCustomWorldSessions(userId) {
return [...getSessionBucket(userId).values()];
},
async getCustomWorldSession(userId, sessionId) {
return getSessionBucket(userId).get(sessionId) ?? null;
},
async upsertCustomWorldSession(userId, sessionId, session) {
getSessionBucket(userId).set(
sessionId,
JSON.parse(JSON.stringify(session)),
);
return JSON.parse(JSON.stringify(session));
},
};
}
async function waitForOperation(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
sessionId: string,
operationId: string,
) {
for (let attempt = 0; attempt < 60; attempt += 1) {
const operation = await orchestrator.getOperation(
userId,
sessionId,
operationId,
);
if (operation?.status === 'completed' || operation?.status === 'failed') {
return operation;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error('operation did not finish in time');
}
async function createObjectRefiningSession(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
) {
const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。',
});
const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase5-ready-1',
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
focusCardId: null,
selectedCardIds: [],
});
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message1.operation.operationId,
);
const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase5-ready-2',
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
focusCardId: null,
selectedCardIds: [],
});
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message2.operation.operationId,
);
const foundationOperation = await orchestrator.executeAction(
userId,
createdSession.sessionId,
{
action: 'draft_foundation',
},
);
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
foundationOperation.operation.operationId,
);
return (await orchestrator.getSessionSnapshot(
userId,
createdSession.sessionId,
))!;
}
test('phase5 generate_role_assets only allows a single role and moves session into visual_refining', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase5-generate-role-assets';
const session = await createObjectRefiningSession(orchestrator, userId);
const characterIds = session.draftCards
.filter((card) => card.kind === 'character')
.map((card) => card.id);
await assert.rejects(
orchestrator.executeAction(userId, session.sessionId, {
action: 'generate_role_assets',
roleIds: characterIds.slice(0, 2),
}),
);
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'generate_role_assets',
roleIds: [characterIds[0]!],
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
assert.equal(operation?.status, 'completed');
assert.equal(snapshot?.stage, 'visual_refining');
assert.equal(snapshot?.focusCardId, characterIds[0]);
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' &&
message.text.includes('角色资产工坊'),
),
);
});
test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase5-sync-role-assets';
const session = await createObjectRefiningSession(orchestrator, userId);
const characterCard = session.draftCards.find((card) => card.kind === 'character');
assert.ok(characterCard);
const prepareResponse = await orchestrator.executeAction(
userId,
session.sessionId,
{
action: 'generate_role_assets',
roleIds: [characterCard!.id],
},
);
await waitForOperation(
orchestrator,
userId,
session.sessionId,
prepareResponse.operation.operationId,
);
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'sync_role_assets',
roleId: characterCard!.id,
portraitPath: '/generated/characters/shenli-portrait.png',
generatedVisualAssetId: 'visual-shenli-1',
generatedAnimationSetId: 'animation-set-shenli-1',
animationMap: {
idle: { basePath: '/generated/characters/shenli/idle' },
run: { basePath: '/generated/characters/shenli/run' },
attack: { basePath: '/generated/characters/shenli/attack' },
hurt: { basePath: '/generated/characters/shenli/hurt' },
die: { basePath: '/generated/characters/shenli/die' },
},
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
const syncedRole = [...(profile?.playableNpcs ?? []), ...(profile?.storyNpcs ?? [])].find(
(entry) => entry.id === characterCard!.id,
);
const syncedCard = snapshot?.draftCards.find((card) => card.id === characterCard!.id);
const syncedAssetSummary = snapshot?.assetCoverage.roleAssets.find(
(entry) => entry.roleId === characterCard!.id,
);
const latestRecord = await sessionStore.get(userId, session.sessionId);
assert.equal(operation?.status, 'completed');
assert.equal(syncedRole?.imageSrc, '/generated/characters/shenli-portrait.png');
assert.equal(syncedRole?.generatedVisualAssetId, 'visual-shenli-1');
assert.equal(syncedRole?.generatedAnimationSetId, 'animation-set-shenli-1');
assert.equal(
(syncedRole?.animationMap as Record<string, { basePath?: string }> | null)?.idle
?.basePath,
'/generated/characters/shenli/idle',
);
assert.equal(syncedAssetSummary?.status, 'complete');
assert.equal(syncedCard?.assetStatusLabel, '动作已就绪');
assert.ok(syncedCard?.subtitle.includes('动作已就绪'));
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' && message.text.includes('动作已就绪'),
),
);
assert.ok((latestRecord?.checkpoints.length ?? 0) >= 2);
});

View File

@@ -0,0 +1,295 @@
import type {
CustomWorldAssetCoverageSummary,
CustomWorldAssetPriorityTier,
CustomWorldRoleAssetStatus,
CustomWorldRoleAssetSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
const CORE_ROLE_ANIMATION_KEYS = [
'idle',
'run',
'attack',
'hurt',
'die',
] as const;
type DraftRoleRecord = {
id: string;
name: string;
threadIds: string[];
imageSrc?: string | null;
generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
};
type DraftRoleKind = 'playable' | 'story';
type MergeRoleAssetIntoDraftProfilePayload = {
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
};
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
)
: [];
}
function toStringArray(value: unknown) {
return Array.isArray(value)
? value
.map((item) => toText(item))
.filter(Boolean)
.slice(0, 12)
: [];
}
function toAnimationMap(value: unknown) {
return toRecord(value);
}
function hasAnimationSlot(
animationMap: Record<string, unknown> | null | undefined,
slot: string,
) {
const entry = toRecord(animationMap?.[slot]);
if (!entry) {
return false;
}
return Boolean(toText(entry.basePath) || toText(entry.spriteSheetPath));
}
function resolvePriorityTier(
role: DraftRoleRecord,
roleKind: DraftRoleKind,
): CustomWorldAssetPriorityTier {
if (roleKind === 'playable') {
return 'hero';
}
return role.threadIds.length > 0 ? 'featured' : 'supporting';
}
function resolveNextPointCost(
status: CustomWorldRoleAssetStatus,
priorityTier: CustomWorldAssetPriorityTier,
) {
if (status === 'complete') {
return 0;
}
if (status === 'missing') {
return priorityTier === 'supporting' ? 12 : 20;
}
return priorityTier === 'supporting' ? 36 : 60;
}
function collectDraftRoles(profileInput: unknown) {
const profile = toRecord(profileInput);
if (!profile) {
return [] as Array<{ role: DraftRoleRecord; roleKind: DraftRoleKind }>;
}
const normalizeRole = (
item: Record<string, unknown>,
): DraftRoleRecord | null => {
const id = toText(item.id);
const name = toText(item.name);
if (!id || !name) {
return null;
}
return {
id,
name,
threadIds: toStringArray(item.threadIds),
imageSrc: toText(item.imageSrc) || null,
generatedVisualAssetId: toText(item.generatedVisualAssetId) || null,
generatedAnimationSetId: toText(item.generatedAnimationSetId) || null,
animationMap: toAnimationMap(item.animationMap),
};
};
return [
...toRecordArray(profile.playableNpcs)
.map((item) => {
const role = normalizeRole(item);
return role ? { role, roleKind: 'playable' as const } : null;
})
.filter(
(
item,
): item is {
role: DraftRoleRecord;
roleKind: DraftRoleKind;
} => Boolean(item),
),
...toRecordArray(profile.storyNpcs)
.map((item) => {
const role = normalizeRole(item);
return role ? { role, roleKind: 'story' as const } : null;
})
.filter(
(
item,
): item is {
role: DraftRoleRecord;
roleKind: DraftRoleKind;
} => Boolean(item),
),
];
}
export function resolveRoleAssetStatusLabel(status: CustomWorldRoleAssetStatus) {
if (status === 'complete') {
return '动作已就绪';
}
if (status === 'animations_ready') {
return '动作补齐中';
}
if (status === 'visual_ready') {
return '主图已就绪';
}
return '待生成主图';
}
export function buildRoleAssetSummary(params: {
role: DraftRoleRecord;
roleKind: DraftRoleKind;
}): CustomWorldRoleAssetSummary {
const { role, roleKind } = params;
const priorityTier = resolvePriorityTier(role, roleKind);
const missingAnimations = CORE_ROLE_ANIMATION_KEYS.filter(
(slot) => !hasAnimationSlot(role.animationMap, slot),
);
const hasPortrait =
Boolean(role.imageSrc) && Boolean(role.generatedVisualAssetId);
const hasAnimationSet = Boolean(role.generatedAnimationSetId);
const status: CustomWorldRoleAssetStatus = !hasPortrait
? 'missing'
: missingAnimations.length === 0
? 'complete'
: hasAnimationSet
? 'animations_ready'
: 'visual_ready';
return {
roleId: role.id,
roleName: role.name,
roleKind,
priorityTier,
portraitPath: role.imageSrc ?? null,
generatedVisualAssetId: role.generatedVisualAssetId ?? null,
generatedAnimationSetId: role.generatedAnimationSetId ?? null,
status,
missingAnimations,
nextPointCost: resolveNextPointCost(status, priorityTier),
};
}
export function getRoleAssetSummaryById(
draftProfile: unknown,
roleId: string,
) {
const roleEntry = collectDraftRoles(draftProfile).find(
(entry) => entry.role.id === roleId,
);
if (!roleEntry) {
return null;
}
return buildRoleAssetSummary(roleEntry);
}
export function rebuildRoleAssetCoverage(
draftProfile: unknown,
): CustomWorldAssetCoverageSummary {
const roleAssets = collectDraftRoles(draftProfile).map((entry) =>
buildRoleAssetSummary(entry),
);
return {
roleAssets,
sceneAssets: [],
allRoleAssetsReady:
roleAssets.length > 0 &&
roleAssets.every((entry) => entry.status === 'complete'),
allSceneAssetsReady: false,
};
}
export function mergeRoleAssetIntoDraftProfile(
draftProfileInput: Record<string, unknown>,
payload: MergeRoleAssetIntoDraftProfilePayload,
) {
const nextDraftProfile = {
...draftProfileInput,
};
let updatedRole: Record<string, unknown> | null = null;
const updateRoleList = (field: 'playableNpcs' | 'storyNpcs') => {
const currentList = toRecordArray(nextDraftProfile[field]);
let touched = false;
const nextList = currentList.map((item) => {
if (toText(item.id) !== payload.roleId) {
return item;
}
touched = true;
updatedRole = {
...item,
imageSrc: payload.portraitPath,
generatedVisualAssetId: payload.generatedVisualAssetId,
};
if (payload.generatedAnimationSetId !== undefined) {
updatedRole.generatedAnimationSetId = payload.generatedAnimationSetId;
}
if (payload.animationMap !== undefined) {
updatedRole.animationMap = payload.animationMap;
}
return updatedRole;
});
if (touched) {
nextDraftProfile[field] = nextList;
}
return touched;
};
const touched =
updateRoleList('playableNpcs') || updateRoleList('storyNpcs');
if (!touched || !updatedRole) {
throw new Error('目标角色不存在,无法同步角色资产。');
}
return {
draftProfile: nextDraftProfile,
updatedRole,
};
}

View File

@@ -0,0 +1,711 @@
import crypto from 'node:crypto';
import type {
CustomWorldAssetCoverageSummary,
CreatorIntentReadiness,
CustomWorldAgentMessage,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
CustomWorldAgentStage,
CustomWorldDraftCardSummary,
CustomWorldPendingClarification,
CustomWorldSuggestedAction,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { CustomWorldSessionRecord as LegacyCustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import {
buildPendingClarifications,
evaluateCreatorIntentReadiness,
resolveCreatorIntentStage,
} from './customWorldAgentClarificationService.js';
import {
buildAnchorPackFromIntent,
buildDraftSummaryFromIntent,
buildDraftTitleFromIntent,
createEmptyCreatorIntentRecord,
extractCreatorIntentPatch,
mergeCreatorIntentRecord,
normalizeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js';
export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX =
'custom-world-agent-session-';
export type CustomWorldAgentSessionRecord = {
sessionId: string;
userId: string;
seedText: string;
stage: CustomWorldAgentStage;
focusCardId: string | null;
creatorIntent: Record<string, unknown> | null;
creatorIntentReadiness: CreatorIntentReadiness;
anchorPack: Record<string, unknown> | null;
lockState: Record<string, unknown> | null;
draftProfile: Record<string, unknown> | null;
messages: CustomWorldAgentMessage[];
draftCards: CustomWorldDraftCardSummary[];
pendingClarifications: CustomWorldPendingClarification[];
suggestedActions: CustomWorldSuggestedAction[];
recommendedReplies: string[];
qualityFindings: Array<{
id: string;
severity: 'info' | 'warning' | 'blocker';
code: string;
targetId?: string | null;
message: string;
}>;
assetCoverage: CustomWorldAssetCoverageSummary;
operations: CustomWorldAgentOperationRecord[];
checkpoints: Array<{
checkpointId: string;
createdAt: string;
label: string;
}>;
createdAt: string;
updatedAt: string;
};
type CreateSessionInput = {
seedText?: string;
welcomeMessage: string;
pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications'];
creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent'];
creatorIntentReadiness?: CreatorIntentReadiness;
anchorPack?: CustomWorldAgentSessionRecord['anchorPack'];
draftProfile?: CustomWorldAgentSessionRecord['draftProfile'];
stage?: CustomWorldAgentStage;
suggestedActions: CustomWorldSuggestedAction[];
recommendedReplies?: string[];
};
function cloneRecord<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function isStage(value: unknown): value is CustomWorldAgentStage {
return (
value === 'collecting_intent' ||
value === 'clarifying' ||
value === 'foundation_review' ||
value === 'object_refining' ||
value === 'visual_refining' ||
value === 'long_tail_review' ||
value === 'ready_to_publish' ||
value === 'published' ||
value === 'error'
);
}
function isAgentSessionRecord(
value: unknown,
): value is CustomWorldAgentSessionRecord {
const record = toRecord(value);
if (!record) {
return false;
}
return (
typeof record.sessionId === 'string' &&
record.sessionId.startsWith(CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX) &&
typeof record.userId === 'string' &&
isStage(record.stage) &&
Array.isArray(record.messages) &&
Array.isArray(record.operations) &&
typeof record.createdAt === 'string' &&
typeof record.updatedAt === 'string'
);
}
function isCreatorIntentReadiness(
value: unknown,
): value is CreatorIntentReadiness {
const record = toRecord(value);
if (!record) {
return false;
}
return (
typeof record.isReady === 'boolean' &&
Array.isArray(record.completedKeys) &&
Array.isArray(record.missingKeys)
);
}
function mapLegacyClarificationTargetKey(id: string) {
if (id === 'world_hook') return 'world_hook';
if (id === 'player_premise') return 'player_premise';
if (id === 'theme_and_tone' || id === 'tone_boundary') {
return 'theme_and_tone';
}
if (id === 'core_conflict') return 'core_conflict';
if (id === 'relationship_seed' || id === 'relationship_hook') {
return 'relationship_seed';
}
if (id === 'iconic_element' || id === 'iconic_elements') {
return 'iconic_element';
}
return null;
}
function hasUserInput(record: CustomWorldAgentSessionRecord) {
return (
Boolean(record.seedText.trim()) ||
record.messages.some(
(message) => message.role === 'user' && message.text.trim(),
)
);
}
function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) {
const existingIntent =
normalizeCreatorIntentRecord(record.creatorIntent) ??
createEmptyCreatorIntentRecord('freeform');
if (!record.seedText.trim()) {
return existingIntent;
}
const seedPatch = extractCreatorIntentPatch({
currentIntent: existingIntent,
latestUserMessage: record.seedText,
});
return mergeCreatorIntentRecord(existingIntent, seedPatch);
}
function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) {
if (
isCreatorIntentReadiness(
(record as Record<string, unknown>).creatorIntentReadiness,
)
) {
return record.creatorIntentReadiness;
}
return evaluateCreatorIntentReadiness(
normalizeCreatorIntentRecord(record.creatorIntent),
);
}
function buildCompatiblePendingClarifications(
record: CustomWorldAgentSessionRecord,
) {
const normalizedIntent = normalizeCreatorIntentRecord(record.creatorIntent);
const readiness = buildCompatibleReadiness(record);
const legacyClarifications = Array.isArray(record.pendingClarifications)
? record.pendingClarifications
: [];
const nextClarifications = legacyClarifications
.map((entry, index) => {
const targetKey = mapLegacyClarificationTargetKey(entry.id);
if (!targetKey) {
return null;
}
return {
id: entry.id || targetKey,
label: entry.label || '待补充问题',
question: entry.question || '',
targetKey,
priority:
typeof entry.priority === 'number' ? entry.priority : index + 1,
answer: entry.answer,
} satisfies CustomWorldPendingClarification;
})
.filter((entry): entry is CustomWorldPendingClarification =>
Boolean(entry?.question),
)
.slice(0, 3);
if (nextClarifications.length > 0) {
return nextClarifications;
}
return buildPendingClarifications(normalizedIntent, readiness);
}
function buildCompatibleDraftProfile(
record: CustomWorldAgentSessionRecord,
creatorIntent: ReturnType<typeof buildCompatibleCreatorIntent>,
) {
const existingDraftProfile = toRecord(record.draftProfile);
const hasFoundationContent = Boolean(
existingDraftProfile &&
(typeof existingDraftProfile.name === 'string' ||
Array.isArray(existingDraftProfile.playableNpcs) ||
Array.isArray(existingDraftProfile.landmarks) ||
Array.isArray(existingDraftProfile.factions) ||
Array.isArray(existingDraftProfile.threads) ||
Array.isArray(existingDraftProfile.chapters)),
);
if (hasFoundationContent) {
return {
...existingDraftProfile,
name:
toText(existingDraftProfile?.name) ||
toText(existingDraftProfile?.title) ||
buildDraftTitleFromIntent(creatorIntent),
summary:
toText(existingDraftProfile?.summary) ||
buildDraftSummaryFromIntent(creatorIntent),
};
}
return {
...(existingDraftProfile ?? {}),
title:
toText(existingDraftProfile?.title) || buildDraftTitleFromIntent(creatorIntent),
summary:
toText(existingDraftProfile?.summary) ||
buildDraftSummaryFromIntent(creatorIntent),
};
}
function buildCompatibleSuggestedActions(params: {
record: CustomWorldAgentSessionRecord;
stage: CustomWorldAgentStage;
readiness: CreatorIntentReadiness;
draftProfile: Record<string, unknown>;
}) {
if (params.record.suggestedActions.length > 0) {
return params.record.suggestedActions;
}
const actions: CustomWorldSuggestedAction[] = [
{
id: 'request_summary',
type: 'request_summary',
label:
params.stage === 'object_refining' || params.stage === 'visual_refining'
? '总结当前世界底稿'
: '总结当前设定',
},
];
const playableNpcs = Array.isArray(params.draftProfile.playableNpcs)
? params.draftProfile.playableNpcs
: [];
const storyNpcs = Array.isArray(params.draftProfile.storyNpcs)
? params.draftProfile.storyNpcs
: [];
const landmarks = Array.isArray(params.draftProfile.landmarks)
? params.draftProfile.landmarks
: [];
if (params.stage === 'foundation_review' && params.readiness.isReady) {
actions.push({
id: 'draft_foundation',
type: 'draft_foundation',
label: '整理一版世界底稿',
});
return actions;
}
if (params.stage === 'object_refining' || params.stage === 'visual_refining') {
const firstCharacter = toRecord([...playableNpcs, ...storyNpcs][0]);
const firstLandmark = toRecord(landmarks[0]);
actions.push({
id: 'refine_world',
type: 'refine_focus_target',
label: '先看世界总卡',
targetId: 'world-foundation',
});
if (firstCharacter) {
actions.push({
id: `refine-character-${toText(firstCharacter.id) || 'seed'}`,
type: 'refine_focus_target',
label: `精修角色:${toText(firstCharacter.name) || '关键角色'}`,
targetId: toText(firstCharacter.id) || null,
});
}
if (firstLandmark) {
actions.push({
id: `refine-landmark-${toText(firstLandmark.id) || 'seed'}`,
type: 'refine_focus_target',
label: `继续补地点:${toText(firstLandmark.name) || '关键地点'}`,
targetId: toText(firstLandmark.id) || null,
});
}
}
return actions;
}
function normalizeRecommendedReplies(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => toText(item))
.filter(Boolean)
.slice(0, 3);
}
function buildCompatibleAssetCoverage(
record: CustomWorldAgentSessionRecord,
draftProfile: Record<string, unknown>,
) {
const derivedCoverage = rebuildRoleAssetCoverage(draftProfile);
const existingCoverage = toRecord(record.assetCoverage);
const sceneAssets = Array.isArray(existingCoverage?.sceneAssets)
? existingCoverage.sceneAssets
: [];
const allSceneAssetsReady =
typeof existingCoverage?.allSceneAssetsReady === 'boolean'
? existingCoverage.allSceneAssetsReady
: false;
return {
...derivedCoverage,
sceneAssets,
allSceneAssetsReady,
} satisfies CustomWorldAssetCoverageSummary;
}
function applyCompatibility(record: CustomWorldAgentSessionRecord) {
const creatorIntent = buildCompatibleCreatorIntent(record);
const creatorIntentReadiness = evaluateCreatorIntentReadiness(creatorIntent);
const stage =
record.stage === 'collecting_intent' ||
record.stage === 'clarifying' ||
record.stage === 'foundation_review'
? resolveCreatorIntentStage({
hasUserInput: hasUserInput(record),
readiness: creatorIntentReadiness,
})
: record.stage;
const pendingClarifications = buildCompatiblePendingClarifications({
...record,
creatorIntent,
creatorIntentReadiness,
});
const draftProfile = buildCompatibleDraftProfile(record, creatorIntent);
return {
...record,
stage,
creatorIntent,
creatorIntentReadiness,
anchorPack:
record.anchorPack && Object.keys(record.anchorPack).length > 0
? record.anchorPack
: buildAnchorPackFromIntent(creatorIntent, {
completedKeys: creatorIntentReadiness.completedKeys,
missingKeys: creatorIntentReadiness.missingKeys,
}),
draftProfile,
pendingClarifications,
suggestedActions: buildCompatibleSuggestedActions({
record,
stage,
readiness: creatorIntentReadiness,
draftProfile,
}),
assetCoverage: buildCompatibleAssetCoverage(record, draftProfile),
recommendedReplies: normalizeRecommendedReplies(
(record as Record<string, unknown>).recommendedReplies,
),
} satisfies CustomWorldAgentSessionRecord;
}
function toSnapshot(
record: CustomWorldAgentSessionRecord,
): CustomWorldAgentSessionSnapshot {
return {
sessionId: record.sessionId,
stage: record.stage,
focusCardId: record.focusCardId,
creatorIntent: cloneRecord(record.creatorIntent),
creatorIntentReadiness: cloneRecord(record.creatorIntentReadiness),
anchorPack: cloneRecord(record.anchorPack),
lockState: cloneRecord(record.lockState),
draftProfile: cloneRecord(record.draftProfile),
messages: cloneRecord(record.messages),
draftCards: cloneRecord(record.draftCards),
pendingClarifications: cloneRecord(record.pendingClarifications),
suggestedActions: cloneRecord(record.suggestedActions),
recommendedReplies: cloneRecord(record.recommendedReplies),
qualityFindings: cloneRecord(record.qualityFindings),
assetCoverage: cloneRecord(record.assetCoverage),
updatedAt: record.updatedAt,
};
}
export class CustomWorldAgentSessionStore {
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
private async persist(record: CustomWorldAgentSessionRecord) {
await this.runtimeRepository.upsertCustomWorldSession(
record.userId,
record.sessionId,
record as unknown as LegacyCustomWorldSessionRecord,
);
return cloneRecord(record);
}
private async mutate(
userId: string,
sessionId: string,
mutateFn: (record: CustomWorldAgentSessionRecord) => void,
) {
const current = await this.get(userId, sessionId);
if (!current) {
return null;
}
const nextRecord = cloneRecord(current);
mutateFn(nextRecord);
nextRecord.updatedAt = new Date().toISOString();
return this.persist(nextRecord);
}
async create(userId: string, input: CreateSessionInput) {
const sessionId = `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}${crypto.randomBytes(16).toString('hex')}`;
const now = new Date().toISOString();
const welcomeMessage: CustomWorldAgentMessage = {
id: `message-${crypto.randomBytes(8).toString('hex')}`,
role: 'assistant',
kind: 'chat',
text: input.welcomeMessage,
createdAt: now,
relatedOperationId: null,
};
const record: CustomWorldAgentSessionRecord = {
sessionId,
userId,
seedText: input.seedText?.trim() ?? '',
stage: input.stage ?? 'collecting_intent',
focusCardId: null,
creatorIntent: cloneRecord(input.creatorIntent ?? {}),
creatorIntentReadiness: input.creatorIntentReadiness ?? {
isReady: false,
completedKeys: [],
missingKeys: [],
},
anchorPack: cloneRecord(input.anchorPack ?? {}),
lockState: {},
draftProfile: cloneRecord(input.draftProfile ?? {}),
messages: [welcomeMessage],
draftCards: [],
pendingClarifications: cloneRecord(input.pendingClarifications),
suggestedActions: cloneRecord(input.suggestedActions),
recommendedReplies: cloneRecord(input.recommendedReplies ?? []),
qualityFindings: [],
assetCoverage: rebuildRoleAssetCoverage(input.draftProfile ?? {}),
operations: [],
checkpoints: [],
createdAt: now,
updatedAt: now,
};
const compatibleRecord = applyCompatibility(record);
await this.persist(compatibleRecord);
return cloneRecord(compatibleRecord);
}
async list(userId: string) {
const records =
await this.runtimeRepository.listCustomWorldSessions(userId);
return records
.filter((record) => isAgentSessionRecord(record))
.map((record) => cloneRecord(applyCompatibility(record)))
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
async get(userId: string, sessionId: string) {
if (!sessionId.trim()) {
return null;
}
const record = await this.runtimeRepository.getCustomWorldSession(
userId,
sessionId,
);
if (!isAgentSessionRecord(record)) {
return null;
}
return cloneRecord(applyCompatibility(record));
}
async getSnapshot(userId: string, sessionId: string) {
const record = await this.get(userId, sessionId);
return record ? toSnapshot(record) : null;
}
async appendMessage(
userId: string,
sessionId: string,
message: CustomWorldAgentMessage,
) {
return this.mutate(userId, sessionId, (record) => {
record.messages.push(cloneRecord(message));
});
}
async replaceDerivedState(
userId: string,
sessionId: string,
patch: Partial<
Pick<
CustomWorldAgentSessionRecord,
| 'stage'
| 'creatorIntent'
| 'creatorIntentReadiness'
| 'anchorPack'
| 'lockState'
| 'draftProfile'
| 'pendingClarifications'
| 'suggestedActions'
| 'recommendedReplies'
| 'draftCards'
| 'qualityFindings'
| 'focusCardId'
| 'assetCoverage'
>
>,
) {
return this.mutate(userId, sessionId, (record) => {
if (patch.stage) {
record.stage = patch.stage;
}
if (patch.focusCardId !== undefined) {
record.focusCardId = patch.focusCardId;
}
if (patch.creatorIntent !== undefined) {
record.creatorIntent = cloneRecord(patch.creatorIntent);
}
if (patch.creatorIntentReadiness !== undefined) {
record.creatorIntentReadiness = cloneRecord(
patch.creatorIntentReadiness,
);
}
if (patch.anchorPack !== undefined) {
record.anchorPack = cloneRecord(patch.anchorPack);
}
if (patch.lockState !== undefined) {
record.lockState = cloneRecord(patch.lockState);
}
if (patch.draftProfile !== undefined) {
record.draftProfile = cloneRecord(patch.draftProfile);
}
if (patch.pendingClarifications !== undefined) {
record.pendingClarifications = cloneRecord(patch.pendingClarifications);
}
if (patch.suggestedActions !== undefined) {
record.suggestedActions = cloneRecord(patch.suggestedActions);
}
if (patch.recommendedReplies !== undefined) {
record.recommendedReplies = cloneRecord(patch.recommendedReplies);
}
if (patch.draftCards !== undefined) {
record.draftCards = cloneRecord(patch.draftCards);
}
if (patch.qualityFindings !== undefined) {
record.qualityFindings = cloneRecord(patch.qualityFindings);
}
if (patch.assetCoverage !== undefined) {
record.assetCoverage = cloneRecord(patch.assetCoverage);
}
});
}
async createOperation(
userId: string,
sessionId: string,
operation: CustomWorldAgentOperationRecord,
) {
return this.mutate(userId, sessionId, (record) => {
record.operations.push(cloneRecord(operation));
});
}
async getOperation(userId: string, sessionId: string, operationId: string) {
const record = await this.get(userId, sessionId);
if (!record) {
return null;
}
const operation = record.operations.find(
(item) => item.operationId === operationId,
);
return operation ? cloneRecord(operation) : null;
}
async updateOperation(
userId: string,
sessionId: string,
operationId: string,
patch: Partial<CustomWorldAgentOperationRecord>,
) {
return this.mutate(userId, sessionId, (record) => {
const operation = record.operations.find(
(item) => item.operationId === operationId,
);
if (!operation) {
return;
}
if (patch.type) {
operation.type = patch.type;
}
if (patch.status) {
operation.status = patch.status;
}
if (patch.phaseLabel) {
operation.phaseLabel = patch.phaseLabel;
}
if (patch.phaseDetail) {
operation.phaseDetail = patch.phaseDetail;
}
if (typeof patch.progress === 'number') {
operation.progress = patch.progress;
}
if (patch.error !== undefined) {
operation.error = patch.error;
}
});
}
async appendCheckpoint(
userId: string,
sessionId: string,
input: {
checkpointId?: string;
label: string;
},
) {
return this.mutate(userId, sessionId, (record) => {
record.checkpoints.push({
checkpointId:
input.checkpointId ||
`checkpoint-${crypto.randomBytes(8).toString('hex')}`,
createdAt: new Date().toISOString(),
label: input.label,
});
});
}
async listDraftCards(userId: string, sessionId: string) {
const record = await this.get(userId, sessionId);
return record ? cloneRecord(record.draftCards) : null;
}
}

View File

@@ -1,13 +1,13 @@
import type { AppContext } from '../context.js';
import {
type CustomWorldGenerationProgress,
type GenerateCustomWorldProfileInput,
generateCustomWorldProfileFromOrchestrator,
type GenerateCustomWorldProfileInput,
} from '../modules/ai/customWorldOrchestrator.js';
import type { AppContext } from '../context.js';
import type { CustomWorldSession } from './customWorldSessionStore.js';
export async function generateCustomWorldProfile(
_context: AppContext,
context: AppContext,
session: CustomWorldSession,
options: {
onProgress?: (progress: CustomWorldGenerationProgress) => void;
@@ -20,10 +20,14 @@ export async function generateCustomWorldProfile(
generationMode: session.generationMode,
} satisfies GenerateCustomWorldProfileInput;
const profile = await generateCustomWorldProfileFromOrchestrator(input, {
onProgress: options.onProgress,
signal: options.signal,
});
const profile = await generateCustomWorldProfileFromOrchestrator(
context.llmClient,
input,
{
onProgress: options.onProgress,
signal: options.signal,
},
);
return JSON.parse(JSON.stringify(profile)) as Record<string, unknown>;
}

View File

@@ -4,12 +4,13 @@ import type { JsonObject } from '../../../packages/shared/src/contracts/common.j
import type {
CustomWorldGenerationMode,
CustomWorldQuestion,
CustomWorldSessionRecord,
CustomWorldSessionStatus,
} from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
export type CustomWorldSession = {
sessionId: string;
userId: string;
status: CustomWorldSessionStatus;
settingText: string;
creatorIntent: JsonObject | null;
@@ -25,6 +26,36 @@ function cloneSession(session: CustomWorldSession) {
return JSON.parse(JSON.stringify(session)) as CustomWorldSession;
}
function toSessionRecord(session: CustomWorldSession): CustomWorldSessionRecord {
return {
sessionId: session.sessionId,
status: session.status,
settingText: session.settingText,
creatorIntent: session.creatorIntent,
generationMode: session.generationMode,
questions: session.questions,
result: session.result,
lastError: session.lastError,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
}
function toSession(record: CustomWorldSessionRecord) {
return cloneSession({
sessionId: record.sessionId,
status: record.status,
settingText: record.settingText,
creatorIntent: record.creatorIntent ?? null,
generationMode: record.generationMode,
questions: record.questions,
result: record.result,
lastError: record.lastError,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
}
function hasPendingQuestion(questions: CustomWorldQuestion[]) {
return questions.some((question) => !question.answer?.trim());
}
@@ -79,9 +110,11 @@ function buildClarificationQuestions(
}
export class CustomWorldSessionStore {
private readonly sessions = new Map<string, Map<string, CustomWorldSession>>();
constructor(
private readonly runtimeRepository: RuntimeRepositoryPort,
) {}
create(
async create(
userId: string,
settingText: string,
creatorIntent: JsonObject | null,
@@ -91,7 +124,6 @@ export class CustomWorldSessionStore {
const now = new Date().toISOString();
const session: CustomWorldSession = {
sessionId,
userId,
status: 'ready_to_generate',
settingText,
creatorIntent,
@@ -105,19 +137,34 @@ export class CustomWorldSessionStore {
session.status = 'clarifying';
}
const userSessions = this.sessions.get(userId) ?? new Map<string, CustomWorldSession>();
userSessions.set(sessionId, session);
this.sessions.set(userId, userSessions);
await this.runtimeRepository.upsertCustomWorldSession(
userId,
sessionId,
toSessionRecord(session),
);
return cloneSession(session);
}
get(userId: string, sessionId: string) {
const session = this.sessions.get(userId)?.get(sessionId);
return session ? cloneSession(session) : null;
async list(userId: string) {
const sessions = await this.runtimeRepository.listCustomWorldSessions(userId);
return sessions.map((session) => toSession(session));
}
answer(userId: string, sessionId: string, questionId: string, answer: string) {
const session = this.sessions.get(userId)?.get(sessionId);
async get(userId: string, sessionId: string) {
const session = await this.runtimeRepository.getCustomWorldSession(
userId,
sessionId,
);
return session ? toSession(session) : null;
}
async answer(
userId: string,
sessionId: string,
questionId: string,
answer: string,
) {
const session = await this.get(userId, sessionId);
if (!session) {
return null;
}
@@ -132,16 +179,21 @@ export class CustomWorldSessionStore {
? 'clarifying'
: 'ready_to_generate';
session.updatedAt = new Date().toISOString();
await this.runtimeRepository.upsertCustomWorldSession(
userId,
sessionId,
toSessionRecord(session),
);
return cloneSession(session);
}
updateStatus(
async updateStatus(
userId: string,
sessionId: string,
status: CustomWorldSessionStatus,
lastError = '',
) {
const session = this.sessions.get(userId)?.get(sessionId);
const session = await this.get(userId, sessionId);
if (!session) {
return null;
}
@@ -149,11 +201,16 @@ export class CustomWorldSessionStore {
session.status = status;
session.lastError = lastError || undefined;
session.updatedAt = new Date().toISOString();
await this.runtimeRepository.upsertCustomWorldSession(
userId,
sessionId,
toSessionRecord(session),
);
return cloneSession(session);
}
setResult(userId: string, sessionId: string, result: JsonObject) {
const session = this.sessions.get(userId)?.get(sessionId);
async setResult(userId: string, sessionId: string, result: JsonObject) {
const session = await this.get(userId, sessionId);
if (!session) {
return null;
}
@@ -162,6 +219,11 @@ export class CustomWorldSessionStore {
session.lastError = undefined;
session.result = JSON.parse(JSON.stringify(result)) as JsonObject;
session.updatedAt = new Date().toISOString();
await this.runtimeRepository.upsertCustomWorldSession(
userId,
sessionId,
toSessionRecord(session),
);
return cloneSession(session);
}
}

View File

@@ -0,0 +1,233 @@
import type {
CustomWorldAgentStage,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import {
buildDraftSummaryFromIntent,
buildDraftTitleFromIntent,
normalizeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
import {
rebuildRoleAssetCoverage,
resolveRoleAssetStatusLabel,
} from './customWorldAgentRoleAssetStateService.js';
import type {
CustomWorldAgentSessionRecord,
CustomWorldAgentSessionStore,
} from './customWorldAgentSessionStore.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 toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter((item) => item && typeof item === 'object')
: [];
}
function truncateText(value: string, maxLength: number) {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function formatDraftStageLabel(stage: CustomWorldAgentStage) {
if (stage === 'collecting_intent') return '收集世界锚点';
if (stage === 'clarifying') return '补齐关键锚点';
if (stage === 'foundation_review') return '准备整理底稿';
if (stage === 'object_refining') return '精修对象';
if (stage === 'visual_refining') return '视觉工坊';
if (stage === 'long_tail_review') return '扩展长尾';
if (stage === 'ready_to_publish') return '准备发布';
if (stage === 'published') return '已发布';
return '发生错误';
}
function resolveDraftTitle(session: CustomWorldAgentSessionRecord) {
const intent = normalizeCreatorIntentRecord(session.creatorIntent);
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
return (
draftProfile?.name ||
buildDraftTitleFromIntent(intent) ||
toText(session.draftProfile?.title) ||
truncateText(session.seedText, 18) ||
'未命名草稿'
);
}
function resolveDraftSummary(session: CustomWorldAgentSessionRecord) {
const intent = normalizeCreatorIntentRecord(session.creatorIntent);
const compiledSummary = buildDraftSummaryFromIntent(intent);
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
return (
draftProfile?.summary ||
compiledSummary ||
toText(session.draftProfile?.summary) ||
truncateText(session.seedText, 72) ||
'还在收集你的世界锚点。'
);
}
function resolveDraftCounts(session: CustomWorldAgentSessionRecord) {
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
if (draftProfile) {
return {
playableNpcCount: [
...new Set(
[...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map(
(entry) => entry.id,
),
),
].length,
landmarkCount: draftProfile.landmarks.length,
};
}
const playableNpcCount = session.draftCards.filter(
(card) => card.kind === 'character',
).length;
const landmarkCount = session.draftCards.filter(
(card) => card.kind === 'landmark' || card.kind === 'camp',
).length;
return {
playableNpcCount,
landmarkCount,
};
}
function resolveDraftRoleAssetProgress(session: CustomWorldAgentSessionRecord) {
const coverage = rebuildRoleAssetCoverage(session.draftProfile);
const roleVisualReadyCount = coverage.roleAssets.filter(
(entry) => entry.status !== 'missing',
).length;
const roleAnimationReadyCount = coverage.roleAssets.filter(
(entry) => entry.status === 'complete',
).length;
const leadRole = coverage.roleAssets[0];
return {
roleVisualReadyCount,
roleAnimationReadyCount,
roleAssetSummaryLabel: leadRole
? `${leadRole.roleName} · ${resolveRoleAssetStatusLabel(leadRole.status)}`
: coverage.roleAssets.length > 0
? '角色资产进行中'
: null,
};
}
function resolvePublishedCover(profile: Record<string, unknown>) {
const camp = toRecord(profile.camp);
const playableNpcs = toRecordArray(profile.playableNpcs);
const leadNpc = toRecord(playableNpcs[0]);
return toText(camp?.imageSrc) || toText(leadNpc?.imageSrc) || null;
}
export async function listCustomWorldWorkSummaries(
userId: string,
dependencies: {
runtimeRepository: RuntimeRepositoryPort;
customWorldAgentSessions: CustomWorldAgentSessionStore;
},
) {
const [profiles, sessions] = await Promise.all([
dependencies.runtimeRepository.listCustomWorldProfiles(userId),
dependencies.customWorldAgentSessions.list(userId),
]);
const draftItems: CustomWorldWorkSummary[] = sessions.map((session) => {
const counts = resolveDraftCounts(session);
const roleAssetProgress = resolveDraftRoleAssetProgress(session);
return {
workId: `draft:${session.sessionId}`,
sourceType: 'agent_session',
status: 'draft',
title: resolveDraftTitle(session),
subtitle:
normalizeFoundationDraftProfile(session.draftProfile)?.subtitle ||
formatDraftStageLabel(session.stage),
summary: resolveDraftSummary(session),
coverImageSrc: null,
updatedAt: session.updatedAt,
publishedAt: null,
stage: session.stage,
stageLabel: formatDraftStageLabel(session.stage),
playableNpcCount: counts.playableNpcCount,
landmarkCount: counts.landmarkCount,
roleVisualReadyCount: roleAssetProgress.roleVisualReadyCount,
roleAnimationReadyCount: roleAssetProgress.roleAnimationReadyCount,
roleAssetSummaryLabel: roleAssetProgress.roleAssetSummaryLabel,
sessionId: session.sessionId,
profileId: null,
canResume: true,
canEnterWorld: false,
};
});
const publishedItems: CustomWorldWorkSummary[] = profiles.map((profile) => {
const profileRecord = profile as CustomWorldProfileRecord &
Record<string, unknown>;
const playableNpcs = toRecordArray(profileRecord.playableNpcs);
const landmarks = toRecordArray(profileRecord.landmarks);
const updatedAt =
toText(profileRecord.updatedAt) || new Date().toISOString();
const roleVisualReadyCount = playableNpcs.filter(
(entry) =>
Boolean(toText(entry.imageSrc)) &&
Boolean(toText(entry.generatedVisualAssetId)),
).length;
const roleAnimationReadyCount = playableNpcs.filter(
(entry) => Boolean(toText(entry.generatedAnimationSetId)),
).length;
return {
workId: `published:${toText(profileRecord.id) || updatedAt}`,
sourceType: 'published_profile',
status: 'published',
title: toText(profileRecord.name) || '未命名世界',
subtitle: toText(profileRecord.subtitle) || '已发布作品',
summary:
toText(profileRecord.summary) || '这个世界已经可以直接进入体验。',
coverImageSrc: resolvePublishedCover(profileRecord),
updatedAt,
publishedAt: toText(profileRecord.publishedAt) || updatedAt,
stage: 'published',
stageLabel: '已发布',
playableNpcCount: playableNpcs.length,
landmarkCount: landmarks.length,
roleVisualReadyCount,
roleAnimationReadyCount,
roleAssetSummaryLabel:
roleAnimationReadyCount > 0
? `动作已就绪 ${roleAnimationReadyCount}`
: roleVisualReadyCount > 0
? `主图已就绪 ${roleVisualReadyCount}`
: null,
sessionId: null,
profileId: toText(profileRecord.id) || null,
canResume: false,
canEnterWorld: true,
};
});
return [...draftItems, ...publishedItems].sort((left, right) =>
right.updatedAt.localeCompare(left.updatedAt),
);
}