Joueur distant
Javass – étape 7

1 Introduction

Le but de cette étape est d'écrire les classes permettant à un ou plusieurs joueurs d'une partie de Jass de se trouver sur un autre ordinateur que celui sur lequel la partie se déroule.

Le principal intérêt de cette possibilité est qu'elle permet à plusieurs joueurs humains de jouer ensemble à une partie tout en étant chacun sur son propre ordinateur. Cela dit, rien n'empêche certains des joueurs distants d'être des joueurs simulés.

1.1 Jass à distance

L'idée de la technique que nous utiliserons dans cette étape pour offrir aux joueurs la possibilité de jouer à distance est simple et peut s'illustrer au moyen d'un exemple d'émission télévisée de Jass.

Admettons donc qu'une chaîne de télévision désire offrir une émission durant laquelle trois joueurs présents sur le plateau jouent au Jass avec une téléspectatrice jouant depuis chez elle. Cette dernière ne peut bien entendu pas participer physiquement à la partie : elle ne peut ni tenir ses cartes en main, ni en extraire une afin de la poser sur la table lorsque c'est à elle de jouer. Pour qu'elle puisse néanmoins jouer normalement, il faut donc qu'une autre personne, présente sur le plateau, lui serve d'intermédiaire. La téléspectatrice et son intermédiaire communiquent via la télévision et le téléphone : lorsque les cartes sont distribuées, l'intermédiaire prend celles destinées à la téléspectatrice et les montre à la caméra ; lorsque c'est à la téléspectatrice de jouer, elle communique par téléphone la carte qu'elle désire jouer à l'intermédiaire, qui la joue pour elle ; et ainsi de suite.

On pourrait même imaginer une solution plus sophistiquée permettant à la téléspectatrice d'avoir l'illusion de jouer une véritable partie chez elle. Plutôt que de la faire communiquer directement avec son intermédiaire sur le plateau de télévision, un assistant présent chez elle se chargerait de cette communication. La téléspectatrice pourrait alors s'asseoir à une table comme si elle jouait avec d'autres personnes présentes, et l'assistant se chargerait de maintenir cette illusion : une fois informé par l'intermédiaire des cartes présentes dans la main de la téléspectatrice, il lui donnerait des cartes identiques ; de même, une fois informé par l'intermédiaire d'une carte jouée par l'un des trois autres joueurs, l'assistant poserait une carte identique sur la table de la téléspectatrice. Et ainsi de suite.

1.2 Joueur distant

La technique que nous utiliserons dans le projet est très similaire à cette seconde solution. L'intermédiaire et l'assistant mentionnés ci-dessus seront des objets Java, communiquant entre eux au moyen de messages textuels transmis via Internet, mais l'idée de base est la même.

Le premier de ces objets, que nous nommerons le client, joue le rôle de l'intermédiaire présent sur le plateau de télévision dans notre exemple. Cet objet se trouve sur l'ordinateur sur lequel se déroule la partie, et du point de vue de cette dernière, il n'est rien d'autre qu'un joueur comme un autre. En termes Java, cela signifie que le client est une instance d'une classe implémentant l'interface Player.

Le second de ces objets, que nous nommerons le serveur, joue le rôle de l'assistant présent chez la téléspectatrice dans notre exemple. Cet objet se trouve (généralement) sur un autre ordinateur que celui sur lequel la partie se déroule. Son but est de donner l'illusion à un joueur local qu'il participe à une partie normale. En termes Java, cela signifie que le serveur appelle les méthodes du joueur local — un objet de type Player — exactement comme le fait habituellement l'objet JassGame lors d'une partie locale.

Pour illustrer le fonctionnement de cette solution, imaginons une partie de Jass entre quatre joueurs simulés. La partie elle-même, c-à-d l'instance de JassGame, et trois des joueurs simulés, se trouvent sur un premier ordinateur que nous appellerons l'ordinateur A. Le dernier joueur simulé se trouve sur l'ordinateur B.

