Initial cut as a Python package

This commit is contained in:
Zoé Cassiopée Gauthier 2024-03-29 21:00:50 -04:00
commit fecb0db7dc
8 changed files with 430 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
__pycache__/
dist/
*.db
*.json

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.12

21
LICENSE Normal file
View File

@ -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.

29
README.md Normal file
View File

@ -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 average scaled estimators for attackers of the
given type:
```console
ase-cli POKEMON_TYPE_GRASS
ase-cli --level 30 POKEMON_TYPE_GRASS
```

101
pyproject.toml Normal file
View File

@ -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
]

View File

@ -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",
]

View File

@ -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)

View File

@ -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())