Programme principal

Ajul – étape 11

1. Introduction

Le but de cette dernière étape du projet est de le terminer en écrivant la classe principale.

2. Concepts

2.1. Interface finale

La vidéo ci-dessous montre le comportement de l'application durant une partie entre un joueur humain (Humain) et un joueur géré par l'intelligence artificielle (Robot).

Comme on peut le voir, deux courtes pauses sont introduites en fin de manche :

  • une première d'une durée de 1 seconde, entre le dernier tour d'une manche et le vidage des destinations,
  • une seconde d'une durée de 0.7 secondes, entre le vidage des destinations et le remplissage des fabriques de la manche suivante.

Ces pauses ont pour but de faciliter la compréhension de l'évolution de la partie.

2.2. Lancement et configuration du programme

La configuration d'une partie d'Ajul se fait en passant des arguments au programme lors de son lancement. Depuis IntelliJ, cela peut se faire de la manière décrite dans notre guide sur le sujet. Ces arguments peuvent être nommés ou non.

Un argument nommé commence par deux tirets (--), qui sont suivis du nom de l'argument, du signe égal (=), puis de sa valeur. Le seul argument nommé reconnu par Ajul se nomme seed et sa valeur est une chaîne de caractères quelconque, qui est utilisée comme graine pour le générateur aléatoire.

Tous les arguments qui ne sont pas des arguments nommés sont utilisés pour décrire les joueurs. À chacun d'entre eux correspond un joueur, dans l'ordre, dont le nom et le type dépendent de l'argument :

  • si l'argument commence par un caractère souligné (_), alors le joueur est géré par l'intelligence artificielle — c.-à-d. l'algorithme MCTS — et son nom est le reste de l'argument, sans le caractère souligné,
  • sinon, le joueur est un joueur humain et son nom est l'argument.

Par exemple, en passant les arguments suivants au programme :

Aline --seed=ajul26 _Robot Bertrand

on configure une partie à trois joueurs, qui sont :

  1. une joueuse humaine nommée Aline et dont l'identité est P1,
  2. un joueur simulé (MCTS) nommé Robot et dont l'identité est P2,
  3. un joueur humain nommé Bertrand et dont l'identité est P3.

De plus, la graine du générateur aléatoire utilisé pour remplir les fabriques est donnée par la chaîne ajul26. Chaque fois qu'une partie est configurée avec la même graine et le même nombre de joueurs, les fabriques sont remplies exactement de la même manière. Si aucune graine n'est passée explicitement au moyen de l'argument nommé seed, alors une graine aléatoire est choisie.

2.3. Fils d'exécution

Un fil d'exécution (thread of execution, ou simplement thread) est une exécution indépendante d'un programme.

Pour comprendre intuitivement cette idée assez abstraite, on peut imaginer une personne désirant cuisiner un repas constitué d'une entrée et d'un plat principal. Si cette personne est seule, elle n'a pas d'autre choix que de réaliser chacun des deux plats — l'entrée et le plat principal — de manière séquentielle, c.-à-d. l'un après l'autre. Par contre, si une autre personne propose de l'aider, alors elles peuvent se partager les tâches : l'une peut réaliser l'entrée pendant que l'autre réalise le plat principal. Ces deux personnes travaillent alors de manière indépendante, en parallèle, et dès que la plus lente des deux a terminé le plat dont elle a la charge, le repas est prêt.

Un programme est similaire à une recette. Jusqu'à présent, les programmes Java que vous avez écrits ont toujours été exécutés de manière séquentielle, comme si une seule « personne » s'en chargeait. Cette « personne » correspond justement à un fil d'exécution. Et exactement comme dans l'exemple ci-dessus, il est possible de faire en sorte qu'un seul et même programme soit exécuté par plusieurs fils d'exécution travaillant en parallèle, généralement en se chargeant chacun d'une partie différente du programme.

Lorsqu'on utilise ainsi plusieurs fils d'exécution, on fait ce que l'on nomme de la programmation concurrente (concurrent programming). Il s'agit d'un sujet bien trop vaste pour être couvert dans le cadre de ce cours, et vous l'étudierez en détail dans la suite de vos études. Seules les quelques bases nécessaires à la réalisation du projet sont donc expliquées ci-dessous.

2.3.1. Fils d'exécution en Java

