Activité pratique

Durée3h45

Présentation & objectifs

Dans cette séance, vous allez implémenter en Python une version simplifiée du jeu de société Citadelles pour 2 joueurs. Cette activité vous permettra de mettre en pratique les concepts de programmation orientée objet vus en Algo 1 et d’appliquer les bonnes pratiques de développement logiciel acquises dans les séances précédentes (documentation, tests, programmation défensive).

Vous partirez de la conception POO déjà réalisée en Algo 1 (identification des classes, attributs, méthodes et relations) pour aboutir à un programme exécutable permettant de jouer une partie complète.

A la fin de la séance, vous devrez être capable de :

  • Implémenter des classes Python à partir d’un modèle de conception
  • Structurer un projet multi-fichiers
  • Écrire et exécuter des tests unitaires pour valider votre code
  • Créer une interface simple en ligne de commande pour interagir avec votre programme
  • Jouer une partie à 2 rôles (en incarnant les deux joueurs)
Important

Le but de cette session est de vous aider à maîtriser des notions importantes en informatique. Un assistant de programmation intelligent tel que GitHub Copilot, que vous avez peut-être déjà installé, sera capable de vous fournir une solution à ces exercices basée uniquement sur un nom de fichier judicieusement choisi.

Dans un but d’entraînement, nous vous conseillons de désactiver ces outils.

Avant le TP

Pré-requis

Pour pouvoir commencer à travailler sur l’activité, vous devez :

  • Apportez votre propre ordinateur.
  • Assurez-vous d’avoir Python 3 installé.
  • Assurez-vous d’avoir une connexion réseau fonctionnelle.
  • Avoir revu la conception POO de Citadelles réalisée en Algo 1 (session1/practical2).

Documents à consulter

Pour vous rafraîchir la mémoire sur la conception déjà réalisée :

Pendant le TP

Approche incrémentale

Toutes les méthodes d’une classe n’ont pas besoin d’être implémentées immédiatement.

Adoptez une approche progressive :

  • Commencez par créer le squelette de vos classes avec leurs attributs et des méthodes vides (avec pass)
  • Implémentez d’abord les méthodes qui ne dépendent d’aucune autre classe
  • Implémentez ensuite les méthodes qui dépendent de classes déjà disponibles
  • Laissez pour plus tard les méthodes qui dépendent de classes pas encore créées

Exemple : Pour la classe Joueur, vous pouvez implémenter recevoir_or() et payer() tout de suite (elles ne dépendent que de l’attribut or), mais construire_quartier() devra attendre que Jeu et Cite soient implémentées.

Cela évite les blocages et correspond à la manière dont on développe réellement du code.

Organisation de l’activité

Vous allez travailler individuellement ou en binôme pour implémenter le jeu. Le TP est découpé en 6 étapes progressives :

Étape Durée Objectif Livrable
0 15 min Rappel de la conception et mise en place Schéma de classes, dépôt Git
1 45 min Implémentation des classes de base quartier.py, personnage.py
2 45 min Collections de cartes pioche.py, jeu.py, cite.py
3 45 min Classe Joueur joueur.py + tests
4 45 min Classe Partie et intégration partie.py, main.py
5 30 min Tests complets et jeu Programme exécutable
Conseil

Ne passez à l’étape suivante que lorsque tous les tests de l’étape précédente sont validés. Cela vous évitera des problèmes d’intégration complexes.

Étape 0 : Rappel de la conception et mise en place (15 min)

Objectif : S’assurer que tout le monde part sur de bonnes bases en rappelant la conception POO déjà réalisée en Algo 1.

0.1 - Rappel des classes incontournables

En Algo 1, vous avez identifié les classes principales pour modéliser Citadelles. Pour démarrer, voici un rappel des classes incontournables :

  • Carte : classe de base pour tous les types de cartes
  • Quartier et Personnage : sous-classes de Carte
  • Joueur (ou Player) : représente un·e joueur·euse
  • Partie : orchestrate le déroulement du jeu

Question de rappel : Quelles autres classes ou regroupements de cartes aviez-vous identifiés ?

Indice

Pensez aux différents regroupements de cartes mentionnés dans les règles :

  • les quartiers construits par un joueur (la cité)
  • les cartes en main d’un joueur (la main)
  • les cartes disponibles pour être piochées (la pioche)
  • les personnages non utilisés (l’écart)
  • les personnages choisis par un joueur

0.2 - Schéma conceptuel de base

Voici un schéma simplifié des relations entre les principales classes (à compléter et affiner) :

