État d'une partie

tCHu – étape 5

1. Introduction

Le but principal de cette étape est d'écrire les classes permettant de représenter les parties publiques et privées de l'état d'une partie de tCHu. Son but secondaire est d'écrire l'interface représentant un joueur.

2. Concepts

L'état d'une partie de tCHu est constitué des information suivantes :

  1. le contenu de la pioche des billets,
  2. l'état des cartes wagon/locomotive, décrit à l'étape 3,
  3. l'identité du joueur courant,
  4. l'état des deux joueurs, décrit à l'étape 4,
  5. l'identité du dernier joueur, lorsqu'elle est connue.

Tout comme l'état des cartes et l'état des joueurs, cet état est composé d'une partie publique, connue de tous les joueurs, et d'une partie privée, inconnue d'eux. Par exemple, la taille de la pioche de billets est une information publique, mais son contenu exact est une information privée.

La fin d'une partie de tCHu est déterminée par le nombre de wagons restant aux joueurs, de la manière suivante : dès qu'un joueur termine son tour avec deux wagons ou moins, tous les joueurs — lui compris — jouent encore un tour, après quoi la partie est terminée.

3. Mise en œuvre Java

3.1. Classe PublicGameState

La classe PublicGameState du paquetage ch.epfl.tchu.game, publique et immuable, représente la partie publique de l'état d'une partie de tCHu.

Elle possède un constructeur public :

  • PublicGameState(int ticketsCount, PublicCardState cardState, PlayerId currentPlayerId, Map<PlayerId, PublicPlayerState> playerState, PlayerId lastPlayer), qui construit la partie publique de l'état d'une partie de tCHu dans laquelle la pioche de billets a une taille de ticketsCount, l'état public des cartes wagon/locomotive est cardState, le joueur courant est currentPlayerId, l'état public des joueurs est contenu dans playerState, et l'identité du dernier joueur est lastPlayer (qui peut être null si cette identité est encore inconnue) ; lève IllegalArgumentException si la taille de la pioche est strictement négative ou si playerState ne contient pas exactement deux paires clef/valeur, et NullPointerException si l'un des autres arguments (lastPlayer excepté !) est nul.

En plus de ce constructeur public, PublicGameState offre les méthodes publiques suivantes :

  • int ticketsCount(), qui retourne la taille de la pioche de billets,
  • boolean canDrawTickets(), qui retourne vrai ssi il est possible de tirer des billets, c-à-d si la pioche n'est pas vide,
  • PublicCardState cardState(), qui retourne la partie publique de l'état des cartes wagon/locomotive,
  • boolean canDrawCards(), qui retourne vrai ssi il est possible de tirer des cartes, c-à-d si la pioche et la défausse contiennent entre elles au moins 5 cartes,
  • PlayerId currentPlayerId(), qui retourne l'identité du joueur actuel,
  • PublicPlayerState playerState(PlayerId playerId), qui retourne la partie publique de l'état du joueur d'identité donnée,
  • PublicPlayerState currentPlayerState(), qui retourne la partie publique de l'état du joueur courant,
  • List<Route> claimedRoutes(), qui retourne la totalité des routes dont l'un ou l'autre des joueurs s'est emparé,
  • PlayerId lastPlayer(), qui retourne l'identité du dernier joueur, ou null si elle n'est pas encore connue car le dernier tour n'a pas commencé.

3.2. Classe GameState

La classe GameState, du paquetage ch.epfl.tchu.game, publique, finale et immuable, représente l'état d'une partie de tCHu. Elle hérite de PublicGameState et n'offre pas de constructeur public, mais une méthode de construction publique et statique :

  • GameState initial(SortedBag<Ticket> tickets, Random rng), qui retourne l'état initial d'une partie de tCHu dans laquelle la pioche des billets contient les billets donnés et la pioche des cartes contient les cartes de Constants.ALL_CARDS, sans les 8 (2×4) du dessus, distribuées aux joueurs ; ces pioches sont mélangées au moyen du générateur aléatoire donné, qui est aussi utilisé pour choisir au hasard l'identité du premier joueur.

En plus de cette méthode de construction, GameState offre pour commencer deux redéfinitions de méthodes de sa classe mère, à savoir :

  • PlayerState playerState(PlayerId playerId), qui redéfinit la méthode de même nom de PublicGameState pour retourner l'état complet du joueur d'identité donnée, et pas seulement sa partie publique,
  • PlayerState currentPlayerState(), qui redéfinit la méthode de même nom de PublicGameState pour retourner l'état complet du joueur courant, et pas seulement sa partie publique.

