Mokona Guu Center

Test Driven Development : les joueurs fous

Publié le

La fois dernière, j'avais déroulé une partie complète, scriptée, et vérifier l'état final. J'approche donc de la possibilité d'écrire les tests initiaux que je voulais effectuer au début de cette aventure : explorer les possibilités de parties de Dôbutsu Shôgi.

Le test fonctionnel du scénario n'est cependant pas terminé. En effet, j'ai écrit cette partie :

    for x in range(9):
        self.session.ask_player_move()

Cependant, utiliser une session directement n'est pas la manière dont j'avais prévu le système. J'avais écrit pour cela une classe SessionRunner qui fait se dérouler la partie jusqu'à son achèvement.

L'idée est donc de remplacer le code par quelque chose comme

    runner = SessionRunner(self.session)
    runner.launch_game()

Cependant, dans les tests de SessionRunner, j'avais aussi ajouté le concept d'une callback permettant de vérifier que la partie ne durait pas plus que nécessaire, pour éviter qu'un test manqué ne fasse tourner le programme indéfiniment.

Ça donne ça :

    def _ask_player_callback(session):
        self.assertGreater(9, session.get_move_count())

    self._initialize_session(Scenario3_Player1, Scenario3_Player2)

    runner = SessionRunner(self.session)
    runner.set_on_before_ask_player(_ask_player_callback)
    runner.launch_game()

J'ajoute l'import de SessionRunner que j'ai profité pour déplacer dans session.py. Je lance les tests.

    NotImplementedError: Session.get_move_count() is not yet implemented

En effet, tout comme is_finished() lors du dernier épisode, j'avais laissé cette fonction non implémentée, car je ne m'en étais servie qu'à travers des MockObjects.

Le code le plus simple pour que le test passe est de lui faire renvoyer 0 systématiquement.

Ça passe. Mais get_move_count() n'est pas correct.

J'écris donc un test qui vérifie que le compteur de mouvement augmente.

    class SessionTestCase(SessionTests):
        def test_increases_move_count_after_each_player_move(self):
            self.assertEqual(0, self.session.get_move_count())
            self.session.ask_player_move()
            self.assertEqual(1, self.session.get_move_count())
            self.session.ask_player_move()
            self.assertEqual(2, self.session.get_move_count())

J'aurais pu aussi tester le compteur dans le test fonctionnel en vérifiant qu'il était bien à 9 à la fin de la partie. Mais je préfère tester le compteur dans le tests unitaires de la classe, inutile de surcharger le test fonctionnel qui doit vérifier pas mal de choses.

Pour vérifier que la vérification dans la callback agit bien, je force son déclenchement en changeant le 9 en 4. L'erreur se déclenche bien. Je remets donc à 9.

Les joueurs fous

Voilà, j'ai tout ce qu'il faut à présent pour des parties endiablées de Dôbutsu Shôgi. Enfin presque tout.

En effet, les joueurs via le système actuel ne reçoivent aucune information sur l'état du jeu. Uniquement de quoi effectuer des mouvements.

Qu'à cela ne tienne, je vais commencer par implémenter des joueurs qui font effectuer des ordres complètement aléatoires. Juste pour voir.

Je créé un fichier run_mad_players.py dans lequel je prépare une structure pour lancer la partie.

    from session import SessionRunner, SessionFactory
    from player import Player

    class MadPlayer(Player):
        pass

    def main():
        def _ask_player_callback(session):
            if session.get_move_count() > 100:
                print("Game took too many turns. Aborting")
                exit(1)

        player_1 = MadPlayer()
        player_2 = MadPlayer()
        session = SessionFactory().create([player_1, player_2])

        runner = SessionRunner(session)
        runner.set_on_before_ask_player(_ask_player_callback)
        runner.launch_game()


    if __name__ == '__main__':
        main()

Voilà, c'est le squelette minimal pour faire tourner une partie avec deux joueurs partageant la même IA. Vu que les joueurs ne font strictement rien pour le moment, et que ce que j'ai prévu qu'ils fassent peut n'amener à rien, je préfère utiliser la callback pour sortir du programme si la partie dur trop longtemps. 100 tours pour du Dôbutsu Shôgi, ça me semble pas mal.

Je lance le script, cela m'affiche l'erreur, c'est parfait.

