├── 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 |
--------------------------------------------------------------------------------