From 00a5b2fa136a5e4b8228743a25bfb2228bda692f Mon Sep 17 00:00:00 2001 From: Amal Samally Date: Thu, 11 Aug 2016 06:44:51 +0400 Subject: [PATCH] Better inventory: attacks & movesets, IV CP perfection, pokemon level, etc.. (#3455) * Add "level to CP multiplier" data Data is from justinleewells/pogo-optimizer: https://github.com/justinleewells/pogo-optimizer/blob/edd692d/data/game/level-to-cpm.json * Many improvements & additions for the inventory logic - LevelToCPm, FastAttacks, ChargedAttacks, Movesets - More info for each pokemon: attacks data, percent to max cp, IV CP perfection * Add PyCharm/IDEA *.iml (project file) to ignored * Fixes, improvements & refactoring for inventory.py - Return inadvertently deleted pieces of code (thanks to @achretien) - Evolution logic fixes - Other minor fixes - Moveset logic moved to Moveset class * Fix data for pokemons & charged moves * Inventory tests: pokemon data, LevelToCPm, attacks * Fix travis build * Fix info for Hitmonlee & Hitmonchan --- .gitignore | 1 + data/charged_moves.json | 3 +- data/level_to_cpm.json | 81 +++++ data/pokemon.json | 32 +- pokemongo_bot/inventory.py | 676 ++++++++++++++++++++++++++++++++++--- tests/inventory_test.py | 183 ++++++++++ 6 files changed, 907 insertions(+), 69 deletions(-) create mode 100644 data/level_to_cpm.json create mode 100644 tests/inventory_test.py diff --git a/.gitignore b/.gitignore index 4721ce0253..06973c1249 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ share/ # PyCharm IDE settings .idea/ +*.iml # Personal load details src/ diff --git a/data/charged_moves.json b/data/charged_moves.json index 3d487b7201..c3f993191c 100644 --- a/data/charged_moves.json +++ b/data/charged_moves.json @@ -84,9 +84,8 @@ {"id":101,"name":"Flame Charge","type":"Fire","damage":25,"duration":3100,"energy":20,"dps":8.06}, {"id":34,"name":"Heart Stamp","type":"Psychic","damage":20,"duration":2550,"energy":25,"dps":7.84}, {"id":75,"name":"Parabolic Charge","type":"Electric","damage":15,"duration":2100,"energy":20,"dps":7.14}, -{"id":13,"name":"Wrap","type":"Normal","damage":25,"duration":3700,"energy":20,"dps":6.75}, {"id":111,"name":"Icy Wind","type":"Ice","damage":25,"duration":3800,"energy":20,"dps":6.57}, {"id":84,"name":"Disarming Voice","type":"Fairy","damage":25,"duration":3900,"energy":20,"dps":6.41}, {"id":13,"name":"Wrap","type":"Normal","damage":25,"duration":4000,"energy":20,"dps":6.25}, {"id":66,"name":"Shadow Sneak","type":"Ghost","damage":15,"duration":3100,"energy":20,"dps":4.83}, -{"id":48,"name":"Mega Drain","type":"Grass","damage":15,"duration":3200,"energy":20,"dps":4.68}] \ No newline at end of file +{"id":48,"name":"Mega Drain","type":"Grass","damage":15,"duration":3200,"energy":20,"dps":4.68}] diff --git a/data/level_to_cpm.json b/data/level_to_cpm.json new file mode 100644 index 0000000000..d2483d9a41 --- /dev/null +++ b/data/level_to_cpm.json @@ -0,0 +1,81 @@ +{ + "1": 0.094, + "1.5": 0.135137432, + "2": 0.16639787, + "2.5": 0.192650919, + "3": 0.21573247, + "3.5": 0.236572661, + "4": 0.25572005, + "4.5": 0.273530381, + "5": 0.29024988, + "5.5": 0.306057377, + "6": 0.3210876, + "6.5": 0.335445036, + "7": 0.34921268, + "7.5": 0.362457751, + "8": 0.37523559, + "8.5": 0.387592406, + "9": 0.39956728, + "9.5": 0.411193551, + "10": 0.42250001, + "10.5": 0.432926419, + "11": 0.44310755, + "11.5": 0.4530599578, + "12": 0.46279839, + "12.5": 0.472336083, + "13": 0.48168495, + "13.5": 0.4908558, + "14": 0.49985844, + "14.5": 0.508701765, + "15": 0.51739395, + "15.5": 0.525942511, + "16": 0.53435433, + "16.5": 0.542635767, + "17": 0.55079269, + "17.5": 0.558830576, + "18": 0.56675452, + "18.5": 0.574569153, + "19": 0.58227891, + "19.5": 0.589887917, + "20": 0.59740001, + "20.5": 0.604818814, + "21": 0.61215729, + "21.5": 0.619399365, + "22": 0.62656713, + "22.5": 0.633644533, + "23": 0.64065295, + "23.5": 0.647576426, + "24": 0.65443563, + "24.5": 0.661214806, + "25": 0.667934, + "25.5": 0.674577537, + "26": 0.68116492, + "26.5": 0.687680648, + "27": 0.69414365, + "27.5": 0.700538673, + "28": 0.70688421, + "28.5": 0.713164996, + "29": 0.71939909, + "29.5": 0.725571552, + "30": 0.7317, + "30.5": 0.734741009, + "31": 0.73776948, + "31.5": 0.740785574, + "32": 0.74378943, + "32.5": 0.746781211, + "33": 0.74976104, + "33.5": 0.752729087, + "34": 0.75568551, + "34.5": 0.758630378, + "35": 0.76156384, + "35.5": 0.764486065, + "36": 0.76739717, + "36.5": 0.770297266, + "37": 0.7731865, + "37.5": 0.776064962, + "38": 0.77893275, + "38.5": 0.781790055, + "39": 0.78463697, + "39.5": 0.787473578, + "40": 0.79030001 +} \ No newline at end of file diff --git a/data/pokemon.json b/data/pokemon.json index e64b7c31fb..8ccc906d02 100644 --- a/data/pokemon.json +++ b/data/pokemon.json @@ -1232,7 +1232,7 @@ "Previous evolution(s)": [ { "Number": "029", - "Name": "Nidoran F" + "Name": "Nidoran F" } ], "Next Evolution Requirements": { @@ -4326,20 +4326,14 @@ ], "Weight": "49.8 kg", "Height": "1.5 m", - "Next evolution(s)": [ - { - "Number": "107", - "Name": "Hitmonchan" - } - ], "Special Attack(s)": [ "Low Sweep", "Stomp", "Stone Edge" ], "BaseAttack": 148, - "BaseDefense": 100, - "BaseStamina": 172, + "BaseDefense": 172, + "BaseStamina": 100, "CaptureRate": 0.16, "FleeRate": 0.09 }, @@ -4361,12 +4355,6 @@ ], "Weight": "50.2 kg", "Height": "1.4 m", - "Previous evolution(s)": [ - { - "Number": "106", - "Name": "Hitmonlee" - } - ], "Special Attack(s)": [ "Brick Break", "Fire Punch", @@ -4374,8 +4362,8 @@ "Thunder Punch" ], "BaseAttack": 138, - "BaseDefense": 100, - "BaseStamina": 204, + "BaseDefense": 204, + "BaseStamina": 100, "CaptureRate": 0.16, "FleeRate": 0.09 }, @@ -5729,6 +5717,16 @@ "Family": 147, "Name": "Dratini candies" }, + "Next evolution(s)": [ + { + "Number": "148", + "Name": "Dragonair" + }, + { + "Number": "149", + "Name": "Dragonite" + } + ], "Special Attack(s)": [ "Aqua Tail", "Twister", diff --git a/pokemongo_bot/inventory.py b/pokemongo_bot/inventory.py index ea81b7c093..b27eaa388a 100644 --- a/pokemongo_bot/inventory.py +++ b/pokemongo_bot/inventory.py @@ -1,26 +1,45 @@ import json import os + from pokemongo_bot.base_dir import _base_dir ''' Helper class for updating/retrieving Inventory data ''' -class _BaseInventoryComponent(object): - TYPE = None # base key name for items of this type - ID_FIELD = None # identifier field for items of this type + +# +# Abstraction + +class _StaticInventoryComponent(object): STATIC_DATA_FILE = None # optionally load static data from file, # dropping the data in a static variable named STATIC_DATA + STATIC_DATA = None def __init__(self): - self._data = {} if self.STATIC_DATA_FILE is not None: self.init_static_data() @classmethod def init_static_data(cls): if not hasattr(cls, 'STATIC_DATA') or cls.STATIC_DATA is None: - cls.STATIC_DATA = json.load(open(cls.STATIC_DATA_FILE)) + cls.STATIC_DATA = cls.process_static_data( + json.load(open(cls.STATIC_DATA_FILE))) + + @classmethod + def process_static_data(cls, data): + # optional hook for processing the static data + # default is to use the data directly + return data + + +class _BaseInventoryComponent(_StaticInventoryComponent): + TYPE = None # base key name for items of this type + ID_FIELD = None # identifier field for items of this type + + def __init__(self): + self._data = {} + super(_BaseInventoryComponent, self).__init__() def parse(self, item): # optional hook for parsing the dict for this item @@ -42,34 +61,22 @@ def retrieve_data(self, inventory): def refresh(self, inventory): self._data = self.retrieve_data(inventory) - def get(self, id): - return self._data.get(id) + def get(self, object_id): + return self._data.get(object_id) def all(self): return list(self._data.values()) -class Candy(object): - def __init__(self, family_id, quantity): - self.type = Pokemons.name_for(family_id) - self.quantity = quantity - - def consume(self, amount): - if self.quantity < amount: - raise Exception('Tried to consume more {} candy than you have'.format(self.type)) - self.quantity -= amount - - def add(self, amount): - if amount < 0: - raise Exception('Must add positive amount of candy') - self.quantity += amount +# +# Inventory Components class Candies(_BaseInventoryComponent): TYPE = 'candy' ID_FIELD = 'family_id' @classmethod - def family_id_for(self, pokemon_id): + def family_id_for(cls, pokemon_id): return Pokemons.first_evolution_id_for(pokemon_id) def get(self, pokemon_id): @@ -108,17 +115,120 @@ class Pokemons(_BaseInventoryComponent): ID_FIELD = 'id' STATIC_DATA_FILE = os.path.join(_base_dir, 'data', 'pokemon.json') - def parse(self, item): - if 'is_egg' in item: - return Egg(item) - return Pokemon(item) + @classmethod + def process_static_data(cls, data): + pokemon_id = 1 + for poke_info in data: + # prepare types + types = [poke_info['Type I'][0]] # required + for t in poke_info.get('Type II', []): + types.append(t) + poke_info['types'] = types + + # prepare attacks (moves) + cls._process_attacks(poke_info) + cls._process_attacks(poke_info, charged=True) + + # prepare movesets + poke_info['movesets'] = cls._process_movesets(poke_info, pokemon_id) + + # calculate maximum CP for the pokemon (best IVs, lvl 40) + base_attack = poke_info['BaseAttack'] + base_defense = poke_info['BaseDefense'] + base_stamina = poke_info['BaseStamina'] + max_cp = _calc_cp(base_attack, base_defense, base_stamina) + poke_info['max_cp'] = max_cp + + pokemon_id += 1 + return data + + @classmethod + def _process_movesets(cls, poke_info, pokemon_id): + # type: (dict, int) -> List[Moveset] + """ + The optimal moveset is the combination of two moves, one quick move + and one charge move, that deals the most damage over time. + + Because each quick move gains a certain amount of energy (different + for different moves) and each charge move requires a different amount + of energy to use, sometimes, a quick move with lower DPS will be + better since it charges the charge move faster. On the same note, + sometimes a charge move that has lower DPS will be more optimal since + it may require less energy or it may last for a longer period of time. + + Attacker have STAB (Same-type attack bonus - x1.25) pokemon have the + same type as attack. So we add it to the "Combo DPS" of the moveset. + + The defender attacks in intervals of 1 second for the first 2 attacks, + and then in intervals of 2 seconds for the remainder of the attacks. + This explains why we see two consecutive quick attacks at the beginning + of the match. As a result, we add +2 seconds to the DPS calculation + for defender DPS output. + + So to determine an optimal defensive moveset, we follow the same method + as we did for optimal offensive movesets, but instead calculate the + highest "Combo DPS" with an added 2 seconds to the quick move cool down. + + Note: critical hits have not yet been implemented in the game + + See http://pokemongo.gamepress.gg/optimal-moveset-explanation + See http://pokemongo.gamepress.gg/defensive-tactics + """ + + # Prepare movesets + movesets = [] + types = poke_info['types'] + for fm in poke_info['Fast Attack(s)']: + for chm in poke_info['Special Attack(s)']: + movesets.append(Moveset(fm, chm, types, pokemon_id)) + assert len(movesets) > 0 + + # Calculate attack perfection for each moveset + movesets = sorted(movesets, key=lambda m: m.dps_attack) + worst_dps = movesets[0].dps_attack + best_dps = movesets[-1].dps_attack + if best_dps > worst_dps: + for moveset in movesets: + current_dps = moveset.dps_attack + moveset.attack_perfection = \ + (current_dps - worst_dps) / (best_dps - worst_dps) + + # Calculate defense perfection for each moveset + movesets = sorted(movesets, key=lambda m: m.dps_defense) + worst_dps = movesets[0].dps_defense + best_dps = movesets[-1].dps_defense + if best_dps > worst_dps: + for moveset in movesets: + current_dps = moveset.dps_defense + moveset.defense_perfection = \ + (current_dps - worst_dps) / (best_dps - worst_dps) + + return sorted(movesets, key=lambda m: m.dps, reverse=True) + + @classmethod + def _process_attacks(cls, poke_info, charged=False): + # type: (dict, bool) -> List[Attack] + key = 'Fast Attack(s)' if not charged else 'Special Attack(s)' + moves_dict = (ChargedAttacks if charged else FastAttacks).BY_NAME + moves = [] + for name in poke_info[key]: + if name not in moves_dict: + raise KeyError('Unknown {} attack: "{}"'.format( + 'charged' if charged else 'fast', name)) + moves.append(moves_dict[name]) + moves = sorted(moves, key=lambda m: m.dps, reverse=True) + poke_info[key] = moves + assert len(moves) > 0 + return moves @classmethod def data_for(cls, pokemon_id): + # type: (int) -> dict return cls.STATIC_DATA[pokemon_id - 1] @classmethod def name_for(cls, pokemon_id): + # type: (int) -> string return cls.data_for(pokemon_id)['Name'] @classmethod @@ -129,24 +239,194 @@ def first_evolution_id_for(cls, pokemon_id): return pokemon_id @classmethod - def next_evolution_id_for(cls, pokemon_id): + def prev_evolution_id_for(cls, pokemon_id): + data = cls.data_for(pokemon_id) + if 'Previous evolution(s)' in data: + return int(data['Previous evolution(s)'][-1]['Number']) + return None + + @classmethod + def next_evolution_ids_for(cls, pokemon_id): try: - return int(cls.data_for(pokemon_id)['Next evolution(s)'][0]['Number']) + next_evolutions = cls.data_for(pokemon_id)['Next evolution(s)'] except KeyError: - return None + return [] + # get only next level evolutions, not all possible + ids = [] + for p in next_evolutions: + p_id = int(p['Number']) + if cls.prev_evolution_id_for(p_id) == pokemon_id: + ids.append(p_id) + return ids @classmethod - def evolution_cost_for(cls, pokemon_id): + def last_evolution_ids_for(cls, pokemon_id): try: - return int(cls.data_for(pokemon_id)['Next Evolution Requirements']['Amount']) + next_evolutions = cls.data_for(pokemon_id)['Next evolution(s)'] except KeyError: - return + return [pokemon_id] + # get only final evolutions, not all possible + ids = [] + for p in next_evolutions: + p_id = int(p['Number']) + if len(cls.data_for(p_id).get('Next evolution(s)', [])) == 0: + ids.append(p_id) + assert len(ids) > 0 + return ids + + @classmethod + def has_next_evolution(cls, pokemon_id): + poke_info = cls.data_for(pokemon_id) + return 'Next Evolution Requirements' in poke_info \ + or 'Next evolution(s)' in poke_info + + @classmethod + def evolution_cost_for(cls, pokemon_id): + if not cls.has_next_evolution(pokemon_id): + return None + return int(cls.data_for(pokemon_id)['Next Evolution Requirements']['Amount']) + + def parse(self, item): + if 'is_egg' in item: + return Egg(item) + return Pokemon(item) def all(self): # by default don't include eggs in all pokemon (usually just # makes caller's lives more difficult) return [p for p in super(Pokemons, self).all() if not isinstance(p, Egg)] + +# +# Static Components + +class LevelToCPm(_StaticInventoryComponent): + """ + Data for the CP multipliers at different levels + See http://pokemongo.gamepress.gg/cp-multiplier + See https://github.com/justinleewells/pogo-optimizer/blob/edd692d/data/game/level-to-cpm.json + """ + + STATIC_DATA_FILE = os.path.join(_base_dir, 'data', 'level_to_cpm.json') + MAX_LEVEL = 40 + MAX_CPM = .0 + # half of the lowest difference between CPMs + HALF_DIFF_BETWEEN_HALF_LVL = 14e-3 + + @classmethod + def init_static_data(cls): + super(LevelToCPm, cls).init_static_data() + cls.MAX_CPM = cls.cp_multiplier_for(cls.MAX_LEVEL) + + @classmethod + def cp_multiplier_for(cls, level): + # type: (Union[float, int, string]) -> float + level = float(level) + level = str(int(level) if level.is_integer() else level) + return cls.STATIC_DATA[level] + + @classmethod + def level_from_cpm(cls, cp_multiplier): + # type: (float) -> float + for lvl, cpm in cls.STATIC_DATA.iteritems(): + diff = abs(cpm - cp_multiplier) + if diff <= cls.HALF_DIFF_BETWEEN_HALF_LVL: + return float(lvl) + raise ValueError("Unknown cp_multiplier: {}".format(cp_multiplier)) + + +class _Attacks(_StaticInventoryComponent): + BY_NAME = {} # type: Dict[string, Attack] + BY_TYPE = {} # type: Dict[List[Attack]] + BY_DPS = [] # type: List[Attack] + + @classmethod + def process_static_data(cls, moves): + ret = {} + by_type = {} + by_name = {} + fast = cls is FastAttacks + for attack in moves: + attack = Attack(attack) if fast else ChargedAttack(attack) + ret[attack.id] = attack + by_name[attack.name] = attack + + if attack.type not in by_type: + by_type[attack.type] = [] + by_type[attack.type].append(attack) + + for t in by_type.iterkeys(): + attacks = sorted(by_type[t], key=lambda m: m.dps, reverse=True) + min_dps = attacks[-1].dps + max_dps = attacks[0].dps - min_dps + if max_dps > .0: + for attack in attacks: # type: Attack + attack.rate_in_type = (attack.dps - min_dps) / max_dps + by_type[t] = attacks + + cls.BY_NAME = by_name + cls.BY_TYPE = by_type + cls.BY_DPS = sorted(ret.values(), key=lambda m: m.dps, reverse=True) + + return ret + + @classmethod + def data_for(cls, attack_id): + # type: (int) -> Attack + if attack_id not in cls.STATIC_DATA: + raise ValueError("Attack {} not found in {}".format( + attack_id, cls.__name__)) + return cls.STATIC_DATA[attack_id] + + @classmethod + def by_name(cls, name): + # type: (string) -> Attack + return cls.BY_NAME[name] + + @classmethod + def list_for_type(cls, type_name): + # type: (string) -> List[Attack] + """ + :return: Attacks sorted by DPS in descending order + """ + return cls.BY_TYPE[type_name] + + @classmethod + def all(cls): + return cls.STATIC_DATA.values() + + @classmethod + def all_by_dps(cls): + return cls.BY_DPS + + +class FastAttacks(_Attacks): + STATIC_DATA_FILE = os.path.join(_base_dir, 'data', 'fast_moves.json') + + +class ChargedAttacks(_Attacks): + STATIC_DATA_FILE = os.path.join(_base_dir, 'data', 'charged_moves.json') + + +# +# Instances + +class Candy(object): + def __init__(self, family_id, quantity): + self.type = Pokemons.name_for(family_id) + self.quantity = quantity + + def consume(self, amount): + if self.quantity < amount: + raise Exception('Tried to consume more {} candy than you have'.format(self.type)) + self.quantity -= amount + + def add(self, amount): + if amount < 0: + raise Exception('Must add positive amount of candy') + self.quantity += amount + + class Egg(object): def __init__(self, data): self._data = data @@ -158,52 +438,297 @@ def has_next_evolution(self): class Pokemon(object): def __init__(self, data): self._data = data + # Unique ID for this particular Pokemon self.id = data['id'] + # Id of the such pokemons in pokedex self.pokemon_id = data['pokemon_id'] + + # Combat points value self.cp = data['cp'] + # Base CP multiplier, fixed at the catch time + self.cp_bm = data['cp_multiplier'] + # Changeable part of the CP multiplier, increasing at power up + self.cp_am = data.get('additional_cp_multiplier', .0) + # Resulting CP multiplier + self.cp_m = self.cp_bm + self.cp_am + + # Current pokemon level (half of level is a normal value) + self.level = LevelToCPm.level_from_cpm(self.cp_m) + + # Maximum health points + self.hp_max = data['stamina_max'] + # Current health points + self.hp = data.get('stamina', self.hp_max) + assert 0 <= self.hp <= self.hp_max + + # Individial Values of the current pokemon (different for each pokemon) + self.iv_attack = data.get('individual_attack', 0) + self.iv_defense = data.get('individual_defense', 0) + self.iv_stamina = data.get('individual_stamina', 0) + self._static_data = Pokemons.data_for(self.pokemon_id) self.name = Pokemons.name_for(self.pokemon_id) - self.iv = self._compute_iv() + self.nickname = data.get('nickname', self.name) + self.in_fort = 'deployed_fort_id' in data self.is_favorite = data.get('favorite', 0) is 1 + # Basic Values of the current pokemon (identical for all such pokemons) + self.base_attack = self._static_data['BaseAttack'] + self.base_defense = self._static_data['BaseDefense'] + self.base_stamina = self._static_data['BaseStamina'] + + # Maximum possible CP for the current pokemon + self.max_cp = self._static_data['max_cp'] + + self.fast_attack = FastAttacks.data_for(data['move_1']) + self.charged_attack = ChargedAttacks.data_for(data['move_2']) + + # Internal values (IV) perfection percent + self.iv = self._compute_iv_perfection() + + # IV CP perfection - kind of IV perfection percent but calculated + # using weight of each IV in its contribution to CP of the best + # evolution of current pokemon + # So it tends to be more accurate than simple IV perfection + self.ivcp = self._compute_cp_perfection() + + # Exact value of current CP (not rounded) + self.cp_exact = _calc_cp( + self.base_attack, self.base_defense, self.base_stamina, + self.iv_attack, self.iv_defense, self.iv_stamina, self.cp_m) + + # Percent of maximum possible CP + self.cp_percent = self.cp_exact / self.max_cp + + # Get moveset instance with calculated DPS and perfection percents + self.moveset = self._get_moveset() + + def __str__(self): + return self.name + + def __repr__(self): + return self.name + def can_evolve_now(self): - return self.has_next_evolution() and self.candy_quantity >= self.evolution_cost + return self.has_next_evolution() and \ + self.candy_quantity >= self.evolution_cost def has_next_evolution(self): - return 'Next Evolution Requirements' in self._static_data + return Pokemons.has_next_evolution(self.pokemon_id) def has_seen_next_evolution(self): - return pokedex().captured(self.next_evolution_id) + for pokemon_id in self.next_evolution_ids: + if pokedex().captured(pokemon_id): + return True + return False @property - def next_evolution_id(self): - return Pokemons.next_evolution_id_for(self.pokemon_id) + def family_id(self): + return self.first_evolution_id @property def first_evolution_id(self): return Pokemons.first_evolution_id_for(self.pokemon_id) + @property + def prev_evolution_id(self): + return Pokemons.prev_evolution_id_for(self.pokemon_id) + + @property + def next_evolution_ids(self): + return Pokemons.next_evolution_ids_for(self.pokemon_id) + + @property + def last_evolution_ids(self): + return Pokemons.last_evolution_ids_for(self.pokemon_id) + @property def candy_quantity(self): return candies().get(self.pokemon_id).quantity @property def evolution_cost(self): - return self._static_data['Next Evolution Requirements']['Amount'] + return Pokemons.evolution_cost_for(self.pokemon_id) + + def _compute_iv_perfection(self): + total_iv = self.iv_attack + self.iv_defense + self.iv_stamina + iv_perfection = round((total_iv / 45.0), 2) + return iv_perfection + + def _compute_cp_perfection(self): + """ + CP perfect percent is more accurate than IV perfect + + We know attack plays an important role in CP, and different + pokemons have different base value, that's means 15/14/15 is + better than 14/15/15 for lot of pokemons, and if one pokemon's + base def is more than base sta, 15/15/14 is better than 15/14/15. + + See https://github.com/jabbink/PokemonGoBot/issues/469 + + So calculate CP perfection at final level for the best of the final + evolutions of the pokemon. + """ + variants = [] + iv_attack = self.iv_attack + iv_defense = self.iv_defense + iv_stamina = self.iv_stamina + cp_m = LevelToCPm.MAX_CPM + last_evolution_ids = self.last_evolution_ids + for pokemon_id in last_evolution_ids: + poke_info = Pokemons.data_for(pokemon_id) + base_attack = poke_info['BaseAttack'] + base_defense = poke_info['BaseDefense'] + base_stamina = poke_info['BaseStamina'] + + # calculate CP variants at maximum level + worst_cp = _calc_cp(base_attack, base_defense, base_stamina, + 0, 0, 0, cp_m) + perfect_cp = _calc_cp(base_attack, base_defense, base_stamina, + cp_multiplier=cp_m) + current_cp = _calc_cp(base_attack, base_defense, base_stamina, + iv_attack, iv_defense, iv_stamina, cp_m) + cp_perfection = (current_cp - worst_cp) / (perfect_cp - worst_cp) + variants.append(cp_perfection) + + # get best value (probably for the best evolution) + cp_perfection = max(variants) + return cp_perfection + + def _get_moveset(self): + move1 = self.fast_attack + move2 = self.charged_attack + movesets = self._static_data['movesets'] + current_moveset = None + for moveset in movesets: # type: Moveset + if moveset.fast_attack == move1 and moveset.charged_attack == move2: + current_moveset = moveset + break + + if current_moveset is None: + raise Exception("Unexpected moveset [{}, {}] for #{} {}".format( + move1, move2, self.pokemon_id, self.name)) + + return current_moveset + + +class Attack(object): + def __init__(self, data): + # self._data = data # Not needed - all saved in fields + self.id = data['id'] + self.name = data['name'] + self.type = data['type'] + self.damage = data['damage'] + self.duration = data['duration'] / 1000.0 # duration in seconds + + # Energy addition for fast attack + # Energy cost for charged attack + self.energy = data['energy'] + + # Damage Per Second + # recalc for better precision + self.dps = self.damage / self.duration + + # Perfection of the attack in it's type (from 0 to 1) + self.rate_in_type = .0 + + @property + def damage_with_stab(self): + # damage with STAB (Same-type attack bonus) + return self.damage * STAB_FACTOR + + @property + def dps_with_stab(self): + # DPS with STAB (Same-type attack bonus) + return self.dps * STAB_FACTOR + + @property + def energy_per_second(self): + return self.energy / self.duration + + @property + def dodge_window(self): + # TODO: Attack Dodge Window + return NotImplemented + + @property + def is_charged(self): + return False + + def __str__(self): + return self.name + + def __repr__(self): + return self.name + + +class ChargedAttack(Attack): + def __init__(self, data): + super(ChargedAttack, self).__init__(data) + + @property + def is_charged(self): + return True + + +class Moveset(object): + def __init__(self, fm, chm, pokemon_types=(), pokemon_id=-1): + # type: (Attack, ChargedAttack, List[string], int) -> None + self.pokemon_id = pokemon_id + self.fast_attack = fm + self.charged_attack = chm + + # See Pokemons._process_movesets() + # See http://pokemongo.gamepress.gg/optimal-moveset-explanation + # See http://pokemongo.gamepress.gg/defensive-tactics + + fm_number = 100 # for simplicity we use 100 + + fm_energy = fm.energy * fm_number + fm_damage = fm.damage * fm_number + fm_secs = fm.duration * fm_number + + # Defender attacks in intervals of 1 second for the + # first 2 attacks, and then in intervals of 2 seconds + # So add 1.95 seconds to the quick move cool down for defense + # 1.95 is something like an average here + # TODO: Do something better? + fm_defense_secs = (fm.duration + 1.95) * fm_number + + chm_number = fm_energy / chm.energy + chm_damage = chm.damage * chm_number + chm_secs = chm.duration * chm_number + + damage_sum = fm_damage + chm_damage + # raw Damage-Per-Second for the moveset + self.dps = damage_sum / (fm_secs + chm_secs) + # average DPS for defense + self.dps_defense = damage_sum / (fm_defense_secs + chm_secs) + + # apply STAB (Same-type attack bonus) + if fm.type in pokemon_types: + fm_damage *= STAB_FACTOR + if chm.type in pokemon_types: + chm_damage *= STAB_FACTOR + + # DPS for attack (counting STAB) + self.dps_attack = (fm_damage + chm_damage) / (fm_secs + chm_secs) - def _compute_iv(self): - total_IV = 0.0 - iv_stats = ['individual_attack', 'individual_defense', 'individual_stamina'] + # Moveset perfection percent attack and for defense + # Calculated for current pokemon, not between all pokemons + # So 100% perfect moveset can be weak if pokemon is weak (e.g. Caterpie) + self.attack_perfection = .0 + self.defense_perfection = .0 - for individual_stat in iv_stats: - try: - total_IV += self._data[individual_stat] - except Exception: - self._data[individual_stat] = 0 - continue - pokemon_potential = round((total_IV / 45.0), 2) - return pokemon_potential + # TODO: True DPS for real combat (floor(Attack/200 * MovePower * STAB) + 1) + # See http://pokemongo.gamepress.gg/pokemon-attack-explanation + + def __str__(self): + return '[{}, {}]'.format(self.fast_attack, self.charged_attack) + + def __repr__(self): + return '[{}, {}]'.format(self.fast_attack, self.charged_attack) class Inventory(object): @@ -227,8 +752,47 @@ def refresh(self): with open(user_web_inventory, 'w') as outfile: json.dump(inventory, outfile) +# +# Usage helpers + +# STAB (Same-type attack bonus) +STAB_FACTOR = 1.25 _inventory = None +LevelToCPm() # init LevelToCPm +FastAttacks() # init FastAttacks +ChargedAttacks() # init ChargedAttacks + + +def _calc_cp(base_attack, base_defense, base_stamina, + iv_attack=15, iv_defense=15, iv_stamina=15, + cp_multiplier=LevelToCPm.MAX_CPM): + """ + CP calculation + + CP = (Attack * Defense^0.5 * Stamina^0.5 * CP_Multiplier^2) / 10 + CP = (BaseAtk+AtkIV) * (BaseDef+DefIV)^0.5 * (BaseStam+StamIV)^0.5 * Lvl(CPScalar)^2 / 10 + + See https://www.reddit.com/r/TheSilphRoad/comments/4t7r4d/exact_pokemon_cp_formula/ + See https://www.reddit.com/r/pokemongodev/comments/4t7xb4/exact_cp_formula_from_stats_and_cpm_and_an_update/ + See http://pokemongo.gamepress.gg/pokemon-stats-advanced + See http://pokemongo.gamepress.gg/cp-multiplier + See http://gaming.stackexchange.com/questions/280491/formula-to-calculate-pokemon-go-cp-and-hp + + :param base_attack: Pokemon BaseAttack + :param base_defense: Pokemon BaseDefense + :param base_stamina: Pokemon BaseStamina + :param iv_attack: Pokemon IndividualAttack (0..15) + :param iv_defense: Pokemon IndividualDefense (0..15) + :param iv_stamina: Pokemon IndividualStamina (0..15) + :param cp_multiplier: CP Multiplier (0.79030001 is max - value for level 40) + :return: CP as float + """ + return (base_attack + iv_attack) \ + * ((base_defense + iv_defense)**0.5) \ + * ((base_stamina + iv_stamina)**0.5) \ + * (cp_multiplier ** 2) / 10 + def init_inventory(bot): global _inventory @@ -257,3 +821,15 @@ def pokemons(refresh=False): def items(): return _inventory.items + + +def levels_to_cpm(): + return LevelToCPm + + +def fast_attacks(): + return FastAttacks + + +def charged_attacks(): + return ChargedAttacks diff --git a/tests/inventory_test.py b/tests/inventory_test.py new file mode 100644 index 0000000000..8362ce2b91 --- /dev/null +++ b/tests/inventory_test.py @@ -0,0 +1,183 @@ +import unittest + +from pokemongo_bot.inventory import * + + +class InventoryTest(unittest.TestCase): + def test_pokemons(self): + # Init data + self.assertEqual(len(Pokemons().all()), 0) # No inventory loaded here + + obj = Pokemons + self.assertEqual(len(obj.STATIC_DATA), 151) + + for poke_info in obj.STATIC_DATA: + name = poke_info['Name'] + pokemon_id = int(poke_info['Number']) + self.assertTrue(1 <= pokemon_id <= 151) + + self.assertGreaterEqual(len(poke_info['movesets']), 1) + self.assertTrue(262 <= poke_info['max_cp'] <= 4145) + self.assertTrue(1 <= len(poke_info['types']) <= 2) + self.assertTrue(40 <= poke_info['BaseAttack'] <= 284) + self.assertTrue(20 <= poke_info['BaseDefense'] <= 500) + self.assertTrue(54 <= poke_info['BaseStamina'] <= 242) + self.assertTrue(.0 <= poke_info['CaptureRate'] <= .56) + self.assertTrue(.0 <= poke_info['FleeRate'] <= .99) + self.assertTrue(1 <= len(poke_info['Weaknesses']) <= 7) + self.assertTrue(3 <= len(name) <= 10) + + self.assertGreaterEqual(len(poke_info['Classification']), 11) + self.assertGreaterEqual(len(poke_info['Fast Attack(s)']), 1) + self.assertGreaterEqual(len(poke_info['Special Attack(s)']), 1) + + self.assertIs(obj.data_for(pokemon_id), poke_info) + self.assertIs(obj.name_for(pokemon_id), name) + + first_evolution_id = obj.first_evolution_id_for(pokemon_id) + self.assertGreaterEqual(first_evolution_id, 1) + next_evolution_ids = obj.next_evolution_ids_for(pokemon_id) + last_evolution_ids = obj.last_evolution_ids_for(pokemon_id) + candies_cost = obj.evolution_cost_for(pokemon_id) + obj.prev_evolution_id_for(pokemon_id) # just call test + self.assertGreaterEqual(len(last_evolution_ids), 1) + + if not obj.has_next_evolution(pokemon_id): + assert 'Next evolution(s)' not in poke_info + assert 'Next Evolution Requirements' not in poke_info + else: + self.assertGreaterEqual(len(next_evolution_ids), 1) + self.assertLessEqual(len(next_evolution_ids), len(last_evolution_ids)) + + reqs = poke_info['Next Evolution Requirements'] + self.assertEqual(reqs["Family"], first_evolution_id) + candies_name = obj.name_for(first_evolution_id) + ' candies' + self.assertEqual(reqs["Name"], candies_name) + self.assertIsNotNone(candies_cost) + self.assertTrue(12 <= candies_cost <= 400) + self.assertEqual(reqs["Amount"], candies_cost) + + evolutions = poke_info["Next evolution(s)"] + self.assertGreaterEqual(len(evolutions), len(next_evolution_ids)) + + for p in evolutions: + p_id = int(p["Number"]) + self.assertNotEqual(p_id, pokemon_id) + self.assertEqual(p["Name"], obj.name_for(p_id)) + + for p_id in next_evolution_ids: + self.assertEqual(obj.prev_evolution_id_for(p_id), pokemon_id) + prev_evs = obj.data_for(p_id)["Previous evolution(s)"] + self.assertGreaterEqual(len(prev_evs), 1) + self.assertEqual(int(prev_evs[-1]["Number"]), pokemon_id) + self.assertEqual(prev_evs[-1]["Name"], name) + + # Only Eevee has 3 next evolutions + self.assertEqual(len(next_evolution_ids), + 1 if pokemon_id != 133 else 3) + + if "Previous evolution(s)" in poke_info: + for p in poke_info["Previous evolution(s)"]: + p_id = int(p["Number"]) + self.assertNotEqual(p_id, pokemon_id) + self.assertEqual(p["Name"], obj.name_for(p_id)) + + # + # Specific pokemons testing + + poke = Pokemon({ + "num_upgrades": 2, "move_1": 210, "move_2": 69, "pokeball": 2, + "favorite": 1, "pokemon_id": 42, "battles_attacked": 4, + "stamina": 76, "stamina_max": 76, "individual_attack": 9, + "individual_defense": 4, "individual_stamina": 8, + "cp_multiplier": 0.4627983868122101, + "additional_cp_multiplier": 0.018886566162109375, + "cp": 653, "nickname": "Golb", "id": 13632861873471324}) + self.assertEqual(poke.level, 12.5) + self.assertEqual(poke.iv, 0.47) + self.assertAlmostEqual(poke.ivcp, 0.482845351) + self.assertAlmostEqual(poke.max_cp, 1921.34561459) + self.assertAlmostEqual(poke.cp_percent, 0.34000973) + self.assertTrue(poke.is_favorite) + self.assertEqual(poke.name, 'Golbat') + self.assertEqual(poke.nickname, "Golb") + self.assertAlmostEqual(poke.moveset.dps, 10.7540173053) + self.assertAlmostEqual(poke.moveset.dps_attack, 12.14462299) + self.assertAlmostEqual(poke.moveset.dps_defense, 4.876681614) + self.assertAlmostEqual(poke.moveset.attack_perfection, 0.4720730048) + self.assertAlmostEqual(poke.moveset.defense_perfection, 0.8158081497) + + poke = Pokemon({ + "move_1": 221, "move_2": 129, "pokemon_id": 19, "cp": 110, + "individual_attack": 6, "stamina_max": 22, "individual_defense": 14, + "cp_multiplier": 0.37523558735847473, "id": 7841053399}) + self.assertEqual(poke.level, 7.5) + self.assertEqual(poke.iv, 0.44) + self.assertAlmostEqual(poke.ivcp, 0.452398293) + self.assertAlmostEqual(poke.max_cp, 581.64643575) + self.assertAlmostEqual(poke.cp_percent, 0.189251848608) + self.assertFalse(poke.is_favorite) + self.assertEqual(poke.name, 'Rattata') + self.assertEqual(poke.nickname, 'Rattata') + self.assertAlmostEqual(poke.moveset.dps, 12.5567813108) + self.assertAlmostEqual(poke.moveset.dps_attack, 15.6959766385) + self.assertAlmostEqual(poke.moveset.dps_defense, 5.54282440561) + self.assertAlmostEqual(poke.moveset.attack_perfection, 0.835172881385) + self.assertAlmostEqual(poke.moveset.defense_perfection, 0.603137650999) + + def test_levels_to_cpm(self): + l2c = LevelToCPm + self.assertIs(levels_to_cpm(), l2c) + max_cpm = l2c.cp_multiplier_for(l2c.MAX_LEVEL) + self.assertEqual(l2c.MAX_LEVEL, 40) + self.assertEqual(l2c.MAX_CPM, max_cpm) + self.assertEqual(len(l2c.STATIC_DATA), 79) + + self.assertEqual(l2c.cp_multiplier_for("1"), 0.094) + self.assertEqual(l2c.cp_multiplier_for(1), 0.094) + self.assertEqual(l2c.cp_multiplier_for(1.0), 0.094) + self.assertEqual(l2c.cp_multiplier_for("17.5"), 0.558830576) + self.assertEqual(l2c.cp_multiplier_for(17.5), 0.558830576) + self.assertEqual(l2c.cp_multiplier_for('40.0'), 0.79030001) + self.assertEqual(l2c.cp_multiplier_for(40.0), 0.79030001) + self.assertEqual(l2c.cp_multiplier_for(40), 0.79030001) + + self.assertEqual(l2c.level_from_cpm(0.79030001), 40.0) + self.assertEqual(l2c.level_from_cpm(0.7903), 40.0) + + def test_attacks(self): + self._test_attacks(fast_attacks, FastAttacks) + self._test_attacks(charged_attacks, ChargedAttacks) + + def _test_attacks(self, callback, clazz): + charged = clazz is ChargedAttacks + self.assertIs(callback(), clazz) + + # check consistency + attacks = clazz.all_by_dps() + number = len(attacks) + self.assertTrue(number > 0) + self.assertGreaterEqual(len(clazz.BY_TYPE), 17) + self.assertEqual(number, len(clazz.all())) + self.assertEqual(number, len(clazz.STATIC_DATA)) + self.assertEqual(number, len(clazz.BY_NAME)) + self.assertEqual(number, sum([len(l) for l in clazz.BY_TYPE.values()])) + + # check data + prev_dps = float("inf") + for attack in attacks: # type: Attack + self.assertGreater(attack.id, 0) + self.assertGreater(len(attack.name), 0) + self.assertGreater(len(attack.type), 0) + self.assertGreaterEqual(attack.damage, 0) + self.assertGreater(attack.duration, .0) + self.assertGreater(attack.energy, 0) + self.assertGreaterEqual(attack.dps, 0) + self.assertTrue(.0 <= attack.rate_in_type <= 1.0) + self.assertLessEqual(attack.dps, prev_dps) + self.assertEqual(attack.is_charged, charged) + self.assertIs(attack, clazz.data_for(attack.id)) + self.assertIs(attack, clazz.by_name(attack.name)) + self.assertTrue(attack in clazz.BY_TYPE[attack.type]) + self.assertIsInstance(attack, ChargedAttack if charged else Attack) + prev_dps = attack.dps