J'implémente alors un joueur fou.

    def clamp(minimum, maximum, value):
        """ Gets the clamped value. Using sorted() """
        return sorted((minimum, maximum, value))[1]


    class MadPlayer(Player):
        """ This player makes random moves and captures.
        It doesn't know how to drop. """

        def ask_move(self, effector):
            source = (random.randint(BOARD_MINIMAL_X, BOARD_MAXIMAL_X),
                      random.randint(BOARD_MINIMAL_Y, BOARD_MAXIMAL_Y))

            shift = (random.randint(-1, 1),
                     random.randint(-1, 1))

            destination = (source[0] + shift[0],
                           source[1] + shift[1])

            # Use of sort to clamp the value
            destination = (clamp(BOARD_MINIMAL_X, BOARD_MAXIMAL_X, destination[0]),
                           clamp(BOARD_MINIMAL_Y, BOARD_MAXIMAL_Y, destination[1]))

            action = random.sample(["move", "capture"], 1)[0]

            getattr(effector, action)(source, destination)

Je remets mon affichage de board via str en place (voir épisode précédent) pour avoir une petite idée de ce qu'il se passe. 100 itération n'est pas assez, les joueurs font vraiment n'importe quoi et surtout, passent souvent leur tour (dans le cas d'un mouvement invalide, le système ignore le choix et passe au joueur suivant).

Et ça donne quoi ?

Déjà, 100 tours, ce n'est pas assez. Normal pour des joueurs qui font n'importe quoi. Je mets large, avec 10000. Les jeux se terminent alors. Ouf.

Statistiques joueurs fous

Je lance 1000 parties de ce type pour voir ce que cela donne. Déjà, elles se terminent toutes. La partie la plus longue a duré 1024 mouvements. Avec une médiane à 148 mouvements. Pour du Shôgi, on sent que les joueurs se tournent autour sans réfléchir.

Fun, mais ça n'amène pas à grand chose. Ou plutôt si, une borne maximale : toute IA qui serait plus mauvaise que ça serait rudement mauvaise.

Et les tests ?!!!

Ben c'est vrai quoi ? En plein dans une série d'articles sur le TDD, voilà que j'écris du code directement, comme ça, sans avoir écrit de test qui échoue au préalable. Que se passe-t-il ?

Il se passe tout d'abord que les joueurs fous utilisent beaucoup le hasard, du coup, je l'ai déjà dit, ce n'est pas facile à tester.

Ensuite, je suis dans une phase exploratoire. Contrairement à la construction du logiciel jusqu'à maintenant, où les étapes étaient claires, ici, j'explore les possibilités de jeux du Dôbutsu Shôgi, sans savoir vraiment ce que je vais trouver. Ce n'est pas du code pérenne, et écrire en TDD pour de l'exploratoire alourdi beaucoup la procédure. Pour du code qui change tout le temps, ce n'est pas nécessaire.

Cependant, attention, il y a deux choses qui méritent encore des tests. Tout d'abord les parties qui sont appelées à rester, les fonctions d'aide, devraient être testées. Ensuite, les parties qui, les des explorations, semblent être communes à plusieurs cas et qui bougent peu devraient être réécrites en TDD.

Réécrite, et non pas testées a posteriori. Cela signifie que le code qui doit être promu à un status plus permanent ne doit pas être utilisé pour les tests. Cependant, l'expérience gagnée à avoir écrit ce code une première fois est bien sûr utilisée pour diriger les tests vers le but à atteindre.

Dans ce cadre, clamp aurait du être testé. Oui. J'avoue.

Les yeux des joueurs

Les joueurs fous peuvent faire n'importe quoi... sauf parachuter. Ils ne peuvent pas car ils n'ont pas accès aux pièces capturées. La classe joueur pourrait en tenir compte, mais il faudrait pour cela vérifier que le mouvement demandé était bien légal, donc soit en appelant une validation de mouvement soit en vérifiant que le tablier a été modifié.

Voilà qui est bien complexe.

J'y vais doucement, et je vais donner aux joueurs fous la possibilité de regarder ce qu'il y a dans la réserve. Cependant, la réserve n'est pas accessible pour le moment, puisque c'est un membre privé de la session. Même si le concept de privé en Python n'est pas très fort, je ne vais pas aller le piocher directement.

Je pourrai aussi donner l'information lors du ask_move, en plus de l'effector. Cela fera un argument de plus. Sans bonne raison.

Bon, je suis en expérimentation, je vais donc passer un Tray obtenu de session.

J'ajoute un test sur la session, tout d'abord :

    def test_creates_a_tray(self):
        self.assertIsNotNone(self.session.get_tray())

