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 54 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
54 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
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
12 changes: 4 additions & 8 deletions docs/ext/commands/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,7 @@ Example:


@bot.command()
async def command(ctx, argument_1, argument_2):
...
async def command(ctx, argument_1, argument_2): ...

An invocation of ``!command some string`` would pass ``"some"`` to ``argument_1`` and ``"string"`` to ``argument_2``.

Expand All @@ -107,8 +106,7 @@ Example:
.. code-block:: python

@bot.command()
async def command(ctx, argument_1, *arguments):
...
async def command(ctx, argument_1, *arguments): ...

An invocation of ``!command some longer string`` would pass ``"some"`` to ``argument_1`` and ``("longer", "string")``
to ``arguments``.
Expand All @@ -128,8 +126,7 @@ Example:
.. code-block:: python

@bot.command()
async def command(ctx, argument_1, *, argument_2):
...
async def command(ctx, argument_1, *, argument_2): ...

An invocation of ``!command some longer string`` would pass ``"some"`` to ``argument_1`` and ``"longer string"`` to
``argument_2``.
Expand All @@ -150,8 +147,7 @@ Example:
.. code-block:: python

@bot.command()
async def command(ctx, argument_1, **arguments):
...
async def command(ctx, argument_1, **arguments): ...

An invocation of ``!command some string=long`` would pass ``"some"`` to ``argument_1`` and ``{"string": "long"}`` to
``**arguments``.
Expand Down
28 changes: 28 additions & 0 deletions docs/ext/dota2/api.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.. currentmodule:: steam.ext.dota2

API Reference
===============

The following section outlines the API of steam.py's Dota 2 extension module.


Client
------

.. attributetable:: Client

.. autoclass:: Client
:members:
:inherited-members:

Bot
----

``ext.dota2`` also provides a :class:`Bot` class, which is a subclass of :class:`Client` and :class:`steam.ext.commands.Bot`.

.. attributetable:: steam.ext.dota2.Bot

.. autoclass:: steam.ext.dota2.Bot
:members:
:inherited-members:

11 changes: 11 additions & 0 deletions docs/ext/dota2/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
(steam-ext-dota2)=

# `steam.ext.dota2` - A Dota 2 Game Coordinator client

This extension offers the ability to interact with the Dota 2 Game Coordinator (Dota 2 GC)

```{toctree}
:maxdepth: 2

api
```
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ steam API Reference <api>
steam.ext.commands API Reference <ext/commands/api.rst>
steam.ext.csgo API Reference <ext/csgo/api.rst>
steam.ext.tf2 API Reference <ext/tf2/api.rst>
steam.ext.dota2 API Reference <ext/dota2/api.rst>
```

## Extensions
Expand All @@ -41,4 +42,5 @@ steam.py has extension modules to help with common tasks.
ext/commands/index.rst
ext/csgo/index.rst
ext/tf2/index.rst
ext/dota2/index.rst
```
3 changes: 1 addition & 2 deletions steam/ext/commands/cog.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,7 @@ class Cog(Generic[BotT]):

.. code:: python

class MyCog(commands.Cog, name="SpecialCogName"):
...
class MyCog(commands.Cog, name="SpecialCogName"): ...

Defaults to ``Cog.__name__``.

Expand Down
8 changes: 4 additions & 4 deletions steam/ext/commands/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -906,8 +906,7 @@ def is_mod(ctx: commands.Context) -> bool:

@is_mod
@bot.command
async def kick(ctx: commands.Context, user: steam.User) -> None:
...
async def kick(ctx: commands.Context, user: steam.User) -> None: ...

This will raise an :exc:`steam.ext.commands.CheckFailure` if the user is not an a mod in the clan.

Expand Down Expand Up @@ -1009,8 +1008,9 @@ def cooldown(rate: int, per: float, type: BucketType = BucketType.Default) -> Co

