Mokona Guu Center

Test Driven Development : parachutages lucides

Publié le

La fois dernière, j'avais terminé à programmer des joueurs qui effectuaient des coups valides de mouvements et de capture. J'avais laissé de côté les parachutages ainsi que la détection d'une victoire immédiate.

Ce seront les sujets de cet épisode.

Parachutes

Afin de savoir s'il y a des pièces à parachuter, un LucidPlayer doit avoir accès à la réserve, ce qui n'est pas le cas actuellement. Par contre, le MadPlayer lui, a un set_tray. Je pourrais donc ajouter un set_tray à LucidPlayer, au risque de continuer à ajouter des set ceci et celà.

Je remplace set_board par :

    def set_environement(self, board=None, tray=None):
        self.board = board
        self.tray = tray

Les variables nommées évitent des appels avec un ordre d'arguments pas forcément évident à retenir, le nom de la fonction étant assez fourre-tout. Peut-être que dans le future, les arguments, s'ils augmentent en nombre, devront être encapsulés dans un autre concept.

Pour le moment, ça passe comme ça.

Avant de passer un quelconque tray à la fonction, je remplace juste les anciens appels à set_board puis je lance le programme, pour vérifier qu'il fonctionne toujours.

L'appel ressemble au final à ceci :

    runner.get_player(0).set_environement(
        board=session.get_board(),
        tray=session.get_tray()
    )
    runner.get_player(1).set_environement(
        board=session.get_board(),
        tray=session.get_tray()
    )

Et je pose la question : est-ce que cela ne vaudrait pas le coup de passer directement session à l'environnement ? Tel quel, non. Dans un langage typé statiquement, je pourrais utiliser une interface qui limiterait la classe du joueur aux appels pour récupérer les morceaux de session nécessaire, sans avoir accès à l'objet complet. “Oui, il est toujours possible de passer outre le système et d'aller chercher l'objet en entier... Mais je parle ici d'éviter l'erreur d'une utilisation bien intentionnée.”

En Python avec son typage dynamique, faire la même chose demande un peu plus d'effort car passer l'objet donnera au joueur l'accès complet à ses informations.

Donc pour le moment, je décompose les éléments de la session dont j'ai besoin en les passant en tant qu'arguments.

Je peux donc maintenant ajouter à la liste des mouvements permis les possibilités de parachutages.

Voici un premier jet, rapide, exploratoire :

    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) and
                       self.board.get_piece_at(destination).get_controller() != self._role):
                        allowed_movements.append((piece, source, destination))

        my_pieces_on_tray = [piece for piece in self.tray.pieces if piece.get_controller() == self._role]
        for piece in my_pieces_on_tray:
            for y in range(BOARD_MAXIMAL_Y + 1):
                for x in range(BOARD_MAXIMAL_X + 1):
                    destination = (x, y)
                    if self.board.get_piece_at(destination).get_controller() == 0:
                        allowed_movements.append((piece, None, destination))

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

C'est moche, très moche. Du code redondant, deux parties distinctes dans la même fonction (chercher les mouvements de pièces sur le tablier puis chercher les parachutages).

Sans tests, je ne suis pas non plus entièrement persuadé que cela fonctionne, ce qui est assez gênant.

Mais puisque le programme à l'air de fonctionner « à vue de nez », la phase exploratoire peut-être considérée comme réussie.

Un petit tour de statistiques montre que la durée des parties semble s'allonger légèrement. Pas très surprenant vu qu'elles deviennent un peu complexes.

Statistiques joueurs lucides et parachutage

Mais avant d'aller plus loin, un peu de nettoyage s'impose.

Du balai !

Il est important dans un développement de logiciel d'alterner les phases d'exploration, lorsqu'une partie du programme est du domaine de la preuve de concept, du prototype. Il faut vérifier rapidement que ce que l'on veut faire a du sens.

Cependant, ce genre de développement, afin d'être rapide et pour ne pas avoir perdu de temps en cas d'échec du prototype, amène généralement à une qualité de code médiocre, qu'il faut rapidement corriger pour éviter de voir s'empiler des morceaux pas très reluisants.

Si le code est laissé à la dérive sans s'occuper de le nettoyer, le coût de maintenance et de modification va augmenter jusqu'à gêner tout développement futur.

Autrement dit : range ta chambre !