Tous les programmes Java que vous avez écrits jusqu'ici n'étaient constitués que d'un seul fil d'exécution, généralement nommé le fil principal (main thread). Il est toutefois possible de créer plusieurs fils indépendants au sein d'un seul et même programme, comme nous allons l'illustrer.

Pour ce faire, nous utiliserons un programme d'exemple, dont la première version est tout à fait classique et ne comporte qu'un seul fil d'exécution :

public final class ThreadsExample {
  static void main() {
    print26('a');
    print26('A');
    System.out.print('_');
  }

  private static void print26(char c) {
    for (int i = 0; i < 26; i++)
      System.out.print((char)(c + i));
  }
}

Lorsqu'on exécute ce programme, il affiche la ligne ci-dessous, le premier appel à print26 affichant l'alphabet en minuscule, le second affichant l'alphabet en majuscule, et le dernier appel à print affichant un caractère souligné (_) :

abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_

On peut maintenant modifier ce programme pour faire en sorte que chacun des appels à print26 soit fait par un fil d'exécution séparé, représenté par une instance de Thread. La lambda passée à la méthode startVirtualThread contient le code que le fil doit exécuter.

public final class ThreadsExample {
  static void main() throws Exception {
    Thread t1 = Thread.startVirtualThread(() -> print26('a'));
    Thread t2 = Thread.startVirtualThread(() -> print26('A'));
    System.out.print('_');
    t1.join(); t2.join(); // attend que t1 et t2 terminent
  }
  // … print26 identique à avant
}

Lorsqu'on exécute cette version modifiée du programme, trois fils d'exécution fonctionnent en parallèle : le fil principal, qui crée et démarre chacun des deux fils auxiliaires puis affiche un caractère souligné ; le premier fil auxiliaire, t1, qui affiche l'alphabet en minuscule ; et le second fil auxiliaire, t2, qui affiche l'alphabet en majuscule.

Étant donné que ces trois fils s'exécutent en parallèle, il est possible que l'alphabet minuscule, l'alphabet majuscule et le caractère souligné soient entrelacés de manière arbitraire, plutôt que d'être affichés dans cet ordre comme initialement. On peut observer cela en exécutant plusieurs fois le programme ci-dessus. Ainsi les lignes suivantes ont été obtenues en le lançant dix fois de suite :

ab_ABCDEFGHIJKLMNOPQRSTUVWXYZcdefghijklmnopqrstuvwxyz
abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuv_ABCDEFGHIJKLMNOPQRSTUVWXYZwxyz
abc_ABCDEFGHIJKLMNOPQRSTUVWXYZdefghijklmnopqrstuvwxyz
abcdefgh_ABCDEFGHIJKijkLMNOPQRSTUVWXYZlmnopqrstuvwxyz
abcd_ABCDEFGHIJKLMNOPQRSTUVWXYZefghijklmnopqrstuvwxyz
ab_ABCDEFGcdefghijklmnopqrstuvwHIJKLMNOPQRxyzSTUVWXYZ
abc_ABCDEFGHIJKLMNOPQRSTUVWXYZdefghijklmnopqrstuvwxyz
abcde_ABCDEFGHIJKLMNOPQRSTUVWXYZfghijklmnopqrstuvwxyz
ab_ABCDEFGHIJKLMNOPQRSTUVWXYZcdefghijklmnopqrstuvwxyz

Comme on le voit, différentes exécutions peuvent donner des résultats différents, car chaque fil procède de manière indépendante, à son propre rythme. Il est toutefois important de comprendre que chacun des fils s'exécute de manière séquentielle, comme d'habitude. Donc les lettres des alphabets minuscule et majuscule apparaissent toujours dans le bon ordre. Ce qui varie est l'entrelacement des deux alphabets et du caractère souligné.

2.3.2. Fils d'exécution et JavaFX

