Aller au contenu

TP et projets#

Programmation orientée objet et paradigmes

Projet La Bataille, en objets#

quatre classes qui jouent ensemble

Durée : 3 h environ (deux séances) · Sur machine, seul ou par deux

Fichiers à télécharger

dossier en ligne archive .zip

But du projet

Programmer de bout en bout le jeu de cartes de la Bataille, puis le faire jouer mille fois pour répondre à de vraies questions : combien de temps dure une partie ? Avoir des As aide-t-il à gagner ? Une partie peut-elle ne jamais finir ? Le programme repose sur quatre classes qui interagissent, avec des attributs encapsulés, des méthodes spéciales et des tests ; les statistiques s’écrivent dans le style fonctionnel. ▶ signale ce qui est à programmer et tester.

Consignes

  • L’extension est facultative.

  • Le fichier projet_poo_depart.py est à télécharger (lien ci-dessus) : squelette des classes et, en fin de fichier, des tests. Relancer le fichier après chaque partie : chaque ligne [A FAIRE] doit devenir [OK]. On ne modifie pas les tests.

  • Tous les attributs dont le nom commence par _ sont privés : en dehors de leur classe, on n’y accède que par des méthodes.

  • Usage de l’IA, indiqué sur chaque exercice : aucune ; en appui (déboguer, reformuler, vérifier ; la réponse finale est écrite et comprise par vous) ; l’exercice s’appuie sur une réponse d’IA. Voir la charte d’usage de l’IA.

Les règles (version 32 cartes)

On utilise un jeu de 32 cartes : huit valeurs (7, 8, 9, 10, Valet, Dame, Roi, As, de la plus faible à la plus forte) dans quatre couleurs (pique, coeur, carreau, trefle). La couleur ne compte pas pour comparer deux cartes.

  • Le paquet est mélangé puis distribué entièrement, une carte à chacun à tour de rôle : 16 cartes par joueur, en tas, faces cachées.

  • À chaque pli, chaque joueur retourne la carte du dessus de son tas et la pose sur le tapis. La plus forte l’emporte : son propriétaire ramasse tout le tapis et le place sous son tas.

  • En cas d’égalité, c’est la bataille : chacun pose une carte face cachée, puis on rejoue (une carte face visible chacun), et ainsi de suite jusqu’à départager. Le gagnant ramasse tout le tapis.

  • Un joueur qui n’a plus de cartes a perdu, y compris au milieu d’une bataille (l’autre ramasse alors le tapis).

L’architecture du programme#

Avant d’écrire la moindre ligne, on fixe les classes, ce qu’elles savent (attributs) et ce qu’elles savent faire (méthodes).

★ Exercice 1 — Lire l’architecture #

  1. Quelle classe crée des objets d’une autre classe dans son constructeur ? Lesquels ?

  2. Pourquoi nom n’est-il pas privé, alors que _main l’est ? Donner une action interdite par les règles que l’encapsulation de _main rend plus difficile.

  3. Ada a pour tas Dame de coeur, 9 de pique, Roi de trefle, 7 de coeur (carte du dessus en premier) et Alan 8 de trefle, 9 de coeur, As de pique, 10 de carreau. Jouer la partie à la main : écrire les deux tas après chaque pli. Qui gagne, et en combien de plis ?

La classe Carte#

★ Exercice 2 — Une carte qui se présente #

▶ Dans projet_poo_depart.py, compléter la classe Carte (les listes VALEURS, de la plus faible à la plus forte, et COULEURS sont déjà définies en haut du fichier). Il faut écrire le constructeur (il lève ValueError("carte inconnue") avec raise, voir le cours, « Signaler une erreur », si la valeur n’est pas dans VALEURS ou la couleur pas dans COULEURS), les accesseurs valeur() et couleur(), la méthode rang() (position de la valeur dans VALEURS : \(0\) pour un 7, \(7\) pour un As), et __repr__ (le texte affiché quand on tape le nom de l’objet). On doit obtenir :

>>> c = Carte("Roi", "coeur")
>>> c
Roi de coeur
>>> c.rang()
6

Pourquoi vaut-il mieux vérifier la valeur dès le constructeur, plutôt qu’au moment de comparer deux cartes ?

★★ Exercice 3 — Comparer et trier des cartes au-delà du programme #