Pour faire cela, je commence par extraire les deux parties de recherche : le mouvement/capture et le parachutage.

    def ask_move(self, effector):
        allowed_movements = []
        allowed_movements.extend(self._get_all_valid_movements())
        allowed_movements.extend(self._get_all_valid_drops())

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

Et là, je vais pouvoir modifier le fonctionnement de _get_all_valid_movements et de _get_all_valid_drops. J'espère, avec toute cette série d'articles, vous avoir convaincu que modifier ces fonctions sans filet était une très mauvaise idée.

Cependant, j'en ai fait deux fonctions privées, que je ne peux donc pas tester.

Je commence donc par extraire des fonctions indépendantes qui sont appelées par les méthodes de la classe LucidPlayer.

Ce qui donne :

    def _get_all_valid_movements(self):
        return get_valid_movements(self.board, self._role)

    def _get_all_valid_drops(self):
        return get_valid_drops(self.board, self.tray, self._role)

Je vais commencer par simplifier get_valid_drops, qui est plus simple et qui ressemble après toutes ses transformations à cela :

    def get_valid_drops(board, tray, role):
        allowed_drops = []

        my_pieces_on_tray = [piece for piece in tray.pieces if piece.get_controller() == role]
        for piece in my_pieces_on_tray:
            for y in range(BOARD_MAXIMAL_Y + 1):
                for x in range(BOARD_MAXIMAL_X + 1):
                    destination = (x, y)
                    if board.get_piece_at(destination).get_controller() == 0:
                        allowed_drops.append((piece, None, destination))

        return allowed_drops

Étant donné que j'ai écrit du code non testé, j'ai deux choix. Soit j'écris des tests autour de cette fonction pour la solidifier avant de la modifier, soit j'écris un test d'une fonction similaire, en ignorant celle-ci.

En pratique et si la fonction était assez grosse, complexe et que je ne l'avais pas écrite moi-même, ou il y a longtemps, j'opterais probablement pour la première solution, qui me permettrait d'explorer la manière dont la fonction réagit.

Dans une démarche TDD pure, je n'ai pas d'autre choix que de partir sur la deuxième option.

C'est aussi un choix plus sûr : qui me dit que ma méthode exploratoire était la bonne ? Est-ce que la fonction répond exactement à ce que je voulais modéliser ? Oui probablement, elle est simple, mais une bonne manière d'en être certain, c'est de repasser en TDD.

Je créé donc un fichier ia_helpers_tests.py dans lequel j'écris mon premier test.

    import unittest
    from tray import Tray
    from board import Board
    from player import P1


    def get_valid_drops(board, tray, role):
        return []


    class GetValidDropsTestCase(unittest.TestCase):
        def test_returns_empty_list_if_tray_is_empty(self):
            tray = Tray()
            board = Board()
            self.assertEqual([], get_valid_drops(board, tray, P1))

Deuxième test, si une pièce se trouve sur la réserve et que le tablier est vide, je dois avoir autant de réponses que de cases :

    def test_returns_every_position_if_board_empty(self):
        tray = Tray()
        tray.push_piece(Piece(PAWN, P1))

        board = Board()

        movements = get_valid_drops(board, tray, P1)

        size_x, size_y = board.get_size()
        board_size = size_x * size_y

        self.assertEqual(board_size, len(movements))

Que je résous comme ceci :

    def get_valid_drops(board, tray, role):
        if len(tray.pieces) == 0:
            return []

        movements = [tray.pieces[0] for y in range(BOARD_MAXIMAL_Y + 1) for x in range(BOARD_MAXIMAL_X + 1)]
        return movements

Halte aux globales

Ça fait un bout de temps maintenant que je traîne les constantes globales BOARD_MAXIMAL_X et associées. Or la géométrie du tablier est une propriété du tablier. En utilisant les constantes qui ont permis la création du tablier, j'utilise une information identique qui vient de deux endroits différents.

Cela m'oblige à importer systématiquement les variables, et cela n'est pas très flexible. Si je veux changer la façon d'initialiser le tablier, je vais potentiellement casser plein de code si je veux me passer des constantes.

Je fais donc un petit tour par board_tests.py pour y ajouter :

    def test_gives_its_size(self):
        self.assertEqual((BOARD_MAXIMAL_X + 1, BOARD_MAXIMAL_Y + 1), self.board.get_size())

Que je résous de cette manière :

    def get_size(self):
        return (BOARD_MAXIMAL_X + 1, BOARD_MAXIMAL_Y + 1)

