This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -45,13 +45,8 @@ import {
streamCharacterPanelChatReply,
streamNpcRecruitDialogue,
} from './ai';
import {
buildOfflineCharacterPanelChatReply,
buildOfflineCharacterPanelChatSuggestions,
buildOfflineNpcRecruitDialogue,
} from './aiFallbacks';
import type { StoryGenerationContext } from './aiTypes';
import type { CharacterChatTargetStatus } from './characterChatPrompt';
import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
const [
BACKSTORY_UNLOCK_AFFINITY_EASED,
@@ -105,6 +100,8 @@ function createContext(
overrides: Partial<StoryGenerationContext> = {},
): StoryGenerationContext {
return {
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 3,
playerHp: 30,
playerMaxHp: 40,
playerMana: 12,
@@ -410,7 +407,57 @@ function createCustomWorldResponse(
};
}
describe('ai orchestration fallbacks', () => {
function createApiEnvelopeResponse(data: unknown) {
return {
ok: true,
status: 200,
headers: new Headers(),
text: async () =>
JSON.stringify({
ok: true,
data,
error: null,
meta: {
apiVersion: '2026-04-08',
},
}),
} as Response;
}
function createSseResponse(text: string) {
const encoder = new TextEncoder();
const chunks = [
encoder.encode(
`data: ${JSON.stringify({
choices: [{ delta: { content: text } }],
})}\n\n`,
),
];
let index = 0;
return {
ok: true,
status: 200,
headers: new Headers(),
body: {
getReader() {
return {
async read() {
if (index >= chunks.length) {
return { done: true, value: undefined };
}
const value = chunks[index];
index += 1;
return { done: false, value };
},
};
},
},
text: async () => '',
} as Response;
}
describe('ai runtime client orchestration', () => {
const playerCharacter = createCharacter();
const targetCharacter = createCharacter({
id: 'ally',
@@ -431,9 +478,15 @@ describe('ai orchestration fallbacks', () => {
streamPlainTextCompletionMock.mockReset();
});
it('falls back to the offline story response when story generation loses connectivity', async () => {
it('requests initial story from the runtime api server', async () => {
const availableOptions = [createStoryOption()];
requestChatMessageContentMock.mockRejectedValue(connectivityError);
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
storyText: '山路尽头传来新的动静。',
options: availableOptions,
encounter: null,
}),
);
const response = await generateInitialStory(
WorldType.WUXIA,
@@ -443,12 +496,22 @@ describe('ai orchestration fallbacks', () => {
{ availableOptions },
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/story/initial',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 3,
requestOptions: { availableOptions },
}),
}),
);
expect(response.storyText).toBe('山路尽头传来新的动静。');
expect(response.options).toEqual(availableOptions);
expect(response.options).not.toBe(availableOptions);
expect(response.storyText.length).toBeGreaterThan(0);
});
it('repairs mixed-language story text before returning the story response', async () => {
it('requests next story step from the runtime api server', async () => {
const availableOptions = [
createStoryOption({
functionId: 'idle_explore_forward',
@@ -456,117 +519,46 @@ describe('ai orchestration fallbacks', () => {
text: '继续沿山道探路。',
}),
];
requestChatMessageContentMock
.mockResolvedValueOnce(
JSON.stringify({
storyText: 'The forest is quiet. 你听见远处的风声。',
encounter: null,
options: [
{
functionId: 'idle_explore_forward',
actionText: 'Move forward carefully.',
},
],
}),
)
.mockResolvedValueOnce(
JSON.stringify({
storyText: '林间重新安静下来,你听见远处的风声。',
encounter: null,
options: [
{
functionId: 'idle_explore_forward',
actionText: '继续沿山道探路。',
},
],
}),
);
const response = await generateInitialStory(
WorldType.WUXIA,
playerCharacter,
monsters,
context,
{ availableOptions },
);
expect(response.storyText).toBe('林间重新安静下来,你听见远处的风声。');
expect(response.options[0]?.actionText).toBe('继续沿山道探路。');
expect(requestChatMessageContentMock).toHaveBeenCalledTimes(2);
expect(requestChatMessageContentMock.mock.calls[1]?.[2]).toEqual(
expect.objectContaining({
debugLabel: 'story-language-repair',
}),
);
});
it('ignores generated encounter payloads during post-battle continuations when no new scene encounter is pending', async () => {
const availableOptions = [
createStoryOption({
functionId: 'idle_explore_forward',
actionText: '先稳住呼吸,再看看前面的动静。',
text: '先稳住呼吸,再看看前面的动静。',
}),
];
const sceneWithNpc = getScenePresetsByWorld(WorldType.WUXIA).find(
(scene) => (scene.npcs?.length ?? 0) > 0,
);
const targetNpcId = sceneWithNpc?.npcs?.[0]?.id;
if (!sceneWithNpc || !targetNpcId) {
throw new Error('Expected a wuxia scene with at least one npc preset.');
}
requestChatMessageContentMock.mockResolvedValue(
JSON.stringify({
storyText: '山道总算安静下来,你收住气息,重新判断前路。',
encounter: {
kind: 'npc',
npcId: targetNpcId,
},
options: [
{
functionId: 'idle_explore_forward',
actionText: '先稳住呼吸,再看看前面的动静。',
},
],
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
storyText: '林间重新安静下来,你听见远处的风声。',
encounter: null,
options: availableOptions,
}),
);
const response = await generateNextStep(
WorldType.WUXIA,
playerCharacter,
[],
[
{
text: '挥刀抢攻',
options: [],
historyRole: 'action',
},
{
text: '山道客已经败下阵来。',
options: [],
historyRole: 'result',
},
],
'挥刀抢攻',
createContext({
sceneId: sceneWithNpc.id,
sceneName: sceneWithNpc.name,
sceneDescription: sceneWithNpc.description,
pendingSceneEncounter: false,
}),
monsters,
storyHistory,
'继续向前',
context,
{ availableOptions },
);
expect(response.encounter).toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/story/continue',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 3,
choice: '继续向前',
requestOptions: { availableOptions },
}),
}),
);
expect(response.storyText).toBe('林间重新安静下来,你听见远处的风声。');
expect(response.options).toEqual(availableOptions);
const userPrompt = requestChatMessageContentMock.mock.calls.at(-1)?.[1];
expect(userPrompt).toContain('encounter 必须为 null');
expect(userPrompt).toContain('战斗结束后的续写');
});
it('returns offline character chat suggestions when the plain-text client reports connectivity errors', async () => {
requestPlainTextCompletionMock.mockRejectedValue(connectivityError);
it('requests character chat suggestions from the runtime api server', async () => {
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
text: '先说你真正担心的事。\n这件事你还瞒了我什么\n先别急我们慢慢说。',
}),
);
const suggestions = await generateCharacterPanelChatSuggestions(
WorldType.WUXIA,
@@ -579,21 +571,33 @@ describe('ai orchestration fallbacks', () => {
targetStatus,
);
expect(suggestions).toEqual(
buildOfflineCharacterPanelChatSuggestions(targetCharacter),
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/character/suggestions',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
targetCharacter,
conversationHistory: [],
conversationSummary: '',
targetStatus,
}),
}),
);
expect(suggestions).toEqual([
'先说你真正担心的事。',
'这件事你还瞒了我什么?',
'先别急,我们慢慢说。',
]);
});
it('streams the offline character chat reply and forwards it to onUpdate when connectivity fails', async () => {
it('streams character chat reply from the runtime api server', async () => {
const onUpdate = vi.fn();
const playerMessage = 'Tell me what you are really worried about.';
const conversationSummary = 'Lan has started to trust the player more.';
const fallbackReply = buildOfflineCharacterPanelChatReply(
targetCharacter,
playerMessage,
conversationSummary,
fetchMock.mockResolvedValue(
createSseResponse('我会认真回答你,但这件事没你想得那么简单。'),
);
streamPlainTextCompletionMock.mockRejectedValue(connectivityError);
const reply = await streamCharacterPanelChatReply(
WorldType.WUXIA,
@@ -608,16 +612,33 @@ describe('ai orchestration fallbacks', () => {
{ onUpdate },
);
expect(reply).toBe(fallbackReply);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/character/reply/stream',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
targetCharacter,
conversationHistory: [],
conversationSummary,
playerMessage,
targetStatus,
}),
}),
);
expect(reply).toBe('我会认真回答你,但这件事没你想得那么简单。');
expect(onUpdate).toHaveBeenCalledOnce();
expect(onUpdate).toHaveBeenCalledWith(fallbackReply);
expect(onUpdate).toHaveBeenCalledWith(
'我会认真回答你,但这件事没你想得那么简单。',
);
});
it('uses the extracted NPC recruit fallback when recruit dialogue streaming loses connectivity', async () => {
it('streams npc recruit dialogue from the runtime api server', async () => {
const onUpdate = vi.fn();
const encounter = createEncounter();
const fallbackReply = buildOfflineNpcRecruitDialogue(encounter);
streamPlainTextCompletionMock.mockRejectedValue(connectivityError);
fetchMock.mockResolvedValue(
createSseResponse('你:和我一起走下去吧。\nLan我答应你。'),
);
const reply = await streamNpcRecruitDialogue(
WorldType.WUXIA,
@@ -631,9 +652,23 @@ describe('ai orchestration fallbacks', () => {
{ onUpdate },
);
expect(reply).toBe(fallbackReply);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/npc/recruit/stream',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
encounter,
invitationText: 'Join us.',
recruitSummary: 'The party is ready to travel together.',
}),
}),
);
expect(reply).toBe('你:和我一起走下去吧。\nLan我答应你。');
expect(onUpdate).toHaveBeenCalledOnce();
expect(onUpdate).toHaveBeenCalledWith(fallbackReply);
expect(onUpdate).toHaveBeenCalledWith(
'你:和我一起走下去吧。\nLan我答应你。',
);
});
it('rejects custom world output when the model does not generate enough NPCs and scenes', async () => {