From 074939d8818e6073be4a918b25d7deadd43a5b7b Mon Sep 17 00:00:00 2001 From: omg-xtao <100690902+omg-xtao@users.noreply.github.com> Date: Fri, 5 Jul 2024 21:58:00 +0800 Subject: [PATCH] :sparkles: Support ZZZClient --- pyproject.toml | 2 +- simnet/__init__.py | 3 +- simnet/client/base.py | 2 + simnet/client/components/chronicle/base.py | 7 +- simnet/client/components/chronicle/genshin.py | 2 +- .../client/components/chronicle/starrail.py | 2 +- simnet/client/components/chronicle/zzz.py | 218 ++++++++++++++++++ simnet/client/components/daily.py | 10 +- simnet/client/components/lab.py | 9 + simnet/client/components/wish/base.py | 3 + simnet/client/components/wish/zzz.py | 54 +++++ simnet/client/routes.py | 18 +- simnet/client/zzz.py | 24 ++ simnet/models/lab/record.py | 53 +++++ simnet/models/zzz/__init__.py | 0 simnet/models/zzz/calculator.py | 69 ++++++ simnet/models/zzz/character.py | 36 +++ simnet/models/zzz/chronicle/__init__.py | 0 simnet/models/zzz/chronicle/notes.py | 110 +++++++++ simnet/models/zzz/chronicle/stats.py | 38 +++ simnet/models/zzz/wish.py | 64 +++++ simnet/utils/enums.py | 2 + simnet/utils/paginator.py | 2 +- simnet/utils/player.py | 47 ++++ 24 files changed, 764 insertions(+), 11 deletions(-) create mode 100644 simnet/client/components/chronicle/zzz.py create mode 100644 simnet/client/components/wish/zzz.py create mode 100644 simnet/client/zzz.py create mode 100644 simnet/models/zzz/__init__.py create mode 100644 simnet/models/zzz/calculator.py create mode 100644 simnet/models/zzz/character.py create mode 100644 simnet/models/zzz/chronicle/__init__.py create mode 100644 simnet/models/zzz/chronicle/notes.py create mode 100644 simnet/models/zzz/chronicle/stats.py create mode 100644 simnet/models/zzz/wish.py diff --git a/pyproject.toml b/pyproject.toml index 85434ae..6378c57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.8" -httpx = "^0.25.0" +httpx = ">=0.25.0" pydantic = "<2.0.0,>=1.10.7" [tool.poetry.group.dev.dependencies] diff --git a/simnet/__init__.py b/simnet/__init__.py index 9e9363a..7930b23 100644 --- a/simnet/__init__.py +++ b/simnet/__init__.py @@ -1,5 +1,6 @@ from simnet.client.genshin import GenshinClient from simnet.client.starrail import StarRailClient +from simnet.client.zzz import ZZZClient from simnet.utils.enums import Game, Region -__all__ = ("StarRailClient", "GenshinClient", "Game", "Region") +__all__ = ("StarRailClient", "GenshinClient", "ZZZClient", "Game", "Region") diff --git a/simnet/client/base.py b/simnet/client/base.py index adab195..25718b3 100644 --- a/simnet/client/base.py +++ b/simnet/client/base.py @@ -217,6 +217,8 @@ class BaseClient(AsyncContextManager["BaseClient"]): headers["x-rpc-device_id"] = self.get_device_id() headers["x-rpc-device_fp"] = self.get_device_fp() if self.region == Region.OVERSEAS: + if self.game == Game.ZZZ: + headers["x-rpc-lang"] = self.lang or lang headers["x-rpc-language"] = self.lang or lang if ds is None: app_version, client_type, ds = generate_dynamic_secret(self.region, ds_type, new_ds, data, params) diff --git a/simnet/client/components/chronicle/base.py b/simnet/client/components/chronicle/base.py index 4f53937..8be2027 100644 --- a/simnet/client/components/chronicle/base.py +++ b/simnet/client/components/chronicle/base.py @@ -50,10 +50,13 @@ class BaseChronicleClient(BaseClient): TimedOut: If the request times out. BadRequest: If the response contains an error. """ - base_url = RECORD_URL.get_url(region or self.region) + base_url = RECORD_URL.get_url(region or self.region, game or Game.GENSHIN) if game: - base_url = base_url / game.value / endpoint_type + if game == Game.ZZZ: + base_url = base_url / endpoint_type / "zzz" + else: + base_url = base_url / game.value / endpoint_type url = base_url / endpoint new_ds = self.region == Region.CHINESE diff --git a/simnet/client/components/chronicle/genshin.py b/simnet/client/components/chronicle/genshin.py index badf609..dc55f9e 100644 --- a/simnet/client/components/chronicle/genshin.py +++ b/simnet/client/components/chronicle/genshin.py @@ -312,7 +312,7 @@ class GenshinBattleChronicleClient(BaseChronicleClient): if self.account_id is not None and stuid is None: self.cookies.set("stuid", str(self.account_id)) if self.region == Region.OVERSEAS: - route = RECORD_URL.get_url(self.region) / "../community/apihub/api/widget/data" + route = RECORD_URL.get_url(self.region, self.game) / "../community/apihub/api/widget/data" params = {"game_id": "2"} data = await self.request_lab(route, params=params, lang=lang) model = NotesOverseaWidget diff --git a/simnet/client/components/chronicle/starrail.py b/simnet/client/components/chronicle/starrail.py index b5f8381..355a16a 100644 --- a/simnet/client/components/chronicle/starrail.py +++ b/simnet/client/components/chronicle/starrail.py @@ -412,7 +412,7 @@ class StarRailBattleChronicleClient(BaseChronicleClient): if self.account_id is not None and stuid is None: self.cookies.set("stuid", str(self.account_id)) if self.region == Region.OVERSEAS: - route = RECORD_URL.get_url(self.region) / "../community/apihub/api/hsr_widget" + route = RECORD_URL.get_url(self.region, self.game) / "../community/apihub/api/hsr_widget" data = await self.request_lab(route, lang=lang) model = StarRailNoteOverseaWidget else: diff --git a/simnet/client/components/chronicle/zzz.py b/simnet/client/components/chronicle/zzz.py new file mode 100644 index 0000000..27e3f56 --- /dev/null +++ b/simnet/client/components/chronicle/zzz.py @@ -0,0 +1,218 @@ +from typing import Optional, Mapping, Dict, Any, List + +from simnet.client.components.chronicle.base import BaseChronicleClient +from simnet.errors import BadRequest, DataNotPublic +from simnet.models.lab.record import RecordCard +from simnet.models.zzz.calculator import ZZZCalculatorCharacterDetails +from simnet.models.zzz.chronicle.notes import ZZZNote +from simnet.models.zzz.chronicle.stats import ZZZUserStats, ZZZAvatarBasic, ZZZBuddyBasic +from simnet.utils.enums import Game +from simnet.utils.player import recognize_region, recognize_zzz_server + +__all__ = ("ZZZBattleChronicleClient",) + + +class ZZZBattleChronicleClient(BaseChronicleClient): + """A client for retrieving data from ZZZ's battle chronicle component. + + This class is used to retrieve various data objects from StarRail's battle chronicle component, + including real-time notes, user statistics, and character information. + """ + + async def _request_zzz_record( + self, + endpoint: str, + player_id: Optional[int] = None, + endpoint_type: str = "api", + method: str = "GET", + lang: Optional[str] = None, + payload: Optional[Dict[str, Any]] = None, + ) -> Mapping[str, Any]: + """Get an arbitrary object from ZZZ's battle chronicle. + + Args: + endpoint (str): The endpoint of the object to retrieve. + player_id (Optional[int], optional): The player ID. Defaults to None. + method (str, optional): The HTTP method to use. Defaults to "GET". + lang (Optional[str], optional): The language of the data. Defaults to None. + payload (Optional[Dict[str, Any]], optional): The request payload. Defaults to None. + + Returns: + Mapping[str, Any]: The requested object. + + Raises: + BadRequest: If the request is invalid. + DataNotPublic: If the requested data is not public. + """ + payload = dict(payload or {}) + + player_id = player_id or self.player_id + payload = dict(role_id=player_id, server=recognize_zzz_server(player_id), **payload) + + data, params = None, None + if method == "POST": + data = payload + else: + params = payload + + return await self.request_game_record( + endpoint, + endpoint_type=endpoint_type, + lang=lang, + game=Game.ZZZ, + region=recognize_region(player_id, game=Game.ZZZ), + params=params, + data=data, + ) + + async def get_zzz_notes( + self, + player_id: Optional[int] = None, + lang: Optional[str] = None, + autoauth: bool = True, + ) -> ZZZNote: + """Get ZZZ's real-time notes. + + Args: + player_id (Optional[int], optional): The player ID. Defaults to None. + lang (Optional[str], optional): The language of the data. Defaults to None. + autoauth (bool, optional): Whether to automatically authenticate the user. Defaults to True. + + Returns: + ZZZNote: The requested real-time notes. + + Raises: + BadRequest: If the request is invalid. + DataNotPublic: If the requested data is not public. + """ + try: + data = await self._request_zzz_record("note", player_id, lang=lang) + except DataNotPublic as e: + # error raised only when real-time notes are not enabled + if player_id and self.player_id != player_id: + raise BadRequest(e.response, "Cannot view real-time notes of other users.") from e + if not autoauth: + raise BadRequest(e.response, "Real-time notes are not enabled.") from e + await self.update_settings(3, True, game=Game.ZZZ) + data = await self._request_zzz_record("note", player_id, lang=lang) + + return ZZZNote(**data) + + async def get_zzz_user( + self, + player_id: Optional[int] = None, + *, + lang: Optional[str] = None, + ) -> "ZZZUserStats": + """Get ZZZ user statistics. + + Args: + player_id (Optional[int], optional): The player ID. Defaults to None. + lang (Optional[str], optional): The language of the data. Defaults to None. + + Returns: + ZZZUserStats: The requested user statistics. + + Raises: + BadRequest: If the request is invalid. + DataNotPublic: If the requested data is not public. + """ + data = await self._request_zzz_record("index", player_id, lang=lang) + return ZZZUserStats(**data) + + async def get_zzz_characters( + self, + player_id: Optional[int] = None, + lang: Optional[str] = None, + ) -> "ZZZAvatarBasic": + """Get ZZZ character basic information. + + Args: + player_id (Optional[int], optional): The player ID. Defaults to None. + lang (Optional[str], optional): The language of the data. Defaults to None. + + Returns: + ZZZAvatarBasic: The requested character information. + + Raises: + BadRequest: If the request is invalid. + DataNotPublic: If the requested data is not public. + """ + data = await self._request_zzz_record("avatar/basic", player_id, lang=lang) + return ZZZAvatarBasic(**data) + + async def get_zzz_character_info( + self, + characters: List[int], + player_id: Optional[int] = None, + lang: Optional[str] = None, + ) -> "ZZZCalculatorCharacterDetails": + """Get ZZZ character detail information. + + Args: + characters (List[int]): A list of character IDs. + player_id (Optional[int], optional): The player ID. Defaults to None. + lang (Optional[str], optional): The language of the data. Defaults to None. + + Returns: + ZZZCalculatorCharacterDetails: The requested character information. + + Raises: + BadRequest: If the request is invalid. + DataNotPublic: If the requested data is not public. + """ + ch = characters + if isinstance(characters, int): + ch = [characters] + payload = {"need_wiki": "true", "id_list[]": ch} + data = await self._request_zzz_record("avatar/info", player_id, lang=lang, payload=payload) + return ZZZCalculatorCharacterDetails(**data) + + async def get_zzz_buddy_list( + self, + player_id: Optional[int] = None, + lang: Optional[str] = None, + ) -> "ZZZBuddyBasic": + """Get ZZZ buddy basic information. + + Args: + player_id (Optional[int], optional): The player ID. Defaults to None. + lang (Optional[str], optional): The language of the data. Defaults to None. + + Returns: + ZZZBuddyBasic: The requested buddy information. + + Raises: + BadRequest: If the request is invalid. + DataNotPublic: If the requested data is not public. + """ + data = await self._request_zzz_record("buddy/info", player_id, lang=lang) + return ZZZBuddyBasic(**data) + + async def get_record_card( + self, + account_id: Optional[int] = None, + *, + lang: Optional[str] = None, + ) -> Optional[RecordCard]: + """Get a zzz player record cards. + + Args: + account_id: Optional[int], the user's account ID, defaults to None + lang: Optional[str], the language version of the request, defaults to None + + Returns: + Starrail user record cards. + + Returns: + Optional[RecordCard]: RecordCard objects. + """ + account_id = account_id or self.account_id + + record_cards = await self.get_record_cards(account_id, lang=lang) + + for record_card in record_cards: + if record_card.game == Game.ZZZ: + return record_card + + return None diff --git a/simnet/client/components/daily.py b/simnet/client/components/daily.py index ed6694e..3e24891 100644 --- a/simnet/client/components/daily.py +++ b/simnet/client/components/daily.py @@ -10,7 +10,7 @@ from simnet.client.routes import REWARD_URL from simnet.errors import GeetestTriggered from simnet.models.lab.daily import DailyRewardInfo, DailyReward, ClaimedDailyReward from simnet.utils.enums import Game, Region -from simnet.utils.player import recognize_genshin_server, recognize_starrail_server +from simnet.utils.player import recognize_genshin_server, recognize_starrail_server, recognize_zzz_server __all__ = ("DailyRewardClient",) @@ -82,6 +82,14 @@ class DailyRewardClient(BaseClient): ) params = params.set("uid", self.player_id) params = params.set("region", recognize_starrail_server(self.player_id)) + if self.game == Game.ZZZ: + headers["referer"] = ( + "https://act.mihoyo.com/bbs/event/signin/zzz/e202406242138391.html?" + "act_id=e202406242138391&mhy_auth_required=true&mhy_presentation_style=fullscreen&" + "utm_source=bbs&utm_medium=zzz&utm_campaign=icon" + ) + params = params.set("uid", self.player_id) + params = params.set("region", recognize_zzz_server(self.player_id)) url = base_url / endpoint diff --git a/simnet/client/components/lab.py b/simnet/client/components/lab.py index 2ad0c74..deb6d1c 100644 --- a/simnet/client/components/lab.py +++ b/simnet/client/components/lab.py @@ -261,3 +261,12 @@ class LabClient(BaseClient): """ accounts = await self.get_game_accounts(lang=lang) return [account for account in accounts if account.game == Game.STARRAIL] + + async def get_zzz_accounts(self, *, lang: Optional[str] = None) -> List[Account]: + """Get the zzz accounts of the currently logged-in user. + + Returns: + List[Account]: A list of account info objects of zzz accounts. + """ + accounts = await self.get_game_accounts(lang=lang) + return [account for account in accounts if account.game == Game.ZZZ] diff --git a/simnet/client/components/wish/base.py b/simnet/client/components/wish/base.py index e72cf9b..5df5947 100644 --- a/simnet/client/components/wish/base.py +++ b/simnet/client/components/wish/base.py @@ -47,6 +47,9 @@ class BaseWishClient(BaseClient): params["authkey"] = unquote(authkey) params["lang"] = create_short_lang_code(lang or self.lang) + if game == Game.ZZZ: + params["real_gacha_type"] = params.get("gacha_type", "") + return await self.request_api("GET", url, params=params) async def wish_history( diff --git a/simnet/client/components/wish/zzz.py b/simnet/client/components/wish/zzz.py new file mode 100644 index 0000000..cd5e55b --- /dev/null +++ b/simnet/client/components/wish/zzz.py @@ -0,0 +1,54 @@ +from functools import partial +from typing import Optional, List + +from simnet.client.components.wish.base import BaseWishClient +from simnet.models.zzz.wish import ZZZWish +from simnet.utils.enums import Game +from simnet.utils.paginator import WishPaginator + +__all__ = ("ZZZWishClient",) + + +class ZZZWishClient(BaseWishClient): + """The ZZZWishClient class for making requests towards the Wish API.""" + + async def wish_history( + self, + banner_types: Optional[List[int]] = None, + limit: Optional[int] = None, + lang: Optional[str] = None, + authkey: Optional[str] = None, + end_id: int = 0, + ) -> List[ZZZWish]: + """ + Get the wish history for a list of banner types. + + Args: + banner_types (Optional[List[int]], optional): The banner types to get the wish history for. + limit (Optional[int] , optional): The maximum number of wishes to retrieve. + If not provided, all available wishes will be returned. + lang (Optional[str], optional): The language code to use for the request. + If not provided, the class default will be used. + authkey (Optional[str], optional): The authorization key for making the request. + end_id (int, optional): The ending ID of the last wish to retrieve. + + Returns: + List[ZZZWish]: A list of ZZZWish objects representing the retrieved wishes. + """ + banner_types = banner_types or [1, 2, 3, 5] + if isinstance(banner_types, int): + banner_types = [banner_types] + wishes = [] + for banner_type in banner_types: + paginator = WishPaginator( + end_id, + partial( + self.get_wish_page, + banner_type=banner_type, + game=Game.ZZZ, + authkey=authkey, + ), + ) + items = await paginator.get(limit) + wishes.extend([ZZZWish(**i) for i in items]) + return sorted(wishes, key=lambda wish: wish.time.timestamp()) diff --git a/simnet/client/routes.py b/simnet/client/routes.py index 8fececf..53ac9b0 100644 --- a/simnet/client/routes.py +++ b/simnet/client/routes.py @@ -220,9 +220,17 @@ class GameRoute(BaseRoute): return self.urls[region][game] -RECORD_URL = InternationalRoute( - overseas="https://bbs-api-os.hoyolab.com/game_record", - chinese="https://api-takumi-record.mihoyo.com/game_record/app", +RECORD_URL = GameRoute( + overseas=dict( + genshin="https://bbs-api-os.hoyolab.com/game_record", + hkrpg="https://bbs-api-os.hoyolab.com/game_record", + nap="https://sg-act-nap-api.hoyolab.com/event/game_record_zzz", + ), + chinese=dict( + genshin="https://api-takumi-record.mihoyo.com/game_record/app", + hkrpg="https://api-takumi-record.mihoyo.com/game_record/app", + nap="https://api-takumi-record.mihoyo.com/event/game_record_zzz", + ), ) GACHA_INFO_URL = GameRoute( @@ -233,6 +241,7 @@ GACHA_INFO_URL = GameRoute( chinese=dict( genshin="https://public-operation-hk4e.mihoyo.com/gacha_info/api", hkrpg="https://api-takumi.mihoyo.com/common/gacha_record/api", + nap="https://public-operation-nap.mihoyo.com/common/gacha_record/api", ), ) @@ -264,11 +273,13 @@ REWARD_URL = GameRoute( genshin="https://sg-hk4e-api.hoyolab.com/event/sol/?act_id=e202102251931481", honkai3rd="https://sg-public-api.hoyolab.com/event/mani/?act_id=e202110291205111", hkrpg="https://sg-public-api.hoyolab.com/event/luna/os/?act_id=e202303301540311", + nap="https://sg-act-nap-api.hoyolab.com/event/luna/zzz/os/?act_id=e202406031448091", ), chinese=dict( genshin="https://api-takumi.mihoyo.com/event/luna/?act_id=e202311201442471", honkai3rd="https://api-takumi.mihoyo.com/event/luna/?act_id=e202207181446311", hkrpg="https://api-takumi.mihoyo.com/event/luna/?act_id=e202304121516551", + nap="https://act-nap-api.mihoyo.com/event/luna/zzz/?act_id=e202406242138391", ), ) TAKUMI_URL = InternationalRoute( @@ -321,6 +332,7 @@ CODE_HOYOLAB_URL = GameRoute( overseas=dict( genshin="https://sg-hk4e-api.hoyolab.com/common/apicdkey/api/webExchangeCdkeyHyl", hkrpg="https://sg-hkrpg-api.hoyolab.com/common/apicdkey/api/webExchangeCdkeyHyl", + nap="https://public-operation-nap.hoyoverse.com/common/apicdkey/api/webExchangeCdkey", ), chinese={}, ) diff --git a/simnet/client/zzz.py b/simnet/client/zzz.py new file mode 100644 index 0000000..2ca1d54 --- /dev/null +++ b/simnet/client/zzz.py @@ -0,0 +1,24 @@ +from typing import Optional + +from simnet.client.components.auth import AuthClient +from simnet.client.components.chronicle.zzz import ZZZBattleChronicleClient +from simnet.client.components.daily import DailyRewardClient +from simnet.client.components.lab import LabClient +from simnet.client.components.verify import VerifyClient +from simnet.client.components.wish.zzz import ZZZWishClient +from simnet.utils.enums import Game + +__all__ = ("ZZZClient",) + + +class ZZZClient( + ZZZBattleChronicleClient, + ZZZWishClient, + DailyRewardClient, + AuthClient, + LabClient, + VerifyClient, +): + """A simple http client for StarRail endpoints.""" + + game: Optional[Game] = Game.ZZZ diff --git a/simnet/models/lab/record.py b/simnet/models/lab/record.py index 7ff0f0c..a02f660 100644 --- a/simnet/models/lab/record.py +++ b/simnet/models/lab/record.py @@ -58,6 +58,8 @@ class Account(APIModel): return Game.HONKAI if "hkrpg" in self.game_biz: return Game.STARRAIL + if "nap" in self.game_biz: + return Game.ZZZ try: return Game(self.game_biz) except ValueError: @@ -420,6 +422,57 @@ class StarRailRecodeCard(RecordCard): return int(self.data[3].value) +class ZZZRecodeCard(RecordCard): + """ZZZ record card.""" + + @property + def game(self) -> Game: + """Returns the game associated with the record card. + + Returns: + Game: The game associated with the record card. + """ + return Game.ZZZ + + @property + def days_active(self) -> int: + """Returns the number of days the user has been active. + + Returns: + int: The number of days the user has been active. + """ + return int(self.data[0].value) + + @property + def world_level_name(self) -> str: + """ + Returns the user's world level name. + + Returns: + str: The user's world level name. + """ + return self.data[1].value + + @property + def characters(self) -> int: + """Returns the number of characters the user has. + + Returns: + int: The number of characters the user has. + """ + return int(self.data[2].value) + + @property + def buddies(self) -> int: + """Returns the number of buddies the user has found. + + Returns: + int: The number of buddies the user has found. + """ + return int(self.data[3].value) + + RECORD_CARD_MAP.setdefault(1, HonkaiRecordCard) RECORD_CARD_MAP.setdefault(2, GenshinRecordCard) RECORD_CARD_MAP.setdefault(6, StarRailRecodeCard) +RECORD_CARD_MAP.setdefault(8, ZZZRecodeCard) diff --git a/simnet/models/zzz/__init__.py b/simnet/models/zzz/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/simnet/models/zzz/calculator.py b/simnet/models/zzz/calculator.py new file mode 100644 index 0000000..4c26247 --- /dev/null +++ b/simnet/models/zzz/calculator.py @@ -0,0 +1,69 @@ +from typing import List + +from pydantic import Field + +from simnet.models.base import APIModel +from simnet.models.zzz.character import ZZZPartialCharacter + + +class ZZZCalculatorWeaponProperty(APIModel): + + property_name: str + property_id: int + base: str + + +class ZZZCalculatorWeapon(APIModel): + + id: int + level: int + name: str + star: int + icon: str + rarity: str + properties: List[ZZZCalculatorWeaponProperty] + main_properties: List[ZZZCalculatorWeaponProperty] + talent_title: str + talent_content: str + profession: int + + +class ZZZCalculatorAvatarProperty(ZZZCalculatorWeaponProperty): + + add: str + final: str + + +class ZZZCalculatorAvatarSkillItem(APIModel): + title: str + text: str + + +class ZZZCalculatorAvatarSkill(APIModel): + + level: int + skill_type: int + items: List[ZZZCalculatorAvatarSkillItem] + + +class ZZZCalculatorAvatarRank(APIModel): + + id: int + name: str + desc: str + pos: int + is_unlocked: bool + + +class ZZZCalculatorCharacter(ZZZPartialCharacter): + + equip: List + weapon: ZZZCalculatorWeapon + properties: List[ZZZCalculatorAvatarProperty] + skills: List[ZZZCalculatorAvatarSkill] + ranks: List[ZZZCalculatorAvatarRank] + + +class ZZZCalculatorCharacterDetails(APIModel): + + characters: List[ZZZCalculatorCharacter] = Field(alias="avatar_list") diff --git a/simnet/models/zzz/character.py b/simnet/models/zzz/character.py new file mode 100644 index 0000000..578ee7e --- /dev/null +++ b/simnet/models/zzz/character.py @@ -0,0 +1,36 @@ +"""Starrail base character model.""" + +from pydantic import Field + +from simnet.models.base import APIModel + + +class ZZZBaseCharacter(APIModel): + """Base character model.""" + + id: int + element_type: int + rarity: str + group_icon_path: str + hollow_icon_path: str + + +class ZZZPartialCharacter(ZZZBaseCharacter): + """Character without any equipment.""" + + name: str = Field(alias="name_mi18n") + full_name: str = Field(alias="full_name_mi18n") + camp_name: str = Field(alias="camp_name_mi18n") + avatar_profession: int + level: int + rank: int + + +class ZZZBaseBuddy(APIModel): + """Base Buddy model.""" + + id: int + name: str + rarity: str + level: int + star: int diff --git a/simnet/models/zzz/chronicle/__init__.py b/simnet/models/zzz/chronicle/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/simnet/models/zzz/chronicle/notes.py b/simnet/models/zzz/chronicle/notes.py new file mode 100644 index 0000000..55b43da --- /dev/null +++ b/simnet/models/zzz/chronicle/notes.py @@ -0,0 +1,110 @@ +import datetime +import enum + +from simnet.models.base import APIModel + + +class ZZZNoteProgress(APIModel): + """ + Represents the progress of the user. + + Attributes: + max (int): The maximum progress of the user. + current (int): The current progress of the user. + """ + + max: int + current: int + + +class ZZZNoteEnergy(APIModel): + """ + Represents the energy of the user. + + Attributes: + progress (ZZZNoteProgress): The progress of the progress + restore (int): The restore of the progress + """ + + progress: ZZZNoteProgress + restore: datetime.timedelta + + +class ZZZNoteVitality(APIModel): + """ + Represents the vitality of the user. + + Attributes: + max (int): The maximum vitality of the user. + current (int): The current vitality of the user. + """ + + max: int + current: int + + +class ZZZNoteVhsSaleState(str, enum.Enum): + """ + Represents the state of the vhs sale of the user. + """ + + FREE = "SaleStateNo" + DOING = "SaleStateDoing" + DONE = "SaleStateDone" + + +class ZZZNoteVhsSale(APIModel): + """ + Represents the vhs sale of the user. + + Attributes: + sale_state (ZZZNoteVhsSaleState): The state of the vhs sale of the user. + """ + + sale_state: ZZZNoteVhsSaleState + + +class ZZZNoteCardSignState(str, enum.Enum): + """ + Represents the state of the card sign of the user. + """ + + FREE = "CardSignNo" + DONE = "CardSignDone" + + +class ZZZNote(APIModel): + """Represents a ZZZ Note. + + Attributes: + energy (ZZZNoteEnergy): The energy of the user. + vitality (ZZZNoteVitality): The vitality of the user. + vhs_sale (ZZZNoteVhsSale): The vhs sale of the user. + card_sign (ZZZNoteCardSignState): The card sign of the user. + """ + + energy: ZZZNoteEnergy + vitality: ZZZNoteVitality + vhs_sale: ZZZNoteVhsSale + card_sign: ZZZNoteCardSignState + + @property + def current_stamina(self) -> int: + return self.energy.progress.current + + @property + def max_stamina(self) -> int: + return self.energy.progress.max + + @property + def stamina_recover_time(self) -> datetime: + """A property that returns the time when resin will be fully recovered.""" + return datetime.datetime.now().astimezone() + self.energy.restore + + @property + def current_train_score(self) -> int: + return self.vitality.current + + @property + def max_train_score(self) -> int: + return self.vitality.max diff --git a/simnet/models/zzz/chronicle/stats.py b/simnet/models/zzz/chronicle/stats.py new file mode 100644 index 0000000..d8cc46e --- /dev/null +++ b/simnet/models/zzz/chronicle/stats.py @@ -0,0 +1,38 @@ +"""Starrail chronicle stats.""" + +import typing + +from pydantic import Field + +from simnet.models.base import APIModel +from simnet.models.zzz import character + + +class ZZZStats(APIModel): + """Overall user stats.""" + + active_days: int + avatar_num: int + world_level_name: str + cur_period_zone_layer_count: int + buddy_num: int + + +class ZZZAvatarBasic(APIModel): + """Basic avatar""" + + characters: typing.Sequence[character.ZZZPartialCharacter] = Field(alias="avatar_list") + + +class ZZZBuddyBasic(APIModel): + """Basic buddy""" + + buddy_list: typing.Sequence[character.ZZZBaseBuddy] = Field(alias="list") + + +class ZZZUserStats(ZZZAvatarBasic): + """User stats with characters without equipment.""" + + stats: ZZZStats + cur_head_icon_url: str + buddy_list: typing.Sequence[character.ZZZBaseBuddy] diff --git a/simnet/models/zzz/wish.py b/simnet/models/zzz/wish.py new file mode 100644 index 0000000..8703091 --- /dev/null +++ b/simnet/models/zzz/wish.py @@ -0,0 +1,64 @@ +from datetime import datetime +from enum import IntEnum +from typing import Any + +from pydantic import Field, validator + +from simnet.models.base import APIModel + + +class ZZZBannerType(IntEnum): + """Banner types in wish histories.""" + + STANDARD = PERMANENT = NOVICE = 1 + """Permanent standard banner.""" + + CHARACTER = 2 + """Rotating character banner.""" + + WEAPON = 3 + """Rotating weapon banner.""" + + BANGBOO = 5 + """BangBoo banner.""" + + +class ZZZWish(APIModel): + """Wish made on any banner.""" + + uid: int + """User ID of the wish maker.""" + + id: int + """ID of the wished item.""" + + type: str = Field(alias="item_type") + """Type of the wished item.""" + + item_id: int = Field(alias="item_id") + """ID of the wished item.""" + + name: str + """Name of the wished item.""" + + rarity: int = Field(alias="rank_type") + """Rarity of the wished item.""" + + time: datetime + """Time when the wish was made.""" + + banner_id: int = Field(alias="gacha_id") + """ID of the banner the wish was made on.""" + + banner_type: ZZZBannerType = Field(alias="gacha_type") + """Type of the banner the wish was made on.""" + + @validator("banner_type", pre=True) + def cast_banner_type(cls, v: Any) -> int: + """Converts the banner type from any type to int.""" + return int(v) + + @validator("rarity") + def add_rarity(cls, v: int) -> int: + """Add rarity 1.""" + return v + 1 diff --git a/simnet/utils/enums.py b/simnet/utils/enums.py index 36dc6fe..2929635 100644 --- a/simnet/utils/enums.py +++ b/simnet/utils/enums.py @@ -24,8 +24,10 @@ class Game(str, _enum.Enum): GENSHIN (Game): Represents the game "Genshin Impact". HONKAI (Game): Represents the game "Honkai Impact 3rd". STARRAIL (Game): Represents the game "Honkai Impact 3rd RPG". + ZZZ (Game): Represents the game "Zenless Zone Zero". """ GENSHIN = "genshin" HONKAI = "honkai3rd" STARRAIL = "hkrpg" + ZZZ = "nap" diff --git a/simnet/utils/paginator.py b/simnet/utils/paginator.py index 85dfaa7..1078395 100644 --- a/simnet/utils/paginator.py +++ b/simnet/utils/paginator.py @@ -50,7 +50,7 @@ class WishPaginator: if limit and len(all_items) >= limit: break - await asyncio.sleep(0.5) + await asyncio.sleep(1) # Return up to the specified limit. return all_items[: min(len(all_items), limit)] if limit else all_items diff --git a/simnet/utils/player.py b/simnet/utils/player.py index 6fb1710..3f18a0f 100644 --- a/simnet/utils/player.py +++ b/simnet/utils/player.py @@ -8,6 +8,7 @@ UID_LENGTH: Mapping[Game, int] = { Game.GENSHIN: 9, Game.STARRAIL: 9, Game.HONKAI: 8, + Game.ZZZ: 9, } UID_RANGE: Mapping[Game, Mapping[Region, Sequence[int]]] = { Game.GENSHIN: { @@ -22,6 +23,9 @@ UID_RANGE: Mapping[Game, Mapping[Region, Sequence[int]]] = { Region.OVERSEAS: (1, 2), Region.CHINESE: (3, 4), }, + Game.ZZZ: { + Region.OVERSEAS: (13,), + }, } @@ -104,6 +108,33 @@ def recognize_starrail_server(player_id: int) -> str: raise ValueError(f"player id {player_id} isn't associated with any server") +def recognize_zzz_server(player_id: int) -> str: + """Recognize which server a ZZZ UID is from. + + Args: + player_id (int): The player ID to recognize the server for. + + Returns: + str: The name of the server associated with the given player ID. + + Raises: + ValueError: If the player ID is not associated with any server. + """ + if len(str(player_id)) == 8: + return "prod_gf_cn" + server = { + 10: "prod_gf_us", + 15: "prod_gf_eu", + 13: "prod_gf_jp", + 17: "prod_gf_sg", + }.get(recognize_game_uid_first_digit(player_id, Game.ZZZ)) + + if server: + return server + + raise ValueError(f"player id {player_id} isn't associated with any server") + + def recognize_region(player_id: int, game: Game) -> Optional[Region]: """ Recognizes the region of a player ID for a given game. @@ -115,6 +146,9 @@ def recognize_region(player_id: int, game: Game) -> Optional[Region]: Returns: Optional[Region]: The region the player ID belongs to if it can be recognized, None otherwise. """ + if game == Game.ZZZ and len(str(player_id)) == 8: + return Region.CHINESE + for region, digits in UID_RANGE[game].items(): first = recognize_game_uid_first_digit(player_id, game) if first in digits: @@ -141,6 +175,8 @@ def recognize_server(player_id: int, game: Game) -> str: return recognize_genshin_server(player_id) if game == Game.STARRAIL: return recognize_starrail_server(player_id) + if game == Game.ZZZ: + return recognize_zzz_server(player_id) raise ValueError(f"{game} is not a valid game") @@ -162,6 +198,15 @@ def recognize_starrail_game_biz(game_uid: int) -> str: return "hkrpg_cn" if game_uid < 600000000 else "hkrpg_global" +def recognize_zzz_game_biz(game_uid: int) -> str: + """Recognizes the game biz of a player ID for a game biz. + + Returns: + str: The game biz the player ID belongs to. + """ + return "nap_cn" if len(str(game_uid)) == 8 else "nap_global" + + def recognize_game_biz(player_id: int, game: Game) -> str: """ Recognizes the game biz of a player ID for a given game. @@ -180,4 +225,6 @@ def recognize_game_biz(player_id: int, game: Game) -> str: return recognize_genshin_game_biz(player_id) if game == Game.STARRAIL: return recognize_starrail_game_biz(player_id) + if game == Game.ZZZ: + return recognize_zzz_game_biz(player_id) raise ValueError(f"{game} is not a valid game")