Notez que ces redéfinitions ont un type de retour différent de celui des méthodes originales définies dans PublicGameState ! Ce genre de redéfinitions est autorisé en Java, pour peu que le type de retour de la redéfinition soit un sous-type de celui de la définition originale — on dit alors que ce type de retour est covariant. C'est bien le cas ici car PlayerState est un sous-type de PublicPlayerState.

La classe GameState offre de plus un certain nombre de méthodes publiques qui permettent d'obtenir différentes informations à propos de l'état, ou de dériver un nouvel état similaire au récepteur. Un premier groupe de méthodes concerne les billets et les cartes :

  • SortedBag<Ticket> topTickets(int count), qui retourne les count billets du sommet de la pioche, ou lève IllegalArgumentException si count n'est pas compris entre 0 et la taille de la pioche (inclus),
  • GameState withoutTopTickets(int count), qui retourne un état identique au récepteur, mais sans les count billets du sommet de la pioche, ou lève IllegalArgumentException si count n'est pas compris entre 0 et la taille de la pioche (inclus),
  • Card topCard(), qui retourne la carte au sommet de la pioche, ou lève IllegalArgumentException si la pioche est vide,
  • GameState withoutTopCard(), qui retourne un état identique au récepteur mais sans la carte au sommet de la pioche, ou lève IllegalArgumentException si la pioche est vide,
  • GameState withMoreDiscardedCards(SortedBag<Card> discardedCards), qui retourne un état identique au récepteur mais avec les cartes données ajoutées à la défausse,
  • GameState withCardsDeckRecreatedIfNeeded(Random rng), qui retourne un état identique au récepteur sauf si la pioche de cartes est vide, auquel cas elle est recréée à partir de la défausse, mélangée au moyen du générateur aléatoire donné.

Un second groupe de méthodes permet d'obtenir un état dérivé de l'état courant en réponse à des actions entreprises par un joueur. La première d'entre elles est destinée à être utilisée en début de partie pour ajouter les billets choisis par un joueur à sa main :

  • GameState withInitiallyChosenTickets(PlayerId playerId, SortedBag<Ticket> chosenTickets), qui retourne un état identique au récepteur mais dans lequel les billets donnés ont été ajoutés à la main du joueur donné ; lève IllegalArgumentException si le joueur en question possède déjà au moins un billet.

Notez bien que cette méthode ne doit pas modifier la pioche de billets ! En effet, les 5 billets distribués initialement aux joueurs auront déjà été extraits préalablement de la pioche au moyen de la méthode withoutTopTickets, et le seul but de withInitiallyChosenTickets est de modifier l'état du joueur pour y stocker le sous-ensemble de ces 5 billets qu'il a choisi de garder.

Les autres méthodes du groupe permettent d'obtenir un état dérivé en réponse à une action effectuée par le joueur courant. C'est donc toujours son état à lui qui est différent dans l'état retourné. Contrairement à la méthode précédente, celles ci-dessous « modifient » aussi soit la pioche des billets (première méthode), soit celle des cartes (deux méthodes suivantes), soit la défausse (dernière méthode) :

  • GameState withChosenAdditionalTickets(SortedBag<Ticket> drawnTickets, SortedBag<Ticket> chosenTickets), qui retourne un état identique au récepteur, mais dans lequel le joueur courant a tiré les billets drawnTickets du sommet de la pioche, et choisi de garder ceux contenus dans chosenTicket ; lève IllegalArgumentException si l'ensemble des billets gardés n'est pas inclus dans celui des billets tirés,
  • GameState withDrawnFaceUpCard(int slot), qui retourne un état identique au récepteur si ce n'est que la carte face retournée à l'emplacement donné a été placée dans la main du joueur courant, et remplacée par celle au sommet de la pioche ; lève IllegalArgumentException s'il n'est pas possible de tirer des cartes, c-à-d si canDrawCards retourne faux,
  • GameState withBlindlyDrawnCard(), qui retourne un état identique au récepteur si ce n'est que la carte du sommet de la pioche a été placée dans la main du joueur courant ; lève IllegalArgumentException s'il n'est pas possible de tirer des cartes, c-à-d si canDrawCards retourne faux,
  • GameState withClaimedRoute(Route route, SortedBag<Card> cards), qui retourne un état identique au récepteur mais dans lequel le joueur courant s'est emparé de la route donnée au moyen des cartes données.