Au démarrage d'une application JavaFX, un fil d'exécution nommé le fil (d'application) JavaFX (JavaFX [Application] Thread) est automatiquement créé par la méthode launch. C'est ce fil qui se charge ensuite d'appeler la méthode start.

Le fil JavaFX a pour but de gérer l'interface graphique, et c'est donc lui qui exécute, par exemple, tous les gestionnaires d'événement attachés au graphe de scène. Il faut de plus impérativement que ce soit lui qui exécute la totalité du code modifiant l'interface graphique. Il est très important de se souvenir de cela, car le non-respect de cette règle produit des plantages difficiles à diagnostiquer. De plus, il faut aussi être conscient du fait que tout blocage du fil JavaFX provoque un blocage de l'interface graphique. Il ne faut donc jamais bloquer ce fil pour une durée indéterminée.

2.3.3. Fils d'exécution Ajul

Pour Ajul, nous utiliserons deux fils d'exécution :

  • le fil JavaFX, créé automatiquement comme nous l'avons dit,
  • le fil chargé de gérer la partie, créé par nos soins.

L'existence de ces deux fils d'exécution présente un risque qui n'existe pas dans les programmes n'en utilisant qu'un, lié aux valeurs non immuables partagées entre eux. En effet, lorsqu'une valeur non immuable est partagée entre deux fils d'exécution, et qu'elle n'a pas été prévue pour cela — en anglais, on dit qu'elle n'est pas thread-safe — elle peut être corrompue en cas de modification simultanée.

Pour comprendre cela, imaginons une liste de type ArrayList — classe qui n'est pas thread-safe — partagée entre deux fils d'exécution. Supposons que la liste soit vide, et que chaque fil désire y ajouter un élément. Le premier fil lit la taille actuelle (0) et place son élément à la position 0 ; à cet instant précis, le second fil fait de même, lit la taille actuelle (toujours 0) et place son élément à la position 0, écrasant celui du premier fil, avant d'incrémenter la taille, qui vaut alors 1 ; finalement, le premier fil termine son ajout en incrémentant la taille, qui vaut alors 2. La liste est alors corrompue, l'un des éléments ayant écrasé l'autre.

Pour éviter ce genre de problèmes — très difficiles à reproduire et à diagnostiquer, car dépendant de l'entrelacement exact des exécutions des deux fils, qui varie d'une exécution à l'autre — il ne faut partager entre plusieurs fils d'exécution que des valeurs qui sont soit immuables, soit thread-safe. C'est ce que nous ferons dans ce projet, en nous efforçant de plus de limiter le nombre de valeurs partagées entre les deux fils.

2.3.4. Synchronisation des fils Ajul

L'une des valeurs — non immuable, mais thread-safe — que les deux fils d'exécution d'Ajul partageront aura pour but de les synchroniser. Cela est nécessaire car, lorsque c'est à un joueur humain de jouer, le fil chargé de gérer la partie doit attendre que le joueur en question ait choisi le coup qu'il désire jouer en interagissant avec l'interface graphique.

Nous utiliserons pour cela une file bloquante bornée (bounded blocking queue), similaire à une file normale si ce n'est que :

  • lorsqu'un fil d'exécution essaie de retirer un élément d'une telle file alors qu'elle est vide, il est bloqué jusqu'à ce qu'un élément y soit ajouté par un autre fil,
  • lorsqu'un fil d'exécution essaie d'ajouter un élément à une telle file alors qu'elle est pleine, il est bloqué jusqu'à ce qu'un élément en soit retiré par un autre fil.

Dans notre cas, la file contiendra le coup joué par le joueur humain. Le fil gérant la partie bloquera en attendant que le fil JavaFX y place le coup joué par le joueur humain.

3. Mise en œuvre Java

3.1. Correction d'erreurs

Avant d'entamer cette étape, il convient de corriger certaines erreurs qui ont été faites durant les étapes précédentes.

3.1.1. Générateur aléatoire de simulation pour MCTS

La §3.1.1 de l'étape 8 conseillait d'utiliser le nombre total de points du nœud à évaluer comme graine pour le générateur aléatoire utilisé pour simuler la fin de la partie depuis lui. Or ce nombre de points vaut toujours 0, et même s'il n'est pas faux d'utiliser 0 comme graine pour ce générateur, utiliser la méthode totalPoints de MctsNode pour cela est inutilement compliqué, d'autant que c'est la seule utilisation de cette méthode dans tout le projet.

Dès lors, la méthode totalPoints de MctsNode peut être supprimée, et un unique générateur aléatoire peut être créé au début de la méthode nextMove de MctsPlayer pour simuler toutes les fins de partie. La graine de ce générateur peut soit être constante, soit dépendre de l'état de la partie — p. ex. la valeur retournée par sa méthode pkTileBag().

(Le fait que la graine ne soit pas totalement aléatoire facilite les tests et le débogage, car cela garantit que notre « intelligence artificielle » joue toujours le même coup pour un état de partie donné.)

3.1.2. Emplacements associés aux ancres

La §3.4 de l'étape 8 demandait que la méthode create de Tiles associe à chaque ancre l'emplacement qui lui correspond, au moyen de setLocation. Or cela est inutile, car le reste du projet n'a jamais besoin d'obtenir l'emplacement correspondant à une ancre.

Dès lors, aucun emplacement ne doit être associé aux ancres par la méthode create de Tiles.

3.2. Classe Main

La classe Main, du sous-paquetage gui, publique et finale, est la classe principale du projet. Comme toute classe principale d'une application utilisant JavaFX, elle hérite de Application et fournit une mise en œuvre de sa méthode start. Elle possède de plus une méthode main qui ne fait rien d'autre qu'appeler launch en lui passant les arguments reçus.

La redéfinition de la méthode start doit :

  1. analyser les arguments passés au programme afin de configurer la partie,
  2. construire l'interface graphique principale en combinant les éléments créés par les classes BoardUI et TileOverlayUI, au moyen du graphe de scène décrit plus bas,
  3. démarrer le fil d'exécution gérant la partie.

3.2.1. Graphe de scène

Le graphe de scène de l'application finale est très simple et consiste en un unique panneau StackPane, qui empile le plateau de jeu (arrière-plan) et le panneau contenant les tuiles (avant-plan), et auquel la feuille de style ajul.css est attachée.

Ce graphe de scène doit être placé dans la scène associée à la fenêtre principale de l'application, qui est passée à la méthode start. Cette fenêtre doit de plus être configurée pour que :

3.2.2. Conseils de programmation

  1. Analyse des arguments

    L'analyse des arguments est facilitée par JavaFX, qui sépare les arguments nommés des autres et y donne accès au moyen de la méthode getParameters.

    Si le nombre de joueurs est invalide, votre programme peut simplement planter avec une exception, p. ex. de type Error.

  2. Graine du générateur aléatoire

    Le générateur aléatoire utilisé pour remplir les fabriques doit être créé avec la variante de la méthode create de RandomGeneratorFactory qui prend un tableau d'octets en argument.

    Si l'argument nommé seed est passé au programme, le tableau en question doit être celui contenant les octets représentant la chaîne associée à cet argument lorsqu'elle est encodée en UTF-8. Par exemple, si cette chaîne est ajul26, alors le tableau doit contenir les octets {97, 106, 117, 108, 50, 54}. La méthode getBytes de String permet d'obtenir aisément ce tableau.

    Si l'argument nommé seed n'est pas passé au programme, alors le tableau doit contenir 8 octets réellement aléatoires, qui doivent être obtenus au moyen de la méthode getSeed de SecureRandom.

  3. Joueurs MCTS

    Dans un souci de simplicité, tous les joueurs simulés utilisent l'algorithme MCTS avec 100 000 itérations.

  4. Fils d'exécution

    Comme nous l'avons dit, Ajul utilise deux fils d'exécution : le fil JavaFX, qui gère l'interface graphique, et le fil qui gère la partie.

    Le fil JavaFX est créé automatiquement au démarrage de l'application, par contre le fil qui gère la partie doit l'être explicitement. Cela doit être fait au moyen de la méthode startVirtualThread utilisée dans l'exemple plus haut.

    Le fil gérant la partie doit créer un état de partie — non immuable, et auquel un observateur de points est attaché — et le gérer de la manière habituelle, en appelant les méthodes fillFactories, registerMove, endRound et endGame au bon moment. Il doit de plus effectuer les pauses décrites dans l'introduction, au moyen de la méthode sleep.

    Bien entendu, chaque fois que l'état de la partie change, ce changement doit être communiqué à l'interface graphique, pour que l'affichage puisse être mis à jour correctement. Cette communication se fait de manière implicite, en changeant le contenu de la propriété contenant la version immuable de l'état de la partie, ce qui a pour effet d'avertir ses observateurs.

    Attention toutefois : ce changement doit impérativement être fait dans le fil JavaFX, afin que les observateurs — qui modifient le graphe de scène au moyen d'animations — s'exécutent aussi dans ce fil. Comme d'habitude, cela peut se faire au moyen de la méthode runLater, ainsi :

    ImmutableGameState immutableGameState =
      gameState.immutable();
    Platform.runLater(() -> gameStateP.set(immutableGameState));
    

    gameState est la version non immuable de l'état de la partie, et gameStateP est la propriété contenant la version immuable de cet état de partie.

    Le fil gérant la partie est également responsable de la gestion du ou des joueur(s) humain(s), ce qui implique de consulter et/ou modifier certaines des valeurs partagées avec les composants gérant l'interface graphique, à savoir BoardUI et TileOverlayUI. Ces valeurs sont :

    • l'ensemble des coups valides, qui est vide sauf lorsque c'est à un joueur humain de jouer ; à ce moment-là, le fil gérant la partie le remplit avec les coups valides, et le fil JavaFX le consulte chaque fois qu'un joueur humain commence à déplacer des tuiles à la souris,
    • la file bloquante contenant le coup joué par le joueur humain, sur laquelle le fil gérant la partie bloque en attendant que le joueur humain ait pris sa décision ; le fil JavaFX se charge de placer dans cette file le coup joué lorsque le joueur humain dépose des tuiles sur une destination à même de les accueillir.

    Comme cela a été expliqué plus haut, lors du partage de valeurs entre plusieurs fils d'exécution, il faut faire attention à ce qu'elles soient immuables ou thread-safe — et les deux valeurs ci-dessus doivent donc l'être.

    L'ensemble des coups valides, qui a le type Set<Move>, ne peut pas simplement être une instance de HashSet ou TreeSet car ces classes ne sont pas thread-safe, comme expliqué dans leur documentation. Heureusement, la classe Collections offre la méthode synchronizedSet qui utilise le patron Decorator pour rendre un ensemble quelconque thread-safe. On peut donc l'utiliser ainsi pour créer l'ensemble des coups valides :

    Set<Move> validMoves =
      Collections.synchronizedSet(new HashSet<>());
    

    La file bloquante contenant le coup joué est pour sa part forcément thread-safe, car utiliser une file bloquante n'a de sens qu'en présence de fils d'exécution multiples. La bibliothèque Java offre plusieurs mises en œuvre des files bloquantes, c.-à-d. plusieurs classes implémentant l'interface BlockingQueue. Celle que nous utiliserons dans ce projet est SynchronousQueue, qui est une file un peu particulière dans le sens où on pourrait considérer que sa capacité est de 0 éléments. En pratique, cela signifie que lorsque deux fils d'exécution utilisent une telle file pour s'échanger une valeur, ils bloquent jusqu'à ce que cet échange soit possible, et il se produit alors instantanément — c.-à-d. sans que la valeur ne soit stockée dans la file.

  5. Exception InterruptedException

    Certaines méthodes utilisées dans cette étape, comme sleep, peuvent lever l'exception InterruptedException si le fil qui les exécute est interrompu durant leur exécution. Comme cela ne devrait jamais être le cas dans ce projet, cette exception peut simplement être rattrapée, puis une exception de type unchecked peut être levée à sa place, ainsi :

    try {
      // … code pouvant lever une InterruptedException
    } catch (InterruptedException e) {
      throw new Error(e);
    }
    

3.3. Tests

Étant donné que la classe représentant le programme principal doit absolument avoir le bon nom et posséder une méthode main ayant la bonne signature, nous vous fournissons un fichier de vérification de signature pour cette étape. Il doit être intégré à votre projet comme ceux des étapes 1 à 6. Notez toutefois que les anciens fichiers de vérification de signature ne sont plus nécessaires, et peuvent être supprimés.

4. Résumé

Pour cette étape, vous devez :

  • écrire la classe Main selon les indications données plus haut,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies.
  • rendre votre projet au plus tard le 29 mai à 18h00, au moyen du programme Submit.java fourni et des jetons disponibles sur votre page privée.

N'attendez surtout pas le dernier moment pour effectuer votre rendu, car vous n'êtes pas à l'abri d'imprévus.

Si vous manquez la date limite de rendu, vous avez encore la possibilité de faire un rendu tardif au moyen des jetons prévus à cet effet, et ce durant les 2 heures qui suivent, mais il vous en coûtera une pénalité inconditionnelle de 11 points.