Carte (classe abstraite)
├── Quartier (classe abstraite)
│   ├── Noble (ex: Manoir, Palais)
│   ├── Religieux (ex: Temple, Eglise)
│   ├── Commercial (ex: Taverne, Echoppe)
│   ├── Militaire (ex: Tour de guet, Caserne)
│   └── Prestige (ex: Cour des miracles, Donjon)
└── Personnage (classe abstraite)
    ├── Assassin
    ├── Voleur
    ├── Magicien
    ├── Roi
    ├── Eveque
    ├── Marchand
    ├── Architecte
    └── Condottiere
Partie
├── Joueur (x2)
│   ├── Main (cartes Quartier)
│   ├── Cité (quartiers construits)
│   └── Personnages choisis
├── Pioche (cartes Quartier)
└── Personnages (gestion des personnages disponibles/écartés)

0.3 - Vérification de la compréhension

Avant de commencer à coder, assurez-vous de pouvoir répondre à ces questions :

  1. Quels attributs une carte Quartier doit-elle avoir ?
  2. Quels attributs une carte Personnage doit-elle avoir ?
  3. Comment modéliser la relation entre un Joueur et ses cartes (main, cité, personnages) ?
  4. Où stocker les cartes Quartier qui n’ont pas encore été piochées ?

0.4 - Mise en place de l’environnement

  1. Création du projet :

    • Créez un répertoire citadelles/ pour votre projet
    • Dans ce répertoire, créez un sous-répertoire tests/ pour les tests unitaires
  2. Création du fichier __init__.py (optionnel mais recommandé) : Pour faire de citadelles/ un package Python, créez un fichier vide __init__.py à la racine.

  3. Initialisation Git :

    cd citadelles/
    git init
    git add .
    git commit -m "Initial commit - Debut implementation Citadelles"

Étape 1 : Implémentation des classes de base (45 min)

Objectif : Coder les classes les plus simples et indépendantes.

Attributs protegés

Les attributs des classes seront toujours considérés comme protégés, ainsi leur nom devra toujours commencer par le caractère _.

Ca ne sera cependant pas le cas des paramètres (noms utilisés dans la signature d’une méthode) ni des arguments (éléments passés lors de l’appel de la méthode).

Toutes les méthodes peuvent être implémentées

Ces classes ne dépendent d’aucune autre classe du projet. Vous pouvez implémenter toutes leurs méthodes dès maintenant.

Aide

Pour créer une énumération en Python, utilisez le module enum :

from enum import Enum

class Couleur(Enum):
    NOBLE = "Noble"
    RELIGIEUX = "Religieux"
    COMMERCIAL = "Commercial"
    MILITAIRE = "Militaire"
    PRESTIGE = "Prestige"

Pour créer une classe abstraite, utilisez le module abc :

from abc import ABC, abstractmethod

class Quartier(ABC):
    @abstractmethod
    def get_points(self) -> int:
        pass # 'pass' indique de ne rien faire tout en étant syntaxiquement correct

1.0 - Classe Carte (classe de base)

Créez la classe Carte dans carte.py :

  • Carte (classe abstraite avec attribut : _nom)

Exigences :

  • Chaque classe doit être documentée (docstring).
  • Cette classe servira de base pour Quartier et Personnage.
Code pour Carte

Voici le code complet pour carte.py :

from abc import ABC

class Carte(ABC):
    """Classe abstraite représentant une carte dans le jeu Citadelles."""
    
    def __init__(self, nom: str) -> None:
        """
        Initialise une carte avec son nom.
        
        Args:
            nom: Le nom de la carte
        """
        self._nom = nom
    
    @property
    def nom(self) -> str:
        """Retourne le nom de la carte."""
        return self._nom
    
    def __str__(self) -> str:
        """Retourne une représentation textuelle de la carte."""
        return f"{self.__class__.__name__}: {self._nom}"

Conseil : Testez la création d’une carte et l’affichage avec __str__.

Tests pour Carte

Voici des tests à copier dans le fichier test_carte.py (dans le dossier ./tests/) :

import unittest
from carte import Carte

class TestCarte(unittest.TestCase):
    def test_creation_quartier_valide(self):
        c = Carte("une carte")
        self.assertEqual(c._nom, "une carte")
        self.assertEqual(str(c), "Carte: une carte")

Conseil : Testez aussi les limites (nom absent) et les types incorrects.

Remarques: Pour lancer les tests, depuis la racine du projet, lancez la commande suivante dans le terminal:

python -m unittest discover -s tests -v

1.1 - Hiérarchie des Quartiers

Créez les classes suivantes dans quartier.py :

  • Couleur (énumération pour les 5 couleurs : Noble, Religieux, Commercial, Militaire, Prestige)
  • Quartier (classe héritant de Carte avec attributs : _nom, _couleur, _cout, _points)
  • Sous-classes de Quartier pour chaque type (ex: Noble, Religieux, etc.)

Exigences :

  • Quartier hérite de Carte (utilisez from carte import Carte)
  • Chaque classe doit être documentée (docstring).
  • Vérifiez que les arguments cout et points sont des entiers positifs (programmation défensive).
  • Écrivez des tests unitaires dans test_quartier.py.

