Ensembles de tuiles et coups

Ajul – étape 2

1. Introduction

Cette seconde étape a pour but d'écrire des classes permettant de manipuler des ensembles de tuiles — utiles pour représenter les fabriques, la zone centrale, et le sac de tuiles — ainsi que les coups joués par les joueurs.

Avant de la commencer, lisez les (courts) guides Synchroniser son travail et Sauvegarder son travail, qui vous donneront des conseils importants concernant la synchronisation et la sauvegarde de votre projet au cours du semestre.

2. Concepts

2.1. Coups

Comme nous l'avons vu dans l'introduction au projet, lorsque c'est à un joueur de jouer, il doit prendre la totalité des tuiles d'une couleur donnée d'une source (fabrique ou zone centrale) et les placer sur une destination (ligne motif ou plancher) de son choix, sur son plateau personnel. En d'autres termes, un coup (move) est caractérisé par trois éléments :

  1. la source de laquelle les tuiles sont prises,
  2. la couleur des tuiles choisies,
  3. la destination sur laquelle les tuiles sont placées.

Il faut noter que la destination est celle choisie par le joueur, mais il est possible que cette destination n'ait pas une capacité suffisante pour recevoir la totalité des tuiles. Certaines d'entre elles peuvent donc devoir être placées sur la ligne plancher, voire même — si cette dernière est pleine — retirées du plateau.

Les coups font partie des éléments du jeu que nous devrons représenter, et il convient donc d'examiner comment faire cela.

2.1.1. Représentation objet

La manière la plus naturelle de représenter un coup en Java est certainement de définir une classe dotée de trois attributs — la source, la couleur de tuile et la destination du coup — qui pourrait ressembler à ceci :

public final class Move {
  private final TileSource source;
  private final TileKind.Colored tileColor;
  private final TileDestination destination;

  // … constructeur, accesseurs, méthodes
}

TileSource, TileKind.Colored et TileDestination sont les types définis à l'étape précédente.

Cette représentation est tout à fait valide et facile à utiliser, et c'est elle que nous vous recommanderions d'utiliser dans la plupart des cas. Une classe très similaire à celle-ci est d'ailleurs à définir dans le cadre de cette étape (§3.4), et sera utilisée dans certaines parties du projet.

Toutefois, nous utiliserons aussi des coups dans la mise en œuvre de notre « intelligence artificielle », qui fonctionne en simulant un très grand nombre de parties. Afin que ces simulations puissent se faire aussi rapidement que possible, il est important que toutes les données qu'elles utilisent soient représentées de manière aussi compacte que possible.

Dès lors, nous représenterons aussi les coups — et la plupart des éléments qui constituent l'état du jeu — de manière empaquetée. Comme expliqué dans le cours sur les types entiers, l'empaquetage consiste à stocker plusieurs valeurs dans les bits d'un « entier » Java, généralement pour économiser de la mémoire et améliorer les performances du programme. Les coups se prêtent très bien à une telle représentation, décrite à la section suivante.

2.1.2. Représentation empaquetée

Afin de représenter un coup de manière empaquetée, il faut déterminer comment représenter chacune de ses trois composantes en utilisant aussi peu de bits que possible.

Sachant qu'il y a 10 sources au maximum, il est facile de voir que 4 bits (donc 24 = 16 valeurs possibles) sont nécessaires à la représentation de l'index de la source du coup. De même, sachant qu'il y a 5 couleurs de tuiles différentes, 3 bits sont nécessaires à la représentation de l'index de la couleur choisie. Finalement, comme il y a 6 destinations, 3 bits sont également nécessaires à la représentation de l'index de la destination. Au total, 10 bits sont donc nécessaires pour représenter un coup de manière empaquetée.

Ces 10 bits peuvent être stockés dans un entier Java de type short (16 bits), par exemple en plaçant l'index de la source dans les 4 bits de poids faible, l'index de la couleur dans les 3 bits suivants, et l'index de la destination dans les 3 bits suivants — les 6 bits de poids forts étant laissés à 0.

Avec cette technique, le coup consistant à prendre toutes les tuiles de la couleur C (index 2) depuis la septième fabrique (index 7) afin de les placer sur la dernière ligne de motif (index 4) serait représenté par les 16 bits suivants :

000000 100 010 0111

