Jeu distant

tCHu – étape 8

1 Introduction

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

2 Concepts

2.1 Jeu distant

La classe Game écrite à l'étape 6 fait l'hypothèse qu'elle peut « dialoguer » avec les joueurs — qui implémentent l'interface Player — en appelant leurs différentes méthodes. Par exemple, pour savoir quelle action le joueur courant désire effectuer, la classe Game appelle la méthode nextTurn de ce joueur.

Or, comme cela a été dit à plusieurs reprises, au moins un des joueurs d'une partie se trouve sur un autre ordinateur que celui sur lequel la classe Game s'exécute. Cela pose problème, car Java n'offre pas la possibilité à un objet se trouvant sur un ordinateur donné d'appeler une méthode d'un objet se trouvant sur un autre ordinateur1. La solution que nous utiliserons pour résoudre ce problème a été esquissée à l'étape précédente, et consiste à faire en sorte que l'instance de Game « dialogue » avec les joueurs distants en échangeant avec eux des messages textuels à travers le réseau.

Pour cela, il serait bien entendu possible de modifier Game afin qu'elle ne communique plus avec les joueurs par appel de méthodes, mais plutôt par échange de messages textuels via le réseau. Cette solution serait toutefois peu élégante, car elle forcerait Game à toujours dialoguer avec les joueurs de la sorte, ce qui serait inutilement contraignant. Il existe en effet beaucoup de situations dans lesquelles au moins un des deux joueurs se trouve sur le même ordinateur — et dans le même programme — que l'instance de Game gérant la partie, qui peut alors communiquer avec lui au moyen de simples appels de méthodes.

Il est donc nettement préférable d'offrir la possibilité à un joueur d'être soit un joueur local — c-à-d un objet de type Player se trouvant dans le même programme que l'instance de Game gérant la partie —, soit un joueur distant — c-à-d un objet de type Player se trouvant sur un autre ordinateur. Ces deux joueurs doivent toutefois apparaître identiques à la classe Game.

Cela peut se faire en introduisant les deux nouveaux objets suivants, dont le but est de permettre à une instance de Player d'être utilisée à distance :

  1. ce que nous appellerons un mandataire (proxy), un objet de type Player qui se trouve dans le même programme que l'instance de Game gérant la partie, et qui a pour but de représenter le joueur distant se trouvant dans un autre programme — et généralement sur un autre ordinateur,
  2. ce que nous appellerons le client, un objet qui se trouve dans le même programme que le joueur distant, et qui agit sur lui en fonction des instructions qu'il reçoit du mandataire.

Le mandataire se trouve donc dans le programme que nous avons nommé le serveur à l'étape précédente, tandis que le client se trouve dans le programme que nous avons nommé le client. Notez bien que nous utilisons le terme « client » pour désigner à la fois :

  • le programme complet qu'un joueur (humain) exécute sur son propre ordinateur pour jouer à une partie distante, et
  • la partie de ce programme chargée de dialoguer avec le mandataire du joueur se trouvant dans le serveur.

Le contexte permet généralement de déterminer duquel des deux il est question.

Cette organisation est présentée à la figure 1 ci-dessous. On y voit le serveur et le client introduits à l'étape précédente, ainsi que des instances des classes qui nous intéressent ici : le mandataire du joueur distant, RemotePlayerProxy, et son client, RemotePlayerClient. Il est important de se souvenir que le serveur et le client s'exécutent généralement sur deux ordinateurs différents, reliés entre eux par le réseau.

Le serveur contient l'instance de Game qui gère la partie. Elle communique avec les joueurs — des instances de classes qui implémentent l'interface Player — en appelant leurs méthodes. Un de ces joueurs est un joueur distant, qui est représenté dans le serveur par une instance de RemotePlayerProxy.

Le client contient l'interface graphique du joueur distant, ainsi qu'une instance de RemotePlayerClient qui est chargée de dialoguer avec le mandataire du joueur — l'instance de RemotePlayerProxy se trouvant sur le serveur — et d'agir sur le joueur distant en fonction des messages reçus.

tchu-player-proxy-client;32.png
Figure 1 : Organisation du jeu distant