Conseils :

  • Veillez à typer les paramètres des méthodes, pour une meilleure lisibilité et maintenabilité du code.
  • Indiquez également le type retour des méthodes.
  • Utilisez les exceptions de type ValueError pour la programmation défensive.
  • Appuyez-vous sur les tests pour développer la classe, en particulier respectez l’ordre et les noms des paramètres.
  • L’héritage impose, dans la méthode __init__(self, ...), d’appeler super().__init(...) avec les arguments attendus par la classe mère.
Exemples de tests pour Quartier

Voici des exemples de tests que votre fichier test_quartier.py pourrait contenir :

import unittest
from quartier import Quartier, Couleur, Noble, Religieux

class TestQuartier(unittest.TestCase):
    def test_creation_quartier_valide(self):
        q = Quartier("Temple", Couleur.RELIGIEUX, 1, 1)
        self.assertEqual(q._nom, "Temple")
        self.assertEqual(q._couleur, Couleur.RELIGIEUX)
        self.assertEqual(q._cout, 1)
        self.assertEqual(q._points, 1)

    def test_creation_quartier_cout_negatif(self):
        with self.assertRaises(ValueError):
            Quartier("Temple", Couleur.RELIGIEUX, -1, 1)

    def test_creation_quartier_points_negatifs(self):
        with self.assertRaises(ValueError):
            Quartier("Temple", Couleur.RELIGIEUX, 1, -5)

    def test_heritage_noble(self):
        manoir = Noble("Manoir", 3, 2)
        self.assertEqual(manoir._nom, "Manoir")
        self.assertEqual(manoir._couleur, Couleur.NOBLE)
        self.assertEqual(manoir._cout, 3)

    def test_couleur_enum(self):
        # Vérifie que toutes les couleurs sont définies
        self.assertEqual(len(Couleur), 5)
        self.assertIn(Couleur.NOBLE, Couleur)
        self.assertIn(Couleur.PRESTIGE, Couleur)

Conseil : Testez aussi les limites (cout=0, points=0) et les types incorrects.

Code de chargement des quartiers

Pour vous faire gagner du temps, voici une fonction utilitaire pour créer toutes les cartes Quartier. Ajoutez-la à la fin de votre fichier quartier.py :

def creer_quartiers() -> list[Quartier]:
    """
    Crée et retourne la liste complète des quartiers pour Citadelles à 2 joueurs.
    
    Returns:
        Liste de tous les quartiers du jeu
    """
    return [
        # Noble (jaune) - 12 cartes
        Noble("Manoir", 3, 2),
        Noble("Manoir", 3, 2),
        Noble("Manoir", 3, 2),
        Noble("Manoir", 3, 2),
        Noble("Palais", 5, 4),
        Noble("Palais", 5, 4),
        Noble("Château", 4, 3),
        Noble("Château", 4, 3),
        Noble("Château", 4, 3),
        
        # Religieux (bleu) - 12 cartes
        Religieux("Temple", 1, 1),
        Religieux("Temple", 1, 1),
        Religieux("Temple", 1, 1),
        Religieux("Eglise", 2, 2),
        Religieux("Eglise", 2, 2),
        Religieux("Eglise", 2, 2),
        Religieux("Monastère", 3, 3),
        Religieux("Monastère", 3, 3),
        Religieux("Monastère", 3, 3),
        Religieux("Cathedrale", 5, 5),
        
        # Commercial (vert) - 21 cartes
        Commercial("Taverne", 1, 1),
        Commercial("Taverne", 1, 1),
        Commercial("Taverne", 1, 1),
        Commercial("Taverne", 1, 1),
        Commercial("Taverne", 1, 1),
        Commercial("Echoppe", 2, 2),
        Commercial("Echoppe", 2, 2),
        Commercial("Echoppe", 2, 2),
        Commercial("Echoppe", 2, 2),
        Commercial("Marche", 2, 2),
        Commercial("Marche", 2, 2),
        Commercial("Marche", 2, 2),
        Commercial("Comptoir", 3, 3),
        Commercial("Comptoir", 3, 3),
        Commercial("Comptoir", 3, 3),
        Commercial("Port", 4, 4),
        Commercial("Port", 4, 4),
        Commercial("Hotel de ville", 5, 5),
        
        # Militaire (rouge) - 12 cartes
        Militaire("Tour de guet", 1, 1),
        Militaire("Tour de guet", 1, 1),
        Militaire("Tour de guet", 1, 1),
        Militaire("Prison", 2, 2),
        Militaire("Prison", 2, 2),
        Militaire("Prison", 2, 2),
        Militaire("Caserne", 3, 3),
        Militaire("Caserne", 3, 3),
        Militaire("Caserne", 3, 3),
        Militaire("Forteresse", 5, 5),
        
        # Prestige (violet) - 14 cartes
        Prestige("Cour des miracles", 2, 3),
        Prestige("Donjon", 3, 3),
        Prestige("Tresor imperial", 4, 4),
    ]

