import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../services/aiService', () => ({ generateNextStep: vi.fn(), })); const { isRpgRuntimeServerFunctionIdMock, } = vi.hoisted(() => ({ isRpgRuntimeServerFunctionIdMock: vi.fn(() => false), })); vi.mock('../../services/rpg-runtime', () => ({ isRpgRuntimeServerFunctionId: isRpgRuntimeServerFunctionIdMock, })); import { generateNextStep } from '../../services/aiService'; import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types'; import { createStoryChoiceActions } from './choiceActions'; function createTestCharacter(): Character { return { id: 'test-hero', name: '测试主角', title: '游侠', description: '一名测试用主角', backstory: '测试背景', avatar: '/hero.png', portrait: '/hero-portrait.png', assetFolder: 'hero', assetVariant: 'default', attributes: { strength: 10, agility: 10, intelligence: 10, spirit: 10, }, personality: 'calm', skills: [ { id: 'skill-basic', name: '试探一击', animation: AnimationState.ATTACK, damage: 10, manaCost: 0, cooldownTurns: 1, range: 1, style: 'steady', }, ], adventureOpenings: {}, }; } function createBaseState(): GameState { return { worldType: WorldType.WUXIA, customWorldProfile: null, playerCharacter: createTestCharacter(), runtimeStats: { playTimeMs: 0, lastPlayTickAt: null, hostileNpcsDefeated: 0, questsAccepted: 0, itemsUsed: 0, scenesTraveled: 0, }, currentScene: 'Story', storyHistory: [], characterChats: {}, animationState: AnimationState.IDLE, currentEncounter: null, npcInteractionActive: false, currentScenePreset: null, sceneHostileNpcs: [], playerX: 0, playerOffsetY: 0, playerFacing: 'right', playerActionMode: 'idle', scrollWorld: false, inBattle: true, playerHp: 100, playerMaxHp: 100, playerMana: 20, playerMaxMana: 20, playerSkillCooldowns: {}, activeCombatEffects: [], playerCurrency: 0, playerInventory: [], playerEquipment: { weapon: null, armor: null, relic: null, }, npcStates: { 'npc-opponent': { affinity: 0, helpUsed: false, chattedCount: 0, giftsGiven: 0, inventory: [], recruited: false, }, }, quests: [], roster: [], companions: [], currentBattleNpcId: 'npc-opponent', currentNpcBattleMode: 'fight', currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, }; } function createBattleOption(functionId = 'battle_all_in_crush'): StoryOption { return { functionId, actionText: '挥刀抢攻', text: '挥刀抢攻', visuals: { playerAnimation: AnimationState.ATTACK, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, }; } function createFallbackStory(text = 'fallback'): StoryMoment { return { text, options: [], }; } const neverNpcEncounter = ( encounter: GameState['currentEncounter'], ): encounter is Encounter => false; describe('createStoryChoiceActions', () => { beforeEach(() => { isRpgRuntimeServerFunctionIdMock.mockReset(); isRpgRuntimeServerFunctionIdMock.mockReturnValue(false); }); it('reveals deferred adventure options when story_continue_adventure is selected', async () => { const state = { ...createBaseState(), inBattle: false, sceneHostileNpcs: [], currentBattleNpcId: null, currentNpcBattleMode: null, }; const deferredOptions = [ { functionId: 'idle_explore_forward', actionText: '继续向前探索', text: '继续向前探索', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right' as const, scrollWorld: false, monsterChanges: [], }, }, ] satisfies StoryOption[]; const continueOption: StoryOption = { functionId: 'story_continue_adventure', actionText: '查看后续', text: '查看后续', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right' as const, scrollWorld: false, monsterChanges: [], }, }; const currentStory: StoryMoment = { text: '对话已经完成', options: [continueOption], deferredOptions, }; const setCurrentStory = vi.fn(); const generateStoryForState = vi.fn(); const handleNpcInteraction = vi.fn(); const { handleChoice } = createStoryChoiceActions({ gameState: state, currentStory, isLoading: false, setGameState: vi.fn(), setCurrentStory, setAiError: vi.fn(), setIsLoading: vi.fn(), setBattleReward: vi.fn(), buildResolvedChoiceState: vi.fn(), playResolvedChoice: vi.fn(), buildStoryContextFromState: vi.fn(), buildStoryFromResponse: vi.fn((_, __, response) => response), buildFallbackStoryForState: vi.fn(() => createFallbackStory()), generateStoryForState, getAvailableOptionsForState: vi.fn(() => null), getStoryGenerationHostileNpcs: vi.fn(() => []), getResolvedSceneHostileNpcs: vi.fn( (inputState: GameState) => inputState.sceneHostileNpcs, ), buildNpcStory: vi.fn(() => createFallbackStory()), handleNpcBattleConversationContinuation: vi.fn(() => false), updateQuestLog: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), getCampCompanionTravelScene: vi.fn(() => null), enterNpcInteraction: vi.fn(() => false), handleNpcInteraction, handleTreasureInteraction: vi.fn(() => false), commitGeneratedStateWithEncounterEntry: vi.fn(), finalizeNpcBattleResult: vi.fn(() => null), isContinueAdventureOption: vi.fn( (option: StoryOption) => option.functionId === 'story_continue_adventure', ), isCampTravelHomeOption: vi.fn(() => false), isRegularNpcEncounter: neverNpcEncounter, isNpcEncounter: neverNpcEncounter, npcPreviewTalkFunctionId: 'npc_preview_talk', fallbackCompanionName: '同伴', turnVisualMs: 820, }); await handleChoice(continueOption); expect(setCurrentStory).toHaveBeenCalledWith({ ...currentStory, options: deferredOptions, deferredOptions: undefined, }); expect(generateStoryForState).not.toHaveBeenCalled(); expect(handleNpcInteraction).not.toHaveBeenCalled(); }); it('applies deferred runtime state when story_continue_adventure reveals the next act', async () => { const state = { ...createBaseState(), inBattle: false, currentBattleNpcId: null, currentNpcBattleMode: null, }; const deferredOptions = [ { functionId: 'idle_observe_signs', actionText: '观察下一幕的线索', text: '观察下一幕的线索', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right' as const, scrollWorld: false, monsterChanges: [], }, }, ] satisfies StoryOption[]; const continueOption: StoryOption = { functionId: 'story_continue_adventure', actionText: '继续冒险', text: '继续冒险', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right' as const, scrollWorld: false, monsterChanges: [], }, }; const currentStory: StoryMoment = { text: '对话已经完成', options: [continueOption], deferredOptions, deferredRuntimeState: { storyEngineMemory: { discoveredFactIds: [], activeThreadIds: [], resolvedScarIds: [], recentCarrierIds: [], currentSceneActState: { sceneId: 'scene-bridge', chapterId: 'scene-bridge-chapter', currentActId: 'scene-bridge-act-2', currentActIndex: 1, completedActIds: ['scene-bridge-act-1'], visitedActIds: ['scene-bridge-act-1', 'scene-bridge-act-2'], }, }, }, }; const setCurrentStory = vi.fn(); const setGameState = vi.fn(); const { handleChoice } = createStoryChoiceActions({ gameState: state, currentStory, isLoading: false, setGameState, setCurrentStory, setAiError: vi.fn(), setIsLoading: vi.fn(), setBattleReward: vi.fn(), buildResolvedChoiceState: vi.fn(), playResolvedChoice: vi.fn(), buildStoryContextFromState: vi.fn(), buildStoryFromResponse: vi.fn((_, __, response) => response), buildFallbackStoryForState: vi.fn(() => createFallbackStory()), generateStoryForState: vi.fn(), getAvailableOptionsForState: vi.fn(() => null), getStoryGenerationHostileNpcs: vi.fn(() => []), getResolvedSceneHostileNpcs: vi.fn( (inputState: GameState) => inputState.sceneHostileNpcs, ), buildNpcStory: vi.fn(() => createFallbackStory()), handleNpcBattleConversationContinuation: vi.fn(() => false), updateQuestLog: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), getCampCompanionTravelScene: vi.fn(() => null), enterNpcInteraction: vi.fn(() => false), handleNpcInteraction: vi.fn(), handleTreasureInteraction: vi.fn(() => false), commitGeneratedStateWithEncounterEntry: vi.fn(), finalizeNpcBattleResult: vi.fn(() => null), isContinueAdventureOption: vi.fn( (option: StoryOption) => option.functionId === 'story_continue_adventure', ), isCampTravelHomeOption: vi.fn(() => false), isRegularNpcEncounter: neverNpcEncounter, isNpcEncounter: neverNpcEncounter, npcPreviewTalkFunctionId: 'npc_preview_talk', fallbackCompanionName: '同伴', turnVisualMs: 820, }); await handleChoice(continueOption); expect(setGameState).toHaveBeenCalledWith( expect.objectContaining({ storyEngineMemory: expect.objectContaining({ currentSceneActState: expect.objectContaining({ currentActId: 'scene-bridge-act-2', }), }), }), ); expect(setCurrentStory).toHaveBeenCalledWith({ ...currentStory, options: deferredOptions, deferredOptions: undefined, deferredRuntimeState: undefined, }); }); it('keeps npc chat choices on the local UI path so chat mode can continue streaming locally', async () => { const state = createBaseState(); const option = createBattleOption('npc_chat'); const setGameState = vi.fn(); const setCurrentStory = vi.fn(); const handleNpcInteraction = vi.fn(() => true); isRpgRuntimeServerFunctionIdMock.mockReturnValue(true); const { handleChoice } = createStoryChoiceActions({ gameState: { ...state, currentEncounter: { id: 'npc-opponent', kind: 'npc', npcName: '山道客', npcDescription: '拦路的陌生人', npcAvatar: '/npc.png', context: '山道相遇', }, npcInteractionActive: true, inBattle: false, sceneHostileNpcs: [], currentBattleNpcId: null, currentNpcBattleMode: null, }, currentStory: createFallbackStory('当前故事'), isLoading: false, setGameState, setCurrentStory, setAiError: vi.fn(), setIsLoading: vi.fn(), setBattleReward: vi.fn(), buildResolvedChoiceState: vi.fn(), playResolvedChoice: vi.fn(), buildStoryContextFromState: vi.fn(), buildStoryFromResponse: vi.fn((_, __, response) => response), buildFallbackStoryForState: vi.fn(() => createFallbackStory()), generateStoryForState: vi.fn(), getAvailableOptionsForState: vi.fn(() => null), getStoryGenerationHostileNpcs: vi.fn(() => []), getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs), buildNpcStory: vi.fn(() => createFallbackStory()), handleNpcBattleConversationContinuation: vi.fn(() => false), updateQuestLog: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), getCampCompanionTravelScene: vi.fn(() => null), enterNpcInteraction: vi.fn(() => false), handleNpcInteraction, handleTreasureInteraction: vi.fn(() => false), commitGeneratedStateWithEncounterEntry: vi.fn(), finalizeNpcBattleResult: vi.fn(() => null), isContinueAdventureOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false), isRegularNpcEncounter: (encounter): encounter is Encounter => Boolean(encounter?.kind === 'npc'), isNpcEncounter: (encounter): encounter is Encounter => Boolean(encounter?.kind === 'npc'), npcPreviewTalkFunctionId: 'npc_preview_talk', fallbackCompanionName: '同伴', turnVisualMs: 820, }); await handleChoice(option); expect(handleNpcInteraction).toHaveBeenCalledWith( expect.objectContaining({ functionId: 'npc_chat', }), ); expect(setGameState).not.toHaveBeenCalled(); expect(setCurrentStory).not.toHaveBeenCalled(); }); it('keeps npc trade choices on the local UI path so the trade modal can collect payload', async () => { const state: GameState = { ...createBaseState(), currentEncounter: { id: 'npc-merchant', kind: 'npc' as const, npcName: '梁伯', npcDescription: '沿街商贩', npcAvatar: '/npc.png', context: '沿街商贩', }, npcInteractionActive: true, inBattle: false, sceneHostileNpcs: [], currentBattleNpcId: null, currentNpcBattleMode: null, }; const option: StoryOption = { functionId: 'npc_trade', actionText: '交易', text: '交易', interaction: { kind: 'npc' as const, npcId: 'npc-merchant', action: 'trade' as const, }, visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right' as const, scrollWorld: false, monsterChanges: [], }, }; const handleNpcInteraction = vi.fn(() => true); isRpgRuntimeServerFunctionIdMock.mockReturnValue(true); const { handleChoice } = createStoryChoiceActions({ gameState: state, currentStory: createFallbackStory('当前故事'), isLoading: false, setGameState: vi.fn(), setCurrentStory: vi.fn(), setAiError: vi.fn(), setIsLoading: vi.fn(), setBattleReward: vi.fn(), buildResolvedChoiceState: vi.fn(), playResolvedChoice: vi.fn(), buildStoryContextFromState: vi.fn(), buildStoryFromResponse: vi.fn((_, __, response) => response), buildFallbackStoryForState: vi.fn(() => createFallbackStory()), generateStoryForState: vi.fn(), getAvailableOptionsForState: vi.fn(() => null), getStoryGenerationHostileNpcs: vi.fn(() => []), getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs), buildNpcStory: vi.fn(() => createFallbackStory()), handleNpcBattleConversationContinuation: vi.fn(() => false), updateQuestLog: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), getCampCompanionTravelScene: vi.fn(() => null), enterNpcInteraction: vi.fn(() => false), handleNpcInteraction, handleTreasureInteraction: vi.fn(() => false), commitGeneratedStateWithEncounterEntry: vi.fn(), finalizeNpcBattleResult: vi.fn(() => null), isContinueAdventureOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false), isRegularNpcEncounter: (encounter): encounter is Encounter => Boolean(encounter?.kind === 'npc'), isNpcEncounter: (encounter): encounter is Encounter => Boolean(encounter?.kind === 'npc'), npcPreviewTalkFunctionId: 'npc_preview_talk', fallbackCompanionName: '同伴', turnVisualMs: 820, }); await handleChoice(option); expect(handleNpcInteraction).toHaveBeenCalledWith(option); }); it('reopens npc chat instead of running generic follow-up after local npc victory', async () => { const encounter: Encounter = { id: 'npc-opponent', kind: 'npc', npcName: '山道客', npcDescription: '拦路旧敌', npcAvatar: '/npc.png', context: '山道旧案', }; const state = { ...createBaseState(), currentEncounter: encounter, npcInteractionActive: true, }; const option = createBattleOption(); const afterSequence = { ...state, inBattle: false, sceneHostileNpcs: [], currentNpcBattleOutcome: 'fight_victory' as const, }; const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写')); const setCurrentStory = vi.fn(); const setGameState = vi.fn(); const handleNpcBattleConversationContinuation = vi.fn(() => true); const { handleChoice } = createStoryChoiceActions({ gameState: state, currentStory: createFallbackStory(), isLoading: false, setGameState, setCurrentStory, setAiError: vi.fn(), setIsLoading: vi.fn(), setBattleReward: vi.fn(), buildResolvedChoiceState: vi.fn(() => ({ optionKind: 'battle' as const, battlePlan: null, afterSequence, })), playResolvedChoice: vi.fn().mockResolvedValue(afterSequence), buildStoryContextFromState: vi.fn(() => ({ playerHp: 100, playerMaxHp: 100, playerMana: 20, playerMaxMana: 20, inBattle: false, playerX: 0, playerFacing: 'right', playerAnimation: AnimationState.IDLE, skillCooldowns: {}, })), buildStoryFromResponse: vi.fn((_, __, response) => response), buildFallbackStoryForState: vi.fn(() => createFallbackStory()), generateStoryForState, getAvailableOptionsForState: vi.fn(() => null), getStoryGenerationHostileNpcs: vi.fn(() => []), getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs), buildNpcStory: vi.fn(() => createFallbackStory()), handleNpcBattleConversationContinuation, updateQuestLog: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), getCampCompanionTravelScene: vi.fn(() => null), enterNpcInteraction: vi.fn(() => false), handleNpcInteraction: vi.fn(() => false), handleTreasureInteraction: vi.fn(() => false), commitGeneratedStateWithEncounterEntry: vi.fn(), finalizeNpcBattleResult: vi.fn(() => ({ nextState: { ...afterSequence, currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, inBattle: false, }, resultText: '山道客已经败下阵来。胜利奖励:无战利品。', })), isContinueAdventureOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false), isRegularNpcEncounter: neverNpcEncounter, isNpcEncounter: neverNpcEncounter, npcPreviewTalkFunctionId: 'npc_preview_talk', fallbackCompanionName: '同伴', turnVisualMs: 820, }); await handleChoice(option); expect(handleNpcBattleConversationContinuation).toHaveBeenCalledWith( expect.objectContaining({ nextState: expect.objectContaining({ currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, }), encounter, actionText: '挥刀抢攻', resultText: '山道客已经败下阵来。胜利奖励:无战利品。', battleMode: 'fight', }), ); expect(generateStoryForState).not.toHaveBeenCalled(); expect(setCurrentStory).not.toHaveBeenCalledWith( createFallbackStory('战后续写'), ); }); it('injects an escape resolution into the immediate story context before ai continuation', async () => { const mockedGenerateNextStep = vi.mocked(generateNextStep); mockedGenerateNextStep.mockResolvedValue({ storyText: '你落到山道外侧,呼吸总算稳了下来。', options: [], }); const state = { ...createBaseState(), currentBattleNpcId: null, currentNpcBattleMode: null, sceneHostileNpcs: [ { id: 'wolf-1', name: '山狼', action: '低伏逼近', description: '一头山狼', animation: 'idle' as const, xMeters: 3.2, yOffset: 0, facing: 'left' as const, attackRange: 1.4, speed: 7, hp: 10, maxHp: 10, renderKind: 'npc' as const, }, ], }; const option = createBattleOption('battle_escape_breakout'); const afterSequence = { ...state, inBattle: false, sceneHostileNpcs: [], playerX: -1.2, }; const setBattleReward = vi.fn(); const incrementRuntimeStats = vi.fn((inputState: GameState) => inputState); const buildStoryContextFromState = vi.fn(() => ({ playerHp: 100, playerMaxHp: 100, playerMana: 20, playerMaxMana: 20, inBattle: false, playerX: -1.2, playerFacing: 'right' as const, playerAnimation: AnimationState.IDLE, skillCooldowns: {}, })); const { handleChoice } = createStoryChoiceActions({ gameState: state, currentStory: createFallbackStory(), isLoading: false, setGameState: vi.fn(), setCurrentStory: vi.fn(), setAiError: vi.fn(), setIsLoading: vi.fn(), setBattleReward, buildResolvedChoiceState: vi.fn(() => ({ optionKind: 'escape' as const, battlePlan: null, afterSequence, })), playResolvedChoice: vi.fn().mockResolvedValue(afterSequence), buildStoryContextFromState, buildStoryFromResponse: vi.fn((_, __, response) => response), buildFallbackStoryForState: vi.fn(() => createFallbackStory()), generateStoryForState: vi.fn(), getAvailableOptionsForState: vi.fn(() => null), getStoryGenerationHostileNpcs: vi.fn(() => []), getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs), buildNpcStory: vi.fn(() => createFallbackStory()), handleNpcBattleConversationContinuation: vi.fn(() => false), updateQuestLog: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats, getCampCompanionTravelScene: vi.fn(() => null), enterNpcInteraction: vi.fn(() => false), handleNpcInteraction: vi.fn(() => false), handleTreasureInteraction: vi.fn(() => false), commitGeneratedStateWithEncounterEntry: vi.fn(), finalizeNpcBattleResult: vi.fn(() => null), isContinueAdventureOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false), isRegularNpcEncounter: neverNpcEncounter, isNpcEncounter: neverNpcEncounter, npcPreviewTalkFunctionId: 'npc_preview_talk', fallbackCompanionName: '同伴', turnVisualMs: 820, }); await handleChoice(option); expect(mockedGenerateNextStep).toHaveBeenCalledTimes(1); const history = mockedGenerateNextStep.mock.calls[0]?.[3] as StoryMoment[]; expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([ 'action:挥刀抢攻', 'result:你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。', ]); expect(buildStoryContextFromState).toHaveBeenCalledWith( expect.objectContaining({ inBattle: false, sceneHostileNpcs: [], }), expect.objectContaining({ lastFunctionId: 'battle_escape_breakout', recentActionResult: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。', }), ); expect(setBattleReward).toHaveBeenCalledTimes(1); expect(setBattleReward).toHaveBeenCalledWith(null); expect(incrementRuntimeStats).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ hostileNpcsDefeated: 0 }), ); }); });