Improve the pyproject.toml configuration, add first test, and create more type hints

This commit is contained in:
Zoé Cassiopée Gauthier 2024-04-17 18:06:46 -04:00
parent e1cf1cb194
commit 207178f822
9 changed files with 256 additions and 115 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
__pycache__/
.coverage
dist/
src/pogo_scaled_estimators/_version.py
*.sqlite

View File

@ -27,7 +27,7 @@ classifiers = [
]
[project.urls]
"Source" = "https://git.blorp.dev/zo/pogo-scaled-estimators"
Source = "https://git.blorp.dev/zo/pogo-scaled-estimators"
[project.scripts]
ase-cli = "pogo_scaled_estimators:main_cli"
@ -38,18 +38,46 @@ source = "vcs"
[tool.hatch.build.hooks.vcs]
version-file = "src/pogo_scaled_estimators/_version.py"
[tool.hatch.envs.lint]
detached = true
[tool.hatch.envs.default]
dependencies = [
"coverage[toml]",
"pyright",
"pytest",
"requests-mock",
"ruff",
]
[tool.hatch.envs.lint.scripts]
all = ["style", "typing"]
format = ["ruff format --fix {args:.}"]
style = ["ruff check {args:.}"]
typing = ["pyright"]
[tool.hatch.envs.default.scripts]
test = "pytest {args:tests}"
test-cov = "coverage run -m pytest {args:tests}"
cov-report = [
"- coverage combine",
"coverage report",
]
cov = [
"test-cov",
"cov-report",
]
format-check = "ruff format --check --diff {args:.}"
format-fix = "ruff format {args:.}"
lint-check = "ruff check {args:.}"
lint-fix = "ruff check --fix {args:.}"
types-check = "pyright"
[tool.coverage.run]
branch = true
source_pkgs = ["pogo_scaled_estimators", "tests"]
[tool.coverage.paths]
pogo_scaled_estimators = ["src/pogo_scaled_estimators", "*/pogo_scaled_estimators/src/pogo_scaled_estimators"]
tests = ["tests", "*/pogo_scaled_estimators/tests"]
[tool.coverage.report]
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
[tool.ruff]
target-version = "py312"
@ -112,46 +140,18 @@ ignore = [
]
[tool.pyright]
include = ["src/glimmer", "tests"]
exclude = ["**/__pycache__"]
include = ["src/pogo_scaled_estimators", "tests"]
reportMissingImports = true
reportMissingTypeStubs = false
pythonVersion = "3.12"
pythonPlatform = "Linux"
typeCheckingMode = "standard"
strictListInference = true
strictDictionaryInference = true
strictSetInference = true
reportAssertAlwaysTrue = "error"
reportInvalidStringEscapeSequence = "error"
reportSelfClsParameterName = "error"
reportConstantRedefinition = "error"
reportDeprecated = "error"
reportDuplicateImport = "error"
reportIncompatibleMethodOverride = "error"
reportIncompatibleVariableOverride = "error"
reportInconsistentConstructor = "error"
reportMatchNotExhaustive = "warning"
reportOverlappingOverload = "error"
reportMissingSuperCall = "error"
reportPrivateUsage = "warning"
reportTypeCommentUsage = "error"
reportUnnecessaryCast = "error"
reportUnnecessaryComparison = "error"
reportUnnecessaryContains = "error"
reportUnnecessaryIsInstance = "error"
reportUnusedClass = "warning"
reportUnusedImport = "warning"
reportUnusedFunction = "warning"
reportUnusedVariable = "warning"
reportUntypedBaseClass = "error"
reportUntypedClassDecorator = "error"
reportUntypedFunctionDecorator = "error"
reportUntypedNamedTuple = "error"
reportCallInDefaultInitializer = "error"
reportImplicitOverride = "error"
reportPropertyTypeMismatch = "warning"
reportShadowedImports = "warning"
reportUninitializedInstanceVariable = "warning"
reportUnnecessaryTypeIgnoreComment = "warning"
reportUnusedCallResult = "warning"
typeCheckingMode = "strict"
reportMissingParameterType = "none"
reportUnknownArgumentType = "none"
reportUnknownLambdaType = "none"
reportUnknownMemberType = "none"
reportUnknownParameterType = "none"
reportUnknownVariableType = "none"
reportUnusedFunction = "none"