La figure 1 illustre ce qu'il se passe lorsque l'instance de Game désire demander au joueur distant quelle action il désire effectuer, car son tour vient de commencer :

  1. l'instance de Game appelle la méthode nextTurn du mandataire — qui, du point de vue de l'instance de Game, est le joueur distant,
  2. le mandataire envoie, via le réseau, un message textuel consistant en la chaîne NEXT_TURN au client du joueur,
  3. à la réception de ce message, le client appelle la méthode nextTurn du joueur distant, provoquant la mise à jour de l'interface graphique, qui indique au joueur humain que c'est à son tour de jouer, puis attend sa décision,
  4. en admettant que le joueur (humain) décide de tirer des cartes, la méthode nextTurn retourne la valeur TurnKind.DRAW_CARDS au client du joueur,
  5. le client du joueur sérialise cette valeur en la chaîne 1, selon la technique décrite à l'étape précédente, pour la transmettre en réponse au mandataire, via le réseau,
  6. à la réception de cette réponse, le mandataire la désérialise pour obtenir la valeur TurnKind.DRAW_CARDS, qu'il retourne en résultat de nextTurn.

Sachant que le joueur distant désire tirer une carte, l'instance de Game appelle alors la méthode drawSlot du mandataire, qui envoie un message textuel DRAW_SLOT au client, et ainsi de suite.

2.2 Communication via le réseau

Pour communiquer via le réseau, le mandataire et le client s'échangent des messages textuels transmis au moyen du protocole TCP, un des protocoles d'Internet. La description complète de ce protocole sort du cadre de ce cours, mais une rapide introduction est donnée ci-dessous, et les personnes intéressées par plus de détails se rapporteront à Wikipedia en suivant les liens donnés dans le texte.

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

ip-stack;16.png
Figure 2 : Organisation en couches de quelques protocoles d'Internet

Comme dit plus haut, le protocole du jeu, désigné par tCHu 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.

2.2.1 IP

À 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 (hosts) — souvent des ordinateurs — 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). Il existe deux sortes d'adresses IP — nommées IPv4 et IPv6 — mais seules les premières sont décrites ci-dessous, car elles sont de loin les plus fréquemment utilisées.

Une adresse IPv4 est une valeur de 32 bits, généralement représentée par les quatre octets la composant, en base 10, séparés par un point. Par exemple, 128.178.243.14 est la représentation d'une telle adresse, en l'occurrence celle de la machine hébergeant le serveur Web au cours. Certaines adresses ont une signification particulière et prédéterminée. Par exemple, l'adresse 127.0.0.1 désigne toujours l'hôte local (local host), c-à-d l'hôte sur laquelle on l'utilise. Par exemple, si vous lisez ce texte sur un ordinateur, une tablette ou un téléphone, l'hôte local est cet appareil.

Comme il n'est pas facile pour un humain de se souvenir d'une adresse IP numérique, Internet est doté d'un service de noms nommé DNS (Domain Name System), qui joue le rôle d'annuaire et permet 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.

2.2.2 TCP

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

Le fait que TCP requiert une connexion signifie 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. Ces données sont échangées sous la forme de flots d'octets, et pas de paquets comme avec IP.

TCP augmente la notion d'adresse fournie par IP en lui ajoutant un numéro de port (port number), un entier compris 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.

2.3 Exemple client/serveur

Afin de rendre plus concrets 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 2021 afin de connaître le successeur de ce nombre, et affiche le résultat (2022) à 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 habituelle.

Lorsque 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 un retour à la ligne — le caractère de contrôle LF pour être précis,
  • 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 d'attraper les exceptions de type IOException et de les lever à nouveau, en quelque sorte, sous forme d'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 alors 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 = 2021;
      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);
    }
  }
}

En exécutant d'abord le serveur, puis le client, ce dernier devrait afficher le message suivant à l'écran :

succ(2021) = 2022

Cela fait, les deux programmes devraient terminer leur exécution. Notez que lorsque le serveur est lancé, il bloque sur l'appel à accept tant et aussi longtemps que le client ne se connecte pas à lui.

3 Mise en œuvre Java

