Files
Genarrative/src/services/ai.test.ts
T
kdletters 387a1c26e3 完成全量检查整改
清理已跟踪原始日志与个人本地配置
修复前后端有效门禁、测试和构建问题
补齐生产 Jenkins、SpacetimeDB 本地命令与文档约束
收紧图片编辑器状态、附件与媒体引用测试
排除已下线旧创作入口测试并清理 warning
2026-07-17 16:56:46 +08:00

1272 lines
38 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { StoryRuntimeProjectionResponse } from '../../packages/shared/src/contracts/story';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
import type {
Character,
Encounter,
SceneHostileNpc,
StoryMoment,
StoryOption,
} from '../types';
import { AnimationState, WorldType } from '../types';
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: 120000,
isLlmConnectivityError: (error: unknown) => error === connectivityError,
isLlmTimeoutError: (error: unknown) => error === timeoutError,
requestChatMessageContent: requestChatMessageContentMock,
requestPlainTextCompletion: requestPlainTextCompletionMock,
streamPlainTextCompletion: streamPlainTextCompletionMock,
}));
import {
generateCharacterPanelChatSuggestions,
generateCustomWorldProfile,
generateCustomWorldSceneImage,
generateInitialStory,
generateNextStep,
streamCharacterPanelChatReply,
streamNpcRecruitDialogue,
} from './ai';
import { streamNpcChatTurn } from './aiService';
import type { StoryGenerationContext } from './aiTypes';
import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
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',
name: 'Lin',
title: 'Wanderer',
description: 'A cautious traveler.',
backstory: 'Walked out of the northern mountains.',
avatar: '/avatars/lin.png',
portrait: '/portraits/lin.png',
assetFolder: 'lin',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 8,
intelligence: 7,
spirit: 9,
},
personality: 'Calm, observant, and steady.',
skills: [],
adventureOpenings: {},
...overrides,
};
}
function createStoryOption(overrides: Partial<StoryOption> = {}): StoryOption {
return {
functionId: 'rest',
actionText: 'Pause and recover.',
text: 'Pause and recover.',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
...overrides,
};
}
function createContext(
overrides: Partial<StoryGenerationContext> = {},
): StoryGenerationContext {
return {
runtimeSessionId: 'runtime-main',
storySessionId: 'storysess-main',
runtimeActionVersion: 3,
playerHp: 30,
playerMaxHp: 40,
playerMana: 12,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
sceneId: null,
sceneName: 'Forest Trail',
sceneDescription: 'A quiet mountain path.',
pendingSceneEncounter: false,
observeSignsRequested: false,
recentActionResult: null,
...overrides,
};
}
function createTargetStatus(
overrides: Partial<CharacterChatTargetStatus> = {},
): CharacterChatTargetStatus {
return {
roleLabel: 'Companion',
hp: 18,
maxHp: 20,
mana: 9,
maxMana: 12,
...overrides,
};
}
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
npcName: 'Lan',
npcDescription: 'A sharp-eyed scout.',
npcAvatar: '/avatars/lan.png',
context: 'Campfire',
...overrides,
};
}
function createPlayableNpc(index: number) {
return {
name: `角色${index + 1}`,
title: `身份${index + 1}`,
role: `世界职责${index + 1}`,
description: `角色描述${index + 1}`,
backstory: `角色背景${index + 1}`,
personality: `角色性格${index + 1}`,
motivation: `角色动机${index + 1}`,
combatStyle: `战斗风格${index + 1}`,
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'],
},
],
};
}
function createStoryNpc(index: number) {
return {
name: `世界NPC${index + 1}`,
title: `头衔${index + 1}`,
role: `职责${index + 1}`,
description: `世界NPC描述${index + 1}`,
backstory: `世界NPC背景${index + 1}`,
personality: `世界NPC性格${index + 1}`,
motivation: `世界NPC动机${index + 1}`,
combatStyle: `世界NPC战斗风格${index + 1}`,
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,
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}`,
actNPCNames: 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,
};
}
function createApiEnvelopeResponse(data: unknown) {
return {
ok: true,
status: 200,
headers: new Headers(),
text: async () =>
JSON.stringify({
ok: true,
data,
error: null,
meta: {
apiVersion: '2026-06-16',
},
}),
} as Response;
}
type RuntimeProjectionOverrides = Omit<
Partial<StoryRuntimeProjectionResponse>,
'storySession'
> & {
storySession?: Partial<StoryRuntimeProjectionResponse['storySession']>;
};
function createRuntimeProjection(
overrides: RuntimeProjectionOverrides = {},
): StoryRuntimeProjectionResponse {
const storySession = {
storySessionId: 'storysess-main',
runtimeSessionId: 'runtime-main',
actorUserId: 'user-main',
worldProfileId: 'profile-main',
initialPrompt: '进入山路',
openingSummary: null,
latestNarrativeText: '山路尽头传来新的动静。',
latestChoiceFunctionId: null,
status: 'active',
version: 3,
createdAt: '2026-04-08T00:00:00.000Z',
updatedAt: '2026-04-08T00:00:01.000Z',
...(overrides.storySession ?? {}),
} satisfies StoryRuntimeProjectionResponse['storySession'];
return {
storySession,
storyEvents: overrides.storyEvents ?? [],
serverVersion: overrides.serverVersion ?? storySession.version,
gameState: {
runtimeSessionId: storySession.runtimeSessionId,
storySessionId: storySession.storySessionId,
runtimeActionVersion: overrides.serverVersion ?? storySession.version,
currentScene: 'Story',
playerEquipment: { weapon: null, armor: null, relic: null },
...(overrides.gameState ?? {}),
},
actor: overrides.actor ?? {
hp: 30,
maxHp: 40,
mana: 12,
maxMana: 20,
currency: 0,
currencyText: '0 铜钱',
},
inventory: overrides.inventory ?? {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
options: overrides.options ?? [],
status: overrides.status ?? {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
currentNarrativeText:
overrides.currentNarrativeText ?? storySession.latestNarrativeText,
actionResultText: overrides.actionResultText ?? null,
toast: overrides.toast ?? null,
};
}
function createSseResponse(text: string) {
const encoder = new TextEncoder();
const chunks = [
encoder.encode(
`data: ${JSON.stringify({
choices: [{ delta: { content: text } }],
})}\n\n`,
),
];
let index = 0;
return {
ok: true,
status: 200,
headers: new Headers(),
body: {
getReader() {
return {
async read() {
if (index >= chunks.length) {
return { done: true, value: undefined };
}
const value = chunks[index];
index += 1;
return { done: false, value };
},
};
},
},
text: async () => '',
} as Response;
}
function createNpcChatTurnSseResponse(reply: string) {
const encoder = new TextEncoder();
const completePayload = {
npcReply: reply,
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
suggestions: [],
functionSuggestions: [],
pendingQuestOffer: null,
chatDirective: null,
};
const chunks = [
encoder.encode(
`event: reply_delta\ndata: ${JSON.stringify({ text: reply })}\n\n`,
),
encoder.encode(
`event: complete\ndata: ${JSON.stringify(completePayload)}\n\n`,
),
encoder.encode('data: [DONE]\n\n'),
];
let index = 0;
return {
ok: true,
status: 200,
headers: new Headers(),
body: {
getReader() {
return {
async read() {
if (index >= chunks.length) {
return { done: true, value: undefined };
}
const value = chunks[index];
index += 1;
return { done: false, value };
},
};
},
},
text: async () => '',
} as Response;
}
describe('ai runtime client orchestration', () => {
const playerCharacter = createCharacter();
const targetCharacter = createCharacter({
id: 'ally',
name: 'Lan',
title: 'Scout',
personality: 'Dry, practical, and quietly protective.',
});
const context = createContext();
const transientSnapshot: NonNullable<
StoryGenerationContext['runtimeSnapshot']
> = {
bottomTab: 'adventure',
gameState: {
worldType: WorldType.WUXIA,
runtimeSessionId: 'runtime-preview',
runtimePersistenceDisabled: true,
} as NonNullable<StoryGenerationContext['runtimeSnapshot']>['gameState'],
currentStory: null,
};
const targetStatus = createTargetStatus();
const monsters: SceneHostileNpc[] = [];
const storyHistory: StoryMoment[] = [];
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
requestChatMessageContentMock.mockReset();
requestPlainTextCompletionMock.mockReset();
streamPlainTextCompletionMock.mockReset();
});
it('requests initial story from the story session projection', async () => {
const availableOptions = [createStoryOption()];
fetchMock.mockResolvedValue(
createApiEnvelopeResponse(
createRuntimeProjection({
options: availableOptions.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
detailText: null,
scope: 'story',
payload: null,
enabled: true,
reason: null,
})),
currentNarrativeText: '山路尽头传来新的动静。',
storySession: {
latestNarrativeText: '山路尽头传来新的动静。',
},
}),
),
);
const response = await generateInitialStory(
WorldType.WUXIA,
playerCharacter,
monsters,
context,
{ availableOptions },
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/story/sessions/storysess-main/runtime-projection',
expect.objectContaining({
method: 'GET',
}),
);
expect(response.storyText).toBe('山路尽头传来新的动静。');
expect(response.options).toEqual(availableOptions);
});
it('requests next story step from the story session action endpoint', async () => {
const availableOptions = [
createStoryOption({
functionId: 'idle_explore_forward',
actionText: '继续沿山道探路。',
text: '继续沿山道探路。',
}),
];
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
projection: createRuntimeProjection({
serverVersion: 4,
currentNarrativeText: '林间重新安静下来,你听见远处的风声。',
storySession: {
latestNarrativeText: '林间重新安静下来,你听见远处的风声。',
latestChoiceFunctionId: 'idle_explore_forward',
version: 4,
},
options: availableOptions.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
detailText: null,
scope: 'story',
payload: null,
enabled: true,
reason: null,
})),
}),
}),
);
const response = await generateNextStep(
WorldType.WUXIA,
playerCharacter,
monsters,
storyHistory,
'继续向前',
{
...context,
lastFunctionId: 'idle_explore_forward',
},
{ availableOptions },
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/story/sessions/storysess-main/actions/resolve',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
storySessionId: 'storysess-main',
clientVersion: 3,
functionId: 'idle_explore_forward',
actionText: '继续向前',
payload: {
optionText: '继续向前',
observeSignsRequested: false,
recentActionResult: null,
},
}),
}),
);
expect(response.storyText).toBe('林间重新安静下来,你听见远处的风声。');
expect(response.options).toEqual(availableOptions);
});
it('requests character chat suggestions from the runtime api server', async () => {
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
text: '先说你真正担心的事。\n这件事你还瞒了我什么?\n先别急,我们慢慢说。',
}),
);
const suggestions = await generateCharacterPanelChatSuggestions(
WorldType.WUXIA,
playerCharacter,
targetCharacter,
storyHistory,
context,
[],
'',
targetStatus,
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/character/suggestions',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
targetCharacter,
conversationHistory: [],
conversationSummary: '',
targetStatus,
}),
}),
);
expect(suggestions).toEqual([
'先说你真正担心的事。',
'这件事你还瞒了我什么?',
'先别急,我们慢慢说。',
]);
});
it('streams character chat reply from the runtime api server', async () => {
const onUpdate = vi.fn();
const playerMessage = 'Tell me what you are really worried about.';
const conversationSummary = 'Lan has started to trust the player more.';
fetchMock.mockResolvedValue(
createSseResponse('我会认真回答你,但这件事没你想得那么简单。'),
);
const reply = await streamCharacterPanelChatReply(
WorldType.WUXIA,
playerCharacter,
targetCharacter,
storyHistory,
context,
[],
conversationSummary,
playerMessage,
targetStatus,
{ onUpdate },
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/character/reply/stream',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
targetCharacter,
conversationHistory: [],
conversationSummary,
playerMessage,
targetStatus,
}),
}),
);
expect(reply).toBe('我会认真回答你,但这件事没你想得那么简单。');
expect(onUpdate).toHaveBeenCalledOnce();
expect(onUpdate).toHaveBeenCalledWith(
'我会认真回答你,但这件事没你想得那么简单。',
);
});
it('attaches transient snapshot to session based chat requests only when provided', async () => {
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
text: '先确认眼下的局势。\n问清对方的真实目的。\n保持距离继续观察。',
}),
);
await generateCharacterPanelChatSuggestions(
WorldType.WUXIA,
playerCharacter,
targetCharacter,
storyHistory,
createContext({
runtimeSessionId: 'runtime-preview',
runtimeSnapshot: transientSnapshot,
}),
[],
'',
targetStatus,
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/character/suggestions',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-preview',
snapshot: transientSnapshot,
targetCharacter,
conversationHistory: [],
conversationSummary: '',
targetStatus,
}),
}),
);
});
it('attaches transient snapshot to npc chat turn session requests', async () => {
const encounter = createEncounter();
fetchMock.mockResolvedValue(
createNpcChatTurnSseResponse('先把眼前的事说清楚。'),
);
const result = await streamNpcChatTurn(
WorldType.WUXIA,
playerCharacter,
encounter,
monsters,
storyHistory,
createContext({
runtimeSessionId: 'runtime-preview',
runtimeSnapshot: transientSnapshot,
}),
[],
'你刚才看见了什么?',
{ chattedCount: 0 },
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/npc/turn/stream',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-preview',
snapshot: transientSnapshot,
encounter,
conversationHistory: [],
dialogue: [],
playerMessage: '你刚才看见了什么?',
npcState: { chattedCount: 0 },
npcInitiatesConversation: false,
questOfferContext: null,
combatContext: null,
chatDirective: null,
}),
}),
);
expect(result.npcReply).toBe('先把眼前的事说清楚。');
});
it('streams npc recruit dialogue from the runtime api server', async () => {
const onUpdate = vi.fn();
const encounter = createEncounter();
fetchMock.mockResolvedValue(
createSseResponse('你:和我一起走下去吧。\nLan:好,我答应你。'),
);
const reply = await streamNpcRecruitDialogue(
WorldType.WUXIA,
playerCharacter,
encounter,
monsters,
storyHistory,
context,
'Join us.',
'The party is ready to travel together.',
{ onUpdate },
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/npc/recruit/stream',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
encounter,
invitationText: 'Join us.',
recruitSummary: 'The party is ready to travel together.',
}),
}),
);
expect(reply).toBe('你:和我一起走下去吧。\nLan:好,我答应你。');
expect(onUpdate).toHaveBeenCalledOnce();
expect(onUpdate).toHaveBeenCalledWith(
'你:和我一起走下去吧。\nLan:好,我答应你。',
);
});
it('rejects custom world output when the model does not generate enough NPCs and scenes', async () => {
requestPlainTextCompletionMock.mockResolvedValue(
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 10 generated scenes|至少产出 10 个场景|至少需要 10 个场景/i,
);
});
it('keeps the generated custom world dossier item-free when the model output is valid', async () => {
requestPlainTextCompletionMock.mockResolvedValue(
JSON.stringify(
createCustomWorldResponse({
items: [
{
name: '不应保留的物品',
category: '材料',
rarity: 'rare',
description: '这个字段应该被清空',
tags: ['测试'],
},
],
}),
),
);
const profile =
await generateCustomWorldProfile('一个需要很多角色和场景的世界');
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).not.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('reports staged progress while generating a custom world', async () => {
requestPlainTextCompletionMock.mockResolvedValue(
JSON.stringify(createCustomWorldResponse()),
);
const onProgress = vi.fn();
await generateCustomWorldProfile('一个需要展示真实进度的世界', {
onProgress,
});
const phaseIds = onProgress.mock.calls.map(
(call) =>
(call[0] as { phaseId?: string; overallProgress?: number }).phaseId,
);
const lastProgress = onProgress.mock.calls.at(-1)?.[0] as
| { overallProgress?: number; estimatedRemainingMs?: number | null }
| undefined;
expect(phaseIds).toContain('framework');
expect(phaseIds).toContain('playable-outline');
expect(phaseIds).toContain('story-outline');
expect(phaseIds).toContain('landmark-seed');
expect(phaseIds).not.toContain('landmark-network');
expect(phaseIds).toContain('playable-narrative');
expect(phaseIds).toContain('playable-dossier');
expect(phaseIds).toContain('story-narrative');
expect(phaseIds).toContain('story-dossier');
expect(phaseIds).toContain('finalize');
expect(lastProgress?.overallProgress).toBe(100);
expect(lastProgress?.estimatedRemainingMs).toBe(0);
});
it('passes abort signals through custom world generation and rejects when interrupted', async () => {
requestPlainTextCompletionMock.mockImplementation(
(_system: string, _user: string, options?: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
options?.signal?.addEventListener(
'abort',
() =>
reject(options.signal?.reason ?? new Error('世界生成已中断。')),
{ once: true },
);
}),
);
const abortController = new AbortController();
const generation = generateCustomWorldProfile('一个会被中断的世界', {
signal: abortController.signal,
});
abortController.abort(new Error('手动中断生成'));
await expect(generation).rejects.toThrow('手动中断生成');
expect(requestPlainTextCompletionMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.objectContaining({
signal: abortController.signal,
}),
);
});
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('attaches creator intent and anchor pack when generating from creator cards', async () => {
requestPlainTextCompletionMock.mockResolvedValue(
JSON.stringify(
createCustomWorldResponse({
name: '锚点世界',
}),
),
);
const profile = await generateCustomWorldProfile({
settingText: '世界一句话:一个被灵潮反复改写地形的边境世界。',
creatorIntent: {
sourceMode: 'card',
rawSettingText: '',
worldHook: '一个被灵潮反复改写地形的边境世界。',
themeKeywords: ['边境', '灵潮'],
toneDirectives: ['紧张', '潮湿'],
playerPremise: '玩家是前巡夜人。',
openingSituation: '刚进城就卷入旧案。',
coreConflicts: ['旧案名单再次出现'],
keyFactions: [],
keyCharacters: [
{
id: 'creator-character-1',
name: '沈砺',
role: '灰炬向导',
publicMask: '看起来只是个带路人',
hiddenHook: '一直在查旧撤离线',
relationToPlayer: '会先怀疑玩家身份',
notes: '',
locked: true,
},
],
keyLandmarks: [],
iconicElements: ['裂潮灯塔'],
forbiddenDirectives: ['不要出现现代枪械'],
},
});
expect(profile.name).toBe('锚点世界');
expect(profile.creatorIntent?.sourceMode).toBe('card');
expect(profile.creatorIntent?.keyCharacters[0]?.name).toBe('沈砺');
expect(profile.anchorPack?.keyCharacterAnchors[0]?.name).toBe('沈砺');
expect(profile.anchorPack?.lockedAnchorIds).toContain(
'creator-character-1',
);
});
it('generates a custom world scene image through the local proxy and returns the saved asset path', async () => {
fetchMock.mockResolvedValue({
ok: true,
text: async () =>
JSON.stringify({
ok: true,
data: {
ok: true,
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
assetId: 'custom-scene-1',
model: 'wan2.7-image',
size: '1280*720',
taskId: 'task-123',
prompt: '系统整理后的提示词',
actualPrompt: '扩写后的提示词',
},
error: null,
meta: {
apiVersion: '2026-06-16',
},
}),
} as Response);
const result = await generateCustomWorldSceneImage({
profile: {
id: 'custom-world-1',
name: '测试世界',
subtitle: '副标题',
summary: '世界概述',
tone: '世界基调',
playerGoal: '核心目标',
settingText: '原始设定',
},
landmark: {
id: 'landmark-1',
name: '雾潮码头',
description: '被潮雾与旧升降机包围的码头。',
},
userPrompt: '雨夜的栈桥横跨黑色海沟,塔楼灯火被潮雾吞没。',
size: '1280*720',
referenceImageSrc: '/scene_bg/reference-layout.png',
});
const sceneImageCalls = fetchMock.mock.calls.filter(
([url]) => url === '/api/runtime/custom-world/scene-image',
);
expect(sceneImageCalls).toHaveLength(1);
expect(sceneImageCalls[0]).toEqual([
'/api/runtime/custom-world/scene-image',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
}),
]);
const [, request] = sceneImageCalls[0] as [string, RequestInit];
const requestBody = JSON.parse(String(request.body)) as {
userPrompt: string;
referenceImageSrc?: string;
};
expect(requestBody.referenceImageSrc).toBe(
'/scene_bg/reference-layout.png',
);
expect(requestBody.userPrompt).toContain('雨夜的栈桥横跨黑色海沟');
expect(result).toEqual({
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
assetId: 'custom-scene-1',
model: 'wan2.7-image',
size: '1280*720',
taskId: 'task-123',
prompt: '系统整理后的提示词',
actualPrompt: '扩写后的提示词',
});
});
it('surfaces proxy error messages when scene image generation fails', async () => {
fetchMock.mockResolvedValue({
ok: false,
text: async () =>
JSON.stringify({
error: {
message: 'DashScope API key 无效。',
},
}),
} as Response);
await expect(
generateCustomWorldSceneImage({
profile: {
id: 'custom-world-1',
name: '测试世界',
subtitle: '副标题',
summary: '世界概述',
tone: '世界基调',
playerGoal: '核心目标',
settingText: '原始设定',
},
landmark: {
id: 'landmark-1',
name: '雾潮码头',
description: '被潮雾与旧升降机包围的码头。',
},
}),
).rejects.toThrow('DashScope API key 无效。');
});
});