Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -1,22 +1,27 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
|
||||
|
||||
const {
|
||||
connectivityError,
|
||||
fetchMock,
|
||||
requestChatMessageContentMock,
|
||||
requestPlainTextCompletionMock,
|
||||
streamPlainTextCompletionMock,
|
||||
timeoutError,
|
||||
} = vi.hoisted(() => ({
|
||||
connectivityError: new Error('LLM unavailable'),
|
||||
fetchMock: vi.fn(),
|
||||
requestChatMessageContentMock: vi.fn(),
|
||||
requestPlainTextCompletionMock: vi.fn(),
|
||||
streamPlainTextCompletionMock: vi.fn(),
|
||||
timeoutError: new Error('LLM timed out'),
|
||||
}));
|
||||
|
||||
vi.mock('./llmClient', () => ({
|
||||
CUSTOM_WORLD_REQUEST_TIMEOUT_MS: 45000,
|
||||
CUSTOM_WORLD_REQUEST_TIMEOUT_MS: 120000,
|
||||
isLlmConnectivityError: (error: unknown) => error === connectivityError,
|
||||
isLlmTimeoutError: (error: unknown) => error === timeoutError,
|
||||
requestChatMessageContent: requestChatMessageContentMock,
|
||||
requestPlainTextCompletion: requestPlainTextCompletionMock,
|
||||
streamPlainTextCompletion: streamPlainTextCompletionMock,
|
||||
@@ -46,6 +51,13 @@ import {
|
||||
import type { StoryGenerationContext } from './aiTypes';
|
||||
import type { CharacterChatTargetStatus } from './characterChatPrompt';
|
||||
|
||||
const [
|
||||
BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
||||
|
||||
function createCharacter(overrides: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
@@ -144,6 +156,53 @@ function createPlayableNpc(index: number) {
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: [`接触点${index + 1}`],
|
||||
tags: [`标签${index + 1}`],
|
||||
backstoryReveal: {
|
||||
publicSummary: `公开背景${index + 1}`,
|
||||
chapters: [
|
||||
{
|
||||
id: `surface-${index + 1}`,
|
||||
title: '表层来意',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
teaser: `提示${index + 1}-1`,
|
||||
content: `内容${index + 1}-1`,
|
||||
contextSnippet: `摘要${index + 1}-1`,
|
||||
},
|
||||
{
|
||||
id: `scar-${index + 1}`,
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
teaser: `提示${index + 1}-2`,
|
||||
content: `内容${index + 1}-2`,
|
||||
contextSnippet: `摘要${index + 1}-2`,
|
||||
},
|
||||
{
|
||||
id: `hidden-${index + 1}`,
|
||||
title: '隐藏执念',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
teaser: `提示${index + 1}-3`,
|
||||
content: `内容${index + 1}-3`,
|
||||
contextSnippet: `摘要${index + 1}-3`,
|
||||
},
|
||||
{
|
||||
id: `final-${index + 1}`,
|
||||
title: '最终底牌',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
teaser: `提示${index + 1}-4`,
|
||||
content: `内容${index + 1}-4`,
|
||||
contextSnippet: `摘要${index + 1}-4`,
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: [
|
||||
{ name: `技能${index + 1}-1`, summary: '技能说明1', style: '起手压制' },
|
||||
{ name: `技能${index + 1}-2`, summary: '技能说明2', style: '机动周旋' },
|
||||
{ name: `技能${index + 1}-3`, summary: '技能说明3', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{ name: `物品${index + 1}-1`, category: '武器', quantity: 1, rarity: 'rare', description: '物品说明1', tags: ['物品标签1'] },
|
||||
{ name: `物品${index + 1}-2`, category: '消耗品', quantity: 2, rarity: 'uncommon', description: '物品说明2', tags: ['物品标签2'] },
|
||||
{ name: `物品${index + 1}-3`, category: '专属物品', quantity: 1, rarity: 'rare', description: '物品说明3', tags: ['物品标签3'] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,14 +219,139 @@ function createStoryNpc(index: number) {
|
||||
initialAffinity: index % 4 === 0 ? -10 : 6,
|
||||
relationshipHooks: [`关系${index + 1}`],
|
||||
tags: [`线索${index + 1}`],
|
||||
backstoryReveal: {
|
||||
publicSummary: `世界公开背景${index + 1}`,
|
||||
chapters: [
|
||||
{
|
||||
id: `surface-story-${index + 1}`,
|
||||
title: '表层来意',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
teaser: `提示${index + 1}-1`,
|
||||
content: `内容${index + 1}-1`,
|
||||
contextSnippet: `摘要${index + 1}-1`,
|
||||
},
|
||||
{
|
||||
id: `scar-story-${index + 1}`,
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
teaser: `提示${index + 1}-2`,
|
||||
content: `内容${index + 1}-2`,
|
||||
contextSnippet: `摘要${index + 1}-2`,
|
||||
},
|
||||
{
|
||||
id: `hidden-story-${index + 1}`,
|
||||
title: '隐藏执念',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
teaser: `提示${index + 1}-3`,
|
||||
content: `内容${index + 1}-3`,
|
||||
contextSnippet: `摘要${index + 1}-3`,
|
||||
},
|
||||
{
|
||||
id: `final-story-${index + 1}`,
|
||||
title: '最终底牌',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
teaser: `提示${index + 1}-4`,
|
||||
content: `内容${index + 1}-4`,
|
||||
contextSnippet: `摘要${index + 1}-4`,
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: [
|
||||
{ name: `世界技能${index + 1}-1`, summary: '技能说明1', style: '起手压制' },
|
||||
{ name: `世界技能${index + 1}-2`, summary: '技能说明2', style: '机动周旋' },
|
||||
{ name: `世界技能${index + 1}-3`, summary: '技能说明3', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{ name: `世界物品${index + 1}-1`, category: '武器', quantity: 1, rarity: 'rare', description: '物品说明1', tags: ['物品标签1'] },
|
||||
{ name: `世界物品${index + 1}-2`, category: '消耗品', quantity: 2, rarity: 'uncommon', description: '物品说明2', tags: ['物品标签2'] },
|
||||
{ name: `世界物品${index + 1}-3`, category: '专属物品', quantity: 1, rarity: 'rare', description: '物品说明3', tags: ['物品标签3'] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createLandmark(index: number) {
|
||||
function createLandmark(
|
||||
index: number,
|
||||
options?: {
|
||||
storyNpcNames?: string[];
|
||||
landmarkCount?: number;
|
||||
},
|
||||
) {
|
||||
const landmarkCount = options?.landmarkCount ?? 10;
|
||||
const nextName = `场景${((index + 1) % landmarkCount) + 1}`;
|
||||
const prevName = `场景${((index - 1 + landmarkCount) % landmarkCount) + 1}`;
|
||||
|
||||
return {
|
||||
name: `场景${index + 1}`,
|
||||
description: `场景描述${index + 1}`,
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: options?.storyNpcNames ?? [
|
||||
`世界NPC${index + 1}`,
|
||||
`世界NPC${index + 2}`,
|
||||
`世界NPC${index + 3}`,
|
||||
],
|
||||
connections:
|
||||
landmarkCount > 1
|
||||
? [
|
||||
{
|
||||
targetLandmarkName: nextName,
|
||||
relativePosition: 'forward',
|
||||
summary: `沿主路可到${nextName}`,
|
||||
},
|
||||
{
|
||||
targetLandmarkName: prevName,
|
||||
relativePosition: 'back',
|
||||
summary: `回身可返${prevName}`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createCustomWorldResponse(
|
||||
overrides: Partial<{
|
||||
name: string;
|
||||
subtitle: string;
|
||||
summary: string;
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
templateWorldType: 'WUXIA' | 'XIANXIA';
|
||||
playableNpcs: ReturnType<typeof createPlayableNpc>[];
|
||||
storyNpcs: ReturnType<typeof createStoryNpc>[];
|
||||
landmarks: ReturnType<typeof createLandmark>[];
|
||||
items: Array<Record<string, unknown>>;
|
||||
}> = {},
|
||||
) {
|
||||
const storyNpcs =
|
||||
overrides.storyNpcs ??
|
||||
Array.from({ length: 25 }, (_, index) => createStoryNpc(index));
|
||||
const landmarks =
|
||||
overrides.landmarks ??
|
||||
Array.from({ length: 10 }, (_, index) =>
|
||||
createLandmark(index, {
|
||||
landmarkCount: 10,
|
||||
storyNpcNames: [
|
||||
storyNpcs[index % storyNpcs.length]?.name ?? `世界NPC${index + 1}`,
|
||||
storyNpcs[(index + 1) % storyNpcs.length]?.name ??
|
||||
`世界NPC${index + 2}`,
|
||||
storyNpcs[(index + 2) % storyNpcs.length]?.name ??
|
||||
`世界NPC${index + 3}`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
templateWorldType: 'WUXIA' as const,
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs,
|
||||
landmarks,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -282,60 +466,40 @@ describe('ai orchestration fallbacks', () => {
|
||||
|
||||
it('rejects custom world output when the model does not generate enough NPCs and scenes', async () => {
|
||||
requestPlainTextCompletionMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
templateWorldType: 'WUXIA',
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs: Array.from({ length: 10 }, (_, index) =>
|
||||
createStoryNpc(index),
|
||||
),
|
||||
landmarks: Array.from({ length: 4 }, (_, index) =>
|
||||
createLandmark(index),
|
||||
),
|
||||
}),
|
||||
JSON.stringify(
|
||||
createCustomWorldResponse({
|
||||
storyNpcs: Array.from({ length: 10 }, (_, index) =>
|
||||
createStoryNpc(index),
|
||||
),
|
||||
landmarks: Array.from({ length: 4 }, (_, index) =>
|
||||
createLandmark(index, { landmarkCount: 4 }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
generateCustomWorldProfile('一个需要很多角色和场景的世界'),
|
||||
).rejects.toThrow(
|
||||
/requires at least 30 unique NPCs|requires at least 10 generated scenes|did not return enough non-playable NPCs|至少产出 30 名唯一角色|至少产出 10 个场景/i,
|
||||
/requires at least 30 unique NPCs|requires at least 10 generated scenes|did not return enough non-playable NPCs|至少产出 30 名唯一角色|至少产出 10 个场景|至少需要 25 名场景角色/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the generated custom world dossier item-free when the model output is valid', async () => {
|
||||
requestPlainTextCompletionMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
templateWorldType: 'WUXIA',
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs: Array.from({ length: 25 }, (_, index) =>
|
||||
createStoryNpc(index),
|
||||
),
|
||||
landmarks: Array.from({ length: 10 }, (_, index) =>
|
||||
createLandmark(index),
|
||||
),
|
||||
items: [
|
||||
{
|
||||
name: '不应保留的物品',
|
||||
category: '材料',
|
||||
rarity: 'rare',
|
||||
description: '这个字段应该被清空',
|
||||
tags: ['测试'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
JSON.stringify(
|
||||
createCustomWorldResponse({
|
||||
items: [
|
||||
{
|
||||
name: '不应保留的物品',
|
||||
category: '材料',
|
||||
rarity: 'rare',
|
||||
description: '这个字段应该被清空',
|
||||
tags: ['测试'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const profile =
|
||||
@@ -344,9 +508,108 @@ describe('ai orchestration fallbacks', () => {
|
||||
expect(profile.playableNpcs).toHaveLength(5);
|
||||
expect(profile.storyNpcs).toHaveLength(25);
|
||||
expect(profile.landmarks).toHaveLength(10);
|
||||
expect(
|
||||
profile.landmarks.every((landmark) => landmark.sceneNpcIds.length >= 3),
|
||||
).toBe(true);
|
||||
expect(
|
||||
profile.landmarks.every((landmark) => landmark.connections.length > 0),
|
||||
).toBe(true);
|
||||
expect(profile.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('generates custom worlds through a framework stage plus segmented narrative and dossier batches', async () => {
|
||||
requestPlainTextCompletionMock.mockResolvedValue(
|
||||
JSON.stringify(createCustomWorldResponse()),
|
||||
);
|
||||
|
||||
await generateCustomWorldProfile('一个需要拆分生成的世界');
|
||||
|
||||
const debugLabels = requestPlainTextCompletionMock.mock.calls.map(
|
||||
(call) => (call[2] as { debugLabel?: string } | undefined)?.debugLabel,
|
||||
);
|
||||
|
||||
expect(debugLabels).toContain('custom-world-framework');
|
||||
expect(debugLabels).toContain('custom-world-playable-outline-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-story-outline-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-landmark-seed-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-landmark-network-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-playable-narrative-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-playable-dossier-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-story-narrative-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-story-dossier-batch-1');
|
||||
});
|
||||
|
||||
it('retries custom world generation with a longer timeout after the first timeout attempt', async () => {
|
||||
requestPlainTextCompletionMock
|
||||
.mockRejectedValueOnce(timeoutError)
|
||||
.mockResolvedValue(
|
||||
JSON.stringify(
|
||||
createCustomWorldResponse({
|
||||
name: '重试世界',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const profile = await generateCustomWorldProfile('一个生成很慢的世界');
|
||||
|
||||
expect(profile.name).toBe('重试世界');
|
||||
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
timeoutMs: 120000,
|
||||
debugLabel: 'custom-world-framework',
|
||||
}),
|
||||
);
|
||||
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
timeoutMs: 180000,
|
||||
debugLabel: 'custom-world-framework-retry-2',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('repairs invalid custom world json through a follow-up formatting request', async () => {
|
||||
requestPlainTextCompletionMock
|
||||
.mockResolvedValueOnce(
|
||||
`{
|
||||
"name": "修复世界",
|
||||
"subtitle": "副标题",
|
||||
"summary": "概述",
|
||||
"tone": "基调",
|
||||
"playerGoal": "目标",
|
||||
"templateWorldType": "WUXIA",
|
||||
"playableNpcs": [{ name: "角色1" }],
|
||||
"storyNpcs": [],
|
||||
"landmarks": []
|
||||
}`,
|
||||
)
|
||||
.mockResolvedValue(
|
||||
JSON.stringify(
|
||||
createCustomWorldResponse({
|
||||
name: '修复世界',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const profile = await generateCustomWorldProfile('一个格式容易损坏的世界');
|
||||
|
||||
expect(profile.name).toBe('修复世界');
|
||||
expect(profile.playableNpcs).toHaveLength(5);
|
||||
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('你是 JSON 修复器'),
|
||||
expect.stringContaining('不要输出 playableNpcs、storyNpcs、landmarks、items'),
|
||||
expect.objectContaining({
|
||||
debugLabel: 'custom-world-framework-json-repair',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates a custom world scene image through the local proxy and returns the saved asset path', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
Reference in New Issue
Block a user