From 1b2c7763240b8f0fa62c967567d8db58cad81d5d Mon Sep 17 00:00:00 2001 From: RaenonX Date: Thu, 19 Nov 2020 19:00:29 -0600 Subject: [PATCH] ADD - basic skill data parsing functionality Signed-off-by: RaenonX --- .github/workflows/ci-tests.yml | 24 ++ .github/workflows/cqa.yml | 28 ++ .gitignore | 8 + .pydocstyle | 30 ++ .pylintrc | 38 +++ README.md | 8 + dlparse/__init__.py | 1 + dlparse/enums/__init__.py | 2 + dlparse/enums/affliction.py | 23 ++ dlparse/errors.py | 25 ++ dlparse/model/__init__.py | 2 + dlparse/model/skill.py | 53 +++ dlparse/mono/__init__.py | 1 + dlparse/mono/asset/__init__.py | 3 + dlparse/mono/asset/base/__init__.py | 3 + dlparse/mono/asset/base/asset.py | 31 ++ dlparse/mono/asset/base/entry.py | 23 ++ dlparse/mono/asset/base/master.py | 67 ++++ dlparse/mono/asset/base/parser.py | 21 ++ dlparse/mono/asset/base/player_action.py | 138 ++++++++ dlparse/mono/asset/master/__init__.py | 5 + dlparse/mono/asset/master/action_hit_attr.py | 62 ++++ dlparse/mono/asset/master/chara_data.py | 311 ++++++++++++++++++ dlparse/mono/asset/master/cheat_detection.py | 49 +++ dlparse/mono/asset/master/skill_data.py | 165 ++++++++++ dlparse/mono/asset/player_action/__init__.py | 6 + dlparse/mono/asset/player_action/bullet.py | 26 ++ dlparse/mono/asset/player_action/hit.py | 18 + dlparse/mono/asset/player_action/prefab.py | 64 ++++ dlparse/mono/path/__init__.py | 2 + dlparse/mono/path/player_action.py | 43 +++ dlparse/transformer/__init__.py | 2 + dlparse/transformer/skill.py | 83 +++++ main.py | 6 + notes/assets/player_action_components.md | 118 +++++++ precommit.sh | 30 ++ requirements-dev.txt | 9 + requirements.txt | 0 tests/__init__.py | 0 tests/conftest.py | 9 + tests/static.py | 9 + tests/test_anamoly_check/__init__.py | 0 tests/test_anamoly_check/test_skills.py | 1 + tests/test_mono/__init__.py | 0 tests/test_mono/test_asset/__init__.py | 0 .../test_asset/test_cheat_detection.py | 39 +++ tests/test_mono/test_entry/__init__.py | 0 tests/test_mono/test_entry/test_base.py | 8 + tests/test_mono/test_entry/test_chara_data.py | 153 +++++++++ tests/test_mono/test_path/__init__.py | 0 .../test_mono/test_path/test_player_action.py | 7 + tests/test_transformer/__init__.py | 0 tests/test_transformer/conftest.py | 20 ++ tests/test_transformer/test_skill_atk.py | 197 +++++++++++ 54 files changed, 1971 insertions(+) create mode 100644 .github/workflows/ci-tests.yml create mode 100644 .github/workflows/cqa.yml create mode 100644 .gitignore create mode 100644 .pydocstyle create mode 100644 .pylintrc create mode 100644 README.md create mode 100644 dlparse/__init__.py create mode 100644 dlparse/enums/__init__.py create mode 100644 dlparse/enums/affliction.py create mode 100644 dlparse/errors.py create mode 100644 dlparse/model/__init__.py create mode 100644 dlparse/model/skill.py create mode 100644 dlparse/mono/__init__.py create mode 100644 dlparse/mono/asset/__init__.py create mode 100644 dlparse/mono/asset/base/__init__.py create mode 100644 dlparse/mono/asset/base/asset.py create mode 100644 dlparse/mono/asset/base/entry.py create mode 100644 dlparse/mono/asset/base/master.py create mode 100644 dlparse/mono/asset/base/parser.py create mode 100644 dlparse/mono/asset/base/player_action.py create mode 100644 dlparse/mono/asset/master/__init__.py create mode 100644 dlparse/mono/asset/master/action_hit_attr.py create mode 100644 dlparse/mono/asset/master/chara_data.py create mode 100644 dlparse/mono/asset/master/cheat_detection.py create mode 100644 dlparse/mono/asset/master/skill_data.py create mode 100644 dlparse/mono/asset/player_action/__init__.py create mode 100644 dlparse/mono/asset/player_action/bullet.py create mode 100644 dlparse/mono/asset/player_action/hit.py create mode 100644 dlparse/mono/asset/player_action/prefab.py create mode 100644 dlparse/mono/path/__init__.py create mode 100644 dlparse/mono/path/player_action.py create mode 100644 dlparse/transformer/__init__.py create mode 100644 dlparse/transformer/skill.py create mode 100644 main.py create mode 100644 notes/assets/player_action_components.md create mode 100644 precommit.sh create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/static.py create mode 100644 tests/test_anamoly_check/__init__.py create mode 100644 tests/test_anamoly_check/test_skills.py create mode 100644 tests/test_mono/__init__.py create mode 100644 tests/test_mono/test_asset/__init__.py create mode 100644 tests/test_mono/test_asset/test_cheat_detection.py create mode 100644 tests/test_mono/test_entry/__init__.py create mode 100644 tests/test_mono/test_entry/test_base.py create mode 100644 tests/test_mono/test_entry/test_chara_data.py create mode 100644 tests/test_mono/test_path/__init__.py create mode 100644 tests/test_mono/test_path/test_player_action.py create mode 100644 tests/test_transformer/__init__.py create mode 100644 tests/test_transformer/conftest.py create mode 100644 tests/test_transformer/test_skill_atk.py diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 00000000..befefe06 --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,24 @@ +name: DL Data Parser - CI Tests + +on: [push] + +jobs: + pytest: + name: Run tests + + runs-on: windows-latest + + continue-on-error: true + + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-python@v2 + + - name: Install required packages + run: | + pip install -r requirements-dev.txt + + - name: Run tests + run: | + pytest diff --git a/.github/workflows/cqa.yml b/.github/workflows/cqa.yml new file mode 100644 index 00000000..f339d3a3 --- /dev/null +++ b/.github/workflows/cqa.yml @@ -0,0 +1,28 @@ +name: DL Data Parser - CQA + +on: [push] + +jobs: + cqa: + name: CQA + + runs-on: windows-latest + + continue-on-error: true + + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-python@v2 + + - name: Install required packages + run: | + pip install -r requirements-dev.txt + + - name: pydocstyle checks (`dlparse`) + run: | + pydocstyle dlparse --count + + - name: pylint checks (`dlparse`) + run: | + pylint dlparse diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b6bc1cf8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Game assets (excluding images) +.data/media/assets/_gluonresources/resources/images + +# Test cache +.pytest_cache + +# IntelliJ project files +.idea diff --git a/.pydocstyle b/.pydocstyle new file mode 100644 index 00000000..38ee1b92 --- /dev/null +++ b/.pydocstyle @@ -0,0 +1,30 @@ +[pydocstyle] +ignore = + # Public method missing docstring - `pylint` will check if there's really missing the docstring + D102, + # Magic method missing docstring - no need for it + D105, + # __init__ missing docstring - optional. add details to class docstring + D107, + # Blank line required before docstring - mutually exclusive to D204 + D203, + # Multi-line docstring summary should start at the first line - mutually exclusive to D213 + D212, + # Section underline is over-indented + D215, + # First line should be in imperative mood + D401, + # First word of the docstring should not be This + D404, + # Section name should end with a newline + D406, + # Missing dashed underline after section + D407, + # Section underline should be in the line following the section’s name + D408, + # Section underline should match the length of its name + D409, + # No blank lines allowed between a section header and its content + D412, + # Missing blank line after last section + D413 diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 00000000..571c8720 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,38 @@ +[BASIC] + +# Reason of the good names: +# - _ +# often used as dummy variable during unpacking +# - T +# often used to for TypeVar +# - f +# often used as a file stream name +# - i, j, k +# often used in for loops +# - s +# often used to represent "string" +# - v +# often used to represent "value" +# - dt, tz +# often used in datetime handling (dt for datetime, tz for timezone) +# - ex +# often used as the var name of exception caught by try..except +# - fn +# often used to represent a function address + +good-names=_,T,f,i,j,k,s,v,dt,ex,fn,tz + +[DESIGN] + +max-args=10 + +[FORMAT] + +max-line-length=119 + +[MESSAGES CONTROL] + +# TODO: `unsubscriptable-object` generates false positives for python 3.9 and pylint==2.6. +# https://github.com/PyCQA/pylint/issues/3882 +# Re-enable it when the issue is fixed. +disable=unsubscriptable-object diff --git a/README.md b/README.md new file mode 100644 index 00000000..be396866 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# dragalia-data-parse + +This parses the original Dragalia Lost assets to be the file usable for [DL info website][DL-info]. + +Developed under Python 3.9. + +[DL-info]: http://dl.raenonx.cc +[RaenonX-DL]: https://github.com/RaenonX-DL diff --git a/dlparse/__init__.py b/dlparse/__init__.py new file mode 100644 index 00000000..d0fddb70 --- /dev/null +++ b/dlparse/__init__.py @@ -0,0 +1 @@ +"""Main parser for the Dragalia Lost data.""" diff --git a/dlparse/enums/__init__.py b/dlparse/enums/__init__.py new file mode 100644 index 00000000..6c8cc3b3 --- /dev/null +++ b/dlparse/enums/__init__.py @@ -0,0 +1,2 @@ +"""Various in-asset enums.""" +from .affliction import Affliction diff --git a/dlparse/enums/affliction.py b/dlparse/enums/affliction.py new file mode 100644 index 00000000..ccd6e93c --- /dev/null +++ b/dlparse/enums/affliction.py @@ -0,0 +1,23 @@ +"""Affliction enums.""" +from enum import IntEnum + +__all__ = ("Affliction",) + + +class Affliction(IntEnum): + """Affliction enums used in the assets.""" + + NONE = 0 + POISON = 1 + BURN = 2 + FREEZE = 3 + PARALYZE = 4 + BLIND = 5 + STUN = 6 + CURSE = 7 + BOG = 9 + SLEEP = 10 + FROSTBITE = 11 + FLASHBURN = 12 + CRASHWIND = 13 + SHADOWBLIGHT = 14 diff --git a/dlparse/errors.py b/dlparse/errors.py new file mode 100644 index 00000000..3733ac9c --- /dev/null +++ b/dlparse/errors.py @@ -0,0 +1,25 @@ +"""Error classes to be raised during runtime.""" +from typing import Optional + +__all__ = ("SkillDataNotFound", "ActionDataNotFound") + + +class SkillDataNotFound(ValueError): + """Error to be raised if the skill data is not found.""" + + def __init__(self, skill_id: int): + super().__init__(f"Skill data of ID `{skill_id}` not found") + + self._skill_id = skill_id + + @property + def skill_id(self): + """Get the skill ID that causes this error.""" + return self._skill_id + + +class ActionDataNotFound(ValueError): + """Error to be raised if the action data file is not found.""" + + def __init__(self, action_id: int, skill_id: Optional[int] = None): + super().__init__(f"Action data of action ID `{action_id}` / skill ID `{skill_id}` not found") diff --git a/dlparse/model/__init__.py b/dlparse/model/__init__.py new file mode 100644 index 00000000..834acf4c --- /dev/null +++ b/dlparse/model/__init__.py @@ -0,0 +1,2 @@ +"""Various data models to be output.""" +from .skill import AttackingSkillData diff --git a/dlparse/model/skill.py b/dlparse/model/skill.py new file mode 100644 index 00000000..83346f94 --- /dev/null +++ b/dlparse/model/skill.py @@ -0,0 +1,53 @@ +"""Models for character skills.""" +from dataclasses import dataclass, field + +from dlparse.mono.asset import HitAttrEntry + +__all__ = ("AttackingSkillData",) + + +@dataclass +class AttackingSkillData: + """ + An attacking skill data. + + Both ``hit_count`` and ``mods`` should be sorted by the skill level. + + For example, if skill level 1 has 1 hit and 100% mods while skill level 2 has 2 hits and 150% + 200% mods, + ``hit_count`` should be ``[1, 2]`` and ``mods`` should be ``[[1.0], [1.5, 2.0]]``. + """ + + hit_count: list[int] + mods: list[list[float]] + + damage_hit_attrs: list[list[HitAttrEntry]] + + total_mod: list[float] = field(init=False) + + def __post_init__(self): + self.total_mod = [sum(mods) for mods in self.mods] + + @property + def hit_count_at_max(self) -> int: + """Get the skill hit count at the max level.""" + return self.hit_count[-1] + + @property + def total_mod_at_max(self) -> float: + """Get the total skill modifier at the max level.""" + return self.total_mod[-1] + + @property + def mods_at_max(self) -> list[float]: + """Get the skill modifiers at the max level.""" + return self.mods[-1] + + @property + def max_available_level(self) -> int: + """ + Get the max available level of a skill. + + This max level does **NOT** reflect the actual max level in-game. + To get such, character data is needed. + """ + return len(self.hit_count) diff --git a/dlparse/mono/__init__.py b/dlparse/mono/__init__.py new file mode 100644 index 00000000..d3370ae9 --- /dev/null +++ b/dlparse/mono/__init__.py @@ -0,0 +1 @@ +"""Classes for mono behaviors.""" diff --git a/dlparse/mono/asset/__init__.py b/dlparse/mono/asset/__init__.py new file mode 100644 index 00000000..49d5425e --- /dev/null +++ b/dlparse/mono/asset/__init__.py @@ -0,0 +1,3 @@ +"""Asset classes for mono behavior scripts.""" +from .master import * # noqa +from .player_action import * # noqa diff --git a/dlparse/mono/asset/base/__init__.py b/dlparse/mono/asset/base/__init__.py new file mode 100644 index 00000000..31fafc2c --- /dev/null +++ b/dlparse/mono/asset/base/__init__.py @@ -0,0 +1,3 @@ +"""Base classes for mono behavior scripts.""" +from .master import MasterEntryBase, MasterAssetBase, MasterParserBase +from .player_action import ActionComponentBase, ActionAssetBase, ActionParserBase, ActionComponentDamageDealerMixin diff --git a/dlparse/mono/asset/base/asset.py b/dlparse/mono/asset/base/asset.py new file mode 100644 index 00000000..99d0b2d1 --- /dev/null +++ b/dlparse/mono/asset/base/asset.py @@ -0,0 +1,31 @@ +"""Base asset class.""" +import os +from abc import ABC, abstractmethod +from typing import Type, Optional + +from .parser import ParserBase + +__all__ = ("AssetBase",) + + +class AssetBase(ABC): + """Base class for the mono behavior assets.""" + + asset_file_name: Optional[str] = None + + def __init__(self, parser_cls: Type[ParserBase], file_path: Optional[str] = None, /, + asset_dir: Optional[str] = None): + file_path = file_path or (asset_dir and os.path.join(asset_dir, self.asset_file_name)) + + if not file_path: + raise ValueError("Either `file_path` or " + "`asset_dir` and `asset_file_name` (class attribute) must be given.") + + self._data = parser_cls.parse_file(file_path) + + def __len__(self): + return len(self._data) + + @abstractmethod + def __iter__(self): + raise NotImplementedError() diff --git a/dlparse/mono/asset/base/entry.py b/dlparse/mono/asset/base/entry.py new file mode 100644 index 00000000..b72d69a8 --- /dev/null +++ b/dlparse/mono/asset/base/entry.py @@ -0,0 +1,23 @@ +"""Base entry class for mono behavior.""" +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime +from typing import Union, Optional + +__all__ = ("EntryBase",) + + +@dataclass +class EntryBase(ABC): + """Base class for the entries in the mono behavior assets.""" + + @staticmethod + @abstractmethod + def parse_raw(data: dict[str, Union[str, int, float]]) -> "EntryBase": + """Parse a raw data entry to be the asset entry class.""" + raise NotImplementedError() + + @staticmethod + def parse_datetime(datetime_str: str) -> Optional[datetime]: + """Parse ``datetime_str`` to be :class:`datetime` if it's not an empty string.""" + return datetime.strptime(datetime_str, "%Y/%m/%d %H:%M:%S") if datetime_str else None diff --git a/dlparse/mono/asset/base/master.py b/dlparse/mono/asset/base/master.py new file mode 100644 index 00000000..582fdb19 --- /dev/null +++ b/dlparse/mono/asset/base/master.py @@ -0,0 +1,67 @@ +"""Base object for the master assets.""" +import json +from abc import ABC +from dataclasses import dataclass +from typing import Type, Optional, Any, Callable, Union + +from .asset import AssetBase +from .entry import EntryBase +from .parser import ParserBase + +__all__ = ("MasterEntryBase", "MasterAssetBase", "MasterParserBase") + + +@dataclass +class MasterEntryBase(EntryBase, ABC): + """Base class for the entries in the master mono behavior asset.""" + + id: int # pylint: disable=invalid-name + + +class MasterParserBase(ParserBase, ABC): + """Base parser class for parsing the master asset files.""" + + @staticmethod + def get_entries(file_path: str) -> dict[int, dict]: + """Get a dict of data entries which value needs to be further parsed.""" + with open(file_path) as f: + data = json.load(f) + + if "dict" not in data: + raise ValueError("Key `dict` not in the data") + data = data["dict"] + + if "entriesValue" not in data: + raise ValueError("Key `dict.entriesValue` not in the data") + if "entriesKey" not in data: + raise ValueError("Key `dict.entriesKey` not in the data") + + entry_keys = filter(lambda key: key != 0, data["entriesKey"]) # Only the entries with key != 0 is valid + entry_values = data["entriesValue"] + + return dict(zip(entry_keys, entry_values)) + + @staticmethod + def parse_file(file_path: str) -> dict[int, Any]: + """Parse a file as a :class:`dict` which key is the ID of the value.""" + raise NotImplementedError() + + +class MasterAssetBase(AssetBase, ABC): + """Base class for a master mono behavior asset.""" + + def __init__(self, parser_cls: Type[MasterParserBase], file_path: Optional[str] = None, /, + asset_dir: Optional[str] = None): + super().__init__(parser_cls, file_path, asset_dir=asset_dir) + + def __iter__(self): + return iter(self._data.values()) + + def filter(self, condition: Callable[[MasterEntryBase], bool]) -> list[MasterEntryBase]: + """Get a list of data which matches the ``condition``.""" + return [data for data in self if condition(data)] + + def get_data_by_id(self, data_id: Union[int, str], default: Optional[MasterEntryBase] = None) \ + -> Optional[MasterEntryBase]: + """Get a data by its ``data_id``. Returns ``default`` if not found.""" + return self._data.get(data_id, default) diff --git a/dlparse/mono/asset/base/parser.py b/dlparse/mono/asset/base/parser.py new file mode 100644 index 00000000..9ea25290 --- /dev/null +++ b/dlparse/mono/asset/base/parser.py @@ -0,0 +1,21 @@ +"""Base parser class.""" +from abc import ABC, abstractmethod +from typing import Any + +__all__ = ("ParserBase",) + + +class ParserBase(ABC): + """Base parser class for parsing the asset file.""" + + # pylint: disable=too-few-public-methods + + def __init__(self): + raise RuntimeError("Parser class is not allowed to be instantiated. " + "Use the class methods or static methods directly instead.") + + @staticmethod + @abstractmethod + def parse_file(file_path: str) -> Any: + """Parse the file.""" + raise NotImplementedError() diff --git a/dlparse/mono/asset/base/player_action.py b/dlparse/mono/asset/base/player_action.py new file mode 100644 index 00000000..6384b71b --- /dev/null +++ b/dlparse/mono/asset/base/player_action.py @@ -0,0 +1,138 @@ +"""Base object for the player action assets.""" +import json +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Type, Optional, Callable, Union, ClassVar + +from .asset import AssetBase +from .entry import EntryBase +from .parser import ParserBase + +__all__ = ( + "ActionComponentBase", "ActionComponentDamageDealerMixin", + "ActionAssetBase", + "ActionParserBase" +) + + +@dataclass +class ActionComponentCondition(EntryBase): + """Condition data of an action component.""" + + type_id: int + type_values: list[int] + + @staticmethod + def parse_raw(data: dict[str, Union[int, list[int]]]) -> Optional["ActionComponentCondition"]: + if data.get("_conditionType", 0): + return None + + return ActionComponentCondition( + type_id=data["_conditionType"], + type_values=data["_conditionValue"] + ) + + +@dataclass +class ActionComponentLoop(EntryBase): + """Loop data of an action component.""" + + loop_count: int + restart_frame: int + restart_sec: float + + @staticmethod + def parse_raw(data: dict[str, Union[int, float]]) -> Optional["ActionComponentLoop"]: + if data.get("flag", 0): + return None + + return ActionComponentLoop( + loop_count=data["loopNum"], + restart_frame=data["restartFrame"], + restart_sec=data["restartSec"] + ) + + +@dataclass +class ActionComponentBase(EntryBase, ABC): + """Base class for the components in the player action mono behavior asset.""" + + command_type_id: int + + speed: float + + time_start: float + time_duration: float + + condition_data: Optional[ActionComponentCondition] + loop_data: Optional[ActionComponentLoop] + + @staticmethod + @abstractmethod + def parse_raw(data: dict[str, Union[str, int, float]]) -> "ActionComponentBase": + """Parse a raw data to be the component class.""" + raise NotImplementedError() + + @classmethod + def get_base_kwargs(cls, raw_data: dict[str, Union[str, int, float, dict[str, Union[int, float]]]]) \ + -> dict[str, Union[int, float, Optional[ActionComponentCondition], Optional[ActionComponentLoop]]]: + """Get the base kwargs for constructing the component.""" + return { + "command_type_id": raw_data["commandType"], + "speed": raw_data["_speed"], + "time_start": raw_data["_seconds"], + "time_duration": raw_data["_duration"], + "condition_data": ActionComponentCondition.parse_raw(raw_data["_conditionData"]), + "loop_data": ActionComponentLoop.parse_raw(raw_data["_loopData"]) + } + + +@dataclass +class ActionComponentDamageDealerMixin(EntryBase, ABC): + """ + Mixin class for damage dealing components. + + A damage dealing component should have hit label(s) assigned. + """ + + # pylint: disable=invalid-name + NON_DAMAGE_DEALING_LABELS: ClassVar[set[str]] = { + "CMN_AVOID" + } + + hit_labels: list[str] + + +class ActionParserBase(ParserBase, ABC): + """Base parser class for parsing the player action asset files.""" + + @staticmethod + def get_components(file_path: str) -> list[dict]: + """Get a list of components as raw data, which needs to be further parsed.""" + with open(file_path) as f: + data = json.load(f) + + if "Components" not in data: + raise ValueError("Key `Components` not in the data") + + return data["Components"] + + @staticmethod + def parse_file(file_path: str) -> list[ActionComponentBase]: + """Parse a file as a list of components.""" + raise NotImplementedError() + + +class ActionAssetBase(AssetBase, ABC): + """Base class for a player action mono behavior asset.""" + + def __init__(self, parser_cls: Type[ActionParserBase], file_path: Optional[str] = None, /, + asset_dir: Optional[str] = None): + super().__init__(parser_cls, file_path, asset_dir=asset_dir) + + def __iter__(self): + return iter(self._data) + + def filter(self, condition: Callable[[ActionComponentBase], bool]) -> list[ActionComponentBase]: + """Get a list of components which matches the ``condition``.""" + return [data for data in self if condition(data)] diff --git a/dlparse/mono/asset/master/__init__.py b/dlparse/mono/asset/master/__init__.py new file mode 100644 index 00000000..7fe07250 --- /dev/null +++ b/dlparse/mono/asset/master/__init__.py @@ -0,0 +1,5 @@ +"""Classes for the master assets.""" +from .action_hit_attr import HitAttrAsset, HitAttrEntry +from .chara_data import CharaDataAsset, CharaDataEntry +from .cheat_detection import CheatDetectionAsset, CheatDetectionEntry +from .skill_data import SkillDataAsset, SkillDataEntry diff --git a/dlparse/mono/asset/master/action_hit_attr.py b/dlparse/mono/asset/master/action_hit_attr.py new file mode 100644 index 00000000..36837e30 --- /dev/null +++ b/dlparse/mono/asset/master/action_hit_attr.py @@ -0,0 +1,62 @@ +"""Classes for handling the player action hit attribute asset.""" +from dataclasses import dataclass +from typing import Union, Optional + +from dlparse.mono.asset.base import MasterEntryBase, MasterAssetBase, MasterParserBase + +__all__ = ("HitAttrEntry", "HitAttrAsset", "HitAttrParser") + + +@dataclass +class HitAttrEntry(MasterEntryBase): + """Single entry of a hit attribute data.""" + + id: str + + damage_modifier: float + + @staticmethod + def parse_raw(data: dict[str, Union[str, float, int]]) -> "HitAttrEntry": + return HitAttrEntry( + id=data["_Id"], + damage_modifier=data["_DamageAdjustment"], + ) + + @property + def deal_damage(self) -> bool: + """ + Check if the hit actually deals damage. + + Some hits seem to be dummy hit. For example, Renee S1 def down (`DAG_002_03_H03_DEFDOWN_LV03`). + """ + return self.damage_modifier != 0 + + +class HitAttrAsset(MasterAssetBase): + """Player action hit attribute asset class.""" + + asset_file_name = "PlayerActionhitAttribute.json" + + def __init__(self, file_path: Optional[str] = None, /, + asset_dir: Optional[str] = None): + super().__init__(HitAttrParser, file_path, asset_dir=asset_dir) + + @staticmethod + def get_hit_label(original_label: str, level: int) -> str: + """ + Get the hit label at ``level``. + + For example, if ``original_label`` is ``SWD_110_04_H01_LV02`` and ``level`` is ``3``, + the return will be ``SWD_110_04_H01_LV03``. + """ + return original_label[:-1] + str(level) + + +class HitAttrParser(MasterParserBase): + """Class to parse the player action hit attribute file.""" + + @classmethod + def parse_file(cls, file_path: str) -> dict[int, HitAttrEntry]: + entries = cls.get_entries(file_path) + + return {key: HitAttrEntry.parse_raw(value) for key, value in entries.items()} diff --git a/dlparse/mono/asset/master/chara_data.py b/dlparse/mono/asset/master/chara_data.py new file mode 100644 index 00000000..b84457bf --- /dev/null +++ b/dlparse/mono/asset/master/chara_data.py @@ -0,0 +1,311 @@ +"""Classes for handling the character data asset.""" +from dataclasses import dataclass +from datetime import datetime +from typing import Union, Optional + +from dlparse.mono.asset.base import MasterEntryBase, MasterAssetBase, MasterParserBase + +__all__ = ("CharaDataEntry", "CharaDataAsset", "CharaDataParser") + + +@dataclass +class CharaDataEntry(MasterEntryBase): + """Single entry of a character data.""" + + # pylint: disable=too-many-instance-attributes + + name_label: str + second_name_label: str + emblem_id: int + + weapon_type_id: int + rarity: int + + max_limit_break_count: int + + element_id: int + + chara_type_id: int + chara_base_id: int + chara_variation_id: int + + max_hp: int + max_hp_1: int + plus_hp_0: int + plus_hp_1: int + plus_hp_2: int + plus_hp_3: int + plus_hp_4: int + plus_hp_5: int + mc_full_bonus_hp: int + + max_atk: int + max_atk_1: int + plus_atk_0: int + plus_atk_1: int + plus_atk_2: int + plus_atk_3: int + plus_atk_4: int + plus_atk_5: int + mc_full_bonus_atk: int + + def_coef: float + + mode_change_id: int + """Gala Leif / Mitsuba, etc.""" + mode_1_id: int + mode_2_id: int + mode_3_id: int + mode_4_id: int + keep_mode_on_revive: bool + + combo_original_id: int + combo_mode_1_id: int + combo_mode_2_id: int + + skill_1_id: int + skill_2_id: int + + passive_1_lv_1_id: int + passive_1_lv_2_id: int + passive_1_lv_3_id: int + passive_1_lv_4_id: int + passive_2_lv_1_id: int + passive_2_lv_2_id: int + passive_2_lv_3_id: int + passive_2_lv_4_id: int + passive_3_lv_1_id: int + passive_3_lv_2_id: int + passive_3_lv_3_id: int + passive_3_lv_4_id: int + + ex_1_id: int + ex_2_id: int + ex_3_id: int + ex_4_id: int + ex_5_id: int + + cex_1_id: int + cex_2_id: int + cex_3_id: int + cex_4_id: int + cex_5_id: int + + fs_type_id: int + fs_count_max: int + + ss_cost_max_self: int + ss_skill_id: int + ss_skill_level: int + ss_skill_cost: int + ss_skill_relation_id: int + """SS cost offset or similar. OG!Hawk and OG!Nefaria for now.""" + ss_release_item_id: int + ss_release_item_quantity: int + + unique_dragon_id: int + + is_dragon_drive: bool + """Bellina, etc.""" + is_playable: bool + + max_friendship_point: int + """Raid event units.""" + + grow_material_start: Optional[datetime] + grow_material_end: Optional[datetime] + grow_material_id: int + + @classmethod + def parse_raw(cls, data: dict[str, Union[str, int, float]]) -> "CharaDataEntry": + return CharaDataEntry( + id=data["_Id"], + name_label=data["_Name"], + second_name_label=data["_SecondName"], + emblem_id=data["_EmblemId"], + weapon_type_id=data["_WeaponType"], + rarity=data["_Rarity"], + max_limit_break_count=data["_MaxLimitBreakCount"], + element_id=data["_ElementalType"], + chara_type_id=data["_CharaType"], + chara_base_id=data["_BaseId"], + chara_variation_id=data["_VariationId"], + max_hp=data["_MaxHp"], + max_hp_1=data["_AddMaxHp1"], + plus_hp_0=data["_PlusHp0"], + plus_hp_1=data["_PlusHp1"], + plus_hp_2=data["_PlusHp2"], + plus_hp_3=data["_PlusHp3"], + plus_hp_4=data["_PlusHp4"], + plus_hp_5=data["_PlusHp5"], + mc_full_bonus_hp=data["_McFullBonusHp5"], + max_atk=data["_MaxAtk"], + max_atk_1=data["_AddMaxAtk1"], + plus_atk_0=data["_PlusAtk0"], + plus_atk_1=data["_PlusAtk1"], + plus_atk_2=data["_PlusAtk2"], + plus_atk_3=data["_PlusAtk3"], + plus_atk_4=data["_PlusAtk4"], + plus_atk_5=data["_PlusAtk5"], + mc_full_bonus_atk=data["_McFullBonusAtk5"], + def_coef=data["_DefCoef"], + mode_change_id=data["_ModeChangeType"], + mode_1_id=data["_ModeId1"], + mode_2_id=data["_ModeId2"], + mode_3_id=data["_ModeId3"], + mode_4_id=data["_ModeId4"], + keep_mode_on_revive=bool(data["_KeepModeOnRevive"]), + combo_original_id=data["_OriginCombo"], + combo_mode_1_id=data["_Mode1Combo"], + combo_mode_2_id=data["_Mode2Combo"], + skill_1_id=data["_Skill1"], + skill_2_id=data["_Skill2"], + passive_1_lv_1_id=data["_Abilities11"], + passive_1_lv_2_id=data["_Abilities12"], + passive_1_lv_3_id=data["_Abilities13"], + passive_1_lv_4_id=data["_Abilities14"], + passive_2_lv_1_id=data["_Abilities21"], + passive_2_lv_2_id=data["_Abilities22"], + passive_2_lv_3_id=data["_Abilities23"], + passive_2_lv_4_id=data["_Abilities24"], + passive_3_lv_1_id=data["_Abilities31"], + passive_3_lv_2_id=data["_Abilities32"], + passive_3_lv_3_id=data["_Abilities33"], + passive_3_lv_4_id=data["_Abilities34"], + ex_1_id=data["_ExAbilityData1"], + ex_2_id=data["_ExAbilityData2"], + ex_3_id=data["_ExAbilityData3"], + ex_4_id=data["_ExAbilityData4"], + ex_5_id=data["_ExAbilityData5"], + cex_1_id=data["_ExAbility2Data1"], + cex_2_id=data["_ExAbility2Data2"], + cex_3_id=data["_ExAbility2Data3"], + cex_4_id=data["_ExAbility2Data4"], + cex_5_id=data["_ExAbility2Data5"], + fs_type_id=data["_ChargeType"], + fs_count_max=data["_MaxChargeLv"], + ss_cost_max_self=data["_HoldEditSkillCost"], + ss_skill_id=data["_EditSkillId"], + ss_skill_level=data["_EditSkillLevelNum"], + ss_skill_cost=data["_EditSkillCost"], + ss_skill_relation_id=data["_EditSkillRelationId"], + ss_release_item_id=data["_EditReleaseEntityId1"], + ss_release_item_quantity=data["_EditReleaseEntityQuantity1"], + unique_dragon_id=data["_UniqueDragonId"], + is_dragon_drive=bool(data["_IsEnhanceChara"]), + is_playable=bool(data["_IsPlayable"]), + max_friendship_point=data["_MaxFriendshipPoint"], + grow_material_start=cls.parse_datetime(data["_GrowMaterialOnlyStartDate"]), + grow_material_end=cls.parse_datetime(data["_GrowMaterialOnlyEndDate"]), + grow_material_id=data["_GrowMaterialId"], + ) + + @property + def is_70_mc(self) -> bool: + """Check if the character has mana spiral.""" + return self.max_limit_break_count >= 5 + + @property + def max_hp_at_50(self) -> int: + """ + Get the max HP of the character at 50 MC. + + This includes the max base parameters, + with the bonus given from mana circle and the additional bonus after 50 MC. + """ + base = self.max_hp + mc_plus = self.plus_hp_0 + self.plus_hp_1 + self.plus_hp_2 + self.plus_hp_3 + self.plus_hp_4 + full_bonus = self.mc_full_bonus_hp + + return base + mc_plus + full_bonus + + @property + def max_hp_at_70(self) -> int: + """ + Get the max HP of the character at 70 MC. + + This includes the max base parameters, + with the bonus given from mana circle and the additional bonus after 50 MC. + """ + base = self.max_hp_1 + mc_plus = self.plus_hp_0 + self.plus_hp_1 + self.plus_hp_2 + self.plus_hp_3 + self.plus_hp_4 + self.plus_hp_5 + full_bonus = self.mc_full_bonus_hp + + return base + mc_plus + full_bonus + + @property + def max_hp_current(self) -> int: + """ + Get the max HP of the character at the current maximum max MC. + + This includes the max base parameters, + with the bonus given from mana circle and the additional bonus after 50 MC. + """ + if self.is_70_mc: + return self.max_hp_at_70 + + return self.max_hp_at_50 + + @property + def max_atk_at_50(self) -> int: + """ + Get the max ATK of the character at 50 MC. + + This includes the max base parameters, + with the bonus given from mana circle and the additional bonus after 50 MC. + """ + base = self.max_atk + mc_plus = self.plus_atk_0 + self.plus_atk_1 + self.plus_atk_2 + self.plus_atk_3 + self.plus_atk_4 + full_bonus = self.mc_full_bonus_atk + + return base + mc_plus + full_bonus + + @property + def max_atk_at_70(self) -> int: + """ + Get the max ATK of the character at 70 MC. + + This includes the max base parameters, + with the bonus given from mana circle and the additional bonus after 50 MC. + """ + base = self.max_atk_1 + mc_plus = ( + self.plus_atk_0 + self.plus_atk_1 + self.plus_atk_2 + + self.plus_atk_3 + self.plus_atk_4 + self.plus_atk_5 + ) + full_bonus = self.mc_full_bonus_atk + + return base + mc_plus + full_bonus + + @property + def max_atk_current(self) -> int: + """ + Get the max ATK of the character at the current maximum max MC. + + This includes the max base parameters, + with the bonus given from mana circle and the additional bonus after 50 MC. + """ + if self.is_70_mc: + return self.max_atk_at_70 + + return self.max_atk_at_50 + + +class CharaDataAsset(MasterAssetBase): + """Character data asset class.""" + + asset_file_name = "CharaData.json" + + def __init__(self, file_path: Optional[str] = None, /, + asset_dir: Optional[str] = None): + super().__init__(CharaDataParser, file_path, asset_dir=asset_dir) + + +class CharaDataParser(MasterParserBase): + """Class to parse the character data file.""" + + @classmethod + def parse_file(cls, file_path: str) -> dict[int, CharaDataEntry]: + entries = cls.get_entries(file_path) + + return {key: CharaDataEntry.parse_raw(value) for key, value in entries.items()} diff --git a/dlparse/mono/asset/master/cheat_detection.py b/dlparse/mono/asset/master/cheat_detection.py new file mode 100644 index 00000000..8918e421 --- /dev/null +++ b/dlparse/mono/asset/master/cheat_detection.py @@ -0,0 +1,49 @@ +"""Classes for handling the cheat detection param asset.""" +from dataclasses import dataclass +from typing import Union, Optional + +from dlparse.mono.asset.base import MasterEntryBase, MasterAssetBase, MasterParserBase + +__all__ = ("CheatDetectionEntry", "CheatDetectionAsset", "CheatDetectionParser") + + +@dataclass +class CheatDetectionEntry(MasterEntryBase): + """Single entry of a cheat detection data.""" + + max_enemy_damage: int + max_enemy_break_damage: int + max_enemy_player_distance: int + max_player_heal: int + max_player_move_speed: int + + @staticmethod + def parse_raw(data: dict[str, Union[str, int]]) -> "CheatDetectionEntry": + return CheatDetectionEntry( + id=data["_Id"], + max_enemy_damage=data["_MaxEnemyDamage"], + max_enemy_break_damage=data["_MaxEnemyBreakDamage"], + max_enemy_player_distance=data["_MaxEnemyPlayerDistance"], + max_player_heal=data["_MaxPlayerHeal"], + max_player_move_speed=data["_MaxPlayerMoveSpeed"] + ) + + +class CheatDetectionAsset(MasterAssetBase): + """Cheat detection parameter asset class.""" + + asset_file_name = "CheatDetectionParam.json" + + def __init__(self, file_path: Optional[str] = None, /, + asset_dir: Optional[str] = None): + super().__init__(CheatDetectionParser, file_path, asset_dir=asset_dir) + + +class CheatDetectionParser(MasterParserBase): + """Class to parse the cheat detection file.""" + + @classmethod + def parse_file(cls, file_path: str) -> dict[int, CheatDetectionEntry]: + entries = cls.get_entries(file_path) + + return {key: CheatDetectionEntry.parse_raw(value) for key, value in entries.items()} diff --git a/dlparse/mono/asset/master/skill_data.py b/dlparse/mono/asset/master/skill_data.py new file mode 100644 index 00000000..75259cd8 --- /dev/null +++ b/dlparse/mono/asset/master/skill_data.py @@ -0,0 +1,165 @@ +"""Classes for handling the skill data asset.""" +from dataclasses import dataclass +from typing import Union, Optional + +from dlparse.mono.asset.base import MasterEntryBase, MasterAssetBase, MasterParserBase + +__all__ = ("SkillDataEntry", "SkillDataAsset", "SkillDataParser") + + +@dataclass +class SkillDataEntry(MasterEntryBase): + """Single entry of a skill data.""" + + # pylint: disable=too-many-instance-attributes + + name_label: str + + skill_type_id: int + + icon_lv1_label: str + icon_lv2_label: str + icon_lv3_label: str + icon_lv4_label: str + description_lv1_label: str + description_lv2_label: str + description_lv3_label: str + description_lv4_label: str + + sp_lv1: int + sp_lv2: int + sp_lv3: int + sp_lv4: int + + sp_ss_lv1: int + sp_ss_lv2: int + sp_ss_lv3: int + sp_ss_lv4: int + + sp_dragon_lv1: int + sp_dragon_lv2: int + sp_dragon_lv3: int + sp_dragon_lv4: int + + sp_gauge_count: int + + required_buff_id: int + required_buff_count: int + + action_1_id: int + action_2_id: int + action_3_id: int + action_4_id: int + + adv_skill_lv1: int + """Skill level enhancement after 50 MC.""" + adv_skill_lv1_action_id: int + """Action ID to be used after skill enhancement.""" + + ability_1_id: int + ability_2_id: int + ability_3_id: int + ability_4_id: int + + trans_skill_id: int + """Phase change. The skill ID of the next phase.""" + trans_condition_id: int + trans_hit_count: int + trans_text_label: str + trans_time: float + trans_action_id: int + + max_use_count: int + """Currently only used on Mars (Dragon).""" + mode_change_skill_id: int + """Currently only used on Mega Man.""" + as_helper_skill_id: int + """Currently only used on Mega Man (S2).""" + + is_affected_by_tension_lv1: bool + is_affected_by_tension_lv2: bool + is_affected_by_tension_lv3: bool + is_affected_by_tension_lv4: bool + + @staticmethod + def parse_raw(data: dict[str, Union[str, int]]) -> "SkillDataEntry": + return SkillDataEntry( + id=data["_Id"], + name_label=data["_Name"], + skill_type_id=data["_SkillType"], + icon_lv1_label=data["_SkillLv1IconName"], + icon_lv2_label=data["_SkillLv2IconName"], + icon_lv3_label=data["_SkillLv3IconName"], + icon_lv4_label=data["_SkillLv4IconName"], + description_lv1_label=data["_Description1"], + description_lv2_label=data["_Description2"], + description_lv3_label=data["_Description3"], + description_lv4_label=data["_Description4"], + sp_lv1=data["_Sp"], + sp_lv2=data["_SpLv2"], + sp_lv3=data["_SpLv3"], + sp_lv4=data["_SpLv4"], + sp_ss_lv1=data["_SpEdit"], + sp_ss_lv2=data["_SpLv2Edit"], + sp_ss_lv3=data["_SpLv3Edit"], + sp_ss_lv4=data["_SpLv4Edit"], + sp_dragon_lv1=data["_SpDragon"], + sp_dragon_lv2=data["_SpLv2Dragon"], + sp_dragon_lv3=data["_SpLv3Dragon"], + sp_dragon_lv4=data["_SpLv4Dragon"], + sp_gauge_count=data["_SpGaugeCount"], + required_buff_id=data["_RequiredBuffId"], + required_buff_count=data["_RequiredBuffCount"], + action_1_id=data["_ActionId1"], + action_2_id=data["_ActionId2"], + action_3_id=data["_ActionId3"], + action_4_id=data["_ActionId4"], + adv_skill_lv1=data["_AdvancedSkillLv1"], + adv_skill_lv1_action_id=data["_AdvancedActionId1"], + ability_1_id=data["_Ability1"], + ability_2_id=data["_Ability2"], + ability_3_id=data["_Ability3"], + ability_4_id=data["_Ability4"], + trans_skill_id=data["_TransSkill"], + trans_condition_id=data["_TransCondition"], + trans_hit_count=data["_TransHitCount"], + trans_text_label=data["_TransText"], + trans_time=data["_TransTime"], + trans_action_id=data["_TransBuff"], + max_use_count=data["_MaxUseNum"], + mode_change_skill_id=data["_ModeChange"], + as_helper_skill_id=data["_Support"], + is_affected_by_tension_lv1=bool(data["_IsAffectedByTension"]), + is_affected_by_tension_lv2=bool(data["_IsAffectedByTensionLv2"]), + is_affected_by_tension_lv3=bool(data["_IsAffectedByTensionLv3"]), + is_affected_by_tension_lv4=bool(data["_IsAffectedByTensionLv4"]), + ) + + @property + def is_attacking_skill(self): + """ + Check if the skill is an attacking skill. + + Currently, ``is_affected_by_tension_lv1`` is used to perform the check. + """ + return self.is_affected_by_tension_lv1 + + +class SkillDataAsset(MasterAssetBase): + """Skill data asset class.""" + + asset_file_name = "SkillData.json" + + def __init__(self, file_path: Optional[str] = None, /, + asset_dir: Optional[str] = None): + super().__init__(SkillDataParser, file_path, asset_dir=asset_dir) + + +class SkillDataParser(MasterParserBase): + """Class to parse the skill data.""" + + @classmethod + def parse_file(cls, file_path: str) -> dict[int, SkillDataEntry]: + entries = cls.get_entries(file_path) + + return {key: SkillDataEntry.parse_raw(value) for key, value in entries.items()} diff --git a/dlparse/mono/asset/player_action/__init__.py b/dlparse/mono/asset/player_action/__init__.py new file mode 100644 index 00000000..f64343c3 --- /dev/null +++ b/dlparse/mono/asset/player_action/__init__.py @@ -0,0 +1,6 @@ +""" +Player action component classes. + +Check the document at ``notes/assets/player_action_components.md`` for details of each type of the components. +""" +from .prefab import PlayerActionPrefab diff --git a/dlparse/mono/asset/player_action/bullet.py b/dlparse/mono/asset/player_action/bullet.py new file mode 100644 index 00000000..01cf49eb --- /dev/null +++ b/dlparse/mono/asset/player_action/bullet.py @@ -0,0 +1,26 @@ +"""Class for ``ActionPartsBullet`` action component.""" +from dataclasses import dataclass +from typing import Union + +from dlparse.mono.asset.base import ActionComponentBase, ActionComponentDamageDealerMixin + + +@dataclass +class ActionBullet(ActionComponentBase, ActionComponentDamageDealerMixin): + """Class of ``ActionPartsBullet`` component in the player action asset.""" + + @classmethod + def parse_raw(cls, data: dict[str, Union[str, dict[str, str]]]) -> "ActionBullet": + kwargs = cls.get_base_kwargs(data) + + # Main hit labels + labels_possible: list[str] = [data["_hitAttrLabel"], data["_hitAttrLabel2nd"]] + + # Labels in arrange bullet + if "_arrangeBullet" in data: + labels_possible.append(data["_arrangeBullet"]["_abHitAttrLabel"]) + + return ActionBullet( + hit_labels=[label for label in labels_possible if label], + **kwargs + ) diff --git a/dlparse/mono/asset/player_action/hit.py b/dlparse/mono/asset/player_action/hit.py new file mode 100644 index 00000000..81e217dc --- /dev/null +++ b/dlparse/mono/asset/player_action/hit.py @@ -0,0 +1,18 @@ +"""Class for ``ActionPartsHit`` action component.""" +from dataclasses import dataclass + +from dlparse.mono.asset.base import ActionComponentBase, ActionComponentDamageDealerMixin + + +@dataclass +class ActionHit(ActionComponentBase, ActionComponentDamageDealerMixin): + """Class of ``ActionPartsHit`` component in the player action asset.""" + + @classmethod + def parse_raw(cls, data: dict[str, str]) -> "ActionHit": + kwargs = cls.get_base_kwargs(data) + + return ActionHit( + hit_labels=[data["_hitLabel"]], + **kwargs + ) diff --git a/dlparse/mono/asset/player_action/prefab.py b/dlparse/mono/asset/player_action/prefab.py new file mode 100644 index 00000000..7658a1f6 --- /dev/null +++ b/dlparse/mono/asset/player_action/prefab.py @@ -0,0 +1,64 @@ +"""Prefab file class for getting the components.""" +from typing import Type + +from dlparse.mono.asset.base import ( + ActionAssetBase, ActionParserBase, ActionComponentBase, ActionComponentDamageDealerMixin +) +from .bullet import ActionBullet +from .hit import ActionHit + +__all__ = ("PlayerActionPrefab",) + + +class PlayerActionParser(ActionParserBase): + """Player action prefab file parser.""" + + SCRIPT_KEY: str = "$Script" + + SCRIPT_CLASS: dict[str, Type[ActionComponentBase]] = { + "ActionPartsHit": ActionHit, + "ActionPartsBullet": ActionBullet + } + + @classmethod + def parse_file(cls, file_path: str) -> list[ActionComponentBase]: + components_raw: list[dict] = cls.get_components(file_path) + components: list[ActionComponentBase] = [] + + for component in components_raw: + component_class = cls.SCRIPT_CLASS.get(component.get(cls.SCRIPT_KEY)) + + if not component_class: + # No corresponding component class or no script key specified + continue + + components.append(component_class.parse_raw(component["_data"])) + + return components + + +class PlayerActionPrefab(ActionAssetBase): + """Class representing a single player action prefab file.""" + + def __init__(self, file_path: str): + super().__init__(PlayerActionParser, file_path) + + # Pre-categorize components for faster access + self._damaging_hits: list[ActionComponentDamageDealerMixin] = [] + + for component in self: + if isinstance(component, ActionComponentDamageDealerMixin): + self._damaging_hits.append(component) + + @property + def damage_dealing_hit_labels(self) -> list[str]: + """ + Get a :class:`list` of damage dealing hit labels. + + Note that the damage dealing hit here may not actually deal damage. The hit could attack + """ + return [ + hit_label for action_hit in sorted(self._damaging_hits, key=lambda component: component.time_start) + for hit_label in action_hit.hit_labels + if hit_label not in ActionComponentDamageDealerMixin.NON_DAMAGE_DEALING_LABELS + ] diff --git a/dlparse/mono/path/__init__.py b/dlparse/mono/path/__init__.py new file mode 100644 index 00000000..a3824122 --- /dev/null +++ b/dlparse/mono/path/__init__.py @@ -0,0 +1,2 @@ +"""Classes for getting the file path.""" +from .player_action import PlayerActionFilePathFinder diff --git a/dlparse/mono/path/player_action.py b/dlparse/mono/path/player_action.py new file mode 100644 index 00000000..54bd6b77 --- /dev/null +++ b/dlparse/mono/path/player_action.py @@ -0,0 +1,43 @@ +"""Classes for getting the player action file path.""" +import os + + +__all__ = ("PlayerActionFilePathFinder",) + + +class PlayerActionFilePathFinder: + """Class to index the player action file paths for easier access.""" + + def _init_indexing(self, file_root_path: str): + for path, _, files in os.walk(file_root_path): + for name in files: + action_id = self.extract_action_id(name) + + self._index[action_id] = os.path.join(path, name) + + def __init__(self, file_root_path: str): + self._index: dict[int, str] = {} # K = action ID; V = file path + self._init_indexing(file_root_path) + + def get_file_path(self, action_id: int) -> str: + """ + Get the file path for ``action_id``. + + :raises FileNotFoundError: action file not found + """ + file_path = self._index.get(action_id, None) + + if not file_path: + raise FileNotFoundError(f"File for action ID {action_id} not found") + + return file_path + + @staticmethod + def extract_action_id(file_name: str) -> int: + """ + Extract the action ID from ``file_name``. + + This assumes ``file_name`` is **ALWAYS** in the format of ``PlayerAction_XXXXXXXX.prefab.json``, + where XXXXXXXX corresponds to the action ID. + """ + return int(file_name[13:21]) diff --git a/dlparse/transformer/__init__.py b/dlparse/transformer/__init__.py new file mode 100644 index 00000000..a271cbcc --- /dev/null +++ b/dlparse/transformer/__init__.py @@ -0,0 +1,2 @@ +"""Various transformers to "transform" the data.""" +from .skill import SkillTransformer diff --git a/dlparse/transformer/skill.py b/dlparse/transformer/skill.py new file mode 100644 index 00000000..71e56266 --- /dev/null +++ b/dlparse/transformer/skill.py @@ -0,0 +1,83 @@ +"""Skill data transformer.""" +from typing import Optional + +from dlparse.errors import SkillDataNotFound, ActionDataNotFound +from dlparse.model import AttackingSkillData +from dlparse.mono.asset import SkillDataAsset, SkillDataEntry, HitAttrEntry, HitAttrAsset, PlayerActionPrefab +from dlparse.mono.path import PlayerActionFilePathFinder + +__all__ = ("SkillTransformer",) + + +class SkillTransformer: + """Class to transform the skill data.""" + + SKILL_MAX_LV_ATK = 4 + """Currently known max level for attacking skills.""" + + def __init__(self, skill_data_asset: SkillDataAsset, hit_attr_asset: HitAttrAsset, + action_path_finder: PlayerActionFilePathFinder): + self._skill_data = skill_data_asset + self._hit_attr = hit_attr_asset + self._action_path = action_path_finder + + def transform_supportive(self, skill_id: int): + """Transform skill of ``skill_id`` to :class:`SupportiveSkillData`.""" + raise NotImplementedError() + + def transform_attacking(self, skill_id: int) -> AttackingSkillData: + """ + Transform skill of ``skill_id`` to :class:`AttackingSkillData`. + + :raises SkillDataNotFound: if the skill data is not found + :raises ActionDataNotFound: if the action data file of the skill is not found + """ + # Get the skill data + skill_data: Optional[SkillDataEntry] = self._skill_data.get_data_by_id(skill_id) + if not skill_data: + raise SkillDataNotFound(skill_id) + + # Get the skill action data + action_id = skill_data.action_1_id + action_file_path = self._action_path.get_file_path(action_id) + + if not action_file_path: + raise ActionDataNotFound(action_id, skill_id) + + # Get the skill action prefab data + action_prefab = PlayerActionPrefab(action_file_path) + + # Get skill data + hits: list[int] = [] + mods: list[list[float]] = [] + dmg_label: list[list[HitAttrEntry]] = [] + + for hit_label_root in action_prefab.damage_dealing_hit_labels: + for skill_lv in range(1, self.SKILL_MAX_LV_ATK + 1): + hit_label = self._hit_attr.get_hit_label(hit_label_root, skill_lv) + + hit_attr_data: Optional[HitAttrEntry] = self._hit_attr.get_data_by_id(hit_label) + if not hit_attr_data: + # No further skill data available + break + + mod = hit_attr_data.damage_modifier + + # Data to be attached to the model + if hit_attr_data.deal_damage: + if skill_lv > len(mods): + mods.append([mod]) + hits.append(1) + dmg_label.append([hit_attr_data]) + else: + lv_index = skill_lv - 1 + + mods[lv_index].append(mod) + hits[lv_index] += 1 + dmg_label[lv_index].append(hit_attr_data) + + return AttackingSkillData( + hit_count=hits, + mods=mods, + damage_hit_attrs=dmg_label + ) diff --git a/main.py b/main.py new file mode 100644 index 00000000..a53c62e8 --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + pass + + +if __name__ == '__main__': + main() diff --git a/notes/assets/player_action_components.md b/notes/assets/player_action_components.md new file mode 100644 index 00000000..6265d445 --- /dev/null +++ b/notes/assets/player_action_components.md @@ -0,0 +1,118 @@ +# Notes on Player Action Components + + +## Attacking Components + +> Components that actually attacks the target. + + +### ``ActionPartsBullet`` + +Single attacking projectile of a skill. + +A single component corresponds to a single attack. + + +### ``ActionPartsHit`` + +An attacking action that comes from the player of a skill. + +A single component corresponds to a single attack. + +#### Attribute notes + +- Hit label = `CMN_AVOID` seems to mean invincible, which may appear for projectile attack (Euden S2 - `101401012`). + + +### ``ActionPartsPivotBullet`` + +Needs investigation. Should relate to ``ActionPartsBullet``. + + +## Active Components + +> Components that users can actively trigger whenever it's possible. + + +### ``ActionPartsActiveCancel`` + +Needs investigation. Possibly active canceling action. + +May be used for calculating camera duration. + + +## Effecting Components + +> Components that are the side effects (affecting the gameplay) of the action. + + +### ``ActionPartsSettingHit`` + +Sets an area with some special effects. (Wedding Elisanne S1 / S2) + +This does **not** deal damage. + +#### Attribute notes + +- `_lifetime` is the duration of the area. + + +## Camera Controlling Components + +> Components that are related to the camera control / camera duration. + + +### ``ActionPartsCameraMotion`` + +Needs investigation. Could be just the camera movement. + +May be used for calculating camera duration. + + +### ``ActionPartsHitStop`` + +Needs investigation. Frozen action after ``ActionPartsHit``. + +May be used for calculating camera duration. + + +## Miscellaneous / Unknown + +> Components that cannot be / have not been categorized. + + +### ``ActionPartsEffect`` + +Seems to be the skill effect. + +May be used for calculating camera duration. + + +### ``ActionPartsMotion`` + +Needs investigation. + +May be used for calculating camera duration. + + +### ``ActionPartsMoveTimeCurve`` + +Needs investigation. Possibly the variant of ``ActionPartsMotion`` but based on some other parameter, +or the character movement curving function (could check Summer Julietta S1). + +May be used for calculating camera duration. + + +### ``ActionPartsRotateTarget`` + +Needs investigation. + + +### ``ActionPartsSound`` + +SE of the action. + + +### ``ActionPartsSendSignal`` + +Needs investigation. diff --git a/precommit.sh b/precommit.sh new file mode 100644 index 00000000..e6cd6b39 --- /dev/null +++ b/precommit.sh @@ -0,0 +1,30 @@ +# ------ Variables + +CLR_RED= +CLR_GRN= +CLR_CYN= +CLR_NC= + +# ------ Functions + +run_cmd_exit_on_err() { + if ! $1; then + echo "${CLR_RED}Error @ $2${CLR_NC}" + read -p "Press enter to continue." -r + exit 1 + fi +} + +# ------ Main + +echo "${CLR_CYN}Checking with pydocstyle (dlparse)...${CLR_NC}" +run_cmd_exit_on_err "pydocstyle dlparse --count" "pydocstyle check (dlparse)" + +echo "${CLR_CYN}Checking with pylint (dlparse)...${CLR_NC}" +run_cmd_exit_on_err "pylint dlparse" "pylint check (dlparse)" + +echo "${CLR_CYN}Running code tests...${CLR_NC}" +run_cmd_exit_on_err pytest "code test" + +echo "--- ${CLR_GRN}All checks passed.${CLR_NC} ---" +read -p "Press enter to continue." -r diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..7ac71938 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,9 @@ +# CQA check +pydocstyle +pylint + +# CI +pytest + +# Other necessary requirements +-r requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..355267da --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from tests.static import PATH_MASTER_ASSET_DIR + + +@pytest.fixture +def asset_master_dir(): + """Get the directory of the master assets.""" + return PATH_MASTER_ASSET_DIR diff --git a/tests/static.py b/tests/static.py new file mode 100644 index 00000000..6f8b8a56 --- /dev/null +++ b/tests/static.py @@ -0,0 +1,9 @@ +import os + +__all__ = ("PATH_MASTER_ASSET_DIR", "PATH_PLAYER_ACTION_ASSET_ROOT") + + +PATH_ASSET_ROOT = os.path.join(".data", "media", "assets", "_gluonresources", "resources") + +PATH_PLAYER_ACTION_ASSET_ROOT = os.path.join(PATH_ASSET_ROOT, "actions", "playeraction") +PATH_MASTER_ASSET_DIR = os.path.join(PATH_ASSET_ROOT, "master") diff --git a/tests/test_anamoly_check/__init__.py b/tests/test_anamoly_check/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_anamoly_check/test_skills.py b/tests/test_anamoly_check/test_skills.py new file mode 100644 index 00000000..23c6666b --- /dev/null +++ b/tests/test_anamoly_check/test_skills.py @@ -0,0 +1 @@ +# TEST: Get all possible skill IDs and transform all of them diff --git a/tests/test_mono/__init__.py b/tests/test_mono/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_mono/test_asset/__init__.py b/tests/test_mono/test_asset/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_mono/test_asset/test_cheat_detection.py b/tests/test_mono/test_asset/test_cheat_detection.py new file mode 100644 index 00000000..8da4b82e --- /dev/null +++ b/tests/test_mono/test_asset/test_cheat_detection.py @@ -0,0 +1,39 @@ +import os + +from dlparse.mono.asset import CheatDetectionAsset, CheatDetectionEntry + + +def test_parse_cheat_detection_explicit_path(asset_master_dir): + cheat_detection = CheatDetectionAsset(os.path.join(asset_master_dir, "CheatDetectionParam.json")) + + entry = CheatDetectionEntry( + id=1, + max_enemy_damage=50000000, + max_enemy_break_damage=60000000, + max_enemy_player_distance=35, + max_player_heal=25000, + max_player_move_speed=10 + ) + + assert len(cheat_detection) == 1 + assert cheat_detection.get_data_by_id(1) == entry + assert cheat_detection.filter(lambda data: data.max_enemy_damage == 50000000) == [entry] + assert cheat_detection.filter(lambda data: data.max_enemy_damage == 87) == [] + + +def test_parse_cheat_detection_implicit_path(asset_master_dir): + cheat_detection = CheatDetectionAsset(asset_dir=asset_master_dir) + + entry = CheatDetectionEntry( + id=1, + max_enemy_damage=50000000, + max_enemy_break_damage=60000000, + max_enemy_player_distance=35, + max_player_heal=25000, + max_player_move_speed=10 + ) + + assert len(cheat_detection) == 1 + assert cheat_detection.get_data_by_id(1) == entry + assert cheat_detection.filter(lambda data: data.max_enemy_damage == 50000000) == [entry] + assert cheat_detection.filter(lambda data: data.max_enemy_damage == 87) == [] diff --git a/tests/test_mono/test_entry/__init__.py b/tests/test_mono/test_entry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_mono/test_entry/test_base.py b/tests/test_mono/test_entry/test_base.py new file mode 100644 index 00000000..5cc4e9c1 --- /dev/null +++ b/tests/test_mono/test_entry/test_base.py @@ -0,0 +1,8 @@ +from datetime import datetime + +from dlparse.mono.asset.base import MasterEntryBase + + +def test_parse_datetime(): + assert MasterEntryBase.parse_datetime("2020/07/28 05:59:59") == datetime(2020, 7, 28, 5, 59, 59) + assert MasterEntryBase.parse_datetime("2020/07/28 06:00:00") == datetime(2020, 7, 28, 6) diff --git a/tests/test_mono/test_entry/test_chara_data.py b/tests/test_mono/test_entry/test_chara_data.py new file mode 100644 index 00000000..0560792b --- /dev/null +++ b/tests/test_mono/test_entry/test_chara_data.py @@ -0,0 +1,153 @@ +from dlparse.mono.asset import CharaDataEntry + + +def create_dummy(**kwargs) -> CharaDataEntry: + params = { + "id": -1, + "name_label": "Dummy", + "second_name_label": "Dummy", + "emblem_id": -1, + "weapon_type_id": -1, + "rarity": -1, + "max_limit_break_count": -1, + "element_id": -1, + "chara_type_id": -1, + "chara_base_id": -1, + "chara_variation_id": -1, + "max_hp": -1, + "max_hp_1": -1, + "plus_hp_0": -1, + "plus_hp_1": -1, + "plus_hp_2": -1, + "plus_hp_3": -1, + "plus_hp_4": -1, + "plus_hp_5": -1, + "mc_full_bonus_hp": -1, + "max_atk": -1, + "max_atk_1": -1, + "plus_atk_0": -1, + "plus_atk_1": -1, + "plus_atk_2": -1, + "plus_atk_3": -1, + "plus_atk_4": -1, + "plus_atk_5": -1, + "mc_full_bonus_atk": -1, + "def_coef": -1, + "mode_change_id": -1, + "mode_1_id": -1, + "mode_2_id": -1, + "mode_3_id": -1, + "mode_4_id": -1, + "keep_mode_on_revive": False, + "combo_original_id": -1, + "combo_mode_1_id": -1, + "combo_mode_2_id": -1, + "skill_1_id": -1, + "skill_2_id": -1, + "passive_1_lv_1_id": -1, + "passive_1_lv_2_id": -1, + "passive_1_lv_3_id": -1, + "passive_1_lv_4_id": -1, + "passive_2_lv_1_id": -1, + "passive_2_lv_2_id": -1, + "passive_2_lv_3_id": -1, + "passive_2_lv_4_id": -1, + "passive_3_lv_1_id": -1, + "passive_3_lv_2_id": -1, + "passive_3_lv_3_id": -1, + "passive_3_lv_4_id": -1, + "ex_1_id": -1, + "ex_2_id": -1, + "ex_3_id": -1, + "ex_4_id": -1, + "ex_5_id": -1, + "cex_1_id": -1, + "cex_2_id": -1, + "cex_3_id": -1, + "cex_4_id": -1, + "cex_5_id": -1, + "fs_type_id": -1, + "fs_count_max": -1, + "ss_cost_max_self": -1, + "ss_skill_id": -1, + "ss_skill_level": -1, + "ss_skill_cost": -1, + "ss_skill_relation_id": -1, + "ss_release_item_id": -1, + "ss_release_item_quantity": -1, + "unique_dragon_id": -1, + "is_dragon_drive": False, + "is_playable": False, + "max_friendship_point": -1, + "grow_material_start": None, + "grow_material_end": None, + "grow_material_id": -1, + } + + params.update(kwargs) + + return CharaDataEntry(**params) + + +def test_is_70_mc(): + entry = create_dummy(max_limit_break_count=4) + assert not entry.is_70_mc + + entry = create_dummy(max_limit_break_count=5) + assert entry.is_70_mc + + +def test_max_hp_50(): + entry = create_dummy( + max_limit_break_count=4, + max_hp=1, max_hp_1=256, + plus_hp_0=2, plus_hp_1=4, plus_hp_2=8, plus_hp_3=16, plus_hp_4=32, plus_hp_5=64, + mc_full_bonus_hp=128 + ) + + assert not entry.is_70_mc + assert entry.max_hp_at_50 == 191 + assert entry.max_hp_at_70 == 510 + assert entry.max_hp_current == 191 + + +def test_max_hp_70(): + entry = create_dummy( + max_limit_break_count=5, + max_hp=1, max_hp_1=256, + plus_hp_0=2, plus_hp_1=4, plus_hp_2=8, plus_hp_3=16, plus_hp_4=32, plus_hp_5=64, + mc_full_bonus_hp=128 + ) + + assert entry.is_70_mc + assert entry.max_hp_at_50 == 191 + assert entry.max_hp_at_70 == 510 + assert entry.max_hp_current == 510 + + +def test_max_atk_50(): + entry = create_dummy( + max_limit_break_count=4, + max_atk=1, max_atk_1=256, + plus_atk_0=2, plus_atk_1=4, plus_atk_2=8, plus_atk_3=16, plus_atk_4=32, plus_atk_5=64, + mc_full_bonus_atk=128 + ) + + assert not entry.is_70_mc + assert entry.max_atk_at_50 == 191 + assert entry.max_atk_at_70 == 510 + assert entry.max_atk_current == 191 + + +def test_max_atk_70(): + entry = create_dummy( + max_limit_break_count=5, + max_atk=1, max_atk_1=256, + plus_atk_0=2, plus_atk_1=4, plus_atk_2=8, plus_atk_3=16, plus_atk_4=32, plus_atk_5=64, + mc_full_bonus_atk=128 + ) + + assert entry.is_70_mc + assert entry.max_atk_at_50 == 191 + assert entry.max_atk_at_70 == 510 + assert entry.max_atk_current == 510 diff --git a/tests/test_mono/test_path/__init__.py b/tests/test_mono/test_path/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_mono/test_path/test_player_action.py b/tests/test_mono/test_path/test_player_action.py new file mode 100644 index 00000000..0ecd18c4 --- /dev/null +++ b/tests/test_mono/test_path/test_player_action.py @@ -0,0 +1,7 @@ +from dlparse.mono.path import PlayerActionFilePathFinder + + +def test_file_action_id_extraction(): + assert PlayerActionFilePathFinder.extract_action_id("PlayerAction_00400707.prefab.json") == 400707 + assert PlayerActionFilePathFinder.extract_action_id("PlayerAction_20400707.prefab.json") == 20400707 + assert PlayerActionFilePathFinder.extract_action_id("PlayerAction_99400707.prefab.json") == 99400707 diff --git a/tests/test_transformer/__init__.py b/tests/test_transformer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_transformer/conftest.py b/tests/test_transformer/conftest.py new file mode 100644 index 00000000..1098cd0b --- /dev/null +++ b/tests/test_transformer/conftest.py @@ -0,0 +1,20 @@ +import pytest + +from dlparse.mono.asset import SkillDataAsset, HitAttrAsset +from dlparse.mono.path import PlayerActionFilePathFinder +from dlparse.transformer import SkillTransformer +from tests.static import PATH_MASTER_ASSET_DIR, PATH_PLAYER_ACTION_ASSET_ROOT + +# Asset instances +_skill_data: SkillDataAsset = SkillDataAsset(asset_dir=PATH_MASTER_ASSET_DIR) +_hit_attr: HitAttrAsset = HitAttrAsset(asset_dir=PATH_MASTER_ASSET_DIR) +_pa_path_finder: PlayerActionFilePathFinder = PlayerActionFilePathFinder(PATH_PLAYER_ACTION_ASSET_ROOT) + +# Transformets +_transformer_skill: SkillTransformer = SkillTransformer(_skill_data, _hit_attr, _pa_path_finder) + + +@pytest.fixture +def transformer_skill(): + """Get the skill transformer.""" + return _transformer_skill diff --git a/tests/test_transformer/test_skill_atk.py b/tests/test_transformer/test_skill_atk.py new file mode 100644 index 00000000..c36144e4 --- /dev/null +++ b/tests/test_transformer/test_skill_atk.py @@ -0,0 +1,197 @@ +import pytest + +from dlparse.errors import SkillDataNotFound +from dlparse.transformer import SkillTransformer + + +def test_skill_not_found(transformer_skill: SkillTransformer): + with pytest.raises(SkillDataNotFound) as error: + transformer_skill.transform_attacking(87) + + assert error.value.skill_id == 87 + + +def test_single_hit_1(transformer_skill: SkillTransformer): + # Wedding Elisanne S1 + # https://dragalialost.gamepedia.com/Wedding_Elisanne + skill_data = transformer_skill.transform_attacking(101503021) + + assert skill_data.hit_count == [1, 1, 1] + assert skill_data.hit_count_at_max == 1 + assert skill_data.total_mod == [12.03, 13.38, 14.85] + assert skill_data.total_mod_at_max == 14.85 + assert skill_data.mods == [[12.03], [13.38], [14.85]] + assert skill_data.mods_at_max == [14.85] + assert skill_data.max_available_level == 3 + + +def test_single_hit_2(transformer_skill: SkillTransformer): + # Wedding Elisanne S2 + # https://dragalialost.gamepedia.com/Wedding_Elisanne + skill_data = transformer_skill.transform_attacking(101503022) + + assert skill_data.hit_count == [1, 1] + assert skill_data.hit_count_at_max == 1 + assert skill_data.total_mod == [10.02, 10.515] + assert skill_data.total_mod_at_max == 10.515 + assert skill_data.mods == [[10.02], [10.515]] + assert skill_data.mods_at_max == [10.515] + assert skill_data.max_available_level == 2 + + +def test_single_projectile(transformer_skill: SkillTransformer): + # Euden S2 + # https://dragalialost.gamepedia.com/The_Prince + skill_data = transformer_skill.transform_attacking(101401012) + + assert skill_data.hit_count == [1, 1, 1] + assert skill_data.hit_count_at_max == 1 + assert skill_data.total_mod == [11.94, 13.27, 14.74] + assert skill_data.total_mod_at_max == 14.74 + assert skill_data.mods == [[11.94], [13.27], [14.74]] + assert skill_data.mods_at_max == [14.74] + assert skill_data.max_available_level == 3 + + +def test_multi_hits_same_damage_1(transformer_skill: SkillTransformer): + # Templar Hope S2 + # https://dragalialost.gamepedia.com/Templar_Hope + # Mods for 70 MC has already inserted, but not yet released + skill_data = transformer_skill.transform_attacking(101403022) + + assert skill_data.hit_count == [2, 2, 2] + assert skill_data.hit_count_at_max == 2 + assert skill_data.total_mod == [22.7, 26.48, 28.0] + assert skill_data.total_mod_at_max == 28.0 + assert skill_data.mods == [[11.35, 11.35], [13.24, 13.24], [14.0, 14.0]] + assert skill_data.mods_at_max == [14.0, 14.0] + assert skill_data.max_available_level == 3 + + +def test_multi_hits_same_damage_2(transformer_skill: SkillTransformer): + # Ranzal S1 + # https://dragalialost.gamepedia.com/Ranzal + skill_data = transformer_skill.transform_attacking(104403011) + + assert skill_data.hit_count == [4, 4, 4, 4] + assert skill_data.hit_count_at_max == 4 + assert skill_data.total_mod == [18.72, 20.8, 23.12, 28.912] + assert skill_data.total_mod_at_max == 28.912 + assert skill_data.mods == [[4.68] * 4, [5.2] * 4, [5.78] * 4, [7.228] * 4] + assert skill_data.mods_at_max == [7.228] * 4 + assert skill_data.max_available_level == 4 + + +def test_multi_hits_different_damage_1(transformer_skill: SkillTransformer): + # Summer Julietta S1 + # https://dragalialost.gamepedia.com/Summer_Julietta + skill_data = transformer_skill.transform_attacking(104502011) + + assert skill_data.hit_count == [3, 3, 3, 3] + assert skill_data.hit_count_at_max == 3 + assert skill_data.total_mod == pytest.approx([22.352, 23.452, 24.64, 27.082]) + assert skill_data.total_mod_at_max == 27.082 + assert skill_data.mods == [ + [11.176, 3.344, 7.832], [11.726, 3.52, 8.206], [12.32, 3.696, 8.624], [13.552, 4.048, 9.482] + ] + assert skill_data.mods_at_max == [13.552, 4.048, 9.482] + assert skill_data.max_available_level == 4 + + +def test_multi_hits_different_damage_2(transformer_skill: SkillTransformer): + # Gala Euden S2 + # https://dragalialost.gamepedia.com/Gala_Prince + skill_data = transformer_skill.transform_attacking(101504032) + + assert skill_data.hit_count == [13] * 2 + assert skill_data.hit_count_at_max == 13 + assert skill_data.total_mod == pytest.approx([1.2 * 3 + 3.6 * 10, 1.34 * 3 + 4 * 10]) + assert skill_data.total_mod_at_max == pytest.approx(44.02) + assert skill_data.mods == [ + [1.2] + [3.6] + [1.2] * 2 + [3.6] * 9, + [1.34] + [4] + [1.34] * 2 + [4] * 9, + ] + assert skill_data.mods_at_max == [1.34] + [4] + [1.34] * 2 + [4] * 9 + assert skill_data.max_available_level == 2 + + +def test_has_dummy_hits(transformer_skill: SkillTransformer): + # Renee S1 + # https://dragalialost.gamepedia.com/Renee + skill_data = transformer_skill.transform_attacking(103402031) + + assert skill_data.hit_count == [6, 6, 6, 6] + assert skill_data.hit_count_at_max == 6 + assert skill_data.total_mod == pytest.approx([16.98, 17.82, 18.66, 18.78]) + assert skill_data.total_mod_at_max == pytest.approx(18.78) + assert skill_data.mods == [[2.83] * 6, [2.97] * 6, [3.11] * 6, [3.13] * 6] + assert skill_data.mods_at_max == [3.13] * 6 + assert skill_data.max_available_level == 4 + + +def test_has_punisher(transformer_skill: SkillTransformer): + # Veronica S1 + # https://dragalialost.gamepedia.com/Veronica + skill_data_base = transformer_skill.transform_attacking(107505011) + + # Base data + skill_data = skill_data_base.under_condition() + + assert skill_data.hit_count == [4, 4, 4, 4] + assert skill_data.hit_count_at_max == 4 + assert skill_data.total_mod == pytest.approx([ + 2.69 * 3 + 3.37, + 2.99 * 3 + 3.73, + 3.32 * 3 + 4.15, + 5.97 * 3 + 6.86, + ]) + assert skill_data.total_mod_at_max == pytest.approx(5.97 * 3 + 6.86) + assert skill_data.mods == [ + [2.69] * 3 + [3.37], + [2.99] * 3 + [3.73], + [3.32] * 3 + [4.15], + [5.97] * 3 + [6.86], + ] + assert skill_data.mods_at_max == [5.97] * 3 + [6.86] + assert skill_data.max_available_level == 4 + + # Poison Punisher + skill_data = skill_data_base.under_condition() + + assert skill_data.hit_count == [4, 4, 4, 4] + assert skill_data.hit_count_at_max == 4 + assert skill_data.total_mod == pytest.approx([ + (2.69 * 3 + 3.37) * 1.2, + (2.99 * 3 + 3.73) * 1.2, + (3.32 * 3 + 4.15) * 1.2, + (5.97 * 3 + 6.86) * 1.2, + ]) + assert skill_data.total_mod_at_max == pytest.approx((5.97 * 3 + 6.86) * 1.2) + assert skill_data.mods == [ + [2.69 * 1.2] * 3 + [3.37 * 1.2], + [2.99 * 1.2] * 3 + [3.73 * 1.2], + [3.32 * 1.2] * 3 + [4.15 * 1.2], + [5.97 * 1.2] * 3 + [6.86 * 1.2], + ] + assert skill_data.mods_at_max == [5.97 * 1.2] * 3 + [6.86 * 1.2] + assert skill_data.max_available_level == 4 + + +def test_buff_related(transformer_skill: SkillTransformer): + # Karina S1 + # https://dragalialost.gamepedia.com/Karina + skill_data = transformer_skill.transform_attacking(104402011) + + assert skill_data.hit_count == [2, 2, 2, 2] + assert skill_data.hit_count_at_max == 2 + assert skill_data.total_mod == pytest.approx([5.96 * 2, 6.63 * 2, 7.36 * 2, 8.18 * 2]) + assert skill_data.total_mod_at_max == pytest.approx(16.36) + assert skill_data.mods == [[5.96] * 2, [6.63] * 2, [7.36] * 2, [8.18] * 2] + assert skill_data.mods_at_max == [8.18] * 2 + assert skill_data.max_available_level == 4 + + +def test_sigil_released(transformer_skill: SkillTransformer): + # Nevin S2 + # https://dragalialost.gamepedia.com/Nevin + pass