├── .gitignore ├── manual ├── visual_out.gif └── terminal.svg ├── card_back ├── cards │ ├── 0B.png │ ├── 0R.png │ ├── 6B.png │ ├── 6R.png │ ├── 7B.png │ ├── 7R.png │ ├── 8B.png │ ├── 8R.png │ ├── 9B.png │ ├── 9R.png │ ├── CC.png │ ├── DD.png │ ├── HH.png │ └── SS.png └── anchor │ └── anchor.png ├── requirements.txt ├── config.json ├── README.md ├── exa_gui.py └── exa_logic.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .DS_Store 3 | *.mov 4 | .pylintrc 5 | -------------------------------------------------------------------------------- /manual/visual_out.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/manual/visual_out.gif -------------------------------------------------------------------------------- /card_back/cards/0B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/cards/0B.png -------------------------------------------------------------------------------- /card_back/cards/0R.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/cards/0R.png -------------------------------------------------------------------------------- /card_back/cards/6B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/cards/6B.png -------------------------------------------------------------------------------- /card_back/cards/6R.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/cards/6R.png -------------------------------------------------------------------------------- /card_back/cards/7B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/cards/7B.png -------------------------------------------------------------------------------- /card_back/cards/7R.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/cards/7R.png -------------------------------------------------------------------------------- /card_back/cards/8B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/cards/8B.png -------------------------------------------------------------------------------- /card_back/cards/8R.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/cards/8R.png -------------------------------------------------------------------------------- /card_back/cards/9B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/cards/9B.png -------------------------------------------------------------------------------- /card_back/cards/9R.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/cards/9R.png -------------------------------------------------------------------------------- /card_back/cards/CC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/cards/CC.png -------------------------------------------------------------------------------- /card_back/cards/DD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/cards/DD.png -------------------------------------------------------------------------------- /card_back/cards/HH.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/cards/HH.png -------------------------------------------------------------------------------- /card_back/cards/SS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/cards/SS.png -------------------------------------------------------------------------------- /card_back/anchor/anchor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronrudkin/exapunks_solitaire_bot/HEAD/card_back/anchor/anchor.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mss==4.0.3 2 | opencv-python==4.8.1.78 3 | numpy==1.22.0 4 | Pillow==10.3.0 5 | PyAutoGUI==0.9.47 6 | six==1.11.0 7 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "resolution_scale_click": 0.5, 3 | "max_window_x": 1316, 4 | "max_window_y": 822, 5 | "stack_width": 134, 6 | "stack_height": 30, 7 | "base_stack_offset_x": 55, 8 | "base_stack_offset_y": 332, 9 | "freecell_offset_x": 1046, 10 | "freecell_offset_y": 89, 11 | "card_sprite_x": 22, 12 | "card_sprite_y": 23, 13 | "number_stacks": 9, 14 | "cards_per_stack_base": 4, 15 | "anchor_filename": "card_back/anchor/anchor.png", 16 | "card_filename": "card_back/cards/*.png", 17 | "click_offset_x": 40, 18 | "click_offset_y": 21, 19 | "base_delay": 0.2, 20 | "window_click_offset_x": 100, 21 | "window_click_offset_y": 100, 22 | "new_game_offset_x": 1064, 23 | "new_game_offset_y": 780 24 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # exapunks_solitaire_bot 2 | 3 | This code solves ПАСЬЯНС solitaire as implemented in Exapunks (not intended for public use at this time). 4 | 5 | `exa_logic.py` contains all of the code to solve solitaire. `exa_gui.py` contains code to read the screen, detect the game being played, and implement the solution via mouse -- simply run `python exa_gui.py` to read directly from the screen or `python exa_gui.py screenshot.png` to load from a previously saved screenshot. To play multiple games in a row, start a new game, then run `python exa_gui.py loop n` where n is the number of games to play. Running `exa_logic.py` directly generates a random game and solves it. 6 | 7 | Currently, the code expects the game to be running in a 1920x1080 window, unobscured, anywhere on the screen, and expects a 2x DPI screen (e.g. Mac Retina). To make it work with other resolutions or DPI scales, edit the pixel offsets found in `config.json`. The computer vision algorithm used to identify cards is OpenCV's template matching, and so when running in other resolutions you should also replace the card sprites found in `card_back/` with resolution appropriate ones. 8 | 9 | ### Example of graphical autoplay 10 | 11 | ![Graphical autoplay using exa_gui.py](manual/visual_out.gif) 12 | 13 | ### Offline solving a randomly generated game 14 | 15 | ![Terminal output of exa_logic.png](manual/terminal.svg) 16 | 17 | ### Installation and requirements 18 | 19 | Clone this repository. The offline solution AI, `exa_logic.py`, has no external dependencies and runs in pure Python2 or Python3. To allow screen reading and automated graphical solution, install external dependencies using `pip install -r requirements.txt`. You may also need to install system level libraries for OpenCV. 20 | 21 | ### `config.json` syntax 22 | 23 | ``` 24 | { 25 | "resolution_scale_click": 0.5, // DPI scaling: 0.5 for 2x DPI (Mac Retina), 1 for standard monitors 26 | "max_window_x": 1316, // How wide the Solitaire game is in the window from the top left corner 27 | "max_window_y": 822, // How tall the Solitaire game is in the window from the top left corner 28 | "stack_width": 134, // How many pixels horizontally to move from card stack to card stack 29 | "stack_height": 30, // How many pixels vertically to move from card stack to card stack 30 | "base_stack_offset_x": 55, // From the top left corner of the Solitaire game (anchor.png), how far horizontal to the stack 31 | "base_stack_offset_y": 332, // From the top left corner of the Solitaire game (anchor.png), how far vertical to the stack 32 | "freecell_offset_x": 1046, // From the top left corner of the Solitaire game (anchor.png), how far horizontal to the free cell 33 | "freecell_offset_y": 89, // From the top left corner of the Solitaire game (anchor.png), how far vertical to the free cell 34 | "card_sprite_x": 22, // How wide are the card sprite clippings (cards/*.png) 35 | "card_sprite_y": 23, // How tall are the card sprite clippings (cards/*.png) 36 | "number_stacks": 9, // How many stacks are in the game -- this should be left alone 37 | "cards_per_stack_base": 4, // How many cards are dealt per stack in a new game -- this should be left alone 38 | "anchor_filename": "card_back/anchor/anchor.png", // Location of the image that anchors the top-left corner of the game 39 | "card_filename": "card_back/cards/*.png", // Location of the card images 40 | "click_offset_x": 40, // How many pixels inside the card should the mouse click, horizontal 41 | "click_offset_y": 21, // How many pixels inside the card should the mouse click, vertical 42 | "base_delay": 0.2, // How long to use as a base delay between actions (other delays are multiples of this) 43 | "window_click_offset_x": 100, // When getting window focus, where should the mouse click, horizontal (leave this alone) 44 | "window_click_offset_y": 100, // When getting window focus, where should the mouse click, vertical (leave this alone) 45 | "new_game_offset_x": 1064, // How far from top left corner of window to the new game button, horizontal 46 | "new_game_offset_y": 780 // How far from top left corner of window to the new game button, vertical 47 | } 48 | ``` 49 | 50 | Two notes: 51 | 1. The default values are calibrated for a 1920x1080 window 52 | 2. The offsets should not take into account DPI -- `resolution_scale_click` is the only setting that controls monitor DPI 53 | 54 | ### Algorithm details 55 | 56 | The solver is implemented as a greedy (best-first) search. Given a game state, evaluate all possible legal moves (excluding those which lead to previously visited game states). Score each legal move. Add the moves to an array and sort by score descending. Visit the first move on the list. Continue until the queue of moves is exhausted (loss condition) or the game is solved. The algorithm also prohibits chains of moves longer than length 100, although in practice all games are solvable in fewer moves. 57 | 58 | Although this approach is not guaranteed to be minimal length, computation is extremely quick and the algorithm is resilient to local maxima. The score function implemented is simple: Each complete stack is worth 20 points. Each empty slot is worth 10 points. Each incomplete stack is worth 5 points less the number of cards that are inaccessible. Of course, these values are arbitrary and any scores will produce a solution if one exists. 59 | -------------------------------------------------------------------------------- /exa_gui.py: -------------------------------------------------------------------------------- 1 | """ 2 | Handles the screen capture, asking exa_logic to solve the game, using the mouse 3 | to solve the game. 4 | """ 5 | 6 | from __future__ import print_function 7 | import glob 8 | import json 9 | import sys 10 | import time 11 | import cv2 12 | import mss 13 | import numpy as np 14 | import pyautogui 15 | import six 16 | from PIL import Image 17 | import exa_logic 18 | from pathlib import Path 19 | 20 | CONFIG = json.load(open("config.json")) 21 | 22 | 23 | def anchor_and_clip(image): 24 | """ 25 | Locates the Exapunks game inside the full screenshot and clips it out. 26 | """ 27 | 28 | corner = cv2.imread(CONFIG["anchor_filename"]) 29 | result = cv2.matchTemplate(image, corner, cv2.TM_SQDIFF) 30 | x, y = cv2.minMaxLoc(result)[2] 31 | 32 | crop_image = image[ 33 | y:y + CONFIG["max_window_y"], 34 | x:x + CONFIG["max_window_x"] 35 | ] 36 | return x, y, crop_image 37 | 38 | 39 | def read_freecells(): 40 | """ 41 | Determines how many unlocked free cells there are. For exapunks, this is 42 | always one unlocked cell. 43 | """ 44 | return [0] 45 | 46 | 47 | def read_stacks(image): 48 | """ Determines which cards are in which stack """ 49 | 50 | cards = [] 51 | card_names = [] 52 | for file_iterator in glob.glob(CONFIG["card_filename"]): 53 | card_name = Path(file_iterator).stem 54 | card_names.append(card_name) 55 | cards.append(cv2.imread(file_iterator)) 56 | 57 | stacks = [] 58 | for x_stack in range(CONFIG["number_stacks"]): 59 | stack = [] 60 | for y_stack in range(CONFIG["cards_per_stack_base"]): 61 | coord_x = ( 62 | CONFIG["base_stack_offset_x"] + 63 | (CONFIG["stack_width"] * x_stack) 64 | ) 65 | coord_y = ( 66 | CONFIG["base_stack_offset_y"] + 67 | (CONFIG["stack_height"] * y_stack) 68 | ) 69 | crop_image = image[ 70 | coord_y:coord_y + CONFIG["card_sprite_y"], 71 | coord_x:coord_x + CONFIG["card_sprite_x"] 72 | ] 73 | 74 | result_scores = [ 75 | cv2.matchTemplate(crop_image, cards[i], cv2.TM_SQDIFF) 76 | for i in range(len(cards)) 77 | ] 78 | 79 | card_type = card_names[result_scores.index(min(result_scores))] 80 | stack.append(card_type) 81 | 82 | stacks.append(stack) 83 | 84 | return stacks 85 | 86 | 87 | def computer_hash(my_image): 88 | """ 89 | Uses image to build the game, returns information for solving the game 90 | """ 91 | 92 | print("Beginning screen detection") 93 | offset_screen_x, offset_screen_y, my_image = anchor_and_clip(my_image) 94 | freecells = read_freecells() 95 | freecell_hash = "".join(["F/" if x == 0 else "FL/" for x in freecells]) 96 | stacks = read_stacks(my_image) 97 | stack_hash = "".join( 98 | ["S%s/" % "".join([str(s) for s in stack]) for stack in stacks] 99 | ) 100 | print("Done. Game detected.") 101 | return [offset_screen_x, offset_screen_y, stack_hash + freecell_hash] 102 | 103 | 104 | def read_file(filename): 105 | """ Reads a screenshot from a file and solves it. """ 106 | print("Beginning file read...") 107 | my_image = cv2.imread(filename) 108 | return computer_hash(my_image) 109 | 110 | 111 | def grab_screenshot(): 112 | """ Takes a screenshot from the screen and solves it. """ 113 | print("Taking screenshot...") 114 | with mss.mss() as screenshot: 115 | monitor = screenshot.monitors[0] 116 | shot = screenshot.grab(monitor) 117 | frame = np.array( 118 | Image.frombytes("RGB", (shot.width, shot.height), shot.rgb) 119 | ) 120 | frame_2 = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) 121 | return computer_hash(frame_2) 122 | 123 | 124 | def execute_solution(offset_x, offset_y, moves): 125 | """ Executes solution by moving mouse and clicking. """ 126 | 127 | # First, click the window 128 | pyautogui.mouseDown( 129 | (offset_x + CONFIG["window_click_offset_x"]) * 130 | CONFIG["resolution_scale_click"], 131 | (offset_y + CONFIG["window_click_offset_y"]) * 132 | CONFIG["resolution_scale_click"], 133 | button="left" 134 | ) 135 | time.sleep(CONFIG["base_delay"] * 3) 136 | pyautogui.mouseUp() 137 | time.sleep(CONFIG["base_delay"] * 5) 138 | 139 | # Now, replay the moves one by one 140 | for move in moves: 141 | # which stack, how many cards down -> which stack, how many cards down 142 | x_pre, y_pre, x_post, y_post = move 143 | 144 | # If it's a regular stack, move to the offset 145 | if x_pre < CONFIG["number_stacks"]: 146 | x_pre_final = ( 147 | offset_x + 148 | CONFIG["base_stack_offset_x"] + 149 | (CONFIG["stack_width"] * x_pre) + 150 | CONFIG["click_offset_x"] 151 | ) 152 | y_pre_final = ( 153 | offset_y + 154 | CONFIG["base_stack_offset_y"] + 155 | (CONFIG["stack_height"] * y_pre) + 156 | CONFIG["click_offset_y"] 157 | ) 158 | # Separate offsets for freecell 159 | else: 160 | x_pre_final = ( 161 | offset_x + 162 | CONFIG["freecell_offset_x"] + 163 | (CONFIG["stack_width"] * (x_pre - CONFIG["number_stacks"])) + 164 | CONFIG["click_offset_x"] 165 | ) 166 | y_pre_final = ( 167 | offset_y + 168 | CONFIG["freecell_offset_y"] + 169 | CONFIG["click_offset_y"] 170 | ) 171 | 172 | if x_post < CONFIG["number_stacks"]: 173 | x_post_final = ( 174 | offset_x + 175 | CONFIG["base_stack_offset_x"] + 176 | (CONFIG["stack_width"] * x_post) + 177 | CONFIG["click_offset_x"] 178 | ) 179 | y_post_final = ( 180 | offset_y + 181 | CONFIG["base_stack_offset_y"] + 182 | (CONFIG["stack_height"] * y_post) + 183 | CONFIG["click_offset_y"] 184 | ) 185 | else: 186 | x_post_final = ( 187 | offset_x + 188 | CONFIG["freecell_offset_x"] + 189 | (CONFIG["stack_width"] * (x_post - CONFIG["number_stacks"])) + 190 | CONFIG["click_offset_x"] 191 | ) 192 | y_post_final = ( 193 | offset_y + 194 | CONFIG["freecell_offset_y"] + 195 | CONFIG["click_offset_y"] 196 | ) 197 | 198 | # Move the mouse to the beginning place 199 | pyautogui.moveTo( 200 | x_pre_final * CONFIG["resolution_scale_click"], 201 | y_pre_final * CONFIG["resolution_scale_click"], 202 | duration=CONFIG["base_delay"] 203 | ) 204 | 205 | # Click and drag to the end 206 | pyautogui.dragTo( 207 | x_post_final * CONFIG["resolution_scale_click"], 208 | y_post_final * CONFIG["resolution_scale_click"], 209 | duration=CONFIG["base_delay"], 210 | button="left" 211 | ) 212 | 213 | # Wait for a while 214 | time.sleep(CONFIG["base_delay"]) 215 | 216 | 217 | def click_new_game(offset_x, offset_y): 218 | """ Literally just clicks the new game button. """ 219 | 220 | pyautogui.mouseDown( 221 | (offset_x + CONFIG["new_game_offset_x"]) * 222 | CONFIG["resolution_scale_click"], 223 | (offset_y + CONFIG["new_game_offset_y"]) * 224 | CONFIG["resolution_scale_click"], 225 | button="left" 226 | ) 227 | time.sleep(CONFIG["base_delay"] * 3) 228 | pyautogui.mouseUp() 229 | time.sleep(CONFIG["base_delay"] * 5) 230 | 231 | 232 | def loop_many(max_i=3): 233 | """ 234 | Plays more than one game in a row, clicking new game when necessary. 235 | """ 236 | 237 | # Just play a bunch of games 238 | for i in range(max_i): 239 | offset_x, offset_y, game_hash = grab_screenshot() 240 | game = exa_logic.Game() 241 | game.exact_setup(game_hash) 242 | result = game.global_solve(-1) 243 | execute_solution(offset_x, offset_y, result) 244 | 245 | print("Done game %d / %d... " % (i + 1, max_i)) 246 | time.sleep(4) 247 | 248 | if i < max_i - 1: 249 | click_new_game(offset_x, offset_y) 250 | time.sleep(CONFIG["base_delay"] * 25) 251 | 252 | 253 | def main(): 254 | """ 255 | Dispatches by reading file argument on command line or taking snapshot 256 | of screen. 257 | """ 258 | 259 | if len(sys.argv) > 1 and sys.argv[1]: 260 | if sys.argv[1] == "loop": 261 | loop_many(int(sys.argv[2])) 262 | return 263 | 264 | _, _, game_hash = read_file(sys.argv[1]) 265 | offset_x = 0 266 | offset_y = 0 267 | else: 268 | offset_x, offset_y, game_hash = grab_screenshot() 269 | 270 | print(hash) 271 | game = exa_logic.Game() 272 | game.exact_setup(game_hash) 273 | print(game) 274 | result = game.global_solve(-1) 275 | print(result) 276 | 277 | # If it was a screen grab, we can actually do this -- just type n/q/c to 278 | # quit or anything else to continue 279 | if result is not None and offset_x and offset_y: 280 | x = six.moves.input("Ready for automated solution? ") 281 | if x.lower() in ["n", "q", "c"]: 282 | return 283 | 284 | execute_solution(offset_x, offset_y, result) 285 | 286 | 287 | if __name__ == "__main__": 288 | main() 289 | -------------------------------------------------------------------------------- /exa_logic.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the entire game logic portion of this Solitaire solver -- enough to 3 | produce a valid game and solid it, or take a specific game and solve it. 4 | """ 5 | 6 | from __future__ import print_function 7 | from collections import deque 8 | from timeit import default_timer as timer 9 | import random 10 | import copy 11 | import time 12 | 13 | 14 | class Stack(object): 15 | """ 16 | A stack is a place where cards can go; types are 'stack' and 'freecell'. 17 | """ 18 | 19 | face_values = ["H", "S", "C", "D"] 20 | num_values = ["6", "7", "8", "9"] 21 | face_cards = ["HH", "SS", "CC", "DD"] 22 | 23 | def __init__(self, card_type, locked): 24 | self.card_type = card_type 25 | self.locked = locked 26 | self.stack = [] 27 | self.past_cards = "" 28 | 29 | def __str__(self): 30 | """ Quick way to print out the contents of this stack to the user. """ 31 | return "Type: %s%s: %s" % (self.card_type, 32 | " [Locked]" if self.locked else "", 33 | str(self.stack)) 34 | 35 | def hash(self): 36 | """ 37 | This function is a hash of what's going on in the stack, used to 38 | verify we haven't visited this location before. 39 | """ 40 | 41 | type_str = self.card_type[0].upper() 42 | if self.stack != "X": 43 | card_str = "".join([str(x) for x in self.stack]) 44 | else: 45 | card_str = "X[" + str(self.past_cards) + "]" 46 | return type_str + card_str 47 | 48 | def init_cards(self, cards): 49 | """ Adds the initial cards to the stack. """ 50 | if self.stack: 51 | raise Exception("Illegal init, cards already there") 52 | 53 | if self.card_type == "freecell": 54 | raise Exception("Illegal init, can't init cards in free cell") 55 | 56 | self.stack = cards 57 | 58 | @classmethod 59 | def compatible(cls, top, bottom): 60 | """ 61 | Tests compatibility of two cards -- can bottom be placed under top in 62 | a solitaire context? 63 | """ 64 | top_str, top_suit = list(top) 65 | bottom_str, bottom_suit = list(bottom) 66 | 67 | if top_suit == bottom_suit and top_str in cls.face_values: 68 | return 1 69 | 70 | if (top_str in cls.face_values or bottom_str in cls.face_values): 71 | return 0 72 | 73 | top_number = int(top_str) if top_str in cls.num_values else 10 74 | bottom_number = int(bottom_str) if bottom_str in cls.num_values else 10 75 | 76 | if bottom_number == top_number - 1 and top_suit != bottom_suit: 77 | return 1 78 | 79 | return 0 80 | 81 | def is_move_to_legal(self, card): 82 | """ Is a move described by the argument 'card' to this stack legal? """ 83 | 84 | # If stack is locked, not valid 85 | if self.locked: 86 | return 0 87 | 88 | # If it's a freecell and it already has something, not valid 89 | if self.card_type == "freecell" and self.stack: 90 | return 0 91 | 92 | # If it's a freecell and you're trying to move more than one card, not 93 | # valid 94 | if self.card_type == "freecell" and len(card) > 1: 95 | return 0 96 | 97 | # If it's a stack and there are cards and the card you're moving 98 | # doesn't match the last card on the stack, not valid 99 | if (self.card_type == "stack" and self.stack and not 100 | self.compatible(self.stack[-1], card[0])): 101 | return 0 102 | 103 | return 1 104 | 105 | def is_move_from_legal(self): 106 | """ Is there a legal move from this stack? """ 107 | 108 | # If it's locked, no 109 | if self.locked: 110 | return 0 111 | 112 | # If there's nothing here, no 113 | if not self.stack: 114 | return 0 115 | 116 | return 1 117 | 118 | def resolve_move_to(self, card): 119 | """ Actually modify the stack by moving the card. """ 120 | 121 | # If we can't do this move then error 122 | if not self.is_move_to_legal(card): 123 | raise Exception("Trying to force illegal move") 124 | 125 | # Do the move 126 | self.stack = self.stack + card 127 | 128 | # If we just created a collapse, handle the collapse 129 | if (len(self.stack) == 4 and self.stack == [self.stack[0]] * 4 and 130 | self.stack[0] in self.__class__.face_cards): 131 | # Lock the stack, save which card type was in the lock for user 132 | # debug, mark the stack cards as X 133 | self.locked = True 134 | self.past_cards = self.stack[0] 135 | self.stack = "X" 136 | 137 | def which_cards_moving(self): 138 | """ 139 | We're moving from this stack -- how many cards do we take? So if the 140 | stack ends with 2 of the same card, both move. 141 | """ 142 | 143 | # By default, it's one card 144 | card_move = [self.stack[-1]] 145 | 146 | # How many more? 147 | for i in range(len(self.stack) - 2, -1, -1): 148 | if not self.compatible(self.stack[i], card_move[-1]): 149 | break 150 | 151 | card_move.append(self.stack[i]) 152 | 153 | # We appended the cards backwards, so let's reverse to correct. 154 | card_move.reverse() 155 | 156 | # Return the cards 157 | return card_move 158 | 159 | def resolve_move_from(self, max_stack=0): 160 | """ 161 | Actually modify the stack by removing the cards. 'max_stack' argument 162 | limits the number of cards we can move. 163 | """ 164 | 165 | # Again, make sure we can actually do this move 166 | if not self.is_move_from_legal(): 167 | raise Exception("Trying to force illegal move") 168 | 169 | # If there's no max stack, just take all the cards we can. If not, only 170 | # take the maximum number. 171 | if not max_stack: 172 | card_move = self.which_cards_moving() 173 | else: 174 | card_move = self.stack[-max_stack:] 175 | 176 | # Figure out what's left 177 | remaining_keep = len(self.stack) - len(card_move) 178 | if len(card_move) == len(self.stack): 179 | self.stack = [] 180 | else: 181 | self.stack = self.stack[0:remaining_keep] 182 | 183 | # Return the cards that are leaving 184 | return card_move 185 | 186 | def is_complete(self): 187 | """ 188 | Quick check: Is this stack collapsed and done? Used for detecting game 189 | end. 190 | """ 191 | if self.stack == "X": 192 | return 1 193 | 194 | # The two different patterns of complete number cards 195 | if (self.stack == ["0R", "9B", "8R", "7B", "6R"] or 196 | self.stack == ["0B", "9R", "8B", "7R", "6B"]): 197 | return 1 198 | 199 | return 0 200 | 201 | 202 | class Game(object): 203 | """ 204 | A game is a collection of stacks and rules governing the generation 205 | of plausible moves, as well as a meta-solver function that solves the game. 206 | """ 207 | 208 | face_cards = ["HH", "SS", "CC", "DD"] * 4 209 | number_cards = [ 210 | "0B", "0R", "9B", "9R", "8B", 211 | "8R", "7B", "7R", "6B", "6R" 212 | ] * 2 213 | 214 | def __init__(self, card_stacks=9, freecells=1, max_depth=100): 215 | """ 216 | Initial setup of the stacks and free cells; 'how_many_free' is the 217 | number of unlocked freecells. 218 | """ 219 | 220 | base_stacks = [Stack("stack", 0) for _ in range(card_stacks)] 221 | cell_stacks = [Stack("freecell", 0) for _ in range(freecells)] 222 | self.stacks = base_stacks + cell_stacks 223 | self.card_stacks = card_stacks 224 | self.depth = 0 225 | self.max_depth = max_depth 226 | self.move_history = [] 227 | self.score = 0 228 | 229 | def __str__(self): 230 | """ Again, user-friendly printing helper. """ 231 | base_str = "Current Game State...\n=======\n" 232 | for i in range(len(self.stacks)): 233 | base_str += "#%d %s\n" % (i, str(self.stacks[i])) 234 | 235 | base_str += self.hash() + "\n" 236 | base_str += "=====" 237 | 238 | return base_str 239 | 240 | def hash(self): 241 | """ Hash the entire game, one stack at a time. """ 242 | stack_chunks = [] 243 | for i in range(len(self.stacks)): 244 | stack_chunks.append("%s/" % self.stacks[i].hash()) 245 | 246 | # Why do we sort the stacks? Imagine a game with just two stacks, one 247 | # which has 888, and one which is empty. "888 / empty" is the same 248 | # game as "empty / 888". Sorting resolves this 249 | stack_chunks.sort() 250 | stack_text = "".join(stack_chunks) 251 | 252 | return stack_text 253 | 254 | @staticmethod 255 | def seed(seed): 256 | """ 257 | Very dumb helper to set seed. I thought I might need something more 258 | sophisticated, but I didn't. 259 | """ 260 | random.seed(seed) 261 | 262 | def deal_cards(self): 263 | """ If we don't have a game in mind, generate a random one. """ 264 | 265 | # 6-10 black and red * 2, 4x each face color 266 | cards = self.__class__.face_cards + self.__class__.number_cards 267 | 268 | # Shuffle 269 | random.shuffle(cards) 270 | 271 | # Divide into stacks 272 | new_stacks = [cards[(i * 4):(i * 4) + 4] for i in 273 | range(self.card_stacks)] 274 | 275 | # Initialize the actual stacks 276 | for i in range(self.card_stacks): 277 | self.stacks[i].init_cards(new_stacks[i]) 278 | 279 | def exact_setup(self, game_hash): 280 | """ Play a specific game based on a user-provided hash. """ 281 | 282 | stack_set = [] 283 | 284 | stack_hashes = [x for x in game_hash.split("/") if len(x)] 285 | for stack in stack_hashes: 286 | stack_type = "stack" if stack[0] == "S" else "freecell" 287 | 288 | past_card = -1 289 | 290 | if len(stack) == 1: 291 | cards = [] 292 | lock_type = 0 293 | elif not stack[1:].startswith("X"): 294 | cards = list([stack[(1 + i):(1 + i + 2)] for i in 295 | range(0, len(stack[1:]), 2)]) 296 | lock_type = 0 297 | else: 298 | lock_type = 1 299 | cards = "X" 300 | past_card = int(stack[3:5]) 301 | 302 | new_stack = Stack(stack_type, lock_type) 303 | new_stack.stack = cards 304 | if past_card > -1: 305 | new_stack.past_cards = past_card 306 | 307 | stack_set.append(new_stack) 308 | 309 | # Overwrite the game's whole stack set with the stacks. 310 | self.stacks = stack_set 311 | 312 | def get_score(self, override=0): 313 | """ Score for current game. These point values were my first guess. """ 314 | 315 | # We've already calculated a score, so just return it 316 | if self.score and not override: 317 | return self.score 318 | 319 | score = 0 320 | # Iterate over the stacks 321 | for i in self.stacks: 322 | # Collapsed? 20 points 323 | if i.is_complete(): 324 | score = score + 20 325 | # Empty stack? 10 points 326 | elif i.card_type == "stack" and not i.stack: 327 | score = score + 10 328 | # Stack with cards? 5 - the number of inaccessible cards. 329 | elif i.card_type == "stack" and i.stack: 330 | # What does a complete stack look like? 331 | if (len(i.stack) == 5 and 332 | (i.stack == ["0R", "9B", "8R", "7B", "6R"] or 333 | i.stack == ["0B", "9R", "8B", "7R", "6B"])): 334 | score = 10 335 | else: 336 | num_cards_trapped = next( 337 | (j + 1 for j in range(len(i.stack) - 2, -1, -1) if not 338 | i.compatible(i.stack[j], i.stack[j + 1])), 0) 339 | score = score + 5 - num_cards_trapped 340 | 341 | self.score = score 342 | return score 343 | 344 | def is_complete(self): 345 | """ 346 | Is the game complete? Check all stacks and look for collapsed stacks 347 | equal in number to card types. 348 | """ 349 | 350 | num_complete = sum([self.stacks[i].is_complete() for i in 351 | range(len(self.stacks))]) 352 | return num_complete == 8 353 | 354 | def is_dead(self): 355 | """ If there are no valid moves, this method of proceeding is dead. """ 356 | return len(self.enumerate_moves()) == 0 357 | 358 | def enumerate_moves(self): 359 | """ List all valid moves but don't execute them. """ 360 | 361 | valid_moves = [] 362 | 363 | # Check moves from every cell to every cell using a nested loop. The i 364 | # iterator will be the destination and the j iterator the origin. 365 | for i in range(len(self.stacks)): 366 | # Don't bother checking moves to locked cell 367 | if self.stacks[i].locked: 368 | continue 369 | 370 | # And now the origin 371 | for j in range(len(self.stacks)): 372 | # Self move isn't a valid move 373 | if j == i: 374 | continue 375 | 376 | # Don't just undo the previous move (the state iterator in 377 | # global_solve should prevent this anyway) 378 | if self.move_history and self.move_history[-1] == (i, j): 379 | continue 380 | 381 | # Valid moves -- it's valid only if the top card in the 382 | # current movable stack would be a legal move 383 | if (self.stacks[j].is_move_from_legal() and 384 | self.stacks[i].is_move_to_legal( 385 | [self.stacks[j].which_cards_moving()[0]])): 386 | valid_moves.append((j, i)) 387 | 388 | # Return all valid moves 389 | return valid_moves 390 | 391 | def play_game(self, moves, print_level): 392 | """ 393 | Once a solution has been found, execute the move set and print the 394 | output. 395 | """ 396 | 397 | # No moves??? Uh???? 398 | if not moves: 399 | raise Exception("No moves for successful game solve?") 400 | 401 | count = 1 402 | fixed_moves = [] 403 | 404 | # Iterate the moves and unpack 405 | for i, j in moves: 406 | 407 | # Take the cards off the origin stack 408 | if self.stacks[j].card_type == "freecell": 409 | cards_move = self.stacks[i].resolve_move_from(1) 410 | else: 411 | cards_move = self.stacks[i].resolve_move_from(0) 412 | 413 | y_offset_pre = len(self.stacks[i].stack) 414 | y_offset_post = len(self.stacks[j].stack) 415 | 416 | # The text we're going to print 417 | unlock_text = "" 418 | cards_move_text = "[%s]" % ", ".join([str(c) for c in cards_move]) 419 | old_stack_text = "[%s]" % ", ".join([str(c) for c in 420 | self.stacks[j].stack]) 421 | 422 | # Now put the card on the destination stack. 423 | self.stacks[j].resolve_move_to(cards_move) 424 | 425 | # Check if that collapsed 426 | if self.stacks[j].stack != "X": 427 | new_stack_text = "[%s]" % ", ".join([str(c) for c in 428 | self.stacks[j].stack]) 429 | else: 430 | new_stack_text = "Collapse" 431 | 432 | # Show the user 433 | if print_level > -1: 434 | print("Move %d: Move from %s %d to %s %d" % 435 | (count, self.stacks[i].card_type, i, 436 | self.stacks[j].card_type, j)) 437 | print(" %s -> %s = %s%s" % 438 | (cards_move_text, old_stack_text, 439 | new_stack_text, unlock_text)) 440 | 441 | fixed_moves.append((i, y_offset_pre, j, y_offset_post)) 442 | count += 1 443 | 444 | # Hand back the same move set with numbers of cards attached for 445 | # whatever reason 446 | return fixed_moves 447 | 448 | def global_solve(self, print_level=0): 449 | """ Greedy hill-climber queue search to solve the game. """ 450 | 451 | # Because this isn't recursive, only the top level should call this 452 | if self.depth > 0: 453 | raise Exception("Should only call this from top level.") 454 | 455 | begin = timer() 456 | print("Solving game...") 457 | 458 | # Record previously visited game states to avoid loops 459 | visited_nodes = [] 460 | 461 | # deque is an efficient fifo data structure, but this might not 462 | # actually matter given we're re-sorting the queue later. 463 | nodes_to_visit = deque([self]) 464 | 465 | # These are mostly about print outputs -- 466 | max_depth = 0 467 | max_score = 0 468 | i = 0 469 | done = 0 470 | 471 | # Let's just go through the queue 472 | while nodes_to_visit: 473 | current = nodes_to_visit.popleft() 474 | 475 | # Have we already been to the state we're trying to go to? 476 | game_hash = current.hash() 477 | if game_hash in visited_nodes: 478 | continue 479 | 480 | # Print anything? 481 | if print_level > 0 and (current.get_score() > max_score or 482 | current.depth > max_depth or 483 | print_level == 2): 484 | print("%d [D%d L%d]: %s. Score: %d" % 485 | (i, current.depth, len(nodes_to_visit), 486 | current.hash(), current.get_score())) 487 | max_depth = max(max_depth, current.depth) 488 | max_score = max(max_score, current.get_score()) 489 | 490 | # Mark this new state as having been visited 491 | visited_nodes.append(current.hash()) 492 | 493 | # Are we done here? 494 | if current.is_complete(): 495 | end = timer() 496 | print("Game complete in %d moves. Time elapsed %.2f seconds" % 497 | (len(current.move_history), round(end - begin, 2))) 498 | result_moves = self.play_game( 499 | current.move_history, print_level) 500 | done = 1 501 | break 502 | 503 | # If not, let's play -- what are my current descendents? 504 | results = current.solve() 505 | if results is not None: 506 | # Add the current descendents to the queue 507 | nodes_to_visit.extend(results) 508 | 509 | # Cheat by sorting -- greedy hill climb 510 | nodes_to_visit = deque(sorted(nodes_to_visit, 511 | key=lambda k: -k.get_score())) 512 | i += 1 513 | 514 | # Note to the user it's not solvable 515 | if not done: 516 | print("Game cannot be solved.") 517 | return [] 518 | 519 | return result_moves 520 | 521 | def solve(self): 522 | """ Ask the current game state for its immediate children. """ 523 | 524 | # Soft cap on complexity. This typically doesn't get invoked. 525 | if self.depth > self.max_depth: 526 | return None 527 | 528 | # If it's complete the global solver should have noticed 529 | if self.is_complete(): 530 | raise Exception("Trying to solve complete game.") 531 | 532 | # No moves? Die. 533 | if self.is_dead() and self.depth > 0: 534 | return None 535 | 536 | # Get ready to store the children 537 | results = [] 538 | valid_moves = self.enumerate_moves() 539 | 540 | # Iterate through moves 541 | for move in valid_moves: 542 | i, j = move 543 | 544 | # Child is a copy of the current game which we'll modify 545 | new_game = copy.deepcopy(self) 546 | 547 | # When resolving, there are two routes -- move one of stack 548 | # to freecell, or move all of stack somewhere. 549 | if new_game.stacks[j].card_type == "freecell": 550 | cards_move = new_game.stacks[i].resolve_move_from(1) 551 | else: 552 | cards_move = new_game.stacks[i].resolve_move_from(0) 553 | 554 | # Run the move 555 | new_game.stacks[j].resolve_move_to(cards_move) 556 | 557 | # Now add 1 to child depth, add the move to the move history, 558 | # pre-bake the score, and add to the list of children 559 | new_game.depth = new_game.depth + 1 560 | new_game.move_history.append(move) 561 | new_game.get_score(1) 562 | results.append(new_game) 563 | 564 | # This was a secondary check in case I wanted to limit valid moves in 565 | # the above iterator, but I ultimately didn't, so this should never 566 | # happen 567 | if self.depth == 0 and not results: 568 | raise Exception("Impossible to solve game") 569 | 570 | # No results? 571 | if not results: 572 | return None 573 | 574 | # Results 575 | return results 576 | 577 | 578 | def main(): 579 | """ 580 | Dumb helper to run basic launch from terminal functionality of exa_logic.py 581 | """ 582 | my_game = Game() 583 | my_game.deal_cards() 584 | 585 | print(my_game) 586 | time.sleep(0.5) 587 | my_game.global_solve(0) 588 | 589 | 590 | if __name__ == "__main__": 591 | main() 592 | -------------------------------------------------------------------------------- /manual/terminal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 63 | 84 | 85 | 86 | aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ p aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ py aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ pyt aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ pyth aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ pytho aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ python aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ python aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ python e aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ python ex aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ python exa aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ python exa_ aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ python exa_l aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ python exa_lo aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ python exa_logic.py aaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ python exa_logic.py Current Game State...=======#0 Type: stack: ['6B', 'DD', 'CC', '8B']#1 Type: stack: ['0B', 'SS', '6R', '9B']#2 Type: stack: ['DD', 'SS', 'DD', '0R']#3 Type: stack: ['0R', 'CC', 'HH', '7B']#4 Type: stack: ['SS', '7R', '6R', '8R']#5 Type: stack: ['SS', '7B', '8R', '9B']#6 Type: stack: ['HH', '6B', '7R', '9R']#7 Type: stack: ['8B', 'CC', '9R', 'HH']#8 Type: stack: ['0B', 'CC', 'DD', 'HH']#9 Type: freecell: []F/S0BCCDDHH/S0BSS6R9B/S0RCCHH7B/S6BDDCC8B/S8BCC9RHH/SDDSSDD0R/SHH6B7R9R/SSS7B8R9B/SSS7R6R8R/=====Solving game...Game complete in 34 moves. Time elapsed 0.21 secondsMove 1: Move from stack 4 to stack 1 [8R] -> [0B, SS, 6R, 9B] = [0B, SS, 6R, 9B, 8R]Move 2: Move from stack 3 to stack 1 [7B] -> [0B, SS, 6R, 9B, 8R] = [0B, SS, 6R, 9B, 8R, 7B]Move 3: Move from stack 4 to stack 1 [6R] -> [0B, SS, 6R, 9B, 8R, 7B] = [0B, SS, 6R, 9B, 8R, 7B, 6R]Move 4: Move from stack 4 to stack 0 [7R] -> [6B, DD, CC, 8B] = [6B, DD, CC, 8B, 7R]Move 5: Move from stack 4 to freecell 9 [SS] -> [] = [SS]Move 6: Move from stack 1 to stack 2 [9B, 8R, 7B, 6R] -> [DD, SS, DD, 0R] = [DD, SS, DD, 0R, 9B, 8R, 7B, 6R]Move 7: Move from stack 2 to stack 4 [0R, 9B, 8R, 7B, 6R] -> [] = [0R, 9B, 8R, 7B, 6R]Move 8: Move from stack 7 to stack 3 [HH] -> [0R, CC, HH] = [0R, CC, HH, HH]Move 9: Move from stack 8 to stack 3 [HH] -> [0R, CC, HH, HH] = [0R, CC, HH, HH, HH]Move 10: Move from stack 8 to stack 2 [DD] -> [DD, SS, DD] = [DD, SS, DD, DD]Move 11: Move from stack 0 to stack 6 [8B, 7R] -> [HH, 6B, 7R, 9R] = [HH, 6B, 7R, 9R, 8B, 7R]Move 12: Move from stack 8 to stack 0 [CC] -> [6B, DD, CC] = [6B, DD, CC, CC]Move 13: Move from stack 7 to stack [CC] -> [6B, DD, CC, CC] = [6B, DD, CC, CC, CC]Move 15: Move from stack 7 to stack 8 [8B] -> [0B, 9R] = [0B, 9R, 8B]Move 16: Move from stack 0 to stack 7 [CC, CC, CC] -> [] = [CC, CC, CC]Move 17: Move from stack 0 to stack 2 [DD] -> [DD, SS, DD, DD] = [DD, SS, DD, DD, DD]Move 18: Move from stack 0 to stack 6 [6B] -> [HH, 6B, 7R, 9R, 8B, 7R] = [HH, 6B, 7R, 9R, 8B, 7R, 6B]Move 19: Move from stack 3 to stack 0 [HH, HH, HH] -> [] = [HH, HH, HH]Move 20: Move from stack 3 to stack 7 [CC] -> [CC, CC, CC] = CollapseMove 21: Move from stack 5 to stack 3 [9B] -> [0R] = [0R, 9B]Move 22: Move from stack 5 to stack 3 [8R] -> [0R, 9B] = [0R, 9B, 8R]Move 23: Move from stack 5 to stack 3 [7B] -> [0R, 9B, 8R] = [0R, 9B, 8R, 7B]Move 24: Move from stack 1 to stack 3 [6R] -> [0R, 9B, 8R, 7B] = [0R, 9B, 8R, 7B, 6R]Move 25: Move from stack 1 to stack 5 [SS] -> [SS] = [SS, SS]Move 26: Move from stack 6 to Move 26: Move from stack 6 to stack 1 [9R, 8B, 7R, 6B] -> [0B] = [0B, 9R, 8B, 7R, 6B]Move 27: Move from stack 6 to stack 8 [7R] -> [0B, 9R, 8B] = [0B, 9R, 8B, 7R]Move 28: Move from stack 6 to stack 8 [6B] -> [0B, 9R, 8B, 7R] = [0B, 9R, 8B, 7R, 6B]Move 29: Move from stack 6 to stack 0 [HH] -> [HH, HH, HH] = CollapseMove 30: Move from stack 2 to stack 6 [DD, DD, DD] -> [] = [DD, DD, DD]Move 31: Move from stack 5 to stack 2 [SS, SS] -> [DD, SS] = [DD, SS, SS, SS]Move 32: Move from freecell 9 to stack 2 [SS] -> [DD, SS, SS, SS] = [DD, SS, SS, SS, SS]Move 33: Move from stack 2 to stack 5 [SS, SS, SS, SS] -> [] = CollapseMove 34: Move from stack 6 to stack 2 [DD, DD, DD] -> [DD] = Collapseaaronrudkin@mb:~/Desktop/exapunks_solitaire_bot$ 87 | --------------------------------------------------------------------------------