Les deux classes à mettre en œuvre pour cette étape sont celles représentant le mandataire du joueur distant et son client, décrits plus haut. Pour les écrire, vous pouvez bien entendu vous inspirer de l'exemple du serveur successeur ci-dessus, le mandataire étant similaire au serveur successeur, le client étant similaire au client successeur.

Il vous faut toutefois tenir compte d'une différence très importante existant entre l'exemple ci-dessus et le projet tCHu : dans l'exemple, c'est le client qui envoie une demande (un message) au serveur, et le serveur lui répond ; dans le projet tCHu, c'est au contraire le serveur qui envoie — à travers le mandataire — des demandes au client, et le client lui répond.

3.1 Classe RemotePlayerProxy

La classe instanciable RemotePlayerProxy du paquetage ch.epfl.tchu.net représente un mandataire (proxy en anglais) de joueur distant. Elle implémente l'interface Player et peut ainsi jouer le rôle d'un joueur.

Son constructeur prend en argument la « prise » (socket), de type Socket, que le mandataire utilise pour communiquer à travers le réseau avec le client par échange de messages textuels.

Les seules méthodes publiques offertes par cette classe sont les mises en œuvre concrètes des méthodes de l'interface Player. Chacune d'entre elles fonctionne de la même manière :

  • les éventuels arguments de la méthode sont sérialisés individuellement au moyen des serdes écrits à l'étape précédente,
  • le texte du message est construit en séparant au moyen du caractère d'espacement les éléments suivants, dans l'ordre :
    1. le nom du message, qui est celui de l'élément du type énuméré MessageId qui correspond à la méthode,
    2. les arguments, dans le même ordre que la méthode les accepte,
    3. un retour à la ligne (code ASCII 10, écrit \n en Java),
  • le message est envoyé sur le réseau via la « prise »,
  • si la méthode retourne une valeur — c-à-d que son type de retour n'est pas void — alors une ligne est lue depuis le réseau, désérialisée au moyen du serde correspondant au type de retour de la méthode, et retournée.

Notez que les noms des joueurs passés à la méthode initPlayers dans une table associative de type Map<PlayerId, String> sont sérialisés comme une liste de chaînes de caractères. Le premier élément de cette liste est le nom du premier joueur, le second le nom du second. En d'autres termes, la table associative passée à initiPlayers est sérialisée de manière similaire à celle de PublicGameState contenant l'état (public) des joueurs.

Par exemple, le message correspondant à l'appel à initPlayers suivant :

player.initPlayers(
  PLAYER_1,
  Map.of(PLAYER_1, "Ada", PLAYER_2, "Charles"));

est le message :

INIT_PLAYERS 0 QWRh,Q2hhcmxlcw==

QWRh étant la sérialisation de la chaîne Ada, Q2hhcmxlcw== celle de la chaîne Charles.

3.1.1 Conseils de programmation

Si vous avez utilisé les noms suggérés à l'étape précédente pour les éléments du type énuméré MessageId, alors vous pouvez obtenir la chaîne correspondant au nom d'un message au moyen de la méthode name. Son utilisation est illustrée par le test JUnit ci-dessous, qui s'exécute avec succès :

assertEquals("INIT_PLAYERS", MessageId.INIT_PLAYERS.name());

De plus, pour simplifier votre code, vous pouvez définir des méthodes auxiliaires permettant de :

  1. envoyer un message étant donné son identité et les chaînes de caractères correspondants à la sérialisation de ses arguments,
  2. recevoir un message.

Chacune de ces deux méthodes contient un try/catch similaire à celui donné en exemple plus haut et attrapant les exceptions de type IOException pour les lever à nouveau sous la forme d'instances de UncheckedIOException.

Notez finalement que comme les messages échangés sur le réseau, et leurs réponses, sont constitués chacun d'une ligne ne contenant que des caractères ASCII, l'utilisation d'instances de BufferedReader et BufferedWriter créées comme dans l'exemple plus haut simplifie énormément les choses. En particulier, la méthode readLine de BufferedReader peut alors être utilisée pour lire un message. Attention toutefois à ne surtout pas utiliser la méthode newLine de BufferedWriter pour terminer un message, car ceux-ci doivent impérativement se terminer par un unique retour à la ligne (\n), or sur certaines plateformes (p.ex. Windows), newLine produit d'autres caractères.

