classDiagram class Pokemon { # _name : str # _level : int # _type : str # _current_stat : Statistic + get_pokemon_attack_coef() float } class Statistic { - __hp : int - __attack : int - __defense : int - __sp_atk : int - __sp_def : int - __speed : int } class Attack{ # _name : str # _power : int # _description : str + compute_damage(Pokemon, Pokemon) int } class Battle{ - first_monstie : Pokemon - second_monstie : Pokemon - winner : Pokemon } Pokemon o-- Statistic : possesses Pokemon o-- Attack : uses Battle o.."2" Pokemon : call
Python, OOP, Unit Tests and Documentation
1 Todo
Before you start
This tutorial combines explanations and code phases.
The explanations in this tutorial should not take precedence over those of your teacher. Take them as a knowledge base for later, but always prefer oral explanations.
- OOP reminder
- Unit tests
- Business objects
- Strategy design pattern
2 What you will be coding
Our game will be inspired by Pokémon Unite (no knowledge of the game or Pokémon is necessary).
In a nutshell, there will be Pokemons competing in Battles by throwing Attacks.
2.1 Business objects
You’re going to create business objects for a Pokémon game.
A business object is a representation of a specific entity in the context of a business application. It encapsulates the data and behaviour associated with that entity and is often used to model real-world concepts in the application domain.
This leads to objects containing mainly attributes and very few methods other than getter and setter.
To take another example, here are the business objects for a simple application that manages bike hire:
- Bike: Represents a bike (id, type, model…)
- Customer: Represents a person (id, surname, first name, etc.)
- Rental: Represents a rental transaction (id, Client, Bike, start, end, amount, etc.)
2.2 The application’s business objects
Pokemon
: which have various characteristics and statistics- a name
- a level
- a type
- here the types are : Attacker, Defender, Versatile, Helper and Fast (and not: Fire, Water, Plant…)
- statistics contained in a Statistic object defined below
Statistic
: to avoid overloading the Pokemon class, their stats are stored in an object of the Statistic class.- hp: health points
- attack, defense, speed… which are used to determine the strength of its attacks
Attack
: different types of attack available to Pokemons (part 3)Battle
: used to pit 2 Pokemons against each other to determine the outcome of the battle (part 4)
2.3 Class diagram
Here is a very simplified class diagram of the main classes to be coded during these practical sessions.
3 Get the code
At the end of the course, don’t forget to:
- Save your work using Git
- Delete or pause your VSCode service
3.1 Launch VScode
3.2 Fork and Clone
The TP code can be found on this GitHub repository: ENSAI-2A-complement-info-TP
A fork is a personal copy of another user’s repository.
This allows you to freely experiment with changes without affecting the original project, and then propose your modifications back to the original repository via a pull request if desired.
In VSCode:
-
- File > Open Folder >
/home/onyxia/work/<repo_name>
> OK
- File > Open Folder >
4 Modelling and implementation
Initially, we’re going to work only on the Pokemons
(the Statistic
class has already been coded).
Before writing any code, we’re going to think about the best possible design for our Pokémons. Our design will try as far as possible to respect the following rule: low coupling, high cohesion.
In other words, we will try to make :
- classes that are as disjoint as possible (low coupling) so that a modification to one class does not require us to modify the others
- while trying to have in each class tasks that are as linked as possible (strong cohesion).
4.1 First approach: if/elif/else
We’re interested in the get_pokemon_attack_coef()
method, which will be used to determine the power of the attack according to the type of Pokemon.
-
- Imagine if there were several similar blocks of code in our application, and we had to add a new type
4.2 The power of OOP
Instead of externalising the behaviour of our Pokemons, we’re going to put all their specific behaviour into child classes of a Pokemon
superclass. This is made possible by two properties of OOP objects:
- inheritance: it’s possible to specialise an existing class by modifying its behaviour, or by adding new ones.
- polymorphism: two functions can have the same name but different behaviours
Furthermore, as each of our Pokemons will necessarily be of a type, none of them will simply be of the Pokemon
class, which allows us to make this class abstract. By clearly defining our abstract class, we’ll have :
- A blueprint for all the classes that inherit from it. This has the following advantages
- Provides information on how the code is structured
- Automatically generate the methods to be defined
- Limits bugs. If a method is forgotten, the code crashes on start-up, which prevents unexpected behaviour that is difficult to detect.
- A single interface for all Pokemon types. Whatever the type of Pokemon, it will be considered as an
AbstractPokemon
everywhere in the code.
-
- rename the file to abstract_pokemon.py as well
You should end up with a tree structure similar to this one:
ENSAI-2A-COMPLEMENTS-INFO-TP
└── src
└── business_object
├── pokemon
│ ├── abstract_pokemon.py
│ ├── attacker_pokemon.py
│ ├── defender_pokemon.py
│ └── all_rounder_pokemon.py └── statistic.py
To make an abstract class, use the abc
package.
For inspiration, here’s an example of what’s expected:
abstract_personnage.py
from abc import ABC, abstractmethod
class AbstractPersonnage(ABC):
def __init__(self, phrase_attaque:str, phrase_defense:str):
self._phrase_attaque = phrase_attaque
self._phrase_defense = phrase_defense
@abstractmethod # decorator that defines a method as abstract
def damages(self) -> int:
pass
magicien.py
from abstract_personnage import AbstractPersonnage
class Magicien(AbstractPersonnage):
def __init__(self):
super().__init__("Throw a fireball", "Use a magic barrier")
def damages(self) -> int:
return 10
To help you, here is the class diagram:
classDiagram class AbstractPokemon { <<abstract>> # _name : str # _level : int # _type : str # _current_stat : Statistic + get_pokemon_attack_coef() float } class Statistic { - __hp : int - __attack : int - __defense : int - __sp_atk : int - __sp_def : int - __speed : int } AbstractPokemon <|-- AttackerPokemon AbstractPokemon <|-- DefenderPokemon AbstractPokemon <|-- AllRounderPokemon AbstractPokemon o-- Statistic
4.3 Test your code
-
pytest -v
To do this, you will use the pytest package in python.
An example of a test is given in the TestDefenderPokemon
class. As a reminder, a test breaks down into 3 parts:
- GIVEN: create the objects needed to run the test
- WHEN: call the method to be tested
- THEN: check the result
The test classes will be organised as follows, reproducing the architecture of your application:
ENSAI-2A-COMPLEMENTS-INFO-TP
└── src
├── business_object
│ ├── pokemon
│ │ ├── abstract_pokemon.py
│ │ ├── attacker_pokemon.py
│ │ ├── defender_pokemon.py
│ │ └── all_rounder_pokemon.py
│ └── statistic.py
└── test
└── test_business_object
└── test_pokemon
├── test_attacker_pokemon.py
├── test_defender_pokemon.py └── test_all_rounder_pokemon.py
- Check that the parent folder in the VSCode explorer is : ENSAI-2A-complement-info-TP
- Use full import paths rather than relative paths
- the path root is set in the
src
folder - example:
from business_object.pokemon.abstract_pokemon import AbstractPokemon
- the path root is set in the
- Create
__init__.py
files (empty)- in ALL the folders you create
- it’s a bit of a pain but it can unblock the situation
5 Aggregation, the other way of adding flexibility to code
Now that our Pokémons are done, we’re going to add the attacks.
Our system will have to respect certain constraints:
- Several types of attack will co-exist, each with a different way of calculating damage:
- Variable damage attacks divided into 2 types:
- Physical attacks, which use the Pokémon’s attack and defence
- Special attacks, which use the Pokémon’s special attack and special defence
- Fixed damage attacks, which do a fixed amount of damage.
- Variable damage attacks divided into 2 types:
- A pokémon can have several attacks, and the type of attack must be transparent to the pokémon.
5.1 Fixed damage attacks
We’ll start with fixed damage attacks. As there will be another type of attack, all attacks will inherit from the AbstractAttack
class. This class has the abstract method compute_damage() which will need to be implemented in the child classes.
classDiagram class AbstractAttack{ <<abstract>> # _power : int # _name : str # _description : str + compute_damage(APkm, APkm)$ int } class FixedDamageAttack{ + compute_damage(APkm,APkm) int } AbstractAttack <|-- FixedDamageAttack
-
- class and its compute_damage() method, which will simply return the power of the attack.
5.2 Variable damage attacks
Next we’re going to code the variable damage attacks. They use the following formula:
\[Damage = \big ( \frac{(\frac{2 \times Level}{5}+2) \times Power \times Att}{Def \times 50} +2\big) \times random \times other\_multipliers\]
with:
- \(Att\): equal to either the attack or special attack of the attacking Pokemon
- \(Def\): equal to either the defence or special defence of the defending Pokemon
- \(Power\): the attack’s power value
- \(random\) : a value in the range [0.85; 1]
- \(other\_ multipliers\): Other possible multipliers, such as the pokemon’s attack coefficient
The only difference between physical and special attacks is the coefficients \(Att\) and \(Def\).
The rest of the damage formula is identical. So we’re going to use the template method design pattern, the UML model of which is as follows.
classDiagram class AbstractAttack{ <<abstract>> # _power : int # _name : str # _description : str + compute_damage(APkm, APkm)$ int } class FixedDamageAttack{ + compute_damage(APkm,APkm ) int } class AbstractFormulaAttack{ <<abstract>> -get_attack_stat(APkm)$ float -get_defense_stat(APkm)$ float +compute_damage(APkm,APkm) int } class PhysicalFormulaAttack{ -get_attack_stat(APkm) float -get_defense_stat(APkm) float } class SpecialFormulaAttack{ -get_attack_stat(APkm) float -get_defense_stat(APkm) float } AbstractAttack <|-- FixedDamageAttack AbstractAttack <|-- AbstractFormulaAttack AbstractFormulaAttack <|-- SpecialFormulaAttack AbstractFormulaAttack <|-- PhysicalFormulaAttack
The AbstractFormulaAttack
class will contain the methods :
- compute_damage()
- This method will contain the formula for calculating damage
- It will call the get_attack_stat() and get_defense_stat() methods to find out which statistics to use
- get_attack_stat() and get_defense_stat() (abstract).
- These methods will need to be implemented in the child classes to determine which statistics to use
6 Final architecture
If you have time!
We are now going to reattach the ends to create our final architecture:
classDiagram class AbstractPokemon { <<abstract>> # _current_stat : Statistique # _level : int # _name : str # _attack_list : List~AbstractAttack~ +get_pokemon_attack_coef()$ float +level_up() None } class Statistique { - hp : int - attaque : int - defense : int - spe_atk : int - spe_def : int - vitesse : int } class BattleService { + resolve_battle(APkm, APkm) : Battle + get_order(APkm, APkm) + choose_attack(APkm) : AAttack } class Battle{ - first_monstie : APkm - second_monstie : APkm - winner : APkm - rounds : List<Round> } class Round{ attacker: APkm defender: APkm dealt_damage: int attack_description: str } BattleService ..>"2" AbstractPokemon : use AbstractPokemon <|-- Attacker AbstractPokemon <|-- Defender AbstractPokemon <|-- AllRounder Statistique --* AbstractPokemon Battle .. BattleService Battle .. Round class AbstractAttack{ <<abstract>> # _power : int # _name : str # _description : str +compute_damage(APkm, APkm)$ int } class FixedDamageAttack{ + compute_damage(APkm,APkm ) int } class AbstractFormulaAttack{ <<abstract>> -get_attack_stat(APkm)$ float -get_defense_stat(APkm)$ float + compute_damage(APkm,APkm ) int } class PhysicalFormulaAttack{ -get_attack_stat(APkm)$ float -get_defense_stat(APkm)$ float } class SpecialFormulaAttack{ -get_attack_stat(APkm) float -get_defense_stat(APkm) float } AbstractAttack <|-- FixedDamageAttack AbstractAttack <|-- AbstractFormulaAttack AbstractFormulaAttack <|-- SpecialFormulaAttack AbstractFormulaAttack <|-- PhysicalFormulaAttack BattleService >.. AbstractAttack : use AbstractPokemon o-->"0..*" AbstractAttack
This architecture makes it possible to decorrelate pokemon attacks and specify the behaviour of attacks as they are inherited. The advantages are:
- For the
AbstractPokemon
class, all attacks areAbstractAttacks
. As long as they expose the compute_damage() method, our program will work. This makes it easy to add new types of attack. - A Pokémon can have any type of attack.
- We can add a status system such as paralysis or poison quite easily. To do this, we need to modify the
AbstractAttack
class and the classes that inherit from it. This is potentially time-consuming, but doesn’t require any changes to the “Pokémon” part of our architecture. - One person could concentrate on creating Pokémons, while another could concentrate on creating attacks without difficulty. The two parts of the code are relatively independent, the only area of coupling being the
AbstractPokemon
andAbstractAttack
classes, which are used above all to define what must be done by the daughter classes and what is accessible to the outside.
Externalizing the behaviour of attacks into specific classes and then linking them to Pokémon via a fairly flexible aggregation relationship that allows a Pokémon’s attacks to be changed dynamically is the strategy design pattern.
End of the Lab
- Push your code to GitHub
- Pause or delete your datalab services