Utilisation : Dans votre classe Pioche, vous pourrez faire :

from quartier import creer_quartiers
pioche = Pioche(creer_quartiers())

1.2 - Hiérarchie des Personnages

Créez les classes suivantes dans personnage.py :

  • Personnage (classe héritant de Carte avec attributs : _nom, _description, _ordre)
  • Sous-classes pour chaque rôle : Assassin, Voleur, Magicien, Roi, Eveque, Marchand, Architecte, Condottiere

Exigences :

  • Personnage hérite de Carte (utilisez from carte import Carte)
  • L’attribut _ordre détermine l’ordre de jeu (Assassin=1, Voleur=2, …, Condottiere=8).
  • Implémentez la méthode __eq__ dans la classe Personnage pour permettre la comparaison d’instances (ex: if p1 == p2). Deux personnages sont égaux s’ils ont le même ordre.
  • Documentez chaque classe.
  • Écrivez des tests dans test_personnage.py.

Conseils :

  • Les conseils précédents s’appliquent ici également.
  • N’hésitez pas à copier/coller depuis d’autres fichiers (en adaptant les parties dupliquées) et despuis les Règles simplifiées pour 2 joueurs.
Exemples de tests pour Personnage

Voici des exemples de tests pour votre fichier test_personnage.py :

import unittest
from personnage import *

class TestPersonnage(unittest.TestCase):
    def test_creation_assassin(self):
        assassin = Assassin()
        self.assertEqual(assassin._nom, "Assassin")
        self.assertEqual(assassin._ordre, 1)

    def test_creation_roi(self):
        roi = Roi()
        self.assertEqual(roi._nom, "Roi")
        self.assertEqual(roi._ordre, 4)  # Roi est le 4ème

    def test_creation_condottiere(self):
        condottiere = Condottiere()
        self.assertEqual(condottiere._ordre, 8)  # Condottiere est le dernier

    def test_ordres_uniques(self):
        # Vérifie que tous les personnages ont des ordres différents
        personnages = [Assassin(), Voleur(), Magicien(), Roi(), 
                      Eveque(), Marchand(), Architecte(), Condottiere()]
        ordres = [p._ordre for p in personnages]
        self.assertEqual(len(set(ordres)), len(ordres))  # Tous uniques

Conseil : Testez aussi que les descriptions sont bien définies et que les noms sont corrects.

Code de chargement des quartiers

Pour vous faire gagner du temps, voici une fonction utilitaire pour créer toutes les cartes Personnage. Ajoutez-la à la fin de votre fichier personnage.py :

def creer_personnages() -> list[Personnage]:
    """
    Crée et retourne la liste complète des personnages pour Citadelles à 2 joueurs.
    
    Returns:
        Liste de tous les personnages du jeu
    """
    return [
        Assassin(),
        Voleur(),
        Magicien(),
        Roi(),
        Eveque(),
        Marchand(),
        Architecte(),
        Condottiere()
    ]

Utilisation : Dans votre classe Pioche, vous pourrez faire :

from personnage import creer_personnages
pioche = Pioche(creer_personnages())

Étape 2 : Collections de cartes (45 min)

Objectif : Implémenter les classes qui gèrent les groupes de cartes.

Toutes les méthodes peuvent être implémentées

Ces classes dépendent uniquement de Quartier et Personnage (déjà implémentées en Étape 1). Vous pouvez implémenter toutes leurs méthodes dès maintenant.

2.1 - Pioche

Créez la classe Pioche dans pioche.py avec les méthodes :

  • melanger(self) : mélange les cartes de la pioche
  • piocher(self) -> Carte : pioche la première carte (retourne None si vide)
  • ajouter_carte(self, carte:Carte) : ajoute une carte à la pioche
  • est_vide(self) -> bool : retourne True si la pioche est vide

De plus, le constructeur prendra en paramètre une liste de cartes qui sera stocker dans l’attribut _cartes.

Remarque : Dans une pioche, nous ne souhaitons manipuler que des objets de type de Carte. C’est pourquoi dans le constructeur, le paramètre cartes devra être typé avec cartes : list[Carte]. Cela implique d’importer correctement la classe Carte dans Pioche.

Conseil : Le package random dispose d’une méthode nommée shuffle permettant de mélanger (en place) les éléments d’une liste passée en paramètre (Documentation).

Tests : test_pioche.py

Exemples de tests pour Pioche

Voici des exemples de tests pour votre fichier test_pioche.py :

import unittest
from pioche import Pioche
from quartier import Quartier, Couleur

