import { beforeEach, describe, expect, it, vi } from 'vitest'; const { rollHostileNpcLootMock, resolveServerRuntimeChoiceMock, } = vi.hoisted(() => ({ rollHostileNpcLootMock: vi.fn(), resolveServerRuntimeChoiceMock: vi.fn(), })); vi.mock('../../data/hostileNpcPresets', async () => { const actual = await vi.importActual( '../../data/hostileNpcPresets', ); return { ...actual, rollHostileNpcLoot: rollHostileNpcLootMock, }; }); vi.mock('.', () => ({ resolveRpgRuntimeChoice: resolveServerRuntimeChoiceMock, })); import type { Character, GameState, StoryMoment, StoryOption } from '../../types'; import { buildCombatResolutionContextText, buildHostileNpcBattleReward, buildReasonedOptionCatalog, 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): 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(() => { rollHostileNpcLootMock.mockReset(); resolveServerRuntimeChoiceMock.mockReset(); }); it('deduplicates option catalogs by function id for post-battle recovery', () => { const options = buildReasonedOptionCatalog([ createOption('npc_chat'), createOption('npc_chat'), createOption('npc_help'), ]); expect(options.map((option) => option.functionId)).toEqual([ 'npc_chat', 'npc_help', ]); }); 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('builds escape and victory context text for local battle resolution', () => { const baseState = createState({ inBattle: true, sceneHostileNpcs: [ { id: 'wolf', name: '山狼' }, ] as GameState['sceneHostileNpcs'], }); expect( buildCombatResolutionContextText({ baseState, afterSequence: { ...baseState, inBattle: false, sceneHostileNpcs: [], }, optionKind: 'escape', projectedBattleReward: null, getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs, }), ).toContain('你已成功逃脱'); expect( buildCombatResolutionContextText({ baseState: { ...baseState, currentBattleNpcId: null, }, afterSequence: { ...baseState, inBattle: false, sceneHostileNpcs: [], }, optionKind: 'battle', projectedBattleReward: { id: 'reward-1', defeatedHostileNpcs: [{ id: 'wolf', name: '山狼' }], items: [ { id: 'loot-1', category: '材料', name: '狼牙', quantity: 1, rarity: 'common', tags: [] }, ], }, getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs, }), ).toContain('战利品:狼牙。'); }); it('builds defeated hostile rewards from locally resolved battle states', async () => { rollHostileNpcLootMock.mockResolvedValue([ { id: 'loot-1', category: '材料', name: '狼牙', quantity: 1, rarity: 'common', tags: [], }, ]); const reward = await buildHostileNpcBattleReward( createState({ inBattle: true, sceneHostileNpcs: [ { id: 'wolf', name: '山狼' }, ] as GameState['sceneHostileNpcs'], currentBattleNpcId: null, }), createState({ inBattle: false, sceneHostileNpcs: [], }), 'battle', (state) => state.sceneHostileNpcs, ); expect(rollHostileNpcLootMock).toHaveBeenCalledTimes(1); expect(reward?.items[0]).toEqual( expect.objectContaining({ name: '狼牙', }), ); }); it('keeps defeated hostile reward render keys unique for duplicate monster ids', async () => { rollHostileNpcLootMock.mockResolvedValue([]); const reward = await buildHostileNpcBattleReward( createState({ inBattle: true, sceneHostileNpcs: [ { id: 'monster-16', name: '雷翼甲' }, { id: 'monster-16', name: '雷翼乙', xMeters: 4.1, yOffset: 32 }, ] as GameState['sceneHostileNpcs'], currentBattleNpcId: null, }), createState({ inBattle: false, sceneHostileNpcs: [], }), 'battle', (state) => state.sceneHostileNpcs, ); expect(reward?.defeatedHostileNpcs).toHaveLength(2); expect(reward?.defeatedHostileNpcs.map((npc) => npc.id)).toEqual([ 'monster-16', 'monster-16', ]); expect(new Set(reward?.defeatedHostileNpcs.map((npc) => npc.renderKey)).size) .toBe(2); }); 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('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: '服务端故事', }), ); }); });