Assignment for FIT1008/FIT2085/FIT1054 - A1
Welcome to the Card Game Assignment! This assignment is designed to help you learn the fundamentals of algorithms and data structures by implementing a card game. Below are the details of the game, its rules, and your tasks. Good luck and have fun!
The card game involves multiple players and a deck of cards. The objective is to be the first player to get rid of all your cards. Each card has a color and a label, and there are special black cards with unique abilities.
- You cannot use any built-in data structures or algorithms from any libraries except the ones provided to you in the scaffold (and no, you cannot modify the code for these data structure either).
- This means that the use of python in-built lists, dictionaries, tuples, and sets (among other built in data structures) is forbidden. In addition to this ArrayR is also prohibited usage of this unless provided by the scaffold will result in a penalty in approach marks.
- You cannot use any built-in sorting algorithms. You must use one of the sorting algorithms given to you in the scaffold OR use a data structure that facilitates sorting algorithms.
- You cannot use generative AI, machine learning, or any other form of AI software.
- You cannot use any hard coded constants apart from the ones provided to you in the constants class. If you need to use a constant, please add it to the constants class.
- You cannot access the internals of any data structures or any classes except the custom classes that you edit in the tasks. You can only access the methods of the data structures that are provided to you in the scaffold. Each use of the internals of a data structure will count as a major mistake and will lose at least half of the marks for the task.
-
The game is played with a deck of cards. The deck consists of 112 cards, built from four blocks of 26 cards: one block for each green, red, blue, and yellow colors. Each color has two cards of each rank (0-9), two Draw Two cards, two Skip cards, two Reverse cards, four Crazy Crazy cards and four Crazy Draw Four cards.
-
Each player is dealt 7 cards face down (note: whether cards are face up or down will never be relevant for our set-up; we have added this info for those who know the game). The remaining cards are placed in a Draw Pile (also face down). There is also a Discard Pile where played cards are placed. The top card from the Draw Pile is then placed in the Discard Pile face up, and the game begins!
-
The player with the lowest index in the collection goes first (determined in our set-up as the player with index 0 in the array of players given as input). Play then proceeds in an increasing order of the player's index. Thus, in our set-up, we will start with the player at index 0 and move up to K where K is the number of players - 1.
-
To play a card, a player puts a card on top of the Discard Pile facing up.
-
A player can only play a card if that card is of the same color, or has the same label (number/reverse/skip) as the one at the top of the Discard Pile. Alternatively, a Crazy card, or a Crazy Draw Four card can always be played. In our set-up, the player starts scanning its cards from index 0, until they find a playable card.
-
If a player can play at least one card, they must. If they cannot, they must draw a card from the Draw Pile. If that card can be played, they must play it. Otherwise, they keep the card and the turn moves to the next player.
-
If a player plays a Crazy Crazy card, the player gets to choose the color to continue play.
-
If a player plays a Crazy Draw Four card, the color is changed to a randomly chosen color, and the next player must draw four cards from the Draw Pile and cannot play either of them. The next player must also skip their turn. The turn then moves to the player after the next player.
-
If the Draw Pile is empty, the Discard Pile (except the top card) is shuffled and placed as the new Draw Pile. (Detailed instructions on how to do this are given in Task 4.)
-
A player wins by playing all their cards. Once a player runs out of cards, the game ends immediately and that player is declared the winner. However, if the last play is a Draw Two or Draw Four card, the next player must draw the appropriate number of cards before the game ends.
File card.py
has two enum
classes: CardColor
and CardLabel
. You must now modify it to implement the Card
class with the following instance attributes:
color
- an enum value representing the color of the card.label
- an enum value representing the label of the card.
You must also modify file player.py
to implement the Player
class with the following attributes:
name
- a string representing the name of the player.position
- an integer representing the position of the player in the game. The player with the lowest position goes first. Positions start at 0.hand
- a ordered collection ofCard
objects representing the cards in the player's hand. This list must be ordered by color of the card, then by card label in an increasing order according to the enum values. If the card has the same color and label, then consider the first card added to the hand to be the one with the lower index*.add_card(self, card: Card)
- a method that takes aCard
object as an argument and adds it to the hand. The method should returnNone
. Assume that the card being passed is a valid card.play_card(self, index: int)
- a method that takes an integer as an argument and removes the card at the given index from the player's hand. The method should return the card that was removed from the player's hand.__len__(self)
- a method that returns the number of cards in the player's hand.__getitem__(self, index: int)
- a method that takes an integer as an argument and returns the card at the given index in the player's hand.
NOTE - For both of these classes, please add additional helper methods if you think they are necessary (including a __str__
method to help with debugging).
* Our test cases will never check the order of cards with the same color and label.
You have been given some code in file game.py
. You must now modify it to implement the Game
class with the following attributes:
-
__init__(self)
- The constructor of the class. This method takes no arguments but should be used to set up the various instance variables mentioned below. -
players
- a collection ofPlayer
objects representing the players in the game. The game should commence in the order of theposition
of the players. When initialising theGame
object, this collection should be empty. You can assume that the players passed through will always be in the correct ascending order of their positions. -
draw_pile
- a collection ofCard
objects representing the cards that players may draw to play the game. When initialising theGame
object, this collection should be empty. -
discard_pile
- a collection ofCard
objects representing the cards already played in the game. When initialising theGame
object, this collection should be empty. -
current_player
- aPlayer
object representing the player whose turn it is to play. When initialising theGame
object, this object should be empty. -
current_color
- anenum
value representing the color of the top card of thediscard_pile
. When initialising theGame
object, this object should be empty. -
current_label
- anenum
value representing the label of the top card of thediscard_pile
. When initialising theGame
object, this object should be empty. -
initialise_game(self, players: ArrayR)
- This method performs the following tasks:- Use the array of
Player
objects being passed to this method to populate theplayers
attribute of theGame
object. - Call the method
generate_cards
to get an ArrayR of all cards - Starting from card index 0 and player index 0, deal one card to each player in the order of the players. Continue to deal cards until each player has
Constants.NUM_CARDS_AT_INIT
cards in their hand. For example - if you have a game of 3 players with each player getting 2 cards, the players will have the following card indices in hand: Player 1 - 0, 3 Player 2 - 1, 4 Player 3 - 2, 5 - Continue from the index where you finished dealing the cards and add these cards to the
draw_pile
, ensuring the last index of the generated cards ends up at the top of thedraw_pile
. - A card should then be drawn to be the top of the
discard_pile
. The top card of thediscard_pile
should be a number card. If the top card is a special card add it to thediscard_pile
and drew a new card. Repeat this until you get a non-special card. Special cards include anything that is not a number card. - Update the
current_color
andcurrent_label
attributes appropriately once the top card of thediscard_pile
is a number card. Note that thecurrent_player
attribute remains unchanged, i.e.,None
(indicating the game has not yet started).
- Use the array of
NOTE - You can assume that there are at least 2 players. You can also assume that the players will not have duplicate names.
Now that you have the basic structure of the game, it is time to implement the game logic. To do this, modify the Game
class to implement the following methods:
-
draw_card(self, player: Player, playing: bool)
- a method that takes aPlayer
object as an argument and draws aCard
object from thedraw_pile
. If the card can be played and theplaying
argument isTrue
, the card should be returned. Otherwise, the card should be added to the player's hand and the method should returnNone
. This method should be called multiple times if the special card is a Draw 2 or Draw 4. -
next_player(self)
- a method that gets the Player object of the next player (note: ifcurrent_player
isNone
, this should simply return the Player to play the next turn). This will be helpful when you are making the next player draw cards. The method should return thePlayer
object of the next player. This method should NOT update thecurrent_player
of the game object. This method should merely probe the next player in the order of the players. -
play_reverse(self)
- a method that changes the direction of play. If the direction of play is clockwise, it should be changed to anti-clockwise, and vice versa. The method should returnNone
. -
play_skip(self)
- a method that skips the next player's turn. The method should returnNone
. -
crazy_play(self, card: Card)
- a method that takes aCard
object and changes the game'scurrent_color
instance variable to a randomly chosen color. To choose the color, we use the following code:CardColor(RandomGen.randint(0,3))
whereRandomGen
is an instance of theRandomGen
class andCardColor
is an enum class representing the colors of the cards. If its a CRAZY Draw 4 card, this method makes the next player draw 4 cards from thedraw_pile
. The method should returnNone
.- Redundant calls to
RandomGen.randint(0,3)
will result in a loss of marks similar to as is explained withRandomGen.random_shuffle(temp_array)
in Task 4. - You can assume that the card being passed is a CRAZY card.
- Try to reuse predefined methods where possible to achieve this. Not reusing methods will result in a loss of marks.
- Redundant calls to
You must now implement the play_game
method in the Game
class. The play_game
method should simulate the game. The method should return the Player
object of the winner. The method should follow the rules of the game mentioned before and recapitulated below:
play_game(self)
- a method that starts the game. The game should continue until a player has no cards left in their hand. The method should return a reference to the player who won the game. You should utilise the methods defined above to achieve this method's purpose. Please remember the rules of the game! Here is a summary of the rules:- The player with the lowest position in the collection goes first.
- Eg the element at the front of the queue, top of the stack, 0th index of the array, etc.
- In our version of the game, we will be playing cards starting at index 0 and moving to the right.
- The game ends when 1 player has no cards left in their hand.
- The only circumstance for a game to momentarily continue is if the final card played is a Draw Two or Draw Four card, the next player must draw the appropriate number of cards before the game ends.
- You will not have to consider a situation where the game ends in a draw as players have no available moves or cards to draw. All games tested will have a single winner.
- A player can only play a card if that card has the same color or the same label as the top card of the discard pile. Alternatively, a Crazy card, or a Crazy Draw Four card can be played over any other card.
- If a player cannot play a card, they must draw a card from the
draw_pile
. If that card can be played, they must play it. Otherwise, they keep the card and the turn moves to the next player. - If a player plays a draw two card, the next player must draw two cards from the Draw Pile and cannot play either of them. The next player's turn is also skipped. For example - if Bob plays Draw 2 and Charlie is the next player, Charlie must draw 2 cards from the
draw_pile
and cannot play either of them. Charlie's turn is then skipped. - Remember, if the draw pile is empty, the discard pile (except the top card) is shuffled and placed in the draw pile. The top card of the discard pile is then placed back on top of the discard pile. This must be done by:
- removing the top card of the discard pile and storing it elsewhere
- remove each card from the top of the discard pile and add it to an array
- shuffle the array by calling
RandomGen.random_shuffle(temp_array)
- Important you must avoid redundant shuffling. If the array is already shuffled, you should not shuffle it again. Redudant shuffling will result in your game producing different results to the expected results thus reducing your testcase marks.
- Start at index 0 of the shuffled array and add each card back to the draw pile, with the last index going on top of the draw pile.
- Add the stored card back to the top of the discard pile.
- Similarly, if a player plays a draw four card, the next player must draw four cards from the Draw Pile and cannot play either of them. The turn then moves to the next player. For example - if Bob plays Draw 4 and Charlie is the next player, Charlie must draw 4 cards from the
draw_pile
and cannot play either of them. Charlie's turn is then skipped. - If a player plays a CRAZY card, the player gets to choose the color to continue play. In our version of the game, we will be choosing a color using the given RandomGen class using the following code:
CardColor(RandomGen.randint(0,3))
whereRandomGen
is an instance of theRandomGen
class andCardColor
is an enum class representing the colors of the cards. The next player can play any card of that color or a CRAZY card.
- The player with the lowest position in the collection goes first.
In the file game_stats.py
, you must implement the GameStats
class. The GameStats
class should have the following attributes:
-
__init__(self, players: ArrayR)
- The constructor of the class. This method takes an ArrayR of Player objects as an argument and should be used to set up the instance variable mentioned below. -
stats
- A table structure that has the players as the rows and the following columns (in order):player_name
- The name of the player.games_played
- The total number of games played by the player.turns_taken
- The total number of turns taken by the player.cards_played
- The total number of cards played by the player.cards_drawn
- The total number of cards drawn by the player. (This does NOT include the cards given at the start of the game)cards_left
- The total number of cards left in the player's hand at the end of the game.wins
- The number of games won by the player.
-
players
- A collection of Player objects that exist in the statistics. This collection should be the same as the one passed to the constructor while initialising the object. A player should be added to this collection only if they are not already in the collection when being passed to theadd_player
method.
NOTE - This means if there are 3 players, the table should have 3 rows and 7 columns (mentioned above). Also, except the player_name
column, all other columns should be initialised to 0. Another thing to note is that the total number of distinct players will NEVER exceed the MAX_PLAYERS constant.
The class should also have the following methods defined:
-
add_player(self, player: Player)
- This method adds a new non-existant player to thestats
table. The method should returnNone
. This method should also initialise the player's statistics to 0. -
record_game_played(self, player: Player)
- This method adds 1 to thegames_played
attribute of the player passed as an argument. The method should returnNone
. -
record_turn(self, player: Player)
- This method adds 1 to theturns_taken
attribute of the player passed as an argument. The method should returnNone
. -
record_card_played(self, player: Player)
- This method adds 1 to thecards_played
attribute of the player passed as an argument. The method should returnNone
. -
record_card_drawn(self, player: Player)
- This method adds 1 to thecards_drawn
attribute of the player passed as an argument. The method should returnNone
. -
record_game_won(self, player: Player)
- This method adds 1 to thewins
attribute of the player passed as an argument. The method should returnNone
. -
record_cards_left(self, player: Player)
- This method adds the number of cards left in the player's hand to thecards_left
attribute of the player passed as an argument. The method should returnNone
. -
sort_stats(self, stat: StatsColumn, ascending=True)
- This method sorts thestats
table by the column passed as an argument. The method should returnNone
. You can assume that the value forstat
will be a valid value from theStatsColumn
enum class.
You can also assume that the order of the columns will not change. The sorting should be done in the order passed to the ascending
argument. If ascending
is True
, the sorting should be done in an increasing order. If ascending
is False
, the sorting should be done in a decreasing order. Keep your sorting stable. This means that if two players have the same value in the column being sorted, the order of these players should not change.
-
__len__(self)
- This method returns the number of players in thestats
table. -
__str__(self)
- This method generates a report of the game statistics. The report should be a string in a tabular form that contains the following information:- The total number of games played.
- The total number of turns taken.
- The total number of cards played.
- The total number of cards drawn.
- The total number of cards left in the players' hands.
- The total number of games won by each player.
Here is an example output of the __str__
method:
Name Games Played Turns Taken Cards Played Cards Drawn Cards Left Games Won
Alice 1 2 1 3 4 0
Bob 1 2 1 1 2 0
Charlie 1 2 0 2 4 0
David 1 2 2 0 0 1
You will then need to edit your version of the Game
class to account for the new GameStats
class. You should do this by doing the following:
-
Add a new attribute to the
Game
class calledgame_stats
that is an instance of theGameStats
class. This attribute should be initialised in theinitialise_game
method when this method is called the first time on the game object. Since the values of all the stats are 0 when initialised, you should set all of these values to 0 in theinitialise_game
method. If theinitialise_game
method is called multiple times, thegame_stats
attribute should not be reset to 0. -
Update the
play_game
method to record the appropriate statistics in thegame_stats
object. You should record the statistics mentioned above.