From dbabb1ab7a3f3ad9be697de08134463220a55806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aluerie=E2=9D=A4?= Date: Sat, 5 Oct 2024 16:39:49 +0300 Subject: [PATCH] Introduce `ext.deadlock` --- steam/app.py | 3 + steam/ext/deadlock/__init__.py | 12 ++ steam/ext/deadlock/client.py | 59 ++++++++ steam/ext/deadlock/enums.py | 127 ++++++++++++++++ steam/ext/deadlock/models.py | 55 +++++++ steam/ext/deadlock/protobufs/__init__.py | 14 ++ .../ext/deadlock/protobufs/client_messages.py | 31 ++++ steam/ext/deadlock/protobufs/common.py | 25 ++++ steam/ext/deadlock/protobufs/sdk.py | 141 ++++++++++++++++++ steam/ext/deadlock/state.py | 100 +++++++++++++ 10 files changed, 567 insertions(+) create mode 100644 steam/ext/deadlock/__init__.py create mode 100644 steam/ext/deadlock/client.py create mode 100644 steam/ext/deadlock/enums.py create mode 100644 steam/ext/deadlock/models.py create mode 100644 steam/ext/deadlock/protobufs/__init__.py create mode 100644 steam/ext/deadlock/protobufs/client_messages.py create mode 100644 steam/ext/deadlock/protobufs/common.py create mode 100644 steam/ext/deadlock/protobufs/sdk.py create mode 100644 steam/ext/deadlock/state.py diff --git a/steam/app.py b/steam/app.py index aaa65f29..5135d01a 100644 --- a/steam/app.py +++ b/steam/app.py @@ -1425,6 +1425,7 @@ def value(self) -> int: DOTA2 = "DOTA 2", 570 CSGO = "Counter Strike Global-Offensive", 730 STEAM = "Steam", 753 + DEADLOCK = "Deadlock", 1422450 @property def _state(self) -> ConnectionState: @@ -1442,6 +1443,8 @@ def _state(self) -> ConnectionState: """The Counter Strike Global-Offensive app.""" LFD2 = Apps.LFD2 """The Left 4 Dead 2 app.""" +DEADLOCK = Apps.DEADLOCK +"""The Deadlock app.""" STEAM = Apps.STEAM """The Steam app with context ID 6 (gifts).""" diff --git a/steam/ext/deadlock/__init__.py b/steam/ext/deadlock/__init__.py new file mode 100644 index 00000000..5fc56d13 --- /dev/null +++ b/steam/ext/deadlock/__init__.py @@ -0,0 +1,12 @@ +""" +steam.ext.deadlock +~~~~~~~~~~~~~~ + +A library for interacting with the Deadlock 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 * diff --git a/steam/ext/deadlock/client.py b/steam/ext/deadlock/client.py new file mode 100644 index 00000000..9794c031 --- /dev/null +++ b/steam/ext/deadlock/client.py @@ -0,0 +1,59 @@ +"""Licensed under The MIT License (MIT) - Copyright (c) 2020-present James H-B. See LICENSE""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from ..._const import DOCS_BUILDING +from ..._gc import Client as Client_ +from ...app import DEADLOCK +from ...ext import commands +from .models import ClientUser, PartialUser +from .state import GCState # noqa: TCH001 + +if TYPE_CHECKING: + from ...types.id import Intable + from ...utils import cached_property + 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 Deadlock 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 = DEADLOCK + _ClientUserCls = ClientUser + _state: GCState # type: ignore # PEP 705 + + if TYPE_CHECKING: + + @cached_property + def user(self) -> ClientUser: ... + + if TYPE_CHECKING or DOCS_BUILDING: + + def get_user(self, id: Intable) -> User | None: ... + + async def fetch_user(self, id: Intable) -> User: ... + + # TODO: maybe this should exist as a part of the whole lib (?) + def instantiate_partial_user(self, id: Intable) -> PartialUser: + return self._state.get_partial_user(id) + + +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`. + """ diff --git a/steam/ext/deadlock/enums.py b/steam/ext/deadlock/enums.py new file mode 100644 index 00000000..46e7adf5 --- /dev/null +++ b/steam/ext/deadlock/enums.py @@ -0,0 +1,127 @@ +"""Licensed under The MIT License (MIT) - Copyright (c) 2020-present James H-B. See LICENSE""" + +from __future__ import annotations + +from ...enums import IntEnum + + +class EMsg(IntEnum): + # EGCBaseClientMsg from source: gcsystemmsgs.proto + + GCPingRequest = 3001 + GCPingResponse = 3002 + GCToClientPollConvarRequest = 3003 + GCToClientPollConvarResponse = 3004 + GCCompressedMsgToClient = 3005 + GCCompressedMsgToClient_Legacy = 523 + GCToClientRequestDropped = 3006 + GCClientWelcome = 4004 + GCServerWelcome = 4005 + GCClientHello = 4006 + GCServerHello = 4007 + GCClientConnectionStatus = 4009 + GCServerConnectionStatus = 4010 + + # EGCCitadelClientMessages from source: citadel_gcmessages_client.proto + ClientToGCStartMatchmaking = 9010 + ClientToGCStartMatchmakingResponse = 9011 + ClientToGCStopMatchmaking = 9012 + ClientToGCStopMatchmakingResponse = 9013 + GCToClientMatchmakingStopped = 9014 + ClientToGCLeaveLobby = 9015 + ClientToGCLeaveLobbyResponse = 9016 + ClientToGCIsInMatchmaking = 9017 + ClientToGCIsInMatchmakingResponse = 9018 + GCToClientDevPlaytestStatus = 9019 + ClientToGCDevSetMMBias = 9023 + ClientToGCGetProfileCard = 9024 + ClientToGCGetProfileCardResponse = 9025 + ClientToGCUpdateRoster = 9026 + ClientToGCUpdateRosterResponse = 9027 + GCToClientProfileCardUpdated = 9028 + GCToClientDevAnnouncements = 9029 + ClientToGCModifyDevAnnouncements = 9030 + ClientToGCModifyDevAnnouncementsResponse = 9031 + GCToClientSDRTicket = 9100 + ClientToGCReplacementSDRTicket = 9101 + ClientToGCReplacementSDRTicketResponse = 9102 + ClientToGCSetServerConVar = 9107 + ClientToGCSetServerConVarResponse = 9108 + ClientToGCSpectateLobby = 9109 + ClientToGCSpectateLobbyResponse = 9110 + ClientToGCPostMatchSurveyResponse = 9111 + ClientToGCGetMatchHistory = 9112 + ClientToGCGetMatchHistoryResponse = 9113 + ClientToGCSpectateUser = 9116 + ClientToGCSpectateUserResponse = 9117 + ClientToGCPartyCreate = 9123 + ClientToGCPartyCreateResponse = 9124 + ClientToGCPartyLeave = 9125 + ClientToGCPartyLeaveResponse = 9126 + ClientToGCPartyJoin = 9127 + ClientToGCPartyJoinResponse = 9128 + ClientToGCPartyAction = 9129 + ClientToGCPartyActionResponse = 9130 + ClientToGCPartyStartMatch = 9131 + ClientToGCPartyStartMatchResponse = 9132 + ClientToGCPartyInviteUser = 9133 + ClientToGCPartyInviteUserResponse = 9134 + GCToClientPartyEvent = 9135 + GCToClientCanRejoinParty = 9137 + ClientToGCPartyJoinViaCode = 9138 + ClientToGCPartyJoinViaCodeResponse = 9139 + ClientToGCPartyUpdateRoster = 9140 + ClientToGCPartyUpdateRosterResponse = 9141 + ClientToGCPartySetReadyState = 9142 + ClientToGCPartySetReadyStateResponse = 9143 + ClientToGCGetAccountStats = 9164 + ClientToGCGetAccountStatsResponse = 9165 + GCToClientAccountStatsUpdated = 9166 + ClientToGCGetMatchMetaData = 9167 + ClientToGCGetMatchMetaDataResponse = 9168 + ClientToGCDevAction = 9172 + ClientToGCDevActionResponse = 9173 + ClientToGCRecordClientEvents = 9174 + ClientToGCRecordClientEventsResponse = 9175 + ClientToGCSetNewPlayerProgress = 9176 + ClientToGCSetNewPlayerProgressResponse = 9177 + ClientToGCUpdateAccountSync = 9178 + ClientToGCUpdateAccountSyncResponse = 9179 + ClientToGCGetHeroChoice = 9180 + ClientToGCGetHeroChoiceResponse = 9181 + ClientToGCUnlockHero = 9182 + ClientToGCUnlockHeroResponse = 9183 + ClientToGCBookUnlock = 9184 + ClientToGCBookUnlockResponse = 9185 + ClientToGCGetBook = 9186 + ClientToGCGetBookResponse = 9187 + GCToClientBookUpdated = 9188 + ClientToGCSubmitPlaytestUser = 9189 + ClientToGCSubmitPlaytestUserResponse = 9190 + ClientToGCUpdateHeroBuild = 9193 + ClientToGCUpdateHeroBuildResponse = 9194 + ClientToGCFindHeroBuilds = 9195 + ClientToGCFindHeroBuildsResponse = 9196 + ClientToGCReportPlayerFromMatch = 9197 + ClientToGCReportPlayerFromMatchResponse = 9198 + ClientToGCGetAccountMatchReports = 9199 + ClientToGCGetAccountMatchReportsResponse = 9200 + ClientToGCDeleteHeroBuild = 9201 + ClientToGCDeleteHeroBuildResponse = 9202 + ClientToGCGetActiveMatches = 9203 + ClientToGCGetActiveMatchesResponse = 9204 + ClientToGCGetDiscordLink = 9205 + ClientToGCGetDiscordLinkResponse = 9206 + ClientToGCPartySetMode = 9207 + ClientToGCPartySetModeResponse = 9208 + ClientToGCGrantForumAccess = 9209 + ClientToGCGrantForumAccessResponse = 9210 + ClientToGCModeratorRequest = 9211 + ClientToGCModeratorRequestResponse = 9212 + ClientToGCGetFriendGameStatus = 9213 + ClientToGCGetFriendGameStatusResponse = 9214 + ClientToGCUpdateHeroBuildPreference = 9215 + ClientToGCUpdateHeroBuildPreferenceResponse = 9216 + ClientToGCGetOldHeroBuildData = 9217 + ClientToGCGetOldHeroBuildDataResponse = 9218 + ClientToGCUpdateSpectatorStatus = 9219 diff --git a/steam/ext/deadlock/models.py b/steam/ext/deadlock/models.py new file mode 100644 index 00000000..ac9dcf23 --- /dev/null +++ b/steam/ext/deadlock/models.py @@ -0,0 +1,55 @@ +"""Licensed under The MIT License (MIT) - Copyright (c) 2020-present James H-B. See LICENSE""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +from ... import abc, user +from ..._gc.client import ClientUser as ClientUser_ + +if TYPE_CHECKING: + from .protobufs import client_messages, common + from .state import GCState + +UserT = TypeVar("UserT", bound=abc.PartialUser) + +__all__ = ( + "ClientUser", + "PartialUser", + "User", +) + + +class PartialUser(abc.PartialUser): + __slots__ = () + _state: GCState + + async def account_stats(self): + """Fetch user's Deadlock account stats.""" + proto = await self._state.fetch_account_stats(self.id, False, True) + return AccountStats(proto) + + +class User(PartialUser, user.User): # type: ignore + __slots__ = () + + +class ClientUser(PartialUser, ClientUser_): # type: ignore + # TODO: if TYPE_CHECKING: for inventory + ... + + +class AccountStats: + def __init__(self, proto: client_messages.ClientToGCGetAccountStatsResponse): + self.account_id = proto.stats.account_id + self.stats = [AccountHeroStats(p) for p in proto.stats.stats] + + +class AccountHeroStats: + def __init__(self, proto: common.AccountHeroStats) -> None: + self.hero_id: int = proto.hero_id # TODO: Modelize Enum + self.stat_id: list[int] = proto.stat_id + self.total_value: list[int] = proto.total_value + self.medals_bronze: list[int] = proto.medals_bronze + self.medals_silver: list[int] = proto.medals_silver + self.medals_gold: list[int] = proto.medals_gold diff --git a/steam/ext/deadlock/protobufs/__init__.py b/steam/ext/deadlock/protobufs/__init__.py new file mode 100644 index 00000000..5303e31e --- /dev/null +++ b/steam/ext/deadlock/protobufs/__init__.py @@ -0,0 +1,14 @@ +from typing import Final + +import betterproto + +APP_ID: Final = 1422450 + +from ....protobufs.msg import GCProtobufMessage +from . import ( + client_messages as client_messages, + common as common, + sdk as sdk, +) + +[setattr(cls, "_betterproto", betterproto.ProtoClassMetadata(cls)) for cls in GCProtobufMessage.__subclasses__()] diff --git a/steam/ext/deadlock/protobufs/client_messages.py b/steam/ext/deadlock/protobufs/client_messages.py new file mode 100644 index 00000000..ae5a1aa7 --- /dev/null +++ b/steam/ext/deadlock/protobufs/client_messages.py @@ -0,0 +1,31 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# sources: citadel_gcmessages_client.proto +# plugin: python-betterproto + +from __future__ import annotations + +import betterproto + +from ....protobufs.msg import GCProtobufMessage +from ..enums import EMsg +from .common import AccountStats # noqa: TCH001 + + +class ClientToGCGetAccountStatsResponseEResult(betterproto.Enum): + InternalError = 0 + Success = 1 + Disabled = 2 + TooBusy = 3 + RateLimited = 4 + InvalidPermissions = 5 + + +class ClientToGCGetAccountStats(GCProtobufMessage, msg=EMsg.ClientToGCGetAccountStats): + account_id: int = betterproto.uint32_field(1) + dev_access_hint: bool = betterproto.bool_field(2) + friend_access_hint: bool = betterproto.bool_field(3) + + +class ClientToGCGetAccountStatsResponse(GCProtobufMessage, msg=EMsg.ClientToGCGetAccountStatsResponse): + eresult: ClientToGCGetAccountStatsResponseEResult = betterproto.enum_field(1) + stats: AccountStats = betterproto.message_field(2) diff --git a/steam/ext/deadlock/protobufs/common.py b/steam/ext/deadlock/protobufs/common.py new file mode 100644 index 00000000..f8a6285a --- /dev/null +++ b/steam/ext/deadlock/protobufs/common.py @@ -0,0 +1,25 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# sources: citadel_gcmessages_client.proto +# plugin: python-betterproto + +from __future__ import annotations + +from dataclasses import dataclass + +import betterproto + + +@dataclass(eq=False, repr=False) +class AccountStats(betterproto.Message): + account_id: int = betterproto.uint32_field(1) + stats: list[AccountHeroStats] = betterproto.message_field(2) + + +@dataclass(eq=False, repr=False) +class AccountHeroStats(betterproto.Message): + hero_id: int = betterproto.uint32_field(1) + stat_id: list[int] = betterproto.uint32_field(2) + total_value: list[int] = betterproto.uint64_field(3) + medals_bronze: list[int] = betterproto.uint32_field(4) + medals_silver: list[int] = betterproto.uint32_field(5) + medals_gold: list[int] = betterproto.uint32_field(6) diff --git a/steam/ext/deadlock/protobufs/sdk.py b/steam/ext/deadlock/protobufs/sdk.py new file mode 100644 index 00000000..ccdd50bf --- /dev/null +++ b/steam/ext/deadlock/protobufs/sdk.py @@ -0,0 +1,141 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# sources: gcsdk_gcmessages.proto +# plugin: python-betterproto +from __future__ import annotations + +from dataclasses import dataclass + +import betterproto + +from ....protobufs.msg import GCProtobufMessage +from ..enums import EMsg + + +class ESourceEngine(betterproto.Enum): + Source1 = 0 + Source2 = 1 + + +class PartnerAccountType(betterproto.Enum): + NONE = 0 + PerfectWorld = 1 + Invalid = 3 + + +class GCConnectionStatus(betterproto.Enum): + HaveSession = 0 + GCGoingDown = 1 + NoSession = 2 + NoSessionInLogonQueue = 3 + NoSteam = 4 + Suspended = 5 + SteamGoingDown = 6 + + +class ClientHello(GCProtobufMessage, msg=EMsg.GCClientHello): + version: int = betterproto.uint32_field(1) + socache_have_versions: list[SOCacheHaveVersion] = betterproto.message_field(2) + client_session_need: int = betterproto.uint32_field(3) + client_launcher: PartnerAccountType = betterproto.enum_field(4) + secret_key: str = betterproto.string_field(5) + client_language: int = betterproto.uint32_field(6) + engine: ESourceEngine = betterproto.enum_field(7) + steamdatagram_login: bytes = betterproto.bytes_field(8) + platform_id: int = betterproto.uint32_field(9) + game_msg: bytes = betterproto.bytes_field(10) + os_type: int = betterproto.int32_field(11) + render_system: int = betterproto.uint32_field(12) + render_system_req: int = betterproto.uint32_field(13) + screen_width: int = betterproto.uint32_field(14) + screen_height: int = betterproto.uint32_field(15) + screen_refresh: int = betterproto.uint32_field(16) + render_width: int = betterproto.uint32_field(17) + render_height: int = betterproto.uint32_field(18) + swap_width: int = betterproto.uint32_field(19) + swap_height: int = betterproto.uint32_field(20) + is_steam_china: bool = betterproto.bool_field(22) + is_steam_china_client: bool = betterproto.bool_field(24) + platform_name: str = betterproto.string_field(23) + + +@dataclass(eq=False, repr=False) +class SOCacheHaveVersion(betterproto.Message): + soid: SOIDOwner = betterproto.message_field(1) + version: float = betterproto.fixed64_field(2) + service_id: int = betterproto.uint32_field(3) + cached_file_version: int = betterproto.uint32_field(4) + + +@dataclass(eq=False, repr=False) +class SOIDOwner(betterproto.Message): + type: int = betterproto.uint32_field(1) + id: int = betterproto.uint64_field(2) + + +class ConnectionStatus(GCProtobufMessage, msg=EMsg.GCClientConnectionStatus): + status: GCConnectionStatus = betterproto.enum_field(1) + client_session_need: int = betterproto.uint32_field(2) + queue_position: int = betterproto.int32_field(3) + queue_size: int = betterproto.int32_field(4) + wait_seconds: int = betterproto.int32_field(5) + estimated_wait_seconds_remaining: int = betterproto.int32_field(6) + + +class ClientWelcome(GCProtobufMessage, msg=EMsg.GCClientWelcome): + version: int = betterproto.uint32_field(1) + game_data: bytes = betterproto.bytes_field(2) + outofdate_subscribed_caches: list[SOCacheSubscribed] = betterproto.message_field(3) + uptodate_subscribed_caches: list[SOCacheSubscriptionCheck] = betterproto.message_field(4) + location: ClientWelcomeLocation = betterproto.message_field(5) + gc_socache_file_version: int = betterproto.uint32_field(9) + txn_country_code: str = betterproto.string_field(10) + game_data2: bytes = betterproto.bytes_field(11) + rtime32_gc_welcome_timestamp: int = betterproto.uint32_field(12) + currency: int = betterproto.uint32_field(13) + balance: int = betterproto.uint32_field(14) + balance_url: str = betterproto.string_field(15) + has_accepted_china_ssa: bool = betterproto.bool_field(16) + is_banned_steam_china: bool = betterproto.bool_field(17) + additional_welcome_msgs: CExtraMsgBlock = betterproto.message_field(18) + # steam_learn_server_info: SteamLearnServerInfo = betterproto.message_field(20) + # ^ private steam learn stuff (useless for us) but it has ridiculous amount of chaining protos + + +@dataclass(eq=False, repr=False) +class SOCacheSubscribed(betterproto.Message): + objects: list[SOCacheSubscribedSubscribedType] = betterproto.message_field(2) + version: float = betterproto.fixed64_field(3) + owner_soid: SOIDOwner = betterproto.message_field(4) + service_id: int = betterproto.uint32_field(5) + service_list: list[int] = betterproto.uint32_field(6) + sync_version: float = betterproto.fixed64_field(7) + + +@dataclass(eq=False, repr=False) +class SOCacheSubscriptionCheck(betterproto.Message): + version: float = betterproto.fixed64_field(2) + owner_soid: SOIDOwner = betterproto.message_field(3) + service_id: int = betterproto.uint32_field(4) + service_list: list[int] = betterproto.uint32_field(5) + sync_version: float = betterproto.fixed64_field(6) + + +@dataclass(eq=False, repr=False) +class ClientWelcomeLocation(betterproto.Message): + latitude: float = betterproto.float_field(1) + longitude: float = betterproto.float_field(2) + country: str = betterproto.string_field(3) + + +@dataclass(eq=False, repr=False) +class CExtraMsgBlock(betterproto.Message): + msg_type: int = betterproto.uint32_field(1) + contents: bytes = betterproto.bytes_field(2) + msg_key: int = betterproto.uint64_field(3) + is_compressed: bool = betterproto.bool_field(4) + + +@dataclass(eq=False, repr=False) +class SOCacheSubscribedSubscribedType(betterproto.Message): + type_id: int = betterproto.int32_field(1) + object_data: list[bytes] = betterproto.bytes_field(2) diff --git a/steam/ext/deadlock/state.py b/steam/ext/deadlock/state.py new file mode 100644 index 00000000..8993276d --- /dev/null +++ b/steam/ext/deadlock/state.py @@ -0,0 +1,100 @@ +"""Licensed under The MIT License (MIT) - Copyright (c) 2020-present James H-B. See LICENSE""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +from ..._gc import GCState as GCState_ +from ...app import DEADLOCK +from ...enums import IntEnum +from ...errors import WSException +from ...id import _ID64_TO_ID32 +from ...state import parser +from .models import PartialUser, User +from .protobufs import client_messages, sdk + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + from weakref import WeakValueDictionary + + from ...protobufs import friends + from ...types.id import ID32, ID64, Intable + from .client import Client + + +class Result(IntEnum): + # TODO: is there an official list for this? + Invalid = 0 + OK = 1 + + +class GCState(GCState_[Any]): # TODO: implement basket-analogy for deadlock + client: Client # type: ignore # PEP 705 + _users: WeakValueDictionary[ID32, User] + _APP = DEADLOCK # type: ignore + + def _store_user(self, proto: friends.CMsgClientPersonaStateFriend) -> User: + try: + user = self._users[_ID64_TO_ID32(proto.friendid)] + except KeyError: + user = User(state=self, proto=proto) + self._users[user.id] = user + else: + user._update(proto) + return user + + def get_partial_user(self, id: Intable) -> PartialUser: + return PartialUser(self, id) + + if TYPE_CHECKING: + + def get_user(self, id: ID32) -> User | None: ... + + async def fetch_user(self, user_id64: ID64) -> User: ... + + async def fetch_users(self, user_id64s: Iterable[ID64]) -> Sequence[User]: ... + + async def _maybe_user(self, id: Intable) -> User: ... + + async def _maybe_users(self, id64s: Iterable[ID64]) -> Sequence[User]: ... + + def _get_gc_message(self) -> sdk.ClientHello: + return sdk.ClientHello() + + @parser + def parse_client_goodbye(self, msg: sdk.ConnectionStatus | None = None) -> None: + if msg is None or msg.status == sdk.GCConnectionStatus.NoSession: + self.dispatch("gc_disconnect") + self._gc_connected.clear() + self._gc_ready.clear() + if msg is not None: + self.dispatch("gc_status_change", msg.status) + + @parser + async def parse_gc_client_connect(self, msg: sdk.ClientWelcome) -> None: + if not self._gc_ready.is_set(): + self._gc_ready.set() + self.dispatch("gc_ready") + + # DEADLOCK RELATED PROTO CALLS + + async def fetch_account_stats( + self, + account_id: int, + dev_access_hint: bool, + friend_access_hint: bool, + *, + timeout: float = 10.0, + ) -> client_messages.ClientToGCGetAccountStatsResponse: + """Fetch user's account stats.""" + await self.ws.send_gc_message( + client_messages.ClientToGCGetAccountStats( + account_id=account_id, dev_access_hint=dev_access_hint, friend_access_hint=friend_access_hint + ) + ) + async with asyncio.timeout(timeout): + response = await self.ws.gc_wait_for(client_messages.ClientToGCGetAccountStatsResponse) + if response.eresult != Result.OK: + raise WSException(response) + return response