Partie de Jass
Javass – étape 5

1 Introduction

Le but de cette cinquième étape est d'écrire les classes permettant de représenter une partie de Jass. Une fois que vous l'aurez terminée, il vous sera possible de simuler une partie aléatoire complète.

1.1 Déroulement d'une partie

Comme cela a été brièvement expliqué dans l'introduction du projet, une partie de Jass se déroule en une succession de tours, jusqu'à ce qu'une équipe ait le nombre de points nécessaire pour gagner.

Un tour consiste en une succession de 9 plis. Lors du premier tour d'une partie, le joueur qui débute le premier pli est celui qui possède le sept de carreau. Ensuite, le joueur qui débute le second tour est le joueur suivant — placé à droite du joueur ayant débuté le premier tour —, et ainsi de suite.

Dans le jeu de Jass normal, l'atout est choisi au début du tour par le premier joueur ou son partenaire. Afin de simplifier les choses pour ce projet, nous avons toutefois décidé de changer les règles et de choisir l'atout aléatoirement au début de chaque tour.

1.2 Vitesse de jeu

Le fait que les ordinateurs modernes soient très rapides est généralement un avantage mais pose deux problèmes dans le cadre de ce projet :

  1. dans certaines situations, il peut arriver que le joueur simulé joue instantanément, p.ex. lorsqu'il n'a qu'une carte jouable en main, ce qui peut révéler de l'information aux autres joueurs,
  2. lorsque la dernière carte d'un pli est posée, si le pli est ramassé instantanément pour que le prochain tour commence, les joueurs humains n'ont pas le temps de voir la carte en question.

Afin de résoudre ces deux problèmes, nous introduirons des délais à des moments importants du jeu, de la manière suivante :

  • lorsqu'un joueur — généralement simulé — joue plus rapidement qu'un temps minimum donné, un délai artificiel est introduit pour cacher ce fait,
  • lorsque la dernière carte d'un pli est déposée, le pli n'est ramassé qu'après qu'un certain délai se soit écoulé.

Le premier type de délai sera géré au moyen d'un joueur spécial, à réaliser dans le cadre de cette étape. Le second sera quant à lui géré lors d'une étape ultérieure, mais aura un impact indirect sur cette étape-ci, puisque la classe qui simule une partie complète devra offrir la possibilité de faire progresser celle-ci pli par pli.

1.3 Hasard

Le hasard intervient à plusieurs moments dans une partie de Jass, p.ex. au début d'un tour lors du mélange des cartes ou du choix de l'atout (selon nos règles simplifiées). La question de savoir comment obtenir un comportement aléatoire dans un programme Java se pose donc.

La solution retenue habituellement en informatique consiste à utiliser ce que l'on nomme un générateur de nombres pseudo-aléatoires (pseudorandom number generator, abrégé PRNG ou simplement RNG). Un PRNG est un algorithme capable de générer une séquence de nombres qui semble aléatoire et satisfait certains critères statistiques.

Pour initialiser un PRNG, on lui fournit un nombre que l'on nomme une graine (seed) et qui détermine totalement la séquence de valeurs qu'il produit par la suite. C'est la raison pour laquelle un tel générateur est dit pseudo-aléatoire, car il est en fait totalement déterministe.

Dans la bibliothèque Java, il existe deux classes représentant les PRNGs : Random et SplittableRandom. Elles appartiennent toutes deux au paquetage java.util et se ressemblent beaucoup. La première est plus ancienne, et donc utilisée plus souvent, mais la seconde a l'avantage d'être plus rapide. Dans cette étape, nous utiliserons la première, tandis que dans le joueur simulé, nous utiliserons la seconde.

Ces deux classes possèdent un constructeur prenant en argument la graine à utiliser, ainsi que des méthodes permettant d'obtenir des nombres aléatoires dans des bornes données. Par exemple, Random fournit deux variantes d'une méthode nommée nextInt :

  • nextInt() retourne un entier de type int compris entre Integer.MIN_VALUE (inclus) et Integer.MAX_VALUE (inclus),
  • nextInt(int bound) retourne un entier de type int compris entre 0 (inclus) et bound (exclus).

Ces entiers sont uniformément distribués dans les intervalles spécifiés. Différentes variantes d'une méthode nommée nextLong permettent d'obtenir des entiers de type long de manière similaire.

