Skip to content

Comments

J1 Summary Project Review#26

Open
ngjunsiang wants to merge 34 commits intomainfrom
review
Open

J1 Summary Project Review#26
ngjunsiang wants to merge 34 commits intomainfrom
review

Conversation

@ngjunsiang
Copy link
Collaborator

[This is a placeholder pull review: do not approve this PR! It will subsequently be updated with more commits.]

In this review, I'll show you how your project can be further refactored to make it easier for team members and future contributors to understand, and contribute more easily.

We start with some readability fixes, following PEP8 rules for Python development. Replit's editor provides a "Format" button to do this easily, saving you the manual effort.

@ngjunsiang
Copy link
Collaborator Author

Unsafe practices: eval()

eval() invokes the Python interpreter on a string, treating it as Python code and evaluating its result. It's very powerful (as you can imagine, it can do anything that Python can), often too powerful for what you really want to do, and therefore also very dangerous without proper validation or restriction.

ctps/ctps.py

Lines 65 to 67 in 17511b4

choice = eval(
f"self.interface.{self.get_now_room_name().lower()}_menu()"
) #choice = eval(f"interface.{self.get_now_room_name().lower()}_menu()")

Here eval() is used to invoke a method based on the room name, for which we have safer and less powerful code structures which should be used instead: [286da95]

ctps/ctps.py

Lines 65 to 67 in 286da95

room_name = self.get_now_room_name().lower()
mtd = getattr(self.interface, room_name + "_menu")
choice = mtd()

As the name suggests, getattr() lets us retrieve attributes (but also methods) from any object in Python, by passing a string representing the attribute (or method) name. So let's remove eval() and use getattr().

@ngjunsiang
Copy link
Collaborator Author

Abstraction for testing

ctps/ctps.py

Lines 63 to 80 in 286da95

def get_choice(self):
"Dispalys and gets player choice. Display results afterwards"
room_name = self.get_now_room_name().lower()
mtd = getattr(self.interface, room_name + "_menu")
choice = mtd()
print(choice + '\n')
if choice == 'Move to next room':
self.next_room()
elif choice == 'Move to previous room':
self.prev_room()
elif choice == 'Look around':
combat = battle.Battle(self.player, self.get_now_room())
combat.battle_start()
else:
print("Invalid choice")
print()

It is difficult for us to test this game in an automated fashion, because everything that happens in a single cycle of the game loop happens in one method:

  1. Getting options
  2. Displaying options in a menu
  3. Getting the player's choice
  4. Executing the results of player's choice
  5. Evaluating the outcome of a battle

To test this in a script, we need a way to pass choices to Game which doesn't involve input(). Let's refactor the code to enable this.

@ngjunsiang
Copy link
Collaborator Author

Abstraction: avoiding redundancy

We see our first chance to eliminate redundancy in interface.py:

ctps/interface.py

Lines 9 to 65 in 286da95

def start_menu(self):
print(script.start_menu['title'])
[
print(f"{num+1}: {value}")
for num, value in enumerate(script.start_menu["options"])
]
choice = int(input(script.prompt))
return script.start_menu['options'][choice - 1]
def combat_menu(self, player_health, enemy_health):
print(f"Your health: {player_health}")
print(f"Enemy health: {enemy_health}")
def dungeon_menu(self):
print(script.dungeon_menu['message'])
[
print(f"{num+1}: {value}")
for num, value in enumerate(script.dungeon_menu["options"])
]
choice = int(input(script.prompt))
return script.dungeon_menu['options'][choice - 1]
def kitchen_menu(self):
print(script.kitchen_menu['message'])
[
print(f"{num+1}: {value}")
for num, value in enumerate(script.kitchen_menu["options"])
]
choice = int(input(script.prompt))
return script.kitchen_menu['options'][choice - 1]
def hall_menu(self):
print(script.hall_menu['message'])
[
print(f"{num+1}: {value}")
for num, value in enumerate(script.hall_menu["options"])
]
choice = int(input(script.prompt))
return script.hall_menu['options'][choice - 1]
def toilet_menu(self):
print(script.toilet_menu['message'])
[
print(f"{num+1}: {value}")
for num, value in enumerate(script.toilet_menu["options"])
]
choice = int(input(script.prompt))
return script.toilet_menu['options'][choice - 1]
def bedroom_menu(self):
print(script.bedroom_menu['message'])
[
print(f"{num+1}: {value}")
for num, value in enumerate(script.bedroom_menu["options"])
]
choice = int(input(script.prompt))
return script.bedroom_menu['options'][choice - 1]

53 lines of very similar code: do we really need all that? Spot the repeating pattern:

