Source code for arez.mixins

from __future__ import annotations

from math import nan, floor
from functools import wraps
from datetime import datetime
from abc import abstractmethod
from typing import (
    Optional, Union, List, Tuple, Generator, Awaitable, TypeVar, Literal, cast, TYPE_CHECKING
)

from . import responses
from .enums import Queue, Region

if TYPE_CHECKING:
    from .items import Device
    from .champion import Champion, Skin
    from .cache import DataCache, CacheEntry
    from .player import PartialPlayer, Player


__all__ = [
    "CacheClient",
    "CacheObject",
    "Expandable",
    "WinLoseMixin",
    "KDAMixin",
    "MatchMixin",
    "MatchPlayerMixin",
]
_A = TypeVar("_A")


class CacheClient:
    """
    Abstract base class that has to be met by most (if not all) objects that interact with the API.

    Provides access to the core of this wrapper, that is the `.request` method
    and the cache system.
    """
    def __init__(self, api: DataCache):
        self._api = api


[docs]class CacheObject: """ Base class representing objects that can be returned from the data cache. You will sometimes find these on objects returned from the API, when the cache was either incomplete or disabled. Attributes ---------- id : int The object's ID.\n Defaults to ``0`` if not set. name : str The object's name.\n Defaults to ``Unknown`` if not set. """ def __init__(self, *, id: int = 0, name: str = "Unknown"): self._id: int = id self._name: str = name self._hash: Optional[int] = None @property def id(self) -> int: return self._id @property def name(self) -> str: return self._name def __repr__(self) -> str: return f"{self.__class__.__name__}: {self._name}({self._id})" def __eq__(self, other: object) -> bool: if isinstance(other, self.__class__): if self._id != 0 and other._id != 0: return self._id == other._id elif self._name != "Unknown" and other._name != "Unknown": return self._name == other._name return NotImplemented def __hash__(self) -> int: if self._hash is None: self._hash = hash((self.__class__.__name__, self._name, self._id)) return self._hash
class Expandable(Awaitable[_A]): """ An abstract class that can be used to make partial objects "expandable" to their full version. Subclasses should overwrite the `_expand` method with proper implementation, returning the full expanded object. """ # Subclasses will have their `_expand` method doc linked as the `__await__` doc. def __init_subclass__(cls): # Create a new await method # Copy over the docstring and annotations @wraps(cls._expand) def __await__(self: Expandable[_A]): return self._expand().__await__() # Attach the method to the subclass setattr(cls, "__await__", __await__) # solely to satisfy MyPy def __await__(self) -> Generator[_A, None, _A]: raise NotImplementedError @abstractmethod async def _expand(self) -> _A: raise NotImplementedError class WinLoseMixin: """ Represents player's wins and losses. Contains useful helper attributes. Attributes ---------- wins : int The amount of wins. losses : int The amount of losses. """ def __init__(self, *, wins: int, losses: int): self.wins = wins self.losses = losses @property def matches_played(self) -> int: """ The amount of matches played. This is just ``wins + losses``. :type: int """ return self.wins + self.losses @property def winrate(self) -> float: """ The calculated winrate as a fraction.\n `nan` is returned if there was no matches played. :type: float """ return self.wins / self.matches_played if self.matches_played > 0 else nan @property def winrate_text(self) -> str: """ The calculated winrate as a percentage string of up to 3 decimal places accuracy.\n The format is: ``"48.213%"``\n ``"N/A"`` is returned if there was no matches played. :type: str """ return f"{round(self.winrate * 100, 3)}%" if self.matches_played > 0 else "N/A" class KDAMixin: """ Represents player's kills, deaths and assists. Contains useful helper attributes. Attributes ---------- kills : int The amount of kills. deaths : int The amount of deaths. assists : int The amount of assists. """ def __init__(self, *, kills: int, deaths: int, assists: int): self.kills: int = kills self.deaths: int = deaths self.assists: int = assists @property def kda(self) -> float: """ The calculated KDA.\n The formula is: ``(kills + assists / 2) / deaths``.\n `nan` is returned if there was no deaths. :type: float """ return (self.kills + self.assists / 2) / self.deaths if self.deaths > 0 else nan @property def kda2(self) -> float: """ The calculated KDA.\n The formula is: ``(kills + assists / 2) / max(deaths, 1)``, treating 0 and 1 deaths the same, meaning this will never return `nan`. :type: float """ return (self.kills + self.assists / 2) / max(self.deaths, 1) @property def df(self) -> int: """ The Dominance Factor.\n The formula is: ``kills * 2 + deaths * -3 + assists``.\n The value signifies how "useful" the person was to the team overall. Best used when scaled and compared between team members in a match (allied and enemy). :type: int """ return self.kills * 2 + self.deaths * -3 + self.assists @property def kda_text(self) -> str: """ Kills, deaths and assists as a slash-delimited string.\n The format is: ``kills/deaths/assists``, or ``1/2/3``. :type: str """ return f"{self.kills}/{self.deaths}/{self.assists}" class MatchMixin: """ Represents basic information about a match. Attributes ---------- id : int The match ID. queue : Queue The queue this match was played in. region : Region The region this match was played in. timestamp : datetime.datetime A timestamp of when this match happened. duration : Duration The duration of the match. map_name : str The name of the map played. score : Tuple[int, int] The match's ending score. winning_team : Literal[1, 2] The winning team of this match. """ def __init__( self, match_data: Union[responses.MatchPlayerObject, responses.HistoryMatchObject] ): self.id: int = match_data["Match"] if "hasReplay" in match_data: # we're in a full match data match_data = cast(responses.MatchPlayerObject, match_data) stamp = match_data["Entry_Datetime"] queue = match_data["match_queue_id"] score = (match_data["Team1Score"], match_data["Team2Score"]) else: # we're in a partial (player history) match data match_data = cast(responses.HistoryMatchObject, match_data) stamp = match_data["Match_Time"] queue = match_data["Match_Queue_Id"] my_team = match_data["TaskForce"] other_team = 1 if my_team == 2 else 2 score = ( match_data[f"Team{my_team}Score"], # type: ignore[misc] match_data[f"Team{other_team}Score"], # type: ignore[misc] ) self.queue = Queue(queue, _return_default=True) self.region = Region(match_data["Region"], _return_default=True) from .utils import _convert_timestamp, _convert_map_name, Duration # circular imports self.timestamp: datetime = _convert_timestamp(stamp) self.duration = Duration(seconds=match_data["Time_In_Match_Seconds"]) self.map_name: str = _convert_map_name(match_data["Map_Game"]) if self.queue.is_tdm(): # Score correction for TDM matches score = (score[0] + 36, score[1] + 36) self.score: Tuple[int, int] = score self.winning_team: Literal[1, 2] = match_data["Winning_TaskForce"] class MatchPlayerMixin(KDAMixin, CacheClient): """ Represents basic information about a player in a match. Attributes ---------- player : Union[PartialPlayer, Player] The player who participated in this match.\n This is usually a new partial player object.\n All attributes, Name, ID and Platform, should be present. champion : Union[Champion, CacheObject] The champion used by the player in this match.\n With incomplete cache, this will be a `CacheObject` with the name and ID set. loadout : MatchLoadout The loadout used by the player in this match. items : List[MatchItem] A list of items bought by the player during this match. credits : int The amount of credits earned this match. experience : int The base amount of experience gained from this match. kills : int The amount of player kills. deaths : int The amount of deaths. assists : int The amount of assists. damage_done : int The amount of damage dealt. damage_bot : int The amount of damage done by the player's bot after they disconnected. damage_taken : int The amount of damage taken. damage_mitigated : int The amount of damage mitigated (shielding). healing_done : int The amount of healing done to other players. healing_bot : int The amount of healing done by the player's bot after they disconnected. healing_self : int The amount of healing done to self (self-sustain). objective_time : int The amount of objective time the player got, in seconds. multikill_max : int The maximum multikill player did during the match. skin : Union[Skin, CacheObject] The skin the player had equipped for this match.\n With incomplete cache, this will be a `CacheObject` with the name and ID set. team_number : Literal[1, 2] The team this player belongs to. team_score : int The score of the player's team. winner : bool `True` if the player won this match, `False` otherwise. """ def __init__( self, player: Union[Player, PartialPlayer], cache_entry: Optional[CacheEntry], match_data: Union[responses.MatchPlayerObject, responses.HistoryMatchObject], ): CacheClient.__init__(self, player._api) if "hasReplay" in match_data: # we're in a full match data match_data = cast(responses.MatchPlayerObject, match_data) creds = match_data["Gold_Earned"] kills = match_data["Kills_Player"] damage = match_data["Damage_Player"] champion_name = match_data["Reference_Name"] else: # we're in a partial (player history) match data match_data = cast(responses.HistoryMatchObject, match_data) creds = match_data["Gold"] kills = match_data["Kills"] damage = match_data["Damage"] champion_name = match_data["Champion"] KDAMixin.__init__( self, kills=kills, deaths=match_data["Deaths"], assists=match_data["Assists"] ) # Champion champion_id = match_data["ChampionId"] champion: Optional[Union[Champion, CacheObject]] = None if cache_entry is not None: champion = cache_entry.champions.get(champion_id) if champion is None: champion = CacheObject(id=champion_id, name=champion_name) self.champion: Union[Champion, CacheObject] = champion # Skin skin_id = match_data["SkinId"] skin: Optional[Union[Skin, CacheObject]] = None if cache_entry is not None: skin = cache_entry.skins.get(skin_id) if skin is None: # pragma: no cover skin = CacheObject(id=skin_id, name=match_data["Skin"]) self.skin: Union[Skin, CacheObject] = skin # Other self.player: Union[Player, PartialPlayer] = player self.credits: int = creds self.damage_done: int = damage self.damage_bot: int = match_data["Damage_Bot"] self.damage_taken: int = match_data["Damage_Taken"] self.damage_mitigated: int = match_data["Damage_Mitigated"] self.healing_done: int = match_data["Healing"] self.healing_bot: int = match_data["Healing_Bot"] self.healing_self: int = match_data["Healing_Player_Self"] self.objective_time: int = match_data["Objective_Assists"] self.multikill_max: int = match_data["Multi_kill_Max"] self.team_number: Literal[1, 2] = match_data["TaskForce"] self.team_score: int = match_data[f"Team{self.team_number}Score"] # type: ignore[misc] self.winner: bool = self.team_number == match_data["Winning_TaskForce"] seconds: int = match_data["Time_In_Match_Seconds"] self.experience: int = floor(seconds * (275/6) + (15000 if self.winner else 0)) from .items import MatchLoadout, MatchItem # cyclic imports self.items: List[MatchItem] = [] for i in range(1, 5): item_id = match_data[f"ActiveId{i}"] # type: ignore[misc] if not item_id: continue item: Optional[Union[Device, CacheObject]] = None if cache_entry is not None: item = cache_entry.items.get(item_id) if item is None: if "hasReplay" in match_data: # we're in a full match data item_name = match_data[f"Item_Active_{i}"] # type: ignore[misc] else: # we're in a partial (player history) match data item_name = match_data[f"Active_{i}"] # type: ignore[misc] item = CacheObject(id=item_id, name=item_name) if "hasReplay" in match_data: # we're in a full match data level = match_data[f"ActiveLevel{i}"] + 1 # type: ignore[misc] else: # we're in a partial (player history) match data level = match_data[f"ActiveLevel{i}"] // 4 + 1 # type: ignore[misc] self.items.append(MatchItem(item, level)) self.loadout = MatchLoadout(cache_entry, match_data) @property def shielding(self) -> int: """ This is an alias for the `damage_mitigated` attribute. :type: int """ return self.damage_mitigated