Integrate role asset studio into custom world agent flow
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)`,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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, summary,targetLandmarkName 必须指向本次输出的其他场景名',
|
||||
'',
|
||||
'约束:',
|
||||
'- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。',
|
||||
'- 角色名字、势力名、场景名必须互相区分,避免重复。',
|
||||
'- 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
221
server-node/src/routes/customWorldAgent.ts
Normal file
221
server-node/src/routes/customWorldAgent.ts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
161
server-node/src/services/customWorldAgentClarificationService.ts
Normal file
161
server-node/src/services/customWorldAgentClarificationService.ts
Normal 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';
|
||||
}
|
||||
1041
server-node/src/services/customWorldAgentDraftCompiler.ts
Normal file
1041
server-node/src/services/customWorldAgentDraftCompiler.ts
Normal file
File diff suppressed because it is too large
Load Diff
322
server-node/src/services/customWorldAgentDraftEditService.ts
Normal file
322
server-node/src/services/customWorldAgentDraftEditService.ts
Normal 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');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
1128
server-node/src/services/customWorldAgentIntentExtractionService.ts
Normal file
1128
server-node/src/services/customWorldAgentIntentExtractionService.ts
Normal file
File diff suppressed because it is too large
Load Diff
1625
server-node/src/services/customWorldAgentOrchestrator.ts
Normal file
1625
server-node/src/services/customWorldAgentOrchestrator.ts
Normal file
File diff suppressed because it is too large
Load Diff
351
server-node/src/services/customWorldAgentPhase2.test.ts
Normal file
351
server-node/src/services/customWorldAgentPhase2.test.ts
Normal 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, ['冷峻']);
|
||||
});
|
||||
259
server-node/src/services/customWorldAgentPhase3.test.ts
Normal file
259
server-node/src/services/customWorldAgentPhase3.test.ts
Normal 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);
|
||||
});
|
||||
311
server-node/src/services/customWorldAgentPhase4.test.ts
Normal file
311
server-node/src/services/customWorldAgentPhase4.test.ts
Normal 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);
|
||||
});
|
||||
276
server-node/src/services/customWorldAgentPhase5.test.ts
Normal file
276
server-node/src/services/customWorldAgentPhase5.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
711
server-node/src/services/customWorldAgentSessionStore.ts
Normal file
711
server-node/src/services/customWorldAgentSessionStore.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
233
server-node/src/services/customWorldWorkSummaryService.ts
Normal file
233
server-node/src/services/customWorldWorkSummaryService.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user