En effet, en base 2, 210 s'écrit 102, 710 s'écrit 1112 et 410 s'écrit 1002.

Par rapport à la représentation objet, cette représentation empaquetée a les avantages suivants :

  • elle consomme moins de mémoire, puisqu'un coup est représenté au moyen de 16 bits, alors qu'une instance d'une classe comme Move en nécessite au strict minimum 6 fois plus,
  • la création d'un coup peut se faire par simple manipulation de bits, et ne nécessite pas d'allouer de la mémoire pour y stocker les attributs.

Bien entendu, la représentation empaquetée possède également de nombreux inconvénients par rapport à la représentation objet :

  • aucun type spécifique n'existe pour les coups, qui sont de simples valeurs de type short qu'il est très facile de confondre avec d'autres valeurs du même type,
  • il n'est pas possible de définir des méthodes sur les valeurs empaquetées, comme p.ex. toString, car ce ne sont pas des objets.

Dès lors, comme nous l'avons dit, une représentation empaquetée ne devrait être utilisée que lorsque les gains en performance sont conséquents et justifient ces inconvénients.

2.1.3. Nombre de coups valides

Sachant que le nombre de sources, de couleurs de tuiles et de destinations sont tous bornés, il est clair que le nombre de coups jouables à un moment donné l'est aussi. Il peut être utile d'avoir une idée du nombre maximum de coups valides entre lesquels un joueur pourrait devoir choisir.

Comme il y a un maximum de 10 sources de tuiles (dans une partie à 4 joueurs), 5 couleurs de tuiles et 6 destinations, il est immédiatement clair que le nombre de coups valides ne peut en tout cas pas dépasser 10×5×6 = 300.

En examinant attentivement les règles et en réfléchissant un peu, on se rend toutefois compte que ce nombre maximum de coups valides est en réalité inférieur à 300 car :

  • les 10 sources ne peuvent pas simultanément contenir des tuiles, car pour que la zone centrale en contienne, il faut qu'au moins une des fabriques soit vide,
  • une fabrique contient 4 tuiles, donc au maximum 4 couleurs différentes.

Ces deux constatations impliquent qu'un joueur a un choix maximum de coups valides lorsque :

  • il joue dans une partie à 4 joueurs, avec 9 fabriques,
  • chacune de ces 9 fabriques contient 4 tuiles de couleurs différentes, et la zone centrale ne contient donc aucune tuile colorée,
  • toutes les lignes de motif du joueur sont vides.

Dans ce cas, le nombre de coups valides est de 9×4×6 = 216. Il s'agit du nombre maximal de coups valides, car aucune autre situation n'en offre plus que celle décrite ci-dessus.

2.2. Ensembles de tuiles

Les sources de tuiles contiennent toutes un sous-ensemble des tuiles existantes, et il en va de même du sac duquel les tuiles sont tirées au début d'une manche. Pour pouvoir représenter l'état du jeu, il faut donc que nous sachions comment représenter des ensembles de tuiles.

Une première manière de les représenter serait de s'inspirer du jeu physique, et de créer par exemple 101 objets représentant chacun une tuile — 5×20 objets pour les tuiles colorées, plus un pour le marqueur de premier joueur. Cela fait, un ensemble de tuile pourrait être représenté par un sous-ensemble de ces objets.

Cette solution serait tout à fait acceptable, mais étant donné que les tuiles d'une couleur donnée sont indistinguables les unes des autres, il est aussi possible de représenter un ensemble de tuiles par le nombre de tuiles de chaque sorte qu'il contient. Sachant qu'il existe 20 tuiles colorée de chaque couleur, le nombre de tuiles d'une couleur donnée présent dans un ensemble peut être représenté au moyen de 5 bits, tandis qu'un seul bit suffit pour le marqueur de premier joueur. Au total, il suffit donc de 5×5 + 1 = 26 bits pour représenter n'importe quel ensemble de tuiles pouvant apparaître durant une partie d'Ajul.

Une fois encore, étant donné que les ensembles de tuiles seront utilisés lors des simulations de parties faites par « l'intelligence artificielle », il paraît raisonnable de les représenter de manière empaquetée.

2.2.1. Représentation empaquetée

Étant donné que 26 bits sont nécessaires pour représenter un ensemble de tuiles, il semble logique de les stocker dans un entier Java de type int (32 bits). Mais comme un tel entier a 6 bits de plus que nécessaire, on peut se demander s'il n'est pas possible d'utiliser judicieusement ces bits supplémentaires.

