194 lines
5.9 KiB
TypeScript
194 lines
5.9 KiB
TypeScript
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'));
|
||
});
|