This assignment will be closed on November 25, 2024 (23:59:59).
You must be authenticated to submit your files

Tutorial 7: Naive War (card game)

O. Blazy from U. Fahrenberg

Setup: Before You Start

Create a new empty project in Spyder, and call it Tutorial_7.

Now create a new program file, war.py. All the functions you write have to be added to this file.

Context and Objectives

We are going to implement the basic functionalities for a card game.

At the end of the mandatory part of the tutorial, you are going to be one step away from being able to play War, which is a card game which seems to be almost globally known. War is its most common name; you may know it as Battle. In this tutorial we will implement a naïve version of it.

We will play a game, where each player play simultaneously the first card of its deck, and the player with the highest card wins all of them and place at the end of their deck in a random order. To handle draws, we are going to consider the classical order of suits: Clubs ♣ < Diamonds < Hearts < Spades ♠. The game ends when a player has all the cards.

Skills we will practice today: writing classes, creating objects, interactions between different classes/objects.

Exercises

Exercise 1: A Class for Playing Cards

We will play with the usual French playing cards, where a deck consists of four suits of thirteen cards each.

Create a new class Card in your war.py file. Your class should begin as follows:

class Card:
    """French playing cards.

    Class attributes:
    suit_names -- the four suits Clubs, Diamonds, Hearts, Spades
    rank_names -- the 13 ranks in each suit: Two--Ten, Jack, Queen, King, Ace

    Data attributes:
    suit, rank -- the Card's suit and rank, as indices into the lists above
    """

    suit_names = ['Clubs', 'Diamonds', 'Hearts', 'Spades']
    rank_names = ['Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight',
             'Nine', 'Ten', 'Jack', 'Queen', 'King', 'Ace']

Add an __init__() method to the Card class. This method has three parameters: self (the current/active object itself), suit, and rank. It initializes the data attributes of the object with the same names. Your method should begin as follows:

    def __init__(self, suit, rank):
  • suit represents an index into the suit_names list (an integer between 0 and 3);
  • rank represents an index into the rank_names list (an integer between 0 and 12);

Once your __init__() method is defined, you should be able to run the following test:

In [1]: c = Card(2, 12)

In [2]: c.suit
Out[2]: 2

In [3]: c.rank
Out[3]: 12

In [4]: c.suit_names[c.suit]
Out[106]: 'Hearts'

Add a __str__() method to the Card class, which uses the object’s suit and rank attributes as indices into the corresponding lists, i.e. suit_names or rank_names, in order to return a readable card value. Your method should begin as follows:

def __str__(self):

Once this is done, you should be able to run the following simple test:

In [5]: c = Card(0, 0)

In [6]: str(c)  # str(c) uses c.__str__(), if it exists
Out[6]: 'Two of Clubs'

In [7]: print(c)  # equivalent to print(str(c))
Two of Clubs

In [8]: print(Card(3, 12))
Ace of Spades

Upload your file war.py:

Upload form is only available when connected

Interlude: Some Cards Are More Equal Than Others

To play the game, we will need to be able to check whether or not two card objects represent the same playing card.

Equality Testing with __eq__: The Problem

When Python compares the equality of two objects (of any class), say obj1 == obj2, then it will search for a method named __eq__ in obj1. The __eq__ method should take two arguments, self (the active object, obj1 in this case) and other (a name for the other object being compared, obj2 in this case), and return True or False, depending on whether the objects should be considered equal or not.

If obj1 has no __eq__ method but obj2 does, then Python will call obj2’s __eq__ method to compare equality (and then self will be a name for obj2 and other a name for obj1).

If neither obj1 or obj2 has a __eq__ method, then by default Python will simply test whether obj1 and obj2 are names for the same object in memory. This means that if obj1 and obj2 were created separately, then obj1 == obj2 will be false, even if the objects have exactly the same attribute values. Often this is not the behaviour that we want! For a more meaningful equality test, we must define an __eq__ method.

For example, before adding an __eq__ method to your Card class, you should see the following behaviour:

In [9]: c1 = Card(0, 0)

In [10]: c2 = Card(0, 0)

In [11]: c1 == c2  # **before** __eq__ is defined in Card
Out[11]: False

This is because c1 and c2 are distinct objects, at different addresses in memory:

In [12]: c1
Out[12]: <__main__.Card at 0x7fa5f46a7278>

In [13]: c2
Out[13]: <__main__.Card at 0x7fa5f46a7a90>

The Solution

