Mokona Guu Center

Test Driven Development : des joueurs un peu moins fous

Publié le

À présent que je peux lancer des parties avec des joueurs qui font des choix automatiquement, je vais implémenter des joueurs un peu moins automatiques que les précédents.

Leur but : ne pas passer à côté d'une victoire immédiate, c'est-à-dire lorsqu'il peut capturer le Lion adverse ou placer son Lion sur la ligne adverse.

Afin que les joueurs puissent prendre des décisions, ils doivent avoir un moyen d'obtenir l'état du tablier pour l'analyser.

Passer par l'effector n'est pas une bonne idée. Cet objet est là pour agir sur le jeu. Je pourrais ajouter un paramètre à l'appel de ask_move contenant des informations sur l'état du jeu. Cela nécessiterait quelques modifications au système. Je pourrais aussi passer en paramètre à mon LucidPlayer les informations nécessaires, cela ne demanderait aucune modification au système actuel.

Si je donne les informations sur le tablier au joueur, j'évite de passer un argument en plus. Cependant, le joueur conserve alors cette information de manière durable. C'est peut-être moins flexible : tout état gardé par la classe de joueur rendra le système moins flexible. Si je veux demander à un joueur un mouvement dans un état de jeu différent (pour tester sa réponse, ou bien pour paralléliser des traitements par exemple) je devrais dupliquer ces états conservés.

Si dans l'autre cas tous les états sont passés au joueur qui se comporte donc comme une fonction pure (sans effet de bord), j'obtiens une grande flexibilité. J'empêche cependant des effets de bords qui pourraient permettre au joueur de garder en mémoire des éléments pour construire une stratégie.

Il est cependant possible de garder le côté fonctionnel si ask_move passe un objet dans lequel la classe de joueur peut stocker des informations dont elle aura besoin plus tard.

Allons au plus simple pour le moment, je vais passer les informations dont j'ai besoin à la classe. Je garde en tête que, si le besoin s'en fait sentir, une transformation un peu plus fonctionnelle peut se faire.

Vous pouvez tout à fait faire l'autre choix, je le pense tout aussi valable à ce stade là.

    from player import Player
    from run_game import GameRunner


    class LucidPlayer(Player):
        """ This player makes random moves, captures and drops.

            It won't miss a move leading to immediate victory. """

        def set_board(self, board):
            self.board = board

        def ask_move(self, effector):
            pass


    def main():
        runner = GameRunner(LucidPlayer, LucidPlayer)
        session = runner.get_session()
        runner.get_player(0).set_board(session.get_board())
        runner.get_player(1).set_board(session.get_board())
        runner.set_maximum_move_count(2000)

        runner.launch_game()

        print(session.get_move_count())


    if __name__ == '__main__':
        main()

get_board n'existe pas sur Session. Mais j'ai un test dans les tests de session qui teste déjà la présence d'un board créé. L'accès était juste direct, je change donc cette fonction :

    def test_creates_a_board(self):
        self.assertIsNotNone(self.session.board)

en

    def test_creates_a_board(self):
        self.assertIsNotNone(self.session.get_board())

Je créé la méthode correspondante (qui est évidente et que je ne note pas ici) et j'en profite, une fois que le test passe, pour remplacer tous les appels directs au membre board par l'appel à la fonction (il y en a quelques-uns dans les tests de Session).

Bien, mon lanceur de joueurs lucide s'arrête toujours au bout de 2000 mouvements faute d'action des joueurs, mais au moins, il tourne.