@bot.command
@commands.cooldown(rate=1, per=10, type=commands.BucketType.User)
async def once_every_ten_seconds(ctx: commands.Context) -> None:
... # this can only be invoked a user every ten seconds.
async def once_every_ten_seconds(
ctx: commands.Context,
) -> None: ... # this can only be invoked a user every ten seconds.
"""

def decorator(command: MaybeCommandT) -> MaybeCommandT:
Expand Down
17 changes: 7 additions & 10 deletions steam/ext/commands/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,9 @@ class Converter(ConverterBase[T_co], ABC):
.. code:: python

@bot.command
async def command(ctx, user: steam.User):
... # this will tell the parser to convert user from a str to a steam.User object.
async def command(
ctx, user: steam.User
): ... # this will tell the parser to convert user from a str to a steam.User object.


# invoked as
Expand Down Expand Up @@ -205,14 +206,12 @@ def register(cls, command: MaybeCommandT) -> MaybeCommandT:
.. code:: python

class CustomUserConverter(commands.Converter[steam.User]):
async def convert(self, ctx: commands.Context, argument: str) -> steam.User:
...
async def convert(self, ctx: commands.Context, argument: str) -> steam.User: ...


@bot.command
@CustomUserConverter.register
async def is_cool(ctx, user: steam.User):
...
async def is_cool(ctx, user: steam.User): ...

In this example ``is_cool``'s user parameter would be registered to the ``CustomUserConverter`` rather than
the global :class:`UserConverter`.
Expand Down Expand Up @@ -411,8 +410,7 @@ class Default(Protocol, Any if TYPE_CHECKING else object):
.. code:: python

@bot.command()
async def info(ctx, user=DefaultAuthor):
... # if no user is passed it will be ctx.author
async def info(ctx, user=DefaultAuthor): ... # if no user is passed it will be ctx.author

A custom default:

Expand All @@ -425,8 +423,7 @@ async def default(self, ctx: commands.Context) -> commands.Command:

# then later
@bot.command
async def source(ctx: commands.Context, command: commands.Command = CurrentCommand):
... # command would now be source
async def source(ctx: commands.Context, command: commands.Command = CurrentCommand): ... # command would now be source


# this could also be mixed in with a converter to convert a string to a command.
Expand Down
12 changes: 12 additions & 0 deletions steam/ext/dota2/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""
steam.ext.dota2
~~~~~~~~~~~~~~

A library for interacting with the Dota 2 Game Coordinator.

Licensed under The MIT License (MIT) - Copyright (c) 2020-present James H-B. See LICENSE
"""

from .client import *
from .enums import *
from .models import *
208 changes: 208 additions & 0 deletions steam/ext/dota2/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"""Licensed under The MIT License (MIT) - Copyright (c) 2020-present James H-B. See LICENSE"""

from __future__ import annotations

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

from ..._const import DOCS_BUILDING, timeout
from ..._gc import Client as Client_
from ...app import DOTA2
from ...ext import commands
from ...utils import (
MISSING,
cached_property,
)
from .models import ClientUser, LiveMatch, MatchDetails
from .protobufs import client_messages, watch
from .state import GCState # noqa: TCH001

if TYPE_CHECKING:
from ...types.id import Intable
from .enums import Hero
from .models import User

__all__ = (
"Client",
"Bot",
)


class Client(Client_):
"""Represents a client connection that connects to Steam. This class is used to interact with the Steam API, CMs
and the Dota 2 Game Coordinator.

:class:`Client` is a subclass of :class:`steam.Client`, so whatever you can do with :class:`steam.Client` you can
do with :class:`Client`.
"""

_APP: Final = DOTA2
_ClientUserCls = ClientUser
_state: GCState # type: ignore # PEP 705

if TYPE_CHECKING:

@cached_property
def user(self) -> ClientUser:
...

async def top_live_matches(self, *, hero: Hero = MISSING, limit: int = 100) -> list[LiveMatch]:
"""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

* 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
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
fetch, i.e. Dota 2 will sometimes give 90 matches when `limit` is 100.
Or even "successfully" return 0 matches.

Returns
-------
List of currently live top matches.

Raises
------
ValueError
`limit` value should be between 1 and 100 inclusively.
asyncio.TimeoutError
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)
)

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]:
"""Fetch currently live tournament matches

Parameters
----------
league_id
Tournament league_id

Returns
-------
List of currently live tournament matches.

Raises
------
asyncio.TimeoutError
Request time-outed. The reason is usually Dota 2 Game Coordinator lagging or being down.
"""

future = self._state.ws.gc_wait_for(
watch.GCToClientFindTopSourceTVGamesResponse,
check=lambda msg: msg.league_id == league_id,
)
await self._state.ws.send_gc_message(watch.ClientToGCFindTopSourceTVGames(league_id=league_id))

async with timeout(15.0):
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]:
"""Fetch currently live matches by lobby_ids

Parameters
----------
lobby_ids
Lobby IDs

Returns
-------
List of live matches.

Raises
------
asyncio.TimeoutError
Request time-outed. The reason is usually Dota 2 Game Coordinator lagging or being down.
"""
future = self._state.ws.gc_wait_for(
watch.GCToClientFindTopSourceTVGamesResponse,
check=lambda msg: msg.specific_games == True,
)
await self._state.ws.send_gc_message(watch.ClientToGCFindTopSourceTVGames(lobby_ids=lobby_ids))

async with timeout(15.0):
response = await future
# todo: test with more than 10 lobby_ids, Game Coordinator will probably chunk it wrongly or fail at all

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)
Aluerie marked this conversation as resolved.
Show resolved Hide resolved

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:

def get_user(self, id: Intable) -> User | None:
...

async def fetch_user(self, id: Intable) -> User:
...


class Bot(commands.Bot, Client):
"""Represents a Steam bot.

:class:`Bot` is a subclass of :class:`~steam.ext.commands.Bot`, so whatever you can do with
:class:`~steam.ext.commands.Bot` you can do with :class:`Bot`.
"""
Loading
Loading