commit f87036124221b88f28fbade4d52960e5db32b280 Author: Zoé Cassiopée Gauthier Date: Fri Mar 29 21:00:50 2024 -0400 Initial cut as a Python package diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48a3e0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +dist/ +*.db +*.json diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..274552a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Zoé Cassiopée Gauthier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f75c02 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Pokémon GO Average Scaled Estimators + +## Installation + +Once downloaded, this package can be installed locally in development mode: + +```console +pip install -e . +``` + +## Usage + +To run the simulations, choose your parameters such as attacker type, attacker level, and wether Party Power is active. +Try to fill the stored results database with any of the following: + +```console +ase-cli --refresh POKEMON_TYPE_GRASS # Grass attackers. Defaults to level 40 and no Party Power. +ase-cli --refresh --level 30 POKEMON_TYPE_GRASS # Level 30 Grass attackers, no Party Power. +ase-cli --refresh --party 2 POKEMON_TYPE_GRASS # Level 40 Grass attackers, Party Power with two trainers. +ase-cli --refresh POKEMON_TYPE_DARK POKEMON_TYPE_GHOST # Combined Dark and Ghost attackers. +``` + +Once the database contains the desired simulation results, display the scaled estimators for attackers of thec given +type: + +```console +ase-cli POKEMON_TYPE_GRASS +ase-cli --level 30 POKEMON_TYPE_GRASS +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e9dbb36 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,101 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "pogo-scaled-estimators" +version = "1.0a1" +dependencies = [ + "requests", +] +requires-python = ">=3.12" +authors = [ + { name = "Zoé Cassiopée Gauthier", email = "zoe.gauthier@blorp.dev" }, +] +description = "Calculates scaled difficulty estimators from Pokebattler simulations." +readme = "README.md" +license = "MIT" +keywords = [] +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", +] + +[project.urls] +"Source" = "https://git.blorp.dev/zo/pogo-scaled-estimators" + +[project.scripts] +ase-cli = "pogo_scaled_estimators:main_cli" + +[tool.hatch.envs.lint] +detached = true +dependencies = [ + "pyright", + "ruff", +] + +[tool.hatch.envs.lint.scripts] +all = ["style", "typing"] +format = ["ruff format --fix {args:.}"] +style = ["ruff check {args:.}"] +typing = ["pyright"] + +[tool.ruff] +target-version = "py312" +line-length = 120 + +[tool.ruff.lint] +select = [ + # Note: these are ordered to match https://beta.ruff.rs/docs/rules/ + "F", # PyFlakes + "E", # pycodestyle errors + "W", # pycodestyle warnings + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "ASYNC", # flake8-async + "BLE", # flake8-blind-except + "FBT", # flake8-boolean-trap + "B", # flake8-bugbear + "A", # flake8-builtins + "COM", # flake8-commas + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "T10", # flake8-debugger + "EM", # flake8-errmsg + "ISC", # flake8-implicit-str-concat + "PIE", # flake8-pie + "PT", # flake8-pytest-style + "Q", # flake8-quotes + "RET", # flake8-return + "SLOT", # flake8-slots + "SIM", # flake8-simplify + "TID", # flake8-tidy-imports + "PTH", # flake8-pathlib + "TD", # flake8-todo + "PL", # PyLint + "TRY", # tryceratops + "NPY", # NumPy + "RUF", # Ruff +] +ignore = [ + #----- Rules recommended by https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", # tab-identation + "E111", # indentation-with-invalid-multiple + "E114", # indentation-with-invalid-multiple-comment + "E117", # over-indented + "E501", # line-too-long + "D206", # indent-with-spaces + "D300", # triple-single-quotes + "Q000", # bad-quotes-inline-string + "Q001", # bad-quotes-multiline-string + "Q002", # bad-quotes-docstring + "Q003", # avoidable-escaped-quote + "COM812", # missing-trailing-comma + "COM819", # prohibited-trailing-comma + "ISC001", # single-line-implicit-string-concatenation + "ISC002", # multi-line-implicit-string-concatenation +] diff --git a/src/pogo_scaled_estimators/__init__.py b/src/pogo_scaled_estimators/__init__.py new file mode 100644 index 0000000..62db3ad --- /dev/null +++ b/src/pogo_scaled_estimators/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2024 Zoé Cassiopée Gauthier. +# +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +from pogo_scaled_estimators.calculator import Calculator +from pogo_scaled_estimators.cli import main_cli + +__all__ = [ + "main_cli", + "Calculator", +] diff --git a/src/pogo_scaled_estimators/calculator.py b/src/pogo_scaled_estimators/calculator.py new file mode 100644 index 0000000..223af8b --- /dev/null +++ b/src/pogo_scaled_estimators/calculator.py @@ -0,0 +1,231 @@ +# Copyright 2024 Zoé Cassiopée Gauthier. +# +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +import contextlib +import functools +import json +import operator +import sqlite3 +import time +import urllib.parse +from pathlib import Path + +import requests + +WEAKNESS = 1.6 +DOUBLE_WEAKNESS = WEAKNESS * WEAKNESS + + +class Calculator: + def __init__(self, attacker_types): + self.db = sqlite3.connect("ase.db") + # db.set_trace_callback(print) + + with contextlib.suppress(sqlite3.OperationalError): + self.db.execute( + "CREATE TABLE estimators(defender, raid_tier, attacker, level, quick_move, charged_move, estimator, party)" + ) + + self.raids = self._pokebattler_resource("raids") + self.pokemon = self._pokebattler_resource("pokemon") + self.resists = self._pokebattler_resource("resists") + self.moves = self._pokebattler_resource("moves") + + self.attacker_types = attacker_types + + def _pokebattler_resource(self, name): + p = Path(f"./{name}.json") + if not p.exists(): + response = requests.get(f"https://fight.pokebattler.com/{name}") + with p.open(mode="wb") as fp: + fp.write(response.content) + + with p.open() as fp: + return json.load(fp) + + def calculate(self, level=40, party=1): + raid_bosses = self._raid_bosses() + charged_moves = [ + move["moveId"] for move in self.moves["move"] if "type" in move and move["type"] in self.attacker_types + ] + res = self.db.execute( + f"""SELECT DISTINCT(attacker) FROM estimators WHERE charged_move IN ("{'","'.join(charged_moves)}")""" + ) + attackers = {row[0]: {"RAID_LEVEL_3": [], "RAID_LEVEL_5": [], "RAID_LEVEL_MEGA": []} for row in res.fetchall()} + + defenders = functools.reduce(operator.iconcat, raid_bosses.values(), []) + res = self.db.execute( + f""" + SELECT e.raid_tier, e.defender, e.attacker, MIN(e.estimator) / m.min_estimator + FROM estimators e + INNER JOIN ( + SELECT defender, MIN(estimator) AS min_estimator + FROM estimators + WHERE party = ? AND level = 40 + AND attacker IN ("{'","'.join(attackers.keys())}") + AND defender IN ("{'","'.join(defenders)}") + GROUP BY defender + ) AS m ON e.defender = m.defender + WHERE party = ? AND level = ? AND attacker IN ("{'","'.join(attackers.keys())}") + GROUP BY e.defender, e.attacker + """, + (party, party, level), + ) + for raid_tier, _defender, attacker, estimator in res.fetchall(): + if raid_tier == "RAID_LEVEL_MEGA_5": + simplified_raid_tier = "RAID_LEVEL_MEGA" + elif raid_tier == "RAID_LEVEL_ULTRA_BEAST": + simplified_raid_tier = "RAID_LEVEL_5" + else: + simplified_raid_tier = raid_tier + attackers[attacker][simplified_raid_tier].append(estimator) + + ase = {} + for attacker, estimators in attackers.items(): + if not estimators["RAID_LEVEL_3"] or not estimators["RAID_LEVEL_5"] or not estimators["RAID_LEVEL_MEGA"]: + continue + ase = ( + 0.15 * sum(estimators["RAID_LEVEL_3"]) / len(estimators["RAID_LEVEL_3"]) + + 0.50 * sum(estimators["RAID_LEVEL_5"]) / len(estimators["RAID_LEVEL_5"]) + + 0.35 * sum(estimators["RAID_LEVEL_MEGA"]) / len(estimators["RAID_LEVEL_MEGA"]) + ) + print(f"{attacker},{ase}") + + def _raid_bosses(self): + raid_tiers = [] + raid_bosses = {} + + for raid_level in ["3", "5", "MEGA", "MEGA_5", "ULTRA_BEAST"]: + tier = f"RAID_LEVEL_{raid_level}" + raid_tiers.extend( + [ + tier, + f"{tier}_LEGACY", + f"{tier}_FUTURE", + ] + ) + raid_bosses[tier] = [] + + for tier in filter(lambda tier: tier["tier"] in raid_tiers, self.raids["tiers"]): + for boss in (raid["pokemon"] for raid in tier["raids"]): + if boss.endswith("_FORM"): + continue + boss_pokemon = next(filter(lambda mon: mon["pokemonId"] == boss, self.pokemon["pokemon"])) + if ("candyToEvolve" in boss_pokemon or boss in ["SEADRA", "SEALEO"]) and boss not in [ + "KELDEO", + "LUMINEON", + "MANAPHY", + "PHIONE", + "STUNFISK", + "TERRAKION", + ]: + continue + boss_types = ( + boss_pokemon["type"], + boss_pokemon.get("type2", "POKEMON_TYPE_NONE"), + ) + if any(self._is_weak(attacker_type, boss_types) for attacker_type in self.attacker_types): + raid_bosses[tier["info"]["guessTier"]].append(boss) + + return raid_bosses + + def _is_weak(self, attacker_type, defender_types): + pokemon_types = list(self.resists.keys()) + defender_type_indices = ( + pokemon_types.index(defender_types[0]), + pokemon_types.index(defender_types[1]), + ) + attack_resist = ( + self.resists[attacker_type][defender_type_indices[0]] + * self.resists[attacker_type][defender_type_indices[1]] + ) + + # Check for double weakness. + if attack_resist >= DOUBLE_WEAKNESS: + return True + + # Checkout for single weakness if not double weak to anything else. + any_double_weaknesses = any( + r[defender_type_indices[0]] * r[defender_type_indices[1]] >= DOUBLE_WEAKNESS for r in self.resists.values() + ) + if not any_double_weaknesses and attack_resist >= WEAKNESS: + return True + + return False + + def load_estimators(self, tier, defender, level, party): + base_url = "https://fight.pokebattler.com" + query_string = { + "sort": "ESTIMATOR", + "weatherCondition": "NO_WEATHER", + "dodgeStrategy": "DODGE_REACTION_TIME", + "aggregation": "AVERAGE", + "includeLegendary": "true", + "includeShadow": "true", + "includeMegas": "true", + "attackerTypes": self.attacker_types, + "primalAssistants": "", + "numParty": str(party), + } + if tier != "RAID_LEVEL_3": + query_string["friendshipLevel"] = "FRIENDSHIP_LEVEL_4" + url = f"{base_url}/raids/defenders/{defender}/levels/{tier}/attackers/levels/{level}/strategies/CINEMATIC_ATTACK_WHEN_POSSIBLE/DEFENSE_RANDOM_MC?{urllib.parse.urlencode(query_string, doseq=True)}" + response = requests.get(url) + for attacker in response.json()["attackers"][0]["randomMove"]["defenders"]: + for attacker_moves in attacker["byMove"]: + res = self.db.execute( + "SELECT estimator FROM estimators WHERE defender=? AND attacker=? AND level=? AND quick_move=? AND charged_move=? AND party=?", + ( + defender, + attacker["pokemonId"], + level, + attacker_moves["move1"], + attacker_moves["move2"], + party, + ), + ) + estimator = res.fetchone() + if estimator is not None: + print( + defender, + attacker["pokemonId"], + attacker_moves["move1"], + attacker_moves["move2"], + estimator[0], + ) + continue + + self.db.execute( + "INSERT INTO estimators(defender, raid_tier, attacker, level, quick_move, charged_move, estimator, party) VALUES(?, ?, ?, ?, ?, ?, ?, ?)", + ( + defender, + tier, + attacker["pokemonId"], + level, + attacker_moves["move1"], + attacker_moves["move2"], + attacker_moves["result"]["estimator"], + party, + ), + ) + print( + defender, + attacker["pokemonId"], + attacker_moves["move1"], + attacker_moves["move2"], + attacker_moves["result"]["estimator"], + ) + self.db.commit() + + def refresh(self, level=40, party=1): + for tier, defenders in self._raid_bosses().items(): + for defender in defenders: + try: + self.load_estimators(tier, defender, level, party) + except json.decoder.JSONDecodeError: + time.sleep(30) + self.load_estimators(tier, defender, level, party) + time.sleep(30) diff --git a/src/pogo_scaled_estimators/cli.py b/src/pogo_scaled_estimators/cli.py new file mode 100644 index 0000000..35b1f28 --- /dev/null +++ b/src/pogo_scaled_estimators/cli.py @@ -0,0 +1,30 @@ +# Copyright 2024 Zoé Cassiopée Gauthier. +# +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +import argparse +import sys + +import pogo_scaled_estimators.calculator + + +def main_cli(): + parser = argparse.ArgumentParser() + parser.add_argument("type", nargs="+", help="an attacker type") + parser.add_argument("--level", type=int, default=40) + parser.add_argument("--party", type=int, default=1) + parser.add_argument("--refresh", action="store_true", default=False) + + args = parser.parse_args() + + calculator = pogo_scaled_estimators.calculator.Calculator(args.type) + if args.refresh: + calculator.refresh(level=args.level, party=args.party) + else: + calculator.calculate(level=args.level, party=args.party) + + +if __name__ == "__main__": + sys.exit(main_cli())