Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce ext.dota2 - Dota 2 Game Coordinator extension #460

Draft
wants to merge 60 commits into
base: main
Choose a base branch
from
Draft
Changes from 1 commit
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
de7a99f
🪧Compile protobufs into `.py` mirrors
Aluerie Jan 23, 2024
e21b9ef
🐸Initial implementation for Dota2 GC
Aluerie Jan 23, 2024
513ea0c
📃Initial docs for ext.dota2
Aluerie Jan 23, 2024
60e4c39
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 23, 2024
89bdc97
✒️Stealth edit to `fetch_top_source_tv_games` docstring
Aluerie Jan 23, 2024
dd0a3b8
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 23, 2024
6355e21
Apply suggestions from code review by Gobot
Aluerie Jan 23, 2024
89b5b01
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 23, 2024
ca12e0e
Apply suggestions from code review
Aluerie Jan 23, 2024
728fa0e
⛔Remove Steam NonSense
Aluerie Jan 24, 2024
f96967f
🔨Fix bad enum namings
Aluerie Jan 24, 2024
494bd5c
🪓 Total Overhaul of the PR
Aluerie Jan 24, 2024
f8bd9b6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 24, 2024
2e9383f
🖋️Small doc edit
Aluerie Jan 24, 2024
da096d4
🆘Apply suggestions from code review by Gobot
Aluerie Jan 25, 2024
ab90872
🩺 More feedback corrections
Aluerie Jan 25, 2024
c07dff9
🪿Remove `fmt: off` from betterproto Enums
Aluerie Jan 25, 2024
dd1b731
🤺Quotes ' -> "
Aluerie Jan 25, 2024
db78cd0
🪥 Few more brush ups
Aluerie Jan 25, 2024
8ec073f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 25, 2024
3c89f4a
🪥and more
Aluerie Jan 25, 2024
e353b49
📺LiveMatchPlayer should subclass Partial user
Aluerie Jan 25, 2024
49ac537
🦸‍♀️heroes property for LiveMatch
Aluerie Jan 25, 2024
9fd115f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 25, 2024
aa7433b
🤧fix import * abd `__all__`
Aluerie Jan 25, 2024
8561fcb
Merge branch 'introduce-ext.dota2' of https://github.com/Aluerie/stea…
Aluerie Jan 25, 2024
59bf6fd
🔣Doc String + Remove C prefix
Aluerie Jan 25, 2024
037355f
📈 Glicko Rating and Behavior Summary
Aluerie Jan 27, 2024
5a19a1d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 27, 2024
7e3ddb1
💥MatchDetails, MM stats, rating - very raw
Aluerie Feb 3, 2024
3b83928
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 3, 2024
b4bd089
Merge branch 'Gobot1234:main' into introduce-ext.dota2
Aluerie Sep 21, 2024
69d0c92
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 21, 2024
bd12ec8
Merge branch 'Gobot1234:main' into introduce-ext.dota2
Aluerie Sep 21, 2024
517e33f
🏅Implement RankTier Medals
Aluerie Sep 24, 2024
04fcdef
🔬Introduce Match Minimal
Aluerie Sep 29, 2024
5d50888
📂Divide `models.py` into a folder
Aluerie Sep 30, 2024
38a8f39
Create models.py
Aluerie Sep 30, 2024
702f883
🧓Match History
Aluerie Sep 30, 2024
f553a8d
📲Social Feed Post Message
Aluerie Sep 30, 2024
bfef6bd
🪥Some Brush-ups
Aluerie Sep 30, 2024
da9d459
〽️Instantiate Partial Match
Aluerie Sep 30, 2024
8d166c3
🖼️Profile Request
Aluerie Sep 30, 2024
37a8fa7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 30, 2024
55f0486
Doc Edits
Aluerie Sep 30, 2024
87e9220
Merge branch 'introduce-ext.dota2' of https://github.com/Aluerie/stea…
Aluerie Sep 30, 2024
39f907f
🔮Refactor things back to proper state -> models
Aluerie Oct 2, 2024
047da1f
🔝Separate state for TopSource too
Aluerie Oct 2, 2024
6abe799
🦜Separate state for MM stats
Aluerie Oct 2, 2024
12c6df1
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 2, 2024
e47e624
🌐 `replay_url`, `metadata_url`
Aluerie Oct 2, 2024
4a54ad1
🙂Add Facets to protos
Aluerie Oct 3, 2024
f465544
🎶Fix Match History
Aluerie Oct 7, 2024
1a96c93
🆕Add Kez hero
Aluerie Nov 22, 2024
f8f9e4a
🪥`timeout` keyword arg for some state methods
Aluerie Dec 31, 2024
5319c4b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 31, 2024
031fef9
Merge branch 'Gobot1234:main' into introduce-ext.dota2
Aluerie Dec 31, 2024
186fe70
Bump typing-extensions from 4.10.0 to 4.12.2
Aluerie Jan 29, 2025
501c0f1
Revert "Bump typing-extensions from 4.10.0 to 4.12.2"
Aluerie Jan 31, 2025
e37e82c
Merge branch 'Gobot1234:main' into introduce-ext.dota2
Aluerie Jan 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
🪥Some Brush-ups
Erm
Aluerie committed Sep 30, 2024
commit bfef6bd5bb8f183e6fb66e1974154618ab107cd8
125 changes: 62 additions & 63 deletions steam/ext/dota2/client.py
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@