L'extrait de programme ci-dessous montre comment utiliser un PRNG de type Random initialisé avec la graine 2019 pour obtenir une séquence de 5 entiers uniformément distribués entre 0 et 1000 (exclus) :

Random rng = new Random(2019);
for (int i = 0; i < 5; ++i)
  System.out.println(rng.nextInt(1000));

Lorsqu'on l'exécute, il affiche successivement 420, 330, 115, 821 et 531.

2 Mise en œuvre Java

Les classes et interfaces à réaliser dans le cadre de cette étape permettent soit de représenter l'état d'une partie (JassGame) ou d'un tour (TurnState), ainsi que les joueurs (Player et PacedPlayer).

2.1 Classe TurnState

La classe TurnState du paquetage ch.epfl.javass.jass, publique, finale et immuable, représente l'état d'un tour de jeu. Celui-ci est formé de trois attributs, que TurnState stocke sous forme empaquetée :

  1. le score actuel,
  2. l'ensemble des cartes qui n'ont pas encore été jouées durant le tour, et
  3. le pli actuel.

Si l'utilité du score et du pli actuels est évidente, celle de l'ensemble des cartes qui n'ont pas encore été jouées l'est peut-être moins : cet attribut est destiné à être utilisé par le joueur simulé pour déterminer les cartes que les autres joueurs sont susceptibles de posséder.

Comme plusieurs classes définies dans les étapes précédentes, TurnState n'offre pas de constructeur public, mais des méthodes statiques qui en jouent le rôle et qui sont au nombre de deux :

  • TurnState initial(Color trump, Score score, PlayerId firstPlayer), qui retourne l'état initial correspondant à un tour de jeu dont l'atout, le score initial et le joueur initial sont ceux donnés,
  • TurnState ofPackedComponents(long pkScore, long pkUnplayedCards, int pkTrick), qui retourne l'état dont les composantes (empaquetées) sont celles données, ou lève IllegalArgumentException si l'une d'entre elles est invalide selon la méthode isValid correspondante.

En plus de ces méthodes de construction, la classe TurnState offre des accesseurs permettant d'obtenir les versions empaquetées de ses attributs :

  • long packedScore(), qui retourne la version empaquetée du score courant,
  • long packedUnplayedCards(), qui retourne la version empaquetée de l'ensemble des cartes pas encore jouées,
  • int packedTrick(), qui retourne la version empaquetée du pli courant,

ainsi que des accesseurs permettant d'obtenir les versions objets de ces mêmes attributs :

  • Score score(), qui retourne le score courant,
  • CardSet unplayedCards(), qui retourne l'ensemble des cartes pas encore jouées,
  • Trick trick(), qui retourne le pli courant.

Finalement, la classe TurnState offre encore les méthodes suivantes :

  • boolean isTerminal(), qui retourne vrai ssi l'état est terminal, c-à-d si le dernier pli du tour a été joué,
  • PlayerId nextPlayer(), qui retourne l'identité du joueur devant jouer la prochaine carte, ou lève l'exception IllegalStateException si le pli courant est plein,
  • TurnState withNewCardPlayed(Card card), qui retourne l'état correspondant à celui auquel on l'applique après que le prochain joueur ait joué la carte donnée, ou lève IllegalStateException si le pli courant est plein,
  • TurnState withTrickCollected(), qui retourne l'état correspondant à celui auquel on l'applique après que le pli courant ait été ramassé, ou lève IllegalStateException si le pli courant n'est pas terminé (c-à-d plein),
  • TurnState withNewCardPlayedAndTrickCollected(Card card), qui retourne l'état correspondant à celui auquel on l'applique après que le prochain joueur ait joué la carte donnée, et que le pli courant ait été ramassé s'il est alors plein ; lève IllegalStateException si le pli courant est plein.

Notez qu'un appel à withNewCardPlayedAndTrickCollected est équivalent à un appel à withNewCardPlayed, suivi d'un appel à withTrickCollected si le pli peut être ramassé à ce moment.

2.2 Interface Player

