Creating a bot for EXAPUNKS's solitaire
| 979 words | 5 minutes
It’s December, so it’s again time for AOC! Same as last year, I figured I wouldn’t stress too much about completing AOC on time or at all. Based on previous years I noticed that it’s in general harder for me to wrap my head around puzzles involving grid/graph evaluations, so after completing 9 different days, I slowly stopped working on AOC.
Then again, my puzzle-solving itch still remained, so I bought EXAPUNKS from Steam’s Winter Sale. The puzzles in EXAPUNKS are great. They are relatively small, as the solutions are less than 100 lines, but they are still challenging enough. In addition to programming puzzles, there’s also minigames that you play manually. One of them is solitaire (pictured below). Now, the whole game is about finding automated solutions to problems, so I figured… why not extend this to the solitaire as well?
I’ve never worked with GUI automation and I wouldn’t consider myself to be proficient with Python either, so I figured I could tackle both issues with this mini-project. The solution consists of 3 main parts:
1. Get screen and find cards
Python is often nice choice for prototyping, because a lot of functionality can be hidden behind a single
pip install. For taking screenshots and finding parts of them, there’s
pyscreeze with very clear API. They recommend installing
cv2 library to speed up image location. This helped a little bit, but
locateAll still felt somewhat slow, so I moved simple loop for the different cards into ThreadPoolExecutor. This helped to speed up this part from around 5 sec to around 2 sec.
2. Find winning moves
Once we know how our cards are arranged, we can try to find a sequence of moves that will win the game. The game states can be considered as graph nodes, so we model node transitions and use something similar to Dijkstra’s algorithm to search through all the state space, until winning state is found.
Dijkstra’s algorithm finds shortest path by prioritizing exploration of paths with smallest sum of link weights. In this case, the priority queue is ordered by two conditions – by number of completed stacks and then by number of moves taken. This allows the search to proceed with the game, as movements from and to completed stacks are discouraged, as doing so makes the stacks not complete anymore.
The speed here depends heavily on the game situation. In some cases it’s enough to explore 2,000 game states, in others the algorithm checks 20,000 states. Thus, this part may take any time between 1-10 seconds. Anyway, at the end of this, we get a list of moves in form of
(from_idx, to_idx, count) tuples.
3. Play a single game
Similarly to finding the card positions, I found that controlling the GUI programmatically was also easy, as there’s
pyautogui with clear APIs. The hardest thing in this part was figuring out how to compute relevant coordinates and adjusting them during multiple tests. But once those coordinates are computed, then it’s just a matter of calling
I also found out that you can’t be too fast with GUI automation. Specifically, there’s a “new game” button and simply clicking it wouldn’t work. However, the button worked fine when I programmed the mouse to click and drag for 20 pixels before releasing it. Similarly,
dragTo functions would fail to connect with the application if the animation time was set to 0.10 seconds or less. Maybe it’s a good thing to keep in mind that GUIs are primarily made for humans so maybe they expect human-like input.
Checking the execution time, this part is the slowest, resulting in around 28 seconds per game. Taking into account issues mentioned above, there’s probably not much we can do here, so it’s nice that I didn’t spend too much time thinking about the other parts being “too slow”.
4. Combining all together
Finally, bringing all of this together is as simple as:
This yields a bot that’s able to play the game continuously, taking 30-40 seconds per single game. The video below is speed up x2. Full source is available here: https://github.com/yanto77/auto-solitaire.
And with this said and done, the two shining achievements are mine! 🎉
… I guess I didn’t get that far from AOC-like puzzles. 😅