Programmes principaux

tCHu – étape 11

1. Introduction

Le but de cette étape est de terminer le projet tCHu en écrivant les programmes principaux du client et du serveur, de même qu'une classe permettant de relier au reste du programme le joueur graphique écrit à l'étape précédente.

2. Concepts

2.1. Client et serveur

Pour jouer une partie de tCHu, deux programmes séparés doivent être exécutés, sur deux ordinateurs différents connectés entre eux par le réseau :

  1. le serveur (server) doit être lancé en premier sur l'un des deux ordinateurs,
  2. le client (client) doit ensuite être lancé sur l'autre ordinateur, en lui fournissant les informations lui permettant de se connecter au serveur.

Le serveur a deux buts : d'une part il gère la partie — distribution des billets, choix du joueur initial, etc. —, et d'autre part il gère l'interface graphique du premier joueur1. Au début de son exécution, il attend que le client se connecte à lui par le réseau avant de commencer la partie.

Le client quant à lui gère l'interface graphique du second joueur et la communication avec le serveur. Au début de son exécution, il se connecte au serveur par le réseau, puis réagit aux messages que celui-ci lui envoie.

Le serveur et le client doivent donc chacun gérer deux formes de communication :

  1. la communication avec le joueur humain auquel ils correspondent, au travers de l'interface graphique,
  2. la communication avec l'autre partie — le client pour le serveur, et le serveur pour le client —, au travers du réseau.

Cette nécessité de communiquer par deux canaux différents pose un problème. En effet, comme nous l'avons vu, un programme qui attend un message en provenance du réseau est totalement bloqué. Comment faire en sorte qu'un programme bloqué de la sorte puisse néanmoins gérer l'interface graphique, de manière à ce que le joueur humain puisse continuer à interagir avec elle ?

Avec vos connaissances actuelles en Java, cela n'est pas possible. Pour résoudre ce problème, il nous faut donc introduire une nouvelle notion, celle de fil d'exécution.