On peut penser que cela ne change pas grand chose. En effet, get_size() se sert des mêmes informations que j'utilisais déjà. Le test est même trivial par rapport à l'implémentation : je teste exactement ce que je construits.

Oui, mais je peux maintenant me passer des constantes que je dois systématiquement augmenter de 1. J'ai moins de dépendances entre mes fichiers, et le code est plus flexible.

    def get_valid_drops(board, tray, role):
        if len(tray.pieces) == 0:
            return []

        size_x, size_y = board.get_size()
        movements = [tray.pieces[0] for y in range(size_y) for x in range(size_x)]
        return movements

    class GetValidDropsTestCase(unittest.TestCase):
        def test_returns_every_position_if_board_empty(self):
            tray = Tray()
            tray.push_piece(Piece(PAWN, P1))

            board = Board()

            movements = get_valid_drops(board, tray, P1)

            size_x, size_y = board.get_size()
            board_size = size_x * size_y

            self.assertEqual(board_size, len(movements))

Le troisième test dit que si le tablier est plein, la liste des parachutages possible doit être vide :

    def fill_board(board):
        size_x, size_y = board.get_size()
        for y in range(size_y):
            for x in range(size_x):
                board.place_piece(Piece(PAWN, P1), (x, y))

    class GetValidDropsTestCase(unittest.TestCase):
        def test_returns_empty_list_if_board_is_full(self):
            tray = Tray()
            tray.push_piece(Piece(PAWN, P1))

            board = Board()
            fill_board(board)

            self.assertEqual([], get_valid_drops(board, tray, P1))

Que je résous en ajoutant une vérification de case vide :

    def get_valid_drops(board, tray, role):
        if len(tray.pieces) == 0:
            return []

        size_x, size_y = board.get_size()
        movements = [tray.pieces[0]
                     for y in range(size_y)
                     for x in range(size_x)
                     if board.get_piece_at((x, y)).get_controller() == 0]
        return movements

J'ajoute ensuite un test sur la vérification du joueur. Un joueur n'utilise de la réserve que les pièces qui lui appartiennent. En effet, dans mon implémentation, l'objet représentant la réserve est un objet commun aux deux joueurs, plutôt qu'un objet par joueur.

    def test_returns_empty_list_if_tray_has_no_piece_for_player(self):
        tray = Tray()
        tray.push_piece(Piece(PAWN, P2))

        board = Board()

        self.assertEqual([], get_valid_drops(board, tray, P1))

Résolution par un simple test :

    def get_valid_drops(board, tray, role):
        if len(tray.pieces) == 0 or tray.pieces[0].get_controller() != role:
            return []

        size_x, size_y = board.get_size()
        movements = [tray.pieces[0]
                     for y in range(size_y)
                     for x in range(size_x)
                     if board.get_piece_at((x, y)).get_controller() == 0]
        return movements

Tout ceci fonctionne avec la première pièce de la réserve, reste donc à vérifier un cas un peu plus complexe. Dans ce cas, il y a trois pièces dans la réserve dont deux valides pour le joueur concerné, et deux emplacements libres sur le tablier. Je dois donc obtenir 4 possibilités.

    def test_returns_combination_of_empty_places_for_valid_tray_pieces(self):
        tray = Tray()
        tray.push_piece(Piece(PAWN, P2))
        tray.push_piece(Piece(PAWN, P1))
        tray.push_piece(Piece(GIRAFFE, P1))

        board = Board()
        fill_board(board)
        board.remove_piece((0, 0))
        board.remove_piece((1, 0))

        movements = get_valid_drops(board, tray, P1)

        self.assertEqual(4, len(movements))
        self.assertIn((Piece(PAWN, P1), None, (0, 0)), movements)
        self.assertIn((Piece(GIRAFFE, P1), None, (0, 0)), movements)
        self.assertIn((Piece(PAWN, P1), None, (1, 0)), movements)
        self.assertIn((Piece(GIRAFFE, P1), None, (1, 0)), movements)

Une réponse naïve peut être celle-ci :

    def get_valid_drops(board, tray, role):
        if len(tray.pieces) == 0:
            return []

        size_x, size_y = board.get_size()
        movements = []
        for piece in tray.pieces:
            if piece.get_controller() == role:
                movements.extend([(piece, None, (x, y))
                                  for y in range(size_y)
                                  for x in range(size_x)
                                  if board.get_piece_at((x, y)).get_controller() == 0])
        return movements

Les tests passent.