Finalement, un troisième et dernier groupe de méthodes permet de déterminer quand le dernier tour commence, et de gérer la fin du tour d'un joueur :

  • boolean lastTurnBegins(), qui retourne vrai ssi le dernier tour commence, c-à-d si l'identité du dernier joueur est actuellement inconnue mais que le joueur courant n'a plus que deux wagons ou moins ; cette méthode doit être appelée uniquement à la fin du tour d'un joueur,
  • GameState forNextTurn(), qui termine le tour du joueur courant, c-à-d retourne un état identique au récepteur si ce n'est que le joueur courant est celui qui suit le joueur courant actuel ; de plus, si lastTurnBegins retourne vrai, le joueur courant actuel devient le dernier joueur.

3.2.1. Conseils de programmation

Pour choisir aléatoirement le premier joueur dans la méthode initial, vous pouvez utiliser la méthode nextInt du générateur aléatoire, en lui passant le nombre de joueurs en argument. Elle retournera un entier choisi au hasard entre 0 et ce nombre, que vous pourrez utiliser pour déterminer l'identité du premier joueur.

GameState ne possède pas de constructeur public, mais elle en possède un privé, et celui-ci doit bien entendu appeler le constructeur de PublicGameState, sa classe mère.

En écrivant cet appel, vous constaterez un problème : le constructeur de la classe mère attend en argument une table associative contenant l'état public des joueurs, or ce que le constructeur de GameState possède est l'état complet (public et privé). En d'autres termes, le constructeur de PublicGameState attend une valeur de type Map<PlayerId, PublicPlayerState> mais le constructeur de GameState ne possède qu'une valeur de type Map<PlayerId, PlayerState>.

Sachant que PlayerState est un sous-type de PublicPlayerState, on pourrait penser que Map<PlayerId, PlayerState> est aussi un sous-type de Map<PlayerId, PublicPlayerState>, et que l'appel peut donc se faire sans problème en raison du principe de substitution. Ce n'est toutefois pas le cas, et pour que l'appel soit accepté, il faut impérativement copier la table au moyen de la méthode Map.copyOf avant de la passer au constructeur de la classe mère. Étonnamment, cette copie permet de résoudre le problème de typage.

La source de ce problème de typage, ainsi que la raison pour laquelle une simple copie suffit à le résoudre, seront examinées ultérieurement au cours.

En raison de cet appel à Map.copyOf, une instance de GameState contiendra deux copies de la table de l'état des joueurs, avec deux types différents mais un contenu identique. Ces tables étant petites, cela ne pose toutefois pas de problème particulier1.

Pour finir, sachez que même si vous pouvez très bien utiliser l'une des mises en œuvre des tables associatives vue au cours (HashMap ou TreeMap) pour stocker l'état des joueurs, une autre solution existe ici : la classe EnumMap. Comme son nom l'indique, elle n'est utilisable que lorsque les clefs proviennent d'un type énuméré — ce qui est le cas avec PlayerId —, mais dans ce cas elle est plus efficace que HashMap et TreeMap. Le constructeur de EnumMap attend en argument l'objet représentant la classe des clefs, et la syntaxe à utiliser pour l'appeler est donc :

Map<PlayerId, PlayerState> m = new EnumMap<>(PlayerId.class);

3.3. Type énuméré TurnKind

Le type énuméré TurnKind, imbriqué dans l'interface Player décrite à la section suivante, représente les trois types d'actions qu'un joueur de tCHu peut effectuer durant un tour — voir la §2 de l'étape 3. Les membres de ce type sont, dans l'ordre :

  • DRAW_TICKETS, qui représente un tour durant lequel le joueur tire des billets,
  • DRAW_CARDS, qui représente un tour durant lequel le joueur tire des cartes wagon/locomotive,
  • CLAIM_ROUTE, qui représente un tour durant lequel le joueur s'empare d'une route (ou tente en tout cas de le faire).

En plus de ces trois membres, le type énuméré TurnKind offre un champ public et statique ALL, qui est comme d'habitude une liste immuable de tous ses membres, dans l'ordre de déclaration.

3.4. Interface Player

L'interface Player du paquetage ch.epfl.tchu.game, publique, représente un joueur de tCHu.

Les méthodes de cette interface sont destinées à être appelées à différents moments de la partie, soit pour communiquer certaines informations concernant son déroulement au joueur, soit pour obtenir certaines informations de ce dernier, p.ex. le type d'action qu'il désire effectuer.

Toutes les méthodes de cette interface sont abstraites, et vous n'avez donc pas à les mettre en œuvre à ce stade, il vous suffit de les définir. Leur but est toutefois rapidement décrit ci-dessous, pour que vous sachiez à quoi elles servent.