class TestPioche(unittest.TestCase):
    def setUp(self):
        # Crée une pioche avec 3 quartiers pour les tests
        cartes = [
            Quartier("Temple", Couleur.RELIGIEUX, 1, 1),
            Quartier("Eglise", Couleur.RELIGIEUX, 2, 2),
            Quartier("Manoir", Couleur.NOBLE, 3, 2)
        ]
        self.pioche = Pioche(cartes)

    def test_pioche_initiale(self):
        self.assertEqual(len(self.pioche._cartes), 3)

    def test_piocher(self):
        carte = self.pioche.piocher()
        self.assertIsNotNone(carte)
        self.assertEqual(carte._nom, "Temple")
        self.assertEqual(len(self.pioche._cartes), 2)

    def test_piocher_vide(self):
        # Vide la pioche
        self.pioche.piocher()
        self.pioche.piocher()
        self.pioche.piocher()
        self.assertTrue(self.pioche.est_vide())
        self.assertIsNone(self.pioche.piocher())

    def test_ajouter_carte(self):
        nouveau_quartier = Quartier("Taverne", Couleur.COMMERCIAL, 1, 1)
        self.pioche.ajouter_carte(nouveau_quartier)
        self.assertEqual(len(self.pioche._cartes), 4)

    def test_melanger(self):
        # Crée une pioche avec des cartes dans un ordre connu
        cartes = [
            Quartier("A", Couleur.NOBLE, 1, 1),
            Quartier("B", Couleur.NOBLE, 1, 1),
            Quartier("C", Couleur.NOBLE, 1, 1)
        ]
        pioche = Pioche(cartes)
        pioche.melanger()
        # On ne peut pas tester l'ordre aléatoire, mais on peut vérifier
        # que la pioche a toujours 3 cartes
        self.assertEqual(len(pioche._cartes), 3)

Conseil : Testez aussi que piocher() retourne bien None quand la pioche est vide.

2.2 - Jeu

À quoi sert la classe Jeu ?

La classe Jeu représente le jeu de cartes en main d’un joueur, c’est-à-dire l’ensemble des cartes Quartier qu’il a piochées mais pas encore utilisées. Elle permet de stocker temporairement les cartes disponibles pour un joueur pendant son tour. Cette classe doit simplement gérer l’ajout et le retrait de cartes, sans connaître leur utilisation future.

Responsabilité unique : Jeu ne gère que la collection de cartes. La logique de construction ou de défausse est gérée par la classe Joueur.

Créez la classe Jeu dans jeu.py avec les méthodes :

  • ajouter_carte(self, carte: Carte) : ajoute une carte Quartier au jeu (quand le joueur pioche)
  • retirer_carte(self, carte: Carte) -> Carte : retire une carte du jeu (retourne la carte retirée)
  • get_cartes() -> list[Carte] : retourne la liste des cartes du jeu

Le constructeur ne déclare aucun paramètre mais devra initialiser une liste (de cartes) vide comme attribut (protegé) de la classe.

Tests : test_jeu.py

Exemples de tests pour Jeu

Voici des exemples de tests pour votre fichier test_jeu.py :

import unittest
from jeu import Jeu
from quartier import Quartier, Couleur

class TestJeu(unittest.TestCase):
    def setUp(self):
        self.jeu = Jeu()
        self.quartier1 = Quartier("Temple", Couleur.RELIGIEUX, 1, 1)
        self.quartier2 = Quartier("Manoir", Couleur.NOBLE, 3, 2)

    def test_ajouter_carte(self):
        self.jeu.ajouter_carte(self.quartier1)
        self.assertEqual(len(self.jeu.get_cartes()), 1)
        self.assertIn(self.quartier1, self.jeu.get_cartes())

    def test_ajouter_plusieurs_cartes(self):
        self.jeu.ajouter_carte(self.quartier1)
        self.jeu.ajouter_carte(self.quartier2)
        self.assertEqual(len(self.jeu.get_cartes()), 2)

    def test_retirer_carte(self):
        self.jeu.ajouter_carte(self.quartier1)
        self.jeu.ajouter_carte(self.quartier2)
        
        carte_retiree = self.jeu.retirer_carte(self.quartier1)
        self.assertEqual(carte_retiree, self.quartier1)
        self.assertEqual(len(self.jeu.get_cartes()), 1)
        self.assertNotIn(self.quartier1, self.jeu.get_cartes())

    def test_retirer_autre_carte(self):
        self.jeu.ajouter_carte(self.quartier1)
        self.jeu.ajouter_carte(self.quartier2)
        
        self.jeu.retirer_carte(self.quartier2)
        self.assertEqual(len(self.jeu.get_cartes()), 1)
        self.assertNotIn(self.quartier2, self.jeu.get_cartes())

    def test_retirer_carte_absente(self):
        # Tenter de retirer une carte qui n'est pas dans le jeu
        with self.assertRaises(ValueError):
            self.jeu.retirer_carte(self.quartier1)