Mais il y a plus simple. Et le nom du test l'indique. Le résultat est l'ensemble des combinaisons des pièces disponibles et positions.

La première chose à faire lorsque l'on est face à un algorithme générique, c'est de vérifier qu'il n'est pas disponible dans les outils de base du langage. Et le produit cartésien, puisque tel est le nom de cette opération, existe dans le module itertools de la bibliothèque standard de Python.

Ce qui permet d'écrire :

    def get_valid_drops(board, tray, role):
        if len(tray.pieces) == 0:
            return []

        player_pieces = [piece for piece in tray.pieces if piece.get_controller() == role]

        size_x, size_y = board.get_size()
        free_spaces = [(x, y)
                       for x in range(size_x)
                       for y in range(size_y)
                       if board.get_piece_at((x, y)).get_controller() == 0]

        return list(itertools.product(player_pieces, [None], free_spaces))

À vrai dire, le test len(tray.piece) == 0 est inutile. Il peut, ou peut ne pas, être un accélérateur de la fonction si l'on considère que la plupart du temps, la réserve est vide. Seul une étude des performances en cas réel pourra nous le dire. Je mets cette considération en commentaires.

Cette version fonctionnelle me semble beaucoup mieux écrite que l'approche naïve. De plus, si des problèmes de performances sont causés par ces calculs, il est très possible de permettre à board de garder la liste des espaces vides, si celle-ci est accédée souvent. De même tray pourrait garder un état séparé pour chaque joueur.

Le fait d'écrire la fonction comme composition de deux listes rend les modifications futures éventuelles plus simples.

Quand à la différence de performance entre l'approche naïve et l'approche fonctionnelle, je ne me prononcerai pas avant d'avoir étudier les performances.

Ce que je fais... Pour trouver que la version fonctionnelle est légèrement plus rapide que la version naïve. Tout va bien.

Puisque j'ai maintenant une fonction get_valid_drops testée, je peux la déplacer dans un fichier ia_helpers.py et l'appeler dans LucidPlayer.

Aux mouvements maintenant

J'ai testé la fonction qui me permet de trouver les parachutages valides, je passe donc maintenant à la fonction qui me permet de trouver l'ensemble des mouvements valides.

Et tout comme pour le passage précédent, je passe en TDD en faisant table rase de ce que j'ai déjà écrit, mis à part le format de la liste retournée.

    def get_valid_movements(board, role):
        return []


    class GetValidMovementsTestCase(unittest.TestCase):
        def test_returns_empty_if_no_piece_on_board(self):
            board = Board()

            self.assertEqual([], get_valid_movements(board, P1))

Le problème avec les mouvements valides est que les tests peuvent être longs si je veux être exhaustif. Je devrais essayer toutes les pièces dans différentes situations.

Plutôt que d'être exhaustifs, je vais choisir quelques cas intéressants. Je commence par des situations où la réponse est une avancée de 1 d'une pièce. Soit car c'est un pion, soit parce que le mouvement est limité par d'autres pièces (dans le cas de la giraffe).

    class GetValidMovementsTestCase(unittest.TestCase):
        def setUp(self):
            self.board = Board()

        def test_returns_empty_if_no_piece_on_board(self):
            self.assertEqual([], get_valid_movements(self.board, P1))

        def test_returns_one_movement_for_a_pawn(self):
            self.board.place_piece(Piece(PAWN, P1), (0, 1))
            self.board.place_piece(Piece(PAWN, P2), (1, 2))

            self.assertEqual([(Piece(PAWN, P1), (0, 1), (0, 2))], get_valid_movements(self.board, P1))
            self.assertEqual([(Piece(PAWN, P2), (1, 2), (1, 1))], get_valid_movements(self.board, P2))

        def test_returns_one_movement_for_giraffe_on_first_line_with_pieces_on_sides(self):
            self.board.place_piece(Piece(PAWN, P1), (0, 0))
            self.board.place_piece(Piece(GIRAFFE, P1), (1, 0))
            self.board.place_piece(Piece(PAWN, P1), (2, 0))

            valid_movements = get_valid_movements(self.board, P1)
            self.assertIn((Piece(GIRAFFE, P1), (1, 0), (1, 1)), valid_movements)