Qui échoue car la fonction n'existe pas. Je créé la fonction qui retourne le Tray interne à la session. Si cela pose problème dans le futur, je pourrai renvoyer un proxy interdisant l'écriture.

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

        def set_tray(self, tray):
            self._tray = tray

        def ask_move(self, effector):
            source = (random.randint(BOARD_MINIMAL_X, BOARD_MAXIMAL_X),
                      random.randint(BOARD_MINIMAL_Y, BOARD_MAXIMAL_Y))

            shift = (random.randint(-1, 1),
                     random.randint(-1, 1))

            destination = (source[0] + shift[0],
                           source[1] + shift[1])

            # Use of sort to clamp the value
            destination = (clamp(BOARD_MINIMAL_X, BOARD_MAXIMAL_X, destination[0]),
                           clamp(BOARD_MINIMAL_Y, BOARD_MAXIMAL_Y, destination[1]))

            possibilities = ["move", "capture"]
            if len(self._tray.pieces) > 0:
                possibilities.append("drop")

            action = random.sample(possibilities, 1)[0]

            if action == "drop":
                source = random.sample(self._tray.pieces, 1)[0]

            getattr(effector, action)(source, destination)

Parachutages

Qu'est-ce que ça donne après 1000 itérations ? Les parties sont légèrement plus courtes, la partie la plus longue est pour moi à 851 mouvements. Vos résultats peuvent être différents, il ne faut pas oublier que ces joueurs font vraiment n'importe quoi.

Statistiques avec Joueurs Fous et Parachutes

Le nombre de mouvements de la partie médiane est de 141. C'est légèrement plus bas que le 148 précédent, mais pas tant que ça.

Le parachutage au Shogi rend le jeu agressif. C'est une attaque qui peut faire mal, la pièce peut arriver à un endroit très dangereux. Il est donc normal que le nombre de mouvements soit plus bas.

Mais en lançant plusieurs fois les deux versions, il y a vraiment peu de différence.

Il s'agirait donc de passer à quelque chose de plus crédible.

L'éclair de lucidité

Je vais donc créer une nouvelle IA qui va jouer tout comme la première de manière aléatoire, sauf si la victoire est possible, auquel cas ce mouvement est effectué.

Comment faire pour savoir si le joueur est capable de prendre un lion ou d'amener le sien sur la ligne adverse ? D'une manière ou d'une autre, le joueur doit voir le contenu du tablier.

Je fais toujours de l'exploration. Je créé donc un nouveau fichier run_lucid_players.py.

Pour débuter, je recopie le code minimal initial indiqué précédemment. Et immédiatement, une alarme résonne dans ma tête. J'ai beau être dans de l'expérimentation, copier/coller un morceau de code ne me plait pas. La duplication se paie toujours assez cher en dette technique.

Il est temps de fabriquer un lanceur d'IA !

Le lanceur

Je créé donc un fichier run_game_tests.py afin de préparer le terrain. C'est-à-dire... écrire des tests.

    class RunGameTestCase(unittest.TestCase):
        def test_creates_two_players(self):
            game_runner = GameRunner(PlayerClass1, PlayerClass2)
            self.assertEqual(PlayerClass1, game_runner.get_player(0).__class__)
            self.assertEqual(PlayerClass2, game_runner.get_player(1).__class__)
            self.assertIsNotNone(game_runner.get_session())

Le test nécessite tout d'abord la création d'une classe GameRunner avec deux arguments.

    class GameRunner:
        def __init__(self, player_class1, player_class2):
            pass

Puis une méthode pour récupérer un joueur.

    def get_player(self, player_index):
        return PlayerClass1()

Ce qui fait passer le premier assert, mais pas le second. Donc :

    class GameRunner:
        def __init__(self, player_class1, player_class2):
            self.players = [player_class1(), player_class2()]

        def get_player(self, player_index):
            return self.players[player_index]

Ce qui fait passer les deux premiers tests.

Ici, je fais une pause pour expliquer un choix qui est souvent une question : est-ce qu'il faut tester les cas d'erreurs ?

Ici, il peut y en avoir plusieurs. Passer None ou un entier à la construction de GameRunner par exemple. Ou bien demander le type du 3ième joueur. Sans compter que l'on peut passer n'importe quoi à get_player, ce qui ne fonctionnera probablement pas.

Faut-il tester ces cas ? C'est une question que l'on me pose souvent.

La réponse diffère en fonction de ce qui est testé. Et la réponse n'est donc pas simple.

Il est parfois intéressant d'avoir des tests de cas d'erreurs si l'erreur à un intérêt pour le système. Ici, les erreurs seront détectées par l'interpréteur Python et seront renvoyées sous forme assez claire si vraiment une erreur apparaît.

Si un None is not callable ou un list index out of range apparaît, alors il sera peut-être temps de créer de quoi récupérer de l'erreur ou bien de régler un bug.

