Skip to content

Commit 538b865

Browse files
authored
Refactor the engine to make machine learning work easier (#5)
1 parent cac4473 commit 538b865

5 files changed

Lines changed: 104 additions & 32 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "apologies"
3-
version = "0.1.10"
3+
version = "0.1.11"
44
description = "Python code to play a game similar to Sorry"
55
authors = ["Kenneth J. Pronovici <pronovic@ieee.org>"]
66
license = "Apache-2.0"

src/apologies/engine.py

Lines changed: 59 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import attr
1212

13-
from .game import Game, GameMode, PlayerColor, PlayerView
13+
from .game import Game, GameMode, Player, PlayerColor, PlayerView
1414
from .rules import Move, Rules
1515
from .source import CharacterInputSource
1616
from .util import CircularQueue
@@ -54,6 +54,17 @@ class Engine:
5454
"""
5555
Game engine that coordinates character actions in a game.
5656
57+
Normally, playing a game via an engine is as simple as::
58+
59+
engine.start_game()
60+
while not engine.completed:
61+
state = engine.play_next()
62+
63+
This plays a turn for each player, one after another, until the
64+
game is complete. Other, more fine-grained methods exist if you
65+
need to structure game play differently for your purposes (for
66+
instance, to train a machine learning model).
67+
5768
Attributes:
5869
mode(GameMode): The game mode
5970
characters(List[Character]): Characters playing the game
@@ -102,6 +113,11 @@ def state(self) -> str:
102113
else:
103114
return "Game waiting to start"
104115

116+
@property
117+
def game(self) -> Game:
118+
"""A reference to the underlying game."""
119+
return self._game
120+
105121
@property
106122
def started(self) -> bool:
107123
"""Whether the game is started."""
@@ -112,6 +128,11 @@ def completed(self) -> bool:
112128
"""Whether the game is completed."""
113129
return self._game.completed
114130

131+
def reset(self) -> Game:
132+
"""Reset game state."""
133+
self._game = self._default_game()
134+
return self._game
135+
115136
def start_game(self) -> Game:
116137
"""
117138
Start the game, returning game state.
@@ -131,57 +152,66 @@ def play_next(self) -> Game:
131152
"""
132153
if self.completed:
133154
raise ValueError("Game is complete")
155+
134156
saved = self._game.copy()
135157
try:
136158
color = self._queue.next()
137-
if self.mode == GameMode.ADULT:
138-
self._play_next_adult(color)
139-
else:
140-
self._play_next_standard(color)
159+
character = self._map[color]
160+
161+
done = False
162+
while not done:
163+
view = self._game.create_player_view(color)
164+
move = self.choose_next_move(character, view)
165+
done = self.execute_move(color, move)
166+
141167
return self._game
142168
except Exception as e:
143169
self._game = saved # put back original so a failed call is idempotent
144170
raise e
145171

146-
def _play_next_standard(self, color: PlayerColor) -> None:
147-
"""Play the next turn under the rules for standard mode."""
148-
card = self._game.deck.draw()
149-
player = self._game.players[color]
150-
character = self._map[color]
151-
view = self._game.create_player_view(color)
152-
legal_moves = self._rules.construct_legal_moves(view, card=card)
172+
def construct_legal_moves(self, view: PlayerView) -> List[Move]:
173+
"""Construct the legal moves based on a player view."""
174+
return self._rules.construct_legal_moves(view, card=None if self.mode == GameMode.ADULT else self._game.deck.draw())
175+
176+
def choose_next_move(self, character: Character, view: PlayerView) -> Move:
177+
"""Choose the next move for a character based on a player view."""
178+
legal_moves = self.construct_legal_moves(view)
153179
move = character.choose_move(self.mode, view, legal_moves[:], Rules.evaluate_move)
154180
if move not in legal_moves: # an illegal move is ignored and we choose randomly for the character
155-
self._game.track("Illegal move: a random legal move will be chosen", player)
181+
self._game.track("Illegal move: a random legal move will be chosen", view.player)
156182
move = random.choice(legal_moves)
157-
if not move.actions: # a move with no actions is a forfeit
183+
return move
184+
185+
def execute_move(self, color: PlayerColor, move: Move) -> bool:
186+
"""Execute a move for a player, returning True if the player's turn is done."""
187+
player = self._game.players[color]
188+
if self.mode == GameMode.ADULT:
189+
return self._execute_move_adult(player, move)
190+
else:
191+
return self._execute_move_standard(player, move)
192+
193+
def _execute_move_standard(self, player: Player, move: Move) -> bool:
194+
"""Play the next turn under the rules for standard mode, returning True if the player's turn is done."""
195+
if not move.actions:
158196
self._game.deck.discard(move.card)
159197
self._game.track("Turn is forfeit; discarded card %s" % move.card.cardtype.value, player)
198+
return True # player's turn is done if they forfeit
160199
else:
161200
self._rules.execute_move(self._game, player, move) # tracks history, potentially completes game
162201
self._game.deck.discard(move.card)
163-
if not self.completed and self._rules.draw_again(move.card):
164-
self._play_next_standard(color) # recursive call for next move
202+
return self.completed or not self._rules.draw_again(move.card) # player's turn is done unless they can draw again
165203

166-
def _play_next_adult(self, color: PlayerColor) -> None:
167-
"""Play the next move under the rules for adult mode."""
168-
player = self._game.players[color]
169-
character = self._map[color]
170-
view = self._game.create_player_view(color)
171-
legal_moves = self._rules.construct_legal_moves(view, card=None)
172-
move = character.choose_move(self.mode, view, legal_moves[:], Rules.evaluate_move)
173-
if move not in legal_moves: # an illegal move is ignored and we choose randomly for the character
174-
self._game.track("Illegal move: a random legal move will be chosen", player)
175-
move = random.choice(legal_moves)
176-
if not move.actions: # a move with no actions is a forfeit
204+
def _execute_move_adult(self, player: Player, move: Move) -> bool:
205+
"""Play the next move under the rules for adult mode, returning True if the player's turn is done."""
206+
if not move.actions:
177207
player.hand.remove(move.card)
178208
self._game.deck.discard(move.card)
179209
player.hand.append(self._game.deck.draw())
180210
self._game.track("Turn is forfeit; discarded card %s" % move.card.cardtype.value, player)
211+
return True # player's turn is done if they forfeit
181212
else:
182213
self._rules.execute_move(self._game, player, move) # tracks history, potentially completes game
183214
player.hand.remove(move.card)
184215
self._game.deck.discard(move.card)
185216
player.hand.append(self._game.deck.draw())
186-
if not self.completed and self._rules.draw_again(move.card):
187-
self._play_next_adult(color) # recursive call for next move
217+
return self.completed or not self._rules.draw_again(move.card) # player's turn is done unless they can draw again

src/apologies/rules.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Implements rules related to game play.
66
"""
77

8+
import uuid
89
from enum import Enum
910
from typing import List, Optional
1011

@@ -69,11 +70,17 @@ class Move:
6970
card(Card): The card that is being played by this move
7071
actions(List[Action]): List of actions to execute
7172
side_effects(List[Action]): List of side effects that occurred as a result of the actions
73+
id(str): Identifier for this move, which must be unique among all legal moves this move is grouped with
7274
"""
7375

7476
card = attr.ib(type=Card)
7577
actions = attr.ib(type=List[Action])
7678
side_effects = attr.ib(type=List[Action])
79+
id = attr.ib(type=str)
80+
81+
@id.default
82+
def _default_id(self) -> str:
83+
return uuid.uuid4().hex
7784

7885
@side_effects.default
7986
def _default_side_effects(self) -> List[Action]:

tests/test_engine.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def test_constructor(self):
5151
assert engine.started is False
5252
assert engine.completed is False
5353
assert engine.state == "Game waiting to start"
54+
assert engine.game is engine._game
5455
assert len(engine._game.players) == 2
5556
assert engine._queue.entries == [PlayerColor.RED, PlayerColor.YELLOW]
5657
assert engine._rules.mode == GameMode.STANDARD
@@ -76,6 +77,12 @@ def test_completed(self):
7677
assert engine.completed is True
7778
assert engine.state == "Game completed"
7879

80+
def test_reset(self):
81+
engine = TestEngine._create_engine()
82+
saved = engine._game
83+
engine.reset()
84+
assert engine._game is not None and engine._game is not saved and not engine._game.started
85+
7986
def test_start_game(self):
8087
engine = TestEngine._create_engine()
8188
engine._rules.start_game = MagicMock()

tests/test_rules.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
# vim: set ft=python ts=4 sw=4 expandtab:
33
# pylint: disable=no-self-use,protected-access,too-many-locals,too-many-statements
44

5-
from unittest.mock import MagicMock, call
5+
from unittest.mock import MagicMock, call, patch
66

77
import pytest
88

99
from apologies.game import ADULT_HAND, DECK_SIZE, PAWNS, Card, CardType, Game, GameMode, Pawn, PlayerColor, Position
1010
from apologies.rules import Action, ActionType, BoardRules, Move, Rules
1111

12+
_UUID = MagicMock(return_value=MagicMock(hex="uuid")) # any call to get a random UUID returns a UUID with hex value "uuid"
13+
1214

1315
class TestAction:
1416
def test_constructor(self):
@@ -21,12 +23,22 @@ def test_constructor(self):
2123

2224

2325
class TestMove:
24-
def test_constructor(self):
26+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
27+
def test_constructor_uuid(self):
2528
card = Card(3, CardType.CARD_12)
2629
actions = [Action(ActionType.MOVE_TO_START, pawn=Pawn(PlayerColor.BLUE, 1, "whatever"))]
2730
move = Move(card, actions)
2831
assert move.card is card
2932
assert move.actions == actions
33+
assert move.id == "uuid"
34+
35+
def test_constructor_explicit(self):
36+
card = Card(3, CardType.CARD_12)
37+
actions = [Action(ActionType.MOVE_TO_START, pawn=Pawn(PlayerColor.BLUE, 1, "whatever"))]
38+
move = Move(card, actions, id="whatever")
39+
assert move.card is card
40+
assert move.actions == actions
41+
assert move.id == "whatever"
3042

3143

3244
class TestRules:
@@ -84,6 +96,7 @@ def test_start_game_adult(self):
8496
assert game.players[PlayerColor.BLUE].pawns[0].position.square == 19
8597
assert len(game.players[PlayerColor.BLUE].hand) == ADULT_HAND
8698

99+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
87100
def test_construct_legal_moves_no_moves_with_card(self):
88101
card = MagicMock()
89102

@@ -114,6 +127,7 @@ def test_construct_legal_moves_no_moves_with_card(self):
114127
[call(PlayerColor.RED, card, pawn1, all_pawns), call(PlayerColor.RED, card, pawn2, all_pawns)]
115128
)
116129