View File

@ -6,11 +6,12 @@
import math
from enum import Flag, auto
from typing import final
from typing import TypeGuard, final, get_args
from rich.progress import Progress
from pogo_scaled_estimators.pokebattler_proxy import MovesetResult, PokebattlerProxy, Raid
from pogo_scaled_estimators.typing import PokemonType
from pogo_scaled_estimators.utilities import format_move_name, format_pokemon_name
@ -22,17 +23,25 @@ class Filter(Flag):
DISALLOW_LEGENDARY_POKEMON = auto()
class NotAPokemonTypeError(TypeError):
def __init__(self, pokemon_types: list[str]):
super().__init__(f"{pokemon_types} contains invalid Pokebattler type name")
@final
class Calculator:
def __init__(self, attacker_types: list[str], filters: Filter = Filter.NO_FILTER) -> None:
self.attacker_types = attacker_types
if self._is_list_of_types(attacker_types):
self.attacker_types: list[PokemonType] = attacker_types
else:
raise NotAPokemonTypeError(attacker_types)
self.filters = filters
self._pokebattler_proxy = PokebattlerProxy()
self._progress = Progress()
def calculate(self, level: int = 40, party: int = 1) -> None:
raid_bosses = self._pokebattler_proxy.raid_bosses(self.attacker_types)
attackers = {
attackers: dict[str, dict[str, list[MovesetResult]]] = {
attacker: {"RAID_LEVEL_3": [], "RAID_LEVEL_5": [], "RAID_LEVEL_MEGA": []}
for attacker in self._pokebattler_proxy.with_charged_moves(self.attacker_types)
if self._allowed_attacker(attacker)
@ -66,6 +75,8 @@ class Calculator:
for attacker, movesets in results.items()
if movesets
}
if not best_movesets:
continue
best_estimator = min(best_movesets.values(), key=lambda moveset: moveset.estimator).estimator
for attacker, moveset in best_movesets.items():
attackers[attacker][simplified_raid_tier].append(moveset.scale(best_estimator))
@ -86,6 +97,9 @@ class Calculator:
f"[bold]{format_pokemon_name(attacker, attacker_type)}[/bold] ({format_move_name(fast_move, fast_move_type)}/{format_move_name(charged_move, charged_move_type)}): {ase:.2f}"
)
def _is_list_of_types(self, attacker_types: list[str]) -> TypeGuard[list[PokemonType]]:
return all(attacker_type in get_args(PokemonType) for attacker_type in attacker_types)
def _allowed_attacker(self, pokemon_id: str) -> bool:
if Filter.DISALLOW_MEGA_POKEMON in self.filters and ("_MEGA" in pokemon_id or "_PRIMAL" in pokemon_id):
return False
@ -121,17 +135,19 @@ class Calculator:
return ("MOVE_NONE", "MOVE_NONE")
return min(movesets, key=lambda moveset: self._ase(moveset_results_by_tier, only=moveset))
def _ase(self, moveset_results_by_tier: dict[str, list[MovesetResult]], only=None) -> float:
try:
def _ase(
self, moveset_results_by_tier: dict[str, list[MovesetResult]], only: tuple[str, str] | None = None
) -> float:
return (
0.15 * self._average_estimator(moveset_results_by_tier["RAID_LEVEL_3"], only)
+ 0.50 * self._average_estimator(moveset_results_by_tier["RAID_LEVEL_5"], only)
+ 0.35 * self._average_estimator(moveset_results_by_tier["RAID_LEVEL_MEGA"], only)
)
except ZeroDivisionError:
return float("inf")
def _average_estimator(self, moveset_results: list[MovesetResult], only: tuple[str, str] | None = None) -> float:
if only:
moveset_results = [m for m in moveset_results if m.fast_move == only[0] and m.charged_move == only[1]]
try:
return sum(moveset.estimator for moveset in moveset_results) / len(moveset_results)
except ZeroDivisionError:
return float("inf")

View File

