import { beforeEach, describe, expect, it, vi } from 'vitest'; const { resolveServerRuntimeChoiceMock } = vi.hoisted(() => ({ resolveServerRuntimeChoiceMock: vi.fn(), })); vi.mock('.', () => ({ resolveRpgRuntimeChoice: resolveServerRuntimeChoiceMock, })); import type { Character, GameState, StoryMoment, StoryOption } from '../../types'; import { WorldType } from '../../types/core'; import { runServerRuntimeChoiceAction, shouldOpenLocalRuntimeNpcModal, } from './storyChoiceRuntime'; function createCharacter(): Character { return { id: 'hero', name: '沈行', title: '试剑客', description: '测试角色', backstory: '测试背景', avatar: '/hero.png', portrait: '/hero.png', assetFolder: 'hero', assetVariant: 'default', attributes: { strength: 10, agility: 10, intelligence: 10, spirit: 10, }, personality: 'calm', skills: [], adventureOpenings: {}, } as unknown as Character; } function createStory(text: string, options: StoryOption[] = []): StoryMoment { return { text, options, }; } function createOption( functionId: string, interaction?: StoryOption['interaction'], ): StoryOption { return { functionId, actionText: functionId, text: functionId, interaction, visuals: { playerAnimation: 'idle', playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, } as StoryOption; } function createState(overrides: Partial = {}): GameState { return { worldType: 'WUXIA', customWorldProfile: null, playerCharacter: createCharacter(), runtimeStats: { playTimeMs: 0, lastPlayTickAt: null, hostileNpcsDefeated: 0, questsAccepted: 0, itemsUsed: 0, scenesTraveled: 0, }, currentScene: 'Story', storyHistory: [], characterChats: {}, animationState: 'idle', currentEncounter: null, npcInteractionActive: false, currentScenePreset: null, sceneHostileNpcs: [], playerX: 0, playerOffsetY: 0, playerFacing: 'right', playerActionMode: 'idle', scrollWorld: false, inBattle: false, playerHp: 100, playerMaxHp: 100, playerMana: 20, playerMaxMana: 20, playerSkillCooldowns: {}, activeCombatEffects: [], playerCurrency: 0, playerInventory: [], playerEquipment: { weapon: null, armor: null, relic: null, }, npcStates: {}, quests: [], roster: [], companions: [], currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, ...overrides, } as GameState; } describe('storyChoiceRuntime', () => { beforeEach(() => { resolveServerRuntimeChoiceMock.mockReset(); }); it('keeps npc chat, trade and gift on the local runtime npc interaction path', () => { expect( shouldOpenLocalRuntimeNpcModal( createOption('npc_chat', { kind: 'npc', npcId: 'npc-friend', action: 'chat', }), ), ).toBe(true); expect( shouldOpenLocalRuntimeNpcModal( createOption('npc_trade', { kind: 'npc', npcId: 'npc-merchant', action: 'trade', }), ), ).toBe(true); expect( shouldOpenLocalRuntimeNpcModal( createOption('npc_gift', { kind: 'npc', npcId: 'npc-friend', action: 'gift', }), ), ).toBe(true); expect( shouldOpenLocalRuntimeNpcModal(createOption('idle_explore_forward')), ).toBe(false); }); it('applies server runtime responses and falls back locally when the request fails', async () => { const gameState = createState(); const currentStory = createStory('当前故事'); const setBattleReward = vi.fn(); const setAiError = vi.fn(); const setIsLoading = vi.fn(); const setGameState = vi.fn(); const setCurrentStory = vi.fn(); resolveServerRuntimeChoiceMock.mockResolvedValueOnce({ hydratedSnapshot: { gameState: { ...gameState, runtimeActionVersion: 3, }, }, nextStory: createStory('服务端故事'), }); await runServerRuntimeChoiceAction({ gameState, currentStory, option: createOption('npc_chat'), character: createCharacter(), setBattleReward, setAiError, setIsLoading, setGameState, setCurrentStory: setCurrentStory as (story: StoryMoment) => void, buildFallbackStoryForState: () => createStory('fallback'), }); expect(setGameState).toHaveBeenCalledWith( expect.objectContaining({ runtimeActionVersion: 3, }), ); expect(setCurrentStory).toHaveBeenCalledWith( expect.objectContaining({ text: '服务端故事', }), ); resolveServerRuntimeChoiceMock.mockRejectedValueOnce(new Error('boom')); const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => undefined); try { await runServerRuntimeChoiceAction({ gameState, currentStory: null, option: createOption('npc_chat'), character: createCharacter(), setBattleReward, setAiError, setIsLoading, setGameState, setCurrentStory: setCurrentStory as (story: StoryMoment) => void, buildFallbackStoryForState: () => createStory('fallback'), }); } finally { consoleErrorSpy.mockRestore(); } expect(setAiError).toHaveBeenCalledWith('boom'); expect(setCurrentStory).toHaveBeenCalledWith( expect.objectContaining({ text: 'fallback', }), ); }); it('plays server battle presentation before committing the hydrated snapshot', async () => { const gameState = createState({ inBattle: true, playerHp: 30, playerMana: 10, sceneHostileNpcs: [ { id: 'wolf', name: '山狼', action: '逼近', description: '山狼', animation: 'idle', xMeters: 3, yOffset: 0, facing: 'left', attackRange: 1, speed: 1, hp: 18, maxHp: 18, }, ], }); const finalState = createState({ ...gameState, inBattle: false, playerHp: 26, sceneHostileNpcs: [], }); const setGameState = vi.fn(); resolveServerRuntimeChoiceMock.mockResolvedValueOnce({ response: { presentation: { battle: { targetId: 'wolf', damageDealt: 18, damageTaken: 4, outcome: 'victory', }, resultText: '山狼被你压制下去。', }, }, hydratedSnapshot: { gameState: finalState, }, nextStory: createStory('服务端故事'), }); await runServerRuntimeChoiceAction({ gameState, currentStory: createStory('当前故事'), option: createOption('battle_attack_basic'), character: createCharacter(), setBattleReward: vi.fn(), setAiError: vi.fn(), setIsLoading: vi.fn(), setGameState, setCurrentStory: vi.fn() as (story: StoryMoment) => void, buildFallbackStoryForState: () => createStory('fallback'), turnVisualMs: 1, }); expect(setGameState).toHaveBeenCalledWith( expect.objectContaining({ animationState: 'idle', playerHp: 26, sceneHostileNpcs: expect.arrayContaining([ expect.objectContaining({ id: 'wolf', hp: 0, animation: 'die', }), ]), }), ); expect(setGameState).toHaveBeenLastCalledWith(finalState); }); it('uses the server-returned defeat revive snapshot without local death reconstruction', async () => { const gameState = createState({ worldType: WorldType.WUXIA, inBattle: true, playerHp: 6, playerMaxHp: 30, playerMana: 10, playerMaxMana: 10, currentScenePreset: { id: 'wuxia-bamboo-road', name: '竹林古道', description: '风穿竹影,路面狭长。', imageSrc: '/scene-a.png', connectedSceneIds: [], connections: [], forwardSceneId: undefined, treasureHints: [], npcs: [], }, sceneHostileNpcs: [ { id: 'wolf', name: '山狼', action: '逼近', description: '山狼', animation: 'idle', xMeters: 3, yOffset: 0, facing: 'left', attackRange: 1, speed: 1, hp: 4, maxHp: 18, }, ], }); const serverRevivedState = createState({ ...gameState, inBattle: false, playerHp: 30, playerMana: 10, currentEncounter: { kind: 'npc', id: 'wolf', npcName: '山狼', npcDescription: '林间伏击的野兽', npcAvatar: '狼', context: '复活后的首场景威胁', hostile: true, }, sceneHostileNpcs: [], currentNpcBattleOutcome: null, currentScenePreset: { id: 'wuxia-bamboo-road', name: '竹林古道', description: '风穿竹影,路面狭长。', imageSrc: '/scene-a.png', connectedSceneIds: ['wuxia-mountain-gate'], connections: [ { sceneId: 'wuxia-mountain-gate', relativePosition: 'forward', summary: '沿主路继续深入前方区域', }, ], forwardSceneId: 'wuxia-mountain-gate', treasureHints: [], npcs: [], }, }); const setGameState = vi.fn(); const setCurrentStory = vi.fn(); const serverDeathStory = createStory('你在战斗中倒下,随后在竹林古道重新醒来。', [ createOption('story_continue_adventure'), ]); resolveServerRuntimeChoiceMock.mockResolvedValueOnce({ response: { presentation: { battle: { targetId: 'wolf', damageDealt: 22, damageTaken: 8, outcome: 'defeat', }, resultText: '你在山狼的反扑下倒地。', }, }, hydratedSnapshot: { gameState: serverRevivedState, }, nextStory: serverDeathStory, }); await runServerRuntimeChoiceAction({ gameState, currentStory: createStory('当前故事'), option: createOption('battle_all_in_crush'), character: createCharacter(), setBattleReward: vi.fn(), setAiError: vi.fn(), setIsLoading: vi.fn(), setGameState, setCurrentStory: setCurrentStory as (story: StoryMoment) => void, buildFallbackStoryForState: () => createStory('fallback'), turnVisualMs: 1, }); expect(setGameState).toHaveBeenCalledWith( expect.objectContaining({ playerHp: 0, animationState: 'die', inBattle: false, }), ); expect(setGameState).toHaveBeenLastCalledWith(serverRevivedState); expect(setCurrentStory).toHaveBeenCalledWith(serverDeathStory); }); it('commits server-returned next-scene state after idle_travel_next_scene resolution', async () => { const gameState = createState({ currentScenePreset: { id: 'wuxia-bamboo-road', name: '竹林古道', description: '风穿竹影,路面狭长。', imageSrc: '/scene-a.png', connectedSceneIds: ['wuxia-rain-street'], connections: [ { sceneId: 'wuxia-rain-street', relativePosition: 'forward', summary: '沿石板路继续前行', }, ], forwardSceneId: 'wuxia-rain-street', treasureHints: [], npcs: [], }, currentEncounter: { kind: 'npc', id: 'npc-bridge', npcName: '桥头行商', npcDescription: '正准备收摊离开的行商', npcAvatar: '桥', context: '桥口', hostile: false, }, npcInteractionActive: true, }); const setGameState = vi.fn(); const setCurrentStory = vi.fn(); resolveServerRuntimeChoiceMock.mockResolvedValueOnce({ hydratedSnapshot: { gameState: { ...gameState, runtimeActionVersion: 3, currentScenePreset: { id: 'wuxia-rain-street', name: '夜雨长街', description: '雨丝压低灯火,街面反着潮光。', imageSrc: '/scene-b.png', connectedSceneIds: ['wuxia-bamboo-road'], connections: [ { sceneId: 'wuxia-bamboo-road', relativePosition: 'back', summary: '可以沿原路退回竹林古道', }, ], forwardSceneId: 'wuxia-ferry-bridge', treasureHints: [], npcs: [], }, currentEncounter: null, npcInteractionActive: false, sceneHostileNpcs: [], runtimeStats: { ...gameState.runtimeStats, scenesTraveled: 1, }, }, }, nextStory: createStory('服务端故事'), }); await runServerRuntimeChoiceAction({ gameState, currentStory: createStory('当前故事'), option: createOption('idle_travel_next_scene'), character: createCharacter(), setBattleReward: vi.fn(), setAiError: vi.fn(), setIsLoading: vi.fn(), setGameState, setCurrentStory: setCurrentStory as (story: StoryMoment) => void, buildFallbackStoryForState: () => createStory('fallback'), }); expect(setGameState).toHaveBeenCalledWith( expect.objectContaining({ currentScenePreset: expect.objectContaining({ id: 'wuxia-rain-street', }), runtimeStats: expect.objectContaining({ scenesTraveled: 1, }), }), ); expect(setCurrentStory).toHaveBeenCalledWith( expect.objectContaining({ text: '服务端故事', }), ); }); });