import { beforeEach, describe, expect, it, vi } from 'vitest'; const { requestJsonMock } = vi.hoisted(() => ({ requestJsonMock: vi.fn(), })); vi.mock('../apiClient', async () => { const actual = await vi.importActual('../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, 'storySession' > & { storySession?: Partial; }; function createStorySession( overrides: Partial = {}, ): 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('梁伯'); }); });