├── tests ├── __init__.py ├── test_token.py ├── test_board.py ├── test_ai.py ├── test_arbiter.py └── test_game.py ├── requirements-dev.txt ├── MANIFEST.in ├── xo ├── __init__.py ├── __main__.py ├── error.py ├── token.py ├── board.py ├── arbiter.py ├── game.py ├── ai.py └── cli.py ├── .gitignore ├── Makefile ├── CHANGELOG.rst ├── LICENSE.txt ├── setup.py └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | twine 2 | wheel 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.rst LICENSE.txt README.rst 2 | -------------------------------------------------------------------------------- /xo/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.0.0' 2 | __author__ = 'Dwayne Crooks' 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | __pycache__/ 3 | build/ 4 | dist/ 5 | venv/ 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | python setup.py sdist bdist_wheel 3 | 4 | clean: 5 | rm -rf build dist *.egg-info 6 | -------------------------------------------------------------------------------- /xo/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from .cli import main 4 | 5 | 6 | if __name__ == '__main__': 7 | sys.exit(main()) 8 | -------------------------------------------------------------------------------- /xo/error.py: -------------------------------------------------------------------------------- 1 | """Exception classes raised by xo. 2 | 3 | The base exception class is XOError, which inherits from Exception. It doesn't 4 | define any behavior of its own, but is the base class for all exceptions defined 5 | in this package. 6 | """ 7 | 8 | 9 | class XOError(Exception): 10 | pass 11 | 12 | 13 | class IllegalStateError(XOError): 14 | pass 15 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Change Log 2 | ---------- 3 | 4 | `1.0.0`_ (2016-09-09) 5 | +++++++++++++++++++++ 6 | 7 | **Added** 8 | 9 | - A board data structure 10 | - An arbiter 11 | - A game engine 12 | - An AI based on the Minimax algorithm 13 | - A CLI 14 | 15 | 0.0.1 (2016-09-05) 16 | ++++++++++++++++++ 17 | 18 | Birth! 19 | 20 | .. _`Unreleased`: https://github.com/dwayne/xo-python/compare/v1.0.0...HEAD 21 | .. _`1.0.0`: https://github.com/dwayne/xo-python/compare/v0.0.1...v1.0.0 22 | -------------------------------------------------------------------------------- /xo/token.py: -------------------------------------------------------------------------------- 1 | """ A token is either 'x' or 'o'. Anything else is considered an empty piece. 2 | 3 | The tokens together with all the different representations of the empty piece 4 | are all called pieces. 5 | 6 | The canonical pieces are just 'x', 'o' and ' '. 7 | """ 8 | 9 | 10 | def istoken(c): 11 | return c == 'x' or c == 'o' 12 | 13 | def isempty(c): 14 | return not istoken(c) 15 | 16 | def other_token(t): 17 | if t == 'x': 18 | return 'o' 19 | elif t == 'o': 20 | return 'x' 21 | else: 22 | raise ValueError('must be a token: {}'.format(t)) 23 | 24 | def canonical_piece(c): 25 | if c == 'x' or c == 'o': 26 | return c 27 | return ' ' 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Dwayne Crooks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from setuptools import setup 4 | 5 | 6 | with open('xo/__init__.py', encoding='utf-8') as f: 7 | version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', 8 | f.read(), re.MULTILINE).group(1) 9 | 10 | 11 | with open('README.rst', encoding='utf-8') as f: 12 | readme = f.read() 13 | 14 | 15 | with open('CHANGELOG.rst', encoding='utf-8') as f: 16 | changelog = f.read() 17 | 18 | 19 | packages = [ 20 | 'xo' 21 | ] 22 | 23 | 24 | setup( 25 | name='xo', 26 | version=version, 27 | description='A Tic-tac-toe CLI game and library.', 28 | long_description=readme + '\n\n' + changelog, 29 | url='https://github.com/dwayne/xo-python', 30 | author='Dwayne Crooks', 31 | author_email='me@dwaynecrooks.com', 32 | license='MIT', 33 | classifiers=[ 34 | 'Development Status :: 5 - Production/Stable', 35 | 'Environment :: Console', 36 | 'Intended Audience :: Developers', 37 | 'License :: OSI Approved :: MIT License', 38 | 'Operating System :: POSIX :: Linux', 39 | 'Programming Language :: Python', 40 | 'Programming Language :: Python :: 3.5', 41 | 'Programming Language :: Python :: Implementation :: CPython', 42 | 'Topic :: Games/Entertainment :: Board Games' 43 | ], 44 | keywords='tic-tac-toe tic tac toe noughts crosses', 45 | packages=packages, 46 | entry_points={ 47 | 'console_scripts': [ 48 | 'xo=xo.cli:main' 49 | ] 50 | } 51 | ) 52 | -------------------------------------------------------------------------------- /tests/test_token.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from xo import token 4 | 5 | 6 | class IsTokenTestCase(unittest.TestCase): 7 | def test_when_given_x(self): 8 | self.assertTrue(token.istoken('x')) 9 | 10 | def test_when_given_o(self): 11 | self.assertTrue(token.istoken('o')) 12 | 13 | def test_when_given_anything_other_than_x_or_o(self): 14 | self.assertFalse(token.istoken(' ')) 15 | self.assertFalse(token.istoken('.')) 16 | 17 | 18 | class IsEmptyTestCase(unittest.TestCase): 19 | def test_when_given_x(self): 20 | self.assertFalse(token.isempty('x')) 21 | 22 | def test_when_given_o(self): 23 | self.assertFalse(token.isempty('o')) 24 | 25 | def test_when_given_anything_other_than_x_or_o(self): 26 | self.assertTrue(token.isempty(' ')) 27 | self.assertTrue(token.isempty('.')) 28 | 29 | 30 | class OtherTokenTestCase(unittest.TestCase): 31 | def test_when_given_x(self): 32 | self.assertEqual(token.other_token('x'), 'o') 33 | 34 | def test_when_given_o(self): 35 | self.assertEqual(token.other_token('o'), 'x') 36 | 37 | def test_when_given_anything_other_than_x_or_o(self): 38 | for piece in [' ', '.']: 39 | with self.assertRaisesRegex(ValueError, 'must be a token: {}'.format(piece)): 40 | token.other_token(piece) 41 | 42 | 43 | class CanonicalPieceTestCase(unittest.TestCase): 44 | def test_when_given_x(self): 45 | self.assertEqual(token.canonical_piece('x'), 'x') 46 | 47 | def test_when_given_o(self): 48 | self.assertEqual(token.canonical_piece('o'), 'o') 49 | 50 | def test_when_given_anything_other_than_x_or_o(self): 51 | self.assertEqual(token.canonical_piece(' '), ' ') 52 | self.assertEqual(token.canonical_piece('.'), ' ') 53 | -------------------------------------------------------------------------------- /xo/board.py: -------------------------------------------------------------------------------- 1 | from .token import canonical_piece, istoken 2 | 3 | 4 | nrows = 3 5 | ncols = 3 6 | ncells = nrows * ncols 7 | 8 | 9 | class Board: 10 | @classmethod 11 | def fromstring(cls, layout=''): 12 | cells = [' '] * ncells 13 | 14 | for i, piece in enumerate(layout): 15 | if i >= ncells: 16 | break 17 | 18 | if istoken(piece): 19 | cells[i] = piece 20 | 21 | return cls(cells) 22 | 23 | # This should never be called directly. Use fromstring instead. 24 | def __init__(self, cells): 25 | self.cells = cells 26 | 27 | def __getitem__(self, pos): 28 | return self.cells[self._idx(*pos)] 29 | 30 | def __setitem__(self, pos, piece): 31 | self.cells[self._idx(*pos)] = canonical_piece(piece) 32 | 33 | def __iter__(self): 34 | return self._each_piece() 35 | 36 | def _each_piece(self): 37 | for i, piece in enumerate(self.cells): 38 | yield self._idx_to_row(i), self._idx_to_col(i), piece 39 | 40 | def toascii(self): 41 | return '\n---+---+---\n'.join([ 42 | ' {} | {} | {} '.format(self.cells[0], self.cells[1], self.cells[2]), 43 | ' {} | {} | {} '.format(self.cells[3], self.cells[4], self.cells[5]), 44 | ' {} | {} | {} '.format(self.cells[6], self.cells[7], self.cells[8]) 45 | ]) 46 | 47 | def __str__(self): 48 | return ''.join(piece if istoken(piece) else '.' for piece in self.cells) 49 | 50 | @staticmethod 51 | def contains(r, c): 52 | return 1 <= r <= nrows and 1 <= c <= ncols 53 | 54 | @classmethod 55 | def _idx(cls, r, c): 56 | if cls.contains(r, c): 57 | return ncols * (r - 1) + (c - 1) 58 | else: 59 | raise IndexError('position out of bounds: {}, {}'.format(r, c)) 60 | 61 | @staticmethod 62 | def _idx_to_row(i): 63 | return i // ncols + 1 64 | 65 | @staticmethod 66 | def _idx_to_col(i): 67 | return i % ncols + 1 68 | -------------------------------------------------------------------------------- /tests/test_board.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from xo.board import Board 4 | 5 | 6 | class BoardCreationTestCase(unittest.TestCase): 7 | def test_when_layout_is_empty(self): 8 | self.assertEqual(str(Board.fromstring()), '.........') 9 | 10 | def test_when_layout_contains_non_pieces(self): 11 | self.assertEqual(str(Board.fromstring('x.o-*x1o^')), 'x.o..x.o.') 12 | 13 | def test_when_layout_is_non_empty_but_shorter_than_9_characters(self): 14 | self.assertEqual(str(Board.fromstring('x')), 'x........') 15 | 16 | def test_when_layout_is_longer_than_9_characters(self): 17 | self.assertEqual(str(Board.fromstring('x oxoxo')), 'x.......o') 18 | 19 | 20 | class BoardGetItemTestCase(unittest.TestCase): 21 | def setUp(self): 22 | self.board = Board.fromstring('x.o.o.x.x') 23 | 24 | def test_when_board_contains_position(self): 25 | self.assertEqual(self.board[1, 1], 'x') 26 | self.assertEqual(self.board[1, 2], ' ') 27 | self.assertEqual(self.board[1, 3], 'o') 28 | self.assertEqual(self.board[2, 1], ' ') 29 | self.assertEqual(self.board[2, 2], 'o') 30 | self.assertEqual(self.board[2, 3], ' ') 31 | self.assertEqual(self.board[3, 1], 'x') 32 | self.assertEqual(self.board[3, 2], ' ') 33 | self.assertEqual(self.board[3, 3], 'x') 34 | 35 | def test_when_board_does_not_contain_position(self): 36 | with self.assertRaisesRegex(IndexError, 'position out of bounds: 0, 0'): 37 | self.board[0, 0] 38 | 39 | 40 | class BoardSetItemTestCase(unittest.TestCase): 41 | def setUp(self): 42 | self.board = Board.fromstring() 43 | 44 | def test_when_board_contains_position(self): 45 | self.board[1, 1] = 'o' 46 | self.board[1, 3] = 'x' 47 | self.board[2, 2] = 'o' 48 | self.board[3, 3] = 'x' 49 | 50 | self.assertEqual(str(self.board), 'o.x.o...x') 51 | 52 | def test_when_board_does_not_contain_position(self): 53 | with self.assertRaisesRegex(IndexError, 'position out of bounds: 2, 4'): 54 | self.board[2, 4] = 'o' 55 | 56 | 57 | class BoardIterationTestCase(unittest.TestCase): 58 | def setUp(self): 59 | self.board = Board.fromstring('x.xo..') 60 | 61 | def test_it_iterates_over_each_piece_in_row_major_order(self): 62 | expected = [ 63 | (1, 1, 'x'), 64 | (1, 2, ' '), 65 | (1, 3, 'x'), 66 | (2, 1, 'o'), 67 | (2, 2, ' '), 68 | (2, 3, ' '), 69 | (3, 1, ' '), 70 | (3, 2, ' '), 71 | (3, 3, ' ') 72 | ] 73 | 74 | for i, (r, c, piece) in enumerate(self.board): 75 | with self.subTest(r=r, c=c): 76 | self.assertEqual((r, c, piece), expected[i]) 77 | 78 | 79 | class BoardToASCIITestCase(unittest.TestCase): 80 | def setUp(self): 81 | self.board = Board.fromstring('x...o') 82 | 83 | def test_it_outputs_a_multiline_string_representation(self): 84 | self.assertEqual( 85 | self.board.toascii(), 86 | ' x | | \n' 87 | '---+---+---\n' 88 | ' | o | \n' 89 | '---+---+---\n' 90 | ' | | ' 91 | ) 92 | -------------------------------------------------------------------------------- /tests/test_ai.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import xo.ai as ai 4 | from xo.board import Board 5 | 6 | 7 | class OpeningGameTestCase(unittest.TestCase): 8 | def test_xeeeeeeee(self): 9 | self.assertEqual(ai.evaluate(Board.fromstring('x'), 'o').positions, 10 | [(2, 2)] 11 | ) 12 | 13 | def test_exeeeeeee(self): 14 | self.assertEqual(ai.evaluate(Board.fromstring('.x'), 'o').positions, 15 | [(1, 1), (1, 3), (2, 2), (3, 2)] 16 | ) 17 | 18 | def test_eeeexeeee(self): 19 | self.assertEqual(ai.evaluate(Board.fromstring('....x'), 'o').positions, 20 | [(1, 1), (1, 3), (3, 1), (3, 3)] 21 | ) 22 | 23 | 24 | class MiddleGameTestCase(unittest.TestCase): 25 | def test_xoeeeeeee(self): 26 | self.assertEqual(ai.evaluate(Board.fromstring('xo'), 'x').positions, 27 | [(2, 1), (2, 2), (3, 1)] 28 | ) 29 | 30 | def test_xeoeeeeee(self): 31 | self.assertEqual(ai.evaluate(Board.fromstring('x.o'), 'x').positions, 32 | [(2, 1), (3, 1), (3, 3)] 33 | ) 34 | 35 | def test_xeeeoeeee(self): 36 | self.assertEqual(ai.evaluate(Board.fromstring('x...o'), 'x').positions, 37 | [(1, 2), (1, 3), (2, 1), (2, 3), (3, 1), (3, 2), (3, 3)] 38 | ) 39 | 40 | def test_xeeeeoeee(self): 41 | self.assertEqual(ai.evaluate(Board.fromstring('x....o'), 'x').positions, 42 | [(1, 3), (2, 2), (3, 1)] 43 | ) 44 | 45 | def test_xeeeeeeeo(self): 46 | self.assertEqual(ai.evaluate(Board.fromstring('x.......o'), 'x').positions, 47 | [(1, 3), (3, 1)] 48 | ) 49 | 50 | 51 | class EndGameTestCase(unittest.TestCase): 52 | def test_xoexoeeee(self): 53 | self.assertEqual(ai.evaluate(Board.fromstring('xo.xo.'), 'x').positions, 54 | [(3, 1)] 55 | ) 56 | 57 | def test_xoexoeeee(self): 58 | self.assertEqual(ai.evaluate(Board.fromstring('xo.xo.'), 'o').positions, 59 | [(3, 2)] 60 | ) 61 | 62 | def test_xexeoeeee(self): 63 | self.assertEqual(ai.evaluate(Board.fromstring('x.x.o'), 'o').positions, 64 | [(1, 2)] 65 | ) 66 | 67 | def test_xeooeexex(self): 68 | self.assertEqual(ai.evaluate(Board.fromstring('x.oo..x.x'), 'o').positions, 69 | [(1, 2), (2, 2), (2, 3), (3, 2)] 70 | ) 71 | 72 | 73 | class BadArgumentTestCase(unittest.TestCase): 74 | def test_when_not_a_token(self): 75 | with self.assertRaisesRegex(ValueError, 'must be a token: .'): 76 | ai.evaluate(Board.fromstring(), '.') 77 | 78 | def test_when_no_moves_available(self): 79 | with self.assertRaisesRegex(ValueError, 'no available moves: xxxoo....'): 80 | ai.evaluate(Board.fromstring('xxxoo'), 'x') 81 | 82 | with self.assertRaisesRegex(ValueError, 'no available moves: xxxoo....'): 83 | ai.evaluate(Board.fromstring('xxxoo'), 'o') 84 | 85 | def test_when_board_is_invalid(self): 86 | with self.assertRaisesRegex(ValueError, 'invalid board: xxx......'): 87 | ai.evaluate(Board.fromstring('xxx'), 'x') 88 | 89 | with self.assertRaisesRegex(ValueError, 'invalid board: xxx......'): 90 | ai.evaluate(Board.fromstring('xxx'), 'o') 91 | 92 | def test_when_not_token_turn(self): 93 | with self.assertRaisesRegex(ValueError, "not x's turn to play: xxo......"): 94 | ai.evaluate(Board.fromstring('xxo'), 'x') 95 | -------------------------------------------------------------------------------- /tests/test_arbiter.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import xo.arbiter as arbiter 4 | from xo.board import Board 5 | 6 | 7 | class InProgressPositionsTestCase(unittest.TestCase): 8 | def test_when_board_is_empty(self): 9 | self.assertEqual( 10 | arbiter.outcome(Board.fromstring(), 'x')['status'], 11 | arbiter.STATUS_IN_PROGRESS 12 | ) 13 | 14 | self.assertEqual( 15 | arbiter.outcome(Board.fromstring(), 'o')['status'], 16 | arbiter.STATUS_IN_PROGRESS 17 | ) 18 | 19 | def test_when_board_is_exeoex(self): 20 | self.assertEqual( 21 | arbiter.outcome(Board.fromstring(' x o x'), 'x')['status'], 22 | arbiter.STATUS_IN_PROGRESS 23 | ) 24 | 25 | self.assertEqual( 26 | arbiter.outcome(Board.fromstring(' x o x'), 'o')['status'], 27 | arbiter.STATUS_IN_PROGRESS 28 | ) 29 | 30 | 31 | class GameoverPositionsTestCase(unittest.TestCase): 32 | def test_when_x_wins(self): 33 | outcome = arbiter.outcome(Board.fromstring('xxxoo'), 'x') 34 | 35 | self.assertEqual(outcome['status'], arbiter.STATUS_GAMEOVER) 36 | self.assertEqual(outcome['reason'], arbiter.REASON_WINNER) 37 | self.assertEqual(outcome['details'], [{ 38 | 'where': 'row', 39 | 'index': 1, 40 | 'positions': [(1, 1), (1, 2), (1, 3)] 41 | }]) 42 | 43 | def test_when_o_loses(self): 44 | outcome = arbiter.outcome(Board.fromstring('oo xxx'), 'o') 45 | 46 | self.assertEqual(outcome['status'], arbiter.STATUS_GAMEOVER) 47 | self.assertEqual(outcome['reason'], arbiter.REASON_LOSER) 48 | self.assertEqual(outcome['details'], [{ 49 | 'where': 'row', 50 | 'index': 2, 51 | 'positions': [(2, 1), (2, 2), (2, 3)] 52 | }]) 53 | 54 | def test_when_game_is_squashed(self): 55 | for token in ['x', 'o']: 56 | outcome = arbiter.outcome(Board.fromstring('xoxxoooxx'), token) 57 | 58 | self.assertEqual(outcome['status'], arbiter.STATUS_GAMEOVER) 59 | self.assertEqual(outcome['reason'], arbiter.REASON_SQUASHED) 60 | 61 | 62 | class InvalidPositionsTestCase(unittest.TestCase): 63 | def test_when_too_many_moves_ahead(self): 64 | for token in ['x', 'o']: 65 | outcome = arbiter.outcome(Board.fromstring('xx'), token) 66 | 67 | self.assertEqual(outcome['status'], arbiter.STATUS_INVALID) 68 | self.assertEqual( 69 | outcome['reason'], 70 | arbiter.REASON_TOO_MANY_MOVES_AHEAD 71 | ) 72 | 73 | def test_when_two_winners(self): 74 | for token in ['x', 'o']: 75 | outcome = arbiter.outcome(Board.fromstring('xo xo xo'), token) 76 | 77 | self.assertEqual(outcome['status'], arbiter.STATUS_INVALID) 78 | self.assertEqual(outcome['reason'], arbiter.REASON_TWO_WINNERS) 79 | 80 | 81 | class ArgumentErrorTestCase(unittest.TestCase): 82 | def test_when_not_given_a_token(self): 83 | with self.assertRaisesRegex(ValueError, 'must be a token: '): 84 | arbiter.outcome(Board.fromstring(), ' ') 85 | 86 | 87 | class CountPiecesTestCase(unittest.TestCase): 88 | def test_it_computes_the_number_of_xs_os_and_es(self): 89 | piece_counts = arbiter.count_pieces(Board.fromstring('xoxoo')) 90 | 91 | self.assertEqual(piece_counts['xs'], 2) 92 | self.assertEqual(piece_counts['os'], 3) 93 | self.assertEqual(piece_counts['es'], 4) 94 | -------------------------------------------------------------------------------- /xo/arbiter.py: -------------------------------------------------------------------------------- 1 | from .token import istoken, other_token 2 | 3 | 4 | STATUS_INVALID = 'invalid' 5 | STATUS_GAMEOVER = 'gameover' 6 | STATUS_IN_PROGRESS = 'in-progress' 7 | 8 | 9 | REASON_TOO_MANY_MOVES_AHEAD = 'too-many-moves-ahead' 10 | REASON_TWO_WINNERS = 'two-winners' 11 | REASON_WINNER = 'winner' 12 | REASON_LOSER = 'loser' 13 | REASON_SQUASHED = 'squashed' 14 | 15 | 16 | def outcome(board, token): 17 | if not istoken(token): 18 | raise ValueError('must be a token: {}'.format(token)) 19 | 20 | piece_counts = count_pieces(board) 21 | 22 | if _two_or_more_moves_ahead(piece_counts): 23 | result = { 24 | 'status': STATUS_INVALID, 25 | 'reason': REASON_TOO_MANY_MOVES_AHEAD 26 | } 27 | else: 28 | winners = _find_winners(board) 29 | 30 | if _has_two_winners(winners): 31 | result = { 32 | 'status': STATUS_INVALID, 33 | 'reason': REASON_TWO_WINNERS 34 | } 35 | elif _is_winner(winners, token): 36 | result = { 37 | 'status': STATUS_GAMEOVER, 38 | 'reason': REASON_WINNER, 39 | 'details': winners[token] 40 | } 41 | elif _is_winner(winners, other_token(token)): 42 | result = { 43 | 'status': STATUS_GAMEOVER, 44 | 'reason': REASON_LOSER, 45 | 'details': winners[other_token(token)] 46 | } 47 | elif _is_squashed(piece_counts): 48 | result = { 49 | 'status': STATUS_GAMEOVER, 50 | 'reason': REASON_SQUASHED 51 | } 52 | else: 53 | result = { 'status': STATUS_IN_PROGRESS } 54 | 55 | result['piece_counts'] = piece_counts 56 | 57 | return result 58 | 59 | 60 | def count_pieces(board): 61 | xs, os, es = 0, 0, 0 62 | 63 | for _, _, piece in board: 64 | if piece == 'x': 65 | xs += 1 66 | elif piece == 'o': 67 | os += 1 68 | else: 69 | es += 1 70 | 71 | return { 'xs': xs, 'os': os, 'es': es } 72 | 73 | 74 | def _two_or_more_moves_ahead(piece_counts): 75 | return abs(piece_counts['xs'] - piece_counts['os']) >= 2 76 | 77 | 78 | _winning_positions = [ 79 | { 'where': 'row', 'index': 1, 'positions': [(1, 1), (1, 2), (1, 3)] }, 80 | { 'where': 'row', 'index': 2, 'positions': [(2, 1), (2, 2), (2, 3)] }, 81 | { 'where': 'row', 'index': 3, 'positions': [(3, 1), (3, 2), (3, 3)] }, 82 | 83 | { 'where': 'column', 'index': 1, 'positions': [(1, 1), (2, 1), (3, 1)] }, 84 | { 'where': 'column', 'index': 2, 'positions': [(1, 2), (2, 2), (3, 2)] }, 85 | { 'where': 'column', 'index': 3, 'positions': [(1, 3), (2, 3), (3, 3)] }, 86 | 87 | { 'where': 'diagonal', 'index': 1, 'positions': [(1, 1), (2, 2), (3, 3)] }, 88 | { 'where': 'diagonal', 'index': 2, 'positions': [(1, 3), (2, 2), (3, 1)] } 89 | ] 90 | 91 | 92 | def _find_winners(board): 93 | winners = { 'x': [], 'o': [] } 94 | 95 | for w in _winning_positions: 96 | x = board[w['positions'][0]] 97 | y = board[w['positions'][1]] 98 | z = board[w['positions'][2]] 99 | 100 | if _is_winning(x, y, z): 101 | winners[x].append({ 102 | 'where': w['where'], 103 | 'index': w['index'], 104 | 'positions': list(w['positions']) 105 | }) 106 | 107 | return winners 108 | 109 | 110 | def _is_winning(x, y, z): 111 | return istoken(x) and x == y and y == z 112 | 113 | 114 | def _has_two_winners(winners): 115 | return len(winners['x']) > 0 and len(winners['o']) > 0 116 | 117 | 118 | def _is_winner(winners, token): 119 | return len(winners[token]) > 0 120 | 121 | 122 | def _is_squashed(piece_counts): 123 | return piece_counts['es'] == 0 124 | -------------------------------------------------------------------------------- /xo/game.py: -------------------------------------------------------------------------------- 1 | from . import arbiter 2 | from .error import IllegalStateError 3 | from .board import Board 4 | from .token import isempty, istoken, other_token 5 | 6 | 7 | STATE_INIT = 'init' 8 | STATE_PLAYING = 'playing' 9 | STATE_GAMEOVER = 'gameover' 10 | 11 | 12 | EVENT_NAME_INVALID_MOVE = 'invalid-move' 13 | EVENT_NAME_NEXT_TURN = 'next-turn' 14 | EVENT_NAME_GAMEOVER = 'gameover' 15 | 16 | 17 | EVENT_REASON_OUT_OF_BOUNDS = 'out-of-bounds' 18 | EVENT_REASON_OCCUPIED = 'occupied' 19 | EVENT_REASON_WINNER = 'winner' 20 | EVENT_REASON_SQUASHED = 'squashed' 21 | 22 | 23 | class Game: 24 | def __init__(self): 25 | self.state = STATE_INIT 26 | self.board = None 27 | self.turn = None 28 | self.statistics = { 'total': 0, 'xwins': 0, 'owins': 0, 'squashed': 0 } 29 | 30 | def next_turn(self): 31 | if self.turn: 32 | return other_token(self.turn) 33 | else: 34 | return None 35 | 36 | def start(self, token): 37 | if self.state == STATE_INIT: 38 | if not istoken(token): 39 | raise ValueError('must be a token: {}'.format(token)) 40 | 41 | self.state = STATE_PLAYING 42 | self.board = Board.fromstring() 43 | self.turn = token 44 | else: 45 | raise IllegalStateError(self.state) 46 | 47 | def moveto(self, r, c): 48 | if self.state == STATE_PLAYING: 49 | if Board.contains(r, c): 50 | if isempty(self.board[r, c]): 51 | self.board[r, c] = self.turn 52 | last_move = { 'r': r, 'c': c, 'token': self.turn } 53 | 54 | outcome = arbiter.outcome(self.board, self.turn) 55 | 56 | if outcome['status'] == arbiter.STATUS_IN_PROGRESS: 57 | self.turn = other_token(self.turn) 58 | 59 | return { 60 | 'name': EVENT_NAME_NEXT_TURN, 61 | 'last_move': last_move 62 | } 63 | elif outcome['status'] == arbiter.STATUS_GAMEOVER: 64 | self.state = STATE_GAMEOVER 65 | self.statistics['total'] += 1 66 | 67 | if outcome['reason'] == arbiter.REASON_WINNER: 68 | self._restart_turn = self.turn 69 | self.statistics['{}wins'.format(self.turn)] += 1 70 | 71 | return { 72 | 'name': EVENT_NAME_GAMEOVER, 73 | 'reason': EVENT_REASON_WINNER, 74 | 'last_move': last_move, 75 | 'details': outcome['details'] 76 | } 77 | elif outcome['reason'] == arbiter.REASON_SQUASHED: 78 | self._restart_turn = other_token(self.turn) 79 | self.statistics['squashed'] += 1 80 | 81 | return { 82 | 'name': EVENT_NAME_GAMEOVER, 83 | 'reason': EVENT_REASON_SQUASHED, 84 | 'last_move': last_move 85 | } 86 | else: 87 | return { 88 | 'name': EVENT_NAME_INVALID_MOVE, 89 | 'reason': EVENT_REASON_OCCUPIED 90 | } 91 | else: 92 | return { 93 | 'name': EVENT_NAME_INVALID_MOVE, 94 | 'reason': EVENT_REASON_OUT_OF_BOUNDS 95 | } 96 | else: 97 | raise IllegalStateError(self.state) 98 | 99 | def restart(self): 100 | if self.state == STATE_GAMEOVER: 101 | self.state = STATE_PLAYING 102 | self.board = Board.fromstring() 103 | self.turn = self._restart_turn 104 | else: 105 | raise IllegalStateError(self.state) 106 | -------------------------------------------------------------------------------- /xo/ai.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from collections import namedtuple 4 | 5 | from . import arbiter 6 | from .board import ncells 7 | from .token import isempty, istoken, other_token 8 | 9 | 10 | MinimaxResult = namedtuple('MinimaxResult', 'score depth positions') 11 | 12 | 13 | def evaluate(board, token, use_cache=True): 14 | outcome = arbiter.outcome(board, token) 15 | 16 | if outcome['status'] == arbiter.STATUS_IN_PROGRESS: 17 | other = other_token(token) 18 | token_piece_count = outcome['piece_counts']['{}s'.format(token)] 19 | other_piece_count = outcome['piece_counts']['{}s'.format(other)] 20 | 21 | if token_piece_count <= other_piece_count: 22 | if use_cache and outcome['piece_counts']['es'] >= ncells - 1: 23 | return _cached_minimax_result[str(board)] 24 | else: 25 | return _maximize(board, token, other, 0) 26 | else: 27 | raise ValueError("not {}'s turn to play: {}".format(token, board)) 28 | elif outcome['status'] == arbiter.STATUS_GAMEOVER: 29 | raise ValueError('no available moves: {}'.format(board)) 30 | else: 31 | raise ValueError('invalid board: {}'.format(board)) 32 | 33 | 34 | def _maximize(board, a, b, depth): 35 | outcome = arbiter.outcome(board, b) 36 | 37 | if _terminal(outcome): 38 | return MinimaxResult(_min_terminal_score(outcome, depth), depth, []) 39 | 40 | max_score = -math.inf 41 | max_positions = [] 42 | 43 | for r, c, piece in board: 44 | if isempty(piece): 45 | pos = (r, c) 46 | 47 | board[pos] = a 48 | 49 | min_score, min_depth, _ = _minimize(board, b, a, depth + 1) 50 | 51 | if min_score > max_score: 52 | max_score = min_score 53 | max_depth = min_depth 54 | max_positions = [pos] 55 | elif min_score == max_score: 56 | max_depth = min_depth 57 | max_positions.append(pos) 58 | 59 | board[pos] = ' ' 60 | 61 | return MinimaxResult(max_score, max_depth, max_positions) 62 | 63 | 64 | def _minimize(board, a, b, depth): 65 | outcome = arbiter.outcome(board, b) 66 | 67 | if _terminal(outcome): 68 | return MinimaxResult(_max_terminal_score(outcome, depth), depth, []) 69 | 70 | min_score = math.inf 71 | min_positions = [] 72 | 73 | for r, c, piece in board: 74 | if isempty(piece): 75 | pos = (r, c) 76 | 77 | board[pos] = a 78 | 79 | max_score, max_depth, _ = _maximize(board, b, a, depth + 1) 80 | 81 | if max_score < min_score: 82 | min_score = max_score 83 | min_depth = max_depth 84 | min_positions = [pos] 85 | elif max_score == min_score: 86 | min_depth = max_depth 87 | min_positions.append(pos) 88 | 89 | board[pos] = ' ' 90 | 91 | return MinimaxResult(min_score, min_depth, min_positions) 92 | 93 | 94 | def _terminal(outcome): 95 | return outcome['status'] == arbiter.STATUS_GAMEOVER 96 | 97 | 98 | _maximum_depth = 9 99 | 100 | 101 | def _max_terminal_score(outcome, depth): 102 | if outcome['reason'] == arbiter.REASON_WINNER: 103 | return 2 * (_maximum_depth - depth) + _maximum_depth + 1 104 | elif outcome['reason'] == arbiter.REASON_SQUASHED: 105 | return depth 106 | else: 107 | # Should never be reached 108 | raise ValueError('unexpected outcome: {}'.format(outcome)) 109 | 110 | 111 | def _min_terminal_score(outcome, depth): 112 | return -_max_terminal_score(outcome, depth) 113 | 114 | 115 | _cached_minimax_result = { 116 | '.........': MinimaxResult(score=9, depth=9, positions=[ 117 | (1, 1), (1, 2), (1, 3), 118 | (2, 1), (2, 2), (2, 3), 119 | (3, 1), (3, 2), (3, 3) 120 | ]), 121 | 'x........': MinimaxResult(score=-8, depth=8, positions=[ 122 | (2, 2) 123 | ]), 124 | '.x.......': MinimaxResult(score=-8, depth=8, positions=[ 125 | (1, 1), (1, 3), 126 | (2, 2), (3, 2) 127 | ]), 128 | '..x......': MinimaxResult(score=-8, depth=8, positions=[ 129 | (2, 2) 130 | ]), 131 | '...x.....': MinimaxResult(score=-8, depth=8, positions=[ 132 | (1, 1), (2, 2), 133 | (2, 3), (3, 1) 134 | ]), 135 | '....x....': MinimaxResult(score=-8, depth=8, positions=[ 136 | (1, 1), (1, 3), 137 | (3, 1), (3, 3) 138 | ]), 139 | '.....x...': MinimaxResult(score=-8, depth=8, positions=[ 140 | (1, 3), (2, 1), 141 | (2, 2), (3, 3) 142 | ]), 143 | '......x..': MinimaxResult(score=-8, depth=8, positions=[ 144 | (2, 2) 145 | ]), 146 | '.......x.': MinimaxResult(score=-8, depth=8, positions=[ 147 | (1, 2), (2, 2), 148 | (3, 1), (3, 3) 149 | ]), 150 | '........x': MinimaxResult(score=-8, depth=8, positions=[ 151 | (2, 2) 152 | ]), 153 | 'o........': MinimaxResult(score=-8, depth=8, positions=[ 154 | (2, 2) 155 | ]), 156 | '.o.......': MinimaxResult(score=-8, depth=8, positions=[ 157 | (1, 1), (1, 3), 158 | (2, 2), (3, 2) 159 | ]), 160 | '..o......': MinimaxResult(score=-8, depth=8, positions=[ 161 | (2, 2) 162 | ]), 163 | '...o.....': MinimaxResult(score=-8, depth=8, positions=[ 164 | (1, 1), (2, 2), 165 | (2, 3), (3, 1) 166 | ]), 167 | '....o....': MinimaxResult(score=-8, depth=8, positions=[ 168 | (1, 1), (1, 3), 169 | (3, 1), (3, 3) 170 | ]), 171 | '.....o...': MinimaxResult(score=-8, depth=8, positions=[ 172 | (1, 3), (2, 1), 173 | (2, 2), (3, 3) 174 | ]), 175 | '......o..': MinimaxResult(score=-8, depth=8, positions=[ 176 | (2, 2) 177 | ]), 178 | '.......o.': MinimaxResult(score=-8, depth=8, positions=[ 179 | (1, 2), (2, 2), 180 | (3, 1), (3, 3) 181 | ]), 182 | '........o': MinimaxResult(score=-8, depth=8, positions=[ 183 | (2, 2) 184 | ]) 185 | } 186 | -------------------------------------------------------------------------------- /tests/test_game.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import xo.game as game 4 | 5 | from xo.error import IllegalStateError 6 | from xo.game import Game 7 | 8 | 9 | class InitStateTestCase(unittest.TestCase): 10 | def setUp(self): 11 | self.game = Game() 12 | 13 | def test_it_is_in_init_state(self): 14 | self.assertEqual(self.game.state, game.STATE_INIT) 15 | self.assertIsNone(self.game.board) 16 | self.assertIsNone(self.game.turn) 17 | self.assertIsNone(self.game.next_turn()) 18 | 19 | def test_it_is_not_allowed_to_call_moveto(self): 20 | with self.assertRaisesRegex(IllegalStateError, game.STATE_INIT): 21 | self.game.moveto(1, 1) 22 | 23 | def test_it_is_not_allowed_to_call_restart(self): 24 | with self.assertRaisesRegex(IllegalStateError, game.STATE_INIT): 25 | self.game.restart() 26 | 27 | 28 | class PlayingStateTestCase(unittest.TestCase): 29 | def setUp(self): 30 | self.game = Game() 31 | self.game.start('x') 32 | 33 | def test_it_is_in_playing_state(self): 34 | self.assertEqual(self.game.state, game.STATE_PLAYING) 35 | self.assertEqual(str(self.game.board), '.........') 36 | self.assertEqual(self.game.turn, 'x') 37 | self.assertEqual(self.game.next_turn(), 'o') 38 | 39 | def test_it_is_not_allowed_to_call_start(self): 40 | with self.assertRaisesRegex(IllegalStateError, game.STATE_PLAYING): 41 | self.game.start('o') 42 | 43 | def test_it_is_not_allowed_to_call_restart(self): 44 | with self.assertRaisesRegex(IllegalStateError, game.STATE_PLAYING): 45 | self.game.restart() 46 | 47 | 48 | class GameoverStateTestCase(unittest.TestCase): 49 | def setUp(self): 50 | self.game = Game() 51 | self.game.start('o') 52 | self.game.moveto(1, 1) 53 | self.game.moveto(1, 2) 54 | self.game.moveto(2, 1) 55 | self.game.moveto(2, 2) 56 | self.game.moveto(3, 1) 57 | 58 | def test_it_is_in_gameover_state(self): 59 | self.assertEqual(self.game.state, game.STATE_GAMEOVER) 60 | self.assertEqual(str(self.game.board), 'ox.ox.o..') 61 | self.assertEqual(self.game.turn, 'o') 62 | self.assertEqual(self.game.next_turn(), 'x') 63 | self.assertEqual(self.game.statistics['total'], 1) 64 | self.assertEqual(self.game.statistics['xwins'], 0) 65 | self.assertEqual(self.game.statistics['owins'], 1) 66 | self.assertEqual(self.game.statistics['squashed'], 0) 67 | 68 | def test_it_is_not_allowed_to_call_start(self): 69 | with self.assertRaisesRegex(IllegalStateError, game.STATE_GAMEOVER): 70 | self.game.start('x') 71 | 72 | def test_it_is_not_allowed_to_call_moveto(self): 73 | with self.assertRaisesRegex(IllegalStateError, game.STATE_GAMEOVER): 74 | self.game.moveto(2, 2) 75 | 76 | 77 | class GamePlayTestCase(unittest.TestCase): 78 | def setUp(self): 79 | self.game = Game() 80 | self.game.start('x') 81 | 82 | def test_when_move_is_out_of_bounds(self): 83 | event = self.game.moveto(0, 1) 84 | 85 | self.assertEqual(event['name'], game.EVENT_NAME_INVALID_MOVE) 86 | self.assertEqual(event['reason'], game.EVENT_REASON_OUT_OF_BOUNDS) 87 | 88 | def test_when_move_is_to_an_occupied_position(self): 89 | self.game.moveto(1, 1) 90 | event = self.game.moveto(1, 1) 91 | 92 | self.assertEqual(event['name'], game.EVENT_NAME_INVALID_MOVE) 93 | self.assertEqual(event['reason'], game.EVENT_REASON_OCCUPIED) 94 | 95 | def test_when_next_turn(self): 96 | event = self.game.moveto(1, 1) 97 | 98 | self.assertEqual(event['name'], game.EVENT_NAME_NEXT_TURN) 99 | self.assertEqual( 100 | event['last_move'], 101 | { 'r': 1, 'c': 1, 'token': 'x' } 102 | ) 103 | 104 | self.assertEqual(self.game.state, game.STATE_PLAYING) 105 | self.assertEqual(str(self.game.board), 'x........') 106 | self.assertEqual(self.game.turn, 'o') 107 | self.assertEqual(self.game.next_turn(), 'x') 108 | 109 | def test_when_x_wins(self): 110 | self.game.moveto(2, 2) 111 | self.game.moveto(1, 2) 112 | self.game.moveto(2, 1) 113 | self.game.moveto(2, 3) 114 | self.game.moveto(1, 1) 115 | self.game.moveto(3, 1) 116 | event = self.game.moveto(3, 3) 117 | 118 | self.assertEqual(event['name'], game.EVENT_NAME_GAMEOVER) 119 | self.assertEqual( 120 | event['last_move'], 121 | { 'r': 3, 'c': 3, 'token': 'x' } 122 | ) 123 | self.assertEqual( 124 | event['details'], 125 | [{ 126 | 'index': 1, 127 | 'where': 'diagonal', 128 | 'positions': [(1, 1), (2, 2), (3, 3)] 129 | }] 130 | ) 131 | 132 | self.assertEqual(self.game.state, game.STATE_GAMEOVER) 133 | self.assertEqual(str(self.game.board), 'xo.xxoo.x') 134 | self.assertEqual(self.game.turn, 'x') 135 | self.assertEqual(self.game.next_turn(), 'o') 136 | self.assertEqual(self.game.statistics['total'], 1) 137 | self.assertEqual(self.game.statistics['xwins'], 1) 138 | self.assertEqual(self.game.statistics['owins'], 0) 139 | self.assertEqual(self.game.statistics['squashed'], 0) 140 | 141 | def test_when_game_is_squashed(self): 142 | self.game.moveto(1, 1) 143 | self.game.moveto(2, 2) 144 | self.game.moveto(3, 3) 145 | self.game.moveto(2, 3) 146 | self.game.moveto(2, 1) 147 | self.game.moveto(3, 1) 148 | self.game.moveto(1, 3) 149 | self.game.moveto(1, 2) 150 | event = self.game.moveto(3, 2) 151 | 152 | self.assertEqual(event['name'], game.EVENT_NAME_GAMEOVER) 153 | self.assertEqual(event['reason'], game.EVENT_REASON_SQUASHED) 154 | self.assertEqual( 155 | event['last_move'], 156 | { 'r': 3, 'c': 2, 'token': 'x' } 157 | ) 158 | 159 | self.assertEqual(self.game.state, game.STATE_GAMEOVER) 160 | self.assertEqual(str(self.game.board), 'xoxxoooxx') 161 | self.assertEqual(self.game.turn, 'x') 162 | self.assertEqual(self.game.next_turn(), 'o') 163 | self.assertEqual(self.game.statistics['total'], 1) 164 | self.assertEqual(self.game.statistics['xwins'], 0) 165 | self.assertEqual(self.game.statistics['owins'], 0) 166 | self.assertEqual(self.game.statistics['squashed'], 1) 167 | 168 | 169 | class RestartGameTestCase(unittest.TestCase): 170 | def setUp(self): 171 | self.game = Game() 172 | self.game.start('o') 173 | 174 | def test_restart_after_a_win(self): 175 | self.game.moveto(1, 3) 176 | self.game.moveto(1, 1) 177 | self.game.moveto(2, 3) 178 | self.game.moveto(2, 1) 179 | self.game.moveto(3, 3) 180 | 181 | self.game.restart() 182 | 183 | self.assertEqual(self.game.state, game.STATE_PLAYING) 184 | self.assertEqual(str(self.game.board), '.........') 185 | self.assertEqual(self.game.turn, 'o') 186 | self.assertEqual(self.game.next_turn(), 'x') 187 | self.assertEqual(self.game.statistics['total'], 1) 188 | self.assertEqual(self.game.statistics['xwins'], 0) 189 | self.assertEqual(self.game.statistics['owins'], 1) 190 | self.assertEqual(self.game.statistics['squashed'], 0) 191 | 192 | def test_restart_after_a_squashed_game(self): 193 | self.game.moveto(1, 1) 194 | self.game.moveto(2, 2) 195 | self.game.moveto(3, 3) 196 | self.game.moveto(2, 3) 197 | self.game.moveto(2, 1) 198 | self.game.moveto(3, 1) 199 | self.game.moveto(1, 3) 200 | self.game.moveto(1, 2) 201 | self.game.moveto(3, 2) 202 | 203 | self.game.restart() 204 | 205 | self.assertEqual(self.game.state, game.STATE_PLAYING) 206 | self.assertEqual(str(self.game.board), '.........') 207 | self.assertEqual(self.game.turn, 'x') 208 | self.assertEqual(self.game.next_turn(), 'o') 209 | self.assertEqual(self.game.statistics['total'], 1) 210 | self.assertEqual(self.game.statistics['xwins'], 0) 211 | self.assertEqual(self.game.statistics['owins'], 0) 212 | self.assertEqual(self.game.statistics['squashed'], 1) 213 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | xo 2 | == 3 | 4 | .. image:: https://img.shields.io/pypi/v/xo.svg 5 | :target: https://pypi.python.org/pypi/xo 6 | 7 | A `Python `_ CLI game and library for `Tic-tac-toe `_. 8 | 9 | The library is written in a modular way. Its overall design consists of 4 decoupled components: 10 | 11 | 1. A Tic-tac-toe board data structure, ``xo.board``. 12 | 2. An arbiter for analyzing the state of a board, ``xo.arbiter``. 13 | 3. A game engine to implement and enforce the Tic-tac-toe game logic, ``xo.game``. 14 | 4. And finally, an AI for finding excellent moves, ``xo.ai``. 15 | 16 | **The board** 17 | 18 | .. code-block:: python 19 | 20 | >>> from xo.board import isempty, Board 21 | 22 | >>> board = Board.fromstring('..x.o') 23 | >>> print(board) 24 | ..x.o.... 25 | 26 | >>> print(board.toascii()) 27 | | | x 28 | ---+---+--- 29 | | o | 30 | ---+---+--- 31 | | | 32 | 33 | >>> board[1, 3] 34 | x 35 | >>> board[3, 3] = 'x' 36 | >>> print(board) 37 | ..x.o...x 38 | 39 | >>> for r, c, piece in board: 40 | ... if isempty(piece): 41 | ... print('{}, {}'.format(r, c)) 42 | ... 43 | 1, 1 44 | 1, 2 45 | 2, 1 46 | 2, 3 47 | 3, 1 48 | 3, 2 49 | 50 | The board isn't concerned with whether or not a given layout can be reached in an actual Tic-tac-toe game. Hence, the following is perfectly legal: 51 | 52 | .. code-block:: python 53 | 54 | >>> board = Board.fromstring('xxxxxxxxo') 55 | >>> print(board) 56 | xxxxxxxxo 57 | 58 | The arbiter is concerned about that though and can detect such invalid board layouts. 59 | 60 | **The arbiter** 61 | 62 | .. code-block:: python 63 | 64 | >>> from xo import arbiter 65 | >>> from xo.board import Board 66 | 67 | >>> arbiter.outcome(Board.fromstring(), 'x') 68 | { 69 | 'piece_counts': {'os': 0, 'xs': 0, 'es': 9}, 70 | 'status': 'in-progress' 71 | } 72 | 73 | >>> arbiter.outcome(Board.fromstring('xxxoo'), 'o') 74 | { 75 | 'piece_counts': {'os': 2, 'xs': 3, 'es': 4}, 76 | 'details': [ 77 | {'index': 1, 'positions': [(1, 1), (1, 2), (1, 3)], 'where': 'row'} 78 | ], 79 | 'status': 'gameover', 80 | 'reason': 'loser' 81 | } 82 | 83 | >>> arbiter.outcome(Board.fromstring('xxxxxxxxo'), 'x') 84 | { 85 | 'piece_counts': {'os': 1, 'xs': 8, 'es': 0}, 86 | 'status': 'invalid', 87 | 'reason': 'too-many-moves-ahead' 88 | } 89 | 90 | **The game engine** 91 | 92 | Enforcer of the game rules. 93 | 94 | .. code-block:: python 95 | 96 | >>> from xo.game import Game 97 | 98 | >>> game = Game() 99 | >>> game.start('x') 100 | >>> game.moveto(1, 1) 101 | { 102 | 'name': 'next-turn', 103 | 'last_move': {'token': 'x', 'r': 1, 'c': 1} 104 | } 105 | >>> game.moveto(1, 1) 106 | { 107 | 'name': 'invalid-move', 108 | 'reason': 'occupied' 109 | } 110 | >>> game.moveto(0, 0) 111 | { 112 | 'name': 'invalid-move', 113 | 'reason': 'out-of-bounds' 114 | } 115 | >>> game.moveto(2, 2) 116 | { 117 | 'name': 'next-turn', 118 | 'last_move': {'token': 'o', 'r': 2, 'c': 2} 119 | } 120 | >>> game.moveto(3, 1) 121 | { 122 | 'name': 'next-turn', 123 | 'last_move': {'token': 'x', 'r': 3, 'c': 1} 124 | } 125 | >>> print(game.board.toascii()) 126 | x | | 127 | ---+---+--- 128 | | o | 129 | ---+---+--- 130 | x | | 131 | 132 | >>> game.moveto(3, 3) 133 | { 134 | 'name': 'next-turn', 135 | 'last_move': {'token': 'o', 'r': 3, 'c': 3} 136 | } 137 | >>> game.moveto(2, 1) 138 | { 139 | 'name': 'gameover', 140 | 'reason': 'winner', 141 | 'last_move': {'token': 'x', 'r': 2, 'c': 1}, 142 | 'details': [{'index': 1, 'positions': [(1, 1), (2, 1), (3, 1)], 'where': 'column'}] 143 | } 144 | 145 | >>> game.moveto(1, 3) 146 | ... 147 | xo.error.IllegalStateError: gameover 148 | 149 | >>> # start a new game 150 | >>> game.restart() 151 | >>> # since x won, it would be x's turn to play 152 | >>> # if the game was squashed then it would have been o's turn to play 153 | >>> game.moveto(1, 1) 154 | >>> print(game.board.toascii()) 155 | x | | 156 | ---+---+--- 157 | | | 158 | ---+---+--- 159 | | | 160 | 161 | **The AI** 162 | 163 | No Tic-tac-toe library is complete without an AI that can play a perfect game of Tic-tac-toe. 164 | 165 | .. code-block:: python 166 | 167 | >>> from xo import ai 168 | >>> from xo.board import Board 169 | 170 | >>> ai.evaluate(Board.fromstring('xo.xo.'), 'x') 171 | MinimaxResult(score=26, depth=1, positions=[(3, 1)]) 172 | 173 | >>> ai.evaluate(Board.fromstring('xo.xo.'), 'o') 174 | MinimaxResult(score=26, depth=1, positions=[(3, 2)]) 175 | 176 | >>> ai.evaluate(Board.fromstring('x.o'), 'x') 177 | MinimaxResult(score=18, depth=5, positions=[(2, 1), (3, 1), (3, 3)]) 178 | 179 | Finally, ``xo.cli`` brings it all together in its implementation of the command-line Tic-tac-toe game. It's interesting to see how easy it becomes to implement the game so be sure to check it out. 180 | 181 | **Note:** *An extensive suite of tests is also available that can help you better understand how each component is supposed to work.* 182 | 183 | Installation 184 | ------------ 185 | 186 | Install it using: 187 | 188 | .. code-block:: bash 189 | 190 | $ pip install xo 191 | 192 | You would now have access to an executable called ``xo``. Type 193 | 194 | .. code-block:: bash 195 | 196 | $ xo 197 | 198 | to starting playing immediately. 199 | 200 | Usage 201 | ----- 202 | 203 | For help, type 204 | 205 | .. code-block:: bash 206 | 207 | $ xo -h 208 | 209 | By default ``xo`` is configured for a human player to play with ``x`` and a computer player to play with ``o``. However, this can be easily changed to allow any of the other 3 possibilities: 210 | 211 | .. code-block:: bash 212 | 213 | $ # Computer vs Human 214 | $ xo -x computer -o human 215 | 216 | $ # Human vs Human 217 | $ xo -x human -o human 218 | $ xo -o human # since x defaults to human 219 | 220 | $ # Computer vs Computer 221 | $ xo -x computer -o computer 222 | $ xo -x computer # since o defaults to computer 223 | 224 | You can also change who plays first. By default it's the ``x`` player. 225 | 226 | .. code-block:: bash 227 | 228 | $ # Let o play first 229 | $ xo -f o 230 | 231 | Finally, when letting the computers battle it out you can specify the number of times you want them to play each other. By default they play 50 rounds. 232 | 233 | .. code-block:: bash 234 | 235 | $ xo -x computer -r 5 236 | ..... 237 | 238 | Game statistics 239 | --------------- 240 | Total games played: 5 (2.438 secs) 241 | Number of times x won: 0 242 | Number of times o won: 0 243 | Number of squashed games: 5 244 | 245 | Development 246 | ----------- 247 | 248 | Get the source code. 249 | 250 | .. code-block:: bash 251 | 252 | $ git clone git@github.com:dwayne/xo-python.git 253 | 254 | Create a `virtual environment `_ and activate it. 255 | 256 | .. code-block:: bash 257 | 258 | $ cd xo-python 259 | $ pyvenv venv 260 | $ . venv/bin/activate 261 | 262 | Then, upgrade ``pip`` and ``setuptools`` and install the development dependencies. 263 | 264 | .. code-block:: bash 265 | 266 | (venv) $ pip install -U pip setuptools 267 | (venv) $ pip install -r requirements-dev.txt 268 | 269 | You're now all set to begin development. 270 | 271 | Testing 272 | ------- 273 | 274 | Tests are written using the `unittest `_ unit testing framework. 275 | 276 | Run all tests. 277 | 278 | .. code-block:: bash 279 | 280 | (venv) $ python -m unittest 281 | 282 | Run a specific test module. 283 | 284 | .. code-block:: bash 285 | 286 | (venv) $ python -m unittest tests.test_arbiter 287 | 288 | Run a specific test case. 289 | 290 | .. code-block:: bash 291 | 292 | (venv) $ python -m unittest tests.test_arbiter.GameoverPositionsTestCase 293 | 294 | Run a specific test method. 295 | 296 | .. code-block:: bash 297 | 298 | (venv) $ python -m unittest tests.test_arbiter.GameoverPositionsTestCase.test_when_x_wins 299 | 300 | Credits 301 | ------- 302 | 303 | Thanks to `Patrick Henry Winston `_ for clarifying the Minimax algorithm. His `video `_ on the topic was a joy to watch. 304 | 305 | Copyright 306 | --------- 307 | 308 | Copyright (c) 2016 Dwayne Crooks. See `LICENSE `_ for further details. 309 | -------------------------------------------------------------------------------- /xo/cli.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | import sys 4 | import time 5 | 6 | from collections import namedtuple 7 | 8 | from . import ai, game 9 | from .token import isempty, istoken, other_token 10 | 11 | 12 | Player = namedtuple('Player', 'token ishuman') 13 | 14 | 15 | class Console: 16 | def __init__(self, input=sys.stdin, output=sys.stdout): 17 | self.input = input 18 | self.output = output 19 | 20 | def write(self, s): 21 | self.output.write(s) 22 | self.output.flush() 23 | 24 | def writeln(self, s=''): 25 | self.write(s + '\n') 26 | 27 | def getln(self, prompt='> '): 28 | self.write(prompt) 29 | return self.input.readline() 30 | 31 | 32 | class Orchestrator: 33 | def __init__(self, player1=Player('x', True), player2=Player('o', False), console=Console()): 34 | if not istoken(player1.token): 35 | raise ValueError('player1 has an invalid token: {}'.format(player1.token)) 36 | if not istoken(player2.token): 37 | raise ValueError('player2 has an invalid token: {}'.format(player2.token)) 38 | if player1.token == player2.token: 39 | raise ValueError('both players cannot play with the same token: {}'.format(player1.token)) 40 | 41 | self._first_player = player1 42 | 43 | self._players = {} 44 | self._players[player1.token] = player1 45 | self._players[player2.token] = player2 46 | 47 | self._console = console 48 | 49 | self._num_human_players = 0 50 | if player1.ishuman: 51 | self._num_human_players += 1 52 | if player2.ishuman: 53 | self._num_human_players += 1 54 | 55 | def start(self, rounds=50): 56 | start_time = time.time() 57 | 58 | try: 59 | self._play(rounds) 60 | except KeyboardInterrupt: 61 | self._console.writeln() 62 | if self._num_human_players > 0: 63 | self._console.writeln("We're deeply saddened to see you go, ;(.") 64 | 65 | self._elapsed_time = time.time() - start_time 66 | self._show_game_statistics() 67 | 68 | if self._num_human_players > 0: 69 | self._console.writeln() 70 | self._console.writeln('Thank you for playing. Please come back anytime.') 71 | 72 | def _play(self, rounds): 73 | self._init_and_start_game() 74 | 75 | if self._num_human_players == 0: 76 | playing = rounds > 0 77 | else: 78 | self._console.writeln('Welcome to Tic-tac-toe') 79 | self._console.writeln('Play as many games as you want') 80 | self._console.writeln('Press Ctrl-C to exit at any time') 81 | self._console.writeln() 82 | playing = True 83 | 84 | while playing: 85 | event = self._play_one_turn() 86 | 87 | if event['name'] == game.EVENT_NAME_GAMEOVER: 88 | self._handle_game_over(event['reason']) 89 | 90 | if self._num_human_players == 0: 91 | rounds -= 1 92 | playing = rounds > 0 93 | 94 | if not playing: 95 | self._console.writeln() 96 | else: 97 | playing = self._ask_to_play_again(event) 98 | 99 | if playing: 100 | self._game.restart() 101 | 102 | def _init_and_start_game(self): 103 | self._game = game.Game() 104 | self._game.start(self._first_player.token) 105 | 106 | def _play_one_turn(self): 107 | player = self._current_player() 108 | 109 | if player.ishuman: 110 | if self._num_human_players == 2: 111 | self._console.writeln("{}'s turn".format(player.token)) 112 | else: 113 | self._console.writeln('Your turn ({})'.format(player.token)) 114 | 115 | self._console.writeln(self._game.board.toascii()) 116 | 117 | while True: 118 | r, c = self._get_input_move() 119 | event = self._game.moveto(r, c) 120 | 121 | if event['name'] == game.EVENT_NAME_INVALID_MOVE: 122 | if event['reason'] == game.EVENT_REASON_OUT_OF_BOUNDS: 123 | self._console.writeln('Sorry, but that move was not on the board') 124 | elif event['reason'] == game.EVENT_REASON_OCCUPIED: 125 | self._console.writeln('Sorry, but that position is already taken') 126 | self._console.writeln('Please, try again') 127 | else: 128 | return event 129 | else: 130 | positions = ai.evaluate(self._game.board, self._game.turn).positions 131 | random.shuffle(positions) 132 | r, c = positions[0] 133 | event = self._game.moveto(r, c) 134 | 135 | if self._num_human_players == 1: 136 | self._console.writeln('The computer played at {}, {}'.format(r, c)) 137 | 138 | return event 139 | 140 | def _handle_game_over(self, reason): 141 | player = self._current_player() 142 | 143 | if reason == game.EVENT_REASON_WINNER: 144 | if self._num_human_players == 2: 145 | self._console.writeln('Congratulations! {} won.'.format(player.token)) 146 | elif self._num_human_players == 1: 147 | if player.ishuman: 148 | self._console.writeln('Congratulations! You won.') 149 | else: 150 | self._console.writeln('The computer won. Better luck next time.') 151 | else: 152 | self._console.write(player.token) 153 | elif reason == game.EVENT_REASON_SQUASHED: 154 | if self._num_human_players > 0: 155 | self._console.writeln('Game squashed.') 156 | else: 157 | self._console.write('.') 158 | 159 | if self._num_human_players > 0: 160 | self._console.writeln(self._game.board.toascii()) 161 | 162 | def _get_input_move(self): 163 | show_help = True 164 | 165 | while True: 166 | s = self._console.getln() 167 | s = re.split('\s+', s.strip()) 168 | if len(s) == 2: 169 | try: 170 | return [int(t) for t in s] 171 | except ValueError: 172 | pass 173 | 174 | if show_help: 175 | show_help = False 176 | self._console.writeln('Please enter a move in the format "r c", where r and c are numbers for e.g.') 177 | r, c = self._first_open_position() 178 | self._console.writeln('> {} {}'.format(r, c)) 179 | 180 | def _first_open_position(self): 181 | for r, c, piece in self._game.board: 182 | if isempty(piece): 183 | return r, c 184 | 185 | def _ask_to_play_again(self, event): 186 | self._console.writeln('Do you want to play again? (Y/n)') 187 | 188 | while True: 189 | s = self._console.getln() 190 | s = s.strip().lower() 191 | if s in ['', 'y', 'yes']: 192 | return True 193 | elif s in ['n', 'no']: 194 | return False 195 | 196 | def _show_game_statistics(self): 197 | stats = self._game.statistics 198 | 199 | self._console.writeln() 200 | self._console.writeln('Game statistics') 201 | self._console.writeln('---------------') 202 | if self._num_human_players == 0: 203 | self._console.writeln('Total games played: {} ({:.3f} secs)'.format( 204 | stats['total'], self._elapsed_time)) 205 | else: 206 | self._console.writeln('Total games played: {}'.format(stats['total'])) 207 | self._console.writeln('Number of times x won: {}'.format(stats['xwins'])) 208 | self._console.writeln('Number of times o won: {}'.format(stats['owins'])) 209 | self._console.writeln('Number of squashed games: {}'.format(stats['squashed'])) 210 | 211 | def _current_player(self): 212 | return self._players[self._game.turn] 213 | 214 | 215 | def main(): 216 | import argparse 217 | 218 | parser = argparse.ArgumentParser(description='A Tic-tac-toe game.') 219 | 220 | player_choices = ['human', 'computer'] 221 | 222 | parser.add_argument('-x', choices=player_choices, default='human', 223 | help='who controls x (default: human)') 224 | parser.add_argument('-o', choices=player_choices, default='computer', 225 | help='who controls o (default: computer)') 226 | 227 | parser.add_argument('-r', '--rounds', type=int, default=50, 228 | metavar='n', 229 | help='the number of rounds to let two computer players play (default: 50)') 230 | 231 | parser.add_argument('-f', '--first', choices=['x', 'o'], default='x', 232 | help='who plays first (default: x)') 233 | 234 | args = parser.parse_args() 235 | 236 | players = { 237 | 'x': Player('x', args.x == 'human'), 238 | 'o': Player('o', args.o == 'human') 239 | } 240 | 241 | player1 = players[args.first] 242 | player2 = players[other_token(args.first)] 243 | 244 | rounds = max(0, args.rounds) 245 | 246 | Orchestrator(player1, player2).start(rounds) 247 | 248 | return 0 249 | --------------------------------------------------------------------------------