@ -5,31 +5,47 @@
# https://opensource.org/licenses/MIT.
import argparse
import logging
import operator
import sys
from functools import reduce
from rich.logging import RichHandler
from pogo_scaled_estimators._version import version
from pogo_scaled_estimators.calculator import Calculator, Filter
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("--no-legacy", dest="filters", action="append_const", const=Filter.DISALLOW_LEGACY_MOVES)
_ = parser.add_argument("--no-mega", dest="filters", action="append_const", const=Filter.DISALLOW_MEGA_POKEMON)
_ = parser.add_argument("--no-shadow", dest="filters", action="append_const", const=Filter.DISALLOW_SHADOW_POKEMON)
_ = parser.add_argument(
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("--no-legacy", dest="filters", action="append_const", const=Filter.DISALLOW_LEGACY_MOVES)
parser.add_argument("--no-mega", dest="filters", action="append_const", const=Filter.DISALLOW_MEGA_POKEMON)
parser.add_argument("--no-shadow", dest="filters", action="append_const", const=Filter.DISALLOW_SHADOW_POKEMON)
parser.add_argument(
"--no-legendary", dest="filters", action="append_const", const=Filter.DISALLOW_LEGENDARY_POKEMON
)
_ = parser.add_argument("--version", action="version", version=version)
parser.add_argument(
"-v", "--verbose", action="store_const", dest="log_level", const=logging.DEBUG, default=logging.WARNING
)
parser.add_argument("--version", action="version", version=version)
args = parser.parse_args()
logging.basicConfig(level=args.log_level, handlers=[RichHandler()])
log = logging.getLogger()
requests_log = logging.getLogger("urllib3")
requests_log.setLevel(args.log_level)
requests_log.propagate = True
filters = reduce(operator.or_, args.filters or [], Filter.NO_FILTER)
try:
calculator = Calculator(args.type, filters)
calculator.calculate(level=args.level, party=args.party)
except Exception:
log.exception("Could not calculate ASE.")
if __name__ == "__main__":

View File

@ -7,10 +7,12 @@
import urllib.parse
from dataclasses import dataclass
from functools import cached_property
from typing import cast, final
from typing import NotRequired, TypedDict, cast, final
import requests_cache
from pogo_scaled_estimators.typing import PokemonType
BASE_URL = "https://fight.pokebattler.com"
WEAKNESS = 1.6
DOUBLE_WEAKNESS = WEAKNESS * WEAKNESS
@ -27,7 +29,7 @@ class Raid:
@dataclass
class Move:
move_id: str
typing: str
typing: PokemonType
@dataclass
@ -36,37 +38,58 @@ class MovesetResult:
charged_move: str
estimator: float
def scale(self, factor: float):
def scale(self, factor: float) -> "MovesetResult":
return MovesetResult(self.fast_move, self.charged_move, self.estimator / factor)
class PokebattlerMove(TypedDict):
moveId: str
type: PokemonType
class PokebattlerPokemon(TypedDict):
pokemonId: str
type: PokemonType
type2: NotRequired[PokemonType]
quickMoves: list[str]
cinematicMoves: list[str]
movesets: list[dict[str, str]]
class PokebattlerRaid(TypedDict):
pokemonId: str
class PokebattlerRaidTierInfo(TypedDict):
guessTier: str
class PokebattlerRaidTier(TypedDict):
tier: str
info: PokebattlerRaidTierInfo
raids: list[PokebattlerRaid]
@final
class PokebattlerProxy:
def __init__(self):
self._cached_session = requests_cache.CachedSession("pokebatter_cache", cache_control=True)
self._pokemon: dict | None = None
self._raids: dict | None = None
self._resists: dict | None = None
@property
def cached_session(self):
return self._cached_session
def __init__(self, log_level="INFO"):
self._cached_session = requests_cache.CachedSession("pokebatter_cache", cache_control=True, use_cache_dir=True)
@cached_property
def moves(self) -> dict:
return self.cached_session.get(f"{BASE_URL}/moves").json()["move"]
def moves(self) -> list[PokebattlerMove]:
return self._cached_session.get(f"{BASE_URL}/moves").json()["move"]
@cached_property
def pokemon(self) -> dict:
return self.cached_session.get(f"{BASE_URL}/pokemon").json()["pokemon"]
def pokemon(self) -> list[PokebattlerPokemon]:
return self._cached_session.get(f"{BASE_URL}/pokemon").json()["pokemon"]
@cached_property
def raids(self) -> dict:
return self.cached_session.get(f"{BASE_URL}/raids").json()
def raids(self) -> list[PokebattlerRaidTier]:
return self._cached_session.get(f"{BASE_URL}/raids").json()["tiers"]
@cached_property
def resists(self) -> dict:
return self.cached_session.get(f"{BASE_URL}/resists").json()
def resists(self) -> dict[str, list[float]]:
return self._cached_session.get(f"{BASE_URL}/resists").json()
def simulate(self, raid: Raid) -> dict[str, list[MovesetResult]]:
query_string = {
@ -85,7 +108,8 @@ class PokebattlerProxy:
url = f"{BASE_URL}/raids/defenders/{raid.defender}/levels/{raid.tier}/attackers/levels/{raid.level}/strategies/CINEMATIC_ATTACK_WHEN_POSSIBLE/DEFENSE_RANDOM_MC?{urllib.parse.urlencode(query_string, doseq=True)}"
response = self._cached_session.get(url)
results: dict[str, list[MovesetResult]] = {}
for attacker in response.json()["attackers"][0]["randomMove"]["defenders"]:
response_json = response.json()
for attacker in response_json["attackers"][0]["randomMove"]["defenders"]:
results[attacker["pokemonId"]] = [
MovesetResult(
attacker_moves["move1"], attacker_moves["move2"], cast(float, attacker_moves["result"]["estimator"])
@ -94,9 +118,9 @@ class PokebattlerProxy:
]
return results
def raid_bosses(self, attacker_types: list[str]) -> dict:
raid_tiers = []
raid_bosses = {}
def raid_bosses(self, attacker_types: list[PokemonType]) -> dict[str, list[str]]:
raid_tiers: list[str] = []
raid_bosses: dict[str, list[str]] = {}
for raid_level in ["3", "5", "MEGA", "MEGA_5", "ULTRA_BEAST"]:
tier = f"RAID_LEVEL_{raid_level}"
@ -109,21 +133,12 @@ class PokebattlerProxy:
)
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"]):
for tier in filter(lambda tier: tier["tier"] in raid_tiers, self.raids):
for boss in (raid["pokemonId"] for raid in tier["raids"]):
if boss.endswith("_FORM"):
continue
boss_pokemon = next(filter(lambda mon: mon["pokemonId"] == boss, self.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: PokebattlerPokemon = next(filter(lambda mon: mon["pokemonId"] == boss, self.pokemon))
boss_types: tuple[PokemonType, PokemonType] = (
boss_pokemon["type"],
boss_pokemon.get("type2", "POKEMON_TYPE_NONE"),
)
@ -132,7 +147,7 @@ class PokebattlerProxy:
return raid_bosses
def _is_weak(self, attacker_type: str, defender_types: tuple[str, str]) -> bool:
def _is_weak(self, attacker_type: PokemonType, defender_types: tuple[PokemonType, PokemonType]) -> bool:
pokemon_types = list(self.resists.keys())
defender_type_indices = (
pokemon_types.index(defender_types[0]),
@ -156,8 +171,8 @@ class PokebattlerProxy:
return False
def with_charged_moves(self, attacker_types: list[str]) -> list[str]:
charged_moves = [
def with_charged_moves(self, attacker_types: list[PokemonType]) -> list[str]:
charged_moves: list[str] = [
move["moveId"]
for move in self.moves
if "moveId" in move and "type" in move and move["type"] in attacker_types
@ -168,10 +183,10 @@ class PokebattlerProxy:
if any(moveset["cinematicMove"] in charged_moves for moveset in mon["movesets"])
]
def find_pokemon(self, name: str) -> dict:
def find_pokemon(self, name: str) -> PokebattlerPokemon:
return next(filter(lambda mon: mon["pokemonId"] == name, self.pokemon))
def pokemon_type(self, name: str) -> str:
def pokemon_type(self, name: str) -> PokemonType:
return self.find_pokemon(name)["type"]
def find_move(self, move_id: str) -> Move:

View File

@ -0,0 +1,29 @@
# 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 typing import Literal
PokemonType = Literal[
"POKEMON_TYPE_NONE",
"POKEMON_TYPE_BUG",
"POKEMON_TYPE_DARK",
"POKEMON_TYPE_DRAGON",
"POKEMON_TYPE_ELECTRIC",
"POKEMON_TYPE_FAIRY",
"POKEMON_TYPE_FIGHTING",
"POKEMON_TYPE_FIRE",
"POKEMON_TYPE_FLYING",
"POKEMON_TYPE_GHOST",
"POKEMON_TYPE_GRASS",
"POKEMON_TYPE_GROUND",
"POKEMON_TYPE_ICE",
"POKEMON_TYPE_NORMAL",
"POKEMON_TYPE_POISON",
"POKEMON_TYPE_PSYCHIC",
"POKEMON_TYPE_ROCK",
"POKEMON_TYPE_STEEL",
"POKEMON_TYPE_WATER",
]

View File

@ -4,7 +4,11 @@
# license that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.
POKEMON_TYPE_COLORS = {
from typing import Final
from pogo_scaled_estimators.typing import PokemonType
POKEMON_TYPE_COLORS: Final[dict[PokemonType, str]] = {
"POKEMON_TYPE_BUG": "green_yellow",
"POKEMON_TYPE_DARK": "bright_black",
"POKEMON_TYPE_DRAGON": "dodger_blue2",
@ -27,7 +31,7 @@ POKEMON_TYPE_COLORS = {
MINIMUM_SPECIAL_NAME_PARTS = 2
def format_pokemon_name(name: str, pokemon_type: str | None = None):
def format_pokemon_name(name: str, pokemon_type: PokemonType | None = None):
parts = [part.capitalize() for part in name.split("_")]
if parts[-1] == "Mega" or parts[-1] == "Primal":
parts = [parts[-1]] + parts[:-1]
@ -41,7 +45,7 @@ def format_pokemon_name(name: str, pokemon_type: str | None = None):
return formatted_name
def format_move_name(name, move_type: str | None = None):
def format_move_name(name: str, move_type: PokemonType | None = None):
parts = [part.capitalize() for part in name.split("_")]
if parts[-1] == "Fast":
parts = parts[:-1]

5
tests/__init__.py Normal file
View File

@ -0,0 +1,5 @@
# 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.

View File

@ -0,0 +1,55 @@
# 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 unittest.mock
import pytest
import requests
from pogo_scaled_estimators.pokebattler_proxy import MovesetResult, PokebattlerProxy, Raid
class MockSession(requests.Session):
def __init__(self, *args, **kwargs):
kwargs.pop("cache_control", None)
kwargs.pop("use_cache_dir", None)
super().__init__(*[], **kwargs)
@pytest.fixture(autouse=True)
def _disable_requests_cache():
"""Replace CachedSession with a regular Session for all test functions"""
with unittest.mock.patch("requests_cache.CachedSession", MockSession):
yield
def test_simulate(requests_mock):
requests_mock.get(
"https://fight.pokebattler.com/raids/defenders/MEWTWO_SHADOW_FORM/levels/RAID_LEVEL_5_SHADOW/attackers/levels/40/strategies/CINEMATIC_ATTACK_WHEN_POSSIBLE/DEFENSE_RANDOM_MC",
json={
"attackers": [
{
"randomMove": {
"move1": "RANDOM",
"move2": "RANDOM",
"defenders": [
{
"pokemonId": "BIDOOF",
"byMove": [
{"move1": "TACKLE_FAST", "move2": "HYPER_FANG", "result": {"estimator": 5.4321}},
],
"cp": 721,
}
],
}
}
]
},
)
pokebattler_proxy = PokebattlerProxy()
raid = Raid("RAID_LEVEL_5_SHADOW", "MEWTWO_SHADOW_FORM")
results = pokebattler_proxy.simulate(raid)
assert results == {"BIDOOF": [MovesetResult("TACKLE_FAST", "HYPER_FANG", 5.4321)]}