Ces méthodes sont :

  • void initPlayers(PlayerId ownId, Map<PlayerId, String> playerNames), qui est appelée au début de la partie pour communiquer au joueur sa propre identité ownId, ainsi que les noms des différents joueurs, le sien inclus, qui se trouvent dans playerNames,
  • void receiveInfo(String info), qui est appelée chaque fois qu'une information doit être communiquée au joueur au cours de la partie ; cette information est donnée sous la forme d'une chaîne de caractères, généralement produite par la classe Info définie à l'étape 3,
  • void updateState(PublicGameState newState, PlayerState ownState), qui est appelée chaque fois que l'état du jeu a changé, pour informer le joueur de la composante publique de ce nouvel état, newState, ainsi que de son propre état, ownState,
  • void setInitialTicketChoice(SortedBag<Ticket> tickets), qui est appelée au début de la partie pour communiquer au joueur les cinq billets qui lui ont été distribués,
  • SortedBag<Ticket> chooseInitialTickets(), qui est appelée au début de la partie pour demander au joueur lesquels des billets qu'on lui a distribué initialement (via la méthode précédente) il garde,
  • TurnKind nextTurn(), qui est appelée au début du tour d'un joueur, pour savoir quel type d'action il désire effectuer durant ce tour,
  • SortedBag<Ticket> chooseTickets(SortedBag<Ticket> options), qui est appelée lorsque le joueur a décidé de tirer des billets supplémentaires en cours de partie, afin de lui communiquer les billets tirés et de savoir lesquels il garde,
  • int drawSlot(), qui est appelée lorsque le joueur a décidé de tirer des cartes wagon/locomotive, afin de savoir d'où il désire les tirer : d'un des emplacements contenant une carte face visible — auquel cas la valeur retourne est comprise entre 0 et 4 inclus —, ou de la pioche — auquel cas la valeur retournée vaut Constants.DECK_SLOT (c-à-d -1),
  • Route claimedRoute(), qui est appelée lorsque le joueur a décidé de (tenter de) s'emparer d'une route, afin de savoir de quelle route il s'agit,
  • SortedBag<Card> initialClaimCards(), qui est appelée lorsque le joueur a décidé de (tenter de) s'emparer d'une route, afin de savoir quelle(s) carte(s) il désire initialement utiliser pour cela,
  • SortedBag<Card> chooseAdditionalCards(List<SortedBag<Card>> options), qui est appelée lorsque le joueur a décidé de tenter de s'emparer d'un tunnel et que des cartes additionnelles sont nécessaires, afin de savoir quelle(s) carte(s) il désire utiliser pour cela, les possibilités lui étant passées en argument ; si le multiensemble retourné est vide, cela signifie que le joueur ne désire pas choisir l'une de ces possibilités.

Le fait qu'il existe deux méthodes — setInitialTicketChoice et chooseInitialTickets — permettant au joueur de choisir les billets qu'il garde en début de partie peut sembler étrange : pourquoi ne pas avoir une seule méthode à laquelle on passe les billets tirés, et qui retourne ceux choisis par le joueur, exactement comme la méthode chooseTickets le fait pour les billets tirés en cours de partie ?

La raison en est que cette phase du jeu est la seule qui se déroule en parallèle, c-à-d que les deux joueurs choisissent en même temps — et pas chacun leur tour — les billets qu'ils désirent garder. Pour permettre cela, il faut avoir à disposition les deux méthodes susmentionnées afin de pouvoir :

  1. communiquer à chaque joueur les billets qui lui ont été distribués initialement, au moyen de la méthode setInitialTicketChoice,
  2. demander à chaque joueur les billets qu'il désire garder, au moyen de la méthode chooseInitialTickets.

Si une seule méthode était disponible, alors le premier joueur devrait choisir ses billets avant que le second joueur ne puisse voir les siens, ce qui ne serait pas idéal.

3.5. Tests

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

4. Résumé

Pour cette étape, vous devez :

  • écrire les classes PublicGameState et GameState, ainsi que l'interface Player, selon les indications données ci-dessus,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies,
  • rendre votre code au plus tard le 26 mars 2021 à 17h00, via le système de rendu.

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. Souvenez-vous qu'aucun retard, aussi insignifiant soit-il, ne sera toléré !

Notes de bas de page

1

En réalité, Map.copyOf essaie de détecter si la table qu'on lui demande de copier est immuable, et si c'est le cas, elle la retourne telle quelle. Grâce à cette optimisation, il est probable qu'une instance de GameState ne contienne en réalité que deux références vers la même table.