1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 09:54:17 +08:00
parent 67c584b4df
commit 50759f3c1e
159 changed files with 16938 additions and 16925 deletions

View File

@@ -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(