├── tests ├── __init__.py ├── test_langs.py ├── test_errors.py ├── conftest.py └── test_models │ ├── test_zzz.py │ ├── test_gi.py │ └── test_hsr.py ├── .python-version ├── docs ├── api_reference │ ├── enums.md │ ├── errors.md │ ├── utils.md │ ├── models │ │ ├── gi.md │ │ ├── hsr.md │ │ └── zzz.md │ └── clients │ │ ├── gi.md │ │ ├── hsr.md │ │ ├── zzz.md │ │ └── client.md ├── getting_started.md └── index.md ├── .gitattributes ├── hakushin ├── models │ ├── __init__.py │ ├── gi │ │ ├── __init__.py │ │ ├── new.py │ │ ├── mw.py │ │ ├── weapon.py │ │ ├── artifact.py │ │ ├── stygian.py │ │ └── character.py │ ├── zzz │ │ ├── __init__.py │ │ ├── items.py │ │ ├── new.py │ │ ├── common.py │ │ ├── disc.py │ │ ├── bangboo.py │ │ ├── weapon.py │ │ └── character.py │ ├── hsr │ │ ├── __init__.py │ │ ├── new.py │ │ ├── enemy_groups.py │ │ ├── light_cone.py │ │ ├── relic.py │ │ ├── character.py │ │ ├── monster.py │ │ └── endgame.py │ └── base.py ├── clients │ ├── __init__.py │ ├── base.py │ ├── zzz.py │ └── gi.py ├── __init__.py ├── errors.py ├── enums.py ├── client.py ├── constants.py └── utils.py ├── renovate.json ├── .github ├── workflows │ ├── ruff-lint.yml │ ├── release.yml │ ├── pytest.yml │ ├── bump-version.yml │ └── build-docs.yml └── FUNDING.yml ├── ruff.toml ├── pyproject.toml ├── README.md ├── .gitignore ├── mkdocs.yml └── CLAUDE.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 -------------------------------------------------------------------------------- /docs/api_reference/enums.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ::: hakushin.enums 4 | -------------------------------------------------------------------------------- /docs/api_reference/errors.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ::: hakushin.errors 4 | -------------------------------------------------------------------------------- /docs/api_reference/utils.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ::: hakushin.utils 4 | -------------------------------------------------------------------------------- /docs/api_reference/models/gi.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ::: hakushin.models.gi 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /docs/api_reference/clients/gi.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ::: hakushin.clients.gi 4 | -------------------------------------------------------------------------------- /docs/api_reference/clients/hsr.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ::: hakushin.clients.hsr 4 | -------------------------------------------------------------------------------- /docs/api_reference/clients/zzz.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ::: hakushin.clients.zzz 4 | -------------------------------------------------------------------------------- /docs/api_reference/models/hsr.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ::: hakushin.models.hsr 4 | -------------------------------------------------------------------------------- /docs/api_reference/models/zzz.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ::: hakushin.models.zzz 4 | -------------------------------------------------------------------------------- /hakushin/models/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from . import gi, hsr, zzz 4 | -------------------------------------------------------------------------------- /hakushin/clients/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .gi import * 4 | from .hsr import * 5 | from .zzz import * 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>seriaati/renovate-config" 5 | ] 6 | } -------------------------------------------------------------------------------- /hakushin/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from . import utils 4 | from .client import * 5 | from .constants import * 6 | from .enums import * 7 | from .errors import * 8 | from .models import * 9 | -------------------------------------------------------------------------------- /hakushin/models/gi/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .artifact import * 4 | from .character import * 5 | from .mw import * 6 | from .new import * 7 | from .stygian import * 8 | from .weapon import * 9 | -------------------------------------------------------------------------------- /hakushin/models/zzz/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .bangboo import * 4 | from .character import * 5 | from .disc import * 6 | from .items import * 7 | from .new import * 8 | from .weapon import * 9 | -------------------------------------------------------------------------------- /hakushin/models/hsr/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .character import * 4 | from .endgame import * 5 | from .enemy_groups import * 6 | from .light_cone import * 7 | from .monster import * 8 | from .new import * 9 | from .relic import * 10 | -------------------------------------------------------------------------------- /.github/workflows/ruff-lint.yml: -------------------------------------------------------------------------------- 1 | name: Ruff lint 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | workflow_dispatch: 7 | pull_request: 8 | types: [opened, synchronize, reopened, closed] 9 | 10 | jobs: 11 | ruff-lint: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v6 16 | - uses: astral-sh/ruff-action@v3 -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | create-release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v6 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: Create release 17 | uses: seriaati/create-release@main 18 | with: 19 | pypi_token: ${{ secrets.PYPI_TOKEN }} 20 | tool: "uv" 21 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: Pytest 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | workflow_dispatch: 7 | pull_request: 8 | types: [opened, synchronize] 9 | 10 | jobs: 11 | pytest: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v6 16 | 17 | - name: Install uv 18 | uses: astral-sh/setup-uv@v7 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v6 22 | 23 | - name: Run tests 24 | run: uv run pytest -------------------------------------------------------------------------------- /tests/test_langs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | import hakushin 6 | 7 | 8 | @pytest.mark.parametrize("lang", list(hakushin.Language)) 9 | async def test_langs(lang: hakushin.Language) -> None: 10 | async with hakushin.HakushinAPI(hakushin.Game.GI, lang) as client: 11 | await client.fetch_character_detail("10000098") 12 | 13 | async with hakushin.HakushinAPI(hakushin.Game.HSR, lang) as client: 14 | await client.fetch_character_detail(1309) 15 | 16 | async with hakushin.HakushinAPI(hakushin.Game.ZZZ, lang) as client: 17 | await client.fetch_character_detail(1011) 18 | -------------------------------------------------------------------------------- /docs/api_reference/clients/client.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ## HakushinAPI 4 | 5 | You should use the `HakushinAPI` class to get clients for the different games. 6 | 7 | For methods that each client has, refer to the different game sections in the sidebar. 8 | 9 | ```py 10 | import hakushin 11 | 12 | # Genshin Impact client 13 | async with hakushin.HakushinAPI(hakushin.Game.GI) as client: 14 | ... 15 | 16 | # Honkai Star Rail client 17 | async with hakushin.HakushinAPI(hakushin.Game.HSR) as client: 18 | ... 19 | 20 | # Zenless Zone Zero client 21 | async with hakushin.HakushinAPI(hakushin.Game.ZZZ) as client: 22 | ... 23 | ``` 24 | -------------------------------------------------------------------------------- /.github/workflows/bump-version.yml: -------------------------------------------------------------------------------- 1 | name: Bump Version 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | create-release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v6 12 | with: 13 | fetch-depth: 0 14 | 15 | - name: Update version number 16 | uses: seriaati/update-ver-num@main 17 | with: 18 | github_token: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | - name: Create release 21 | uses: seriaati/create-release@main 22 | with: 23 | pypi_token: ${{ secrets.PYPI_TOKEN }} 24 | tool: "uv" 25 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: seriaati 4 | patreon: seriaati 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: seriaati 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: seria 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /hakushin/models/hsr/new.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pydantic import Field 4 | 5 | from ..base import APIModel 6 | 7 | __all__ = ("New",) 8 | 9 | 10 | class New(APIModel): 11 | """Represent new Honkai Star Rail data. 12 | 13 | Attributes: 14 | character_ids: A list of character IDs. 15 | light_cone_ids: A list of light cone IDs. 16 | relic_set_ids: A list of relic set IDs. 17 | monster_ids: A list of monster IDs. 18 | item_ids: A list of item IDs. 19 | version: The current version. 20 | """ 21 | 22 | character_ids: list[int] = Field(alias="character") 23 | light_cone_ids: list[int] = Field(alias="lightcone") 24 | relic_set_ids: list[int] = Field(alias="relicset") 25 | monster_ids: list[int] = Field(alias="monster") 26 | item_ids: list[int] = Field(alias="item") 27 | version: str 28 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | import hakushin 8 | 9 | if TYPE_CHECKING: 10 | from hakushin.clients.gi import GIClient 11 | from hakushin.clients.hsr import HSRClient 12 | from hakushin.clients.zzz import ZZZClient 13 | 14 | 15 | async def test_gi_not_found(gi_client: GIClient) -> None: 16 | with pytest.raises(hakushin.errors.NotFoundError): 17 | await gi_client.fetch_character_detail("0") 18 | 19 | 20 | async def test_hsr_not_found(hsr_client: HSRClient) -> None: 21 | with pytest.raises(hakushin.errors.NotFoundError): 22 | await hsr_client.fetch_character_detail(0) 23 | 24 | 25 | async def test_zzz_not_found(zzz_client: ZZZClient) -> None: 26 | with pytest.raises(hakushin.errors.NotFoundError): 27 | await zzz_client.fetch_character_detail(0) 28 | -------------------------------------------------------------------------------- /hakushin/models/gi/new.py: -------------------------------------------------------------------------------- 1 | """Genshin Impact new data model.""" 2 | 3 | from __future__ import annotations 4 | 5 | from pydantic import Field 6 | 7 | from ..base import APIModel 8 | 9 | __all__ = ("New",) 10 | 11 | 12 | class New(APIModel): 13 | """Represent new Genshin Impact data. 14 | 15 | Attributes: 16 | character_ids: A list of character IDs. 17 | weapon_ids: A list of weapon IDs. 18 | artifact_set_ids: A list of artifact set IDs. 19 | monster_ids: A list of monster IDs. 20 | item_ids: A list of item IDs. 21 | version: The current version. 22 | """ 23 | 24 | character_ids: list[str | int] = Field(alias="character") 25 | weapon_ids: list[int] = Field(alias="weapon") 26 | artifact_set_ids: list[int] = Field(alias="artifact") 27 | monster_ids: list[int] = Field(alias="monster") 28 | item_ids: list[int] = Field(alias="item") 29 | version: str 30 | -------------------------------------------------------------------------------- /hakushin/models/zzz/items.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Literal 4 | 5 | from pydantic import Field, field_validator 6 | 7 | from ..base import APIModel 8 | 9 | __all__ = ("Item",) 10 | 11 | 12 | class Item(APIModel): 13 | """Represent a ZZZ item. 14 | 15 | Attributes: 16 | icon: Icon URL of the item. 17 | rarity: Rarity of the item. 18 | class_: Class of the item. 19 | name: Name of the item. 20 | id: ID of the item. 21 | """ 22 | 23 | icon: str 24 | rarity: Literal[1, 2, 3, 4, 5] = Field(alias="rank") 25 | class_: int = Field(alias="class") 26 | name: str 27 | id: int 28 | 29 | @field_validator("icon", mode="before") 30 | @classmethod 31 | def __convert_icon(cls, value: str) -> str: 32 | icon = value.rsplit("/", maxsplit=1)[-1].split(".", maxsplit=1)[0] 33 | return f"https://api.hakush.in/zzz/UI/{icon}.webp" 34 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | import hakushin 8 | from hakushin.client import HakushinAPI 9 | 10 | if TYPE_CHECKING: 11 | from collections.abc import AsyncGenerator 12 | 13 | from hakushin.clients.gi import GIClient 14 | from hakushin.clients.hsr import HSRClient 15 | from hakushin.clients.zzz import ZZZClient 16 | 17 | 18 | @pytest.fixture 19 | async def gi_client() -> AsyncGenerator[GIClient]: 20 | async with HakushinAPI(hakushin.Game.GI) as client: 21 | yield client 22 | 23 | 24 | @pytest.fixture 25 | async def hsr_client() -> AsyncGenerator[HSRClient]: 26 | async with HakushinAPI(hakushin.Game.HSR) as client: 27 | yield client 28 | 29 | 30 | @pytest.fixture 31 | async def zzz_client() -> AsyncGenerator[ZZZClient]: 32 | async with HakushinAPI(hakushin.Game.ZZZ) as client: 33 | yield client 34 | -------------------------------------------------------------------------------- /.github/workflows/build-docs.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | permissions: 7 | contents: write 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | 14 | - name: Configure Git Credentials 15 | run: | 16 | git config user.name github-actions[bot] 17 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 18 | 19 | - name: Install uv 20 | uses: astral-sh/setup-uv@v7 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v6 24 | 25 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 26 | - uses: actions/cache@v5 27 | with: 28 | key: mkdocs-material-${{ env.cache_id }} 29 | path: .cache 30 | restore-keys: | 31 | mkdocs-material- 32 | 33 | - run: uv run --group docs mkdocs gh-deploy --force 34 | -------------------------------------------------------------------------------- /hakushin/models/zzz/new.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pydantic import Field 4 | 5 | from ..base import APIModel 6 | 7 | __all__ = ("New",) 8 | 9 | 10 | class New(APIModel): 11 | """Represent new Zenless Zone Zero data. 12 | 13 | Attributes: 14 | character_ids: A list of character IDs. 15 | bangboo_ids: A list of Bangboo IDs. 16 | weapon_ids: A list of weapon IDs. 17 | equipment_ids: A list of equipment IDs. 18 | item_ids: A list of item IDs. 19 | current_version: The current version. 20 | previous_versions: A list of previous versions. 21 | """ 22 | 23 | character_ids: list[int] = Field(alias="character") 24 | bangboo_ids: list[int] = Field(alias="bangboo") 25 | weapon_ids: list[int] = Field(alias="weapon") 26 | equipment_ids: list[int] = Field(alias="equipment") 27 | item_ids: list[int] = Field(alias="item") 28 | current_version: str = Field(alias="version") 29 | previous_versions: list[str] = Field(alias="previous") 30 | -------------------------------------------------------------------------------- /hakushin/models/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Self 4 | 5 | from pydantic import BaseModel, model_validator 6 | 7 | from ..utils import cleanup_text, remove_ruby_tags, replace_device_params 8 | 9 | 10 | class APIModel(BaseModel): 11 | """Provide base functionality for all Hakushin API data models. 12 | 13 | This class extends Pydantic's BaseModel with automatic text cleanup for 14 | common fields like name, description, and story. It handles formatting 15 | by removing ruby tags, cleaning up text, and replacing device parameters. 16 | """ 17 | 18 | @model_validator(mode="after") 19 | def __format_fields(self) -> Self: 20 | for field_name, field_value in self.model_dump().items(): 21 | if field_name in {"name", "description", "story"} and isinstance(field_value, str): 22 | setattr( 23 | self, 24 | field_name, 25 | replace_device_params(remove_ruby_tags(cleanup_text(field_value))), 26 | ) 27 | 28 | return self 29 | -------------------------------------------------------------------------------- /hakushin/errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __all__ = ("HakushinError", "NotFoundError") 4 | 5 | 6 | class HakushinError(Exception): 7 | """Represent the base class for exceptions in the Hakushin API. 8 | 9 | Attributes: 10 | status: HTTP status code of the error. 11 | message: Error message. 12 | url: URL that caused the error. 13 | """ 14 | 15 | def __init__(self, status: int, message: str, url: str) -> None: 16 | super().__init__(message) 17 | self.status = status 18 | self.message = message 19 | self.url = url 20 | 21 | def __str__(self) -> str: 22 | return f"{self.status}: {self.message} ({self.url})" 23 | 24 | def __repr__(self) -> str: 25 | return f"<{self.__class__.__name__} status={self.status} message={self.message!r} url={self.url!r}>" 26 | 27 | 28 | class NotFoundError(HakushinError): 29 | """Raise when the requested resource is not found.""" 30 | 31 | def __init__(self, url: str) -> None: 32 | super().__init__(404, "The requested resource was not found.", url) 33 | -------------------------------------------------------------------------------- /hakushin/models/zzz/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pydantic import Field, computed_field 4 | 5 | from ..base import APIModel 6 | 7 | __all__ = ("ZZZExtraProp", "ZZZMaterial") 8 | 9 | 10 | class ZZZMaterial(APIModel): 11 | """Represent a generic ZZZ material. 12 | 13 | Attributes: 14 | id: ID of the material. 15 | amount: Amount of the material. 16 | """ 17 | 18 | id: int 19 | amount: int 20 | 21 | 22 | class ZZZExtraProp(APIModel): 23 | """Represent a generic ZZZ extra property. 24 | 25 | Attributes: 26 | id: ID of the property. 27 | name: Name of the property. 28 | format: Format of the property. 29 | value: Value of the property. 30 | """ 31 | 32 | id: int = Field(alias="Prop") 33 | name: str = Field(alias="Name") 34 | format: str = Field(alias="Format") 35 | value: int = Field(alias="Value") 36 | 37 | @computed_field 38 | @property 39 | def formatted_value(self) -> str: 40 | """Get the formatted value of this prop.""" 41 | if "%" in self.format: 42 | return f"{self.value / 100:.0%}%" 43 | return str(self.value) 44 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 100 2 | target-version = "py311" 3 | 4 | [lint] 5 | ignore = [ 6 | "E501", 7 | "ANN401", 8 | "ANN003", 9 | "PLR0913", 10 | "PLR2004", 11 | "PLR0917", 12 | "PLR6301", 13 | "ANN002", 14 | ] 15 | preview = true 16 | select = [ 17 | "E", 18 | "W", 19 | "C90", 20 | "F", 21 | "UP", 22 | "B", 23 | "SIM", 24 | "I", 25 | "N", 26 | "TCH", 27 | "ANN", 28 | "ASYNC", 29 | "A", 30 | "C4", 31 | "EM", 32 | "FA", 33 | "ICN", 34 | "G", 35 | "PIE", 36 | "T20", 37 | "ARG", 38 | "ERA", 39 | "LOG", 40 | "PL", 41 | "TRY", 42 | "RUF022", 43 | ] 44 | 45 | [lint.per-file-ignores] 46 | "**/__init__.py" = ["F401", "F403"] 47 | "**/client.py" = ["PLR0904", "A002"] 48 | "**/models/*.py" = ["N805", "TCH"] 49 | "**/tests/*.py" = ["ANN001"] 50 | "test.py" = ["ALL"] 51 | 52 | [lint.isort] 53 | required-imports = ["from __future__ import annotations"] 54 | split-on-trailing-comma = false 55 | 56 | [lint.flake8-type-checking] 57 | quote-annotations = true 58 | runtime-evaluated-base-classes = ["pydantic.BaseModel"] 59 | 60 | [format] 61 | skip-magic-trailing-comma = true 62 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "hakushin-py" 3 | version = "0.5.1" 4 | description = "Async API wrapper for hakush.in written in Python" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | dependencies = [ 8 | "aiohttp-client-cache[sqlite]>=0.12.3", 9 | "aiohttp>=3.10.9", 10 | "loguru>=0.7.2", 11 | "pydantic>=2.9.2", 12 | ] 13 | authors = [{ "name" = "seriaati", "email" = "seria.ati@gmail.com" }] 14 | license = { file = "LICENSE" } 15 | 16 | [project.urls] 17 | Homepage = "https://github.com/seriaati/hakushin-py" 18 | Repository = "https://github.com/seriaati/hakushin-py.git" 19 | Issues = "https://github.com/seriaati/hakushin-py/issues" 20 | 21 | [dependency-groups] 22 | docs = ["mkdocs-material>=9.6.15", "mkdocstrings[python]>=0.29.1", "griffe-pydantic>=1.1.7"] 23 | test = ["pytest>=8.4.1", "pytest-asyncio>=1.1.0"] 24 | 25 | [build-system] 26 | requires = ["uv_build>=0.9.0,<0.10.0"] 27 | build-backend = "uv_build" 28 | 29 | [tool.pytest.ini_options] 30 | asyncio_mode = "auto" 31 | asyncio_default_fixture_loop_scope = "function" 32 | 33 | [tool.pyright] 34 | enableTypeIgnoreComments = false 35 | reportIncompatibleMethodOverride = false 36 | reportIncompatibleVariableOverride = false 37 | reportUnnecessaryComparison = true 38 | reportUnnecessaryContains = true 39 | reportUnnecessaryIsInstance = true 40 | reportUnnecessaryTypeIgnoreComment = true 41 | typeCheckingMode = "standard" 42 | 43 | [tool.uv] 44 | default-groups = "all" 45 | 46 | [tool.uv.build-backend] 47 | module-root = "" 48 | module-name = "hakushin" 49 | -------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Installation 4 | 5 | ```bash 6 | pip install hakushin-py 7 | ``` 8 | 9 | ## Usage 10 | 11 | Every API call goes through the `HakushinAPI` class. You can see more details in the [API Reference](./api_reference/clients/client.md). 12 | 13 | ```py 14 | import hakushin 15 | 16 | async with hakushin.HakushinAPI(hakushin.Game.GI) as api: 17 | characters = await api.fetch_characters() 18 | print(characters) 19 | ``` 20 | 21 | Overall, it's pretty straightforward. You can find all the available methods in the [API Reference](./api_reference/clients/client.md). 22 | 23 | ## Tips 24 | 25 | ### Starting and Closing the Client Properly 26 | 27 | Remember to call `start()` and `close()` or use `async with` to ensure proper connection management. 28 | 29 | ```py 30 | import hakushin 31 | 32 | async with hakushin.HakushinAPI() as api: 33 | ... 34 | 35 | # OR 36 | api = hakushin.HakushinAPI() 37 | await api.start() 38 | ... 39 | await api.close() 40 | ``` 41 | 42 | ### Finding Model Attributes 43 | 44 | Refer to the [Models](./api_reference/models/models.md) section for a list of all available models and their attributes. 45 | 46 | ### Catching Errors 47 | 48 | Refer to the [Errors](./api_reference/errors.md) section for a list of all available exceptions, catch them with `try/except` blocks. 49 | 50 | ```py 51 | import hakushin 52 | 53 | async with hakushin.HakushinAPI(hakushin.Game.GI) as api: 54 | try: 55 | await api.fetch_character(0) 56 | except hakushin.errors.NotFoundError: 57 | print("Character does not exist.") 58 | ``` 59 | -------------------------------------------------------------------------------- /tests/test_models/test_zzz.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from hakushin.clients.zzz import ZZZClient 7 | 8 | 9 | async def test_new(zzz_client: ZZZClient) -> None: 10 | await zzz_client.fetch_new() 11 | 12 | 13 | async def test_characters(zzz_client: ZZZClient) -> None: 14 | await zzz_client.fetch_characters() 15 | 16 | 17 | async def test_character_detail(zzz_client: ZZZClient) -> None: 18 | characters = await zzz_client.fetch_characters() 19 | for ch in characters: 20 | await zzz_client.fetch_character_detail(ch.id) 21 | 22 | 23 | async def test_weapons(zzz_client: ZZZClient) -> None: 24 | await zzz_client.fetch_weapons() 25 | 26 | 27 | async def test_weapon_detail(zzz_client: ZZZClient) -> None: 28 | weapons = await zzz_client.fetch_weapons() 29 | for weapon in weapons: 30 | await zzz_client.fetch_weapon_detail(weapon.id) 31 | 32 | 33 | async def test_bangbooss(zzz_client: ZZZClient) -> None: 34 | await zzz_client.fetch_bangboos() 35 | 36 | 37 | async def test_bangboo_detail(zzz_client: ZZZClient) -> None: 38 | bangboos = await zzz_client.fetch_bangboos() 39 | for bangboo in bangboos: 40 | await zzz_client.fetch_bangboo_detail(bangboo.id) 41 | 42 | 43 | async def test_drive_discs(zzz_client: ZZZClient) -> None: 44 | await zzz_client.fetch_drive_discs() 45 | 46 | 47 | async def test_drive_disc_detail(zzz_client: ZZZClient) -> None: 48 | drive_discs = await zzz_client.fetch_drive_discs() 49 | for drive_disc in drive_discs: 50 | await zzz_client.fetch_drive_disc_detail(drive_disc.id) 51 | 52 | 53 | async def test_items(zzz_client: ZZZClient) -> None: 54 | await zzz_client.fetch_items() 55 | -------------------------------------------------------------------------------- /tests/test_models/test_gi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from hakushin.clients.gi import GIClient 7 | 8 | 9 | async def test_new(gi_client: GIClient) -> None: 10 | await gi_client.fetch_new() 11 | 12 | 13 | async def test_characters(gi_client: GIClient) -> None: 14 | await gi_client.fetch_characters() 15 | 16 | 17 | async def test_character(gi_client: GIClient) -> None: 18 | new = await gi_client.fetch_new() 19 | for chara_id in new.character_ids: 20 | await gi_client.fetch_character_detail(str(chara_id)) 21 | 22 | 23 | async def test_weapons(gi_client: GIClient) -> None: 24 | await gi_client.fetch_weapons() 25 | 26 | 27 | async def test_weapon(gi_client: GIClient) -> None: 28 | gi_new = await gi_client.fetch_new() 29 | for weapon_id in gi_new.weapon_ids: 30 | await gi_client.fetch_weapon_detail(weapon_id) 31 | 32 | 33 | async def test_artifact_sets(gi_client: GIClient) -> None: 34 | await gi_client.fetch_artifact_sets() 35 | 36 | 37 | async def test_artifact_set(gi_client: GIClient) -> None: 38 | gi_new = await gi_client.fetch_new() 39 | for artifact_set_id in gi_new.artifact_set_ids: 40 | await gi_client.fetch_artifact_set_detail(artifact_set_id) 41 | 42 | 43 | async def test_stygians(gi_client: GIClient) -> None: 44 | stygians = await gi_client.fetch_stygians() 45 | for entry in stygians: 46 | await gi_client.fetch_stygian_detail(entry.id) 47 | 48 | 49 | async def test_mw_costumes(gi_client: GIClient) -> None: 50 | await gi_client.fetch_mw_costumes() 51 | 52 | 53 | async def test_mw_costume_sets(gi_client: GIClient) -> None: 54 | await gi_client.fetch_mw_costume_sets() 55 | 56 | 57 | async def test_mw_items(gi_client: GIClient) -> None: 58 | await gi_client.fetch_mw_items() 59 | -------------------------------------------------------------------------------- /hakushin/models/gi/mw.py: -------------------------------------------------------------------------------- 1 | """Miliastra Wonderland costume and item models.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Literal 6 | 7 | from pydantic import Field, field_validator 8 | 9 | from hakushin.enums import MWCostumeBodyType 10 | from hakushin.models.base import APIModel 11 | 12 | __all__ = ("MWCostume", "MWCostumeSet", "MWItem") 13 | 14 | 15 | class BaseMWCostume(APIModel): 16 | """Miliastra Wonderland costume base model""" 17 | 18 | id: int = Field(alias="Id") 19 | name: str = Field(alias="Name") 20 | description: str = Field(alias="Desc") 21 | rarity: Literal[5, 4, 3, 2] | None = Field(alias="Rarity") 22 | icon: str = Field(alias="Icon") 23 | 24 | body_types: list[MWCostumeBodyType] = Field(alias="BodyType") 25 | colors: list[str] = Field(alias="Color") 26 | 27 | @field_validator("rarity", mode="before") 28 | @classmethod 29 | def __convert_rarity(cls, v: Literal["Purple", "Blue", "Green", "None"]) -> int | None: 30 | mapping = {"Orange": 5, "Purple": 4, "Blue": 3, "Green": 2, "None": None} 31 | return mapping[v] 32 | 33 | @field_validator("icon", mode="before") 34 | @classmethod 35 | def __icon_url(cls, v: str) -> str: 36 | return f"https://api.hakush.in/gi/UI/{v}.webp" 37 | 38 | 39 | class MWCostumeSet(BaseMWCostume): 40 | """Miliastra Wonderland costume set""" 41 | 42 | 43 | class MWCostume(BaseMWCostume): 44 | """Miliastra Wonderland costume""" 45 | 46 | slots: list[str] = Field(alias="SlotType") 47 | 48 | 49 | class MWItem(APIModel): 50 | """Miliastra Wonderland item""" 51 | 52 | id: int 53 | name: str = Field(alias="Name") 54 | description: str = Field(alias="Desc") 55 | rarity: Literal[5, 4, 3, 2, 1] | None = Field(alias="Rank") 56 | icon: str | None = Field(alias="Icon") 57 | type: str = Field(alias="ItemType") 58 | sources: list[str] = Field(alias="SourceList") 59 | 60 | @field_validator("icon", mode="before") 61 | @classmethod 62 | def __icon_url(cls, v: str) -> str | None: 63 | if not v: 64 | return None 65 | return f"https://api.hakush.in/gi/UI/{v}.webp" 66 | -------------------------------------------------------------------------------- /tests/test_models/test_hsr.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from hakushin.clients.hsr import HSRClient 7 | 8 | 9 | async def test_new(hsr_client: HSRClient) -> None: 10 | await hsr_client.fetch_new() 11 | 12 | 13 | async def test_characters(hsr_client: HSRClient) -> None: 14 | await hsr_client.fetch_characters() 15 | 16 | 17 | async def test_character(hsr_client: HSRClient) -> None: 18 | new = await hsr_client.fetch_new() 19 | for chara_id in new.character_ids: 20 | await hsr_client.fetch_character_detail(chara_id) 21 | 22 | 23 | async def test_light_cones(hsr_client: HSRClient) -> None: 24 | await hsr_client.fetch_light_cones() 25 | 26 | 27 | async def test_light_cone(hsr_client: HSRClient) -> None: 28 | hsr_new = await hsr_client.fetch_new() 29 | for light_cone_id in hsr_new.light_cone_ids: 30 | await hsr_client.fetch_light_cone_detail(light_cone_id) 31 | 32 | 33 | async def test_relic_set(hsr_client: HSRClient) -> None: 34 | hsr_new = await hsr_client.fetch_new() 35 | for relic_set_id in hsr_new.relic_set_ids: 36 | await hsr_client.fetch_relic_set_detail(relic_set_id) 37 | 38 | 39 | async def test_monsters(hsr_client: HSRClient) -> None: 40 | hsr_new = await hsr_client.fetch_new() 41 | for monster_id in hsr_new.monster_ids: 42 | await hsr_client.fetch_monsters_detail(monster_id) 43 | 44 | 45 | async def test_moc(hsr_client: HSRClient) -> None: 46 | mocs = await hsr_client.fetch_moc() 47 | for moc in mocs: 48 | await hsr_client.fetch_moc_detail(moc.id) 49 | 50 | 51 | async def test_pf(hsr_client: HSRClient) -> None: 52 | pfs = await hsr_client.fetch_pf() 53 | for pf in pfs: 54 | await hsr_client.fetch_pf_detail(pf.id) 55 | 56 | 57 | async def test_apoc(hsr_client: HSRClient) -> None: 58 | apocs = await hsr_client.fetch_apoc() 59 | for apoc in apocs: 60 | await hsr_client.fetch_apoc_detail(apoc.id) 61 | 62 | 63 | async def test_enemy_stat_calculations(hsr_client: HSRClient) -> None: 64 | mocs = await hsr_client.fetch_moc() 65 | for moc in mocs: 66 | await hsr_client.fetch_moc_detail(moc.id, full=True) 67 | 68 | pfs = await hsr_client.fetch_pf() 69 | for pf in pfs: 70 | await hsr_client.fetch_pf_detail(pf.id, full=True) 71 | 72 | apocs = await hsr_client.fetch_apoc() 73 | for apoc in apocs: 74 | await hsr_client.fetch_apoc_detail(apoc.id, full=True) 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hakushin-py 2 | 3 | ## Introduction 4 | 5 | hakushin-py is an async API wrapper for [hakush.in](https://hakush.in/) written in Python. 6 | 7 | hakush.in is a website that displays Genshin Impact, Honkai Star Rail, Zenless Zone Zero, and Wuthering Waves game data. 8 | 9 | Developing something for Hoyoverse games? You might be interested in [other API wrappers](https://github.com/seriaati#api-wrappers) written by me. 10 | 11 | > Note: I am not the developer of hakush.in 12 | 13 | ## Important Note 14 | 15 | This wrapper does not support all endpoints from hakush.in, it is mainly focused on fetching the beta game data. This means I selectively chose the endpoints and API fields that I personally think are useful for theorycrafting. If you want a more complete wrapper for game data, use [ambry.py](https://github.com/seriaati/ambr) and [yatta.py](https://github.com/seriaati/yatta) instead. 16 | 17 | However, **there is an exception for ZZZ**, since Project Ambr and Yatta has no equivalent for ZZZ, this wrapper supports all endpoints for the ZZZ Hakushin API. 18 | 19 | Wuthering Waves support is currently not planned. 20 | 21 | ## Features 22 | 23 | - Fully typed. 24 | - Fully asynchronous by using `aiohttp`, and `asyncio`, suitable for Discord bots. 25 | - Provides direct icon URLs. 26 | - Supports Python 3.11+. 27 | - Supports all game languages. 28 | - Supports persistent caching using SQLite. 29 | - Supports [Pydantic V2](https://github.com/pydantic/pydantic), this also means full autocomplete support. 30 | 31 | ## Installation 32 | 33 | ```bash 34 | # pypi 35 | pip install hakushin-py 36 | 37 | # git 38 | pip install git+https://github.com/seriaati/hakushin-py.git 39 | ``` 40 | 41 | Note: This wrapper changes very rapidly since the Hakushin API also changes very rapidly to keep up with the latest game data changes, so I recommend installing the git version, which has more update to date fixes. 42 | 43 | ## Quick Example 44 | 45 | ```py 46 | import hakushin 47 | import asyncio 48 | 49 | async def main() -> None: 50 | async with hakushin.HakushinAPI(hakushin.Game.GI, hakushin.Language.EN) as client: 51 | await client.fetch_characters() 52 | async with hakushin.HakushinAPI(hakushin.Game.HSR, hakushin.Language.JA) as client: 53 | await client.fetch_light_cones() 54 | async with hakushin.HakushinAPI(hakushin.Game.ZZZ, hakushin.Language.KO) as client: 55 | await client.fetch_weapons() 56 | 57 | asyncio.run(main()) 58 | ``` 59 | 60 | ## Getting Started 61 | 62 | Read the [documentation](https://gh.seria.moe/hakushin-py) to learn more about on how to use this wrapper. 63 | 64 | ## Questions, Issues, Feedback, Contributions 65 | 66 | Whether you want to make any bug reports, feature requests, or contribute to the project, simply open an issue or pull request in this repository. 67 | 68 | If GitHub is not your type, you can find my contact information on [my GitHub profile](https://github.com/seriaati). 69 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # hakushin-py 2 | 3 | ## Introduction 4 | 5 | hakushin-py is an async API wrapper for [hakush.in](https://hakush.in/) written in Python. 6 | 7 | hakush.in is a website that displays Genshin Impact, Honkai Star Rail, Zenless Zone Zero, and Wuthering Waves game data. 8 | 9 | Developing something for Hoyoverse games? You might be interested in [other API wrappers](https://github.com/seriaati#api-wrappers) written by me. 10 | 11 | > Note: I am not the developer of hakush.in 12 | 13 | ## Important Note 14 | 15 | This wrapper does not support all endpoints from hakush.in, it is mainly focused on fetching the beta game data. This means I selectively chose the endpoints and API fields that I personally think are useful for theorycrafting. If you want a more complete wrapper for game data, use [ambry.py](https://github.com/seriaati/ambr) and [yatta.py](https://github.com/seriaati/yatta) instead. 16 | 17 | However, **there is an exception for ZZZ**, since Project Ambr and Yatta has no equivalent for ZZZ, this wrapper supports all endpoints for the ZZZ Hakushin API. 18 | 19 | Wuthering Waves support is currently not planned. 20 | 21 | ## Features 22 | 23 | - Fully typed. 24 | - Fully asynchronous by using `aiohttp`, and `asyncio`, suitable for Discord bots. 25 | - Provides direct icon URLs. 26 | - Supports Python 3.11+. 27 | - Supports all game languages. 28 | - Supports persistent caching using SQLite. 29 | - Supports [Pydantic V2](https://github.com/pydantic/pydantic), this also means full autocomplete support. 30 | 31 | ## Installation 32 | 33 | ```bash 34 | # pypi 35 | pip install hakushin-py 36 | 37 | # git 38 | pip install git+https://github.com/seriaati/hakushin-py.git 39 | ``` 40 | 41 | Note: This wrapper changes very rapidly since the Hakushin API also changes very rapidly to keep up with the latest game data changes, so I recommend installing the git version, which has more update to date fixes. 42 | 43 | ## Quick Example 44 | 45 | ```py 46 | import hakushin 47 | import asyncio 48 | 49 | async def main() -> None: 50 | async with hakushin.HakushinAPI(hakushin.Game.GI, hakushin.Language.EN) as client: 51 | await client.fetch_characters() 52 | async with hakushin.HakushinAPI(hakushin.Game.HSR, hakushin.Language.JA) as client: 53 | await client.fetch_light_cones() 54 | async with hakushin.HakushinAPI(hakushin.Game.ZZZ, hakushin.Language.KO) as client: 55 | await client.fetch_weapons() 56 | 57 | asyncio.run(main()) 58 | ``` 59 | 60 | ## Getting Started 61 | 62 | Read the [documentation](https://gh.seria.moe/hakushin-py) to learn more about on how to use this wrapper. 63 | 64 | ## Questions, Issues, Feedback, Contributions 65 | 66 | Whether you want to make any bug reports, feature requests, or contribute to the project, simply open an issue or pull request in this repository. 67 | 68 | If GitHub is not your type, you can find my contact information on [my GitHub profile](https://github.com/seriaati). 69 | -------------------------------------------------------------------------------- /hakushin/models/hsr/enemy_groups.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from pydantic import Field, field_validator 6 | 7 | from ..base import APIModel 8 | 9 | __all__ = ("EliteGroup", "HardLevelGroup") 10 | 11 | 12 | class EliteGroup(APIModel): 13 | """Represent an EliteGroup in HSR. 14 | 15 | All enemies in HSR follow the following formula for ATK, DEF, HP, and SPD: 16 | Base x BaseModifyRatio x EliteGroup Ratio x HardLevelGroup(Level) Ratio x (1 + HPMultiplier) 17 | 18 | Attributes: 19 | id: The ID of the group. 20 | attack_ratio: The ratio to multiply to get final attack. 21 | defence_ratio: The ratio to multiply to get final defence. 22 | hp_ratio: The ratio to multiply to get final HP. 23 | spd_ratio: The ratio to multiply to get final speed. 24 | """ 25 | 26 | id: int = Field(alias="EliteGroup") 27 | attack_ratio: float = Field(alias="AttackRatio", default=0) 28 | defence_ratio: float = Field(alias="DefenceRatio", default=0) 29 | hp_ratio: float = Field(alias="HPRatio", default=0) 30 | spd_ratio: float = Field(alias="SpeedRatio", default=0) 31 | stance_ratio: float = Field(alias="StanceRatio", default=0) 32 | 33 | @field_validator( 34 | "attack_ratio", "defence_ratio", "hp_ratio", "spd_ratio", "stance_ratio", mode="before" 35 | ) 36 | @classmethod 37 | def handle_missing_fields(cls, value: Any) -> float: 38 | return 0 if value is None else value 39 | 40 | 41 | class HardLevelGroup(APIModel): 42 | """Represent a HardLevelGroup in HSR. 43 | 44 | All enemies in HSR follow the following formula for ATK, DEF, HP, and SPD: 45 | Base x BaseModifyRatio x EliteGroup Ratio x HardLevelGroup(Level) Ratio x (1 + HPMultiplier) 46 | 47 | Attributes: 48 | id: The ID of the group. 49 | level: The level of the enemy. 50 | attack_ratio: The ratio to multiply to get final attack. 51 | defence_ratio: The ratio to multiply to get final defence. 52 | hp_ratio: The ratio to multiply to get final HP. 53 | spd_ratio: The ratio to multiply to get final speed. 54 | """ 55 | 56 | id: int = Field(alias="HardLevelGroup") 57 | level: int = Field(alias="Level") 58 | attack_ratio: float = Field(alias="AttackRatio", default=0) 59 | defence_ratio: float = Field(alias="DefenceRatio", default=0) 60 | hp_ratio: float = Field(alias="HPRatio", default=0) 61 | spd_ratio: float = Field(alias="SpeedRatio", default=0) 62 | stance_ratio: float = Field(alias="StanceRatio", default=0) 63 | status_resistance: float = Field(alias="StatusResistance", default=0) 64 | 65 | @field_validator( 66 | "attack_ratio", 67 | "defence_ratio", 68 | "hp_ratio", 69 | "spd_ratio", 70 | "stance_ratio", 71 | "status_resistance", 72 | mode="before", 73 | ) 74 | @classmethod 75 | def handle_missing_fields(cls, value: Any) -> float: 76 | return 0 if value is None else value 77 | -------------------------------------------------------------------------------- /hakushin/enums.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import IntEnum, StrEnum 4 | 5 | __all__ = ( 6 | "GIElement", 7 | "Game", 8 | "HSRElement", 9 | "HSREndgameType", 10 | "HSRPath", 11 | "Language", 12 | "MWCostumeBodyType", 13 | "ZZZAttackType", 14 | "ZZZElement", 15 | "ZZZSkillType", 16 | "ZZZSpecialty", 17 | ) 18 | 19 | 20 | class Game(StrEnum): 21 | """Represent games supported by the Hakushin API.""" 22 | 23 | GI = "gi" 24 | """Genshin Impact.""" 25 | HSR = "hsr" 26 | """Honkai: Star Rail.""" 27 | ZZZ = "zzz" 28 | """Zenless Zone Zero.""" 29 | 30 | 31 | class Language(StrEnum): 32 | """Represent languages supported by the Hakushin API.""" 33 | 34 | EN = "en" 35 | """English.""" 36 | ZH = "zh" 37 | """Simple Chinese.""" 38 | KO = "ko" 39 | """Korean.""" 40 | JA = "ja" 41 | """Japanese.""" 42 | 43 | 44 | class GIElement(StrEnum): 45 | """Represent a Genshin Impact element.""" 46 | 47 | HYDRO = "Hydro" 48 | PYRO = "Pyro" 49 | CRYO = "Cryo" 50 | ELECTRO = "Electro" 51 | ANEMO = "Anemo" 52 | GEO = "Geo" 53 | DENDRO = "Dendro" 54 | 55 | 56 | class HSRElement(StrEnum): 57 | """Represent an HSR element.""" 58 | 59 | ICE = "Ice" 60 | FIRE = "Fire" 61 | THUNDER = "Thunder" 62 | WIND = "Wind" 63 | PHYSICAL = "Physical" 64 | QUANTUM = "Quantum" 65 | IMAGINARY = "Imaginary" 66 | 67 | 68 | class HSRPath(StrEnum): 69 | """Represent an HSR character path.""" 70 | 71 | PRESERVATION = "Knight" 72 | THE_HUNT = "Rogue" 73 | DESTRUCTION = "Warrior" 74 | ERUDITION = "Mage" 75 | HARMONY = "Shaman" 76 | NIHILITY = "Warlock" 77 | ABUNDANCE = "Priest" 78 | REMEMBRANCE = "Memory" 79 | 80 | 81 | class HSREndgameType(StrEnum): 82 | """Represent an HSR endgame.""" 83 | 84 | MEMORY_OF_CHAOS = "maze" 85 | PURE_FICTION = "story" 86 | APOCALYPTIC_SHADOW = "boss" 87 | 88 | 89 | class ZZZSpecialty(IntEnum): 90 | """Represent a ZZZ character specialty.""" 91 | 92 | ATTACK = 1 93 | STUN = 2 94 | ANOMALY = 3 95 | SUPPORT = 4 96 | DEFENSE = 5 97 | RUPTURE = 6 98 | 99 | 100 | class ZZZElement(IntEnum): 101 | """Represent a ZZZ character element.""" 102 | 103 | PHYSICAL = 200 104 | FIRE = 201 105 | ICE = 202 106 | ELECTRIC = 203 107 | ETHER = 205 108 | 109 | 110 | class ZZZAttackType(IntEnum): 111 | """Represent a ZZZ character attack type.""" 112 | 113 | SLASH = 101 114 | STRIKE = 102 115 | PIERCE = 103 116 | 117 | 118 | class ZZZSkillType(StrEnum): 119 | """Represent a ZZZ character skill type.""" 120 | 121 | BASIC = "Basic" 122 | DODGE = "Dodge" 123 | SPECIAL = "Special" 124 | CHAIN = "Chain" 125 | ASSIST = "Assist" 126 | 127 | 128 | class MWCostumeBodyType(StrEnum): 129 | """Miliastra Wonderland costume body types""" 130 | 131 | GIRL = "BODY_GIRL" 132 | BOY = "BODY_BOY" 133 | -------------------------------------------------------------------------------- /hakushin/models/zzz/disc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pydantic import Field, field_validator 4 | 5 | from ..base import APIModel 6 | 7 | __all__ = ("DriveDisc", "DriveDiscDetail", "DriveDiscInfo") 8 | 9 | 10 | class DriveDiscInfo(APIModel): 11 | """Represent drive disc information in a specific language. 12 | 13 | Contains localized drive disc name and set bonus descriptions 14 | for 2-piece and 4-piece effects. 15 | 16 | Attributes: 17 | name: Drive disc set name. 18 | two_piece_effect: Effect when 2 pieces are equipped. 19 | four_piece_effect: Effect when 4 pieces are equipped. 20 | """ 21 | 22 | name: str 23 | two_piece_effect: str = Field(alias="desc2") 24 | four_piece_effect: str = Field(alias="desc4") 25 | 26 | 27 | class DriveDisc(APIModel): 28 | """Represent a Zenless Zone Zero drive disc set. 29 | 30 | Drive discs are equipment sets that provide bonuses when multiple 31 | pieces are equipped. Contains basic info and localized descriptions. 32 | 33 | Attributes: 34 | id: Unique drive disc set identifier. 35 | icon: Drive disc icon image URL. 36 | name: Set name (may be empty if not in API response). 37 | two_piece_effect: 2-piece effect description (may be empty). 38 | four_piece_effect: 4-piece effect description (may be empty). 39 | en_info: English localization data. 40 | ko_info: Korean localization data. 41 | chs_info: Chinese Simplified localization data. 42 | ja_info: Japanese localization data. 43 | """ 44 | 45 | id: int 46 | icon: str 47 | name: str = Field("") # This field doesn't exist in the API response 48 | two_piece_effect: str = Field("") # Same here 49 | four_piece_effect: str = Field("") # Same here 50 | 51 | en_info: DriveDiscInfo | None = Field(None, alias="EN") 52 | ko_info: DriveDiscInfo | None = Field(None, alias="KO") 53 | chs_info: DriveDiscInfo = Field(alias="CHS") 54 | ja_info: DriveDiscInfo | None = Field(None, alias="JA") 55 | 56 | @field_validator("icon") 57 | @classmethod 58 | def __convert_icon(cls, icon: str) -> str: 59 | filename = icon.rsplit("/", maxsplit=1)[-1].split(".", maxsplit=1)[0] 60 | return f"https://api.hakush.in/zzz/UI/{filename}.webp" 61 | 62 | 63 | class DriveDiscDetail(APIModel): 64 | """Provide comprehensive drive disc set information. 65 | 66 | Contains complete drive disc data including set bonuses, lore, 67 | and visual assets for a specific drive disc set. 68 | 69 | Attributes: 70 | id: Unique drive disc set identifier. 71 | name: Drive disc set name. 72 | two_piece_effect: Effect when 2 pieces are equipped. 73 | four_piece_effect: Effect when 4 pieces are equipped. 74 | story: Background lore and story text. 75 | icon: Drive disc icon image URL. 76 | """ 77 | 78 | id: int = Field(alias="Id") 79 | name: str = Field(alias="Name") 80 | two_piece_effect: str = Field(alias="Desc2") 81 | four_piece_effect: str = Field(alias="Desc4") 82 | story: str = Field(alias="Story") 83 | icon: str = Field(alias="Icon") 84 | 85 | @field_validator("icon") 86 | @classmethod 87 | def __convert_icon(cls, icon: str) -> str: 88 | filename = icon.rsplit("/", maxsplit=1)[-1].split(".", maxsplit=1)[0] 89 | return f"https://api.hakush.in/zzz/UI/{filename}.webp" 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ 153 | test.py 154 | .vscode/ 155 | 156 | # Claude Code 157 | .claude/ -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # Project information 2 | site_name: hakushin-py 3 | site_url: https://gh.seria.moe/hakushin-py 4 | site_author: Seria Ati 5 | site_description: Async API wrapper for hakush.in written in Python 6 | 7 | # Repository 8 | repo_name: seriaati/hakushin-py 9 | repo_url: https://github.com/seriaati/hakushin-py 10 | 11 | # Copyright 12 | copyright: Copyright © 2025 - 2025 Seria Ati 13 | 14 | # Configuration 15 | theme: 16 | name: material 17 | features: 18 | - navigation.tabs 19 | - content.code.copy 20 | palette: 21 | # Palette toggle for automatic mode 22 | - media: "(prefers-color-scheme)" 23 | toggle: 24 | icon: material/brightness-auto 25 | name: Switch to light mode 26 | primary: black 27 | accent: black 28 | 29 | # Palette toggle for light mode 30 | - media: "(prefers-color-scheme: light)" 31 | scheme: default 32 | toggle: 33 | icon: material/brightness-7 34 | name: Switch to dark mode 35 | primary: black 36 | accent: black 37 | 38 | # Palette toggle for dark mode 39 | - media: "(prefers-color-scheme: dark)" 40 | scheme: slate 41 | toggle: 42 | icon: material/brightness-4 43 | name: Switch to system preference 44 | primary: black 45 | accent: black 46 | 47 | plugins: 48 | - search 49 | - mkdocstrings: 50 | handlers: 51 | python: 52 | paths: ["hakushin"] 53 | inventories: 54 | - https://docs.python.org/3/objects.inv 55 | - https://mkdocstrings.github.io/griffe/objects.inv 56 | - https://docs.pydantic.dev/latest/objects.inv 57 | options: 58 | backlinks: tree 59 | docstring_style: google 60 | docstring_options: 61 | ignore_init_summary: true 62 | docstring_section_style: list 63 | parameter_headings: true 64 | relative_crossrefs: true 65 | scoped_crossrefs: true 66 | type_parameter_headings: true 67 | show_root_heading: true 68 | show_root_full_path: false 69 | show_root_toc_entry: false 70 | show_symbol_type_heading: true 71 | show_if_no_docstring: true 72 | show_source: false 73 | show_bases: false 74 | separate_signature: true 75 | signature_crossrefs: true 76 | show_symbol_type_toc: true 77 | show_signature_annotations: true 78 | show_signature_type_parameters: true 79 | summary: true 80 | filters: 81 | - "!^_" 82 | unwrap_annotated: true 83 | extensions: 84 | - griffe_pydantic: 85 | schema: false 86 | 87 | markdown_extensions: 88 | - admonition 89 | - pymdownx.details 90 | - pymdownx.superfences 91 | - pymdownx.highlight: 92 | anchor_linenums: true 93 | line_spans: __span 94 | pygments_lang_class: true 95 | - pymdownx.inlinehilite 96 | - pymdownx.snippets 97 | 98 | nav: 99 | - Home: index.md 100 | - Getting Started: getting_started.md 101 | - API Reference: 102 | - Clients: 103 | - Usage: api_reference/clients/client.md 104 | - Genshin Impact: api_reference/clients/gi.md 105 | - Honkai Star Rail: api_reference/clients/hsr.md 106 | - Zenless Zone Zero: api_reference/clients/zzz.md 107 | - Models: 108 | - Genshin Impact: api_reference/models/gi.md 109 | - Honkai Star Rail: api_reference/models/hsr.md 110 | - Zenless Zone Zero: api_reference/models/zzz.md 111 | - Errors: api_reference/errors.md 112 | - Enums: api_reference/enums.md 113 | - Utils: api_reference/utils.md 114 | -------------------------------------------------------------------------------- /hakushin/models/gi/weapon.py: -------------------------------------------------------------------------------- 1 | """Genshin Impact weapon models.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any, Literal 6 | 7 | from pydantic import Field, field_validator, model_validator 8 | 9 | from ..base import APIModel 10 | 11 | __all__ = ("Weapon", "WeaponDetail", "WeaponProperty", "WeaponRefinement", "WeaponStatModifier") 12 | 13 | 14 | class WeaponProperty(APIModel): 15 | """Represent a weapon's property. 16 | 17 | Attributes: 18 | type: Type of the property. 19 | init_value: Initial value of the property. 20 | growth_type: Growth type of the property. 21 | """ 22 | 23 | type: str = Field(alias="propType") 24 | init_value: float = Field(alias="initValue") 25 | growth_type: str = Field(alias="type") 26 | 27 | 28 | class WeaponStatModifier(APIModel): 29 | """Represent a weapon's stat modifier. 30 | 31 | Attributes: 32 | base: Base value of the stat modifier. 33 | levels: Dictionary of level-based stat modifiers. 34 | """ 35 | 36 | base: float = Field(alias="Base") 37 | levels: dict[str, float] = Field(alias="Levels") 38 | 39 | 40 | class WeaponRefinement(APIModel): 41 | """Represent a weapon's refinement. 42 | 43 | Attributes: 44 | name: Name of the refinement. 45 | description: Description of the refinement. 46 | parameters: List of parameters for the refinement. 47 | """ 48 | 49 | name: str = Field(alias="Name") 50 | description: str = Field(alias="Desc") 51 | parameters: list[float] = Field(alias="ParamList") 52 | 53 | 54 | class WeaponDetail(APIModel): 55 | """Represent a Genshin Impact weapon detail. 56 | 57 | Attributes: 58 | name: Name of the weapon. 59 | description: Description of the weapon. 60 | rarity: Rarity of the weapon. 61 | icon: Icon URL of the weapon. 62 | stat_modifiers: Dictionary of stat modifiers for the weapon. 63 | xp_requirements: Dictionary of XP requirements for the weapon. 64 | ascension: Dictionary of ascension data for the weapon. 65 | refinments: Dictionary of refinements for the weapon. 66 | """ 67 | 68 | name: str = Field(alias="Name") 69 | description: str = Field(alias="Desc") 70 | rarity: Literal[1, 2, 3, 4, 5] = Field(alias="Rarity") 71 | icon: str = Field(alias="Icon") 72 | 73 | stat_modifiers: dict[str, WeaponStatModifier] = Field(alias="StatsModifier") 74 | xp_requirements: dict[str, float] = Field(alias="XPRequirements") 75 | ascension: dict[str, dict[str, float]] = Field(alias="Ascension") 76 | refinments: dict[str, WeaponRefinement] = Field(alias="Refinement") 77 | 78 | @field_validator("icon", mode="before") 79 | @classmethod 80 | def __convert_icon(cls, value: str) -> str: 81 | return f"https://api.hakush.in/gi/UI/{value}.webp" 82 | 83 | 84 | class Weapon(APIModel): 85 | """Represent a Genshin Impact weapon. 86 | 87 | Attributes: 88 | id: ID of the weapon. 89 | icon: Icon URL of the weapon. 90 | rarity: Rarity of the weapon. 91 | description: Description of the weapon. 92 | names: Dictionary of names in different languages. 93 | name: Name of the weapon. 94 | """ 95 | 96 | id: int 97 | icon: str 98 | rarity: Literal[1, 2, 3, 4, 5] = Field(alias="rank") 99 | description: str = Field(alias="desc") 100 | names: dict[Literal["EN", "CHS", "KR", "JP"], str] 101 | name: str = Field("") 102 | 103 | @field_validator("icon", mode="before") 104 | @classmethod 105 | def __convert_icon(cls, value: str) -> str: 106 | return f"https://api.hakush.in/gi/UI/{value}.webp" 107 | 108 | @model_validator(mode="before") 109 | @classmethod 110 | def __transform_names(cls, values: dict[str, Any]) -> dict[str, Any]: 111 | values["names"] = { 112 | "EN": values.pop("EN"), 113 | "CHS": values.pop("CHS"), 114 | "KR": values.pop("KR"), 115 | "JP": values.pop("JP"), 116 | } 117 | return values 118 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Development Commands 6 | 7 | ### Testing 8 | 9 | - Run all tests: `pytest` 10 | - Run specific test file: `pytest tests/test_errors.py` 11 | - Run tests with async support: Tests use `pytest-asyncio` with auto mode configured 12 | 13 | ### Linting and Formatting 14 | 15 | - Run linter: `ruff check` 16 | - Auto-fix linting issues: `ruff check --fix` 17 | - Format code: `ruff format` 18 | - Type checking: `pyright` (configured in pyproject.toml) 19 | 20 | ### Documentation 21 | 22 | - Build docs: `mkdocs build` 23 | - Serve docs locally: `mkdocs serve` 24 | - Docs are hosted at 25 | 26 | ### Writing Docstrings 27 | 28 | 1. For class methods that use "cls" as their first argument, decorate them with the @classmethod decorator. Put the decorator right under the pydantic field_validator or model_validator decorator. 29 | 2. If a class/method/function already contains docstrings but is not in Google style, fix it to Google style. 30 | 3. If a class/method/function already contains docstrings but contains type annotation, remove it. For example, "description (str)" should be changed to "description" because typings are already written in the code as type annotations. 31 | 4. If any public class/method/function does not have docstrings, add Google style docstring for it. Do not add docstrings for private methods and magic methods. 32 | 5. For python files in the "/models" folder, if a class is not exported to "all", add it to "all". 33 | 6. Convert the names of all Pydantic validator methods to dunder methods. For exampe, "convert_icon" or "_convert_icon" should become "__convert_icon". 34 | 7. For docstrings of every object, if they are not in imperative tone, convert them to imperative tone. 35 | 36 | ### Package Management 37 | 38 | - Install dependencies: `uv sync` 39 | - Add new dependency: `uv add ` 40 | - Build package: `uv build` 41 | 42 | ## Architecture Overview 43 | 44 | ### Core Design 45 | 46 | hakushin-py is an async API wrapper for the hakush.in API that fetches game data for Genshin Impact, Honkai Star Rail, and Zenless Zone Zero. The library is built around a factory pattern with game-specific clients. 47 | 48 | ### Key Components 49 | 50 | #### Client Factory (`hakushin/client.py`) 51 | 52 | - `HakushinAPI` class serves as the main entry point 53 | - Uses overloaded `__new__` method to return game-specific clients based on `Game` enum 54 | - Supports GI, HSR, and ZZZ games with proper type hints 55 | 56 | #### Base Client (`hakushin/clients/base.py`) 57 | 58 | - `BaseClient` provides shared functionality for all game clients 59 | - Handles HTTP requests with caching via `aiohttp-client-cache` and SQLite backend 60 | - Implements context manager protocol for session management 61 | - Base URL: `https://api.hakush.in` 62 | 63 | #### Game-Specific Clients 64 | 65 | - `GIClient` (Genshin Impact): `hakushin/clients/gi.py` 66 | - `HSRClient` (Honkai Star Rail): `hakushin/clients/hsr.py` 67 | - `ZZZClient` (Zenless Zone Zero): `hakushin/clients/zzz.py` 68 | 69 | #### Models Architecture 70 | 71 | - All models inherit from `APIModel` in `hakushin/models/base.py` 72 | - `APIModel` extends Pydantic's `BaseModel` with automatic text cleanup 73 | - Game-specific models organized in subdirectories: `gi/`, `hsr/`, `zzz/` 74 | - Models handle text formatting (cleanup, ruby tag removal, device param replacement) 75 | 76 | ### Language Support 77 | 78 | - `Language` enum supports multiple languages 79 | - HSR uses special language mapping via `HSR_API_LANG_MAP` 80 | - Other games use direct language values 81 | 82 | ### Caching Strategy 83 | 84 | - SQLite-based HTTP response caching with configurable TTL (default: 1 hour) 85 | - Cache stored at `./.cache/hakushin/aiohttp-cache.db` by default 86 | - Can be disabled per request when needed 87 | 88 | ### Error Handling 89 | 90 | - Custom exceptions in `hakushin/errors.py` 91 | - `NotFoundError` for 404 responses 92 | - `HakushinError` for other HTTP errors 93 | 94 | ## Testing Structure 95 | 96 | - Game-specific test files: `tests/test_models/test_{game}.py` 97 | - Shared fixtures in `tests/conftest.py` provide pre-configured clients 98 | - Tests use async fixtures with proper context management 99 | -------------------------------------------------------------------------------- /hakushin/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Literal, overload 4 | 5 | from hakushin.enums import Game, Language 6 | 7 | from .clients import GIClient, HSRClient, ZZZClient 8 | 9 | if TYPE_CHECKING: 10 | from aiohttp import ClientSession 11 | 12 | __all__ = ("HakushinAPI",) 13 | 14 | 15 | class HakushinAPI: 16 | """Represent a client to interact with the Hakushin API.""" 17 | 18 | @overload 19 | def __new__( 20 | cls, 21 | game: Literal[Game.GI], 22 | lang: Language = Language.EN, 23 | *, 24 | cache_path: str = "./.cache/hakushin/aiohttp-cache.db", 25 | cache_ttl: int = 3600, 26 | headers: dict[str, Any] | None = None, 27 | debug: bool = False, 28 | session: ClientSession | None = None, 29 | ) -> GIClient: 30 | """Initialize a new GIClient instance. 31 | 32 | Args: 33 | game: The game to initialize the client for. 34 | lang: The language to use for API responses. 35 | cache_path: The path to the cache database. 36 | cache_ttl: The time-to-live for cached responses, in seconds. 37 | headers: Optional custom headers to include in API requests. 38 | debug: Whether to enable debug mode. 39 | session: An optional aiohttp ClientSession to use. 40 | 41 | Returns: 42 | An instance of GIClient. 43 | """ 44 | 45 | @overload 46 | def __new__( 47 | cls, 48 | game: Literal[Game.HSR], 49 | lang: Language = Language.EN, 50 | *, 51 | cache_path: str = "./.cache/hakushin/aiohttp-cache.db", 52 | cache_ttl: int = 3600, 53 | headers: dict[str, Any] | None = None, 54 | debug: bool = False, 55 | session: ClientSession | None = None, 56 | ) -> HSRClient: 57 | """Initialize a new HSRClient instance. 58 | 59 | Args: 60 | game: The game to initialize the client for. 61 | lang: The language to use for API responses. 62 | cache_path: The path to the cache database. 63 | cache_ttl: The time-to-live for cached responses, in seconds. 64 | headers: Optional custom headers to include in API requests. 65 | debug: Whether to enable debug mode. 66 | session: An optional aiohttp ClientSession to use. 67 | 68 | Returns: 69 | An instance of HSRClient. 70 | """ 71 | 72 | @overload 73 | def __new__( 74 | cls, 75 | game: Literal[Game.ZZZ], 76 | lang: Language = Language.EN, 77 | *, 78 | cache_path: str = "./.cache/hakushin/aiohttp-cache.db", 79 | cache_ttl: int = 3600, 80 | headers: dict[str, Any] | None = None, 81 | debug: bool = False, 82 | session: ClientSession | None = None, 83 | ) -> ZZZClient: 84 | """Initialize a new ZZZClient instance. 85 | 86 | Args: 87 | game: The game to initialize the client for. 88 | lang: The language to use for API responses. 89 | cache_path: The path to the cache database. 90 | cache_ttl: The time-to-live for cached responses, in seconds. 91 | headers: Optional custom headers to include in API requests. 92 | debug: Whether to enable debug mode. 93 | session: An optional aiohttp ClientSession to use. 94 | 95 | Returns: 96 | An instance of ZZZClient. 97 | """ 98 | 99 | def __new__( 100 | cls, 101 | game: Game, 102 | lang: Language = Language.EN, 103 | *, 104 | cache_path: str = "./.cache/hakushin/aiohttp-cache.db", 105 | cache_ttl: int = 3600, 106 | headers: dict[str, Any] | None = None, 107 | debug: bool = False, 108 | session: ClientSession | None = None, 109 | ) -> GIClient | HSRClient | ZZZClient: 110 | if game is Game.GI: 111 | return GIClient( 112 | lang, 113 | cache_path=cache_path, 114 | cache_ttl=cache_ttl, 115 | headers=headers, 116 | debug=debug, 117 | session=session, 118 | ) 119 | if game is Game.HSR: 120 | return HSRClient( 121 | lang, 122 | cache_path=cache_path, 123 | cache_ttl=cache_ttl, 124 | headers=headers, 125 | debug=debug, 126 | session=session, 127 | ) 128 | return ZZZClient( 129 | lang, 130 | cache_path=cache_path, 131 | cache_ttl=cache_ttl, 132 | headers=headers, 133 | debug=debug, 134 | session=session, 135 | ) 136 | -------------------------------------------------------------------------------- /hakushin/models/hsr/light_cone.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Literal 4 | 5 | from pydantic import Field, computed_field, field_validator, model_validator 6 | 7 | from ...constants import HSR_LIGHT_CONE_RARITY_MAP 8 | from ...enums import HSRPath 9 | from ..base import APIModel 10 | 11 | __all__ = ("LightCone", "LightConeDetail", "SuperimposeInfo") 12 | 13 | 14 | class SuperimposeInfo(APIModel): 15 | """Represent a light cone's superimpose information. 16 | 17 | Attributes: 18 | name: The name of the superimpose information. 19 | description: The description of the superimpose information. 20 | parameters: A dictionary of parameters for the superimpose information. 21 | """ 22 | 23 | name: str = Field(alias="Name") 24 | description: str = Field(alias="Desc") 25 | parameters: dict[str, list[float]] = Field(alias="Level") 26 | 27 | @model_validator(mode="before") 28 | @classmethod 29 | def __flatten_parameters(cls, values: dict[str, Any]) -> dict[str, Any]: 30 | level = values["Level"] 31 | for key in level: 32 | level[key] = level[key]["ParamList"] 33 | 34 | return values 35 | 36 | 37 | class LightConeDetail(APIModel): 38 | """Represent an HSR light cone detail. 39 | 40 | Attributes: 41 | id: The ID of the light cone. 42 | name: The name of the light cone. 43 | description: The description of the light cone. 44 | path: The path of the light cone. 45 | rarity: The rarity of the light cone. 46 | superimpose_info: Superimpose information for the light cone. 47 | ascension_stats: A list of ascension stats for the light cone. 48 | """ 49 | 50 | id: int = Field(alias="Id") 51 | name: str = Field(alias="Name") 52 | description: str | None = Field(alias="Desc", default=None) 53 | path: HSRPath = Field(alias="BaseType") 54 | rarity: Literal[3, 4, 5] = Field(alias="Rarity") 55 | superimpose_info: SuperimposeInfo = Field(alias="Refinements") 56 | ascension_stats: list[dict[str, Any]] = Field(alias="Stats") 57 | 58 | @field_validator("rarity", mode="before") 59 | @classmethod 60 | def __convert_rarity(cls, value: str) -> Literal[3, 4, 5]: 61 | return HSR_LIGHT_CONE_RARITY_MAP[value] 62 | 63 | @model_validator(mode="before") 64 | @classmethod 65 | def __extract_id(cls, values: dict[str, Any]) -> dict[str, Any]: 66 | values["Id"] = values["Stats"][0]["EquipmentID"] 67 | return values 68 | 69 | @computed_field 70 | @property 71 | def icon(self) -> str: 72 | """Get the light cone's icon URL.""" 73 | return f"https://api.hakush.in/hsr/UI/lightconemediumicon/{self.id}.webp" 74 | 75 | @computed_field 76 | @property 77 | def image(self) -> str: 78 | """Get the light cone's image URL.""" 79 | return self.icon.replace("lightconemediumicon", "lightconemaxfigures") 80 | 81 | 82 | class LightCone(APIModel): 83 | """Represent an HSR light cone. 84 | 85 | Attributes: 86 | id: The ID of the light cone. 87 | rarity: The rarity of the light cone. 88 | description: The description of the light cone. 89 | path: The path of the light cone. 90 | names: A dictionary of names in different languages. 91 | name: The name of the light cone. 92 | """ 93 | 94 | id: int # This field is not present in the API response. 95 | rarity: Literal[3, 4, 5] = Field(alias="rank") 96 | description: str = Field(alias="desc") 97 | path: HSRPath = Field(alias="baseType") 98 | names: dict[Literal["en", "cn", "kr", "jp"], str] 99 | name: str = Field("") # The value of this field is assigned in post processing. 100 | 101 | @computed_field 102 | @property 103 | def icon(self) -> str: 104 | """Get the light cone's icon URL.""" 105 | return f"https://api.hakush.in/hsr/UI/lightconemediumicon/{self.id}.webp" 106 | 107 | @field_validator("description", mode="before") 108 | @classmethod 109 | def __handle_null_value(cls, value: str | None) -> str: 110 | return value or "???" 111 | 112 | @field_validator("icon", mode="before") 113 | @classmethod 114 | def __convert_icon(cls, value: str) -> str: 115 | return f"https://api.hakush.in/hsr/UI/avatarshopicon/{value}.webp" 116 | 117 | @field_validator("rarity", mode="before") 118 | @classmethod 119 | def __convert_rarity(cls, value: str) -> Literal[3, 4, 5]: 120 | return HSR_LIGHT_CONE_RARITY_MAP[value] 121 | 122 | @model_validator(mode="before") 123 | @classmethod 124 | def __transform_names(cls, values: dict[str, Any]) -> dict[str, Any]: 125 | # This is probably the most questionable API design decision I've ever seen. 126 | values["names"] = { 127 | "en": values.pop("en"), 128 | "cn": values.pop("cn"), 129 | "kr": values.pop("kr"), 130 | "jp": values.pop("jp"), 131 | } 132 | return values 133 | -------------------------------------------------------------------------------- /hakushin/clients/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final, Self 4 | 5 | from aiohttp_client_cache.backends.sqlite import SQLiteBackend 6 | from aiohttp_client_cache.session import CachedSession 7 | from loguru import logger 8 | 9 | from ..constants import HSR_API_LANG_MAP 10 | from ..enums import Game, Language 11 | from ..errors import HakushinError, NotFoundError 12 | 13 | if TYPE_CHECKING: 14 | import aiohttp 15 | 16 | 17 | class BaseClient: 18 | """Provide base functionality for interacting with the Hakushin API. 19 | 20 | This class handles HTTP requests, caching, session management, and error handling 21 | for all game-specific clients. It implements the async context manager protocol 22 | for proper resource management. 23 | 24 | Attributes: 25 | BASE_URL: The base URL for the Hakushin API. 26 | lang: The language for API responses. 27 | cache_ttl: Time-to-live for cached responses in seconds. 28 | """ 29 | 30 | BASE_URL: Final[str] = "https://api.hakush.in" 31 | 32 | def __init__( 33 | self, 34 | game: Game, 35 | lang: Language = Language.EN, 36 | *, 37 | cache_path: str = "./.cache/hakushin/aiohttp-cache.db", 38 | cache_ttl: int = 3600, 39 | headers: dict[str, Any] | None = None, 40 | debug: bool = False, 41 | session: aiohttp.ClientSession | None = None, 42 | ) -> None: 43 | """Initialize the Hakushin API client. 44 | 45 | Args: 46 | game: The game to fetch data for. 47 | lang: The language to fetch data in. 48 | cache_path: The path to the cache database. 49 | cache_ttl: The time-to-live for cache entries. 50 | headers: The headers to pass with the request. 51 | debug: Whether to enable debug logging. 52 | session: The client session to use. 53 | """ 54 | self.lang = lang 55 | self.cache_ttl = cache_ttl 56 | 57 | self._game = game 58 | self._using_custom_session = session is not None 59 | self._session = session 60 | self._cache = SQLiteBackend(cache_path, expire_after=cache_ttl) 61 | self._headers = headers or {"User-Agent": "hakuashin-py"} 62 | self._debug = debug 63 | 64 | async def __aenter__(self) -> Self: 65 | await self.start() 66 | return self 67 | 68 | async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001 69 | await self.close() 70 | 71 | async def _request( 72 | self, 73 | endpoint: str, 74 | *, 75 | use_cache: bool, 76 | static: bool = False, 77 | in_data: bool = False, 78 | version: str | None = None, 79 | ) -> dict[str, Any]: 80 | if self._session is None: 81 | msg = "Call `start` before making requests." 82 | raise RuntimeError(msg) 83 | 84 | if static and in_data: 85 | msg = "static and data cannot be True at the same time." 86 | raise RuntimeError(msg) 87 | 88 | game = self._game 89 | lang = HSR_API_LANG_MAP[self.lang] if game is Game.HSR else self.lang.value 90 | 91 | if static: 92 | url = f"{self.BASE_URL}/{game.value}/{endpoint}.json" 93 | elif in_data: 94 | url = f"{self.BASE_URL}/{game.value}/data/{endpoint}.json" 95 | else: 96 | url = f"{self.BASE_URL}/{game.value}/data/{lang}/{endpoint}.json" 97 | 98 | if version: 99 | url = url.replace("/data/", f"/{version}/") 100 | 101 | logger.debug(f"Requesting {url}") 102 | 103 | if not use_cache and isinstance(self._session, CachedSession): 104 | async with self._session.disabled(), self._session.get(url) as resp: 105 | if resp.status != 200: 106 | self._handle_error(resp.status, url) 107 | data = await resp.json() 108 | else: 109 | async with self._session.get(url) as resp: 110 | if resp.status != 200: 111 | self._handle_error(resp.status, url) 112 | data = await resp.json() 113 | 114 | # for HSR Memory of Chaos, returns a list 115 | if isinstance(data, list): 116 | data = {"Level": data} 117 | 118 | return data 119 | 120 | def _handle_error(self, code: int, url: str) -> None: 121 | match code: 122 | case 404: 123 | raise NotFoundError(url) 124 | case _: 125 | raise HakushinError(code, "An error occurred while fetching data.", url) 126 | 127 | async def start(self) -> None: 128 | """Start the client session. 129 | 130 | Initialize the aiohttp session with caching support if not already provided. 131 | This method must be called before making any API requests. 132 | """ 133 | self._session = self._session or CachedSession(headers=self._headers, cache=self._cache) 134 | 135 | async def close(self) -> None: 136 | """Close the client session. 137 | 138 | Clean up the aiohttp session and release resources. This should be called 139 | when done with the client to prevent resource leaks. 140 | """ 141 | if self._session is not None and not self._using_custom_session: 142 | await self._session.close() 143 | -------------------------------------------------------------------------------- /hakushin/models/gi/artifact.py: -------------------------------------------------------------------------------- 1 | """Genshin Impact artifact models.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any, Literal 6 | 7 | from pydantic import Field, field_validator, model_validator 8 | 9 | from ..base import APIModel 10 | 11 | __all__ = ( 12 | "Artifact", 13 | "ArtifactSet", 14 | "ArtifactSetDetail", 15 | "ArtifactSetDetailSetEffects", 16 | "ArtifactSetEffect", 17 | "ArtifactSetEffects", 18 | "SetEffect", 19 | ) 20 | 21 | 22 | class SetEffect(APIModel): 23 | """Represent a set effect. 24 | 25 | Attributes: 26 | id: ID of the set effect. 27 | affix_id: Affix ID of the set effect. 28 | name: Name of the set effect. 29 | description: Description of the set effect. 30 | parameters: List of parameters for the set effect. 31 | """ 32 | 33 | id: int 34 | affix_id: int = Field(alias="affixId") 35 | name: str = Field(alias="Name") 36 | description: str = Field(alias="Desc") 37 | parameters: list[float] = Field(alias="paramList") 38 | 39 | 40 | class ArtifactSetDetailSetEffects(APIModel): 41 | """Represent set effects of an artifact set detail. 42 | 43 | Attributes: 44 | two_piece: Two-piece set effect. 45 | four_piece: Four-piece set effect, if available. 46 | """ 47 | 48 | two_piece: SetEffect 49 | four_piece: SetEffect | None = None 50 | 51 | 52 | class Artifact(APIModel): 53 | """Represent a Genshin Impact artifact. 54 | 55 | Attributes: 56 | icon: Icon URL of the artifact. 57 | name: Name of the artifact. 58 | description: Description of the artifact. 59 | """ 60 | 61 | icon: str = Field(alias="Icon") 62 | name: str = Field(alias="Name") 63 | description: str = Field(alias="Desc") 64 | 65 | @field_validator("icon", mode="before") 66 | @classmethod 67 | def __convert_icon(cls, value: str) -> str: 68 | """Convert the icon path to a full URL.""" 69 | return f"https://api.hakush.in/gi/UI/{value}.webp" 70 | 71 | 72 | class ArtifactSetDetail(APIModel): 73 | """Represent a Genshin Impact artifact set detail. 74 | 75 | Attributes: 76 | id: ID of the artifact set. 77 | icon: Icon URL of the artifact set. 78 | set_effect: Set effects of the artifact set. 79 | parts: Parts of the artifact set. 80 | """ 81 | 82 | id: int = Field(alias="Id") 83 | icon: str = Field(alias="Icon") 84 | set_effect: ArtifactSetDetailSetEffects = Field(alias="Affix") 85 | parts: dict[str, Artifact] = Field(alias="Parts") 86 | 87 | @field_validator("icon", mode="before") 88 | @classmethod 89 | def __convert_icon(cls, value: str) -> str: 90 | """Convert the icon path to a full URL.""" 91 | return f"https://api.hakush.in/gi/UI/{value}.webp" 92 | 93 | @field_validator("set_effect", mode="before") 94 | @classmethod 95 | def __assign_set_effect(cls, value: list[dict[str, Any]]) -> dict[str, Any]: 96 | """Assign the set effect to the appropriate fields.""" 97 | return {"two_piece": value[0], "four_piece": value[1] if len(value) > 1 else None} 98 | 99 | 100 | class ArtifactSetEffect(APIModel): 101 | """Represent an artifact set effect. 102 | 103 | Attributes: 104 | names: Dictionary of names in different languages. 105 | name: Name of the artifact set effect. 106 | descriptions: Dictionary of descriptions in different languages. 107 | description: Description of the artifact set effect. 108 | """ 109 | 110 | names: dict[Literal["EN", "KR", "CHS", "JP"], str] 111 | name: str = Field("") # The value of this field is assigned in post processing. 112 | descriptions: dict[Literal["EN", "KR", "CHS", "JP"], str] = Field(alias="desc") 113 | description: str = Field("") # The value of this field is assigned in post processing. 114 | 115 | @model_validator(mode="before") 116 | @classmethod 117 | def __transform_names(cls, values: dict[str, Any]) -> dict[str, Any]: 118 | """Transform the names field.""" 119 | values["names"] = values.pop("name") 120 | return values 121 | 122 | 123 | class ArtifactSetEffects(APIModel): 124 | """Represent artifact set effects. 125 | 126 | Attributes: 127 | two_piece: Two-piece set effect. 128 | four_piece: Four-piece set effect, if available. 129 | """ 130 | 131 | two_piece: ArtifactSetEffect 132 | four_piece: ArtifactSetEffect | None = None 133 | 134 | 135 | class ArtifactSet(APIModel): 136 | """Represent a Genshin Impact artifact set. 137 | 138 | Attributes: 139 | id: ID of the artifact set. 140 | icon: Icon URL of the artifact set. 141 | rarities: List of rarities for the artifact set. 142 | set_effect: Set effects of the artifact set. 143 | names: Dictionary of names in different languages. 144 | name: Name of the artifact set. 145 | """ 146 | 147 | id: int 148 | icon: str 149 | rarities: list[int] = Field(alias="rank") 150 | set_effect: ArtifactSetEffects = Field(alias="set") 151 | names: dict[Literal["EN", "KR", "CHS", "JP"], str] 152 | name: str = Field("") # The value of this field is assigned in post processing. 153 | 154 | @field_validator("icon", mode="before") 155 | @classmethod 156 | def __convert_icon(cls, value: str) -> str: 157 | """Convert the icon path to a full URL.""" 158 | return f"https://api.hakush.in/gi/UI/{value}.webp" 159 | 160 | @field_validator("set_effect", mode="before") 161 | @classmethod 162 | def __assign_set_effects(cls, value: dict[str, Any]) -> dict[str, Any]: 163 | """Assign the set effects to the appropriate fields.""" 164 | return { 165 | "two_piece": next(iter(value.values())), 166 | "four_piece": list(value.values())[1] if len(value) > 1 else None, 167 | } 168 | 169 | @model_validator(mode="before") 170 | @classmethod 171 | def __extract_names(cls, values: dict[str, Any]) -> dict[str, Any]: 172 | """Extract names from the set effect.""" 173 | values["names"] = next(iter(values["set"].values()))["name"] 174 | return values 175 | -------------------------------------------------------------------------------- /hakushin/models/hsr/relic.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Literal, Self 4 | 5 | from pydantic import Field, computed_field, field_validator, model_validator 6 | 7 | from ...utils import replace_placeholders 8 | from ..base import APIModel 9 | 10 | __all__ = ( 11 | "Relic", 12 | "RelicSet", 13 | "RelicSetDetail", 14 | "RelicSetEffect", 15 | "RelicSetEffects", 16 | "SetDetailSetEffect", 17 | "SetDetailSetEffects", 18 | ) 19 | 20 | 21 | class Relic(APIModel): 22 | """Represent an HSR relic. 23 | 24 | Attributes: 25 | id: The ID of the relic. 26 | name: The name of the relic. 27 | description: The description of the relic. 28 | story: The story of the relic. 29 | """ 30 | 31 | id: int = Field(0) # This field is not present in the API response. 32 | name: str = Field(alias="Name") 33 | description: str | None = Field(alias="Desc", default=None) 34 | story: str | None = Field(alias="Story", default=None) 35 | 36 | @computed_field 37 | @property 38 | def icon(self) -> str: 39 | """Get the relic's icon URL.""" 40 | relic_id = str(self.id)[1:4] 41 | part_id = str(self.id)[-1] 42 | return f"https://api.hakush.in/hsr/UI/relicfigures/IconRelic_{relic_id}_{part_id}.webp" 43 | 44 | 45 | class SetDetailSetEffect(APIModel): 46 | """Represent a relic set detail's set effect. 47 | 48 | Attributes: 49 | description: The description of the set effect. 50 | parameters: A list of parameters for the set effect. 51 | """ 52 | 53 | description: str = Field(alias="Desc") 54 | parameters: list[float] = Field(alias="ParamList") 55 | 56 | @model_validator(mode="after") 57 | def __format_parameters(self) -> Self: 58 | self.description = replace_placeholders(self.description, self.parameters) 59 | return self 60 | 61 | 62 | class SetDetailSetEffects(APIModel): 63 | """Represent relic set detail's set effects. 64 | 65 | Attributes: 66 | two_piece: The two-piece set effect. 67 | four_piece: The four-piece set effect, if available. 68 | """ 69 | 70 | two_piece: SetDetailSetEffect 71 | four_piece: SetDetailSetEffect | None = None 72 | 73 | 74 | class RelicSetDetail(APIModel): 75 | """Represent an HSR relic set detail. 76 | 77 | Attributes: 78 | name: The name of the relic set. 79 | icon: The icon URL of the relic set. 80 | parts: A dictionary of relic parts. 81 | set_effects: The set effects of the relic set. 82 | """ 83 | 84 | name: str = Field(alias="Name") 85 | icon: str = Field(alias="Icon") 86 | parts: dict[str, Relic] = Field(alias="Parts") 87 | set_effects: SetDetailSetEffects = Field(alias="RequireNum") 88 | 89 | @field_validator("icon", mode="before") 90 | @classmethod 91 | def __convert_icon(cls, value: str) -> str: 92 | icon_id = value.rsplit("/", maxsplit=1)[-1].split(".", maxsplit=1)[0] 93 | return f"https://api.hakush.in/hsr/UI/itemfigures/{icon_id}.webp" 94 | 95 | @field_validator("set_effects", mode="before") 96 | @classmethod 97 | def __assign_set_effects(cls, value: dict[str, Any]) -> dict[str, Any]: 98 | return {"two_piece": value["2"], "four_piece": value.get("4")} 99 | 100 | @field_validator("parts", mode="before") 101 | @classmethod 102 | def __convert_parts(cls, value: dict[str, Any]) -> dict[str, Any]: 103 | return {key: Relic(id=int(key), **value[key]) for key in value} 104 | 105 | 106 | class RelicSetEffect(APIModel): 107 | """Represent a relic set effect. 108 | 109 | Attributes: 110 | descriptions: A dictionary of descriptions in different languages. 111 | description: The description of the relic set effect. 112 | parameters: A list of parameters for the relic set effect. 113 | """ 114 | 115 | descriptions: dict[Literal["en", "cn", "kr", "jp"], str] 116 | description: str = Field("") # The value of this field is assigned in post processing. 117 | parameters: list[float] = Field(alias="ParamList") 118 | 119 | @model_validator(mode="before") 120 | @classmethod 121 | def __assign_descriptions(cls, value: dict[str, Any]) -> dict[str, Any]: 122 | value["descriptions"] = { 123 | "en": value.pop("en"), 124 | "cn": value.pop("cn"), 125 | "kr": value.pop("kr"), 126 | "jp": value.pop("jp"), 127 | } 128 | return value 129 | 130 | 131 | class RelicSetEffects(APIModel): 132 | """Represent a relic set's set effects. 133 | 134 | Attributes: 135 | two_piece: The two-piece set effect. 136 | four_piece: The four-piece set effect, if available. 137 | """ 138 | 139 | two_piece: RelicSetEffect 140 | four_piece: RelicSetEffect | None = None 141 | 142 | 143 | class RelicSet(APIModel): 144 | """Represent an HSR relic set. 145 | 146 | Attributes: 147 | id: The ID of the relic set. 148 | icon: The icon URL of the relic set. 149 | names: A dictionary of names in different languages. 150 | name: The name of the relic set. 151 | set_effect: The set effects of the relic set. 152 | """ 153 | 154 | id: int # This field is not present in the API response. 155 | icon: str 156 | names: dict[Literal["en", "cn", "kr", "jp"], str] 157 | name: str = Field("") # The value of this field is assigned in post processing. 158 | set_effect: RelicSetEffects = Field(alias="set") 159 | 160 | @field_validator("icon", mode="before") 161 | @classmethod 162 | def __convert_icon(cls, value: str) -> str: 163 | icon_id = value.rsplit("/", maxsplit=1)[-1].split(".", maxsplit=1)[0] 164 | return f"https://api.hakush.in/hsr/UI/itemfigures/{icon_id}.webp" 165 | 166 | @field_validator("set_effect", mode="before") 167 | @classmethod 168 | def __assign_set_effect(cls, value: dict[str, Any]) -> dict[str, Any]: 169 | return {"two_piece": value["2"], "four_piece": value.get("4")} 170 | 171 | @model_validator(mode="before") 172 | @classmethod 173 | def __assign_names(cls, value: dict[str, Any]) -> dict[str, Any]: 174 | value["names"] = { 175 | "en": value.pop("en"), 176 | "cn": value.pop("cn"), 177 | "kr": value.pop("kr"), 178 | "jp": value.pop("jp"), 179 | } 180 | return value 181 | -------------------------------------------------------------------------------- /hakushin/models/hsr/character.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Literal 4 | 5 | from pydantic import Field, computed_field, field_validator, model_validator 6 | 7 | from ...constants import HSR_CHARA_RARITY_MAP 8 | from ...enums import HSRElement, HSRPath 9 | from ..base import APIModel 10 | 11 | __all__ = ("Character", "CharacterDetail", "Eidolon", "Skill", "SkillLevelInfo") 12 | 13 | 14 | class SkillLevelInfo(APIModel): 15 | """Represent a skill's level information. 16 | 17 | Attributes: 18 | level: The level of the skill. 19 | parameters: A list of parameters for the skill level. 20 | """ 21 | 22 | level: int = Field(alias="Level") 23 | parameters: list[float] = Field(alias="ParamList") 24 | 25 | 26 | class Skill(APIModel): 27 | """Represent a character's skill. 28 | 29 | Attributes: 30 | name: The name of the skill. 31 | description: The description of the skill, if available. 32 | type: The type of the skill, if available. 33 | tag: The tag of the skill. 34 | energy_generation: The energy generation of the skill, if available. 35 | level_info: A dictionary of skill level information. 36 | """ 37 | 38 | name: str = Field(alias="Name") 39 | description: str | None = Field(None, alias="Desc") 40 | type: str | None = Field(None, alias="Type") 41 | tag: str = Field(alias="Tag") 42 | energy_generation: int | None = Field(None, alias="SPBase") 43 | level_info: dict[str, SkillLevelInfo] = Field(alias="Level") 44 | 45 | @computed_field 46 | @property 47 | def max_level(self) -> int: 48 | """Get the skill's maximum level.""" 49 | return max(int(level) for level in self.level_info) 50 | 51 | 52 | class Eidolon(APIModel): 53 | """Represent a character's eidolon. 54 | 55 | Attributes: 56 | id: The ID of the eidolon. 57 | name: The name of the eidolon. 58 | description: The description of the eidolon. 59 | parameters: A list of parameters for the eidolon. 60 | """ 61 | 62 | id: int = Field(alias="Id") 63 | name: str = Field(alias="Name") 64 | description: str = Field(alias="Desc") 65 | parameters: list[float] = Field(alias="ParamList") 66 | 67 | @computed_field 68 | @property 69 | def image(self) -> str: 70 | """Get the eidolon's image URL.""" 71 | character_id = str(self.id)[:4] 72 | eidolon_index = str(self.id)[-1] 73 | return f"https://api.hakush.in/hsr/UI/rank/_dependencies/textures/{character_id}/{character_id}_Rank_{eidolon_index}.webp" 74 | 75 | 76 | class CharacterDetail(APIModel): 77 | """Represent an HSR character detail. 78 | 79 | Attributes: 80 | id: The ID of the character. 81 | name: The name of the character. 82 | description: The description of the character. 83 | rarity: The rarity of the character. 84 | eidolons: A dictionary of eidolons for the character. 85 | skills: A dictionary of skills for the character. 86 | ascension_stats: A dictionary of ascension stats for the character. 87 | """ 88 | 89 | id: int = Field(alias="Id") 90 | name: str = Field(alias="Name") 91 | description: str = Field(alias="Desc") 92 | rarity: Literal[4, 5] = Field(alias="Rarity") 93 | eidolons: dict[str, Eidolon] = Field(alias="Ranks") 94 | skills: dict[str, Skill] = Field(alias="Skills") 95 | ascension_stats: dict[str, dict[str, Any]] = Field(alias="Stats") 96 | 97 | @field_validator("rarity", mode="before") 98 | @classmethod 99 | def __convert_rarity(cls, value: str) -> Literal[4, 5]: 100 | return HSR_CHARA_RARITY_MAP[value] 101 | 102 | @field_validator("description", mode="before") 103 | @classmethod 104 | def __convert_description(cls, value: str | None) -> str: 105 | return value or "" 106 | 107 | @model_validator(mode="before") 108 | @classmethod 109 | def __extract_id(cls, values: dict[str, Any]) -> dict[str, Any]: 110 | values["Id"] = values["Relics"]["AvatarID"] 111 | return values 112 | 113 | @property 114 | def icon(self) -> str: 115 | """Get the character's icon URL.""" 116 | return f"https://api.hakush.in/hsr/UI/avatarshopicon/{self.id}.webp" 117 | 118 | @property 119 | def gacha_art(self) -> str: 120 | """Get the character's gacha art URL.""" 121 | return self.icon.replace("avatarshopicon", "avatardrawcard") 122 | 123 | 124 | class Character(APIModel): 125 | """Represent an HSR character. 126 | 127 | Attributes: 128 | id: The ID of the character. 129 | icon: The icon URL of the character. 130 | rarity: The rarity of the character. 131 | description: The description of the character. 132 | path: The path of the character. 133 | element: The element of the character. 134 | names: A dictionary of names in different languages. 135 | name: The name of the character. 136 | """ 137 | 138 | id: int # This field is not present in the API response. 139 | icon: str 140 | rarity: Literal[4, 5] = Field(alias="rank") 141 | description: str = Field(alias="desc") 142 | path: HSRPath = Field(alias="baseType") 143 | element: HSRElement = Field(alias="damageType") 144 | names: dict[Literal["en", "cn", "kr", "jp"], str] 145 | name: str = Field("") # The value of this field is assigned in post processing. 146 | 147 | @field_validator("icon", mode="before") 148 | @classmethod 149 | def __convert_icon(cls, value: str) -> str: 150 | return f"https://api.hakush.in/hsr/UI/avatarshopicon/{value}.webp" 151 | 152 | @field_validator("rarity", mode="before") 153 | @classmethod 154 | def __convert_rarity(cls, value: str) -> Literal[4, 5]: 155 | return HSR_CHARA_RARITY_MAP[value] 156 | 157 | @field_validator("description", mode="before") 158 | @classmethod 159 | def __convert_description(cls, value: str | None) -> str: 160 | return value or "" 161 | 162 | @model_validator(mode="before") 163 | @classmethod 164 | def __transform_names(cls, values: dict[str, Any]) -> dict[str, Any]: 165 | # This is probably the most questionable API design decision I've ever seen. 166 | values["names"] = { 167 | "en": values.pop("en"), 168 | "cn": values.pop("cn"), 169 | "kr": values.pop("kr"), 170 | "jp": values.pop("jp"), 171 | } 172 | return values 173 | -------------------------------------------------------------------------------- /hakushin/models/zzz/bangboo.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Literal 4 | 5 | from pydantic import Field, field_validator, model_validator 6 | 7 | from ...constants import ZZZ_SA_RARITY_CONVERTER 8 | from ...utils import cleanup_text 9 | from ..base import APIModel 10 | from .common import ZZZExtraProp, ZZZMaterial 11 | 12 | __all__ = ("Bangboo", "BangbooAscension", "BangbooDetail", "BangbooSkill") 13 | 14 | 15 | class Bangboo(APIModel): 16 | """Represent a Zenless Zone Zero bangboo companion. 17 | 18 | Bangboos are AI companions that assist agents in combat and exploration. 19 | They have different rarities, skills, and can be leveled up. 20 | 21 | Attributes: 22 | id: Unique bangboo identifier. 23 | icon: Bangboo icon image URL. 24 | rarity: Bangboo rarity rank (S or A). 25 | code_name: Bangboo code designation. 26 | description: Bangboo description text. 27 | name: Bangboo display name (may be empty). 28 | names: Bangboo names in different languages. 29 | """ 30 | 31 | id: int 32 | icon: str 33 | rarity: Literal["S", "A"] | None = Field(alias="rank") 34 | code_name: str = Field(alias="codename") 35 | description: str = Field(alias="desc") 36 | name: str = Field("") # This field doesn't exist in the API response 37 | names: dict[Literal["EN", "JA", "CHS", "KO"], str] 38 | 39 | @field_validator("icon") 40 | @classmethod 41 | def __convert_icon(cls, value: str) -> str: 42 | value = value.rsplit("/", maxsplit=1)[-1].split(".", maxsplit=1)[0] 43 | return f"https://api.hakush.in/zzz/UI/{value}.webp" 44 | 45 | @field_validator("rarity", mode="before") 46 | @classmethod 47 | def __convert_rarity(cls, value: int | None) -> Literal["S", "A"] | None: 48 | return ZZZ_SA_RARITY_CONVERTER[value] if value is not None else None 49 | 50 | @model_validator(mode="before") 51 | @classmethod 52 | def __pop_names(cls, values: dict[str, Any]) -> dict[str, Any]: 53 | values["names"] = { 54 | "EN": values.pop("EN"), 55 | "KO": values.pop("KO"), 56 | "CHS": values.pop("CHS"), 57 | "JA": values.pop("JA"), 58 | } 59 | return values 60 | 61 | 62 | class BangbooAscension(APIModel): 63 | """Represent bangboo ascension phase data. 64 | 65 | Contains stat bonuses, level requirements, and materials needed 66 | for each bangboo ascension phase. 67 | 68 | Attributes: 69 | max_hp: Maximum HP bonus at this phase. 70 | attack: Attack stat bonus. 71 | defense: Defense stat bonus. 72 | max_level: Maximum level achievable in this phase. 73 | min_level: Minimum level for this phase. 74 | materials: Required materials for ascension. 75 | extra_props: Additional properties gained. 76 | """ 77 | 78 | max_hp: int = Field(alias="HpMax") 79 | attack: int = Field(alias="Attack") 80 | defense: int = Field(alias="Defence") 81 | max_level: int = Field(alias="LevelMax") 82 | min_level: int = Field(alias="LevelMin") 83 | materials: list[ZZZMaterial] = Field(alias="Materials") 84 | extra_props: list[ZZZExtraProp] = Field(alias="Extra") 85 | 86 | @field_validator("extra_props", mode="before") 87 | @classmethod 88 | def __convert_extra_props(cls, value: dict[str, dict[str, Any]]) -> list[ZZZExtraProp]: 89 | return [ZZZExtraProp(**prop) for prop in value.values()] 90 | 91 | @field_validator("materials", mode="before") 92 | @classmethod 93 | def __convert_materials(cls, value: dict[str, int]) -> list[ZZZMaterial]: 94 | return [ZZZMaterial(id=int(id_), amount=amount) for id_, amount in value.items()] 95 | 96 | 97 | class BangbooSkill(APIModel): 98 | """Represent a bangboo skill or ability. 99 | 100 | Each bangboo has multiple skills that provide different effects 101 | and bonuses during gameplay. 102 | 103 | Attributes: 104 | name: Skill name. 105 | description: Skill effect description. 106 | properties: List of skill properties. 107 | parameter: Skill parameter values. 108 | """ 109 | 110 | name: str = Field(alias="Name") 111 | description: str = Field(alias="Desc") 112 | properties: list[str] = Field(alias="Property") 113 | parameter: str = Field(alias="Param") 114 | 115 | @field_validator("description") 116 | @classmethod 117 | def __cleanup_text(cls, value: str) -> str: 118 | return cleanup_text(value) 119 | 120 | 121 | class BangbooDetail(APIModel): 122 | """Provide comprehensive bangboo information and progression data. 123 | 124 | Contains complete bangboo details including stats, ascension data, 125 | skills, and all progression information. 126 | 127 | Attributes: 128 | id: Unique bangboo identifier. 129 | code_name: Bangboo code designation. 130 | name: Bangboo display name. 131 | description: Bangboo description text. 132 | rarity: Bangboo rarity rank (S or A). 133 | icon: Bangboo icon image URL. 134 | stats: Base stats dictionary. 135 | ascensions: Ascension data by level (key starts from 1). 136 | skills: Skills organized by type (A, B, C). 137 | """ 138 | 139 | id: int = Field(alias="Id") 140 | code_name: str = Field(alias="CodeName") 141 | name: str = Field(alias="Name") 142 | description: str = Field(alias="Desc") 143 | rarity: Literal["S", "A"] = Field(alias="Rarity") 144 | icon: str = Field(alias="Icon") 145 | stats: dict[str, float] = Field(alias="Stats") 146 | ascensions: dict[str, BangbooAscension] = Field(alias="Level") 147 | skills: dict[Literal["A", "B", "C"], dict[str, BangbooSkill]] = Field(alias="Skill") 148 | 149 | @field_validator("skills", mode="before") 150 | @classmethod 151 | def __unnest_level( 152 | cls, value: dict[Literal["A", "B", "C"], dict[Literal["Level"], dict[str, Any]]] 153 | ) -> dict[Literal["A", "B", "C"], dict[str, BangbooSkill]]: 154 | return {key: value[key]["Level"] for key in value} 155 | 156 | @field_validator("icon") 157 | @classmethod 158 | def __convert_icon(cls, value: str) -> str: 159 | value = value.rsplit("/", maxsplit=1)[-1].split(".", maxsplit=1)[0] 160 | return f"https://api.hakush.in/zzz/UI/{value}.webp" 161 | 162 | @field_validator("rarity", mode="before") 163 | @classmethod 164 | def __convert_rarity(cls, value: int) -> Literal["S", "A"]: 165 | return ZZZ_SA_RARITY_CONVERTER[value] 166 | -------------------------------------------------------------------------------- /hakushin/models/gi/stygian.py: -------------------------------------------------------------------------------- 1 | """Genshin Impact Stygian Onslaught models.""" 2 | 3 | from __future__ import annotations 4 | 5 | import datetime 6 | import re 7 | from typing import Any, Literal 8 | 9 | from pydantic import Field, field_validator, model_validator 10 | 11 | from hakushin.models.base import APIModel 12 | 13 | __all__ = ( 14 | "Stygian", 15 | "StygianDetail", 16 | "StygianDifficultyConfig", 17 | "StygianEnemy", 18 | "StygianEnemyBuff", 19 | "StygianEnemyRecommendation", 20 | "StygianLevel", 21 | ) 22 | 23 | 24 | class StygianDifficultyConfig(APIModel): 25 | """Configuration for a Stygian Onslaught difficulty. 26 | 27 | Attributes: 28 | level: The difficulty level. 29 | name: The name of the difficulty. 30 | descriptions: A list of descriptions for the difficulty. 31 | """ 32 | 33 | level: int = Field(alias="Level") 34 | name: str = Field(alias="Name") 35 | descriptions: list[str] = Field(alias="DescList") 36 | 37 | 38 | class StygianEnemyBuff(APIModel): 39 | """A buff associated with a Stygian Abyss enemy. 40 | 41 | Attributes: 42 | name: The buff name. 43 | description: The buff description. 44 | """ 45 | 46 | name: str 47 | description: str 48 | 49 | 50 | class StygianEnemyRecommendation(APIModel): 51 | """Recommendations for dealing with a Stygian Abyss enemy. 52 | 53 | Attributes: 54 | recommend: Recommended strategies or characters. 55 | dont_recommend: Strategies or characters to avoid. 56 | """ 57 | 58 | recommend: str 59 | dont_recommend: str | None = None 60 | 61 | @field_validator("recommend", "dont_recommend", mode="after") 62 | @classmethod 63 | def __remove_color_tags(cls, v: str | None) -> str | None: 64 | # Remove and tags 65 | if v is not None: 66 | v = re.sub(r"", "", v) 67 | v = re.sub(r"", "", v) 68 | return v 69 | 70 | 71 | class StygianEnemy(APIModel): 72 | """A Stygian Onslaught enemy. 73 | 74 | Attributes: 75 | id: The enemy ID. 76 | name: The enemy name. 77 | description: The enemy description. 78 | icon: The enemy icon URL. 79 | buffs: A list of buffs associated with the enemy. 80 | recommendation: Recommendations for dealing with the enemy. 81 | """ 82 | 83 | id: int 84 | name: str = Field(alias="Name") 85 | description: str = Field(alias="Desc") 86 | icon: str = Field(alias="Icon") 87 | 88 | buffs: list[StygianEnemyBuff] = Field(default_factory=list) 89 | recommendation: StygianEnemyRecommendation | None = None 90 | 91 | @field_validator("icon", mode="after") 92 | @classmethod 93 | def __process_icon(cls, v: str) -> str: 94 | return f"https://api.hakush.in/gi/UI/{v}.webp" 95 | 96 | @model_validator(mode="before") 97 | @classmethod 98 | def __process_model(cls, v: dict[str, Any]) -> dict[str, Any]: 99 | if "MonsterBuffNameList" in v and "MonsterBuffDetailList" in v: 100 | names = v.pop("MonsterBuffNameList", []) 101 | descs = v.pop("MonsterBuffDetailList", []) 102 | v["buffs"] = [ 103 | StygianEnemyBuff(name=name, description=desc) 104 | for name, desc in zip(names, descs, strict=False) 105 | ] 106 | if "RecommendList" in v: 107 | recs = v.pop("RecommendList", []) 108 | v["recommendation"] = StygianEnemyRecommendation( 109 | recommend=recs[0], dont_recommend=recs[1] if len(recs) > 1 else None 110 | ) 111 | return v 112 | 113 | 114 | class StygianLevel(APIModel): 115 | """A Stygian Onslaught level. 116 | 117 | Attributes: 118 | id: The level ID. 119 | enemy_level: The enemy level. 120 | difficulty_config: The difficulty configuration. 121 | enemies: A mapping of enemy IDs to enemy details. 122 | """ 123 | 124 | id: int 125 | enemy_level: int = Field(alias="MonsterLevel") 126 | difficulty_config: StygianDifficultyConfig = Field(alias="DifficultyConfig") 127 | enemies: dict[int, StygianEnemy] = Field(alias="LevelConfig") 128 | 129 | @field_validator("enemies", mode="before") 130 | @classmethod 131 | def __process_enemies(cls, v: dict[str, Any]) -> dict[int, StygianEnemy]: 132 | return { 133 | int(enemy_id): StygianEnemy(id=int(enemy_id), **enemy_data) 134 | for enemy_id, enemy_data in v.items() 135 | } 136 | 137 | 138 | class StygianDetail(APIModel): 139 | """Details of a Stygian Onslaught entry. 140 | 141 | Attributes: 142 | id: The Stygian ID. 143 | name: The name of the Stygian. 144 | start_at: The start datetime of the Stygian event. 145 | end_at: The end datetime of the Stygian event. 146 | levels: A mapping of level IDs to StygianLevel objects. 147 | """ 148 | 149 | id: int = Field(alias="Id") 150 | name: str = Field(alias="Name") 151 | start_at: datetime.datetime = Field(alias="BeginTime") 152 | end_at: datetime.datetime = Field(alias="EndTime") 153 | levels: dict[int, StygianLevel] = Field(alias="Level") 154 | 155 | @field_validator("levels", mode="before") 156 | @classmethod 157 | def __process_levels(cls, v: dict[str, Any]) -> dict[int, StygianLevel]: 158 | return { 159 | int(level_id): StygianLevel(id=int(level_id), **level_data) 160 | for level_id, level_data in v.items() 161 | } 162 | 163 | 164 | class Stygian(APIModel): 165 | """A Stygian Onslaught entry. 166 | 167 | Attributes: 168 | id: The Stygian ID. 169 | names: A dictionary of names in different languages. 170 | beta_start_at: The start datetime of the beta period, if applicable. 171 | beta_end_at: The end datetime of the beta period, if applicable. 172 | live_start_at: The start datetime of the live period, if applicable. 173 | live_end_at: The end datetime of the live period, if applicable. 174 | name: The name of the Stygian (added in post-processing). 175 | """ 176 | 177 | id: int 178 | names: dict[Literal["EN", "CHS", "KR", "JP"], str] 179 | 180 | beta_start_at: datetime.datetime | None = Field(default=None, alias="begin") 181 | beta_end_at: datetime.datetime | None = Field(default=None, alias="begin") 182 | live_start_at: datetime.datetime | None = Field(default=None, alias="live_begin") 183 | live_end_at: datetime.datetime | None = Field(default=None, alias="live_end") 184 | 185 | # Added in post-processing 186 | name: str = "" 187 | 188 | @model_validator(mode="before") 189 | @classmethod 190 | def __transform_names(cls, values: dict[str, Any]) -> dict[str, Any]: 191 | values["names"] = { 192 | "EN": values.pop("EN"), 193 | "CHS": values.pop("CHS"), 194 | "KR": values.pop("KR"), 195 | "JP": values.pop("JP"), 196 | } 197 | return values 198 | -------------------------------------------------------------------------------- /hakushin/models/hsr/monster.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Literal 4 | 5 | from pydantic import Field, field_validator, model_validator 6 | 7 | from ...enums import HSRElement 8 | from ..base import APIModel 9 | 10 | __all__ = ("ChildMonster", "DamageTypeResistance", "HSREnemySkill", "Monster", "MonsterDetail") 11 | 12 | 13 | class HSREnemySkill(APIModel): 14 | """Represents an enemy skill's information. 15 | 16 | Attributes: 17 | id: The id of the skill. 18 | name: The name of the skill 19 | desc: The description of what the skill does 20 | damage_type: The type of damage the skill does (out of the HSRElements or None) 21 | """ 22 | 23 | id: int = Field(alias="Id") 24 | name: str = Field(alias="SkillName", default="") 25 | desc: str = Field(alias="SkillDesc", default="") 26 | damage_type: HSRElement | None = Field(alias="DamageType", default=None) 27 | 28 | @field_validator("name", "desc", mode="before") 29 | @classmethod 30 | def default_empty_string(cls, value: str | None) -> str: 31 | return value if isinstance(value, str) else "" 32 | 33 | @field_validator("damage_type", mode="before") 34 | @classmethod 35 | def empty_string_to_none(cls: type[HSREnemySkill], v: str | None) -> str | None: 36 | if not v: 37 | return None 38 | return v 39 | 40 | 41 | class DamageTypeResistance(APIModel): 42 | """Represent the damage resistance of an enemy in HSR. 43 | 44 | Attributes: 45 | element: The element of the resistance. 46 | value: The value of the resistance. 47 | """ 48 | 49 | element: HSRElement = Field(alias="DamageType") 50 | value: float = Field(alias="Value") 51 | 52 | 53 | class ChildMonster(APIModel): 54 | """Represent the specific details of a monster type. 55 | 56 | Attributes: 57 | id: The ID of this instance of the monster. 58 | attack_modify_ratio: Multiplier applied to the monster's base attack. 59 | defence_modify_ratio: Multiplier applied to the monster's base defense. 60 | hp_modify_ratio: Multiplier applied to the monster's base HP. 61 | spd_modify_ratio: Multiplier applied to the monster's base speed. 62 | spd_modify_value: An optional fixed value added to the monster's speed (can override base speed). 63 | stance_modify_value: Multiplier applied to the monster's base toughness. 64 | stance_weak_list: List of elemental types that this monster is weak to (for toughness damage). 65 | damage_type_resistances: List of resistances the monster has against specific elements. 66 | skills: List of skills this monster instance can use in combat. 67 | """ 68 | 69 | id: int = Field(alias="Id") 70 | attack_modify_ratio: float = Field(alias="AttackModifyRatio", default=1) 71 | defence_modify_ratio: float = Field(alias="DefenceModifyRatio", default=1) 72 | hp_modify_ratio: float = Field(alias="HPModifyRatio", default=1) 73 | spd_modify_ratio: float = Field(alias="SpeedModifyRatio", default=1) 74 | spd_modify_value: float | None = Field(alias="SpeedModifyValue", default=None) 75 | stance_modify_value: float = Field(alias="StanceModifyRatio", default=1) 76 | 77 | stance_weak_list: list[HSRElement] = Field(alias="StanceWeakList") 78 | damage_type_resistances: list[DamageTypeResistance] = Field(alias="DamageTypeResistance") 79 | skills: list[HSREnemySkill] = Field(alias="SkillList") 80 | 81 | 82 | class MonsterDetail(APIModel): 83 | """Represent an enemy monster with details in HSR. 84 | 85 | Attributes: 86 | id: Unique identifier for the monster. 87 | name: Name of the monster. 88 | description: The description of the monster. 89 | attack_base: The base attack stat for this monster. 90 | defence_base: The base defense stat. 91 | hp_base: The base HP value. 92 | spd_base: The base speed stat. 93 | stance_base: The base toughness value. 94 | status_resistance_base: The base status resistance (used for debuff resist chance). 95 | monster_types: A list of `ChildMonster` variants derived from this monster. 96 | """ 97 | 98 | id: int = Field(alias="Id") 99 | rank: str = Field(alias="Rank") 100 | name: str = Field(alias="Name", default="") 101 | description: str = Field(alias="Desc", default="") 102 | attack_base: float = Field(alias="AttackBase", default=0) 103 | defence_base: float = Field(alias="DefenceBase", default=0) 104 | hp_base: float = Field(alias="HPBase", default=0) 105 | spd_base: float = Field(alias="SpeedBase", default=0) 106 | stance_base: float = Field(alias="StanceBase", default=0) 107 | status_resistance_base: float = Field(alias="StatusResistanceBase", default=0) 108 | 109 | monster_types: list[ChildMonster] = Field(alias="Child") 110 | 111 | @field_validator( 112 | "attack_base", 113 | "defence_base", 114 | "hp_base", 115 | "spd_base", 116 | "stance_base", 117 | "status_resistance_base", 118 | mode="before", 119 | ) 120 | @classmethod 121 | def default_zero_if_none(cls, value: int | float | None) -> int | float: 122 | return value if isinstance(value, (int, float)) else 0 123 | 124 | @property 125 | def icon(self) -> str: 126 | """Get the monster's icon URL.""" 127 | return f"https://api.hakush.in/hsr/UI/monsterfigure/Monster_{self.id}.webp" 128 | 129 | 130 | class Monster(APIModel): 131 | """ 132 | Represent an enemy monster in HSR. 133 | 134 | Attributes: 135 | id: The ID of the monster. 136 | icon: The icon URL of the monster. 137 | children: A list of child monster IDs associated with this monster. 138 | weaknesses: List of elements that this monster is weak to (used for breaking toughness). 139 | names: A dictionary of names in different languages. 140 | description: The English description of the monster. 141 | name: The English name of the monster. 142 | """ 143 | 144 | id: int # This field is not present in the API response. 145 | icon: str 146 | children: list[int] = Field(alias="child") 147 | weaknesses: list[HSRElement] = Field(alias="weak") 148 | names: dict[Literal["en", "cn", "kr", "jp"], str] 149 | description: str = Field(alias="desc") 150 | name: str = Field("") # The value of this field is assigned in post processing. 151 | 152 | @field_validator("icon", mode="before") 153 | @classmethod 154 | def __convert_icon(cls, value: str) -> str: 155 | filename = value.rsplit("/", 1)[-1] 156 | filename = filename.replace(".png", ".webp") 157 | return f"https://api.hakush.in/hsr/UI/monsterfigure/{filename}" 158 | 159 | @model_validator(mode="before") 160 | @classmethod 161 | def __transform_names(cls, values: dict[str, Any]) -> dict[str, Any]: 162 | # This is probably the most questionable API design decision I've ever seen. 163 | values["names"] = { 164 | "en": values.pop("en"), 165 | "cn": values.pop("cn"), 166 | "kr": values.pop("kr"), 167 | "jp": values.pop("jp"), 168 | } 169 | return values 170 | -------------------------------------------------------------------------------- /hakushin/models/zzz/weapon.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Literal 4 | 5 | from pydantic import Field, computed_field, field_validator, model_validator 6 | 7 | from ...constants import ZZZ_SAB_RARITY_CONVERTER 8 | from ...enums import ZZZSpecialty 9 | from ...utils import cleanup_text 10 | from ..base import APIModel 11 | 12 | __all__ = ( 13 | "Weapon", 14 | "WeaponDetail", 15 | "WeaponLevel", 16 | "WeaponProp", 17 | "WeaponRefinement", 18 | "WeaponStar", 19 | "WeaponType", 20 | ) 21 | 22 | 23 | class Weapon(APIModel): 24 | """Represent a ZZZ weapon (w-engine). 25 | 26 | Attributes: 27 | id: ID of the weapon. 28 | icon: Icon URL of the weapon. 29 | name: Name of the weapon. 30 | names: Dictionary of names in different languages. 31 | specialty: Specialty of the weapon. 32 | rarity: Rarity of the weapon. 33 | """ 34 | 35 | id: int 36 | icon: str 37 | name: str = Field("") # This field doesn't exist in the API response 38 | names: dict[Literal["EN", "JA", "CHS", "KO"], str] 39 | specialty: ZZZSpecialty = Field(alias="type") 40 | rarity: Literal["S", "A", "B"] = Field(alias="rank") 41 | 42 | @field_validator("rarity", mode="before") 43 | @classmethod 44 | def __convert_rarity(cls, value: int | None) -> Literal["S", "A", "B"] | None: 45 | """Convert the rarity value to a string literal.""" 46 | return ZZZ_SAB_RARITY_CONVERTER[value] if value is not None else None 47 | 48 | @field_validator("icon") 49 | @classmethod 50 | def __convert_icon(cls, value: str) -> str: 51 | """Convert the icon path to a full URL.""" 52 | return f"https://api.hakush.in/zzz/UI/{value}.webp" 53 | 54 | @model_validator(mode="before") 55 | @classmethod 56 | def __pop_names(cls, values: dict[str, Any]) -> dict[str, Any]: 57 | """Pop names from the values and assign them to the 'names' field.""" 58 | values["names"] = { 59 | "EN": values.pop("EN"), 60 | "KO": values.pop("KO"), 61 | "CHS": values.pop("CHS"), 62 | "JA": values.pop("JA"), 63 | } 64 | return values 65 | 66 | 67 | class WeaponType(APIModel): 68 | """Represent a weapon type classification. 69 | 70 | Defines the specialty and name of weapon types that characters 71 | can use in Zenless Zone Zero. 72 | 73 | Attributes: 74 | type: Weapon specialty classification. 75 | name: Human-readable weapon type name. 76 | """ 77 | 78 | type: ZZZSpecialty 79 | name: str 80 | 81 | 82 | class WeaponProp(APIModel): 83 | """Represent a weapon stat property. 84 | 85 | Contains stat information including names, formatting, and values 86 | for weapon statistics like attack, crit rate, etc. 87 | 88 | Attributes: 89 | name: Primary property name. 90 | name2: Secondary property name. 91 | format: Value formatting specification. 92 | value: Numerical property value. 93 | """ 94 | 95 | name: str = Field(alias="Name") 96 | name2: str = Field(alias="Name2") 97 | format: str = Field(alias="Format") 98 | value: float = Field(alias="Value") 99 | 100 | @computed_field 101 | @property 102 | def formatted_value(self) -> str: 103 | """Get the formatted value of this property.""" 104 | if "%" in self.format: 105 | return f"{self.value / 100:.0%}%" 106 | return str(round(self.value)) 107 | 108 | 109 | class WeaponLevel(APIModel): 110 | """Represent weapon leveling information. 111 | 112 | Contains experience requirements and stat scaling rates 113 | for weapon level progression. 114 | 115 | Attributes: 116 | exp: Experience points required. 117 | rate: Primary stat scaling rate. 118 | rate2: Secondary stat scaling rate. 119 | """ 120 | 121 | exp: int = Field(alias="Exp") 122 | rate: int = Field(alias="Rate") 123 | rate2: int = Field(alias="Rate2") 124 | 125 | 126 | class WeaponStar(APIModel): 127 | """Represent weapon star ranking information. 128 | 129 | Contains star rating data and randomization rates 130 | for weapon rarity and quality assessment. 131 | 132 | Attributes: 133 | star_rate: Star rating value. 134 | rand_rate: Randomization rate factor. 135 | """ 136 | 137 | star_rate: int = Field(alias="StarRate") 138 | rand_rate: int = Field(alias="RandRate") 139 | 140 | 141 | class WeaponRefinement(APIModel): 142 | """Represent weapon refinement level data. 143 | 144 | Contains information about weapon refinement stages 145 | and their associated names or effects. 146 | 147 | Attributes: 148 | name: Refinement level name or description. 149 | """ 150 | 151 | name: str = Field(alias="Name") 152 | description: str = Field(alias="Desc") 153 | 154 | @field_validator("description") 155 | @classmethod 156 | def __cleanup_text(cls, value: str) -> str: 157 | """Clean up the description text.""" 158 | return cleanup_text(value) 159 | 160 | 161 | class WeaponDetail(APIModel): 162 | """Represent detailed information about a ZZZ weapon. 163 | 164 | Attributes: 165 | id: ID of the weapon. 166 | code_name: Code name of the weapon. 167 | name: Name of the weapon. 168 | description: Description of the weapon. 169 | description2: Second description of the weapon. 170 | short_description: Short description of the weapon. 171 | rarity: Rarity of the weapon. 172 | icon: Icon URL of the weapon. 173 | type: Type of the weapon. 174 | base_property: Base property of the weapon. 175 | rand_property: Random property of the weapon. 176 | levels: Dictionary of weapon levels. 177 | stars: Dictionary of weapon stars. 178 | materials: Materials required for the weapon. 179 | refinements: Dictionary of weapon refinements. 180 | """ 181 | 182 | id: int = Field(alias="Id") 183 | code_name: str = Field(alias="CodeName") 184 | name: str = Field(alias="Name") 185 | description: str = Field(alias="Desc") 186 | description2: str = Field(alias="Desc2") 187 | short_description: str = Field(alias="Desc3") 188 | rarity: Literal["S", "A", "B"] | None = Field(alias="Rarity") 189 | icon: str = Field(alias="Icon") 190 | type: WeaponType = Field(alias="WeaponType") 191 | base_property: WeaponProp = Field(alias="BaseProperty") 192 | rand_property: WeaponProp = Field(alias="RandProperty") 193 | levels: dict[str, WeaponLevel] = Field(alias="Level") 194 | stars: dict[str, WeaponStar] = Field(alias="Stars") 195 | materials: str = Field(alias="Materials") 196 | refinements: dict[str, WeaponRefinement] = Field(alias="Talents") # {'1': ..., '2': ...} 197 | """Dictionary of refinements, key starts from 1.""" 198 | 199 | @field_validator("type", mode="before") 200 | @classmethod 201 | def __convert_type(cls, value: dict[str, str]) -> WeaponType: 202 | """Convert the weapon type data to a WeaponType object.""" 203 | first_item = next(iter(value.items())) 204 | return WeaponType(type=ZZZSpecialty(int(first_item[0])), name=first_item[1]) 205 | 206 | @field_validator("icon") 207 | @classmethod 208 | def __convert_icon(cls, value: str) -> str: 209 | """Convert the icon path to a full URL.""" 210 | value = value.rsplit("/", maxsplit=1)[-1].split(".", maxsplit=1)[0] 211 | return f"https://api.hakush.in/zzz/UI/{value}.webp" 212 | 213 | @field_validator("rarity", mode="before") 214 | @classmethod 215 | def __convert_rarity(cls, value: int | None) -> Literal["S", "A", "B"] | None: 216 | """Convert the rarity value to a string literal.""" 217 | return ZZZ_SAB_RARITY_CONVERTER[value] if value is not None else None 218 | -------------------------------------------------------------------------------- /hakushin/clients/zzz.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from ..constants import ZZZ_LANG_MAP 6 | from ..enums import Game, Language 7 | from ..models import zzz 8 | from .base import BaseClient 9 | 10 | if TYPE_CHECKING: 11 | from collections.abc import Sequence 12 | 13 | from aiohttp import ClientSession 14 | 15 | __all__ = ("ZZZClient",) 16 | 17 | 18 | class ZZZClient(BaseClient): 19 | """Client to interact with the Hakushin Zenless Zone Zero API.""" 20 | 21 | def __init__( 22 | self, 23 | lang: Language = Language.EN, 24 | *, 25 | cache_path: str = "./.cache/hakushin/aiohttp-cache.db", 26 | cache_ttl: int = 3600, 27 | headers: dict[str, Any] | None = None, 28 | debug: bool = False, 29 | session: ClientSession | None = None, 30 | ) -> None: 31 | super().__init__( 32 | Game.ZZZ, 33 | lang, 34 | cache_path=cache_path, 35 | cache_ttl=cache_ttl, 36 | headers=headers, 37 | debug=debug, 38 | session=session, 39 | ) 40 | 41 | async def fetch_new(self, *, use_cache: bool = True) -> zzz.New: 42 | """Fetch the ID of beta items in Zenless Zone Zero. 43 | 44 | Args: 45 | use_cache: Whether to use the response cache. 46 | 47 | Returns: 48 | A model representing the new items. 49 | """ 50 | data = await self._request("new", use_cache=use_cache, static=True) 51 | return zzz.New(**data) 52 | 53 | async def fetch_characters( 54 | self, *, version: str | None = None, use_cache: bool = True 55 | ) -> list[zzz.Character]: 56 | """Fetch all Zenless Zone Zero characters. 57 | 58 | Args: 59 | use_cache: Whether to use the response cache. 60 | 61 | Returns: 62 | A list of character objects. 63 | """ 64 | data = await self._request("character", use_cache=use_cache, in_data=True, version=version) 65 | 66 | characters = [ 67 | zzz.Character(id=int(char_id), **char) 68 | for char_id, char in data.items() 69 | if char_id not in {"2011", "2021"} # Exclude MCs 70 | ] 71 | for char in characters: 72 | char.name = char.names[ZZZ_LANG_MAP[self.lang]] 73 | 74 | return characters 75 | 76 | async def fetch_character_detail( 77 | self, character_id: int, *, version: str | None = None, use_cache: bool = True 78 | ) -> zzz.CharacterDetail: 79 | """Fetch the details of a Zenless Zone Zero character. 80 | 81 | Args: 82 | character_id: The character ID. 83 | version: The game version to fetch data for. 84 | use_cache: Whether to use the response cache. 85 | 86 | Returns: 87 | The character details object. 88 | """ 89 | endpoint = f"character/{character_id}" 90 | data = await self._request(endpoint, use_cache=use_cache, version=version) 91 | return zzz.CharacterDetail(**data) 92 | 93 | async def fetch_weapons( 94 | self, *, use_cache: bool = True, version: str | None = None 95 | ) -> list[zzz.Weapon]: 96 | """Fetch all Zenless Zone Zero weapons (w-engines). 97 | 98 | Args: 99 | use_cache: Whether to use the response cache. 100 | version: The game version to fetch data for. 101 | Returns: 102 | A list of weapon objects. 103 | """ 104 | data = await self._request("weapon", use_cache=use_cache, in_data=True, version=version) 105 | 106 | weapons = [zzz.Weapon(id=int(weapon_id), **weapon) for weapon_id, weapon in data.items()] 107 | for weapon in weapons: 108 | weapon.name = weapon.names[ZZZ_LANG_MAP[self.lang]] 109 | 110 | return weapons 111 | 112 | async def fetch_weapon_detail( 113 | self, weapon_id: int, *, use_cache: bool = True 114 | ) -> zzz.WeaponDetail: 115 | """Fetch the details of a Zenless Zone Zero weapon. 116 | 117 | Args: 118 | weapon_id: The weapon ID. 119 | use_cache: Whether to use the response cache. 120 | 121 | Returns: 122 | The weapon details object. 123 | """ 124 | endpoint = f"weapon/{weapon_id}" 125 | data = await self._request(endpoint, use_cache=use_cache) 126 | return zzz.WeaponDetail(**data) 127 | 128 | async def fetch_bangboos( 129 | self, *, use_cache: bool = True, version: str | None = None 130 | ) -> list[zzz.Bangboo]: 131 | """Fetch all Zenless Zone Zero bangboos. 132 | 133 | Args: 134 | use_cache: Whether to use the response cache. 135 | version: The game version to fetch data for. 136 | Returns: 137 | A list of bangboo objects. 138 | """ 139 | data = await self._request("bangboo", use_cache=use_cache, in_data=True, version=version) 140 | bangboos = [ 141 | zzz.Bangboo(id=int(bangboo_id), **bangboo) for bangboo_id, bangboo in data.items() 142 | ] 143 | for bangboo in bangboos: 144 | bangboo.name = bangboo.names[ZZZ_LANG_MAP[self.lang]] 145 | return bangboos 146 | 147 | async def fetch_bangboo_detail( 148 | self, bangboo_id: int, *, use_cache: bool = True 149 | ) -> zzz.BangbooDetail: 150 | """Fetch the details of a Zenless Zone Zero bangboo. 151 | 152 | Args: 153 | bangboo_id: The bangboo ID. 154 | use_cache: Whether to use the response cache. 155 | 156 | Returns: 157 | The bangboo details object. 158 | """ 159 | endpoint = f"bangboo/{bangboo_id}" 160 | data = await self._request(endpoint, use_cache=use_cache) 161 | return zzz.BangbooDetail(**data) 162 | 163 | async def fetch_drive_discs( 164 | self, *, use_cache: bool = True, version: str | None = None 165 | ) -> list[zzz.DriveDisc]: 166 | """Fetch all Zenless Zone Zero drive discs. 167 | 168 | Args: 169 | use_cache: Whether to use the response cache. 170 | version: The game version to fetch data for. 171 | Returns: 172 | A list of drive disc objects. 173 | """ 174 | data = await self._request("equipment", use_cache=use_cache, in_data=True, version=version) 175 | drive_discs = [ 176 | zzz.DriveDisc(id=int(drive_disc_id), **drive_disc) 177 | for drive_disc_id, drive_disc in data.items() 178 | ] 179 | for drive_disc in drive_discs: 180 | if self.lang is Language.EN: 181 | info = drive_disc.en_info 182 | elif self.lang is Language.KO: 183 | info = drive_disc.ko_info 184 | elif self.lang is Language.ZH: 185 | info = drive_disc.chs_info 186 | else: 187 | info = drive_disc.ja_info 188 | 189 | if info is None: 190 | continue 191 | 192 | drive_disc.name = info.name 193 | drive_disc.two_piece_effect = info.two_piece_effect 194 | drive_disc.four_piece_effect = info.four_piece_effect 195 | 196 | return drive_discs 197 | 198 | async def fetch_drive_disc_detail( 199 | self, drive_disc_id: int, *, use_cache: bool = True 200 | ) -> zzz.DriveDiscDetail: 201 | """Fetch the details of a Zenless Zone Zero drive disc. 202 | 203 | Args: 204 | drive_disc_id: The drive disc ID. 205 | use_cache: Whether to use the response cache. 206 | 207 | Returns: 208 | The drive disc details object. 209 | """ 210 | endpoint = f"equipment/{drive_disc_id}" 211 | data = await self._request(endpoint, use_cache=use_cache) 212 | return zzz.DriveDiscDetail(**data) 213 | 214 | async def fetch_items(self, *, use_cache: bool = True) -> Sequence[zzz.Item]: 215 | """Fetch all Zenless Zone Zero items. 216 | 217 | Args: 218 | use_cache: Whether to use the response cache. 219 | 220 | Returns: 221 | A list of item objects. 222 | """ 223 | data = await self._request("item", use_cache=use_cache) 224 | items = [zzz.Item(id=int(item_id), **item) for item_id, item in data.items()] 225 | return items 226 | -------------------------------------------------------------------------------- /hakushin/clients/gi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from hakushin.enums import Game, Language 6 | 7 | from ..constants import GI_LANG_MAP 8 | from ..models import gi 9 | from .base import BaseClient 10 | 11 | if TYPE_CHECKING: 12 | from aiohttp import ClientSession 13 | 14 | __all__ = ("GIClient",) 15 | 16 | 17 | class GIClient(BaseClient): 18 | """Client to interact with the Hakushin Genshin Impact API.""" 19 | 20 | def __init__( 21 | self, 22 | lang: Language = Language.EN, 23 | *, 24 | cache_path: str = "./.cache/hakushin/aiohttp-cache.db", 25 | cache_ttl: int = 3600, 26 | headers: dict[str, Any] | None = None, 27 | debug: bool = False, 28 | session: ClientSession | None = None, 29 | ) -> None: 30 | super().__init__( 31 | Game.GI, 32 | lang, 33 | cache_path=cache_path, 34 | cache_ttl=cache_ttl, 35 | headers=headers, 36 | debug=debug, 37 | session=session, 38 | ) 39 | 40 | async def fetch_new(self, *, use_cache: bool = True) -> gi.New: 41 | """Fetch the ID of beta items in Genshin Impact. 42 | 43 | Args: 44 | use_cache: Whether to use the response cache. 45 | 46 | Returns: 47 | A model representing the new items. 48 | """ 49 | data = await self._request("new", use_cache=use_cache, static=True) 50 | return gi.New(**data) 51 | 52 | async def fetch_characters( 53 | self, *, use_cache: bool = True, version: str | None = None 54 | ) -> list[gi.Character]: 55 | """Fetch all Genshin Impact characters. 56 | 57 | Args: 58 | use_cache: Whether to use the response cache. 59 | version: The version of the characters to fetch. 60 | 61 | Returns: 62 | A list of character objects. 63 | """ 64 | data = await self._request("character", use_cache=use_cache, in_data=True, version=version) 65 | 66 | characters = [gi.Character(id=char_id, **char) for char_id, char in data.items()] 67 | for char in characters: 68 | char.name = char.names[GI_LANG_MAP[self.lang]] 69 | 70 | return characters 71 | 72 | async def fetch_character_detail( 73 | self, character_id: str, *, use_cache: bool = True 74 | ) -> gi.CharacterDetail: 75 | """Fetch the details of a Genshin Impact character. 76 | 77 | Args: 78 | character_id: The character ID. 79 | use_cache: Whether to use the response cache. 80 | 81 | Returns: 82 | The character details object. 83 | """ 84 | endpoint = f"character/{character_id}" 85 | data = await self._request(endpoint, use_cache=use_cache) 86 | return gi.CharacterDetail(**data) 87 | 88 | async def fetch_weapons( 89 | self, *, use_cache: bool = True, version: str | None = None 90 | ) -> list[gi.Weapon]: 91 | """Fetch all Genshin Impact weapons. 92 | 93 | Args: 94 | use_cache: Whether to use the response cache. 95 | version: The version of the weapons to fetch. 96 | 97 | Returns: 98 | A list of weapon objects. 99 | """ 100 | endpoint = "weapon" 101 | data = await self._request(endpoint, use_cache=use_cache, in_data=True, version=version) 102 | weapons = [gi.Weapon(id=int(weapon_id), **weapon) for weapon_id, weapon in data.items()] 103 | for weapon in weapons: 104 | weapon.name = weapon.names[GI_LANG_MAP[self.lang]] 105 | return weapons 106 | 107 | async def fetch_weapon_detail( 108 | self, weapon_id: int, *, use_cache: bool = True 109 | ) -> gi.WeaponDetail: 110 | """Fetch the details of a Genshin Impact weapon. 111 | 112 | Args: 113 | weapon_id: The weapon ID. 114 | use_cache: Whether to use the response cache. 115 | 116 | Returns: 117 | The weapon details object. 118 | """ 119 | endpoint = f"weapon/{weapon_id}" 120 | data = await self._request(endpoint, use_cache=use_cache) 121 | return gi.WeaponDetail(**data) 122 | 123 | async def fetch_artifact_sets( 124 | self, *, use_cache: bool = True, version: str | None = None 125 | ) -> list[gi.ArtifactSet]: 126 | """Fetch all Genshin Impact artifact sets. 127 | 128 | Args: 129 | use_cache: Whether to use the response cache. 130 | version: The version of the artifact sets to fetch. 131 | Returns: 132 | A list of artifact set objects. 133 | """ 134 | endpoint = "artifact" 135 | data = await self._request(endpoint, use_cache=use_cache, in_data=True, version=version) 136 | sets = [gi.ArtifactSet(**set_) for set_ in data.values()] 137 | for set_ in sets: 138 | set_.name = set_.names[GI_LANG_MAP[self.lang]] 139 | set_.set_effect.two_piece.name = set_.set_effect.two_piece.names[GI_LANG_MAP[self.lang]] 140 | set_.set_effect.two_piece.description = set_.set_effect.two_piece.descriptions[ 141 | GI_LANG_MAP[self.lang] 142 | ] 143 | if set_.set_effect.four_piece is not None: 144 | set_.set_effect.four_piece.name = set_.set_effect.four_piece.names[ 145 | GI_LANG_MAP[self.lang] 146 | ] 147 | set_.set_effect.four_piece.description = set_.set_effect.four_piece.descriptions[ 148 | GI_LANG_MAP[self.lang] 149 | ] 150 | return sets 151 | 152 | async def fetch_artifact_set_detail( 153 | self, set_id: int, *, use_cache: bool = True 154 | ) -> gi.ArtifactSetDetail: 155 | """Fetch the details of a Genshin Impact artifact set. 156 | 157 | Args: 158 | set_id: The artifact set ID. 159 | use_cache: Whether to use the response cache. 160 | 161 | Returns: 162 | The artifact set details object. 163 | """ 164 | endpoint = f"artifact/{set_id}" 165 | data = await self._request(endpoint, use_cache=use_cache) 166 | return gi.ArtifactSetDetail(**data) 167 | 168 | async def fetch_stygians(self, *, use_cache: bool = True) -> list[gi.Stygian]: 169 | """Fetch all Genshin Impact Stygian Onslaught entries. 170 | 171 | Args: 172 | use_cache: Whether to use the response cache. 173 | 174 | Returns: 175 | A list of Stygian objects. 176 | """ 177 | data = await self._request("leyline", use_cache=use_cache, in_data=True) 178 | 179 | stygian_entries = [ 180 | gi.Stygian(id=int(stygian_id), **stygian) for stygian_id, stygian in data.items() 181 | ] 182 | for entry in stygian_entries: 183 | entry.name = entry.names[GI_LANG_MAP[self.lang]] 184 | 185 | return stygian_entries 186 | 187 | async def fetch_stygian_detail( 188 | self, stygian_id: int, *, use_cache: bool = True 189 | ) -> gi.StygianDetail: 190 | """Fetch the details of a Stygian Onslaught entry. 191 | 192 | Args: 193 | stygian_id: The Stygian ID. 194 | use_cache: Whether to use the response cache. 195 | 196 | Returns: 197 | The Stygian details object. 198 | """ 199 | endpoint = f"leyline/{stygian_id}" 200 | data = await self._request(endpoint, use_cache=use_cache) 201 | return gi.StygianDetail(**data) 202 | 203 | async def fetch_mw_costumes(self, *, use_cache: bool = True) -> list[gi.MWCostume]: 204 | """Fetch all Miliastra Wonderland costumes and costume sets. 205 | 206 | Args: 207 | use_cache: Whether to use the response cache. 208 | 209 | Returns: 210 | A list of Miliastra Wonderland costumes and costume sets. 211 | """ 212 | endpoint = "beyond/costume_all" 213 | data = await self._request(endpoint, use_cache=use_cache) 214 | 215 | return [gi.MWCostume(**costume) for costume in data.values()] 216 | 217 | async def fetch_mw_costume_sets(self, *, use_cache: bool = True) -> list[gi.MWCostumeSet]: 218 | """Fetch all Miliastra Wonderland costume sets. 219 | 220 | Args: 221 | use_cache: Whether to use the response cache. 222 | 223 | Returns: 224 | A list of Miliastra Wonderland costume sets. 225 | """ 226 | endpoint = "beyond/costume_suit_all" 227 | data = await self._request(endpoint, use_cache=use_cache) 228 | 229 | return [gi.MWCostumeSet(**costume_set) for costume_set in data.values()] 230 | 231 | async def fetch_mw_items(self, *, use_cache: bool = True) -> list[gi.MWItem]: 232 | """Fetch all Miliastra Wonderland items. 233 | 234 | Args: 235 | use_cache: Whether to use the response cache. 236 | 237 | Returns: 238 | A list of Miliastra Wonderland items. 239 | """ 240 | endpoint = "beyond/item_all" 241 | data = await self._request(endpoint, use_cache=use_cache) 242 | 243 | return [gi.MWItem(id=int(item_id), **item) for item_id, item in data.items()] 244 | -------------------------------------------------------------------------------- /hakushin/models/gi/character.py: -------------------------------------------------------------------------------- 1 | """Genshin Impact character models.""" 2 | from __future__ import annotations 3 | 4 | from typing import Any, Literal 5 | 6 | from pydantic import Field, field_validator, model_validator 7 | 8 | from ...constants import GI_CHARA_RARITY_MAP 9 | from ...enums import GIElement 10 | from ..base import APIModel 11 | 12 | __all__ = ( 13 | "Character", 14 | "CharacterConstellation", 15 | "CharacterDetail", 16 | "CharacterInfo", 17 | "CharacterPassive", 18 | "CharacterSkill", 19 | "CharacterStatsModifier", 20 | "FightPropGrowthCurve", 21 | "Namecard", 22 | "SkillUpgradeInfo", 23 | "UpgradeMaterial", 24 | "UpgradeMaterialInfo", 25 | "UpgradeMaterialInfos", 26 | ) 27 | 28 | 29 | class Namecard(APIModel): 30 | """Represent a character's namecard. 31 | 32 | Attributes: 33 | id: ID of the namecard. 34 | name: Name of the namecard. 35 | description: Description of the namecard. 36 | icon: Icon URL of the namecard. 37 | """ 38 | 39 | id: int = Field(alias="Id") 40 | name: str = Field(alias="Name") 41 | description: str = Field(alias="Desc") 42 | icon: str = Field(alias="Icon") 43 | 44 | @field_validator("icon", mode="before") 45 | @classmethod 46 | def __convert_icon(cls, value: str) -> str: 47 | return f"https://api.hakush.in/gi/UI/{value}.webp" 48 | 49 | 50 | class CharacterInfo(APIModel): 51 | """Represent a character's information. 52 | 53 | Attributes: 54 | namecard: Character's namecard, if available. 55 | """ 56 | 57 | namecard: Namecard | None = Field(None, alias="Namecard") 58 | 59 | @field_validator("namecard", mode="before") 60 | @classmethod 61 | def __handle_empty_namecard(cls, value: dict[str, Any] | None) -> dict[str, Any] | None: 62 | return value or None 63 | 64 | 65 | class SkillUpgradeInfo(APIModel): 66 | """Represent a character's skill upgrade information. 67 | 68 | Attributes: 69 | level: Level of the skill upgrade. 70 | icon: Icon URL of the skill upgrade. 71 | attributes: List of attributes for the skill upgrade. 72 | parameters: List of parameters for the skill upgrade. 73 | """ 74 | 75 | level: int = Field(alias="Level") 76 | icon: str = Field(alias="Icon") 77 | attributes: list[str] = Field(alias="Desc") 78 | parameters: list[float] = Field(alias="Param") 79 | 80 | @field_validator("icon", mode="before") 81 | @classmethod 82 | def __convert_icon(cls, value: str) -> str: 83 | return f"https://api.hakush.in/gi/UI/{value}.webp" 84 | 85 | @field_validator("attributes", mode="before") 86 | @classmethod 87 | def __remove_empty_attributes(cls, value: list[str]) -> list[str]: 88 | return [attr for attr in value if attr] 89 | 90 | 91 | class CharacterSkill(APIModel): 92 | """Represent a character's skill. 93 | 94 | Attributes: 95 | name: Name of the skill. 96 | description: Description of the skill. 97 | upgrade_info: Dictionary of skill upgrade information. 98 | """ 99 | 100 | name: str = Field(alias="Name") 101 | description: str = Field(alias="Desc") 102 | upgrade_info: dict[str, SkillUpgradeInfo] = Field(alias="Promote") 103 | 104 | 105 | class CharacterPassive(APIModel): 106 | """Represent a character's passive talent. 107 | 108 | Attributes: 109 | name: Name of the passive talent. 110 | description: Description of the passive talent. 111 | parameters: List of parameters for the passive talent. 112 | icon: Icon URL of the passive talent. 113 | """ 114 | 115 | name: str = Field(alias="Name") 116 | description: str = Field(alias="Desc") 117 | unlock: int | list[int] = Field(alias="Unlock") 118 | parameters: list[float] = Field(alias="ParamList") 119 | icon: str = Field(alias="Icon") 120 | 121 | @field_validator("icon", mode="before") 122 | @classmethod 123 | def __convert_icon(cls, value: str) -> str: 124 | return f"https://api.hakush.in/gi/UI/{value}.webp" 125 | 126 | 127 | class CharacterConstellation(APIModel): 128 | """Represent a character's constellation. 129 | 130 | Attributes: 131 | name: Name of the constellation. 132 | description: Description of the constellation. 133 | parameters: List of parameters for the constellation. 134 | icon: Icon URL of the constellation. 135 | """ 136 | 137 | name: str = Field(alias="Name") 138 | description: str = Field(alias="Desc") 139 | parameters: list[float] = Field(alias="ParamList") 140 | icon: str = Field(alias="Icon") 141 | 142 | @field_validator("icon", mode="before") 143 | @classmethod 144 | def __convert_icon(cls, value: str) -> str: 145 | return f"https://api.hakush.in/gi/UI/{value}.webp" 146 | 147 | 148 | class UpgradeMaterial(APIModel): 149 | """Represent a character's upgrade material. 150 | 151 | Attributes: 152 | name: Name of the material. 153 | id: ID of the material. 154 | count: Count of the material. 155 | rarity: Rarity of the material. 156 | """ 157 | 158 | name: str = Field(alias="Name") 159 | id: int = Field(alias="Id") 160 | count: int = Field(alias="Count") 161 | rarity: Literal[0, 1, 2, 3, 4, 5] = Field(alias="Rank") 162 | 163 | @property 164 | def icon(self) -> str: 165 | """Get the material's icon URL.""" 166 | return f"https://api.hakush.in/gi/UI/UI_ItemIcon_{self.id}.webp" 167 | 168 | 169 | class UpgradeMaterialInfo(APIModel): 170 | """Represent character's upgrade material information. 171 | 172 | Attributes: 173 | materials: List of upgrade materials. 174 | mora_cost: Mora cost for the upgrade. 175 | """ 176 | 177 | materials: list[UpgradeMaterial] = Field(alias="Mats") 178 | mora_cost: int = Field(alias="Cost") 179 | 180 | 181 | class UpgradeMaterialInfos(APIModel): 182 | """Represent character's upgrade material information. 183 | 184 | Attributes: 185 | ascensions: List of upgrade material information for ascensions. 186 | talents: List of lists of upgrade material information for talents. 187 | """ 188 | 189 | ascensions: list[UpgradeMaterialInfo] = Field(alias="Ascensions") 190 | talents: list[list[UpgradeMaterialInfo]] = Field(alias="Talents") 191 | 192 | 193 | class FightPropGrowthCurve(APIModel): 194 | """Represent a character's stat growth curve data. 195 | 196 | Attributes: 197 | stat_type: Type of the stat. 198 | growth_type: Type of the growth curve. 199 | """ 200 | 201 | stat_type: str = Field(alias="type") 202 | growth_type: str = Field(alias="growCurve") 203 | 204 | 205 | class CharacterStatsModifier(APIModel): 206 | """Represent a character's stat modifiers. 207 | 208 | Attributes: 209 | hp: HP stat modifiers. 210 | atk: ATK stat modifiers. 211 | def_: DEF stat modifiers. 212 | ascension: List of ascension stat modifiers. 213 | prop_growth_curves: List of property growth curves. 214 | """ 215 | 216 | hp: dict[str, float] = Field(alias="HP") 217 | atk: dict[str, float] = Field(alias="ATK") 218 | def_: dict[str, float] = Field(alias="DEF") 219 | ascension: list[dict[str, float]] = Field(alias="Ascension") 220 | prop_growth_curves: list[FightPropGrowthCurve] = Field(alias="PropGrowCurves") 221 | 222 | 223 | class CharacterDetail(APIModel): 224 | """Represent a Genshin Impact character detail. 225 | 226 | Attributes: 227 | name: Name of the character. 228 | description: Description of the character. 229 | info: Character information. 230 | rarity: Rarity of the character. 231 | icon: Icon URL of the character. 232 | skills: List of character skills. 233 | passives: List of character passive talents. 234 | constellations: List of character constellations. 235 | stamina_recovery: Stamina recovery rate of the character. 236 | base_hp: Base HP of the character. 237 | base_atk: Base ATK of the character. 238 | base_def: Base DEF of the character. 239 | crit_rate: Critical rate of the character. 240 | crit_dmg: Critical damage of the character. 241 | stats_modifier: Character stat modifiers. 242 | upgrade_materials: Character upgrade materials. 243 | """ 244 | 245 | # Info 246 | name: str = Field(alias="Name") 247 | description: str = Field(alias="Desc") 248 | info: CharacterInfo = Field(alias="CharaInfo") 249 | rarity: Literal[4, 5] = Field(alias="Rarity") 250 | icon: str = Field(alias="Icon") 251 | 252 | # Combat 253 | skills: list[CharacterSkill] = Field(alias="Skills") 254 | passives: list[CharacterPassive] = Field(alias="Passives") 255 | constellations: list[CharacterConstellation] = Field(alias="Constellations") 256 | 257 | # Props 258 | stamina_recovery: float = Field(alias="StaminaRecovery") 259 | base_hp: float = Field(alias="BaseHP") 260 | base_atk: float = Field(alias="BaseATK") 261 | base_def: float = Field(alias="BaseDEF") 262 | crit_rate: float = Field(alias="CritRate") 263 | crit_dmg: float = Field(alias="CritDMG") 264 | 265 | stats_modifier: CharacterStatsModifier = Field(alias="StatsModifier") 266 | upgrade_materials: UpgradeMaterialInfos = Field(alias="Materials") 267 | 268 | @property 269 | def gacha_art(self) -> str: 270 | """Get the character's gacha art URL.""" 271 | return self.icon.replace("AvatarIcon", "Gacha_AvatarImg") 272 | 273 | @field_validator("icon", mode="before") 274 | @classmethod 275 | def __convert_icon(cls, value: str) -> str: 276 | return f"https://api.hakush.in/gi/UI/{value}.webp" 277 | 278 | @field_validator("rarity", mode="before") 279 | @classmethod 280 | def __convert_rarity(cls, value: str) -> Literal[4, 5]: 281 | return GI_CHARA_RARITY_MAP[value] 282 | 283 | 284 | class Character(APIModel): 285 | """Represent a Genshin Impact character. 286 | 287 | Attributes: 288 | id: ID of the character. 289 | icon: Icon URL of the character. 290 | rarity: Rarity of the character. 291 | description: Description of the character. 292 | element: Element of the character, if available. 293 | names: Dictionary of names in different languages. 294 | name: Name of the character. 295 | """ 296 | 297 | id: str 298 | icon: str 299 | rarity: Literal[4, 5] = Field(alias="rank") 300 | description: str = Field(alias="desc") 301 | element: GIElement | None = None 302 | names: dict[Literal["EN", "CHS", "KR", "JP"], str] 303 | name: str = Field("") 304 | 305 | @field_validator("icon", mode="before") 306 | @classmethod 307 | def __convert_icon(cls, value: str) -> str: 308 | return f"https://api.hakush.in/gi/UI/{value}.webp" 309 | 310 | @field_validator("rarity", mode="before") 311 | @classmethod 312 | def __convert_rarity(cls, value: str) -> Literal[4, 5]: 313 | return GI_CHARA_RARITY_MAP[value] 314 | 315 | @field_validator("element", mode="before") 316 | @classmethod 317 | def __convert_element(cls, value: str) -> GIElement | None: 318 | return GIElement(value) if value else None 319 | 320 | @model_validator(mode="before") 321 | @classmethod 322 | def __transform_names(cls, values: dict[str, Any]) -> dict[str, Any]: 323 | values["names"] = { 324 | "EN": values.pop("EN"), 325 | "CHS": values.pop("CHS"), 326 | "KR": values.pop("KR"), 327 | "JP": values.pop("JP"), 328 | } 329 | return values 330 | -------------------------------------------------------------------------------- /hakushin/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Final, Literal 4 | 5 | from .enums import Game, HSRPath, Language 6 | 7 | __all__ = ( 8 | "ASCENDED_LEVEL_TO_ASCENSION", 9 | "ASCENSION_TO_MAX_LEVEL", 10 | "GI_CHARA_RARITY_MAP", 11 | "GI_LANG_MAP", 12 | "GI_SPRITE_PRESET_MAP", 13 | "HSR_API_LANG_MAP", 14 | "HSR_CHARA_RARITY_MAP", 15 | "HSR_LIGHT_CONE_RARITY_MAP", 16 | "HSR_PATH_NAMES", 17 | "NOT_ASCENDED_LEVEL_TO_ASCENSION", 18 | "PERCENTAGE_FIGHT_PROPS", 19 | "STAT_TO_FIGHT_PROP", 20 | "TRAILBLAZER_NAMES", 21 | "ZZZ_LANG_MAP", 22 | "ZZZ_SAB_RARITY_CONVERTER", 23 | "ZZZ_SA_RARITY_CONVERTER", 24 | ) 25 | 26 | GI_CHARA_RARITY_MAP: Final[dict[str, Literal[4, 5]]] = { 27 | "QUALITY_PURPLE": 4, 28 | "QUALITY_ORANGE": 5, 29 | "QUALITY_ORANGE_SP": 5, 30 | } 31 | """Map GI character rarity strings to integer values.""" 32 | 33 | HSR_CHARA_RARITY_MAP: Final[dict[str, Literal[4, 5]]] = { 34 | "CombatPowerAvatarRarityType4": 4, 35 | "CombatPowerAvatarRarityType5": 5, 36 | } 37 | """Map HSR character rarity strings to integer values.""" 38 | 39 | HSR_LIGHT_CONE_RARITY_MAP: Final[dict[str, Literal[3, 4, 5]]] = { 40 | "CombatPowerLightconeRarity3": 3, 41 | "CombatPowerLightconeRarity4": 4, 42 | "CombatPowerLightconeRarity5": 5, 43 | } 44 | """Map HSR light cone rarity strings to integer values.""" 45 | 46 | HSR_API_LANG_MAP: Final[dict[Language, Literal["en", "jp", "kr", "cn"]]] = { 47 | Language.EN: "en", 48 | Language.JA: "jp", 49 | Language.KO: "kr", 50 | Language.ZH: "cn", 51 | } 52 | """Map Language enum to HSR API language strings.""" 53 | 54 | GI_LANG_MAP: Final[dict[Language, Literal["EN", "JP", "KR", "CHS"]]] = { 55 | Language.EN: "EN", 56 | Language.JA: "JP", 57 | Language.KO: "KR", 58 | Language.ZH: "CHS", 59 | } 60 | """Map Language enum to GI data language strings.""" 61 | 62 | ZZZ_LANG_MAP: Final[dict[Language, Literal["EN", "KO", "CHS", "JA"]]] = { 63 | Language.EN: "EN", 64 | Language.JA: "JA", 65 | Language.KO: "KO", 66 | Language.ZH: "CHS", 67 | } 68 | """Map Language enum to ZZZ API language strings.""" 69 | 70 | PERCENTAGE_FIGHT_PROPS: Final[set[str]] = { 71 | "FIGHT_PROP_HP_PERCENT", 72 | "FIGHT_PROP_ATTACK_PERCENT", 73 | "FIGHT_PROP_DEFENSE_PERCENT", 74 | "FIGHT_PROP_SPEED_PERCENT", 75 | "FIGHT_PROP_CRITICAL", 76 | "FIGHT_PROP_CRITICAL_HURT", 77 | "FIGHT_PROP_CHARGE_EFFICIENCY", 78 | "FIGHT_PROP_ADD_HURT", 79 | "FIGHT_PROP_HEAL_ADD", 80 | "FIGHT_PROP_HEALED_ADD", 81 | "FIGHT_PROP_FIRE_ADD_HURT", 82 | "FIGHT_PROP_WATER_ADD_HURT", 83 | "FIGHT_PROP_GRASS_ADD_HURT", 84 | "FIGHT_PROP_ELEC_ADD_HURT", 85 | "FIGHT_PROP_ICE_ADD_HURT", 86 | "FIGHT_PROP_WIND_ADD_HURT", 87 | "FIGHT_PROP_PHYSICAL_ADD_HURT", 88 | "FIGHT_PROP_ROCK_ADD_HURT", 89 | "FIGHT_PROP_SKILL_CD_MINUS_RATIO", 90 | "FIGHT_PROP_ATTACK_PERCENT_A", 91 | "FIGHT_PROP_DEFENSE_PERCENT_A", 92 | "FIGHT_PROP_HP_PERCENT_A", 93 | "criticalChance", 94 | "criticalDamage", 95 | "breakDamageAddedRatio", 96 | "breakDamageAddedRatioBase", 97 | "healRatio", 98 | "sPRatio", 99 | "statusProbability", 100 | "statusResistance", 101 | "criticalChanceBase", 102 | "criticalDamageBase", 103 | "healRatioBase", 104 | "sPRatioBase", 105 | "statusProbabilityBase", 106 | "statusResistanceBase", 107 | "physicalAddedRatio", 108 | "physicalResistance", 109 | "fireAddedRatio", 110 | "fireResistance", 111 | "iceAddedRatio", 112 | "iceResistance", 113 | "thunderAddedRatio", 114 | "thunderResistance", 115 | "windAddedRatio", 116 | "windResistance", 117 | "quantumAddedRatio", 118 | "quantumResistance", 119 | "imaginaryAddedRatio", 120 | "imaginaryResistance", 121 | "hPAddedRatio", 122 | "attackAddedRatio", 123 | "defenceAddedRatio", 124 | "healTakenRatio", 125 | "physicalResistanceDelta", 126 | "fireResistanceDelta", 127 | "iceResistanceDelta", 128 | "thunderResistanceDelta", 129 | "windResistanceDelta", 130 | "quantumResistanceDelta", 131 | "imaginaryResistanceDelta", 132 | } 133 | """Set of fight prop keys that represent percentage values.""" 134 | 135 | HSR_PATH_NAMES: Final[dict[Language, dict[HSRPath, str]]] = { 136 | Language.EN: { 137 | HSRPath.ABUNDANCE: "Abundance", 138 | HSRPath.DESTRUCTION: "Destruction", 139 | HSRPath.ERUDITION: "Erudition", 140 | HSRPath.HARMONY: "Harmony", 141 | HSRPath.NIHILITY: "Nihility", 142 | HSRPath.PRESERVATION: "Preservation", 143 | HSRPath.THE_HUNT: "The Hunt", 144 | HSRPath.REMEMBRANCE: "Remembrance", 145 | }, 146 | Language.JA: { 147 | HSRPath.ABUNDANCE: "豊穣", 148 | HSRPath.DESTRUCTION: "壊滅", 149 | HSRPath.ERUDITION: "知恵", 150 | HSRPath.HARMONY: "調和", 151 | HSRPath.NIHILITY: "虚無", 152 | HSRPath.PRESERVATION: "存護", 153 | HSRPath.THE_HUNT: "巡狩", 154 | HSRPath.REMEMBRANCE: "記憶", 155 | }, 156 | Language.ZH: { 157 | HSRPath.ABUNDANCE: "丰饶", 158 | HSRPath.DESTRUCTION: "毁灭", 159 | HSRPath.ERUDITION: "智识", 160 | HSRPath.HARMONY: "同谐", 161 | HSRPath.NIHILITY: "虚无", 162 | HSRPath.PRESERVATION: "存护", 163 | HSRPath.THE_HUNT: "巡猎", 164 | HSRPath.REMEMBRANCE: "记忆", 165 | }, 166 | Language.KO: { 167 | HSRPath.ABUNDANCE: "풍요", 168 | HSRPath.DESTRUCTION: "파멸", 169 | HSRPath.ERUDITION: "지식", 170 | HSRPath.HARMONY: "화합", 171 | HSRPath.NIHILITY: "공허", 172 | HSRPath.PRESERVATION: "보존", 173 | HSRPath.THE_HUNT: "수렵", 174 | HSRPath.REMEMBRANCE: "기억", 175 | }, 176 | } 177 | """Map HSRPath enum to localized path names.""" 178 | 179 | TRAILBLAZER_NAMES: Final[dict[Language, str]] = { 180 | Language.EN: "Trailblazer", 181 | Language.JA: "開拓者", 182 | Language.ZH: "开拓者", 183 | Language.KO: "개척자", 184 | } 185 | """Map Language enum to localized Trailblazer names.""" 186 | 187 | STAT_TO_FIGHT_PROP: Final[dict[str, str]] = { 188 | "BaseHP": "FIGHT_PROP_BASE_HP", 189 | "BaseDEF": "FIGHT_PROP_BASE_DEFENSE", 190 | "BaseATK": "FIGHT_PROP_BASE_ATTACK", 191 | } 192 | """Map stat keys to fight prop keys.""" 193 | 194 | NOT_ASCENDED_LEVEL_TO_ASCENSION: Final[dict[Game, dict[int, int]]] = { 195 | Game.GI: {80: 5, 70: 4, 60: 3, 50: 2, 40: 1, 20: 0}, 196 | Game.HSR: {70: 5, 60: 4, 50: 3, 40: 2, 30: 1, 20: 0}, 197 | } 198 | """Map non-ascended levels to ascension numbers for each game.""" 199 | 200 | ASCENDED_LEVEL_TO_ASCENSION: Final[dict[Game, dict[tuple[int, int], int]]] = { 201 | Game.GI: {(80, 90): 6, (70, 80): 5, (60, 70): 4, (50, 60): 3, (40, 50): 2, (20, 40): 1}, 202 | Game.HSR: {(70, 80): 6, (60, 70): 5, (50, 60): 4, (40, 50): 3, (30, 40): 2, (20, 30): 1}, 203 | } 204 | """Map ascended level ranges to ascension numbers for each game.""" 205 | 206 | ASCENSION_TO_MAX_LEVEL: Final[dict[Game, dict[int, int]]] = { 207 | Game.GI: {0: 20, 1: 40, 2: 50, 3: 60, 4: 70, 5: 80, 6: 90}, 208 | Game.HSR: {0: 20, 1: 30, 2: 40, 3: 50, 4: 60, 5: 70, 6: 80}, 209 | } 210 | """Map ascension numbers to maximum levels for each game.""" 211 | 212 | ZZZ_SAB_RARITY_CONVERTER: Final[dict[int, Literal["B", "A", "S"]]] = {2: "B", 3: "A", 4: "S"} 213 | """Convert ZZZ S/A/B rarity integer values to string literals.""" 214 | ZZZ_SA_RARITY_CONVERTER: Final[dict[int, Literal["A", "S"]]] = {3: "A", 4: "S"} 215 | """Convert ZZZ S/A rarity integer values to string literals.""" 216 | 217 | GI_ICON_URL_PREFIX: Final[str] = "https://api.hakush.in/gi/UI" 218 | _gi_sprite_preset_map: dict[str, str] = { 219 | "SPRITE_PRESET#1101": "UI_Gcg_DiceS_Frost", 220 | "SPRITE_PRESET#1102": "UI_Gcg_DiceS_Water", 221 | "SPRITE_PRESET#1103": "UI_Gcg_DiceS_Fire", 222 | "SPRITE_PRESET#1104": "UI_Gcg_DiceS_Elect", 223 | "SPRITE_PRESET#1105": "UI_Gcg_DiceS_Wind", 224 | "SPRITE_PRESET#1106": "UI_Gcg_DiceS_Roach", 225 | "SPRITE_PRESET#1107": "UI_Gcg_DiceS_Grass", 226 | "SPRITE_PRESET#1108": "UI_Gcg_DiceS_Same", 227 | "SPRITE_PRESET#1109": "UI_Gcg_DiceS_Diff", 228 | "SPRITE_PRESET#1110": "UI_Gcg_DiceS_Energy", 229 | "SPRITE_PRESET#1111": "UI_Gcg_DiceS_Any", 230 | "SPRITE_PRESET#1112": "UI_Gcg_DiceS_Legend", 231 | "SPRITE_PRESET#1113": "UI_Gcg_Buff_ElementMastery", 232 | "SPRITE_PRESET#2100": "UI_Gcg_Buff_Common_Element_Physics", 233 | "SPRITE_PRESET#2101": "UI_Gcg_Buff_Common_Element_Ice", 234 | "SPRITE_PRESET#2102": "UI_Gcg_Buff_Common_Element_Water", 235 | "SPRITE_PRESET#2103": "UI_Gcg_Buff_Common_Element_Fire", 236 | "SPRITE_PRESET#2104": "UI_Gcg_Buff_Common_Element_Electric", 237 | "SPRITE_PRESET#2105": "UI_Gcg_Buff_Common_Element_Wind", 238 | "SPRITE_PRESET#2106": "UI_Gcg_Buff_Common_Element_Rock", 239 | "SPRITE_PRESET#2107": "UI_Gcg_Buff_Common_Element_Grass", 240 | "SPRITE_PRESET#3002": "UI_Gcg_Tag_Card_Talent", 241 | "SPRITE_PRESET#3005": "UI_Gcg_Tag_Card_Talent", 242 | "SPRITE_PRESET#3003": "UI_Gcg_Tag_Card_Weapon", 243 | "SPRITE_PRESET#3004": "UI_Gcg_Tag_Card_Relic", 244 | "SPRITE_PRESET#3006": "UI_Gcg_Tag_Card_Talent", 245 | "SPRITE_PRESET#3007": "UI_Gcg_Tag_Card_Legend", 246 | "SPRITE_PRESET#3008": "UI_Gcg_Tag_Card_Vehicle", 247 | "SPRITE_PRESET#3104": "UI_Gcg_Tag_Card_Location", 248 | "SPRITE_PRESET#3103": "UI_Gcg_Tag_Card_Ally", 249 | "SPRITE_PRESET#3102": "UI_Gcg_Tag_Card_Item", 250 | "SPRITE_PRESET#3105": "UI_Gcg_Tag_Card_Ally", 251 | "SPRITE_PRESET#3101": "UI_Gcg_Tag_Card_Food", 252 | "SPRITE_PRESET#3201": "UI_Gcg_Tag_Weapon_Catalyst", 253 | "SPRITE_PRESET#3202": "UI_Gcg_Tag_Weapon_Bow", 254 | "SPRITE_PRESET#3203": "UI_Gcg_Tag_Weapon_Claymore", 255 | "SPRITE_PRESET#3204": "UI_Gcg_Tag_Weapon_Polearm", 256 | "SPRITE_PRESET#3205": "UI_Gcg_Tag_Weapon_Sword", 257 | "SPRITE_PRESET#3401": "UI_Gcg_Tag_Faction_Mondstadt", 258 | "SPRITE_PRESET#3402": "UI_Gcg_Tag_Faction_Liyue", 259 | "SPRITE_PRESET#3403": "UI_Gcg_Tag_Faction_Inazuma", 260 | "SPRITE_PRESET#3404": "UI_Gcg_Tag_Faction_Sumeru", 261 | "SPRITE_PRESET#3405": "UI_Gcg_Tag_Faction_Fontaine", 262 | "SPRITE_PRESET#3406": "UI_Gcg_Tag_Faction_Natlan", 263 | "SPRITE_PRESET#3501": "UI_Gcg_Tag_Faction_Fatui", 264 | "SPRITE_PRESET#3502": "UI_Gcg_Tag_Faction_Hili", 265 | "SPRITE_PRESET#3503": "UI_Gcg_Tag_Faction_Monster", 266 | "SPRITE_PRESET#3504": "UI_Gcg_Tag_Faction_Pneuma", 267 | "SPRITE_PRESET#3505": "UI_Gcg_Tag_Faction_Ousia", 268 | "SPRITE_PRESET#3506": "UI_Gcg_Tag_Faction_Sacred", 269 | "SPRITE_PRESET#4001": "UI_Gcg_Buff_Common_Atk_Self", 270 | "SPRITE_PRESET#4002": "UI_Gcg_Buff_Common_Atk_Up", 271 | "SPRITE_PRESET#4003": "UI_Gcg_Buff_Common_Barrier", 272 | "SPRITE_PRESET#4004": "UI_Gcg_Buff_Common_Food", 273 | "SPRITE_PRESET#4005": "UI_Gcg_Buff_Common_Frozen", 274 | "SPRITE_PRESET#4006": "UI_Gcg_Buff_Common_Heal", 275 | "SPRITE_PRESET#4007": "UI_Gcg_Buff_Common_Shield", 276 | "SPRITE_PRESET#4008": "UI_Gcg_Buff_Common_Special", 277 | "SPRITE_PRESET#4101": "UI_Gcg_Buff_Kaeya_E", 278 | "SPRITE_PRESET#4102": "UI_Gcg_Buff_Mona_E", 279 | "SPRITE_PRESET#4103": "UI_Gcg_Buff_Noel_E", 280 | "SPRITE_PRESET#4104": "UI_Gcg_Buff_Razor_E", 281 | "SPRITE_PRESET#4105": "UI_Gcg_Buff_Xiangling_E", 282 | "SPRITE_PRESET#4106": "UI_Gcg_Buff_Yoimiya_E", 283 | "SPRITE_PRESET#4201": "UI_Icon_AlchemySim_Type_Small_1", 284 | "SPRITE_PRESET#4202": "UI_Icon_AlchemySim_Type_Small_2", 285 | "SPRITE_PRESET#4203": "UI_Icon_AlchemySim_Type_Small_3", 286 | "SPRITE_PRESET#4204": "UI_Icon_AlchemySim_Type_Small_4", 287 | "SPRITE_PRESET#4205": "UI_Icon_AlchemySim_Type_Small_5", 288 | "SPRITE_PRESET#4301": "UI_Icon_LanV5GreetingCard_Friend", 289 | "SPRITE_PRESET#11001": "UI_Buff_Element02_Frost", 290 | "SPRITE_PRESET#11002": "UI_Buff_Element02_Water", 291 | "SPRITE_PRESET#11003": "UI_Buff_Element02_Fire", 292 | "SPRITE_PRESET#11004": "UI_Buff_Element02_Elect", 293 | "SPRITE_PRESET#11005": "UI_Buff_Element02_Wind", 294 | "SPRITE_PRESET#11006": "UI_Buff_Element02_Roach", 295 | "SPRITE_PRESET#11007": "UI_Buff_Element02_Grass", 296 | "SPRITE_PRESET#11021": "UI_LeyLineChallenge_Icon_ElectGrass", 297 | "SPRITE_PRESET#11022": "UI_LeyLineChallenge_Icon_ElectIce", 298 | "SPRITE_PRESET#11023": "UI_LeyLineChallenge_Icon_ElectRock", 299 | "SPRITE_PRESET#11024": "UI_LeyLineChallenge_Icon_ElectWind", 300 | "SPRITE_PRESET#11025": "UI_LeyLineChallenge_Icon_FireElect", 301 | "SPRITE_PRESET#11026": "UI_LeyLineChallenge_Icon_FireGrass", 302 | "SPRITE_PRESET#11027": "UI_LeyLineChallenge_Icon_FireIce", 303 | "SPRITE_PRESET#11028": "UI_LeyLineChallenge_Icon_FireWind", 304 | "SPRITE_PRESET#11029": "UI_LeyLineChallenge_Icon_GrassWter", 305 | "SPRITE_PRESET#11030": "UI_LeyLineChallenge_Icon_IceRock", 306 | "SPRITE_PRESET#11031": "UI_LeyLineChallenge_Icon_IceWater", 307 | "SPRITE_PRESET#11032": "UI_LeyLineChallenge_Icon_IceWind", 308 | "SPRITE_PRESET#11033": "UI_LeyLineChallenge_Icon_RockFire", 309 | "SPRITE_PRESET#11034": "UI_LeyLineChallenge_Icon_WaterElect", 310 | "SPRITE_PRESET#11035": "UI_LeyLineChallenge_Icon_WaterFire", 311 | "SPRITE_PRESET#11036": "UI_LeyLineChallenge_Icon_WaterRock", 312 | "SPRITE_PRESET#11037": "UI_LeyLineChallenge_Icon_WindWater", 313 | "SPRITE_PRESET#21001": "UI_DisplayItemIcon_410030", 314 | "SPRITE_PRESET#21002": "UI_Icon_AutoChess_Text_Heal", 315 | } 316 | GI_SPRITE_PRESET_MAP: Final[dict[str, str]] = { 317 | k: f"{GI_ICON_URL_PREFIX}/{v}.webp" for k, v in _gi_sprite_preset_map.items() 318 | } 319 | -------------------------------------------------------------------------------- /hakushin/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import TYPE_CHECKING, TypeVar 5 | 6 | from .constants import ( 7 | ASCENDED_LEVEL_TO_ASCENSION, 8 | ASCENSION_TO_MAX_LEVEL, 9 | GI_SPRITE_PRESET_MAP, 10 | NOT_ASCENDED_LEVEL_TO_ASCENSION, 11 | PERCENTAGE_FIGHT_PROPS, 12 | STAT_TO_FIGHT_PROP, 13 | ) 14 | from .enums import Game 15 | 16 | if TYPE_CHECKING: 17 | from .models import gi, hsr 18 | 19 | 20 | def format_num(digits: int, calculation: float) -> str: 21 | """Format a number to a string with a fixed number of digits after the decimal point. 22 | 23 | Args: 24 | digits: Number of digits after the decimal point. 25 | calculation: Number to format. 26 | 27 | Returns: 28 | The formatted number. 29 | """ 30 | return f"{calculation:.{digits}f}" 31 | 32 | 33 | def replace_layout(text: str) -> str: 34 | """Replace the layout in a string with the corresponding word. 35 | 36 | Args: 37 | text: The text to format. 38 | 39 | Returns: 40 | The formatted text. 41 | """ 42 | if "LAYOUT" in text: 43 | brackets = re.findall(r"{LAYOUT.*?}", text) 44 | word_to_replace = re.findall(r"{LAYOUT.*?#(.*?)}", brackets[0])[0] 45 | text = text.replace("".join(brackets), word_to_replace) 46 | return text 47 | 48 | 49 | def replace_params(text: str, param_list: list[float]) -> list[str]: 50 | """Replace parameters in a string with the corresponding values. 51 | 52 | Args: 53 | text: The text to replace the parameters in. 54 | param_list: The list of parameters to replace the values with. 55 | 56 | Returns: 57 | The list of strings with the replaced parameters. 58 | """ 59 | params: list[str] = re.findall(r"{[^}]*}", text) 60 | 61 | for item in params: 62 | if "param" not in item: 63 | continue 64 | 65 | param_text = re.findall(r"{param(\d+):([^}]*)}", item)[0] 66 | param, value = param_text 67 | 68 | if value in {"F1P", "F2P"}: 69 | result = format_num(int(value[1]), param_list[int(param) - 1] * 100) 70 | text = re.sub(re.escape(item), f"{result}%", text) 71 | elif value in {"F1", "F2"}: 72 | result = format_num(int(value[1]), param_list[int(param) - 1]) 73 | text = re.sub(re.escape(item), result, text) 74 | elif value == "P": 75 | result = format_num(0, param_list[int(param) - 1] * 100) 76 | text = re.sub(re.escape(item), f"{result}%", text) 77 | elif value == "I": 78 | result = int(param_list[int(param) - 1]) 79 | text = re.sub(re.escape(item), str(round(result)), text) 80 | 81 | text = replace_layout(text) 82 | text = text.replace("{NON_BREAK_SPACE}", "") 83 | text = text.replace("#", "") 84 | return text.split("|") 85 | 86 | 87 | def replace_device_params(text: str) -> str: 88 | """Replace device parameters in a string with the corresponding values.""" 89 | # Replace '{LAYOUT_CONSOLECONTROLLER#stick}' with 'stick/' 90 | text = re.sub(r"{LAYOUT_CONSOLECONTROLLER#(.*?)}", r"\1/", text) 91 | 92 | # Replace '{LAYOUT_FALLBACK#joystick}' with 'joystick' 93 | text = re.sub(r"{LAYOUT_FALLBACK#(.*?)}", r"\1", text) 94 | 95 | return text 96 | 97 | 98 | def cleanup_text(text: str) -> str: 99 | """Remove HTML tags and sprite presets from a string. 100 | 101 | Args: 102 | text: The text to clean. 103 | 104 | Returns: 105 | The cleaned text. 106 | """ 107 | clean = re.compile(r"<.*?>|\{SPRITE_PRESET#[^\}]+\}") 108 | return re.sub(clean, "", text).replace("\\n", "\n").replace("\r\n", "\n") 109 | 110 | 111 | def replace_placeholders(text: str, param_list: list[float]) -> str: 112 | """Replace placeholders in the given text with values from the parameter list. 113 | 114 | Args: 115 | text: The text containing placeholders to be replaced. 116 | param_list: The list of parameter values. 117 | 118 | Returns: 119 | The text with placeholders replaced by their corresponding values. 120 | """ 121 | placeholders: list[str] = re.findall(r"#\d+\[[^\]]+\]%?", text) 122 | 123 | for placeholder in placeholders: 124 | try: 125 | index = int(placeholder[1]) 126 | format_ = placeholder[-1] 127 | value = param_list[index - 1] 128 | except (ValueError, IndexError): 129 | continue 130 | 131 | if format_ == "%": 132 | value *= 100 133 | text = text.replace(placeholder, f"{round(value)}{'%' if format_ == '%' else ''}") 134 | 135 | return text 136 | 137 | 138 | def get_ascension_from_level(level: int, ascended: bool, game: Game) -> int: 139 | """Get the ascension from the level and ascended status. 140 | 141 | Args: 142 | level: The level. 143 | ascended: Whether the entity is ascended. 144 | game: The game. 145 | 146 | Returns: 147 | The ascension level. 148 | """ 149 | if not ascended and level in NOT_ASCENDED_LEVEL_TO_ASCENSION[game]: 150 | return NOT_ASCENDED_LEVEL_TO_ASCENSION[game][level] 151 | 152 | for (start, end), ascension in ASCENDED_LEVEL_TO_ASCENSION[game].items(): 153 | if start <= level <= end: 154 | return ascension 155 | 156 | return 0 157 | 158 | 159 | def get_max_level_from_ascension(ascension: int, game: Game) -> int: 160 | """Get the max level from the ascension. 161 | 162 | Args: 163 | ascension: The ascension level. 164 | game: The game. 165 | 166 | Returns: 167 | The max level. 168 | """ 169 | return ASCENSION_TO_MAX_LEVEL[game][ascension] 170 | 171 | 172 | def calc_gi_chara_upgrade_stat_values( 173 | character: gi.CharacterDetail, level: int, ascended: bool 174 | ) -> dict[str, float]: 175 | """Calculate the stat values of a GI character at a certain level and ascension status. 176 | 177 | Args: 178 | character: The character to calculate the stats for. 179 | level: The level of the character. 180 | ascended: Whether the character is ascended. 181 | 182 | Returns: 183 | A dictionary of stat values. 184 | """ 185 | result: dict[str, float] = {} 186 | 187 | result["FIGHT_PROP_BASE_HP"] = character.base_hp * character.stats_modifier.hp[str(level)] 188 | result["FIGHT_PROP_BASE_ATTACK"] = character.base_atk * character.stats_modifier.atk[str(level)] 189 | result["FIGHT_PROP_BASE_DEFENSE"] = ( 190 | character.base_def * character.stats_modifier.def_[str(level)] 191 | ) 192 | 193 | ascension = get_ascension_from_level(level, ascended, Game.GI) 194 | ascension = character.stats_modifier.ascension[ascension - 1] 195 | for fight_prop, value in ascension.items(): 196 | stat = STAT_TO_FIGHT_PROP.get(fight_prop, fight_prop) 197 | if stat not in result: 198 | result[stat] = 0 199 | result[stat] += value 200 | 201 | return result 202 | 203 | 204 | def calc_hsr_chara_upgrade_stat_values( 205 | character: hsr.CharacterDetail, level: int, ascended: bool 206 | ) -> dict[str, float]: 207 | """Calculate the stat values of an HSR character at a certain level and ascension status. 208 | 209 | Args: 210 | character: The character to calculate the stats for. 211 | level: The level of the character. 212 | ascended: Whether the character is ascended. 213 | 214 | Returns: 215 | A dictionary of stat values. 216 | """ 217 | result: dict[str, float] = {} 218 | 219 | ascension = get_ascension_from_level(level, ascended, Game.HSR) 220 | stats = character.ascension_stats[str(ascension)] 221 | 222 | result["baseHP"] = stats["HPBase"] + stats["HPAdd"] * (level - 1) 223 | result["baseAttack"] = stats["AttackBase"] + stats["AttackAdd"] * (level - 1) 224 | result["baseDefence"] = stats["DefenceBase"] + stats["DefenceAdd"] * (level - 1) 225 | 226 | result["baseSpeed"] = stats["SpeedBase"] 227 | result["criticalChanceBase"] = stats["CriticalChance"] 228 | result["criticalDamageBase"] = stats["CriticalDamage"] 229 | 230 | return result 231 | 232 | 233 | def calc_weapon_upgrade_stat_values( 234 | weapon: gi.WeaponDetail, level: int, ascended: bool 235 | ) -> dict[str, float]: 236 | """Calculate the stat values of a GI weapon at a certain level and ascension. 237 | 238 | Args: 239 | weapon: The weapon to calculate the stats for. 240 | level: The level of the weapon. 241 | ascended: Whether the weapon is ascended. 242 | 243 | Returns: 244 | A dictionary of stat values. 245 | """ 246 | result: dict[str, float] = {} 247 | 248 | result["FIGHT_PROP_BASE_ATTACK"] = ( 249 | weapon.stat_modifiers["ATK"].base * weapon.stat_modifiers["ATK"].levels[str(level)] 250 | ) 251 | 252 | for fight_prop, value in weapon.stat_modifiers.items(): 253 | if fight_prop == "ATK": 254 | continue 255 | result[fight_prop] = value.base * value.levels[str(level)] 256 | 257 | ascension = get_ascension_from_level(level, ascended, Game.GI) 258 | if ascension == 0: 259 | ascension = 1 260 | ascension = weapon.ascension[str(ascension)] 261 | for fight_prop, value in ascension.items(): 262 | if fight_prop not in result: 263 | result[fight_prop] = 0 264 | result[fight_prop] += value 265 | 266 | return result 267 | 268 | 269 | def calc_light_cone_upgrade_stat_values( 270 | light_cone: hsr.LightConeDetail, level: int, ascended: bool 271 | ) -> dict[str, float]: 272 | """Calculate the stat values of an HSR light cone at a certain level and ascension status. 273 | 274 | Args: 275 | light_cone: The light cone to calculate the stats for. 276 | level: The level of the light cone. 277 | ascended: Whether the light cone is ascended. 278 | 279 | Returns: 280 | A dictionary of stat values. 281 | """ 282 | result: dict[str, float] = {} 283 | 284 | ascension = get_ascension_from_level(level, ascended, Game.HSR) 285 | stats = light_cone.ascension_stats[ascension] 286 | 287 | result["baseHP"] = stats["BaseHP"] + stats["BaseHPAdd"] * (level - 1) 288 | result["baseAttack"] = stats["BaseAttack"] + stats["BaseAttackAdd"] * (level - 1) 289 | result["baseDefence"] = stats["BaseDefence"] + stats["BaseDefenceAdd"] * (level - 1) 290 | 291 | return result 292 | 293 | 294 | def format_stat_values(values: dict[str, float]) -> dict[str, str]: 295 | """Format the stat values to a human-readable format. 296 | 297 | Percentage values will be rounded to 1 decimal, while others will be rounded to the nearest integer. 298 | 299 | Args: 300 | values: A dictionary of fight prop ID and value. 301 | 302 | Returns: 303 | A dictionary of formatted stat values. 304 | """ 305 | result: dict[str, str] = {} 306 | 307 | for fight_prop, value in values.items(): 308 | if fight_prop in PERCENTAGE_FIGHT_PROPS: 309 | # round to 1 decimal 310 | result[fight_prop] = f"{value * 100:.1f}%" 311 | else: 312 | result[fight_prop] = str(round(value)) 313 | 314 | return result 315 | 316 | 317 | T = TypeVar("T") 318 | 319 | 320 | def replace_fight_prop_with_name( 321 | values: dict[str, T], manual_weapon: dict[str, str] 322 | ) -> dict[str, T]: 323 | """Replace the fight prop with the corresponding name. 324 | 325 | Manual weapon example: https://gi.yatta.moe/api/v2/en/manualWeapon 326 | 327 | Args: 328 | values: A dictionary of fight prop ID and value. 329 | manual_weapon: A dictionary from project ambr with fight prop ID and value. 330 | 331 | Returns: 332 | A dictionary with fight props replaced by names. 333 | """ 334 | return { 335 | manual_weapon.get(fight_prop, fight_prop): value for fight_prop, value in values.items() 336 | } 337 | 338 | 339 | def get_skill_attributes(descriptions: list[str], params: list[int | float]) -> str: 340 | """Get the skill attributes from the descriptions. 341 | 342 | Args: 343 | descriptions: The list of descriptions. 344 | params: The list of parameters. 345 | 346 | Returns: 347 | A string containing the skill attributes. 348 | """ 349 | result = "" 350 | for desc in descriptions: 351 | try: 352 | k, v = replace_params(desc, params) 353 | except ValueError: 354 | continue 355 | result += f"{k}: {v}\n" 356 | return result 357 | 358 | 359 | def remove_ruby_tags(text: str) -> str: 360 | """Remove ruby tags from a string. 361 | 362 | Args: 363 | text: The text to process. 364 | 365 | Returns: 366 | The text with ruby tags removed. 367 | """ 368 | # Remove {RUBY_E#} tags 369 | text = re.sub(r"\{RUBY_E#\}", "", text) 370 | # Remove {RUBY_B...} tags 371 | text = re.sub(r"\{RUBY_B#.*?\}", "", text) 372 | return text 373 | 374 | 375 | def extract_sprite_presets(text: str) -> list[tuple[str, str]]: 376 | """Extract sprite presets from a string. 377 | 378 | Args: 379 | text: The text to process. 380 | 381 | Returns: 382 | A list of tuples containing the SPRITE_PRESET keyword and icon URL. 383 | """ 384 | result: list[tuple[str, str]] = [] 385 | 386 | for keyword, icon_url in GI_SPRITE_PRESET_MAP.items(): 387 | # keyword example: SPRITE_PRESET#21002 388 | match = re.search(rf"\{{{keyword}\}}", text) 389 | if match: 390 | result.append((keyword, icon_url)) 391 | 392 | return result 393 | -------------------------------------------------------------------------------- /hakushin/models/hsr/endgame.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Literal 4 | 5 | from pydantic import Field, field_validator, model_validator 6 | 7 | from ...enums import HSRElement, HSREndgameType 8 | from ..base import APIModel 9 | 10 | __all__ = ( 11 | "ApocBuff", 12 | "ApocDetail", 13 | "EndgameBaseModel", 14 | "EndgameBuffOptions", 15 | "EndgameHalf", 16 | "EndgameStage", 17 | "EndgameSummary", 18 | "EndgameWave", 19 | "FullApocDetail", 20 | "FullEndgameBaseModel", 21 | "FullEndgameHalf", 22 | "FullEndgameStage", 23 | "FullEndgameWave", 24 | "FullMOCDetail", 25 | "FullPFDetail", 26 | "MOCDetail", 27 | "PFDetail", 28 | "ProcessedEnemy", 29 | ) 30 | 31 | 32 | class ProcessedEnemy(APIModel): 33 | """Represents a processed enemy instance in a HSR endgame stage. 34 | 35 | Attributes: 36 | id: The unique monster ID. 37 | name: The name of the enemy. 38 | weaknesses: A list of elements this enemy is weak to. 39 | level: The level of the enemy. 40 | base_hp: The calculated HP of the enemy after all multipliers. 41 | speed: The calculated speed value. 42 | toughness: The calculated toughness value. 43 | effect_res: Total status effect resistance value. 44 | """ 45 | 46 | id: int 47 | name: str = "" 48 | weaknesses: list[HSRElement] 49 | level: int 50 | base_hp: int 51 | speed: int | None 52 | toughness: int | None 53 | effect_res: float | None 54 | 55 | 56 | class EndgameWave(APIModel): 57 | """Represents a wave of enemies in an endgame half. 58 | 59 | Attributes: 60 | enemies: A list of enemy IDs. 61 | hp_multiplier: Multiplier applied to enemy HP in this wave. 62 | """ 63 | 64 | enemies: list[int] = Field(default_factory=list) 65 | hp_multiplier: float = Field(alias="HPMultiplier", default=0) 66 | 67 | @field_validator("hp_multiplier", mode="before") 68 | @classmethod 69 | def __handle_missing_hp(cls, value: Any) -> float: 70 | return 0 if value is None else value 71 | 72 | @model_validator(mode="before") 73 | @classmethod 74 | def __extract_monster_ids(cls, values: dict[str, Any]) -> dict[str, Any]: 75 | """ 76 | If this model is being parsed from a raw dictionary with Monster1, Monster2... keys, 77 | extract them into the 'enemies' list. Otherwise, assume enemies is already provided. 78 | """ 79 | if "enemies" not in values: 80 | enemies = [] 81 | for key, val in values.items(): 82 | if key.startswith("Monster") and isinstance(val, int): 83 | enemies.append(val) 84 | values["enemies"] = enemies 85 | return values 86 | 87 | 88 | class FullEndgameWave(EndgameWave): 89 | """Represents a wave of processed enemies in an endgame half. 90 | 91 | Attributes: 92 | enemies: A list of processed enemy instances. 93 | hp_multiplier: Multiplier applied to enemy HP in this wave. 94 | """ 95 | 96 | enemies: list[ProcessedEnemy] = Field(default_factory=list) # type: ignore 97 | 98 | 99 | class EndgameHalf(APIModel): 100 | """Represents one half of an endgame stage (first or second). 101 | 102 | Attributes: 103 | hlg_id: ID of the HardLevelGroup used to determine difficulty scaling. 104 | hlg_level: Level of the HardLevelGroup (affects enemy stats). 105 | eg_id: ID of the EliteGroup (affects enemy traits). 106 | waves: List of enemy waves in this half. 107 | """ 108 | 109 | hlg_id: int = Field(alias="HardLevelGroup", default=1) 110 | hlg_level: int = Field(alias="Level", default=1) 111 | eg_id: int = Field(alias="EliteGroup", default=1) 112 | waves: list[EndgameWave] = Field(alias="MonsterList") 113 | 114 | 115 | class FullEndgameHalf(EndgameHalf): 116 | """Represents one half of an endgame stage (first or second) with processed enemies. 117 | 118 | Attributes: 119 | hlg_id: ID of the HardLevelGroup used to determine difficulty scaling. 120 | hlg_level: Level of the HardLevelGroup (affects enemy stats). 121 | eg_id: ID of the EliteGroup (affects enemy traits). 122 | waves: List of enemy waves in this half with processed enemies. 123 | """ 124 | 125 | waves: list[FullEndgameWave] = Field(alias="MonsterList") 126 | 127 | 128 | class EndgameStage(APIModel): 129 | """Represents a stage in an endgame mode. 130 | 131 | Attributes: 132 | id: Unique ID of the stage. 133 | name: Stage name. 134 | first_half_weaknesses: Elements that enemies in the first half are weak to. 135 | second_half_weaknesses: Elements that enemies in the second half are weak to. 136 | first_half: The first half of the stage. 137 | second_half: The second half of the stage. 138 | """ 139 | 140 | id: int = Field(alias="Id") 141 | name: str = Field(alias="Name") 142 | 143 | first_half_weaknesses: list[HSRElement] = Field(alias="DamageType1") 144 | second_half_weaknesses: list[HSRElement] = Field(alias="DamageType2") 145 | 146 | first_half: EndgameHalf = Field(alias="EventIDList1") 147 | second_half: EndgameHalf | None = Field(alias="EventIDList2") 148 | 149 | @model_validator(mode="before") 150 | @classmethod 151 | def __unwrap_event_lists(cls, values: dict[str, Any]) -> dict[str, Any]: 152 | if "EventIDList1" in values and isinstance(values["EventIDList1"], list): 153 | values["EventIDList1"] = values["EventIDList1"][0] 154 | 155 | if "EventIDList2" in values: 156 | if isinstance(values["EventIDList2"], list) and values["EventIDList2"]: 157 | values["EventIDList2"] = values["EventIDList2"][0] 158 | elif not values["EventIDList2"]: 159 | values["EventIDList2"] = None # Let Pydantic handle it as optional 160 | else: 161 | values["EventIDList2"] = None 162 | 163 | return values 164 | 165 | 166 | class FullEndgameStage(EndgameStage): 167 | """Represents a stage in an endgame mode with processed enemies. 168 | 169 | Attributes: 170 | first_half: The first half of the stage with processed enemies. 171 | second_half: The second half of the stage with processed enemies. 172 | """ 173 | 174 | first_half: FullEndgameHalf = Field(alias="EventIDList1") 175 | second_half: FullEndgameHalf | None = Field(alias="EventIDList2") 176 | 177 | 178 | class EndgameBaseModel(APIModel): 179 | """Abstract base class for all HSR endgame modes. 180 | 181 | Attributes: 182 | id: Unique ID of the endgame event. 183 | name: Display name of the event. 184 | begin_time: Event start timestamp. 185 | end_time: Event end timestamp. 186 | stages: List of stages in this endgame mode. 187 | """ 188 | 189 | id: int = Field(alias="Id") 190 | name: str = Field(alias="Name", default="") 191 | begin_time: str = Field(alias="BeginTime", default="") 192 | end_time: str = Field(alias="EndTime", default="") 193 | stages: list[EndgameStage] = Field(alias="Level") 194 | 195 | @field_validator("name", "begin_time", "end_time", mode="before") 196 | @classmethod 197 | def __handle_missing_fields(cls, value: Any) -> str: 198 | return "" if value is None else value 199 | 200 | 201 | class FullEndgameBaseModel(EndgameBaseModel): 202 | """Endgame base model with processed enemies. 203 | 204 | Attributes: 205 | id: Unique ID of the endgame event. 206 | name: Display name of the event. 207 | begin_time: Event start timestamp. 208 | end_time: Event end timestamp. 209 | stages: List of stages in this endgame mode with processed enemies. 210 | """ 211 | 212 | stages: list[FullEndgameStage] = Field(alias="Level") 213 | 214 | 215 | class EndgameSummary(APIModel): 216 | """Summary metadata for an HSR endgame event. 217 | 218 | Attributes: 219 | id: ID of the endgame. 220 | type: The type/category of the endgame. 221 | names: Dictionary containing localized names in English (en), Chinese (cn), Korean (kr), and Japanese (jp). 222 | name: The selected name to display (populated during post-processing). 223 | begin: Event start timestamp. 224 | end: Event end timestamp. 225 | """ 226 | 227 | id: int 228 | type: HSREndgameType 229 | names: dict[Literal["en", "cn", "kr", "jp"], str] 230 | name: str = Field("") # The value of this field is assigned in post processing. 231 | begin: str = Field(alias="live_begin", default="") 232 | end: str = Field(alias="live_end", default="") 233 | 234 | @model_validator(mode="before") 235 | @classmethod 236 | def __transform_names(cls, values: dict[str, Any]) -> dict[str, Any]: 237 | values["names"] = { 238 | "en": values.pop("en", "") or "", 239 | "cn": values.pop("cn", "") or "", 240 | "kr": values.pop("kr", "") or "", 241 | "jp": values.pop("jp", "") or "", 242 | } 243 | return values 244 | 245 | 246 | class MOCBase(APIModel): 247 | memory_turbulence: str = Field(alias="MemoryTurbulence") 248 | 249 | @model_validator(mode="before") 250 | @classmethod 251 | def __transform_data(cls, data: dict[str, Any]) -> dict[str, Any]: 252 | first_level = data["Level"][0] 253 | data["Name"] = first_level["GroupName"] 254 | data["MemoryTurbulence"] = first_level["Desc"] 255 | data["BeginTime"] = first_level["BeginTime"] 256 | data["EndTime"] = first_level["EndTime"] 257 | return data 258 | 259 | 260 | class MOCDetail(EndgameBaseModel, MOCBase): 261 | """Memory of Chaos event details. 262 | 263 | Attributes: 264 | memory_turbulence: Global modifier for the current MoC rotation. 265 | """ 266 | 267 | 268 | class FullMOCDetail(FullEndgameBaseModel, MOCBase): 269 | """Memory of Chaos event details with processed enemies. 270 | 271 | Attributes: 272 | memory_turbulence: Global modifier for the current MoC rotation. 273 | """ 274 | 275 | 276 | class EndgameBuffOptions(APIModel): 277 | """Represents a selectable buff modifier in endgame. 278 | 279 | Attributes: 280 | name: Name of the buff. 281 | desc: Description of the buff effect. 282 | params: List of parameters applied by the buff. 283 | """ 284 | 285 | name: str = Field(alias="Name") 286 | desc: str = Field(alias="Desc") 287 | params: list[float] = Field(alias="Param") 288 | 289 | 290 | class ApocBuff(APIModel): 291 | """Represents the fixed global buff in Apocalyptic Shadow. 292 | 293 | Attributes: 294 | name: Name of the buff. 295 | desc: Description of the effect. 296 | """ 297 | 298 | name: str = Field(alias="Name", default="") 299 | desc: str = Field(alias="Desc", default="") 300 | 301 | @field_validator("name", "desc", mode="before") 302 | @classmethod 303 | def __handle_missing_fields(cls, value: Any) -> str: 304 | return "" if value is None else value 305 | 306 | 307 | class ApocBase(APIModel): 308 | buff: ApocBuff = Field(alias="Buff") 309 | 310 | buff_list_1: list[EndgameBuffOptions] = Field(alias="BuffList1") 311 | buff_list_2: list[EndgameBuffOptions] = Field(alias="BuffList2") 312 | 313 | 314 | class ApocDetail(EndgameBaseModel, ApocBase): 315 | """Apocalyptic Shadow event details. 316 | 317 | Attributes: 318 | buff: The static global buff applied in all stages. 319 | buff_list_1: Selectable buffs for first half. 320 | buff_list_2: Selectable buffs for second half. 321 | """ 322 | 323 | 324 | class FullApocDetail(FullEndgameBaseModel, ApocBase): 325 | """Apocalyptic Shadow event details with processed enemies. 326 | 327 | Attributes: 328 | buff: The static global buff applied in all stages. 329 | buff_list_1: Selectable buffs for first half. 330 | buff_list_2: Selectable buffs for second half. 331 | """ 332 | 333 | 334 | class PFBase(APIModel): 335 | buff_options: list[EndgameBuffOptions] = Field(alias="Option") 336 | buff_suboptions: list[EndgameBuffOptions] = Field(alias="SubOption") 337 | 338 | 339 | class PFDetail(EndgameBaseModel, PFBase): 340 | """Pure Fiction event details. 341 | 342 | Attributes: 343 | buff_options: First tier of optional buffs. 344 | buff_suboptions: Second tier of optional buffs. 345 | """ 346 | 347 | 348 | class FullPFDetail(FullEndgameBaseModel, PFBase): 349 | """Pure Fiction event details with processed enemies. 350 | 351 | Attributes: 352 | buff_options: First tier of optional buffs. 353 | buff_suboptions: Second tier of optional buffs. 354 | """ 355 | 356 | @model_validator(mode="before") 357 | @classmethod 358 | def __transform_level_data(cls, data: dict[str, Any]) -> dict[str, Any]: 359 | levels = data.get("Level", []) 360 | transformed_stages = [] 361 | 362 | for raw_stage in levels: 363 | infinite_list_stage_1 = list(raw_stage["InfiniteList1"].values()) 364 | infinite_list_stage_2 = list(raw_stage["InfiniteList2"].values()) 365 | raw_stage["EventIDList1"][0]["EliteGroup"] = infinite_list_stage_1[0]["EliteGroup"] 366 | raw_stage["EventIDList2"][0]["EliteGroup"] = infinite_list_stage_2[0]["EliteGroup"] 367 | 368 | raw_stage["EventIDList1"][0]["MonsterList"] = [] 369 | 370 | for wave in infinite_list_stage_1: 371 | unique_wave_enemies = list(set(wave["MonsterGroupIDList"])) 372 | enemies_dict = {f"Monster{i}": enemy for i, enemy in enumerate(unique_wave_enemies)} 373 | param_list = wave.get("ParamList", []) 374 | enemies_dict["HPMultiplier"] = param_list[1] if len(param_list) > 1 else 0.0 375 | raw_stage["EventIDList1"][0]["MonsterList"].append(enemies_dict) 376 | 377 | raw_stage["EventIDList2"][0]["MonsterList"] = [] 378 | 379 | for wave in infinite_list_stage_2: 380 | unique_wave_enemies = list(set(wave["MonsterGroupIDList"])) 381 | enemies_dict = {f"Monster{i}": enemy for i, enemy in enumerate(unique_wave_enemies)} 382 | param_list = wave.get("ParamList", []) 383 | enemies_dict["HPMultiplier"] = param_list[1] if len(param_list) > 1 else 0.0 384 | raw_stage["EventIDList2"][0]["MonsterList"].append(enemies_dict) 385 | transformed_stages.append(raw_stage) 386 | 387 | data["Level"] = transformed_stages 388 | return data 389 | -------------------------------------------------------------------------------- /hakushin/models/zzz/character.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Literal 4 | 5 | from pydantic import Field, computed_field, field_validator, model_validator 6 | 7 | from ...constants import ZZZ_SA_RARITY_CONVERTER 8 | from ...enums import ZZZAttackType, ZZZElement, ZZZSkillType, ZZZSpecialty 9 | from ...utils import cleanup_text 10 | from ..base import APIModel 11 | from .common import ZZZExtraProp, ZZZMaterial 12 | 13 | __all__ = ( 14 | "CharaCoreSkillLevel", 15 | "CharaSkillDescParam", 16 | "CharaSkillDescParamProp", 17 | "Character", 18 | "CharacterAscension", 19 | "CharacterCoreSkill", 20 | "CharacterDetail", 21 | "CharacterExtraAscension", 22 | "CharacterInfo", 23 | "CharacterProp", 24 | "CharacterSkill", 25 | "CharacterSkillDesc", 26 | "MindscapeCinema", 27 | ) 28 | 29 | 30 | class CharacterSkin(APIModel): 31 | """Represent a character skin in Zenless Zone Zero. 32 | 33 | Contains information about character skins including IDs, names, 34 | and image URLs. 35 | 36 | Attributes: 37 | id: Unique skin identifier. 38 | name: Skin name. 39 | description: Skin description. 40 | image: Skin image URL. 41 | """ 42 | 43 | id: int = Field(alias="Id") 44 | name: str = Field(alias="Name") 45 | description: str = Field(alias="Desc") 46 | image: str = Field(alias="Image") 47 | 48 | @field_validator("image") 49 | @classmethod 50 | def __convert_image(cls, value: str) -> str: 51 | return f"https://api.hakush.in/zzz/UI/{value}.webp" 52 | 53 | 54 | class Character(APIModel): 55 | """Represent a Zenless Zone Zero character (agent). 56 | 57 | Contains basic character information including stats, element, specialty, 58 | and visual assets. Agents are the playable characters in ZZZ. 59 | 60 | Attributes: 61 | id: Unique character identifier. 62 | name: Character name/code. 63 | rarity: Character rarity rank (S or A). 64 | specialty: Character specialty type. 65 | element: Elemental attribute of the character. 66 | attack_type: Combat attack type. 67 | image: Character portrait image URL. 68 | en_description: English description text. 69 | names: Character names in different languages. 70 | """ 71 | 72 | id: int 73 | name: str = Field(alias="code") 74 | rarity: Literal["S", "A"] | None = Field(alias="rank") 75 | specialty: ZZZSpecialty = Field(alias="type") 76 | element: ZZZElement | None = None 77 | attack_type: ZZZAttackType | None = Field(None, alias="hit") 78 | image: str = Field(alias="icon") 79 | en_description: str = Field(alias="desc") 80 | names: dict[Literal["EN", "KO", "CHS", "JA"], str] 81 | skins: list[CharacterSkin] = Field(alias="skin", default_factory=list) 82 | 83 | @computed_field 84 | @property 85 | def phase_3_cinema_art(self) -> str: 86 | """Agent phase 3 mindscape cinema art. 87 | 88 | Example: https://api.hakush.in/zzz/UI/Mindscape_1041_3.webp 89 | """ 90 | return f"https://api.hakush.in/zzz/UI/Mindscape_{self.id}_3.webp" 91 | 92 | @computed_field 93 | @property 94 | def phase_2_cinema_art(self) -> str: 95 | """Agent phase 2 mindscape cinema art. 96 | 97 | Example: https://api.hakush.in/zzz/UI/Mindscape_1041_2.webp 98 | """ 99 | return f"https://api.hakush.in/zzz/UI/Mindscape_{self.id}_2.webp" 100 | 101 | @computed_field 102 | @property 103 | def phase_1_cinema_art(self) -> str: 104 | """Agent phase 1 mindscape cinema art. 105 | 106 | Example: https://api.hakush.in/zzz/UI/Mindscape_1041_1.webp 107 | """ 108 | return f"https://api.hakush.in/zzz/UI/Mindscape_{self.id}_1.webp" 109 | 110 | @computed_field 111 | @property 112 | def icon(self) -> str: 113 | """Agent icon. 114 | 115 | Example: https://api.hakush.in/zzz/UI/IconRoleSelect01.webp 116 | """ 117 | return self.image.replace("Role", "RoleSelect") 118 | 119 | @field_validator("rarity", mode="before") 120 | @classmethod 121 | def __convert_rarity(cls, value: int | None) -> Literal["S", "A"] | None: 122 | return ZZZ_SA_RARITY_CONVERTER[value] if value is not None else None 123 | 124 | @field_validator("attack_type", mode="before") 125 | @classmethod 126 | def __convert_attack_type(cls, value: int) -> ZZZAttackType | None: 127 | try: 128 | return ZZZAttackType(value) 129 | except ValueError: 130 | return None 131 | 132 | @field_validator("image") 133 | @classmethod 134 | def __convert_image(cls, value: str) -> str: 135 | return f"https://api.hakush.in/zzz/UI/{value}.webp" 136 | 137 | @model_validator(mode="before") 138 | @classmethod 139 | def __pop_names(cls, values: dict[str, Any]) -> dict[str, Any]: 140 | values["names"] = { 141 | "EN": values.pop("EN"), 142 | "KO": values.pop("KO"), 143 | "CHS": values.pop("CHS"), 144 | "JA": values.pop("JA"), 145 | } 146 | return values 147 | 148 | @field_validator("skins", mode="before") 149 | @classmethod 150 | def __convert_skins(cls, value: dict[str, dict[str, Any]]) -> list[CharacterSkin]: 151 | return [CharacterSkin(Id=int(k), **v) for k, v in value.items()] 152 | 153 | 154 | class CharacterProp(APIModel): 155 | """Represent a character property in Zenless Zone Zero. 156 | 157 | Properties include elements, weapon types, attack types, and other 158 | character attributes that are referenced by ID and name. 159 | 160 | Attributes: 161 | id: Unique property identifier. 162 | name: Human-readable property name. 163 | """ 164 | 165 | id: int 166 | name: str 167 | 168 | @model_validator(mode="before") 169 | @classmethod 170 | def __transform(cls, values: dict[str, Any] | Literal[0]) -> dict[str, Any]: 171 | if values == 0: 172 | return {"id": 0, "name": "Unknown"} 173 | first_item = next(iter(values.items())) 174 | return {"id": first_item[0], "name": first_item[1]} 175 | 176 | 177 | class CharacterInfo(APIModel): 178 | """Contain detailed character lore and background information. 179 | 180 | Provides extensive character details including personal information, 181 | impressions, descriptions, and unlock requirements. 182 | 183 | Attributes: 184 | birthday: Character birth date. 185 | full_name: Character's complete name. 186 | gender: Character gender. 187 | female_impression: Female player impression text. 188 | male_impression: Male player impression text. 189 | outlook_desc: Character outlook description. 190 | profile_desc: Character profile description. 191 | faction: Character faction or group. 192 | unlock_conditions: List of conditions to unlock the character. 193 | """ 194 | 195 | birthday: str = Field(alias="Birthday") 196 | full_name: str = Field(alias="FullName") 197 | gender: str = Field(alias="Gender") 198 | female_impression: str = Field(alias="ImpressionF") 199 | male_impression: str = Field(alias="ImpressionM") 200 | outlook_desc: str = Field(alias="OutlookDesc") 201 | profile_desc: str = Field(alias="ProfileDesc") 202 | faction: str = Field(alias="Race") 203 | unlock_conditions: list[str] = Field(alias="UnlockCondition") 204 | 205 | @field_validator("female_impression", "male_impression", "outlook_desc", "profile_desc") 206 | @classmethod 207 | def __cleanup_text(cls, value: str) -> str: 208 | return cleanup_text(value) 209 | 210 | 211 | class MindscapeCinema(APIModel): 212 | """Represent a character mindscape cinema level (constellation equivalent). 213 | 214 | Mindscape cinemas are upgrades that enhance character abilities 215 | and provide new effects when unlocked. 216 | 217 | Attributes: 218 | level: Cinema level (1-6). 219 | name: Cinema ability name. 220 | description: Primary effect description. 221 | description2: Secondary effect description. 222 | """ 223 | 224 | level: int = Field(alias="Level") 225 | name: str = Field(alias="Name") 226 | description: str = Field(alias="Desc") 227 | description2: str = Field(alias="Desc2") 228 | 229 | @field_validator("description", "description2") 230 | @classmethod 231 | def __cleanup_text(cls, value: str) -> str: 232 | return cleanup_text(value) 233 | 234 | 235 | class CharacterAscension(APIModel): 236 | """Represent character ascension phase data. 237 | 238 | Contains stat bonuses and material requirements for each 239 | character ascension phase. 240 | 241 | Attributes: 242 | max_hp: Maximum HP bonus at this phase. 243 | attack: Attack stat bonus. 244 | defense: Defense stat bonus. 245 | max_level: Maximum level achievable in this phase. 246 | min_level: Minimum level for this phase. 247 | materials: Required materials for ascension. 248 | """ 249 | 250 | max_hp: int = Field(alias="HpMax") 251 | attack: int = Field(alias="Attack") 252 | defense: int = Field(alias="Defence") 253 | max_level: int = Field(alias="LevelMax") 254 | min_level: int = Field(alias="LevelMin") 255 | materials: list[ZZZMaterial] = Field(alias="Materials") 256 | 257 | @field_validator("materials", mode="before") 258 | @classmethod 259 | def __convert_materials(cls, value: dict[str, int]) -> list[ZZZMaterial]: 260 | return [ZZZMaterial(id=int(k), amount=v) for k, v in value.items()] 261 | 262 | 263 | class CharacterExtraAscension(APIModel): 264 | """Represent character bonus ascension data. 265 | 266 | Contains additional ascension bonuses and properties that are 267 | granted beyond the standard ascension phases. 268 | 269 | Attributes: 270 | max_level: Maximum level for this bonus phase. 271 | props: Additional properties and bonuses gained. 272 | """ 273 | 274 | max_level: int = Field(alias="MaxLevel") 275 | props: list[ZZZExtraProp] = Field(alias="Extra") 276 | 277 | @field_validator("props", mode="before") 278 | @classmethod 279 | def __convert_props(cls, value: dict[str, dict[str, Any]]) -> list[ZZZExtraProp]: 280 | return [ZZZExtraProp(**data) for data in value.values()] 281 | 282 | 283 | class CharaSkillDescParamProp(APIModel): 284 | """Represent skill description parameter properties. 285 | 286 | Contains numerical properties for skill parameter calculations 287 | including base values, growth rates, and formatting. 288 | 289 | Attributes: 290 | main: Base parameter value. 291 | growth: Growth rate per level. 292 | format: Display formatting specification. 293 | """ 294 | 295 | main: int = Field(alias="Main") 296 | growth: int = Field(alias="Growth") 297 | format: str = Field(alias="Format") 298 | 299 | 300 | class CharaSkillDescParam(APIModel): 301 | """Represent a skill description parameter. 302 | 303 | Contains parameter information for skill descriptions including 304 | names, descriptions, and numerical properties. 305 | 306 | Attributes: 307 | name: Parameter name. 308 | description: Parameter description. 309 | params: Dictionary of parameter properties. 310 | """ 311 | 312 | name: str = Field(alias="Name") 313 | description: str = Field(alias="Desc") 314 | params: dict[str, CharaSkillDescParamProp] | None = Field(None, alias="Param") 315 | 316 | 317 | class CharacterSkillDesc(APIModel): 318 | """Represent a character skill description entry. 319 | 320 | Contains detailed information about specific skill effects 321 | including names, descriptions, and parameters. 322 | 323 | Attributes: 324 | name: Skill description name. 325 | description: Skill effect description. 326 | params: List of skill parameters. 327 | """ 328 | 329 | name: str = Field(alias="Name") 330 | description: str | None = Field(None, alias="Desc") 331 | params: list[CharaSkillDescParam] | None = Field(None, alias="Param") 332 | 333 | 334 | class CharacterSkill(APIModel): 335 | """Represent a character skill with upgrade information. 336 | 337 | Contains complete skill data including descriptions, upgrade materials, 338 | and skill type classification. 339 | 340 | Attributes: 341 | descriptions: List of skill effect descriptions. 342 | materials: Required materials for skill upgrades by level. 343 | type: Skill type classification. 344 | """ 345 | 346 | descriptions: list[CharacterSkillDesc] = Field(alias="Description") 347 | materials: dict[str, list[ZZZMaterial]] = Field(alias="Material") 348 | type: ZZZSkillType = Field(alias="Type") 349 | 350 | @field_validator("materials", mode="before") 351 | @classmethod 352 | def __convert_materials(cls, value: dict[str, dict[str, int]]) -> dict[str, list[ZZZMaterial]]: 353 | return { 354 | k: [ZZZMaterial(id=int(k), amount=v) for k, v in data.items()] 355 | for k, data in value.items() 356 | } 357 | 358 | 359 | class CharaCoreSkillLevel(APIModel): 360 | """Represent a single level of a character core skill. 361 | 362 | Core skills are passive abilities that can be upgraded to provide 363 | enhanced effects and bonuses. 364 | 365 | Attributes: 366 | level: Core skill level. 367 | id: Unique core skill identifier. 368 | names: Skill names in different contexts. 369 | descriptions: Skill effect descriptions. 370 | """ 371 | 372 | level: int = Field(alias="Level") 373 | id: int = Field(alias="Id") 374 | names: list[str] = Field(alias="Name") 375 | descriptions: list[str] = Field(alias="Desc") 376 | 377 | @field_validator("descriptions") 378 | @classmethod 379 | def __cleanup_text(cls, value: list[str]) -> list[str]: 380 | return [cleanup_text(v) for v in value] 381 | 382 | 383 | class CharacterCoreSkill(APIModel): 384 | """Represent a character's core skill progression system. 385 | 386 | Core skills are passive abilities that provide ongoing benefits 387 | and can be upgraded through multiple levels. 388 | 389 | Attributes: 390 | levels: Core skill levels mapped by level number. 391 | level_up_materials: Materials required for each upgrade level. 392 | """ 393 | 394 | levels: dict[int, CharaCoreSkillLevel] = Field(alias="Level") 395 | level_up_materials: dict[str, list[ZZZMaterial]] | None = Field(None, alias="Materials") 396 | 397 | @field_validator("level_up_materials", mode="before") 398 | @classmethod 399 | def __convert_materials(cls, value: dict[str, dict[str, int]]) -> dict[str, list[ZZZMaterial]]: 400 | return { 401 | k: [ZZZMaterial(id=int(k), amount=v) for k, v in data.items()] 402 | for k, data in value.items() 403 | } 404 | 405 | @field_validator("levels", mode="before") 406 | @classmethod 407 | def __intify_keys(cls, value: dict[str, dict[str, Any]]) -> dict[int, CharaCoreSkillLevel]: 408 | return {v["Level"]: CharaCoreSkillLevel(**v) for v in value.values()} 409 | 410 | 411 | class CharacterDetail(APIModel): 412 | """Provide comprehensive character information and progression data. 413 | 414 | Contains complete character details including stats, skills, ascension data, 415 | mindscape cinemas, and all progression information for a ZZZ agent. 416 | 417 | Attributes: 418 | id: Unique character identifier. 419 | image: Character portrait image URL. 420 | name: Character display name. 421 | code_name: Character code designation. 422 | rarity: Character rarity rank (S or A). 423 | specialty: Character weapon specialty. 424 | element: Character elemental attribute. 425 | attack_type: Character combat attack type. 426 | faction: Character faction or camp. 427 | gender: Character gender (M or F). 428 | info: Detailed character background information. 429 | stats: Base character statistics. 430 | mindscape_cinemas: Character mindscape cinema upgrades. 431 | ascension: Character ascension phase data. 432 | extra_ascension: Additional ascension bonuses. 433 | skills: Character skills by type. 434 | passive: Character core passive skill. 435 | """ 436 | 437 | id: int = Field(alias="Id") 438 | image: str = Field(alias="Icon") 439 | name: str = Field(alias="Name") 440 | code_name: str = Field(alias="CodeName") 441 | rarity: Literal["S", "A"] | None = Field(alias="Rarity") 442 | specialty: CharacterProp = Field(alias="WeaponType") 443 | element: CharacterProp = Field(alias="ElementType") 444 | attack_type: CharacterProp = Field(alias="HitType") 445 | faction: CharacterProp = Field(alias="Camp") 446 | gender: Literal["M", "F"] = Field(alias="Gender") 447 | info: CharacterInfo | None = Field(alias="PartnerInfo") 448 | stats: dict[str, float] = Field(alias="Stats") 449 | mindscape_cinemas: list[MindscapeCinema] = Field(alias="Talent") 450 | ascension: list[CharacterAscension] = Field(alias="Level") 451 | extra_ascension: list[CharacterExtraAscension] = Field(alias="ExtraLevel") 452 | skills: dict[ZZZSkillType, CharacterSkill] = Field(alias="Skill") 453 | passive: CharacterCoreSkill = Field(alias="Passive") 454 | skins: list[CharacterSkin] = Field(alias="Skin", default_factory=list) 455 | 456 | @computed_field 457 | @property 458 | def phase_3_cinema_art(self) -> str: 459 | """Agent phase 3 mindscape cinema art. 460 | 461 | Example: https://api.hakush.in/zzz/UI/Mindscape_1041_3.webp 462 | """ 463 | return f"https://api.hakush.in/zzz/UI/Mindscape_{self.id}_3.webp" 464 | 465 | @computed_field 466 | @property 467 | def phase_2_cinema_art(self) -> str: 468 | """Agent phase 2 mindscape cinema art. 469 | 470 | Example: https://api.hakush.in/zzz/UI/Mindscape_1041_2.webp 471 | """ 472 | return f"https://api.hakush.in/zzz/UI/Mindscape_{self.id}_2.webp" 473 | 474 | @computed_field 475 | @property 476 | def phase_1_cinema_art(self) -> str: 477 | """Agent phase 1 mindscape cinema art. 478 | 479 | Example: https://api.hakush.in/zzz/UI/Mindscape_1041_1.webp 480 | """ 481 | return f"https://api.hakush.in/zzz/UI/Mindscape_{self.id}_1.webp" 482 | 483 | @computed_field 484 | @property 485 | def icon(self) -> str: 486 | """Character icon. 487 | 488 | Example: https://api.hakush.in/zzz/UI/IconRoleSelect01.webp 489 | """ 490 | return self.image.replace("Role", "RoleSelect") 491 | 492 | @field_validator("info", mode="before") 493 | @classmethod 494 | def __convert_info(cls, value: dict[str, Any]) -> CharacterInfo | None: 495 | return None if not value else CharacterInfo(**value) 496 | 497 | @field_validator("skills", mode="before") 498 | @classmethod 499 | def __convert_skills( 500 | cls, value: dict[str, dict[str, Any]] 501 | ) -> dict[ZZZSkillType, CharacterSkill]: 502 | return { 503 | ZZZSkillType(k): CharacterSkill(Type=ZZZSkillType(k), **v) for k, v in value.items() 504 | } 505 | 506 | @field_validator("extra_ascension", mode="before") 507 | @classmethod 508 | def __convert_extra_ascension( 509 | cls, value: dict[str, dict[str, Any]] 510 | ) -> list[CharacterExtraAscension]: 511 | return [CharacterExtraAscension(**data) for data in value.values()] 512 | 513 | @field_validator("ascension", mode="before") 514 | @classmethod 515 | def __convert_ascension(cls, value: dict[str, dict[str, Any]]) -> list[CharacterAscension]: 516 | return [CharacterAscension(**data) for data in value.values()] 517 | 518 | @field_validator("mindscape_cinemas", mode="before") 519 | @classmethod 520 | def __dict_to_list(cls, value: dict[str, dict[str, Any]]) -> list[MindscapeCinema]: 521 | return [MindscapeCinema(**data) for data in value.values()] 522 | 523 | @field_validator("stats", mode="before") 524 | @classmethod 525 | def __pop_tags(cls, value: dict[str, Any]) -> dict[str, float]: 526 | value.pop("Tags", None) 527 | return value 528 | 529 | @field_validator("gender", mode="before") 530 | @classmethod 531 | def __transform_gender(cls, value: int) -> Literal["M", "F"]: 532 | # Hope I don't get cancelled for this. 533 | # Female is '2' btw. 534 | return "M" if value == 1 else "F" 535 | 536 | @field_validator("rarity", mode="before") 537 | @classmethod 538 | def __convert_rarity(cls, value: int | None) -> Literal["S", "A"] | None: 539 | return ZZZ_SA_RARITY_CONVERTER[value] if value is not None else None 540 | 541 | @field_validator("image") 542 | @classmethod 543 | def __convert_image(cls, value: str) -> str: 544 | return f"https://api.hakush.in/zzz/UI/{value}.webp" 545 | 546 | @field_validator("skins", mode="before") 547 | @classmethod 548 | def __convert_skins(cls, value: dict[str, dict[str, Any]]) -> list[CharacterSkin]: 549 | return [CharacterSkin(Id=int(k), **v) for k, v in value.items()] 550 | --------------------------------------------------------------------------------