La résolution à ce moment est comme ceci :

    def get_valid_movements(board, role):
        my_pieces = [(piece, location) for (piece, location)
                     in board.get_pieces_locations()
                     if piece.get_controller() == role]

        if len(my_pieces) == 0:
            return []

        valid_movements = []
        if role == P1:
            for piece in my_pieces:
                dest_x, dest_y = piece[1]
                dest_y += forward_factor[role]
                destination = (dest_x, dest_y)
                movement = (piece[0], piece[1], destination)

                valid_movements.append(movement)

            return valid_movements
        return [(Piece(PAWN, P2), (1, 2), (1, 1))]

Beaucoup de simplification, et un cas complètement hard codé pour le joueur 2.

Mais je peux simplifier facilement sans casser les tests. Les complexités venant de la construction petit à petit de la fonction à travers les tests.

    def get_valid_movements(board, role):
        my_pieces = [(piece, location) for (piece, location)
                     in board.get_pieces_locations()
                     if piece.get_controller() == role]

        valid_movements = []
        for piece in my_pieces:
            dest_x, dest_y = piece[1]
            dest_y += forward_factor[role]
            destination = (dest_x, dest_y)
            movement = (piece[0], piece[1], destination)

            valid_movements.append(movement)

        return valid_movements

Comme d'habitude, j'ajoute un peu de diversité avec le test suivant, pour lequel je change de joueur et je libère un peu d'espace autour de la pièce.

    def test_returns_three_movement_for_giraffe_on_last_line(self):
        self.board.place_piece(Piece(GIRAFFE, P2), (1, 3))

        valid_movements = get_valid_movements(self.board, P2)
        self.assertEqual(3, len(valid_movements))
        self.assertIn((Piece(GIRAFFE, P2), (1, 3), (1, 2)), valid_movements)
        self.assertIn((Piece(GIRAFFE, P2), (1, 3), (2, 3)), valid_movements)
        self.assertIn((Piece(GIRAFFE, P2), (1, 3), (0, 3)), valid_movements)

Pour la résolution, je prends les cases voisines de la case de la pièce et j'appelle une validation de mouvement, un peu comme je le faisais dans run_lucid_players.py, dans lequel je vérifierais tous les emplacements possibles.

    def get_valid_movements(board, role):
        my_pieces = [(piece, location) for (piece, location)
                     in board.get_pieces_locations()
                     if piece.get_controller() == role]

        neighbours = list(itertools.product([-1, 0, 1], [-1, 0, 1]))
        validator = MovementValidator()

        valid_movements = []
        for piece in my_pieces:
            for neighbour in neighbours:
                if neighbour != (0, 0):
                    dest_x, dest_y = piece[1]
                    destination = dest_x + neighbour[0], dest_y + neighbour[1]

                    if (validator.is_movement_valid(piece[0], piece[1], destination) and
                       board.get_piece_at(destination).get_controller() == 0):
                        movement = (piece[0], piece[1], destination)
                        valid_movements.append(movement)

        return valid_movements

Mais le test ne passe pas. J'obtiens 4 mouvements possibles plutôt que 3. En regardant pourquoi, je vois que le mouvement vers (1, 4) est considéré comme valide. Ce qui est faux. Cette emplacement n'existe pas.

Je n'avais pas ce problème avec la version non testée car je parcourais l'intégralité des emplacements valides. Par construction, je ne pouvais pas sortir du tablier. Mais ici, je le peux. Je dois donc vérifier que les coordonnées de destination ont un sens.

J'ai une fonction _is_location_valid dans Board, qui est censée être privée (mollement privée, car j'utilise un seul tiret bas en préfixage ; Python considère les noms avec deux tirets en préfixe privés).

Cela serait dommage de ne pas l'utiliser, et il n'y a pas vraiment de raison qu'elle ne soit pas publique.

D'où :

    def get_valid_movements(board, role):
        my_pieces = [(piece, location) for (piece, location)
                     in board.get_pieces_locations()
                     if piece.get_controller() == role]

        neighbours = list(itertools.product([-1, 0, 1], [-1, 0, 1]))
        validator = MovementValidator()

        valid_movements = []
        for piece in my_pieces:
            for neighbour in neighbours:
                if neighbour != (0, 0):
                    dest_x, dest_y = piece[1]
                    destination = dest_x + neighbour[0], dest_y + neighbour[1]

                    if not board.is_location_valid(destination):
                        continue

                    if (validator.is_movement_valid(piece[0], piece[1], destination) and
                       board.get_piece_at(destination).get_controller() == 0):
                        movement = (piece[0], piece[1], destination)
                        valid_movements.append(movement)

        return valid_movements

