1
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user