130+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
117131
def test_construct_legal_moves_no_moves_no_card(self):
118132
card = None
119133

@@ -151,6 +165,7 @@ def test_construct_legal_moves_no_moves_no_card(self):
151165
]
152166
)
153167

168+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
154169
def test_construct_legal_moves_with_moves_with_card(self):
155170
card = MagicMock()
156171

@@ -187,6 +202,7 @@ def test_construct_legal_moves_with_moves_with_card(self):
187202
[call(PlayerColor.RED, card, pawn1, all_pawns), call(PlayerColor.RED, card, pawn2, all_pawns)]
188203
)
189204

205+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
190206
def test_construct_legal_moves_with_moves_no_card(self):
191207
card = None
192208

@@ -498,6 +514,7 @@ def _legal_moves(color, game, index, cardtype):
498514

499515

500516
class TestLegalMoves:
517+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
501518
def test_construct_legal_moves_card_1(self):
502519
# No legal moves if no pawn in start, on the board, or in safe
503520
game = _setup_game()
@@ -544,6 +561,7 @@ def test_construct_legal_moves_card_1(self):
544561
card, pawn, view, moves = _legal_moves(RED, game, 0, "1")
545562
assert moves == [Move(card, actions=[_square(pawn, 7)], side_effects=[_bump(view, GREEN, 1)])]
546563

