Skip to content

Commit

Permalink
Add Partial ZZZ Support (#200)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
seriaati committed Jul 5, 2024
1 parent dd06c8b commit 21ccb3c
Show file tree
Hide file tree
Showing 12 changed files with 324 additions and 16 deletions.
10 changes: 6 additions & 4 deletions genshin/client/components/chronicle/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion genshin/client/components/chronicle/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Battle chronicle component."""

from . import genshin, honkai, starrail
from . import genshin, honkai, starrail, zzz

__all__ = ["BattleChronicleClient"]

Expand All @@ -9,5 +9,6 @@ class BattleChronicleClient(
genshin.GenshinBattleChronicleClient,
honkai.HonkaiBattleChronicleClient,
starrail.StarRailBattleChronicleClient,
zzz.ZZZBattleChronicleClient,
):
"""Battle chronicle component."""
89 changes: 89 additions & 0 deletions genshin/client/components/chronicle/zzz.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion genshin/client/components/hoyolab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions genshin/client/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"WEBSTATIC_URL",
"WEB_LOGIN_URL",
"YSULOG_URL",
"ZZZ_RECORD_URL",
"Route",
]

Expand Down Expand Up @@ -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/",
Expand Down Expand Up @@ -184,18 +189,21 @@ 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",
),
)

CODE_URL = GameRoute(
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(),
)
Expand Down
1 change: 1 addition & 0 deletions genshin/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
from .hoyolab import *
from .model import *
from .starrail import *
from .zzz import *
4 changes: 4 additions & 0 deletions genshin/models/zzz/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Zenless Zone Zero models."""

from .character import *
from .chronicle import *
57 changes: 57 additions & 0 deletions genshin/models/zzz/character.py
Original file line number Diff line number Diff line change
@@ -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."""
4 changes: 4 additions & 0 deletions genshin/models/zzz/chronicle/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""ZZZ chronicle models."""

from .notes import *
from .stats import *
72 changes: 72 additions & 0 deletions genshin/models/zzz/chronicle/notes.py
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions genshin/models/zzz/chronicle/stats.py
Original file line number Diff line number Diff line change
@@ -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")
Loading

0 comments on commit 21ccb3c

Please sign in to comment.