Conseil : Testez aussi les cas limites (main vide, carte déjà retirée, etc.).

2.3 - Cité

Créez la classe Cite dans cite.py avec les méthodes :

  • construire(self, quartier: Quartier) : construit un quartier dans la cité
  • get_quartiers(self) : retourne la liste des quartiers construits
  • get_points(self) : retourne le total des points

Conseils :

  • N’oubliez pas d’initialiser les attributs.
  • Pensez à ajouter, si ce n’est pas déjà fait, une méthode get_points dans Quartier.

Tests : test_cite.py

Exemples de tests pour Cité

Voici des exemples de tests pour votre fichier test_cite.py :

import unittest
from cite import Cite
from quartier import Quartier, Couleur

class TestCite(unittest.TestCase):
    def setUp(self):
        self._cite = Cite()
        self.quartier1 = Quartier("Temple", Couleur.RELIGIEUX, 1, 1)
        self.quartier2 = Quartier("Manoir", Couleur.NOBLE, 3, 2)
        self.quartier3 = Quartier("Cathedrale", Couleur.RELIGIEUX, 5, 5)

    def test_construire_quartier(self):
        self._cite.construire(self.quartier1)
        self.assertEqual(len(self._cite.get_quartiers()), 1)
        self.assertIn(self.quartier1, self._cite.get_quartiers())

    def test_get_points(self):
        self._cite.construire(self.quartier1)  # 1 point
        self._cite.construire(self.quartier2)  # 2 points
        self.assertEqual(self._cite.get_points(), 3)

    def test_construire_plusieurs_quartiers(self):
        self._cite.construire(self.quartier1)
        self._cite.construire(self.quartier2)
        self._cite.construire(self.quartier3)
        self.assertEqual(len(self._cite.get_quartiers()), 3)
        self.assertEqual(self._cite.get_points(), 8)  # 1 + 2 + 5

    def test_cite_vide(self):
        self.assertEqual(len(self._cite.get_quartiers()), 0)
        self.assertEqual(self._cite.get_points(), 0)

Conseil : Testez aussi que la construction d’un quartier qui est déjà dans la cité est interdite (programmation défensive).

Étape 3 : Classe Joueur (45 min)

Objectif : Implémenter la classe centrale Joueur.

La classe Joueur dépend de Jeu et Cite (implémentées en Étape 2). Dans la version de Citadelles à deux joueurs, chaque joueur gère deux personnages.

Créez la classe Joueur dans joueur.py avec :

  • Attributs : _nom, _jeu (instance de Jeu), _cite (instance de Cite), _pieces (entier), _personnages (liste de Personnage ou vide)
  • Méthodes :
    • recevoir_or(self, montant : int) : ajoute de l’or
    • payer(self, montant : int) -> bool : retire de l’or (retourne True si succès)
    • construire_quartier(self, quartier: Quartier) -> bool : construit un quartier du jeu, indique par retour si la construction a eu lieu.
    • get_points() -> int : retourne les points totaux

Exigences :

  • Vérifiez que le joueur a assez d’or avant de construire.
  • Vérifiez que le quartier est dans le jeu du joueur.
  • Écrivez des tests complets dans test_joueur.py.
  • Vérifier bien les imports.

Remarques: Le mot or est reservé en Python (opérateur booléen), nous ne pouvons donc pas l’utiliser comme nom d’attribut ou de paramètre. C’est pourquoi nous utilisons le terme pieces.

Exemples de tests pour Joueur

Voici des exemples de tests pour votre fichier test_joueur.py :

import unittest
from joueur import Joueur
from jeu import Jeu
from cite import Cite
from personnage import Assassin
from quartier import Quartier, Couleur