Pour que le quatrième joueur simulé puisse participer à la partie, il faut qu'un client le représente — en quelque sorte — sur l'ordinateur A, et communique avec un serveur sur l'ordinateur B. Le serveur, quant à lui, appelle les méthodes du joueur simulé qui s'exécute sur l'ordinateur B en fonction des messages que lui envoie le client via le réseau.

Admettons que ce soit justement au joueur simulé distant de jouer en premier au premier tour de la partie. L'instance de JassGame représentant cette dernière appelle la méthode cardToPlay du client afin de savoir quelle carte il désire jouer. Le client n'étant qu'un représentant d'un joueur, et pas un joueur réel, il se contente d'envoyer un message au serveur le priant de bien vouloir demander au joueur simulé quelle carte il désire jouer. A la réception de ce message, le serveur appelle la méthode cardToPlay du joueur simulé, et lorsque celui-ci répond, il transmet la réponse au client via le réseau, qui retourne le résultat de sa méthode cardToPlay à lui.

Comme cet exemple l'illustre, l'objet JassGame n'a absolument pas « conscience » du fait que le quatrième joueur simulé est distant : de son point de vue, le client est le quatrième joueur.

1.2.1 Messages

Pour communiquer entre eux, le client et le serveur s'échangent, via le réseau, des messages textuels. Dans un soucis de simplicité, ces messages consistent chacun en une unique ligne de texte, encodée en ASCII1.

Les messages sont toujours envoyés par le client au serveur, et ce dernier ne répond généralement rien, sauf dans le cas où le message est celui qui demande quelle carte le joueur distant désire jouer. Dans ce cas, le serveur répond en envoyant la carte en question.

Sachant d'une part que le but du client et du serveur est de permettre à un joueur d'être distant, et d'autre part que les joueurs sont représentés par l'interface Player dans ce projet, il devrait être clair que les messages échangés entre le client et le serveur correspondent directement aux méthodes de cette interface. En d'autre termes, à chaque méthode de Player correspond un message. Ces derniers sont identifiés par quatre lettres majuscules de l'alphabet latin, et la correspondance entre les méthodes de Player et les identificateurs de messages est donnée par la table suivante :

Méthode de Player Message
setPlayers PLRS
setTrump TRMP
updateHand HAND
updateTrick TRCK
cardToPlay CARD
updateScore SCOR
setWinningTeam WINR

Un message envoyé par le client consiste en le nom du message — les quatre lettres données dans la table ci-dessus — suivi d'un espace, des arguments du message séparés par des espaces et finalement un caractère de retour à la ligne — le caractère de contrôle LF, dont le code est 10, noté \n en Java.

Par exemple, le message TRMP correspond à la méthode setTrump et a donc pour but de communiquer l'atout. Cet atout est représenté par l'entier donnant l'index de la couleur correspondante dans l'énumération Card.Color. Dès lors, le message que le client envoie au serveur pour lui dire que l'atout est cœur est la ligne suivante :

TRMP 1

Le cas de setTrump est simple car le seul argument donné à cette méthode est une couleur, que l'on peut facilement représenter au moyen d'un entier comme illustré ci-dessus. Par contre, la manière dont il est possible de représenter sous forme textuelle les autres paramètres des méthodes de l'interface Player est peut-être moins évidente. Elle constitue donc le sujet de la section suivante.

1.2.2 Représentation des valeurs

Afin de pouvoir représenter les arguments des méthodes de Player sous forme textuelle — de plus en utilisant uniquement les caractères ASCII — il faut trouver comment représenter textuellement des objets de plusieurs types :

  1. différents types énumérés (PlayerId pour setPlayers, Color pour setTrump, TeamId pour setWinningTeam),
  2. Card (pour le résultat de cardToPlay),
  3. Score (pour updateScore),
  4. CardSet (pour updateHand et cardToPlay),
  5. Trick (pour updateTrick),
  6. TurnState (pour cardToPlay),
  7. Map<PlayerId, String> (pour setPlayers).

Pour les types énumérés, nous l'avons vu, une solution simple consiste à les représenter au moyen de (la représentation textuelle) de l'entier donnant leur position dans l'énumération, c-à-d celui retourné par la méthode ordinal.

