Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Partial ZZZ Support #200

Merged
merged 11 commits into from
Jul 5, 2024
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
Loading