import assert from 'node:assert/strict'; import test from 'node:test'; import type { CharacterChatSuggestionsRequest, NpcChatTurnRequest, } from '../../../../packages/shared/src/contracts/story.js'; import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js'; import { generateCharacterChatSuggestionsFromOrchestrator, streamNpcChatTurnFromOrchestrator, } 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[4]; type TestStoryOption = Awaited< ReturnType >['options'][number]; const TEST_WORLD = 'WUXIA' as Parameters< typeof generateInitialStoryFromOrchestrator >[1]; type TestCharacter = Parameters[2]; function createTestCharacter(overrides: Partial = {}) { return { ...createTestPlayerCharacter(), ...overrides, }; } function createStoryContext(): TestStoryContext { return { playerHp: 120, playerMaxHp: 120, playerMana: 40, playerMaxMana: 40, inBattle: false, playerX: 320, playerFacing: 'right', playerAnimation: 'idle', skillCooldowns: {}, sceneId: 'inn_room', sceneName: '客栈内室', sceneDescription: '昏黄灯火照着刚刚停下脚步的木桌。', pendingSceneEncounter: false, }; } function createAvailableOptions(context: TestStoryContext) { void context; return [ { functionId: 'idle_explore_forward', actionText: '继续向前探索前路', text: '继续向前探索前路', visuals: { playerAnimation: 'idle', playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, }, { functionId: 'idle_observe_signs', actionText: '停步观察附近的风吹草动', text: '停步观察附近的风吹草动', visuals: { playerAnimation: 'idle', playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, }, ] as TestStoryOption[]; } test('story orchestrator repairs mixed-language narrative on the server side', async () => { const context = createStoryContext(); const availableOptions = createAvailableOptions(context); const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = []; const llmClient = { requestMessageContent: async ({ systemPrompt, userPrompt, }: { systemPrompt: string; userPrompt: string; }) => { capturedPrompts.push({ systemPrompt, userPrompt }); if (capturedPrompts.length === 1) { return JSON.stringify({ storyText: 'The room falls quiet for a moment.', encounter: null, options: availableOptions.map((option) => ({ functionId: option.functionId, actionText: option.actionText, })), }); } return JSON.stringify({ storyText: '房间里短暂安静了一瞬,你能听见灯火轻轻噼啪作响。', encounter: null, options: availableOptions.map((option) => ({ functionId: option.functionId, actionText: option.actionText, })), }); }, } as const; const response = await generateInitialStoryFromOrchestrator( llmClient as never, TEST_WORLD, createTestCharacter(), [], context, { availableOptions, }, ); assert.equal(capturedPrompts.length, 2); assert.equal(capturedPrompts[0]?.systemPrompt, SYSTEM_PROMPT); assert.match(capturedPrompts[0]?.userPrompt ?? '', /客栈内室/u); assert.equal( response.storyText, '房间里短暂安静了一瞬,你能听见灯火轻轻噼啪作响。', ); assert.deepEqual( response.options.map((option) => option.functionId), availableOptions.map((option) => option.functionId), ); }); test('chat orchestrator builds character suggestion prompts on the server side', async () => { const payload = { worldType: TEST_WORLD, playerCharacter: createTestCharacter(), targetCharacter: createTestCharacter({ id: 'test-companion', name: '测试同伴', title: '听风客', }), storyHistory: [], context: createStoryContext(), conversationHistory: [ { speaker: 'player', text: '刚才那阵风是不是也不太对劲?' }, { speaker: 'character', text: '像是有人故意把门帘掀起来了一样。' }, ], conversationSummary: '两人刚在客栈里察觉到不寻常的动静。', targetStatus: { roleLabel: '同行角色', hp: 95, maxHp: 120, mana: 28, maxMana: 40, affinity: 18, }, } satisfies CharacterChatSuggestionsRequest; const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = []; const llmClient = { requestMessageContent: async ({ systemPrompt, userPrompt, }: { systemPrompt: string; userPrompt: string; }) => { capturedPrompts.push({ systemPrompt, userPrompt }); return '先别急,我们再听一轮。\n你刚才看见谁动门帘了吗?\n要不我先去门边探一眼。'; }, } as const; const text = await generateCharacterChatSuggestionsFromOrchestrator( llmClient as never, payload, ); assert.equal(text.split('\n').length, 3); assert.equal( capturedPrompts[0]?.systemPrompt, CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, ); assert.match(capturedPrompts[0]?.userPrompt ?? '', /客栈/u); assert.match(capturedPrompts[0]?.userPrompt ?? '', /两人刚在客栈里察觉到不寻常的动静/u); assert.match(capturedPrompts[0]?.userPrompt ?? '', new RegExp(payload.targetCharacter.name, 'u')); }); test('chat orchestrator returns pending npc quest offers from the server side', async () => { const encounter = { kind: 'npc', id: 'npc_scout_01', npcName: '巡路人', npcDescription: '熟悉桥口风向的探子', context: '巡路人', characterId: 'scout-quest', } as const; const requestPayload = { worldType: TEST_WORLD, character: createTestCharacter(), player: createTestCharacter(), encounter, monsters: [], history: [], context: createStoryContext(), conversationHistory: [ { speaker: 'player', text: '你像是还有别的话想说。' }, { speaker: 'npc', text: '这地方最近确实不太平。' }, ], dialogue: [ { speaker: 'player', text: '你像是还有别的话想说。' }, { speaker: 'npc', text: '这地方最近确实不太平。' }, ], playerMessage: '如果你愿意,我可以继续追下去。', npcState: { affinity: 28, chattedCount: 1, }, questOfferContext: { turnCount: 2, encounter, state: { worldType: TEST_WORLD, currentScenePreset: { id: 'quest-bridge', name: '断桥口', description: '桥口被风和旧账压得很紧。', npcs: [ { id: 'npc_bandit_01', name: '断桥匪首', hostile: true, monsterPresetId: 'npc_bandit_01', }, ], treasureHints: [], }, currentEncounter: encounter, storyHistory: [], customWorldProfile: null, storyEngineMemory: null, playerCharacter: createTestCharacter(), playerHp: 32, playerMaxHp: 40, playerMana: 12, playerMaxMana: 16, playerInventory: [], playerEquipment: { weapon: null, armor: null, relic: null, }, companions: [], roster: [], quests: [], npcStates: { npc_scout_01: { affinity: 28, chattedCount: 1, helpUsed: false, giftsGiven: 0, inventory: [], recruited: false, }, }, }, }, } satisfies NpcChatTurnRequest; const responseChunks: string[] = []; let requestCount = 0; const llmClient = { streamMessageContent: async ({ onUpdate, }: { onUpdate?: (text: string) => void; }) => { const reply = '如果你真愿意追查,我这里确实有件事想托给你。'; onUpdate?.(reply); return reply; }, requestMessageContent: async () => { requestCount += 1; if (requestCount === 1) { return '这件事最早是从什么时候开始的\n桥口最近到底少了什么人\n你想让我先盯哪条线'; } return JSON.stringify({ intent: { title: '断桥巡线', description: '巡路人希望你去断桥口查清最近被人故意压下的风声。', summary: '去断桥口查清最近被人故意压下的风声。', narrativeType: 'investigation', dramaticNeed: '有人在桥口刻意遮掩痕迹。', issuerGoal: '确认是谁在桥口截断消息。', playerHook: '你可以顺着桥口的异常把事情继续追下去。', worldReason: '桥口异动会影响接下来整段路的安全。', recommendedObjectiveKinds: ['defeat_hostile_npc', 'talk_to_npc'], urgency: 'medium', intimacy: 'cooperative', rewardTheme: 'currency', followupHooks: ['巡路人还藏着半句没说完的话。'], }, }); }, } as const; const request = { method: 'POST', originalUrl: '/api/runtime/chat/npc/turn/stream', requestId: 'test-request', requestStartedAt: Date.now(), header: () => '', on: () => request, } as never; const response = { locals: {}, statusCode: 200, setHeader: () => undefined, status(code: number) { this.statusCode = code; return this; }, write(chunk: string) { responseChunks.push(chunk); return true; }, end(chunk?: string) { if (chunk) { responseChunks.push(chunk); } return this; }, } as never; await streamNpcChatTurnFromOrchestrator(llmClient as never, { request, response, payload: requestPayload, }); const eventText = responseChunks.join(''); const completeBlock = eventText .split('\n\n') .find((block) => block.includes('event: complete')); assert.ok(completeBlock); const completeLine = completeBlock ?.split('\n') .find((line) => line.startsWith('data:')); assert.ok(completeLine); const payload = JSON.parse(completeLine!.slice(5).trim()) as { pendingQuestOffer?: { quest?: { issuerNpcId?: string; }; introText?: string; } | null; }; assert.equal(payload.pendingQuestOffer?.quest?.issuerNpcId, 'npc_scout_01'); assert.match(payload.pendingQuestOffer?.introText ?? '', /正式交给你/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); });