We will now add a method __eq__() to the Card class, declaring quite reasonably that two cards are equal if (and only if) their ranks and suits are equal. To this end, add the following method to your class, which provides a deep equality check for cards:

    def __eq__(self, other):
        return self.rank == other.rank and self.suit == other.suit

We should then see

In [14]: c1 = Card(0, 0)

In [15]: c2 = Card(0, 0)

In [16]: c1 == c2  # **after** __eq__ is defined in Card
Out[16]: True

Upload your file war.py:

Upload form is only available when connected

Inequality Testing with __gt__:

To find the highest card, a natural approach is to try to find the max(). Python inner mechanisms, for that use the comparison operator > defined for the class. Like for equality testing, you can override its behavior by once again introducing a new method __gt__ (for greater than). That tests which of two cards is the highest, by first comparing their rank, and in case of equality by comparing their suits.

    def __gt__(self, other):
        # returns True if and only if this Card is higher than the other

We should then see

In [17]: c1 = Card(1, 0)

In [18]: c2 = Card(0, 0)

In [19]: c1 > c2
Out[19]: True

In [20]: c1 = Card(1, 0)

In [21]: c2 = Card(1, 1)

In [22]: c1 > c2
Out[22]: False

In [23]: c1 = Card(1, 0)

In [24]: c2 = Card(0, 2)

In [25]: c1 > c2
Out[25]: False

Upload your file war.py:

Upload form is only available when connected

Exercise 2: A Class for Decks of Cards

The next thing we need is a class to represent a full deck of cards.

Create a new class Deck (still in war.py), which should begin as follows:

class Deck:
    """A deck of Cards.

    Data attributes:
    cards -- a list of all Cards in the Deck
    """

Add an __init__() method to your Deck class. This should fill the object’s cards attribute with all the Cards in the Deck, in the standard deck ordering with “aces high”: Two of Clubs, Three of Clubs, …, King of Clubs, Ace of Clubs, Two of Diamonds, …, Ace of Diamonds, Two of Hearts, …, Ace of Hearts, Two of Spades, …, Ace of Spades.

For testing purposes, your __init__() method should take an extra integer parameter minrank that gives the minimal rank of the cards in the deck. Hence, your __init__() method should begin as follows:

    def __init__(self, minrank):

We will use minrank to create decks of cards which do not contain some of the smaller cards. Thus, Deck(n) should create a deck which does not contain the cards with a rank (strictly) less than n.

Hint: Using len(Card.rank_names) -1 is a good way to access the number of ranks in the current card games.

Also add a __str__() method to your Deck class. This should return the cards in a Deck object in the order they appear in its cards attribute, separated by the string ', '. Your method should begin as follows:

    def __str__(self):

Hint: You might want to implement your method by first creating a list l containing the string representations of all cards in the deck and then return ', '.join(l).

You can quickly test your __init__() and __str__() methods in Deck using the following example:

In [26]: d = Deck(10)

In [27]: print(d)
Queen of Clubs, King of Clubs, Ace of Clubs, Queen of Diamonds, King of Diamonds, Ace of Diamonds, Queen of Hearts, King of Hearts, Ace of Hearts, Queen of Spades, King of Spades, Ace of Spades

Upload your file war.py:

Upload form is only available when connected

Exercise 3: More about Decks

Add a method pop() to your Deck class which removes and returns the last card in the deck. You may assume that the deck is not empty before pop() is called. We will need pop() later for dealing cards. Your method should start as follows:

    def pop(self):
        """Remove and return last card from deck."""

You can quickly test your pop() method using this example:

In [28]: d = Deck(10)

In [29]: print(d)
Queen of Clubs, King of Clubs, Ace of Clubs, Queen of Diamonds, King of Diamonds, Ace of Diamonds, Queen of Hearts, King of Hearts, Ace of Hearts, Queen of Spades, King of Spades, Ace of Spades

In [30]: c = d.pop()

In [31]: print(c)
Ace of Spades

In [32]: print(d)
Queen of Clubs, King of Clubs, Ace of Clubs, Queen of Diamonds, King of Diamonds, Ace of Diamonds, Queen of Hearts, King of Hearts, Ace of Hearts, Queen of Spades, King of Spades

Finally, we need to be able to shuffle a deck of cards. Add a method shuffle to your Deck class which shuffles a deck’s Cards randomly. To this end, add import random to the beginning of your war.py file, and then use the shuffle method of the random module

Your method should start with

    def shuffle(self):
        """Shuffle the deck."""

Hint: If l is a list, then random.shuffle(l) shuffles the list in place (that is, it modifies l and returns None).

