From abed5b950633ac577cc2e458c89a6d021e7e4ad0 Mon Sep 17 00:00:00 2001 From: seria Date: Thu, 25 Jul 2024 07:35:35 +0800 Subject: [PATCH] Merge branch dev into master (#199) * Support Imaginarium Theater (#198) * Add models and method * Add test * Continue to use Aliased field cuz it magically works again * Add medal_num field Co-authored-by: omg-xtao <100690902+omg-xtao@users.noreply.github.com> * Rename is_enhanced field * Improve implementation * Add need_detail param --------- Co-authored-by: omg-xtao <100690902+omg-xtao@users.noreply.github.com> * 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 * Return Game.ZZZ for GenshinAccount.game * Prevent enum crashes (#203) * Add Support for Passing in More Device Info (#202) * Update game auth headers with client custom headers * Add device_id and device_fp setter * Allow passing in custom payload * Allow passing in custom device_name, device_model, and client_type * Refactor * Use GameRoute for battle chronicle route * Fix honkai -> honkai3rd * Forgot to add kwargs to overload * Export ImgTheaterData to dunder all * Add unknown img theater difficulty * Use game-specific game_biz header for game auth * Handle special card wapi endpoints * feat: Add ZZZ gacha support + fix for Chronicled Banner (#206) * Add More ZZZ Features (#207) * Fix get_zz_user method doctsring * Add 2 new icon props and rename 1 icon prop * Add get_zzz_characters method * Add get_bangboos method * Add full agent info * Run nox * Clarify docstrings and add list conversion * Rename methods for better consistency * Add icon field to WEngine Co-authored-by: omg-xtao <100690902+omg-xtao@users.noreply.github.com> * Rename star field to refinement * Fix requesting the wrong endpoint --------- Co-authored-by: omg-xtao <100690902+omg-xtao@users.noreply.github.com> * Add property type * Fix lang arg has no effect on ZZZ endpoitns * Add missing disc prop types * Merge DISC_IMPACT and ENGINE_IMPACT * Add full_name field to ZZZBaseAgent * Fix typo speciality -> specialty * Fix stuff related to AgentSkill * Add game_name and game_logo fields to RecordCard * Add ZZZRecordCard * Support TOT daily reward claiming * Fix Game.TOT not being recognized * Change Game.TOT enum value to 'tot' * Implement recognize_region for Game.TOT * Add banner_art prop to BattleSuit model * Support recognizing game_biz for Game.TOT * Support TOT code redemption * Add caching to get_server_region method * Add region param to redeem_code method * Add boss kills and sub-area explorations to the Exploration model and Long-Term Encounter points (#209) * Add boss kills and sub-area explorations to Exploration * Add stored encounter points and refresh countdown * Ran nox reformatting * Change returning None to returning an empty list * Remove validators as they are no longer required * Add "explored" property to AreaExploration * Support ZZZ Shiyu defense * Fix dunder all not formatted * Remove wiki tests * Remove model reserialization test * Fix raising error for no game even the request doesnt need it * Fix calcualtor test not passing * Update fixture genshin UID and hoyolab ID * Remove unused import * Add type ignore for challenge_time field * Fix type error * Fix invalid import of ModelField * Fix type error * Fix missing type annotation on headers attr * Fix invalid attr access * Update user nickname accordingly * Remove event_loop fixture * Fix test failing caused by get_gacha_items --------- Co-authored-by: omg-xtao <100690902+omg-xtao@users.noreply.github.com> Co-authored-by: Furia <83609040+FuriaPaladins@users.noreply.github.com> --- genshin/__main__.py | 2 +- genshin/client/components/auth/client.py | 7 +- .../client/components/auth/subclients/game.py | 62 ++++- genshin/client/components/base.py | 20 +- genshin/client/components/chronicle/base.py | 15 +- genshin/client/components/chronicle/client.py | 3 +- .../client/components/chronicle/genshin.py | 17 ++ genshin/client/components/chronicle/zzz.py | 143 ++++++++++ genshin/client/components/gacha.py | 62 ++++- genshin/client/components/hoyolab.py | 25 +- genshin/client/components/lineup.py | 5 +- genshin/client/routes.py | 10 + genshin/constants.py | 26 +- genshin/models/__init__.py | 1 + genshin/models/genshin/chronicle/__init__.py | 1 + .../models/genshin/chronicle/img_theater.py | 153 +++++++++++ genshin/models/genshin/chronicle/notes.py | 21 +- genshin/models/genshin/chronicle/stats.py | 23 ++ genshin/models/genshin/gacha.py | 43 ++- genshin/models/honkai/battlesuit.py | 1 + genshin/models/hoyolab/record.py | 36 ++- genshin/models/model.py | 3 +- .../models/starrail/chronicle/challenge.py | 2 +- genshin/models/zzz/__init__.py | 4 + genshin/models/zzz/character.py | 244 ++++++++++++++++++ genshin/models/zzz/chronicle/__init__.py | 5 + genshin/models/zzz/chronicle/challenge.py | 140 ++++++++++ genshin/models/zzz/chronicle/notes.py | 72 ++++++ genshin/models/zzz/chronicle/stats.py | 41 +++ genshin/types.py | 3 + genshin/utility/auth.py | 4 - genshin/utility/ds.py | 1 + genshin/utility/uid.py | 57 +++- tests/client/components/test_calculator.py | 2 +- .../components/test_genshin_chronicle.py | 6 + tests/client/components/test_hoyolab.py | 6 +- tests/client/components/test_wiki.py | 13 - tests/client/components/test_wish.py | 8 +- tests/conftest.py | 20 +- tests/models/test_model.py | 29 +-- 40 files changed, 1230 insertions(+), 106 deletions(-) create mode 100644 genshin/client/components/chronicle/zzz.py create mode 100644 genshin/models/genshin/chronicle/img_theater.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/challenge.py create mode 100644 genshin/models/zzz/chronicle/notes.py create mode 100644 genshin/models/zzz/chronicle/stats.py delete mode 100644 tests/client/components/test_wiki.py diff --git a/genshin/__main__.py b/genshin/__main__.py index 3076f6fe..0db65494 100644 --- a/genshin/__main__.py +++ b/genshin/__main__.py @@ -276,7 +276,7 @@ async def wishes(client: genshin.Client, limit: typing.Optional[int] = None) -> longest = max(len(v) for v in banner_names.values()) async for wish in client.wish_history(limit=limit): - banner = click.style(wish.banner_name.ljust(longest), bold=True) + banner = click.style(wish.name.ljust(longest), bold=True) click.echo(f"{banner} | {wish.time.astimezone()} - {wish.name} ({'★' * wish.rarity} {wish.type})") diff --git a/genshin/client/components/auth/client.py b/genshin/client/components/auth/client.py index 0bbe36b9..98677501 100644 --- a/genshin/client/components/auth/client.py +++ b/genshin/client/components/auth/client.py @@ -322,7 +322,6 @@ async def verify_mmt(self, mmt_result: MMTResult) -> None: if not data["data"]: errors.raise_for_retcode(data) - @base.region_specific(types.Region.OVERSEAS) async def os_game_login( self, account: str, @@ -339,13 +338,15 @@ async def os_game_login( - IncorrectGameAccount: Invalid account provided. - IncorrectGamePassword: Invalid password provided. """ + api_server = "api.geetest.com" if self.region is types.Region.CHINESE else "api-na.geetest.com" + result = await self._shield_login(account, password, encrypted=encrypted) if isinstance(result, RiskyCheckMMT): if geetest_solver: mmt_result = await geetest_solver(result) else: - mmt_result = await server.solve_geetest(result, port=port) + mmt_result = await server.solve_geetest(result, port=port, api_server=api_server) result = await self._shield_login(account, password, encrypted=encrypted, mmt_result=mmt_result) @@ -357,7 +358,7 @@ async def os_game_login( if geetest_solver: mmt_result = await geetest_solver(mmt) else: - mmt_result = await server.solve_geetest(mmt, port=port) + mmt_result = await server.solve_geetest(mmt, port=port, api_server=api_server) await self._send_game_verification_email(result.account.device_grant_ticket, mmt_result=mmt_result) diff --git a/genshin/client/components/auth/subclients/game.py b/genshin/client/components/auth/subclients/game.py index 417df2ef..889f1089 100644 --- a/genshin/client/components/auth/subclients/game.py +++ b/genshin/client/components/auth/subclients/game.py @@ -8,7 +8,7 @@ import aiohttp -from genshin import constants, errors, types +from genshin import constants, errors from genshin.client import routes from genshin.client.components import base from genshin.models.auth.cookie import DeviceGrantResult, GameLoginResult @@ -26,13 +26,20 @@ async def _risky_check( self, action_type: str, api_name: str, *, username: typing.Optional[str] = None ) -> RiskyCheckResult: """Check if the given action (endpoint) is risky (whether captcha verification is required).""" + if self.default_game is None: + raise ValueError("No default game set.") + payload = {"action_type": action_type, "api_name": api_name} if username: payload["username"] = username + headers = auth_utility.RISKY_CHECK_HEADERS.copy() + headers["x-rpc-game_biz"] = constants.GAME_BIZS[self.region][self.default_game] + headers.update(self.custom_headers) + async with aiohttp.ClientSession() as session: async with session.post( - routes.GAME_RISKY_CHECK_URL.get_url(self.region), json=payload, headers=auth_utility.RISKY_CHECK_HEADERS + routes.GAME_RISKY_CHECK_URL.get_url(self.region), json=payload, headers=headers ) as r: data = await r.json() @@ -77,6 +84,9 @@ async def _shield_login( raise ValueError("No default game set.") headers = auth_utility.SHIELD_LOGIN_HEADERS.copy() + headers["x-rpc-game_biz"] = constants.GAME_BIZS[self.region][self.default_game] + headers.update(self.custom_headers) + if mmt_result: headers["x-rpc-risky"] = mmt_result.to_rpc_risky() else: @@ -108,6 +118,9 @@ async def _send_game_verification_email( # noqa: D102 missing docstring in over self, action_ticket: str, *, + device_model: typing.Optional[str] = None, + device_name: typing.Optional[str] = None, + client_type: typing.Optional[int] = None, mmt_result: RiskyCheckMMTResult, ) -> None: ... @@ -116,17 +129,32 @@ async def _send_game_verification_email( # noqa: D102 missing docstring in over self, action_ticket: str, *, + device_model: typing.Optional[str] = None, + device_name: typing.Optional[str] = None, + client_type: typing.Optional[int] = None, mmt_result: None = ..., ) -> typing.Union[None, RiskyCheckMMT]: ... async def _send_game_verification_email( - self, action_ticket: str, *, mmt_result: typing.Optional[RiskyCheckMMTResult] = None + self, + action_ticket: str, + *, + device_model: typing.Optional[str] = None, + device_name: typing.Optional[str] = None, + client_type: typing.Optional[int] = None, + mmt_result: typing.Optional[RiskyCheckMMTResult] = None, ) -> typing.Union[None, RiskyCheckMMT]: """Send email verification code. Returns `None` if success, `RiskyCheckMMT` if geetest verification is required. """ + if self.default_game is None: + raise ValueError("No default game set.") + headers = auth_utility.GRANT_TICKET_HEADERS.copy() + headers["x-rpc-game_biz"] = constants.GAME_BIZS[self.region][self.default_game] + headers.update(self.custom_headers) + if mmt_result: headers["x-rpc-risky"] = mmt_result.to_rpc_risky() else: @@ -141,10 +169,10 @@ async def _send_game_verification_email( "way": "Way_Email", "action_ticket": action_ticket, "device": { - "device_model": "iPhone15,4", - "device_id": auth_utility.DEVICE_ID, - "client": 1, - "device_name": "iPhone", + "device_model": device_model or "iPhone15,4", + "device_id": self.device_id or auth_utility.DEVICE_ID, + "client": client_type or 1, + "device_name": device_name or "iPhone", }, } async with aiohttp.ClientSession() as session: @@ -160,16 +188,20 @@ async def _send_game_verification_email( async def _verify_game_email(self, code: str, action_ticket: str) -> DeviceGrantResult: """Verify the email code.""" + if self.default_game is None: + raise ValueError("No default game set.") + payload = {"code": code, "ticket": action_ticket} + headers = auth_utility.GRANT_TICKET_HEADERS.copy() + headers["x-rpc-game_biz"] = constants.GAME_BIZS[self.region][self.default_game] + headers.update(self.custom_headers) + async with aiohttp.ClientSession() as session: - async with session.post( - routes.DEVICE_GRANT_URL.get_url(self.region), json=payload, headers=auth_utility.GRANT_TICKET_HEADERS - ) as r: + async with session.post(routes.DEVICE_GRANT_URL.get_url(self.region), json=payload, headers=headers) as r: data = await r.json() return DeviceGrantResult(**data["data"]) - @base.region_specific(types.Region.OVERSEAS) async def _os_game_login(self, uid: str, game_token: str) -> GameLoginResult: """Log in to the game.""" if self.default_game is None: @@ -177,17 +209,21 @@ async def _os_game_login(self, uid: str, game_token: str) -> GameLoginResult: payload = { "channel_id": 1, - "device": auth_utility.DEVICE_ID, + "device": self.device_id or auth_utility.DEVICE_ID, "app_id": constants.APP_IDS[self.default_game][self.region], } payload["data"] = json.dumps({"uid": uid, "token": game_token, "guest": False}) payload["sign"] = auth_utility.generate_sign(payload, constants.APP_KEYS[self.default_game][self.region]) + headers = auth_utility.GAME_LOGIN_HEADERS.copy() + headers["x-rpc-game_biz"] = constants.GAME_BIZS[self.region][self.default_game] + headers.update(self.custom_headers) + async with aiohttp.ClientSession() as session: async with session.post( routes.GAME_LOGIN_URL.get_url(self.region, self.default_game), json=payload, - headers=auth_utility.GAME_LOGIN_HEADERS, + headers=headers, ) as r: data = await r.json() diff --git a/genshin/client/components/base.py b/genshin/client/components/base.py index 5d43e286..cbef2c87 100644 --- a/genshin/client/components/base.py +++ b/genshin/client/components/base.py @@ -94,7 +94,7 @@ def __init__( self.uid = uid self.hoyolab_id = hoyolab_id - self.custom_headers = dict(headers or {}) + self.custom_headers: typing.Dict[str, str] = dict(headers or {}) self.custom_headers.update({"x-rpc-device_id": device_id} if device_id else {}) self.custom_headers.update({"x-rpc-device_fp": device_fp} if device_fp else {}) @@ -111,6 +111,24 @@ def __repr__(self) -> str: ) return f"<{type(self).__name__} {', '.join(f'{k}={v!r}' for k, v in kwargs.items() if v)}>" + @property + def device_id(self) -> typing.Optional[str]: + """The device id used in headers.""" + return self.custom_headers.get("x-rpc-device_id") + + @device_id.setter + def device_id(self, device_id: str) -> None: + self.custom_headers["x-rpc-device_id"] = device_id + + @property + def device_fp(self) -> typing.Optional[str]: + """The device fingerprint used in headers.""" + return self.custom_headers.get("x-rpc-device_fp") + + @device_fp.setter + def device_fp(self, device_fp: str) -> None: + self.custom_headers["x-rpc-device_fp"] = device_fp + @property def hoyolab_id(self) -> typing.Optional[int]: """The logged-in user's hoyolab uid. diff --git a/genshin/client/components/chronicle/base.py b/genshin/client/components/chronicle/base.py index f3077871..82121779 100644 --- a/genshin/client/components/chronicle/base.py +++ b/genshin/client/components/chronicle/base.py @@ -48,15 +48,14 @@ async def request_game_record( **kwargs: typing.Any, ) -> typing.Mapping[str, typing.Any]: """Make a request towards the game record endpoint.""" - game = game or self.default_game - if game is None: - raise RuntimeError("No default game set.") + if is_card_wapi: + base_url = routes.CARD_WAPI_URL.get_url(region or self.region) + else: + game = game or self.default_game + if game is None: + raise RuntimeError("No default game set.") + base_url = routes.RECORD_URL.get_url(region or self.region, game) - base_url = ( - routes.RECORD_URL.get_url(region or self.region, game) - if not is_card_wapi - else routes.CARD_WAPI_URL.get_url(region or self.region) - ) url = base_url / endpoint mi18n_task = asyncio.create_task(self._fetch_mi18n("bbs", lang=lang or self.lang)) 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/genshin.py b/genshin/client/components/chronicle/genshin.py index 2f88b698..d89d60eb 100644 --- a/genshin/client/components/chronicle/genshin.py +++ b/genshin/client/components/chronicle/genshin.py @@ -107,6 +107,23 @@ async def get_genshin_spiral_abyss( return models.SpiralAbyss(**data) + async def get_imaginarium_theater( + self, + uid: int, + *, + previous: bool = False, + need_detail: bool = True, + lang: typing.Optional[str] = None, + ) -> models.ImgTheater: + """Get Genshin Impact imaginarium theater runs.""" + payload = { + "schedule_type": 2 if previous else 1, # There's 1 season for now but I assume it works like this + "need_detail": str(need_detail).lower(), + } + data = await self._request_genshin_record("role_combat", uid, lang=lang, payload=payload) + + return models.ImgTheater(**data) + async def get_genshin_notes( self, uid: typing.Optional[int] = None, diff --git a/genshin/client/components/chronicle/zzz.py b/genshin/client/components/chronicle/zzz.py new file mode 100644 index 00000000..9c15687c --- /dev/null +++ b/genshin/client/components/chronicle/zzz.py @@ -0,0 +1,143 @@ +"""StarRail battle chronicle component.""" + +import typing + +from genshin import errors, types, utility +from genshin.models import zzz 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 ZZZ user stats.""" + data = await self._request_zzz_record("index", uid, lang=lang, cache=False) + return models.ZZZUserStats(**data) + + async def get_zzz_agents( + self, uid: typing.Optional[int] = None, *, lang: typing.Optional[str] = None + ) -> typing.Sequence[models.ZZZPartialAgent]: + """Get all owned ZZZ characters (only brief info).""" + data = await self._request_zzz_record("avatar/basic", uid, lang=lang, cache=False) + return [models.ZZZPartialAgent(**item) for item in data["avatar_list"]] + + async def get_bangboos( + self, uid: typing.Optional[int] = None, *, lang: typing.Optional[str] = None + ) -> typing.Sequence[models.ZZZBaseBangboo]: + """Get all owned ZZZ bangboos.""" + data = await self._request_zzz_record("buddy/info", uid, lang=lang, cache=False) + return [models.ZZZBaseBangboo(**item) for item in data["list"]] + + @typing.overload + async def get_zzz_agent_info( + self, + character_id: int, + *, + uid: typing.Optional[int] = None, + lang: typing.Optional[str] = None, + ) -> models.ZZZFullAgent: ... + @typing.overload + async def get_zzz_agent_info( + self, + character_id: typing.Sequence[int], + *, + uid: typing.Optional[int] = None, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.ZZZFullAgent]: ... + async def get_zzz_agent_info( + self, + character_id: typing.Union[int, typing.Sequence[int]], + *, + uid: typing.Optional[int] = None, + lang: typing.Optional[str] = None, + ) -> typing.Union[models.ZZZFullAgent, typing.Sequence[models.ZZZFullAgent]]: + """Get a ZZZ character's detailed info.""" + if isinstance(character_id, list): + character_id = tuple(character_id) + + data = await self._request_zzz_record("avatar/info", uid, lang=lang, payload={"id_list[]": character_id}) + if isinstance(character_id, int): + return models.ZZZFullAgent(**data["avatar_list"][0]) + return [models.ZZZFullAgent(**item) for item in data["avatar_list"]] + + async def get_shiyu_defense( + self, uid: typing.Optional[int] = None, *, previous: bool = False, lang: typing.Optional[str] = None + ) -> models.ShiyuDefense: + """Get ZZZ Shiyu defense stats.""" + payload = {"schedule_type": 2 if previous else 1, "need_all": "true"} + data = await self._request_zzz_record("challenge", uid, lang=lang, payload=payload) + return models.ShiyuDefense(**data) diff --git a/genshin/client/components/gacha.py b/genshin/client/components/gacha.py index 19fc90b0..bd41b7bb 100644 --- a/genshin/client/components/gacha.py +++ b/genshin/client/components/gacha.py @@ -64,7 +64,7 @@ async def _get_gacha_page( lang=lang, game=game, authkey=authkey, - params=dict(gacha_type=banner_type, size=20, end_id=end_id), + params=dict(gacha_type=banner_type, real_gacha_type=banner_type, size=20, end_id=end_id), ) return data["list"] @@ -85,8 +85,7 @@ async def _get_wish_page( game=types.Game.GENSHIN, ) - banner_names = await self.get_banner_names(lang=lang, authkey=authkey) - return [models.Wish(**i, banner_name=banner_names[banner_type]) for i in data] + return [models.Wish(**i, banner_type=banner_type) for i in data] async def _get_warp_page( self, @@ -107,6 +106,25 @@ async def _get_warp_page( return [models.Warp(**i, banner_type=banner_type) for i in data] + async def _get_signal_page( + self, + end_id: int, + banner_type: models.ZZZBannerType, + *, + lang: typing.Optional[str] = None, + authkey: typing.Optional[str] = None, + ) -> typing.Sequence[models.SignalSearch]: + """Get a single page of warps.""" + data = await self._get_gacha_page( + end_id=end_id, + banner_type=banner_type, + lang=lang, + authkey=authkey, + game=types.Game.ZZZ, + ) + + return [models.SignalSearch(**i, banner_type=banner_type) for i in data] + def wish_history( self, banner_type: typing.Optional[typing.Union[int, typing.Sequence[int]]] = None, @@ -117,7 +135,7 @@ def wish_history( end_id: int = 0, ) -> paginators.Paginator[models.Wish]: """Get the wish history of a user.""" - banner_types = banner_type or [100, 200, 301, 302] + banner_types = banner_type or [100, 200, 301, 302, 500] if not isinstance(banner_types, typing.Sequence): banner_types = [banner_types] @@ -177,6 +195,41 @@ def warp_history( return paginators.MergedPaginator(iterators, key=lambda wish: wish.time.timestamp()) + def signal_history( + self, + banner_type: typing.Optional[typing.Union[int, typing.Sequence[int]]] = None, + *, + limit: typing.Optional[int] = None, + lang: typing.Optional[str] = None, + authkey: typing.Optional[str] = None, + end_id: int = 0, + ) -> paginators.Paginator[models.SignalSearch]: + """Get the signal search history of a user.""" + banner_types = banner_type or [1, 2, 3, 5] + + if not isinstance(banner_types, typing.Sequence): + banner_types = [banner_types] + + iterators: typing.List[paginators.Paginator[models.SignalSearch]] = [] + for banner in banner_types: + iterators.append( + paginators.CursorPaginator( + functools.partial( + self._get_signal_page, + banner_type=typing.cast(models.ZZZBannerType, banner), + lang=lang, + authkey=authkey, + ), + limit=limit, + end_id=end_id, + ) + ) + + if len(iterators) == 1: + return iterators[0] + + return paginators.MergedPaginator(iterators, key=lambda wish: wish.time.timestamp()) + @deprecation.deprecated("get_genshin_banner_names") async def get_banner_names( self, @@ -289,6 +342,7 @@ async def get_genshin_gacha_items( lang: typing.Optional[str] = None, ) -> typing.Sequence[models.GachaItem]: """Get the list of characters and weapons that can be gotten from the gacha.""" + raise RuntimeError("This method is currently broken, if you know how to fix it, please open an issue.") lang = lang or self.lang data = await self.request_webstatic( f"/hk4e/gacha_info/{server}/items/{lang}.json", diff --git a/genshin/client/components/hoyolab.py b/genshin/client/components/hoyolab.py index 7e1804dc..5f492708 100644 --- a/genshin/client/components/hoyolab.py +++ b/genshin/client/components/hoyolab.py @@ -2,6 +2,7 @@ import asyncio import typing +import warnings from genshin import types, utility from genshin.client import cache as client_cache @@ -16,6 +17,19 @@ class HoyolabClient(base.BaseClient): """Hoyolab component.""" + async def _get_server_region(self, uid: int, game: types.Game) -> str: + """Fetch the server region of an account from the API.""" + data = await self.request( + routes.GET_USER_REGION_URL.get_url(), + params=dict(game_biz=utility.get_prod_game_biz(self.region, game)), + cache=client_cache.cache_key("server_region", game=game, uid=uid, region=self.region), + ) + for account in data["list"]: + if account["game_uid"] == str(uid): + return account["region"] + + raise ValueError(f"Failed to recognize server for game {game!r} and uid {uid!r}") + async def search_users( self, keyword: str, @@ -116,6 +130,7 @@ async def redeem_code( *, game: typing.Optional[types.Game] = None, lang: typing.Optional[str] = None, + region: typing.Optional[str] = None, ) -> None: """Redeems a gift code for the current user.""" if game is None: @@ -124,16 +139,22 @@ 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, types.Game.TOT}: raise ValueError(f"{game} does not support code redemption.") uid = uid or await self._get_uid(game) + try: + region = region or utility.recognize_server(uid, game) + except Exception: + warnings.warn(f"Failed to recognize server for game {game!r} and uid {uid!r}, fetching from API now.") + region = await self._get_server_region(uid, game) + await self.request( routes.CODE_URL.get_url(self.region, game), params=dict( uid=uid, - region=utility.recognize_server(uid, game), + region=region, cdkey=code, game_biz=utility.get_prod_game_biz(self.region, game), lang=utility.create_short_lang_code(lang or self.lang), diff --git a/genshin/client/components/lineup.py b/genshin/client/components/lineup.py index 69326e3d..2ca3c8c9 100644 --- a/genshin/client/components/lineup.py +++ b/genshin/client/components/lineup.py @@ -106,8 +106,7 @@ def get_lineups( if scenario is not None: scenario = int(scenario) - if characters is not None: - characters = [int(i) for i in characters] + character_ids = [int(i) for i in characters] if characters is not None else None if match_characters: order = "Match" @@ -118,7 +117,7 @@ def get_lineups( functools.partial( self._get_lineup_page, tag_id=scenario, - roles=characters, + roles=character_ids, order=order, uid=uid, lang=lang, diff --git a/genshin/client/routes.py b/genshin/client/routes.py index cd7eb5dc..5bea27ff 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -24,6 +24,7 @@ "GACHA_URL", "GET_COOKIE_TOKEN_BY_GAME_TOKEN_URL", "GET_STOKEN_BY_GAME_TOKEN_URL", + "GET_USER_REGION_URL", "HK4E_URL", "INFO_LEDGER_URL", "LINEUP_URL", @@ -200,12 +201,14 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: 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", + tot="https://sg-public-api.hoyolab.com/event/luna/os?act_id=e202202281857121", ), 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", + tot="https://api-takumi.mihoyo.com/event/luna?act_id=e202202251749321", ), ) @@ -214,6 +217,7 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: 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", + tot="https://sg-public-api.hoyoverse.com/common/apicdkey/api/webExchangeCdkey", ), chinese=dict(), ) @@ -222,10 +226,12 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: overseas=dict( genshin="https://hk4e-api-os.hoyoverse.com/gacha_info/api/", hkrpg="https://api-os-takumi.mihoyo.com/common/gacha_record/api/", + nap="https://public-operation-nap-sg.hoyoverse.com/common/gacha_record/api/", ), chinese=dict( genshin="https://hk4e-api.mihoyo.com/event/gacha_info/api/", hkrpg="https://api-takumi.mihoyo.com/common/gacha_record/api/", + nap="https://public-operation-nap.mihoyo.com/common/gacha_record/api/", ), ) YSULOG_URL = InternationalRoute( @@ -309,3 +315,7 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: nap="https://nap-sdk.mihoyo.com/nap_cn/combo/granter/login/v2/login", ), ) + +GET_USER_REGION_URL = Route( + "https://api-account-os.hoyoverse.com/account/binding/api/getUserGameRolesOfRegionByCookieToken" +) diff --git a/genshin/constants.py b/genshin/constants.py index 1d6d89b6..d5a621a6 100644 --- a/genshin/constants.py +++ b/genshin/constants.py @@ -4,7 +4,16 @@ from . import types -__all__ = ["APP_IDS", "APP_KEYS", "DS_SALT", "GEETEST_RETCODES", "LANGS"] +__all__ = [ + "APP_IDS", + "APP_KEYS", + "CN_TIMEZONE", + "DS_SALT", + "GAME_BIZS", + "GEETEST_RECORD_KEYS", + "GEETEST_RETCODES", + "LANGS", +] LANGS = { @@ -87,3 +96,18 @@ """Keys used to submit geetest result.""" CN_TIMEZONE = datetime.timezone(datetime.timedelta(hours=8)) + +GAME_BIZS = { + types.Region.OVERSEAS: { + types.Game.GENSHIN: "hk4e_global", + types.Game.STARRAIL: "hkrpg_global", + types.Game.HONKAI: "bh3_os", + types.Game.ZZZ: "nap_global", + }, + types.Region.CHINESE: { + types.Game.GENSHIN: "hk4e_cn", + types.Game.STARRAIL: "hkrpg_cn", + types.Game.HONKAI: "bh3_cn", + types.Game.ZZZ: "nap_cn", + }, +} 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/genshin/chronicle/__init__.py b/genshin/models/genshin/chronicle/__init__.py index 094b8f71..842045c0 100644 --- a/genshin/models/genshin/chronicle/__init__.py +++ b/genshin/models/genshin/chronicle/__init__.py @@ -3,6 +3,7 @@ from .abyss import * from .activities import * from .characters import * +from .img_theater import * from .notes import * from .stats import * from .tcg import * diff --git a/genshin/models/genshin/chronicle/img_theater.py b/genshin/models/genshin/chronicle/img_theater.py new file mode 100644 index 00000000..88a6b422 --- /dev/null +++ b/genshin/models/genshin/chronicle/img_theater.py @@ -0,0 +1,153 @@ +import datetime +import enum +import typing + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +from genshin.constants import CN_TIMEZONE +from genshin.models.genshin import character +from genshin.models.model import Aliased, APIModel + +__all__ = ( + "Act", + "ActCharacter", + "ImgTheater", + "ImgTheaterData", + "TheaterBuff", + "TheaterCharaType", + "TheaterDifficulty", + "TheaterSchedule", + "TheaterStats", +) + + +class TheaterCharaType(enum.IntEnum): + """The type of character in the context of the imaginarium theater gamemode.""" + + NORMAL = 1 + TRIAL = 2 + SUPPORT = 3 + + +class TheaterDifficulty(enum.IntEnum): + """The difficulty of the imaginarium theater data.""" + + UNKNOWN = 0 + EASY = 1 + NORMAL = 2 + HARD = 3 + + +class ActCharacter(character.BaseCharacter): + """A character in an act.""" + + type: TheaterCharaType = Aliased("avatar_type") + level: int + + +class TheaterBuff(APIModel): + """Represents either a 'mystery cache' or a 'wondrous boom'.""" + + icon: str + name: str + description: str = Aliased("desc") + received_audience_support: bool = Aliased("is_enhanced") + """Whether external audience support is received.""" + id: int + + +class Act(APIModel): + """One act in the theater.""" + + characters: typing.Sequence[ActCharacter] = Aliased("avatars") + mystery_caches: typing.Sequence[TheaterBuff] = Aliased("choice_cards") + wondroud_booms: typing.Sequence[TheaterBuff] = Aliased("buffs") + medal_obtained: bool = Aliased("is_get_medal") + round_id: int + finish_time: int # As timestamp + finish_datetime: datetime.datetime = Aliased("finish_date_time") + + @pydantic.validator("finish_datetime", pre=True) + def __parse_datetime(cls, value: typing.Mapping[str, typing.Any]) -> datetime.datetime: + return datetime.datetime( + year=value["year"], + month=value["month"], + day=value["day"], + hour=value["hour"], + minute=value["minute"], + second=value["second"], + tzinfo=CN_TIMEZONE, + ) + + +class TheaterStats(APIModel): + """Imaginarium theater stats.""" + + difficulty: TheaterDifficulty = Aliased("difficulty_id") + best_record: int = Aliased("max_round_id") + """The maximum act the player has reached.""" + heraldry: int # Not sure what this is + star_challenge_stellas: typing.Sequence[bool] = Aliased("get_medal_round_list") + """Whether the player has obtained the medal for each act.""" + fantasia_flowers_used: int = Aliased("coin_num") + """The number of Fantasia Flowers used.""" + audience_support_trigger_num: int = Aliased("avatar_bonus_num") + """The number of external audience support triggers.""" + player_assists: int = Aliased("rent_cnt") + """The number of supporting cast characters assisting other players.""" + medal_num: int + """The number of medals the player has obtained.""" + + +class TheaterSchedule(APIModel): + """Imaginarium theater schedule.""" + + start_time: int # As timestamp + end_time: int # As timestamp + schedule_type: int # Not sure what this is + id: int = Aliased("schedule_id") + start_datetime: datetime.datetime = Aliased("start_date_time") + end_datetime: datetime.datetime = Aliased("end_date_time") + + @pydantic.validator("start_datetime", "end_datetime", pre=True) + def __parse_datetime(cls, value: typing.Mapping[str, typing.Any]) -> datetime.datetime: + return datetime.datetime( + year=value["year"], + month=value["month"], + day=value["day"], + hour=value["hour"], + minute=value["minute"], + second=value["second"], + tzinfo=CN_TIMEZONE, + ) + + +class ImgTheaterData(APIModel): + """Imaginarium theater data.""" + + acts: typing.Sequence[Act] = Aliased(alias="rounds_data") + backup_characters: typing.Sequence[ActCharacter] = Aliased(alias="backup_avatars") # Not sure what this is + stats: TheaterStats = Aliased(alias="stat") + schedule: TheaterSchedule + has_data: bool + has_detail_data: bool + + @pydantic.root_validator(pre=True) + def __unnest_detail(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + detail: typing.Optional[typing.Dict[str, typing.Any]] = values.get("detail") + values["rounds_data"] = detail.get("rounds_data", []) if detail is not None else [] + values["backup_avatars"] = detail.get("backup_avatars", []) if detail is not None else [] + return values + + +class ImgTheater(APIModel): + """Imaginarium theater.""" + + datas: typing.Sequence[ImgTheaterData] = Aliased("data") + unlocked: bool = Aliased("is_unlock") diff --git a/genshin/models/genshin/chronicle/notes.py b/genshin/models/genshin/chronicle/notes.py index aba31f80..6bc33379 100644 --- a/genshin/models/genshin/chronicle/notes.py +++ b/genshin/models/genshin/chronicle/notes.py @@ -97,7 +97,14 @@ class TaskRewardStatus(str, enum.Enum): class TaskReward(APIModel): """Status of the Commission/Task.""" - status: TaskRewardStatus + status: typing.Union[TaskRewardStatus, str] + + @pydantic.validator("status", pre=True) + def __prevent_enum_crash(cls, v: str) -> typing.Union[TaskRewardStatus, str]: + try: + return TaskRewardStatus(v) + except ValueError: + return v class AttendanceRewardStatus(str, enum.Enum): @@ -112,9 +119,16 @@ class AttendanceRewardStatus(str, enum.Enum): class AttendanceReward(APIModel): """Status of the Encounter Point.""" - status: AttendanceRewardStatus + status: typing.Union[AttendanceRewardStatus, str] progress: int + @pydantic.validator("status", pre=True) + def __prevent_enum_crash(cls, v: str) -> typing.Union[AttendanceRewardStatus, str]: + try: + return AttendanceRewardStatus(v) + except ValueError: + return v + class DailyTasks(APIModel): """Daily tasks section.""" @@ -127,6 +141,9 @@ class DailyTasks(APIModel): attendance_rewards: typing.Sequence[AttendanceReward] attendance_visible: bool + stored_attendance: float + stored_attendance_refresh_countdown: datetime.timedelta = Aliased("attendance_refresh_time") + class ArchonQuestStatus(str, enum.Enum): """Archon quest status.""" diff --git a/genshin/models/genshin/chronicle/stats.py b/genshin/models/genshin/chronicle/stats.py index 0734a886..9f45e277 100644 --- a/genshin/models/genshin/chronicle/stats.py +++ b/genshin/models/genshin/chronicle/stats.py @@ -17,6 +17,8 @@ from . import abyss, activities, characters __all__ = [ + "AreaExploration", + "BossKill", "Exploration", "FullGenshinUserStats", "GenshinUserStats", @@ -69,6 +71,25 @@ class Offering(APIModel): icon: str = "" +class BossKill(APIModel): + """Boss kills in exploration""" + + name: str + kills: int = Aliased("kill_num") + + +class AreaExploration(APIModel): + """Area exploration data.""" + + name: str + raw_explored: int = Aliased("exploration_percentage") + + @property + def explored(self) -> float: + """The percentage explored. (Note: This can go above 100%)""" + return self.raw_explored / 10 + + class Exploration(APIModel): """Exploration data.""" @@ -88,6 +109,8 @@ class Exploration(APIModel): map_url: str offerings: typing.Sequence[Offering] + boss_list: typing.Sequence[BossKill] + area_exploration_list: typing.Sequence[AreaExploration] @property def explored(self) -> float: diff --git a/genshin/models/genshin/gacha.py b/genshin/models/genshin/gacha.py index 777b32a3..551b1cb7 100644 --- a/genshin/models/genshin/gacha.py +++ b/genshin/models/genshin/gacha.py @@ -21,8 +21,10 @@ "BannerDetailsUpItem", "GachaItem", "GenshinBannerType", + "SignalSearch", "Warp", "Wish", + "ZZZBannerType", ] @@ -41,6 +43,9 @@ class GenshinBannerType(enum.IntEnum): WEAPON = 302 """Rotating weapon banner.""" + CHRONICLED = 500 + """Chronicled banner.""" + # these are special cases # they exist inside the history but should be counted as the same @@ -64,6 +69,22 @@ class StarRailBannerType(enum.IntEnum): """Rotating weapon banner.""" +class ZZZBannerType(enum.IntEnum): + """Banner types in wish histories.""" + + STANDARD = PERMANENT = 1 + """Permanent standard banner.""" + + CHARACTER = 2 + """Rotating character banner.""" + + WEAPON = 3 + """Rotating weapon banner.""" + + BANGBOO = 5 + """Bangboo banner.""" + + class Wish(APIModel, Unique): """Wish made on any banner.""" @@ -75,8 +96,7 @@ class Wish(APIModel, Unique): rarity: int = Aliased("rank_type") time: datetime.datetime - banner_type: GenshinBannerType = Aliased("gacha_type") - banner_name: str + banner_type: GenshinBannerType @pydantic.validator("banner_type", pre=True) def __cast_banner_type(cls, v: typing.Any) -> int: @@ -103,6 +123,25 @@ def __cast_banner_type(cls, v: typing.Any) -> int: return int(v) +class SignalSearch(APIModel, Unique): + """Signal Search made on any banner.""" + + uid: int + + id: int + item_id: int + type: str = Aliased("item_type") + name: str + rarity: int = Aliased("rank_type") + time: datetime.datetime + + banner_type: ZZZBannerType + + @pydantic.validator("banner_type", pre=True) + def __cast_banner_type(cls, v: typing.Any) -> int: + return int(v) + + class BannerDetailItem(APIModel): """Item that may be gotten from a banner.""" diff --git a/genshin/models/honkai/battlesuit.py b/genshin/models/honkai/battlesuit.py index b7ba35eb..fe87d436 100644 --- a/genshin/models/honkai/battlesuit.py +++ b/genshin/models/honkai/battlesuit.py @@ -39,6 +39,7 @@ class Battlesuit(APIModel, Unique): rarity: int = Aliased("star") closeup_icon_background: str = Aliased("avatar_background_path") tall_icon: str = Aliased("figure_path") + banner_art: str = Aliased("image_path") @pydantic.validator("tall_icon") def __autocomplete_figpath(cls, tall_icon: str, values: typing.Dict[str, typing.Any]) -> str: diff --git a/genshin/models/hoyolab/record.py b/genshin/models/hoyolab/record.py index e47225d4..1d04e772 100644 --- a/genshin/models/hoyolab/record.py +++ b/genshin/models/hoyolab/record.py @@ -46,10 +46,14 @@ class GenshinAccount(APIModel): def game(self) -> types.Game: if "hk4e" in self.game_biz: return types.Game.GENSHIN - elif "bh3" in self.game_biz: + if "bh3" in self.game_biz: return types.Game.HONKAI - elif "hkrpg" in self.game_biz: + if "hkrpg" in self.game_biz: return types.Game.STARRAIL + if "nap" in self.game_biz: + return types.Game.ZZZ + if "nxx" in self.game_biz: + return types.Game.TOT try: return types.Game(self.game_biz) @@ -159,11 +163,15 @@ def __new__(cls, **kwargs: typing.Any) -> RecordCard: cls = GenshinRecordCard elif game_id == 6: cls = StarRailRecodeCard + elif game_id == 8: + cls = ZZZRecordCard return super().__new__(cls) # type: ignore game_id: int game_biz: str = "" + game_name: str + game_logo: str = Aliased("logo") uid: int = Aliased("game_role_id") data: typing.Sequence[RecordCardData] @@ -249,3 +257,27 @@ def achievements(self) -> int: @property def chests(self) -> int: return int(self.data[3].value) + + +class ZZZRecordCard(RecordCard): + """ZZZ record card.""" + + @property + def game(self) -> types.Game: + return types.Game.ZZZ + + @property + def days_active(self) -> int: + return int(self.data[0].value) + + @property + def inter_knot_reputation(self) -> str: + return self.data[1].value + + @property + def agents_recruited(self) -> int: + return int(self.data[2].value) + + @property + def bangboo_obtained(self) -> int: + return int(self.data[3].value) diff --git a/genshin/models/model.py b/genshin/models/model.py index 9f80e7f3..335e4336 100644 --- a/genshin/models/model.py +++ b/genshin/models/model.py @@ -10,6 +10,7 @@ if typing.TYPE_CHECKING: import pydantic.v1 as pydantic + from pydantic.v1.fields import ModelField else: try: import pydantic.v1 as pydantic @@ -162,7 +163,7 @@ def dict(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: def _get_mi18n( self, - field: typing.Union[pydantic.fields.ModelField, str], + field: typing.Union[ModelField, str], lang: str, *, default: typing.Optional[str] = None, diff --git a/genshin/models/starrail/chronicle/challenge.py b/genshin/models/starrail/chronicle/challenge.py index cf4304b7..d1b274fc 100644 --- a/genshin/models/starrail/chronicle/challenge.py +++ b/genshin/models/starrail/chronicle/challenge.py @@ -155,7 +155,7 @@ def __unnest_groups(cls, values: Dict[str, Any]) -> Dict[str, Any]: class APCShadowFloorNode(FloorNode): """Node for a apocalyptic shadow floor.""" - challenge_time: Optional[PartialTime] + challenge_time: Optional[PartialTime] # type: ignore[assignment] buff: Optional[ChallengeBuff] score: int boss_defeated: bool 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..82fb9202 --- /dev/null +++ b/genshin/models/zzz/character.py @@ -0,0 +1,244 @@ +import enum +import typing + +from genshin.models.model import Aliased, APIModel, Unique + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +__all__ = ( + "AgentSkill", + "AgentSkillItem", + "DiscSetEffect", + "WEngine", + "ZZZAgentProperty", + "ZZZAgentRank", + "ZZZBaseAgent", + "ZZZDisc", + "ZZZElementType", + "ZZZFullAgent", + "ZZZPartialAgent", + "ZZZProperty", + "ZZZPropertyType", + "ZZZSkillType", + "ZZZSpecialty", +) + + +class ZZZElementType(enum.IntEnum): + """ZZZ element type.""" + + PHYSICAL = 200 + FIRE = 201 + ICE = 202 + ELECTRIC = 203 + ETHER = 205 + + +class ZZZSpecialty(enum.IntEnum): + """ZZZ agent compatible specialty.""" + + 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") + full_name: str = Aliased("full_name_mi18n") + specialty: ZZZSpecialty = Aliased("avatar_profession") + faction_icon: str = Aliased("group_icon_path") + flat_icon: str = Aliased("hollow_icon_path") + + @property + def square_icon(self) -> str: + """Example: https://act-webstatic.hoyoverse.com/game_record/zzz/role_square_avatar/role_square_avatar_1131.png""" + return ( + f"https://act-webstatic.hoyoverse.com/game_record/zzz/role_square_avatar/role_square_avatar_{self.id}.png" + ) + + @property + def rectangle_icon(self) -> str: + """Example: https://act-webstatic.hoyoverse.com/game_record/zzz/role_rectangle_avatar/role_rectangle_avatar_1131.png""" + return f"https://act-webstatic.hoyoverse.com/game_record/zzz/role_rectangle_avatar/role_rectangle_avatar_{self.id}.png" + + @property + def banner_icon(self) -> str: + """Example: https://act-webstatic.hoyoverse.com/game_record/zzz/role_vertical_painting/role_vertical_painting_1131.png""" + return f"https://act-webstatic.hoyoverse.com/game_record/zzz/role_vertical_painting/role_vertical_painting_{self.id}.png" + + +class ZZZPartialAgent(ZZZBaseAgent): + """Character without any equipment.""" + + level: int + rank: int + """Also known as Mindscape Cinema in-game.""" + + +class ZZZPropertyType(enum.IntEnum): + """ZZZ property type.""" + + # Agent prop + AGENT_HP = 1 + AGENT_ATK = 2 + AGENT_DEF = 3 + AGENT_IMPACT = 4 + AGENT_CRIT_RATE = 5 + AGENT_CRIT_DMG = 6 + AGENT_ANOMALY_MASTERY = 7 + AGENT_ANOMALY_PROFICIENCY = 8 + AGENT_PEN_RATIO = 9 + AGENT_ENERGY_GEN = 10 + + # Disc drive + DISC_HP = 11103 + DISC_ATK = 12103 + DISC_DEF = 13103 + DISC_PEN = 23203 + DISC_BONUS_PHYSICAL_DMG = 31503 + DISC_BONUS_FIRE_DMG = 31603 + DISC_BONUS_ICE_DMG = 31703 + DISC_BONUS_ELECTRIC_DMG = 31803 + DISC_BONUS_ETHER_DMG = 31903 + + # W-engine + ENGINE_HP = 11102 + ENGINE_BASE_ATK = 12101 + ENGINE_ATK = 12102 + ENGINE_DEF = 13102 + ENGINE_ENERGY_REGEN = 30502 + + # Disc drive and w-engine shared + CRIT_RATE = 20103 + CRIT_DMG = 21103 + ANOMALY_PROFICIENCY = 31203 + PEN_RATIO = 23103 + IMPACT = 12202 + + +class ZZZProperty(APIModel): + """A property (stat) for disc or w-engine.""" + + name: str = Aliased("property_name") + type: typing.Union[int, ZZZPropertyType] = Aliased("property_id") + value: str = Aliased("base") + + @pydantic.validator("type", pre=True) + def __cast_id(cls, v: int) -> typing.Union[int, ZZZPropertyType]: + # Prevent enum crash + try: + return ZZZPropertyType(v) + except ValueError: + return v + + +class ZZZAgentProperty(ZZZProperty): + """A property model, but for agents.""" + + add: str + final: str + + +class DiscSetEffect(APIModel): + """A disc set effect.""" + + id: int = Aliased("suit_id") + name: str + owned_num: int = Aliased("own") + two_piece_description: str = Aliased("desc1") + four_piece_description: str = Aliased("desc2") + + +class WEngine(APIModel): + """A ZZZ W-engine, it's like a weapon.""" + + id: int + level: int + name: str + icon: str + refinement: typing.Literal[1, 2, 3, 4, 5] = Aliased("star") + rarity: typing.Literal["B", "A", "S"] + properties: typing.Sequence[ZZZProperty] + main_properties: typing.Sequence[ZZZProperty] + effect_title: str = Aliased("talent_title") + effect_description: str = Aliased("talent_content") + profession: ZZZSpecialty + + +class ZZZDisc(APIModel): + """A ZZZ disc, like an artifact in Genshin.""" + + id: int + level: int + name: str + icon: str + rarity: typing.Literal["B", "A", "S"] + main_properties: typing.Sequence[ZZZProperty] + properties: typing.Sequence[ZZZProperty] + set_effect: DiscSetEffect = Aliased("equip_suit") + position: int = Aliased("equipment_type") + + +class ZZZSkillType(enum.IntEnum): + """ZZZ agent skill type.""" + + BASIC_ATTACK = 0 + DODGE = 2 + ASSIST = 6 + SPECIAL_ATTACK = 1 + CHAIN_ATTACK = 3 + CORE_SKILL = 5 + + +class AgentSkillItem(APIModel): + """An agent skill item.""" + + title: str + text: str + + +class AgentSkill(APIModel): + """ZZZ agent skill model.""" + + level: int + type: ZZZSkillType = Aliased("skill_type") + items: typing.Sequence[AgentSkillItem] + """One skill can have different forms (?), so there are multiple 'items'.""" + + +class ZZZAgentRank(APIModel): + """ZZZ agent rank model.""" + + id: int + name: str + description: str = Aliased("desc") + position: int = Aliased("pos") + unlocked: bool = Aliased("is_unlocked") + + +class ZZZFullAgent(ZZZBaseAgent): + """Character with equipment.""" + + level: int + rank: int + """Also known as Mindscape Cinema in-game.""" + faction_name: str = Aliased("camp_name_mi18n") + properties: typing.Sequence[ZZZAgentProperty] + discs: typing.Sequence[ZZZDisc] = Aliased("equip") + w_engine: typing.Optional[WEngine] = Aliased("weapon", default=None) + skills: typing.Sequence[AgentSkill] + ranks: typing.Sequence[ZZZAgentRank] + """Also known as Mindscape Cinemas in-game.""" diff --git a/genshin/models/zzz/chronicle/__init__.py b/genshin/models/zzz/chronicle/__init__.py new file mode 100644 index 00000000..3e5e3912 --- /dev/null +++ b/genshin/models/zzz/chronicle/__init__.py @@ -0,0 +1,5 @@ +"""ZZZ chronicle models.""" + +from .challenge import * +from .notes import * +from .stats import * diff --git a/genshin/models/zzz/chronicle/challenge.py b/genshin/models/zzz/chronicle/challenge.py new file mode 100644 index 00000000..e6f60b62 --- /dev/null +++ b/genshin/models/zzz/chronicle/challenge.py @@ -0,0 +1,140 @@ +import datetime +import typing + +from genshin.constants import CN_TIMEZONE +from genshin.models.model import Aliased, APIModel +from genshin.models.zzz.character import ZZZElementType + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +__all__ = ( + "ShiyuDefense", + "ShiyuDefenseBangboo", + "ShiyuDefenseBuff", + "ShiyuDefenseCharacter", + "ShiyuDefenseFloor", + "ShiyuDefenseMonster", + "ShiyuDefenseNode", +) + + +class ShiyuDefenseBangboo(APIModel): + """Shiyu Defense bangboo model.""" + + id: int + rarity: typing.Literal["S", "A"] + level: int + + @property + def icon(self) -> str: + return f"https://act-webstatic.hoyoverse.com/game_record/zzz/bangboo_square_avatar/bangboo_square_avatar_{self.id}.png" + + +class ShiyuDefenseCharacter(APIModel): + """Shiyu Defense character model.""" + + id: int + level: int + rarity: typing.Literal["S", "A"] + element: ZZZElementType = Aliased("element_type") + + @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 ShiyuDefenseBuff(APIModel): + """Shiyu Defense buff model.""" + + title: str + text: str + + +class ShiyuDefenseMonster(APIModel): + """Shiyu Defense monster model.""" + + id: int + name: str + weakness: ZZZElementType = Aliased("weak_element_type") + level: int + + +class ShiyuDefenseNode(APIModel): + """Shiyu Defense node model.""" + + characters: typing.List[ShiyuDefenseCharacter] = Aliased("avatars") + bangboo: ShiyuDefenseBangboo = Aliased("buddy") + recommended_elements: typing.List[ZZZElementType] = Aliased("element_type_list") + enemies: typing.List[ShiyuDefenseMonster] = Aliased("monster_info") + + @pydantic.validator("enemies", pre=True) + @classmethod + def __convert_enemies( + cls, value: typing.Dict[typing.Literal["level", "list"], typing.Any] + ) -> typing.List[ShiyuDefenseMonster]: + level = value["level"] + result: typing.List[ShiyuDefenseMonster] = [] + for monster in value["list"]: + monster["level"] = level + result.append(ShiyuDefenseMonster(**monster)) + return result + + +class ShiyuDefenseFloor(APIModel): + """Shiyu Defense floor model.""" + + index: int = Aliased("layer_index") + rating: typing.Literal["S", "A", "B"] + id: int = Aliased("layer_id") + buffs: typing.List[ShiyuDefenseBuff] + node_1: ShiyuDefenseNode + node_2: ShiyuDefenseNode + challenge_time: datetime.datetime = Aliased("floor_challenge_time") + name: str = Aliased("zone_name") + + @pydantic.validator("challenge_time", pre=True) + @classmethod + def __add_timezone( + cls, v: typing.Dict[typing.Literal["year", "month", "day", "hour", "minute", "second"], int] + ) -> datetime.datetime: + return datetime.datetime( + v["year"], v["month"], v["day"], v["hour"], v["minute"], v["second"], tzinfo=CN_TIMEZONE + ) + + +class ShiyuDefense(APIModel): + """ZZZ Shiyu Defense model.""" + + schedule_id: int + begin_time: datetime.datetime = Aliased("hadal_begin_time") + end_time: datetime.datetime = Aliased("hadal_end_time") + has_data: bool + ratings: typing.Mapping[typing.Literal["S", "A", "B"], int] = Aliased("rating_list") + floors: typing.List[ShiyuDefenseFloor] = Aliased("all_floor_detail") + fastest_clear_time: int = Aliased("fast_layer_time") + """Fastest clear time this season in seconds.""" + max_floor: int = Aliased("max_layer") + + @pydantic.validator("ratings", pre=True) + @classmethod + def __convert_ratings( + cls, v: typing.List[typing.Dict[typing.Literal["times", "rating"], typing.Any]] + ) -> typing.Mapping[typing.Literal["S", "A", "B"], int]: + return {d["rating"]: d["times"] for d in v} + + @pydantic.validator("begin_time", "end_time", pre=True) + @classmethod + def __add_timezone( + cls, v: typing.Dict[typing.Literal["year", "month", "day", "hour", "minute", "second"], int] + ) -> datetime.datetime: + return datetime.datetime( + v["year"], v["month"], v["day"], v["hour"], v["minute"], v["second"], tzinfo=CN_TIMEZONE + ) 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..c1d263c4 --- /dev/null +++ b/genshin/models/zzz/chronicle/stats.py @@ -0,0 +1,41 @@ +"""ZZZ data overview models.""" + +import typing + +from genshin.models.model import Aliased, APIModel + +from ..character import ZZZPartialAgent + +__all__ = ( + "ZZZBaseBangboo", + "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/types.py b/genshin/types.py index 93122b79..ebabb5bb 100644 --- a/genshin/types.py +++ b/genshin/types.py @@ -36,6 +36,9 @@ class Game(str, enum.Enum): ZZZ = "nap" """Zenless Zone Zero""" + TOT = "tot" + """Tears of Themis""" + IDOr = typing.Union[int, UniqueT] """Allows partial objects.""" diff --git a/genshin/utility/auth.py b/genshin/utility/auth.py index cbd6c78d..00148254 100644 --- a/genshin/utility/auth.py +++ b/genshin/utility/auth.py @@ -90,20 +90,17 @@ RISKY_CHECK_HEADERS = { "x-rpc-client_type": "1", "x-rpc-channel_id": "1", - "x-rpc-game_biz": "hkrpg_global", } SHIELD_LOGIN_HEADERS = { "x-rpc-client_type": "1", "x-rpc-channel_id": "1", - "x-rpc-game_biz": "hkrpg_global", "x-rpc-device_id": DEVICE_ID, } GRANT_TICKET_HEADERS = { "x-rpc-client_type": "1", "x-rpc-channel_id": "1", - "x-rpc-game_biz": "hkrpg_global", "x-rpc-device_id": DEVICE_ID, "x-rpc-language": "en", } @@ -111,7 +108,6 @@ GAME_LOGIN_HEADERS = { "x-rpc-client_type": "1", "x-rpc-channel_id": "1", - "x-rpc-game_biz": "hkrpg_global", "x-rpc-device_id": DEVICE_ID, } diff --git a/genshin/utility/ds.py b/genshin/utility/ds.py index 2a9e3164..d9a47951 100644 --- a/genshin/utility/ds.py +++ b/genshin/utility/ds.py @@ -54,6 +54,7 @@ def get_ds_headers( "x-rpc-app_version": "1.5.0", "x-rpc-client_type": "5", "x-rpc-language": lang, + "x-rpc-lang": lang, "ds": generate_dynamic_secret(), } elif region == types.Region.CHINESE: diff --git a/genshin/utility/uid.py b/genshin/utility/uid.py index 64e4b668..75cccf46 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,20 @@ 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_" + elif game is types.Game.TOT: + game_biz = "nxx_" - 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 +122,32 @@ 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) + + raise ValueError(f"recognize_server is not implemented for game {game}") def recognize_game(uid: int, region: types.Region) -> typing.Optional[types.Game]: @@ -135,6 +164,14 @@ 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 in {types.Game.ZZZ, types.Game.TOT}: + if len(str(uid)) == 8: + return types.Region.CHINESE + return types.Region.OVERSEAS + + if game not in UID_RANGE: + return None + for region, digits in UID_RANGE[game].items(): if str(uid)[:-8] in digits: return region diff --git a/tests/client/components/test_calculator.py b/tests/client/components/test_calculator.py index 1334166f..f4c6693a 100644 --- a/tests/client/components/test_calculator.py +++ b/tests/client/components/test_calculator.py @@ -93,7 +93,7 @@ async def test_calculate(client: genshin.Client): assert len(cost.artifacts) == 5 and all(len(i.list) == 2 for i in cost.artifacts) assert len(cost.talents) == 9 assert len(cost.total) == 25 - assert cost.total[0].name == "Mora" and cost.total[0].amount == 9_533_850 + assert cost.total[0].name == "Hero's Wit" async def test_furnishing_calculate(client: genshin.Client): diff --git a/tests/client/components/test_genshin_chronicle.py b/tests/client/components/test_genshin_chronicle.py index ce4daff3..81c667ee 100644 --- a/tests/client/components/test_genshin_chronicle.py +++ b/tests/client/components/test_genshin_chronicle.py @@ -29,6 +29,12 @@ async def test_spiral_abyss(client: genshin.Client, genshin_uid: int): assert data +async def test_imaginarium_theater(client: genshin.Client, genshin_uid: int): + data = await client.get_imaginarium_theater(genshin_uid) + + assert data + + async def test_notes(lclient: genshin.Client, genshin_uid: int): data = await lclient.get_notes(genshin_uid) diff --git a/tests/client/components/test_hoyolab.py b/tests/client/components/test_hoyolab.py index 1f2f5a43..03f69b24 100644 --- a/tests/client/components/test_hoyolab.py +++ b/tests/client/components/test_hoyolab.py @@ -10,7 +10,7 @@ async def test_game_accounts(lclient: genshin.Client): async def test_search(client: genshin.Client, hoyolab_id: int): - users = await client.search_users("sadru") + users = await client.search_users("seria_ati") for user in users: if user.hoyolab_id == hoyolab_id: @@ -18,13 +18,13 @@ async def test_search(client: genshin.Client, hoyolab_id: int): else: raise AssertionError("Search did not return the correct users") - assert user.nickname == "sadru" + assert user.nickname == "seria_ati" async def test_hoyolab_user(client: genshin.Client, hoyolab_id: int): user = await client.get_hoyolab_user(hoyolab_id) - assert user.nickname == "sadru" + assert user.nickname == "seria_ati" async def test_recommended_users(client: genshin.Client): diff --git a/tests/client/components/test_wiki.py b/tests/client/components/test_wiki.py deleted file mode 100644 index f60053da..00000000 --- a/tests/client/components/test_wiki.py +++ /dev/null @@ -1,13 +0,0 @@ -import genshin - -# async def test_wiki_previews(client: genshin.Client): -# preview = await client.get_wiki_previews(genshin.models.WikiPageType.CHARACTER) - -# assert preview - - -async def test_wiki_page(client: genshin.Client): - page = await client.get_wiki_page(10) - - assert page.modules["Attributes"]["list"][0]["key"] == "Name" - assert page.modules["Attributes"]["list"][0]["value"] == ["Keqing"] diff --git a/tests/client/components/test_wish.py b/tests/client/components/test_wish.py index c7f1040f..b73e099c 100644 --- a/tests/client/components/test_wish.py +++ b/tests/client/components/test_wish.py @@ -31,7 +31,7 @@ async def test_banner_details(lclient: genshin.Client): assert details.banner_type in [100, 200, 301, 302, 400] -async def test_gacha_items(lclient: genshin.Client): - items = await lclient.get_gacha_items() - assert items[0].is_character() - assert not items[-1].is_character() +# async def test_gacha_items(lclient: genshin.Client): +# items = await lclient.get_genshin_gacha_items() +# assert items[0].is_character() +# assert not items[-1].is_character() diff --git a/tests/conftest.py b/tests/conftest.py index 48ae63e7..a1e87edf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,22 +2,20 @@ import json import os import typing -import warnings import pytest import genshin +# @pytest.fixture(scope="session") +# def event_loop(): +# with warnings.catch_warnings(): +# warnings.simplefilter("ignore") -@pytest.fixture(scope="session") -def event_loop(): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - - loop = asyncio.get_event_loop() +# loop = asyncio.get_event_loop() - yield loop - loop.close() +# yield loop +# loop.close() @pytest.fixture(scope="session") @@ -159,7 +157,7 @@ async def lcnclient(local_chinese_cookies: typing.Mapping[str, str]): @pytest.fixture(scope="session") def genshin_uid(): - return 710785423 + return 901211014 @pytest.fixture(scope="session") @@ -169,7 +167,7 @@ def honkai_uid(): @pytest.fixture(scope="session") def hoyolab_id(): - return 8366222 + return 7368957 @pytest.fixture(scope="session") diff --git a/tests/models/test_model.py b/tests/models/test_model.py index 57c5891c..074bb523 100644 --- a/tests/models/test_model.py +++ b/tests/models/test_model.py @@ -1,4 +1,3 @@ -import os import typing import pytest @@ -157,19 +156,19 @@ def APIModel___new__(cls: typing.Type[genshin.models.APIModel], *args: typing.An genshin.models.APIModel.__new__ = APIModel___new__ -def test_model_reserialization(): - for cls, model in sorted(all_models.items(), key=lambda pair: pair[0].__name__): - cls(**model.dict()) +# def test_model_reserialization(): +# for cls, model in sorted(all_models.items(), key=lambda pair: pair[0].__name__): +# cls(**model.dict()) - if hasattr(model, "as_dict"): - getattr(model, "as_dict")() +# if hasattr(model, "as_dict"): +# getattr(model, "as_dict")() - # dump all parsed models - data = ",\n".join( - f'"{cls.__name__}": {model.json(indent=4, ensure_ascii=False, models_as_dict=True)}' - for cls, model in all_models.items() - ) - data = "{" + data + "}" - os.makedirs(".pytest_cache", exist_ok=True) - with open(".pytest_cache/hoyo_parsed.json", "w", encoding="utf-8") as file: - file.write(data) +# # dump all parsed models +# data = ",\n".join( +# f'"{cls.__name__}": {model.json(indent=4, ensure_ascii=False, models_as_dict=True)}' +# for cls, model in all_models.items() +# ) +# data = "{" + data + "}" +# os.makedirs(".pytest_cache", exist_ok=True) +# with open(".pytest_cache/hoyo_parsed.json", "w", encoding="utf-8") as file: +# file.write(data)