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 :
- la source de laquelle les tuiles sont prises,
- la couleur des tuiles choisies,
- 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
}
où 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
Moveen 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
shortqu'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
où 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 :
- Créer un tableau vide (le réservoir) d'une taille n égale à celle de l'échantillon désiré.
- Placer les n premiers éléments de l'ensemble à échantillonner dans le réservoir.
- 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
shortqui 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
reetimde typedouble, - un constructeur prenant en argument la valeur de ces attributs et les initialisant,
- des méthodes d'accès (getters) publics nommés
re()etim()pour ces attributs, - une méthode
equalsretournant vrai si et seulement si l'objet qu'on lui passe est aussi une instance deComplexet que ses attributs sont égaux à ceux dethis, - une méthode
hashCodecompatible avec la méthodeequals— le but de cette méthode et la signification de sa compatibilité avecequalsseront examinés ultérieurement dans le cours, - une méthode
toStringretournant 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 à
FULLmais 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
counttuiles de la sortetileKind, boolean isEmpty(int pkTileSet)- qui retourne vrai ssi (si et seulement si) l'ensemble de tuiles empaqueté
pkTileSetest 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
tileKindque contient l'ensemble de tuiles empaquetépkTileSet, int subsetOf(int pkTileSet, TileKind tileKind)- qui retourne le sous-ensemble de l'ensemble de tuiles empaqueté
pkTileSetconstitué de toutes les tuiles de la sortetileKindqu'il contient, int add(int pkTileSet, TileKind tileKind)- qui retourne un ensemble de tuiles empaqueté égal à
pkTileSetsi ce n'est qu'il contient exactement une tuile de la sortetileKinden plus, int remove(int pkTileSet, TileKind tileKind)- qui retourne un ensemble de tuiles empaqueté égal à
pkTileSetsi ce n'est qu'il contient exactement une tuile de la sortetileKinden moins, int union(int pkTileSet1, int pkTileSet2)- qui retourne l'union (empaquetée) des ensembles de tuiles empaquetés
pkTileSet1etpkTileSet2, int difference(int pkTileSet1, int pkTileSet2)- qui retourne la différence (empaquetée) des ensembles de tuiles empaquetés
pkTileSet1etpkTileSet2, oùpkTileSet2doit être un sous-ensemble depkTileSet1, int copyColoredInto(int pkTileSet, TileKind.Colored[] destination)- qui copie, dans le tableau
destination, les tuiles colorées de l'ensemble empaquetépkTileSetet retourne l'index, dans le tableaudestination, 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
pkTileSetet le place dans le tableaudestinationà partir de l'indexoffset, en utilisant l'algorithme d'échantillonnage par réservoir et en générant les nombres aléatoires au moyen derandomGenerator; retourne la somme deoffsetet 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àEouFIRST_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
- 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éthodesofetunion, toutes les tuiles à l'ensemble vide. - Parcours des tuiles d'un ensemble
Faites bien attention au fait que, pour obtenir les résultats attendus, vos méthodes
copyColoredInto,sampleColoredIntoettoStringdoivent 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.ALLouTileKind.Colored.ALLen fonction des cas) puis à utilisercountOfpour déterminer combien de tuiles d'une sorte donnée s'y trouvent effectivement. - Remplissage d'un tableau de tuile
- 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 classeStringJoiner, 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înea+b+c:StringJoiner j = new StringJoiner("+"); String abc = j.add("a").add("b").add("c").toString(); - 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
differencefait l'hypothèse que les deux entiers qu'elle reçoit (pkTileSet1etpkTileSet2) 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 typeintet 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,MoveetPkTileSetselon 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.javafourni 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.