L'interface Player du paquetage ch.epfl.javass.jass, publique, représente un joueur. Elle est destinée à être implémentée par les différents types de joueurs que nous réaliserons par la suite — joueur simulé, joueur humain, etc. Elle ne possède qu'une seule méthode abstraite :

  • Card cardToPlay(TurnState state, CardSet hand), qui retourne la carte que le joueur désire jouer, sachant que l'état actuel du tour est celui décrit par state et que le joueur a les cartes hand en main.

Le fait de passer au joueur les cartes de sa main en argument évite qu'il ne doive les mémoriser, ce qui simplifie la mise en œuvre des joueurs.

En plus de la méthode cardToPlay, l'interface Player possède plusieurs méthodes dont la mise en œuvre par défaut est vide. Elles permettent d'informer le joueur d'événements qui se produisent lors du déroulement de la partie, et seront principalement utilisées pour mettre à jour l'interface graphique. Il s'agit de :

  • void setPlayers(PlayerId ownId, Map<PlayerId, String> playerNames), qui est appelée une seule fois en début de partie pour informer le joueur qu'il a l'identité ownId et que les différents joueurs (lui inclus) sont nommés selon le contenu de la table associative playerNames,
  • void updateHand(CardSet newHand), qui est appelée chaque fois que la main du joueur change — soit en début de tour lorsque les cartes sont distribuées, soit après qu'il ait joué une carte — pour l'informer de sa nouvelle main,
  • void setTrump(Color trump), qui est appelée chaque fois que l'atout change — c-à-d au début de chaque tour — pour informer le joueur de l'atout,
  • void updateTrick(Trick newTrick), qui est appelée chaque fois que le pli change, c-à-d chaque fois qu'une carte est posée ou lorsqu'un pli terminé est ramassé et que le prochain pli (vide) le remplace,
  • void updateScore(Score score), qui est appelée chaque fois que le score change, c-à-d chaque fois qu'un pli est ramassé,
  • void setWinningTeam(TeamId winningTeam), qui est appelée une seule fois dès qu'une équipe à gagné en obtenant 1000 points ou plus.

La raison pour laquelle ces méthodes ont une mise en œuvre par défaut vide est qu'elles ne sont pas utilisées par le joueur simulé, dont la classe peut donc se contenter de définir la méthode cardToPlay.

2.3 Classe PacedPlayer

La classe PacedPlayer du paquetage ch.epfl.javass.jass, publique et finale, permet de s'assurer qu'un joueur met un temps minimum pour jouer. Comme cette classe représente un joueur, elle implémente l'interface Player. De plus, elle offre un unique constructeur public :

  • PacedPlayer(Player underlyingPlayer, double minTime), qui retourne un joueur qui se comporte exactement comme le joueur sous-jacent donné (underlyingPlayer), si ce n'est que la méthode cardToPlay ne retourne jamais son résultat en un temps inférieur à minTime secondes.

Les seules méthodes publiques offertes par PacedPlayer sont celles de l'interface Player, et à l'exception de cardToPlay, elles ne font rien d'autre qu'appeler les méthodes correspondantes du joueur sous-jacent.

2.3.1 Conseils de programmation

Pour garantir que la méthode cardToPlay prenne le temps minimum qu'elle doit prendre, on peut procéder ainsi :

  • mémoriser l'heure courante au début de la méthode,
  • appeler la méthode cardToPlay du joueur sous-jacent,
  • consulter à nouveau l'heure courante, et s'il ne s'est pas écoulé assez de temps, attendre le temps restant sans rien faire.

Pour déterminer l'heure courante, le plus simple est d'utiliser la méthode statique currentTimeMillis de la classe System, qui la retourne sous la forme du nombre de millisecondes écoulées depuis le 1er janvier 1970 à minuit.

Pour attendre sans rien faire, il faut utiliser la méthode statique sleep de la classe Thread. Notez qu'elle peut lever l'exception InterruptedException, que vous pouvez simplement ignorer en écrivant quelque chose comme :

try {
  Thread.sleep(…);
} catch (InterruptedException e) { /* ignore */ }

2.4 Classe JassGame

La classe JassGame du paquetage ch.epfl.javass.jass, publique et finale, représente une partie de Jass. Elle offre un unique constructeur public :

  • JassGame(long rngSeed, Map<PlayerId, Player> players, Map<PlayerId, String> playerNames), qui construit une partie de Jass avec la graine aléatoire et les joueurs donnés, dont l'identité est donnée par la table associative players et le nom par la table associative playerNames (voir les conseils de programmation plus bas).