Now you should see something similar to the following (your output may differ due to randomization).

In [33]: d = Deck(10)

In [34]: print(d)
Queen of Clubs, King of Clubs, Ace of Clubs, Queen of Diamonds, King of Diamonds, Ace of Diamonds, Queen of Hearts, King of Hearts, Ace of Hearts, Queen of Spades, King of Spades, Ace of Spades

In [35]: d.shuffle()

In [36]: print(d)
Ace of Spades, King of Clubs, Queen of Spades, King of Diamonds, Queen of Clubs, Queen of Hearts, Queen of Diamonds, Ace of Hearts, King of Spades, King of Hearts, Ace of Clubs, Ace of Diamonds

Upload your file war.py:

Upload form is only available when connected

Exercise 4: A Class for Players

We need to represent players of the card game. To this end, add a class Player to your code. Your class should start as follows:

class Player:
    """A player of the card game.

    Data attributes:
    name -- the name of the player
    hand -- a Deck containing composed of the player's cards (their "hand")
    """

Add an __init__() method to your class which initializes the object’s name to the given string and the object’s hand list to an empty Deck. Hence your __init__() method should begin as follows:

    def __init__(self, name):

Add a __str__() method to your class which returns a string consisting of the string 'Player ', followed by the player’s name, and then either a string representing the player’s cards (separated by ', ') or the string 'has no cards' if the player has no cards. Your __str__() method should begin as follows:

    def __str__(self):

For example, in the console we should see

In [37]: p = Player('Adam')

In [38]: print(p)
Player Adam has no cards

In [39]: p.hand = Deck(12)

In [40]: print(p)
Player Adam has: Ace of Clubs, Ace of Diamonds, Ace of Hearts, Ace of Spades

It should be noted, that from now on, using player.hand.cards is a neat shortcut to access the list of cards in the player player hand.

Upload your file war.py:

Upload form is only available when connected

Exercise 5: Players Playing

We need the following additional methods for the Player class:

  1. a method add_card() which adds a card, given as input, at the beginning of the player’s hand;
  2. a method num_cards() which returns the number of cards in the player’s hand;
  3. finally, a method remove_card() which returns the bottom (i.e., last) card from the player’s hand.

Your methods should begin as follows:

    def add_card(self, card):
        """Add card to this player's hand."""

    def num_cards(self):
        """Return the number of cards in this player's hand."""

    def remove_card(self):
        """Remove the last card from this player's hand and return it."""

Test your Player class. In the console we expect the following:

In [41]: p = Player('Liza')

In [42]: p.num_cards()
Out[42]: 0

In [43]: p.add_card(Card(2, 4))

In [44]: p.num_cards()
Out[44]: 1

In [45]: print(p)
Player Liza has: Six of Hearts

In [46]: c = p.remove_card()

In [47]: print(c)
Six of Hearts

In [48]: print(p)
Player Liza has no cards

Upload your file war.py:

Upload form is only available when connected

Exercise 6: A Class for Card Games

The last class we need is one which represents a card game. Create a new class CardGame, which should begin as follows:

class CardGame:
    """A class for playing card games.

    Data attributes:
    players -- a list of Player objects which participate in the game
    deck -- a Deck of Cards used for playing
    num_cards -- the number of Cards in the game
    """

Add __init__() and __str__() methods to your CardGame class.

The __init__ method should begin as follows:

    def __init__(self, player_names, minrank):

__init__ takes as input a list of player names and an integer minrank like the Deck class. The method then initializes a list players of Player objects. Further, __init__() creates a Deck object deck using Deck(minrank), and assigns to num_cards the number of cards in the deck, for later use.

The __str__() method should begin as follows:

    def __str__(self):

__str__ returns a string representing each player and all of their cards, with each player (and their cards) separated by a newline character ('\n'), (implicitly) using the Player class’s __str__() method.

Add a burn_cards() method to your CardGame class which removes a list of cards cards (passed as a parameter) from the game’s deck.

Your methods should start as follows:

    def burn_cards(self, cards):
        """Remove the cards 'cards' from this game's deck for those that exists,
        and update the number of cards in the deck accordingly"""

Test your work. In the console, we expect the following behavior:

In [49]: g = CardGame(['Grace', 'Emmy', 'Sofia'], 11)

In [50]: print(g)
Player Grace has no cards
Player Emmy has no cards
Player Sofia has no cards

In [51]: g.num_cards
Out[51]: 8

In [52]: print(g.deck)
King of Clubs, Ace of Clubs, King of Diamonds, Ace of Diamonds, King of Hearts, Ace of Hearts, King of Spades, Ace of Spades