Cette fonction commence à être un peu complexe à mon goût. Je dois simplifier ça. Je sors neighbours et validator de la fonction, puisque ce sont des constantes. Inutile de les recalculer à chaque fois.

Je transforme le code de manière un peu plus fonctionnelle, pour que ce soit plus simple à lire.

    displacements = list(itertools.product([-1, 0, 1], [-1, 0, 1]))
    displacements.remove((0, 0))

    validator = MovementValidator()

    def get_valid_movements(board, role):
        my_pieces = [(piece, location) for (piece, location)
                     in board.get_pieces_locations()
                     if piece.get_controller() == role]

        valid_movements = []
        for piece in my_pieces:
            dest_x, dest_y = piece[1]

            neighbours = [(dest_x + dx, dest_y + dy)
                          for (dx, dy) in displacements]

            valid_places = [n for n in neighbours
                            if board.is_location_valid(n) and
                            board.get_piece_at(n).get_controller() == 0 and
                            validator.is_movement_valid(piece[0], piece[1], n)]

            valid_movements.extend([(piece[0], piece[1], destination)
                                    for destination
                                    in valid_places])

        return valid_movements

Cette version est cependant légèrement (très très légèrement) plus lente que la première version ; pourvu que dans la première version, les deux constantes soient retirées de la fonction.

Étonnamment, si je remplace le calcul par valid_places par ceci :

    valid_places = [
        n for n in neighbours
        if board.is_location_valid(n) and
        validator.is_movement_valid(piece[0], piece[1], n) and
        board.get_piece_at(n).get_controller() == 0
    ]

C'est-à-dire en inversant les deux derniers tests, alors la nouvelle fonction devient légèrement (très très légèrement) plus rapide.

Je ne l'aurais pas juré. Il faut toujours faire confiance à son profiler.

Et les prises ?

Pour le moment, je n'ai testé que des mouvements sans prise. J'ajoute donc un test avec prise.

    def test_returns_one_capture_movement_for_pawns_face_to_face(self):
        self.board.place_piece(Piece(PAWN, P1), (1, 1))
        self.board.place_piece(Piece(PAWN, P2), (1, 2))

        self.assertEqual([(Piece(PAWN, P1), (1, 1), (1, 2))], get_valid_movements(self.board, P1))

Qui se résous en changeant la vérification de l'occupation de l'emplacement :

    valid_places = [
        n for n in neighbours
        if board.is_location_valid(n) and
        validator.is_movement_valid(piece[0], piece[1], n) and
        board.get_piece_at(n).get_controller() != role
    ]

Je vérifie au passage que ma nouvelle fonction est plus rapide que celle que j'avais programmée initialement dans run_lucid_players.py, ce qui est le cas.

J'opère donc l'extraction de cette nouvelle fonction dans ia_helpers.py puis son remplacement dans run_lucid_players.py.

Je lance l'intégralité des tests, je lance une simulation de Lucid Players. Tout passe.

Il reste juste quelque chose que je n'aime pas trop, ce sont les définitions au niveau global du script dans ia_players.py qui au final contient ceci :

    import itertools
    from movementvalidator import MovementValidator


    def get_valid_drops(board, tray, role):
        # The following test might be an optimization if @@tray@@ is often empty.
        # This should be profiled to be sure.
        if len(tray.pieces) == 0:
            return []

        player_pieces = [piece for piece in tray.pieces if piece.get_controller() == role]

        size_x, size_y = board.get_size()
        free_spaces = [(x, y)
                       for x in range(size_x)
                       for y in range(size_y)
                       if board.get_piece_at((x, y)).get_controller() == 0]

        return list(itertools.product(player_pieces, [None], free_spaces))


    displacements = list(itertools.product([-1, 0, 1], [-1, 0, 1]))
    displacements.remove((0, 0))

    validator = MovementValidator()


    def get_valid_movements(board, role):
        my_pieces = [(piece, location) for (piece, location)
                     in board.get_pieces_locations()
                     if piece.get_controller() == role]

        valid_movements = []
        for piece in my_pieces:
            dest_x, dest_y = piece[1]

            neighbours = [(dest_x + dx, dest_y + dy)
                          for (dx, dy) in displacements]

            valid_places = [n for n in neighbours
                            if board.is_location_valid(n) and
                            validator.is_movement_valid(piece[0], piece[1], n) and
                            board.get_piece_at(n).get_controller() != role]

            valid_movements.extend([(piece[0], piece[1], destination)
                                    for destination
                                    in valid_places])

        return valid_movements