Your final project is to write a command-line solver for a two-player game of perfect information. The game cannot contain randomness (via dice or card draws), or information that that is known to only one player. You may not use games of severely bounded depth, such as Tic-Tac-Toe. You may consider generalizations of those games, such as Connect 4. The following list contains examples. Game not from this list must be approved, to make sure they are suitable.
k-Hands Chopsticks
Deterministic Qwirkle
The project is divided into three sprints, each of which will have feedback and evaluation. Objectives for the project include:
Functionality: your code should work and satisfy the specification for each sprint.
Communicating computation: working in a group requires that you be able to communicate computational concepts. You should ensure everyone in the group understands the code well enough to work on it.
Design - abstraction and decomposition: you must decide how to decompose your problem, how to represent your data, what functions to define, and what types to give those functions. “It works” is a poor justification for such a decision. You should divide your types and functions on logical lines that naturally fit the structure of the problem you are solving. Towards the second sprint you should consider dividing the project into separate modules.
For this sprint, you will need to be able to represent the board game in Haskell, make moves on the board game, and tell if a player has won the board game. You should have a variable defined that represents the initial game state. I should be able to play the game by loading your code in GHCI and calling functions you have defined.
This sprint should implement the following stories, each of which should be implemented on one or more branches. In total, you will add 6 stories (pieces of functionality) to your list of tasks to be finished by the sprint, called the backlog. I have included an Estimate of the relative difficulty of each story, selected from the range 1,2,3,5,8. The Estimate considers the amount of work required, the logical difficulty of the story, and how much the story will effect the rest of the code base. The difficulty of stories 2, 3, and 4 stories can vary significantly from game to game.
Story 1: Define data types or type aliases for a player, game state, move, and winner. I suggest data types (or possibly type aliases) to represent:
The state of the board Game, including both tangible and intangible components.
Tangible componentdris includes pieces and their locations.
Intangible components may include whose turn it is, what the current bet in, or any other information not represented on the game board, but critical to game play.
A Move on the game board.
The Winner of a game.
You may have subtypes for these types as well. Be careful of using strings or integers where enumerated types would have more fidelity.
Estimate: 8.
Story 2: Check who has won the game state, if anyone, with a function of type Game -> Winner.
Estimate: 5 requires story 1.
Story 3: Compute the result of making a legal move in a game state, with a function of type Game -> Move -> Game.
Estimate: 2 requires story 1.
Story 4: Compute the legal moves from a game state, with a function of type Game -> [Move].
Estimate: 3 requires story 1.
Story 5: Pretty-print a game into a string, with a function of type Game -> String. You should not override the Show typeclass. This is primarily for debugging purposes.
Estimate: 2, requires story 1, lower priority.
Story 6: All of these functions should consider possible errors or edge cases: what if there no winner, what if the move is not legal for the current game, etc. Use Maybe's or Either's appropriately.
Estimate: 2, requires stories 1-4, for full credit.
There are two components for this sprint. In total, you will add 10 stories (pieces of functionality) to your list of tasks to be finished by the sprint, called the backlog. For this sprint, You can add these stories to the Issues tab on your GitHub page if you like. Many of these stories include specific function names and type signatures: this is usually bad practice, but is useful for this course.
First, you will solve games by determining the optimal move for a player for a game state. We will add the following stories for solving the game to our backlog:
Story 7: If necessary, ensure that your game has bounded depth. For games that can cycle, such as checkers and chess, add a turn counter and tie the game when it hits zero.
Estimate: 2
Story 8: Refactor a Winner or Outcome type that represents a win or tie (but not an ongoing game).
Have your "check who has won" function, from story 2, be of type Game -> Maybe Winner.
Estimate: 1
Story 9: Write a function whoWillWin :: Game -> Winner.
Considers every valid move, the resulting game state, and chooses the move with the best outcome for the current player. This will involve recursively searching through the game states that result from that move. Think Scavenge!
Estimate: 8, requires story 8.
Story 10: Write a function bestMove :: Game -> Move that takes a Game and return the best Move.
Given a game state, you should return a move that can force a win for the current player. Failing that, return a move that can force a tie for the current player.
This is very similar to whoWillWin, but keeps track of what the first move was that could force that outcome. That means you should not use this function to write whoWillWin.
Estimate: 5, requires story 9.
Second, you will implement a simple interface. Your program should read a file name from standard input or an argument, solve the game, and print the winning move. This can be broken down into the following stories, which we will add to our backlog.
Story 11: Design simple text format that is easy for your program to read and write. It should probably be different your "pretty show" from the first sprint.
The input format should describe the board game in progress, and can look very similar to your internal representation.
For instance, each square is a 0 for blank, 1 for player 1, or 2 for player 2. The current turn or other intangible components are given in the first (or last) few lines.
I suggest you use newlines, spaces, or other delimiters that afford the use of lines, words, and splitOn.
Estimate: 1
Story 12: Define a function readGame :: String -> Game that takes a string in your text format and returns the corresponding game.
You should design your format in story 5. to make this as easy as possible.
Estimate: 2, requires story 11
Story 13: Define a function showGame :: Game -> String that takes a game and turns it into a string in your text format.
Ensure that readGame (showGame game) works for a variety of inputs!
Estimate: 1, requires story 11
Story 14: Define a series of IO actions to wrap around these functions, as well as bestMove, and define a main IO action. You will need to build four I/O actions: one each to read and write game states from a file, one that computes and prints the winning move, and a simple main action.
At least some of these actions will need to be in a Main module that imports your other module(s).
writeGame :: Game -> FilePath -> IO ()
loadGame :: FilePath -> IO Game
putBestMove :: Game -> IO () that computes the best move and prints it to standard output. For full credit, also print the outcome that moves forces.
main :: IO (), which reads a file name from standard input or the arguments, loads the game, and prints the best move.
Estimate: 3, requires stories 10, 11, 12, and 13
Story 15: Define at least four files that can be read in by loadGame, representing games in various states of play. To be good test cases, you should have at least two games that are a maximum of three or four moves from the end, which that can be won or lost based on different choices.
Estimate: 1, requires story 11
There are also a couple of stories related to testing and error handling. In proper software development, these would be part of every story. For this course, we have split them into their own. Note story 6 is extended here.
Story 16: Define several test cases for each function: games to compute valid moves, who has won, who will win, and the best move from; games that should result from making moves on those games; and strings to convert to and from games.
You will need games that are only a few moves from the end. I suggest at least one each that is finished, one move, two moves, and four moves from the end.
Caveat: In this context, "from the end" means "worst case number of moves, no matter how either player plays." Even if there is a way to win in one move, you might end up searching every other move first - and taking those all the way to the end of the game.
Estimate: 3, lower priority.
Story 6: All functions should consider possible errors or edge cases. Return a Maybe Move, Maybe Game, etc. as appropriate.
Estimate: 2-5, depending on game, which functions needs to change, and familiarity with Maybe and do blocks. For full credit.
There are three components for this sprint. In total, you will add 11 stories to your backlog (list of tasks). I have included an Estimate of the relative difficulty of each story, selected from the range 1,2,3,5,8. You can add these stories to the Issues tab on your GitHub page if you like. As always function names and types are examples, not requirements.
First, you will implement a version of your game solver with a cut-off depth.
Story 17: Define an evaluation function, i.e. rateGame :: Game -> Rating, that returns an integer estimate of the game's state.
Your evaluation function should return a positive value if the game is good for Player One, and a negative value if the game is good for Player Two.
A game that has been won must get the best possible rating for that player: ensure it is higher (or lower) than any game that is still ongoing.
There is tension between getting a accurate evaluation function and an quick evaluation function. It usually turns out to be better to get a quick rating, even if it is not particularly accurate. In specific you should not try to look at possible moves when evaluating a board: it would be better to set your cut-off depth (see below) to a higher value.
Estimate: 3.
Story 18: Implement a version of Story 9/Story 10 (whoWillWin/bestMove) that takes a cut-off depth as a parameter. You can combine them into one function, i.e. whoMightWin :: Game -> Int -> (Rating, Move), or keep them as two different functions, whoMightWin/goodMove. After searching through a "depth" number of consecutive moves, stop searching and evaluate the current game.
You should return the move and/or rating that forces the game to the best board state within the cut-off depth.
You can use maximize/minimize. In fact, this is often called the minimax algorithm, because one player tries to maximize the rating while the other attempts to minimize it.
Estimate: 3, requires story 17.
Story 19: When you searched for the best possible outcome, you were likely able to take advantage of Haskell's laziness to avoid evaluating some recursive calls. Once you find a winning outcome, you did not need to search the rest of the list. However, maximize/minimize will always evaluate the full list. Find a way to stop searching when you find the best state (winning) for the current player.
Estimate: 2, requires story 18, for full credit.
Story 20: Create at least four more test input files. These should be games that are further from the end, but at an interesting state. Include games where one player is dominating, games that are evenly matched, and games near the start.
Estimate: 2
Second, you will have to implement a command-line interface, supporting the following flags. For full credit you should use the GetOpt library. Be sure to test your interface on the inputs from Story 20 above.
Story 21: Your program should take one argument: the name of a file describing the board in progress. Output a good move, using a reasonable cutoff depth (likely between 3 and 8), and output just the move.
Estimate: 2, requires story 18.
Story 22: Support the -w, --winner flag. When passed, the program should print out the best move, using the exhaustive search from the Story 9/Story 10 in the second sprint with no cut-off depth.
Estimate: 1.
Story 23: Support the -d <num>, --depth <num> flag, allowing the user to specify <num> as a cutoff depth, instead of your default. This has no effect when combined with the -w flag. You may print a warning if both are provided.
Estimate: 2.
Story 24: Support the -h, --help flag, which should print out a good help message and quit the program.
Estimate: 2.
Story 25: Support the -m <move>, --move <move> flag, which should <move> and print out the resulting board to stdout.
You should print in the input format. If the -v flag is provided, you may pretty-print instead.
The move should be 1-indexed. If a move requires multiple values, the move should be a tuple of numbers separated by a comma with no space. You may use letters instead of numbers if you choose, which should be lower-cased and 'a'-indexed.
Estimate: 1, for full credit.
Story 25: Support the -v, --verbose flag, which should output both the move and a description of how good it is: win, lose, tie, or a rating.
Estimate: 1, for full credit.
Story 26: Support the -i, --interactive flag, which start a new game and plays against the computer. Make sure this is compatible with the -d flag. You may also support a file-name argument of a game in progress to play against the computer.
Estimate: 5, requires story 18, for extra credit
As in the previous sprints, we will list error-checking as a separate story, even though it logically is a part of each story.
Story 27: Get your program to compile when you type make, by copying a Makefile from another project. You likely only have to change the executable name.
Story 6: (Full Credit) All functions should consider possible errors or edge cases, and use Maybe's appropriately. For the first sprint, consider if there is no winner, the move is not legal for the current game, etc. For the second sprint, consider invalid input files and games that have no available moves. For the third sprint, consider invalid flags or command line arguments. You can ignore extraneous arguments.
Estimate: 3.
Rubric:
Your project will be evaluated as follows:
Functionality (73 points)
Game mechanics: 20 points
Exact game solver: 15 points
Cut-off depth solver: 13 points
Reasonable evaluation function: 2 points
Avoiding unnecessary work: 3 points
Command-line interface: 10 points
Move and verbose flags: 5 points
Error-handling: 5 points
Points will be lost for any refutable pattern matching or use of fromJust.
Points may be lost for uses of head/tail, if they are avoidable/dangerous
Design (27 points)
Well-designed data types 8 points
Well-decomposed functions 10 points
Good module decomposition 2 points
Good variable names 2 points
Efficient/idiomatic code 5 points
Points may be lost for uses of indexing (!!), if they are avoidable or dangerous.
Points may be lost for inefficient (Schlemiel) functions, if they are part of the core logic of the program. A slow reading/showing function is fine, a slow move-making function is note.
You will not lose points for "unnecessary" checks, so long as the checks are themselves implemented well.