Document ID: /fineweb-2-swissfilter-quality_10-filterrobots/filtered/06901.jsonl.gz/690

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 :
- le serveur (server) doit être lancé en premier sur l'un des deux ordinateurs,
- 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 :
- la communication avec le joueur humain auquel ils correspondent, au travers de l'interface graphique,
- 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 à
_) :
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 :
- le fil réseau, qui exécute la boucle de
RemotePlayerClient,
- 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 :
- le fil de jeu, qui exécute la méthode
runde
Game,
- 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 :
withAddedCardsde
PlayerState,
totalSizede
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)); }
où
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é à
chooseTicketsde
GraphicalPlayer— à l'endroit des premiers triples points d'interrogation — doit placer le choix du joueur dans une file bloquante,
- la méthode
chooseTicketsde
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 :
initPlayersconstruit, sur le fil JavaFX, l'instance du joueur graphique
GraphicalPlayerqu'elle adapte ; cette instance est stockée dans un attribut (celui nommé
graphicalPlayerdans les exemples ci-dessus) afin de pouvoir être utilisée par les autres méthodes,
receiveInfoappelle, sur le fil JavaFX, la méthode du même nom du joueur graphique,
updateStateappelle, sur le fil JavaFX, la méthode
setStatedu joueur graphique,
setInitialTicketChoiceappelle, sur le fil JavaFX, la méthode
chooseTicketsdu 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,
chooseInitialTicketsbloque en attendant que la file utilisée également par
setInitialTicketChoicecontienne une valeur, puis la retourne,
nextTurnappelle, sur le fil JavaFX, la méthode
startTurndu 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,
chooseTicketsenchaîne les actions effectuées par
setInitialTicketChoiceet
chooseInitialTickets,
drawSlotteste (sans bloquer !) si la file contenant les emplacements des cartes contient une valeur ; si c'est le cas, cela signifie que
drawSlotest appelée pour la première fois du tour, et que le gestionnaire installé par
nextTurna placé l'emplacement de la première carte tirée dans cette file, qu'il suffit donc de retourner ; sinon, cela signifie que
drawSlotest 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
drawCarddu 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é,
claimedRouteextrait et retourne le premier élément de la file contenant les routes, qui y aura été placé par le gestionnaire passé à
startTurnpar
nextTurn,
initialClaimCardsest similaire à
claimedRoutemais utilise la file contenant les multiensembles de cartes,
chooseAdditionalCardsappelle, 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 :
- 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,
- créant un client distant — une instance de
RemotePlayerClient— associé à un joueur graphique — une instance de
GraphicalPlayerAdapter,
- démarrant le fil gérant l'accès au réseau, qui ne fait rien d'autre qu'exécuter la méthode
rundu 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 :
- le nom de l'hôte sur lequel le serveur s'exécute — par défaut
localhost,
- 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 :
- analysant les arguments passés au programme afin de déterminer les noms des deux joueurs,
- attendant une connexion de la part du client sur le port 5108,
- créant les deux joueurs, le premier étant un joueur graphique, le second un mandataire du joueur distant qui se trouve sur le client,
- démarrant le fil d'exécution gérant la partie, qui ne fait rien d'autre qu'exécuter la méthode
playde
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.
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,
ClientMainet
ServerMainselon 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
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.
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.
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.