Pour les types Card, CardSet, Trick et Score, une solution très simple existe également puisqu'ils ont une représentation empaquetée. On peut donc les représenter comme (la représentation textuelle) de leur valeur empaquetée, qui est toujours un entier.

La question se pose toutefois de savoir dans quelle base représenter ces entiers. Les deux les plus naturelles sont la base 10 et la base 16, et nous avons choisi la seconde, principalement car les valeurs empaquetées sont plus faciles à interpréter pour les êtres humains dans cette base. D'autre part, elle permet d'économiser un peu de place par rapport à la base 10, même si cela n'a que peu d'importance ici vu le faible nombre de messages échangés lors d'une partie.

Le type TurnState n'a quant à lui pas de représentation empaquetée, mais il est composé de trois valeurs qui, elles, en ont une. On peut donc représenter une valeur de type TurnState par la représentation textuelle de ces trois valeurs (trois entiers, donc) séparés par un caractère qui peut être quelconque pourvu qu'il ne constitue pas un chiffre valide. Nous avons choisi la virgule.

La table associative de type Map<PlayerId, String> donnant le nom des joueurs peut être représentée de manière similaire. En effet, on sait qu'elle comporte toujours exactement quatre valeurs, qui sont les noms des quatre joueurs. Il suffit donc de représenter ces noms sous forme textuelle, et de les séparer par une virgule.

A première vue, il semblerait que représenter les noms des joueurs de manière textuelle est trivial, puisque ces noms sont déjà des chaînes de caractères. On devrait donc pouvoir représenter ces chaînes par elles-mêmes, sans devoir les transformer de quelque manière que ce soit.

Malheureusement, ce n'est pas le cas, pour deux raisons : premièrement, les noms des joueurs peuvent très bien contenir des caractères qui n'existent pas en ASCII, or nos messages ne sont constitués que de caractères ASCII ; deuxièmement, un nom de joueur contenant une virgule poserait problème, car c'est le caractère que nous utilisons pour séparer les noms les uns des autres.

Une solution simple existe à ces deux problèmes : il suffit de trouver une manière d'encoder les octets constituant la représentation en UTF-8 des noms des joueurs en une chaîne de caractères ASCII valide et ne contenant pas de virgule.

Ce problème se rencontre très souvent en informatique lorsque plusieurs ordinateurs échangent des données, et il existe déjà plusieurs encodages permettant de représenter une suite quelconque d'octets en utilisant uniquement des caractères ASCII. Le plus célèbre d'entre eux est probablement base64, qui utilise 64 caractères ASCII différents pour encoder les octets par paquets de 6 bits. Ces 64 caractères sont (dans l'ordre) :

  • les 26 lettres majuscules de l'alphabet latin (de A à Z),
  • les 26 lettres minuscules de l'alphabet latin (de a à z),
  • les 10 chiffres (de 0 à 9),
  • le plus (+) et la barre oblique (/).

De plus, le signe égal (=) est utilisé au besoin à la fin des chaînes encodées afin de garantir qu'elles contiennent un multiple de 4 caractères.

Les détails de l'encodage base64 ne seront pas décrits ici, les personnes intéressées pouvant se référer à sa page Wikipedia et aux sites permettant d'encoder et de décoder des valeurs. L'exemple suivant permettra néanmoins de comprendre dans les grandes lignes comment il fonctionne.

Admettons que l'on désire encoder la chaîne Amélie en base64. L'encodage UTF-8 de cette chaîne est constitué des 7 octets ci-dessous, donnés en base 16:

41 6d c3 a9 6c 69 65

Ces 7 octets représentent 56 bits (7×8), les 24 correspondant aux trois premiers octets étant :

01000001 01101101 11000011

Ces 24 octets peuvent être regroupés en 4 paquets de 6 bits ainsi :

010000 010110 110111 000011

Et chacun de ces paquets de 6 bits encodé au moyen du caractère base64 lui correspondant.

Ainsi, le premier paquet de 6 bits représente l'entier 16, et le caractère base64 correspondant est Q. Le second paquet de 6 bits représente l'entier 22, et le caractère base64 correspondant est W. Le troisième paquet de 6 bits représente l'entier 55, et le caractère base64 correspondant est 3. Finalement, le dernier paquet de 6 bits représente l'entier 3, et le caractère base64 correspondant est D. En procédant de la sorte pour la totalité des 56 bits composant la chaîne Amélie, on obtient son encodage base64 qui est QW3DqWxpZQ==.

1.2.3 Exemple

L'exemple ci-dessous illustre les messages échangés entre un client et un serveur au début d'une partie. Tous les types de messages décrits plus haut, à l'exception de WINR, y apparaissent.

 1: PLRS 2 QW3DqWxpZQ==,R2HDq2xsZQ==,w4ltaWxl,TmFkw6hnZQ==
 2: HAND 210002008d0088
 3: TRMP 1
 4: SCOR 0
 5: TRCK 60ffffff
 6: CARD 0,1ff01ff01ff01ff,60ffffff 210002008d0088
 7: 12
 8: HAND 21000200890088
 9: TRCK 60ffffd2
10: TRCK 60fff452
11: TRCK 60fd4452
12: TRCK 60554452
13: SCOR 1e100000000
14: 

La ligne 1 correspond à l'appel à setPlayers et communique au joueur distant qu'il a l'identité PLAYER_3 (2) et que les quatre joueurs se nomment (dans l'ordre) Amélie (QW3DqWxpZQ==), Gaëlle (R2HDq2xsZQ==), Émile (w4ltaWxl) et Nadège (TmFkw6hnZQ==).