class TestJoueur(unittest.TestCase):
    def setUp(self):
        self.joueur = Joueur("Alice")
        self.quartier1 = Quartier("Temple", Couleur.RELIGIEUX, 1, 1)
        self.quartier2 = Quartier("Manoir", Couleur.NOBLE, 3, 2)

    def test_creation_joueur(self):
        self.assertEqual(self.joueur._nom, "Alice")
        self.assertEqual(self.joueur._pieces, 0)
        self.assertIsNone(self.joueur._personnage)
        self.assertIsInstance(self.joueur._jeu, Jeu)
        self.assertIsInstance(self.joueur._cite, Cite)

    def test_recevoir_or(self):
        self.joueur.recevoir_or(5)
        self.assertEqual(self.joueur._pieces, 5)
        
        self.joueur.recevoir_or(3)
        self.assertEqual(self.joueur._pieces, 8)

    def test_payer_suffisant(self):
        self.joueur.recevoir_or(10)
        result = self.joueur.payer(5)
        self.assertTrue(result)
        self.assertEqual(self.joueur._pieces, 5)

    def test_payer_insuffisant(self):
        self.joueur.recevoir_or(3)
        result = self.joueur.payer(5)
        self.assertFalse(result)
        self.assertEqual(self.joueur._pieces, 3)  # L'or ne change pas

    def test_construire_quartier_avec_assez_or(self):
        self.joueur.recevoir_or(10)
        self.joueur._jeu.ajouter_carte(self.quartier1)
        
        result = self.joueur.construire_quartier(self.quartier1)
        self.assertTrue(result)
        self.assertEqual(self.joueur._pieces, 9)  # 10 - 1
        self.assertEqual(len(self.joueur._jeu.get_cartes()), 0)
        self.assertEqual(len(self.joueur._cite.get_quartiers()), 1)

    def test_construire_quartier_sans_assez_or(self):
        self.joueur.recevoir_or(1)
        self.joueur._jeu.ajouter_carte(self.quartier2)  # cout = 3
        
        result = self.joueur.construire_quartier(self.quartier2)
        self.assertFalse(result)
        self.assertEqual(self.joueur._pieces, 1)  # L'or ne change pas
        self.assertEqual(len(self.joueur._cite.get_quartiers()), 0)

    def test_construire_quartier_pas_dans_main(self):
        self.joueur.recevoir_or(10)
        # Le quartier n'est pas dans le jeu
        result = self.joueur.construire_quartier(self.quartier1)
        self.assertFalse(result)

    def test_get_points(self):
        self.joueur.recevoir_or(10)
        self.joueur._jeu.ajouter_carte(self.quartier1)  # 1 point
        self.joueur._jeu.ajouter_carte(self.quartier2)  # 2 points
        
        self.joueur.construire_quartier(self.quartier1)
        self.joueur.construire_quartier(self.quartier2)
        
        self.assertEqual(self.joueur.get_points(), 3)

    def test_personnage(self):
        self.joueur._personnage = Assassin()
        self.assertIsNotNone(self.joueur._personnage)
        self.assertEqual(self.joueur._personnage.nom, "Assassin")

Conseil : Testez aussi les cas limites (montant négatif, quartier déjà construit, etc.) avec la programmation défensive.

Étape 4 : Classe Partie et intégration (45 min)

Objectif : Orchestrer le déroulement du jeu en utilisant les fichiers fournis.

Fichiers à utiliser : Ci-après, vous trouverez 4 fichiers Python à utiliser à partir de cette étape :

Fichier Description À compléter ?
ui.py Classe utilitaire pour l’affichage console (menus, titres, couleurs) ❌ Non
main.py Point d’entrée du programme, utilise Partie et UI ❌ Non
test_partie.py Tests unitaires pour la classe Partie ❌ Non
partie.py Classe Partie avec la logique du jeu Oui (partiellement)
Fichiers fournis

Téléchargez et déplacez ces 4 fichiers vers votre projet citadelles/.

Structure attendue :

citadelles/
├── main.py              # Déjà fourni
├── ui.py                # Déjà fourni
├── partie.py            # À compléter
├── test_partie.py       # Déjà fourni (à utiliser pour valider)
├── ... (vos autres fichiers)
└── tests/
    └── test_partie.py   # Déjà fourni

Classe Partie (partie.py) :

La classe Partie fournie gère le déroulement complet d’une partie. Plusieurs méthodes sont déjà implémentées :

  • __init__() : Initialise la partie avec 2 joueurs, la couronne, les personnages et la pioche
  • _distribuer_quartiers_initiaux() : Distribue 4 quartiers à chaque joueur (ordre dépend de la couronne)
  • _distribuer_personnages() : Gère le choix des personnages par les joueurs
  • _lancer_tour() : Orchestre un tour complet (appelle _jouer_tour pour chaque personnage)
  • _choisir_personnage() : Permet à un joueur de choisir un personnage
  • _jouer_tour() : Gère le tour d’un joueur avec un personnage
  • _choisir_revenu() : Permet de choisir entre or ou quartiers
  • _construire_quartier() : Gère la construction d’un quartier

Méthodes à compléter dans partie.py :

Vous devez implémenter les 3 méthodes marquées TODO :

  1. lancer_partie() : Méthode principale qui orchestrerait :

    • Distribution initiale des quartiers (4 par joueur)
    • Distribution initiale de l’or (2 pièces par joueur)
    • Boucle des tours jusqu’à ce qu’un joueur ait 8 quartiers
    • Comptage des points en fin de partie
  2. _conditions_arret() : Retourne True si un joueur a construit 8 quartiers dans sa cité

  3. _comptage_points() : Calcule et affiche les points de chaque joueur en fin de partie

