Files
Genarrative/server-node/src/modules/ai/orchestrator.test.ts
高物 50759f3c1e
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-20 09:54:17 +08:00

474 lines
15 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,
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<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'));
});
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);
});