Mais si j'écris un test ici, ce que je vais tester, c'est surtout le fonctionnement de Python, pas mon programme.

Inutile dans ce cas-ci.

Retour à mes tests, il faut à présent retourner quelque chose. Je créé donc la méthode suivante :

    class GameRunner:
        def get_session(self):
            return 1

Le test passe. Même si ce n'est vraiment une session.

Je vais donc écrire un nouveau test. Le GameRunner ne doit pas lancer de jeu si je n'indique pas de nombre de tour de jeu maximum. C'est une protection pour le test pour éviter la boucle infinie, comme précédemment. Et cela permettra d'intégrer ensuite le concept de nombre variable de mouvements maximum que j'ai écrit pour le lancement des joueurs fous.

    class GameRunner:
        def test_does_not_run_if_no_maximum_move(self):
            game_runner = GameRunner(PlayerClass1, PlayerClass2)
            game_runner.launch_game()

            session = game_runner.get_session()
            self.assertEqual(0, session.get_move_count())

J'ajoute la méthode launch_game() à GameRunner. J'utilise le même nom de méthode que pour Session, par cohérence.

L'erreur est alors que session n'a pas de méthode get_move_count(), puisque c'est actuellement un entier.

Je créé donc une session :

    class GameRunner:
        def __init__(self, player_class1, player_class2):
            self.players = [player_class1(), player_class2()]
            self.session = SessionFactory().create([self.players[0], self.players[1]])

        def get_session(self):
            return self.session

À présent, je voudrais que le jeu s'arrête au bout d'un certain nombre de mouvements.

    def test_runs_given_number_of_moves():
        game_runner = GameRunner(PlayerClass1, PlayerClass2)
        game_runner.set_maximum_move_count(2)

        game_runner.launch_game()

        session = game_runner.get_session()
        self.assertEqual(2, session.get_move_count())

Le test échoue car set_maximum_move_count n'existe pas. Je l'ajoute.

    class GameRunner:
        def set_maximum_move_count(self, max_move_count):
            pass

Qui ne passe par car le nombre de mouvement à la fin du test est bien évidemment 0, puisque la session n'a pas été lancée.

Voici une solution :

    class GameHasEnded(Exception):
        pass


    class GameRunner:
        def __init__(self, player_class1, player_class2):
            self.players = [player_class1(), player_class2()]
            self.session = SessionFactory().create([self.players[0], self.players[1]])
            self.max_move_count = 0

        def set_maximum_move_count(self, max_move_count):
            self.max_move_count = max_move_count

        def launch_game(self):
            def _ask_player_callback(session):
                if session.get_move_count() >= self.max_move_count:
                    raise GameHasEnded()

            runner = SessionRunner(self.session)
            runner.set_on_before_ask_player(_ask_player_callback)

            try:
                runner.launch_game()
            except GameHasEnded:
                pass

Je lance le SessionRunner avec la callback comme dans mon lancer de joueurs fous. Plutôt que de sortir brutalement lorsque le nombre de mouvement maximum est écoulé, je lance une exception que j’attrape en retour sans rien faire. Tout autre exception provoquera une erreur.

Je peux donc maintenant écrire mon lanceur de joueurs fous grâce à mon nouveau système.

La seule chose un peu gênante est que mon lanceur était une phase exploratoire, je n'ai donc pas de tests en place pour vérifier que ma réécriture est bonne. Je devrais donc vérifier le fonctionnement manuellement, en comparant les résultats avec mes tests précédents.

Je déplace donc GameRunner et son exception GameHasEnded dans un fichier run_game afin de pouvoir importer les symboles dans run_mad_players.py.

Voilà à quoi ressemble le lanceur après traitement :

    def main():
        runner = GameRunner(MadPlayer, MadPlayer)
        session = runner.get_session()
        runner.get_player(0).set_tray(session.get_tray())
        runner.get_player(1).set_tray(session.get_tray())
        runner.set_maximum_move_count(2000)

        runner.launch_game()

        print(session.get_move_count())

Ce qui me permet d'initialiser mon framework pour les LucidPlayer.

    class LucidPlayer(Player):
        pass

    def main():
        runner = GameRunner(LucidPlayer, LucidPlayer)
        session = runner.get_session()
        # runner.get_player(0).set_tray(session.get_tray())
        # runner.get_player(1).set_tray(session.get_tray())
        runner.set_maximum_move_count(2000)

        runner.launch_game()

        print(session.get_move_count())

Il reste un peu de copie, mais je pour le moment, je vous laisse. À la prochaine.