La ligne 2 correspond à l'appel à updateHand et communique au joueur que sa main est { ♠9,♠K,♡6,♡8,♡9,♡K,♢7,♣6,♣J } (210002008d008816).

La ligne 3 correspond à l'appel à setTrump et communique au joueur que l'atout est cœur (1).

La ligne 4 correspond à l'appel à updateScore et communique au joueur que le score initial des deux équipes est nul (0).

La ligne 5 correspond à l'appel à updateTrick et communique au joueur que le pli actuel est d'atout cœur, son index est 0, le premier joueur est PLAYER_3 et qu'il ne contient actuellement aucune carte (60ffffff16).

La ligne 6 correspond à l'appel à cardToPlay et communique au joueur l'état courant du tour ainsi que sa main, identique à celle qui lui a été transmise à la ligne 2.

La ligne 7 est la réponse du joueur, qui a décidé de jouer la carte ♡8 (1216).

Et ainsi de suite.

La structure des messages échangés entre le client et le serveur étant claire, il importe de dire quelques mots concernant la manière dont ils peuvent communiquer via Internet.

1.3 Communication via Internet

Les différents appareils connectés à Internet — généralement appelée hôtes (host) — communiquent entre eux au moyen d'un grand nombre de protocoles distincts, dont la description complète sort du cadre de cette étape. Néanmoins, une rapide introduction est nécessaire et les personnes intéressées par plus de détails peuvent suivre les liens vers Wikipedia inclus ci-dessous.

Les protocoles d'Internet sont organisés en différentes couches, correspondant à différents niveaux d'abstraction. Les protocoles d'une couche donnée sont toujours basés sur ceux de la couche juste en-dessous. Trois de ces couches et plusieurs protocoles importants de chacune d'entre elles sont illustrées sur la figure 1.

Sorry, your browser does not support SVG.

Figure 1 : Organisation en couches de quelques protocoles d'Internet

Comme nous le verrons plus loin, le protocole du jeu, désigné par Javass dans cette figure, est basé sur le protocole TCP de la couche inférieure, lui-même basé sur IP. Beaucoup de protocoles connus — p.ex. HTTP qui est le protocole du Web, ou SMTP et IMAP qui sont liés au courrier électronique — se basent également sur TCP.

1.3.1 IP

A la couche la plus basse qui nous intéresse ici se trouve le protocole de base d'Internet, nommé Internet Protocol et généralement abrégé IP. Ce protocole permet à deux hôtes connectés à Internet de s'échanger des messages, appelés paquets (packets), d'une taille maximale fixe. Aucune garantie n'est offerte quant à cette transmission, et il est donc possible que les paquets soient corrompus, perdus, dupliqués ou arrivent dans un ordre différent de celui d'envoi.

