import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels'; import { getScenePresetsByWorld } from '../data/scenePresets'; import type { Character, Encounter, SceneHostileNpc, StoryMoment, StoryOption, } from '../types'; import { AnimationState, WorldType } from '../types'; import type { StoryRuntimeProjectionResponse } from '../../packages/shared/src/contracts/story'; 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 { 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 { 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 { 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 { return { roleLabel: 'Companion', hp: 18, maxHp: 20, mana: 9, maxMana: 12, ...overrides, }; } function createEncounter(overrides: Partial = {}): 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[]; storyNpcs: ReturnType[]; landmarks: ReturnType[]; items: Array>; }> = {}, ) { 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-04-08', }, }), } as Response; } type RuntimeProjectionOverrides = Omit< Partial, 'storySession' > & { storySession?: Partial; }; 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['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-04-08', }, }), } 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 无效。'); }); });