diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/algorithms/__init__.py b/src/algorithms/__init__.py new file mode 100644 index 0000000..3c54937 --- /dev/null +++ b/src/algorithms/__init__.py @@ -0,0 +1,4 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# SPDX-License-Identifier: BSD 3-Clause - +# Copyright (c) 2022 Jimmy Bierenbroodspot. - +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/src/algorithms/bogorithm.py b/src/algorithms/bogorithm.py new file mode 100644 index 0000000..663ba90 --- /dev/null +++ b/src/algorithms/bogorithm.py @@ -0,0 +1,52 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# SPDX-License-Identifier: BSD 3-Clause - +# Copyright (c) 2022 Jimmy Bierenbroodspot. - +# ---------------------------------------------------------------------------------------------------------------------- +"""A mastermind algorithm that tries to use the fundamentals of bogosort to solve a game of mastermind. It pops a random +combination from the pool of possible combinations and checks if it is the correct code, then repeats. This is obviously +horrifically inefficient. + This algorithm needs at best 1 guess and at most n^r, where n = the amount of objects and r = the sample size, guesses. +Because the probability of each amount of guesses to win the game (k) is equally distributed the average amount of +guesses needed by this algorithm is n^r/2. For a standard game of mastermind (n = 6 and r = 4) is this 6^4/2 = 1296/2 = +648 and since this number for most algorithms is around 3 or 4 makes bogorithm orders of magnitude worse than most +algorithms out there. + This could be improved on by reducing the incompatible codes from the pool of possible guesses. This makes it the +simple algorithm but with a random guess rather than the first pick from the pool. +""" +import typing +import random + +import src.python.mastermind.mastermind as game +import initialization as init + +# Overwrite game length to max amount of guesses. +init.GAME_LENGTH = 1296 + + +def main() -> None: + game_simulation: typing.Generator[typing.Tuple[int, int, bool], game.Code, None] + answer: typing.Tuple[int, int, bool] + guess: game.Code + game_round: int = 0 + possible_combinations: typing.List[game.Code] = init.get_combinations() + + game_simulation = game.simulate_game( + init.COLOURS, init.GAME_LENGTH, init.GAME_WIDTH + ) + for _ in game_simulation: + game_round += 1 + + guess = possible_combinations.pop( + random.randint(0, len(possible_combinations) - 1) + ) + answer = game_simulation.send(guess) + + if answer[2]: + print(f"Game won in {game_round} guesses!") + break + else: + print("Game lost.") + + +if __name__ == "__main__": + main() diff --git a/src/algorithms/initialization.py b/src/algorithms/initialization.py new file mode 100644 index 0000000..fdbfa72 --- /dev/null +++ b/src/algorithms/initialization.py @@ -0,0 +1,56 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# SPDX-License-Identifier: BSD 3-Clause - +# Copyright (c) 2022 Jimmy Bierenbroodspot. - +# ---------------------------------------------------------------------------------------------------------------------- +"""A module containing some constants, types and functions which are commonly shared between algorithms.""" +import typing +import json + +import scripts.generate_set as generate_set +import src.python.mastermind.mastermind as game + +# These constants are declared here because they remain the same value in the entire module since I haven't implemented +# a way to customize them yet +GAME_WIDTH: int = 4 +GAME_LENGTH: int = 8 +COLOURS: typing.Tuple[int, ...] = tuple(num for num in range(6)) + +# Create generic Json type for ease of use, this has no actual functionality other than showing that something is a +# json serialized object. +Json: typing.Generic = typing.TypeVar("Json") + + +def get_combinations() -> Json: + """Retrieves a list of all possible combinations from combinations.json. + + :return: A Json serialized object with all possible combinations. + """ + json_io: typing.TextIO + json_string: str + + # Generate dataset with possible combinations + generate_set.main() + + with open("./combinations.json", "r") as json_io: + json_string = json_io.read() + + return json.loads(json_string) + + +def reduce( + possible_combinations: Json, guess: game.Code, score: typing.Tuple[int, int] +) -> Json: + """Compares the score of all combinations in possible_combinations against the given score. + + :param possible_combinations: A list of possible combinations. + :param guess: A sequence containing integers. + :param score: A tuple containing 2 integers. + :return: A list + """ + # Using a list comprehension is absolutely useless here because game.compare_codes is a relatively expensive + # function. I just really like writing comprehensions. + return [ + possible_combination + for possible_combination in possible_combinations + if score == game.compare_codes(guess, possible_combination) + ] diff --git a/src/algorithms/koois.py b/src/algorithms/koois.py new file mode 100644 index 0000000..d01ede9 --- /dev/null +++ b/src/algorithms/koois.py @@ -0,0 +1,116 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# SPDX-License-Identifier: BSD 3-Clause - +# Copyright (c) 2022 Jimmy Bierenbroodspot. - +# ---------------------------------------------------------------------------------------------------------------------- +""" +Mastermind solving algorithm using B. Kooi's new strategy algorithm. + +This code is developed using pseudocode found in the following article: +Kooi, B. (2005). Yet another mastermind strategy. ICGA Journal, 28(1), 13-20. +""" +import typing +import collections + +import initialization as init +import src.python.mastermind.mastermind as game + + +def main() -> None: + game_simulation: typing.Generator[typing.Tuple[int, int, bool], game.Code, None] + guess: game.Code + answer: typing.Tuple[int, int, bool] + categories: typing.Set[game.Code] + partition_counts: typing.Dict[game.Code, int] + largest_category: game.Code + game_round: int = 0 + combinations: init.Json = init.get_combinations() + + game_simulation = game.simulate_game( + init.COLOURS, init.GAME_LENGTH, init.GAME_WIDTH + ) + for _ in game_simulation: + game_round += 1 + + # Find all categories left in the combinations and count the partitions. + categories = get_all_categories(combinations) + partition_counts = { + category: get_partition_count(combinations, category) + for category in categories + } + # Find the largest category. + largest_category = max(partition_counts, key=partition_counts.get) + guess = next( + combination + for combination in combinations + if get_category(combination) == largest_category + ) + answer = game_simulation.send(guess) + print(f"Guessed: {guess}; answer: {answer}") + + # Check if the game is won + if answer[2]: + print(f"Game won in {game_round} guesses!") + break + + combinations = init.reduce(combinations, guess, answer[:2]) + else: + print("Game lost.") + + +def get_category(combination: game.Code) -> game.Code: + """Decides the category of a combination. In most papers these categories are describes as: AAAA, AAAB, AABB, AABC + and ABCD. + + :param combination: A Code combination of any width. + :return: A list that substitutes the letters in a category for integers in such that AABC == [0, 0, 1, 2]. + """ + # Creates a list of values within the counter, we do not actually know to which number each value belongs. + combination_counts: typing.List[int] = sorted( + collections.Counter(combination).values(), reverse=True + ) + category: game.Code = [] # This could've been a comprehension... + + # Appends the amount each colour (number) appears in the combination. + for counter, combination_count in enumerate(combination_counts): + category.extend([counter] * combination_count) + + return tuple(category) + + +def get_partition_count( + possible_combinations: init.Json, combination: game.Code +) -> int: + """Finds the amount a given combination can be partitioned in by comparing the codes and calculating the possible + answer. + + :param possible_combinations: A list with unique Code combinations. + :param combination: A Code combination. + :return: The amount of possible partitions. + """ + # Comprehensions are fun, aren't they? + return len( + collections.Counter( + [ + game.compare_codes(combination, possible_combination) + for possible_combination in possible_combinations + ] + ) + ) + + +def get_all_categories( + possible_combinations: init.Json, +) -> typing.Set[typing.Tuple[int]]: + """Gets all categories in a list of combinations. + + :param possible_combinations: A list of possible combinations. + :return: A set with one of every available category. + """ + return { + tuple(get_category(possible_combination)) + for possible_combination in possible_combinations + } + + +if __name__ == "__main__": + main() diff --git a/src/algorithms/simple.py b/src/algorithms/simple.py new file mode 100644 index 0000000..b12ca06 --- /dev/null +++ b/src/algorithms/simple.py @@ -0,0 +1,44 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# SPDX-License-Identifier: BSD 3-Clause - +# Copyright (c) 2022 Jimmy Bierenbroodspot. - +# ---------------------------------------------------------------------------------------------------------------------- +""" +Mastermind solving algorithm using L. Sterling's and E. Shapiro's simple algorithm. + +This code is developed using code found in the following book: +Sterling, L., & Shapiro, E. (1994). The art of Prolog: advanced programming techniques (2nd ed.). MIT Press. +""" +from __future__ import annotations +import typing + +import src.python.mastermind.mastermind as game +import initialization as init + + +def main() -> None: + game_simulation: typing.Generator[typing.Tuple[int, int, bool], game.Code, None] + guess: game.Code + answer: typing.Tuple[int, int, bool] + game_round: int = 0 + possible_combinations: init.Json = init.get_combinations() + + game_simulation = game.simulate_game(init.COLOURS, init.GAME_LENGTH, init.GAME_WIDTH) + for _ in game_simulation: + game_round += 1 + + guess = possible_combinations[0] + answer = game_simulation.send(guess) + print(f"Guessed: {guess}; answer: {answer}") + + # Check if the game is won + if answer[2]: + print(f'Game won in {game_round + 1} guesses!') + break + + possible_combinations = init.reduce(possible_combinations, guess, answer[:2]) + else: + print('Game lost.') + + +if __name__ == "__main__": + main() diff --git a/src/mastermind.py b/src/mastermind.py new file mode 100644 index 0000000..64d9250 --- /dev/null +++ b/src/mastermind.py @@ -0,0 +1,255 @@ +""" +# ---------------------------------------------------------------------------------------------------------------------- +# SPDX-License-Identifier: BSD 3-Clause - +# Copyright (c) 2022 Jimmy Bierenbroodspot. - +# ---------------------------------------------------------------------------------------------------------------------- + +A game of mastermind designed to be as modular as possible. If this module is directly executed you can play a +rudimentary game of mastermind in the terminal. This way is clunky and low-effort since this is not the intended UI. +""" +import typing +import random +import collections +import os +import logging +import argparse + +Code: typing.Union[typing.Tuple[int, ...], typing.List[int]] = typing.TypeVar('Code') + +GAME_HEADER: str = """# ---------------------------------------------- MasterMind -------------------------------------\ +--------------------- # +# # +# How to play: # +# The computer will randomly decide a secret code and you will have to crack that code. You enter a sequence of # +# numbers and the computer will tell you how many numbers are the correct number in the right place and how many # +# numbers are correct but in the wrong place. # +# # +# How to customize: # +# Run this module with the '--help' parameter to see how to customize the game rules. # +# # +# Note: # +# For ease of use and extendability numbers are substituted for the colours in the original MasterMind game. This # +# way you can submit a code by simply entering 0012 for example. # +# -------------------------------------------------------------------------------------------------------------------- # +""" + + +def _init_logging() -> None: + """Initializes logging by configuring logging and setting the logging location. + + :return: None. + """ + log_location: str = os.path.join(os.getcwd(), 'logs', 'mastermind-debug.log') + os.makedirs(os.path.dirname(log_location), exist_ok=True) + logging.basicConfig( + filename=log_location, + encoding='utf-8', + level=logging.DEBUG, + format='%(asctime)s %(levelname)-8s %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + filemode='a+', + ) + + +def _init_arguments() -> argparse.Namespace: + """Initializes arguments + + :return: An argparse.Namespace object with all parsed arguments. + """ + parser: argparse.ArgumentParser = argparse.ArgumentParser() + parser.add_argument('--colours', '-c', help='Amount of possible colours, default 6', type=int, default=6) + parser.add_argument('--length', '-l', help='Amount of rounds before lose condition, default 8', type=int, default=8) + parser.add_argument('--width', '-w', help='Length of codes, default 4', type=int, default=4) + parser.add_argument('--debug', help='Enables debugging', action='store_true') + parser.add_argument('--no_header', help='Disables the game header', action='store_true') + + return parser.parse_args() + + +def generate_secret_code(colours: typing.Tuple[int, ...], length: int) -> Code: + """Creates a `Code` tuple with random colours taken from the colours. + + :param colours: All possible colours to choose from. + :param length: Length of generated code. + :return: A tuple with 4 random colours. + """ + return tuple(random.choices(colours, k=length)) + + +def compare_codes(secret: Code, to_compare: Code) -> typing.Tuple[int, int]: + """Takes two tuples and returns whether a colour is the correct colour and in the correct position or incorrect + position but correct colour. + + :param secret: The secret code to compare against. + :param to_compare: Input code to compare with. + :return: A tuple where the first index is correct order, correct position and the second is incorrect position but + correct colour. + """ + correct_order: int = 0 + incorrect_order: int = 0 + # Counter here is used to create a dictionary-like object with the frequency of every colour. + frequencies: typing.List[typing.Counter] = [ + collections.Counter(secret), + collections.Counter(to_compare), + ] + + # Enumerate() creates type object which consists of index, value tuple pairs for each item in an + # iterable. Here I create two enumerate objects and compare them against each other. If there is a match it means + # both the colour and the index, thus position, are the same which means a colour is in the right position. + for index, colour in enumerate(secret): + if (index, colour) in enumerate(to_compare): + correct_order += 1 + + # The frequencies are compared to each other and once a match is found incorrect_order is incremented by the lowest + # frequency. + for colour in frequencies[0]: + if colour in frequencies[1]: + incorrect_order += min(frequencies[0][colour], frequencies[1][colour]) + logging.debug(f'{frequencies}\t{incorrect_order, incorrect_order}') + # Since we know that an equal amount of correct pairs are marked as incorrect order + incorrect_order -= correct_order + + return correct_order, incorrect_order + + +# All bools start with is, right? +def is_won(correctness: typing.Tuple[int, int], board_width: int) -> bool: + """Compares the left of correctness against the width of the board. + + :param correctness: A tuple that contains the amount of colours in the correct position on the first index. + :param board_width: Width of the board. + :return: True if the first index is the same as the board width. + """ + return correctness[0] == board_width + + +def get_user_input(colours: typing.Tuple[int, ...], message: str, code_length: int) -> Code: + """Asks user to input a colour code. + + :param colours: All possible colours to choose from. + :param message: Message to display. + :param code_length: Expected seq_length of the code. + :return: A list containing a colour code. + """ + colour: str + example_code: str + code: Code = [] + is_correct = False + + while not is_correct: + code = clean_user_input(input(message)) + # Check if code consists of only allowed values and is of the correct length, all() returns True if all values + # within are True. + is_correct = all((colour in colours) for colour in code) and len(code) == code_length + + # Ugly bit of code to improve feedback given to user. I would prefer not using print statements outside the main + # function but the way it is built makes that very hard. + if not is_correct: + # A quirky little way of showing the user what a proper code looks like + example_code = ''.join(map(str, random.choices(colours, k=code_length))) + print(f"Please enter a code of length {code_length} with these values: {colours},\n" + f"for example: {example_code}") + else: + return code + + +def clean_user_input(user_input: str) -> Code: + """Turns a string into a tuple of int. + + :param user_input: A string containing numeric characters. + :return: A Code list populated by integers if input is numeric, otherwise an empty list is returned. + """ + cleaned_code: Code = [] + user_input = user_input.replace(' ', '') # Remove all whitespace. + + if user_input.isnumeric(): + cleaned_code = [int(character) for character in user_input] + + return cleaned_code + + +def simulate_game(colours: typing.Tuple[int, ...], + game_length: int, + board_width: int) -> typing.Generator[typing.Tuple[int, int, bool], Code, None]: + """A game of mastermind where you compare user input against a computer generated code where the correctness of this + code will be yielded after every round. + + :param colours: All possible colours to choose from. + :param game_length: Amount of rounds. + :param board_width: Width of the board. + :return: The amount of correct positions, correct colour but incorrect position and whether the game is won or not. + + :example: + game_simulation = simulate_game(colours, length, board_width) + for _ in game_simulation: + guess = [0, 0, 1, 2] + answer = game_simulation.send(guess) # Send the guess to the generator and store the answer at the same time + + if answer[2]: # If the third value is True the game has been won. + print('game is won!') + else: # If the generator stops the game is lost. + print('game is lost!') + """ + guess: Code + won: bool + correctness: typing.Tuple[int, int] + secret_code: Code = generate_secret_code(colours, board_width) + logging.debug(f'Secret code is:\t{secret_code}') + + for _ in range(game_length): + # Whenever yield is to the right of an equal sign, and you call generator.send() it will assign that value to + # whatever is left of the equals sign. + guess = yield + logging.debug(f'Guessed code is:\t{guess}') + + # Compares the guess against the secret code. + correctness = compare_codes(secret_code, guess) + won = is_won(correctness, board_width) + logging.debug(f'{colours}\tCorrectness for this round:\t{correctness}') + + # If won is True the last correctness is yielded and the generator is terminated. + if won: + logging.debug('Game is won') + yield *correctness, won + return + # If the game is not yet won the correctness and False is returned. + yield *correctness, won + # If the loop runs out of iterations the game is lost. + logging.debug('Game is lost') + + +def main() -> None: + colours: typing.Tuple[int, ...] + arguments: argparse.Namespace + game_length: int + game_width: int + game: typing.Generator[typing.Tuple[int, int, bool], Code, None] + won_game: bool + game_round: typing.Tuple[int, int, bool] = 0, 0, False + + arguments = _init_arguments() + + if arguments.debug: + _init_logging() + + colours = tuple(number for number in range(arguments.colours)) + game_length = arguments.length + game_width = arguments.width + + if not arguments.no_header: + print(GAME_HEADER) + # Simulate game + game = simulate_game(colours, game_length, game_width) + for _ in game: + game_round = game.send(get_user_input(colours, 'Enter your code:\n>>\t', game_width)) + print(f"{game_round[0]} numbers are in the correct in a correct position and\n" + f"{game_round[1]} are correct in an incorrect position.\n") + + if game_round[2]: + print('Congratulations, you won!') + else: + print('You lost, better luck next time') + + +if __name__ == "__main__": + main()