Initial cut as a Python package

This commit is contained in:
Zoé Cassiopée Gauthier 2024-03-29 21:00:50 -04:00
commit 9ee1cc6c8d
8 changed files with 415 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.

1
README.md Normal file
View File

@ -0,0 +1 @@
# Pokémon GO Scaled Estimators

117
pyproject.toml Normal file
View File

@ -0,0 +1,117 @@
[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
#-----
"A003", # builtin-attribute-shadowing
"TD002", # missing-todo-author
"TD003", # missing-todo-link
"SIM108", # if-else-block-instead-of-if-exp
"C408", # unnecessary-collection-call
"UP012", # unnecessary-encode-utf8
"RET504", # unnecessary-assign
# PyLint too aggressive about too many args, statements, branches, and
# returns
"PLR0904", # too-many-public-methods
"PLR0911", # too-many-return-statements
"PLR0912", # too-many-branches
"PLR0913", # too-many-arguments
"PLR0915", # too-many-statements
"PLR2004", # magic-value-comparison
]

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,228 @@
# 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
class Calculator:
def __init__(self):
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")
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:
resource = json.load(fp)
return resource
def calculate(self, attacker_types, level=40, party=1):
raid_bosses = self._raid_bosses(attacker_types)
charged_moves = [
move["moveId"] for move in self.moves["move"] if "type" in move and move["type"] in 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, attacker_types):
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 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 >= 2.56:
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]] >= 2.56 for r in self.resists.values()
)
if not any_double_weaknesses and attack_resist >= 1.6:
return True
return False
def load_estimators(self, attacker_types, 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": 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, attacker_types, level, party):
for tier, defenders in self._raid_bosses(attacker_types).items():
for defender in defenders:
try:
self.load_estimators(attacker_types, tier, defender, level, party)
except json.decoder.JSONDecodeError:
time.sleep(30)
self.load_estimators(attacker_types, 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()
if args.refresh:
calculator.refresh(args.type, level=args.level, party=args.party)
else:
calculator.calculate(args.type, level=args.level, party=args.party)
if __name__ == "__main__":
sys.exit(main_cli())