import asyncio
from functools import partial
from typing import TYPE_CHECKING, Final
from typing import TYPE_CHECKING, Any, Final, overload

from ..._const import DOCS_BUILDING, timeout
from ..._gc import Client as Client_
@@ -14,7 +14,7 @@
MISSING,
cached_property,
)
from .models import ClientUser, LiveMatch, MatchDetails, MatchMinimal, PartialUser
from .models import ClientUser, LiveMatch, MatchMinimal, PartialUser
from .protobufs import client_messages, watch
from .state import GCState # noqa: TCH001

@@ -46,21 +46,55 @@ class Client(Client_):
@cached_property
def user(self) -> ClientUser: ...

# TODO: maybe this should exist as a part of the whole lib (?)
def instantiate_partial_user(self, id: Intable) -> PartialUser:
return PartialUser(self._state, id)

async def _fetch_find_top_source_tv_games(self, *, limit: int = 100, **kwargs: Any) -> list[LiveMatch]:
"""Private helper method to use `watch.ClientToGCFindTopSourceTVGames` requests.

Valid kwargs are fields from `watch.ClientToGCFindTopSourceTVGames` definition.
Some combinations are not working together.
This is why `Client` offers three methods below with offered prepared kwargs options that make sense.
"""
if limit < 1 or limit > 100:
raise ValueError("limit value should be between 1 and 100 inclusively.")

# mini-math: limit 100 -> start_game 90, 91 -> 90, 90 -> 80
start_game = (limit - 1) // 10 * 10

def check(start_game: int, msg: watch.GCToClientFindTopSourceTVGamesResponse) -> bool:
return msg.start_game == start_game

futures = [
self._state.ws.gc_wait_for(
watch.GCToClientFindTopSourceTVGamesResponse,
check=partial(check, start_game),
)
for start_game in range(0, start_game + 1, 10)
]
await self._state.ws.send_gc_message(watch.ClientToGCFindTopSourceTVGames(start_game=start_game, **kwargs))
async with timeout(15.0):
responses = await asyncio.gather(*futures)
# each response.game_list is 10 games (except possibly last one if filtered by hero)
live_matches = [LiveMatch(self._state, match) for response in responses for match in response.game_list]
# still need to slice the list, i.e. limit = 85, but live_matches above will have 90 matches
return live_matches[:limit]

async def top_live_matches(self, *, hero: Hero = MISSING, limit: int = 100) -> list[LiveMatch]:
"""Fetch top live matches
"""Fetch top live matches.

This is similar to game list in the Watch Tab of Dota 2 game app.
"Top matches" in this context means

* featured tournament matches
* highest average MMR matches
* featured tournament matches
* highest average MMR matches

