diff --git a/.gitignore b/.gitignore index 20b4468..1abdb47 100644 --- a/.gitignore +++ b/.gitignore @@ -111,4 +111,5 @@ ENV/ # Program specific files *bot_algo_time*.json *montecarlo_tree*.json -script.py \ No newline at end of file +script.py +montecarlo \ No newline at end of file diff --git a/Makefile b/Makefile index 233cd06..9fd1bc1 100644 --- a/Makefile +++ b/Makefile @@ -56,22 +56,14 @@ lint: lint/flake8 ## check style test: ## run tests quickly with the default Python pytest -# This takes a very long time to execute -# coverage: ## check code coverage quickly with the default Python -# coverage run --source quantikai -m pytest -# coverage report -m -# coverage html -# $(BROWSER) htmlcov/index.html - install: clean ## install the package to the active Python's site-packages ## python setup.py install - pip install -r requirements_dev.txt - pip install --editable . + pip install --editable .[dev] cli: # e.g. make cli ARG=bot - python src/quantikai/cli.py $(ARG) + quantikai $(ARG) dev: flask --app src/quantikai/wsgi.py run --debug devg: - gunicorn -w 4 'quantikai.wsgi:create_app()' \ No newline at end of file + gunicorn --workers 3 'quantikai.wsgi:create_app()' \ No newline at end of file diff --git a/README.md b/README.md index 75985b3..68b4dd0 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,13 @@ Tested on Linux with Python 3.12 make install # Get help - python src/quantikai/cli.py --help - # Same as: - make cli ARG=--help + quantikai --help # Play against a bot - make cli ARG=bot + quantikai bot # Play again yourself - make cli ARG=human + quantikai human # Deploy the web interface # with gunicorn (recommended) @@ -52,17 +50,17 @@ FEATURE IN PROGRESS: To get a better bot (from the web interface), do: ```bash - make cli ARG=montecarlo + quantikai montecarlo --depth=2 make devg ``` -`make cli ARG=montecarlo` pre-computes the game tree, going much deeper than the on-the-fly algorithm. The result is then used for the first -few moves on the board, subsequent moves are calcultaed on the fly. +`quantikai montecarlo --depth=2` pre-computes the game tree, going much deeper than the on-the-fly algorithm. The result is then used for the first +2 moves on the board, subsequent moves are calcultaed on the fly. ## Timing ```bash - make cli ARG=timer + quantikai timer ``` This writes to a file "bot_algo_time.json". @@ -158,6 +156,38 @@ Each run is composed of 3 phases: Update the score of each node that has been visited during this run. If the node is a move by the current player, add the reward. If the node is a move by the opponent, then flip the reward: 0 for a win of the current player, number of moves played for a loss. +#### Montecarlo - pre-compute the game tree + +Pre-compute the game tree and save it to files (one file per number of pieces on the board and color to play to keep the files small enough to hold in memory). +Expected advantage: compute the tree with way more iterations, getting a better result. +The issue is that in that case, we compute every state of the board instead of using symmetries for the first 2 moves. Indeed, the opponent may play anything, so if we compute only non-redundant moves we have to map the other board state to these moves. Otherwise, the increased number of moves to compute (eg 16*4 instead of 3 for the first move) negates the advantage of offline computation. + +#### Montecarlo - paralellization + +See the biography for running parallel computing for the MonteCarlo method, the simplest is also the fastest: `single-run parallelization`, run n iterations in different processes without sharing data and sum the results. + +Significant speed-up with the CLI, but the `multiprocess` library interfers with the gunicorn processes (`multiprocess.dummy` is worse than) so it needs more work to use it with the web app. + +10'000 iterations, algo time per number of pawns on the board: + +```json + "0": 16.65, + "1": 14.92, + "2": 13.11, + "3": 2.9, + "4": 10.3, +``` + +10'000 iterations, 5 processes running 2'000 iterations each: + +```json + "0": 4.3, + "1": 4.96, + "2": 4.29, + "3": 1.15, + "4": 3.07, +``` + ### Speed bottleneck A Node contains a board and the next move to play. @@ -173,7 +203,7 @@ How to measure time gains: print(min(d.repeat(20, 100000))) ``` -- for global algos: `make cli ARG=timer` +- for global algos: `quantikai timer` #### Board operations: save the board state as a dict of moves vs a list of list @@ -259,11 +289,16 @@ TODO #### Parallel computation -Instead of `n_iter` sequential runs, we might imagine doing `n` runs in parallel for `n_iter` times. We would introduce a random element in the -selection steps, so that the `n` runs are different. This is probably getting closer to a reinforcement learning setup, with a Markov decision process. +For the Monte Carlo tree search, there are 3 methods of parallelization: + +- leaf parallelization +- root (or single-run) parallelization +- tree parallelization ## Sources 1. [A Survey of MonteCarlo Search Methods](http://www.incompleteideas.net/609%20dropbox/other%20readings%20and%20resources/MCTS-survey.pdf) -2. [AlphaZero](https://arxiv.org/pdf/1712.01815) -3. [MuZero](https://arxiv.org/pdf/1911.08265) +2. [Tristan Cazenave, Nicolas Jouandeau. On the Parallelization of UCT. Computer Games Workshop, Jun 2007, Amsterdam, Netherlands. ￿hal-02310186￿](https://hal.science/hal-02310186/document) +3. [Parallel Monte-Carlo Tree Search](https://dke.maastrichtuniversity.nl/m.winands/documents/multithreadedMCTS2.pdf) +4. [AlphaZero](https://arxiv.org/pdf/1712.01815) +5. [MuZero](https://arxiv.org/pdf/1911.08265) diff --git a/pyproject.toml b/pyproject.toml index 5d187f8..9a110ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,15 +18,23 @@ classifiers = [ ] license = {text = "MIT license"} dependencies = [ - + "bump2version==0.5.11", + "wheel==0.33.6", + "typer==0.15.1", + "watchdog==6.0.0", + "Flask==3.1.0", + "gunicorn==23.0.0", ] +[project.scripts] +quantikai = "quantikai.cli:app" [project.optional-dependencies] dev = [ - "coverage", # testing - "black", # linting - "flake8", # linting - "pytest", # testing + "coverage==4.5.4", # testing + "black==24.10.0", # linting + "flake8==7.1.1", # linting + "pytest==6.2.4", # testing + "tox==3.14.0", ] [project.urls] @@ -55,9 +63,32 @@ strict = true warn_unreachable = true warn_no_return = true -[[tool.mypy.overrides]] +[tool.mypy.overrides] # Don't require test functions to include types module = "tests.*" allow_untyped_defs = true disable_error_code = "attr-defined" +[tool.black] +line-length = 79 +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist +)/ +''' + +[tool.flake8] +max-line-length = 79 +max-complexity = 18 + + + diff --git a/requirements_dev.txt b/requirements_dev.txt deleted file mode 100644 index 804510c..0000000 --- a/requirements_dev.txt +++ /dev/null @@ -1,12 +0,0 @@ -bump2version==0.5.11 -wheel==0.33.6 -tox==3.14.0 -coverage==4.5.4 -Sphinx==7.2.6 - -pytest==6.2.4 -typer==0.15.1 -watchdog==6.0.0 - -Flask==3.1.0 -gunicorn==23.0.0 \ No newline at end of file diff --git a/src/quantikai/bot/__init__.py b/src/quantikai/bot/__init__.py index 7ca6129..710707a 100644 --- a/src/quantikai/bot/__init__.py +++ b/src/quantikai/bot/__init__.py @@ -1,5 +1,5 @@ -from quantikai.bot.main import get_best_move -from quantikai.bot.comparison import get_method_times from quantikai.bot import montecarlo +from quantikai.bot.comparison import get_method_times +from quantikai.bot.main import get_best_move __all__ = ["get_best_move", "get_method_times", "montecarlo"] diff --git a/src/quantikai/bot/comparison.py b/src/quantikai/bot/comparison.py index 66172e1..394de08 100644 --- a/src/quantikai/bot/comparison.py +++ b/src/quantikai/bot/comparison.py @@ -1,13 +1,13 @@ """Compare the execution time of each method""" -import datetime import copy -import pathlib +import datetime import json +import pathlib import timeit from quantikai.bot import minmax, montecarlo -from quantikai.game import Board, Pawns, Colors, Player, Move +from quantikai.game import Board, Colors, Move, Pawns, Player def init_test_values(): @@ -55,8 +55,8 @@ def init_test_values(): def get_method_times(): - n_iter = 10 - n_repeat = 5 + n_iter = 5 + n_repeat = 1 timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") @@ -71,7 +71,8 @@ def get_method_times(): result_file = pathlib.Path("bot_algo_time_" + timestamp + ".json") current_color = Colors.BLUE times["montecarlo"]["args"] = { - "iterations": 10000, + "num_process": 5, + "iterations": 2000, "use_depth": True, } times["minmax"]["args"] = {} @@ -88,7 +89,9 @@ def get_method_times(): **times["minmax"]["args"] ) ) - times["minmax"][idx] = round(min(d.repeat(n_repeat, n_iter)) / n_iter, 2) + times["minmax"][idx] = round( + min(d.repeat(n_repeat, n_iter)) / n_iter, 2 + ) d = timeit.Timer( lambda: montecarlo.get_best_move( @@ -98,7 +101,9 @@ def get_method_times(): **times["montecarlo"]["args"] ) ) - times["montecarlo"][idx] = round(min(d.repeat(n_repeat, n_iter)) / n_iter, 2) + times["montecarlo"][idx] = round( + min(d.repeat(n_repeat, n_iter)) / n_iter, 2 + ) # Update the measures at every iteration result_file.write_text(json.dumps(times, indent=2)) diff --git a/src/quantikai/bot/main.py b/src/quantikai/bot/main.py index 79c348c..3a2f181 100644 --- a/src/quantikai/bot/main.py +++ b/src/quantikai/bot/main.py @@ -1,19 +1,19 @@ import pathlib -from quantikai.game import Board, Player, Move -from quantikai.bot import montecarlo, minmax +from quantikai.bot import montecarlo +from quantikai.game import Board, Move, Player def get_best_move( board: Board, current_player: Player, other_player: Player, - game_tree_file: pathlib.Path | None, + game_tree_folder: pathlib.Path | None = None, ) -> Move | None: return montecarlo.get_best_move( board=board, current_player=current_player, other_player=other_player, - game_tree_file=game_tree_file, + game_tree_folder=game_tree_folder, ) diff --git a/src/quantikai/bot/minmax.py b/src/quantikai/bot/minmax.py index 3602576..04a5a5f 100644 --- a/src/quantikai/bot/minmax.py +++ b/src/quantikai/bot/minmax.py @@ -1,6 +1,6 @@ import copy -from quantikai.game import Board, Player, Colors, Move +from quantikai.game import Board, Colors, Move, Player def get_best_move( diff --git a/src/quantikai/bot/montecarlo/__init__.py b/src/quantikai/bot/montecarlo/__init__.py index c1bf5ba..558c483 100644 --- a/src/quantikai/bot/montecarlo/__init__.py +++ b/src/quantikai/bot/montecarlo/__init__.py @@ -1,8 +1,8 @@ from quantikai.bot.montecarlo.main import ( + generate_tree, get_best_move, get_best_play, get_move_stats, - generate_tree, ) __all__ = ["get_best_move", "get_best_play", "get_move_stats", "generate_tree"] diff --git a/src/quantikai/bot/montecarlo/game_tree.py b/src/quantikai/bot/montecarlo/game_tree.py index f454617..286be33 100644 --- a/src/quantikai/bot/montecarlo/game_tree.py +++ b/src/quantikai/bot/montecarlo/game_tree.py @@ -1,9 +1,10 @@ -import pathlib import json +import pathlib -from quantikai.game import Board, FrozenBoard, Move, Colors from quantikai.bot.montecarlo.node import Node from quantikai.bot.montecarlo.score import MonteCarloScore +from quantikai.game import Board, Colors, FrozenBoard, Move +from quantikai.game.exceptions import InvalidFileException class GameTreeError(Exception): @@ -83,33 +84,72 @@ def get_move_stats(self, frozen_board: FrozenBoard, depth: int = 16): move_stats = [ (node.move_to_play, montecarlo) for node, montecarlo in self._game_tree.items() - if node.board == best_play[-1][0].board and node.move_to_play is not None + if node.board == best_play[-1][0].board + and node.move_to_play is not None ] - move_stats.sort(key=lambda x: x[1].times_visited, reverse=True) + move_stats.sort( + key=lambda x: (x[1].times_visited, x[1].score), reverse=True + ) return move_stats - # TODO - # Test, and remove these functions if I do not implement a pre-compute of the game tree - def to_file(self, path: pathlib.Path, player_color: Colors, max_depth: int = 16): - game_tree_json = ( + def get(self, depth: int) -> "GameTree": + return GameTree( { - "node": node.to_compressed(), - "montecarlo": montecarlo.to_compressed(), + node: montecarloscore + for node, montecarloscore in self._game_tree.items() + if len(node.board) == depth } - for node, montecarlo in self._game_tree.items() - if len(node.board) <= max_depth - and node.move_to_play is not None - and node.move_to_play.color == player_color ) - class StreamArray(list): - def __iter__(self): - return game_tree_json + @staticmethod + def sum(game_trees: list["GameTree"]) -> "GameTree": + if len(game_trees) == 0: + return GameTree() + if len(game_trees) == 1: + return game_trees[0] + new_gm = dict() + for node in game_trees[0]._game_tree: + mscores = [ + g._game_tree[node] for g in game_trees if node in g._game_tree + ] + new_gm[node] = MonteCarloScore( + times_visited=sum([m.times_visited for m in mscores]), + times_parent_visited=sum( + [m.times_parent_visited for m in mscores] + ), + score=sum([m.score for m in mscores]), + uct=sum([m.uct for m in mscores]), + ) + return GameTree(new_gm) - def __len__(self): - return 1 + # TODO + # Test, and remove these functions if I do not implement a pre-compute of the game tree + def to_file( + self, path: pathlib.Path, player_color: Colors, max_depth: int = 16 + ): + # TODO - possible improvement: for each board keep only the best move + # Not mandatory as the file size is < 500kB and it is nice for analysis purpose (eg board analysis function) + if not path.is_dir(): + raise InvalidFileException( + f"{path} does not exist or is not a directory." + ) - pathlib.Path(path).write_text(json.dumps(StreamArray())) + for idx in range(max_depth): + file_path = path / self.get_file_name( + depth=idx, player_color=player_color + ) + game_tree_json = [ + { + "node": node.to_compressed(), + "montecarlo": montecarlo.to_compressed(), + } + for node, montecarlo in self._game_tree.items() + if len(node.board) == idx + and node.move_to_play is not None + and node.move_to_play.color == player_color + ] + if len(game_tree_json) > 0: + file_path.write_text(json.dumps(game_tree_json)) class GameTreeDecoder(json.JSONDecoder): def __init__(self, *args, **kwargs): @@ -125,10 +165,29 @@ def object_hook(self, dct): ) return dct + @staticmethod + def get_file_name(depth: int, player_color: Colors): + return f"{depth}_{player_color.value}.json" + @classmethod - def from_file(cls, path: pathlib.Path): + def from_file( + cls, folder_path: pathlib.Path | None, depth: int, player_color: Colors + ): + if folder_path is None: + raise InvalidFileException( + f"The Montecarlo game tree file {folder_path} does not exist." + ) + file_path = pathlib.Path(folder_path) / cls.get_file_name( + depth=depth, player_color=player_color + ) + if not file_path.exists(): + raise InvalidFileException( + f"The Montecarlo game tree file {file_path} does not exist." + ) game_tree_as_list = json.loads( - pathlib.Path(path).read_text(), cls=cls.GameTreeDecoder + file_path.read_text(), cls=cls.GameTreeDecoder ) - game_tree: dict[Node, MonteCarloScore] = {n: m for n, m in game_tree_as_list} + game_tree: dict[Node, MonteCarloScore] = { + n: m for n, m in game_tree_as_list + } return cls(game_tree) diff --git a/src/quantikai/bot/montecarlo/main.py b/src/quantikai/bot/montecarlo/main.py index 42c410f..68ed8b7 100644 --- a/src/quantikai/bot/montecarlo/main.py +++ b/src/quantikai/bot/montecarlo/main.py @@ -1,14 +1,18 @@ import copy +import multiprocessing import pathlib +import random -from quantikai.game import Board, Player, Move, Colors +from quantikai.bot.montecarlo.game_tree import GameTree from quantikai.bot.montecarlo.node import Node from quantikai.bot.montecarlo.score import MonteCarloScore -from quantikai.bot.montecarlo.game_tree import GameTree +from quantikai.game import Board, Colors, Move, Player +from quantikai.game.exceptions import InvalidFileException ITERATIONS = 10000 USE_DEPTH = True -GAME_TREE_FILE_MAX_DEPTH = 3 +GAME_TREE_FILE_MAX_DEPTH = 2 +NUM_PROCESS = 1 def _explore_node( @@ -33,6 +37,9 @@ def _explore_node( player.color, optimize=not all_possible_moves, ) + # Order the possible moves randomly for the single-run parallelization + possible_moves = list(possible_moves) + random.shuffle(possible_moves) # Choose the node with the best trade-off exploration/exploitation node_to_explore = None @@ -58,36 +65,26 @@ def _explore_node( return is_win, node_to_explore -def _montecarlo_algo( +def _one_process_algo( board: Board, current_player: Player, other_player: Player, iterations: int, use_depth: bool, all_possible_moves: bool = False, + multiprocess_list: list = None, ) -> GameTree: - """Execute the montecarlo algorithm, up to generating the 'game tree' i.e. the graph of the moves with their scores. - Args: - board (Board): _description_ - current_player (Player): _description_ - other_player (Player): _description_ - iterations (int): _description_ - use_depth (bool): _description_ - all_possible_moves (bool, optional): whether to consider redundant moves or not (eg by exploiting board symmetry). Defaults to False. - Returns: - GameTree: _description_ - """ frozen_board = board.get_frozen() # hashable version of the board root_node = Node(board=frozen_board) game_tree = GameTree() game_tree.add(node=root_node) + random.seed() + for _ in range(iterations): is_current = False # which player is playing - parent_node = root_node - tmp_board = copy.deepcopy(board) tmp_player = copy.deepcopy(current_player) tmp_other = copy.deepcopy(other_player) @@ -135,16 +132,74 @@ def _montecarlo_algo( reward = depth_reward else: reward = 0 + if multiprocess_list is not None: + multiprocess_list.append(game_tree.get(depth=len(frozen_board))) return game_tree +def _montecarlo_algo( + board: Board, + current_player: Player, + other_player: Player, + iterations: int, + use_depth: bool, + all_possible_moves: bool = False, + num_process: int = NUM_PROCESS, +) -> GameTree: + """Execute the montecarlo algorithm, up to generating the 'game tree' i.e. the graph of the moves with their scores. + Args: + board (Board): _description_ + current_player (Player): _description_ + other_player (Player): _description_ + iterations (int): _description_ + use_depth (bool): _description_ + all_possible_moves (bool, optional): whether to consider redundant moves or not (eg by exploiting board symmetry). Defaults to False. + + Returns: + GameTree: _description_ + """ + if num_process == 1: + return _one_process_algo( + board=board, + current_player=current_player, + other_player=other_player, + iterations=iterations, + use_depth=use_depth, + all_possible_moves=all_possible_moves, + ) + manager = multiprocessing.Manager() + multiprocess_list = manager.list() + jobs = list() + for _ in range(num_process): + p = multiprocessing.Process( + target=_one_process_algo, + args=( + board, + current_player, + other_player, + iterations, + use_depth, + all_possible_moves, + multiprocess_list, + ), + ) + jobs.append(p) + p.start() + + for proc in jobs: + proc.join() + + return GameTree.sum(list(multiprocess_list)) + + def get_best_move( board: Board, current_player: Player, other_player: Player, iterations: int = ITERATIONS, use_depth: bool = USE_DEPTH, - game_tree_file: pathlib.Path | None = None, + num_process=NUM_PROCESS, + game_tree_folder: pathlib.Path | None = None, ) -> Move | None: """http://www.incompleteideas.net/609%20dropbox/other%20readings%20and%20resources/MCTS-survey.pdf Upper Confidence Bounds for Trees (UCT) @@ -182,15 +237,20 @@ def get_best_move( """ frozen_board = board.get_frozen() # hashable version of the board game_tree = None - if game_tree_file is not None and len(frozen_board) <= GAME_TREE_FILE_MAX_DEPTH: - game_tree = GameTree.from_file(path=game_tree_file) - else: + try: + game_tree = GameTree.from_file( + folder_path=game_tree_folder, + depth=len(frozen_board), + player_color=current_player.color, + ) + except InvalidFileException: game_tree = _montecarlo_algo( board=board, current_player=current_player, other_player=other_player, iterations=iterations, use_depth=use_depth, + num_process=num_process, ) return game_tree.get_best_move(frozen_board) @@ -203,19 +263,25 @@ def get_move_stats( depth: int = 0, iterations: int = ITERATIONS, use_depth: bool = USE_DEPTH, - game_tree_file: pathlib.Path | None = None, + num_process: int = NUM_PROCESS, + game_tree_folder: pathlib.Path | None = None, ) -> list[tuple[Move, MonteCarloScore]]: frozen_board = board.get_frozen() # hashable version of the board game_tree = None - if game_tree_file is not None and len(frozen_board) <= GAME_TREE_FILE_MAX_DEPTH: - game_tree = GameTree.from_file(game_tree_file) - else: + try: + game_tree = GameTree.from_file( + folder_path=game_tree_folder, + depth=len(frozen_board), + player_color=current_player.color, + ) + except InvalidFileException: game_tree = _montecarlo_algo( board=board, current_player=current_player, other_player=other_player, iterations=iterations, use_depth=use_depth, + num_process=num_process, ) return game_tree.get_move_stats(frozen_board=frozen_board, depth=depth) @@ -227,22 +293,27 @@ def get_best_play( depth: int = 16, iterations: int = ITERATIONS, use_depth: bool = USE_DEPTH, - game_tree_file: pathlib.Path | None = None, + num_process: int = NUM_PROCESS, + game_tree_folder: pathlib.Path | None = None, ) -> list[tuple[Node, MonteCarloScore]]: frozen_board = board.get_frozen() # hashable version of the board game_tree = None - if game_tree_file is not None and len(frozen_board) <= GAME_TREE_FILE_MAX_DEPTH: - game_tree = GameTree.from_file(game_tree_file) - else: + try: + game_tree = GameTree.from_file( + folder_path=game_tree_folder, + depth=len(frozen_board), + player_color=current_player.color, + ) + except InvalidFileException: game_tree = _montecarlo_algo( board=board, current_player=current_player, other_player=other_player, iterations=iterations, use_depth=use_depth, + num_process=num_process, ) - return game_tree.get_best_play( frozen_board=frozen_board, depth=depth, @@ -258,6 +329,7 @@ def generate_tree( max_depth: int = GAME_TREE_FILE_MAX_DEPTH, iterations: int = ITERATIONS, use_depth: bool = USE_DEPTH, + num_process=NUM_PROCESS, ) -> None: """Generate the MonteCarlo algorithm game tree and save it to a file. @@ -272,13 +344,19 @@ def generate_tree( iterations (int, optional): MonteCarlo algorithm parameter: number of iterations. Defaults to ITERATIONS. use_depth (bool, optional): MonteCarlo algorithm parameter: reward depends on the depth. Defaults to USE_DEPTH. """ - + # whether we use all possible moves or remove the redundant ones + all_possible_moves = not ( + max_depth <= 2 and main_player_color == first_player.color + ) game_tree = _montecarlo_algo( board=board, current_player=first_player, other_player=second_player, iterations=iterations, use_depth=use_depth, - all_possible_moves=True, # compute the whole tree, no optimization on "get_possible_moves" + all_possible_moves=all_possible_moves, + num_process=num_process, + ) + game_tree.to_file( + path=path, player_color=main_player_color, max_depth=max_depth ) - game_tree.to_file(path=path, player_color=main_player_color, max_depth=max_depth) diff --git a/src/quantikai/bot/montecarlo/node.py b/src/quantikai/bot/montecarlo/node.py index bc78c4b..4a9ce8e 100644 --- a/src/quantikai/bot/montecarlo/node.py +++ b/src/quantikai/bot/montecarlo/node.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, asdict +from dataclasses import dataclass from quantikai.game import FrozenBoard, Move @@ -17,14 +17,20 @@ def to_json(self): return { "board": self.board.to_json(), "move_to_play": ( - None if self.move_to_play is None else self.move_to_play.to_json() + None + if self.move_to_play is None + else self.move_to_play.to_json() ), } def to_compressed(self): return [ self.board.to_compressed(), - None if self.move_to_play is None else self.move_to_play.to_compressed(), + ( + None + if self.move_to_play is None + else self.move_to_play.to_compressed() + ), ] @classmethod diff --git a/src/quantikai/bot/montecarlo/score.py b/src/quantikai/bot/montecarlo/score.py index 478a98d..0004bea 100644 --- a/src/quantikai/bot/montecarlo/score.py +++ b/src/quantikai/bot/montecarlo/score.py @@ -3,7 +3,7 @@ DEFAULT_UCT: float = 1000000 # higher value to increase exploration, lower for exploitation -UCT_CST = 1.5 +UCT_CST = 2 @dataclass @@ -28,7 +28,9 @@ def compute_score( if self.times_visited == 0: self.uct = DEFAULT_UCT else: - self.uct = (self.score / self.times_visited) + 2 * uct_cst * math.sqrt( + self.uct = ( + self.score / self.times_visited + ) + 2 * uct_cst * math.sqrt( 2 * math.log(self.times_parent_visited) / self.times_visited ) self.times_parent_visited += 1 diff --git a/src/quantikai/cli.py b/src/quantikai/cli.py index e48aefb..bf2d1a6 100644 --- a/src/quantikai/cli.py +++ b/src/quantikai/cli.py @@ -1,10 +1,11 @@ """Console script for quantikai""" -import typer import pathlib + +import typer from rich.console import Console -from quantikai import game, play, bot +from quantikai import bot, game, play app = typer.Typer() console = Console() @@ -45,22 +46,14 @@ def rules(): """ ) board = game.Board( - board=[ - [ - (game.Pawns.B, game.Colors.RED), - (game.Pawns.A, game.Colors.RED), - (game.Pawns.B, game.Colors.RED), - None, - ], - [None, None, None, None], - [ - None, - None, - (game.Pawns.A, game.Colors.BLUE), - (game.Pawns.B, game.Colors.BLUE), - ], - [None, None, None, (game.Pawns.D, game.Colors.BLUE)], - ] + { + (0, 0): (game.Pawns.B, game.Colors.RED), + (0, 1): (game.Pawns.A, game.Colors.RED), + (0, 2): (game.Pawns.B, game.Colors.RED), + (2, 2): (game.Pawns.A, game.Colors.BLUE), + (2, 3): (game.Pawns.B, game.Colors.BLUE), + (3, 3): (game.Pawns.D, game.Colors.BLUE), + } ) board.print() print( @@ -75,15 +68,55 @@ def timer(): @app.command("montecarlo") -def generate_montecarlo_tree(): +def generate_montecarlo_tree(depth: int = 16): + montecarlo_dir = pathlib.Path.cwd() / "montecarlo" + montecarlo_dir.mkdir(parents=True, exist_ok=True) + use_depth = True + iterations = 25000 # 50000 OK, 100000 = process killed + num_process = 5 bot.montecarlo.generate_tree( - path=pathlib.Path("montecarlo_tree_blue.json"), + path=montecarlo_dir, board=game.Board(), first_player=game.Player(color=game.Colors.BLUE), second_player=game.Player(color=game.Colors.RED), main_player_color=game.Colors.RED, - iterations=50000, # 50000 OK, 100000 = process killed - use_depth=True, + iterations=iterations, + use_depth=use_depth, + max_depth=depth, + num_process=num_process, + ) + bot.montecarlo.generate_tree( + path=montecarlo_dir, + board=game.Board(), + first_player=game.Player(color=game.Colors.BLUE), + second_player=game.Player(color=game.Colors.RED), + main_player_color=game.Colors.BLUE, + iterations=iterations, + use_depth=use_depth, + max_depth=depth, + num_process=num_process, + ) + bot.montecarlo.generate_tree( + path=montecarlo_dir, + board=game.Board(), + first_player=game.Player(color=game.Colors.RED), + second_player=game.Player(color=game.Colors.BLUE), + main_player_color=game.Colors.RED, + iterations=iterations, + use_depth=use_depth, + max_depth=depth, + num_process=num_process, + ) + bot.montecarlo.generate_tree( + path=montecarlo_dir, + board=game.Board(), + first_player=game.Player(color=game.Colors.RED), + second_player=game.Player(color=game.Colors.BLUE), + main_player_color=game.Colors.BLUE, + iterations=iterations, + use_depth=use_depth, + max_depth=depth, + num_process=num_process, ) diff --git a/src/quantikai/game/__init__.py b/src/quantikai/game/__init__.py index a12c3df..eb8b29b 100644 --- a/src/quantikai/game/__init__.py +++ b/src/quantikai/game/__init__.py @@ -1,8 +1,8 @@ from quantikai.game.board import Board, FrozenBoard -from quantikai.game.player import Player -from quantikai.game.move import Move from quantikai.game.enums import Colors, Pawns from quantikai.game.exceptions import InvalidMoveError +from quantikai.game.move import Move +from quantikai.game.player import Player __all__ = [ "Board", diff --git a/src/quantikai/game/board.py b/src/quantikai/game/board.py index 4125f28..cc1c59d 100644 --- a/src/quantikai/game/board.py +++ b/src/quantikai/game/board.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from typing import Generator -from quantikai.game.exceptions import InvalidMoveError from quantikai.game.enums import Colors, Pawns +from quantikai.game.exceptions import InvalidMoveError from quantikai.game.move import Move @@ -28,7 +28,12 @@ def from_compressed(cls, body): return cls( frozenset( { - (int(item[0]), int(item[1]), Pawns[item[2]], Colors[item[3]]) + ( + int(item[0]), + int(item[1]), + Pawns[item[2]], + Colors[item[3]], + ) for item in body } ) @@ -39,7 +44,12 @@ class Board: _board: dict[tuple[int, int], tuple[Pawns, Colors]] _size: int = 4 - def __init__(self, board=None): + def __init__( + self, + board: ( + FrozenBoard | dict[tuple[int, int], tuple[Pawns, Colors]] | None + ) = None, + ): if board is not None: if isinstance(board, FrozenBoard): self._board = {(x, y): (p, c) for x, y, p, c in board.items()} @@ -87,7 +97,6 @@ def play(self, move: Move, strict: bool = True): self._board[(move.x, move.y)] = (move.pawn, move.color) return self._move_is_a_win(move.x, move.y) - # TODO def print(self): upper_idx = " " for x in range(0, 4): @@ -124,7 +133,9 @@ def have_possible_move(self, color: Colors): pass return False - def get_possible_moves(self, pawns: list[Pawns], color: Colors, optimize=False): + def get_possible_moves( + self, pawns: list[Pawns], color: Colors, optimize=False + ): """_summary_ Args: @@ -136,67 +147,44 @@ def get_possible_moves(self, pawns: list[Pawns], color: Colors, optimize=False): set[Move]: _description_ """ moves = set() - # Case 1: No pawn on the board - if optimize and len(self._board) == 0: - # Only need to check for one pawn and one section minus one cell because of symmetry - # Actually I am not even sure the first move matters - moves = { - (0, 0, Pawns.A, color), - (0, 1, Pawns.A, color), - (1, 1, Pawns.A, color), - } - # Case 2: 1 pawn on the board - # Diagonal symmetry: both sections next to the played section are the same and in opposite section 2 cells are - # the same - # Pawn: either play the same pawn or a different one. If different, does not matter which one - elif optimize and len(self._board) == 1: - (bx, by), (bpawn, _) = list(self._board.items())[0] - other_pawn = [p for p in Pawns if p != bpawn][0] - moves = ( - { - # other pawn, same section - (i, j, other_pawn, color) - for (i, j) in self._get_section_elements(bx, by) - if (i, j) != (bx, by) - } - | { - # other pawn, adjacent section - (i, j, other_pawn, color) - for (i, j) in self._get_section_elements((bx + 2) % 4, by) - } - | { - # same pawn, adjacent section (only 2 possible positions) - (i, j, bpawn, color) - for (i, j) in self._get_section_elements((bx + 2) % 4, by) - if i != bx and j != by - } - | { - # same or other pawn, opposite section - (i, j, p, color) - for p in [bpawn, other_pawn] - for i, j in self._get_section_elements((bx + 2) % 4, (by + 2) % 4) - } - ) - else: - # Get all possible moves - moves: set = { - (i, j, p, color) - for i in range(self._size) - for j in range(self._size) - for p in pawns - } - for (bx, by), (bpawn, bcolor) in self._board.items(): - for pawn in pawns: - moves.discard((bx, by, pawn, color)) - if bcolor != color: - for idx in range(self._size): - moves.discard((idx, by, bpawn, color)) - moves.discard((bx, idx, bpawn, color)) - for i, j in self._get_section_elements(bx, by): - moves.discard((i, j, bpawn, color)) + conditions = list() + if optimize: + # playable pawns are those already on board + one unknown + pawns_on_board = {p for p, _ in self._board.values() if p in pawns} + extra_pawns = set(pawns) - pawns_on_board + pawns = list(pawns_on_board) + if len(extra_pawns) > 0: + pawns.append(list(extra_pawns)[0]) + if self._horizontal_symmetry() == self._board: + conditions.append(lambda x, y: x <= 1) + if self._vertical_symmetry() == self._board: + conditions.append(lambda x, y: y <= 1) + if self._diag_left_symmetry() == self._board: + conditions.append(lambda x, y: x <= y) + if self._diag_right_symmetry() == self._board: + conditions.append(lambda x, y: x + y <= 3) + + moves: set = { + (i, j, p, color) + for i in range(self._size) + for j in range(self._size) + for p in pawns + } + for (bx, by), (bpawn, bcolor) in self._board.items(): + for pawn in pawns: + moves.discard((bx, by, pawn, color)) + if bcolor != color: + for idx in range(self._size): + moves.discard((idx, by, bpawn, color)) + moves.discard((bx, idx, bpawn, color)) + for i, j in self._get_section_elements(bx, by): + moves.discard((i, j, bpawn, color)) # Optimization: 30% speed-up using tuples instead of Move in the previous step for item in moves: - yield Move(*item) + if all([c(item[0], item[1]) for c in conditions]): + yield Move(*item) + else: + continue def get_frozen(self) -> FrozenBoard: return FrozenBoard( @@ -204,7 +192,12 @@ def get_frozen(self) -> FrozenBoard: ) def _check_move_is_valid(self, move: Move): - if move.x < 0 or move.y < 0 or move.x >= self._size or move.y >= self._size: + if ( + move.x < 0 + or move.y < 0 + or move.x >= self._size + or move.y >= self._size + ): raise InvalidMoveError( "x and y must be between 0 and 4, their values are: " + str(move.x) @@ -219,14 +212,18 @@ def _check_move_is_valid(self, move: Move): and self._board[(move.x, j)][0] == move.pawn and self._board[(move.x, j)][1] != move.color ): - raise InvalidMoveError("there is an opponent's pawn in that row") + raise InvalidMoveError( + "there is an opponent's pawn in that row" + ) for i in range(self._size): if ( (i, move.y) in self._board and self._board[(i, move.y)][0] == move.pawn and self._board[(i, move.y)][1] != move.color ): - raise InvalidMoveError("there is an opponent's pawn in that column") + raise InvalidMoveError( + "there is an opponent's pawn in that column" + ) # check section for i, j in self._get_section_elements(move.x, move.y): if ( @@ -234,10 +231,14 @@ def _check_move_is_valid(self, move: Move): and self._board[(i, j)][0] == move.pawn and self._board[(i, j)][1] != move.color ): - raise InvalidMoveError("there is an opponent's pawn in that section") + raise InvalidMoveError( + "there is an opponent's pawn in that section" + ) def _move_is_a_win(self, x: int, y: int): - return self._row_win(x) or self._column_win(y) or self._section_win(x, y) + return ( + self._row_win(x) or self._column_win(y) or self._section_win(x, y) + ) def _ctxt(self, txt: str, color: Colors | None = None) -> str: if color == "BLUE": @@ -248,7 +249,10 @@ def _ctxt(self, txt: str, color: Colors | None = None) -> str: def _row_win(self, x: int): other_pawns: set[Pawns] = set() for j in range(self._size): - if not (x, j) in self._board or self._board[(x, j)][0] in other_pawns: + if ( + not (x, j) in self._board + or self._board[(x, j)][0] in other_pawns + ): return False other_pawns.add(self._board[(x, j)][0]) return True @@ -256,7 +260,10 @@ def _row_win(self, x: int): def _column_win(self, y: int): other_pawns: set[Pawns] = set() for i in range(self._size): - if not (i, y) in self._board or self._board[(i, y)][0] in other_pawns: + if ( + not (i, y) in self._board + or self._board[(i, y)][0] in other_pawns + ): return False other_pawns.add(self._board[(i, y)][0]) return True @@ -278,3 +285,15 @@ def _get_section_elements( for i in range(2 * (x // 2), 2 * (x // 2 + 1)) for j in range(2 * (y // 2), 2 * (y // 2 + 1)) ) + + def _horizontal_symmetry(self) -> dict: + return {(3 - x, y): pc for ((x, y), pc) in self._board.items()} + + def _vertical_symmetry(self) -> dict: + return {(x, 3 - y): pc for ((x, y), pc) in self._board.items()} + + def _diag_left_symmetry(self) -> dict: + return {(y, x): pc for ((x, y), pc) in self._board.items()} + + def _diag_right_symmetry(self) -> dict: + return {(3 - y, 3 - x): pc for ((x, y), pc) in self._board.items()} diff --git a/src/quantikai/game/exceptions.py b/src/quantikai/game/exceptions.py index 415f978..a8620a5 100644 --- a/src/quantikai/game/exceptions.py +++ b/src/quantikai/game/exceptions.py @@ -1,3 +1,8 @@ class InvalidMoveError(Exception): def __init__(self, message): super().__init__(message) + + +class InvalidFileException(Exception): + def __init__(self, message): + super().__init__(message) diff --git a/src/quantikai/game/move.py b/src/quantikai/game/move.py index fcd751e..8035a54 100644 --- a/src/quantikai/game/move.py +++ b/src/quantikai/game/move.py @@ -10,7 +10,6 @@ class Move: pawn: Pawns color: Colors - # TODO def to_json(self): return { "x": self.x, @@ -26,4 +25,6 @@ def to_compressed(self): def from_compressed(cls, body): if body is None: return None - return cls(x=body[0], y=body[1], pawn=Pawns[body[2]], color=Colors[body[3]]) + return cls( + x=body[0], y=body[1], pawn=Pawns[body[2]], color=Colors[body[3]] + ) diff --git a/src/quantikai/game/player.py b/src/quantikai/game/player.py index 50c71a0..89eea60 100644 --- a/src/quantikai/game/player.py +++ b/src/quantikai/game/player.py @@ -1,20 +1,22 @@ from dataclasses import dataclass, field -from quantikai.game.exceptions import InvalidMoveError from quantikai.game.enums import Colors, Pawns +from quantikai.game.exceptions import InvalidMoveError @dataclass class Player: color: Colors - pawns: list[Pawns] = field(default_factory=lambda: 2 * [pawn for pawn in Pawns]) + pawns: list[Pawns] = field( + default_factory=lambda: 2 * [pawn for pawn in Pawns] + ) def remove(self, pawn: Pawns): self.check_has_pawn(pawn) self.pawns.remove(pawn) def check_has_pawn(self, pawn): - if not pawn in self.pawns: + if pawn not in self.pawns: raise InvalidMoveError("You do not have this pawn.") def get_printable_list_pawns(self): @@ -23,11 +25,15 @@ def get_printable_list_pawns(self): t += pawn.value + " " return "Player " + self.color.name + " available pawns: " + t - # TODO @classmethod def from_json(cls, body): - return cls(color=Colors[body["color"]], pawns=[Pawns(p) for p in body["pawns"]]) + return cls( + color=Colors[body["color"]], + pawns=[Pawns(p) for p in body["pawns"]], + ) - # TODO def to_json(self): - return {"color": self.color.name, "pawns": [p.name for p in self.pawns]} + return { + "color": self.color.name, + "pawns": [p.name for p in self.pawns], + } diff --git a/src/quantikai/play.py b/src/quantikai/play.py index ff3d153..36aa2af 100644 --- a/src/quantikai/play.py +++ b/src/quantikai/play.py @@ -1,7 +1,6 @@ import itertools -import random -from quantikai import game, bot +from quantikai import bot, game class InvalidInputError(Exception): @@ -20,13 +19,15 @@ def parse_input(user_input: str): int(inp[1]) except ValueError: raise InvalidInputError( - "Please enter the coordinates of your pawn as numbers separated by a space, e.g. 0 1 " + "Please enter the coordinates of your pawn as" + "numbers separated by a space, e.g. 0 1 " ) try: game.Pawns[inp[2]] except: raise InvalidInputError( - "Valid values for the pawn are: " + str([x.name for x in game.Pawns]) + "Valid values for the pawn are: " + + str([x.name for x in game.Pawns]) ) return int(inp[0]), int(inp[1]), game.Pawns[inp[2]] @@ -47,7 +48,11 @@ def _human_player_loop(board, player): x, y, pawn = None, None, None while 1: user_input = input( - "Player " + player.color.name + ", please play (" + pawn_list + ")\n" + "Player " + + player.color.name + + ", please play (" + + pawn_list + + ")\n" ) try: x, y, pawn = parse_input(user_input) @@ -75,7 +80,9 @@ def human_play(): try: is_a_win = _human_player_loop(board, player) if is_a_win: - print("Congratulations, player " + player.color.name + " WINS !") + print( + "Congratulations, player " + player.color.name + " WINS !" + ) break except game.InvalidMoveError as e: print(e) @@ -83,7 +90,9 @@ def human_play(): player = next(player_cycle) if not board.have_possible_move(player.color): print( - "Player " + player.color.name + " has no possible move left, he loses!" + "Player " + + player.color.name + + " has no possible move left, he loses!" ) break @@ -122,14 +131,18 @@ def bot_play(): move = bot.get_best_move(board, player, other_player) if move is None: - print("Player " + player.color.name + " gives up and loses.") + print( + "Player " + player.color.name + " gives up and loses." + ) break is_a_win = board.play(move) player.remove(move.pawn) board.print() if is_a_win: - print("Congratulations, player " + player.color.name + " WINS !") + print( + "Congratulations, player " + player.color.name + " WINS !" + ) break except game.InvalidMoveError as e: print(e) diff --git a/src/quantikai/wsgi.py b/src/quantikai/wsgi.py index f2370b0..c39800e 100644 --- a/src/quantikai/wsgi.py +++ b/src/quantikai/wsgi.py @@ -1,20 +1,20 @@ -from flask import Flask, render_template, request, session, jsonify import pathlib -from quantikai import game, bot +from flask import Flask, jsonify, render_template, request, session + +from quantikai import bot, game from quantikai.bot import montecarlo PLAYER_WIN_MSG = "Congratulations, you win!" BOT_WIN_MSG = "The bot wins!" -# TODO - not using the file -MONTECARLO_FILE = pathlib.Path("montecarlo_tree_blue.json") +MONTECARLO_FILE = pathlib.Path.cwd() / "montecarlo" def create_app(): app = Flask( __name__, - template_folder=pathlib.Path("web/templates"), - static_folder=pathlib.Path("web/static"), + template_folder=pathlib.Path("web") / "templates", + static_folder=pathlib.Path("web") / "static", ) app.secret_key = b'_5#y2L"F4Q8z\n\xec]/' @@ -72,7 +72,9 @@ def bot_turn(): board, bot_player, human_player, - game_tree_file=MONTECARLO_FILE if MONTECARLO_FILE.exists() else None, + game_tree_folder=( + MONTECARLO_FILE if MONTECARLO_FILE.exists() else None + ), ) if move is None: game_is_over = True @@ -81,7 +83,8 @@ def bot_turn(): game_is_over = board.play(move) bot_player.remove(move.pawn) if game_is_over or ( - not game_is_over and not board.have_possible_move(human_player.color) + not game_is_over + and not board.have_possible_move(human_player.color) ): game_is_over = True win_message = BOT_WIN_MSG @@ -108,14 +111,18 @@ def get_board_analysis(): human_player, bot_player, depth=depth, - game_tree_file=MONTECARLO_FILE if MONTECARLO_FILE.exists() else None, + game_tree_folder=( + MONTECARLO_FILE if MONTECARLO_FILE.exists() else None + ), ) return montecarlo.get_move_stats( board, bot_player, human_player, depth=depth, - game_tree_file=MONTECARLO_FILE if MONTECARLO_FILE.exists() else None, + game_tree_folder=( + MONTECARLO_FILE if MONTECARLO_FILE.exists() else None + ), ) @app.post("/gameprediction") @@ -129,14 +136,19 @@ def get_best_play(): board, human_player, bot_player, - game_tree_file=MONTECARLO_FILE if MONTECARLO_FILE.exists() else None, + game_tree_folder=( + MONTECARLO_FILE if MONTECARLO_FILE.exists() else None + ), + ) + else: + best_play = montecarlo.get_best_play( + board, + bot_player, + human_player, + game_tree_folder=( + MONTECARLO_FILE if MONTECARLO_FILE.exists() else None + ), ) - best_play = montecarlo.get_best_play( - board, - bot_player, - human_player, - game_tree_file=MONTECARLO_FILE if MONTECARLO_FILE.exists() else None, - ) return [(node.to_json(), mscore) for node, mscore in best_play] return app diff --git a/tests/bot/montecarlo/test_game_tree.py b/tests/bot/montecarlo/test_game_tree.py index d4f8ef3..8d6c3b5 100644 --- a/tests/bot/montecarlo/test_game_tree.py +++ b/tests/bot/montecarlo/test_game_tree.py @@ -1,9 +1,9 @@ import pytest from quantikai.bot.montecarlo.main import GameTree, MonteCarloScore -from quantikai.bot.montecarlo.score import DEFAULT_UCT -from quantikai.game import Board, Pawns, Colors, Move, FrozenBoard from quantikai.bot.montecarlo.node import Node +from quantikai.bot.montecarlo.score import DEFAULT_UCT +from quantikai.game import Board, Colors, Move, Pawns @pytest.fixture @@ -18,7 +18,9 @@ def parent_node(board): @pytest.fixture def node(board): - return Node(board=board.get_frozen(), move_to_play=Move(2, 2, Pawns.A, Colors.RED)) + return Node( + board=board.get_frozen(), move_to_play=Move(2, 2, Pawns.A, Colors.RED) + ) @pytest.fixture @@ -83,7 +85,10 @@ def test_get_best_play_max_depth(board, game_tree, parent_node, node): ( node, MonteCarloScore( - times_visited=1, times_parent_visited=1, score=1, uct=DEFAULT_UCT + times_visited=1, + times_parent_visited=1, + score=1, + uct=DEFAULT_UCT, ), ) ] @@ -99,7 +104,10 @@ def test_get_best_play_depth_0(board, game_tree, parent_node, node): ( node, MonteCarloScore( - times_visited=1, times_parent_visited=1, score=1, uct=DEFAULT_UCT + times_visited=1, + times_parent_visited=1, + score=1, + uct=DEFAULT_UCT, ), ) ] @@ -127,7 +135,10 @@ def test_get_best_play_depth_less(board, game_tree, parent_node, node): ( node, MonteCarloScore( - times_visited=1, times_parent_visited=1, score=1, uct=DEFAULT_UCT + times_visited=1, + times_parent_visited=1, + score=1, + uct=DEFAULT_UCT, ), ) ] @@ -154,13 +165,19 @@ def test_get_best_play_depth_1(board, game_tree, parent_node, node): ( node, MonteCarloScore( - times_visited=1, times_parent_visited=1, score=1, uct=DEFAULT_UCT + times_visited=1, + times_parent_visited=1, + score=1, + uct=DEFAULT_UCT, ), ), ( extra_node, MonteCarloScore( - times_visited=1, times_parent_visited=1, score=1, uct=DEFAULT_UCT + times_visited=1, + times_parent_visited=1, + score=1, + uct=DEFAULT_UCT, ), ), ] @@ -170,30 +187,25 @@ def test_get_best_play_depth_1(board, game_tree, parent_node, node): def test_to_file(game_tree, tmp_path): - filepath = tmp_path / "game_tree.json" - game_tree.to_file(filepath, player_color=Colors.BLUE) + game_tree.to_file(tmp_path, player_color=Colors.BLUE) def test_from_file_red(game_tree, tmp_path, board): - filepath = tmp_path / "game_tree.json" - game_tree.to_file(filepath, player_color=Colors.RED) - gm: GameTree = GameTree.from_file(filepath) + game_tree.to_file(tmp_path, player_color=Colors.RED) + gm: GameTree = GameTree.from_file( + tmp_path, depth=1, player_color=Colors.RED + ) assert gm._game_tree == { Node( - board=board.get_frozen(), move_to_play=Move(2, 2, Pawns.A, Colors.RED) + board=board.get_frozen(), + move_to_play=Move(2, 2, Pawns.A, Colors.RED), ): MonteCarloScore( times_visited=0, times_parent_visited=0, score=0, uct=DEFAULT_UCT ), Node( - board=board.get_frozen(), move_to_play=Move(2, 3, Pawns.A, Colors.RED) + board=board.get_frozen(), + move_to_play=Move(2, 3, Pawns.A, Colors.RED), ): MonteCarloScore( times_visited=0, times_parent_visited=0, score=0, uct=DEFAULT_UCT ), } - - -def test_from_file_blue(game_tree, tmp_path): - filepath = tmp_path / "game_tree.json" - game_tree.to_file(filepath, player_color=Colors.BLUE) - gm = GameTree.from_file(filepath) - assert len(gm._game_tree) == 0 diff --git a/tests/bot/montecarlo/test_montecarlo.py b/tests/bot/montecarlo/test_montecarlo.py index 001d5c9..1bded41 100644 --- a/tests/bot/montecarlo/test_montecarlo.py +++ b/tests/bot/montecarlo/test_montecarlo.py @@ -2,11 +2,10 @@ import copy - +from quantikai.bot import montecarlo from quantikai.bot.montecarlo.main import _montecarlo_algo -from quantikai.game import Board, Pawns, Colors, Player, Move from quantikai.bot.montecarlo.node import Node -from quantikai.bot import montecarlo +from quantikai.game import Board, Colors, Move, Pawns, Player def test_best_move_none(): diff --git a/tests/bot/test_minmax.py b/tests/bot/test_minmax.py index ec45947..6d5d80d 100644 --- a/tests/bot/test_minmax.py +++ b/tests/bot/test_minmax.py @@ -1,10 +1,7 @@ """Tests for `minmax` package.""" -import pytest - - -from quantikai.game import Board, Pawns, Colors, Player, Move from quantikai.bot import minmax +from quantikai.game import Board, Colors, Move, Pawns, Player def test_best_move_none(): diff --git a/tests/game/test_board.py b/tests/game/test_board.py index 8eeee75..eb40bb2 100644 --- a/tests/game/test_board.py +++ b/tests/game/test_board.py @@ -3,8 +3,7 @@ """Tests for `game` package.""" import pytest - -from quantikai.game import Board, Move, Pawns, Colors, InvalidMoveError +from quantikai.game import Board, Colors, InvalidMoveError, Move, Pawns @pytest.fixture @@ -109,7 +108,8 @@ def test_board_invalid_column(row_idx, column_idx): @pytest.mark.parametrize( - "pawn,row_idx, column_idx", [("A", 1, 1), ("B", 0, 3), ("C", 3, 0), ("D", 2, 2)] + "pawn,row_idx, column_idx", + [("A", 1, 1), ("B", 0, 3), ("C", 3, 0), ("D", 2, 2)], ) def test_board_invalid_section(pawn, row_idx, column_idx): board = Board( @@ -294,82 +294,139 @@ def test_get_possible_move_edge_case(): def test_get_possible_moves_optimize_empty_board(): board = Board() - moves = board.get_possible_moves( - pawns=list(Pawns), color=Colors.BLUE, optimize=True + moves = list( + board.get_possible_moves( + pawns=list(Pawns), color=Colors.BLUE, optimize=True + ) ) - assert set(moves) == { - Move(0, 0, Pawns.A, Colors.BLUE), - Move(0, 1, Pawns.A, Colors.BLUE), - Move(1, 1, Pawns.A, Colors.BLUE), - } + pawns = {m.pawn for m in moves} + assert len(pawns) == 1 + assert all({m.color == Colors.BLUE for m in moves}) + positions = {(m.x, m.y) for m in moves} + assert positions == {(0, 0), (0, 1), (1, 1)} -def test_get_possible_moves_optimize_one(): +def test_get_possible_moves_optimize_left_diag(): board = Board() board.play(Move(0, 0, Pawns.A, Colors.RED)) moves = set( - board.get_possible_moves(pawns=list(Pawns), color=Colors.BLUE, optimize=True) + board.get_possible_moves( + pawns=list(Pawns), color=Colors.BLUE, optimize=True + ) ) same_pawn_moves = { - Move(2, 1, Pawns.A, Colors.BLUE), - Move(3, 1, Pawns.A, Colors.BLUE), + Move(1, 2, Pawns.A, Colors.BLUE), + Move(1, 3, Pawns.A, Colors.BLUE), Move(2, 2, Pawns.A, Colors.BLUE), Move(2, 3, Pawns.A, Colors.BLUE), - Move(3, 2, Pawns.A, Colors.BLUE), Move(3, 3, Pawns.A, Colors.BLUE), } assert same_pawn_moves.issubset(moves) other_pawn_moves = moves - same_pawn_moves + other_pawns = {m.pawn for m in other_pawn_moves} + assert len(other_pawns) == 1 positions = {(m.x, m.y) for m in other_pawn_moves} assert positions == { - # bottom left section - (2, 0), - (2, 1), - (3, 0), - (3, 1), - # upper left section (0, 1), - (1, 0), + (0, 2), + (0, 3), (1, 1), - # bottom right section + (1, 2), + (1, 3), (2, 2), (2, 3), - (3, 2), (3, 3), } -def test_get_possible_moves_optimize_one_section_1(): - board = Board({(0, 1): (Pawns.A, Colors.RED)}) +def test_get_possible_moves_optimize_right_diag(): + board = Board() + board.play(Move(0, 3, Pawns.A, Colors.RED)) moves = set( - board.get_possible_moves(pawns=list(Pawns), color=Colors.BLUE, optimize=True) + board.get_possible_moves( + pawns=list(Pawns), color=Colors.BLUE, optimize=True + ) ) same_pawn_moves = { + Move(1, 0, Pawns.A, Colors.BLUE), + Move(1, 1, Pawns.A, Colors.BLUE), Move(2, 0, Pawns.A, Colors.BLUE), + Move(2, 1, Pawns.A, Colors.BLUE), Move(3, 0, Pawns.A, Colors.BLUE), - Move(2, 2, Pawns.A, Colors.BLUE), - Move(2, 3, Pawns.A, Colors.BLUE), - Move(3, 2, Pawns.A, Colors.BLUE), - Move(3, 3, Pawns.A, Colors.BLUE), } assert same_pawn_moves.issubset(moves) other_pawn_moves = moves - same_pawn_moves + other_pawns = {m.pawn for m in other_pawn_moves} + assert len(other_pawns) == 1 positions = {(m.x, m.y) for m in other_pawn_moves} assert positions == { - # bottom left section + (0, 0), + (0, 1), + (0, 2), + (1, 0), + (1, 1), + (1, 2), (2, 0), (2, 1), (3, 0), - (3, 1), - # upper left section + } + + +def test_get_possible_moves_optimize_horizontal(): + board = Board() + board.play(Move(1, 0, Pawns.A, Colors.RED)) + board.play(Move(2, 0, Pawns.A, Colors.RED)) + moves = set( + board.get_possible_moves( + pawns=list(Pawns), color=Colors.BLUE, optimize=True + ) + ) + same_pawn_moves = { + Move(0, 2, Pawns.A, Colors.BLUE), + Move(0, 3, Pawns.A, Colors.BLUE), + } + assert same_pawn_moves.issubset(moves) + other_pawn_moves = moves - same_pawn_moves + other_pawns = {m.pawn for m in other_pawn_moves} + assert len(other_pawns) == 1 + positions = {(m.x, m.y) for m in other_pawn_moves} + assert positions == { (0, 0), - (1, 0), + (0, 1), + (0, 2), + (0, 3), (1, 1), - # bottom right section - (2, 2), - (2, 3), - (3, 2), - (3, 3), + (1, 2), + (1, 3), + } + + +def test_get_possible_moves_optimize_vertical(): + board = Board() + board.play(Move(1, 0, Pawns.A, Colors.RED)) + board.play(Move(1, 3, Pawns.A, Colors.RED)) + moves = set( + board.get_possible_moves( + pawns=list(Pawns), color=Colors.BLUE, optimize=True + ) + ) + same_pawn_moves = { + Move(2, 1, Pawns.A, Colors.BLUE), + Move(3, 1, Pawns.A, Colors.BLUE), + } + assert same_pawn_moves.issubset(moves) + other_pawn_moves = moves - same_pawn_moves + other_pawns = {m.pawn for m in other_pawn_moves} + assert len(other_pawns) == 1 + positions = {(m.x, m.y) for m in other_pawn_moves} + assert positions == { + (0, 0), + (0, 1), + (1, 1), + (2, 0), + (2, 1), + (3, 0), + (3, 1), } diff --git a/tests/game/test_player.py b/tests/game/test_player.py deleted file mode 100644 index e69de29..0000000