564+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
547565
def test_construct_legal_moves_card_2(self):
548566
# No legal moves if no pawn in start, on the board, or in safe
549567
game = _setup_game()
@@ -590,6 +608,7 @@ def test_construct_legal_moves_card_2(self):
590608
card, pawn, view, moves = _legal_moves(RED, game, 0, "2")
591609
assert moves == [Move(card, actions=[_square(pawn, 8)], side_effects=[_bump(view, GREEN, 1)])]
592610

611+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
593612
def test_construct_legal_moves_card_3(self):
594613
# No legal moves if no pawn on the board, or in safe
595614
game = _setup_game()
@@ -617,6 +636,7 @@ def test_construct_legal_moves_card_3(self):
617636
card, pawn, view, moves = _legal_moves(RED, game, 0, "3")
618637
assert moves == [Move(card, actions=[_square(pawn, 9)], side_effects=[_bump(view, GREEN, 1)])]
619638

639+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
620640
def test_construct_legal_moves_card_4(self):
621641
# No legal moves if no pawn on the board, or in safe
622642
game = _setup_game()
@@ -644,6 +664,7 @@ def test_construct_legal_moves_card_4(self):
644664
card, pawn, view, moves = _legal_moves(RED, game, 0, "4")
645665
assert moves == [Move(card, actions=[_square(pawn, 2)], side_effects=[_bump(view, GREEN, 1)])]
646666