3.2 Classe RemotePlayerClient

La classe instanciable RemotePlayerClient du paquetage ch.epfl.tchu.net représente un client de joueur distant.

Son constructeur prend en arguments le joueur (de type Player) auquel elle doit fournir un accès distant, ainsi que le nom (de type String) et le port (de type int) à utiliser pour se connecter au mandataire.

En dehors du constructeur, cette classe n'offre qu'une seule méthode publique, nommée p.ex. run, qui ne prend aucun argument et ne retourne aucun résultat. Cette méthode effectue une boucle durant laquelle elle :

  • attend un message en provenance du mandataire,
  • le découpe en utilisant le caractère d'espacement comme séparateur,
  • détermine le type du message en fonction de la première chaîne résultant du découpage,
  • en fonction de ce type de message, désérialise les arguments, appelle la méthode correspondante du joueur ; si cette méthode retourne un résultat, le sérialise pour le renvoyer au mandataire en réponse.

3.2.1 Conseils de programmation

Si vous avez utilisé les noms suggérés à l'étape précédente pour les éléments du type énuméré MessageId, alors vous pouvez obtenir l'élément de ce type énuméré correspondant à une chaîne au moyen de la méthode valueOf, qui constitue en quelque sorte l'inverse de la méthode name. Son utilisation est illustrée par le test JUnit ci-dessous, qui s'exécute avec succès :

assertEquals(MessageId.INIT_PLAYERS,
	     MessageId.valueOf("INIT_PLAYERS"));

De plus, souvenez-vous que pour découper un message en ses composants, vous pouvez utiliser la méthode split de String. N'oubliez pas de passer la chaîne de séparation à la méthode quote de Pattern au préalable !

3.3 Tests

Pour tester vos deux classes, il est relativement facile d'écrire un petit serveur d'exemple comme celui-ci :

public final class TestServer {
  public static void main(String[] args) throws IOException {
    System.out.println("Starting server!");
    try (ServerSocket serverSocket = new ServerSocket(5108);
	 Socket socket = serverSocket.accept()) {
      Player playerProxy = new RemotePlayerProxy(socket);
      var playerNames = Map.of(PLAYER_1, "Ada",
			       PLAYER_2, "Charles");
      playerProxy.initPlayers(PLAYER_1, playerNames);
    }
    System.out.println("Server done!");
  }
}

ainsi qu'un petit client d'exemple comme celui-ci (à compléter) :

public final class TestClient {
  public static void main(String[] args) {
    System.out.println("Starting client!");
    RemotePlayerClient playerClient =
      new RemotePlayerClient(new TestPlayer(),
			     "localhost",
			     5108);
    playerClient.run();
    System.out.println("Client done!");
  }

  private final static class TestPlayer implements Player {
    @Override
    public void initPlayers(PlayerId ownId,
			    Map<PlayerId, String> names) {
      System.out.printf("ownId: %s\n", ownId);
      System.out.printf("playerNames: %s\n", names);
    }

    // … autres méthodes de Player
  }
}

En lançant d'abord le serveur, vous devriez constater qu'il démarre, affiche :

Starting server!

à l'écran, puis bloque sur l'appel à accept, en attendant la connexion du client. En lançant alors le client — sans terminer l'exécution du serveur, bien entendu —, vous devriez voir ce dernier afficher :

Starting client!
ownId: PLAYER_1
playerNames: {PLAYER_1=Ada, PLAYER_2=Charles}
Client done!

tandis que le serveur devrait afficher :

Server done!

Vous pouvez bien entendu augmenter ce serveur et ce client de test pour essayer d'appeler toutes les méthodes de Player, pas seulement initPlayers.

4 Résumé

Pour cette étape, vous devez :

  • écrire les classes RemotePlayerProxy et RemotePlayerClient selon les indications données ci-dessus,
  • 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

En fait, un objet Java ne peut appeler que des méthodes d'objets se trouvant dans le même programme que lui. Il n'est donc jamais possible à un objet se trouvant dans un programme donné d'appeler des méthodes d'objets se trouvant dans un autre programme, même si les deux programmes sont en cours d'exécution sur le même ordinateur.