@@ -3,10 +3,12 @@ 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 {
|
||||
@@ -195,6 +197,179 @@ test('chat orchestrator builds character suggestion prompts on the server side',
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user