Le cours a signalé qu’il existe d’autres méthodes spéciales, qui permettent d’utiliser == ou < sur nos propres objets. Écrire a == b déclenche l’appel a.__eq__(b) ; écrire a < b déclenche a.__lt__(b) (lt pour less than).

  1. ▶ Avant d’écrire ces méthodes, taper les deux instructions suivantes. Noter ce que Python répond à chacune (une valeur, ou une erreur). Que compare == par défaut ?

    >>> Carte("Roi", "coeur") == Carte("Roi", "coeur")
    >>> sorted([Carte("Roi", "coeur"), Carte("7", "pique")])
    
  2. ▶ Écrire __eq__(self, autre) et __lt__(self, autre), qui renvoient un booléen en comparant les rangs des deux cartes (la couleur ne compte pas). Vérifier que le tri donne bien les cartes de la plus faible à la plus forte :

    >>> main = [Carte("Roi", "coeur"), Carte("7", "pique"),
    ...         Carte("As", "pique"), Carte("9", "coeur")]
    >>> sorted(main)
    [7 de pique, 9 de coeur, Roi de coeur, As de pique]
    
  3. Avec ces définitions, Carte("Roi", "coeur") == Carte("Roi", "trefle") vaut True. Est-ce gênant pour la Bataille ? Dans quel autre jeu (ou quelle autre situation) cette définition de l’égalité poserait-elle problème ?

Les classes Paquet et Joueur#

★★ Exercice 4 — Le paquet #

  1. ▶ Écrire le constructeur : il fabrique les 32 cartes (une par couple valeur/couleur) dans l’attribut privé _cartes, une liste de Carte, avec deux boucles for imbriquées (une sur les couleurs, une sur les valeurs). Pour les plus à l’aise : le réécrire ensuite en une ligne, avec une liste en compréhension à deux boucles for.

  2. ▶ Écrire taille(), puis la méthode melanger(graine=None) (paramètre par défaut : voir le chapitre Récursivité). Si une graine est donnée, elle appelle d’abord random.seed(graine) ; dans tous les cas, elle mélange ensuite les cartes avec la fonction random.shuffle. (Le module random est déjà importé.) À quoi sert la graine quand on veut tester le programme ?

  3. ▶ Écrire distribuer(nb_joueurs) : la carte d’indice i va au joueur i % nb_joueurs ; le paquet est vide ensuite, et la méthode renvoie la liste des mains (une liste de cartes par joueur). Pour deux joueurs, la carte \(0\) va au joueur \(0\), la carte \(1\) au joueur \(1\), la carte \(2\) au joueur \(0\), etc.

★★ Exercice 5 — Le joueur #

La main est une liste de cartes dont la carte du dessus est au début.

  1. ▶ Écrire le constructeur, nb_cartes(), a_perdu(), jouer_carte() (retire et renvoie la carte du dessus) et ramasser(cartes) (ajoute les cartes reçues, dans l’ordre, à la fin de la main, c’est-à-dire sous le tas). Le constructeur reçoit le nom et la liste des cartes de départ.

  2. Le test de la partie 3 ajoute une carte à la liste cartes après avoir créé le joueur, et vérifie que la main d’Ada n’a pas changé. Parmi les deux écritures suivantes, laquelle faut-il donc mettre dans le constructeur : self._main = cartes, ou bien self._main = [c for c in cartes] ? Expliquer ce qui différencie les deux.

  3. ▶ Écrire nb_cartes_de(valeur) en une ligne, avec une liste en compréhension : on garde les cartes de self._main dont la valeur est celle cherchée, et on compte le résultat avec len(...). Pourquoi cette méthode est-elle nécessaire pour compter les As d’un joueur depuis l’extérieur de la classe ?

    Coup de pouce

    [c for c in self._main if ...] garde les cartes c de la main qui vérifient la condition ; la condition compare c.valeur() à la valeur cherchée.

La classe Partie : faire jouer les objets ensemble#

★★★ Exercice 6 — Un pli, avec ses batailles #

Le constructeur de Partie est fourni : il crée un paquet, le mélange, le distribue et crée les deux joueurs j1 et j2.

▶ Écrire jouer_pli() qui joue un pli complet et renvoie le joueur qui ramasse le tapis. Une liste locale tapis, vide au départ, accumule toutes les cartes posées pendant le pli (visibles ou cachées). On répète les étapes suivantes tant que le pli n’est pas terminé :

  1. si l’un des joueurs n’a plus de cartes (a_perdu()), l’autre est le gagnant du pli : c’est fini ;

  2. sinon, chaque joueur pose sa carte du dessus (jouer_carte()) : on les ajoute au tapis. Si l’une est strictement plus forte, son propriétaire est le gagnant du pli : c’est fini ;

  3. sinon (égalité), on compte une bataille de plus (nb_batailles) et, si les deux joueurs ont encore des cartes, chacun en pose une de plus, face cachée : on l’ajoute simplement au tapis sans la comparer. On revient alors à l’étape a) (la carte face visible suivante sera comparée à l’étape b)).

