├── requirements.txt ├── .gitignore ├── ballchasing ├── typed │ ├── __init__.py │ ├── shallow_group.py │ ├── shallow_replay.py │ ├── shared.py │ ├── deep_replay.py │ └── deep_group.py ├── __init__.py ├── util.py ├── stats_info.tsv ├── constants.py └── api.py ├── pyproject.toml ├── LICENSE ├── scripts ├── test_typed.py ├── test.py └── make_types.py ├── .github └── workflows │ └── python-publish.yml ├── README.md └── get_maps.py /requirements.txt: -------------------------------------------------------------------------------- 1 | . -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.replay 2 | .idea 3 | dist/ -------------------------------------------------------------------------------- /ballchasing/typed/__init__.py: -------------------------------------------------------------------------------- 1 | # Imports for the objects meant to be initialized directly 2 | from ballchasing.typed.deep_group import DeepGroup 3 | from ballchasing.typed.deep_replay import DeepReplay 4 | from ballchasing.typed.shallow_group import ShallowGroup 5 | from ballchasing.typed.shallow_replay import ShallowReplay 6 | -------------------------------------------------------------------------------- /ballchasing/typed/shallow_group.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | from ballchasing.typed.shared import User, BaseGroup 5 | 6 | 7 | @dataclass 8 | class ShallowGroup(BaseGroup): 9 | # id: str = "" 10 | # link: str = "" 11 | # name: str = "" 12 | # created: Optional[datetime] = None 13 | # player_identification: str = "" 14 | # team_identification: str = "" 15 | # shared: bool = False 16 | direct_replays: int = 0 17 | indirect_replays: int = 0 18 | user: Optional[User] = None 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "python-ballchasing" 7 | version = "0.4.1" 8 | authors = [ 9 | { name = "Rolv-Arild Braaten", email = "rolv_arild@hotmail.com" }, 10 | ] 11 | description = "A Python wrapper around the Ballchasing API" 12 | readme = "README.md" 13 | requires-python = ">=3.9" 14 | license = { text = "MIT License" } 15 | classifiers = [ 16 | "Programming Language :: Python :: 3.9", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | ] 20 | dependencies = [ 21 | "requests", 22 | ] 23 | 24 | [project.urls] 25 | "Homepage" = "https://github.com/Rolv-Arild/python-ballchasing" 26 | "Download" = "https://pypi.python.org/pypi/python-ballchasing" 27 | 28 | [tool.setuptools.packages.find] 29 | where = ["."] 30 | 31 | [tool.setuptools.package-data] 32 | ballchasing = ["*.tsv"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Rolv-Arild Braaten 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /scripts/test_typed.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | from datetime import datetime, timedelta 4 | 5 | import ballchasing as bc 6 | from ballchasing.typed.deep_group import DeepGroup 7 | from ballchasing.typed.deep_replay import DeepReplay 8 | from ballchasing.typed.shallow_replay import ShallowReplay 9 | 10 | # from ballchasing.typed import Replay, Group 11 | 12 | key = os.environ.get("BALLCHASING_API_KEY") 13 | 14 | api = bc.Api(key) 15 | 16 | playlists = bc.Playlist.ALL 17 | 18 | min_date = datetime(2015, 7, 7) 19 | max_date = datetime.now() 20 | 21 | while True: 22 | playlist = random.choice(playlists) 23 | date = datetime.fromtimestamp(random.randint(int(min_date.timestamp()), int(max_date.timestamp()))) 24 | 25 | for replay in api.get_replays(replay_after=date, 26 | replay_before=date + timedelta(days=1), 27 | playlist=playlists, 28 | count=10): 29 | deep_replay = api.get_replay(replay["id"]) 30 | typed_replay = ShallowReplay(**replay) 31 | typed_deep_replay = DeepReplay(**deep_replay) 32 | 33 | for group in typed_deep_replay.groups: 34 | deep_group = api.get_group(group.id) 35 | deep_typed_group = DeepGroup(**deep_group) 36 | -------------------------------------------------------------------------------- /ballchasing/typed/shallow_replay.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Optional, List 3 | 4 | from ballchasing.typed.shared import _DictToTypeMixin, BasePlayer, BaseTeam, BaseReplay 5 | 6 | 7 | @dataclass 8 | class PlayerSR(BasePlayer): 9 | # start_time: float = 0.0 10 | # end_time: float = 0.0 11 | # name: str = "" 12 | # id: Optional[PlayerID] = None 13 | # pro: bool = False 14 | # mvp: bool = False 15 | # rank: Optional[Rank] = None 16 | score: int = 0 17 | 18 | 19 | @dataclass 20 | class TeamSR(BaseTeam): 21 | # name: str = "" 22 | goals: int = 0 23 | players: List[PlayerSR] = field(default_factory=list) 24 | 25 | def __eq__(self, other): 26 | if isinstance(other, TeamSR): 27 | for p1 in self.players: 28 | for p2 in other.players: 29 | if p1 == p2: 30 | break 31 | else: 32 | return False 33 | return True 34 | return NotImplemented 35 | 36 | 37 | @dataclass 38 | class ShallowReplay(BaseReplay): 39 | # id: str = "" 40 | # link: str = "" 41 | # rocket_league_id: str = "" 42 | # recorder: str = "" 43 | # map_code: str = "" 44 | # map_name: str = "" 45 | # playlist_id: str = "" 46 | # playlist_name: str = "" 47 | # duration: int = 0 48 | # overtime: bool = False 49 | # overtime_seconds: int = 0 50 | # season: int = 0 51 | # season_type: str = "" 52 | # date: Optional[datetime] = None 53 | # visibility: str = "" 54 | # created: Optional[datetime] = None 55 | # uploader: Optional[User] = None 56 | # min_rank: Optional[Rank] = None 57 | # max_rank: Optional[Rank] = None 58 | # groups: List[BaseGroup] = field(default_factory=list) 59 | replay_title: str = "" # title in deep replay 60 | date_has_tz: bool = False # date_has_timezone in deep replay 61 | blue: Optional[TeamSR] = None 62 | orange: Optional[TeamSR] = None 63 | 64 | def scoreline(self): 65 | bg = self.blue.goals if self.blue else 0 66 | og = self.orange.goals if self.orange else 0 67 | return bg, og 68 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package to PyPI when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | release-build: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.x" 28 | 29 | - name: Build release distributions 30 | run: | 31 | # NOTE: put your own distribution build steps here. 32 | python -m pip install build 33 | python -m build 34 | 35 | - name: Upload distributions 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: release-dists 39 | path: dist/ 40 | 41 | pypi-publish: 42 | runs-on: ubuntu-latest 43 | needs: 44 | - release-build 45 | permissions: 46 | # IMPORTANT: this permission is mandatory for trusted publishing 47 | id-token: write 48 | 49 | # Dedicated environments with protections for publishing are strongly recommended. 50 | # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules 51 | environment: 52 | name: pypi 53 | # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: 54 | # url: https://pypi.org/p/YOURPROJECT 55 | # 56 | # ALTERNATIVE: if your GitHub Release name is the PyPI project version string 57 | # ALTERNATIVE: exactly, uncomment the following line instead: 58 | # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }} 59 | 60 | steps: 61 | - name: Retrieve release distributions 62 | uses: actions/download-artifact@v4 63 | with: 64 | name: release-dists 65 | path: dist/ 66 | 67 | - name: Publish release distributions to PyPI 68 | uses: pypa/gh-action-pypi-publish@release/v1 69 | with: 70 | packages-dir: dist/ 71 | -------------------------------------------------------------------------------- /scripts/test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | import ballchasing as bc 5 | from ballchasing import ShallowReplay, DeepReplay, ShallowGroup, DeepGroup 6 | 7 | if __name__ == '__main__': 8 | # Basic tests 9 | token = os.environ.get("BALLCHASING_API_KEY") 10 | api = bc.Api(token) 11 | print(api) 12 | # api.get_replays(season="123") 13 | 14 | replays_response = list(api.get_replays(uploader=api.steam_id, season="f14", count=1000)) 15 | print(f"Successfully fetched {len(replays_response)} replays") 16 | typed_replays_response = [ShallowReplay(**replay) for replay in replays_response] 17 | print(f"Succcessfully converted {len(typed_replays_response)} replays to typed objects") 18 | replay_response = api.get_replay(replays_response[0]["id"]) 19 | print(f"Successfully fetched deep replay {replay_response['id']}") 20 | typed_replay_response = DeepReplay(**replay_response) 21 | print(f"Succcessfully converted replay {replay_response['id']} to typed object") 22 | 23 | api.download_replay(replay_response["id"], "./") 24 | print(f"Successfully downloaded replay {replay_response['id']} to current directory") 25 | api.delete_replay(replay_response["id"]) 26 | print(f"Successfully deleted replay {replay_response['id']}") 27 | upload_response = api.upload_replay("./" + replay_response["id"] + ".replay") 28 | print(f"Successfully uploaded replay {upload_response['id']}") 29 | 30 | groups_response = list(api.get_groups()) 31 | print(f"Successfully fetched {len(groups_response)} groups") 32 | typed_groups_response = [ShallowGroup(**group) for group in groups_response] 33 | print(f"Succcessfully converted {len(typed_groups_response)} groups to typed objects") 34 | group_response = api.get_group(groups_response[0]["id"]) 35 | print(f"Successfully fetched group {group_response['id']}") 36 | typed_group_response = DeepGroup(**group_response) 37 | print(f"Succcessfully converted deep group {group_response['id']} to typed object") 38 | 39 | create_group_response = api.create_group(name=f"test-{time.time()}", 40 | player_identification=bc.PlayerIdentification.BY_ID, 41 | team_identification=bc.TeamIdentification.BY_DISTINCT_PLAYERS) 42 | print(f"Successfully created group {create_group_response['id']}") 43 | api.patch_group(create_group_response["id"], 44 | team_identification=bc.TeamIdentification.BY_PLAYER_CLUSTERS) 45 | print(f"Successfully patched group {create_group_response['id']}") 46 | 47 | api.patch_replay(upload_response["id"], group=create_group_response["id"]) 48 | print(f"Successfully patched replay {upload_response['id']} with group {create_group_response['id']}") 49 | 50 | api.delete_group(create_group_response["id"]) 51 | print(f"Successfully deleted group {create_group_response['id']}") 52 | print("Nice") 53 | -------------------------------------------------------------------------------- /ballchasing/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # MIT License 3 | # 4 | # Copyright (c) 2020 Rolv-Arild Braaten 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | 24 | """A library that provides a Python interface to the Ballchasing API.""" 25 | import sys 26 | 27 | # In Python 3.8+, importlib.metadata is in the standard library. 28 | # For older versions, a backport is available as importlib_metadata. 29 | if sys.version_info >= (3, 8): 30 | from importlib.metadata import version, metadata 31 | else: 32 | from importlib_metadata import version, metadata 33 | 34 | try: 35 | # This will read the version from the installed package's metadata 36 | # (which is defined in pyproject.toml) 37 | __version__ = version("python-ballchasing") 38 | 39 | # You can also get other metadata in a similar way 40 | pkg_metadata = metadata("python-ballchasing") 41 | __author__ = pkg_metadata.get("Author-Email") 42 | __description__ = pkg_metadata.get("Summary") 43 | except Exception: 44 | # If the package is not installed (e.g., when running in a development 45 | # environment without an editable install), you can fall back to defaults. 46 | __version__ = "0.0.0-dev" 47 | __description__ = 'A Python wrapper around the Ballchasing API' 48 | __author__ = 'Rolv-Arild Braaten ' 49 | 50 | # Import the main classes and constants to make them easily accessible to users. 51 | from .api import BallchasingApi 52 | 53 | Api = BallchasingApi # For importing like `import ballchasing; api = ballchasing.Api` 54 | 55 | from .constants import ( 56 | Playlist, 57 | Rank, 58 | Season, 59 | MatchResult, 60 | ReplaySortBy, 61 | GroupSortBy, 62 | SortDir, 63 | Visibility, 64 | PlayerIdentification, 65 | TeamIdentification, 66 | Map 67 | ) 68 | 69 | from .typed import ( 70 | ShallowReplay, 71 | ShallowGroup, 72 | DeepReplay, 73 | DeepGroup 74 | ) 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Ballchasing 2 | An easy-to-use and comprehensive Python wrapper for the [ballchasing.com API](https://ballchasing.com/doc/api). 3 | 4 | ## Installation 5 | You can install the library from PyPI using pip: 6 | ``` 7 | pip install python-ballchasing 8 | ``` 9 | 10 | ## Authentication 11 | Before you can use the library, you need an API authentication key from ballchasing.com. 12 | 1. Log in to [ballchasing.com](https://ballchasing.com/). 13 | 2. Navigate to the Upload tab. 14 | 3. Create an API key and copy it. 15 | 16 | **Keep your API key secure and do not share it publicly.** 17 | 18 | ## API 19 | For detailed information, please refer to the docs inside the code. For the most part it follows the API spec closely, but there are some differences. 20 | 21 | The API is exposed via the `BallchasingApi` class. 22 | 23 | Making the client: 24 | ```python 25 | from ballchasing import BallchasingApi 26 | api = BallchasingApi("Your token here") 27 | ``` 28 | or equivalently 29 | ```python 30 | import ballchasing 31 | api = ballchasing.Api("Your token here") 32 | ``` 33 | By default this will also ping the API to make sure it's working. 34 | 35 | --- 36 | Some simple examples: 37 | ```python 38 | # Get lots of SSL replays 39 | 40 | from ballchasing import Rank # there's also Playlist, Season, Map, and more 41 | 42 | replays = api.get_replays( 43 | min_rank=Rank.SUPERSONIC_LEGEND, 44 | count=10_000 # The API limits you to 200 replays per request but the library handles this for you 45 | ) 46 | 47 | for replay in replays: # (replays is an iterable so you don't need to wait for all the replays to be collected) 48 | ... # Do something with the replays 49 | ``` 50 | 51 | ```python 52 | # Get a specific replay with more detail than the iterator (including stats!) 53 | replay = api.get_replay("2627e02a-aa46-4e13-b66b-b76a32069a07") 54 | ``` 55 | 56 | ```python 57 | # Get groups by the "RLCS Referee" account 58 | groups = api.get_groups(creator="76561199225615730") 59 | 60 | for group in groups: 61 | # Download the group 62 | api.download_group( 63 | group_id=group["id"], 64 | path="/path/to/destination/" 65 | recursive=True, # To download all the replays and retain the group structure with subfolders 66 | ) 67 | 68 | # Get replays from the group 69 | replays = get_group_replays( 70 | group_id=group["id"], 71 | deep=True, # To get detailed replay info 72 | ) 73 | for replay in replays: 74 | api.download_replay(replay_id=replay["id"], path="/path/to/destination/") # You could also download like this 75 | ``` 76 | 77 | --- 78 | Additionally, there's the option to put responses (replays, groups) into typed objects for better type hinting and validation. 79 | It also attempts to fill in missing variables not returned by ballchasing (e.g. if a team has 0 goals they won't have a "goal" entry in the response) 80 | The classes can also be found under the `typing` folder and used as a reference for what the API returns. 81 | 82 | ```python 83 | # When creating the API, you can specify a default setting for typing (defaults to False) 84 | api = ballchasing.Api("Your token here", typed=True) 85 | 86 | # or you can specify it explicitly 87 | replay = api.get_replay("2627e02a-aa46-4e13-b66b-b76a32069a07", typed=True) 88 | print(replay.blue.players[0].name) # Example of easy attribute access 89 | 90 | group = api.get_group("g2-vs-bds-hwbf2eyolb", typed=True) 91 | print(group.players[0].name) 92 | ``` 93 | -------------------------------------------------------------------------------- /ballchasing/util.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | 6 | def to_rfc3339(dt: Optional[datetime]): 7 | if dt is None: 8 | return dt 9 | elif isinstance(dt, str): 10 | return dt 11 | elif isinstance(dt, datetime): 12 | return dt.isoformat("T") + "Z" 13 | else: 14 | raise ValueError("Date must be either string or datetime") 15 | 16 | 17 | def from_rfc3339(s: str): 18 | """ 19 | Convert an RFC3339 formatted string to a datetime object. 20 | """ 21 | s = s.replace("Z", "+00:00") 22 | try: 23 | dt = datetime.fromisoformat(s) 24 | except ValueError: 25 | dt = datetime.strptime(s, "%Y-%m-%dT%H:%M:%S.%f%z") 26 | return dt 27 | 28 | 29 | def _get_stats_info(): 30 | cur_path = Path(__file__).parent 31 | with open(cur_path / "stats_info.tsv") as stats_info: 32 | stats_info = [line.strip().split("\t") for line in stats_info] 33 | 34 | header = stats_info[0] 35 | stats = {} 36 | for row in stats_info[1:]: 37 | stat_info = {} 38 | for k, v in zip(header, row): 39 | if k.startswith("is_"): 40 | v = v.lower() in ("true", "1") 41 | elif k == "dtype": 42 | v = { 43 | "str": str, 44 | "int": int, 45 | "float": float, 46 | "bool": bool, 47 | "datetime": datetime, 48 | }[v] 49 | stat_info[k] = v 50 | stats[stat_info["name"]] = stat_info 51 | return stats 52 | 53 | 54 | stats_info = _get_stats_info() 55 | 56 | 57 | def get_value(replay, path, dtype, *path_args): 58 | tree = replay 59 | for branch in path.format(*path_args).split("."): 60 | if isinstance(tree, dict): 61 | tree = tree.get(branch, None) 62 | elif isinstance(tree, list): 63 | tree = tree[int(branch)] 64 | 65 | if tree is None: 66 | return "MISSING" 67 | if isinstance(tree, (dict, list)): 68 | return "MISSING" 69 | 70 | if dtype == datetime: 71 | return to_rfc3339(tree) 72 | 73 | return dtype(tree) 74 | 75 | 76 | def parse_replay_stats(replay: dict): 77 | replay_stats = {} 78 | team_stats = {} 79 | player_stats = {} 80 | 81 | for stat in stats_info.values(): 82 | is_replay = stat["is_replay"] 83 | is_team = stat["is_team"] 84 | is_player = stat["is_player"] 85 | name = stat["name"] 86 | path = stat["path"] 87 | dtype = stat["dtype"] 88 | 89 | if is_replay: 90 | v = get_value(replay, path, dtype) 91 | replay_stats[name] = v 92 | 93 | if is_team: 94 | for team in ("blue", "orange"): 95 | ts = team_stats.setdefault(team, {}) 96 | v = get_value(replay, path, dtype, team) 97 | ts[name] = v 98 | 99 | if is_player: 100 | for team in "blue", "orange": 101 | for n, p in enumerate(replay[team]["players"]): 102 | pid = p["id"] 103 | pid = pid["platform"] + ":" + pid["id"] 104 | ps = player_stats.setdefault(pid, {}) 105 | v = get_value(replay, path, dtype, team, n) 106 | ps[name] = v 107 | 108 | return { 109 | "replay": replay_stats, 110 | "teams": team_stats, 111 | "players": player_stats 112 | } 113 | -------------------------------------------------------------------------------- /get_maps.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import re 4 | from collections import Counter 5 | from itertools import chain 6 | 7 | import requests 8 | from tqdm import tqdm 9 | 10 | import ballchasing as bc 11 | 12 | key = os.environ.get("BALLCHASING_API_KEY") 13 | 14 | api = bc.Api(key) 15 | 16 | all_maps = api.get_maps() 17 | 18 | if "uf_day_p" not in all_maps: 19 | print("Adding Futura Garden map to the list") 20 | all_maps["uf_day_p"] = "Futura Garden" 21 | if "stadium_10a_p" not in all_maps: 22 | print("Adding 10th Anniversary Stadium map to the list") 23 | all_maps["stadium_10a_p"] = "DFH Stadium (10th Anniversary)" 24 | for map_code, map_name in all_maps.items(): 25 | if "(" in map_name and ")" not in map_name: 26 | # Fix maps with missing closing parenthesis 27 | print(f"Adding missing parenthesis for map {map_code}: {map_name}") 28 | all_maps[map_code] = map_name + ")" 29 | 30 | rlbot_map_list = requests.get( 31 | "https://raw.githubusercontent.com/RLBot/gui/refs/heads/master/frontend/src/arena-names.ts" 32 | ).text 33 | standard_maps_rlbot = re.search(r"MAPS_STANDARD\s*=\s*(\{[^}]+\})", rlbot_map_list) 34 | if standard_maps_rlbot: 35 | standard_maps_rlbot = eval(standard_maps_rlbot.group(1)) 36 | standard_maps_rlbot = {v.lower(): k for k, v in standard_maps_rlbot.items()} 37 | else: 38 | standard_maps_rlbot = {} 39 | 40 | nonstandard_maps_rlbot = re.search(r"MAPS_NON_STANDARD\s*=\s*(\{[^}]+\})", rlbot_map_list) 41 | if nonstandard_maps_rlbot: 42 | nonstandard_maps_rlbot = eval(nonstandard_maps_rlbot.group(1)) 43 | nonstandard_maps_rlbot = {v.lower(): k for k, v in nonstandard_maps_rlbot.items()} 44 | else: 45 | nonstandard_maps_rlbot = {} 46 | 47 | # Some corrections: 48 | # if "farm_hw_p" in standard_maps_rlbot: 49 | # standard_maps_rlbot["farm_hw_p"] = nonstandard_maps_rlbot["farm_hw_p"] 50 | # del nonstandard_maps_rlbot["farm_hw_p"] 51 | standard_maps_rlbot["woods_night_p"] = all_maps["woods_night_p"] 52 | standard_maps_rlbot["farm_upsidedown_p"] = all_maps["farm_upsidedown_p"] 53 | 54 | # Update all_maps with any missing maps that are in the RLBot list 55 | for map_code, map_name in chain(standard_maps_rlbot.items(), nonstandard_maps_rlbot.items()): 56 | if map_code not in all_maps: 57 | all_maps[map_code] = map_name 58 | 59 | 60 | # for seasons in bc.Season.ALL[::-1]: 61 | # # Check start and end of season in case of removals 62 | # for sort_dir in (bc.SortDir.ASCENDING, bc.SortDir.DESCENDING): 63 | # for replay in api.get_replays(season=seasons, sort_dir=sort_dir, count=200): 64 | # map_code = replay.get("map_code") 65 | # if map_code is None: 66 | # print(f"Replay {replay['id']} has no map code!") 67 | # continue 68 | # map_name = replay.get("map_name") # Likely missing 69 | # if map_code not in all_maps: 70 | # print(f"Map {map_code} ({map_name}) not in the list of maps!") 71 | # print() 72 | 73 | 74 | def print_maps(maps: dict): 75 | print(", ".join(f'"{m}"' for m in sorted(maps.keys()))) 76 | print(", ".join(f'{m.upper()}' for m in sorted(maps.keys()))) 77 | print(", ".join(f'"{maps[m]}"' for m in sorted(maps.keys()))) 78 | print() 79 | 80 | 81 | print_maps(all_maps) 82 | 83 | standard_playlists = bc.Playlist.RANKED + bc.Playlist.UNRANKED 84 | non_standard_playlists = [bc.Playlist.RANKED_HOOPS, bc.Playlist.RANKED_DROPSHOT, 85 | bc.Playlist.HOOPS, bc.Playlist.DROPSHOT, 86 | bc.Playlist.GRIDIRON, bc.Playlist.ROCKETLABS] 87 | 88 | 89 | def check_map(code): 90 | map_name = all_maps.get(code, None) 91 | if map_code in standard_maps_rlbot or map_name in standard_maps_rlbot.values(): 92 | return True 93 | if map_code in nonstandard_maps_rlbot or map_name in nonstandard_maps_rlbot.values(): 94 | return False 95 | # return None 96 | # First, check playlists with only non-standard maps 97 | for replay in api.get_replays(map_id=code, playlist=non_standard_playlists, count=100): 98 | if replay["playlist_id"] in non_standard_playlists: 99 | return False 100 | 101 | # Check both casual and ranked 102 | for playlists in (bc.Playlist.RANKED + (bc.Playlist.RANKED_SNOWDAY,), 103 | bc.Playlist.UNRANKED): 104 | # Start with later seasons since it can include more maps, but do them individually in case of changing maps 105 | # Do only f2p since a few early seasons had non-standard maps 106 | for seasons in bc.Season.FREE_TO_PLAY[::-1]: 107 | # Check start and end of season in case of removals 108 | for sort_dir in (bc.SortDir.ASCENDING, bc.SortDir.DESCENDING): 109 | for replay in api.get_replays(season=seasons, map_id=code, playlist=playlists, sort_dir=sort_dir, 110 | count=200): 111 | if replay["playlist_id"] in bc.Playlist.RANKED: 112 | return True 113 | elif replay["playlist_id"] in bc.Playlist.UNRANKED: 114 | return None 115 | return False 116 | 117 | 118 | standard_maps = {} 119 | uncertain_maps = {} 120 | non_standard_maps = {} 121 | for map_code, map_name in tqdm(all_maps.items()): 122 | res = check_map(map_code) 123 | if res: 124 | standard_maps[map_code] = map_name 125 | elif res is None: 126 | uncertain_maps[map_code] = map_name 127 | else: 128 | non_standard_maps[map_code] = map_name 129 | 130 | print_maps(standard_maps) 131 | print_maps(uncertain_maps) 132 | print_maps(non_standard_maps) 133 | print_maps({**non_standard_maps, **uncertain_maps}) 134 | print("Done!") 135 | -------------------------------------------------------------------------------- /scripts/make_types.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | from dataclasses import make_dataclass, fields 4 | from typing import List, Any 5 | 6 | from requests import HTTPError 7 | 8 | import ballchasing as bc 9 | 10 | api = bc.Api(os.environ.get("BALLCHASING_API_KEY")) 11 | 12 | 13 | # Creates a starting point for dataclasses that contain response data 14 | 15 | def get_diverse_replays(limit=1000, deep=False): 16 | remaining = limit 17 | while True: 18 | rank = random.choice(bc.Rank.ALL) 19 | season = random.choice(bc.Season.ALL) 20 | playlist = random.choice(bc.Playlist.ALL) 21 | map_id = random.choice(bc.Map.ALL) 22 | try: 23 | replays = api.get_replays( 24 | playlist=playlist, 25 | season=season, 26 | max_rank=rank, 27 | map_id=map_id, 28 | count=10, 29 | deep=deep, 30 | ) 31 | 32 | for replay in replays: 33 | yield replay 34 | remaining -= 1 35 | if remaining <= 0: 36 | return 37 | except HTTPError: 38 | continue 39 | 40 | 41 | def get_diverse_groups(deep=False): 42 | if deep: 43 | for group in get_diverse_groups(deep=False): 44 | yield api.get_group(group["id"]) 45 | else: 46 | groups = api.get_groups(count=10) 47 | yield from groups 48 | groups = api.get_groups(creator="76561199225615730") 49 | yield from groups 50 | 51 | 52 | classes = {} 53 | 54 | 55 | def get_structure(name, item): 56 | if isinstance(item, (str, int, float, bool)): 57 | return type(item) 58 | elif isinstance(item, list): 59 | sub_name = name.title().replace("_", "") 60 | if sub_name[-1] == "s": 61 | sub_name = sub_name[:-1] # Remove plural 's' for lists 62 | sub_structures = [get_structure(sub_name, item) for item in item] 63 | if len(sub_structures) == 0: 64 | return List[Any] 65 | else: 66 | return List[sub_structures.pop()] 67 | elif isinstance(item, dict): 68 | s = {} 69 | for key, value in item.items(): 70 | sub_name = name + "|" + key.title().replace("_", "") 71 | s[key] = get_structure(sub_name, value) 72 | name = name.replace("|", "_") 73 | if name in classes: 74 | # Update with new fields if there are any 75 | existing_dc = classes[name] 76 | for field in fields(existing_dc): 77 | if field.name not in s: 78 | t = field.type 79 | if t == int and s.get(field.name) == float: 80 | t = float 81 | s[field.name] = t 82 | dc = make_dataclass(name, s.items()) 83 | classes[name] = dc 84 | return dc 85 | else: 86 | return Any 87 | 88 | 89 | def print_class(cls): 90 | print("@dataclass") 91 | print(f"class {cls.__name__}:") 92 | for field in fields(cls): 93 | if type(field.type) == type(List): 94 | default = "field(default_factory=list)" 95 | print(f" {field.name}: List[{field.type.__args__[0].__name__}] = {default}") 96 | else: 97 | if field.type == str: 98 | default = '""' 99 | print(f" {field.name}: {field.type.__name__} = {default}") 100 | elif field.type in {int, float, bool}: 101 | default = field.type() 102 | print(f" {field.name}: {field.type.__name__} = {default}") 103 | else: 104 | default = None 105 | print(f" {field.name}: Optional[{field.type.__name__}] = {default}") 106 | print() 107 | 108 | 109 | def main(): 110 | global classes 111 | 112 | classes_per_category = {} 113 | for deep in (False, True): 114 | name = "DeepReplay" if deep else "ShallowReplay" 115 | it = get_diverse_replays(deep=deep) 116 | # it = tqdm(it) 117 | for replay in it: 118 | structure = get_structure(name, replay) 119 | # break 120 | 121 | for cls in classes.values(): 122 | print_class(cls) 123 | print("=" * 80) 124 | classes_per_category[name] = classes 125 | classes = {} 126 | 127 | for deep in (False, True): 128 | name = "DeepGroup" if deep else "ShallowGroup" 129 | for group in get_diverse_groups(deep=deep): 130 | structure = get_structure(name, group) 131 | # break 132 | 133 | for cls in classes.values(): 134 | print_class(cls) 135 | print("=" * 80) 136 | 137 | classes_per_category[name] = classes 138 | classes = {} 139 | 140 | # Print all classes without duplicates 141 | 142 | all_classes = {} 143 | for category, cls_dict in classes_per_category.items(): 144 | for cls_name, cls in cls_dict.items(): 145 | # for cls_name2, cls2 in all_classes.items(): 146 | # share_all = True 147 | # if len(fields(cls2)) != len(fields(cls)): 148 | # share_all = False 149 | # elif set(f.name for f in fields(cls2)) != set(f.name for f in fields(cls)): 150 | # share_all = False 151 | # else: 152 | # for f in fields(cls2): 153 | # if f.type != next((f2.type for f2 in fields(cls) if f2.name == f.name), None): 154 | # share_all = False 155 | # break 156 | # if share_all and cls_name != cls_name2: 157 | # new_name = f"shared_{cls_name}_{cls_name2}" 158 | # new_cls = make_dataclass(new_name, [(f.name, f.type) for f in fields(cls)]) 159 | # all_classes[new_name] = new_cls 160 | # break 161 | # else: # No break 162 | # all_classes[cls_name] = cls 163 | if cls_name not in all_classes: 164 | all_classes[cls_name] = cls 165 | else: 166 | # Check that they share all fields (name and type) 167 | existing_cls = all_classes[cls_name] 168 | share_all = True 169 | if len(fields(existing_cls)) != len(fields(cls)): 170 | share_all = False 171 | elif set(f.name for f in fields(existing_cls)) != set(f.name for f in fields(cls)): 172 | share_all = False 173 | else: 174 | for f in fields(existing_cls): 175 | if f.type != next((f2.type for f2 in fields(cls) if f2.name == f.name), None): 176 | share_all = False 177 | break 178 | if not share_all: 179 | all_classes[f"{cls_name}_{category}"] = cls 180 | for cls in all_classes.values(): 181 | print_class(cls) 182 | 183 | print("=" * 80) 184 | # Find unique classes (by fields) 185 | groups = {} 186 | for cls_name, cls in all_classes.items(): 187 | fields_set = frozenset((f.name, f.type) for f in fields(cls)) 188 | if fields_set not in groups: 189 | groups[fields_set] = [] 190 | groups[fields_set].append(cls_name) 191 | 192 | 193 | if __name__ == '__main__': 194 | main() 195 | -------------------------------------------------------------------------------- /ballchasing/typed/shared.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, fields, field 2 | from datetime import datetime 3 | from functools import total_ordering 4 | from typing import get_args, Optional, List 5 | 6 | from ballchasing.util import from_rfc3339 7 | 8 | 9 | class _DictToTypeMixin: 10 | """ 11 | Mixin class to convert dicts to their respective types in dataclasses. 12 | """ 13 | 14 | def __post_init__(self): 15 | for field in fields(self): 16 | value = getattr(self, field.name) 17 | actual_type = field.type 18 | if args := get_args(field.type): 19 | actual_type = args[0] 20 | if isinstance(value, dict): 21 | new_value = actual_type(**value) 22 | setattr(self, field.name, new_value) 23 | elif isinstance(value, list): 24 | new_value = [actual_type(**item) if isinstance(item, dict) else item for item in value] 25 | setattr(self, field.name, new_value) 26 | elif isinstance(value, str) and actual_type is datetime: 27 | # Load rfc3339 formatted strings as datetime objects 28 | new_value = from_rfc3339(value) 29 | setattr(self, field.name, new_value) 30 | # else: # For testing 31 | # # Check that the value has the type specified in the dataclass field 32 | # if value is not None and not isinstance(value, actual_type): 33 | # if isinstance(value, int) and actual_type is float: 34 | # pass # ints can be converted losslessly to floats 35 | # else: 36 | # raise TypeError(f"Expected {field.name} to be of type {actual_type}, got {type(value)}") 37 | 38 | 39 | @dataclass 40 | class User: 41 | steam_id: str = "" 42 | name: str = "" 43 | profile_url: str = "" 44 | avatar: str = "" 45 | 46 | def __eq__(self, other): 47 | if isinstance(other, User): 48 | return self.steam_id == other.steam_id 49 | return False 50 | 51 | 52 | @dataclass 53 | class PlayerID: 54 | platform: str = "" 55 | id: str = "" 56 | player_number: int = 0 57 | 58 | def __repr__(self): 59 | if self.player_number == 0: 60 | return f"{self.platform}:{self.id}" 61 | return f"{self.platform}:{self.id}#{self.player_number}" 62 | 63 | 64 | @total_ordering 65 | @dataclass 66 | class Rank: 67 | tier: int = 0 68 | division: int = 0 69 | name: str = "" 70 | id: str = "" 71 | 72 | def __eq__(self, other): 73 | return self.tier == other.tier and self.division == other.division 74 | 75 | def __lt__(self, other): 76 | return (self.tier, self.division) < (other.tier, other.division) 77 | 78 | @classmethod 79 | def from_id(cls, rank_id: str): 80 | """ 81 | Create a Rank object from a rank ID string, e.g. "grand-champion-1" 82 | """ 83 | from ballchasing.constants import Rank as RankConstants 84 | ranks = [r for r in RankConstants.ALL if r != "grand-champion"] 85 | if rank_id == RankConstants.GRAND_CHAMPION_LEGACY: 86 | rank_id = RankConstants.GRAND_CHAMPION_1 87 | try: 88 | tier = ranks.index(rank_id) 89 | division = 0 # Division 0 is not really a thing but ID doesn't include it 90 | name = rank_id.replace("-", " ").title().strip() 91 | name = name.replace("1", "I").replace("2", "II").replace("3", "III") 92 | name = name + " Division 0" 93 | return cls(tier=tier, division=division, name=name, id=rank_id) 94 | except ValueError: 95 | raise ValueError(f"Invalid rank ID: {rank_id}. Must be one of {ranks}.") 96 | 97 | @classmethod 98 | def from_name(cls, rank_name: str): 99 | rank_id = rank_name.lower().replace(" ", "-") 100 | rank_id = rank_id.replace("i", "1").replace("ii", "2").replace("iii", "3") 101 | if "-division-" in rank_id: 102 | rank_id, division = rank_id.split("-division-") 103 | division = int(division) 104 | else: 105 | division = 0 106 | 107 | res = cls.from_id(rank_id) 108 | res.division = division 109 | res.name = rank_name 110 | return res 111 | 112 | 113 | @dataclass 114 | class BasePlayer(_DictToTypeMixin): 115 | start_time: float = 0.0 116 | end_time: float = 0.0 117 | name: str = "" 118 | id: Optional[PlayerID] = None 119 | pro: bool = False 120 | mvp: bool = False 121 | rank: Optional[Rank] = None 122 | 123 | def __eq__(self, other): 124 | if isinstance(other, BasePlayer): 125 | return self.id == other.id 126 | return NotImplemented 127 | 128 | def is_bot(self): 129 | return self.id.id == "" and self.id.platform == "" 130 | 131 | 132 | @dataclass 133 | class BaseTeam(_DictToTypeMixin): 134 | name: str = "" 135 | # Further specified in subclasses, needed for methods: 136 | players: List[BasePlayer] = field(default_factory=list) 137 | 138 | def __eq__(self, other): 139 | # Teams are equal if all players are the same 140 | if isinstance(other, BaseTeam): 141 | for p1 in self.players: 142 | for p2 in other.players: 143 | if p1 == p2: 144 | break 145 | else: 146 | return False 147 | return True 148 | return NotImplemented 149 | 150 | 151 | @dataclass 152 | class BasicGroup: # Shared between replays and shallow/deep groups 153 | id: str = "" 154 | name: str = "" 155 | link: str = "" 156 | 157 | def __eq__(self, other): 158 | if isinstance(other, BasicGroup): 159 | return self.id == other.id 160 | return NotImplemented 161 | 162 | 163 | @dataclass 164 | class BaseGroup(BasicGroup, _DictToTypeMixin): # Shared between shallow and deep groups 165 | created: Optional[datetime] = "" 166 | player_identification: str = "" 167 | team_identification: str = "" 168 | shared: bool = False 169 | 170 | 171 | @dataclass 172 | class BaseReplay(_DictToTypeMixin): # Shared between shallow and deep replays 173 | id: str = "" 174 | link: str = "" 175 | rocket_league_id: str = "" 176 | recorder: str = "" 177 | map_code: str = "" 178 | map_name: str = "" 179 | playlist_id: str = "" 180 | playlist_name: str = "" 181 | duration: int = 0 182 | overtime: bool = False 183 | overtime_seconds: int = 0 184 | season: int = 0 185 | season_type: str = "" 186 | date: Optional[datetime] = None 187 | visibility: str = "" 188 | created: Optional[datetime] = None 189 | uploader: Optional[User] = None 190 | min_rank: Optional[Rank] = None 191 | max_rank: Optional[Rank] = None 192 | groups: List[BasicGroup] = field(default_factory=list) 193 | # Further specified in subclasses, needed for methods: 194 | blue: Optional[BaseTeam] = None 195 | orange: Optional[BaseTeam] = None 196 | 197 | def players(self): 198 | blue = self.blue.players if self.blue else [] 199 | orange = self.orange.players if self.orange else [] 200 | return blue + orange 201 | 202 | def teams(self): 203 | teams = [] 204 | if self.blue: 205 | teams.append(self.blue) 206 | if self.orange: 207 | teams.append(self.orange) 208 | return teams 209 | 210 | def team_sizes(self): 211 | bs = len(self.blue.players) if self.blue else 0 212 | os = len(self.orange.players) if self.orange else 0 213 | return bs, os 214 | 215 | def __eq__(self, other): 216 | if isinstance(other, BaseReplay): 217 | return self.id == other.id or self.rocket_league_id == other.rocket_league_id 218 | return NotImplemented 219 | -------------------------------------------------------------------------------- /ballchasing/typed/deep_replay.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Optional, List 3 | 4 | from ballchasing.typed.shared import _DictToTypeMixin, BasePlayer, BaseReplay, BaseTeam 5 | 6 | 7 | @dataclass 8 | class CameraSettings: 9 | fov: int = 0 10 | height: int = 0 11 | pitch: int = 0 12 | distance: int = 0 13 | stiffness: float = 0.0 14 | swivel_speed: float = 0.0 15 | transition_speed: int = 0 16 | 17 | 18 | @dataclass 19 | class TeamBallStatsDR: 20 | possession_time: float = 0.0 21 | time_in_side: float = 0.0 22 | 23 | 24 | @dataclass 25 | class TeamCoreStatsDR: 26 | shots: int = 0 27 | shots_against: int = 0 28 | goals: int = 0 29 | goals_against: int = 0 30 | saves: int = 0 31 | assists: int = 0 32 | score: int = 0 33 | shooting_percentage: int = 0 34 | 35 | 36 | @dataclass 37 | class PlayerCoreStatsDR: 38 | shots: int = 0 39 | shots_against: int = 0 40 | goals: int = 0 41 | goals_against: int = 0 42 | saves: int = 0 43 | assists: int = 0 44 | score: int = 0 45 | mvp: bool = False 46 | shooting_percentage: int = 0 47 | 48 | 49 | @dataclass 50 | class TeamBoostStatsDR: 51 | bpm: int = 0 52 | bcpm: float = 0.0 53 | avg_amount: float = 0.0 54 | amount_collected: int = 0 55 | amount_stolen: int = 0 56 | amount_collected_big: int = 0 57 | amount_stolen_big: int = 0 58 | amount_collected_small: int = 0 59 | amount_stolen_small: int = 0 60 | count_collected_big: int = 0 61 | count_stolen_big: int = 0 62 | count_collected_small: int = 0 63 | count_stolen_small: int = 0 64 | amount_overfill: int = 0 65 | amount_overfill_stolen: int = 0 66 | amount_used_while_supersonic: int = 0 67 | time_zero_boost: float = 0.0 68 | time_full_boost: float = 0.0 69 | time_boost_0_25: float = 0.0 70 | time_boost_25_50: float = 0.0 71 | time_boost_50_75: float = 0.0 72 | time_boost_75_100: float = 0.0 73 | 74 | 75 | @dataclass 76 | class PlayerBoostStatsDR: 77 | bpm: int = 0 78 | bcpm: float = 0.0 79 | avg_amount: float = 0.0 80 | amount_collected: int = 0 81 | amount_stolen: int = 0 82 | amount_collected_big: int = 0 83 | amount_stolen_big: int = 0 84 | amount_collected_small: int = 0 85 | amount_stolen_small: int = 0 86 | count_collected_big: int = 0 87 | count_stolen_big: int = 0 88 | count_collected_small: int = 0 89 | count_stolen_small: int = 0 90 | amount_overfill: int = 0 91 | amount_overfill_stolen: int = 0 92 | amount_used_while_supersonic: int = 0 93 | time_zero_boost: float = 0.0 94 | percent_zero_boost: float = 0.0 95 | time_full_boost: float = 0.0 96 | percent_full_boost: float = 0.0 97 | time_boost_0_25: float = 0.0 98 | time_boost_25_50: float = 0.0 99 | time_boost_50_75: float = 0.0 100 | time_boost_75_100: float = 0.0 101 | percent_boost_0_25: float = 0.0 102 | percent_boost_25_50: float = 0.0 103 | percent_boost_50_75: float = 0.0 104 | percent_boost_75_100: float = 0.0 105 | 106 | 107 | @dataclass 108 | class TeamMovementStatsDR: 109 | total_distance: int = 0 110 | time_supersonic_speed: float = 0.0 111 | time_boost_speed: float = 0.0 112 | time_slow_speed: float = 0.0 113 | time_ground: float = 0.0 114 | time_low_air: float = 0.0 115 | time_high_air: float = 0.0 116 | time_powerslide: float = 0.0 117 | count_powerslide: int = 0 118 | 119 | 120 | @dataclass 121 | class PlayerMovementStatsDR: 122 | avg_speed: int = 0 123 | total_distance: int = 0 124 | time_supersonic_speed: float = 0.0 125 | time_boost_speed: float = 0.0 126 | time_slow_speed: float = 0.0 127 | time_ground: float = 0.0 128 | time_low_air: float = 0.0 129 | time_high_air: float = 0.0 130 | time_powerslide: float = 0.0 131 | count_powerslide: int = 0 132 | avg_powerslide_duration: float = 0.0 133 | avg_speed_percentage: float = 0.0 134 | percent_slow_speed: float = 0.0 135 | percent_boost_speed: float = 0.0 136 | percent_supersonic_speed: float = 0.0 137 | percent_ground: float = 0.0 138 | percent_low_air: float = 0.0 139 | percent_high_air: float = 0.0 140 | 141 | 142 | @dataclass 143 | class TeamPositioningStatsDR: 144 | time_defensive_third: float = 0.0 145 | time_neutral_third: float = 0.0 146 | time_offensive_third: float = 0.0 147 | time_defensive_half: float = 0.0 148 | time_offensive_half: float = 0.0 149 | time_behind_ball: float = 0.0 150 | time_infront_ball: float = 0.0 151 | 152 | 153 | @dataclass 154 | class PlayerPositioningStatsDR: 155 | avg_distance_to_ball: int = 0 156 | avg_distance_to_ball_possession: int = 0 157 | avg_distance_to_ball_no_possession: int = 0 158 | avg_distance_to_mates: int = 0 159 | time_defensive_third: float = 0.0 160 | time_neutral_third: float = 0.0 161 | time_offensive_third: float = 0.0 162 | time_defensive_half: float = 0.0 163 | time_offensive_half: float = 0.0 164 | time_behind_ball: float = 0.0 165 | time_infront_ball: float = 0.0 166 | time_most_back: float = 0.0 167 | time_most_forward: float = 0.0 168 | time_closest_to_ball: float = 0.0 169 | time_farthest_from_ball: float = 0.0 170 | percent_defensive_third: float = 0.0 171 | percent_offensive_third: float = 0.0 172 | percent_neutral_third: float = 0.0 173 | percent_defensive_half: float = 0.0 174 | percent_offensive_half: float = 0.0 175 | percent_behind_ball: float = 0.0 176 | percent_infront_ball: float = 0.0 177 | percent_most_back: float = 0.0 178 | percent_most_forward: float = 0.0 179 | percent_closest_to_ball: float = 0.0 180 | percent_farthest_from_ball: float = 0.0 181 | goals_against_while_last_defender: int = 0 182 | 183 | 184 | @dataclass 185 | class TeamDemoStatsDR: 186 | inflicted: int = 0 187 | taken: int = 0 188 | 189 | 190 | @dataclass 191 | class PlayerDemoStatsDR: 192 | inflicted: int = 0 193 | taken: int = 0 194 | 195 | 196 | @dataclass 197 | class TeamStatsDR(_DictToTypeMixin): 198 | ball: Optional[TeamBallStatsDR] = None 199 | core: Optional[TeamCoreStatsDR] = None 200 | boost: Optional[TeamBoostStatsDR] = None 201 | movement: Optional[TeamMovementStatsDR] = None 202 | positioning: Optional[TeamPositioningStatsDR] = None 203 | demo: Optional[TeamDemoStatsDR] = None 204 | 205 | 206 | @dataclass 207 | class PlayerStatsDR(_DictToTypeMixin): 208 | core: Optional[PlayerCoreStatsDR] = None 209 | boost: Optional[PlayerBoostStatsDR] = None 210 | movement: Optional[PlayerMovementStatsDR] = None 211 | positioning: Optional[PlayerPositioningStatsDR] = None 212 | demo: Optional[PlayerDemoStatsDR] = None 213 | 214 | 215 | @dataclass 216 | class PlayerDR(BasePlayer): 217 | # start_time: float = 0.0 218 | # end_time: float = 0.0 219 | # name: str = "" 220 | # id: Optional[PlayerID] = None 221 | # pro: bool = False 222 | # mvp: bool = False 223 | # rank: Optional[Rank] = None 224 | car_id: int = 0 225 | car_name: str = "" 226 | camera: Optional[CameraSettings] = None 227 | steering_sensitivity: float = 0 228 | stats: Optional[PlayerStatsDR] = None 229 | 230 | 231 | @dataclass 232 | class TeamDR(BaseTeam): 233 | # name: str = "" 234 | color: str = "" 235 | players: List[PlayerDR] = field(default_factory=list) 236 | stats: Optional[TeamStatsDR] = None 237 | 238 | 239 | @dataclass 240 | class Server: 241 | name: str = "" 242 | region: str = "" 243 | 244 | 245 | @dataclass 246 | class DeepReplay(BaseReplay): 247 | # id: str = "" 248 | # link: str = "" 249 | # rocket_league_id: str = "" 250 | # recorder: str = "" 251 | # map_code: str = "" 252 | # map_name: str = "" 253 | # playlist_id: str = "" 254 | # playlist_name: str = "" 255 | # duration: int = 0 256 | # overtime: bool = False 257 | # overtime_seconds: int = 0 258 | # season: int = 0 259 | # season_type: str = "" 260 | # date: Optional[datetime] = None 261 | # visibility: str = "" 262 | # created: Optional[datetime] = None 263 | # uploader: Optional[User] = None 264 | # min_rank: Optional[Rank] = None 265 | # max_rank: Optional[Rank] = None 266 | # groups: List[BaseGroup] = field(default_factory=list) 267 | title: str = "" # replay_title in shallow replay 268 | date_has_timezone: bool = False # date_has_tz in shallow replay 269 | status: str = "" 270 | match_guid: str = "" 271 | match_type: str = "" 272 | team_size: int = 0 273 | blue: Optional[TeamDR] = None 274 | orange: Optional[TeamDR] = None 275 | server: Optional[Server] = None 276 | 277 | def scoreline(self): 278 | try: 279 | bg = self.blue.stats.core.goals 280 | except AttributeError: 281 | bg = 0 282 | try: 283 | og = self.orange.stats.core.goals 284 | except AttributeError: 285 | og = 0 286 | return bg, og 287 | -------------------------------------------------------------------------------- /ballchasing/stats_info.tsv: -------------------------------------------------------------------------------- 1 | name path is_replay is_team is_player type is_player_sum dtype 2 | replay_id id TRUE TRUE TRUE info FALSE str 3 | rocket_league_id rocket_league_id TRUE FALSE FALSE info FALSE str 4 | title title TRUE FALSE FALSE info FALSE str 5 | created created TRUE FALSE FALSE info FALSE datetime 6 | date date TRUE FALSE FALSE info FALSE datetime 7 | map_code map_code TRUE FALSE FALSE info FALSE str 8 | match_type match_type TRUE FALSE FALSE info FALSE str 9 | team_size team_size TRUE FALSE FALSE info FALSE int 10 | uploader uploader.steam_id TRUE FALSE FALSE info FALSE str 11 | status status TRUE FALSE FALSE info FALSE str 12 | duration duration TRUE TRUE FALSE info FALSE float 13 | overtime_duration overtime_seconds TRUE FALSE FALSE info FALSE float 14 | playlist_id playlist_id TRUE FALSE FALSE info FALSE str 15 | season season TRUE FALSE FALSE info FALSE str 16 | season_type season_type TRUE FALSE FALSE info FALSE str 17 | visibility visibility TRUE FALSE FALSE info FALSE str 18 | min_rank min_rank.name TRUE FALSE FALSE info FALSE str 19 | max_rank max_rank.name TRUE FALSE FALSE info FALSE str 20 | color {}.color FALSE TRUE TRUE info FALSE str 21 | team_name {}.name FALSE TRUE FALSE info FALSE str 22 | possession_time {}.stats.ball.possession_time FALSE TRUE FALSE time FALSE float 23 | time_in_side {}.stats.ball.time_in_side FALSE TRUE FALSE time FALSE float 24 | shots {}.stats.core.shots FALSE TRUE FALSE normalizable TRUE int 25 | shots_against {}.stats.core.shots_against FALSE TRUE FALSE normalizable TRUE int 26 | goals {}.stats.core.goals FALSE TRUE FALSE normalizable TRUE int 27 | goals_against {}.stats.core.goals_against FALSE TRUE FALSE normalizable TRUE int 28 | saves {}.stats.core.saves FALSE TRUE FALSE normalizable TRUE int 29 | assists {}.stats.core.assists FALSE TRUE FALSE normalizable TRUE int 30 | score {}.stats.core.score FALSE TRUE FALSE normalizable TRUE int 31 | shooting_percentage {}.stats.core.shooting_percentage FALSE TRUE FALSE average TRUE int 32 | bpm {}.stats.boost.bpm FALSE TRUE FALSE average TRUE float 33 | bcpm {}.stats.boost.bcpm FALSE TRUE FALSE average TRUE float 34 | avg_boost_amount {}.stats.boost.avg_amount FALSE TRUE FALSE average TRUE float 35 | amount_collected {}.stats.boost.amount_collected FALSE TRUE FALSE normalizable TRUE float 36 | amount_stolen {}.stats.boost.amount_stolen FALSE TRUE FALSE normalizable TRUE float 37 | amount_collected_big {}.stats.boost.amount_collected_big FALSE TRUE FALSE normalizable TRUE float 38 | amount_stolen_big {}.stats.boost.amount_stolen_big FALSE TRUE FALSE normalizable TRUE float 39 | amount_collected_small {}.stats.boost.amount_collected_small FALSE TRUE FALSE normalizable TRUE float 40 | amount_stolen_small {}.stats.boost.amount_stolen_small FALSE TRUE FALSE normalizable TRUE float 41 | count_collected_big {}.stats.boost.count_collected_big FALSE TRUE FALSE normalizable TRUE int 42 | count_stolen_big {}.stats.boost.count_stolen_big FALSE TRUE FALSE normalizable TRUE int 43 | count_collected_small {}.stats.boost.count_collected_small FALSE TRUE FALSE normalizable TRUE int 44 | count_stolen_small {}.stats.boost.count_stolen_small FALSE TRUE FALSE normalizable TRUE int 45 | amount_overfill {}.stats.boost.amount_overfill FALSE TRUE FALSE normalizable TRUE float 46 | amount_overfill_stolen {}.stats.boost.amount_overfill_stolen FALSE TRUE FALSE normalizable TRUE float 47 | amount_used_while_supersonic {}.stats.boost.amount_used_while_supersonic FALSE TRUE FALSE normalizable TRUE float 48 | time_zero_boost {}.stats.boost.time_zero_boost FALSE TRUE FALSE time TRUE float 49 | time_full_boost {}.stats.boost.time_full_boost FALSE TRUE FALSE time TRUE float 50 | time_boost_0_25 {}.stats.boost.time_boost_0_25 FALSE TRUE FALSE time TRUE float 51 | time_boost_25_50 {}.stats.boost.time_boost_25_50 FALSE TRUE FALSE time TRUE float 52 | time_boost_50_75 {}.stats.boost.time_boost_50_75 FALSE TRUE FALSE time TRUE float 53 | time_boost_75_100 {}.stats.boost.time_boost_75_100 FALSE TRUE FALSE time TRUE float 54 | time_supersonic_speed {}.stats.movement.time_supersonic_speed FALSE TRUE FALSE time TRUE float 55 | time_boost_speed {}.stats.movement.time_boost_speed FALSE TRUE FALSE time TRUE float 56 | time_slow_speed {}.stats.movement.time_slow_speed FALSE TRUE FALSE time TRUE float 57 | time_ground {}.stats.movement.time_ground FALSE TRUE FALSE time TRUE float 58 | time_low_air {}.stats.movement.time_low_air FALSE TRUE FALSE time TRUE float 59 | time_high_air {}.stats.movement.time_high_air FALSE TRUE FALSE time TRUE float 60 | time_powerslide {}.stats.movement.time_powerslide FALSE TRUE FALSE time TRUE float 61 | count_powerslide {}.stats.movement.count_powerslide FALSE TRUE FALSE normalizable TRUE int 62 | time_defensive_third {}.stats.positioning.time_defensive_third FALSE TRUE FALSE time TRUE float 63 | time_neutral_third {}.stats.positioning.time_neutral_third FALSE TRUE FALSE time TRUE float 64 | time_offensive_third {}.stats.positioning.time_offensive_third FALSE TRUE FALSE time TRUE float 65 | time_defensive_half {}.stats.positioning.time_defensive_half FALSE TRUE FALSE time TRUE float 66 | time_offensive_half {}.stats.positioning.time_offensive_half FALSE TRUE FALSE time TRUE float 67 | time_behind_ball {}.stats.positioning.time_behind_ball FALSE TRUE FALSE time TRUE float 68 | time_infront_ball {}.stats.positioning.time_infront_ball FALSE TRUE FALSE time TRUE float 69 | demos_inflicted {}.stats.demo.inflicted FALSE TRUE FALSE normalizable TRUE int 70 | demos_taken {}.stats.demo.taken FALSE TRUE FALSE normalizable TRUE int 71 | start_time {}.players.{}.start_time FALSE FALSE TRUE info FALSE float 72 | end_time {}.players.{}.end_time FALSE FALSE TRUE info FALSE float 73 | player_name {}.players.{}.name FALSE FALSE TRUE info FALSE str 74 | platform {}.players.{}.id.platform FALSE FALSE TRUE info FALSE str 75 | player_id {}.players.{}.id.id FALSE FALSE TRUE info FALSE str 76 | rank {}.players.{}.rank.tier FALSE FALSE TRUE info FALSE int 77 | division {}.players.{}.rank.division FALSE FALSE TRUE info FALSE int 78 | car_id {}.players.{}.car_id FALSE FALSE TRUE info FALSE int 79 | car_name {}.players.{}.car_name FALSE FALSE TRUE info FALSE str 80 | camera_fov {}.players.{}.camera.fov FALSE FALSE TRUE info FALSE float 81 | camera_height {}.players.{}.camera.height FALSE FALSE TRUE info FALSE float 82 | camera_pitch {}.players.{}.camera.pitch FALSE FALSE TRUE info FALSE float 83 | camera_distance {}.players.{}.camera.distance FALSE FALSE TRUE info FALSE float 84 | camera_stiffness {}.players.{}.camera.stiffness FALSE FALSE TRUE info FALSE float 85 | camera_swivel_speed {}.players.{}.camera.swivel_speed FALSE FALSE TRUE info FALSE float 86 | camera_transition_speed {}.players.{}.camera.transition_speed FALSE FALSE TRUE info FALSE float 87 | steering_sensitivity {}.players.{}.steering_sensitivity FALSE FALSE TRUE info FALSE float 88 | shots {}.players.{}.stats.core.shots FALSE FALSE TRUE normalizable FALSE int 89 | goals {}.players.{}.stats.core.goals FALSE FALSE TRUE normalizable FALSE int 90 | saves {}.players.{}.stats.core.saves FALSE FALSE TRUE normalizable FALSE int 91 | assists {}.players.{}.stats.core.assists FALSE FALSE TRUE normalizable FALSE int 92 | score {}.players.{}.stats.core.score FALSE FALSE TRUE normalizable FALSE int 93 | mvp {}.players.{}.stats.core.mvp FALSE FALSE TRUE info FALSE bool 94 | shooting_percentage {}.players.{}.stats.core.shooting_percentage FALSE FALSE TRUE average FALSE float 95 | bpm {}.players.{}.stats.boost.bpm FALSE FALSE TRUE average FALSE float 96 | bcpm {}.players.{}.stats.boost.bcpm FALSE FALSE TRUE average FALSE float 97 | avg_amount {}.players.{}.stats.boost.avg_amount FALSE FALSE TRUE average FALSE float 98 | amount_collected {}.players.{}.stats.boost.amount_collected FALSE FALSE TRUE normalizable FALSE float 99 | amount_stolen {}.players.{}.stats.boost.amount_stolen FALSE FALSE TRUE normalizable FALSE float 100 | amount_collected_big {}.players.{}.stats.boost.amount_collected_big FALSE FALSE TRUE normalizable FALSE float 101 | amount_stolen_big {}.players.{}.stats.boost.amount_stolen_big FALSE FALSE TRUE normalizable FALSE float 102 | amount_collected_small {}.players.{}.stats.boost.amount_collected_small FALSE FALSE TRUE normalizable FALSE float 103 | amount_stolen_small {}.players.{}.stats.boost.amount_stolen_small FALSE FALSE TRUE normalizable FALSE float 104 | count_collected_big {}.players.{}.stats.boost.count_collected_big FALSE FALSE TRUE normalizable FALSE int 105 | count_stolen_big {}.players.{}.stats.boost.count_stolen_big FALSE FALSE TRUE normalizable FALSE int 106 | count_collected_small {}.players.{}.stats.boost.count_collected_small FALSE FALSE TRUE normalizable FALSE int 107 | count_stolen_small {}.players.{}.stats.boost.count_stolen_small FALSE FALSE TRUE normalizable FALSE int 108 | amount_overfill {}.players.{}.stats.boost.amount_overfill FALSE FALSE TRUE normalizable FALSE float 109 | amount_overfill_stolen {}.players.{}.stats.boost.amount_overfill_stolen FALSE FALSE TRUE normalizable FALSE float 110 | amount_used_while_supersonic {}.players.{}.stats.boost.amount_used_while_supersonic FALSE FALSE TRUE normalizable FALSE float 111 | percent_zero_boost {}.players.{}.stats.boost.percent_zero_boost FALSE FALSE TRUE average FALSE float 112 | percent_full_boost {}.players.{}.stats.boost.percent_full_boost FALSE FALSE TRUE average FALSE float 113 | percent_boost_0_25 {}.players.{}.stats.boost.percent_boost_0_25 FALSE FALSE TRUE average FALSE float 114 | percent_boost_25_50 {}.players.{}.stats.boost.percent_boost_25_50 FALSE FALSE TRUE average FALSE float 115 | percent_boost_50_75 {}.players.{}.stats.boost.percent_boost_50_75 FALSE FALSE TRUE average FALSE float 116 | percent_boost_75_100 {}.players.{}.stats.boost.percent_boost_75_100 FALSE FALSE TRUE average FALSE float 117 | avg_speed {}.players.{}.stats.movement.avg_speed FALSE FALSE TRUE average FALSE float 118 | count_powerslide {}.players.{}.stats.movement.count_powerslide FALSE FALSE TRUE normalizable FALSE int 119 | avg_powerslide_duration {}.players.{}.stats.movement.avg_powerslide_duration FALSE FALSE TRUE average FALSE float 120 | percent_slow_speed {}.players.{}.stats.movement.percent_slow_speed FALSE FALSE TRUE average FALSE float 121 | percent_boost_speed {}.players.{}.stats.movement.percent_boost_speed FALSE FALSE TRUE average FALSE float 122 | percent_supersonic_speed {}.players.{}.stats.movement.percent_supersonic_speed FALSE FALSE TRUE average FALSE float 123 | percent_ground {}.players.{}.stats.movement.percent_ground FALSE FALSE TRUE average FALSE float 124 | percent_low_air {}.players.{}.stats.movement.percent_low_air FALSE FALSE TRUE average FALSE float 125 | percent_high_air {}.players.{}.stats.movement.percent_high_air FALSE FALSE TRUE average FALSE float 126 | avg_distance_to_ball {}.players.{}.stats.positioning.avg_distance_to_ball FALSE FALSE TRUE average FALSE float 127 | avg_distance_to_ball_possession {}.players.{}.stats.positioning.avg_distance_to_ball_possession FALSE FALSE TRUE average FALSE float 128 | avg_distance_to_ball_no_possession {}.players.{}.stats.positioning.avg_distance_to_ball_no_possession FALSE FALSE TRUE average FALSE float 129 | avg_distance_to_mates {}.players.{}.stats.positioning.avg_distance_to_mates FALSE FALSE TRUE average FALSE float 130 | goals_against_while_last_defender {}.players.{}.stats.positioning.goals_against_while_last_defender FALSE FALSE TRUE normalizable FALSE int 131 | percent_defensive_third {}.players.{}.stats.positioning.percent_defensive_third FALSE FALSE TRUE average FALSE float 132 | percent_offensive_third {}.players.{}.stats.positioning.percent_offensive_third FALSE FALSE TRUE average FALSE float 133 | percent_neutral_third {}.players.{}.stats.positioning.percent_neutral_third FALSE FALSE TRUE average FALSE float 134 | percent_defensive_half {}.players.{}.stats.positioning.percent_defensive_half FALSE FALSE TRUE average FALSE float 135 | percent_offensive_half {}.players.{}.stats.positioning.percent_offensive_half FALSE FALSE TRUE average FALSE float 136 | percent_behind_ball {}.players.{}.stats.positioning.percent_behind_ball FALSE FALSE TRUE average FALSE float 137 | percent_infront_ball {}.players.{}.stats.positioning.percent_infront_ball FALSE FALSE TRUE average FALSE float 138 | percent_most_back {}.players.{}.stats.positioning.percent_most_back FALSE FALSE TRUE average FALSE float 139 | percent_most_forward {}.players.{}.stats.positioning.percent_most_forward FALSE FALSE TRUE average FALSE float 140 | percent_closest_to_ball {}.players.{}.stats.positioning.percent_closest_to_ball FALSE FALSE TRUE average FALSE float 141 | percent_farthest_from_ball {}.players.{}.stats.positioning.percent_farthest_from_ball FALSE FALSE TRUE average FALSE float 142 | demos_inflicted {}.players.{}.stats.demo.inflicted FALSE FALSE TRUE normalizable FALSE int 143 | demos_taken {}.players.{}.stats.demo.taken FALSE FALSE TRUE normalizable FALSE int -------------------------------------------------------------------------------- /ballchasing/constants.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, get_args, AnyStr, Union 2 | 3 | AnyPlaylist = Union[ 4 | AnyStr, 5 | Literal[ 6 | "unranked-duels", "unranked-doubles", "unranked-standard", "unranked-chaos", 7 | "private", "season", "offline", "local-lobby", 8 | "ranked-duels", "ranked-doubles", "ranked-solo-standard", "ranked-standard", 9 | "snowday", "rocketlabs", "hoops", "rumble", "tournament", "dropshot", 10 | "ranked-hoops", "ranked-rumble", "ranked-dropshot", "ranked-snowday", 11 | "dropshot-rumble", "heatseeker", "gridiron", "spooky-cube" 12 | ] 13 | ] 14 | 15 | 16 | def _get_literals(type_): 17 | return get_args(get_args(type_)[1]) 18 | 19 | 20 | class Playlist: 21 | ALL = (UNRANKED_DUELS, UNRANKED_DOUBLES, UNRANKED_STANDARD, UNRANKED_CHAOS, PRIVATE, SEASON, OFFLINE, LOCAL_LOBBY, 22 | RANKED_DUELS, RANKED_DOUBLES, RANKED_SOLO_STANDARD, RANKED_STANDARD, SNOWDAY, ROCKETLABS, HOOPS, RUMBLE, 23 | TOURNAMENT, DROPSHOT, RANKED_HOOPS, RANKED_RUMBLE, RANKED_DROPSHOT, RANKED_SNOWDAY, DROPSHOT_RUMBLE, 24 | HEATSEEKER, GRIDIRON, SPOOKY_CUBE) = _get_literals(AnyPlaylist) 25 | # Categories as listed on ballchasing: 26 | RANKED = (RANKED_DUELS, RANKED_DOUBLES, RANKED_STANDARD, RANKED_SOLO_STANDARD) 27 | UNRANKED = (UNRANKED_DUELS, UNRANKED_DOUBLES, UNRANKED_STANDARD, UNRANKED_CHAOS) 28 | EXTRA_MODES = (RANKED_HOOPS, RANKED_RUMBLE, RANKED_DROPSHOT, RANKED_SNOWDAY) 29 | OTHER_MODES = (SNOWDAY, ROCKETLABS, HOOPS, RUMBLE, TOURNAMENT, DROPSHOT, ROCKETLABS, DROPSHOT_RUMBLE, HEATSEEKER, 30 | GRIDIRON, SPOOKY_CUBE) 31 | MISC = (PRIVATE, SEASON, OFFLINE, LOCAL_LOBBY) 32 | 33 | 34 | AnyRank = Union[ 35 | AnyStr, 36 | Literal[ 37 | "unranked", 38 | "bronze-1", "bronze-2", "bronze-3", 39 | "silver-1", "silver-2", "silver-3", 40 | "gold-1", "gold-2", "gold-3", 41 | "platinum-1", "platinum-2", "platinum-3", 42 | "diamond-1", "diamond-2", "diamond-3", 43 | "champion-1", "champion-2", "champion-3", 44 | "grand-champion", # Legacy. Seems to be interchangeable with "grand-champion-1" 45 | "grand-champion-1", "grand-champion-2", "grand-champion-3", 46 | "supersonic-legend" 47 | ], 48 | ] 49 | 50 | 51 | class Rank: 52 | ALL = ( 53 | UNRANKED, 54 | BRONZE_1, BRONZE_2, BRONZE_3, 55 | SILVER_1, SILVER_2, SILVER_3, 56 | GOLD_1, GOLD_2, GOLD_3, 57 | PLATINUM_1, PLATINUM_2, PLATINUM_3, 58 | DIAMOND_1, DIAMOND_2, DIAMOND_3, 59 | CHAMPION_1, CHAMPION_2, CHAMPION_3, 60 | GRAND_CHAMPION_LEGACY, 61 | GRAND_CHAMPION_1, GRAND_CHAMPION_2, GRAND_CHAMPION_3, 62 | SUPERSONIC_LEGEND 63 | ) = _get_literals(AnyRank) 64 | BRONZE = (BRONZE_1, BRONZE_2, BRONZE_3) 65 | SILVER = (SILVER_1, SILVER_2, SILVER_3) 66 | GOLD = (GOLD_1, GOLD_2, GOLD_3) 67 | PLATINUM = (PLATINUM_1, PLATINUM_2, PLATINUM_3) 68 | DIAMOND = (DIAMOND_1, DIAMOND_2, DIAMOND_3) 69 | CHAMPION = (CHAMPION_1, CHAMPION_2, CHAMPION_3) 70 | GRAND_CHAMPION = (GRAND_CHAMPION_1, GRAND_CHAMPION_2, GRAND_CHAMPION_3) 71 | 72 | 73 | AnySeason = Union[ 74 | AnyStr, 75 | Literal[ 76 | "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", 77 | "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", 78 | "f10", "f11", "f12", "f13", "f14", "f15", "f16", "f17", "f18", "f19" 79 | ] 80 | ] 81 | 82 | 83 | class Season: 84 | ALL = ( 85 | SEASON_1_LEGACY, SEASON_2_LEGACY, SEASON_3_LEGACY, SEASON_4_LEGACY, SEASON_5_LEGACY, SEASON_6_LEGACY, 86 | SEASON_7_LEGACY, SEASON_8_LEGACY, SEASON_9_LEGACY, SEASON_10_LEGACY, SEASON_11_LEGACY, SEASON_12_LEGACY, 87 | SEASON_13_LEGACY, SEASON_14_LEGACY, 88 | SEASON_1_FTP, SEASON_2_FTP, SEASON_3_FTP, SEASON_4_FTP, SEASON_5_FTP, SEASON_6_FTP, SEASON_7_FTP, 89 | SEASON_8_FTP, SEASON_9_FTP, SEASON_10_FTP, SEASON_11_FTP, SEASON_12_FTP, SEASON_13_FTP, SEASON_14_FTP, 90 | SEASON_15_FTP, SEASON_16_FTP, SEASON_17_FTP, SEASON_18_FTP, SEASON_19_FTP 91 | ) = _get_literals(AnySeason) 92 | LEGACY = ALL[:14] 93 | FREE_TO_PLAY = ALL[14:] 94 | 95 | 96 | AnyMatchResult = Union[AnyStr, Literal["win", "loss"]] 97 | 98 | 99 | class MatchResult: 100 | WIN, LOSS = _get_literals(AnyMatchResult) 101 | 102 | 103 | AnyReplaySortBy = Union[AnyStr, Literal["replay-date", "upload-date"]] 104 | 105 | 106 | class ReplaySortBy: 107 | REPLAY_DATE, UPLOAD_DATE = _get_literals(AnyReplaySortBy) 108 | 109 | 110 | AnyGroupSortBy = Union[AnyStr, Literal["created", "name"]] 111 | 112 | 113 | class GroupSortBy: 114 | CREATED, NAME = _get_literals(AnyGroupSortBy) 115 | 116 | 117 | AnySortDir = Union[AnyStr, Literal["asc", "desc"]] 118 | 119 | 120 | class SortDir: 121 | ASCENDING, DESCENDING = ASC, DESC = _get_literals(AnySortDir) 122 | 123 | 124 | AnyVisibility = Union[AnyStr, Literal["public", "unlisted", "private"]] 125 | 126 | 127 | class Visibility: 128 | PUBLIC, UNLISTED, PRIVATE = _get_literals(AnyVisibility) 129 | 130 | 131 | AnyPlayerIdentification = Union[AnyStr, Literal["by-id", "by-name"]] 132 | 133 | 134 | class PlayerIdentification: 135 | BY_ID, BY_NAME = _get_literals(AnyPlayerIdentification) 136 | 137 | 138 | AnyTeamIdentification = Union[AnyStr, Literal["by-distinct-players", "by-player-clusters"]] 139 | 140 | 141 | class TeamIdentification: 142 | BY_DISTINCT_PLAYERS, BY_PLAYER_CLUSTERS = _get_literals(AnyTeamIdentification) 143 | 144 | 145 | AnyMap = Union[AnyStr, Literal[ 146 | "arc_darc_p", "arc_p", "arc_standard_p", "bb_p", "beach_night_grs_p", "beach_night_p", "beach_p", "beachvolley", 147 | "chn_stadium_day_p", "chn_stadium_p", "cs_day_p", "cs_hw_p", "cs_p", "eurostadium_dusk_p", "eurostadium_night_p", 148 | "eurostadium_p", "eurostadium_rainy_p", "eurostadium_snownight_p", "farm_grs_p", "farm_hw_p", "farm_night_p", 149 | "farm_p", "farm_upsidedown_p", "ff_dusk_p", "fni_stadium_p", "haunted_trainstation_p", "hoopsstadium_p", 150 | "hoopsstreet_p", "ko_calavera_p", "ko_carbon_p", "ko_quadron_p", "labs_basin_p", "labs_circlepillars_p", 151 | "labs_corridor_p", "labs_cosmic_p", "labs_cosmic_v4_p", "labs_doublegoal_p", "labs_doublegoal_v2_p", 152 | "labs_galleon_mast_p", "labs_galleon_p", "labs_holyfield_p", "labs_holyfield_space_p", "labs_octagon_02_p", 153 | "labs_octagon_p", "labs_pillarglass_p", "labs_pillarheat_p", "labs_pillarwings_p", "labs_underpass_p", 154 | "labs_underpass_v0_p", "labs_utopia_p", "music_p", "neotokyo_arcade_p", "neotokyo_hax_p", "neotokyo_p", 155 | "neotokyo_standard_p", "neotokyo_toon_p", "outlaw_oasis_p", "outlaw_p", "park_bman_p", "park_night_p", "park_p", 156 | "park_rainy_p", "park_snowy_p", "shattershot_p", "stadium_10a_p", "stadium_day_p", "stadium_foggy_p", "stadium_p", 157 | "stadium_race_day_p", "stadium_winter_p", "street_p", "swoosh_p", "throwbackhockey_p", "throwbackstadium_p", 158 | "trainstation_dawn_p", "trainstation_night_p", "trainstation_p", "trainstation_spooky_p", "uf_day_p", 159 | "underwater_grs_p", "underwater_p", "utopiastadium_dusk_p", "utopiastadium_lux_p", "utopiastadium_p", 160 | "utopiastadium_snow_p", "wasteland_grs_p", "wasteland_night_p", "wasteland_night_s_p", "wasteland_p", 161 | "wasteland_s_p", "woods_night_p", "woods_p" 162 | ]] 163 | 164 | 165 | class Map: 166 | ALL = ( 167 | ARC_DARC_P, ARC_P, ARC_STANDARD_P, BB_P, BEACH_NIGHT_GRS_P, BEACH_NIGHT_P, BEACH_P, BEACHVOLLEY, 168 | CHN_STADIUM_DAY_P, CHN_STADIUM_P, CS_DAY_P, CS_HW_P, CS_P, EUROSTADIUM_DUSK_P, EUROSTADIUM_NIGHT_P, 169 | EUROSTADIUM_P, EUROSTADIUM_RAINY_P, EUROSTADIUM_SNOWNIGHT_P, FARM_GRS_P, FARM_HW_P, FARM_NIGHT_P, FARM_P, 170 | FARM_UPSIDEDOWN_P, FF_DUSK_P, FNI_STADIUM_P, HAUNTED_TRAINSTATION_P, HOOPSSTADIUM_P, HOOPSSTREET_P, 171 | KO_CALAVERA_P, KO_CARBON_P, KO_QUADRON_P, LABS_BASIN_P, LABS_CIRCLEPILLARS_P, LABS_CORRIDOR_P, LABS_COSMIC_P, 172 | LABS_COSMIC_V4_P, LABS_DOUBLEGOAL_P, LABS_DOUBLEGOAL_V2_P, LABS_GALLEON_MAST_P, LABS_GALLEON_P, 173 | LABS_HOLYFIELD_P, LABS_HOLYFIELD_SPACE_P, LABS_OCTAGON_02_P, LABS_OCTAGON_P, LABS_PILLARGLASS_P, 174 | LABS_PILLARHEAT_P, LABS_PILLARWINGS_P, LABS_UNDERPASS_P, LABS_UNDERPASS_V0_P, LABS_UTOPIA_P, MUSIC_P, 175 | NEOTOKYO_ARCADE_P, NEOTOKYO_HAX_P, NEOTOKYO_P, NEOTOKYO_STANDARD_P, NEOTOKYO_TOON_P, OUTLAW_OASIS_P, OUTLAW_P, 176 | PARK_BMAN_P, PARK_NIGHT_P, PARK_P, PARK_RAINY_P, PARK_SNOWY_P, SHATTERSHOT_P, STADIUM_10A_P, STADIUM_DAY_P, 177 | STADIUM_FOGGY_P, STADIUM_P, STADIUM_RACE_DAY_P, STADIUM_WINTER_P, STREET_P, SWOOSH_P, THROWBACKHOCKEY_P, 178 | THROWBACKSTADIUM_P, TRAINSTATION_DAWN_P, TRAINSTATION_NIGHT_P, TRAINSTATION_P, TRAINSTATION_SPOOKY_P, UF_DAY_P, 179 | UNDERWATER_GRS_P, UNDERWATER_P, UTOPIASTADIUM_DUSK_P, UTOPIASTADIUM_LUX_P, UTOPIASTADIUM_P, 180 | UTOPIASTADIUM_SNOW_P, WASTELAND_GRS_P, WASTELAND_NIGHT_P, WASTELAND_NIGHT_S_P, WASTELAND_P, WASTELAND_S_P, 181 | WOODS_NIGHT_P, WOODS_P 182 | ) = _get_literals(AnyMap) 183 | NAMES = ( 184 | "Starbase ARC (Aftermath)", "Starbase ARC", "Starbase ARC (Standard)", "Champions Field (NFL)", 185 | "Salty Shores (Salty Fest)", "Salty Shores (Night)", "Salty Shores", "Salty Shores (Volley)", 186 | "Forbidden Temple (Day)", "Forbidden Temple", "Champions Field (Day)", "Rivals Arena", "Champions Field", 187 | "Mannfield (Dusk)", "Mannfield (Night)", "Mannfield", "Mannfield (Stormy)", "Mannfield (Snowy)", 188 | "Farmstead (Pitched)", "Farmstead (Spooky)", "Farmstead (Night)", "Farmstead", "Farmstead (The Upside Down)", 189 | "Estadio Vida (Dusk)", "Forbidden Temple (Fire & Ice)", "Urban Central (Haunted)", "Dunk House", 190 | "The Block (Dusk)", "Calavera", "Carbon", "Quadron", "Basin", "Pillars", "Corridor", "Cosmic", "Cosmic", 191 | "Double Goal", "Double Goal", "Galleon Retro", "Galleon", "Loophole", "Holyfield", "Octagon", "Octagon", 192 | "Hourglass", "Barricade", "Colossus", "Underpass", "Underpass", "Utopia Retro", "Neon Fields", 193 | "Neo Tokyo (Arcade)", "Neo Tokyo (Hacked)", "Neo Tokyo", "Neo Tokyo (Standard)", "Neo Tokyo (Comic)", 194 | "Deadeye Canyon (Oasis)", "Deadeye Canyon", "Beckwith Park (Night)", "Beckwith Park (Midnight)", 195 | "Beckwith Park", "Beckwith Park (Stormy)", "Beckwith Park (Snowy)", "Core 707", 196 | "DFH Stadium (10th Anniversary)", "DFH Stadium (Day)", "DFH Stadium (Stormy)", "DFH Stadium", 197 | "DFH Stadium (Circuit)", "DFH Stadium (Snowy)", "Sovereign Heights (Dusk)", "Champions Field (Nike FC)", 198 | "Throwback Stadium (Snowy)", "Throwback Stadium", "Urban Central (Dawn)", "Urban Central (Night)", 199 | "Urban Central", "Urban Central (Spooky)", "Futura Garden", "AquaDome (Salty Shallows)", "Aquadome", 200 | "Utopia Coliseum (Dusk)", "Utopia Coliseum (Gilded)", "Utopia Coliseum", "Utopia Coliseum (Snowy)", 201 | "Wasteland (Pitched)", "Wasteland (Night)", "Wasteland (Standard, Night)", "Wasteland", "Wasteland (Standard)", 202 | "Drift Woods (Night)", "Drift Woods" 203 | ) 204 | CODE_TO_NAME = {code: name for code, name in zip(ALL, NAMES)} 205 | STANDARD_MAPS = ( 206 | ARC_DARC_P, ARC_STANDARD_P, BEACH_NIGHT_GRS_P, BEACH_NIGHT_P, BEACH_P, CHN_STADIUM_DAY_P, CHN_STADIUM_P, 207 | CS_DAY_P, CS_HW_P, CS_P, EUROSTADIUM_DUSK_P, EUROSTADIUM_NIGHT_P, EUROSTADIUM_P, EUROSTADIUM_RAINY_P, 208 | EUROSTADIUM_SNOWNIGHT_P, FARM_GRS_P, FARM_NIGHT_P, FARM_P, FARM_UPSIDEDOWN_P, FF_DUSK_P, FNI_STADIUM_P, MUSIC_P, 209 | NEOTOKYO_ARCADE_P, NEOTOKYO_HAX_P, NEOTOKYO_STANDARD_P, OUTLAW_OASIS_P, OUTLAW_P, PARK_NIGHT_P, PARK_P, 210 | PARK_RAINY_P, PARK_SNOWY_P, STADIUM_10A_P, STADIUM_DAY_P, STADIUM_FOGGY_P, STADIUM_P, STADIUM_RACE_DAY_P, 211 | STADIUM_WINTER_P, STREET_P, TRAINSTATION_DAWN_P, TRAINSTATION_NIGHT_P, TRAINSTATION_P, UF_DAY_P, 212 | UNDERWATER_GRS_P, UNDERWATER_P, UTOPIASTADIUM_DUSK_P, UTOPIASTADIUM_LUX_P, UTOPIASTADIUM_P, 213 | UTOPIASTADIUM_SNOW_P, WASTELAND_GRS_P, WASTELAND_NIGHT_P, WASTELAND_NIGHT_S_P, WASTELAND_P, WASTELAND_S_P, 214 | WOODS_NIGHT_P, WOODS_P 215 | ) 216 | NON_STANDARD_MAPS = ( 217 | ARC_P, BB_P, BEACHVOLLEY, FARM_HW_P, HAUNTED_TRAINSTATION_P, HOOPSSTADIUM_P, HOOPSSTREET_P, KO_CALAVERA_P, 218 | KO_CARBON_P, KO_QUADRON_P, LABS_BASIN_P, LABS_CIRCLEPILLARS_P, LABS_CORRIDOR_P, LABS_COSMIC_P, LABS_COSMIC_V4_P, 219 | LABS_DOUBLEGOAL_P, LABS_DOUBLEGOAL_V2_P, LABS_GALLEON_MAST_P, LABS_GALLEON_P, LABS_HOLYFIELD_P, 220 | LABS_HOLYFIELD_SPACE_P, LABS_OCTAGON_02_P, LABS_OCTAGON_P, LABS_PILLARGLASS_P, LABS_PILLARHEAT_P, 221 | LABS_PILLARWINGS_P, LABS_UNDERPASS_P, LABS_UNDERPASS_V0_P, LABS_UTOPIA_P, NEOTOKYO_P, NEOTOKYO_TOON_P, 222 | PARK_BMAN_P, SHATTERSHOT_P, SWOOSH_P, THROWBACKHOCKEY_P, THROWBACKSTADIUM_P, TRAINSTATION_SPOOKY_P 223 | ) 224 | -------------------------------------------------------------------------------- /ballchasing/typed/deep_group.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List, Optional 3 | 4 | from ballchasing.typed.shared import _DictToTypeMixin, User, BaseGroup 5 | 6 | 7 | @dataclass 8 | class CreatorDG(User): 9 | # steam_id: str = "" 10 | # name: str = "" 11 | # profile_url: str = "" 12 | # avatar: str = "" 13 | avatar_full: str = "" 14 | avatar_medium: str = "" 15 | 16 | 17 | @dataclass 18 | class PlayerCumulativeCoreStatsDG: 19 | shots: int = 0 20 | shots_against: int = 0 21 | goals: int = 0 22 | goals_against: int = 0 23 | saves: int = 0 24 | assists: int = 0 25 | score: int = 0 26 | mvp: int = 0 27 | shooting_percentage: float = 0.0 28 | 29 | 30 | @dataclass 31 | class PlayerCumulativeBoostStatsDG: 32 | bpm: float = 0.0 33 | bcpm: float = 0.0 34 | avg_amount: float = 0.0 35 | amount_collected: int = 0 36 | amount_stolen: int = 0 37 | amount_collected_big: int = 0 38 | amount_stolen_big: int = 0 39 | amount_collected_small: int = 0 40 | amount_stolen_small: int = 0 41 | count_collected_big: int = 0 42 | count_stolen_big: int = 0 43 | count_collected_small: int = 0 44 | count_stolen_small: int = 0 45 | time_zero_boost: float = 0.0 46 | percent_zero_boost: float = 0.0 47 | time_full_boost: float = 0.0 48 | percent_full_boost: float = 0.0 49 | amount_overfill: int = 0 50 | amount_overfill_stolen: int = 0 51 | amount_used_while_supersonic: int = 0 52 | time_boost_0_25: float = 0.0 53 | time_boost_25_50: float = 0.0 54 | time_boost_50_75: float = 0.0 55 | time_boost_75_100: float = 0.0 56 | percent_boost_0_25: float = 0.0 57 | percent_boost_25_50: float = 0.0 58 | percent_boost_50_75: float = 0.0 59 | percent_boost_75_100: float = 0.0 60 | 61 | 62 | @dataclass 63 | class PlayerCumulativeMovementStatsDG: 64 | avg_speed: float = 0.0 65 | total_distance: int = 0 66 | time_supersonic_speed: float = 0.0 67 | time_boost_speed: float = 0.0 68 | time_slow_speed: float = 0.0 69 | time_ground: float = 0.0 70 | time_low_air: float = 0.0 71 | time_high_air: float = 0.0 72 | time_powerslide: float = 0.0 73 | count_powerslide: int = 0 74 | avg_powerslide_duration: float = 0.0 75 | avg_speed_percentage: float = 0.0 76 | percent_slow_speed: float = 0.0 77 | percent_boost_speed: float = 0.0 78 | percent_supersonic_speed: float = 0.0 79 | percent_ground: float = 0.0 80 | percent_low_air: float = 0.0 81 | percent_high_air: float = 0.0 82 | 83 | 84 | @dataclass 85 | class PlayerCumulativePositioningStatsDG: 86 | avg_distance_to_ball: float = 0.0 87 | avg_distance_to_ball_possession: float = 0.0 88 | avg_distance_to_ball_no_possession: float = 0.0 89 | time_defensive_third: float = 0.0 90 | time_neutral_third: float = 0.0 91 | time_offensive_third: float = 0.0 92 | time_defensive_half: float = 0.0 93 | time_offensive_half: float = 0.0 94 | time_behind_ball: float = 0.0 95 | time_infront_ball: float = 0.0 96 | time_most_back: float = 0.0 97 | time_most_forward: float = 0.0 98 | goals_against_while_last_defender: int = 0 99 | time_closest_to_ball: float = 0.0 100 | time_farthest_from_ball: float = 0.0 101 | percent_defensive_third: float = 0.0 102 | percent_offensive_third: float = 0.0 103 | percent_neutral_third: float = 0.0 104 | percent_defensive_half: float = 0.0 105 | percent_offensive_half: float = 0.0 106 | percent_behind_ball: float = 0.0 107 | percent_infront_ball: float = 0.0 108 | 109 | 110 | @dataclass 111 | class PlayerCumulativeDemoStatsDG: 112 | inflicted: int = 0 113 | taken: int = 0 114 | 115 | 116 | @dataclass 117 | class PlayerCumulativeStatsDG(_DictToTypeMixin): 118 | games: int = 0 119 | wins: int = 0 120 | win_percentage: float = 0 121 | play_duration: int = 0 122 | core: Optional[PlayerCumulativeCoreStatsDG] = None 123 | boost: Optional[PlayerCumulativeBoostStatsDG] = None 124 | movement: Optional[PlayerCumulativeMovementStatsDG] = None 125 | positioning: Optional[PlayerCumulativePositioningStatsDG] = None 126 | demo: Optional[PlayerCumulativeDemoStatsDG] = None 127 | 128 | 129 | @dataclass 130 | class PlayerAverageCoreStatsDG: 131 | shots: float = 0.0 132 | shots_against: int = 0 133 | goals: float = 0.0 134 | goals_against: float = 0.0 135 | saves: float = 0.0 136 | assists: float = 0.0 137 | score: float = 0.0 138 | mvp: int = 0 139 | shooting_percentage: float = 0.0 140 | 141 | 142 | @dataclass 143 | class PlayerAverageBoostStatsDG: 144 | bpm: float = 0.0 145 | bcpm: float = 0.0 146 | avg_amount: float = 0.0 147 | amount_collected: float = 0.0 148 | amount_stolen: float = 0.0 149 | amount_collected_big: int = 0 150 | amount_stolen_big: float = 0.0 151 | amount_collected_small: float = 0.0 152 | amount_stolen_small: int = 0 153 | count_collected_big: float = 0.0 154 | count_stolen_big: float = 0.0 155 | count_collected_small: float = 0.0 156 | count_stolen_small: float = 0.0 157 | time_zero_boost: float = 0.0 158 | percent_zero_boost: float = 0.0 159 | time_full_boost: float = 0.0 160 | percent_full_boost: float = 0.0 161 | amount_overfill: int = 0 162 | amount_overfill_stolen: int = 0 163 | amount_used_while_supersonic: float = 0.0 164 | time_boost_0_25: float = 0.0 165 | time_boost_25_50: float = 0.0 166 | time_boost_50_75: float = 0.0 167 | time_boost_75_100: float = 0.0 168 | percent_boost_0_25: float = 0.0 169 | percent_boost_25_50: float = 0.0 170 | percent_boost_50_75: float = 0.0 171 | percent_boost_75_100: float = 0.0 172 | 173 | 174 | @dataclass 175 | class PlayerAverageMovementStatsDG: 176 | avg_speed: float = 0.0 177 | total_distance: int = 0 178 | time_supersonic_speed: float = 0.0 179 | time_boost_speed: float = 0.0 180 | time_slow_speed: float = 0.0 181 | time_ground: float = 0.0 182 | time_low_air: float = 0.0 183 | time_high_air: float = 0.0 184 | time_powerslide: float = 0.0 185 | count_powerslide: float = 0.0 186 | avg_powerslide_duration: float = 0.0 187 | avg_speed_percentage: float = 0.0 188 | percent_slow_speed: float = 0.0 189 | percent_boost_speed: float = 0.0 190 | percent_supersonic_speed: float = 0.0 191 | percent_ground: float = 0.0 192 | percent_low_air: float = 0.0 193 | percent_high_air: float = 0.0 194 | 195 | 196 | @dataclass 197 | class PlayerAveragePositioningStatsDG: 198 | avg_distance_to_ball: float = 0.0 199 | avg_distance_to_ball_possession: float = 0.0 200 | avg_distance_to_ball_no_possession: float = 0.0 201 | time_defensive_third: float = 0.0 202 | time_neutral_third: float = 0.0 203 | time_offensive_third: float = 0.0 204 | time_defensive_half: float = 0.0 205 | time_offensive_half: float = 0.0 206 | time_behind_ball: float = 0.0 207 | time_infront_ball: float = 0.0 208 | time_most_back: float = 0.0 209 | time_most_forward: float = 0.0 210 | goals_against_while_last_defender: float = 0.0 211 | time_closest_to_ball: float = 0.0 212 | time_farthest_from_ball: float = 0.0 213 | percent_defensive_third: float = 0.0 214 | percent_offensive_third: float = 0.0 215 | percent_neutral_third: float = 0.0 216 | percent_defensive_half: float = 0.0 217 | percent_offensive_half: float = 0.0 218 | percent_behind_ball: float = 0.0 219 | percent_infront_ball: float = 0.0 220 | 221 | 222 | @dataclass 223 | class PlayerAverageDemoStatsDG: 224 | inflicted: float = 0.0 225 | taken: float = 0.0 226 | 227 | 228 | @dataclass 229 | class PlayerAverageStatsDG(_DictToTypeMixin): 230 | core: Optional[PlayerAverageCoreStatsDG] = None 231 | boost: Optional[PlayerAverageBoostStatsDG] = None 232 | movement: Optional[PlayerAverageMovementStatsDG] = None 233 | positioning: Optional[PlayerAveragePositioningStatsDG] = None 234 | demo: Optional[PlayerAverageDemoStatsDG] = None 235 | 236 | 237 | @dataclass 238 | class TeamCumulativeCoreStatsDG: 239 | shots: int = 0 240 | shots_against: int = 0 241 | goals: int = 0 242 | goals_against: int = 0 243 | saves: int = 0 244 | assists: int = 0 245 | score: int = 0 246 | shooting_percentage: float = 0.0 247 | 248 | 249 | @dataclass 250 | class TeamCumulativeBoostStatsDG: 251 | amount_collected: int = 0 252 | amount_stolen: int = 0 253 | amount_collected_big: int = 0 254 | amount_stolen_big: int = 0 255 | amount_collected_small: int = 0 256 | amount_stolen_small: int = 0 257 | count_collected_big: int = 0 258 | count_stolen_big: int = 0 259 | count_collected_small: int = 0 260 | count_stolen_small: int = 0 261 | time_zero_boost: float = 0.0 262 | percent_zero_boost: float = 0.0 263 | time_full_boost: float = 0.0 264 | percent_full_boost: float = 0.0 265 | amount_overfill: int = 0 266 | amount_overfill_stolen: int = 0 267 | amount_used_while_supersonic: int = 0 268 | time_boost_0_25: float = 0.0 269 | time_boost_25_50: float = 0.0 270 | time_boost_50_75: float = 0.0 271 | time_boost_75_100: float = 0.0 272 | 273 | 274 | @dataclass 275 | class TeamCumulativeMovementStatsDG: 276 | total_distance: int = 0 277 | time_supersonic_speed: float = 0.0 278 | time_boost_speed: float = 0.0 279 | time_slow_speed: float = 0.0 280 | time_ground: float = 0.0 281 | time_low_air: int = 0 282 | time_high_air: float = 0.0 283 | time_powerslide: float = 0.0 284 | count_powerslide: int = 0 285 | 286 | 287 | @dataclass 288 | class TeamCumulativePositioningStatsDG: 289 | time_defensive_third: float = 0.0 290 | time_neutral_third: float = 0.0 291 | time_offensive_third: float = 0.0 292 | time_defensive_half: float = 0.0 293 | time_offensive_half: float = 0.0 294 | time_behind_ball: float = 0.0 295 | time_infront_ball: float = 0.0 296 | avg_distance_to_ball: int = 0 297 | avg_distance_to_ball_possession: int = 0 298 | avg_distance_to_ball_no_possession: int = 0 299 | 300 | 301 | @dataclass 302 | class TeamCumulativeDemoStatsDG: 303 | inflicted: int = 0 304 | taken: int = 0 305 | 306 | 307 | @dataclass 308 | class TeamCumulativeStatsDG(_DictToTypeMixin): 309 | games: int = 0 310 | wins: int = 0 311 | win_percentage: float = 0 312 | play_duration: int = 0 313 | core: Optional[TeamCumulativeCoreStatsDG] = None 314 | boost: Optional[TeamCumulativeBoostStatsDG] = None 315 | movement: Optional[TeamCumulativeMovementStatsDG] = None 316 | positioning: Optional[TeamCumulativePositioningStatsDG] = None 317 | demo: Optional[TeamCumulativeDemoStatsDG] = None 318 | 319 | 320 | @dataclass 321 | class TeamAverageCoreStatsDG: 322 | shots: float = 0.0 323 | shots_against: int = 0 324 | goals: int = 0 325 | goals_against: float = 0.0 326 | saves: float = 0.0 327 | assists: float = 0.0 328 | score: float = 0.0 329 | shooting_percentage: float = 0.0 330 | 331 | 332 | @dataclass 333 | class TeamAverageBoostStatsDG: 334 | bpm: int = 0 335 | bcpm: float = 0.0 336 | avg_amount: float = 0.0 337 | amount_collected: float = 0.0 338 | amount_stolen: float = 0.0 339 | amount_collected_big: float = 0.0 340 | amount_stolen_big: float = 0.0 341 | amount_collected_small: float = 0.0 342 | amount_stolen_small: float = 0.0 343 | count_collected_big: float = 0.0 344 | count_stolen_big: float = 0.0 345 | count_collected_small: float = 0.0 346 | count_stolen_small: float = 0.0 347 | time_zero_boost: float = 0.0 348 | percent_zero_boost: float = 0.0 349 | time_full_boost: float = 0.0 350 | percent_full_boost: float = 0.0 351 | amount_overfill: int = 0 352 | amount_overfill_stolen: int = 0 353 | amount_used_while_supersonic: int = 0 354 | time_boost_0_25: float = 0.0 355 | time_boost_25_50: float = 0.0 356 | time_boost_50_75: float = 0.0 357 | time_boost_75_100: float = 0.0 358 | 359 | 360 | @dataclass 361 | class TeamAverageMovementStatsDG: 362 | total_distance: float = 0.0 363 | time_supersonic_speed: float = 0.0 364 | time_boost_speed: float = 0.0 365 | time_slow_speed: float = 0.0 366 | time_ground: float = 0.0 367 | time_low_air: float = 0.0 368 | time_high_air: float = 0.0 369 | time_powerslide: float = 0.0 370 | count_powerslide: float = 0.0 371 | 372 | 373 | @dataclass 374 | class TeamAveragePositioningStatsDG: 375 | time_defensive_third: float = 0.0 376 | time_neutral_third: float = 0.0 377 | time_offensive_third: float = 0.0 378 | time_defensive_half: float = 0.0 379 | time_offensive_half: float = 0.0 380 | time_behind_ball: float = 0.0 381 | time_infront_ball: float = 0.0 382 | avg_distance_to_ball: float = 0.0 383 | avg_distance_to_ball_possession: float = 0.0 384 | avg_distance_to_ball_no_possession: int = 0 385 | 386 | 387 | @dataclass 388 | class TeamAverageDemoStatsDG: 389 | inflicted: float = 0.0 390 | taken: float = 0.0 391 | 392 | 393 | @dataclass 394 | class TeamAverageStatsDG(_DictToTypeMixin): 395 | core: Optional[TeamAverageCoreStatsDG] = None 396 | boost: Optional[TeamAverageBoostStatsDG] = None 397 | movement: Optional[TeamAverageMovementStatsDG] = None 398 | positioning: Optional[TeamAveragePositioningStatsDG] = None 399 | demo: Optional[TeamAverageDemoStatsDG] = None 400 | 401 | 402 | @dataclass 403 | class PlayerDG: 404 | platform: str = "" 405 | id: str = "" 406 | name: str = "" 407 | team: str = "" 408 | 409 | 410 | @dataclass 411 | class PlayerWithStatsDG(PlayerDG, _DictToTypeMixin): 412 | # platform: str = "" 413 | # id: str = "" 414 | # name: str = "" 415 | # team: str = "" 416 | cumulative: Optional[PlayerCumulativeStatsDG] = None 417 | game_average: Optional[PlayerAverageStatsDG] = None 418 | 419 | 420 | @dataclass 421 | class TeamDG(_DictToTypeMixin): 422 | name: str = "" 423 | players: List[PlayerDG] = field(default_factory=list) 424 | cumulative: Optional[TeamCumulativeStatsDG] = None 425 | game_average: Optional[TeamAverageStatsDG] = None 426 | 427 | 428 | @dataclass 429 | class DeepGroup(BaseGroup): 430 | # id: str = "" 431 | # link: str = "" 432 | # name: str = "" 433 | # created: Optional[datetime] = "" 434 | # player_identification: str = "" 435 | # team_identification: str = "" 436 | # shared: bool = False 437 | status: str = "" 438 | creator: Optional[CreatorDG] = None 439 | players: List[PlayerWithStatsDG] = field(default_factory=list) 440 | teams: List[TeamDG] = field(default_factory=list) 441 | -------------------------------------------------------------------------------- /ballchasing/api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | from datetime import datetime 4 | from pathlib import Path 5 | from typing import Optional, Union, List, BinaryIO, Iterator 6 | from urllib.parse import parse_qs, urlparse 7 | 8 | from requests import sessions, Response, ConnectionError, HTTPError 9 | 10 | from ballchasing.constants import GroupSortBy, SortDir, AnyPlaylist, AnyMap, AnySeason, AnyRank, AnyReplaySortBy, \ 11 | AnySortDir, AnyVisibility, AnyGroupSortBy, AnyPlayerIdentification, AnyTeamIdentification, AnyMatchResult 12 | from ballchasing.typed import DeepReplay, ShallowReplay, DeepGroup, ShallowGroup 13 | from .typed.shared import BaseGroup, BasicGroup 14 | from .util import to_rfc3339, parse_replay_stats 15 | 16 | DEFAULT_URL = "https://ballchasing.com/api" 17 | 18 | 19 | class BallchasingApi: 20 | """ 21 | Class for communication with ballchasing.com API (https://ballchasing.com/doc/api) 22 | """ 23 | 24 | def __init__( 25 | self, 26 | auth_key: str, 27 | *, 28 | sleep_time_on_rate_limit: Optional[float] = None, 29 | print_on_rate_limit: bool = False, 30 | base_url=None, 31 | do_initial_ping=True, 32 | typed=False, 33 | ): 34 | """ 35 | 36 | :param auth_key: authentication key for API calls. 37 | :param sleep_time_on_rate_limit: seconds to wait after being rate limited. 38 | Default value is calculated depending on patron type. 39 | :param print_on_rate_limit: whether or not to print upon rate limits. 40 | """ 41 | self.auth_key = auth_key 42 | self._session = sessions.Session() 43 | self._ping_result = None 44 | self.rate_limit_count = 0 45 | self.base_url = DEFAULT_URL if base_url is None else base_url 46 | if do_initial_ping: 47 | self.ping() 48 | if sleep_time_on_rate_limit is None: 49 | self.sleep_time_on_rate_limit = { 50 | "regular": 3600 / 1000, 51 | "gold": 3600 / 2000, 52 | "diamond": 3600 / 5000, 53 | "champion": 1 / 8, 54 | "gc": 1 / 16 55 | }.get(self.patron_type or "regular") 56 | else: 57 | self.sleep_time_on_rate_limit = sleep_time_on_rate_limit 58 | self.print_on_rate_limit = print_on_rate_limit 59 | self.typed = typed 60 | 61 | @property 62 | def steam_name(self): 63 | if self._ping_result is None: 64 | self.ping() 65 | return self._ping_result.get("name") 66 | 67 | @property 68 | def steam_id(self): 69 | if self._ping_result is None: 70 | self.ping() 71 | return self._ping_result.get("steam_id") 72 | 73 | @property 74 | def patron_type(self): 75 | if self._ping_result is None: 76 | self.ping() 77 | return self._ping_result.get("type") 78 | 79 | @property 80 | def quota(self): 81 | if self._ping_result is None: 82 | self.ping() 83 | return self._ping_result.get("quota") 84 | 85 | def _request( 86 | self, 87 | url_or_endpoint: str, 88 | method: str, 89 | **params 90 | ) -> Response: 91 | """ 92 | Helper method for all requests. 93 | 94 | :param url: url or endpoint for request. 95 | :param method: the method to use. 96 | :param params: parameters for GET request. 97 | :return: the request result. 98 | :raises ConnectionError: if the connection fails after max retries. 99 | :raises HTTPError: if the request fails with a status code other than 2xx or 429. 100 | """ 101 | headers = {"Authorization": self.auth_key} 102 | url = f"{self.base_url}{url_or_endpoint}" if url_or_endpoint.startswith("/") else url_or_endpoint 103 | max_retries = 8 104 | retries = 0 105 | while True: 106 | try: 107 | r: Response = self._session.request(method=method, url=url, headers=headers, **params) 108 | if 200 <= r.status_code < 300: 109 | return r 110 | elif r.status_code == 429: 111 | self.rate_limit_count += 1 112 | if self.print_on_rate_limit: 113 | print(f"Rate limited at {url} ({self.rate_limit_count} total rate limits)") 114 | retry_after = r.headers.get("Retry-After", '0') 115 | retry_after = int(retry_after) if retry_after.isdigit() else None 116 | if retry_after: # integer > 0 117 | time.sleep(retry_after) 118 | elif self.sleep_time_on_rate_limit: 119 | time.sleep(self.sleep_time_on_rate_limit) 120 | else: 121 | r.raise_for_status() # Raise an error for any other status code' 122 | except ConnectionError as e: 123 | if retries >= max_retries - 1: 124 | raise e 125 | s = 2 ** retries 126 | print(f"Connection error, trying again in {s} seconds...") 127 | time.sleep(s) 128 | retries += 1 129 | 130 | def ping(self) -> dict: 131 | """ 132 | Use this API to: 133 | 134 | - check if your API key is correct 135 | - check if ballchasing API is reachable 136 | 137 | This method runs automatically at initialization and the steam name and id as well as patron type are stored. 138 | :return: ping response. 139 | """ 140 | result = self._request("/", "GET").json() 141 | self._ping_result = result 142 | return result 143 | 144 | def _iterable_from_request(self, url, params): 145 | # Shared by get_replays and get_groups 146 | remaining = params["count"] 147 | # return_length = True 148 | while remaining > 0: 149 | request_count = min(remaining, 200) 150 | params["count"] = request_count 151 | try: 152 | d = self._request(url, "GET", params=params).json() 153 | except HTTPError as e: 154 | if e.response.status_code == 504: 155 | # Gateway Timeout, retry 156 | time.sleep(5) 157 | continue 158 | else: 159 | raise e 160 | 161 | batch = d["list"][:request_count] 162 | yield from batch 163 | 164 | if "next" not in d: 165 | break 166 | 167 | next_url = d["next"] 168 | remaining -= len(batch) 169 | params["after"] = parse_qs(urlparse(next_url).query)["after"][0] 170 | 171 | def get_replays( 172 | self, 173 | *, 174 | title: Optional[str] = None, 175 | player_name: Optional[Union[str, List[str]]] = None, 176 | player_id: Optional[Union[str, List[str]]] = None, 177 | playlist: Optional[Union[AnyPlaylist, List[AnyPlaylist]]] = None, 178 | season: Optional[Union[AnySeason, List[AnySeason]]] = None, 179 | match_result: Optional[Union[AnyMatchResult, List[AnyMatchResult]]] = None, 180 | min_rank: Optional[AnyRank] = None, 181 | max_rank: Optional[AnyRank] = None, 182 | pro: Optional[bool] = None, 183 | uploader: Optional[str] = None, 184 | group_id: Optional[Union[str, List[str]]] = None, 185 | map_id: Optional[Union[AnyMap, List[AnyMap]]] = None, 186 | created_before: Optional[Union[str, datetime]] = None, 187 | created_after: Optional[Union[str, datetime]] = None, 188 | replay_after: Optional[Union[str, datetime]] = None, 189 | replay_before: Optional[Union[str, datetime]] = None, 190 | count: int = 150, 191 | sort_by: Optional[AnyReplaySortBy] = None, 192 | sort_dir: AnySortDir = SortDir.DESCENDING, 193 | deep: bool = False, 194 | typed: Optional[bool] = None, 195 | ) -> Iterator[Union[dict, ShallowReplay, DeepReplay]]: 196 | """ 197 | This endpoint lets you filter and retrieve replays. The implementation returns an iterator. 198 | 199 | :param title: filter replays by title. 200 | :param player_name: filter replays by a player’s name. 201 | :param player_id: filter replays by a player’s platform id in the $platform:$id, e.g. steam:76561198141161044, 202 | ps4:gamertag, … You can filter replays by multiple player ids, e.g ?player-id=steam:1&player-id=steam:2 203 | :param playlist: filter replays by one or more playlists. 204 | :param season: filter replays by season. Must be a number between 1 and 14 (for old seasons) 205 | or f1, f2, … for the new free to play seasons 206 | :param match_result: filter your replays by result. 207 | :param min_rank: filter your replays based on players minimum rank. 208 | :param max_rank: filter your replays based on players maximum rank. 209 | :param pro: only include replays containing at least one pro player. 210 | :param uploader: only include replays uploaded by the specified user. Accepts either the 211 | numerical 76*************44 steam id, or the special value 'me' 212 | :param group_id: only include replays belonging to the specified group. This only include replays immediately 213 | under the specified group, but not replays in child groups 214 | :param map_id: only include replays in the specified map. Check get_maps for the list of valid map codes 215 | :param created_before: only include replays created (uploaded) before some date. 216 | RFC3339 format, e.g. '2020-01-02T15:00:05+01:00' 217 | :param created_after: only include replays created (uploaded) after some date. 218 | RFC3339 format, e.g. '2020-01-02T15:00:05+01:00' 219 | :param replay_after: only include replays for games that happened after some date. 220 | RFC3339 format, e.g. '2020-01-02T15:00:05+01:00' 221 | :param replay_before: only include replays for games that happened before some date. 222 | RFC3339 format, e.g. '2020-01-02T15:00:05+01:00' 223 | :param count: returns at most count replays. Since the implementation uses an iterator it supports iterating 224 | past the limit of 200 set by the API 225 | :param sort_by: sort replays according the selected field 226 | :param sort_dir: sort direction 227 | :param deep: whether to get full stats for each replay (will be much slower). 228 | :param typed: whether to return a typed object (default is self.typed). 229 | :return: an iterator over the replays returned by the API. 230 | """ 231 | url = f"{self.base_url}/replays" 232 | params = {"title": title, "player-name": player_name, "player-id": player_id, "playlist": playlist, 233 | "season": season, "match-result": match_result, "min-rank": min_rank, "max-rank": max_rank, 234 | "pro": pro, "uploader": uploader, "group": group_id, "map": map_id, 235 | "created-before": to_rfc3339(created_before), "created-after": to_rfc3339(created_after), 236 | "replay-date-after": to_rfc3339(replay_after), "replay-date-before": to_rfc3339(replay_before), 237 | "count": count, "sort-by": sort_by, "sort-dir": sort_dir} 238 | 239 | if typed is None: 240 | typed = self.typed 241 | 242 | iterator = self._iterable_from_request(url, params) 243 | if deep: 244 | iterator = (self.get_replay(r["id"], typed=typed) for r in iterator) 245 | elif typed: 246 | iterator = (ShallowReplay(**r) for r in iterator) 247 | yield from iterator 248 | 249 | def get_replay(self, replay_id: str, *, typed: Optional[bool] = None) -> Union[dict, DeepReplay]: 250 | """ 251 | Retrieve a given replay’s details and stats. 252 | 253 | :param replay_id: the replay id. 254 | :param typed: whether to return a typed object (default is self.typed). 255 | :return: the result of the GET request. 256 | """ 257 | result = self._request(f"/replays/{replay_id}", "GET").json() 258 | if typed is None: 259 | typed = self.typed 260 | if typed: 261 | result = DeepReplay(**result) 262 | return result 263 | 264 | def patch_replay(self, replay_id: str, **params) -> None: 265 | """ 266 | This endpoint can patch one or more fields of the specified replay 267 | 268 | :param replay_id: the replay id. 269 | :param params: parameters for the PATCH request. 270 | """ 271 | self._request(f"/replays/{replay_id}", "PATCH", json=params) 272 | 273 | def upload_replay( 274 | self, 275 | replay_file: Union[str, Path, BinaryIO], 276 | *, 277 | visibility: Optional[AnyVisibility] = None, 278 | group: Optional[str] = None 279 | ) -> dict: 280 | """ 281 | Use this API to upload a replay file to ballchasing.com. 282 | 283 | :param replay_file: replay file to upload. Can be a file path (str or Path) or a file-like object. 284 | :param visibility: to set the visibility of the uploaded replay. 285 | :param group: to upload the replay to an existing group. 286 | :return: the result of the POST request. 287 | """ 288 | if isinstance(replay_file, (str, Path)): 289 | with open(replay_file, "rb") as f: 290 | return self.upload_replay(f, visibility=visibility, group=group) 291 | return self._request(f"/v2/upload", "POST", files={"file": replay_file}, 292 | params={"group": group, "visibility": visibility}).json() 293 | 294 | def delete_replay(self, replay_id: str) -> None: 295 | """ 296 | This endpoint deletes the specified replay. 297 | WARNING: This operation is permanent and undoable. 298 | 299 | :param replay_id: the replay id. 300 | """ 301 | self._request(f"/replays/{replay_id}", "DELETE") 302 | 303 | def get_groups( 304 | self, 305 | *, 306 | name: Optional[str] = None, 307 | creator: Optional[str] = None, 308 | group: Optional[str] = None, 309 | created_before: Optional[Union[str, datetime]] = None, 310 | created_after: Optional[Union[str, datetime]] = None, 311 | count: int = 200, 312 | sort_by: AnyGroupSortBy = GroupSortBy.CREATED, 313 | sort_dir: AnySortDir = SortDir.DESCENDING, 314 | deep: bool = False, 315 | typed: bool = None, 316 | ) -> Iterator[Union[dict, ShallowGroup, DeepGroup]]: 317 | """ 318 | This endpoint lets you filter and retrieve replay groups. 319 | 320 | :param name: filter groups by name 321 | :param creator: only include groups created by the specified user. 322 | Accepts either the numerical 76*************44 steam id, or the special value me 323 | :param group: only include children of the specified group 324 | :param created_before: only include groups created (uploaded) before some date. 325 | RFC3339 format, e.g. 2020-01-02T15:00:05+01:00 326 | :param created_after: only include groups created (uploaded) after some date. 327 | RFC3339 format, e.g. 2020-01-02T15:00:05+01:00 328 | :param count: returns at most count groups. Since the implementation uses an iterator it supports iterating 329 | past the limit of 200 set by the API 330 | :param sort_by: Sort groups according the selected field. 331 | :param sort_dir: Sort direction. 332 | :param deep: whether to get full stats for each group (will be much slower). 333 | :param typed: whether to return a typed object (default is self.typed). 334 | :return: an iterator over the groups returned by the API. 335 | """ 336 | url = f"{self.base_url}/groups/" 337 | params = {"name": name, "creator": creator, "group": group, "created-before": to_rfc3339(created_before), 338 | "created-after": to_rfc3339(created_after), "count": count, "sort-by": sort_by, "sort-dir": sort_dir} 339 | iterator = self._iterable_from_request(url, params) 340 | if typed is None: 341 | typed = self.typed 342 | if deep: 343 | iterator = (self.get_group(g["id"], typed=typed) for g in iterator) 344 | elif typed: 345 | iterator = (ShallowGroup(**g) for g in iterator) 346 | yield from iterator 347 | 348 | def create_group( 349 | self, 350 | *, 351 | name: str, 352 | player_identification: AnyPlayerIdentification, 353 | team_identification: AnyTeamIdentification, 354 | parent: Optional[str] = None 355 | ) -> dict: 356 | """ 357 | Use this API to create a new replay group. 358 | 359 | :param name: the new group name. 360 | :param player_identification: how to identify the same player across multiple replays. 361 | Some tournaments (e.g. RLCS) make players use a pool of generic Steam accounts, 362 | meaning the same player could end up using 2 different accounts in 2 series. 363 | That's when the `by-name` comes in handy 364 | :param team_identification: How to identify the same team across multiple replays. 365 | Set to `by-distinct-players` if teams have a fixed roster of players for 366 | every single game. In some tournaments/leagues, teams allow player rotations, 367 | or a sub can replace another player, in which case use `by-player-clusters`. 368 | :param parent: if set,the new group will be created as a child of the specified group 369 | :return: the result of the POST request. 370 | """ 371 | json = {"name": name, "player_identification": player_identification, 372 | "team_identification": team_identification, "parent": parent} 373 | return self._request(f"/groups", "POST", json=json).json() 374 | 375 | def get_group( 376 | self, 377 | group_id: str, 378 | *, 379 | typed: Optional[bool] = None 380 | ) -> Union[dict, DeepGroup]: 381 | """ 382 | This endpoint retrieves a specific replay group info and stats given its id. 383 | 384 | :param group_id: the group id. 385 | :param typed: whether to return a typed object (default is self.typed). 386 | :return: the group info with stats. 387 | """ 388 | result = self._request(f"/groups/{group_id}", "GET").json() 389 | if typed is None: 390 | typed = self.typed 391 | if typed: 392 | result = DeepGroup(**result) 393 | return result 394 | 395 | def patch_group(self, group_id: str, **params) -> None: 396 | """ 397 | This endpoint can patch one or more fields of the specified group. 398 | 399 | :param group_id: the group id 400 | :param params: parameters for the PATCH request. 401 | """ 402 | self._request(f"/groups/{group_id}", "PATCH", json=params) 403 | 404 | def delete_group(self, group_id: str) -> None: 405 | """ 406 | This endpoint deletes the specified group. 407 | WARNING: This operation is permanent and undoable. 408 | 409 | :param group_id: the group id. 410 | """ 411 | self._request(f"/groups/{group_id}", "DELETE") 412 | 413 | def get_group_replays( 414 | self, 415 | group: Union[str, dict, BasicGroup], 416 | *, 417 | deep: bool = False, 418 | typed: Optional[bool] = None 419 | ) -> Iterator[Union[dict, ShallowReplay, DeepReplay]]: 420 | """ 421 | Finds all replays in a group, including child groups. 422 | 423 | :param group: the base group id, group dict, or BaseGroup object. 424 | :param deep: whether or not to get full stats for each replay (will be much slower). 425 | :param typed: whether to return a typed object (default is self.typed). 426 | :return: an iterator over all the replays in the group. 427 | """ 428 | for path in self.get_group_tree(group, deep=deep, typed=typed): 429 | group, replay = path 430 | yield replay 431 | 432 | def get_group_tree( 433 | self, 434 | group: Union[str, dict, BaseGroup], 435 | *, 436 | deep: bool = False, 437 | typed: Optional[bool] = None 438 | ): 439 | """ 440 | Finds all replays in a group, and includes the groups leading up to the replays. 441 | :param group: the group id or a group dict. 442 | :param deep: whether to get full stats for each replay and group (will be much slower). 443 | :param typed: whether to return a typed object (default is self.typed). 444 | """ 445 | if isinstance(group, str): 446 | group = self.get_group(group) 447 | if isinstance(group, BasicGroup): 448 | group_id = group.id 449 | else: 450 | group_id = group["id"] 451 | child_groups = self.get_groups(group=group_id, typed=typed) 452 | for child in child_groups: 453 | for path in self.get_group_tree(child, deep=deep, typed=typed): 454 | yield group_id, *path 455 | for replay in self.get_replays(group_id=group_id, deep=deep, typed=typed): 456 | yield group_id, replay 457 | 458 | def download_replay(self, replay_id: str, path: str): 459 | """ 460 | Download a replay file. 461 | 462 | :param replay_id: the replay id. 463 | :param path: the path to download the replay to. Can be a file path or a directory. 464 | """ 465 | r = self._request(f"/replays/{replay_id}/file", "GET") 466 | if os.path.isdir(path): 467 | # If path is a directory, use the replay id as the filename 468 | filename = f"{replay_id}.replay" 469 | path = os.path.join(path, filename) 470 | with open(path, "wb") as f: 471 | f.write(r.content) 472 | 473 | def download_group(self, group_id: str, folder: str, *, keep_tree_structure=True): 474 | """ 475 | Download an entire group. 476 | 477 | :param group_id: the base group id. 478 | :param folder: the folder in which to create the group folder. 479 | :param keep_tree_structure: whether to create new folders for child groups. 480 | """ 481 | folder = os.path.join(folder, group_id) 482 | if keep_tree_structure: 483 | os.makedirs(folder, exist_ok=True) 484 | for child_group in self.get_groups(group=group_id): 485 | self.download_group(child_group["id"], folder, keep_tree_structure=True) 486 | for replay in self.get_replays(group_id=group_id): 487 | self.download_replay(replay["id"], folder) 488 | else: 489 | for replay in self.get_group_replays(group_id): 490 | self.download_replay(replay["id"], folder) 491 | 492 | def get_maps(self): 493 | """ 494 | Use this API to get the list of map codes to map names (map as in stadium). 495 | """ 496 | res = self._request("/maps", "GET").json() 497 | return res 498 | 499 | def get_stats(self, replay: Union[dict, str]): 500 | """ 501 | Gets stats for players, teams and replay info. 502 | 503 | :param replay: the replay to get stats for. Can be a replay id (str) or a replay dict. 504 | :return: a dictionary containing replay, team and player stats. 505 | """ 506 | 507 | if isinstance(replay, str): 508 | replay = self.get_replay(replay, typed=False) 509 | elif isinstance(replay, dict) and "title" not in replay: 510 | replay = self.get_replay(replay["id"], typed=False) 511 | 512 | stats = parse_replay_stats(replay) 513 | return stats 514 | 515 | def __repr__(self): 516 | return f"BallchasingApi(key={self.auth_key},name={self.steam_name}," \ 517 | f"steam_id={self.steam_id},type={self.patron_type})" 518 | --------------------------------------------------------------------------------