Bien entendu, l'échange de tels messages suppose l'existence d'un mécanisme permettant de désigner individuellement chacun des hôtes connecté au réseau, au même titre que l'expédition de lettres et paquets physiques suppose l'existance d'un mécanisme permettant de désigner une boîte aux lettres, l'adresse postale. Dans le cas d'Internet, ce rôle est joué par ce que l'on nomme l'adresse IP (IP address).

A l'origine, une adresse IP était un entier de 32 bits, généralement représenté par les quatre octets le composant, en base 10, séparés par un point. Par exemple, 128.178.243.14 est une telle adresse IP, en l'occurrence celle du serveur Web du cours. Une adresse IP sur 32 bits est appelée adresse IPv4. Certaines d'entre elles ont une signification particulière et prédeterminée. Il en va par exemple ainsi de l'adresse 127.0.0.1, qui désigne toujours l'hôte local (local host), c-à-d l'hôte sur laquelle on l'utilise.

Ces dernières années, au vu de la rapide augmentation du nombre d'hôtes connectés à Internet, il a été nécessaire d'augmenter la taille des adresses, 32 bits ne suffisant plus. Un nouveau format d'adresses, un entier de 128 bits, a donc été introduit et est appelé adresse IPv6. Ces adresses sont généralement représentées par huit groupes représentant chacun 16 bits, en base 16 et séparés par un deux-points. Par exemple, 2002:80B2:DE47:0:0:0:0:0 est une telle adresse.

Etant donné qu'il n'est pas facile pour les êtres humains de se souvenir d'adresses IP (v4 et encore moins v6) numériques, Internet est doté d'un service de noms nommé DNS (Domain Name System) permettant d'utiliser des noms alphanumériques au lieu d'adresses numériques. Par exemple, le nom cs108.epfl.ch correspond à l'adresse 128.178.243.14 mentionnée plus haut, et le nom localhost correspond à l'adresse de l'hôte local, 127.0.0.1.

Le protocole IP n'est presque jamais utilisé directement par des applications, mais sert de base à d'autres protocoles de plus haut niveau. Les deux principaux protocoles construits directement sur IP sont UDP et TCP. La différence principale entre UDP et TCP est que UDP ne garantit pas que les données envoyées par l'expéditeur soient effectivement reçues par le destinataire, tandis que TCP offre cette garantie.

1.3.2 TCP

TCP (Transmission Control Protocol) se base sur IP et y ajoute la notion de connexion et la fiabilité.

Cela signifie d'une part que deux hôtes désirant communiquer au moyen de ce protocole doivent dans un premier temps établir une connexion entre eux, un peu comme deux personnes désirant se parler au téléphone. Une fois la connexion établie, TCP permet aux deux hôtes de s'échanger des données de manière fiable, c-à-d sans aucune perte ou corruption. De plus, ces données sont échangées sous la forme de flots d'octets, et pas de paquet comme avec IP.