Conseils pour compléter :

  • Utilisez les méthodes déjà implémentées (_distribuer_quartiers_initiaux, _distribuer_personnages, _lancer_tour)
  • Inspirez-vous des tests dans test_partie.py pour comprendre le comportement attendu
  • La condition d’arrêt : len(joueur._cite.get_quartiers()) >= 8
  • Pour le comptage des points : utilisez joueur.get_points()

Règles appliquées dans le code fourni :

  • Chaque joueur choisit 2 personnages par tour (méthode _distribuer_personnages)
  • Ordre de jeu : selon l’ordre des personnages (Assassin=1 joue en premier)
  • Tour de jeu pour chaque personnage :
    1. Choix du revenu (2 pièces ou piocher 2 quartiers et en garder 1)
    2. Construction d’un quartier (optionnelle)
    3. Application du pouvoir spécial (à implémenter si temps)
  • La couronne est attribuée aléatoirement au début et change si le Roi est choisi
Important

Le fichier partie.py fourni utilise la classe Jeu pour gérer les personnages disponibles pendant le choix. Assurez-vous que votre classe Jeu (créée en Étape 2) implémente bien :

  • ajouter_carte(self, carte: Carte)
  • retirer_carte(self, carte: Carte) -> Carte
  • get_cartes() -> list[Carte]

Le fichier ui.py nécessite d’importer Carte et Quartier. Votre projet doit donc avoir ces classes définies.

Fichier main.py fourni :

from partie import Partie
from ui import UI

if __name__ == "__main__":
    UI.clear()
    UI.title("🏰 CITADELLES 🏰")
    UI.info("Jeu à 2 joueurs - © B. Faure et B. Bogaert")
    UI.separator()
    
    nom1 = input("Nom du Joueur 1 [Alice] : ") or "Alice"
    nom2 = input("Nom du Joueur 2 [Bob] : ") or "Bob"
    UI.info(f"Début de la partie entre {nom1} et {nom2}")
    UI.separator()
    
    partie = Partie(nom1, nom2)
    partie.lancer_partie()

Fichier ui.py fourni : Classe utilitaire pour un affichage console agréable avec méthodes statiques pour les titres, menus, messages de succès/erreur, etc.

Tests fournis : Le fichier test_partie.py contient des tests pour valider votre implémentation. Exécutez-le avec python -m unittest test_partie.py.

Étape 5 : Intégration et test complet (30 min)

Objectif : Valider que tout fonctionne ensemble.

  1. Exécutez le programme : python main.py
  2. Jouez une partie complète à 2 rôles (vous incarnez les deux joueurs).
  3. Corrigez les bugs en utilisant les messages d’erreur.
  4. Ajoutez des améliorations (si temps) :
    • Implémentez les pouvoirs des personnages (Assassin, Voleur, etc.)
    • Déplacez la logique du joueur depuis la classe Partie vers la classe Joueur
    • Améliorez l’interface (couleurs, tableaux)

Après le TP

Compléter l’activité

Avant la prochaine séance, vous devez :

  • Terminer les parties obligatoires : Les étapes 0 à 4 doivent être fonctionnelles.
  • Pousser votre code sur un dépôt Git (local ou distant) :
    git add .
    git commit -m "Citadelles - Implementation complete"
  • Reviser les concepts POO et les bonnes pratiques (documentation, tests).

Pour aller plus loin (optionnel)

Si vous avez terminé à temps, vous pouvez :

  • Ajouter un système de sauvegarde/chargement de partie.
  • Créer une interface graphique avec tkinter ou pygame.

Structure finale du projet

citadelles/
├── __init__.py          # Déclaration package Python
├── main.py              # Point d'entrée
├── carte.py             # Classe Carte (classe de base)
├── quartier.py          # Classes Quartier et sous-classes
├── personnage.py        # Classes Personnage et sous-classes
├── jeu.py               # Classe Jeu (jeu de cartes)
├── pioche.py            # Classe Pioche
├── cite.py              # Classe Cite
├── joueur.py            # Classe Joueur
├── partie.py            # Classe Partie
└── tests/
    ├── __init__.py
    ├── test_carte.py
    ├── test_quartier.py
    ├── test_personnage.py
    ├── test_jeu.py
    ├── test_pioche.py
    ├── test_cite.py
    └── test_joueur.py
    └── test_joueur.py
Astuce

Pour executer tous les tests d’un coup, vous pouvez créer un fichier run_tests.py :

import unittest
import glob

if __name__ == '__main__':
    loader = unittest.TestLoader()
    suite = unittest.TestSuite()
    
    for test_file in glob.glob('tests/test_*.py'):
        suite.addTests(loader.loadTestsFromName(test_file[:-3]))
    
    runner = unittest.TextTestRunner(verbosity=2)
    runner.run(suite)