667+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
647668
def test_construct_legal_moves_card_5(self):
648669
# No legal moves if no pawn on the board, or in safe
649670
game = _setup_game()
@@ -671,6 +692,7 @@ def test_construct_legal_moves_card_5(self):
671692
card, pawn, view, moves = _legal_moves(RED, game, 0, "5")
672693
assert moves == [Move(card, actions=[_square(pawn, 11)], side_effects=[_bump(view, GREEN, 1)])]
673694

695+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
674696
def test_construct_legal_moves_card_7(self):
675697
# No legal moves if no pawn on the board, or in safe
676698
game = _setup_game()
@@ -742,6 +764,7 @@ def test_construct_legal_moves_card_7(self):
742764
Move(card, actions=[_square(pawn, 12), _square(other2, 56)], side_effects=[]), # split (6, 1)
743765
]
744766

767+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
745768
def test_construct_legal_moves_card_8(self):
746769
# No legal moves if no pawn on the board, or in safe
747770
game = _setup_game()
@@ -769,6 +792,7 @@ def test_construct_legal_moves_card_8(self):
769792
card, pawn, view, moves = _legal_moves(RED, game, 0, "8")
770793
assert moves == [Move(card, actions=[_square(pawn, 14)], side_effects=[_bump(view, GREEN, 1)])]
771794

795+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
772796
def test_construct_legal_moves_card_10(self):
773797
# No legal moves if no pawn on the board, or in safe
774798
game = _setup_game()
@@ -821,6 +845,7 @@ def test_construct_legal_moves_card_10(self):
821845
Move(card, actions=[_square(pawn, 4)], side_effects=[_bump(view, GREEN, 1)]),
822846
]
823847

848+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
824849
def test_construct_legal_moves_card_11(self):
825850
# No legal moves if no pawn on the board, or in safe
826851
game = _setup_game()
@@ -866,6 +891,7 @@ def test_construct_legal_moves_card_11(self):
866891
Move(card, actions=[_square(pawn, 26)], side_effects=[]),
867892
]
868893

894+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
869895
def test_construct_legal_moves_card_12(self):
870896
# No legal moves if no pawn on the board, or in safe
871897
game = _setup_game()
@@ -893,6 +919,7 @@ def test_construct_legal_moves_card_12(self):
893919
card, pawn, view, moves = _legal_moves(RED, game, 0, "12")
894920
assert moves == [Move(card, actions=[_square(pawn, 18)], side_effects=[_bump(view, GREEN, 1)])]
895921

922+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
896923
def test_construct_legal_moves_card_apologies(self):
897924
# No legal moves if no pawn in start
898925
game = _setup_game()
@@ -914,6 +941,7 @@ def test_construct_legal_moves_card_apologies(self):
914941
Move(card, actions=[_square(pawn, 19), _bump(view, BLUE, 1)], side_effects=[]),
915942
]
916943

944+
@patch("apologies.rules.uuid.uuid4", new=_UUID)
917945
def test_construct_legal_moves_special(self):
918946
# Move pawn into safe zone
919947
game = _setup_game()

0 commit comments

Comments
 (0)