D'autre part, TCP augmente la notion d'adresse fournie par IP en lui ajoutant un numéro de port, un entier 16 bits non signé — compris donc entre 0 et 65 535. Ce numéro de port permet à plusieurs applications de se partager une même adresse IP pour peu qu'elles utilisent un numéro de port différent, un peu comme différentes personnes peuvent se partager une même adresse de bâtiment pour peu qu'une information additionnelle (p.ex. un numéro d'appartement) permette de les distinguer.

Les ports dont la valeur est inférieure à 1 024 sont réservés à des protocoles précis, et ne doivent pas être utilisées par d'autres applications. Par exemple, le port 25 du protocole TCP est réservé au protocole SMTP, qui sert à l'envoi de courriels.

1.4 Exemple client/serveur

Afin de rendre concret les différents concepts présentés ci-dessus, et voir comment il est possible de les mettre en œuvre en Java, un petit exemple complet est présenté plus bas. Il est constitué de deux programmes :

  1. un serveur « successeur » qui écoute sur le port TCP 5108 et, dès qu'une connexion y est faite, lit un message textuel d'une ligne constitué de la représentation textuelle, en base 10, d'un entier, puis répond au moyen d'une ligne constituée du successeur de cet entier,
  2. un client qui se connecte au serveur, lui envoie le message 2019 afin de connaître le successeur de ce nombre, et affiche le résultat (2020) à l'écran.

Le code du serveur est présenté en premier ci-après. La plupart de ce code est du code d'entrée/sortie standard, et le fait que ces entrées/sorties se fassent à travers le réseau n'est pas visible. Quelques points méritent toutefois d'être décrits plus en détails.

Pour commencer, le serveur crée une instance de ServerSocket qui est une « prise » (socket) permettant au serveur d'attendre des connexions TCP sur un port donné, ici 5108. Une fois cette prise créée, le serveur appelle sa méthode accept qui bloque l'exécution du programme jusqu'à ce qu'une connexion soit effectuée sur ce port. Une fois que cela se produit, accept retourne une nouvelle prise, de type Socket cette fois, qui peut être utilisée pour échanger des données avec l'entité connectée à l'autre bout, ici le client décrit plus bas. Cet échange de données se fait au moyen des flots d'entrée et de sortie de la prise, qui s'obtiennent au moyen des méthodes getInputStream et getOutputStream. Une fois obtenus, ces flots s'utilisent de manière tout à fait standard.

Une fois que le serveur a lu le nombre envoyé par le client, calculé son successeur et écrit la représentation textuelle de ce dernier sur le flot de sortie, il fait encore deux choses :

  • il écrit une fin de ligne (au moyen de w.write('\n')), la séquence \n représentant en Java le caractère de contrôle LF,
  • il appelle la méthode flush afin de vidanger le flot, ce qui permet de garantir que les données qui y ont été écrites jusqu'alors sont bien envoyées sur le réseau à ce moment-là.

Finalement, l'énoncé catch à la fin de la méthode main a pour but de transformer (en quelque sorte) les éventuelles exceptions d'entrées/sorties de type IOException en exceptions équivalentes mais de type UncheckedIOException. La différence entre les deux types d'exception est que le premier est un type d'exception checked, le second pas. Lorsqu'on ne désire pas traiter les exception de manière particulière, comme ici, il est plus simple d'avoir affaire au second type qu'au premier, car il n'est pas nécessaire d'ajouter les throws dans les déclarations des méthodes.

import static java.nio.charset.StandardCharsets.US_ASCII;

public final class SuccServer {
  public static void main(String[] args) {
    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))) {
      int i = Integer.parseInt(r.readLine());
      int i1 = i + 1;
      w.write(String.valueOf(i1));
      w.write('\n');
      w.flush();
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    }
  }
}

Le code du client est quant à lui présenté ci-dessous. Le seul commentaire qui mérite d'être fait à son sujet est que pour se connecter au serveur le client crée directement une prise de type Socket en passant en arguments au constructeur un nom d'hôte (ici localhost qui désigne l'ordinateur sur lequel s'exécute le client) et un port. Le fait de passer ces arguments au constructeur a pour effet d'établir directement la connexion au serveur. Il est donc immédiatement possible d'échanger des données avec le serveur en utilisant les mêmes techniques que ci-dessus.

import static java.nio.charset.StandardCharsets.US_ASCII;

public final class SuccClient {
  public static void main(String[] args) {
    try (Socket s = new Socket("localhost", 5108);
	 BufferedReader r =
	   new BufferedReader(
	     new InputStreamReader(s.getInputStream(),
				   US_ASCII));
	 BufferedWriter w =
	   new BufferedWriter(
	     new OutputStreamWriter(s.getOutputStream(),
				    US_ASCII))) {
      int i = 2019;
      w.write(String.valueOf(i));
      w.write('\n');
      w.flush();
      int succ = Integer.parseInt(r.readLine());
      System.out.printf("succ(%d) = %d%n", i, succ);
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    }
  }
}

2 Mise en œuvre Java

Cette étape est la première de la seconde partie du projet, durant laquelle vous serez plus libres et moins guidés que durant la première. En particulier, vous avez maintenant le droit de modifier ou augmenter l'interface publique des classes et interfaces proposées, pour peu bien entendu que vous ayez une bonne raison de le faire. De plus, nous nous attendons à ce que vous lisiez et compreniez la documentation des parties de la bibliothèque Java que vous devez utiliser.