In [53]: g.burn_cards([Card(3,12)])

In [54]: g.num_cards
Out[54]: 7

In [55]: print(g.deck)
King of Clubs, Ace of Clubs, King of Diamonds, Ace of Diamonds, King of Hearts, Ace of Hearts, King of Spades


It should be noted, that as for players, the list of cards in a deck deck can be accessed via deck.cards.

Upload your file war.py:

Upload form is only available when connected

Exercise 7: Dealing Cards

Add a shuffle_deck() method to your CardGame class which shuffles the game’s deck, and a deal_cards() method which deals all cards in the deck to the players. The cards are dealt in reverse order (that is, starting from the end of the deck), in a round-robin fashion, starting with players[0], until the deck is empty. Use the Player class’s add_card() method and the pop() method in the Deck class.

Your methods should start as follows:

    def shuffle_deck(self):
        """Shuffle this game's deck."""

    def deal_cards(self):
        """Deal all of the cards in the deck to the players, round-robin."""

Test your work (your output may differ due to randomization):

In [56]: g = CardGame(['Grace', 'Emmy', 'Sofia'], 10)

In [57]: g.shuffle_deck()

In [58]: g.deal_cards()

In [59]: print(g)
Player Grace has: King of Hearts, Queen of Spades, Ace of Diamonds, King of Clubs
Player Emmy has: Ace of Clubs, Queen of Diamonds, Queen of Hearts, Ace of Hearts
Player Sofia has: Ace of Spades, King of Diamonds, King of Spades, Queen of Clubs

In [60]: print(g.deck)

Upload your file war.py:

Upload form is only available when connected

Exercise 8: Playing “Find the highest”

We can now implement our simple card game.

Add a simple_turn() method to your CardGame class which plays the last card of the deck of each active player, and declares the owner of the highest card as the winner.

More precisely, your function must return the name of the winner, and the list of played Cards in the same order. One might assume that the list of active players is up to date, so each player has at least one card in their respective deck.

Your method should start as follows:

    def simple_turn(self):
        """Play a very simple game.
        For each player, play the first card.
        The winner is the player with the highest cards.
        """

Test your work. In the console we expect the following:

In [61]: g = CardGame(['Grace', 'Emmy', 'Sofia'], 9)

In [62]: g.deal_cards()

In [63]: (winner, trick) = g.simple_turn()
Player Grace: Jack of Clubs
Player Emmy: King of Spades
Player Sofia: Queen of Spades
Player Grace wins the round

In [64]: winner
Out[64]: 'Grace'

Play some more simple games. (Use the shuffle_deck() method to get less predictable outcomes…)

Upload your file war.py:

Upload form is only available when connected

Exercise 9: Playing this simple Game

Now you can add a method play_simple() to your CardGame that plays a simple turn, add those cards in a random order at the beginning of the winner’s deck, and repeat until only one player has all the cards. You should be careful and properly handle players with no cards.

Test your work. In the console we expect the following:

In [65]: g = CardGame(['Grace', 'Emmy', 'Sofia'], 10)

In [66]: g.burn_cards([Card(3,12)])

In [67]: g.deal_cards()

In [68]: g.play_simple()
...
Out[68]: 'Emmy'

Upload your file war.py:

Upload form is only available when connected

These exercises should not require you to alter the previous functions.

Exercise 10(Optional): Handle Wars

We can now implement the real game.

Not so Simple turn

Add a war_turn() method to your CardGame class which takes a list of players and plays the first card of their deck, and declares the owner of the highest card as the winner, however in war we disregard suits, so there could be more than one winner. So the function returns a list of winners, and the list of played cards.

Full on war

Add a war() method to your CardGame class which now unrolls a full turn of war.

  1. Call listw,pot=war_turn()
  2. If there is a sole winner, add the cards from pot to their deck
  3. Otherwise
    • Make each of the potential winner add their first card to the pot
    • Restart a new war_turn for them, and iterate until there is a sole winner

Your method should start as follows:

    def war(self,listplayer):
        """Plays a turn of war between active players.
        In case of equality amongst the winning cards
            Each potential winner, add the first card to the pot.
            Play the second card, and compare them.
            Iterate if need be.
        The function return the winner, and the pot (list of card played)
        """

War

Finally, add a method play() to the CardGame class which plays the War game.

Repeat the war_turn() method until there is only one active player. It is recommended to have a list of active players, and update it every time a player loses (cannot play a card), to easily detect a winning condition.