import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../services/aiService', () => ({ generateNextStep: vi.fn(), })); const { isRpgRuntimeServerFunctionIdMock, runServerRuntimeChoiceActionMock, } = vi.hoisted(() => ({ isRpgRuntimeServerFunctionIdMock: vi.fn(() => false), runServerRuntimeChoiceActionMock: vi.fn(), })); vi.mock('../../services/rpg-runtime', () => ({ isRpgRuntimeServerFunctionId: isRpgRuntimeServerFunctionIdMock, })); import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types'; import { createStoryChoiceActions } from './choiceActions'; vi.mock('./storyChoiceRuntime', async () => { return { runCampTravelHomeChoice: vi.fn(), runServerRuntimeChoiceAction: runServerRuntimeChoiceActionMock, shouldOpenLocalRuntimeNpcModal: (option: StoryOption) => ( option.interaction?.kind === 'npc' || !option.interaction ) && ( option.functionId === 'npc_chat' || option.functionId === 'npc_trade' || option.functionId === 'npc_gift' ), }; }); 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', options: StoryOption[] = [], ): StoryMoment { return { text, options, }; } const neverNpcEncounter = ( encounter: GameState['currentEncounter'], ): encounter is Encounter => false; describe('createStoryChoiceActions', () => { beforeEach(() => { isRpgRuntimeServerFunctionIdMock.mockReset(); isRpgRuntimeServerFunctionIdMock.mockReturnValue(false); runServerRuntimeChoiceActionMock.mockReset(); }); 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: { currentScenePreset: { id: 'scene-bridge', name: '断桥', description: '桥上雾气很重。', imageSrc: '/scene-bridge.png', treasureHints: [], npcs: [], }, }, }; 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({ currentScenePreset: expect.objectContaining({ id: 'scene-bridge', }), }), ); expect(setGameState.mock.calls[0]?.[0]).not.toHaveProperty( 'storyEngineMemory', ); 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('routes battle attack and skill choices to the backend resolver even while in battle', async () => { const state = { ...createBaseState(), 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: StoryOption = { ...createBattleOption('battle_use_skill'), runtimePayload: { skillId: 'skill-basic', }, }; const setGameState = vi.fn(); const setCurrentStory = vi.fn(); const buildResolvedChoiceState = vi.fn(() => ({ optionKind: 'battle' as const, battlePlan: null, afterSequence: state, })); const playResolvedChoice = vi.fn().mockResolvedValue(state); isRpgRuntimeServerFunctionIdMock.mockReturnValue(true); const { handleChoice } = createStoryChoiceActions({ gameState: state, currentStory: createFallbackStory(), isLoading: false, setGameState, setCurrentStory, setAiError: vi.fn(), setIsLoading: vi.fn(), setBattleReward: vi.fn(), buildResolvedChoiceState, playResolvedChoice, buildStoryContextFromState: vi.fn(() => ({ playerHp: 100, playerMaxHp: 100, playerMana: 20, playerMaxMana: 20, inBattle: true, playerX: 0, playerFacing: 'right' as const, playerAnimation: AnimationState.IDLE, skillCooldowns: {}, })), buildStoryFromResponse: vi.fn((_, __, response) => response), buildFallbackStoryForState: vi.fn(() => createFallbackStory()), generateStoryForState: vi.fn(), getAvailableOptionsForState: vi.fn(() => [option]), getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs), 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(() => 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(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith( expect.objectContaining({ gameState: state, option, character: state.playerCharacter, }), ); expect(buildResolvedChoiceState).not.toHaveBeenCalled(); expect(playResolvedChoice).not.toHaveBeenCalled(); expect(setGameState).not.toHaveBeenCalled(); expect(setCurrentStory).not.toHaveBeenCalled(); }); it('routes stale battle panel choices to the backend resolver when combat presentation is still visible', async () => { const battleOption = createBattleOption('battle_attack_basic'); const state = { ...createBaseState(), inBattle: false, 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 currentStory: StoryMoment = { text: '山狼还在你面前压低身位,战斗并未真正结束。', options: [battleOption], }; const buildResolvedChoiceState = vi.fn(() => ({ optionKind: 'battle' as const, battlePlan: null, afterSequence: { ...state, inBattle: true, }, })); const playResolvedChoice = vi.fn().mockResolvedValue({ ...state, inBattle: true, }); isRpgRuntimeServerFunctionIdMock.mockReturnValue(true); const { handleChoice } = createStoryChoiceActions({ gameState: state, currentStory, isLoading: false, setGameState: vi.fn(), setCurrentStory: vi.fn(), setAiError: vi.fn(), setIsLoading: vi.fn(), setBattleReward: vi.fn(), buildResolvedChoiceState, playResolvedChoice, buildStoryContextFromState: vi.fn(() => ({ playerHp: 100, playerMaxHp: 100, playerMana: 20, playerMaxMana: 20, inBattle: true, playerX: 0, playerFacing: 'right' as const, playerAnimation: AnimationState.IDLE, skillCooldowns: {}, })), buildStoryFromResponse: vi.fn((_, __, response) => response), buildFallbackStoryForState: vi.fn(() => createFallbackStory()), generateStoryForState: vi.fn(), getAvailableOptionsForState: vi.fn(() => [battleOption]), getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs), 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(() => 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(battleOption); expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith( expect.objectContaining({ gameState: state, currentStory, option: battleOption, character: state.playerCharacter, }), ); expect(buildResolvedChoiceState).not.toHaveBeenCalled(); expect(playResolvedChoice).not.toHaveBeenCalled(); }); it('routes inventory_use combat choices to the backend resolver', async () => { const state = createBaseState(); const option: StoryOption = { ...createBattleOption('inventory_use'), runtimePayload: { itemId: 'focus-tonic', }, }; const buildResolvedChoiceState = vi.fn(); const playResolvedChoice = vi.fn(); 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, playResolvedChoice, buildStoryContextFromState: vi.fn(), buildStoryFromResponse: vi.fn((_, __, response) => response), buildFallbackStoryForState: vi.fn(() => createFallbackStory()), generateStoryForState: vi.fn(), getAvailableOptionsForState: vi.fn(() => [option]), getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs), 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(() => 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(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith( expect.objectContaining({ gameState: state, option, character: state.playerCharacter, }), ); expect(buildResolvedChoiceState).not.toHaveBeenCalled(); expect(playResolvedChoice).not.toHaveBeenCalled(); }); });