Coup de pouce

Une variable gagnant, qui vaut None tant que le pli n’est pas joué, et une boucle while gagnant is None ; après la boucle, gagnant ramasse le tapis (ramasser) et la méthode le renvoie. Les comparaisons s’écrivent c2 < c1 et c1 < c2 grâce à __lt__. Les cartes posées s’ajoutent au tapis dans l’ordre : carte de j1, puis carte de j2.

★★ Exercice 7 — Une partie entière, et un invariant #

  1. ▶ Écrire jouer() : tant qu’aucun des deux joueurs n’a perdu et que nb_plis est inférieur à max_plis, jouer un pli (jouer_pli()) et augmenter nb_plis de 1. À la fin, renvoyer self.vainqueur() (méthode fournie : le nom du vainqueur, ou None si la limite a été atteinte). max_plis évite de boucler indéfiniment si la partie ne finit jamais.

  2. ▶ On vérifie maintenant votre trace à la main (question 3 de l’exercice 1) avec le programme. Une Partie distribue au hasard, mais ici on veut des tas imposés. On procède donc ainsi :

    1. créer une partie quelconque : p = Partie("Ada", "Alan") ;

    2. remplacer ses deux joueurs par ceux de l’exercice 1, en affectant les attributs p.j1 et p.j2 :

      p.j1 = Joueur("Ada", [...])
      p.j2 = Joueur("Alan", [...])
      

      où chaque liste contient les cartes du tas, la carte du dessus en premier (par exemple Carte("Dame", "coeur")) ;

    3. lancer p.jouer() puis afficher le vainqueur, p.nb_plis et p.nb_batailles.

    Le programme annonce-t-il le même vainqueur et le même nombre de plis que votre trace ? Sinon, qui a tort : la trace ou le programme ?

  3. Le test de la partie 4 vérifie qu’en fin de partie les deux joueurs ont 32 cartes au total. Pourquoi cette propriété (un invariant, c’est-à-dire une propriété qui reste vraie tout au long du programme) doit-elle être vraie à la fin de chaque pli ? Donner une erreur de programmation dans jouer_pli qui la ferait échouer.

  4. ▶ Cette fois on joue une partie « pour de vrai » : Partie("Ada", "Alan", 2026) mélange le paquet de façon reproductible (la graine 2026 fixe le mélange : tout le monde obtient la même distribution) et distribue 16 cartes à chacun. Créer cette partie, la jouer avec jouer() et indiquer le vainqueur et le nombre de plis (nb_plis). Le test de la partie 4 attend "Alan" et 106 plis.

Mille parties : des statistiques en style fonctionnel#

On veut maintenant observer le jeu. Dans cette partie, on filtre et on transforme la liste des résultats avec des listes en compréhension, comme en Première. Certaines questions ne se répondent pas avec une compréhension seule : pour une moyenne ou la recherche d’un minimum, on écrit un parcours avec une variable qui accumule ou retient le meilleur résultat ; pour classer les parties selon un critère (sorted), on introduira une fonction lambda, en la construisant pas à pas.

★★ Exercice 8 — Résumer une partie #

Pour observer le jeu, il faut d’abord pouvoir jouer une partie et en garder les faits marquants. On écrit pour cela la fonction resume(graine, tapis_melange=False) (le second paramètre ne sert que dans l’extension : on le transmet simplement à Partie).

  1. ▶ Dans resume, créer la partie Partie("Ada", "Alan", graine, tapis_melange=tapis_melange), relever le nombre d’As du joueur j1 (Ada) avant de jouer, avec nb_cartes_de("As"), puis jouer la partie avec jouer().

  2. ▶ La fonction renvoie un dictionnaire aux cinq clés "graine" (la graine reçue), "vainqueur" (ce que renvoie jouer() : un nom, ou None si max_plis est atteint), "plis" (nb_plis), "batailles" (nb_batailles) et "as_ada". Pour la graine 2026, on doit obtenir :

    >>> resume(2026)
    {'graine': 2026, 'vainqueur': 'Alan', 'plis': 106, 'batailles': 8, 'as_ada': 1}
    
  3. Pourquoi faut-il relever le nombre d’As avant d’appeler jouer() ? Que vaudrait-il après ?

  4. resume est-elle une fonction pure ? Que deviendrait la réponse si l’on supprimait le paramètre graine ?