Parameters
----------
hero
Filter matches by Hero. Note, in this case Game Coordinator will still use only current top100 live matches,
i.e. requesting "filter by Muerta" will return only subset of those matches in which
Muerta is currently being played. It will not look into lower MMR match than top100 to extend the return
Filter matches by Hero. Note, in this case Game Coordinator still only uses current top100 live matches,
i.e. requesting "filter by Muerta" results only in subset of those matches in which
Muerta is currently being played. It does not look into lower MMR match than top100 to extend the return
list to number of games from `limit` argument. This behavior is consistent with how Watch Tab works.
limit
Maximum amount of matches to fetch. This works rather as a boundary limit than "number of matches" to
@@ -79,42 +113,10 @@ async def top_live_matches(self, *, hero: Hero = MISSING, limit: int = 100) -> l
Request time-outed. The reason is usually Dota 2 Game Coordinator lagging or being down.
"""

if limit < 1 or limit > 100:
raise ValueError("limit value should be between 1 and 100 inclusively.")

# mini-math: limit 100 -> start_game 90, 91 -> 90, 90 -> 80
start_game = (limit - 1) // 10 * 10

def callback(start_game: int, msg: watch.GCToClientFindTopSourceTVGamesResponse) -> bool:
return msg.start_game == start_game

futures = [
self._state.ws.gc_wait_for(
watch.GCToClientFindTopSourceTVGamesResponse,
check=partial(callback, start_game),
)
for start_game in range(0, start_game + 1, 10)
]

if hero is MISSING:
await self._state.ws.send_gc_message(watch.ClientToGCFindTopSourceTVGames(start_game=start_game))
else:
await self._state.ws.send_gc_message(
watch.ClientToGCFindTopSourceTVGames(start_game=start_game, hero_id=hero.value)
)
hero_id = hero.value if hero else 0
return await self._fetch_find_top_source_tv_games(limit=limit, hero_id=hero_id)

async with timeout(15.0):
responses = await asyncio.gather(*futures)
# each response.game_list is 10 games (except possibly last one if filtered by hero)
live_matches = [LiveMatch(self._state, match) for response in responses for match in response.game_list]
# still need to slice the list, i.e. limit = 85, but live_matches above will have 90 matches
return live_matches[:limit]

async def tournament_live_matches(
self,
# todo: league_id as integer is not human-readable/gettable thing, introduce methods to easily find those
league_id: int,
) -> list[LiveMatch]:
async def tournament_live_matches(self, league_id: int) -> list[LiveMatch]:
"""Fetch currently live tournament matches

Parameters
@@ -142,11 +144,13 @@ async def tournament_live_matches(
response = await future
return [LiveMatch(self._state, match) for match in response.game_list]

async def live_matches(
self,
# todo: lobby_ids is not easy to get by the user. Introduce methods to get it, i.e. from Rich Presence
lobby_ids: list[int],
) -> list[LiveMatch]:
@overload
async def live_matches(self, *, lobby_id: int = ...) -> LiveMatch: ...

@overload
async def live_matches(self, *, lobby_ids: list[int] = ...) -> list[LiveMatch]: ...

async def live_matches(self, *, lobby_id: int = MISSING, lobby_ids: list[int] = MISSING):
"""Fetch currently live matches by lobby_ids

Parameters
@@ -163,28 +167,23 @@ async def live_matches(
asyncio.TimeoutError
Request time-outed. The reason is usually Dota 2 Game Coordinator lagging or being down.
"""
if lobby_id is not MISSING and lobby_ids is not MISSING:
raise TypeError("Cannot mix lobby_id and lobby_ids keyword arguments.")

response = await self._state.fetch_live_matches(lobby_ids)
return [LiveMatch(self._state, match) for match in response.game_list]

async def match_details(self, match_id: int) -> MatchDetails:
proto = await self._state.fetch_match_details(match_id)
if proto.eresult == 1:
return MatchDetails(self._state, proto.match)
else:
msg = f"Failed to get match_details for {match_id}"
raise ValueError(msg)
lobby_ids = [lobby_id] if lobby_id else lobby_ids
live_matches = await self._fetch_find_top_source_tv_games(limit=len(lobby_ids), lobby_ids=[lobby_ids])
# ig live_matches[0] is IndexError safe because if lobby_id is not valid then TimeoutError occurs above;
return live_matches[0] if lobby_id else live_matches

async def match_minimal(self, match_id: int) -> MatchMinimal:
proto = await self._state.fetch_match_minimal(match_ids=[match_id])
return MatchMinimal(self._state, proto.matches[0])
async def matches_minimal(self, match_id: int) -> list[MatchMinimal]:
proto = await self._state.fetch_matches_minimal(match_ids=[match_id])
return [MatchMinimal(self._state, match) for match in proto.matches]

async def matchmaking_stats(self):
future = self._state.ws.gc_wait_for(client_messages.MatchmakingStatsResponse)
await self._state.ws.send_gc_message(client_messages.MatchmakingStatsRequest())
async with timeout(15.0):
return await future
# return BehaviorSummary(behavior_score=resp.rank_value, communication_score=resp.rank_data1)

if TYPE_CHECKING or DOCS_BUILDING:

22 changes: 12 additions & 10 deletions steam/ext/dota2/enums.py
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@
"Hero",
"GameMode",
"LobbyType",
"Outcome",
"RankTier",
)