En plus de ce constructeur, la classe JassGame offre les méthodes publiques suivantes :

  • boolean isGameOver(), qui retourne vrai ssi la partie est terminée,
  • void advanceToEndOfNextTrick(), qui fait avancer l'état du jeu jusqu'à la fin du prochain pli, ou ne fait rien si la partie est terminée.

Attention : la méthode advanceToEndOfNextTrick ne doit pas ramasser le pli courant, qui doit systématiquement être plein lorsqu'elle retourne ! Cela est nécessaire afin que nous puissions plus tard introduire le délai mentionné à la §1.2.

2.4.1 Conseils de programmation

  1. Méthodes auxiliaires

    La méthode advanceToEndOfNextTrick est relativement complexe à écrire, il vaut donc la peine de définir quelques méthodes auxiliaires privées, permettant p.ex. de mélanger et distribuer les cartes, débuter un tour de jeu ou encore faire jouer le prochain joueur.

  2. Classe EnumMap

    Le constructeur de JassGame doit copier et stocker dans des attributs (idéalement immuables) les deux tables associatives qu'il reçoit. Bien entendu, il serait possible de faire cela au moyen de la technique donnée dans le cours, à savoir ainsi :

    this.players = unmodifiableMap(new HashMap<>(players));
    // … idem pour playerNames
    

    Toutefois, comme les clefs des deux tables associatives ont le type PlayerId, qui est un type énuméré, il est possible de faire légèrement mieux en utilisant la classe EnumMap de la bibliothèque Java au lieu de HashMap. Cette classe n'est utilisable que lorsque les clefs sont d'un type énuméré, et a la caractéristique d'être passablement plus rapide et moins gourmande en mémoire que HashMap. Cela n'a que peu d'importance ici vu la taille des deux tables, mais il est bon de connaître l'existence de EnumMap.

  3. Générateurs aléatoires

    Le constructeur de JassGame reçoit en argument une graine de PRNG qu'elle doit utiliser pour générer les nombre aléatoires nécessaires au mélange des cartes et au choix de l'atout.

    Normalement, vous seriez libre d'utiliser cette graine comme bon vous semble, mais afin de garantir qu'une graine donnée produit exactement la même partie dans tous les projets, il vous est demandé de définir deux PRNGs de type Random dans votre classe, et d'utiliser le premier exclusivement pour mélanger les cartes, et le second exclusivement pour choisir l'atout. De plus, vous devez les initialiser ainsi :

    public JassGame(long rngSeed, /* autres args. */) {
      Random rng = new Random(rngSeed);
      this.shuffleRng = new Random(rng.nextLong());
      this.trumpRng = new Random(rng.nextLong());
      // … reste du constructeur
    }
    

    Pour mélanger les cartes, il suffit de construire une liste de toutes les cartes puis de la passer en premier argument à la méthode shuffle de Collections, le PRNG étant passé en second argument :

    List<Card> deck = …;
    Collections.shuffle(deck, shuffleRng);
    

    Une fois mélangées de la sorte, les cartes sont distribuées par groupes de 9 aux joueurs successifs : les 9 premières sont données au joueur 1, les 9 suivantes au joueur 2, et ainsi de suite.

    Notez que la liste des cartes à mélanger doit être reconstruite au début de chaque tour. Donc avant l'appel à shuffle elle doit toujours contenir les cartes dans l'ordre normal, c-à-d avec le 6 de pique en première position, suivi du 7 de pique et ainsi de suite.

2.5 Tests

Comme d'habitude, nous ne vous fournissons pas de tests mais un fichier de vérification de signatures contenu dans une archive Zip à importer dans votre projet.

Nous vous conseillons donc d'écrire d'une part des tests unitaires, et d'autre part un petit programme simulant une partie aléatoire complète. Pour cela, il vous faut au minimum écrire :

  • une classe simulant un joueur jouant totalement au hasard, nommée p.ex. RandomPlayer,
  • une classe similaire à PacedPlayer mais permettant d'afficher les informations reçues par un joueur, nommée p.ex. PrintingPlayer,
  • une classe principale mettant le tout ensemble.

