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