Files
Genarrative/src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts
2026-05-05 14:40:41 +08:00

770 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
vi.mock('../apiClient', async () => {
const actual =
await vi.importActual<typeof import('../apiClient')>('../apiClient');
return {
...actual,
requestJson: requestJsonMock,
};
});
import { AnimationState } from '../../types';
import type { StoryRuntimeProjectionResponse } from '../../../packages/shared/src/contracts/story';
import {
beginStorySession,
beginRuntimeStorySession,
buildStoryMomentFromRuntimeOptions,
continueStorySession,
getStorySessionState,
getRuntimeClientVersion,
getRuntimeSessionId,
getRuntimeStorySessionId,
getRuntimeStoryState,
isServerRuntimeFunctionId,
isTask5RuntimeFunctionId,
loadRuntimeInventoryView,
buildStoryMomentFromRuntimeProjection,
resolveRuntimeStoryAction,
resolveRuntimeStoryMoment,
shouldUseServerRuntimeOptions,
} from './rpgRuntimeStoryClient';
type RuntimeProjectionOverrides = Omit<
Partial<StoryRuntimeProjectionResponse>,
'storySession'
> & {
storySession?: Partial<StoryRuntimeProjectionResponse['storySession']>;
};
function createStorySession(
overrides: Partial<StoryRuntimeProjectionResponse['storySession']> = {},
): StoryRuntimeProjectionResponse['storySession'] {
return {
storySessionId: 'storysess-main',
runtimeSessionId: 'runtime-main',
actorUserId: 'user-1',
worldProfileId: 'profile-1',
initialPrompt: '进入营地',
openingSummary: null,
latestNarrativeText: '服务端故事',
latestChoiceFunctionId: null,
status: 'active',
version: 1,
createdAt: '2026-04-08T00:00:00.000Z',
updatedAt: '2026-04-08T00:00:01.000Z',
...overrides,
};
}
function createRuntimeProjection(
overrides: RuntimeProjectionOverrides = {},
): StoryRuntimeProjectionResponse {
const storySession = createStorySession(overrides.storySession);
const serverVersion =
overrides.serverVersion ?? storySession.version ?? 1;
return {
storySession,
storyEvents: overrides.storyEvents ?? [],
serverVersion,
gameState: {
runtimeSessionId: storySession.runtimeSessionId,
storySessionId: storySession.storySessionId,
runtimeActionVersion: serverVersion,
currentScene: 'Story',
playerEquipment: { weapon: null, armor: null, relic: null },
...(overrides.gameState ?? {}),
},
actor: overrides.actor ?? {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
currency: 0,
currencyText: '0 铜钱',
},
inventory: overrides.inventory ?? {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
options: overrides.options ?? [],
status: overrides.status ?? {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
currentNarrativeText:
overrides.currentNarrativeText ?? storySession.latestNarrativeText,
actionResultText: overrides.actionResultText ?? null,
toast: overrides.toast ?? null,
};
}
function createRuntimeMutationResponse(
overrides: RuntimeProjectionOverrides = {},
) {
return {
projection: createRuntimeProjection(overrides),
};
}
describe('rpgRuntimeStoryClient', () => {
beforeEach(() => {
requestJsonMock.mockReset();
});
it('creates story sessions through the new story session endpoint', async () => {
requestJsonMock.mockResolvedValue({
storySession: {
storySessionId: 'storysess-main',
runtimeSessionId: 'runtime-main',
actorUserId: 'user-1',
worldProfileId: 'profile-1',
initialPrompt: '进入营地',
openingSummary: '营地开场',
latestNarrativeText: '篝火正在燃烧。',
latestChoiceFunctionId: null,
status: 'active',
version: 1,
createdAt: '2026-04-29T00:00:00.000Z',
updatedAt: '2026-04-29T00:00:00.000Z',
},
storyEvent: {
eventId: 'storyevt-main',
storySessionId: 'storysess-main',
eventKind: 'session_started',
narrativeText: '篝火正在燃烧。',
choiceFunctionId: null,
createdAt: '2026-04-29T00:00:00.000Z',
},
});
const result = await beginStorySession({
runtimeSessionId: 'runtime-main',
worldProfileId: 'profile-1',
initialPrompt: '进入营地',
openingSummary: '营地开场',
});
expect(result.storySession.storySessionId).toBe('storysess-main');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/story/sessions',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
runtimeSessionId: 'runtime-main',
worldProfileId: 'profile-1',
initialPrompt: '进入营地',
openingSummary: '营地开场',
}),
}),
'创建故事会话失败',
expect.any(Object),
);
});
it('continues story sessions through the new story session endpoint', async () => {
requestJsonMock.mockResolvedValue({
storySession: {
storySessionId: 'storysess-main',
runtimeSessionId: 'runtime-main',
actorUserId: 'user-1',
worldProfileId: 'profile-1',
initialPrompt: '进入营地',
openingSummary: null,
latestNarrativeText: '你继续向前。',
latestChoiceFunctionId: 'story_continue',
status: 'active',
version: 2,
createdAt: '2026-04-29T00:00:00.000Z',
updatedAt: '2026-04-29T00:00:01.000Z',
},
storyEvent: {
eventId: 'storyevt-next',
storySessionId: 'storysess-main',
eventKind: 'story_continued',
narrativeText: '你继续向前。',
choiceFunctionId: 'story_continue',
createdAt: '2026-04-29T00:00:01.000Z',
},
});
await continueStorySession({
storySessionId: ' storysess-main ',
narrativeText: '你继续向前。',
choiceFunctionId: 'story_continue',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/story/sessions/continue',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
storySessionId: 'storysess-main',
narrativeText: '你继续向前。',
choiceFunctionId: 'story_continue',
}),
}),
'继续故事会话失败',
expect.any(Object),
);
});
it('reads story session state through the new state endpoint', async () => {
requestJsonMock.mockResolvedValue({
storySession: {
storySessionId: 'storysess-main',
runtimeSessionId: 'runtime-main',
actorUserId: 'user-1',
worldProfileId: 'profile-1',
initialPrompt: '进入营地',
openingSummary: null,
latestNarrativeText: '服务端故事',
latestChoiceFunctionId: null,
status: 'active',
version: 3,
createdAt: '2026-04-29T00:00:00.000Z',
updatedAt: '2026-04-29T00:00:02.000Z',
},
storyEvents: [],
});
await getStorySessionState({ storySessionId: ' storysess-main ' });
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/story/sessions/storysess-main/state',
expect.objectContaining({
method: 'GET',
}),
'读取故事会话状态失败',
expect.any(Object),
);
});
it('starts runtime sessions through the backend runtime endpoint', async () => {
requestJsonMock.mockResolvedValue(
createRuntimeMutationResponse({
storySession: {
storySessionId: 'storysess-server-1',
runtimeSessionId: 'runtime-server-1',
worldProfileId: 'profile-1',
latestNarrativeText: '营地开场',
openingSummary: '营地开场',
version: 2,
updatedAt: '2026-04-28T00:00:00.000Z',
},
serverVersion: 2,
gameState: {
runtimeSessionId: 'runtime-server-1',
storySessionId: 'storysess-server-1',
currentScene: 'Story',
playerCharacter: { id: 'role-1', name: '沈砺' },
playerEquipment: { weapon: null, armor: null, relic: null },
},
currentNarrativeText: '营地开场',
}),
);
const result = await beginRuntimeStorySession({
worldType: 'CUSTOM',
customWorldProfile: { id: 'profile-1' } as never,
character: { id: 'role-1', name: '沈砺' } as never,
runtimeMode: 'play',
disablePersistence: false,
});
expect(result.snapshot.gameState.runtimeSessionId).toBe(
'runtime-server-1',
);
expect(result.snapshot.gameState.storySessionId).toBe(
'storysess-server-1',
);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/story/sessions/runtime',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
worldType: 'CUSTOM',
customWorldProfile: { id: 'profile-1' },
character: { id: 'role-1', name: '沈砺' },
runtimeMode: 'play',
disablePersistence: false,
}),
}),
'初始化运行时开局失败',
expect.any(Object),
);
});
it('builds runtime action requests against the dedicated story endpoint', async () => {
requestJsonMock.mockResolvedValue(
createRuntimeMutationResponse({
storySession: {
storySessionId: 'storysess-custom',
latestNarrativeText: '后端已结算',
latestChoiceFunctionId: 'npc_chat',
version: 2,
},
serverVersion: 2,
gameState: {
storySessionId: 'storysess-custom',
runtimeSessionId: 'runtime-main',
},
storyEvents: [
{
eventId: 'storyevt-2',
storySessionId: 'storysess-custom',
eventKind: 'story_continued',
narrativeText: '后端已结算',
choiceFunctionId: 'npc_chat',
createdAt: '2026-04-08T00:00:01.000Z',
},
],
currentNarrativeText: '后端已结算',
}),
);
await resolveRuntimeStoryAction({
storySessionId: 'storysess-custom',
clientVersion: 9,
option: {
functionId: 'npc_chat',
actionText: '继续交谈',
},
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/story/sessions/storysess-custom/actions/resolve',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
storySessionId: 'storysess-custom',
clientVersion: 9,
functionId: 'npc_chat',
actionText: '继续交谈',
targetId: undefined,
payload: {
optionText: '继续交谈',
},
}),
}),
'执行运行时动作失败',
expect.any(Object),
);
});
it('merges custom runtime payload fields into the action request body', async () => {
requestJsonMock.mockResolvedValue(
createRuntimeMutationResponse({
storySession: {
latestNarrativeText: '后端已结算物品使用',
latestChoiceFunctionId: 'inventory_use',
version: 3,
},
serverVersion: 3,
currentNarrativeText: '后端已结算物品使用',
}),
);
await resolveRuntimeStoryAction({
storySessionId: 'storysess-main',
option: {
functionId: 'inventory_use',
actionText: '使用凝神灵液',
},
payload: {
itemId: 'focus-tonic',
},
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/story/sessions/storysess-main/actions/resolve',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
storySessionId: 'storysess-main',
clientVersion: undefined,
functionId: 'inventory_use',
actionText: '使用凝神灵液',
targetId: undefined,
payload: {
optionText: '使用凝神灵液',
itemId: 'focus-tonic',
},
}),
}),
'执行运行时动作失败',
expect.any(Object),
);
});
it('reads runtime story state by story session id', async () => {
requestJsonMock.mockResolvedValue(
createRuntimeProjection({
storySession: {
latestNarrativeText: '服务端故事',
version: 4,
},
serverVersion: 4,
currentNarrativeText: '服务端故事',
}),
);
await getRuntimeStoryState({
storySessionId: 'storysess-main',
clientVersion: 7,
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/story/sessions/storysess-main/runtime-projection',
expect.objectContaining({
method: 'GET',
}),
'读取运行时故事投影失败',
expect.any(Object),
);
});
it('rejects missing story session id instead of falling back to runtime id', async () => {
expect(() =>
getRuntimeStorySessionId({
storySessionId: '',
}),
).toThrow('运行时故事会话不存在,无法读取服务端投影');
await expect(
loadRuntimeInventoryView({
gameState: {
runtimeSessionId: 'runtime-inventory',
storySessionId: null,
runtimeActionVersion: 5,
} as never,
}),
).rejects.toThrow('运行时故事会话不存在,无法读取服务端投影');
await expect(
continueStorySession({
storySessionId: '',
narrativeText: '继续',
}),
).rejects.toThrow('故事会话不存在,无法继续故事');
await expect(
getStorySessionState({
storySessionId: '',
}),
).rejects.toThrow('故事会话不存在,无法读取故事会话状态');
expect(requestJsonMock).not.toHaveBeenCalled();
});
it('loads backend inventory view from story runtime projection', async () => {
requestJsonMock.mockResolvedValue(
createRuntimeProjection({
storySession: {
storySessionId: 'storysess-inventory',
runtimeSessionId: 'runtime-inventory',
latestNarrativeText: '背包状态',
version: 5,
},
serverVersion: 5,
gameState: {
storySessionId: 'storysess-inventory',
runtimeSessionId: 'runtime-inventory',
},
actor: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
currency: 90,
currencyText: '90 铜钱',
},
inventory: {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [
{
id: 'synthesis-refined-ingot',
name: '压炼锭材',
kind: 'synthesis',
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
resultLabel: '精炼锭材',
currencyCost: 18,
currencyText: '18 铜钱',
requirements: [
{
id: 'material:any',
label: '任意材料',
quantity: 3,
owned: 3,
},
],
canCraft: true,
action: {
functionId: 'forge_craft',
actionText: '制作精炼锭材',
payload: { recipeId: 'synthesis-refined-ingot' },
enabled: true,
},
},
],
},
currentNarrativeText: '',
}),
);
const view = await loadRuntimeInventoryView({
gameState: {
storySessionId: 'storysess-inventory',
runtimeActionVersion: 5,
} as never,
});
expect(view.forgeRecipes[0]?.action.functionId).toBe('forge_craft');
expect(view.playerCurrency).toBe(90);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/story/sessions/storysess-inventory/runtime-projection',
expect.objectContaining({
method: 'GET',
}),
'读取运行时故事投影失败',
expect.any(Object),
);
});
it('keeps disabled runtime options when rebuilding a story moment', () => {
const story = buildStoryMomentFromRuntimeOptions({
storyText: '服务端返回的新故事',
options: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
scope: 'npc',
},
{
functionId: 'npc_recruit',
actionText: '邀请加入队伍',
scope: 'npc',
disabled: true,
reason: '队伍已满',
},
],
});
expect(story.text).toBe('服务端返回的新故事');
expect(story.options).toHaveLength(2);
expect(story.options[0]?.functionId).toBe('npc_chat');
expect(story.options[1]?.functionId).toBe('npc_recruit');
expect(story.options[1]?.disabled).toBe(true);
expect(story.options[1]?.disabledReason).toBe('队伍已满');
});
it('recognizes server-runtime option pools for server-side legality checks', () => {
expect(isTask5RuntimeFunctionId('npc_chat')).toBe(true);
expect(isTask5RuntimeFunctionId('battle_attack_basic')).toBe(true);
expect(isTask5RuntimeFunctionId('npc_trade')).toBe(false);
expect(isServerRuntimeFunctionId('npc_trade')).toBe(true);
expect(isServerRuntimeFunctionId('unknown_action')).toBe(false);
expect(
shouldUseServerRuntimeOptions([
{
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
]),
).toBe(true);
expect(
shouldUseServerRuntimeOptions([
{
functionId: 'npc_trade',
actionText: '交易',
text: '交易',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
]),
).toBe(true);
expect(
shouldUseServerRuntimeOptions([
{
functionId: 'unknown_action',
actionText: '未知动作',
text: '未知动作',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
]),
).toBe(false);
expect(getRuntimeSessionId({ runtimeSessionId: '' })).toBe(
'runtime-main',
);
expect(getRuntimeStorySessionId({ storySessionId: ' storysess-1 ' })).toBe(
'storysess-1',
);
expect(getRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3);
});
it('builds story moments from story runtime projection options', () => {
const story = buildStoryMomentFromRuntimeProjection({
projection: createRuntimeProjection({
storySession: {
storySessionId: 'storysess-main',
runtimeSessionId: 'runtime-main',
latestNarrativeText: '兜底故事',
version: 5,
},
serverVersion: 5,
options: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
detailText: '推进当前话题',
scope: 'npc',
payload: { npcId: 'npc-merchant' },
enabled: false,
reason: '对方暂时不想说话',
},
],
status: {
inBattle: false,
npcInteractionActive: true,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
currentNarrativeText: '服务端投影故事',
}),
});
expect(story.text).toBe('服务端投影故事');
expect(story.options[0]).toEqual(
expect.objectContaining({
functionId: 'npc_chat',
actionText: '继续交谈',
disabled: true,
disabledReason: '对方暂时不想说话',
}),
);
});
it('preserves runtime option interaction metadata from the server response', () => {
const story = buildStoryMomentFromRuntimeOptions({
storyText: '服务端返回的新故事',
options: [
{
functionId: 'npc_trade',
actionText: '交易',
scope: 'npc',
interaction: {
kind: 'npc',
npcId: 'npc-merchant',
action: 'trade',
},
},
],
});
expect(story.options[0]?.interaction).toEqual({
kind: 'npc',
npcId: 'npc-merchant',
action: 'trade',
});
});
it('prefers the richer snapshot story when the server persisted dialogue mode', () => {
const hydratedSnapshot = {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {} as never,
currentStory: {
text: '你:先把话说开。\n梁伯那我就直说了。',
options: [],
displayMode: 'dialogue',
dialogue: [
{ speaker: 'player', text: '先把话说开。' },
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
],
deferredOptions: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
},
} as never;
const story = resolveRuntimeStoryMoment({
response: {
sessionId: 'runtime-main',
serverVersion: 4,
projection: createRuntimeProjection({
storySession: {
latestNarrativeText: '普通文本',
latestChoiceFunctionId: 'npc_chat',
version: 4,
},
serverVersion: 4,
currentNarrativeText: '普通文本',
}),
snapshot: hydratedSnapshot,
inventoryView: {
playerCurrency: 0,
currencyText: '0 铜钱',
inBattle: false,
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
presentation: {
resultText: '后端已结算',
storyText: '普通文本',
battle: null,
},
},
hydratedSnapshot,
fallbackStoryText: '普通文本',
});
expect(story.displayMode).toBe('dialogue');
expect(story.deferredOptions).toHaveLength(1);
expect(story.text).toContain('梁伯');
});
});