2.2. 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 préparer 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 à 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. 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. 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écution2, 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 {
  public static void main(String[] args) {
    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 bien entendu 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 au constructeur de cette classe contient le code que le fil exécutera dès qu'il aura été démarré, ce qui se fait en appelant sa méthode start.

public final class ThreadsExample {
  public static void main(String[] args) {
    new Thread(() -> print26('a')).start();
    new Thread(() -> print26('A')).start();
    System.out.print('_');
  }
  // … 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, qui affiche l'alphabet en minuscule ; et le second fil auxiliaire 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 de suite le programme ci-dessus. Ainsi les dix lignes suivantes ont été obtenues en le lançant dix fois de suite :

abcdefghijklmnopqrstuvwxyzABCDEF_GHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_
abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_
abcde_ABCDEFGHIJKLMNOPQRSTUVWXYZfghijklmnopqrstuvwxyz
abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ
abc_dABCDEFGHIJKLMNOPQRSTUVWXYZefghijklmnopqrstuvwxyz
abcdeABCDEFGHIJKLMNOPQRSTUVWXYZ_fghijklmnopqrstuvwxyz
abcdeABCDEFGHIJKLMNOPQRSTUVWXYZ_fghijklmnopqrstuvwxyz
abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_
abcdeAB_CDEFGHIJKLMNOPQRfghijklmnopqrstuvwxyzSTUVWXYZ

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 différents alphabets et du caractère souligné.

2.4. Communication entre fils d'exécution

Lorsqu'un programme est exécuté par plusieurs fils d'exécution, ceux-ci doivent généralement communiquer entre eux à un moment où à un autre. Or cela est plus difficile qu'il n'y paraît, car les fils travaillent chacun à leur propre rythme, comme nous venons de le voir.

Pour illustrer ce problème, imaginons que nous devions écrire un programme calculant et affichant la somme des 1000 premiers entiers. Admettons de plus que nous désirions faire faire ce calcul par un fil d'exécution distinct du fil principal, qui est lui chargé de l'affichage du résultat. Pour que le fil chargé du calcul puisse communiquer le résultat au fil principal, on peut imaginer qu'il le place dans une file qu'il partage avec le fil principal (q ci-dessous) ainsi :

public final class ThreadCommunication {
  private static int sumUpTo(int max) {
    int sum = 0;
    for (int i = 1; i <= max; i++) sum += i;
    return sum;
  }

  public static void main(String[] args) {
    Queue<Integer> q = new ArrayDeque<>();
    new Thread(() -> q.add(sumUpTo(1000))).start();
    System.out.println(q.remove());
  }
}

(Notez qu'utiliser une simple variable locale de type int ne fonctionne pas, car cette variable n'est pas effectively final, ce qui interdit son utilisation dans la lambda.)

Malheureusement, si on exécute ce programme, il plante généralement avec une exception due au fait que la file est vide lorsque le fil principal tente d'en retirer le résultat au moyen de remove. Cela est tout à fait logique : comme il faut un certain temps au fil de calcul pour démarrer puis effectuer le calcul, le fil principal exécutera généralement l'appel à remove avant que le fil de calcul n'ait exécuté l'appel à add.

Heureusement la bibliothèque Java fournit une variante des files que nous connaissons déjà, et qui sont justement prévues pour résoudre ce type de problème : les files bloquantes (blocking queues).

Comme son nom le suggère, une file bloquante offre, entre autres, une méthode de suppression similaire à remove mais qui bloque l'appelant tant et aussi longtemps que la file est vide. Une file bloquante a généralement une taille maximale fixe, nommée sa capacité (capacity), et offre donc aussi une méthode d'ajout similaire à add mais qui bloque l'appelant tant et aussi longtemps que la file est pleine.

Le concept de file bloquante est décrit par l'interface BlockingQueue, dont la documentation contient une table donnant les noms des versions bloquantes des méthodes d'ajout (put) et de suppression (take). Plusieurs mises en œuvre des files bloquantes existent, mais la seule vraiment indispensable à ce projet est ArrayBlockingQueue. En l'utilisant, on peut récrire le programme ci-dessus de manière correcte :

public final class ThreadCommunication {
  // … sumUpTo comme avant

  public static void main(String[] args) {
    BlockingQueue<Integer> q = new ArrayBlockingQueue<>(1);
    new Thread(() -> q.put(sumUpTo(1000))).start();
    System.out.println(q.take());
  }
}

Notez que, tel quel, ce programme n'est pas encore correct, car malheureusement les méthodes put et take peuvent lever l'exception InterruptedException, qui est de type checked. Il faut donc entourer les appels par des blocs try/catch, ce qui est laissé en exercice. (Ces blocs peuvent simplement lever à nouveau une exception de type Error, car l'exception InterruptedException ne devrait jamais être levée.)

Notez également que l'appel à put pourrait très bien être remplacé par un appel à add, car il n'est pas possible que la file soit pleine au moment de l'ajout. Seule la suppression du résultat doit impérativement être faite de manière bloquante — en appelant take et pas remove — car la file peut très bien être vide au moment où le fil principal tente d'obtenir le résultat.

2.5. 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.

La méthode isFxApplicationThread décrite à l'étape précédente retourne vrai si et seulement si on l'appelle depuis le fil d'exécution JavaFX. C'est la raison pour laquelle nous vous avons conseillé d'ajouter des assertions vérifiant que toutes les méthodes de GraphicalPlayer — qui modifient directement ou indirectement l'interface graphique — sont bien exécutées par le fil JavaFX.

La classe Platform de JavaFX offre une méthode nommée runLater à laquelle on passe un morceau de code — généralement sous la forme d'une lambda — et qui se charge d'exécuter ce code sur le fil JavaFX. Cette méthode peut être appelée depuis n'importe quel fil d'exécution, et elle est donc très utile lorsqu'un fil quelconque désire demander au fil JavaFX d'exécuter du code.

2.6. Fils d'exécution du client et du serveur tCHu

Le client tCHu exécute deux fils distincts, qui sont :

  1. le fil réseau, qui exécute la boucle de RemotePlayerClient,
  2. le fil JavaFX, qui gère l'interface graphique et exécute, entre autres, toutes les méthodes de GraphicalPlayer.

Le serveur tCHu exécute lui aussi deux fils distincts, qui sont :

  1. le fil de jeu, qui exécute la méthode run de Game,
  2. le fil JavaFX, qui joue le même rôle que dans le client.

Dans les deux cas, ces fils doivent communiquer entre eux : le premier fil doit demander au fil JavaFX d'exécuter certaines méthodes de GraphicalPlayer, tandis que le fil JavaFX doit informer l'autre fil des actions effectuées par le joueur (humain) via l'interface graphique.

Lorsque le premier fil désire demander au fil JavaFX d'exécuter des méthodes de GraphicalPlayer, il peut le faire facilement au moyen de la méthode runLater décrite plus haut. Lorsque le fil graphique désire communiquer une action effectuée par le joueur via l'interface graphique au premier fil, il peut simplement utiliser une file bloquante, comme dans l'exemple de la §2.4.

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. Préconditions de withDrawnFaceUpCard et withBlindlyDrawnCard

Les méthodes withDrawnFaceUpCard et withBlindlyDrawnCard de GameState ont actuellement comme précondition que canDrawCards soit vrai. Or cela est incorrect, car le but de canDrawCards est de déterminer si un joueur a le droit de tirer deux cartes durant son tour, et non pas de déterminer si une carte peut être tirée.

Ces préconditions posent problème dans le cas où il reste exactement 5 cartes entre la pioche et la défausse, et que le joueur courant décide de tirer deux cartes, ce qu'il a le droit de faire. Le tirage de la première de ces cartes se passe sans problème, car canDrawCards retourne alors vrai, mais le tirage de la seconde provoque une exception, car canDrawCards retourne alors faux.

Il faut donc supprimer cette précondition des méthodes susmentionnées.

3.1.2. Erreurs

La méthode plural de StringsFr retourne actuellement la chaîne s lorsque l'argument qu'on lui passe vaut 0, ce qui est faux en français. Il faut donc la modifier pour qu'elle retourne la chaîne vide dans ce cas.

3.1.3. Code inutile

Les méthodes suivantes ne sont jamais utilisées, et peuvent être supprimées :

  • withAddedCards de PlayerState,
  • totalSize de PublicCardState.

Bien entendu, les éventuelles méthodes de test qui s'y rapportent doivent également être supprimées.

De plus, l'argument drawnCards de la méthode possibleAdditionalCards de la classe PlayerState n'est pas utilisé et peut être supprimé, de même que la précondition qui vérifie sa valeur.

3.2. Classe GraphicalPlayerAdapter

La classe instanciable GraphicalPlayerAdapter, du paquetage ch.epfl.tchu.gui, a pour but d'adapter (au sens du patron Adapter) une instance de GraphicalPlayer en une valeur de type Player. Elle implémente donc l'interface Player. Il faut toutefois noter que cette classe n'est pas conçue exactement de la manière décrite par le patron Adapter, principalement car elle construit elle-même l'instance de la classe qu'elle adapte — dans la méthode initPlayers comme nous le verrons.

Le constructeur de GraphicalPlayerAdapter ne prend aucun argument. En dehors de ce constructeur, les seules méthodes publiques offertes par cette classe sont celles de l'interface Player. Elles sont toutes — constructeur compris — destinées à être exécutée par un fil d'exécution différent du fil JavaFX. Dans le serveur, ce fil sera celui gérant la partie, tandis que dans le client, ce fil sera celui gérant la communication par le réseau.

La tâche principale de la plupart des méthodes de GraphicalPlayerAdapter est d'appeler la méthode correspondante de GraphicalPlayer, en prenant toutefois bien garde à ce que cet appel soit fait sur le fil d'exécution de JavaFX. Pour cela, elles utilisent bien entendu la méthode runLater de JavaFX.

Par exemple, la méthode receiveInfo de GraphicalPlayerAdapter ne fait rien d'autre qu'appeler la méthode du même nom de GraphicalPlayer, et son code se résume donc à :

@Override
public void receiveInfo(String info) {
  runLater(() -> graphicalPlayer.receiveInfo(info));
}

graphicalPlayer est l'instance de GraphicalPlayer adaptée par GraphicalPlayerAdapter. Cette instance est créée lors de l'appel à initPlayers, comme décrit ci-dessous.

Un problème se pose toutefois pour les méthodes de Player qui doivent retourner une valeur, comme p.ex. chooseTickets. En effet, la méthode chooseTickets de GraphicalPlayer ne retourne rien — son type de retour est void — mais elle prend un argument de plus que celle de Player, un gestionnaire de choix de billets de type ChooseTicketsHandler. Ce gestionnaire est appelé avec le choix du joueur lorsque celui-ci l'a confirmé. Deux questions se posent donc : premièrement, que passer comme gestionnaire de choix ; deuxièmement, comment obtenir les billets choisis afin de les retourner ? En d'autres termes, par quoi remplacer les triples points d'interrogation dans le squelette suivant ?

@Override
public SortedBag<Ticket> chooseTickets(SortedBag<Ticket> ts) {
  runLater(() -> graphicalPlayer.chooseTickets(ts, ???));
  return ???;
}

En réfléchissant un peu, on constate que la méthode chooseTickets de GraphicalPlayer, dont le squelette est présenté ci-dessus, doit bloquer tant et aussi longtemps que le joueur n'a pas fait son choix. En effet, avant cela il ne lui est pas possible de retourner ce choix. Cela suggère la solution suivante :

  • le gestionnaire passé à chooseTickets de GraphicalPlayer — à l'endroit des premiers triples points d'interrogation — doit placer le choix du joueur dans une file bloquante,
  • la méthode chooseTickets de GraphicalPlayerAdapter, dont le squelette est présenté ci-dessus, doit bloquer tant et aussi longtemps que cette file est vide, puis retourner l'élément que le gestionnaire y aura placé.

La file en question peut être créée dans la méthode chooseTickets dont le squelette est présenté ci-dessus, ou alors dans le constructeur de la classe GraphicalPlayerAdapter, car elle est également utile à la mise en œuvre des méthodes setInitialTicketChoice et chooseInitialTickets.

En résumé, les méthodes de GraphicalPlayerAdapter utilisent runLater lorsqu'elles désirent exécuter une méthode de GraphicalPlayer, afin de garantir qu'elle le sera par le fil de JavaFX, et elles utilisent une file bloquante pour communiquer des valeurs depuis le fil JavaFX vers le fil qui exécute les méthodes de GraphicalPlayerAdapter. Étant donné que plusieurs types de valeurs doivent être communiqués de la sorte, plusieurs files bloquantes sont nécessaires.

Le comportement des méthodes de GraphicalPlayerAdapter est donc le suivant :

  • initPlayers construit, sur le fil JavaFX, l'instance du joueur graphique GraphicalPlayer qu'elle adapte ; cette instance est stockée dans un attribut (celui nommé graphicalPlayer dans les exemples ci-dessus) afin de pouvoir être utilisée par les autres méthodes,
  • receiveInfo appelle, sur le fil JavaFX, la méthode du même nom du joueur graphique,
  • updateState appelle, sur le fil JavaFX, la méthode setState du joueur graphique,
  • setInitialTicketChoice appelle, sur le fil JavaFX, la méthode chooseTickets du joueur graphique, pour lui demander de choisir ses billets initiaux, en lui passant un gestionnaire de choix qui stocke le choix du joueur dans une file bloquante,
  • chooseInitialTickets bloque en attendant que la file utilisée également par setInitialTicketChoice contienne une valeur, puis la retourne,
  • nextTurn appelle, sur le fil JavaFX, la méthode startTurn du joueur graphique, en lui passant des gestionnaires d'action qui placent le type de tour choisi, de même que les éventuels « arguments » de l'action — p.ex. la route dont le joueur désire s'emparer — dans des files bloquantes, puis bloque en attendant qu'une valeur soit placée dans la file contenant le type de tour, qu'elle retire et retourne,
  • chooseTickets enchaîne les actions effectuées par setInitialTicketChoice et chooseInitialTickets,
  • drawSlot teste (sans bloquer !) si la file contenant les emplacements des cartes contient une valeur ; si c'est le cas, cela signifie que drawSlot est appelée pour la première fois du tour, et que le gestionnaire installé par nextTurn a placé l'emplacement de la première carte tirée dans cette file, qu'il suffit donc de retourner ; sinon, cela signifie que drawSlot est appelée pour la seconde fois du tour, afin que le joueur tire sa seconde carte, et il faut donc appeler, sur le fil JavaFX, la méthode drawCard du joueur graphique, avant de bloquer en attendant que le gestionnaire qu'on lui passe place l'emplacement de la carte tirée dans la file, qui est alors extrait et retourné,
  • claimedRoute extrait et retourne le premier élément de la file contenant les routes, qui y aura été placé par le gestionnaire passé à startTurn par nextTurn,
  • initialClaimCards est similaire à claimedRoute mais utilise la file contenant les multiensembles de cartes,
  • chooseAdditionalCards appelle, sur le fil JavaFX, la méthode du même nom du joueur graphique puis bloque en attendant qu'un élément soit placé dans la file contenant les multiensembles de cartes, qu'elle retourne.

Notez qu'après avoir terminé cette classe, vous pouvez déjà exécuter le programme de test donné à la §3.5.1, ce qu'il est vivement conseillé de faire.

3.3. Classe ClientMain

La classe ClientMain du paquetage ch.epfl.tchu.gui contient le programme principal du client tCHu. Comme il s'agit d'une application JavaFX, cette classe hérite de Application, et sa méthode main ne fait comme d'habitude rien d'autre qu'appeler launch.

En dehors de la définition — triviale — de main, la classe ClientMain contient uniquement une définition de la méthode start de Application. C'est elle qui se charge de démarrer le client en :

  1. analysant les arguments passés au programme afin de déterminer le nom de l'hôte et le numéro de port du serveur,
  2. créant un client distant — une instance de RemotePlayerClient — associé à un joueur graphique — une instance de GraphicalPlayerAdapter,
  3. démarrant le fil gérant l'accès au réseau, qui ne fait rien d'autre qu'exécuter la méthode run du client créé précédemment.

Notez en particulier que la méthode start n'utilise pas son argument primaryStage, ce qui est tout à fait autorisé. La seule fenêtre ouverte par le client l'est donc par le constructeur de GraphicalPlayer lorsqu'il est appelé par la méthode initPlayers de GraphicalPlayerAdapter.

Les deux arguments acceptés par le client sont optionnels, et dans l'ordre il s'agit de :

  1. le nom de l'hôte sur lequel le serveur s'exécute — par défaut localhost,
  2. le numéro de port sur lequel le serveur écoute — par défaut 5108.

Pour mémoire, les arguments passés à un programme lui sont fournis sous la forme d'un tableau de chaînes de caractères que la méthode main prend en paramètre. Si vous ne savez pas comment passer des arguments à un programme, vous pouvez consulter notre guide à ce sujet pour IntelliJ ou pour Eclipse.

Dans une application JavaFX, les arguments ne sont généralement pas analysés dans la méthode main comme d'habitude, car celle-ci ne fait rien d'autre qu'appeler launch. Au lieu de cela, on analyse les argument dans la méthode start, où ils peuvent être obtenus en appelant la méthode getParameters, puis getRaw sur le résultat. Cette dernière méthode retourne les arguments sous la forme d'une liste de chaînes de caractères.

Dans un soucis de simplicité, toutes les erreurs — p.ex. le fait que la connexion avec le serveur ne puisse pas être établie, ou le fait que le numéro de port passé soit invalide — peuvent simplement faire planter le programme avec le message affiché par défaut lorsqu'une exception n'est pas gérée.

Après avoir terminé cette classe, vous pouvez la tester au moyen du programme de test donné à la §3.5.2 ce qui, une fois encore, est vivement recommandé.

3.4. Classe ServerMain

La classe ServerMain du paquetage ch.epfl.tchu.gui contient le programme principal du serveur tCHu. Là aussi, étant donné qu'il s'agit d'une application JavaFX, cette classe hérite de Application, et sa méthode main appelle simplement launch.

Comme celle du client, la méthode start du serveur ignore son argument primaryStage et se charge de démarrer le serveur en :

  1. analysant les arguments passés au programme afin de déterminer les noms des deux joueurs,
  2. attendant une connexion de la part du client sur le port 5108,
  3. créant les deux joueurs, le premier étant un joueur graphique, le second un mandataire du joueur distant qui se trouve sur le client,
  4. démarrant le fil d'exécution gérant la partie, qui ne fait rien d'autre qu'exécuter la méthode play de Game.

Les deux arguments acceptés par le serveur sont les noms des deux joueurs, dans l'ordre. Comme ceux du client, ces arguments sont optionnels, les noms à utiliser par défaut étant Ada pour la première joueuse, et Charles pour le second joueur.

Là aussi, toute erreur peut simplement provoquer le plantage du programme.

3.5. Tests

Étant donné que les deux classes représentant les programmes principaux doivent impérativement posséder une méthode main ayant la bonne signature, nous vous fournissons une archive Zip contenant 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.

3.5.1. Test local

Une fois la classe GraphicalPlayerAdapter écrite, il est possible d'utiliser le programme de test ci-dessous pour jouer une partie sur un seul ordinateur :

public final class Stage11Test extends Application {
  public static void main(String[] args) { launch(args); }

  @Override
  public void start(Stage primaryStage) {
    SortedBag<Ticket> tickets = SortedBag.of(ChMap.tickets());
    Map<PlayerId, String> names =
      Map.of(PLAYER_1, "Ada", PLAYER_2, "Charles");
    Map<PlayerId, Player> players =
      Map.of(PLAYER_1, new GraphicalPlayerAdapter(),
	     PLAYER_2, new GraphicalPlayerAdapter());
    Random rng = new Random();
    new Thread(() -> Game.play(players, names, tickets, rng))
      .start();
  }
}

Bien entendu, jouer sur un seul ordinateur n'a pas de sens, car les fenêtres des deux joueurs sont visibles sur le même écran, révélant à chacun les billets et cartes de l'adversaire. Le jeu est néanmoins totalement fonctionnel, et l'avantage du programme de test ci-dessus est qu'il n'implique pas de lancer plusieurs programmes séparés, et évite tous les problèmes potentiels liés à la communication réseau.

3.5.2. Test du client

Une fois la classe ClientMain écrite, il est possible de la tester rapidement au moyen d'un « faux » serveur qui envoie simplement des messages prédéfinis au client qui se connecte à lui. Un exemple d'un tel serveur est donné ci-dessous, et les messages qu'il envoie sont simplement ceux donnés en exemple à la §2.1 de l'étape 7.

public final class FakeServer {
  // Messages d'exemple de la §2.1 de l'étape 7
  private static final List<String> MESSAGES = List.of(
    "INIT_PLAYERS 0 QWRh,Q2hhcmxlcw==",
    "RECEIVE_INFO QWRhIGpvdWVyYSBlbiBwcmVtaWVyLgoK",
    "SET_INITIAL_TICKETS 6,1,44,42,42",
    "UPDATE_STATE 36:6,7,4,7,1;97;0:0:0;4;:0;4;: ;0,1,5,5;",
    "CHOOSE_INITIAL_TICKETS");

  public static void main(String[] args) throws IOException {
    try (ServerSocket s0 = new ServerSocket(5108);
	 Socket s = s0.accept();
	 BufferedReader r = new BufferedReader(
	   new InputStreamReader(s.getInputStream(),
				 US_ASCII));
	 BufferedWriter w = new BufferedWriter(
	   new OutputStreamWriter(s.getOutputStream(),
				  US_ASCII))) {
      // Envoi des messages
      for (String m : MESSAGES) {
	w.write(m + '\n');
	w.flush();
      }
      // Attente et impression de la réponse
      System.out.println(r.readLine());
    }
  }
}

En exécutant d'abord ce faux serveur puis votre client, l'interface graphique de ce dernier devrait avoir exactement l'aspect ci-dessous.

client-with-fake-server;128.png
Figure 1 : Effet du faux serveur sur le client (cliquer pour agrandir)

Si vous sélectionnez alors le premier et les deux derniers billets puis cliquez sur Choisir, la chaîne suivante — la réponse du client — devrait être affichée sur la console du serveur :

6,42,42

3.5.3. Test final

Finalement, une fois la classe ServerMain écrite, vous pouvez jouer une véritable partie. Pour commencer, il est conseillé d'essayer de lancer le serveur et le client sur la même machine, afin de vérifier que tout fonctionne ainsi.

Cela fait, vous pouvez essayer de jouer une partie entre deux ordinateurs différents, qui doivent tous deux être connectés au VPN de l'EPFL. L'utilisation du VPN est indispensable pour que le client puisse effectivement se connecter au serveur3.

Une fois les deux ordinateurs connectés au VPN de l'EPFL, le nom (ou l'adresse IP numérique) de l'ordinateur sur lequel le serveur sera exécuté doit être déterminé, ce qui peut se faire au moyen du programme ci-dessous :

public final class ShowMyIpAddress {
  public static void main(String[] args) throws IOException {
    NetworkInterface.networkInterfaces()
      .filter(i -> {
	  try { return i.isUp() && !i.isLoopback(); }
	  catch (IOException e) {
	    throw new UncheckedIOException(e);
	  }
	})
      .flatMap(NetworkInterface::inetAddresses)
      .filter(a -> a instanceof Inet4Address)
      .map(InetAddress::getCanonicalHostName)
      .forEachOrdered(System.out::println);
  }
}

Une des lignes affichées par ce programme devrait commencer par vpn et se terminer par .epfl.ch. Il s'agit du nom de l'hôte (l'ordinateur), qui peut être passé en argument au client, comme expliqué plus bas.

Il est aussi possible d'obtenir l'adresse IP de cet ordinateur au moyen de différents systèmes en ligne, p.ex. au entrant une requête comme my ip address dans WolframAlpha. Dans ce cas, l'adresse IP est souvent donnée sous forme numérique, et devrait commencer par 128.179.

Une fois obtenu le nom de l'ordinateur — ou l'adresse IP numérique — sur lequel fonctionnera le serveur, ce dernier peut être lancé. Ensuite, le client peut être lancé sur l'autre ordinateur, en lui passant en argument le nom ou l'adresse IP du serveur.

Après avoir vérifié que vous pouvez ainsi jouer une partie complète sans problème, nous vous conseillons vivement d'essayer d'en jouer en combinant le serveur d'un groupe avec le client d'un autre groupe. Cela vous permettra de vérifier que les programmes des deux groupes utilisent bien le même protocole de communication, ce qui augmente la probabilité qu'ils respectent la spécification donnée aux étapes 7 et 8. Or il est capital que vos projets respectent ce protocole, car nous testerons vos clients avec notre serveur, et vos serveurs avec notre client.

4. Vérifications finales

Avant de rendre votre projet terminé, nous vous recommandons de vérifier que vous n'avez pas commis d'erreurs qui pourraient faire planter votre programme au démarrage lors des tests que nous effectuerons, ce qui vous ferait perdre la totalité des 20 points attribués au test. Vérifiez en particulier que :

  • la manière dont vous sérialisez et désérialisez les données est exactement celle spécifiée à l'étape 7, au caractère près,
  • le format des messages échangés entre le client et le serveur est exactement celui spécifié à l'étape 8, là aussi au caractère près,
  • le nom des feuilles de style que vous attachez aux nœuds du graphe de scène est exactement celui spécifié aux étapes 9 et 10 ; notez en particulier que ces noms ne doivent surtout pas contenir un nom de chemin comme resources/ en tête.

Une manière simple — et agréable — de faire les deux premières vérifications est de jouer des parties contre d'autres groupes, en utilisant le client d'un groupe et le serveur de l'autre, raison pour laquelle nous vous recommandons une fois encore de le faire.

5. Résumé

Pour cette étape, vous devez :

  • écrire les classes GraphicalPlayerAdapter, ClientMain et ServerMain selon les indications données ci-dessus,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies.
  • rendre votre code au plus tard le 28 mai 2021 à 17h00, via le système de rendu.

Ce rendu est le rendu final, auquel un total de 130 points sont attribués : 110 seront déterminés par lecture de votre code, les 20 restants par test de votre projet.

N'attendez surtout pas le dernier moment pour effectuer votre rendu, car vous n'êtes pas à l'abri d'imprévus. Souvenez-vous qu'aucun retard, aussi insignifiant soit-il, ne sera toléré !

Notes de bas de page

1

Notez que l'étape 7 mentionnait que le serveur ne faisait que gérer la partie, tandis que l'interface graphique de chacun des deux joueurs était gérée par une instance du client chacune. Dans un soucis de simplicité, nous avons finalement décidé de laisser le serveur gérer également l'interface graphique de l'un des joueurs.

2

Cela n'est pas tout à fait vrai, entre autres car toute application JavaFX comporte au moins un fil d'exécution en plus du fil principal, qui gère l'interface graphique. Cela dit, vous n'aviez probablement pas conscience de son existence jusqu'à présent.

3

Une autre solution serait d'utiliser un service comme ngrok pour établir ce que l'on nomme un tunnel TCP entre le serveur et une machine atteignable directement par le client, mais cette option est plus complexe et ne devrait être utilisée que pour jouer avec des personnes n'ayant pas accès au VPN de l'EPFL.