Pour vous aider à démarrer, nous vous proposons des versions très basiques de ces trois classes, à commencer par RandomPlayer :

public final class RandomPlayer implements Player {
  private final Random rng;

  public RandomPlayer(long rngSeed) {
    this.rng = new Random(rngSeed);
  }

  @Override
  public Card cardToPlay(TurnState state, CardSet hand) {
    CardSet playable = state.trick().playableCards(hand);
    return playable.get(rng.nextInt(playable.size()));
  }
}

suivie de PrintingPlayer (seule une méthode est donnée en exemple, les autres sont à écrire) :

public final class PrintingPlayer implements Player {
  private final Player underlyingPlayer;

  public PrintingPlayer(Player underlyingPlayer) {
    this.underlyingPlayer = underlyingPlayer;
  }

  @Override
  public Card cardToPlay(TurnState state, CardSet hand) {
    System.out.print("C'est à moi de jouer... Je joue : ");
    Card c = underlyingPlayer.cardToPlay(state, hand);
    System.out.println(c);
    return c;
  }

  // … autres méthodes de Player (à écrire)
}

et finalement de RandomJassGame qui contient le programme principal :

public final class RandomJassGame {
  public static void main(String[] args) {
    Map<PlayerId, Player> players = new HashMap<>();
    Map<PlayerId, String> playerNames = new HashMap<>();

    for (PlayerId pId: PlayerId.ALL) {
      Player player = new RandomPlayer(2019);
      if (pId == PlayerId.PLAYER_1)
	player = new PrintingPlayer(player);
      players.put(pId, player);
      playerNames.put(pId, pId.name());
    }

    JassGame g = new JassGame(2019, players, playerNames);
    while (! g.isGameOver()) {
      g.advanceToEndOfNextTrick();
      System.out.println("----");
    }
  }
}

En l'exécutant, on voit une partie aléatoire complète ce jouer, qui commence ainsi (seules les 25 premières lignes sont montrées, et elles ont été numérotées) :

 1: Les joueurs sont :
 2:   PLAYER_1 (moi)
 3:   PLAYER_2
 4:   PLAYER_3
 5:   PLAYER_4
 6: Ma nouvelle main : {♠6,♡10,♡Q,♡A,♢6,♢9,♢K,♣6,♣J}
 7: Atout : ♠
 8: Scores: (0,0,0)/(0,0,0)
 9: Pli 0, commencé par PLAYER_2 : 
10: Pli 0, commencé par PLAYER_2 : ♣9
11: Pli 0, commencé par PLAYER_2 : ♣9, ♠8
12: Pli 0, commencé par PLAYER_2 : ♣9, ♠8, ♠J
13: C'est à moi de jouer... Je joue : ♣J
14: Ma nouvelle main : {♠6,♡10,♡Q,♡A,♢6,♢9,♢K,♣6}
15: Pli 0, commencé par PLAYER_2 : ♣9, ♠8, ♠J, ♣J
16: ----
17: Scores: (0,0,0)/(1,22,0)
18: Pli 1, commencé par PLAYER_4 : 
19: Pli 1, commencé par PLAYER_4 : ♠10
20: C'est à moi de jouer... Je joue : ♠6
21: Ma nouvelle main : {♡10,♡Q,♡A,♢6,♢9,♢K,♣6}
22: Pli 1, commencé par PLAYER_4 : ♠10, ♠6
23: Pli 1, commencé par PLAYER_4 : ♠10, ♠6, ♠A
24: Pli 1, commencé par PLAYER_4 : ♠10, ♠6, ♠A, ♠Q
25: ----

3 Résumé

Pour cette étape, vous devez :

  • écrire les classes et interfaces TurnState, Player, PacedPlayer et JassGame selon les instructions données plus haut,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies,
  • rendre votre code au plus tard le 22 mars 2019 à 16h30, via le système de rendu.

Ce rendu est un rendu testé, auquel 18 points sont attribués, au prorata des tests unitaires passés avec succès. Notez que la documentation de votre code ne sera pas évaluée avant le rendu intermédiaire. Dès lors, si vous êtes en retard, ne vous en préoccupez pas pour l'instant.

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é !