Les noms des classes, interfaces et méthodes donnés ne sont dorénavant que des propositions, libre à vous d'en choisir d'autres. Cela dit, afin de ne pas compliquer inutilement le processus de correction, il vous est demandé de ne pas trop vous éloigner de la mise en œuvre que nous vous proposons.

Tout le code de cette étape étant lié à la communication réseau, il est conseillé de le placer dans un paquetage dédié à cela, créé pour l'occasion et nommé p.ex. ch.epfl.javass.net.

2.1 Classe StringSerializer

En programmation, l'action consistant à représenter des objets existants en mémoire en une suite d'octets ou de caractères afin p.ex. de les transmettre via le réseau se nomme sérialisation (en anglais : serialization ou encore marshalling ou pickling). Il vaut la peine d'avoir une classe facilitant la (dé)sérialisation des valeurs échangées entre le client et le serveur, et c'est le but de la classe StringSerializer du paquetage ch.epfl.javass.net.

Il s'agit d'une classe publique, finale et non instanciable, qui contient des méthodes statiques permettant de sérialiser et désérialiser, sous forme de chaînes de caractères, des valeurs des types suivants :

  • entiers de type int et long, sérialisés sous la forme de leur représentation textuelle en base 16 (méthodes nommées p.ex. serializeInt, deserializeInt, serializeLong et deserializeLong),
  • chaînes de caractères de type String, sérialisées par encodage en base64 des octets constituant leur encodage en UTF-8 (serializeString et deserializeString).

D'autre part, la classe StringSerializer contient deux méthodes simplifiant la (dé)sérialisation de valeurs composites comme TurnState ou la table associative des noms des joueurs :

  • la première prend un nombre variable de chaînes en argument, un caractère de séparation (ici la virgule) et retourne la chaîne composée des chaînes séparées par le séparateur (combine),
  • la seconde fait l'inverse et prend une chaîne unique et un caractère de séparation (ici la virgule) et retourne un tableau contenant les chaînes individuelles (split).

2.1.1 Conseils de programmation

La bibliothèque Java contient plusieurs classes et méthodes qui simplifient énormément la définition des méthodes décrites ci-dessus.

Pour encoder et décoder des chaînes en base64, on peut utiliser la classe Base64.

Pour obtenir les octets constituant l'encodage UTF-8 d'une chaîne de caractères, on peut utiliser la méthode getBytes en lui passant l'encodage UTF_8 de StandardCharsets. Pour obtenir une chaîne étant donné les octets constituant son encodage UTF-8, on peut utiliser un des constructeurs de la classe String en lui passant ce même encodage.