Reste donc à implémenter les choix possibles (je n'ose pas dire \"l'intelligence\") de ce joueur.

Pour cela, je voudrais d'abord pouvoir connaître la liste des positions des pièces appartenant au joueur. Mais malheureusement, les instances des joueurs ne savent actuellement pas qui ils sont. Le système ne leur dit pas avec quelles pièces jouer.

Je prends quelles pièces moi ?

Donc :

    class SessionTestCase(SessionTests):
        def test_gives_each_player_a_role(self):
            self.player_1.set_role.assert_called_once_with(P1)

Qui se résous avec :

    class Session:
        def __init__(self, players=[]):
            self.board = Board()

            self._place_row([GIRAFFE, LION, ELEPHANT], P1, 0)
            self._place_row([None, PAWN, None], P1, 1)

            self._place_row([None, PAWN, None], P2, 2)
            self._place_row([ELEPHANT, LION, GIRAFFE], P2, 3)

            players[0].set_role(P1)
            self._dispatcher = PlayerDispatcher(players[0], players[1])

            self._tray = Tray()
            self._validator = MovementValidator()

            self._game = ShogiGame(self.board, self._tray, self._validator)

            self._move_count = 0

D'une part (qui va échouer car set_role n'existe pas dans pour un joueur).

D'autre part :

    class PlayerTestCase(unittest.TestCase):
        def test_can_have_a_role(self):
            player = Player()
            self.assertEqual(0, player.get_role())
            player.set_role(P1)
            self.assertEqual(P1, player.get_role())

Un second test qui échoue en même temps ? Ce n'est pas très correct, mais vu la simplicité de ce que j'ajoute, ça passe ; pour m'en sortir, je lance les tests qui échoue indépendamment par fichier. Puis lorsque tout sera résolu, j'exécuterai tous les fichiers de tests simultanément.

Player s'étoffe donc un peu :

    class Player:
        def __init__(self):
            self._role = 0

        def ask_move(self, effector):
            pass

        def get_role(self):
            return self._role

        def set_role(self, role):
            self._role = role

Je détecte au passage quelques petites erreurs dans mes tests fonctionnels de la session, où l'un de mes joueurs n'hérite pas correctement de Player.

Je reviens donc à mon test initial et j'ajoute le second joueur.

    class SessionTestCase(SessionTests):
        def test_gives_each_player_a_role(self):
            self.player_1.set_role.assert_called_once_with(P1)
            self.player_2.set_role.assert_called_once_with(P2)

Ce qui donne :

    class Session:
        def __init__(self, players=[]):
            self.board = Board()

            self._place_row([GIRAFFE, LION, ELEPHANT], P1, 0)
            self._place_row([None, PAWN, None], P1, 1)

            self._place_row([None, PAWN, None], P2, 2)
            self._place_row([ELEPHANT, LION, GIRAFFE], P2, 3)

            players[0].set_role(P1)
            players[1].set_role(P2)
            self._dispatcher = PlayerDispatcher(players[0], players[1])

            self._tray = Tray()
            self._validator = MovementValidator()

            self._game = ShogiGame(self.board, self._tray, self._validator)

            self._move_count = 0

Voilà, les joueurs savent maintenant avec quelles pièces ils peuvent jouer !

Où sont mes pièces ?

Puisqu'un joueur sait avec quelles pièces il joue, la question d'après est de savoir où sont ces pièces sur le tablier. Pour cela, j'aimerais récupérer une liste de pièces avec leurs position que je pourrais filtrer en fonction du joueur.

J'ai à nouveau un choix à faire.

  • Soit je donne à Board la responsabilité de renvoyer cette liste, ce qui va probablement coûter en temps de construction si elle est fait à chaque fois (avec dans l'idée qu'elle pourra être maintenue entre les demandes pour futur optimisation),
  • soit je donne à un autre objet la responsabilité d'interroger Board en parcourant toutes les positions possibles du tableau,
  • soit je donne à Board la responsabilité de renvoyer la liste déjà filtrée.

Le seconde option est la plus juste : pas de nouvelle responsabilité au niveau de Board. Mais un parcours un peu long à faire. Et encore, le Dôbutsu Shôgi est un jeu où les emplacements sont occupées en grande partie. Pour un jeu avec un ratio de pièces présentes / nombre de cases faible, le parcours serait inutilement long.