@@ -291,6 +292,9 @@ def display_name(self) -> str:
def id(self) -> int:
return self.value

def __bool__(self) -> bool: # type: ignore # idk I need Hero.NONE to be False
return bool(self.value)


class GameMode(IntEnum): # source: dota_shared_enums.proto
NONE = 0
@@ -435,22 +439,20 @@ class RankTier(IntEnum):

@property
def division(self) -> str:
if self.name[-1].isdigit():
return self.name[:-1]
else:
if self.value % 10 == 0:
return self.name
else:
return self.name[:-1]

@property
def stars(self) -> str:
return self.value % 10


# @property
# def display_name(self) -> str:
# if self.value % 10 == 0:
# return self.name
# else:
# return self.name[:-1] + ' ' + self.name[-1]
# do we need it as a factory helper method?
@property
def display_name(self) -> str:
suffix = f' {self.stars}' if self.stars else ''
return self.division + suffix


class Outcome(IntEnum): # source: dota_shared_enums.proto
52 changes: 8 additions & 44 deletions steam/ext/dota2/state.py
Original file line number Diff line number Diff line change
@@ -2,15 +2,15 @@

from __future__ import annotations

from asyncio import timeout
from typing import TYPE_CHECKING, Any

from ..._const import timeout
from ..._gc import GCState as GCState_
from ...app import DOTA2
from ...id import _ID64_TO_ID32
from ...state import parser
from .models import PartialUser, User
from .protobufs import client_messages, common, sdk, watch
from .protobufs import sdk, watch

if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
@@ -21,7 +21,7 @@
from .client import Client


class GCState(GCState_[Any]): # todo: implement basket-analogy for dota2
class GCState(GCState_[Any]): # TODO: implement basket-analogy for dota2
client: Client # type: ignore # PEP 705
_users: WeakValueDictionary[ID32, User]
_APP = DOTA2 # type: ignore
@@ -70,49 +70,13 @@ async def parse_gc_client_connect(self, msg: sdk.ClientWelcome) -> None:
self.dispatch("gc_ready")

# dota fetch proto calls
# the difference between these and the functions in `client`/`models` is that
# these give raw proto responses while the latter modelize/structure/refine them.
# shortcuts to proto calls that are used more than once in `client`/`models`

async def fetch_user_dota2_profile_card(self, user_id: int) -> common.ProfileCard:
"""Fetch User's Dota 2 Profile Card.
async def fetch_matches_minimal(self, match_ids: list[int]) -> watch.ClientToGCMatchesMinimalResponse:
"""Fetch Matches Minimal.

Contains basic info about the account. Kinda mirrors old profile page.
"""
await self.ws.send_gc_message(client_messages.ClientToGCGetProfileCard(account_id=user_id))
return await self.ws.gc_wait_for(
common.ProfileCard,
check=lambda msg: msg.account_id == user_id,
)

async def fetch_match_details(self, match_id: int) -> client_messages.MatchDetailsResponse:
"""Fetch Match Details.

This call is for already finished games. Contains most of the info that can be found in post-match stats.
"""
await self.ws.send_gc_message(client_messages.MatchDetailsRequest(match_id=match_id))
async with timeout(15.0):
return await self.ws.gc_wait_for(
client_messages.MatchDetailsResponse,
check=lambda msg: msg.match.match_id == match_id,
)

async def fetch_match_minimal(self, match_ids: list[int]) -> watch.ClientToGCMatchesMinimalResponse:
"""Fetch Match Minimal.

This call is for already finished games. Contains basic data about the match.
This call is for already finished games. Contains basic data about the matches.
"""
await self.ws.send_gc_message(watch.ClientToGCMatchesMinimalRequest(match_ids=match_ids))
async with timeout(15.0):
return await self.ws.gc_wait_for(
watch.ClientToGCMatchesMinimalResponse,
)

async def fetch_live_matches(self, lobby_ids: list[int]) -> watch.GCToClientFindTopSourceTVGamesResponse:
"""Fetch Live Match by lobby ids."""
await self.ws.send_gc_message(watch.ClientToGCFindTopSourceTVGames(lobby_ids=lobby_ids))
async with timeout(15.0):
# todo: test with more than 10 lobby_ids, Game Coordinator will probably chunk it wrongly or fail at all
return await self.ws.gc_wait_for(
watch.GCToClientFindTopSourceTVGamesResponse,
check=lambda msg: msg.specific_games == True,
)
return await self.ws.gc_wait_for(watch.ClientToGCMatchesMinimalResponse)
Loading