print(message)
        [
            print(f"{num+1}: {value}")
            for num, value in enumerate(options)
        ]
        choice = int(input(prompt))
        return options[choice - 1]

Let's make a function for this: [33d83af]

ctps/interface.py

Lines 8 to 14 in 33d83af

def prompt_choice(message: str, options: list[C], prompt: str) -> C:
"""Helper function to prompt the user for a choice."""
print(message)
for i, value in enumerate(options, start=1):
print(f"{i}: {value}")
choice = int(input(prompt))
return options[choice - 1]

Each method is now reduced to a single function call:

ctps/interface.py

Lines 17 to 54 in 33d83af

class Interface:
def prompt(self):
return script.prompt
def start_menu(self):
return prompt_choice(script.start_menu['title'], script.start_menu["options"], script.prompt)
def combat_menu(self, player_health, enemy_health):
print(f"Your health: {player_health}")
print(f"Enemy health: {enemy_health}")
def dungeon_menu(self):
return prompt_choice(script.dungeon_menu['message'], script.dungeon_menu["options"], script.prompt)
def kitchen_menu(self):
return prompt_choice(script.kitchen_menu['message'], script.kitchen_menu["options"], script.prompt)
def hall_menu(self):
return prompt_choice(script.hall_menu['message'], script.hall_menu["options"], script.prompt)
def toilet_menu(self):
return prompt_choice(script.toilet_menu['message'], script.toilet_menu["options"], script.prompt)
def bedroom_menu(self):
return prompt_choice(script.bedroom_menu['message'], script.bedroom_menu["options"], script.prompt)
def exit_screen(self):
print(script.exit_screen['message'])
def death_msg(self):
print(script.death_msg)
def win_msg(self):
print(script.win_msg)
def caught_msg(self):
print(script.caught_msg)

@ngjunsiang
Copy link
Collaborator Author

Now that feels a little silly; can we use abstraction to further simplify this? Sure!

First we start by collapsing the disparate pieces of data into a consolidated data structure, a nested dict: [0b941b5]

ctps/script.py

Lines 5 to 13 in 0b941b5