La troisième option ne me plait pas du tout. On commence par une requête, puis on en ajoute une autre, et au final, on se retrouve avec une multitude de fonctions spécialisées, avec une partie indéterminée qui ne sert plus à rien. Les classes doivent rester simples.

J'opte donc pour la première possibilité.

    class BoardTestCase(unittest.TestCase):
        def test_gives_an_empty_list_of_pieces_when_empty(self):
            self.assertEqual([], self.board.get_pieces_locations())

Je reprends les bonnes habitudes :

    class Board:
        def get_pieces_locations(self):
            return []

Puis :

    def test_gives_one_piece_with_position_when_one_piece_on_board(self):
        piece, location = PIECE_PAWN_P1, (0, 0)
        self.board.place_piece(piece, location)
        self.assertEqual([(piece, location)], self.board.get_pieces_locations())

Et un peu de magie Python pour résoudre ça :

    def get_pieces_locations(self):
        return [(piece, location) for (location, piece) in self.pieces_on_board.iteritems()]

Une explication au cas où :

  • iteritems() renvoie un itérateur sur l'ensemble des paires (clé, valeur) contenu dans le dictionnaire pieces_on_board,
  • je récupère la clé (location) et la valeur (piece) par le processus dont le nom m'échappe,
  • je créé une liste en « compréhension » formée de toutes les paires avec les clés et valeurs inversées.

Et voilà.

Pas besoin de test supplémentaire.

LucidPlayer peut enfin connaître la liste des pièces qu'il peut bouger sur le tablier :

    def ask_move(self, effector):
        my_pieces = [piece for (piece, location)
                     in self.board.get_pieces_locations()
                     if piece.get_controller() == self._role]

Mais, ça bouge comment une pièce ?

LucidPlayer n'a en effet aucun moyen de savoir comme déplacer une pièce, il ne connaît pas les règles. Or pour capturer le Lion adverse, il faut savoir bouger ses pièces.

Qu'à cela ne tienne, j'y vais en force brute. Après tout, je suis en phase exploratoire et j'essaie de voir rapidement ce qu'il est possible de faire. Ce que j'écris n'est pas destiné à être conservé en l'état. C'est une chose importante à se rappeler pour éviter de le regretter plus tard.

    def ask_move(self, effector):
        my_pieces = [(piece, location) for (piece, location)
                     in self.board.get_pieces_locations()
                     if piece.get_controller() == self._role]

        validator = MovementValidator()

        allowed_movements = []

        for (piece, source) in my_pieces:
            for y in range(BOARD_MAXIMAL_Y + 1):
                for x in range(BOARD_MAXIMAL_X + 1):
                    destination = (x, y)
                    if validator.is_movement_valid(piece, source, destination):
                        allowed_movements.append((piece, source, destination))

        if len(allowed_movements) > 0:
            print([(str(a), b, c) for (a, b, c) in allowed_movements])
            exit(1)

Avec ceci, j'essaie d'amener chaque pièce à chaque emplacement du plateau et je demande au validateur de mouvement si le mouvement est valide. J'en obtiens une liste de déplacements possibles, que j'affiche avant d'arrêter brutalement le programme.

Voici la sortie, que j'étudie attentivement :

    [("Piece('G', 1)", (0, 0), (1, 0)), 
     ("Piece('G', 1)", (0, 0), (0, 1)), 
     ("Piece('G', 1)", (0, 0), (2, 1)),
     ("Piece('G', 1)", (0, 0), (1, 2)), 
     ("Piece('G', 1)", (0, 0), (1, 3)), 
     ("Piece('E', 1)", (2, 0), (1, 1)),
     ("Piece('L', 1)", (1, 0), (0, 0)), 
     ("Piece('L', 1)", (1, 0), (2, 0)), 
     ("Piece('L', 1)", (1, 0), (0, 1)),
     ("Piece('L', 1)", (1, 0), (1, 1)), 
     ("Piece('L', 1)", (1, 0), (2, 1)), 
     ("Piece('L', 1)", (1, 0), (0, 2)),
     ("Piece('L', 1)", (1, 0), (2, 2)), 
     ("Piece('L', 1)", (1, 0), (0, 3)), 
     ("Piece('L', 1)", (1, 0), (2, 3)),
     ("Piece('P', 1)", (1, 1), (1, 2))]

