From 21ccb3ce6fbc0fa4807e917254b6929c763830ee Mon Sep 17 00:00:00 2001 From: seria Date: Fri, 5 Jul 2024 10:49:14 +0900 Subject: [PATCH] Add Partial ZZZ Support (#200) * Add zzz code redeem route * Modify game check logic * Add zzz server recognition function * Add daily reward URLs * Remove unreachable code * Add real-time notes support * Apply reformat and export models to dunder all * Add fetching user stats * Fix code redemption not working * Sort dunder all * Add buddy_list --- genshin/client/components/chronicle/base.py | 10 ++- genshin/client/components/chronicle/client.py | 3 +- genshin/client/components/chronicle/zzz.py | 89 +++++++++++++++++++ genshin/client/components/hoyolab.py | 2 +- genshin/client/routes.py | 8 ++ genshin/models/__init__.py | 1 + genshin/models/zzz/__init__.py | 4 + genshin/models/zzz/character.py | 57 ++++++++++++ genshin/models/zzz/chronicle/__init__.py | 4 + genshin/models/zzz/chronicle/notes.py | 72 +++++++++++++++ genshin/models/zzz/chronicle/stats.py | 40 +++++++++ genshin/utility/uid.py | 50 ++++++++--- 12 files changed, 324 insertions(+), 16 deletions(-) create mode 100644 genshin/client/components/chronicle/zzz.py create mode 100644 genshin/models/zzz/__init__.py create mode 100644 genshin/models/zzz/character.py create mode 100644 genshin/models/zzz/chronicle/__init__.py create mode 100644 genshin/models/zzz/chronicle/notes.py create mode 100644 genshin/models/zzz/chronicle/stats.py diff --git a/genshin/client/components/chronicle/base.py b/genshin/client/components/chronicle/base.py index 7a92bc64..4e3ba856 100644 --- a/genshin/client/components/chronicle/base.py +++ b/genshin/client/components/chronicle/base.py @@ -47,10 +47,12 @@ async def request_game_record( **kwargs: typing.Any, ) -> typing.Mapping[str, typing.Any]: """Make a request towards the game record endpoint.""" - base_url = routes.RECORD_URL.get_url(region or self.region) - - if game: - base_url = base_url / game.value / "api" + if game is types.Game.ZZZ: + base_url = routes.ZZZ_RECORD_URL.get_url(region or self.region) + else: + base_url = routes.RECORD_URL.get_url(region or self.region) + if game is not None: + base_url = base_url / game.value / "api" url = base_url / endpoint diff --git a/genshin/client/components/chronicle/client.py b/genshin/client/components/chronicle/client.py index 94f31407..eeaf336c 100644 --- a/genshin/client/components/chronicle/client.py +++ b/genshin/client/components/chronicle/client.py @@ -1,6 +1,6 @@ """Battle chronicle component.""" -from . import genshin, honkai, starrail +from . import genshin, honkai, starrail, zzz __all__ = ["BattleChronicleClient"] @@ -9,5 +9,6 @@ class BattleChronicleClient( genshin.GenshinBattleChronicleClient, honkai.HonkaiBattleChronicleClient, starrail.StarRailBattleChronicleClient, + zzz.ZZZBattleChronicleClient, ): """Battle chronicle component.""" diff --git a/genshin/client/components/chronicle/zzz.py b/genshin/client/components/chronicle/zzz.py new file mode 100644 index 00000000..8d3fcc8b --- /dev/null +++ b/genshin/client/components/chronicle/zzz.py @@ -0,0 +1,89 @@ +"""StarRail battle chronicle component.""" + +import typing + +from genshin import errors, types, utility +from genshin.models.zzz import chronicle as models + +from . import base + +__all__ = ("ZZZBattleChronicleClient",) + + +class ZZZBattleChronicleClient(base.BaseBattleChronicleClient): + """ZZZ battle chronicle component.""" + + async def _request_zzz_record( + self, + endpoint: str, + uid: typing.Optional[int] = None, + *, + method: str = "GET", + lang: typing.Optional[str] = None, + payload: typing.Optional[typing.Mapping[str, typing.Any]] = None, + cache: bool = True, + ) -> typing.Mapping[str, typing.Any]: + """Get an arbitrary ZZZ object.""" + payload = dict(payload or {}) + original_payload = payload.copy() + + uid = uid or await self._get_uid(types.Game.ZZZ) + payload = dict(role_id=uid, server=utility.recognize_zzz_server(uid), **payload) + + data, params = None, None + if method == "POST": + data = payload + else: + params = payload + + cache_key: typing.Optional[base.ChronicleCacheKey] = None + if cache: + cache_key = base.ChronicleCacheKey( + types.Game.ZZZ, + endpoint, + uid, + lang=lang or self.lang, + params=tuple(original_payload.values()), + ) + + return await self.request_game_record( + endpoint, + lang=lang, + game=types.Game.ZZZ, + region=utility.recognize_region(uid, game=types.Game.ZZZ), + params=params, + data=data, + cache=cache_key, + ) + + async def get_zzz_notes( + self, + uid: typing.Optional[int] = None, + *, + lang: typing.Optional[str] = None, + autoauth: bool = True, + ) -> models.ZZZNotes: + """Get ZZZ sticky notes (real-time notes).""" + try: + data = await self._request_zzz_record("note", uid, lang=lang, cache=False) + except errors.DataNotPublic as e: + # error raised only when real-time notes are not enabled + if uid and (await self._get_uid(types.Game.ZZZ)) != uid: + raise errors.GenshinException(e.response, "Cannot view real-time notes of other users.") from e + if not autoauth: + raise errors.GenshinException(e.response, "Real-time notes are not enabled.") from e + + await self.update_settings(3, True, game=types.Game.ZZZ) + data = await self._request_zzz_record("note", uid, lang=lang, cache=False) + + return models.ZZZNotes(**data) + + async def get_zzz_user( + self, + uid: typing.Optional[int] = None, + *, + lang: typing.Optional[str] = None, + ) -> models.ZZZUserStats: + """Get starrail user.""" + data = await self._request_zzz_record("index", uid, lang=lang, cache=False) + return models.ZZZUserStats(**data) diff --git a/genshin/client/components/hoyolab.py b/genshin/client/components/hoyolab.py index 7e1804dc..93f6f104 100644 --- a/genshin/client/components/hoyolab.py +++ b/genshin/client/components/hoyolab.py @@ -124,7 +124,7 @@ async def redeem_code( game = self.default_game - if not (game == types.Game.GENSHIN or game == types.Game.STARRAIL): + if game not in {types.Game.GENSHIN, types.Game.ZZZ, types.Game.STARRAIL}: raise ValueError(f"{game} does not support code redemption.") uid = uid or await self._get_uid(game) diff --git a/genshin/client/routes.py b/genshin/client/routes.py index 1288c067..7ef6420f 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -37,6 +37,7 @@ "WEBSTATIC_URL", "WEB_LOGIN_URL", "YSULOG_URL", + "ZZZ_RECORD_URL", "Route", ] @@ -138,6 +139,10 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: overseas="https://bbs-api-os.hoyolab.com/game_record/", chinese="https://api-takumi-record.mihoyo.com/game_record/app/", ) +ZZZ_RECORD_URL = InternationalRoute( + overseas="https://sg-act-nap-api.hoyolab.com/event/game_record_zzz/api/zzz/", + chinese="https://api-takumi-record.mihoyo.com/event/game_record_zzz/api/zzz/", +) LINEUP_URL = InternationalRoute( overseas="https://sg-public-api.hoyoverse.com/event/simulatoros/", chinese="https://api-takumi.mihoyo.com/event/platsimulator/", @@ -184,11 +189,13 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: 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=e202306201626331", hkrpg="https://api-takumi.mihoyo.com/event/luna/?act_id=e202304121516551", + nap="https://act-nap-api.mihoyo.com/event/luna/zzz/?act_id=e202406242138391", ), ) @@ -196,6 +203,7 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: overseas=dict( genshin="https://sg-hk4e-api.hoyoverse.com/common/apicdkey/api/webExchangeCdkey", hkrpg="https://sg-hkrpg-api.hoyoverse.com/common/apicdkey/api/webExchangeCdkey", + nap="https://public-operation-nap.hoyoverse.com/common/apicdkey/api/webExchangeCdkey", ), chinese=dict(), ) diff --git a/genshin/models/__init__.py b/genshin/models/__init__.py index b515d70e..0b175760 100644 --- a/genshin/models/__init__.py +++ b/genshin/models/__init__.py @@ -6,3 +6,4 @@ from .hoyolab import * from .model import * from .starrail import * +from .zzz import * diff --git a/genshin/models/zzz/__init__.py b/genshin/models/zzz/__init__.py new file mode 100644 index 00000000..0f1e6308 --- /dev/null +++ b/genshin/models/zzz/__init__.py @@ -0,0 +1,4 @@ +"""Zenless Zone Zero models.""" + +from .character import * +from .chronicle import * diff --git a/genshin/models/zzz/character.py b/genshin/models/zzz/character.py new file mode 100644 index 00000000..b308b922 --- /dev/null +++ b/genshin/models/zzz/character.py @@ -0,0 +1,57 @@ +import enum +import typing + +from genshin.models.model import Aliased, APIModel, Unique + +__all__ = ( + "ZZZBaseAgent", + "ZZZElementType", + "ZZZPartialAgent", + "ZZZSpeciality", +) + + +class ZZZElementType(enum.IntEnum): + """ZZZ element type.""" + + PHYSICAL = 200 + FIRE = 201 + ICE = 202 + ELECTRIC = 203 + ETHER = 205 + + +class ZZZSpeciality(enum.IntEnum): + """ZZZ agent compatible speciality.""" + + ATTACK = 1 + STUN = 2 + ANOMALY = 3 + SUPPORT = 4 + DEFENSE = 5 + + +class ZZZBaseAgent(APIModel, Unique): + """ZZZ base agent model.""" + + id: int # 4 digit number + element: ZZZElementType = Aliased("element_type") + rarity: typing.Literal["S", "A"] + name: str = Aliased("name_mi18n") + speciality: ZZZSpeciality = Aliased("avatar_profession") + faction_icon: str = Aliased("group_icon_path") + flat_icon: str = Aliased("hollow_icon_path") + + @property + def icon(self) -> str: + return ( + f"https://act-webstatic.hoyoverse.com/game_record/zzz/role_square_avatar/role_square_avatar_{self.id}.png" + ) + + +class ZZZPartialAgent(ZZZBaseAgent): + """Character without any equipment.""" + + level: int + rank: int + """Also known as Mindscape Cinema in-game.""" diff --git a/genshin/models/zzz/chronicle/__init__.py b/genshin/models/zzz/chronicle/__init__.py new file mode 100644 index 00000000..4f38b35d --- /dev/null +++ b/genshin/models/zzz/chronicle/__init__.py @@ -0,0 +1,4 @@ +"""ZZZ chronicle models.""" + +from .notes import * +from .stats import * diff --git a/genshin/models/zzz/chronicle/notes.py b/genshin/models/zzz/chronicle/notes.py new file mode 100644 index 00000000..52e00715 --- /dev/null +++ b/genshin/models/zzz/chronicle/notes.py @@ -0,0 +1,72 @@ +"""ZZZ sticky notes (real-time notes) models.""" + +import datetime +import enum +import typing + +from genshin.models.model import Aliased, APIModel + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +__all__ = ("BatteryCharge", "VideoStoreState", "ZZZEngagement", "ZZZNotes") + + +class VideoStoreState(enum.Enum): + """Video store management state.""" + + REVENUE_AVAILABLE = "SaleStateDone" + WAITING_TO_OPEN = "SaleStateNo" + CURRENTLY_OPEN = "SaleStateDoing" + + +class BatteryCharge(APIModel): + """ZZZ battery charge model.""" + + current: int + max: int + seconds_till_full: int = Aliased("restore") + + @property + def is_full(self) -> bool: + """Check if the energy is full.""" + return self.current == self.max + + @property + def full_datetime(self) -> datetime.datetime: + """Get the datetime when the energy will be full.""" + return datetime.datetime.now().astimezone() + datetime.timedelta(seconds=self.seconds_till_full) + + @pydantic.root_validator(pre=True) + def __unnest_progress(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + return {**values, **values.pop("progress")} + + +class ZZZEngagement(APIModel): + """ZZZ engagement model.""" + + current: int + max: int + + +class ZZZNotes(APIModel): + """Zenless Zone Zero sticky notes model.""" + + battery_charge: BatteryCharge = Aliased("energy") + engagement: ZZZEngagement = Aliased("vitality") + scratch_card_completed: bool = Aliased("card_sign") + video_store_state: VideoStoreState + + @pydantic.validator("scratch_card_completed", pre=True) + def __transform_value(cls, v: typing.Literal["CardSignDone", "CardSignNotDone"]) -> bool: + return v == "CardSignDone" + + @pydantic.root_validator(pre=True) + def __unnest_value(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + values["video_store_state"] = values["vhs_sale"]["sale_state"] + return values diff --git a/genshin/models/zzz/chronicle/stats.py b/genshin/models/zzz/chronicle/stats.py new file mode 100644 index 00000000..348c2001 --- /dev/null +++ b/genshin/models/zzz/chronicle/stats.py @@ -0,0 +1,40 @@ +"""ZZZ data overview models.""" + +import typing + +from genshin.models.model import Aliased, APIModel + +from ..character import ZZZPartialAgent + +__all__ = ( + "ZZZStats", + "ZZZUserStats", +) + + +class ZZZStats(APIModel): + """ZZZ data overview stats.""" + + active_days: int + character_num: int = Aliased("avatar_num") + inter_knot_reputation: str = Aliased("world_level_name") + shiyu_defense_frontiers: int = Aliased("cur_period_zone_layer_count") + bangboo_obtained: int = Aliased("buddy_num") + + +class ZZZBaseBangboo(APIModel): + """Base bangboo (buddy) model.""" + + id: int + name: str + rarity: typing.Literal["S", "A"] + level: int + star: int + + +class ZZZUserStats(APIModel): + """Zenless Zone Zero user model.""" + + stats: ZZZStats + agents: typing.Sequence[ZZZPartialAgent] = Aliased("avatar_list") + bangboos: typing.Sequence[ZZZBaseBangboo] = Aliased("buddy_list") diff --git a/genshin/utility/uid.py b/genshin/utility/uid.py index 64e4b668..5a515b7e 100644 --- a/genshin/utility/uid.py +++ b/genshin/utility/uid.py @@ -13,6 +13,7 @@ "recognize_region", "recognize_server", "recognize_starrail_server", + "recognize_zzz_server", ] UID_RANGE: typing.Mapping[types.Game, typing.Mapping[types.Region, typing.Sequence[str]]] = { @@ -51,6 +52,14 @@ } """Mapping of Star Rail servers to their respective UID ranges.""" +ZZZ_SERVER_RANGE: typing.Mapping[str, typing.Sequence[str]] = { + "prod_gf_us": ("10",), + "prod_gf_eu": ("15",), + "prod_gf_jp": ("13",), + "prod_gf_sg": ("17",), +} +"""Mapping of global Zenless Zone Zero servers to their respective UID ranges.""" + def create_short_lang_code(lang: str) -> str: """Create an alternative short lang code.""" @@ -69,16 +78,18 @@ def recognize_genshin_server(uid: int) -> str: def get_prod_game_biz(region: types.Region, game: types.Game) -> str: """Get the game_biz value corresponding to a game and region.""" game_biz = "" - if game == types.Game.HONKAI: + if game is types.Game.HONKAI: game_biz = "bh3_" - elif game == types.Game.GENSHIN: + elif game is types.Game.GENSHIN: game_biz = "hk4e_" - elif game == types.Game.STARRAIL: + elif game is types.Game.STARRAIL: game_biz = "hkrpg_" + elif game is types.Game.ZZZ: + game_biz = "nap_" - if region == types.Region.OVERSEAS: + if region is types.Region.OVERSEAS: game_biz += "global" - elif region == types.Region.CHINESE: + elif region is types.Region.CHINESE: game_biz += "cn" return game_biz @@ -109,16 +120,30 @@ def recognize_starrail_server(uid: int) -> str: raise ValueError(f"UID {uid} isn't associated with any server") +def recognize_zzz_server(uid: int) -> str: + """Recognize which server a Zenless Zone Zero UID is from.""" + # CN region UIDs only has 8 digits, global has 10, so we use this method to recognize the server + # This might change in the future when UIDs run out but... let's keep it like this for now + if len(str(uid)) == 8: + return "prod_gf_cn" + + for server, digits in ZZZ_SERVER_RANGE.items(): + if str(uid)[:-8] in digits: + return server + + raise ValueError(f"UID {uid} isn't associated with any server") + + def recognize_server(uid: int, game: types.Game) -> str: """Recognizes which server a UID is from.""" - if game == types.Game.HONKAI: + if game is types.Game.HONKAI: return recognize_honkai_server(uid) - elif game == types.Game.GENSHIN: + if game is types.Game.GENSHIN: return recognize_genshin_server(uid) - elif game == types.Game.STARRAIL: + if game is types.Game.STARRAIL: return recognize_starrail_server(uid) - else: - raise ValueError(f"{game} is not a valid game") + if game is types.Game.ZZZ: + return recognize_zzz_server(uid) def recognize_game(uid: int, region: types.Region) -> typing.Optional[types.Game]: @@ -135,6 +160,11 @@ def recognize_game(uid: int, region: types.Region) -> typing.Optional[types.Game def recognize_region(uid: int, game: types.Game) -> typing.Optional[types.Region]: """Recognize the region of a uid.""" + if game is types.Game.ZZZ: + if len(str(uid)) == 8: + return types.Region.CHINESE + return types.Region.OVERSEAS + for region, digits in UID_RANGE[game].items(): if str(uid)[:-8] in digits: return region