import functools import arrow import datetime from collections import Counter from typing import List, Dict, Set, Union, Generator from datapipelines import NotFoundError from merakicommons.cache import lazy, lazy_property from merakicommons.container import searchable, SearchableList, SearchableLazyList, SearchableDictionary from .. import configuration from .staticdata import Versions from ..data import Region, Platform, Tier, GameType, GameMode, Queue, Side, Season, Lane, Role, Key, SummonersRiftArea, Tower from .common import CoreData, CoreDataList, CassiopeiaObject, CassiopeiaGhost, CassiopeiaLazyList, provide_default_region, ghost_load_on from ..dto import match as dto from .patch import Patch from .summoner import Summoner from .staticdata.champion import Champion from .staticdata.rune import Rune from .staticdata.summonerspell import SummonerSpell from .staticdata.item import Item from .staticdata.map import Map def load_match_on_attributeerror(method): @functools.wraps(method) def wrapper(self, *args, **kwargs): try: return method(self, *args, **kwargs) except AttributeError: # teamId # The match has only partially loaded this participant and it doesn't have all it's data, so load the full match match = getattr(self, "_{}__match".format(self.__class__.__name__)) if not match._Ghost__is_loaded(MatchData): match.__load__(MatchData) match._Ghost__set_loaded(MatchData) if isinstance(self, Participant): old_participant = self elif isinstance(self, ParticipantStats): old_participant = getattr(self, "_{}__participant".format(self.__class__.__name__)) else: raise RuntimeError("Impossible!") for participant in match.participants: if participant.summoner.name == old_participant.summoner.name: if isinstance(self, Participant): self._data[ParticipantData] = participant._data[ParticipantData] elif isinstance(self, ParticipantStats): self._data[ParticipantStatsData] = participant.stats._data[ParticipantStatsData] return method(self, *args, **kwargs) return method(self, *args, **kwargs) return wrapper _staticdata_to_version_mapping = {} def _choose_staticdata_version(match): # If we want to pull the data for the correct version, we need to pull the entire match data. # However, we can use the creation date (which comes with a matchref) and get the ~ patch and therefore extract the version from the patch. if configuration.settings.version_from_match is None or configuration.settings.version_from_match == "latest": return None # Rather than pick the latest version here, let the obj handle it so it knows which endpoint within the realms data to use if configuration.settings.version_from_match == "version" or hasattr(match._data[MatchData], "version"): majorminor = match.patch.major + "." + match.patch.minor elif configuration.settings.version_from_match == "patch": patch = Patch.from_date(match.creation, region=match.region) majorminor = patch.majorminor else: raise ValueError("Unknown value for setting `version_from_match`:", configuration.settings.version_from_match) try: version = _staticdata_to_version_mapping[majorminor] except KeyError: if int(match.patch.major) >= 10: versions = Versions(region=match.region) # use the first major.minor.x matching occurrence from the versions list version = next(x for x in versions if ".".join(x.split(".")[:2]) == majorminor) else: version = majorminor + ".1" # use major.minor.1 _staticdata_to_version_mapping[majorminor] = version return version ############## # Data Types # ############## class MatchListData(CoreDataList): _dto_type = dto.MatchListDto _renamed = {"champion": "championIds", "queue": "queues", "season": "seasons"} class PositionData(CoreData): _renamed = {} class EventData(CoreData): _renamed = {"eventType": "type", "teamId": "side", "pointCaptured": "capturedPoint", "assistingParticipantIds": "assistingParticipants", "skillSlot": "skill"} def __call__(self, **kwargs): if "position" in kwargs: self.position = PositionData(**kwargs.pop("position")) super().__call__(**kwargs) return self class ParticipantFrameData(CoreData): _renamed = {"totalGold": "goldEarned", "minionsKilled": "creepScore", "xp": "experience", "jungleMinionsKilled": "neutralMinionsKilled"} def __call__(self, **kwargs): if "position" in kwargs: self.position = PositionData(**kwargs.pop("position")) super().__call__(**kwargs) return self class FrameData(CoreData): _renamed = {} def __call__(self, **kwargs): if "events" in kwargs: self.events = [EventData(**event) for event in kwargs.pop("events")] if "participantFrames" in kwargs: self.participantFrames = {int(key): ParticipantFrameData(**pframe) for key, pframe in kwargs.pop("participantFrames").items()} super().__call__(**kwargs) return self class TimelineData(CoreData): _dto_type = dto.TimelineDto _renamed = {"matchId": "id", "frameInterval": "frame_interval"} def __call__(self, **kwargs): if "frames" in kwargs: self.frames = [FrameData(**frame) for frame in kwargs.pop("frames")] super().__call__(**kwargs) return self class ParticipantTimelineData(CoreData): _renamed = {"participantId": "id"} def __call__(self, **kwargs): #timeline.setCreepScore(getStatTotals(item.getCreepsPerMinDeltas(), durationInSeconds)); #timeline.setCreepScoreDifference(getStatTotals(item.getCsDiffPerMinDeltas(), durationInSeconds)); #timeline.setDamageTaken(getStatTotals(item.getDamageTakenPerMinDeltas(), durationInSeconds)); #timeline.setDamageTakenDifference(getStatTotals(item.getDamageTakenDiffPerMinDeltas(), durationInSeconds)); #timeline.setExperience(getStatTotals(item.getXpPerMinDeltas(), durationInSeconds)); #timeline.setExperienceDifference(getStatTotals(item.getXpDiffPerMinDeltas(), durationInSeconds)); super().__call__(**kwargs) return self class ParticipantStatsData(CoreData): _renamed = {} class ParticipantData(CoreData): _renamed = {"participantId": "id", "spell1Id": "summonerSpellDId", "spell2Id": "summonerSpellFId", "highestAchievedSeasonTier": "rankLastSeason", "bot": "isBot", "profileIcon": "profileIconId"} def __call__(self, **kwargs): if "stats" in kwargs: stats = kwargs.pop("stats") if "perk0" in stats: # Assume all the rest are too self.runes = { stats.pop("perk0"): [stats.pop("perk0Var1"), stats.pop("perk0Var2"), stats.pop("perk0Var3")], stats.pop("perk1"): [stats.pop("perk1Var1"), stats.pop("perk1Var2"), stats.pop("perk1Var3")], stats.pop("perk2"): [stats.pop("perk2Var1"), stats.pop("perk2Var2"), stats.pop("perk2Var3")], stats.pop("perk3"): [stats.pop("perk3Var1"), stats.pop("perk3Var2"), stats.pop("perk3Var3")], stats.pop("perk4"): [stats.pop("perk4Var1"), stats.pop("perk4Var2"), stats.pop("perk4Var3")], stats.pop("perk5"): [stats.pop("perk5Var1"), stats.pop("perk5Var2"), stats.pop("perk5Var3")], } self.stat_runes = [ stats.pop("statPerk0", None), stats.pop("statPerk1", None), stats.pop("statPerk2", None), ] stats.pop("runes", None) self.stats = ParticipantStatsData(**stats) if "timeline" in kwargs: self.timeline = ParticipantTimelineData(**kwargs.pop("timeline")) if "teamId" in kwargs: self.side = Side(kwargs.pop("teamId")) if "player" in kwargs: for key, value in kwargs.pop("player").items(): kwargs[key] = value super().__call__(**kwargs) return self class TeamData(CoreData): _renamed = {"dominionVictoryScore": "dominionScore", "firstBaron": "firstBaronKiller", "firstBlood": "firstBloodKiller", "firstDragon": "firstDragonKiller", "firstInhibitor": "firstInhibitorKiller", "firstRiftHerald": "firstRiftHeraldKiller", "firstTower": "firstTowerKiller"} def __call__(self, **kwargs): if "bans" in kwargs: self.bans = [ban["championId"] for ban in kwargs.pop("bans")] if "win" in kwargs: self.isWinner = kwargs.pop("win") != "Fail" if "teamId" in kwargs: self.side = Side(kwargs.pop("teamId")) super().__call__(**kwargs) return self class MatchReferenceData(CoreData): _renamed = {"account_id": "accountId", "gameId": "id", "champion": "championId", "teamId": "side", "platformId": "platform"} def __call__(self, **kwargs): if "timestamp" in kwargs: self.creation = arrow.get(kwargs.pop("timestamp") / 1000) # Set lane and role if they are missing from the data if "lane" not in kwargs: kwargs["lane"] = None if "role" not in kwargs: kwargs["role"] = None super().__call__(**kwargs) return self class MatchData(CoreData): _dto_type = dto.MatchDto _renamed = {"gameId": "id", "gameVersion": "version", "gameMode": "mode", "gameType": "type", "queueId": "queue", "seasonId": "season"} def __call__(self, **kwargs): if "gameCreation" in kwargs: self.creation = arrow.get(kwargs["gameCreation"] / 1000) if "gameDuration" in kwargs: self.duration = datetime.timedelta(seconds=kwargs["gameDuration"]) if "participants" in kwargs: good_participant_ids = [] for participant in kwargs["participants"]: for pid in kwargs["participantIdentities"]: if participant["participantId"] == pid["participantId"] and "player" in pid: good_participant_ids.append(participant["participantId"]) participant["player"] = pid["player"] break self.privateGame = False if len(good_participant_ids) == 0: self.privateGame = True # For each participant id we found that has both a participant and an identity, add it to the match data's participants self.participants = [] for participant in kwargs["participants"]: if self.privateGame or participant["participantId"] in good_participant_ids: participant = ParticipantData(**participant) self.participants.append(participant) assert len(self.participants) == len(kwargs["participants"]) kwargs.pop("participants") kwargs.pop("participantIdentities") if "teams" in kwargs: self.teams = [] for team in kwargs.pop("teams"): team_side = Side(team["teamId"]) participants = [] for participant in self.participants: if participant.side is team_side: participants.append(participant) self.teams.append(TeamData(**team, participants=participants)) super().__call__(**kwargs) return self ############## # Core Types # ############## class MatchHistory(CassiopeiaLazyList): # type: List[Match] """The match history for a summoner. By default, this will return the entire match history.""" _data_types = {MatchListData} def __init__(self, *, summoner: Summoner, begin_index: int = None, end_index: int = None, begin_time: arrow.Arrow = None, end_time: arrow.Arrow = None, queues: Set[Queue] = None, seasons: Set[Season] = None, champions: Set[Champion] = None): assert end_index is None or end_index > begin_index if begin_time is not None and end_time is not None and begin_time > end_time: raise ValueError("`end_time` should be greater than `begin_time`") kwargs = {"region": summoner.region} kwargs["queues"] = queues or [] kwargs["seasons"] = seasons or [] champions = champions or [] kwargs["championIds"] = [champion.id if isinstance(champion, Champion) else champion for champion in champions] kwargs["begin_index"] = begin_index kwargs["end_index"] = end_index if begin_time is not None and not isinstance(begin_time, (int, float)): begin_time = begin_time.timestamp * 1000 kwargs["begin_time"] = begin_time if end_time is not None and not isinstance(end_time, (int, float)): end_time = end_time.timestamp * 1000 kwargs["end_time"] = end_time assert isinstance(summoner, Summoner) self.__account_id_callable = lambda: summoner.account_id self.__summoner = summoner CassiopeiaObject.__init__(self, **kwargs) @classmethod def __get_query_from_kwargs__(cls, *, summoner: Summoner, begin_index: int = None, end_index: int = None, begin_time: arrow.Arrow = None, end_time: arrow.Arrow = None, queues: Set[Queue] = None, seasons: Set[Season] = None, champions: Set[Champion] = None): assert isinstance(summoner, Summoner) query = {"region": summoner.region} query["accountId"] = summoner.account_id if begin_index is not None: query["beginIndex"] = begin_index if end_index is not None: query["endIndex"] = end_index if begin_time is not None: if isinstance(begin_time, arrow.Arrow): begin_time = begin_time.timestamp * 1000 query["beginTime"] = begin_time if end_time is not None: if isinstance(end_time, arrow.Arrow): end_time = end_time.timestamp * 1000 query["endTime"] = end_time if queues is not None: query["queues"] = queues if seasons is not None: query["seasons"] = seasons if champions is not None: champions = [champion.id if isinstance(champion, Champion) else champion for champion in champions] query["champion.ids"] = champions return query @classmethod def from_generator(cls, generator: Generator, summoner: Summoner, **kwargs): self = cls.__new__(cls) kwargs["summoner"] = summoner self.__summoner = summoner CassiopeiaLazyList.__init__(self, generator=generator, **kwargs) return self def __call__(self, **kwargs) -> "MatchHistory": # summoner, begin_index, end_index, begin_time, end_time, queues, seasons, champions kwargs.setdefault("summoner", self.__summoner) kwargs.setdefault("begin_index", self.begin_index) kwargs.setdefault("end_index", self.end_index) kwargs.setdefault("begin_time", self.begin_time) kwargs.setdefault("end_time", self.end_time) kwargs.setdefault("queues", self.queues) kwargs.setdefault("seasons", self.seasons) kwargs.setdefault("champions", self.champions) return MatchHistory(**kwargs) @property def _account_id(self): try: return self.__account_id except AttributeError: self.__account_id = self.__account_id_callable() del self.__account_id_callable # This releases the reference to the summoner return self.__account_id @lazy_property def region(self) -> Region: return Region(self._data[MatchListData].region) @lazy_property def platform(self) -> Platform: return self.region.platform @lazy_property def queues(self) -> Set[Queue]: return {Queue(q) for q in self._data[MatchListData].queues} @lazy_property def seasons(self) -> Set[Season]: return {Season(s) for s in self._data[MatchListData].seasons} @lazy_property def champions(self) -> Set[Champion]: return {Champion(id=cid, region=self.region) for cid in self._data[MatchListData].championIds} @property def begin_index(self) -> Union[int, None]: try: return self._data[MatchListData].beginIndex except AttributeError: return None @property def end_index(self) -> Union[int, None]: try: return self._data[MatchListData].endIndex except AttributeError: return None @property def begin_time(self) -> arrow.Arrow: time = self._data[MatchListData].begin_time if time is not None: return arrow.get(time / 1000) @property def end_time(self) -> arrow.Arrow: time = self._data[MatchListData].end_time if time is not None: return arrow.get(time / 1000) class Position(CassiopeiaObject): _data_types = {PositionData} def __str__(self): return "<Position ({}, {})>".format(self.x, self.y) @property def x(self) -> int: return self._data[PositionData].x @property def y(self) -> int: return self._data[PositionData].y @property def location(self) -> SummonersRiftArea: return SummonersRiftArea.from_position(self) @searchable({str: ["type", "tower_type", "ascended_type", "ward_type", "monster_type", "type", "monster_sub_type", "lane_type", "building_type"]}) class Event(CassiopeiaObject): _data_types = {EventData} @property def tower_type(self) -> Tower: return Tower(self._data[EventData].towerType) @property def side(self) -> Side: return Side(self._data[EventData].side) @property def ascended_type(self) -> str: return self._data[EventData].ascendedType @property def killer_id(self) -> int: return self._data[EventData].killerId @property def level_up_type(self) -> str: return self._data[EventData].levelUpType @property def captured_point(self) -> str: return self._data[EventData].capturedPoint @property def assisting_participants(self) -> List[int]: return self._data[EventData].assistingParticipants @property def ward_type(self) -> str: return self._data[EventData].wardType @property def monster_type(self) -> str: return self._data[EventData].monsterType @property def type(self) -> List[str]: """Legal values: CHAMPION_KILL, WARD_PLACED, WARD_KILL, BUILDING_KILL, ELITE_MONSTER_KILL, ITEM_PURCHASED, ITEM_SOLD, ITEM_DESTROYED, ITEM_UNDO, SKILL_LEVEL_UP, ASCENDED_EVENT, CAPTURE_POINT, PORO_KING_SUMMON""" return self._data[EventData].type @property def skill(self) -> int: return self._data[EventData].skill @property def victim_id(self) -> int: return self._data[EventData].victimId @property def timestamp(self) -> datetime.timedelta: return datetime.timedelta(seconds=self._data[EventData].timestamp/1000) @property def after_id(self) -> int: return self._data[EventData].afterId @property def monster_sub_type(self) -> str: return self._data[EventData].monsterSubType @property def lane_type(self) -> str: return self._data[EventData].laneType @property def item_id(self) -> int: return self._data[EventData].itemId @property def participant_id(self) -> int: return self._data[EventData].participantId @property def building_type(self) -> str: return self._data[EventData].buildingType @property def creator_id(self) -> int: return self._data[EventData].creatorId @property def position(self) -> Position: return Position.from_data(self._data[EventData].position) @property def before_id(self) -> int: return self._data[EventData].beforeId class ParticipantFrame(CassiopeiaObject): _data_types = {ParticipantFrameData} @property def gold_earned(self) -> int: return self._data[ParticipantFrameData].goldEarned @property def team_score(self) -> int: return self._data[ParticipantFrameData].teamScore @property def participant_id(self) -> int: return self._data[ParticipantFrameData].participantId @property def level(self) -> int: return self._data[ParticipantFrameData].level @property def current_gold(self) -> int: return self._data[ParticipantFrameData].currentGold @property def creep_score(self) -> int: return self._data[ParticipantFrameData].creepScore @property def dominion_score(self) -> int: return self._data[ParticipantFrameData].dominionScore @property def position(self) -> Position: return Position.from_data(self._data[ParticipantFrameData].position) @property def experience(self) -> int: return self._data[ParticipantFrameData].experience @property def neutral_minions_killed(self) -> int: return self._data[ParticipantFrameData].neutralMinionsKilled class Frame(CassiopeiaObject): _data_types = {FrameData} @property def timestamp(self) -> datetime.timedelta: return datetime.timedelta(seconds=self._data[FrameData].timestamp/1000) @property def participant_frames(self) -> Dict[int, ParticipantFrame]: return SearchableDictionary({k: ParticipantFrame.from_data(frame) for k, frame in self._data[FrameData].participantFrames.items()}) @property def events(self) -> List[Event]: return SearchableList([Event.from_data(event) for event in self._data[FrameData].events]) class Timeline(CassiopeiaGhost): _data_types = {TimelineData} @provide_default_region def __init__(self, *, id: int = None, region: Union[Region, str] = None): kwargs = {"region": region, "id": id} super().__init__(**kwargs) def __get_query__(self): return {"region": self.region, "platform": self.platform, "id": self.id} @property def id(self): return self._data[TimelineData].id @property def region(self) -> Region: return Region(self._data[TimelineData].region) @property def platform(self) -> Platform: return self.region.platform @CassiopeiaGhost.property(TimelineData) @ghost_load_on def frames(self) -> List[Frame]: return SearchableList([Frame.from_data(frame) for frame in self._data[TimelineData].frames]) @CassiopeiaGhost.property(TimelineData) @ghost_load_on def frame_interval(self) -> int: return self._data[TimelineData].frame_interval @property def first_tower_fallen(self) -> Event: for frame in self.frames: for event in frame.events: if event.type == "BUILDING_KILL" and event.building_type == "TOWER_BUILDING": return event class ParticipantTimeline(CassiopeiaObject): _data_types = {ParticipantTimelineData} @classmethod def from_data(cls, data: CoreData, match: "Match"): self = super().from_data(data) self.__match = match return self @property def frames(self): these = [] for frame in self.__match.timeline.frames: for pid, pframe in frame.participant_frames.items(): pframe.timestamp = frame.timestamp if pid == self.id: these.append(pframe) return these @property def events(self): my_events = [] timeline = self.__match.timeline for frame in timeline.frames: for event in frame.events: try: if event.participant_id == self.id: my_events.append(event) except AttributeError: pass try: if event.creator_id == self.id: my_events.append(event) except AttributeError: pass try: if event.killer_id == self.id: my_events.append(event) except AttributeError: pass try: if event.victim_id == self.id: my_events.append(event) except AttributeError: pass try: if self.id in event.assisting_participants: my_events.append(event) except AttributeError: pass return SearchableList(my_events) @property def champion_kills(self): return self.events.filter(lambda event: event.type == "CHAMPION_KILL" and event.killer_id == self.id) @property def champion_deaths(self): return self.events.filter(lambda event: event.type == "CHAMPION_KILL" and event.victim_id == self.id) @property def champion_assists(self): return self.events.filter(lambda event: event.type == "CHAMPION_KILL" and self.id in event.assisting_participants) @property def lane(self) -> Lane: return Lane.from_match_naming_scheme(self._data[ParticipantTimelineData].lane) @property def role(self) -> Role: return Role.from_match_naming_scheme(self._data[ParticipantTimelineData].role) @property def id(self) -> int: return self._data[ParticipantTimelineData].id @property def cs_diff_per_min_deltas(self) -> Dict[str, float]: return self._data[ParticipantTimelineData].csDiffPerMinDeltas @property def gold_per_min_deltas(self) -> Dict[str, float]: return self._data[ParticipantTimelineData].goldPerMinDeltas @property def xp_diff_per_min_deltas(self) -> Dict[str, float]: return self._data[ParticipantTimelineData].xpDiffPerMinDeltas @property def creeps_per_min_deltas(self) -> Dict[str, float]: return self._data[ParticipantTimelineData].creepsPerMinDeltas @property def xp_per_min_deltas(self) -> Dict[str, float]: return self._data[ParticipantTimelineData].xpPerMinDeltas @property def damage_taken_per_min_deltas(self) -> Dict[str, float]: return self._data[ParticipantTimelineData].damageTakenPerMinDeltas @property def damage_taken_diff_per_min_deltas(self) -> Dict[str, float]: return self._data[ParticipantTimelineData].damageTakenDiffPerMinDeltas class CumulativeTimeline: def __init__(self, id: int, participant_timeline: ParticipantTimeline): self._id = id self._timeline = participant_timeline def __getitem__(self, time: Union[datetime.timedelta, str]) -> "ParticipantState": if isinstance(time, str): time = time.split(":") time = datetime.timedelta(minutes=int(time[0]), seconds=int(time[1])) state = ParticipantState(id=self._id, time=time, participant_timeline=self._timeline) for event in self._timeline.events: if event.timestamp > time: break state._process_event(event) return state class ParticipantState: """The state of a participant at a given point in the timeline.""" def __init__(self, id: int, time: datetime.timedelta, participant_timeline: ParticipantTimeline): self._id = id self._time = time #self._timeline = participant_timeline # Try to get info from the most recent participant timeline object latest_frame = None for frame in participant_timeline.frames: # Round to the nearest second for the frame timestamp because it's off by a few ms rounded_frame_timestamp = datetime.timedelta(seconds=frame.timestamp.seconds) if rounded_frame_timestamp > self._time: break latest_frame = frame self._latest_frame = latest_frame self._item_state = _ItemState() self._skills = Counter() self._kills = 0 self._deaths = 0 self._assists = 0 self._objectives = 0 self._level = 1 self._processed_events = [] def _process_event(self, event: Event): if "ITEM" in event.type: self._item_state.process_event(event) elif "CHAMPION_KILL" == event.type: if event.killer_id == self._id: self._kills += 1 elif event.victim_id == self._id: self._deaths += 1 else: assert self._id in event.assisting_participants self._assists += 1 elif "SKILL_LEVEL_UP" == event.type: if event.level_up_type == "NORMAL": self._skills[event.skill] += 1 self._level += 1 elif event.type in ("WARD_PLACED", "WARD_KILL"): return elif event.type in ("ELITE_MONSTER_KILL", "BUILDING_KILL"): self._objectives += 1 else: #print(f"Did not process event {event.to_dict()}") pass self._processed_events.append(event) @property def items(self) -> SearchableList: return SearchableList([Item(id=id_, region="NA") for id_ in self._item_state._items]) @property def skills(self) -> Dict[Key, int]: skill_keys = {1: Key.Q, 2: Key.W, 3: Key.E, 4: Key.R} skills = {skill_keys[skill]: level for skill, level in self._skills.items()} return skills @property def kills(self) -> int: return self._kills @property def deaths(self) -> int: return self._deaths @property def assists(self) -> int: return self._assists @property def kda(self) -> float: return (self.kills + self.assists) / (self.deaths or 1) @property def objectives(self) -> int: """Number of objectives assisted in.""" return self._objectives @property def level(self) -> int: return self._level @property def gold_earned(self) -> int: return self._latest_frame.gold_earned @property def team_score(self) -> int: return self._latest_frame.team_score @property def current_gold(self) -> int: return self._latest_frame.current_gold @property def creep_score(self) -> int: return self._latest_frame.creep_score @property def dominion_score(self) -> int: return self._latest_frame.dominion_score @property def position(self) -> Position: # The latest position is either from the latest event or from the participant timeline frame latest_frame_ts = self._latest_frame.timestamp latest_event_with_ts = [(getattr(event, 'timestamp', None), getattr(event, 'position', None)) for event in self._processed_events] latest_event_with_ts = [(ts, p) for ts, p in latest_event_with_ts if ts is not None and p is not None] latest_event_ts = sorted(latest_event_with_ts)[-1] if latest_frame_ts > latest_event_ts[0]: return self._latest_frame.position else: return latest_event_ts[1] @property def experience(self) -> int: return self._latest_frame.experience @property def neutral_minions_killed(self) -> int: return self._latest_frame.neutral_minions_killed class _ItemState: def __init__(self, *args): self._items = [] self._events = [] def __str__(self): return str(self._items) def process_event(self, event): items_to_ignore = (2010, 3599, 3520, 3513, 2422) # 2422 is Slightly Magical Boots... I could figure out how to add those and Biscuits to the inventory based on runes but it would be manual... item_id = getattr(event, 'item_id', getattr(event, 'before_id', None)) assert item_id is not None if item_id in items_to_ignore: return if event.type == "ITEM_PURCHASED": self.add(event.item_id) self._events.append(event) elif event.type == "ITEM_DESTROYED": self.destroy(event.item_id) self._events.append(event) elif event.type == "ITEM_SOLD": self.destroy(event.item_id) self._events.append(event) elif event.type == "ITEM_UNDO": self.undo(event) else: raise ValueError(f"Unexpected event type {event.type}") def add(self, item: int): self._items.append(item) def destroy(self, item: int): self._items.reverse() try: self._items.remove(item) except ValueError as error: if item in (3340, 3364, 2319, 2061, 2062, 2056, 2403, 2419, 3400, 2004, 2058, 3200, 2011, 2423, 2055, 2057, 2424, 2059, 2060, 2013, 2421, 3600): # Something weird can happen with trinkets and klepto items pass else: raise error self._items.reverse() def undo(self, event: Event): assert event.after_id == 0 or event.before_id == 0 item_id = event.before_id or event.after_id prev = None while prev is None or prev.item_id != item_id: prev = self._events.pop() if prev.type == "ITEM_PURCHASED": self.destroy(prev.item_id) elif prev.type == "ITEM_DESTROYED": self.add(prev.item_id) elif prev.type == "ITEM_SOLD": self.add(prev.item_id) else: raise TypeError(f"Unexpected event type {prev.type}") @searchable({str: ["items"], Item: ["items"]}) class ParticipantStats(CassiopeiaObject): _data_types = {ParticipantStatsData} @classmethod def from_data(cls, data: ParticipantStatsData, match: "Match", participant: "Participant"): self = super().from_data(data) self.__match = match self.__participant = participant return self @property @load_match_on_attributeerror def kda(self) -> float: return (self.kills + self.assists) / (self.deaths or 1) @property @load_match_on_attributeerror def physical_damage_dealt(self) -> int: return self._data[ParticipantStatsData].physicalDamageDealt @property @load_match_on_attributeerror def magic_damage_dealt(self) -> int: return self._data[ParticipantStatsData].magicDamageDealt @property @load_match_on_attributeerror def neutral_minions_killed_team_jungle(self) -> int: return self._data[ParticipantStatsData].neutralMinionsKilledTeamJungle @property @load_match_on_attributeerror def total_player_score(self) -> int: return self._data[ParticipantStatsData].totalPlayerScore @property @load_match_on_attributeerror def deaths(self) -> int: return self._data[ParticipantStatsData].deaths @property @load_match_on_attributeerror def win(self) -> bool: return self._data[ParticipantStatsData].win @property @load_match_on_attributeerror def neutral_minions_killed_enemy_jungle(self) -> int: return self._data[ParticipantStatsData].neutralMinionsKilledEnemyJungle @property @load_match_on_attributeerror def altars_captured(self) -> int: return self._data[ParticipantStatsData].altarsCaptured @property @load_match_on_attributeerror def largest_critical_strike(self) -> int: return self._data[ParticipantStatsData].largestCriticalStrike @property @load_match_on_attributeerror def total_damage_dealt(self) -> int: return self._data[ParticipantStatsData].totalDamageDealt @property @load_match_on_attributeerror def magic_damage_dealt_to_champions(self) -> int: return self._data[ParticipantStatsData].magicDamageDealtToChampions @property @load_match_on_attributeerror def vision_wards_bought_in_game(self) -> int: return self._data[ParticipantStatsData].visionWardsBoughtInGame @property @load_match_on_attributeerror def damage_dealt_to_objectives(self) -> int: return self._data[ParticipantStatsData].damageDealtToObjectives @property @load_match_on_attributeerror def largest_killing_spree(self) -> int: return self._data[ParticipantStatsData].largestKillingSpree @property @load_match_on_attributeerror def quadra_kills(self) -> int: return self._data[ParticipantStatsData].quadraKills @property @load_match_on_attributeerror def team_objective(self) -> int: return self._data[ParticipantStatsData].teamObjective @property @load_match_on_attributeerror def total_time_crowd_control_dealt(self) -> int: return self._data[ParticipantStatsData].totalTimeCrowdControlDealt @property @load_match_on_attributeerror def longest_time_spent_living(self) -> int: return self._data[ParticipantStatsData].longestTimeSpentLiving @property @load_match_on_attributeerror def wards_killed(self) -> int: return self._data[ParticipantStatsData].wardsKilled @property @load_match_on_attributeerror def first_tower_assist(self) -> bool: return self._data[ParticipantStatsData].firstTowerAssist @property @load_match_on_attributeerror def first_tower_kill(self) -> bool: return self._data[ParticipantStatsData].firstTowerKill @lazy_property @load_match_on_attributeerror def items(self) -> List[Item]: ids = [self._data[ParticipantStatsData].item0, self._data[ParticipantStatsData].item1, self._data[ParticipantStatsData].item2, self._data[ParticipantStatsData].item3, self._data[ParticipantStatsData].item4, self._data[ParticipantStatsData].item5, self._data[ParticipantStatsData].item6 ] version = _choose_staticdata_version(self.__match) return SearchableList([Item(id=id, version=version, region=self.__match.region) if id else None for id in ids]) @property @load_match_on_attributeerror def first_blood_assist(self) -> bool: return self._data[ParticipantStatsData].firstBloodAssist @property @load_match_on_attributeerror def vision_score(self) -> int: return self._data[ParticipantStatsData].visionScore @property @load_match_on_attributeerror def wards_placed(self) -> int: return self._data[ParticipantStatsData].wardsPlaced @property @load_match_on_attributeerror def turret_kills(self) -> int: return self._data[ParticipantStatsData].turretKills @property @load_match_on_attributeerror def triple_kills(self) -> int: return self._data[ParticipantStatsData].tripleKills @property @load_match_on_attributeerror def damage_self_mitigated(self) -> int: return self._data[ParticipantStatsData].damageSelfMitigated @property @load_match_on_attributeerror def level(self) -> int: return self._data[ParticipantStatsData].champLevel @property @load_match_on_attributeerror def node_neutralize_assist(self) -> int: return self._data[ParticipantStatsData].nodeNeutralizeAssist @property @load_match_on_attributeerror def first_inhibitor_kill(self) -> bool: return self._data[ParticipantStatsData].firstInhibitorKill @property @load_match_on_attributeerror def gold_earned(self) -> int: return self._data[ParticipantStatsData].goldEarned @property @load_match_on_attributeerror def magical_damage_taken(self) -> int: return self._data[ParticipantStatsData].magicalDamageTaken @property @load_match_on_attributeerror def kills(self) -> int: return self._data[ParticipantStatsData].kills @property @load_match_on_attributeerror def double_kills(self) -> int: return self._data[ParticipantStatsData].doubleKills @property @load_match_on_attributeerror def node_capture_assist(self) -> int: return self._data[ParticipantStatsData].nodeCaptureAssist @property @load_match_on_attributeerror def true_damage_taken(self) -> int: return self._data[ParticipantStatsData].trueDamageTaken @property @load_match_on_attributeerror def node_neutralize(self) -> int: return self._data[ParticipantStatsData].nodeNeutralize @property @load_match_on_attributeerror def first_inhibitor_assist(self) -> bool: return self._data[ParticipantStatsData].firstInhibitorAssist @property @load_match_on_attributeerror def assists(self) -> int: return self._data[ParticipantStatsData].assists @property @load_match_on_attributeerror def unreal_kills(self) -> int: return self._data[ParticipantStatsData].unrealKills @property @load_match_on_attributeerror def neutral_minions_killed(self) -> int: return self._data[ParticipantStatsData].neutralMinionsKilled @property @load_match_on_attributeerror def objective_player_score(self) -> int: return self._data[ParticipantStatsData].objectivePlayerScore @property @load_match_on_attributeerror def combat_player_score(self) -> int: return self._data[ParticipantStatsData].combatPlayerScore @property @load_match_on_attributeerror def damage_dealt_to_turrets(self) -> int: return self._data[ParticipantStatsData].damageDealtToTurrets @property @load_match_on_attributeerror def altars_neutralized(self) -> int: return self._data[ParticipantStatsData].altarsNeutralized @property @load_match_on_attributeerror def physical_damage_dealt_to_champions(self) -> int: return self._data[ParticipantStatsData].physicalDamageDealtToChampions @property @load_match_on_attributeerror def gold_spent(self) -> int: return self._data[ParticipantStatsData].goldSpent @property @load_match_on_attributeerror def true_damage_dealt(self) -> int: return self._data[ParticipantStatsData].trueDamageDealt @property @load_match_on_attributeerror def true_damage_dealt_to_champions(self) -> int: return self._data[ParticipantStatsData].trueDamageDealtToChampions @property @load_match_on_attributeerror def id(self) -> int: return self._data[ParticipantStatsData].id @property @load_match_on_attributeerror def penta_kills(self) -> int: return self._data[ParticipantStatsData].pentaKills @property @load_match_on_attributeerror def total_heal(self) -> int: return self._data[ParticipantStatsData].totalHeal @property @load_match_on_attributeerror def total_minions_killed(self) -> int: return self._data[ParticipantStatsData].totalMinionsKilled @property @load_match_on_attributeerror def first_blood_kill(self) -> bool: return self._data[ParticipantStatsData].firstBloodKill @property @load_match_on_attributeerror def node_capture(self) -> int: return self._data[ParticipantStatsData].nodeCapture @property @load_match_on_attributeerror def largest_multi_kill(self) -> int: return self._data[ParticipantStatsData].largestMultiKill @property @load_match_on_attributeerror def sight_wards_bought_in_game(self) -> int: return self._data[ParticipantStatsData].sightWardsBoughtInGame @property @load_match_on_attributeerror def total_damage_dealt_to_champions(self) -> int: return self._data[ParticipantStatsData].totalDamageDealtToChampions @property @load_match_on_attributeerror def total_units_healed(self) -> int: return self._data[ParticipantStatsData].totalUnitsHealed @property @load_match_on_attributeerror def inhibitor_kills(self) -> int: return self._data[ParticipantStatsData].inhibitorKills @property @load_match_on_attributeerror def total_score_rank(self) -> int: return self._data[ParticipantStatsData].totalScoreRank @property @load_match_on_attributeerror def total_damage_taken(self) -> int: return self._data[ParticipantStatsData].totalDamageTaken @property @load_match_on_attributeerror def killing_sprees(self) -> int: return self._data[ParticipantStatsData].killingSprees @property @load_match_on_attributeerror def time_CCing_others(self) -> int: return self._data[ParticipantStatsData].timeCCingOthers @property @load_match_on_attributeerror def physical_damage_taken(self) -> int: return self._data[ParticipantStatsData].physicalDamageTaken @searchable({str: ["summoner", "champion", "stats", "runes", "side", "summoner_spell_d", "summoner_spell_f"], Summoner: ["summoner"], Champion: ["champion"], Side: ["side"], Rune: ["runes"], SummonerSpell: ["summoner_spell_d", "summoner_spell_f"]}) class Participant(CassiopeiaObject): _data_types = {ParticipantData} @classmethod def from_data(cls, data: CoreData, match: "Match"): self = super().from_data(data) self.__match = match return self @property def version(self) -> str: version = self.__match.version version = version.split(".")[0:2] version = ".".join(version) + ".1" # Always use x.x.1 because I don't know how to figure out what the last version number should be. return version @property def lane(self) -> Lane: return Lane.from_match_naming_scheme(self._data[ParticipantData].timeline.lane) @property def role(self) -> Role: return Role.from_match_naming_scheme(self._data[ParticipantData].timeline.role) @property def skill_order(self) -> List[Key]: skill_events = self.timeline.events.filter(lambda event: event.type == "SKILL_LEVEL_UP") skill_events.sort(key=lambda event: event.timestamp) skills = [event.skill - 1 for event in skill_events] spells = [self.champion.spells[Key("Q")], self.champion.spells[Key("W")], self.champion.spells[Key("E")], self.champion.spells[Key("R")]] skills = [spells[skill] for skill in skills] return skills @lazy_property @load_match_on_attributeerror def stats(self) -> ParticipantStats: return ParticipantStats.from_data(self._data[ParticipantData].stats, match=self.__match, participant=self) @lazy_property @load_match_on_attributeerror def id(self) -> int: if self._data[ParticipantData].id is None: raise AttributeError return self._data[ParticipantData].id @lazy_property @load_match_on_attributeerror def is_bot(self) -> bool: return self._data[ParticipantData].isBot @lazy_property @load_match_on_attributeerror def runes(self) -> Dict[Rune, int]: version = _choose_staticdata_version(self.__match) runes = SearchableDictionary({Rune(id=rune_id, version=version, region=self.__match.region): perk_vars for rune_id, perk_vars in self._data[ParticipantData].runes.items()}) def keystone(self): for rune in self: if rune.is_keystone: return rune # The bad thing about calling this here is that the runes won't be lazy loaded, so if the user only want the # rune ids then there will be a needless call. That said, it's pretty nice functionality to have and without # making a custom RunePage class, I believe this is the only option. runes.keystone = keystone(runes) return runes @lazy_property @load_match_on_attributeerror def stat_runes(self) -> List[Rune]: version = _choose_staticdata_version(self.__match) runes = SearchableList([Rune(id=rune_id, version=version, region=self.__match.region) for rune_id in self._data[ParticipantData].stat_runes]) return runes @lazy_property @load_match_on_attributeerror def timeline(self) -> ParticipantTimeline: timeline = ParticipantTimeline.from_data(self._data[ParticipantData].timeline, match=self.__match) timeline(id=self.id) return timeline @property def cumulative_timeline(self) -> CumulativeTimeline: return CumulativeTimeline(id=self.id, participant_timeline=self.timeline) @lazy_property @load_match_on_attributeerror def side(self) -> Side: return Side(self._data[ParticipantData].side) @lazy_property @load_match_on_attributeerror def summoner_spell_d(self) -> SummonerSpell: version = _choose_staticdata_version(self.__match) return SummonerSpell(id=self._data[ParticipantData].summonerSpellDId, version=version, region=self.__match.region) @lazy_property @load_match_on_attributeerror def summoner_spell_f(self) -> SummonerSpell: version = _choose_staticdata_version(self.__match) return SummonerSpell(id=self._data[ParticipantData].summonerSpellFId, version=version, region=self.__match.region) @lazy_property @load_match_on_attributeerror def rank_last_season(self) -> Tier: return Tier(self._data[ParticipantData].rankLastSeason) @property @load_match_on_attributeerror def match_history_uri(self) -> str: return self._data[ParticipantData].matchHistoryUri @lazy_property @load_match_on_attributeerror def champion(self) -> "Champion": # See ParticipantStats for info version = _choose_staticdata_version(self.__match) return Champion(id=self._data[ParticipantData].championId, version=version, region=self.__match.region) # All the Player data from ParticipantIdentities.player is contained in the Summoner class. # The non-current accountId and platformId should never be relevant/used, and can be deleted from our type system. # See: https://discussion.developer.riotgames.com/questions/1713/is-there-any-scenario-where-accountid-should-be-us.html @lazy_property def summoner(self) -> Summoner: if self.__match._data[MatchData].privateGame: return None kwargs = {} try: kwargs["id"] = self._data[ParticipantData].summonerId except AttributeError: pass try: kwargs["name"] = self._data[ParticipantData].summonerName except AttributeError: pass kwargs["account_id"] = self._data[ParticipantData].currentAccountId kwargs["region"] = Platform(self._data[ParticipantData].currentPlatformId).region summoner = Summoner(**kwargs) try: summoner(profileIconId=self._data[ParticipantData].profileIconId) except AttributeError: pass return summoner @property def team(self) -> "Team": if self.side == Side.blue: return self.__match.blue_team else: return self.__match.red_team @property def enemy_team(self) -> "Team": if self.side == Side.blue: return self.__match.red_team else: return self.__match.blue_team @searchable({str: ["participants"], bool: ["win"], Champion: ["participants"], Summoner: ["participants"], SummonerSpell: ["participants"]}) class Team(CassiopeiaObject): _data_types = {TeamData} @classmethod def from_data(cls, data: CoreData, match: "Match"): self = super().from_data(data) self.__match = match return self @property def first_dragon(self) -> bool: return self._data[TeamData].firstDragonKiller @property def first_inhibitor(self) -> bool: return self._data[TeamData].firstInhibitorKiller @property def first_rift_herald(self) -> bool: return self._data[TeamData].firstRiftHeraldKiller @property def first_baron(self) -> bool: return self._data[TeamData].firstBaronKiller @property def first_tower(self) -> bool: return self._data[TeamData].firstTowerKiller @property def first_blood(self) -> bool: return self._data[TeamData].firstBloodKiller @property def bans(self) -> List["Champion"]: version = _choose_staticdata_version(self.__match) return [Champion(id=champion_id, version=version, region=self.__match.region) if champion_id != -1 else None for champion_id in self._data[TeamData].bans] @property def baron_kills(self) -> int: return self._data[TeamData].baronKills @property def rift_herald_kills(self) -> int: return self._data[TeamData].riftHeraldKills @property def vilemaw_kills(self) -> int: return self._data[TeamData].vilemawKills @property def inhibitor_kills(self) -> int: return self._data[TeamData].inhibitorKills @property def tower_kills(self) -> int: return self._data[TeamData].towerKills @property def dragon_kills(self) -> int: return self._data[TeamData].dragonKills @property def side(self) -> Side: return self._data[TeamData].side @property def dominion_score(self) -> int: return self._data[TeamData].dominionScore @property def win(self) -> bool: return self._data[TeamData].isWinner @lazy_property def participants(self) -> List[Participant]: return SearchableList([Participant.from_data(p, match=self.__match) for p in self._data[TeamData].participants]) @searchable({str: ["participants", "region", "platform", "season", "queue", "mode", "map", "type"], Region: ["region"], Platform: ["platform"], Season: ["season"], Queue: ["queue"], GameMode: ["mode"], Map: ["map"], GameType: ["type"], Item: ["participants"], Champion: ["participants"], Patch: ["patch"], Summoner: ["participants"], SummonerSpell: ["participants"]}) class Match(CassiopeiaGhost): _data_types = {MatchData} @provide_default_region def __init__(self, *, id: int = None, region: Union[Region, str] = None): kwargs = {"region": region, "id": id} super().__init__(**kwargs) self.__participants = [] # For lazy-loading the participants in a special way self._timeline = None def __get_query__(self): return {"region": self.region, "platform": self.platform, "id": self.id} @classmethod def from_match_reference(cls, ref: MatchReferenceData): instance = cls(id=ref.id, region=ref.region) # The below line is necessary because it's possible to pull this match from the cache (which has Match core objects in it). # In that case, the data will already be loaded and we don't want to overwrite anything. if not hasattr(instance._data[MatchData], "participants"): participant = {"participantId": None, "championId": ref.championId, "timeline": {"lane": ref.lane, "role": ref.role}} player = {"participantId": None, "currentAccountId": ref.accountId, "currentPlatformId": ref.platform} instance(season=ref.season, queue=ref.queue, creation=ref.creation) instance._data[MatchData](participants=[participant], participantIdentities=[{"participantId": None, "player": player, "bot": False}]) instance._timeline = None return instance def __eq__(self, other: "Match"): if not isinstance(other, Match) or self.region != other.region: return False return self.id == other.id def __str__(self): region = self.region id_ = self.id return "Match(id={id_}, region='{region}')".format(id_=id_, region=region.value) __hash__ = CassiopeiaGhost.__hash__ @lazy_property def region(self) -> Region: """The region for this match.""" return Region(self._data[MatchData].region) @property def platform(self) -> Platform: """The platform for this match.""" return self.region.platform @property def id(self) -> int: return self._data[MatchData].id @lazy_property def timeline(self) -> Timeline: if self._timeline is None: self._timeline = Timeline(id=self.id, region=self.region.value) return self._timeline @CassiopeiaGhost.property(MatchData) @ghost_load_on @lazy def season(self) -> Season: return Season.from_id(self._data[MatchData].season) @CassiopeiaGhost.property(MatchData) @ghost_load_on @lazy def queue(self) -> Queue: return Queue.from_id(self._data[MatchData].queue) @CassiopeiaGhost.property(MatchData) @ghost_load_on # This method is lazy-loaded in a special way because of its unique behavior def participants(self) -> List[Participant]: # This is a complicated function because we don't want to load the particpants if the only one the user cares about is the one loaded from a match ref def generate_participants(match): if not hasattr(match._data[MatchData], "participants"): empty_match = True else: empty_match = False # If a participant was provided from a matchref, yield that first yielded_one = False if not empty_match and len(match._data[MatchData].participants) == 1: yielded_one = True try: yield match.__participants[0] except IndexError: p = match._data[MatchData].participants[0] participant = Participant.from_data(p, match=match) match.__participants.append(participant) yield participant # Create all the participants if any haven't been created yet. # Note that it's important to overwrite the one from the matchref if it was loaded because we have more data after we load the full match. if empty_match or yielded_one or len(match.__participants) < len(match._data[MatchData].participants): if not match._Ghost__is_loaded(MatchData): match.__load__(MatchData) match._Ghost__set_loaded(MatchData) # __load__ doesn't trigger __set_loaded. for i, p in enumerate(match._data[MatchData].participants): participant = Participant.from_data(p, match=match) # If we already have this participant in the list, replace it so it stays in the same position for j, pold in enumerate(match.__participants): if hasattr(pold._data[ParticipantData], "currentAccountId") and hasattr(participant._data[ParticipantData], "currentAccountId") and pold._data[ParticipantData].currentAccountId == participant._data[ParticipantData].currentAccountId: match.__participants[j] = participant break else: match.__participants.append(participant) # Yield the rest of the participants for participant in match.__participants[yielded_one:]: yield participant return SearchableLazyList(generate_participants(self)) @CassiopeiaGhost.property(MatchData) @ghost_load_on @lazy def teams(self) -> List[Team]: return [Team.from_data(t, match=self) for i, t in enumerate(self._data[MatchData].teams)] @property def red_team(self) -> Team: if self.teams[0].side is Side.red: return self.teams[0] else: return self.teams[1] @property def blue_team(self) -> Team: if self.teams[0].side is Side.blue: return self.teams[0] else: return self.teams[1] @CassiopeiaGhost.property(MatchData) @ghost_load_on def version(self) -> str: return self._data[MatchData].version @property def patch(self) -> Patch: if hasattr(self._data[MatchData], "version"): version = ".".join(self.version.split(".")[:2]) patch = Patch.from_str(version, region=self.region) else: date = self.creation patch = Patch.from_date(date, region=self.region) return patch @CassiopeiaGhost.property(MatchData) @ghost_load_on @lazy def mode(self) -> GameMode: return GameMode(self._data[MatchData].mode) @CassiopeiaGhost.property(MatchData) @ghost_load_on @lazy def map(self) -> Map: version = _choose_staticdata_version(self) return Map(id=self._data[MatchData].mapId, region=self.region, version=version) @CassiopeiaGhost.property(MatchData) @ghost_load_on @lazy def type(self) -> GameType: return GameType(self._data[MatchData].type) @CassiopeiaGhost.property(MatchData) @ghost_load_on @lazy def duration(self) -> datetime.timedelta: return self._data[MatchData].duration @CassiopeiaGhost.property(MatchData) @ghost_load_on @lazy def creation(self) -> arrow.Arrow: return self._data[MatchData].creation @property def is_remake(self) -> bool: return self.duration < datetime.timedelta(minutes=5) @property def exists(self) -> bool: try: if not self._Ghost__all_loaded: self.__load__() self.type # Make sure we can access this attribute return True except (AttributeError, NotFoundError): return False def kills_heatmap(self): if self.map.name == "Summoner's Rift": rx0, ry0, rx1, ry1 = 0, 0, 14820, 14881 elif self.map.name == "Howling Abyss": rx0, ry0, rx1, ry1 = -28, -19, 12849, 12858 else: raise NotImplemented imx0, imy0, imx1, imy1 = self.map.image.image.getbbox() def position_to_map_image_coords(position): x, y = position.x, position.y x -= rx0 x /= (rx1 - rx0) x *= (imx1 - imx0) y -= ry0 y /= (ry1 - ry0) y *= (imy1 - imy0) return x, y import matplotlib.pyplot as plt size = 8 plt.figure(figsize=(size, size)) plt.imshow(self.map.image.image.rotate(-90)) for p in self.participants: for kill in p.timeline.champion_kills: x, y = position_to_map_image_coords(kill.position) if p.team.side == Side.blue: plt.scatter([x], [y], c="b", s=size * 10) else: plt.scatter([x], [y], c="r", s=size * 10) plt.axis('off') plt.show()