Files
Genarrative/server-node/src/modules/ai/orchestrator.test.ts
2026-04-10 15:37:02 +08:00

194 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
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 { generateInitialStoryFromOrchestrator } from './storyOrchestrator.js';
type TestStoryContext = Parameters<typeof generateInitialStoryFromOrchestrator>[4];
type TestStoryOption = Awaited<
ReturnType<typeof generateInitialStoryFromOrchestrator>
>['options'][number];
const TEST_WORLD = 'WUXIA' as Parameters<
typeof generateInitialStoryFromOrchestrator
>[1];
type TestCharacter = Parameters<typeof generateInitialStoryFromOrchestrator>[2];
function createTestCharacter(overrides: Partial<TestCharacter> = {}) {
return {
...createTestPlayerCharacter<TestCharacter>(),
...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'));
});