diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..ff6415d --- /dev/null +++ b/.coveragerc @@ -0,0 +1,7 @@ +[report] +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain if tests don't hit defensive assertion code: + raise NotImplementedError diff --git a/.gitignore b/.gitignore index 5d32d12..4fad698 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,7 @@ celerybeat-schedule .venv env/ venv/ +*venv/ ENV/ env.bak/ venv.bak/ @@ -126,4 +127,7 @@ dmypy.json # Databases *.pkl -database* \ No newline at end of file +database* + +# Temp +temp* diff --git a/README.md b/README.md index 7233ca6..9cfcb39 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,16 @@ python -m puzzlesolver.puzzles.hanoi ``` to play a puzzle of Towers of Hanoi +Run from the base directory of the respository +``` +python -m puzzlesolver.server +``` +to access the webserver. The server should be running at http://127.0.0.1:9001/puzzles/. + ## Exploring GamesmanPuzzles Tips for exploring this repository: 1. [Follow the guides and learn how to create a puzzle and a solver!](guides) -2. Definitely explore the [puzzlesolver](puzzlesolver) in depth. There should be a README.md in every important directory to explain what each file does. +2. Definitely explore the [puzzlesolver](puzzlesolver) in depth. 3. Understand what a [puzzle tree](https://nyc.cs.berkeley.edu/wiki/Puzzle_tree) is. ## Contributing to GamesmanPuzzles diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..501fb3f --- /dev/null +++ b/codecov.yml @@ -0,0 +1,14 @@ +# configuration related to pull request comments +comment: no # do not comment PR with the result + +coverage: + range: 70..80 # coverage lower than 50 is red, higher than 90 green, between color code + + status: + project: # settings affecting project coverage + default: + target: auto # auto % coverage target + threshold: 5% # allow for 5% reduction of coverage without failing + + # do not run coverage on patch nor changes + patch: false diff --git a/guides/tutorial/0_Puzzle_Prerequisites.md b/guides/tutorial/00_Puzzle_Prerequisites.md similarity index 93% rename from guides/tutorial/0_Puzzle_Prerequisites.md rename to guides/tutorial/00_Puzzle_Prerequisites.md index 70c1d7f..745abd0 100644 --- a/guides/tutorial/0_Puzzle_Prerequisites.md +++ b/guides/tutorial/00_Puzzle_Prerequisites.md @@ -25,4 +25,4 @@ from puzzlesolver import PuzzlePlayer The rest of guide will implement the instance methods of this class. -[Next step: Gameplay Methods](1_Gameplay_Methods.md) +[Next step: Gameplay Methods](01_Gameplay_Methods.md) diff --git a/guides/tutorial/1_Gameplay_Methods.md b/guides/tutorial/01_Gameplay_Methods.md similarity index 97% rename from guides/tutorial/1_Gameplay_Methods.md rename to guides/tutorial/01_Gameplay_Methods.md index 8ba8c41..9ae20d4 100644 --- a/guides/tutorial/1_Gameplay_Methods.md +++ b/guides/tutorial/01_Gameplay_Methods.md @@ -35,4 +35,4 @@ def primitive(self, **kwargs): return PuzzleValue.SOLVABLE return PuzzleValue.UNDECIDED ``` -[Next step: Implementing the Move functions](2_Moves.md) +[Next step: Implementing the Move functions](02_Moves.md) diff --git a/guides/tutorial/2_Moves.md b/guides/tutorial/02_Moves.md similarity index 98% rename from guides/tutorial/2_Moves.md rename to guides/tutorial/02_Moves.md index e70f395..5278094 100644 --- a/guides/tutorial/2_Moves.md +++ b/guides/tutorial/02_Moves.md @@ -59,4 +59,4 @@ python .py ``` If everything runs smoothly, congrats! You have created a playable puzzle! -[Next step: Implementing the Solver methods](3_Solver_Methods.md) +[Next step: Implementing the Solver methods](03_Solver_Methods.md) diff --git a/guides/tutorial/3_Solver_Methods.md b/guides/tutorial/03_Solver_Methods.md similarity index 77% rename from guides/tutorial/3_Solver_Methods.md rename to guides/tutorial/03_Solver_Methods.md index f748bd0..5996538 100644 --- a/guides/tutorial/3_Solver_Methods.md +++ b/guides/tutorial/03_Solver_Methods.md @@ -3,12 +3,15 @@ Now that we have our own puzzle, it's time to solve it with our GeneralSolver! U ## Solver functions #### ```__hash__(self)``` -This hash function allows the solver to use memoization to store previously computed values so that the solver doesn't require any other new computation. Hashes also must be unique such that no two "different" puzzle states have the same hash. Note that two unequal puzzle states do not necessarily have to be "different", as one puzzle state can simply be a variation (i.e. rotation, reflection) of the other state. This fact allows us to further optimize our solver to remove any redundant puzzle states. +This hash function allows the solver to use memoization to store previously computed values so that the solver doesn't require any other new computation. Hashes also must be unique such that no two "different" puzzle states have the same hash. Note that two unequal puzzle states do not necessarily have to be "different", as one puzzle state can simply be a variation (i.e. rotation, reflection) of the other state, or equivalently, **both puzzles have the same remoteness**. This fact allows us to further optimize our solver to remove any redundant puzzle states. Our Hanoi puzzle will not be optimizing over redundant states and will simply just use the hash of the string representation of our stacks. ```python def __hash__(self): - return hash(str(self.stacks)) + from hashlib import sha1 + h = sha1() + h.update(str(self.stacks).encode()) + return int(h.hexdigest(), 16) ``` #### ```generateSolutions(self, **kwargs):``` @@ -28,7 +31,8 @@ def generateSolutions(self, **kwargs): ### Execute Once you have implemented all the required functions, change the last line of the Python file outside of the class to: ```python -PuzzlePlayer(Hanoi(), solver=GeneralSolver()).play() +puzzle = Hanoi() +PuzzlePlayer(puzzle, solver=GeneralSolver(puzzle)).play() ``` On your CLI, execute ```bash @@ -39,6 +43,6 @@ If you have a remoteness of 7 and a Primitive value of "UNDECIDED", congrats! Yo ## Extras Ponder on these questions in how we can optimize this puzzle - If we change our endstate to be a stack on either the middle or right rod, how can we optimize this? -- Why is deserializing a hash to a puzzle a bad idea? +- If you can compute a hash that directly encodes the remoteness, is there a need for a solver? -[Next Part: Implementing a Solver](4_Solver_Prerequisites.md) +[Next Part: Implementing a Solver](04_Solver_Prerequisites.md) diff --git a/guides/tutorial/4_Solver_Prerequisites.md b/guides/tutorial/04_Solver_Prerequisites.md similarity index 56% rename from guides/tutorial/4_Solver_Prerequisites.md rename to guides/tutorial/04_Solver_Prerequisites.md index 29e4cbe..3486d21 100644 --- a/guides/tutorial/4_Solver_Prerequisites.md +++ b/guides/tutorial/04_Solver_Prerequisites.md @@ -1,5 +1,5 @@ # Solver Prerequisites -This next part of the tutorial will teach you how to make a custom solver following the Solver interface. We'll be implementing the BFS algorithm GeneralSolver. This guide will assume that you are already familiar with Python 3 and that you have checked out the following documentation for a [puzzle tree](https://nyc.cs.berkeley.edu/wiki/Puzzle_tree). This guide also assumes that you've followed the prerequisites of creating a Puzzle. +This next part of the tutorial will teach you how to make a custom solver following the Solver interface. We'll be implementing the in memory BFS algorithm GeneralSolver. This guide will assume that you are already familiar with Python 3 and that you have checked out the following documentation for a [puzzle tree](https://nyc.cs.berkeley.edu/wiki/Puzzle_tree). This guide also assumes that you've followed the prerequisites of creating a Puzzle. ## Initialize files Create a **NEW** Python file and import the following: @@ -13,4 +13,4 @@ import queue as q The rest of guide will implement the instance methods of this class. -[Next step: Helper Methods](5_Helper_Methods.md) +[Next step: Helper Methods](05_Helper_Methods.md) diff --git a/guides/tutorial/5_Helper_Methods.md b/guides/tutorial/05_Helper_Methods.md similarity index 93% rename from guides/tutorial/5_Helper_Methods.md rename to guides/tutorial/05_Helper_Methods.md index 78cb70a..b529cdb 100644 --- a/guides/tutorial/5_Helper_Methods.md +++ b/guides/tutorial/05_Helper_Methods.md @@ -11,7 +11,7 @@ class GeneralSolver(Solver): ## Implementing Functions -**```__init__(self, **kwargs)```** +**```__init__(self, puzzle, **kwargs)```** We initialize the dictionaries used to store the values and remoteness of the positions. ```python @@ -40,4 +40,4 @@ def getValue(self, puzzle, **kwargs): ``` Note that in the official `GeneralSolver`, there is no method for `getValue`, however it is defined in the `Solver` class. This is because `getValue` only relies on the function `getRemoteness` and doesn't require any other solver attributes, meaning we can make `getValue` be part of the abstraction. -[Next Step: Implementing the Solver Methods](6_Solver_Methods.md) \ No newline at end of file +[Next Step: Implementing the Solver Methods](06_Solver_Methods.md) \ No newline at end of file diff --git a/guides/tutorial/6_Solver_Methods.md b/guides/tutorial/06_Solver_Methods.md similarity index 57% rename from guides/tutorial/6_Solver_Methods.md rename to guides/tutorial/06_Solver_Methods.md index 5c47781..3408b69 100644 --- a/guides/tutorial/6_Solver_Methods.md +++ b/guides/tutorial/06_Solver_Methods.md @@ -7,7 +7,7 @@ The `solve` function is the core of all solvers in the GamesmanPuzzles and is us Our GeneralSolver traverses the puzzle tree using the solve function. First, start with the function initalization: ```python -def solve(self, **kwargs) +def solve(self, puzzle, **kwargs): ``` Remember back in the puzzle project, we defined a few important functions that were meant to be used for this solver. These functions are: @@ -24,24 +24,21 @@ Following the steps of the algorithm we defined in [puzzle tree:](https://nyc.cs Splitting the algorithm into two separate parts: -Step 1: (the ```helper``` function would be defined in Step 2, 3, & 4) +Step 1: Initializations of the winstates (solutions) of the puzzle as well as setting their remotenesses to 0. Additionally also initalizing the Queue data structure used for BFS. ```python def solve(self, **kwargs): - # continued... - ends = self.puzzle.generateSolutions() - for end in ends: - self.remoteness[hash(end)] = 0 - helper(self, ends) + solutions, queue = self.puzzle.generateSolutions(), q.Queue() + for solution in solutions: + self.remoteness[hash(solution)] = 0 + queue.put(solution) ``` -Step 2, 3, & 4: +Step 2, 3, & 4: The following performs the BFS portion of the algorithm. ```python -def helper(self, puzzles): - queue = q.Queue() - for puzzle in puzzles: queue.put(puzzle) + # BFS for remoteness classification while not queue.empty(): puzzle = queue.get() - for move in puzzle.generateMoves(movetype="undo"): + for move in puzzle.generateMoves('undo'): nextPuzzle = puzzle.doMove(move) if hash(nextPuzzle) not in self.remoteness: self.remoteness[hash(nextPuzzle)] = self.remoteness[hash(puzzle)] + 1 @@ -50,37 +47,42 @@ def helper(self, puzzles): The final solve function should look like this: ```python -def solve(self, **kwargs): - # BFS for remoteness classification - def helper(self, puzzles): - queue = q.Queue() - for puzzle in puzzles: queue.put(puzzle) - while not queue.empty(): - puzzle = queue.get() - for move in puzzle.generateMoves(): - nextPuzzle = puzzle.doMove(move) - if hash(nextPuzzle) not in self.remoteness: - self.remoteness[hash(nextPuzzle)] = self.remoteness[hash(puzzle)] + 1 - queue.put(nextPuzzle) +def solve(self, *args, **kwargs): + """Traverse the entire puzzle tree and classifiers all the + positions with values and remoteness + - If position already exists in memory, returns its value + """ + solutions, queue = self.puzzle.generateSolutions(), q.Queue() + for solution in solutions: + self.remoteness[hash(solution)] = 0 + queue.put(solution) - ends = self.puzzle.generateSolutions() - for end in ends: - self.remoteness[hash(end)] = 0 - helper(self, ends) + # BFS for remoteness classification + while not queue.empty(): + puzzle = queue.get() + for move in puzzle.generateMoves('undo'): + nextPuzzle = puzzle.doMove(move) + if hash(nextPuzzle) not in self.remoteness: + self.remoteness[hash(nextPuzzle)] = self.remoteness[hash(puzzle)] + 1 + queue.put(nextPuzzle) ``` ### Execute Once you have implemented all the required functions, change the last line of the Python file outside of the class to: ```python -PuzzlePlayer(Hanoi(), solver=GeneralSolver(Hanoi())).play() +puzzle = Hanoi() +PuzzlePlayer(puzzle, solver=GeneralSolver(puzzle)).play() ``` On your CLI, execute ```bash python .py ``` -If you have a Solver value of SOLVABLE, congrats! You have successfully implemented a Solver! +If you have a Solver value of SOLVABLE, congrats! You have successfully implemented a BFS Solver! ## Extras Ponder on these questions in how we can optimize this solver -- Try thinking of a way to parallelize this solver. +- Try thinking of a way to optimize the algorithm for this solver. - Are there any ways we can solve this not through BFS? +- This solver will not save the positions it classifies after execution is over. Think about ways we can add persistance to our solvers. + +[Next Step: 7 Server Introduction](07_Server_Introduction.md) diff --git a/guides/tutorial/07_Server_Introduction.md b/guides/tutorial/07_Server_Introduction.md new file mode 100644 index 0000000..649352a --- /dev/null +++ b/guides/tutorial/07_Server_Introduction.md @@ -0,0 +1,26 @@ +# Server Introduction +GamesmanPuzzles provides a Web API to display values of puzzles. This guide will adapt our puzzle from the previous steps into a format which can be displayed onto the Web API. + +You can manually run the server by running +```python +python -m puzzlesolver.server +``` +and accessing [http://localhost:9001](http://localhost:9001). You can explore the Web API using the documentation (to do soon). + +## Prerequisites + +After this guide, you should able to display a puzzle in a test application. + +Begin by importing all the extra dependencies in our Hanoi puzzle that we made before +```python +from puzzlesolver.puzzles import ServerPuzzle +``` + +Change the class we inherit from Puzzle to ServerPuzzle + +```python +class Hanoi(ServerPuzzle): +``` + +The next steps would be implementing extra functions: +[Next Step: PuzzleID](08_PuzzleID.md) diff --git a/guides/tutorial/08_PuzzleID.md b/guides/tutorial/08_PuzzleID.md new file mode 100644 index 0000000..7068cba --- /dev/null +++ b/guides/tutorial/08_PuzzleID.md @@ -0,0 +1,13 @@ +# Puzzle ID +``` +GET puzzles/ +``` +The first part of our url is the identification of our puzzle. This is a class variable and can be used to access the variants (next section). + +Define it inside your class: +```python +class Hanoi(ServerPuzzle): + puzzleid = 'hanoi' +``` + +[Next Step: Variants](09_Variants.md) \ No newline at end of file diff --git a/guides/tutorial/09_Variants.md b/guides/tutorial/09_Variants.md new file mode 100644 index 0000000..1e04936 --- /dev/null +++ b/guides/tutorial/09_Variants.md @@ -0,0 +1,53 @@ +# Variants Introduction + +``` +GET puzzles// +``` + +Sometimes we want our puzzle to support multiple versions of itself. For example, in Hanoi we can support puzzles with more than just three discs. To support this, we introduce **Variants**. + +## Variants +A **variant** is a modified version of a puzzle (i.e. more pieces, different orientation). Each puzzle variant is independent of each other, defined to be that there is no position in one variant that can exist in another variant. + +Each initialized puzzle will have a different variant, so we need to modify the `__init__()` and `generateSolutions()` functions to support different variant (size) numbers. + +#### **`__init__()`** +```python +def __init__(self, size=3, **kwargs): + self.stacks = [ + list(range(size, 0, -1)), + [], + [] + ] +``` + +#### **`generateSolutions()`** +```python +def generateSolutions(self, **kwargs): + newPuzzle = Hanoi(size=int(self.variant)) + newPuzzle.stacks = [ + [], + [], + list(range(int(self.variant), 0, -1)) + ] + return [newPuzzle] +``` + +We also need a property to get the current variant of the puzzle. (`@property` is a tag that we use to defined the function as a property of the function, such as `Hanoi().variant`) +```python +@property +def variant(self): + size = 0 + for stack in self.stacks: + size += len(stack) + return str(size) +``` +Our server requires that we define a dictionary with variant ids as the keys as well as different Solver classes as the values. *Note: The reason for this is that different variants may use different solvers.* + +Just for these purposes, lets consider only variants up to 3 as well as using the GeneralSolver as the main solver. +```python +variants = { + str(i) : GeneralSolver for i in range(1, 4) +} +``` +[Next Step: Positions](10_Positions.md) diff --git a/guides/tutorial/10_Positions.md b/guides/tutorial/10_Positions.md new file mode 100644 index 0000000..da42977 --- /dev/null +++ b/guides/tutorial/10_Positions.md @@ -0,0 +1,84 @@ +# Position Introduction + +``` +GET puzzles/// +``` +Positions strings allow users to query into our PuzzleSolver and find the remoteness of positions. Thus, it's the most crucial functionality and contains most complex methods to implement for a ServerPuzzle. This part of the guide will be implementing those methods. + +## Serialization +When users are accessing the Web API, they need a way to input a puzzle position and return a result. We can do this by **serializing** the puzzle into a string code, which can be **deserialized** back into a puzzle. + +#### **`serialize()`** + +Serializing is to take an object and convert it into string form. The string returned must be unique enough to turn back to said object. *Note: this is different from a hash, as a hash can be the same for two puzzles if one is simply a variation of the other.* +```python +def serialize(self, **kwargs): + result = [] + for stack in self.stacks: + result.append("_".join(str(x) for x in stack)) + return "-".join(result) +``` + +#### **`deserialize()`** + +Deserializing is to take a string and encode it back into an object. After serializing an puzzle, deserializing the serialization must return the same puzzle. +```python +@classmethod +def deserialize(cls, positionid, **kwargs): + puzzle = Hanoi() + puzzle.stacks = [] + stacks = positionid.split("-") + for string in stacks: + if string != "": + stack = [int(x) for x in string.split("_")] + puzzle.stacks.append(stack) + else: puzzle.stacks.append([]) + return puzzle +``` + +## Validation + +We want to make sure the user inputs proper strings, and return helpful messages when they don't. Thus, we must be able handle any input string and check if it's valid. Most of the validation is already handled by default, and `isLegalPosition()` is the only function that needs to be implemented. + +#### **`isLegalPosition()`** + +`isLegalPosition()` is a classmethod that checks if a given string is a valid Puzzle object as well as follows the rules of the given puzzle. For example, you cannot stack a larger disc ontop of a smaller disc in Towers of Hanoi. +```python +@classmethod +def isLegalPosition(cls, positionid, variantid=None, **kwargs): + try: puzzle = cls.deserialize(positionid) + except: return False + unique = set() + if len(puzzle.stacks) != 3: return False + for stack in puzzle.stacks: + if stack != sorted(stack, reverse=True): + return False + unique.update(stack) + if len(unique) != int(puzzle.variant) or min(unique) != 1 or max(unique) != int(puzzle.variant): + return False + return True +``` + +## Start Position +#### **`generateStartPosition()`** + +This function is mainly here to give a starting and example position for a variant in a puzzle, and will be on display at `/puzzles///`. +```python +@classmethod +def generateStartPosition(cls, variantid, **kwargs): + if not isinstance(variantid, str): raise TypeError("Invalid variantid") + if variantid not in Hanoi.variants: raise IndexError("Out of bounds variantid") + return Hanoi(size=int(variantid)) +``` + +## Conclusion: +To test if the ServerPuzzle has been implemented, add the following on the end of your file: +```python +from puzzlesolver.server import test_puzzle +test_puzzle(Hanoi) +``` +Then run: +```bash +python .py +``` +Go to the link the app is running on and test out all the paths (i.e. `hanoi/3/3_2_1--`). If the remoteness of the position is 7 and the remoteness of all other positions is 7 or 6, then congrats! You have successfully implemented a Server Puzzle! diff --git a/guides/tutorial/README.md b/guides/tutorial/README.md index 3a7413d..08710ea 100644 --- a/guides/tutorial/README.md +++ b/guides/tutorial/README.md @@ -1,2 +1,2 @@ # Puzzle and Solver Guide -This guide is meant for newcommers of the GamesmanPuzzles project to gain an intuition of how a Puzzle is solved by implementing the puzzle of Hanoi as well as the GeneralSolver. We'll first implement the Puzzle, and later implement the Solver. Start by looking at the [Puzzle prerequisites](0_Puzzle_Prerequisites.md). \ No newline at end of file +This guide is meant for newcommers of the GamesmanPuzzles project to gain an intuition of how a Puzzle is solved by implementing the puzzle of Hanoi as well as the GeneralSolver. We'll first implement the Puzzle, and later implement the Solver. Start by looking at the [Puzzle prerequisites](00_Puzzle_Prerequisites.md). \ No newline at end of file diff --git a/puzzlesolver/README.md b/puzzlesolver/README.md deleted file mode 100644 index c337831..0000000 --- a/puzzlesolver/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Puzzlesolver -A Python package dedicated to solving puzzles. -## Directories -* [Puzzles](puzzles): Contains all the puzzles used for solving. Put your puzzle in this repository -* [Solvers](solvers): Contains all the solvers used to solve the puzzles. Put your solver in this repository -## Files -* [puzzleplayer.py](puzzleplayer.py): The object used to execute a TUI (Text User Interface) with the user. -* [util.py](util.py): A file for utilities. diff --git a/puzzlesolver/puzzleplayer.py b/puzzlesolver/puzzleplayer.py index b47fae6..30a7900 100644 --- a/puzzlesolver/puzzleplayer.py +++ b/puzzlesolver/puzzleplayer.py @@ -5,11 +5,15 @@ class PuzzlePlayer: - def __init__(self, puzzle, solver=None, auto=False): + def __init__(self, puzzle, solver=None, solverinfo=True, auto=False, bestmove=False): self.base = puzzle self.puzzle = puzzle self.solver = solver + self.solverinfo = solverinfo + if not solver and (auto or bestmove): + raise Exception("Cannot have auto or bestmove arguments without a solver") self.auto = auto + self.bestmove = bestmove if solver: self.solver.solve() @@ -19,28 +23,36 @@ def play(self): self.turn = 0 while self.puzzle.primitive() == PuzzleValue.UNDECIDED: self.printInfo() + self.puzzle.printInfo() self.printTurn() self.printInfo() + self.puzzle.printInfo() print("Game Over") - # Prints the puzzle info def printInfo(self): print("Turn: ", self.turn), print("Primitive: ", self.puzzle.primitive()) - if self.solver: + if self.solverinfo and self.solver: print("Solver: ", self.solver.getValue(self.puzzle)) print("Remoteness: ", self.solver.getRemoteness(self.puzzle)) - print(str(self.puzzle)) + if self.bestmove: print("Best Move: ", self.generateBestMove()) self.turn += 1 # Prompts for input and moves def printTurn(self): - if self.auto: - move = self.generateBestMove() + if self.solver: move = self.generateBestMove() + # Auto generate a possible solution + if self.auto: self.puzzle = self.puzzle.doMove(move) else: moves = list(self.puzzle.generateMoves(movetype="legal")) - print("Possible Moves:", moves) + # Have the best move be the first index + if self.bestmove: + moves.remove(move) + moves.insert(0, move) + print("Possible Moves:") + for count, m in enumerate(moves): + print(str(count) + " -> " + str(m)) print("Enter Piece: ") index = int(input()) if index >= len(moves): @@ -51,9 +63,12 @@ def printTurn(self): # Generates best move from the solver def generateBestMove(self): + if self.solver.getValue(self.puzzle) == PuzzleValue.UNSOLVABLE: return None + if self.puzzle.primitive() == PuzzleValue.SOLVABLE: return None remotes = { self.solver.getRemoteness(self.puzzle.doMove(move)) : move for move in self.puzzle.generateMoves(movetype="legal") } - return remotes[min(remotes.keys())] - + if PuzzleValue.UNSOLVABLE in remotes: + del remotes[PuzzleValue.UNSOLVABLE] + return remotes[min(remotes.keys())] \ No newline at end of file diff --git a/puzzlesolver/puzzles/README.md b/puzzlesolver/puzzles/README.md deleted file mode 100644 index a668724..0000000 --- a/puzzlesolver/puzzles/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Puzzles -This directory contains all the puzzles used for solving. -## Files -* **puzzle.py**: The abstract class used to instantiate all the puzzles. -* **graphpuzzle.py** : The interactive puzzle, useful for creating puzzles in graph layout. -* **hanoi.py** : The Tower of Hanoi puzzle, implemented in 2020 by Anthony Ling diff --git a/puzzlesolver/puzzles/__init__.py b/puzzlesolver/puzzles/__init__.py index fe6bcc6..66e41bd 100644 --- a/puzzlesolver/puzzles/__init__.py +++ b/puzzlesolver/puzzles/__init__.py @@ -1,4 +1,16 @@ from ._models import * from .hanoi import Hanoi +from .pegSolitaire import Peg from .graphpuzzle import GraphPuzzle +from .chairs import Chairs + +puzzleList = { + Peg.puzzleid: Peg, + Hanoi.puzzleid: Hanoi, + Chairs.puzzleid: Chairs +} + +for puzzle in puzzleList.values(): + if not issubclass(puzzle, ServerPuzzle): + raise TypeError("Non-ServerPuzzle class found in puzzleList") diff --git a/puzzlesolver/puzzles/_models/__init__.py b/puzzlesolver/puzzles/_models/__init__.py index 4c4c161..3622a08 100644 --- a/puzzlesolver/puzzles/_models/__init__.py +++ b/puzzlesolver/puzzles/_models/__init__.py @@ -1,2 +1,5 @@ from .puzzle import Puzzle -from . import puzzle \ No newline at end of file +from . import puzzle + +from .serverPuzzle import ServerPuzzle +from . import serverPuzzle diff --git a/puzzlesolver/puzzles/_models/puzzle.py b/puzzlesolver/puzzles/_models/puzzle.py index 8146fe6..7c82965 100644 --- a/puzzlesolver/puzzles/_models/puzzle.py +++ b/puzzlesolver/puzzles/_models/puzzle.py @@ -1,13 +1,16 @@ # These are general functions that you might want to implement if you are to use the # PuzzlePlayer and the GeneralSolver +from abc import ABC, abstractmethod +import progressbar -class Puzzle: +class Puzzle(ABC): # Intializer def __init__(self, **kwargs): pass # Gameplay methods + @abstractmethod def __str__(self): """Returns the string representation of the puzzle. @@ -16,6 +19,7 @@ def __str__(self): """ return "No String representation available" + @abstractmethod def primitive(self, **kwargs): """If the Puzzle is at an endstate, return GameValue.WIN or GameValue.LOSS else return GameValue.UNDECIDED @@ -28,6 +32,7 @@ def primitive(self, **kwargs): """ raise NotImplementedError + @abstractmethod def doMove(self, move, **kwargs): """Given a valid move, returns a new Puzzle object with that move executed. Does nothing to the original Puzzle object @@ -42,6 +47,7 @@ def doMove(self, move, **kwargs): """ raise NotImplementedError + @abstractmethod def generateMoves(self, movetype="all", **kwargs): """Generate moves from self (including undos) @@ -60,6 +66,7 @@ def generateMoves(self, movetype="all", **kwargs): raise NotImplementedError # Solver methods + @abstractmethod def __hash__(self): """Returns a hash of the puzzle. Requirements: @@ -77,6 +84,7 @@ def __hash__(self): """ raise NotImplementedError + @abstractmethod def generateSolutions(self, **kwargs): """Returns a Iterable of Puzzle objects that are solved states @@ -85,7 +93,15 @@ def generateSolutions(self, **kwargs): """ raise NotImplementedError - # Method for PickleSolverWrapper + @property + def numPositions(self): + """Returns the max number of possible positions from the solution state. + Main use is for the progressbar module. + Default is unknown length, can be overwritten + """ + return progressbar.base.UnknownLength + + # Built-in functions def getName(self, **kwargs): """Returns the name of the Puzzle. @@ -93,3 +109,24 @@ def getName(self, **kwargs): String name """ return self.__class__.__name__ + + def printInfo(self): + """Prints the string representation of the puzzle. + Can be custom defined""" + + print(str(self)) + + def generateMovePositions(self, movetype="legal", **kwargs): + """Generate an iterable of puzzles with all moves fitting movetype + executed. + + Inputs: + - movetype: The type of move to generate the puzzles + + Outputs: + - Iterable of puzzles + """ + puzzles = [] + for move in self.generateMoves(movetype=movetype, **kwargs): + puzzles.append((move, self.doMove(move))) + return puzzles diff --git a/puzzlesolver/puzzles/_models/serverPuzzle.py b/puzzlesolver/puzzles/_models/serverPuzzle.py new file mode 100644 index 0000000..c04aee3 --- /dev/null +++ b/puzzlesolver/puzzles/_models/serverPuzzle.py @@ -0,0 +1,101 @@ +from ...util import * +from . import Puzzle +from abc import abstractproperty, abstractclassmethod, abstractmethod + +class ServerPuzzle(Puzzle): + + # Methods and attributes for Server + # Descriptions + puzzleid = Exception("No puzzleid defined") + author = "N/A" + puzzle_name = "N/A" + description = "N/A" + date_created = "N/A" + + """A dictionary with the following + - variantId as the string key + - A Solver class object as the value + + This dictionary is meant to store Solvers for the web server to interact with. + See Hanoi for a dict comprehension example + """ + variants = {} + + @abstractproperty + def variant(self): + """Returns a string defining the variant of this puzzleself. + + Example: '5x5', '3x4', 'reverse3x3' + """ + raise NotImplementedError + + @abstractclassmethod + def deserialize(cls, positionid, **kwargs): + """Returns a Puzzle object based on positionid + + Example: positionid="3_2-1-" for Hanoi creates a Hanoi puzzle + with two stacks of discs ((3,2) and (1)) + + Inputs: + positionid - String id from puzzle, serialize() must be able to generate it + + Outputs: + Puzzle object based on puzzleid and variantid + """ + raise NotImplementedError + + @abstractmethod + def serialize(self, **kwargs): + """Returns a serialized based on self + + Outputs: + String Puzzle + """ + return str(self) + + @abstractclassmethod + def isLegalPosition(cls, positionid, variantid=None, **kwargs): + """Checks if the positionid is valid given the rules of the Puzzle cls. + This function is invariant and only checks if all the rules are satisified + For example, Hanoi cannot have a larger ring on top of a smaller one. + + Outputs: + - True if Puzzle is valid, else False + """ + raise NotImplementedError + + @abstractclassmethod + def generateStartPosition(cls, variantid, **kwargs): + """Returns a Puzzle object containing the start position. + + Outputs: + - Puzzle object + """ + raise NotImplementedError + + # Built-in functions + @classmethod + def validate(cls, positionid=None, variantid=None, **kwargs): + """Checks if the positionid fits the rules set for the puzzle, as + well as if it's supported by the app. + + Inputs: + - positionid: + - variantid: + """ + if variantid is not None: + if not isinstance(variantid, str): raise PuzzleException("Invalid variantid") + if variantid not in cls.variants: raise PuzzleException("Out of bounds variantid") + if positionid is not None: + if not cls.isLegalPosition(positionid): raise PuzzleException("position is not a valid puzzle") + p = cls.deserialize(positionid) + if variantid is not None and p.variant != variantid: + raise PuzzleException("variantid doesn't match puzzleid") + + def getName(self, **kwargs): + """Returns the name of the Puzzle. + + Outputs: + String name + """ + return self.__class__.__name__ + self.variant diff --git a/puzzlesolver/puzzles/chairs.py b/puzzlesolver/puzzles/chairs.py new file mode 100644 index 0000000..56f70b7 --- /dev/null +++ b/puzzlesolver/puzzles/chairs.py @@ -0,0 +1,212 @@ +from copy import deepcopy +from . import ServerPuzzle +from ..util import * +from ..solvers import GeneralSolver, SqliteSolver +from ..puzzleplayer import PuzzlePlayer + +from hashlib import sha1 + +class Chairs(ServerPuzzle): + + puzzleid = 'chairs' + author = "Mark Presten" + puzzle_name = "Chair Hopping" + description = """Move all pieces from one side of the board to the other by hopping over adjacent pieces. The end result should be a flipped version of the starting state.""" + date_created = "April 25, 2020" + + variants = {"10" : SqliteSolver} + + def __init__(self, **kwargs): + self.board = ['x','x','x','x','x', '-', 'o','o','o','o','o'] + + def __str__(self, **kwargs): + return str(self.board) + + @property + def variant(self): + """Returns a string defining the variant of this puzzleself. + Example: '5x5', '3x4', 'reverse3x3' + """ + return "10" + + ### _________ Print Funcs _______________ + def printInfo(self): + #Print Puzzle + print("Puzzle: ") + space = " " + print(space, end="") + for i in self.board: + if i == "-": + i = "_" + print(i + " ", end="") + print("") + space = " [" + print(space, end="") + for i in range(11): + if i == 10: + print(str(i) + "]", end="") + break + print(str(i) + " ", end="") + print("") + + def getName(self, **kwargs): + return "Chairs" + # ________ End Print Funcs _________ + + def primitive(self, **kwargs): + if self.board == ['o','o','o','o','o','-','x','x','x','x','x']: + return PuzzleValue.SOLVABLE + return PuzzleValue.UNDECIDED + + # Generate Legal Moves & all undo moves + def generateMoves(self, movetype="all", **kwargs): + moves = [] + key = False + if movetype=='bi': + return [] + if movetype=='all': + key = True + if movetype=='for' or movetype=='legal' or key: + moves.extend(self.xForward()) + moves.extend(self.oForward()) + if movetype=='undo' or movetype=='back' or key: + moves.extend(self.xBack()) + moves.extend(self.oBack()) + return moves + + ### _____ generateMoves HELPERS _______ ### + + def xForward(self): + moves = [] + for count, ele in enumerate(self.board): + if ele == '-' and count > 0: + if self.board[count - 1] == 'x': + moves.append(count - 1) + if count > 1: + if self.board[count - 2] == 'x': + moves.append(count - 2) + return moves + + def xBack(self): + moves = [] + for count, ele in enumerate(self.board): + if ele == '-' and count < 10: + if self.board[count + 1] == 'x': + moves.append(count + 1) + if count < 9: + if self.board[count + 2] == 'x': + moves.append(count + 2) + return moves + + def oForward(self): + moves = [] + for count, ele in enumerate(self.board): + if ele == '-' and count < 10: + if self.board[count + 1] == 'o': + moves.append(count + 1) + if count < 9: + if self.board[count + 2] == 'o': + moves.append(count + 2) + return moves + + def oBack(self): + moves = [] + for count, ele in enumerate(self.board): + if ele == '-' and count > 0: + if self.board[count - 1] == 'o': + moves.append(count - 1) + if count > 1: + if self.board[count - 2] == 'o': + moves.append(count - 2) + return moves + + ### _________ end HELPERS _________________ ### + + def doMove(self, move, **kwargs): + if move not in self.generateMoves(): raise ValueError + newPuzzle = Chairs() + new_board = deepcopy(self.board) + ind = new_board.index('-') + ele = new_board[move] + new_board[move] = '-' + new_board[ind] = ele + newPuzzle.board = new_board + return newPuzzle + + ### ____________ Solver Funcs ________________ + + def __hash__(self): + h = sha1() + h.update(str(self.board).encode()) + return int(h.hexdigest(), 16) + + def generateSolutions(self, **kwargs): + newPuzzle = Chairs() + newPuzzle.board = ['o','o','o','o','o','-','x','x','x','x','x'] + return [newPuzzle] + + ### ________ Server _________ + @classmethod + def deserialize(cls, positionid, **kwargs): + """Returns a Puzzle object based on positionid + Example: positionid="3_2-1-" for Hanoi creates a Hanoi puzzle + with two stacks of discs ((3,2) and (1)) + Inputs: + positionid - String id from puzzle, serialize() must be able to generate it + Outputs: + Puzzle object based on puzzleid and variantid + """ + puzzle = Chairs() + out = [] + for i in positionid: + out.append(i) + puzzle.board = out + return puzzle + + def serialize(self, **kwargs): + """Returns a serialized based on self + Outputs: + String Puzzle + """ + out = "" + for i in self.board: + out += i + return out + + @classmethod + def isLegalPosition(cls, positionid, variantid=None, **kwargs): + """Checks if the positionid is valid given the rules of the Puzzle cls. + This function is invariant and only checks if all the rules are satisified + For example, Hanoi cannot have a larger ring on top of a smaller one. + Outputs: + - True if Puzzle is valid, else False + """ + try: puzzle = cls.deserialize(positionid) + except: raise PuzzleException("Position is invalid") + xcount = 0 + ocount = 0 + dcount = 0 + for i in puzzle.board: + if i == 'x': + xcount += 1 + elif i == 'o': + ocount += 1 + elif i == '-': + dcount += 1 + if xcount != ocount or xcount != 5 or dcount != 1: + return False + return True + + @classmethod + def generateStartPosition(cls, variantid, **kwargs): + """Returns a Puzzle object containing the start position. + + Outputs: + - Puzzle object + """ + if not isinstance(variantid, str): raise TypeError("Invalid variantid") + if variantid not in Chairs.variants: raise IndexError("Out of bounds variantid") + return Chairs() + +# PuzzlePlayer(Chairs(), solver=GeneralSolver(), auto=True).play() +# PuzzlePlayer(Peg()).play() \ No newline at end of file diff --git a/puzzlesolver/puzzles/hanoi.py b/puzzlesolver/puzzles/hanoi.py index 761c8e1..6f15427 100644 --- a/puzzlesolver/puzzles/hanoi.py +++ b/puzzlesolver/puzzles/hanoi.py @@ -3,42 +3,65 @@ """ from copy import deepcopy -from . import Puzzle +from . import ServerPuzzle from ..util import * -from ..solvers import GeneralSolver +from ..solvers import * from ..puzzleplayer import PuzzlePlayer -class Hanoi(Puzzle): +from hashlib import sha1 + +class Hanoi(ServerPuzzle): + + puzzleid = 'hanoi' + author = "Anthony Ling" + puzzle_name = "Tower of Hanoi" + description = """Move smaller discs ontop of bigger discs. + Fill the rightmost stack.""" + date_created = "April 2, 2020" + + variants = {str(i) : SqliteSolver for i in range(1, 11)} + test_variants = {str(i) : SqliteSolver for i in range(1, 5)} def __init__(self, size=3, **kwargs): - self.size = size - if not isinstance(self.size, int): raise ValueError + if not isinstance(size, int): raise ValueError self.stacks = [ - list(range(self.size, 0, -1)), + list(range(size, 0, -1)), [], [] - ] + ] - def __key(self): - return (str(self.stacks)) + @property + def variant(self): + size = 0 + for stack in self.stacks: + size += len(stack) + return str(size) + + @property + def numPositions(self): + return 3 ** int(self.variant) def __hash__(self): - return hash(self.__key()) + # We're being really lazy here and using built in hashlib functions. + # Can't use regular hash because those are random + h = sha1() + h.update(str(self.stacks).encode()) + return int(h.hexdigest(), 16) def __str__(self): return str(self.stacks) def getName(self): - return 'Hanoi' + str(self.size) + return 'Hanoi' + self.variant def primitive(self, **kwargs): - if self.stacks[2] == list(range(self.size, 0, -1)): + if self.stacks[2] == list(range(int(self.variant), 0, -1)): return PuzzleValue.SOLVABLE return PuzzleValue.UNDECIDED def doMove(self, move, **kwargs): if move not in self.generateMoves(): raise ValueError - newPuzzle = Hanoi(size=self.size) + newPuzzle = Hanoi(size=int(self.variant)) stacks = deepcopy(self.stacks) stacks[move[1]].append(stacks[move[0]].pop()) newPuzzle.stacks = stacks @@ -55,14 +78,52 @@ def generateMoves(self, movetype="all", **kwargs): return moves def generateSolutions(self, **kwargs): - newPuzzle = Hanoi(size=self.size) + newPuzzle = Hanoi(size=int(self.variant)) newPuzzle.stacks = [ [], [], - list(range(self.size, 0, -1)) + list(range(int(self.variant), 0, -1)) ] return [newPuzzle] + @classmethod + def generateStartPosition(cls, variantid, **kwargs): + if not isinstance(variantid, str): raise TypeError("Invalid variantid") + if variantid not in Hanoi.variants: raise IndexError("Out of bounds variantid") + return Hanoi(size=int(variantid)) + + @classmethod + def deserialize(cls, positionid, **kwargs): + puzzle = Hanoi() + puzzle.stacks = [] + stacks = positionid.split("-") + for string in stacks: + if string != "": + stack = [int(x) for x in string.split("_")] + puzzle.stacks.append(stack) + else: puzzle.stacks.append([]) + return puzzle + + def serialize(self, **kwargs): + result = [] + for stack in self.stacks: + result.append("_".join(str(x) for x in stack)) + return "-".join(result) + + @classmethod + def isLegalPosition(cls, positionid, variantid=None, **kwargs): + try: puzzle = cls.deserialize(positionid) + except: return False + unique = set() + if len(puzzle.stacks) != 3: return False + for stack in puzzle.stacks: + if stack != sorted(stack, reverse=True): + return False + unique.update(stack) + if len(unique) != int(puzzle.variant) or min(unique) != 1 or max(unique) != int(puzzle.variant): + return False + return True + if __name__ == "__main__": puzzle = Hanoi(size=3) - PuzzlePlayer(puzzle, GeneralSolver(puzzle=puzzle)).play() + PuzzlePlayer(puzzle, GeneralSolver(puzzle=puzzle), bestmove=True).play() diff --git a/puzzlesolver/puzzles/pegSolitaire.py b/puzzlesolver/puzzles/pegSolitaire.py new file mode 100644 index 0000000..7db0eb4 --- /dev/null +++ b/puzzlesolver/puzzles/pegSolitaire.py @@ -0,0 +1,366 @@ +from copy import deepcopy +from . import ServerPuzzle +from ..util import * +from ..solvers import GeneralSolver, SqliteSolver +from ..puzzleplayer import PuzzlePlayer + +from hashlib import sha1 + +class Peg(ServerPuzzle): + + puzzleid = 'pegSolitaire' + author = "Mark Presten" + puzzle_name = "Peg Solitaire" + description = """Jump over a peg with an adjacent peg, removing it from the board. Have one peg remaining by end of the game.""" + date_created = "April 15, 2020" + + variants = {"Triangle": SqliteSolver} + + def __init__(self, **kwargs): + if len(kwargs) == 1: + for key,value in kwargs.items(): + if key=='board': + self.board = value #[[0],[1,1],[1,1,1],[1,1,1,1],[1,1,1,1,1]] + self.pins = 0 + for outer in range(5): + for inner in range(outer + 1): + if self.board[outer][inner] == 1: + self.pins += 1 + else: + self.board = [[0],[1,1],[1,1,1],[1,1,1,1],[1,1,1,1,1]] + self.pins = 0 + for outer in range(5): + for inner in range(outer + 1): + if self.board[outer][inner] == 1: + self.pins += 1 + + def __str__(self, **kwargs): + return str(self.board) + + @property + def variant(self): + return "Triangle" + + ### _________ Print Funcs _______________ + def printInfo(self): + #Print Puzzle + print("Puzzle: ") + space = " " + for outer in range(5): + print(space, end="") + for inner in range(outer + 1): + print(str(self.board[outer][inner]) + " ", end="") + print("") + temp = list(space) + temp = temp[:-4] + space = "".join(temp) + print(" " + space + " ", end="") + for inner2 in range(outer + 1): + print("[" + str(outer) + "," + str(inner2) + "]" + " ", end="") + print("") + + def getName(self, **kwargs): + return "Peg Solitaire " + self.variant + + # ________ End Print Funcs _________ + + def primitive(self, **kwargs): + if self.pins == 1: + return PuzzleValue.SOLVABLE + return PuzzleValue.UNDECIDED + + # Generate Legal Movees & all undo moves + def generateMoves(self, movetype="all", **kwargs): + moves = [] + key = False + if movetype=='bi': + return [] + if movetype=='all': + key = True + if movetype=='for' or movetype=='legal' or key: + for outer in range(5): + for inner in range(outer + 1): + if self.board[outer][inner] == 1: #for each peg + #LV + check1 = self.search_lv([outer, inner], True) + check1_len = len(check1) + for i in range(check1_len): + moves.append(check1[i]) + #RV + check2 = self.search_rv([outer, inner], True) + check2_len = len(check2) + for i in range(check2_len): + moves.append(check2[i]) + #HV + check3 = self.search_h([outer, inner], True) + check3_len = len(check3) + for i in range(check3_len): + moves.append(check3[i]) + if movetype=='undo' or movetype=='back' or key: + for outer in range(5): + for inner in range(outer + 1): + if self.board[outer][inner] == 1: #for each peg + #LV + check1 = self.search_lv([outer, inner], False) + check1_len = len(check1) + for i in range(check1_len): + moves.append(check1[i]) + #RV + check2 = self.search_rv([outer, inner], False) + check2_len = len(check2) + for i in range(check2_len): + moves.append(check2[i]) + #HV + check3 = self.search_h([outer, inner], False) + check3_len = len(check3) + for i in range(check3_len): + moves.append(check3[i]) + return moves + + ### _____ generateMoves HELPERS _______ ### + + # left vertical + def search_lv(self, peg, legal): + moves = [] + out_ind = peg[0] + inn_ind = peg[1] + # (up) + check_out = out_ind - 1 + if check_out >= 0 and inn_ind <= check_out: + if self.board[check_out][inn_ind] == 0: + # start undo + if not legal: + check_out = out_ind - 2 + if check_out >= 0 and inn_ind <= check_out: + if self.board[check_out][inn_ind] == 0: + moves.append([[out_ind, inn_ind], [check_out, inn_ind], 'undo']) + # end undo + else: + check_out = out_ind + 1 + if check_out <= 4: + if self.board[check_out][inn_ind] == 1: + moves.append([[out_ind + 1, inn_ind],[out_ind - 1, inn_ind]]) + # (down) + check_out = out_ind + 1 + if check_out <= 4: + if self.board[check_out][inn_ind] == 0: + # start undo + if not legal: + check_out = out_ind + 2 + if check_out <= 4: + if self.board[check_out][inn_ind] == 0: + moves.append([[out_ind, inn_ind], [check_out, inn_ind], 'undo']) + # end undo + else: + check_out = out_ind - 1 + if check_out >= 0 and inn_ind <= check_out: + if self.board[check_out][inn_ind] == 1: + moves.append([[out_ind - 1, inn_ind],[out_ind + 1, inn_ind]]) + return moves + + # right vertical + def search_rv(self, peg, legal): + moves = [] + out_ind = peg[0] + inn_ind = peg[1] + # (up) + check_out = out_ind - 1 + check_inn = inn_ind - 1 + if check_out >= 0 and check_inn >= 0: + if self.board[check_out][check_inn] == 0: + # start undo + if not legal: + check_out = out_ind - 2 + check_inn = inn_ind - 2 + if check_out >= 0 and check_inn >= 0: + if self.board[check_out][check_inn] == 0: + moves.append([[out_ind, inn_ind], [check_out, check_inn], 'undo']) + # end undo + else: + check_out = out_ind + 1 + check_inn = inn_ind + 1 + if check_out <= 4: + if self.board[check_out][check_inn] == 1: + moves.append([[out_ind + 1, inn_ind + 1], [out_ind - 1, inn_ind - 1]]) + # (down) + check_out = out_ind + 1 + check_inn = inn_ind + 1 + if check_out <= 4: + if self.board[check_out][check_inn] == 0: + # start undo + if not legal: + check_out = out_ind + 2 + check_inn = inn_ind + 2 + if check_out <= 4: + if self.board[check_out][check_inn] == 0: + moves.append([[out_ind, inn_ind], [check_out, check_inn], 'undo']) + # end undo + else: + check_out = out_ind - 1 + check_inn = inn_ind - 1 + if check_out >= 0 and check_inn >= 0: + if self.board[check_out][check_inn] == 1: + moves.append([[out_ind - 1, inn_ind - 1], [out_ind + 1, inn_ind + 1]]) + return moves + + #horizontal + def search_h(self, peg, legal): + moves = [] + out_ind = peg[0] + inn_ind = peg[1] + # (right) + check_inn = inn_ind + 1 + if check_inn <= out_ind: + if self.board[out_ind][check_inn] == 0: + # start undo + if not legal: + check_inn = inn_ind + 2 + if check_inn <= out_ind: + if self.board[out_ind][check_inn] == 0: + moves.append([[out_ind, inn_ind], [out_ind, check_inn], 'undo']) + # end undo + else: + check_inn = inn_ind - 1 + if check_inn >= 0: + if self.board[out_ind][check_inn] == 1: + moves.append([[out_ind, inn_ind - 1], [out_ind, inn_ind + 1]]) + # (left) + check_inn = inn_ind - 1 + if check_inn >= 0: + if self.board[out_ind][check_inn] == 0: + # start undo + if not legal: + check_inn = inn_ind - 2 + if check_inn >= 0: + if self.board[out_ind][check_inn] == 0: + moves.append([[out_ind, inn_ind], [out_ind, check_inn], 'undo']) + # end undo + else: + check_inn = inn_ind + 1 + if check_inn <= out_ind: + if self.board[out_ind][check_inn] == 1: + moves.append([[out_ind, inn_ind + 1], [out_ind, inn_ind - 1]]) + return moves + + ### _________ end HELPERS _________________ ### + + def doMove(self, move, **kwargs): + if move not in self.generateMoves(): raise ValueError + + new_board = deepcopy(self.board) + from_peg = move[0] + to_peg = move[1] + new_board[from_peg[0]][from_peg[1]] = 0 + new_board[to_peg[0]][to_peg[1]] = 1 + #find middle peg to set to ZERO OR ONE + if len(move) == 3: + set_num = 1 + else: + set_num = 0 + #h + if from_peg[0] == to_peg[0]: + if from_peg[1] > to_peg[1]: + new_board[from_peg[0]][from_peg[1] - 1] = set_num + else: + new_board[from_peg[0]][from_peg[1] + 1] = set_num + #lv + elif from_peg[1] == to_peg[1]: + if from_peg[0] > to_peg[0]: + new_board[from_peg[0] - 1][from_peg[1]] = set_num + else: + new_board[from_peg[0] + 1][from_peg[1]] = set_num + #rv + else: + if from_peg[0] > to_peg[0]: + new_board[from_peg[0] - 1][from_peg[1] - 1] = set_num + else: + new_board[from_peg[0] + 1][from_peg[1] + 1] = set_num + + newPuzzle = Peg(board=new_board) + return newPuzzle + + ### ____________ Solver Funcs ________________ + + def __hash__(self): + h = sha1() + h.update(str(self.board).encode()) + return int(h.hexdigest(), 16) + + @classmethod + def generateStartPosition(cls, variantid, **kwargs): + if not isinstance(variantid, str): raise TypeError("Invalid variantid") + if variantid not in Peg.variants: raise IndexError("Out of bounds variantid") + return Peg(board=[[0],[1,1],[1,1,1],[1,1,1,1],[1,1,1,1,1]]) + + @classmethod + def deserialize(cls, positionid, **kwargs): + """Returns a Puzzle object based on positionid + + Example: positionid="3_2-1-" for Hanoi creates a Hanoi puzzle + with two stacks of discs ((3,2) and (1)) + + Inputs: + positionid - String id from puzzle, serialize() must be able to generate it + + Outputs: + Puzzle object based on puzzleid and variantid + """ + val = 0 + new_val = 0 + out = [] + temp = [] + for i in positionid: + if i == '_': + new_val = len(temp) + if new_val - 1 != val: + raise ValueError + val = new_val + out.append(temp) + temp = [] + continue + temp.append(int(i)) + puzzle = Peg(board=out) + return puzzle + + def serialize(self, **kwargs): + """Returns a serialized based on self + + Outputs: + String Puzzle + """ + s = "" + check = True + for outer in range(5): + for inner in range(outer + 1): + s += str(self.board[outer][inner]) + s += "_" + return s + + @classmethod + def isLegalPosition(cls, positionid, variantid=None, **kwargs): + """Checks if the Puzzle is valid given the rules. + For example, Hanoi cannot have a larger ring on top of a smaller one. + + Outputs: + - True if Puzzle is valid, else False + """ + try: puzzle = cls.deserialize(positionid) + except: return False + if puzzle.pins == 15 or puzzle.pins == 0: + return False + return True + + def generateSolutions(self, **kwargs): + solutions = [] + for outer in range(5): + for inner in range(outer + 1): + temp_board = [[0],[0,0],[0,0,0],[0,0,0,0],[0,0,0,0,0]] + temp_board[outer][inner] = 1 + newPuzzle = Peg(board=temp_board) + solutions.append(newPuzzle) + return solutions + +# board = [[1],[1,1],[0,1,1],[1,1,1,1],[1,1,1,1,1]] +# board2 = [[0],[0,0],[0,0,0],[1,0,0,0],[1,0,0,0,0]] +# PuzzlePlayer(Peg(board=board), solver=GeneralSolver(), auto=True).play() +# PuzzlePlayer(Peg()).play() \ No newline at end of file diff --git a/puzzlesolver/server.py b/puzzlesolver/server.py new file mode 100644 index 0000000..261498b --- /dev/null +++ b/puzzlesolver/server.py @@ -0,0 +1,124 @@ +import flask +from flask import request, jsonify, abort +from .puzzles import puzzleList +from .util import PuzzleException + +import os + +from werkzeug.exceptions import InternalServerError + +app = flask.Flask(__name__) +app.config["DEBUG"] = False +app.config["TESTING"] = False +app.config['DATABASE_DIR'] = 'databases' +app.config['JSONIFY_PRETTYPRINT_REGULAR'] = True + +# Test your server puzzle +def test_puzzle(puzzle): + global puzzleList + puzzleList = {puzzle.puzzleid: puzzle} + init_data() + app.run() + +# Initalizes the data +# TODO: Check if data already exists in disk before solving +def init_data(): + for p_cls in puzzleList.values(): + if app.config["TESTING"] and hasattr(p_cls, 'test_variants'): + variants = p_cls.test_variants + else: + variants = p_cls.variants + for variant in variants: + s_cls = variants[variant] + puzzle = p_cls.generateStartPosition(variant) + solver = s_cls(puzzle, dir_path=app.config['DATABASE_DIR']) + solver.solve(verbose=True) + +# Helper functions +def validate(puzzle_name=None, variant_id=None, position=None): + if puzzle_name == None: + raise ValueError("Nothing to validate") + if puzzle_name not in puzzleList: abort(404, description="PuzzleId not found") + if variant_id != None: + variants = puzzleList[puzzle_name].variants + if variant_id not in variants: abort(404, description="VariantId not found") + if position != None: + try: + puzzleList[puzzle_name].validate(position, variant_id) + except PuzzleException as e: + abort(404, description=str(e)) + +def format_response(response, status="available"): + response = { + "response": response, + "status": status + } + return jsonify(response) + +# Routes +@app.route('/', methods=['GET']) +def puzzles(): + response = { + "puzzles": list(puzzleList.keys()) + } + return format_response(response) + +@app.route('//', methods=['GET']) +def puzzle(puzzle_id): + validate(puzzle_id) + puzzle = puzzleList[puzzle_id] + response = { + "puzzle_id": puzzle_id, + "puzzle_name": puzzle.puzzle_name, + "author": puzzle.author, + "description": puzzle.description, + "date_created": puzzle.date_created, + "variants": list(puzzle.variants.keys()) + } + return format_response(response) + +@app.route('///', methods=['GET']) +def puzzle_variant(puzzle_id, variant_id): + validate(puzzle_id, variant_id) + p = puzzleList[puzzle_id].generateStartPosition(variant_id) + response = { + "starting_pos": p.serialize() + } + return format_response(response) + +@app.route('////', methods=['GET']) +def puzzle_position(puzzle_id, variant_id, position): + validate(puzzle_id, variant_id, position) + p = puzzleList[puzzle_id].deserialize(position) + solver_cls = puzzleList[puzzle_id].variants[variant_id] + s = solver_cls(p, dir_path=app.config['DATABASE_DIR']) + moves = p.generateMovePositions() + response = { + "position": p.serialize(), + "remoteness": s.getRemoteness(p), + "value": s.getValue(p), + "moves": {str(move[0]) : { + "position": move[1].serialize(), + "remoteness": s.getRemoteness(move[1]), + "value": s.getValue(move[1]) + } for move in moves} + } + return format_response(response) + +# Handling Exceptions +@app.errorhandler(InternalServerError) +def handle_500(e): + return format_response("Server error", "error") + +@app.errorhandler(404) +def handle_404(e): + return format_response(str(e), "error") + +if __name__ == "__main__": + init_data() + host, port = '127.0.0.1', 9001 + if 'GMP_HOST' in os.environ: + host = os.environ['GMP_HOST'] + if 'GMP_PORT' in os.environ: + port = os.environ['GMP_PORT'] + app.run(host=host, port=port) diff --git a/puzzlesolver/solvers/README.md b/puzzlesolver/solvers/README.md deleted file mode 100644 index 47cbcf0..0000000 --- a/puzzlesolver/solvers/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Solvers -This directory contains all the solvers used for solving. -## Files -* **solver.py** : The abstract class used to instantiate all the solvers. -* **generalsolver.py** : The BFS Bottom-up solver, implemented in 2020 by Anthony Ling -* **picklesolverwrapper.py** : The Solver Wrapper used to serialize Solver objects, implemented in 2020 by Anthony Ling diff --git a/puzzlesolver/solvers/__init__.py b/puzzlesolver/solvers/__init__.py index e6895ac..270f681 100644 --- a/puzzlesolver/solvers/__init__.py +++ b/puzzlesolver/solvers/__init__.py @@ -1,3 +1,3 @@ from .generalsolver import GeneralSolver -from .picklesolverwrapper import PickleSolverWrapper +from .sqlitesolver import SqliteSolver from .solver import Solver \ No newline at end of file diff --git a/puzzlesolver/solvers/generalsolver.py b/puzzlesolver/solvers/generalsolver.py index b1c6439..0d06940 100644 --- a/puzzlesolver/solvers/generalsolver.py +++ b/puzzlesolver/solvers/generalsolver.py @@ -1,6 +1,7 @@ from .solver import Solver from ..util import * import queue as q +import progressbar class GeneralSolver(Solver): @@ -14,24 +15,28 @@ def getRemoteness(self, puzzle, **kwargs): if hash(puzzle) in self.remoteness: return self.remoteness[hash(puzzle)] return PuzzleValue.UNSOLVABLE - def solve(self, *args, **kwargs): - """Traverse the entire puzzle tree and classifiers all the + def solve(self, *args, verbose=False, **kwargs): + """Traverse the entire puzzle tree and classifies all the positions with values and remoteness - - If position already exists in memory, returns its value - """ - # BFS for remoteness classification - def helper(self, puzzles): - queue = q.Queue() - for puzzle in puzzles: queue.put(puzzle) - while not queue.empty(): - puzzle = queue.get() - for move in puzzle.generateMoves('undo'): - nextPuzzle = puzzle.doMove(move) - if hash(nextPuzzle) not in self.remoteness: - self.remoteness[hash(nextPuzzle)] = self.remoteness[hash(puzzle)] + 1 - queue.put(nextPuzzle) - - ends = self.puzzle.generateSolutions() - for end in ends: - self.remoteness[hash(end)] = 0 - helper(self, ends) \ No newline at end of file + """ + # Progressbar + if verbose: + print('Solving: {}'.format(self.puzzle.getName())) + bar = progressbar.ProgressBar() + bar.max_value = self.puzzle.numPositions + + solutions, queue = self.puzzle.generateSolutions(), q.Queue() + for solution in solutions: + self.remoteness[hash(solution)] = 0 + queue.put(solution) + + # BFS for remoteness classification + while not queue.empty(): + if verbose: bar.update(len(self.remoteness)) + puzzle = queue.get() + for move in puzzle.generateMoves('undo'): + nextPuzzle = puzzle.doMove(move) + if hash(nextPuzzle) not in self.remoteness: + self.remoteness[hash(nextPuzzle)] = self.remoteness[hash(puzzle)] + 1 + queue.put(nextPuzzle) + if verbose: bar.finish() \ No newline at end of file diff --git a/puzzlesolver/solvers/picklesolverwrapper.py b/puzzlesolver/solvers/picklesolverwrapper.py deleted file mode 100644 index a56c2be..0000000 --- a/puzzlesolver/solvers/picklesolverwrapper.py +++ /dev/null @@ -1,27 +0,0 @@ -import pickle -from . import GeneralSolver -from .solver import Solver -from pathlib import Path -from copy import deepcopy - -class PickleSolverWrapper(Solver): - - def __init__(self, puzzle, path="./databases", solver=GeneralSolver, **kwargs): - Path(path).mkdir(parents=True, exist_ok=True) - assert puzzle - try: - f = open(path + "/" + puzzle.getName() + '.pkl', 'rb') - self.solver = pickle.load(f) - assert isinstance(self.solver, Solver) - f.close() - except FileNotFoundError: - print("WARNING: File not found, intializing new memory storage") - self.solver = solver(puzzle) - assert isinstance(self.solver, Solver), "Not a solver" - self.solver.solve() - f = open(path + "/" + puzzle.getName() + ".pkl", 'wb') - pickle.dump(self.solver, f) - f.close() - self.solve = self.solver.solve - self.getRemoteness = self.solver.getRemoteness - \ No newline at end of file diff --git a/puzzlesolver/solvers/solver.py b/puzzlesolver/solvers/solver.py index c094140..07b82ed 100644 --- a/puzzlesolver/solvers/solver.py +++ b/puzzlesolver/solvers/solver.py @@ -16,26 +16,27 @@ def solve(self, *args, **kwargs): """ raise NotImplementedError - def getValue(self, puzzle, **kwargs): - """Returns solved value of the puzzle + def getRemoteness(self, puzzle, **kwargs): + """Finds the remoteness of the puzzle - Inputs + Inputs: puzzle -- the puzzle in question Outputs: - value of puzzle + remoteness of puzzle """ - remoteness = self.getRemoteness(puzzle, **kwargs) - if remoteness == PuzzleValue.UNSOLVABLE: return PuzzleValue.UNSOLVABLE - return PuzzleValue.SOLVABLE + raise NotImplementedError - def getRemoteness(self, puzzle, **kwargs): - """Finds the remoteness of the puzzle + # Built-in functions + def getValue(self, puzzle, **kwargs): + """Returns solved value of the puzzle - Inputs: + Inputs puzzle -- the puzzle in question Outputs: - remoteness of puzzle + value of puzzle """ - raise NotImplementedError \ No newline at end of file + remoteness = self.getRemoteness(puzzle, **kwargs) + if remoteness == PuzzleValue.UNSOLVABLE: return PuzzleValue.UNSOLVABLE + return PuzzleValue.SOLVABLE \ No newline at end of file diff --git a/puzzlesolver/solvers/sqlitesolver.py b/puzzlesolver/solvers/sqlitesolver.py new file mode 100644 index 0000000..589860a --- /dev/null +++ b/puzzlesolver/solvers/sqlitesolver.py @@ -0,0 +1,28 @@ +from . import GeneralSolver +from ..util import * +from pathlib import Path +from sqlitedict import SqliteDict + +class SqliteSolver(GeneralSolver): + + def __init__(self, puzzle, *args, dir_path='databases', **kwargs): + GeneralSolver.__init__(self, puzzle, *args, **kwargs) + Path(dir_path).mkdir(parents=True, exist_ok=True) + self.database_path = dir_path + + @property + def path(self): + return '{}/{}.sqlite'.format(self.database_path, self.puzzle.getName()) + + def getRemoteness(self, puzzle, **kwargs): + with SqliteDict(self.path) as self.remoteness: + if str(hash(puzzle)) in self.remoteness: + return self.remoteness[str(hash(puzzle))] + return PuzzleValue.UNSOLVABLE + + def solve(self, *args, **kwargs): + with SqliteDict(self.path) as self.remoteness: + if self.puzzle.getName() not in self.remoteness: + GeneralSolver.solve(self, *args, **kwargs) + self.remoteness[self.puzzle.getName()] = 1 + self.remoteness.commit() \ No newline at end of file diff --git a/puzzlesolver/util.py b/puzzlesolver/util.py index 4e1b447..88f18e9 100644 --- a/puzzlesolver/util.py +++ b/puzzlesolver/util.py @@ -7,4 +7,8 @@ class PuzzleValue: def contains(key): return (key == PuzzleValue.SOLVABLE or key == PuzzleValue.UNSOLVABLE or - key == PuzzleValue.UNDECIDED) \ No newline at end of file + key == PuzzleValue.UNDECIDED) + +class PuzzleException(Exception): + """An Exception meant to be caught by the server""" + pass diff --git a/requirements.txt b/requirements.txt index 9ad9755..13e4790 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,31 @@ +astroid==2.3.3 attrs==19.3.0 Click==7.0 -decorator==4.4.2 coverage==5.0.3 +decorator==4.4.2 Flask==1.1.1 importlib-metadata==1.5.0 +isort==4.3.21 itsdangerous==1.1.0 Jinja2==2.11.1 +lazy-object-proxy==1.4.3 MarkupSafe==1.1.1 +mccabe==0.6.1 more-itertools==8.2.0 networkx==2.4 packaging==20.1 pluggy==0.13.1 +progressbar2==3.51.0 py==1.8.1 +pylint==2.4.4 pyparsing==2.4.6 pytest==5.3.5 pytest-cov==2.8.1 +python-utils==2.4.0 six==1.14.0 +sqlitedict==1.6.0 +typed-ast==1.4.1 wcwidth==0.1.8 Werkzeug==1.0.0 +wrapt==1.11.2 zipp==3.0.0 diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 5d41e98..0000000 --- a/tests/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Tests -This is where we would be testing our solvers on the games we develop. Ideally, there should be a test for every game and a solver should have at least one game to be tested on. -### Current tests -* test_Hanoi.py: Basic Hanoi test. -* test_PickleWrapper.py: Test for the PickleWrapperSolver -* test_PuzzleTutorial.py: Test for the puzzle tutorial -* test_SolverTutorial.py: Test for the solver tutorial -* test_GeneralSolver.py: Test for GeneralSolver -* test_GraphPuzzle.py: Test for GraphPuzzle -### If you want to test manually: -Go to the base directory and execute. You have to have pytest installed. -``` -pytest -``` diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8356fda --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,40 @@ +import pytest +import tempfile + +from puzzlesolver.solvers import SqliteSolver +from puzzlesolver import server +from puzzlesolver.puzzles import puzzleList, GraphPuzzle +from puzzlesolver.util import PuzzleValue + +@pytest.fixture +def client(tmpdir): + app = server.app + if app.config['TESTING'] != True: + app.config['TESTING'] = True + app.config['DATABASE_DIR'] = tmpdir + + server.init_data() + + with app.test_client() as client: + yield client + +@pytest.fixture +def simple(): + def helper(solver_cls): + forward = GraphPuzzle(0) + bidirectional = GraphPuzzle(1) + backward = GraphPuzzle(2) + sol = GraphPuzzle(3, value=PuzzleValue.SOLVABLE) + + sol.setMove(forward, movetype="for") + sol.setMove(bidirectional, movetype="bi") + sol.setMove(backward, movetype="back") + + solver = solver_cls(sol) + solver.solve() + + assert solver.getRemoteness(backward) == 1 + assert solver.getRemoteness(sol) == 0 + assert solver.getRemoteness(bidirectional) == 1 + assert solver.getRemoteness(forward) == PuzzleValue.UNSOLVABLE + return helper \ No newline at end of file diff --git a/tests/puzzles/test_Chairs.py b/tests/puzzles/test_Chairs.py new file mode 100644 index 0000000..1e792cc --- /dev/null +++ b/tests/puzzles/test_Chairs.py @@ -0,0 +1,81 @@ +import pytest +import json + +from puzzlesolver.puzzles import Chairs +from puzzlesolver.solvers import GeneralSolver +from puzzlesolver.util import * + +# Unit testing +def testHash(): + puzzle0 = Chairs.deserialize('xxxxx-ooooo') + puzzle1 = Chairs.deserialize('xxxxx-ooooo') + puzzle2 = Chairs.deserialize('ooxxx-oxxoo') + puzzle3 = Chairs.deserialize('oo-xxxoxxoo') + assert hash(puzzle0) == hash(puzzle1) + #assert hash(puzzle0) == hash(puzzle2) + assert hash(puzzle0) != hash(puzzle3) + +def testSerialization(): + codes = ['ooxxx-oxxoo', '-ooxxxoxxoo', 'ooooo-xxxxx', 'xxxxxooo-oo', 'xxx-xxoxoooo'] + for code in codes: + puzzle = Chairs.deserialize(code) + assert puzzle.serialize() == code + +def testPrimitive(): + puzzle = Chairs.deserialize('ooooo-xxxxx') + assert puzzle.primitive() == PuzzleValue.SOLVABLE + puzzle = Chairs.deserialize('xxxxx-ooooo') + assert puzzle.primitive() == PuzzleValue.UNDECIDED + puzzle = Chairs.deserialize('ooooox-xxxx') + assert puzzle.primitive() == PuzzleValue.UNDECIDED + +def testMoves(): + puzzle0 = Chairs.deserialize('xxxxx-ooooo') + puzzle1 = puzzle0.doMove(4) + assert puzzle1.serialize() == 'xxxx-xooooo' + puzzle2 = puzzle1.doMove(6) + assert puzzle2.serialize() == 'xxxxox-oooo' + puzzle3 = puzzle2.doMove(5) + assert puzzle3.serialize() == 'xxxxo-xoooo' + + with pytest.raises(Exception): puzzle1.doMove(11) + with pytest.raises(Exception): puzzle0.doMove(2) + with pytest.raises(Exception): puzzle0.doMove(8) + with pytest.raises(Exception): puzzle0.doMove(-1) + + assert len(puzzle0.generateMoves(movetype='for')) == 4 + assert len(puzzle1.generateMoves(movetype='for')) == 3 + assert len(puzzle2.generateMoves(movetype='for')) == 3 + assert len(puzzle3.generateMoves(movetype='for')) == 2 + +def testPositions(): + puzzle0 = Chairs.generateStartPosition('10') + assert puzzle0.serialize() == 'xxxxx-ooooo' + puzzles = puzzle0.generateSolutions() + assert len(puzzles) == 1 + +def testValidation(): + invalid_puzzle = 'xxxoo-ooooo' + valid_puzzle = 'oooxx-ooxxx' + blank_puzzle = "" + weird_input = 'xxxyx_ooo--oo' + pytest.raises(PuzzleException, Chairs.validate, blank_puzzle, "10") + pytest.raises(PuzzleException, Chairs.validate, weird_input, "10") + pytest.raises(PuzzleException, Chairs.validate, invalid_puzzle, "10") + Chairs.validate(valid_puzzle, "10") + +def testPuzzleServer(client): + pid = Chairs.puzzleid + rv = client.get('/{}/'.format(pid)) + d = json.loads(rv.data) + + assert d['response']['variants'] == list(Chairs.variants.keys()) + + def helper(puzzleid, code, variantid, remoteness): + rv = client.get('/{}/{}/{}/'.format(puzzleid, variantid, code)) + d = json.loads(rv.data) + assert d['response']['remoteness'] == remoteness + + helper(pid, 'ooooo-xxxxx', '10', 0) + helper(pid, 'xxxxx-ooooo', '10', 35) + helper(pid, 'xxxxxooooo-', '10', PuzzleValue.UNSOLVABLE) \ No newline at end of file diff --git a/tests/puzzles/test_Hanoi.py b/tests/puzzles/test_Hanoi.py index ab5b146..ac7ab3b 100644 --- a/tests/puzzles/test_Hanoi.py +++ b/tests/puzzles/test_Hanoi.py @@ -1,13 +1,88 @@ import pytest +import json from puzzlesolver.puzzles import Hanoi from puzzlesolver.solvers import GeneralSolver from puzzlesolver.util import * -def testGeneral(): - for i in range(5): - puzzle = Hanoi(size=i) - solver = GeneralSolver(puzzle=puzzle) - solver.solve() - assert solver.getValue(puzzle) == PuzzleValue.SOLVABLE - assert solver.getRemoteness(puzzle) == 2**i - 1 +def move(move0, move1): + return (move0, move1) + +# Unit testing +def testHash(): + puzzle0 = Hanoi.deserialize('3_2_1--') + puzzle1 = Hanoi.deserialize('3_2_1--') + puzzle2 = Hanoi.deserialize('-3_2_1-') + puzzle3 = Hanoi.deserialize('--3_2_1') + assert hash(puzzle0) == hash(puzzle1) + #assert hash(puzzle0) == hash(puzzle2) + assert hash(puzzle0) != hash(puzzle3) + +def testSerialization(): + codes = ['3_2_1--', '-3_2_1-', '--3_2_1', '-3_2-1', '1--'] + + for code in codes: + puzzle = Hanoi.deserialize(code) + assert puzzle.serialize() == code + +def testPrimitive(): + puzzle = Hanoi.deserialize('3_2_1--') + assert puzzle.primitive() == PuzzleValue.UNDECIDED + puzzle = Hanoi.deserialize('--3_2_1') + assert puzzle.primitive() == PuzzleValue.SOLVABLE + +def testMoves(): + puzzle0 = Hanoi.deserialize('3_2_1--') + puzzle1 = puzzle0.doMove(move(0, 1)) + assert puzzle1.serialize() == '3_2-1-' + puzzle2 = puzzle1.doMove(move(0, 2)) + assert puzzle2.serialize() == '3-1-2' + + puzzle3 = puzzle1.doMove(move(1, 0)) + assert puzzle0.serialize() == puzzle3.serialize() + + with pytest.raises(Exception): puzzle1.doMove(move(0, 1)) + with pytest.raises(Exception): puzzle0.doMove(move(1, 0)) + with pytest.raises(Exception): puzzle0.doMove(move(0, 3)) + + assert len(puzzle0.generateMoves()) == 2 + assert len(puzzle1.generateMoves()) == 3 + assert len(puzzle2.generateMoves()) == 3 + assert len(puzzle3.generateMoves()) == 2 + +def testPositions(): + puzzle0 = Hanoi.generateStartPosition('3') + assert puzzle0.serialize() == '3_2_1--' + puzzles = puzzle0.generateSolutions() + assert len(puzzles) == 1 + assert puzzles[0].serialize() == '--3_2_1' + +def testValidation(): + invalid_puzzle = "1_2_3--" + valid_puzzle = "3_2_1--" + blank_puzzle = "" + weird_input = "123__" + pytest.raises(PuzzleException, Hanoi.validate, blank_puzzle, "3") + pytest.raises(PuzzleException, Hanoi.validate, weird_input, "3") + pytest.raises(PuzzleException, Hanoi.validate, invalid_puzzle, "3") + pytest.raises(PuzzleException, Hanoi.validate, valid_puzzle, "4") + Hanoi.validate(valid_puzzle, "3") + +# Server methods +def test_server_puzzle(client): + rv = client.get('/{}/'.format(Hanoi.puzzleid)) + d = json.loads(rv.data) + + assert d['response']['variants'] == list(Hanoi.variants.keys()) + + def helper(puzzleid, code, variantid, remoteness): + rv = client.get('/{}/{}/{}/'.format(puzzleid, variantid, code)) + d = json.loads(rv.data) + assert d['response']['remoteness'] == remoteness + + pid = Hanoi.puzzleid + helper(pid, '1--', 1, 1) + helper(pid, '-1-', 1, 1) + helper(pid, '--1', 1, 0) + + helper(pid, '2_1-3-', 3, 4) \ No newline at end of file diff --git a/tests/puzzles/test_pegSolitaire.py b/tests/puzzles/test_pegSolitaire.py new file mode 100644 index 0000000..9f1c187 --- /dev/null +++ b/tests/puzzles/test_pegSolitaire.py @@ -0,0 +1,88 @@ +import pytest +import json + +from puzzlesolver.puzzles import Peg +from puzzlesolver.solvers import GeneralSolver +from puzzlesolver.util import * + +def move(move0, move1): + return [move0, move1] + +# Unit testing +def testHash(): + puzzle0 = Peg.deserialize('1_11_010_1111_01101_') + puzzle1 = Peg.deserialize('1_11_010_1111_01101_') + puzzle2 = Peg.deserialize('1_11_100_0011_11110_') + puzzle3 = Peg.deserialize('0_10_100_1101_10111_') + assert hash(puzzle0) == hash(puzzle1) + #assert hash(puzzle0) == hash(puzzle2) + assert hash(puzzle0) != hash(puzzle3) + +def testSerialization(): + codes = ['1_11_010_1111_01101_', '1_11_110_0000_01101_', '0_00_010_0000_00000_', '1_11_110_0011_00011_', '0_11_111_1111_00001_'] + for code in codes: + puzzle = Peg.deserialize(code) + assert puzzle.serialize() == code + +def testPrimitive(): + puzzle = Peg.deserialize('1_11_010_1111_01101_') + assert puzzle.primitive() == PuzzleValue.UNDECIDED + puzzle = Peg.deserialize('0_00_000_1000_00000_') + assert puzzle.primitive() == PuzzleValue.SOLVABLE + puzzle = Peg.deserialize('0_00_000_0000_00010_') + assert puzzle.primitive() == PuzzleValue.SOLVABLE + puzzle = Peg.deserialize('0_10_000_0000_00010_') + assert puzzle.primitive() == PuzzleValue.UNDECIDED + +def testMoves(): + puzzle0 = Peg.deserialize('0_11_111_1111_11111_') + puzzle1 = puzzle0.doMove(move([2,0],[0,0])) + assert puzzle1.serialize() == '1_01_011_1111_11111_' + puzzle2 = puzzle1.doMove(move([2,2],[2,0])) + assert puzzle2.serialize() == '1_01_100_1111_11111_' + puzzle3 = puzzle2.doMove(move([3,0],[1,0])) + assert puzzle3.serialize() == '1_11_000_0111_11111_' + + with pytest.raises(Exception): puzzle1.doMove(move([1,0], [1,1])) + with pytest.raises(Exception): puzzle0.doMove(move([0,0], [1,0])) + with pytest.raises(Exception): puzzle0.doMove(move([3,0], [0,0])) + + assert len(puzzle0.generateMoves(movetype='for')) == 2 + assert len(puzzle1.generateMoves(movetype='for')) == 4 + assert len(puzzle2.generateMoves(movetype='for')) == 6 + assert len(puzzle3.generateMoves(movetype='for')) == 8 + +def testPositions(): + puzzle0 = Peg.generateStartPosition('Triangle') + assert puzzle0.serialize() == '0_11_111_1111_11111_' + puzzles = puzzle0.generateSolutions() + assert len(puzzles) == 15 + +def testValidation(): + invalid_puzzle = '1_11_111_1111_11111_' + valid_puzzle = '1_11_000_0111_11111_' + blank_puzzle = "" + weird_input = "111_0_11_22_22_11011" + pytest.raises(PuzzleException, Peg.validate, blank_puzzle, "Triangle") + pytest.raises(PuzzleException, Peg.validate, weird_input, "Triangle") + pytest.raises(PuzzleException, Peg.validate, invalid_puzzle, "Triangle") + Peg.validate(valid_puzzle, "Triangle") + +def testPuzzleServer(client): + pid = Peg.puzzleid + rv = client.get('/{}/'.format(pid)) + d = json.loads(rv.data) + + assert d['response']['variants'] == list(Peg.variants.keys()) + + def helper(puzzleid, code, variantid, remoteness): + rv = client.get('/{}/{}/{}/'.format(puzzleid, variantid, code)) + d = json.loads(rv.data) + assert d['response']['remoteness'] == remoteness + + helper(pid, '0_00_000_0000_10000_', 'Triangle', 0) + helper(pid, '0_00_000_0000_01100_', 'Triangle', 1) + helper(pid, '1_00_000_0000_00000_', 'Triangle', 0) + helper(pid, '1_00_000_0000_10000_', 'Triangle', PuzzleValue.UNSOLVABLE) + + helper(pid, '1_11_000_0111_11111_', 'Triangle', 10) \ No newline at end of file diff --git a/tests/solvers/test_GeneralSolver.py b/tests/solvers/test_GeneralSolver.py index 3a0eada..683cd8d 100644 --- a/tests/solvers/test_GeneralSolver.py +++ b/tests/solvers/test_GeneralSolver.py @@ -4,20 +4,5 @@ from puzzlesolver.solvers import GeneralSolver from puzzlesolver.util import * -def testSimple(): - forward = GraphPuzzle(0) - bidirectional = GraphPuzzle(1) - backward = GraphPuzzle(2) - sol = GraphPuzzle(3, value=PuzzleValue.SOLVABLE) - - sol.setMove(forward, movetype="for") - sol.setMove(bidirectional, movetype="bi") - sol.setMove(backward, movetype="back") - - solver = GeneralSolver(sol) - solver.solve() - - assert solver.getRemoteness(backward) == 1 - assert solver.getRemoteness(sol) == 0 - assert solver.getRemoteness(bidirectional) == 1 - assert solver.getRemoteness(forward) == PuzzleValue.UNSOLVABLE \ No newline at end of file +def testSimple(simple): + simple(GeneralSolver) \ No newline at end of file diff --git a/tests/solvers/test_PickleWrapper.py b/tests/solvers/test_PickleWrapper.py deleted file mode 100644 index c17d039..0000000 --- a/tests/solvers/test_PickleWrapper.py +++ /dev/null @@ -1,55 +0,0 @@ -import pytest - -from puzzlesolver.puzzles import Hanoi -from puzzlesolver.solvers import PickleSolverWrapper -from puzzlesolver.solvers import GeneralSolver -from puzzlesolver.util import * - -import tempfile - -from unittest import mock - -import sys - -class PickableMock(mock.Mock): - def __reduce__(self): - return (mock.Mock, ()) - -def testPickleWrapperGeneral(): - for i in range(1,5): - with tempfile.TemporaryDirectory() as directory: - puzzle = Hanoi(size=i) - solver = PickleSolverWrapper(puzzle=puzzle, path=directory, solver=GeneralSolver) - assert solver.getValue(puzzle) == PuzzleValue.SOLVABLE - assert solver.getRemoteness(puzzle) == 2**i - 1 - -def testPickleWrapper(): - with tempfile.TemporaryDirectory() as directory: - solver_mock = PickableMock(spec=GeneralSolver) - solver_mock.return_value.solve.return_value = True - solver_mock.return_value.getRemoteness.return_value = -1 - - solver_class = mock.Mock() - solver_class.return_value = solver_mock - - solver = PickleSolverWrapper(Hanoi(), path=directory, solver=solver_class) - - assert solver.solve() == solver_mock.solve() - assert solver.getRemoteness() == solver_mock.getRemoteness() - -def testPickleWrapperPersistence(): - open_mock = mock.mock_open() - with mock.patch('puzzlesolver.solvers.picklesolverwrapper.open', open_mock, create=True): - pickle_mock = mock.Mock() - - solver_mock = mock.Mock(spec=GeneralSolver) - solver_mock.solve.return_value = True - solver_mock.getRemoteness.return_value = -1 - - pickle_mock.load.return_value = solver_mock - with mock.patch('puzzlesolver.solvers.picklesolverwrapper.pickle', pickle_mock): - solver = PickleSolverWrapper(Hanoi(), solver=None) - - pickle_mock.load.assert_called_once() - assert solver.solve() == solver_mock.solve() - assert solver.getRemoteness() == solver_mock.getRemoteness() \ No newline at end of file diff --git a/tests/solvers/test_SqliteSolver.py b/tests/solvers/test_SqliteSolver.py new file mode 100644 index 0000000..c708344 --- /dev/null +++ b/tests/solvers/test_SqliteSolver.py @@ -0,0 +1,23 @@ +import pytest + +from puzzlesolver.solvers import SqliteSolver +from puzzlesolver.puzzles import GraphPuzzle +from puzzlesolver.util import PuzzleValue + +def test_simple(tmpdir): + forward = GraphPuzzle(0) + bidirectional = GraphPuzzle(1) + backward = GraphPuzzle(2) + sol = GraphPuzzle(3, value=PuzzleValue.SOLVABLE) + + sol.setMove(forward, movetype="for") + sol.setMove(bidirectional, movetype="bi") + sol.setMove(backward, movetype="back") + + solver = SqliteSolver(sol, dir_path=tmpdir) + solver.solve() + + assert solver.getRemoteness(backward) == 1 + assert solver.getRemoteness(sol) == 0 + assert solver.getRemoteness(bidirectional) == 1 + assert solver.getRemoteness(forward) == PuzzleValue.UNSOLVABLE \ No newline at end of file diff --git a/tests/test_PuzzlePlayer.py b/tests/test_PuzzlePlayer.py index 34fcc7f..ac010f4 100644 --- a/tests/test_PuzzlePlayer.py +++ b/tests/test_PuzzlePlayer.py @@ -23,4 +23,4 @@ def testGeneral(): with mock.patch('puzzlesolver.puzzleplayer.input', input_mock): pp.play() - assert input_mock.call_count == 3 \ No newline at end of file + assert input_mock.call_count == 3 diff --git a/tests/test_Server.py b/tests/test_Server.py new file mode 100644 index 0000000..5d5f113 --- /dev/null +++ b/tests/test_Server.py @@ -0,0 +1,13 @@ +import json + +import pytest +import tempfile + +from puzzlesolver.server import app +from puzzlesolver.puzzles import puzzleList + +def test_default_path(client): + rv = client.get('/') + d = json.loads(rv.data) + for puzzle in d['response']['puzzles']: + assert puzzle in puzzleList \ No newline at end of file diff --git a/tests/tutorials/test_PuzzleTutorial.py b/tests/tutorials/test_PuzzleTutorial.py deleted file mode 100644 index e5174dd..0000000 --- a/tests/tutorials/test_PuzzleTutorial.py +++ /dev/null @@ -1,55 +0,0 @@ -import pytest - -from copy import deepcopy -from puzzlesolver.util import * -from puzzlesolver.puzzles import Puzzle -from puzzlesolver.solvers import GeneralSolver -from puzzlesolver import PuzzlePlayer - -class Hanoi(Puzzle): - def __init__(self, **kwargs): - self.stacks = [[3, 2, 1], [], []] - - def __str__(self): - return str(self.stacks) - - def primitive(self, **kwargs): - if self.stacks[2] == [3, 2, 1]: - return PuzzleValue.SOLVABLE - return PuzzleValue.UNDECIDED - - def doMove(self, move, **kwargs): - if move not in self.generateMoves(): raise ValueError - newPuzzle = Hanoi() - stacks = deepcopy(self.stacks) - stacks[move[1]].append(stacks[move[0]].pop()) - newPuzzle.stacks = stacks - return newPuzzle - - def generateMoves(self, movetype="all", **kwargs): - if movetype=='for' or movetype=='back': return [] - moves = [] - for i, stack1 in enumerate(self.stacks): - if not stack1: continue - for j, stack2 in enumerate(self.stacks): - if i == j: continue - if not stack2 or stack2[-1] > stack1[-1]: moves.append((i, j)) - return moves - - def __hash__(self): - return hash(str(self.stacks)) - - def generateSolutions(self, **kwargs): - newPuzzle = Hanoi() - newPuzzle.stacks = [ - [], - [], - [3, 2, 1] - ] - return [newPuzzle] - -def testTutorial(): - puzzle = Hanoi() - solver = GeneralSolver(puzzle) - solver.solve() - assert solver.getRemoteness(puzzle) == 7 diff --git a/tests/tutorials/test_SolverTutorial.py b/tests/tutorials/test_SolverTutorial.py deleted file mode 100644 index fd3c653..0000000 --- a/tests/tutorials/test_SolverTutorial.py +++ /dev/null @@ -1,44 +0,0 @@ -from puzzlesolver.util import * -from puzzlesolver.solvers import Solver -from puzzlesolver.puzzles import Hanoi -from puzzlesolver import PuzzlePlayer -import queue as q - -class GeneralSolver(Solver): - - def __init__(self, puzzle, **kwargs): - self.remoteness = {} - self.puzzle = puzzle - - def getRemoteness(self, puzzle, **kwargs): - if hash(puzzle) in self.remoteness: return self.remoteness[hash(puzzle)] - return PuzzleValue.UNSOLVABLE - - def getValue(self, puzzle, **kwargs): - remoteness = self.getRemoteness(puzzle, **kwargs) - if remoteness == PuzzleValue.UNSOLVABLE: return PuzzleValue.UNSOLVABLE - return PuzzleValue.SOLVABLE - - def solve(self, **kwargs): - # BFS for remoteness classification - def helper(self, puzzles): - queue = q.Queue() - for puzzle in puzzles: queue.put(puzzle) - while not queue.empty(): - puzzle = queue.get() - for move in puzzle.generateMoves(): - nextPuzzle = puzzle.doMove(move) - if hash(nextPuzzle) not in self.remoteness: - self.remoteness[hash(nextPuzzle)] = self.remoteness[hash(puzzle)] + 1 - queue.put(nextPuzzle) - - ends = self.puzzle.generateSolutions() - for end in ends: - self.remoteness[hash(end)] = 0 - helper(self, ends) - -def testTutorial(): - puzzle = Hanoi() - solver = GeneralSolver(Hanoi()) - solver.solve() - assert solver.getRemoteness(puzzle) == 7 \ No newline at end of file