Pour représenter un entier en base 16, on peut utiliser la méthode toUnsignedString de la classe Integer (ou Long pour les entiers de type long). Pour obtenir un entier dont on a la représentation textuelle en base 16, on peut utiliser la méthode parseUnsignedInt de la classe Integer (ou l'équivalent de la classe Long).

Pour combiner plusieurs chaînes au moyen d'un séparateur, on peut utiliser la méthode join de la classe String. Pour faire l'inverse, la méthode split convient très bien. Vous pouvez simplement lui passer la chaîne représentant le séparateur (ici la virgule ou l'espace) sans vous soucier du fait que son argument est une expression régulière (regular expression), comme le dit la documentation.

2.2 Type énuméré JassCommand

Le type énuméré JassCommand du paquetage ch.epfl.javass.net énumère les 7 types de messages échangés par le client et le serveur (PLRS, TRMP, etc.).

Notez qu'au moyen de la méthode name de ce type énuméré, vous pouvez très facilement convertir une valeur de ce type en la chaîne correspondante. Pour faire l'inverse, tous les types énumérés offrent une méthode statique nommée valueOf, prenant une chaîne en argument et retournant l'élément de l'énumération portant le nom donné.

2.3 Classe RemotePlayerServer

La classe RemotePlayerServer du paquetage ch.epfl.javass.net, publique et finale, représente le serveur d'un joueur, qui attend une connexion sur le port 5108 et pilote un joueur local en fonction des messages reçus.

Elle offre un unique constructeur public auquel on passe le joueur local, de type Player, dont les différentes méthodes doivent être appelées en fonction des messages reçus via le réseau.

En plus du constructeur, elle offre une méthode ne prenant aucun argument et ne retournant rien, nommée p.ex. run, qui, dans une boucle infinie :

  1. attend un message du client,
  2. appelle la méthode correspondante du joueur local, et
  3. dans le cas de cardToPlay, renvoie la valeur de retour au client.

2.4 Classe RemotePlayerClient

La classe RemotePlayerClient du paquetage ch.epfl.javass.net, publique et finale, représente le client d'un joueur. Elle implémente bien entendu l'interface Player. Elle devrait de plus implémenter l'interface AutoCloseable afin de pouvoir être utilisée dans un énoncé try-with-resources.

Son constructeur prend en argument le nom de l'hôte sur lequel s'exécute le serveur du joueur distant, et s'y connecte.

Les différentes méthodes provenant de l'interface Player ne font rien d'autre qu'envoyer les messages correspondants au serveur et, dans le cas de cardToPlay, attendre sa réponse.

La méthode close quant à elle appelle les méthodes close des flots d'entrée et de sortie de la prise utilisée pour se connecter au serveur, ainsi que celle de la prise elle-même.

2.5 Tests

A partir de cette étape, aucun fichier de vérification de signatures ne vous est fourni, étant donné que la signature des différentes méthodes n'est plus spécifiée en détail. Pour la même raison, aucun test unitaire ne sera plus fourni à l'avenir, à vous d'écrire les vôtres. Notez que cela est fortement recommandé en général.

Pour tester cette étape, il est conseillé d'écrire un petit programme simulant une partie entre différents joueurs simulés ou aléatoires, dont au moins un est un joueur distant. Initialement, ce joueur distant peut s'exécuter sur le même ordinateur que la partie elle-même.

Une fois qu'il vous est possible de jouer une partie avec un joueur distant local, il est conseillé d'essayer de jouer avec le joueur distant d'un autre groupe, fonctionnant sur un autre ordinateur. Pour cela, il faut tout d'abord déterminer l'adresse IP de l'ordinateur sur lequel le serveur sera exécuté. Il existe de très nombreux moyens de l'obtenir, le plus simple étant probablement d'utiliser un site comme WhatIsMyIP. En cliquant sur ce lien depuis l'ordinateur exécutant le serveur, vous verrez s'afficher l'adresse IP de son ordinateur, que vous pourrez utiliser comme nom d'hôte passé au client.

Pour faire ces tests, assurez-vous d'être connecté au réseau de l'EPFL, soit par Wifi, soit par le réseau câblé. Pour différentes raisons — en particulier l'utilisation systématique de NAT — il est très peu probable que vous soyez en mesure d'échanger des données entre un serveur et un client situés sur des machines accédant à Internet via un fournisseur d'accès Internet commercial (Swisscom, Cablecom, etc.). Si vous désirez néanmoins effectuer des tests depuis l'extérieur de l'EPFL, connectez-vous dans un premier temps au VPN de l'EPFL.

3 Résumé

Pour cette étape, vous devez :

  • écrire les classes et types énumérés JassCommand, StringSerializer, RemotePlayerClient et RemotePlayerServer (ou équivalent) en fonction des indications données plus haut,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies.

Aucun rendu n'est à faire pour cette étape avant le rendu final. N'oubliez pas de faire régulièrement des copies de sauvegarde de votre travail en suivant nos indications à ce sujet.

Notes de bas de page

1

De très nombreux protocoles Internet sont basés sur l'échange de messages textuels simples encodés en ASCII, p.ex. SMTP (pour l'envoi d'e-mails), IMAP et POP (pour l'accès aux boîtes aux lettres électroniques) et même HTTP (pour l'accès au Web) avant la version 2. L'avantage de tels protocoles textuels est qu'ils sont très faciles à visualiser et à interpréter pour un être humain.