Les mouvements permis ne prennent pas en compte l'emplacement d'une pièce à soit sur la case de destination. C'est normal.

Ce qui n'est pas normal par contre, ce sont les mouvements de la giraffe ! (\"Piece('G', 1)\", (0, 0), (1, 2)) n'est pas un mouvement valide.

Un bug ! Il est temps d'ajouter ce cas dans la liste des tests afin de pouvoir corriger le problème.

Où vas-tu, Giraffe ?

Pour corriger le bug, il s'agit de d'abord le reproduire dans les tests de movementvalidator_tests.py.

    def test_can_validate_a_giraffe_for_player_1(self):
        asserter = MovementAssert(self, Piece(GIRAFFE, P1), (1, 0))

        list_of_allowed_moves = [
            (0, 1), (0, -1),
            (-1, 0), (1, 0)
        ]
        list_of_forbidden_moves = [
            (0, 0),  # Stays still
            (1, 1), (1, -1),
            (-1, 1), (-1, -1),
            (1, 2),
            (0, 2), (0, 3)  # Moves more than one space
        ]

        asserter.move_validation(list_of_allowed_moves, list_of_forbidden_moves)

Pour rappel, la liste des mouvements permits et interdits est une liste de déplacements relatifs à la position de la pièce. J'y ajoute le mouvement (1, 2), qui est interdit, la giraffe bouge orthogonalement et d'une case maximum.

Le test échoue, ce qui est cohérent :

    AssertionError: Should forbid movement (1, 2)

Je remplace la vérification de validité de la giraffe par :

    def is_giraffe_movement_valid(piece, source, destination):
        move_x = abs(destination[0] - source[0])
        move_y = abs(destination[1] - source[1])
        return (move_x + move_y) == 1

Et ça passe. Je peux donc relancer ma recherche de mouvement de LucidPlayer.

Note : on voit ici une partie très puissante du TDD. J'ai modélisé le bug, puis je l'ai corrigé. En m'assurant que les tests passent toujours tous après la résolution du bug, j'épargne beaucoup de tests manuels que j'aurais du faire pour m'assurer que tout fonctionne toujours.

Il y a peut-être toujours des bugs, mais en tout cas, tout ce qui a été modélisé par des tests n'a pas bougé. Je garanti au moins cela, et c'est déjà beaucoup.

Mouvements possibles

La liste des mouvements est à présent :

    [("Piece('G', 1)", (0, 0), (1, 0)), 
     ("Piece('G', 1)", (0, 0), (0, 1)), 
     ("Piece('E', 1)", (2, 0), (1, 1)),
     ("Piece('L', 1)", (1, 0), (0, 0)), 
     ("Piece('L', 1)", (1, 0), (2, 0)), 
     ("Piece('L', 1)", (1, 0), (0, 1)),
     ("Piece('L', 1)", (1, 0), (1, 1)), 
     ("Piece('L', 1)", (1, 0), (2, 1)), 
     ("Piece('L', 1)", (1, 0), (0, 2)),
     ("Piece('L', 1)", (1, 0), (2, 2)), 
     ("Piece('L', 1)", (1, 0), (0, 3)), 
     ("Piece('L', 1)", (1, 0), (2, 3)),
     ("Piece('P', 1)", (1, 1), (1, 2))]

Après vérification, je vois que c'est le Lion qui se permet des déplacements interdits. Même principe qu'avec la Giraffe, j'ajoute un des mouvements problématique à la liste des mouvements à interdire dans mon tests, puis je corrige.

    def is_lion_movement_valid(piece, source, destination):
        move_x = abs(destination[0] - source[0])
        move_y = abs(destination[1] - source[1])
        return ((move_x == 1 and move_y == 1) or
                (move_x + move_y) == 1)

