Debugging in Python, part 11: Another logical error
MP 154: They just keep coming. :)
Note: This post is part of an ongoing series about debugging in Python. The posts in this series will only be available to paid subscribers for the first 6 weeks. After that they will be available to everyone. Thank you to everyone who supports my ongoing work on Mostly Python.
In the last post, we started implementing a Go Fish game. We ran into a logical error when dealing hands, but that error was corrected by the end of the post. In this post we'll get into actual game play.
The code for this post picks up from here, after implementing the fix for all hands pointing to the same list of cards. If you want to follow along, you can make a copy of that folder and make changes from that point.
Letting the player guess
When you run go_fish.py currently, all it does is deal two hands:
$ python go_fish.py Player hand: 2♠ 3♣ 3♥ 6♥ 10♥ Q♦ A♠ Computer hand: X X X X X X X
There are a number of ways we could start implementing game play. We'll need to address any pairs each player starts with, but I'd rather start with the interactive game play. I'll first write code that lets the player take a turn. At this point in the project, we'll assume the human player always goes first.
We already have a player_turn() method that shows the current state of the game. Let's implement the player's turn in that method for now:
def player_turn(self): """Manage the human player's turn.""" self.show_state() # Get player's guess (a rank). msg = "\nWhat card would you like to ask for? " requested_card = input(msg).upper() if requested_card == "QUIT": sys.exit("\nThanks for playing!") if requested_card not in "2345678910JQKA": print("Invalid entry, please try again.") self.player_turn() player_ranks = [c.rank for c in self.player_hand.cards] if requested_card not in player_ranks: print("You don't have that card!") self.player_turn()
We want to let the player ask for a card from the computer's hand. In a game between humans, this would be a question like Do you have a three? There are no suits in this question; you just ask if the other player has a card with the same rank as one of yours. We assign the input value to requested_card, and normalize it to an upper case letter. (The upper() method does nothing to integers, so it should be safe to use on all reasonable input in this game.)
We then check for three things. If the user entered "QUIT", or any variation of that word, we exit. If their response isn't a valid card rank, we call player_turn() and get another guess. Finally, we make sure they actually have a card of this rank in their hand. You can only ask for cards that match what you're holding. (Note that I added import sys at the top of the file.)
I ran go_fish.py at this point, and it seemed to handle all three situations appropriately. The only thing I didn't like was that it shows the state of the game again after each invalid entry, which gets a bit repetitive.
Utility functions
Some people are wary of "utility" files, but I like them if they're named well, and used with a purpose. I want to refine the implementation of handling the player's guess, but I don't want to make player_turn() more complex around guessing. And when it works, I want it out of the way in another file, so I can focus on the actual gameplay.
Here's a much simpler player_turn() method, calling a utility function that gets a valid guess from the player:
def player_turn(self): """Manage the human player's turn.""" self.show_state() requested_card = go_fish_utils.get_player_guess( self.player_hand)
This structure is much easier to manage. We should be able to tell exactly what's happening during a player's turn if most of our code is written like this. (We also need to remove import sys, and replace it with import go_fish_utils.)
Here's go_fish_utils.py:
"""Utility functions for the Go Fish game.""" import sys def get_player_guess(player_hand): """Get a valid guess from the player.""" msg = "\nWhat card would you like to ask for? " requested_card = input(msg).upper() if requested_card == "QUIT": sys.exit("\nThanks for playing!") if requested_card not in "2345678910JQKA": print("Invalid entry, please try again.") get_player_guess(player_hand) player_ranks = [c.rank for c in player_hand.cards] if requested_card not in player_ranks: print("You don't have that card!") get_player_guess(player_hand) # Valid response. return requested_card
There are a few things to note here. This function needs one piece of information from the class, the player_hand instance. Most importantly, whenever the player enters an invalid response, we just call get_player_guess() again. We're not showing the entire state of the game after every invalid response, because that's happening back in player_turn(). We also need to return the player's guess when their response is valid.
Clearing the terminal
This is a small thing, but terminal-based games are much nicer to play when the screen is cleared at appropriate times.
Let's add a utility function for clearing the screen, and call it at the start of the player's turn:
def clear_terminal(): """Clear the terminal.""" if sys.platform == "win32": subprocess.run("cls") else: subprocess.run("clear")
On Windows the command for clearing a terminal is cls, and on other systems it's clear. This function handles that discrepancy cleanly, so in the game code we can just call clear_terminal(). (Make sure to add import subprocess if you're coding along.)
Here's the updated player_turn():
def player_turn(self): """Manage the human player's turn.""" go_fish_utils.clear_terminal() self.show_state() requested_card = go_fish_utils.get_player_guess( self.player_hand)
This makes sure the two hands are always in the same position at the top of the screen at the start of each turn. This will be especially helpful as we start to implement multiple turns.
Processing the player's guess
We've got a cleaner interface, and we've got a valid guess from the player. Now it's time to process that guess. We'll need access to a number of resources from the GoFish class, so this will be a method instead of a utility function.
Here's the first iteration of check_player_guess():
def player_turn(self): """Manage the human player's turn.""" ... requested_card = go_fish_utils.get_player_guess( self.player_hand) self.check_player_guess(requested_card) def check_player_guess(self, guessed_rank): """Process the player's guess.""" computer_ranks = [c.rank for c in self.computer_hand.cards] if guessed_rank in computer_ranks: # Correct guess. Remove card from both hands. player_card = go_fish_utils.remove_card( guessed_rank, self.player_hand) computer_card = go_fish_utils.remove_card( guessed_rank, self.computer_hand) # Add cards to player's pairs. self.player_pairs.append((player_card, computer_card)) # Player gets to go again. msg = "\nYour guess was correct!" msg += " Press Enter to continue." input(msg) self.player_turn() else: # It's the computer's turn now. pass
We probably want to make a single method that handles guesses from either the player or the computer. But there's enough logic to sort out that I'm just going to write a method that considers everything from the player's perspective, and then consider whether it's worth generalizing. It might be simpler to have two methods, one for the human player and one for the computer player, at least in this first version of the game.
The check_player_guess() method gets all the ranks in the computer's hand. If the guessed rank is in computer_ranks, we do three things. We pull the corresponding card from each player's hand. (We'll write the utility function remove_card() in a moment.) We add those cards to the player's pairs, in player_pairs. We then pause a moment before letting the human player have another turn.
If the guess was not correct, we'll move on to the computer's turn. That's just an empty else block for the moment, until we check whether we're processing the player's turn correctly or not.
Here's the utility function remove_card():
def remove_card(target_rank, hand): """Remove the first card with a matching rank, and return it.""" for card in hand.cards: if card.rank == target_rank: hand.cards.remove(card) return card
This function needs two pieces of information: the rank of the card we're looking for, and the hand we're working with. We loop over all the cards in the hand, and remove the first card with a matching rank. We then return that card. There's no error handling here for now; we'll assume this function is only called when the target rank exists in the hand.
Running the game
Let's run the game and see if it works:
$ python go_fish.py Player hand: 2♣ 2♦ 5♣ 5♥ Q♥ K♠ A♠ Computer hand: X X X X X X X What card would you like to ask for? 2 Your guess was correct! Press Enter to continue.
Okay, I guessed correctly! After pressing Enter, I see this:
Player hand: 2♦ 5♣ 5♥ Q♥ K♠ A♠ Computer hand: X X X X X X What card would you like to ask for?
This looks good so far. I guessed right, and now there are six cards in each hand.
Things seem to be working, but it's a lot of work to see if things are working. I had to restart the game three times before guessing correctly. For diagnostic work, I'd like to be able to see the computer's cards, and I want to not clear the terminal at the start of every turn.
Adding a verbosity flag
Many CLI apps have a verbosity flag, usually indicated by -v, that tells the program to show more output. We don't need to add a full argument parsing system in order to implement this. As a shortcut, since we only have one developer-focused CLI arg, we can just check sys.argv.
Here's show_state():
def show_state(self): """Show the current state of the game.""" ... print("\nComputer hand:") if "-v" in sys.argv: self.computer_hand.show() else: self.computer_hand.show(hidden=True)
If -v is in any of the CLI args passed when running go_fish.py, we'll just show the computer's hand. When it's not passed, we'll hide the computer's cards. (Make sure to add import sys again.)
Here's the updated clear_terminal():
def clear_terminal(): """Clear the terminal.""" # Don't clear terminal in verbose mode. if "-v" in sys.argv: return if sys.platform == "win32": subprocess.run("cls") ...
If -v has been passed, we return without clearing the screen.
If you run the game now, you can easily make a series of correct, invalid, and incorrect guesses to assess the current behavior:
$ python go_fish.py -v Player hand: 2♦ 4♣ 6♣ 7♥ 10♦ J♣ A♠ Computer hand: 4♥ 4♠ 6♦ 6♠ J♦ Q♦ Q♠ What card would you like to ask for? 4 Your guess was correct! Press Enter to continue. Player hand: 2♦ 6♣ 7♥ 10♦ J♣ A♠ Computer hand: 4♠ 6♦ 6♠ J♦ Q♦ Q♠ What card would you like to ask for? 6 Your guess was correct! Press Enter to continue. Player hand: 2♦ 7♥ 10♦ J♣ A♠ Computer hand: 4♠ 6♠ J♦ Q♦ Q♠ What card would you like to ask for? 2
Guesses are being processed correctly, and cards are being removed from the hands correctly. Things are looking good in this play-through.
Some confusing errors
I was actually motivated to add the verbosity flag because I saw some unexpected behavior when playing through a few games, and I wasn't able to capture exactly what was happening. After implementing that flag, it was much easier to recognize and examine problematic output.
For example, take a look at this partial play-through:
$ python go_fish.py -v Player hand: 5♥ 7♦ 10♥ J♥ Q♠ K♠ A♠ Computer hand: 2♠ 3♥ 4♣ 4♠ 6♥ Q♣ A♣ What card would you like to ask for? 3 You don't have that card! What card would you like to ask for? A Your guess was correct! Press Enter to continue. Player hand: 5♥ 7♦ 10♥ J♥ Q♠ K♠ A♠ Computer hand: 2♠ 4♣ 4♠ 6♥ Q♣ A♣ What card would you like to ask for?
In this play-through, I started with an invalid guess, and then made a guess that should be correct. The invalid guess (3, which is invalid because I'm not holding a 3), is processed correctly.
When I ask about an A, which we both have, the response says that the guess was correct, as it should. However, it doesn't process that guess correctly. I still have seven cards, and the computer only has 6 cards. Also, the computer still has its Ace! Looking back, we can see that the computer had a 3 removed from its hand. (This is a perfect example of a situation where showing the computer's cards and not clearing the terminal is especially helpful.)
Now I'm curious to see what happens if the first guess is an invalid rank such as P, and the second guess is a correct one:
$ python go_fish.py -v Player hand: 4♣ 4♠ 5♦ 8♠ 10♠ J♦ K♠ Computer hand: 3♠ 5♣ 5♠ 6♣ 6♠ K♣ A♣ What card would you like to ask for? P Invalid entry, please try again. What card would you like to ask for? 5 You don't have that card! What card would you like to ask for?
This is even more confusing. I asked for a P, which isn't even a real rank. It correctly responded that P is invalid, and gave me the chance to try again.
I entered a 5, which is in both hands. It tells me I don't have a 5!
An open bug
I'm getting all kinds of amusing incorrect behavior if I start with invalid guesses, and follow them up with a variety of other guesses.
Rather than getting into all the diagnostics in this post, I'm going to leave the explanation for this behavior as an open question for the moment. If you're curious to try to debug this issue, copy the current state of the project and run it yourself.
Conclusions
Unless you're a much better programmer than I am, logical errors arise all the time during development work. Many of them are small and easily fixed as soon as you see them. Some are much more subtle, and require a bit of investigative work in order to resolve.
I'll share the diagnostic work for this bug in the next post, and a fix as well. If you're running into bugs in your own work, please know that you're not alone. :)