menu = {
'start': {
'title': "Capture The Princess S\n",
'options': ['start', 'exit']
},
'dungeon': {
'message': """
--------------------------------
YOU last remembered the warmth of the blood flowing out of your chest and started to feel the drop in your body temperature. YOU eyesight blurred until it was complete darkness.In that void, YOU asked yourself when and why it had gone wrong.The person YOU once loved turned her back against YOU. Love is now pure hatred. YOU want to avenge. YOU want answers.But YOU are dead. Acceptance is the only thing YOU can do.

I trust a few lines suffice to demonstrate the idea.

Then the *menu methods are reduced to:

ctps/interface.py

Lines 22 to 42 in 0b941b5

def start_menu(self):
return prompt_choice(script.menu['start']['title'], script.menu['start']["options"], script.prompt)
def combat_menu(self, player_health, enemy_health):
print(f"Your health: {player_health}")
print(f"Enemy health: {enemy_health}")
def dungeon_menu(self):
return prompt_choice(script.menu['dungeon']['message'], script.menu['dungeon']["options"], script.prompt)
def kitchen_menu(self):
return prompt_choice(script.menu['kitchen']['message'], script.menu['kitchen']["options"], script.prompt)
def hall_menu(self):
return prompt_choice(script.menu['hall']['message'], script.menu['hall']["options"], script.prompt)
def toilet_menu(self):
return prompt_choice(script.menu['toilet']['message'], script.menu['toilet']["options"], script.prompt)
def bedroom_menu(self):
return prompt_choice(script.menu['bedroom']['message'], script.menu['bedroom']["options"], script.prompt)

@ngjunsiang
Copy link
Collaborator Author

ngjunsiang commented Oct 3, 2024

Now that the menu methods look even more identical, can we abstract further? Let's do it: [51469f1]

ctps/interface.py

Lines 17 to 39 in 51469f1

class Interface:
def prompt(self):
return script.prompt
def combat_menu(self, player_health, enemy_health):
print(f"Your health: {player_health}")
print(f"Enemy health: {enemy_health}")
def prompt_menu(self, room_name: str) -> str:
return prompt_choice(script.menu[room_name]['message'], script.menu[room_name]["options"], script.prompt)
def exit_screen(self):
print(script.exit_screen['message'])
def death_msg(self):
print(script.death_msg)
def win_msg(self):
print(script.win_msg)
def caught_msg(self):
print(script.caught_msg)

We collapsed all the menu methods into one, and it turns out we don't even need getattr():

ctps/ctps.py

Lines 63 to 67 in 51469f1

def get_choice(self):
"Dispalys and gets player choice. Display results afterwards"
room_name = self.get_now_room_name().lower()
choice = self.interface.prompt_menu(room_name)
print(choice + '\n')

@ngjunsiang
Copy link
Collaborator Author

A little cleanup before we proceed: [1edb5a6]

Input validation

With unused code removed, the Interface class seems a little excessive: it doesn't store any attributes, and essentially bundles methods that don't even use self. Let's unbundle them back into functions in the module: [a29a7d1]

Finally, let's make things a little more robust. We'll validate the player's choice before returning the chosen option: [abeeee9]

ctps/interface.py

Lines 8 to 17 in abeeee9

def prompt_choice(message: str, options: list[str], prompt: str) -> str:
"""Helper function to prompt the user for a choice."""
print(message)
for i, value in enumerate(options, start=1):
print(f"{i}: {value}")
choice = input(prompt)
while not choice.isdecimal() or int(choice) - 1 not in range(len(options)):
print("Invalid choice.")
choice = input(prompt)
return options[int(choice) - 1]

Thanks to the abstraction we did earlier, we only need to write the code once, in one place, and all the menus benefit from it.

@ngjunsiang
Copy link
Collaborator Author

ngjunsiang commented Oct 3, 2024

Separating code and data

We still have some room data in data.py. Let's move that all to gamedata.json: [d8f6a03]

Now data.py has only factory functions for creating objects. If we ever need to modify the game data, we know we only need to do so in gamedata.json.

@ngjunsiang
Copy link
Collaborator Author

Separation of concerns

A common idea in programming is that of pure functions vs side effects. Take Game.isover():

ctps/ctps.py

Lines 22 to 32 in abeeee9

def isover(self):
if self.player.isdead():
interface.death_msg()
return True
elif self.princess.isdead():
if all([room.all_enemies_defeated() for room in self.rooms]):
interface.win_msg()
else:
interface.caught_msg()
return True
return False

This method returns a bool representing whether the game is over, but it does so in addition to displaying information. If we need to check if the game is over, we can't do so while avoiding the print(). This makes the method tricky to use.

When we write functions or methods, we typically want them to do one thing but not the other: return a value, or cause an effect (change a global variable, display a message, write data to a file, ...), but seldom both together.

Let's refactor isover() to split up these concerns: [8b1f770]

ctps/ctps.py

Lines 22 to 38 in 8b1f770

def isover(self) -> bool:
if not self.player or not self.princess:
# Game has not been set up
return False
return self.player.isdead() or self.princess.isdead()
def end_game(self) -> None:
if not self.player or not self.princess:
# Game has not been set up
return
if self.player.isdead():
interface.death_msg()
elif self.princess.isdead():
if all([room.all_enemies_defeated() for room in self.rooms]):
interface.win_msg()
else:
interface.caught_msg()

@ngjunsiang
Copy link
Collaborator Author

Breaking up the game loop

Finally, let's break up the game loop to separate concerns and also create smaller methods that make it easier to test our game in automated fashion: [17d0fd5]

ctps/ctps.py

Lines 66 to 70 in 17d0fd5

def get_choice(self) -> str:
"""Prompts player to make a choice."""
room_name = self.get_now_room_name().lower()
choice = interface.prompt_menu(room_name)
return choice

ctps/ctps.py

Lines 72 to 80 in 17d0fd5

def do_choice(self, choice: str) -> None:
print("Your choice:", choice)
if choice == 'Move to next room':
self.next_room()
elif choice == 'Move to previous room':
self.prev_room()
elif choice == 'Look around':
combat = battle.Battle(self.player, self.get_now_room())
combat.battle_start()

We have a method that displays text, takes player input, validates it, and returns it. And another method that takes a valid choice and carries it out. If we want to test our game in a script, we can call do_choice() with our own script of actions without calling get_choice(): concerns nicely separated!

@ngjunsiang
Copy link
Collaborator Author

Refactoring combat

Lastly let's see how to simplify combat. Spot any similarities?

ctps/battle.py

Lines 26 to 48 in 17d0fd5

def player_attack(self):
source = self.player
target = self.room.get_enemies()[0]
source.attack(target)
print(
f"You attacked the {target.get_type()}! {target.get_type()} health: {target.get_health()}"
)
if target.get_health() <= 0:
if target.get_type() == "Princess":
print("Princess is now unconcious! Time to escape!")
else:
print("Soldier defeated!")
self.room.remove_enemy()
if self.room.all_enemies_defeated():
print("All soldiers in the room are defeated!")
def enemy_attack(self):
source = self.room.get_enemies()[0]
target = self.player
source.attack(target)
print(
f"{source.get_type()} attacked you! Your health: {target.get_health()}"
)

Applying encapsulation: [579cfd4]

Let's collapse them: [6196649]

ctps/battle.py

Lines 32 to 41 in 6196649

def attack(self, attacker, defender):
attacker.attack(defender)
if isinstance(attacker, character.Player):
print(
f"You attacked the {defender.get_type()}! {defender.get_type()} health: {defender.get_health()}"
)
else:
print(
f"{attacker.get_type()} attacked you! Your health: {defender.get_health()}"
)

This is the heart of the attack() method: an attacker strikes the defender, and a status message is displayed.
The meat is in battle_start():

ctps/battle.py

Lines 12 to 32 in ebf00ae

def battle_start(self):
if not self.room.all_enemies_defeated():
print("YOU FOUND ENEMIES!!!!")
while not self.battle_over():
attacker = self.player
defender = self.room.get_enemies()[0]
self.attack(attacker, defender)
if defender.isdead():
if defender.get_type() == "Princess":
print("Princess is now unconcious! Time to escape!")
else:
print("Soldier defeated!")
self.room.remove_enemy()
defender = self.room.get_enemies()[0]
attacker, defender = defender, attacker
time.sleep(0.1)
if self.room.all_enemies_defeated():
print("All soldiers in the room are defeated!")
print("Battle over!")
else:
print("All soldiers in the room have been defeated already!")

Here we run the battle cycle, using the same method for attacker and defender to trade blows, and swapping their roles each iteration.

Does it look messy? Yeah, all those if-elses sure make things hard to read ... this is what polymorphism is for. Let's see how we can simply this first before we apply some polymorphism: [37d000b]

@ngjunsiang
Copy link
Collaborator Author

Before we begin, let's standardise things, and make a factory function for Soldier: [63065e6, 2b634e5]

Polymorphism

By making the get_type() method polymorphic for all characters, we can simplify the death message display: [651ede2, 07c8efa]

ctps/battle.py

Lines 22 to 23 in 07c8efa

if defender.isdead():
print(interface.death_msg(defender.get_type()))

@ngjunsiang
Copy link
Collaborator Author

Separation of concerns, revisited

One little problem: it's not quite clear how the attacker deals damage to the defender? The logic of this has been hidden in the Character class, when it's actually part of the battle logic. If we add multipliers and other information later, this complexity should be handled by Battle and not Character. Let's move that logic to its rightful place: [b04ebdd]

@ngjunsiang
Copy link
Collaborator Author

And again, let's apply this to the battle report: [3dec349]

ctps/battle.py

Lines 33 to 41 in 3dec349

def attack(self, attacker, defender) -> None:
defender.receive_damage(attacker.damage)
print(self.attack_report(attacker, defender))
def attack_report(self, attacker, defender) -> str:
if attacker.get_type() == "Player":
return f"You attacked the {defender.get_type()}! {defender.get_type()} health: {defender.get_health()}"
else:
return f"{attacker.get_type()} attacked you! Your health: {defender.get_health()}"

It's a minor point, but now we've separated the logic from what happens in an attack from the logic of how to report an attack. It's a small thing, but the small things add up to make it easier to see what's going on quickly.

Separation of concerns, yet again

Okay, okay, we've already split up the methods, what next? Remember that we want to automate testing, and we don't always want the battle report messages displaying alongside test reports. Is there a way to "disable" printing in the Battle class and just have the battle proceed without the messages? Let's do it: [3b1c2b4]

Since display responsibility is handled by the interface module, let's set a variable report there which determines which function will handle output. By default, this will be the built-in print() function.

Now let's update all the places where we used print() to use report() instead: [14cc8db, 1f2c5f6]

During testing, we don't want game text to be displayed; we want to see test results. So we log all game output to a test.log file instead: [fe8fc2b]

Now we can write our test script: [f6ad3db]

ctps/Test.py

Lines 12 to 17 in f6ad3db

commands = ['start', 'dungeon', 'kitchen']
game = ctps.Game()
game.setup()
for choice in commands:
game.do_choice(choice)

Eliminating the last bit of input from Game: [213b000]

Because of the way we applied abstraction, we can write our test script as a series of commands and run them in a fixed-iteration loop.

@ngjunsiang
Copy link
Collaborator Author

Wrapping up

And that wraps up the review for this project.

There is of course much more than can be done for abstraction, especially if more features are going to be added for this game; as you notice more repetition and repeated patterns in your code, think about how you can reduce it once it starts to get tedious.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant