diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..3d5f44b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,61 @@ +name: Bug Report +description: Report a bug in comlink-python +labels: ["bug"] +body: + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear description of what the bug is. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to Reproduce + description: Minimal code or steps to reproduce the issue. + placeholder: | + from swgoh_comlink import SwgohComlink + comlink = SwgohComlink() + # ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What you expected to happen. + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened. Include full traceback if applicable. + validations: + required: true + + - type: input + id: python-version + attributes: + label: Python Version + placeholder: "3.12.0" + validations: + required: true + + - type: input + id: package-version + attributes: + label: Package Version + placeholder: "1.17.0" + validations: + required: true + + - type: input + id: comlink-version + attributes: + label: Comlink Service Version (if applicable) + placeholder: "0.38.1" diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..055c815 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,25 @@ +name: Feature Request +description: Suggest a new feature or enhancement +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem or Use Case + description: What are you trying to accomplish? How are you currently working around it? + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the API or behavior you'd like to see. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Any alternative approaches you've considered. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..bfc4b4c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "ci" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b48f5f..e31b380 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35fb7f7..ef8d3c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,22 +1,12 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - name: comlink-python release on: workflow_dispatch: -# push: -# branches: -# - main - jobs: release: name: Semantic Release runs-on: ubuntu-latest - defaults: - run: - working-directory: ./scripts concurrency: group: ${{ github.workflow }}-release-${{ github.ref_name }} @@ -37,38 +27,50 @@ jobs: ref: ${{ github.ref_name }} fetch-depth: 0 - # Install dependencies + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies - run: | - python -m pip install uv - uv pip install hatch + run: uv pip install --system hatch git-changelog # Update version - name: Update version - run: uv run hatch version minor + run: hatch version minor # Update CHANGELOG.md - name: Update CHANGELOG.md - run: | - uv pip install git-changelog - git-changelog -B auto -Tio CHANGELOG.md -c angular -s build,deps,fix,feat,refactor -n semver + run: git-changelog -B auto -Tio CHANGELOG.md -c angular -s build,deps,fix,feat,refactor -n semver - # Tag the release in GitHub - - name: Tag the release + # Commit version bump and changelog, then tag and push + - name: Commit and tag run: | VERSION=$(hatch version) - git config user.name "$(git log -n 1 --pretty=format:%an)" - git config user.email "$(git log -n 1 --pretty=format:%ae)" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add src/swgoh_comlink/version.py CHANGELOG.md + git commit -m "chore(release): bump version to $VERSION [skip ci]" git tag -a "v$VERSION" -m "Release version $VERSION" - git push origin "v$VERSION" + git push origin ${{ github.ref_name }} --follow-tags env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Build the package using python3 -m build + # Build the package - name: Build the package - run: uv run hatch build + run: hatch build - # Publish the package to PyPI + # Upload build artifacts for the publish job + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + # Publish the package to PyPI pypi-publish: runs-on: ubuntu-latest needs: @@ -85,6 +87,9 @@ jobs: steps: - name: Retrieve release distributions uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ - name: Publish release distributions to PyPI - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc + uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 diff --git a/.gitignore b/.gitignore index a7ef237..79df143 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,13 @@ __pycache__/ # C extensions *.so +# macOS +.DS_Store + +# Tool caches +.ruff_cache/ +.claude/ + # GitHub Test Workflows .github/test.yml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc7b229..2703363 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,7 +78,7 @@ Be respectful, constructive, and patient. We're all here because we enjoy the ga source .venv/bin/activate # Linux/macOS # .venv\Scripts\activate # Windows - uv pip install -e ".[dev]" + uv sync ``` 4. **Verify the installation** @@ -106,8 +106,18 @@ This starts comlink on `http://localhost:3000`, which is the default URL the lib ``` comlink-python/ ├── .github/ -│ └── workflows/ -│ └── release.yml # Semantic release → PyPI publish +│ ├── ISSUE_TEMPLATE/ +│ │ ├── bug_report.yml # Bug report template +│ │ └── feature_request.yml # Feature request template +│ ├── workflows/ +│ │ ├── ci.yml # CI pipeline (lint, type-check, test, build) +│ │ ├── commitlint.yml # Commit message validation +│ │ ├── labeler.yml # Auto-label PRs by file path +│ │ └── release.yml # Semantic release → PyPI publish +│ ├── CODEOWNERS # Code ownership +│ ├── dependabot.yml # Automated dependency updates +│ ├── labeler.yml # Label-to-path configuration +│ └── pull_request_template.md # PR checklist template ├── docs/ │ └── logging.md # Logging configuration guide ├── examples/ # Usage examples for each endpoint @@ -122,9 +132,11 @@ comlink-python/ │ ├── swgoh_comlink.py # Main SwgohComlink client class │ └── version.py # Package version (managed by hatch) ├── tests/ # Test suite +├── .commitlintrc.json # Commit message lint config (local + CI) ├── pyproject.toml # Project metadata, build config, tool settings ├── uv.lock # Locked dependency versions ├── CHANGELOG.md # Auto-generated from commit history +├── CONTRIBUTING.md # This file ├── LICENSE # MIT License └── README.md ``` @@ -384,13 +396,14 @@ Closes #12 - [ ] Existing tests still pass - [ ] Commit messages follow Angular convention - [ ] Ruff linter passes (`ruff check src/ tests/`) + - [ ] Ruff formatter passes (`ruff format --check src/ tests/`) - [ ] No unrelated changes bundled in 7. **After submitting:** CI checks (lint, tests, commit message validation, build) will run automatically. - Address any failures before requesting review — the maintainer will be notified - via CODEOWNERS once all checks are green. + Address any failures before requesting review — the maintainer is automatically + assigned via CODEOWNERS when a PR is opened. If you need to update your PR after feedback, push additional commits to the same branch on your fork. The PR updates automatically: diff --git a/README.md b/README.md index 7058481..80d7fa1 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,21 @@ # comlink-python +[![CI](https://github.com/swgoh-utils/comlink-python/actions/workflows/ci.yml/badge.svg)](https://github.com/swgoh-utils/comlink-python/actions/workflows/ci.yml) +[![PyPI version](https://badge.fury.io/py/swgoh-comlink.svg)](https://pypi.org/project/swgoh-comlink/) +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + ## Description A python wrapper for the [swgoh-comlink](https://github.com/swgoh-utils/swgoh-comlink) tool. +**Requires Python 3.10 or higher.** + ## Installation + Install from [PyPi package repository](https://pypi.org/project/swgoh-comlink/) using the following shell command. -```buildoutcfg +```bash uv pip install swgoh_comlink ``` @@ -50,27 +58,53 @@ player_roster = player_data['rosterUnit'] roster_with_stats = comlink.get_unit_stats(player_roster) ``` -Usage example with MHAC enabled: +Usage example with HMAC enabled: ```python from swgoh_comlink import SwgohComlink comlink = SwgohComlink( - url='http://localhost:3000', - access_key='public_key', + url='http://localhost:3000', + access_key='public_key', secret_key='this_string_should_be_secret' ) player_data = comlink.get_player(allycode=245866537) player_name = player_data['name'] ``` -# Parameters - -- **_url_**: the URL where the swgoh-comlink service is running. Defaults to `http://localhost:3000` -- **_access_key_**: The "public" portion of the shared key used in HMAC request signing. Defaults to `None` which disables HMAC signing of requests. Can also be read from the ACCESS_KEY environment variable. -- **_secret_key_**: The "private" portion of the key used in HMAC request signing. Defaults to `None` which disables HMAC signing of requests. Can also be read from the SECRET_KEY environment variable. - -# Logging +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `url` | `str` | `http://localhost:3000` | URL where swgoh-comlink is running | +| `stats_url` | `str` | `http://localhost:3223` | URL where swgoh-stats service is running | +| `access_key` | `str` | `None` | HMAC public key. Also reads from `ACCESS_KEY` env var | +| `secret_key` | `str` | `None` | HMAC private key. Also reads from `SECRET_KEY` env var | +| `host` | `str` | `None` | Server hostname (overrides `url` and `stats_url`) | +| `port` | `int` | `3000` | Comlink TCP port (used with `host`) | +| `stats_port` | `int` | `3223` | Stats service TCP port (used with `host`) | +| `verify_ssl` | `bool` | `True` | Enable TLS certificate verification | + +## Available Methods + +| Method | Description | +|--------|-------------| +| `get_player(allycode, player_id, enums)` | Get player data by allycode or player ID | +| `get_player_arena(allycode, player_id, player_details_only, enums)` | Get player arena profile | +| `get_guild(guild_id, include_recent_guild_activity_info, enums)` | Get guild data by guild ID | +| `get_guilds_by_name(name, start_index, count, enums)` | Search guilds by name | +| `get_guilds_by_criteria(search_criteria, start_index, count, enums)` | Search guilds by criteria | +| `get_game_data(version, include_pve_units, request_segment, enums)` | Get game data collections | +| `get_game_metadata(client_specs, enums)` | Get current game and localization versions | +| `get_localization(localization_id, locale, unzip, enums)` | Get localization bundles | +| `get_enums()` | Get game data enums | +| `get_events(enums)` | Get current game events | +| `get_leaderboard(leaderboard_type, league, division, ...)` | Get GAC leaderboard data | +| `get_guild_leaderboard(leaderboard_id, count, enums)` | Get guild leaderboard data | +| `get_unit_stats(request_payload, flags, language)` | Calculate unit stats via swgoh-stats | +| `get_latest_game_data_version()` | Get latest game data and language versions | + +## Logging Logging is handled by the [python logging module](https://docs.python.org/3/library/logging.html). For details on the logging implementation for this package, go [here](docs/logging.md). diff --git a/pyproject.toml b/pyproject.toml index 51d133a..64f2c8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["setuptools>=75.8.1", "requests>=2.32.4", "sentinels"] -build-backend = "setuptools.build_meta" +requires = ["hatchling>=1.27.0"] +build-backend = "hatchling.build" [project] name = "swgoh_comlink" @@ -16,15 +16,13 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] dependencies = [ - "hatch>=1.14.2", - "hatchling>=1.27.0", "requests>=2.32.4", - "sentinels", - "setuptools>=75.8.1", + "sentinels>=1.0", ] [project.urls] @@ -34,10 +32,9 @@ dependencies = [ [tool.pytest.ini_options] addopts = [ "--import-mode=importlib", + "--strict-markers", ] - -[tool.setuptools.dynamic] -version = { attr = "swgoh_comlink.version" } +testpaths = ["tests"] [tool.hatch.version] path = 'src/swgoh_comlink/version.py' @@ -45,30 +42,6 @@ path = 'src/swgoh_comlink/version.py' [tool.hatch.build.targets.wheel] packages = ["src/swgoh_comlink"] -[tool.semantic_release] -version_variable = [# List of possible location of version - "src/swgoh_comlink/version.py", -] -version_pattern = "src/swgoh_comlink/version.py:__version__ = '{version}'" # source location of version string -version_source = "commit" # Set version source to "commit" so that -branch = "main" # branch to make releases of -changelog_file = "CHANGELOG.md" # changelog file -build_command = "pip install -r requirements.txt && pip install build && python3 -m build" # build dists -dist_path = "dist/" # where to put dists -upload_to_release = false # auto-create GitHub release -upload_to_pypi = false # don't auto-upload to PyPI -remove_dist = true # don't remove dists -patch_without_tag = true # patch release by default - -[tool.semantic_release.branches.main] -match = "main" -prerelease = false - -[tool.semantic_release.branches.tools] -match = "2.0-development" -prerelease = true -prerelease_token = "rc" - [tool.uv.sources] swgoh-comlink = { workspace = true } @@ -109,7 +82,6 @@ ignore = [ # Existing violations to address incrementally — do not add new suppressions "src/swgoh_comlink/__init__.py" = ["UP036"] # outdated version block (sys.exit check) "src/swgoh_comlink/exceptions.py" = ["B904"] # raise-without-from (tracked in issue backlog) -"src/swgoh_comlink/swgoh_comlink.py" = ["B904"] # raise-without-from in _post() and get_enums() "src/swgoh_comlink/helpers.py" = ["B007", "B904", "SIM102"] # existing patterns to clean up [tool.ruff.lint.isort] @@ -135,6 +107,7 @@ check_untyped_defs = true source = ["swgoh_comlink"] [tool.coverage.report] +fail_under = 40 exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", diff --git a/src/swgoh_comlink/__init__.py b/src/swgoh_comlink/__init__.py index c067c9c..f8569b0 100644 --- a/src/swgoh_comlink/__init__.py +++ b/src/swgoh_comlink/__init__.py @@ -1,15 +1,7 @@ # coding=utf-8 from __future__ import annotations -import sys - -if sys.version_info[:2] < (3, 10): - exit("Python 3.10 or higher is required for this version of the swgoh-comlink package.") - from swgoh_comlink.swgoh_comlink import SwgohComlink from swgoh_comlink.version import __version__ as version -__all__ = [ - 'SwgohComlink', - 'version' - ] +__all__ = ["SwgohComlink", "version"] diff --git a/src/swgoh_comlink/exceptions.py b/src/swgoh_comlink/exceptions.py index 997e13a..98d280f 100644 --- a/src/swgoh_comlink/exceptions.py +++ b/src/swgoh_comlink/exceptions.py @@ -1,6 +1,6 @@ # coding=utf-8 """ -Custom exceptions for logging +Custom exceptions for swgoh_comlink """ from __future__ import annotations @@ -13,10 +13,11 @@ class SwgohComlinkException(Exception): """Base class for exceptions in this module.""" - def __init__(self, message) -> None: + def __init__(self, message: str | Exception) -> None: super().__init__(message) - logger.exception(f"SwgohComlinkException: {message}", exc_info=True) + # Log at error level; callers are responsible for traceback context + logger.error(f"SwgohComlinkException: {message}") class SwgohComlinkValueError(SwgohComlinkException, ValueError): - ... + """Raised when an argument value is invalid.""" diff --git a/src/swgoh_comlink/globals.py b/src/swgoh_comlink/globals.py index 3c6bd63..d8a649a 100644 --- a/src/swgoh_comlink/globals.py +++ b/src/swgoh_comlink/globals.py @@ -7,23 +7,30 @@ import logging +_LOG_FORMAT = "{asctime} | {levelname:<9} | {name:15} | {module:<14} : {funcName:>30}() [{lineno:_>5}] | {message}" + class LoggingFormatter(logging.Formatter): - """Custom logging formatter class""" + """Custom logging formatter class.""" + + def __init__(self) -> None: + super().__init__(_LOG_FORMAT, "%Y-%m-%d %H:%M:%S", style="{") - def format(self, record): - log_message_format = \ - '{asctime} | {levelname:<9} | {name:15} | {module:<14} : {funcName:>30}() [{lineno:_>5}] | {message}' - formatter = logging.Formatter(log_message_format, "%Y-%m-%d %H:%M:%S", style="{") - return formatter.format(record) + def format(self, record: logging.LogRecord) -> str: + return super().format(record) def get_logger(logger_name: str = __name__, log_level: str = "INFO") -> logging.Logger: - """Return the configured logger""" + """Return the configured logger. + + Guards against adding duplicate handlers when called more than once + for the same *logger_name*. + """ logger = logging.getLogger(logger_name) log_lvl = logging.getLevelName(log_level.upper()) logger.setLevel(log_lvl) - console_handler = logging.StreamHandler() - console_handler.setFormatter(LoggingFormatter()) - logger.addHandler(console_handler) + if not logger.handlers: + console_handler = logging.StreamHandler() + console_handler.setFormatter(LoggingFormatter()) + logger.addHandler(console_handler) return logger diff --git a/src/swgoh_comlink/helpers.py b/src/swgoh_comlink/helpers.py index 0a3e6b7..5246f96 100644 --- a/src/swgoh_comlink/helpers.py +++ b/src/swgoh_comlink/helpers.py @@ -2,6 +2,7 @@ """ Helper objects and functions for swgoh_comlink """ + from __future__ import annotations import inspect @@ -26,17 +27,18 @@ if TYPE_CHECKING: from swgoh_comlink import SwgohComlink # noqa: F401 -# Define sentinels used in parameter checking -OPTIONAL = Sentinel('NotSet') -NotSet = Sentinel('NotSet') -EMPTY = Sentinel('NotSet') -NotGiven = Sentinel('NotGiven') -REQUIRED = Sentinel('REQUIRED') -GIVEN = Sentinel('REQUIRED') -MISSING = Sentinel('REQUIRED') -SET = Sentinel('NotMissing') -MutualExclusiveRequired = Sentinel('MutualExclusiveRequired') -MutualRequiredNotSet = Sentinel('MutualExclusiveRequired') +# Define sentinels used in parameter checking. +# Each sentinel has a unique label matching its primary name for clear debugging output. +OPTIONAL = Sentinel("OPTIONAL") +NotSet = Sentinel("NotSet") +EMPTY = Sentinel("EMPTY") +NotGiven = Sentinel("NotGiven") +REQUIRED = Sentinel("REQUIRED") +GIVEN = Sentinel("GIVEN") +MISSING = Sentinel("MISSING") +SET = Sentinel("SET") +MutualExclusiveRequired = Sentinel("MutualExclusiveRequired") +MutualRequiredNotSet = Sentinel("MutualRequiredNotSet") class DataItems(IntFlag): @@ -161,6 +163,7 @@ def members(cls): class Constants: """Collection of constants used throughout the SwgohComlink project.""" + ALL = -1 CategoryDefinitions = 1 UnlockAnnouncements = 2 @@ -227,959 +230,974 @@ class Constants: RELIC_OFFSET = 2 MAX_VALUES: dict[str, int] = { - "GEAR_TIER": 13, - "UNIT_LEVEL": 85, - "RELIC_TIER": 10, - "UNIT_RARITY": 7, - "MOD_TIER": 5, # Color - "MOD_LEVEL": 15, - "MOD_RARITY": 6, # Pips - } + "GEAR_TIER": 13, + "UNIT_LEVEL": 85, + "RELIC_TIER": 10, + "UNIT_RARITY": 7, + "MOD_TIER": 5, # Color + "MOD_LEVEL": 15, + "MOD_RARITY": 6, # Pips + } LEAGUES: dict[str, int] = { - "kyber": 100, - "aurodium": 80, - "chromium": 60, - "bronzium": 40, - "carbonite": 20, - } + "kyber": 100, + "aurodium": 80, + "chromium": 60, + "bronzium": 40, + "carbonite": 20, + } DIVISIONS: dict[str, int] = {"1": 25, "2": 20, "3": 15, "4": 10, "5": 5} STAT_ENUMS: dict[str, str] = { - "0": "None", - "1": "UnitStat_Health", - "2": "UnitStat_Strength", - "3": "UnitStat_Agility", - "4": "UnitStat_Intelligence", - "5": "UnitStat_Speed", - "6": "UnitStat_AttackDamage", - "7": "UnitStat_AbilityPower", - "8": "UnitStat_Armor", - "9": "UnitStat_Suppression", - "10": "UnitStat_ArmorPenetration", - "11": "UnitStat_SuppressionPenetration", - "12": "UnitStat_DodgeRating_TU5V", - "13": "UnitStat_DeflectionRating_TU5V", - "14": "UnitStat_AttackCriticalRating_TU5V", - "15": "UnitStat_AbilityCriticalRating_TU5V", - "16": "UnitStat_CriticalDamage", - "17": "UnitStat_Accuracy", - "18": "UnitStat_Resistance", - "19": "UnitStat_DodgePercentAdditive", - "20": "UnitStat_DeflectionPercentAdditive", - "21": "UnitStat_AttackCriticalPercentAdditive", - "22": "UnitStat_AbilityCriticalPercentAdditive", - "23": "UnitStat_ArmorPercentAdditive", - "24": "UnitStat_SuppressionPercentAdditive", - "25": "UnitStat_ArmorPenetrationPercentAdditive", - "26": "UnitStat_SuppressionPenetrationPercentAdditive", - "27": "UnitStat_HealthSteal", - "28": "UnitStat_MaxShield", - "29": "UnitStat_ShieldPenetration", - "30": "UnitStat_HealthRegen", - "31": "UnitStat_AttackDamagePercentAdditive", - "32": "UnitStat_AbilityPowerPercentAdditive", - "33": "UnitStat_DodgeNegatePercentAdditive", - "34": "UnitStat_DeflectionNegatePercentAdditive", - "35": "UnitStat_AttackCriticalNegatePercentAdditive", - "36": "UnitStat_AbilityCriticalNegatePercentAdditive", - "37": "UnitStat_DodgeNegateRating", - "38": "UnitStat_DeflectionNegateRating", - "39": "UnitStat_AttackCriticalNegateRating", - "40": "UnitStat_AbilityCriticalNegateRating", - "41": "UnitStat_Offense", - "42": "UnitStat_Defense", - "43": "UnitStat_DefensePenetration", - "44": "UnitStat_EvasionRating", - "45": "UnitStat_CriticalRating", - "46": "UnitStat_EvasionNegateRating", - "47": "UnitStat_CriticalNegateRating", - "48": "UnitStat_OffensePercentAdditive", - "49": "UnitStat_DefensePercentAdditive", - "50": "UnitStat_DefensePenetrationPercentAdditive", - "51": "UnitStat_EvasionPercentAdditive", - "52": "UnitStat_EvasionNegatePercentAdditive", - "53": "UnitStat_CriticalChancePercentAdditive", - "54": "UnitStat_CriticalNegateChancePercentAdditive", - "55": "UnitStat_MaxHealthPercentAdditive", - "56": "UnitStat_MaxShieldPercentAdditive", - "57": "UnitStat_SpeedPercentAdditive", - "58": "UnitStat_CounterAttackRating", - "59": "UnitStat_Taunt", - "60": "UnitStat_DefensePenetrationTargetPercentAdditive", - "61": "UNIT_STAT_STAT_VIEW_MASTERY", - } + "0": "None", + "1": "UnitStat_Health", + "2": "UnitStat_Strength", + "3": "UnitStat_Agility", + "4": "UnitStat_Intelligence", + "5": "UnitStat_Speed", + "6": "UnitStat_AttackDamage", + "7": "UnitStat_AbilityPower", + "8": "UnitStat_Armor", + "9": "UnitStat_Suppression", + "10": "UnitStat_ArmorPenetration", + "11": "UnitStat_SuppressionPenetration", + "12": "UnitStat_DodgeRating_TU5V", + "13": "UnitStat_DeflectionRating_TU5V", + "14": "UnitStat_AttackCriticalRating_TU5V", + "15": "UnitStat_AbilityCriticalRating_TU5V", + "16": "UnitStat_CriticalDamage", + "17": "UnitStat_Accuracy", + "18": "UnitStat_Resistance", + "19": "UnitStat_DodgePercentAdditive", + "20": "UnitStat_DeflectionPercentAdditive", + "21": "UnitStat_AttackCriticalPercentAdditive", + "22": "UnitStat_AbilityCriticalPercentAdditive", + "23": "UnitStat_ArmorPercentAdditive", + "24": "UnitStat_SuppressionPercentAdditive", + "25": "UnitStat_ArmorPenetrationPercentAdditive", + "26": "UnitStat_SuppressionPenetrationPercentAdditive", + "27": "UnitStat_HealthSteal", + "28": "UnitStat_MaxShield", + "29": "UnitStat_ShieldPenetration", + "30": "UnitStat_HealthRegen", + "31": "UnitStat_AttackDamagePercentAdditive", + "32": "UnitStat_AbilityPowerPercentAdditive", + "33": "UnitStat_DodgeNegatePercentAdditive", + "34": "UnitStat_DeflectionNegatePercentAdditive", + "35": "UnitStat_AttackCriticalNegatePercentAdditive", + "36": "UnitStat_AbilityCriticalNegatePercentAdditive", + "37": "UnitStat_DodgeNegateRating", + "38": "UnitStat_DeflectionNegateRating", + "39": "UnitStat_AttackCriticalNegateRating", + "40": "UnitStat_AbilityCriticalNegateRating", + "41": "UnitStat_Offense", + "42": "UnitStat_Defense", + "43": "UnitStat_DefensePenetration", + "44": "UnitStat_EvasionRating", + "45": "UnitStat_CriticalRating", + "46": "UnitStat_EvasionNegateRating", + "47": "UnitStat_CriticalNegateRating", + "48": "UnitStat_OffensePercentAdditive", + "49": "UnitStat_DefensePercentAdditive", + "50": "UnitStat_DefensePenetrationPercentAdditive", + "51": "UnitStat_EvasionPercentAdditive", + "52": "UnitStat_EvasionNegatePercentAdditive", + "53": "UnitStat_CriticalChancePercentAdditive", + "54": "UnitStat_CriticalNegateChancePercentAdditive", + "55": "UnitStat_MaxHealthPercentAdditive", + "56": "UnitStat_MaxShieldPercentAdditive", + "57": "UnitStat_SpeedPercentAdditive", + "58": "UnitStat_CounterAttackRating", + "59": "UnitStat_Taunt", + "60": "UnitStat_DefensePenetrationTargetPercentAdditive", + "61": "UNIT_STAT_STAT_VIEW_MASTERY", + } UNIT_STAT_ENUMS_MAP: dict[str, dict[str, str]] = { - "0": {"enum": "UnitStat_DEFAULT", "nameKey": "None"}, - "1": { - "enum": "UNITSTATMAXHEALTH", - "nameKey": "UnitStat_Health", - "tableKey": "MAX_HEALTH", - }, - "2": { - "enum": "UNITSTATSTRENGTH", - "nameKey": "UnitStat_Strength", - "tableKey": "STRENGTH", - }, - "3": { - "enum": "UNITSTATAGILITY", - "nameKey": "UnitStat_Agility", - "tableKey": "AGILITY", - }, - "4": { - "enum": "UNITSTATINTELLIGENCE", - "nameKey": "UnitStat_Intelligence", - "tableKey": "INTELLIGENCE", - }, - "5": { - "enum": "UNITSTATSPEED", - "nameKey": "UnitStat_Speed", - "tableKey": "SPEED", - }, - "6": { - "enum": "UNITSTATATTACKDAMAGE", - "nameKey": "UnitStat_AttackDamage", - "tableKey": "ATTACK_DAMAGE", - }, - "7": { - "enum": "UNITSTATABILITYPOWER", - "nameKey": "UnitStat_AbilityPower", - "tableKey": "ABILITY_POWER", - }, - "8": { - "enum": "UNITSTATARMOR", - "nameKey": "UnitStat_Armor", - "tableKey": "ARMOR", - }, - "9": { - "enum": "UNITSTATSUPPRESSION", - "nameKey": "UnitStat_Suppression", - "tableKey": "SUPPRESSION", - }, - "10": { - "enum": "UNITSTATARMORPENETRATION", - "nameKey": "UnitStat_ArmorPenetration", - "tableKey": "ARMOR_PENETRATION", - }, - "11": { - "enum": "UNITSTATSUPPRESSIONPENETRATION", - "nameKey": "UnitStat_SuppressionPenetration", - "tableKey": "SUPPRESSION_PENETRATION", - }, - "12": { - "enum": "UNITSTATDODGERATING", - "nameKey": "UnitStat_DodgeRating_TU5V", - "tableKey": "DODGE_RATING", - }, - "13": { - "enum": "UNITSTATDEFLECTIONRATING", - "nameKey": "UnitStat_DeflectionRating_TU5V", - "tableKey": "DEFLECTION_RATING", - }, - "14": { - "enum": "UNITSTATATTACKCRITICALRATING", - "nameKey": "UnitStat_AttackCriticalRating_TU5V", - "tableKey": "ATTACK_CRITICAL_RATING", - }, - "15": { - "enum": "UNITSTATABILITYCRITICALRATING", - "nameKey": "UnitStat_AbilityCriticalRating_TU5V", - "tableKey": "ABILITY_CRITICAL_RATING", - }, - "16": { - "enum": "UNITSTATCRITICALDAMAGE", - "nameKey": "UnitStat_CriticalDamage", - "tableKey": "CRITICAL_DAMAGE", - }, - "17": { - "enum": "UNITSTATACCURACY", - "nameKey": "UnitStat_Accuracy", - "tableKey": "ACCURACY", - }, - "18": { - "enum": "UNITSTATRESISTANCE", - "nameKey": "UnitStat_Resistance", - "tableKey": "RESISTANCE", - }, - "19": { - "enum": "UNITSTATDODGEPERCENTADDITIVE", - "nameKey": "UnitStat_DodgePercentAdditive", - "tableKey": "DODGE_PERCENT_ADDITIVE", - }, - "20": { - "enum": "UNITSTATDEFLECTIONPERCENTADDITIVE", - "nameKey": "UnitStat_DeflectionPercentAdditive", - "tableKey": "DEFLECTION_PERCENT_ADDITIVE", - }, - "21": { - "enum": "UNITSTATATTACKCRITICALPERCENTADDITIVE", - "nameKey": "UnitStat_AttackCriticalPercentAdditive", - "tableKey": "ATTACK_CRITICAL_PERCENT_ADDITIVE", - }, - "22": { - "enum": "UNITSTATABILITYCRITICALPERCENTADDITIVE", - "nameKey": "UnitStat_AbilityCriticalPercentAdditive", - "tableKey": "ABILITY_CRITICAL_PERCENT_ADDITIVE", - }, - "23": { - "enum": "UNITSTATARMORPERCENTADDITIVE", - "nameKey": "UnitStat_ArmorPercentAdditive", - "tableKey": "ARMOR_PERCENT_ADDITIVE", - }, - "24": { - "enum": "UNITSTATSUPPRESSIONPERCENTADDITIVE", - "nameKey": "UnitStat_SuppressionPercentAdditive", - "tableKey": "SUPPRESSION_PERCENT_ADDITIVE", - }, - "25": { - "enum": "UNITSTATARMORPENETRATIONPERCENTADDITIVE", - "nameKey": "UnitStat_ArmorPenetrationPercentAdditive", - "tableKey": "ARMOR_PENETRATION_PERCENT_ADDITIVE", - }, - "26": { - "enum": "UNITSTATSUPPRESSIONPENETRATIONPERCENTADDITIVE", - "nameKey": "UnitStat_SuppressionPenetrationPercentAdditive", - "tableKey": "SUPPRESSION_PENETRATION_PERCENT_ADDITIVE", - }, - "27": { - "enum": "UNITSTATHEALTHSTEAL", - "nameKey": "UnitStat_HealthSteal", - "tableKey": "HEALTH_STEAL", - }, - "28": { - "enum": "UNITSTATMAXSHIELD", - "nameKey": "UnitStat_MaxShield", - "tableKey": "MAX_SHIELD", - }, - "29": { - "enum": "UNITSTATSHIELDPENETRATION", - "nameKey": "UnitStat_ShieldPenetration", - "tableKey": "SHIELD_PENETRATION", - }, - "30": { - "enum": "UNITSTATHEALTHREGEN", - "nameKey": "UnitStat_HealthRegen", - "tableKey": "HEALTH_REGEN", - }, - "31": { - "enum": "UNITSTATATTACKDAMAGEPERCENTADDITIVE", - "nameKey": "UnitStat_AttackDamagePercentAdditive", - "tableKey": "ATTACK_DAMAGE_PERCENT_ADDITIVE", - }, - "32": { - "enum": "UNITSTATABILITYPOWERPERCENTADDITIVE", - "nameKey": "UnitStat_AbilityPowerPercentAdditive", - "tableKey": "ABILITY_POWER_PERCENT_ADDITIVE", - }, - "33": { - "enum": "UNITSTATDODGENEGATEPERCENTADDITIVE", - "nameKey": "UnitStat_DodgeNegatePercentAdditive", - "tableKey": "DODGE_NEGATE_PERCENT_ADDITIVE", - }, - "34": { - "enum": "UNITSTATDEFLECTIONNEGATEPERCENTADDITIVE", - "nameKey": "UnitStat_DeflectionNegatePercentAdditive", - "tableKey": "DEFLECTION_NEGATE_PERCENT_ADDITIVE", - }, - "35": { - "enum": "UNITSTATATTACKCRITICALNEGATEPERCENTADDITIVE", - "nameKey": "UnitStat_AttackCriticalNegatePercentAdditive", - "tableKey": "ATTACK_CRITICAL_NEGATE_PERCENT_ADDITIVE", - }, - "36": { - "enum": "UNITSTATABILITYCRITICALNEGATEPERCENTADDITIVE", - "nameKey": "UnitStat_AbilityCriticalNegatePercentAdditive", - "tableKey": "ABILITY_CRITICAL_NEGATE_PERCENT_ADDITIVE", - }, - "37": { - "enum": "UNITSTATDODGENEGATERATING", - "nameKey": "UnitStat_DodgeNegateRating", - "tableKey": "DODGE_NEGATE_RATING", - }, - "38": { - "enum": "UNITSTATDEFLECTIONNEGATERATING", - "nameKey": "UnitStat_DeflectionNegateRating", - "tableKey": "DEFLECTION_NEGATE_RATING", - }, - "39": { - "enum": "UNITSTATATTACKCRITICALNEGATERATING", - "nameKey": "UnitStat_AttackCriticalNegateRating", - "tableKey": "ATTACK_CRITICAL_NEGATE_RATING", - }, - "40": { - "enum": "UNITSTATABILITYCRITICALNEGATERATING", - "nameKey": "UnitStat_AbilityCriticalNegateRating", - "tableKey": "ABILITY_CRITICAL_NEGATE_RATING", - }, - "41": { - "enum": "UNITSTATOFFENSE", - "nameKey": "UnitStat_Offense", - "tableKey": "OFFENSE", - }, - "42": { - "enum": "UNITSTATDEFENSE", - "nameKey": "UnitStat_Defense", - "tableKey": "DEFENSE", - }, - "43": { - "enum": "UNITSTATDEFENSEPENETRATION", - "nameKey": "UnitStat_DefensePenetration", - "tableKey": "DEFENSE_PENETRATION", - }, - "44": { - "enum": "UNITSTATEVASIONRATING", - "nameKey": "UnitStat_EvasionRating", - "tableKey": "EVASION_RATING", - }, - "45": { - "enum": "UNITSTATCRITICALRATING", - "nameKey": "UnitStat_CriticalRating", - "tableKey": "CRITICAL_RATING", - }, - "46": { - "enum": "UNITSTATEVASIONNEGATERATING", - "nameKey": "UnitStat_EvasionNegateRating", - "tableKey": "EVASION_NEGATE_RATING", - }, - "47": { - "enum": "UNITSTATCRITICALNEGATERATING", - "nameKey": "UnitStat_CriticalNegateRating", - "tableKey": "CRITICAL_NEGATE_RATING", - }, - "48": { - "enum": "UNITSTATOFFENSEPERCENTADDITIVE", - "nameKey": "UnitStat_OffensePercentAdditive", - "tableKey": "OFFENSE_PERCENT_ADDITIVE", - }, - "49": { - "enum": "UNITSTATDEFENSEPERCENTADDITIVE", - "nameKey": "UnitStat_DefensePercentAdditive", - "tableKey": "DEFENSE_PERCENT_ADDITIVE", - }, - "50": { - "enum": "UNITSTATDEFENSEPENETRATIONPERCENTADDITIVE", - "nameKey": "UnitStat_DefensePenetrationPercentAdditive", - "tableKey": "DEFENSE_PENETRATION_PERCENT_ADDITIVE", - }, - "51": { - "enum": "UNITSTATEVASIONPERCENTADDITIVE", - "nameKey": "UnitStat_EvasionPercentAdditive", - "tableKey": "EVASION_PERCENT_ADDITIVE", - }, - "52": { - "enum": "UNITSTATEVASIONNEGATEPERCENTADDITIVE", - "nameKey": "UnitStat_EvasionNegatePercentAdditive", - "tableKey": "EVASION_NEGATE_PERCENT_ADDITIVE", - }, - "53": { - "enum": "UNITSTATCRITICALCHANCEPERCENTADDITIVE", - "nameKey": "UnitStat_CriticalChancePercentAdditive", - "tableKey": "CRITICAL_CHANCE_PERCENT_ADDITIVE", - }, - "54": { - "enum": "UNITSTATCRITICALNEGATECHANCEPERCENTADDITIVE", - "nameKey": "UnitStat_CriticalNegateChancePercentAdditive", - "tableKey": "CRITICAL_NEGATE_CHANCE_PERCENT_ADDITIVE", - }, - "55": { - "enum": "UNITSTATMAXHEALTHPERCENTADDITIVE", - "nameKey": "UnitStat_MaxHealthPercentAdditive", - "tableKey": "MAX_HEALTH_PERCENT_ADDITIVE", - }, - "56": { - "enum": "UNITSTATMAXSHIELDPERCENTADDITIVE", - "nameKey": "UnitStat_MaxShieldPercentAdditive", - "tableKey": "MAX_SHIELD_PERCENT_ADDITIVE", - }, - "57": { - "enum": "UNITSTATSPEEDPERCENTADDITIVE", - "nameKey": "UnitStat_SpeedPercentAdditive", - "tableKey": "SPEED_PERCENT_ADDITIVE", - }, - "58": { - "enum": "UNITSTATCOUNTERATTACKRATING", - "nameKey": "UnitStat_CounterAttackRating", - "tableKey": "COUNTER_ATTACK_RATING", - }, - "59": { - "enum": "UNITSTATTAUNT", - "nameKey": "UnitStat_Taunt", - "tableKey": "TAUNT", - }, - "60": { - "enum": "UNITSTATDEFENSEPENETRATIONTARGETPERCENTADDITIVE", - "nameKey": "UnitStat_DefensePenetrationTargetPercentAdditive", - "tableKey": "DEFENSE_PENETRATION_TARGET_PERCENT_ADDITIVE", - }, - "61": { - "enum": "UNITSTATMASTERY", - "nameKey": "UNIT_STAT_STAT_VIEW_MASTERY", - "tableKey": "MASTERY", - }, - } + "0": {"enum": "UnitStat_DEFAULT", "nameKey": "None"}, + "1": { + "enum": "UNITSTATMAXHEALTH", + "nameKey": "UnitStat_Health", + "tableKey": "MAX_HEALTH", + }, + "2": { + "enum": "UNITSTATSTRENGTH", + "nameKey": "UnitStat_Strength", + "tableKey": "STRENGTH", + }, + "3": { + "enum": "UNITSTATAGILITY", + "nameKey": "UnitStat_Agility", + "tableKey": "AGILITY", + }, + "4": { + "enum": "UNITSTATINTELLIGENCE", + "nameKey": "UnitStat_Intelligence", + "tableKey": "INTELLIGENCE", + }, + "5": { + "enum": "UNITSTATSPEED", + "nameKey": "UnitStat_Speed", + "tableKey": "SPEED", + }, + "6": { + "enum": "UNITSTATATTACKDAMAGE", + "nameKey": "UnitStat_AttackDamage", + "tableKey": "ATTACK_DAMAGE", + }, + "7": { + "enum": "UNITSTATABILITYPOWER", + "nameKey": "UnitStat_AbilityPower", + "tableKey": "ABILITY_POWER", + }, + "8": { + "enum": "UNITSTATARMOR", + "nameKey": "UnitStat_Armor", + "tableKey": "ARMOR", + }, + "9": { + "enum": "UNITSTATSUPPRESSION", + "nameKey": "UnitStat_Suppression", + "tableKey": "SUPPRESSION", + }, + "10": { + "enum": "UNITSTATARMORPENETRATION", + "nameKey": "UnitStat_ArmorPenetration", + "tableKey": "ARMOR_PENETRATION", + }, + "11": { + "enum": "UNITSTATSUPPRESSIONPENETRATION", + "nameKey": "UnitStat_SuppressionPenetration", + "tableKey": "SUPPRESSION_PENETRATION", + }, + "12": { + "enum": "UNITSTATDODGERATING", + "nameKey": "UnitStat_DodgeRating_TU5V", + "tableKey": "DODGE_RATING", + }, + "13": { + "enum": "UNITSTATDEFLECTIONRATING", + "nameKey": "UnitStat_DeflectionRating_TU5V", + "tableKey": "DEFLECTION_RATING", + }, + "14": { + "enum": "UNITSTATATTACKCRITICALRATING", + "nameKey": "UnitStat_AttackCriticalRating_TU5V", + "tableKey": "ATTACK_CRITICAL_RATING", + }, + "15": { + "enum": "UNITSTATABILITYCRITICALRATING", + "nameKey": "UnitStat_AbilityCriticalRating_TU5V", + "tableKey": "ABILITY_CRITICAL_RATING", + }, + "16": { + "enum": "UNITSTATCRITICALDAMAGE", + "nameKey": "UnitStat_CriticalDamage", + "tableKey": "CRITICAL_DAMAGE", + }, + "17": { + "enum": "UNITSTATACCURACY", + "nameKey": "UnitStat_Accuracy", + "tableKey": "ACCURACY", + }, + "18": { + "enum": "UNITSTATRESISTANCE", + "nameKey": "UnitStat_Resistance", + "tableKey": "RESISTANCE", + }, + "19": { + "enum": "UNITSTATDODGEPERCENTADDITIVE", + "nameKey": "UnitStat_DodgePercentAdditive", + "tableKey": "DODGE_PERCENT_ADDITIVE", + }, + "20": { + "enum": "UNITSTATDEFLECTIONPERCENTADDITIVE", + "nameKey": "UnitStat_DeflectionPercentAdditive", + "tableKey": "DEFLECTION_PERCENT_ADDITIVE", + }, + "21": { + "enum": "UNITSTATATTACKCRITICALPERCENTADDITIVE", + "nameKey": "UnitStat_AttackCriticalPercentAdditive", + "tableKey": "ATTACK_CRITICAL_PERCENT_ADDITIVE", + }, + "22": { + "enum": "UNITSTATABILITYCRITICALPERCENTADDITIVE", + "nameKey": "UnitStat_AbilityCriticalPercentAdditive", + "tableKey": "ABILITY_CRITICAL_PERCENT_ADDITIVE", + }, + "23": { + "enum": "UNITSTATARMORPERCENTADDITIVE", + "nameKey": "UnitStat_ArmorPercentAdditive", + "tableKey": "ARMOR_PERCENT_ADDITIVE", + }, + "24": { + "enum": "UNITSTATSUPPRESSIONPERCENTADDITIVE", + "nameKey": "UnitStat_SuppressionPercentAdditive", + "tableKey": "SUPPRESSION_PERCENT_ADDITIVE", + }, + "25": { + "enum": "UNITSTATARMORPENETRATIONPERCENTADDITIVE", + "nameKey": "UnitStat_ArmorPenetrationPercentAdditive", + "tableKey": "ARMOR_PENETRATION_PERCENT_ADDITIVE", + }, + "26": { + "enum": "UNITSTATSUPPRESSIONPENETRATIONPERCENTADDITIVE", + "nameKey": "UnitStat_SuppressionPenetrationPercentAdditive", + "tableKey": "SUPPRESSION_PENETRATION_PERCENT_ADDITIVE", + }, + "27": { + "enum": "UNITSTATHEALTHSTEAL", + "nameKey": "UnitStat_HealthSteal", + "tableKey": "HEALTH_STEAL", + }, + "28": { + "enum": "UNITSTATMAXSHIELD", + "nameKey": "UnitStat_MaxShield", + "tableKey": "MAX_SHIELD", + }, + "29": { + "enum": "UNITSTATSHIELDPENETRATION", + "nameKey": "UnitStat_ShieldPenetration", + "tableKey": "SHIELD_PENETRATION", + }, + "30": { + "enum": "UNITSTATHEALTHREGEN", + "nameKey": "UnitStat_HealthRegen", + "tableKey": "HEALTH_REGEN", + }, + "31": { + "enum": "UNITSTATATTACKDAMAGEPERCENTADDITIVE", + "nameKey": "UnitStat_AttackDamagePercentAdditive", + "tableKey": "ATTACK_DAMAGE_PERCENT_ADDITIVE", + }, + "32": { + "enum": "UNITSTATABILITYPOWERPERCENTADDITIVE", + "nameKey": "UnitStat_AbilityPowerPercentAdditive", + "tableKey": "ABILITY_POWER_PERCENT_ADDITIVE", + }, + "33": { + "enum": "UNITSTATDODGENEGATEPERCENTADDITIVE", + "nameKey": "UnitStat_DodgeNegatePercentAdditive", + "tableKey": "DODGE_NEGATE_PERCENT_ADDITIVE", + }, + "34": { + "enum": "UNITSTATDEFLECTIONNEGATEPERCENTADDITIVE", + "nameKey": "UnitStat_DeflectionNegatePercentAdditive", + "tableKey": "DEFLECTION_NEGATE_PERCENT_ADDITIVE", + }, + "35": { + "enum": "UNITSTATATTACKCRITICALNEGATEPERCENTADDITIVE", + "nameKey": "UnitStat_AttackCriticalNegatePercentAdditive", + "tableKey": "ATTACK_CRITICAL_NEGATE_PERCENT_ADDITIVE", + }, + "36": { + "enum": "UNITSTATABILITYCRITICALNEGATEPERCENTADDITIVE", + "nameKey": "UnitStat_AbilityCriticalNegatePercentAdditive", + "tableKey": "ABILITY_CRITICAL_NEGATE_PERCENT_ADDITIVE", + }, + "37": { + "enum": "UNITSTATDODGENEGATERATING", + "nameKey": "UnitStat_DodgeNegateRating", + "tableKey": "DODGE_NEGATE_RATING", + }, + "38": { + "enum": "UNITSTATDEFLECTIONNEGATERATING", + "nameKey": "UnitStat_DeflectionNegateRating", + "tableKey": "DEFLECTION_NEGATE_RATING", + }, + "39": { + "enum": "UNITSTATATTACKCRITICALNEGATERATING", + "nameKey": "UnitStat_AttackCriticalNegateRating", + "tableKey": "ATTACK_CRITICAL_NEGATE_RATING", + }, + "40": { + "enum": "UNITSTATABILITYCRITICALNEGATERATING", + "nameKey": "UnitStat_AbilityCriticalNegateRating", + "tableKey": "ABILITY_CRITICAL_NEGATE_RATING", + }, + "41": { + "enum": "UNITSTATOFFENSE", + "nameKey": "UnitStat_Offense", + "tableKey": "OFFENSE", + }, + "42": { + "enum": "UNITSTATDEFENSE", + "nameKey": "UnitStat_Defense", + "tableKey": "DEFENSE", + }, + "43": { + "enum": "UNITSTATDEFENSEPENETRATION", + "nameKey": "UnitStat_DefensePenetration", + "tableKey": "DEFENSE_PENETRATION", + }, + "44": { + "enum": "UNITSTATEVASIONRATING", + "nameKey": "UnitStat_EvasionRating", + "tableKey": "EVASION_RATING", + }, + "45": { + "enum": "UNITSTATCRITICALRATING", + "nameKey": "UnitStat_CriticalRating", + "tableKey": "CRITICAL_RATING", + }, + "46": { + "enum": "UNITSTATEVASIONNEGATERATING", + "nameKey": "UnitStat_EvasionNegateRating", + "tableKey": "EVASION_NEGATE_RATING", + }, + "47": { + "enum": "UNITSTATCRITICALNEGATERATING", + "nameKey": "UnitStat_CriticalNegateRating", + "tableKey": "CRITICAL_NEGATE_RATING", + }, + "48": { + "enum": "UNITSTATOFFENSEPERCENTADDITIVE", + "nameKey": "UnitStat_OffensePercentAdditive", + "tableKey": "OFFENSE_PERCENT_ADDITIVE", + }, + "49": { + "enum": "UNITSTATDEFENSEPERCENTADDITIVE", + "nameKey": "UnitStat_DefensePercentAdditive", + "tableKey": "DEFENSE_PERCENT_ADDITIVE", + }, + "50": { + "enum": "UNITSTATDEFENSEPENETRATIONPERCENTADDITIVE", + "nameKey": "UnitStat_DefensePenetrationPercentAdditive", + "tableKey": "DEFENSE_PENETRATION_PERCENT_ADDITIVE", + }, + "51": { + "enum": "UNITSTATEVASIONPERCENTADDITIVE", + "nameKey": "UnitStat_EvasionPercentAdditive", + "tableKey": "EVASION_PERCENT_ADDITIVE", + }, + "52": { + "enum": "UNITSTATEVASIONNEGATEPERCENTADDITIVE", + "nameKey": "UnitStat_EvasionNegatePercentAdditive", + "tableKey": "EVASION_NEGATE_PERCENT_ADDITIVE", + }, + "53": { + "enum": "UNITSTATCRITICALCHANCEPERCENTADDITIVE", + "nameKey": "UnitStat_CriticalChancePercentAdditive", + "tableKey": "CRITICAL_CHANCE_PERCENT_ADDITIVE", + }, + "54": { + "enum": "UNITSTATCRITICALNEGATECHANCEPERCENTADDITIVE", + "nameKey": "UnitStat_CriticalNegateChancePercentAdditive", + "tableKey": "CRITICAL_NEGATE_CHANCE_PERCENT_ADDITIVE", + }, + "55": { + "enum": "UNITSTATMAXHEALTHPERCENTADDITIVE", + "nameKey": "UnitStat_MaxHealthPercentAdditive", + "tableKey": "MAX_HEALTH_PERCENT_ADDITIVE", + }, + "56": { + "enum": "UNITSTATMAXSHIELDPERCENTADDITIVE", + "nameKey": "UnitStat_MaxShieldPercentAdditive", + "tableKey": "MAX_SHIELD_PERCENT_ADDITIVE", + }, + "57": { + "enum": "UNITSTATSPEEDPERCENTADDITIVE", + "nameKey": "UnitStat_SpeedPercentAdditive", + "tableKey": "SPEED_PERCENT_ADDITIVE", + }, + "58": { + "enum": "UNITSTATCOUNTERATTACKRATING", + "nameKey": "UnitStat_CounterAttackRating", + "tableKey": "COUNTER_ATTACK_RATING", + }, + "59": { + "enum": "UNITSTATTAUNT", + "nameKey": "UnitStat_Taunt", + "tableKey": "TAUNT", + }, + "60": { + "enum": "UNITSTATDEFENSEPENETRATIONTARGETPERCENTADDITIVE", + "nameKey": "UnitStat_DefensePenetrationTargetPercentAdditive", + "tableKey": "DEFENSE_PENETRATION_TARGET_PERCENT_ADDITIVE", + }, + "61": { + "enum": "UNITSTATMASTERY", + "nameKey": "UNIT_STAT_STAT_VIEW_MASTERY", + "tableKey": "MASTERY", + }, + } MOD_SET_IDS: dict[str, str] = { - "1": "Health", - "2": "Offense", - "3": "Defense", - "4": "Speed", - "5": "Critical Chance", - "6": "Critical Damage", - "7": "Potency", - "8": "Tenacity", - } + "1": "Health", + "2": "Offense", + "3": "Defense", + "4": "Speed", + "5": "Critical Chance", + "6": "Critical Damage", + "7": "Potency", + "8": "Tenacity", + } MOD_SLOTS: dict[str, str] = { - "2": "Square", - "3": "Arrow", - "4": "Diamond", - "5": "Triangle", - "6": "Circle", - "7": "Plus/Cross", - } + "2": "Square", + "3": "Arrow", + "4": "Diamond", + "5": "Triangle", + "6": "Circle", + "7": "Plus/Cross", + } STATS: dict[str, dict] = { - "1": { - "statId": 1, - "nameKey": "UnitStat_Health", - "descKey": "UnitStatDescription_Health_TU7", - "isDecimal": False, - "name": "Health", - "detailedName": "Max Health" - }, - "2": { - "statId": 2, - "nameKey": "UnitStat_Strength", - "descKey": "UnitStatDescription_Strength", - "isDecimal": False, - "name": "Strength", - "detailedName": "Strength" - }, - "3": { - "statId": 3, - "nameKey": "UnitStat_Agility", - "descKey": "UnitStatDescription_Agility", - "isDecimal": False, - "name": "Agility", - "detailedName": "Agility" - }, - "4": { - "statId": 4, - "nameKey": "UnitStat_Intelligence_TU7", - "descKey": "UnitStatDescription_Intelligence", - "isDecimal": False, - "name": "Tactics", - "detailedName": "Tactics" - }, - "5": { - "statId": 5, - "nameKey": "UnitStat_Speed", - "descKey": "UnitStatDescription_Speed", - "isDecimal": False, - "name": "Speed", - "detailedName": "Speed" - }, - "6": { - "statId": 6, - "nameKey": "UnitStat_AttackDamage", - "descKey": "UnitStatDescription_AttackDamage", - "isDecimal": False, - "name": "Physical Damage", - "detailedName": "Physical Damage" - }, - "7": { - "statId": 7, - "nameKey": "UnitStat_AbilityPower", - "descKey": "UnitStatDescription_AbilityPower", - "isDecimal": False, - "name": "Special Damage", - "detailedName": "Special Damage" - }, - "8": { - "statId": 8, - "nameKey": "UnitStat_Armor", - "descKey": "UnitStatDescription_Armor", - "isDecimal": False, - "name": "Armor", - "detailedName": "Armor" - }, - "9": { - "statId": 9, - "nameKey": "UnitStat_Suppression", - "descKey": "UnitStatDescription_Suppression", - "isDecimal": False, - "name": "Resistance", - "detailedName": "Resistance" - }, - "10": { - "statId": 10, - "nameKey": "UnitStat_ArmorPenetration", - "descKey": "UnitStatDescription_ArmorPenetration", - "isDecimal": False, - "name": "Armor Penetration", - "detailedName": "Armor Penetration" - }, - "11": { - "statId": 11, - "nameKey": "UnitStat_SuppressionPenetration", - "descKey": "UnitStatDescription_SuppressionPenetration", - "isDecimal": False, - "name": "Resistance Penetration", - "detailedName": "Resistance Penetration" - }, - "12": { - "statId": 12, - "nameKey": "UnitStat_DodgeRating_TU5V", - "descKey": "UnitStatDescription_DodgeRating", - "isDecimal": False, - "name": "Dodge Chance", - "detailedName": "Dodge Rating" - }, - "13": { - "statId": 13, - "nameKey": "UnitStat_DeflectionRating_TU5V", - "descKey": "UnitStatDescription_DeflectionRating", - "isDecimal": False, - "name": "Deflection Chance", - "detailedName": "Deflection Rating" - }, - "14": { - "statId": 14, - "nameKey": "UnitStat_AttackCriticalRating_TU5V", - "descKey": "UnitStatDescription_AttackCriticalRating", - "isDecimal": False, - "name": "Physical Critical Chance", - "detailedName": "Physical Critical Rating" - }, - "15": { - "statId": 15, - "nameKey": "UnitStat_AbilityCriticalRating_TU5V", - "descKey": "UnitStatDescription_AbilityCriticalRating", - "isDecimal": False, - "name": "Special Critical Chance", - "detailedName": "Special Critical Rating" - }, - "16": { - "statId": 16, - "nameKey": "UnitStat_CriticalDamage", - "descKey": "UnitStatDescription_CriticalDamage", - "isDecimal": True, - "name": "Critical Damage", - "detailedName": "Critical Damage" - }, - "17": { - "statId": 17, - "nameKey": "UnitStat_Accuracy", - "descKey": "UnitStatDescription_Accuracy", - "isDecimal": True, - "name": "Potency", - "detailedName": "Potency" - }, - "18": { - "statId": 18, - "nameKey": "UnitStat_Resistance", - "descKey": "UnitStatDescription_Resistance", - "isDecimal": True, - "name": "Tenacity", - "detailedName": "Tenacity" - }, - "19": { - "statId": 19, - "nameKey": "UnitStat_DodgePercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Dodge", - "detailedName": "Dodge Percent Additive" - }, - "20": { - "statId": 20, - "nameKey": "UnitStat_DeflectionPercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Deflection", - "detailedName": "Deflection Percent Additive" - }, - "21": { - "statId": 21, - "nameKey": "UnitStat_AttackCriticalPercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Physical Critical Chance", - "detailedName": "Physical Critical Percent Additive" - }, - "22": { - "statId": 22, - "nameKey": "UnitStat_AbilityCriticalPercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Special Critical Chance", - "detailedName": "Special Critical Percent Additive" - }, - "23": { - "statId": 23, - "nameKey": "UnitStat_ArmorPercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Armor", - "detailedName": "Armor Percent Additive" - }, - "24": { - "statId": 24, - "nameKey": "UnitStat_SuppressionPercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Resistance", - "detailedName": "Resistance Percent Additive" - }, - "25": { - "statId": 25, - "nameKey": "UnitStat_ArmorPenetrationPercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Armor Penetration", - "detailedName": "Armor Penetration Percent Additive" - }, - "26": { - "statId": 26, - "nameKey": "UnitStat_SuppressionPenetrationPercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Resistance Penetration", - "detailedName": "Resistance Penetration Percent Additive" - }, - "27": { - "statId": 27, - "nameKey": "UnitStat_HealthSteal", - "descKey": "UnitStatDescription_HealthSteal", - "isDecimal": True, - "name": "Health Steal", - "detailedName": "Health Steal" - }, - "28": { - "statId": 28, - "nameKey": "UnitStat_MaxShield", - "descKey": "UnitStatDescription_MaxShield", - "isDecimal": False, - "name": "Protection", - "detailedName": "Max Protection" - }, - "29": { - "statId": 29, - "nameKey": "UnitStat_ShieldPenetration", - "descKey": "", - "isDecimal": True, - "name": "Protection Ignore", - "detailedName": "Protection Ignore" - }, - "30": { - "statId": 30, - "nameKey": "UnitStat_HealthRegen", - "descKey": "", - "isDecimal": True, - "name": "Health Regeneration", - "detailedName": "Health Regen" - }, - "31": { - "statId": 31, - "nameKey": "UnitStat_AttackDamagePercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Physical Damage", - "detailedName": "Physical Damage Percent Additive" - }, - "32": { - "statId": 32, - "nameKey": "UnitStat_AbilityPowerPercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Special Damage", - "detailedName": "Special Damage Percent Additive" - }, - "33": { - "statId": 33, - "nameKey": "UnitStat_DodgeNegatePercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Physical Accuracy", - "detailedName": "Dodge Negate Percent Additive" - }, - "34": { - "statId": 34, - "nameKey": "UnitStat_DeflectionNegatePercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Special Accuracy", - "detailedName": "Deflection Negate Percent Additive" - }, - "35": { - "statId": 35, - "nameKey": "UnitStat_AttackCriticalNegatePercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Physical Critical Avoidance", - "detailedName": "Physical Critical Negate Percent Additive" - }, - "36": { - "statId": 36, - "nameKey": "UnitStat_AbilityCriticalNegatePercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Special Critical Avoidance", - "detailedName": "Special Critical Negate Percent Additive" - }, - "37": { - "statId": 37, - "nameKey": "UnitStat_DodgeNegateRating", - "descKey": "UnitStatDescription_DodgeNegateRating", - "isDecimal": False, - "name": "Physical Accuracy", - "detailedName": "Dodge Negate Rating" - }, - "38": { - "statId": 38, - "nameKey": "UnitStat_DeflectionNegateRating", - "descKey": "UnitStatDescription_DeflectionNegateRating", - "isDecimal": False, - "name": "Special Accuracy", - "detailedName": "Deflection Negate Rating" - }, - "39": { - "statId": 39, - "nameKey": "UnitStat_AttackCriticalNegateRating", - "descKey": "UnitStatDescription_AttackCriticalNegateRating", - "isDecimal": False, - "name": "Physical Critical Avoidance", - "detailedName": "Physical Critical Negate Rating" - }, - "40": { - "statId": 40, - "nameKey": "UnitStat_AbilityCriticalNegateRating", - "descKey": "UnitStatDescription_AbilityCriticalNegateRating", - "isDecimal": False, - "name": "Special Critical Avoidance", - "detailedName": "Special Critical Negate Rating" - }, - "41": { - "statId": 41, - "nameKey": "UnitStat_Offense", - "descKey": "UnitStatDescription_Offense", - "isDecimal": False, - "name": "Offense", - "detailedName": "Offense" - }, - "42": { - "statId": 42, - "nameKey": "UnitStat_Defense", - "descKey": "UnitStatDescription_Defense", - "isDecimal": False, "name": "Defense", - "detailedName": "Defense" - }, - "43": { - "statId": 43, - "nameKey": "UnitStat_DefensePenetration", - "descKey": "UnitStatDescription_DefensePenetration", - "isDecimal": False, - "name": "Defense Penetration", - "detailedName": "Defense Penetration" - }, - "44": { - "statId": 44, - "nameKey": "UnitStat_EvasionRating", - "descKey": "UnitStatDescription_EvasionRating", - "isDecimal": False, - "name": "Evasion", - "detailedName": "Evasion Rating" - }, - "45": { - "statId": 45, - "nameKey": "UnitStat_CriticalRating", - "descKey": "UnitStatDescription_CriticalRating", - "isDecimal": False, - "name": "Critical Chance", - "detailedName": "Critical Rating" - }, - "46": { - "statId": 46, - "nameKey": "UnitStat_EvasionNegateRating", - "descKey": "UnitStatDescription_EvasionNegateRating", - "isDecimal": False, - "name": "Accuracy", - "detailedName": "Evasion Negate Rating" - }, - "47": { - "statId": 47, - "nameKey": "UnitStat_CriticalNegateRating", - "descKey": "UnitStatDescription_CriticalNegateRating", - "isDecimal": False, - "name": "Critical Avoidance", - "detailedName": "Critical Negate Rating" - }, - "48": { - "statId": 48, - "nameKey": "UnitStat_OffensePercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Offense", - "detailedName": "Offense Percent Additive" - }, - "49": { - "statId": 49, - "nameKey": "UnitStat_DefensePercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Defense", - "detailedName": "Defense Percent Additive" - }, - "50": { - "statId": 50, - "nameKey": "UnitStat_DefensePenetrationPercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Defense Penetration", - "detailedName": "Defense Penetration Percent Additive" - }, - "51": { - "statId": 51, - "nameKey": "UnitStat_EvasionPercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Evasion", - "detailedName": "Evasion Percent Additive" - }, - "52": { - "statId": 52, - "nameKey": "UnitStat_EvasionNegatePercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Accuracy", - "detailedName": "Evasion Negate Percent Additive" - }, - "53": { - "statId": 53, - "nameKey": "UnitStat_CriticalChancePercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Critical Chance", - "detailedName": "Critical Chance Percent Additive" - }, - "54": { - "statId": 54, - "nameKey": "UnitStat_CriticalNegateChancePercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Critical Avoidance", - "detailedName": "Critical Negate Chance Percent Additive" - }, - "55": { - "statId": 55, - "nameKey": "UnitStat_MaxHealthPercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Health", - "detailedName": "Max Health Percent Additive" - }, - "56": { - "statId": 56, - "nameKey": "UnitStat_MaxShieldPercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Protection", - "detailedName": "Max Protection Percent Additive" - }, - "57": { - "statId": 57, - "nameKey": "UnitStat_SpeedPercentAdditive", - "descKey": "", - "isDecimal": True, - "name": "Speed", - "detailedName": "Speed Percent Additive" - }, - "58": { - "statId": 58, - "nameKey": "UnitStat_CounterAttackRating", - "descKey": "", - "isDecimal": True, - "name": "Counter Attack", - "detailedName": "Counter Attack Rating" - }, - "59": { - "statId": 59, - "nameKey": "Combat_Buffs_TASK_NAME_2", - "descKey": "", - "isDecimal": True, - "name": "Taunt", - "detailedName": "Taunt" - }, - "60": { - "statId": 60, - "nameKey": "UnitStat_DefensePenetrationTargetPercentAdditive", - "descKey": "UnitStatDescription_DefensePenetrationTargetPercentAdditive", - "isDecimal": True, - "name": "Defense Penetration", - "detailedName": "Target Defense Penetration Percent Additive" - }, - "61": { - "statId": 61, - "nameKey": "UNIT_STAT_STAT_VIEW_MASTERY", - "descKey": "", - "isDecimal": True, - "name": "Mastery", - "detailedName": "Mastery" - } - } + "1": { + "statId": 1, + "nameKey": "UnitStat_Health", + "descKey": "UnitStatDescription_Health_TU7", + "isDecimal": False, + "name": "Health", + "detailedName": "Max Health", + }, + "2": { + "statId": 2, + "nameKey": "UnitStat_Strength", + "descKey": "UnitStatDescription_Strength", + "isDecimal": False, + "name": "Strength", + "detailedName": "Strength", + }, + "3": { + "statId": 3, + "nameKey": "UnitStat_Agility", + "descKey": "UnitStatDescription_Agility", + "isDecimal": False, + "name": "Agility", + "detailedName": "Agility", + }, + "4": { + "statId": 4, + "nameKey": "UnitStat_Intelligence_TU7", + "descKey": "UnitStatDescription_Intelligence", + "isDecimal": False, + "name": "Tactics", + "detailedName": "Tactics", + }, + "5": { + "statId": 5, + "nameKey": "UnitStat_Speed", + "descKey": "UnitStatDescription_Speed", + "isDecimal": False, + "name": "Speed", + "detailedName": "Speed", + }, + "6": { + "statId": 6, + "nameKey": "UnitStat_AttackDamage", + "descKey": "UnitStatDescription_AttackDamage", + "isDecimal": False, + "name": "Physical Damage", + "detailedName": "Physical Damage", + }, + "7": { + "statId": 7, + "nameKey": "UnitStat_AbilityPower", + "descKey": "UnitStatDescription_AbilityPower", + "isDecimal": False, + "name": "Special Damage", + "detailedName": "Special Damage", + }, + "8": { + "statId": 8, + "nameKey": "UnitStat_Armor", + "descKey": "UnitStatDescription_Armor", + "isDecimal": False, + "name": "Armor", + "detailedName": "Armor", + }, + "9": { + "statId": 9, + "nameKey": "UnitStat_Suppression", + "descKey": "UnitStatDescription_Suppression", + "isDecimal": False, + "name": "Resistance", + "detailedName": "Resistance", + }, + "10": { + "statId": 10, + "nameKey": "UnitStat_ArmorPenetration", + "descKey": "UnitStatDescription_ArmorPenetration", + "isDecimal": False, + "name": "Armor Penetration", + "detailedName": "Armor Penetration", + }, + "11": { + "statId": 11, + "nameKey": "UnitStat_SuppressionPenetration", + "descKey": "UnitStatDescription_SuppressionPenetration", + "isDecimal": False, + "name": "Resistance Penetration", + "detailedName": "Resistance Penetration", + }, + "12": { + "statId": 12, + "nameKey": "UnitStat_DodgeRating_TU5V", + "descKey": "UnitStatDescription_DodgeRating", + "isDecimal": False, + "name": "Dodge Chance", + "detailedName": "Dodge Rating", + }, + "13": { + "statId": 13, + "nameKey": "UnitStat_DeflectionRating_TU5V", + "descKey": "UnitStatDescription_DeflectionRating", + "isDecimal": False, + "name": "Deflection Chance", + "detailedName": "Deflection Rating", + }, + "14": { + "statId": 14, + "nameKey": "UnitStat_AttackCriticalRating_TU5V", + "descKey": "UnitStatDescription_AttackCriticalRating", + "isDecimal": False, + "name": "Physical Critical Chance", + "detailedName": "Physical Critical Rating", + }, + "15": { + "statId": 15, + "nameKey": "UnitStat_AbilityCriticalRating_TU5V", + "descKey": "UnitStatDescription_AbilityCriticalRating", + "isDecimal": False, + "name": "Special Critical Chance", + "detailedName": "Special Critical Rating", + }, + "16": { + "statId": 16, + "nameKey": "UnitStat_CriticalDamage", + "descKey": "UnitStatDescription_CriticalDamage", + "isDecimal": True, + "name": "Critical Damage", + "detailedName": "Critical Damage", + }, + "17": { + "statId": 17, + "nameKey": "UnitStat_Accuracy", + "descKey": "UnitStatDescription_Accuracy", + "isDecimal": True, + "name": "Potency", + "detailedName": "Potency", + }, + "18": { + "statId": 18, + "nameKey": "UnitStat_Resistance", + "descKey": "UnitStatDescription_Resistance", + "isDecimal": True, + "name": "Tenacity", + "detailedName": "Tenacity", + }, + "19": { + "statId": 19, + "nameKey": "UnitStat_DodgePercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Dodge", + "detailedName": "Dodge Percent Additive", + }, + "20": { + "statId": 20, + "nameKey": "UnitStat_DeflectionPercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Deflection", + "detailedName": "Deflection Percent Additive", + }, + "21": { + "statId": 21, + "nameKey": "UnitStat_AttackCriticalPercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Physical Critical Chance", + "detailedName": "Physical Critical Percent Additive", + }, + "22": { + "statId": 22, + "nameKey": "UnitStat_AbilityCriticalPercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Special Critical Chance", + "detailedName": "Special Critical Percent Additive", + }, + "23": { + "statId": 23, + "nameKey": "UnitStat_ArmorPercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Armor", + "detailedName": "Armor Percent Additive", + }, + "24": { + "statId": 24, + "nameKey": "UnitStat_SuppressionPercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Resistance", + "detailedName": "Resistance Percent Additive", + }, + "25": { + "statId": 25, + "nameKey": "UnitStat_ArmorPenetrationPercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Armor Penetration", + "detailedName": "Armor Penetration Percent Additive", + }, + "26": { + "statId": 26, + "nameKey": "UnitStat_SuppressionPenetrationPercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Resistance Penetration", + "detailedName": "Resistance Penetration Percent Additive", + }, + "27": { + "statId": 27, + "nameKey": "UnitStat_HealthSteal", + "descKey": "UnitStatDescription_HealthSteal", + "isDecimal": True, + "name": "Health Steal", + "detailedName": "Health Steal", + }, + "28": { + "statId": 28, + "nameKey": "UnitStat_MaxShield", + "descKey": "UnitStatDescription_MaxShield", + "isDecimal": False, + "name": "Protection", + "detailedName": "Max Protection", + }, + "29": { + "statId": 29, + "nameKey": "UnitStat_ShieldPenetration", + "descKey": "", + "isDecimal": True, + "name": "Protection Ignore", + "detailedName": "Protection Ignore", + }, + "30": { + "statId": 30, + "nameKey": "UnitStat_HealthRegen", + "descKey": "", + "isDecimal": True, + "name": "Health Regeneration", + "detailedName": "Health Regen", + }, + "31": { + "statId": 31, + "nameKey": "UnitStat_AttackDamagePercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Physical Damage", + "detailedName": "Physical Damage Percent Additive", + }, + "32": { + "statId": 32, + "nameKey": "UnitStat_AbilityPowerPercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Special Damage", + "detailedName": "Special Damage Percent Additive", + }, + "33": { + "statId": 33, + "nameKey": "UnitStat_DodgeNegatePercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Physical Accuracy", + "detailedName": "Dodge Negate Percent Additive", + }, + "34": { + "statId": 34, + "nameKey": "UnitStat_DeflectionNegatePercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Special Accuracy", + "detailedName": "Deflection Negate Percent Additive", + }, + "35": { + "statId": 35, + "nameKey": "UnitStat_AttackCriticalNegatePercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Physical Critical Avoidance", + "detailedName": "Physical Critical Negate Percent Additive", + }, + "36": { + "statId": 36, + "nameKey": "UnitStat_AbilityCriticalNegatePercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Special Critical Avoidance", + "detailedName": "Special Critical Negate Percent Additive", + }, + "37": { + "statId": 37, + "nameKey": "UnitStat_DodgeNegateRating", + "descKey": "UnitStatDescription_DodgeNegateRating", + "isDecimal": False, + "name": "Physical Accuracy", + "detailedName": "Dodge Negate Rating", + }, + "38": { + "statId": 38, + "nameKey": "UnitStat_DeflectionNegateRating", + "descKey": "UnitStatDescription_DeflectionNegateRating", + "isDecimal": False, + "name": "Special Accuracy", + "detailedName": "Deflection Negate Rating", + }, + "39": { + "statId": 39, + "nameKey": "UnitStat_AttackCriticalNegateRating", + "descKey": "UnitStatDescription_AttackCriticalNegateRating", + "isDecimal": False, + "name": "Physical Critical Avoidance", + "detailedName": "Physical Critical Negate Rating", + }, + "40": { + "statId": 40, + "nameKey": "UnitStat_AbilityCriticalNegateRating", + "descKey": "UnitStatDescription_AbilityCriticalNegateRating", + "isDecimal": False, + "name": "Special Critical Avoidance", + "detailedName": "Special Critical Negate Rating", + }, + "41": { + "statId": 41, + "nameKey": "UnitStat_Offense", + "descKey": "UnitStatDescription_Offense", + "isDecimal": False, + "name": "Offense", + "detailedName": "Offense", + }, + "42": { + "statId": 42, + "nameKey": "UnitStat_Defense", + "descKey": "UnitStatDescription_Defense", + "isDecimal": False, + "name": "Defense", + "detailedName": "Defense", + }, + "43": { + "statId": 43, + "nameKey": "UnitStat_DefensePenetration", + "descKey": "UnitStatDescription_DefensePenetration", + "isDecimal": False, + "name": "Defense Penetration", + "detailedName": "Defense Penetration", + }, + "44": { + "statId": 44, + "nameKey": "UnitStat_EvasionRating", + "descKey": "UnitStatDescription_EvasionRating", + "isDecimal": False, + "name": "Evasion", + "detailedName": "Evasion Rating", + }, + "45": { + "statId": 45, + "nameKey": "UnitStat_CriticalRating", + "descKey": "UnitStatDescription_CriticalRating", + "isDecimal": False, + "name": "Critical Chance", + "detailedName": "Critical Rating", + }, + "46": { + "statId": 46, + "nameKey": "UnitStat_EvasionNegateRating", + "descKey": "UnitStatDescription_EvasionNegateRating", + "isDecimal": False, + "name": "Accuracy", + "detailedName": "Evasion Negate Rating", + }, + "47": { + "statId": 47, + "nameKey": "UnitStat_CriticalNegateRating", + "descKey": "UnitStatDescription_CriticalNegateRating", + "isDecimal": False, + "name": "Critical Avoidance", + "detailedName": "Critical Negate Rating", + }, + "48": { + "statId": 48, + "nameKey": "UnitStat_OffensePercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Offense", + "detailedName": "Offense Percent Additive", + }, + "49": { + "statId": 49, + "nameKey": "UnitStat_DefensePercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Defense", + "detailedName": "Defense Percent Additive", + }, + "50": { + "statId": 50, + "nameKey": "UnitStat_DefensePenetrationPercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Defense Penetration", + "detailedName": "Defense Penetration Percent Additive", + }, + "51": { + "statId": 51, + "nameKey": "UnitStat_EvasionPercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Evasion", + "detailedName": "Evasion Percent Additive", + }, + "52": { + "statId": 52, + "nameKey": "UnitStat_EvasionNegatePercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Accuracy", + "detailedName": "Evasion Negate Percent Additive", + }, + "53": { + "statId": 53, + "nameKey": "UnitStat_CriticalChancePercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Critical Chance", + "detailedName": "Critical Chance Percent Additive", + }, + "54": { + "statId": 54, + "nameKey": "UnitStat_CriticalNegateChancePercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Critical Avoidance", + "detailedName": "Critical Negate Chance Percent Additive", + }, + "55": { + "statId": 55, + "nameKey": "UnitStat_MaxHealthPercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Health", + "detailedName": "Max Health Percent Additive", + }, + "56": { + "statId": 56, + "nameKey": "UnitStat_MaxShieldPercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Protection", + "detailedName": "Max Protection Percent Additive", + }, + "57": { + "statId": 57, + "nameKey": "UnitStat_SpeedPercentAdditive", + "descKey": "", + "isDecimal": True, + "name": "Speed", + "detailedName": "Speed Percent Additive", + }, + "58": { + "statId": 58, + "nameKey": "UnitStat_CounterAttackRating", + "descKey": "", + "isDecimal": True, + "name": "Counter Attack", + "detailedName": "Counter Attack Rating", + }, + "59": { + "statId": 59, + "nameKey": "Combat_Buffs_TASK_NAME_2", + "descKey": "", + "isDecimal": True, + "name": "Taunt", + "detailedName": "Taunt", + }, + "60": { + "statId": 60, + "nameKey": "UnitStat_DefensePenetrationTargetPercentAdditive", + "descKey": "UnitStatDescription_DefensePenetrationTargetPercentAdditive", + "isDecimal": True, + "name": "Defense Penetration", + "detailedName": "Target Defense Penetration Percent Additive", + }, + "61": { + "statId": 61, + "nameKey": "UNIT_STAT_STAT_VIEW_MASTERY", + "descKey": "", + "isDecimal": True, + "name": "Mastery", + "detailedName": "Mastery", + }, + } UNIT_RARITY: dict[int, str] = { - 1: "ONE_STAR", - 2: "TWO_STAR", - 3: "THREE_STAR", - 4: "FOUR_STAR", - 5: "FIVE_STAR", - 6: "SIX_STAR", - 7: "SEVEN_STAR", - } + 1: "ONE_STAR", + 2: "TWO_STAR", + 3: "THREE_STAR", + 4: "FOUR_STAR", + 5: "FIVE_STAR", + 6: "SIX_STAR", + 7: "SEVEN_STAR", + } UNIT_RARITY_NAMES: dict[str, str] = { - "ONE_STAR": "1", - "TWO_STAR": "2", - "THREE_STAR": "3", - "FOUR_STAR": "4", - "FIVE_STAR": "5", - "SIX_STAR": "6", - "SEVEN_STAR": "7", - } - - LANGUAGES: list[str] = ["chs_cn", "cht_cn", "eng_us", "fre_fr", "ger_de", "ind_id", "ita_it", "jpn_jp", "kor_kr", - "por_br", "rus_ru", "spa_xm", "tha_th", "tur_tr"] + "ONE_STAR": "1", + "TWO_STAR": "2", + "THREE_STAR": "3", + "FOUR_STAR": "4", + "FIVE_STAR": "5", + "SIX_STAR": "6", + "SEVEN_STAR": "7", + } + + LANGUAGES: list[str] = [ + "chs_cn", + "cht_cn", + "eng_us", + "fre_fr", + "ger_de", + "ind_id", + "ita_it", + "jpn_jp", + "kor_kr", + "por_br", + "rus_ru", + "spa_xm", + "tha_th", + "tur_tr", + ] RELIC_TIERS: dict[str, str] = { - "0": "LOCKED", - "1": "UNLOCKED", - "2": "1", - "3": "2", - "4": "3", - "5": "4", - "6": "5", - "7": "6", - "8": "7", - "9": "8", - "10": "9", - "11": "10" - } + "0": "LOCKED", + "1": "UNLOCKED", + "2": "1", + "3": "2", + "4": "3", + "5": "4", + "6": "5", + "7": "6", + "8": "7", + "9": "8", + "10": "9", + "11": "10", + } OMICRON_MODE: dict[int, str] = { - 0: 'Default', - 1: 'ALL', - 4: 'Raid', - 7: 'TB', - 8: 'TW', - 9: 'GAC', - 11: 'Conquest', - 12: 'Galactic Challenge', - 14: 'GAC (3v3)', - 15: 'GAC (5v5)' - } + 0: "Default", + 1: "ALL", + 4: "Raid", + 7: "TB", + 8: "TW", + 9: "GAC", + 11: "Conquest", + 12: "Galactic Challenge", + 14: "GAC (3v3)", + 15: "GAC (5v5)", + } @classmethod def get(cls, item): @@ -1187,8 +1205,11 @@ def get(cls, item): @classmethod def get_names(cls): - return [x for x in list(cls.__dict__.keys()) if not x.startswith('_') and - not isinstance(cls.__dict__[x], classmethod)] + return [ + x + for x in list(cls.__dict__.keys()) + if not x.startswith("_") and not isinstance(cls.__dict__[x], classmethod) + ] def get_raid_leaderboard_ids(campaign_data: list) -> list[str]: @@ -1220,16 +1241,16 @@ def get_raid_leaderboard_ids(campaign_data: list) -> list[str]: it is not a list or contains improperly formatted elements. """ raid_ids = [] - guild_campaigns = next((item for item in campaign_data if item.get('id') == 'GUILD'), None) - for raid in guild_campaigns['campaignMap'][0]['campaignNodeDifficultyGroup'][0]['campaignNode']: - for mission in raid['campaignNodeMission']: + guild_campaigns = next((item for item in campaign_data if item.get("id") == "GUILD"), None) + for raid in guild_campaigns["campaignMap"][0]["campaignNodeDifficultyGroup"][0]["campaignNode"]: + for mission in raid["campaignNodeMission"]: elements = [ - guild_campaigns['id'], - guild_campaigns['campaignMap'][0]['id'], - "NORMAL_DIFF", - raid['id'], - mission['id'] - ] + guild_campaigns["id"], + guild_campaigns["campaignMap"][0]["id"], + "NORMAL_DIFF", + raid["id"], + mission["id"], + ] raid_ids.append(":".join(elements)) return raid_ids @@ -1289,26 +1310,29 @@ def get_function_name() -> str: def func_timer(func): - """Decorator to record total execution time of a function to the configured logger using level DEBUG""" + """Decorator to record total execution time of a function to the configured logger using level DEBUG.""" @wraps(func) def wrap(*args, **kw): """Wrapper function""" + start = time.perf_counter() result = func(*args, **kw) + elapsed = time.perf_counter() - start + logger.debug(f"{func.__name__} executed in {elapsed:.4f}s") return result return wrap def func_debug_logger(func): - """Decorator for applying DEBUG logging to a function""" + """Decorator for applying DEBUG logging to a function.""" @wraps(func) def wrap(*args, **kw): """Wrapper function""" - logger.debug(f"{func.__name__()} called with args: {args} and kwargs: {kw}") + logger.debug(f"{func.__name__} called with args: {args} and kwargs: {kw}") result = func(*args, **kw) - logger.debug(f"{func.__name__()} Result: {result}") + logger.debug(f"{func.__name__} Result: {result}") return result return wrap @@ -1359,7 +1383,7 @@ def sanitize_allycode(allycode: str | int | Sentinel = REQUIRED) -> str: """ _orig_ac = allycode if not allycode and allycode is not GIVEN: - return '' + return "" if isinstance(allycode, int): allycode = str(allycode) if "-" in str(allycode): @@ -1384,19 +1408,20 @@ def human_time(unix_time: int | float | Sentinel = REQUIRED) -> str: is 1970-01-01 00:00:00 """ - print(f"unix_time: {unix_time}") + logger.debug(f"unix_time: {unix_time}") if unix_time is MISSING or not str(unix_time): err_msg = f"{get_function_name()}: The 'unix_time' argument is required." raise SwgohComlinkValueError(err_msg) from datetime import datetime, timezone + if isinstance(unix_time, float): unix_time = int(unix_time) if isinstance(unix_time, str): try: unix_time = int(unix_time) - except SwgohComlinkValueError: + except (ValueError, TypeError) as e: err_msg = f"{get_function_name()}: Unable to convert unix time from {type(unix_time)} to type " - raise SwgohComlinkValueError(err_msg) + raise SwgohComlinkValueError(err_msg) from e if len(str(unix_time)) >= 13: unix_time /= 1000 return datetime.fromtimestamp(unix_time, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") @@ -1515,10 +1540,10 @@ def create_localized_unit_name_dictionary(locale: str | list | Sentinel = REQUIR def get_guild_members( - comlink: SwgohComlink | Sentinel = REQUIRED, - player_id: str | Sentinel = MutualExclusiveRequired, - allycode: str | int | Sentinel = MutualExclusiveRequired, - ) -> list: + comlink: SwgohComlink | Sentinel = REQUIRED, + player_id: str | Sentinel = MutualExclusiveRequired, + allycode: str | int | Sentinel = MutualExclusiveRequired, +) -> list: """Return list of guild member player allycodes based upon provided player ID or allycode Args: @@ -1533,13 +1558,12 @@ def get_guild_members( A player_id or allycode argument is REQUIRED """ - if hasattr(comlink, '__comlink_type__'): + if hasattr(comlink, "__comlink_type__"): comlink_type = comlink.__comlink_type__ else: comlink_type = MISSING - if comlink is MISSING or comlink_type != 'SwgohComlink': - err_msg = (f"{get_function_name()}: The 'comlink' argument is required and must be an " - f"instance of SwgohComlink.") + if comlink is MISSING or comlink_type != "SwgohComlink": + err_msg = f"{get_function_name()}: The 'comlink' argument is required and must be an instance of SwgohComlink." raise SwgohComlinkValueError(err_msg) if player_id is not MutualExclusiveRequired and allycode is not MutualExclusiveRequired: @@ -1558,9 +1582,7 @@ def get_guild_members( return guild["member"] or [] -def get_current_gac_event( - comlink: SwgohComlink | Sentinel = REQUIRED - ) -> dict: +def get_current_gac_event(comlink: SwgohComlink | Sentinel = REQUIRED) -> dict: """Return the event object for the current gac season Args: @@ -1570,7 +1592,7 @@ def get_current_gac_event( Current GAC event object or empty if no event is running """ - if hasattr(comlink, '__comlink_type__'): + if hasattr(comlink, "__comlink_type__"): comlink_type = comlink.__comlink_type__ else: comlink_type = MISSING @@ -1581,14 +1603,15 @@ def get_current_gac_event( current_events = comlink.get_events() - return [event for event in current_events['gameEvent'] if event['type'] == 10][0] + gac_events = [event for event in current_events["gameEvent"] if event["type"] == 10] + if not gac_events: + raise SwgohComlinkValueError(f"{get_function_name()}: No active GAC event found.") + return gac_events[0] def get_gac_brackets( - comlink: SwgohComlink | Sentinel = REQUIRED, - league: str | Sentinel = REQUIRED, - limit: int | Sentinel = OPTIONAL - ) -> dict | None: + comlink: SwgohComlink | Sentinel = REQUIRED, league: str | Sentinel = REQUIRED, limit: int | Sentinel = OPTIONAL +) -> dict | None: """Scan currently running GAC brackets for the requested league and return them as a dictionary Args: @@ -1600,11 +1623,11 @@ def get_gac_brackets( Dictionary containing each GAC bracket as a key """ - if hasattr(comlink, '__comlink_type__'): + if hasattr(comlink, "__comlink_type__"): comlink_type = comlink.__comlink_type__ else: comlink_type = MISSING - if comlink is MISSING or comlink_type != 'SwgohComlink': + if comlink is MISSING or comlink_type != "SwgohComlink": err_msg = f"{get_function_name()}: Invalid comlink instance." raise SwgohComlinkValueError(err_msg) @@ -1628,10 +1651,10 @@ def get_gac_brackets( while number_of_players_in_bracket > 0 and bracket_iteration_limit != bracket: group_id = f"{current_event_instance}:{league}:{bracket}" group_of_8_players = comlink.get_gac_leaderboard( - leaderboard_type=4, - event_instance_id=current_event_instance, - group_id=group_id, - ) + leaderboard_type=4, + event_instance_id=current_event_instance, + group_id=group_id, + ) brackets[bracket] = brackets.get(bracket, group_of_8_players["player"]) bracket += 1 number_of_players_in_bracket = len(group_of_8_players["player"]) @@ -1672,12 +1695,11 @@ def get_current_datacron_sets(datacron_list: list) -> list: """ if not isinstance(datacron_list, list): raise SwgohComlinkValueError( - f"{get_function_name()}, 'datacron_list' must be a list, not {type(datacron_list)}" - ) - import math + f"{get_function_name()}, 'datacron_list' must be a list, not {type(datacron_list)}" + ) current_datacron_sets = [] for datacron in datacron_list: - if int(datacron["expirationTimeMs"]) > math.floor(time.time() * 1000): + if int(datacron["expirationTimeMs"]) > floor(time.time() * 1000): current_datacron_sets.append(datacron) return current_datacron_sets @@ -1696,9 +1718,7 @@ def get_tw_omicrons(skill_list: list) -> list: """ if not isinstance(skill_list, list): - raise SwgohComlinkValueError( - f"'skill_list' must be a list, not {type(skill_list)}" - ) + raise SwgohComlinkValueError(f"'skill_list' must be a list, not {type(skill_list)}") return get_omicron_skills(skill_list, 8) @@ -1708,10 +1728,11 @@ def get_playable_units(units_collection: list[dict]) -> list[dict]: if not isinstance(units_collection, list): raise SwgohComlinkValueError(f"'units_collection' must be a list, not {type(units_collection)}") - return [unit for unit in units_collection - if unit['rarity'] == 7 - and unit['obtainable'] is True - and unit['obtainableTime'] == '0'] + return [ + unit + for unit in units_collection + if unit["rarity"] == 7 and unit["obtainable"] is True and unit["obtainableTime"] == "0" + ] def get_omicron_skills(skill_list: list, omicron_type: int | list[int]) -> list: @@ -1738,7 +1759,7 @@ def get_omicron_skills(skill_list: list, omicron_type: int | list[int]) -> list: omicron_type_list = [omicron_type] if isinstance(omicron_type, int) else omicron_type - return [skill for skill in skill_list if skill['omicronMode'] in omicron_type_list] + return [skill for skill in skill_list if skill["omicronMode"] in omicron_type_list] def get_omicron_skill_tier(skill: dict) -> int | None: @@ -1766,21 +1787,21 @@ def get_omicron_skill_tier(skill: dict) -> int | None: if not isinstance(skill, dict): raise SwgohComlinkValueError(f"'skill' must be a dictionary, not {type(skill)}") - if 'tier' not in skill: + if "tier" not in skill: raise SwgohComlinkValueError("'skill' must contain 'tier' key") - skill_tier = [idx for idx, tier in enumerate(skill['tier']) if tier['isOmicronTier'] is True] + skill_tier = [idx for idx, tier in enumerate(skill["tier"]) if tier["isOmicronTier"] is True] return skill_tier[0] if skill_tier else None def is_omicron_skill( - omicron_skill_list: list[dict], - skill_id: str | None = None, - skill_tier: int | None = None, - *, - roster_unit_skill: dict | None = None - ) -> bool: + omicron_skill_list: list[dict], + skill_id: str | None = None, + skill_tier: int | None = None, + *, + roster_unit_skill: dict | None = None, +) -> bool: """ Check if a given skill is an Omicron skill based on its ID and tier. @@ -1800,19 +1821,22 @@ def is_omicron_skill( if not isinstance(omicron_skill_list, list): raise SwgohComlinkValueError(f"'omicron_skill_list' must be a list, not {type(omicron_skill_list)}") - if not isinstance(skill_id, str): - raise SwgohComlinkValueError(f"'skill_id' must be a string, not {type(skill_id)}") - - if not (skill_id and skill_tier and roster_unit_skill): - raise SwgohComlinkValueError("Invalid 'skill_id', 'skill_tier', or 'roster_unit_skill' argument.") - + # When roster_unit_skill is provided, extract skill_id and skill_tier from it if roster_unit_skill is not None: - skill_id = roster_unit_skill.get('id') - skill_tier = roster_unit_skill.get('tier') + skill_id = roster_unit_skill.get("id") + skill_tier = roster_unit_skill.get("tier") if not skill_id or not skill_tier: raise SwgohComlinkValueError("Invalid 'roster_unit_skill' argument.") + else: + # Validate that skill_id and skill_tier were provided directly + if not isinstance(skill_id, str): + raise SwgohComlinkValueError(f"'skill_id' must be a string, not {type(skill_id)}") + if not skill_id or not skill_tier: + raise SwgohComlinkValueError( + "'skill_id' and 'skill_tier' are required when 'roster_unit_skill' is not provided." + ) - omicron_skill = [omi_skill for omi_skill in omicron_skill_list if omi_skill['id'] == skill_id] + omicron_skill = [omi_skill for omi_skill in omicron_skill_list if omi_skill["id"] == skill_id] if not omicron_skill: return False @@ -1860,8 +1884,11 @@ def skill_exists(value, dict_list: list[dict]) -> bool: return any(value in d.values() for d in dict_list) Unit = namedtuple("Unit", "baseId nameKey") - base_ids: list[NamedTuple] = [Unit(unit.get('baseId'), unit.get('nameKey')) - for unit in unit_list if skill_exists(skill, unit.get('skillReference'))] + base_ids: list[NamedTuple] = [ + Unit(unit.get("baseId"), unit.get("nameKey")) + for unit in unit_list + if skill_exists(skill, unit.get("skillReference")) + ] if base_ids: return base_ids[0] else: @@ -1887,13 +1914,13 @@ def get_datacron_dismantle_value(datacron: dict, datacron_set_list: list, recipe details, which include the quantity and type. """ dismantle_materials = {} - set_id = datacron.get('setId') - focused: bool = datacron.get('focused', False) - affix_tier = len(datacron.get('affix', [])) + set_id = datacron.get("setId") + focused: bool = datacron.get("focused", False) + affix_tier = len(datacron.get("affix", [])) # Helper function to retrieve an object based on its ID def find_object_by_id(obj_list, obj_id): - return next((obj for obj in obj_list if obj.get('id') == obj_id), None) + return next((obj for obj in obj_list if obj.get("id") == obj_id), None) # Find datacron set by setId datacron_set = find_object_by_id(datacron_set_list, set_id) @@ -1901,12 +1928,11 @@ def find_object_by_id(obj_list, obj_id): return dismantle_materials # Find dust recipe ID by affix tier - tier_element = 'focusedTier' if focused else 'tier' + tier_element = "focusedTier" if focused else "tier" dust_recipe_id = next( - (tier.get('dustGrantRecipeId') - for tier in datacron_set.get(tier_element, []) if tier.get('id') == affix_tier), - None - ) + (tier.get("dustGrantRecipeId") for tier in datacron_set.get(tier_element, []) if tier.get("id") == affix_tier), + None, + ) if not dust_recipe_id: return dismantle_materials @@ -1916,17 +1942,21 @@ def find_object_by_id(obj_list, obj_id): return dismantle_materials # Collect dismantle materials - for ingredient in dust_recipe.get('ingredients', []): - dismantle_materials[ingredient.get('id')] = { - "quantity": ingredient.get('maxQuantity'), - "type": ingredient.get('type'), - "focused": focused, - } + for ingredient in dust_recipe.get("ingredients", []): + dismantle_materials[ingredient.get("id")] = { + "quantity": ingredient.get("maxQuantity"), + "type": ingredient.get("type"), + "focused": focused, + } return dismantle_materials def get_datacron_dismantle_total(datacrons: list, datacron_set_list: list, recipe_list: list) -> list: + """Calculate total dismantle materials for a list of datacrons. + + .. note:: This function is not yet implemented and always returns an empty list. + """ dismantle_set_list = [] - for datacron in datacrons: - ... + for _datacron in datacrons: + pass # TODO: implement datacron dismantle calculation return dismantle_set_list diff --git a/src/swgoh_comlink/swgoh_comlink.py b/src/swgoh_comlink/swgoh_comlink.py index 267c8cc..93431f4 100644 --- a/src/swgoh_comlink/swgoh_comlink.py +++ b/src/swgoh_comlink/swgoh_comlink.py @@ -2,6 +2,7 @@ """ Python 3 interface library for swgoh-comlink (https://github.com/swgoh-utils/swgoh-comlink) """ + from __future__ import annotations import functools @@ -25,38 +26,49 @@ logger = get_logger(__name__) -__all__ = ['SwgohComlink'] - -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -url_port_re = re.compile(r'^https://\S+:(\d+)$', re.VERBOSE | re.IGNORECASE) +__all__ = ["SwgohComlink"] +url_port_re = re.compile(r"^https://\S+:(\d+)$", re.VERBOSE | re.IGNORECASE) -def _get_player_payload(allycode: str | int = None, player_id: str = None, enums: bool = False) -> dict: +def _get_player_payload(allycode: str | int | None = None, player_id: str | None = None, enums: bool = False) -> dict: """ - Helper function to build payload for get_player functions - :param allycode: player allyCode - :param player_id: player game ID - :param enums: boolean - :return: dict + Helper function to build payload for get_player functions. + + Args: + allycode: player allyCode + player_id: player game ID + enums: boolean + + Returns: + dict: The constructed payload. + + Raises: + SwgohComlinkValueError: If neither allycode nor player_id is provided. """ + if not allycode and not player_id: + raise SwgohComlinkValueError("Either 'allycode' or 'player_id' must be provided.") payload = {"payload": {}, "enums": enums} # If player ID is provided use that instead of allyCode - if not allycode and player_id: - payload['payload']['playerId'] = f'{player_id}' + if player_id: + payload["payload"]["playerId"] = str(player_id) # Otherwise use allyCode to lookup player data else: - payload['payload']['allyCode'] = f'{allycode}' + payload["payload"]["allyCode"] = str(allycode) return payload def param_alias(param: str, alias: str) -> Callable: + """Decorator to support legacy camelCase parameter aliases. + + Translates a keyword argument from *alias* to *param* so callers + can use either spelling. + """ + def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): - alias_param_value = kwargs.get(alias) - if alias_param_value: - kwargs[param] = alias_param_value - del kwargs[alias] + if alias in kwargs: + kwargs[param] = kwargs.pop(alias) return func(*args, **kwargs) return wrapper @@ -92,79 +104,98 @@ class SwgohComlink: If the 'host' and 'port' parameters are provided, the 'url' and 'stats_url' parameters are ignored. """ - __comlink_type__ = 'SwgohComlink' + __comlink_type__ = "SwgohComlink" - PROTOCOL = 'http' + PROTOCOL = "http" def __init__( - self, url: str = "http://localhost:3000", stats_url: str = "http://localhost:3223", - access_key: str | None = None, secret_key: str | None = None, host: str | None = None, - port: int = 3000, stats_port: int = 3223 - ): + self, + url: str = "http://localhost:3000", + stats_url: str = "http://localhost:3223", + access_key: str | None = None, + secret_key: str | None = None, + host: str | None = None, + port: int = 3000, + stats_port: int = 3223, + verify_ssl: bool = True, + ): self.__version__ = version self.url_base = sanitize_url(url) self.stats_url_base = sanitize_url(stats_url) self.hmac = False # HMAC use disabled by default + self.verify_ssl = verify_ssl + + if not self.verify_ssl: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # host and port parameters override defaults if host: - self.url_base = self.PROTOCOL + f'://{host}:{port}' - self.stats_url_base = self.PROTOCOL + f'://{host}:{stats_port}' + self.url_base = self.PROTOCOL + f"://{host}:{port}" + self.stats_url_base = self.PROTOCOL + f"://{host}:{stats_port}" # Use values passed from client first, otherwise check for environment variables - if access_key: - self.access_key = access_key - elif os.environ.get('ACCESS_KEY'): - self.access_key = os.environ.get('ACCESS_KEY') - else: - self.access_key = None - if secret_key: - self.secret_key = secret_key - elif os.environ.get('SECRET_KEY'): - self.secret_key = os.environ.get('SECRET_KEY') - else: - self.secret_key = None + self.access_key = access_key or os.environ.get("ACCESS_KEY") + self.secret_key = secret_key or os.environ.get("SECRET_KEY") if self.access_key and self.secret_key: self.hmac = True def _get_game_version(self) -> str: md = self.get_game_metadata() - return md['latestGamedataVersion'] + return md["latestGamedataVersion"] + + def _post( + self, + url_base: str | None = None, + endpoint: str | None = None, + payload: dict | list | None = None, + ) -> dict | list: + """Send a POST request to the comlink service. + + Args: + url_base: Base URL to use. Defaults to ``self.url_base``. + endpoint: API endpoint path appended to *url_base*. + payload: JSON body for the request. + + Returns: + Decoded JSON response (dict or list). - def _post(self, url_base: str = None, endpoint: str = None, payload: dict | list = None, ) -> dict: + Raises: + SwgohComlinkException: On any network or decoding error. + """ if not url_base: url_base = self.url_base - post_url = url_base + f'/{endpoint}' + post_url = url_base + f"/{endpoint}" req_headers = {} # If access_key and secret_key are set, perform HMAC security if self.hmac: req_time = str(int(time.time() * 1000)) - req_headers = {"X-Date": f'{req_time}'} + req_headers = {"X-Date": f"{req_time}"} hmac_obj = hmac.new(key=self.secret_key.encode(), digestmod=hashlib.sha256) hmac_obj.update(req_time.encode()) - hmac_obj.update(b'POST') - hmac_obj.update(f'/{endpoint}'.encode()) + hmac_obj.update(b"POST") + hmac_obj.update(f"/{endpoint}".encode()) # json dumps separators needed for compact string formatting required for compatibility with # comlink since it is written with javascript as the primary object model # ordered dicts are also required with the 'payload' key listed first for proper MD5 hash calculation if payload: - payload_string = dumps(payload, separators=(',', ':')) + payload_string = dumps(payload, separators=(",", ":")) else: payload_string = dumps({}) - payload_hash_digest = hashlib.md5(payload_string.encode()).hexdigest() + # NOTE: MD5 is used here because the comlink service (JavaScript) requires it for + # HMAC payload verification. This is a protocol constraint, not a security choice. + payload_hash_digest = hashlib.md5(payload_string.encode()).hexdigest() # noqa: S324 hmac_obj.update(payload_hash_digest.encode()) hmac_digest = hmac_obj.hexdigest() - req_headers['Authorization'] = f'HMAC-SHA256 Credential={self.access_key},Signature={hmac_digest}' + req_headers["Authorization"] = f"HMAC-SHA256 Credential={self.access_key},Signature={hmac_digest}" try: - r = requests.post(post_url, json=payload, headers=req_headers, verify=False) - return loads(r.content.decode('utf-8')) - except Exception as e: - raise SwgohComlinkException(e) + r = requests.post(post_url, json=payload, headers=req_headers, verify=self.verify_ssl) + return loads(r.content.decode("utf-8")) + except requests.RequestException as e: + raise SwgohComlinkException(e) from e def get_unit_stats( - self, request_payload: dict | list, flags: list[str] = None, - language: str = None - ) -> list | dict: + self, request_payload: dict | list, flags: list[str] = None, language: str = None + ) -> list | dict: """ Calculate unit stats using the swgoh-stats service interface to swgoh-comlink. @@ -186,28 +217,39 @@ def get_unit_stats( """ # Define the flags that StatCalc understands - _allowed_flags = {"gameStyle", "calcGP", "onlyGP", "withoutModCalc", "percentVals", "useMax", "scaled", - "unscaled", "statIDs", "enums", "noSpace"} + _allowed_flags = { + "gameStyle", + "calcGP", + "onlyGP", + "withoutModCalc", + "percentVals", + "useMax", + "scaled", + "unscaled", + "statIDs", + "enums", + "noSpace", + } query_string = None flag_str = None if flags: if isinstance(flags, list) and set(flags).issubset(_allowed_flags): - flag_str = 'flags=' + ','.join(flags) + flag_str = "flags=" + ",".join(flags) else: raise SwgohComlinkValueError( - f'Invalid argument. should be a list of strings with one or more of "' - f'{_allowed_flags} flag values.' - ) + f'Invalid argument. should be a list of strings with one or more of "' + f"{_allowed_flags} flag values." + ) if language: - language = f'language={language}' + language = f"language={language}" if flag_str or language: - query_string = '?' + '&'.join(filter(None, iter([flag_str, language]))) + query_string = "?" + "&".join(filter(None, iter([flag_str, language]))) - endpoint_string = 'api' + query_string if query_string else 'api' + endpoint_string = "api" + query_string if query_string else "api" if isinstance(request_payload, dict): request_payload = [request_payload] @@ -220,12 +262,12 @@ def get_enums(self) -> dict: Returns: A dictionary containing the game data enums. """ - url = self.url_base + '/enums' + url = self.url_base + "/enums" try: - r = requests.request('GET', url) - return loads(r.content.decode('utf-8')) - except Exception as e: - raise SwgohComlinkException(e) + r = requests.request("GET", url, verify=self.verify_ssl) + return loads(r.content.decode("utf-8")) + except requests.RequestException as e: + raise SwgohComlinkException(e) from e # alias for non PEP usage of direct endpoint calls getEnums = get_enums @@ -240,21 +282,21 @@ def get_events(self, enums: bool = False) -> dict[str, list]: Returns: A single element dictionary containing a list of the events game data. """ - payload = {'payload': {}, 'enums': enums} - return self._post(endpoint='getEvents', payload=payload) + payload = {"payload": {}, "enums": enums} + return self._post(endpoint="getEvents", payload=payload) # alias for non PEP usage of direct endpoint calls getEvents = get_events def get_game_data( - self, - version: str = "", - include_pve_units: bool = True, - request_segment: int = 0, - enums: bool = False, - items: str = None, - device_platform: str = "Android" - ) -> dict: + self, + version: str = "", + include_pve_units: bool = True, + request_segment: int = 0, + enums: bool = False, + items: str = None, + device_platform: str = "Android", + ) -> dict: """ Get game data @@ -275,38 +317,40 @@ def get_game_data( else: game_version = version payload: dict[str, Any] = { - "payload": { - "version": f"{game_version}", - "devicePlatform": device_platform, - "includePveUnits": include_pve_units, - }, - "enums": enums - } + "payload": { + "version": f"{game_version}", + "devicePlatform": device_platform, + "includePveUnits": include_pve_units, + }, + "enums": enums, + } if items: # presence of 'items' argument overrides the 'request_segment' argument if isinstance(items, int) and str(abs(items)).isdigit(): - payload['payload']['items'] = str(items) + payload["payload"]["items"] = str(items) else: - payload['payload']['items'] = Constants.get(items) or "-1" + payload["payload"]["items"] = Constants.get(items) or "-1" else: if request_segment < 0 or request_segment > 4: raise SwgohComlinkValueError( - 'Invalid argument. should be an integer between 0 and 4, inclusive.' - ) - payload['payload']['requestSegment'] = request_segment + "Invalid argument. should be an integer between 0 and 4, inclusive." + ) + payload["payload"]["requestSegment"] = request_segment - return self._post(endpoint='data', payload=payload) + return self._post(endpoint="data", payload=payload) # alias for non PEP usage of direct endpoint calls getGameData = get_game_data - def get_localization(self, id: str = None, locale: str = None, unzip: bool = False, enums: bool = False) -> dict: + def get_localization( + self, localization_id: str | None = None, locale: str | None = None, unzip: bool = False, enums: bool = False + ) -> dict: """ Get localization data from game Args: - id: latestLocalizationBundleVersion found in game metadata. This method will collect the latest language - version if the 'id' argument is not provided. + localization_id: latestLocalizationBundleVersion found in game metadata. This method will + collect the latest language version if not provided. locale: string Specify only a specific locale to retrieve [for example "ENG_US"] unzip: boolean [Defaults to False] enums: boolean [Defaults to False] @@ -314,15 +358,15 @@ def get_localization(self, id: str = None, locale: str = None, unzip: bool = Fal Returns: A dictionary containing the localization data. """ - if not id: + if not localization_id: current_game_version = self.get_latest_game_data_version() - id = current_game_version['language'] + localization_id = current_game_version["language"] if locale: - id = id + ":" + locale.upper() + localization_id = localization_id + ":" + locale.upper() - payload = {'unzip': unzip, 'enums': enums, 'payload': {'id': id}} - return self._post(endpoint='localization', payload=payload) + payload = {"unzip": unzip, "enums": enums, "payload": {"id": localization_id}} + return self._post(endpoint="localization", payload=payload) # aliases for non PEP usage of direct endpoint calls getLocalization = get_localization @@ -359,7 +403,7 @@ def get_game_metadata(self, client_specs: dict = None, enums: bool = False) -> d payload = {"payload": {"client_specs": client_specs}, "enums": enums} else: payload = {} - return self._post(endpoint='metadata', payload=payload) + return self._post(endpoint="metadata", payload=payload) # alias for non PEP usage of direct endpoint calls getGameMetaData = get_game_metadata @@ -379,7 +423,7 @@ def get_player(self, allycode: str | int = None, player_id: str = None, enums: b A dictionary containing the player information. """ payload = _get_player_payload(allycode=allycode, player_id=player_id, enums=enums) - return self._post(endpoint='player', payload=payload) + return self._post(endpoint="player", payload=payload) # alias for non PEP usage of direct endpoint calls getPlayer = get_player @@ -387,11 +431,10 @@ def get_player(self, allycode: str | int = None, player_id: str = None, enums: b # Introduced in 1.12.0 # Use decorator to alias the player_details_only parameter to 'playerDetailsOnly' to maintain backward compatibility # while fixing the original naming format mistake. - @param_alias(param="player_details_only", alias='playerDetailsOnly') + @param_alias(param="player_details_only", alias="playerDetailsOnly") def get_player_arena( - self, allycode: str | int = None, player_id: str = None, player_details_only: bool = False, - enums: bool = False - ) -> dict: + self, allycode: str | int = None, player_id: str = None, player_details_only: bool = False, enums: bool = False + ) -> dict: """ Get player arena information from game. Either allycode or player_id must be provided. @@ -405,8 +448,8 @@ def get_player_arena( A dictionary containing the player arena information. """ payload = _get_player_payload(allycode=allycode, player_id=player_id, enums=enums) - payload['payload']['playerDetailsOnly'] = player_details_only - return self._post(endpoint='playerArena', payload=payload) + payload["payload"]["playerDetailsOnly"] = player_details_only + return self._post(endpoint="playerArena", payload=payload) # alias to allow for get_arena() calls as a shortcut for get_player_arena() and non PEP variations get_arena = get_player_arena @@ -429,12 +472,12 @@ def get_guild(self, guild_id: str, include_recent_guild_activity_info: bool = Fa A dictionary containing the guild information. """ payload = { - "payload": {"guildId": guild_id, "includeRecentGuildActivityInfo": include_recent_guild_activity_info}, - "enums": enums - } - guild = self._post(endpoint='guild', payload=payload) - if 'guild' in guild.keys(): - guild = guild['guild'] + "payload": {"guildId": guild_id, "includeRecentGuildActivityInfo": include_recent_guild_activity_info}, + "enums": enums, + } + guild = self._post(endpoint="guild", payload=payload) + if "guild" in guild.keys(): + guild = guild["guild"] return guild # alias for non PEP usage of direct endpoint calls @@ -455,18 +498,17 @@ def get_guilds_by_name(self, name: str, start_index: int = 0, count: int = 10, e A dictionary containing the guild search results. """ payload = { - "payload": {"name": name, "filterType": 4, "startIndex": start_index, "count": count}, - "enums": enums - } - return self._post(endpoint='getGuilds', payload=payload) + "payload": {"name": name, "filterType": 4, "startIndex": start_index, "count": count}, + "enums": enums, + } + return self._post(endpoint="getGuilds", payload=payload) # alias for non PEP usage of direct endpoint calls getGuildByName = get_guilds_by_name def get_guilds_by_criteria( - self, search_criteria: dict, start_index: int = 0, count: int = 10, - enums: bool = False - ) -> dict: + self, search_criteria: dict, start_index: int = 0, count: int = 10, enums: bool = False + ) -> dict: """ Search for guild by guild criteria and return matches. @@ -493,20 +535,24 @@ def get_guilds_by_criteria( """ payload = { - "payload": { - "searchCriteria": search_criteria, "filterType": 5, "startIndex": start_index, - "count": count - }, "enums": enums - } - return self._post(endpoint='getGuilds', payload=payload) + "payload": {"searchCriteria": search_criteria, "filterType": 5, "startIndex": start_index, "count": count}, + "enums": enums, + } + return self._post(endpoint="getGuilds", payload=payload) # alias for non PEP usage of direct endpoint calls getGuildByCriteria = get_guilds_by_criteria def get_leaderboard( - self, leaderboard_type: int, *, league: int | str = None, division: int | str = None, - event_instance_id: str = None, group_id: str = None, enums: bool = False - ) -> dict: + self, + leaderboard_type: int, + *, + league: int | str = None, + division: int | str = None, + event_instance_id: str = None, + group_id: str = None, + enums: bool = False, + ) -> dict: """ Retrieve Grand Arena Championship leaderboard information. @@ -538,8 +584,8 @@ def get_leaderboard( Returns: dict: A dictionary containing the leaderboard data. """ - leagues = {'kyber': 100, 'aurodium': 80, 'chromium': 60, 'bronzium': 40, 'carbonite': 20} - divisions = {'1': 25, '2': 20, '3': 15, '4': 10, '5': 5} + leagues = Constants.LEAGUES + divisions = Constants.DIVISIONS # Translate parameters if needed if isinstance(league, str): league = leagues[league.lower()] @@ -547,14 +593,19 @@ def get_leaderboard( division = divisions[str(division).lower()] if isinstance(division, str): division = divisions[division.lower()] - payload: dict[str, Any] = {"payload": {"leaderboardType": leaderboard_type, }, "enums": enums} + payload: dict[str, Any] = { + "payload": { + "leaderboardType": leaderboard_type, + }, + "enums": enums, + } if leaderboard_type == 4: - payload['payload']['eventInstanceId'] = event_instance_id - payload['payload']['groupId'] = group_id + payload["payload"]["eventInstanceId"] = event_instance_id + payload["payload"]["groupId"] = group_id elif leaderboard_type == 6: - payload['payload']['league'] = league - payload['payload']['division'] = division - leaderboard = self._post(endpoint='getLeaderboard', payload=payload) + payload["payload"]["league"] = league + payload["payload"]["division"] = division + leaderboard = self._post(endpoint="getLeaderboard", payload=payload) return leaderboard # alias for non PEP usage of direct endpoint calls @@ -581,8 +632,8 @@ def get_guild_leaderboard(self, leaderboard_id: list, count: int = 200, enums: b Returns: A dictionary containing the guild leaderboard data. """ - payload = dict(payload={'leaderboardId': leaderboard_id, 'count': count}, enums=enums) - return self._post(endpoint='getGuildLeaderboard', payload=payload) + payload = dict(payload={"leaderboardId": leaderboard_id, "count": count}, enums=enums) + return self._post(endpoint="getGuildLeaderboard", payload=payload) # alias for non PEP usage of direct endpoint calls getGuildLeaderboard = get_guild_leaderboard @@ -599,13 +650,12 @@ def get_name_spaces(self, only_compatible: bool = False, enums: bool = False) -> Returns: dict: A dictionary containing the information about the retrieved namespaces. """ - payload = {'payload': {'onlyCompatible': only_compatible}, 'enums': enums} - return self._post(endpoint='getNameSpaces', payload=payload) + payload = {"payload": {"onlyCompatible": only_compatible}, "enums": enums} + return self._post(endpoint="getNameSpaces", payload=payload) def get_segmented_content( - self, content_name_space: str = "current", accept_language: str = "ENG_US", - enums: bool = False - ) -> dict: + self, content_name_space: str = "current", accept_language: str = "ENG_US", enums: bool = False + ) -> dict: """ *** (PLACEHOLDER) - Actual use is unknown at this time *** Retrieves segmented content from a specified namespace with the option to localize content @@ -624,14 +674,10 @@ def get_segmented_content( parameters. """ payload = { - 'payload': { - 'contentNameSpace': content_name_space, - 'acceptLanguage': accept_language - }, - 'enums': enums - } - return self._post(endpoint='getSegmentedContent', payload=payload) - + "payload": {"contentNameSpace": content_name_space, "acceptLanguage": accept_language}, + "enums": enums, + } + return self._post(endpoint="getSegmentedContent", payload=payload) """ Helper methods are below @@ -652,9 +698,9 @@ def get_latest_game_data_version(self) -> dict: """ current_metadata = self.get_metadata() return { - 'game': current_metadata['latestGamedataVersion'], - 'language': current_metadata['latestLocalizationBundleVersion'] - } + "game": current_metadata["latestGamedataVersion"], + "language": current_metadata["latestLocalizationBundleVersion"], + } # alias for shorthand call getVersion = get_latest_game_data_version diff --git a/src/swgoh_comlink/version.py b/src/swgoh_comlink/version.py index a7d8cf9..1629466 100644 --- a/src/swgoh_comlink/version.py +++ b/src/swgoh_comlink/version.py @@ -1,2 +1,2 @@ # coding=utf-8 -__version__ = '1.17.0' +__version__ = "1.17.0" diff --git a/tests/test_get_enums.py b/tests/test_get_enums.py index ab2676b..a6402b7 100644 --- a/tests/test_get_enums.py +++ b/tests/test_get_enums.py @@ -1,27 +1,33 @@ from unittest import TestCase, main, mock -from swgoh_comlink import SwgohComlink - +import requests -def mocked_get_enums(*args, **kwargs): - return { - 'CombatType': { - '1': 'CHARACTER', - '2': 'SHIP', - }, - } +from swgoh_comlink import SwgohComlink class TestGetEnums(TestCase): - @mock.patch.object(SwgohComlink, 'get_enums', side_effect=mocked_get_enums) - def test_get_enums(self, mock_get_enums): - """ - Test that game enums can be retrieved from game server correctly - """ + @mock.patch("requests.request") + def test_get_enums(self, mock_request): + """Test that get_enums() makes a GET request and returns parsed JSON.""" + mock_response = mock.Mock() + mock_response.content = b'{"CombatType": {"1": "CHARACTER", "2": "SHIP"}}' + mock_request.return_value = mock_response + comlink = SwgohComlink() en = comlink.get_enums() - self.assertTrue('CombatType' in en.keys()) + + mock_request.assert_called_once_with("GET", "http://localhost:3000/enums", verify=True) + self.assertIn("CombatType", en) + + @mock.patch("requests.request", side_effect=requests.ConnectionError("Connection refused")) + def test_get_enums_connection_error(self, mock_request): + """Test that get_enums() wraps connection errors in SwgohComlinkException.""" + from swgoh_comlink.exceptions import SwgohComlinkException + + comlink = SwgohComlink() + with self.assertRaises(SwgohComlinkException): + comlink.get_enums() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/test_get_game_data.py b/tests/test_get_game_data.py index 632ac7f..fa18752 100644 --- a/tests/test_get_game_data.py +++ b/tests/test_get_game_data.py @@ -1,35 +1,48 @@ from unittest import TestCase, main, mock from swgoh_comlink import SwgohComlink +from swgoh_comlink.exceptions import SwgohComlinkValueError -def mocked_get_game_metadata(*args, **kwargs): - return { - 'latestGamedataVersion': '0.33.0:aaaabbbb', - 'latestLocalizationBundleVersion': 'loc_bundle_v1', - 'serverVersion': '21.04.0', - } +class TestGetGameData(TestCase): + @mock.patch.object(SwgohComlink, "_post") + def test_get_game_data_with_version(self, mock_post): + """Test that get_game_data() builds correct payload when version is given.""" + mock_post.return_value = {"units": [{"id": "UNIT_001"}]} + comlink = SwgohComlink() + result = comlink.get_game_data(version="0.33.0:abc", include_pve_units=False, request_segment=4) + + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("payload") or call_kwargs[1].get("payload") + self.assertEqual(payload["payload"]["version"], "0.33.0:abc") + self.assertFalse(payload["payload"]["includePveUnits"]) + self.assertEqual(payload["payload"]["requestSegment"], 4) + self.assertIn("units", result) + + @mock.patch.object(SwgohComlink, "_post") + @mock.patch.object(SwgohComlink, "_get_game_version", return_value="0.33.0:auto") + def test_get_game_data_auto_version(self, mock_version, mock_post): + """Test that get_game_data() fetches version automatically when not provided.""" + mock_post.return_value = {"units": []} -def mocked_get_game_data(*args, **kwargs): - return { - 'units': [{'id': 'UNIT_001', 'name': 'Test Unit'}], - } + comlink = SwgohComlink() + comlink.get_game_data() + mock_version.assert_called_once() + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("payload") or call_kwargs[1].get("payload") + self.assertEqual(payload["payload"]["version"], "0.33.0:auto") -class TestGetGameData(TestCase): - @mock.patch.object(SwgohComlink, 'get_game_data', side_effect=mocked_get_game_data) - @mock.patch.object(SwgohComlink, 'get_game_metadata', side_effect=mocked_get_game_metadata) - def test_get_game_data(self, mock_metadata, mock_game_data): - """ - Test that game data can be retrieved from game server correctly - """ + def test_get_game_data_invalid_segment(self): + """Test that invalid request_segment raises SwgohComlinkValueError.""" comlink = SwgohComlink() - game_metadata = comlink.get_game_metadata() - game_version = game_metadata['latestGamedataVersion'] - game_data = comlink.get_game_data(version=game_version, include_pve_units=False, request_segment=4) - self.assertTrue('units' in game_data.keys()) + with self.assertRaises(SwgohComlinkValueError): + comlink.get_game_data(version="v1", request_segment=5) + with self.assertRaises(SwgohComlinkValueError): + comlink.get_game_data(version="v1", request_segment=-1) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/test_get_guild_by_criteria.py b/tests/test_get_guild_by_criteria.py index 9b136ef..0e29643 100644 --- a/tests/test_get_guild_by_criteria.py +++ b/tests/test_get_guild_by_criteria.py @@ -3,22 +3,23 @@ from swgoh_comlink import SwgohComlink -def mocked_get_guilds_by_criteria(*args, **kwargs): - return { - 'guild': [{'id': 'GUILD_001', 'name': 'Test Guild'}], - } - - class TestGetGuildByCriteria(TestCase): - @mock.patch.object(SwgohComlink, 'get_guilds_by_criteria', side_effect=mocked_get_guilds_by_criteria) - def test_get_guild_by_criteria(self, mock_get_guilds): - """ - Test that guild data can be retrieved from game server correctly - """ + @mock.patch.object(SwgohComlink, "_post") + def test_get_guild_by_criteria(self, mock_post): + """Test that get_guilds_by_criteria() builds correct payload.""" + mock_post.return_value = {"guild": [{"id": "GUILD_001"}]} + comlink = SwgohComlink() - p = comlink.get_guilds_by_criteria(search_criteria={"minGuildGalacticPower": 490000000}) - self.assertTrue('guild' in p.keys()) + criteria = {"minGuildGalacticPower": 490000000} + result = comlink.get_guilds_by_criteria(search_criteria=criteria) + + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("payload") or call_kwargs[1].get("payload") + self.assertEqual(payload["payload"]["searchCriteria"], criteria) + self.assertEqual(payload["payload"]["filterType"], 5) + self.assertIn("guild", result) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/test_get_guild_by_name.py b/tests/test_get_guild_by_name.py index be7c961..86be78e 100644 --- a/tests/test_get_guild_by_name.py +++ b/tests/test_get_guild_by_name.py @@ -3,22 +3,23 @@ from swgoh_comlink import SwgohComlink -def mocked_get_guilds_by_name(*args, **kwargs): - return { - 'guild': [{'id': 'GUILD_001', 'name': 'dead'}], - } - - class TestGetGuildByName(TestCase): - @mock.patch.object(SwgohComlink, 'get_guilds_by_name', side_effect=mocked_get_guilds_by_name) - def test_get_guild_by_name(self, mock_get_guilds): - """ - Test that guild data can be retrieved from game server correctly - """ + @mock.patch.object(SwgohComlink, "_post") + def test_get_guild_by_name(self, mock_post): + """Test that get_guilds_by_name() builds correct payload.""" + mock_post.return_value = {"guild": [{"id": "GUILD_001", "name": "dead"}]} + comlink = SwgohComlink() - p = comlink.get_guilds_by_name("dead") - self.assertTrue('guild' in p.keys()) + result = comlink.get_guilds_by_name("dead") + + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("payload") or call_kwargs[1].get("payload") + self.assertEqual(payload["payload"]["name"], "dead") + self.assertEqual(payload["payload"]["filterType"], 4) + self.assertEqual(call_kwargs.kwargs.get("endpoint") or call_kwargs[1].get("endpoint"), "getGuilds") + self.assertIn("guild", result) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/test_get_localization_bundle.py b/tests/test_get_localization_bundle.py index 859121a..9c180bf 100644 --- a/tests/test_get_localization_bundle.py +++ b/tests/test_get_localization_bundle.py @@ -3,33 +3,33 @@ from swgoh_comlink import SwgohComlink -def mocked_get_game_metadata(*args, **kwargs): - return { - 'latestGamedataVersion': '0.33.0:aaaabbbb', - 'latestLocalizationBundleVersion': 'loc_bundle_v1', - 'serverVersion': '21.04.0', - } +class TestGetLocalizationBundle(TestCase): + @mock.patch.object(SwgohComlink, "_post") + def test_get_localization_with_id(self, mock_post): + """Test that get_localization() builds correct payload when localization_id is given.""" + mock_post.return_value = {"localizationBundle": {"en": {}}} + comlink = SwgohComlink() + result = comlink.get_localization(localization_id="loc_v1") -def mocked_get_localization(*args, **kwargs): - return { - 'localizationBundle': {'en': {'KEY_001': 'Test Value'}}, - } + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("payload") or call_kwargs[1].get("payload") + self.assertEqual(payload["payload"]["id"], "loc_v1") + self.assertIn("localizationBundle", result) + @mock.patch.object(SwgohComlink, "_post") + def test_get_localization_with_locale(self, mock_post): + """Test that locale is appended to the localization id.""" + mock_post.return_value = {"localizationBundle": {}} -class TestGetLocalizationBundle(TestCase): - @mock.patch.object(SwgohComlink, 'get_localization', side_effect=mocked_get_localization) - @mock.patch.object(SwgohComlink, 'get_game_metadata', side_effect=mocked_get_game_metadata) - def test_get_localization_bundle(self, mock_metadata, mock_localization): - """ - Test that localization data can be retrieved from game server correctly - """ comlink = SwgohComlink() - game_metadata = comlink.get_game_metadata() - localization_id = game_metadata['latestLocalizationBundleVersion'] - game_data = comlink.get_localization(id=localization_id) - self.assertTrue('localizationBundle' in game_data.keys()) + comlink.get_localization(localization_id="loc_v1", locale="eng_us") + + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("payload") or call_kwargs[1].get("payload") + self.assertEqual(payload["payload"]["id"], "loc_v1:ENG_US") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/test_get_metadata.py b/tests/test_get_metadata.py index de380be..e6438cf 100644 --- a/tests/test_get_metadata.py +++ b/tests/test_get_metadata.py @@ -3,24 +3,37 @@ from swgoh_comlink import SwgohComlink -def mocked_get_game_metadata(*args, **kwargs): - return { - 'latestGamedataVersion': '0.33.0:aaaabbbb', - 'latestLocalizationBundleVersion': 'loc_bundle_v1', - 'serverVersion': '21.04.0', - } - - class TestGetMetadata(TestCase): - @mock.patch.object(SwgohComlink, 'get_game_metadata', side_effect=mocked_get_game_metadata) - def test_get_metadata(self, mock_metadata): - """ - Test that game metadata can be retrieved from game server correctly - """ + @mock.patch.object(SwgohComlink, "_post") + def test_get_metadata(self, mock_post): + """Test that get_game_metadata() builds correct payload.""" + mock_post.return_value = { + "serverVersion": "21.04.0", + "latestGamedataVersion": "0.33.0:abc", + "latestLocalizationBundleVersion": "loc_v1", + } + comlink = SwgohComlink() md = comlink.get_game_metadata() - self.assertTrue('serverVersion' in md.keys()) + + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + self.assertEqual(call_kwargs.kwargs.get("endpoint") or call_kwargs[1].get("endpoint"), "metadata") + self.assertIn("serverVersion", md) + + @mock.patch.object(SwgohComlink, "_post") + def test_get_metadata_with_client_specs(self, mock_post): + """Test that client_specs are included in the payload.""" + mock_post.return_value = {"serverVersion": "21.04.0"} + + comlink = SwgohComlink() + specs = {"platform": "Android", "bundleId": "com.test"} + comlink.get_game_metadata(client_specs=specs) + + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("payload") or call_kwargs[1].get("payload") + self.assertIn("client_specs", payload["payload"]) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/test_get_player.py b/tests/test_get_player.py index eb818f0..51d43d8 100644 --- a/tests/test_get_player.py +++ b/tests/test_get_player.py @@ -1,28 +1,41 @@ from unittest import TestCase, main, mock from swgoh_comlink import SwgohComlink +from swgoh_comlink.exceptions import SwgohComlinkValueError -def mocked_get_player(*args, **kwargs): - return { - 'allyCode': '245866537', - 'level': 85, - 'name': 'Test Player', - 'rosterUnit': [], - } +class TestGetPlayer(TestCase): + @mock.patch.object(SwgohComlink, "_post") + def test_get_player_by_allycode(self, mock_post): + """Test that get_player() builds correct payload for allycode lookup.""" + mock_post.return_value = {"name": "TestPlayer", "allyCode": "245866537", "level": 85} + + comlink = SwgohComlink() + result = comlink.get_player(allycode=245866537) + mock_post.assert_called_once_with( + endpoint="player", payload={"payload": {"allyCode": "245866537"}, "enums": False} + ) + self.assertEqual(result["name"], "TestPlayer") -class TestGetPlayer(TestCase): - @mock.patch.object(SwgohComlink, 'get_player', side_effect=mocked_get_player) - def test_get_player(self, mock_get_player): - """ - Test that player data can be retrieved from game server correctly - """ + @mock.patch.object(SwgohComlink, "_post") + def test_get_player_by_player_id(self, mock_post): + """Test that get_player() uses playerId when provided.""" + mock_post.return_value = {"name": "TestPlayer"} + + comlink = SwgohComlink() + comlink.get_player(player_id="abc123") + + mock_post.assert_called_once_with( + endpoint="player", payload={"payload": {"playerId": "abc123"}, "enums": False} + ) + + def test_get_player_no_identifier(self): + """Test that get_player() raises when neither allycode nor player_id is given.""" comlink = SwgohComlink() - ally_code = 245866537 - p = comlink.get_player(allycode=ally_code) - self.assertTrue('name' in p.keys()) + with self.assertRaises(SwgohComlinkValueError): + comlink.get_player() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/test_get_player_arena.py b/tests/test_get_player_arena.py index ebbf293..6c5616c 100644 --- a/tests/test_get_player_arena.py +++ b/tests/test_get_player_arena.py @@ -1,80 +1,66 @@ -import os -import sys - -sys.path.append(os.path.join(os.path.split(os.getcwd())[0], 'src')) from unittest import TestCase, main, mock from swgoh_comlink import SwgohComlink -def mocked_player_arena(*args, **kwargs): - possible_params = ['allycode', 'player_id:', 'player_details_only', 'playerDetailsOnly', 'enums'] - - for kw in kwargs: - if kw not in possible_params: - raise AttributeError(f'Invalid argument {kw}') - - sample_resp = { - 'allyCode': '314927874', - 'level': 85, - 'name': 'Mar Trepodi', - 'playerRating': {}, - 'pvpProfile': [] - } - return sample_resp - - class TestGetPlayerArena(TestCase): - @mock.patch('swgoh_comlink.SwgohComlink.get_player_arena', side_effect=mocked_player_arena) + @mock.patch.object(SwgohComlink, "_post") def test_get_player_arena(self, mock_post): - """ - Test that player data can be retrieved from game server correctly - """ + """Test that get_player_arena() builds correct payload.""" + mock_post.return_value = { + "allyCode": "314927874", + "level": 85, + "name": "Mar Trepodi", + "playerRating": {}, + "pvpProfile": [], + } + comlink = SwgohComlink() - ally_code = 245866537 - p = comlink.get_player_arena(allycode=ally_code) - self.assertTrue('name' in p.keys()) + p = comlink.get_player_arena(allycode=245866537) - @mock.patch('swgoh_comlink.SwgohComlink.get_player_arena', side_effect=mocked_player_arena) + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("payload") or call_kwargs[1].get("payload") + self.assertEqual(payload["payload"]["allyCode"], "245866537") + self.assertFalse(payload["payload"]["playerDetailsOnly"]) + self.assertIn("name", p) + + @mock.patch.object(SwgohComlink, "_post") def test_get_player_arena_details_only(self, mock_post): - """ - Test that player data can be retrieved from game server correctly - """ + """Test that player_details_only is passed through to the payload.""" + mock_post.return_value = {"name": "Test"} + comlink = SwgohComlink() - ally_code = 245866537 - p = comlink.get_player_arena(allycode=ally_code, player_details_only=True) - self.assertTrue('name' in p.keys()) + comlink.get_player_arena(allycode=245866537, player_details_only=True) - @mock.patch('swgoh_comlink.SwgohComlink.get_player_arena', side_effect=mocked_player_arena) + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("payload") or call_kwargs[1].get("payload") + self.assertTrue(payload["payload"]["playerDetailsOnly"]) + + @mock.patch.object(SwgohComlink, "_post") def test_get_player_arena_details_only_alias(self, mock_post): - """ - Test that player data can be retrieved from game server correctly - """ - comlink = SwgohComlink() - ally_code = 245866537 - p = comlink.get_player_arena(allycode=ally_code, playerDetailsOnly=True) - self.assertTrue('name' in p.keys()) - - @mock.patch('swgoh_comlink.SwgohComlink.get_player_arena', side_effect=mocked_player_arena) - def test_get_player_arena_details_only_alias_neg(self, mock_post): - """ - Test that player data can be retrieved from game server correctly - """ + """Test that the camelCase alias playerDetailsOnly works via param_alias.""" + mock_post.return_value = {"name": "Test"} + comlink = SwgohComlink() - ally_code = 245866537 - p = comlink.get_player_arena(allycode=ally_code, playerDetailsOnly=True) - self.assertTrue('name' in p.keys()) - - @mock.patch('swgoh_comlink.SwgohComlink.get_arena', side_effect=mocked_player_arena) - def test_get_arena(self, mock_post): - """ - Test that player data can be retrieved from game server correctly - """ + comlink.get_player_arena(allycode=245866537, playerDetailsOnly=True) + + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("payload") or call_kwargs[1].get("payload") + self.assertTrue(payload["payload"]["playerDetailsOnly"]) + + @mock.patch.object(SwgohComlink, "_post") + def test_get_player_arena_details_only_alias_false(self, mock_post): + """Test that passing playerDetailsOnly=False propagates correctly (param_alias fix).""" + mock_post.return_value = {"name": "Test"} + comlink = SwgohComlink() - ally_code = 245866537 - p = comlink.get_arena(allycode=ally_code) - self.assertTrue('name' in p.keys()) + comlink.get_player_arena(allycode=245866537, playerDetailsOnly=False) + + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("payload") or call_kwargs[1].get("payload") + self.assertFalse(payload["payload"]["playerDetailsOnly"]) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/test_get_unit_stats.py b/tests/test_get_unit_stats.py index c4a69bb..1754d3e 100644 --- a/tests/test_get_unit_stats.py +++ b/tests/test_get_unit_stats.py @@ -1,36 +1,43 @@ from unittest import TestCase, main, mock from swgoh_comlink import SwgohComlink +from swgoh_comlink.exceptions import SwgohComlinkValueError -def mocked_get_player(*args, **kwargs): - return { - 'allyCode': '245866537', - 'level': 85, - 'name': 'Test Player', - 'rosterUnit': [{'id': 'UNIT_001', 'defId': 'DARTHMALGUS'}], - } +class TestGetUnitStats(TestCase): + @mock.patch.object(SwgohComlink, "_post") + def test_get_unit_stats_with_list(self, mock_post): + """Test that get_unit_stats() posts to stats endpoint with correct payload.""" + mock_post.return_value = {"stats": {"gp": 12345}} + comlink = SwgohComlink() + roster = [{"id": "UNIT_001", "defId": "DARTHMALGUS"}] + result = comlink.get_unit_stats(roster, flags=["calcGP", "gameStyle"]) -def mocked_get_unit_stats(*args, **kwargs): - return { - 'stats': {'gp': 12345}, - } + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + self.assertEqual(call_kwargs.kwargs.get("url_base") or call_kwargs[1].get("url_base"), "http://localhost:3223") + self.assertIn("calcGP", call_kwargs.kwargs.get("endpoint") or call_kwargs[1].get("endpoint")) + self.assertIn("gp", result["stats"]) + @mock.patch.object(SwgohComlink, "_post") + def test_get_unit_stats_dict_wrapped_as_list(self, mock_post): + """Test that a dict payload is automatically wrapped in a list.""" + mock_post.return_value = {} -class TestGetUnitStats(TestCase): - @mock.patch.object(SwgohComlink, 'get_unit_stats', side_effect=mocked_get_unit_stats) - @mock.patch.object(SwgohComlink, 'get_player', side_effect=mocked_get_player) - def test_get_unit_stats(self, mock_get_player, mock_get_unit_stats): - """ - Test that player data can be retrieved from game server correctly - """ comlink = SwgohComlink() - ally_code = 245866537 - p = comlink.get_player(allycode=ally_code) - unit_stats = comlink.get_unit_stats(p['rosterUnit'], flags=['calcGP', 'gameStyle']) - self.assertTrue('gp' in unit_stats['stats'].keys()) + comlink.get_unit_stats({"id": "UNIT_001"}) + + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("payload") or call_kwargs[1].get("payload") + self.assertIsInstance(payload, list) + + def test_get_unit_stats_invalid_flags(self): + """Test that invalid flags raise SwgohComlinkValueError.""" + comlink = SwgohComlink() + with self.assertRaises(SwgohComlinkValueError): + comlink.get_unit_stats([{}], flags=["invalidFlag"]) -if __name__ == '__main__': +if __name__ == "__main__": main()