Et ma nouvelle liste de mouvements est à présent :

    [("Piece('G', 1)", (0, 0), (1, 0)), 
     ("Piece('G', 1)", (0, 0), (0, 1)), 
     ("Piece('E', 1)", (2, 0), (1, 1)),
     ("Piece('L', 1)", (1, 0), (0, 0)), 
     ("Piece('L', 1)", (1, 0), (2, 0)), 
     ("Piece('L', 1)", (1, 0), (0, 1)),
     ("Piece('L', 1)", (1, 0), (1, 1)), 
     ("Piece('L', 1)", (1, 0), (2, 1)), 
     ("Piece('P', 1)", (1, 1), (1, 2))]

Elle est correcte !

Aurais-je pu écrire un test plutôt que de vérifier manuellement ? Oui. Ça aurait été judicieux. Cependant, j'étais en phase exploratoire, le test était court, et j'ai résolu les problèmes. À présent, il est trop tard pour écrire un test car il n'échouerait pas.

Si test il y avait eu, cela aurait créé un objet permettant d'obtenir la liste des mouvements disponibles. Peut-être aurait-ce été une bonne chose, mais je ne sais pas, étant en phase exploratoire, si un tel objet me sera utile.

J'ai donc préféré faire cette partie en mode exploratoire, à la main, en corrigeant cependant les bugs trouvés selon la méthode TDD standard.

Je laisse mes pièces tranquilles

Cette liste doit encore être affinée. En effet, de la liste des mouvements, je dois enlever les mouvements qui me feraient me déplacer vers une case contenant une pièce de joueur courant.

J'ajoute donc ce test :

    for (piece, source) in my_pieces:
        for y in range(BOARD_MAXIMAL_Y + 1):
            for x in range(BOARD_MAXIMAL_X + 1):
                destination = (x, y)
                if (validator.is_movement_valid(piece, source, destination) and
                   self.board.get_piece_at(destination).get_controller() != self._role):
                    allowed_movements.append((piece, source, destination))

Ce qui donne comme liste :

    [("Piece('G', 1)", (0, 0), (0, 1)), 
     ("Piece('L', 1)", (1, 0), (0, 1)), 
     ("Piece('L', 1)", (1, 0), (2, 1)),
     ("Piece('P', 1)", (1, 1), (1, 2))]

C'est la liste correcte des déplacements initiaux permis pour le premier joueur. Parfait.

À noter que cette grosse double liste n'est pas très jolie ni très pythonesque.

Puisque j'ai maintenant une liste de possibilité, je vais en choisir une au hasard et, en fonction de la présence ou non d'une pièce dans la case de destination, demander une capture ou un mouvement.

    if len(allowed_movements) > 0:
        piece, source, destination = random.sample(allowed_movements, 1)[0]
        if self.board.get_piece_at(destination).get_controller() == 0:
            effector.move(source, destination)
        else:
            effector.capture(source, destination)

Et les parachutages là-dedans ?

Je vais y venir, mais la taille de l'article a atteint la taille d'un épisode. Les parachutages seront pour la prochaine fois. En attendant, voici la distribution des parties pour 1000 parties jouées.

Statistiques avec des joueurs lucides, sans parachutage

Tout à coup, le nombre de partie médian tombe à 9. On est toujours sur des parties aléatoires, mais à présent, les joueurs ne passent pas. Les mouvements sont légaux à chaque fois.

Je m'approche donc des renseignements que je voulais avoir il y a quelques mois. Il manque encore des petites choses. Pour le moment, les joueurs ne parachutent pas de pièce. De même, ils ne sautent pas sur une victoire, comme c'est leur mandat initial, ni n'évitent, s'ils le peuvent, une victoire immédiate du joueur adverse.