★★ Exercice 9 — Interroger les mille parties #

On joue mille parties (graines 0 à 999) et on garde leurs résumés : res = [resume(g) for g in range(1000)]. res est une liste de 1000 dictionnaires, comme celui de l’exercice précédent. Une partie est finie si elle a un vainqueur ; sinon elle a atteint max_plis.

  1. ▶ Avec une liste en compréhension, construire la liste finies des parties qui ont un vainqueur (ne garder que les dictionnaires r tels que r["vainqueur"] n’est pas None). Combien y en a-t-il ? Avec une seconde compréhension sur finies, construire la liste gagnees_ada des parties dont le vainqueur est "Ada" : combien Ada en gagne-t-elle, et quel pourcentage des parties finies cela représente-t-il ?

  2. ▶ Avec une liste en compréhension, extraire de finies la liste des nombres de plis, puis en calculer la durée moyenne (écrire une fonction moyenne(tab) qui additionne les valeurs dans un accumulateur, puis divise par len(tab)).

  3. On veut maintenant les graines des trois parties finies les plus longues, et celle de la plus courte. Ici, une compréhension ne suffit plus : on ne filtre ni ne transforme finies, on le classe selon le nombre de plis de chaque partie. sorted accepte pour cela un paramètre key : une fonction qui, à partir d’un résumé r, renvoie la valeur à comparer. ▶ Écrire cette fonction avec un lambda (lambda r: r["plis"] se lit « la fonction qui, à r, associe r["plis"] » ; c’est l’équivalent, pour un usage unique, de def cle(r): return r["plis"]), puis afficher, sous la forme (graine, nombre de plis), les trois parties les plus longues avec sorted(finies, key=..., reverse=True)[:3]. Pour la plus courte, parcourir finies en retenant le résumé qui a le moins de plis (recherche du minimum, comme en Première).

  4. ▶ Recopier et compléter le tableau suivant : pour chaque nombre \(k\) d’As d’Ada au départ (clé "as_ada"), le nombre de parties finies avec ce \(k\) et le pourcentage d’entre elles gagnées par Ada (arrondi à l’unité à l’affichage, avec une f-string : f"{p:.0f}"). Pour chaque \(k\), on construit par compréhension le groupe des parties finies correspondantes, puis le sous-groupe gagné par Ada. Conclure : avoir des As aide-t-il ? Peut-on se fier aux pourcentages de tous les groupes ?

    As d’Ada au départ 0 1 2 3 4
    Parties finies
    Victoires d’Ada (%)

Extension : des parties qui ne finissent jamais ?#

★★★ Exercice 10 — Casser les cycles #

  1. Plus d’un tiers des parties atteignent max_plis = 2000 sans vainqueur. En augmentant max_plis à \(20\,000\) pour quelques-unes, vérifier qu’elles ne finissent toujours pas. Proposer une explication : le programme est déterministe ; que se passe-t-il s’il retrouve exactement la même situation (mêmes tas) qu’à un pli précédent ?

  2. ▶ Dans une vraie partie, les cartes ramassées ne sont pas reposées dans un ordre fixe. Ajouter à Partie une méthode privée _donner(gagnant, tapis) qui mélange le tapis si l’attribut tapis_melange vaut True, puis le fait ramasser ; l’utiliser à chaque ramassage dans jouer_pli.

  3. ▶ Refaire les statistiques avec tapis_melange=True : nombre de parties finies, durée moyenne, partie la plus longue, victoires d’Ada. Les tests des parties 1 à 5 passent-ils toujours ? Pourquoi est-ce important ?

  4. (Libre.) Autres pistes : une partie à trois joueurs ; un affichage pli par pli ; une variante où le gagnant d’une bataille choisit l’ordre du ramassage.

Bilan à rédiger#

Exercice 11 — Ce que j’ai construit #

Rédiger, en une dizaine de lignes, un bilan qui répond aux questions suivantes.

  1. Pour chacune des quatre classes, en une phrase : ce qu’elle représente et la méthode qui vous paraît la plus importante.

  2. Citer un endroit du projet où l’encapsulation a imposé d’écrire une méthode plutôt que d’accéder directement à un attribut. Qu’y a-t-on gagné ?

  3. Le projet mêle trois paradigmes. Citer pour chacun (impératif, objet, fonctionnel) un passage de votre code qui l’illustre, et dire pourquoi il convenait à cet endroit.