770 lines
22 KiB
TypeScript
770 lines
22 KiB
TypeScript
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('梁伯');
|
||
});
|
||
});
|