474 lines
15 KiB
TypeScript
474 lines
15 KiB
TypeScript
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);
|
||
});
|