Une idée est de les répartir afin que les différents compteurs soient séparés par un bit valant toujours 0. En d'autres termes, un ensemble de tuiles empaqueté dans un entier de type int aurait la structure suivante (des espaces ont été ajoutées entre les groupes de bits pour faciliter la lecture) :

0M 0EEEEE 0DDDDD 0CCCCC 0BBBBB 0AAAAA

AAAAA à EEEEE représentent les 5 bits des compteurs de tuiles de couleur A à E, c'est-à-dire le nombre de tuiles de chaque couleur se trouvant dans l'ensemble, compris entre 0 et 2010 (101002) ; le bit M représente le compteur de marqueur de premier joueur, compris entre 0 et 1.

À première vue, cette organisation ne présente pas d'avantage par rapport à celle consistant à mettre les compteurs bout à bout, mais elle permet néanmoins d'optimiser le calcul de la taille de l'ensemble. En effet, pour calculer la taille d'un ensemble de tuiles empaqueté, il faut additionner les 6 compteurs (A à E, et M) qu'il contient. Or la présence des 0 entre les compteurs permet de faire cela de manière subtile et efficace, en commençant par additionner l'entier contenant l'ensemble empaqueté avec lui-même décalé de 6 bits vers la droite, ainsi :

  0M 0EEEEE 0DDDDD 0CCCCC 0BBBBB 0AAAAA (ensemble d'origine)
+ 00 00000M 0EEEEE 0DDDDD 0CCCCC 0BBBBB (ensemble décalé)
───────────────────────────────────────
= 0M EE++MM DD++EE CC++DD BB++CC AA++BB

En procédant ainsi, on constate que les six bits de poids faible du résultat, notés
AA++BB ci-dessus, contiennent la somme A+B. De même, les six bits suivants (BB++CC) contiennent la somme B+C, les six suivants (CC++DD) la somme C+D, les six suivants (DD++EE) la somme D+E, et les six suivants (EE++MM) la somme E+M. En d'autre terme, au prix d'un décalage et d'une unique (!) addition de deux valeurs de type int, on a calculé 5 sommes partielles.

Pour déterminer la somme qui nous intéresse (A+B+C+D+E+M), il suffit d'extraire, au moyen de décalages et de masquages, les sommes partielles A+B, C+D et E+M du résultat de la première addition, et de les additionner entre elles.

Bien entendu, le gain ainsi obtenu est négligeable en termes de performances, mais l'idée d'effectuer une opération en parallèle sur plusieurs valeurs empaquetées dans un entier mérite d'être connue. En anglais, elle est généralement désignée par l'acronyme SWAR, pour SIMD within a register. L'acronyme SIMD signifie quant à lui single instruction, multiple data et dénote l'idée d'appliquer une seule opération à plusieurs données en parallèle. Dans l'exemple ci-dessus, l'opération est l'addition, et elle est appliquée en parallèle aux 5 paires de compteurs (A,B), (B,C), (C,D), (D,E) et (E,M).

Plusieurs autres opérations sur les ensembles de tuiles empaquetés peuvent être effectuées très rapidement et simplement au moyen de techniques SWAR, par exemple :

  • l'union de deux ensembles, qui peut se faire au moyen d'une unique (!) addition de leur représentation empaquetée,
  • la différence entre un ensemble et un de ses sous-ensembles, qui peut se faire au moyen d'une unique (!) soustraction de leur représentation empaquetée,
  • etc.

2.2.2. Échantillonnage

Une des opération que nous devrons pouvoir faire sur un ensemble de tuiles est d'en extraire un échantillon aléatoire (random sample) d'une taille donnée. En d'autres termes, étant donné un ensemble de tuiles, nous devrons pouvoir en extraire un sous-ensemble choisi aléatoirement. Cette opération nous sera utile pour extraire de l'ensemble de tuiles représentant le sac celles à placer sur les fabriques.

Pour faire cela, nous utiliserons une technique simple et élégante que l'on nomme généralement l'échantillonnage par réservoir (reservoir sampling). Elle permet d'extraire d'un ensemble d'éléments dont on ne connaît pas forcément la taille un échantillons aléatoire d'une taille n donnée — en ne stockant jamais plus de n éléments en mémoire. L'échantillonnage par réservoir consiste à procéder ainsi :

  1. Créer un tableau vide (le réservoir) d'une taille n égale à celle de l'échantillon désiré.
  2. Placer les n premiers éléments de l'ensemble à échantillonner dans le réservoir.
  3. Pour chacun des éléments suivants de l'ensemble à échantillonner, tirer un entier aléatoire uniformément réparti entre 0 et l'index de l'élément considéré (inclus), puis :
    • si ce nombre est un index valide pour le réservoir, stocker l'élément considéré à cet index,
    • sinon, ne rien faire (et donc ignorer l'élément considéré).

Lorsque tous les éléments de l'ensemble à échantillonner ont été traités, le tableau contient un échantillon aléatoire de taille n.

Sous forme de pseudo-code, cet algorithme peut être exprimé ainsi :

réservoir := tableau de taille n
i := 0
pour tous les éléments e de l'ensemble à échantillonner:
  si i < n alors:
    réservoir[i] := e
  sinon:
    j := nombre aléatoire entre 0 et i (inclus)
    si j < n alors:
      réservoir[j] := e
  i := i + 1

Cet algorithme n'est pas sans rappeler celui de Fisher-Yates vu à l'étape précédente, et ce n'est pas un hasard : pour obtenir un échantillon aléatoire d'un ensemble de taille n, on peut aussi placer tous ses éléments dans un tableau, le mélanger aléatoirement, puis en extraire ses n premiers éléments. C'est grosso modo l'idée de l'échantillonnage par réservoir, si ce n'est que seuls les n premiers éléments de ce tableau sont stockés en mémoire, puisque ce sont les seuls finalement utiles.

3. Mise en œuvre Java

En plus des classes représentant les coups et les ensembles de tuiles, un type énuméré permettant d'identifier les joueurs est à réaliser pour cette étape.

En lisant les sections qui suivent, vous remarquerez que les classes contenant les méthodes permettant de manipuler des valeurs empaquetées se trouvent dans le sous-paquetage gamestate.packed et ont un nom qui commence par Pk, pour packed (empaqueté). Nous utiliserons cette convention dans tout le projet.

3.1. Type énuméré PlayerId

Le type énuméré PlayerId du paquetage principal, public, permet d'identifier les joueurs. Il possède quatre valeurs qui sont, dans l'ordre :

P1
qui identifie le joueur 1,
P2
qui identifie le joueur 2,
P3
qui identifie le joueur 3,
P4
qui identifie le joueur 4.

En plus de ces valeurs, PlayerId possède un attribut ALL de type List<PlayerId>, défini de la manière usuelle.

3.2. Classe PkMove

La classe PkMove du sous-paquetage gamestate.packed, publique et finale, contient des méthodes statiques permettant de manipuler des coups empaquetés dans des valeurs de type short selon la manière décrite à la §2.1.2, à savoir :

  • l'index de la source est placé dans les 4 bits de poids faible,
  • l'index de la couleur est placé dans les 3 bits suivants,
  • l'index de la destination est placé dans les 3 bits suivants,

et les 6 bits de poids fort valent toujours 0.

PkMove offre les méthodes publiques et statiques suivantes :

short pack(TileSource source, TileKind.Colored color, TileDestination destination)
qui retourne l'entier short qui est la représentation empaquetée du coup dont la source, la couleur de tuile et la destination sont celles données,
TileSource source(short pkMove)
qui retourne la source du coup empaqueté pkMove,
TileKind.Colored color(short pkMove)
qui retourne la couleur du coup empaqueté pkMove,
TileDestination destination(short pkMove)
qui retourne la destination du coup empaqueté pkMove.

3.2.1. Conseils de programmation

Les différentes méthodes de PkMove doivent effectuer des décalages ou des masquages (« et » bit à bit) afin d'insérer ou d'extraire les composantes d'un coup empaqueté. Pour que le code effectuant ces opérations soit clair, il est capital de définir des constantes contenant la position de chacune des composantes (l'index de son premier bit), sa taille (nombre de bits) et un masque ayant ce nombre de bits à 1. Afin de garantir que ces constantes ont la bonne valeur, il faut de plus définir celles qui peuvent l'être par calcul, comme dans l'exemple ci-dessous :

static final int SOURCE_OFFSET = 0;
static final int SOURCE_BITS = 4;
static final int SOURCE_MASK = (1 << SOURCE_BITS) - 1;

static final int COLOR_OFFSET = SOURCE_OFFSET + SOURCE_BITS;
// … COLOR_BITS, COLOR_MASK, DESTINATION_OFFSET, …

Ici, SOURCE_MASK et COLOR_OFFSET sont définies par calcul, et il est donc évident que leur valeur est correcte. De plus, si le nombre de bits nécessaires au stockage de la source était amené à augmenter, il n'y aurait qu'à changer la définition de SOURCE_BITS et les valeurs de SOURCE_MASK et COLOR_OFFSET seraient automatiquement mises à jour.

3.3. Enregistrements Java

Avant de décrire Move, la prochaine classe à mettre en œuvre pour cette étape, il convient de décrire le concept de classe enregistrement (record class), ou simplement enregistrement (record), introduit dans la version 17 de Java.

Un enregistrement est un type particulier de classe qui peut se définir au moyen d'une syntaxe plus concise qu'une classe normale. Dans cette syntaxe, le mot-clef class est remplacé par record et les attributs de la classe sont donnés entre parenthèses, après son nom.

Par exemple, un enregistrement nommé Complex représentant un nombre complexe dont les attributs sont sa partie réelle re et sa partie imaginaire im — tous deux de type double — peut se définir ainsi :

public record Complex(double re, double im) { }

Cette définition est équivalente à celle d'une classe finale (!) dotée de :

  • deux attributs privés et finaux nommés re et im de type double,
  • un constructeur prenant en argument la valeur de ces attributs et les initialisant,
  • des méthodes d'accès (getters) publics nommés re() et im() pour ces attributs,
  • une méthode equals retournant vrai si et seulement si l'objet qu'on lui passe est aussi une instance de Complex et que ses attributs sont égaux à ceux de this,
  • une méthode hashCode compatible avec la méthode equals — le but de cette méthode et la signification de sa compatibilité avec equals seront examinés ultérieurement dans le cours,
  • une méthode toString retournant une chaîne composée du nom de la classe et du nom et de la valeur des attributs de l'instance, p. ex. Complex[re=1.0, im=2.0].

En d'autres termes, la définition plus haut, qui tient sur une ligne, est équivalente à la définition suivante, qui n'utilise que des concepts que vous connaissez déjà :

public final class Complex {
  private final double re;
  private final double im;

  public Complex(double re, double im) {
    this.re = re;
    this.im = im;
  }

  public double re() { return re; }
  public double im() { return im; }

  @Override
  public boolean equals(Object that) {
    // … vrai ssi that :
    // 1. est aussi une instance de Complex, et
    // 2. ses attributs re et im sont identiques.
  }

  @Override
  public int hashCode() {
    // … code omis car peu important
  }

  @Override
  public String toString() {
    return "Complex[re=" + re + ", im=" + im + "]";
  }
}

Comme cet exemple l'illustre, les enregistrements permettent d'éviter d'écrire beaucoup de code répétitif, ce que les anglophones appellent du boilerplate code. Il faut toutefois bien comprendre qu'en dehors d'une syntaxe très concise, les enregistrements n'apportent — pour l'instant en tout cas — rien de nouveau à Java, dans le sens où il est toujours possible de récrire un enregistrement en une classe Java équivalente, comme ci-dessus. En cela, les enregistrements sont similaires aux types énumérés.

Il est bien entendu possible de définir des méthodes dans un enregistrement, qui viennent s'ajouter à celles définies automatiquement. Par exemple, pour doter l'enregistrement Complex d'une méthode modulus retournant son module, il suffit de l'ajouter entre les accolades, ainsi :

public record Complex(double re, double im) {
  public double modulus() { return Math.hypot(re, im); }
}

(La méthode Math.hypot(x,y) retourne \(\sqrt{x^2 + y^2}\)).

Finalement, il est aussi possible de définir ce que l'on nomme un constructeur compact (compact constructor), qui augmente le constructeur que Java ajoute par défaut aux enregistrements. Un constructeur compact doit son nom au fait qu'il semble ne prendre aucun argument, et n'initialise pas explicitement les attributs. En réalité, il prend des arguments qui sont les mêmes que ceux de l'enregistrement (re et im dans notre exemple), et Java lui ajoute automatiquement des affectations de ces arguments aux attributs correspondants.

Par exemple, on pourrait vouloir ajouter un constructeur compact à la classe Complex pour lever une exception si l'un des arguments passés au constructeur était une valeur NaN (not a number, une valeur invalide). On pourrait le faire ainsi :

public record Complex(double re, double im) {
  public Complex {  // constructeur compact
    if (Double.isNaN(re) || Double.isNaN(im))
      throw new IllegalArgumentException();
  }
  // … méthode modulus
}

Ce constructeur compact serait automatiquement traduit en :

public final class Complex {
  // … attributs re et im

  public Complex(double re, double im) {
    if (Double.isNaN(re) || Double.isNaN(im))
      throw new IllegalArgumentException();
    this.re = re;  // ajouté automatiquement
    this.im = im;  // ajouté automatiquement
  }

  // … méthodes modulus, re, im, hashCode, etc.
}

Les enregistrements ne seront pas décrits en détail dans le cours, mais seront introduits au moyen d'exemples similaires à ceux ci-dessus dans la suite du projet. Les personnes intéressées par les détails de leur fonctionnement pourront se rapporter à la §8.10 (Record Classes) de la spécification du langage.

3.4. Enregistrement Move

L'enregistrement Move du sous-paquetage gamestate, public, représente un coup. Il possède trois attributs qui sont, dans l'ordre :

TileSource source
la source de laquelle les tuiles jouées sont obtenues,
TileKind.Colored tileColor
la couleur des tuiles jouées,
TileDestination destination
la destination sur laquelle les tuiles jouées sont placées.

Afin de garantir que ces attributs sont valides, Move possède un constructeur compact qui vérifie, au moyen de la méthode requireNonNull de Objects, qu'aucun d'entre eux ne vaut null.

De plus, Move possède un attribut public, statique et final :

int MAX_MOVES
qui donne le nombre maximum de coups entre lesquels un joueur d'Ajul peut avoir à choisir, calculé comme le produit du nombre de fabriques, du nombre de tuiles par fabrique, et du nombre de destinations.

Move offre également une méthode fabrique pour obtenir une instance à partir d'un coup empaqueté :

Move ofPacked(short pkMove)
qui retourne le coup correspondant au coup empaqueté donné,

et une méthode permettant de faire l'inverse :

short packed()
qui retourne le coup empaqueté correspondant au récepteur — le coup auquel on applique la méthode.

3.5. Classe PkTileSet

La classe PkTileSet du sous-paquetage gamestate.packed, publique et finale, contient des méthodes statiques permettant de manipuler des ensembles de tuiles empaquetés dans des entiers de type int comme décrit à la §2.2.1, à savoir :

  • le nombre de tuiles de couleur A, compris entre 0 et 20 (inclus), est stocké dans les 6 bits de poids faible (index 0 à 5),
  • le nombre de tuiles de couleur B est stocké dans les 6 bits suivants (index 6 à 11),
  • et ainsi de suite pour les couleurs C, D et E,
  • le nombre de marqueurs de premier joueur, compris entre 0 et 1 (inclus), est stocké dans le bit d'index 30,

et le bit de poids le plus fort (index 31) vaut toujours 0.

PkTileSet offre des attributs publics, statiques et finaux qui représentent différents ensembles de tuiles empaquetés constants, à savoir :

int EMPTY
qui représente l'ensemble de tuiles vide,
int FULL
qui représente l'ensemble de tuiles plein, c.-à-d. composé de toutes les tuiles disponibles dans le jeu — 20 tuiles de chacune des couleurs, et le marqueur de premier joueur,
int FULL_COLORED
qui est similaire à FULL mais sans le marqueur de premier joueur.

De plus, PkTileSet offre les méthodes publiques et statiques suivantes qui permettent de manipuler des ensembles de tuiles empaquetés dans des valeurs de type int :

int of(int count, TileKind tileKind)
qui retourne un ensemble de tuiles empaqueté ne contenant que count tuiles de la sorte tileKind,
boolean isEmpty(int pkTileSet)
qui retourne vrai ssi (si et seulement si) l'ensemble de tuiles empaqueté pkTileSet est vide,
int size(int pkTileSet)
qui retourne la taille de l'ensemble de tuiles empaqueté pkTileSet, c.-à-d. le nombre de tuiles qu'il contient,
int countOf(int pkTileSet, TileKind tileKind)
qui retourne le nombre de tuiles de la sorte tileKind que contient l'ensemble de tuiles empaqueté pkTileSet,
int subsetOf(int pkTileSet, TileKind tileKind)
qui retourne le sous-ensemble de l'ensemble de tuiles empaqueté pkTileSet constitué de toutes les tuiles de la sorte tileKind qu'il contient,
int add(int pkTileSet, TileKind tileKind)
qui retourne un ensemble de tuiles empaqueté égal à pkTileSet si ce n'est qu'il contient exactement une tuile de la sorte tileKind en plus,
int remove(int pkTileSet, TileKind tileKind)
qui retourne un ensemble de tuiles empaqueté égal à pkTileSet si ce n'est qu'il contient exactement une tuile de la sorte tileKind en moins,
int union(int pkTileSet1, int pkTileSet2)
qui retourne l'union (empaquetée) des ensembles de tuiles empaquetés pkTileSet1 et pkTileSet2,
int difference(int pkTileSet1, int pkTileSet2)
qui retourne la différence (empaquetée) des ensembles de tuiles empaquetés pkTileSet1 et pkTileSet2, où pkTileSet2 doit être un sous-ensemble de pkTileSet1,
int copyColoredInto(int pkTileSet, TileKind.Colored[] destination)
qui copie, dans le tableau destination, les tuiles colorées de l'ensemble empaqueté pkTileSet et retourne l'index, dans le tableau destination, de l'élément qui suit le dernier qui a été écrit ; les tuiles doivent être placées dans le tableau ordonnées par couleur, c.-à-d. que celles de couleur A doivent apparaître en premier, suivies par celles de couleur B, et ainsi de suite,
int sampleColoredInto(int pkTileSet, TileKind.Colored[] destination, int offset, RandomGenerator randomGenerator)
qui obtient un échantillon aléatoire de l'ensemble pkTileSet et le place dans le tableau destination à partir de l'index offset, en utilisant l'algorithme d'échantillonnage par réservoir et en générant les nombres aléatoires au moyen de randomGenerator ; retourne la somme de offset et de la taille de l'ensemble,
String toString(int pkTileSet)
qui retourne la représentation textuelle de l'ensemble de tuiles, décrite ci-après.

La représentation textuelle d'un ensemble de tuiles est composée d'autant d'éléments qu'il y a de sortes de tuiles dans l'ensemble, chacun d'entre eux étant composé de :

  • le nombre d'occurrences de cette sorte de tuile, qui doit être supérieur à 0,
  • une astérisque (*),
  • le nom de la sorte de tuiles (A à E ou FIRST_PLAYER_MARKER).

Ces éléments sont séparés par des virgules et entourés d'accolades ({}). Par exemple, la représentation textuelle de FULL est :

{20*A,20*B,20*C,20*D,20*E,1*FIRST_PLAYER_MARKER}

3.5.1. Conseils de programmation

  1. Définition de l'attribut FULL

    Pour définir l'attribut FULL, vous pouvez écrire une méthode statique privée, nommée p. ex. computeFull, qui ajoute progressivement, au moyen des méthodes of et union, toutes les tuiles à l'ensemble vide.

  2. Parcours des tuiles d'un ensemble

    Faites bien attention au fait que, pour obtenir les résultats attendus, vos méthodes copyColoredInto, sampleColoredInto et toString doivent absolument parcourir les éléments de l'ensemble par ordre de couleur. C'est-à-dire que les tuiles de couleur A doivent être traitées d'abord, puis celles de couleur B, et ainsi de suite. La manière la plus simple de faire cela consiste à parcourir les sortes de tuiles qui peuvent se trouver dans l'ensemble (c.-à-d. TileKind.ALL ou TileKind.Colored.ALL en fonction des cas) puis à utiliser countOf pour déterminer combien de tuiles d'une sorte donnée s'y trouvent effectivement.

  3. Remplissage d'un tableau de tuile

    Pour remplir de tuiles le tableau passé à copyColoredInto, vous pouvez vous aider de la méthode fill de la classe Arrays.

  4. Construction de la représentation textuelle d'un ensemble

    Pour construire la chaîne retournée par la méthode toString, vous pouvez vous aider de la classe StringJoiner, qui est un bâtisseur de chaîne spécialisé dans la construction de chaînes composées d'éléments séparés par un séparateur. Cette classe peut par exemple être utilisée ainsi pour obtenir la chaîne a+b+c :

    StringJoiner j = new StringJoiner("+");
    String abc = j.add("a").add("b").add("c").toString();
    
  5. Validation des arguments

    Comme vous l'aurez constaté, il ne vous est pas demandé ci-dessus de valider les arguments passés aux différentes méthodes. Par exemple, la méthode difference fait l'hypothèse que les deux entiers qu'elle reçoit (pkTileSet1 et pkTileSet2) représentent des ensembles de tuiles valides, c.-à-d. contenant entre 0 et 20 tuiles de chaque couleur, et un éventuel marqueur de premier joueur. De plus, cette méthode fait l'hypothèse que le second ensemble est un sous-ensemble du premier. Si ce n'est pas le cas, elle peut retourner une valeur invalide.

    Nous avons choisi de ne pas valider explicitement les arguments dans les classes manipulant des données empaquetées, car ces classes doivent offrir les meilleures performances possibles. Toutefois, afin de faciliter le débogage de votre projet, nous vous suggérons d'ajouter à votre classe une méthode privée nommée p. ex. isValid, prenant en argument un entier de type int et retournant vrai s'il est la représentation d'un ensemble de tuiles valide. Vous pouvez ensuite ajouter dans vos méthodes des assertions (énoncé assert) Java vérifiant que les arguments et/ou le résultat de vos méthodes sont valides.

    Si vous faites cela, prenez bien garde à activer les assertions comme expliqué à la fin de notre guide sur l'exécution des programmes, et à ne pas faire d'erreur dans vos assertions, car nous les activerons lors des tests unitaires !

3.6. Vérification des signatures

Pour faciliter votre travail, nous mettons à votre disposition un fichier de vérification de signatures, nommé SignatureChecks_2.java, à importer dans votre projet dans le même dossier que celui contenant le fichier SignatureChecks_1.java. La classe qu'il contient fait référence à la totalité des classes et méthodes de cette étape, ce qui vous permet de vérifier que leurs noms et types sont corrects. Cela est capital, car la moindre faute à ce niveau empêcherait l'exécution de nos tests unitaires.

Nous vous fournirons de tels fichiers pour toutes les étapes jusqu'à la sixième (incluse), et il vous faudra penser à vérifier systématiquement qu'aucune erreur n'est signalée à leur sujet. Faute de cela, votre rendu pourrait se voir refusé par notre système.

3.7. Tests

À partir de cette étape, nous ne vous fournissons plus de tests unitaires, et il vous faut donc les écrire vous-même.

Notez que, pour les étapes 2 à 6, nous mettrons à disposition nos tests le lundi suivant le jour de rendu de chaque étape. Vous aurez alors tout intérêt à les incorporer à votre projet, ce qui peut poser un problème de nommage.

En effet, si vous nommez vos tests selon la convention standard, en ajoutant simplement le suffixe Test au nom de la classe testée, vos tests auront le même nom que les nôtres, et il ne vous sera pas possible d'avoir vos tests et les nôtres dans un même projet. Pour cette raison, nous vous recommandons d'adopter une autre convention de nommage pour vos tests, par exemple en entourant le nom de la classe testée au moyen du préfixe My et du suffixe Test. Ainsi, votre test pour la classe Rational pourrait être nommé MyRationalTest.

4. Résumé

Pour cette étape, vous devez :

  • écrire les classes PlayerId, PkMove, Move et PkTileSet selon les indications plus haut,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies,
  • rendre votre code au plus tard le 27 février à 18h00, au moyen du programme Submit.java fourni et des jetons disponibles sur votre page privée.

Ce rendu est un rendu testé, auquel 20 points sont attribués, au prorata des tests unitaires passés avec succès.

N'attendez surtout pas le dernier moment pour effectuer votre rendu, car vous n'êtes pas à l'abri d'imprévus.

Si vous manquez la date limite de rendu, vous avez encore la possibilité de faire un rendu tardif au moyen des jetons prévus à cet effet, et ce durant les 2 heures qui suivent, mais il vous en coûtera une pénalité inconditionnelle de 2 points.