From 754b562019d241f3a69805b12efc1cb3ae480249 Mon Sep 17 00:00:00 2001 From: RaenonX Date: Fri, 18 Dec 2020 04:02:05 -0600 Subject: [PATCH] ADD - Ability parsing prototype Signed-off-by: RaenonX --- .data/media | 2 +- README.md | 4 +- dlparse/enums/ability_variant.py | 3 + dlparse/enums/buff_parameter.py | 24 ++-- dlparse/enums/skill_condition/category.py | 21 ++- dlparse/enums/skill_condition/composite.py | 6 +- dlparse/enums/skill_condition/items.py | 20 ++- dlparse/errors/mono_entry.py | 26 +++- dlparse/model/__init__.py | 2 + dlparse/model/ability.py | 44 ++++++- dlparse/model/buff_boost.py | 19 ++- dlparse/model/effect_ability.py | 79 ++++++++++++ dlparse/model/effect_action_cond.py | 43 +------ dlparse/model/effect_base.py | 45 +++++++ dlparse/mono/asset/master/__init__.py | 3 +- dlparse/mono/asset/master/ability.py | 121 +++++++++++++++--- .../mono/asset/master/ability_limit_group.py | 55 ++++++++ dlparse/mono/manager.py | 10 +- dlparse/transformer/ability.py | 5 +- notes/enums/AbilityVariant.md | 20 ++- .../{test_skills.py => test_transform.py} | 11 +- .../test_ability/test_main.py | 23 +++- tests/utils/__init__.py | 1 + tests/utils/unit_ability.py | 42 ++++++ tests/utils/unit_base.py | 47 +++++-- 25 files changed, 549 insertions(+), 127 deletions(-) create mode 100644 dlparse/model/effect_ability.py create mode 100644 dlparse/model/effect_base.py create mode 100644 dlparse/mono/asset/master/ability_limit_group.py rename tests/test_anamoly_check/{test_skills.py => test_transform.py} (88%) create mode 100644 tests/utils/unit_ability.py diff --git a/.data/media b/.data/media index a015deae..5b098806 160000 --- a/.data/media +++ b/.data/media @@ -1 +1 @@ -Subproject commit a015deae750b267167c14cae1166f0f5af355b0b +Subproject commit 5b098806a866e91d885816b004ca260016a83a1e diff --git a/README.md b/README.md index e8d7b21a..196c8b5d 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,9 @@ View the data of all given hit attributes. ### Datamining and data deploying pipeline -- \[SimCord\] qwewqa +- \[SimCord\] qwewqa / Mustard Yellow + +- \[SimCord\] eave - **\[OM\] [Ryo][GH-ryo]** diff --git a/dlparse/enums/ability_variant.py b/dlparse/enums/ability_variant.py index 713ae6d1..3323a93b 100644 --- a/dlparse/enums/ability_variant.py +++ b/dlparse/enums/ability_variant.py @@ -25,6 +25,9 @@ class AbilityVariantType(Enum): CHANGE_STATE = 14 """Calls the hit attribute (at str field) or the action condition (ID at ID-A field) if the condition holds.""" + SP_CHARGE = 17 + """Charge the SP gauges.""" + GAUGE_STATUS = 40 """Grants different effects according to the user's gauge status.""" diff --git a/dlparse/enums/buff_parameter.py b/dlparse/enums/buff_parameter.py index 8d6ff995..b6005cdf 100644 --- a/dlparse/enums/buff_parameter.py +++ b/dlparse/enums/buff_parameter.py @@ -37,25 +37,31 @@ class BuffParameter(Enum): """Rate of SP gain. A value of 0.12 means SP +12%.""" SP_GAIN = 202 """Immediate SP gain. A value of 100 means get SP 100.""" - SP_CHARGE_PCT = 203 - """ - Immediately charges the SP by certain % of **ALL** skills. - - A value of 0.15 means to refill 15% SP of all skills. - """ - SP_CHARGE_PCT_S1 = 204 + SP_CHARGE_PCT_S1 = 211 """ Immediately charges the SP of **S1**. A value of 0.15 means to refill 15% SP of S1. """ - SP_CHARGE_PCT_S2 = 205 + SP_CHARGE_PCT_S2 = 212 """ Immediately charges the SP of **S2**. A value of 0.15 means to refill 15% SP of S2. """ - SP_CHARGE_PCT_USED = 206 + SP_CHARGE_PCT_S3 = 213 + """ + Immediately charges the SP of **S3**. + + A value of 0.15 means to refill 15% SP of S3. + """ + SP_CHARGE_PCT_S4 = 214 + """ + Immediately charges the SP of **S4**. + + A value of 0.15 means to refill 15% SP of S4. + """ + SP_CHARGE_PCT_USED = 215 """ Immediately charges the SP of the skill that was just used. diff --git a/dlparse/enums/skill_condition/category.py b/dlparse/enums/skill_condition/category.py index b70455b0..497f5139 100644 --- a/dlparse/enums/skill_condition/category.py +++ b/dlparse/enums/skill_condition/category.py @@ -4,6 +4,7 @@ from dlparse.errors import EnumConversionError from .items import SkillCondition +from ..ability_condition import AbilityCondition from ..condition_base import ConditionCheckResultMixin from ..element import Element from ..status import Status @@ -31,6 +32,7 @@ class SkillConditionCheckResult(ConditionCheckResultMixin, Enum): MULTIPLE_ACTION_CONDITION = auto() MULTIPLE_GAUGE_FILLED = auto() MULTIPLE_LAPIS_CARD = auto() + MULTIPLE_MISC = auto() INTERNAL_NOT_AFFLICTION_ONLY = auto() INTERNAL_NOT_TARGET_ELEMENTAL = auto() @@ -184,7 +186,7 @@ def get_members_lte(self, threshold: float) -> list[SkillCondition]: class SkillConditionCategories: """Categories for skill conditions (:class:`SkillCondition`).""" - # region Target + # region 1xx - Target target_status = SkillConditionCategory[Status]( { # Abnormal statuses @@ -227,7 +229,7 @@ class SkillConditionCategories: ) # endregion - # region Self status (general) + # region 2xx - Self status (general) self_hp_status = SkillConditionCategoryTargetNumber( { SkillCondition.SELF_HP_1: 0, @@ -319,7 +321,7 @@ class SkillConditionCategories: ) # endregion - # region Skill animation/effect + # region 3xx - Skill animation/effect skill_bullet_hit = SkillConditionCategoryTargetNumber( { SkillCondition.BULLET_HIT_1: 1, @@ -389,7 +391,7 @@ class SkillConditionCategories: ) # endregion - # region Self status (special) + # region 4xx - Self status (special) action_condition = SkillConditionCategoryTargetNumber( { # Value is the corresponding Action Condition ID (not necessary means that it needs to exist) @@ -427,6 +429,17 @@ class SkillConditionCategories: ) # endregion + # region 9xx - Miscellaneous + misc = SkillConditionCategory[AbilityCondition]( + { + SkillCondition.QUEST_START: AbilityCondition.QUEST_START, + }, + SkillConditionMaxCount.SINGLE, + "Miscellaneous", + SkillConditionCheckResult.MULTIPLE_MISC + ) + # endregion + _action_cond_cat: dict[int, SkillConditionCategory] = { 1319: self_lapis_card } diff --git a/dlparse/enums/skill_condition/composite.py b/dlparse/enums/skill_condition/composite.py index 44e71706..cbd286af 100644 --- a/dlparse/enums/skill_condition/composite.py +++ b/dlparse/enums/skill_condition/composite.py @@ -26,7 +26,11 @@ class SkillConditionComposite(ConditionCompositeBase[SkillCondition]): SkillCondition.TARGET_OD_STATE, SkillCondition.TARGET_BK_STATE, SkillCondition.MARK_EXPLODES, - SkillCondition.SELF_ENERGIZED + SkillCondition.SELF_ENERGIZED, + SkillCondition.SKILL_USED_S1, + SkillCondition.SKILL_USED_S2, + SkillCondition.SKILL_USED_ALL, + SkillCondition.QUEST_START } # region Target diff --git a/dlparse/enums/skill_condition/items.py b/dlparse/enums/skill_condition/items.py index 079f82d0..19cd6c31 100644 --- a/dlparse/enums/skill_condition/items.py +++ b/dlparse/enums/skill_condition/items.py @@ -15,7 +15,7 @@ class SkillCondition(Enum): NONE = 0 - # region Target + # region 1xx - Target # region Afflicted TARGET_POISONED = 101 TARGET_BURNED = 102 @@ -54,7 +54,7 @@ class SkillCondition(Enum): # endregion # endregion - # region Self status (general) + # region 2xx - Self status (general) # region HP SELF_HP_1 = 200 """User's HP = 1.""" @@ -139,7 +139,7 @@ class SkillCondition(Enum): # endregion # endregion - # region Skill animation/effect + # region 3xx - Skill animation/effect # region Bullet hit count BULLET_HIT_1 = 301 BULLET_HIT_2 = 302 @@ -194,7 +194,7 @@ class SkillCondition(Enum): # endregion # endregion - # region Self status (special) + # region 4xx - Self status (special) # region Action condition (Sigil released, lapis cards, etc.) SELF_SIGIL_LOCKED = 400 # ACID: 1152 SELF_SIGIL_RELEASED = 401 @@ -210,12 +210,22 @@ class SkillCondition(Enum): SELF_GAUGE_FILLED_2 = 452 # endregion + # region Skill usage + SKILL_USED_S1 = 481 + SKILL_USED_S2 = 482 + SKILL_USED_ALL = 489 + # endregion + # region Special (Energized, inspired) SELF_ENERGIZED = 490 - # endregion # endregion + # region 9xx - Miscellaneous (e.g. quest start) + QUEST_START = 901 + + # endregion + def __bool__(self): return self != SkillCondition.NONE diff --git a/dlparse/errors/mono_entry.py b/dlparse/errors/mono_entry.py index a82705f8..a3ddfc3c 100644 --- a/dlparse/errors/mono_entry.py +++ b/dlparse/errors/mono_entry.py @@ -4,7 +4,8 @@ from .base import AppValueError, EntryNotFoundError __all__ = ("SkillDataNotFoundError", "ActionDataNotFoundError", "TextLabelNotFoundError", - "AbilityConditionUnconvertibleError", "BulletMaxCountUnavailableError") + "AbilityLimitDataNotFoundError", "AbilityOnSkillUnconvertibleError", + "AbilityConditionUnconvertibleError", "BulletMaxCountUnavailableError", "AbilityVariantUnconvertibleError") class SkillDataNotFoundError(EntryNotFoundError): @@ -35,6 +36,21 @@ def __init__(self, label: str): super().__init__(f"Text of label {label} not found") +class AbilityLimitDataNotFoundError(EntryNotFoundError): + """Error to be raised if the ability limit data is not found.""" + + def __init__(self, data_id: int): + super().__init__(f"Ability limit data of ID {data_id} not found") + + +class AbilityVariantUnconvertibleError(AppValueError): + """Error to be raised if the ability variant cannot be converted to ability variant effect unit.""" + + def __init__(self, ability_id: int, variant_type: int): + super().__init__(f"Unable to convert ability variant to effect units " + f"(ability ID: {ability_id} / variant type ID {variant_type})") + + class AbilityConditionUnconvertibleError(AppValueError): """Error to be raised if the ability condition cannot be converted to skill condition.""" @@ -43,5 +59,13 @@ def __init__(self, ability_condition: int, val_1: float, val_2: float): f"(ability condition code: {ability_condition} / val 1: {val_1} / val 2: {val_2})") +class AbilityOnSkillUnconvertibleError(AppValueError): + """Error to be raised if the on skill field of the ability cannot be converted to skill condition.""" + + def __init__(self, ability_id: int, on_skill: int): + super().__init__(f"Unable to convert ability on skill condition to skill condition " + f"(ability ID: {ability_id} / on skill: {on_skill}") + + class BulletMaxCountUnavailableError(AppValueError): """Error to be raised if the bullet max count cannot be obtained solely from its action component.""" diff --git a/dlparse/model/__init__.py b/dlparse/model/__init__.py index 398da191..1f59d8fb 100644 --- a/dlparse/model/__init__.py +++ b/dlparse/model/__init__.py @@ -1,7 +1,9 @@ """Various custom data models.""" from .ability import AbilityData from .buff_boost import BuffCountBoostData, BuffZoneBoostData +from .effect_ability import AbilityVariantEffectUnit from .effect_action_cond import ActionConditionEffectUnit, AfflictionEffectUnit +from .effect_base import EffectUnitBase from .hit_base import HitData from .hit_buff import BuffingHitData from .hit_dmg import DamagingHitData diff --git a/dlparse/model/ability.py b/dlparse/model/ability.py index 3b5534d6..37b9979c 100644 --- a/dlparse/model/ability.py +++ b/dlparse/model/ability.py @@ -1,15 +1,51 @@ """Models for ability data.""" -from dataclasses import dataclass -from typing import TYPE_CHECKING +from dataclasses import InitVar, dataclass, field +from typing import TYPE_CHECKING, TypeVar + +from .effect_base import EffectUnitBase if TYPE_CHECKING: - from dlparse.mono.asset import AbilityEntry + from dlparse.mono.asset import AbilityEntry, AbilityLimitGroupAsset __all__ = ("AbilityData",) +T = TypeVar("T", bound=EffectUnitBase) + @dataclass class AbilityData: """A transformed ability data.""" - ability_data: list["AbilityEntry"] + asset_ability_limit: InitVar["AbilityLimitGroupAsset"] # Used for recording the max possible value + + ability_data: dict[int, "AbilityEntry"] + + _effect_units: set[T] = field(init=False) + + def _init_units(self, asset_ability_limit: "AbilityLimitGroupAsset"): + self._effect_units = set() + for ability_entry in self.ability_data.values(): + self._effect_units.update(ability_entry.to_effect_units(asset_ability_limit)) + + def __post_init__(self, asset_ability_limit: "AbilityLimitGroupAsset"): + self._init_units(asset_ability_limit) + + @property + def has_unknown_condition(self): + """Check if the ability data contains any unknown condition.""" + return any(ability_entry.is_unknown_condition for ability_entry in self.ability_data.values()) + + @property + def has_unknown_variants(self): + """Check if the ability data contains any unknown variants.""" + return any(ability_entry.has_unknown_elements for ability_entry in self.ability_data.values()) + + @property + def has_unknown_elements(self) -> bool: + """Check if the ability data contains any unknown variants or condition.""" + return self.has_unknown_condition or self.has_unknown_variants + + @property + def effect_units(self) -> set[T]: + """Get all effect units of the ability.""" + return self._effect_units diff --git a/dlparse/model/buff_boost.py b/dlparse/model/buff_boost.py index ef2898f8..bcf06066 100644 --- a/dlparse/model/buff_boost.py +++ b/dlparse/model/buff_boost.py @@ -1,10 +1,13 @@ """Buff boosting data model.""" from abc import ABC, abstractmethod from dataclasses import dataclass +from typing import TYPE_CHECKING from dlparse.enums import SkillConditionComposite -from dlparse.mono.asset import ActionConditionAsset, BuffCountAsset, HitAttrEntry -from .hit_dmg import DamagingHitData + +if TYPE_CHECKING: + from .hit_dmg import DamagingHitData + from dlparse.mono.asset import ActionConditionAsset, BuffCountAsset, HitAttrEntry __all__ = ("BuffCountBoostData", "BuffZoneBoostData") @@ -17,12 +20,6 @@ class BuffBoostData(ABC): def __hash__(self): raise NotImplementedError() - def __eq__(self, other): - if not isinstance(other, self.__class__): - return False - - return hash(self) == hash(other) - @dataclass(eq=False) class BuffCountBoostData(BuffBoostData): @@ -45,8 +42,8 @@ class BuffCountBoostData(BuffBoostData): @staticmethod def from_hit_attr( - hit_attr: HitAttrEntry, condition_comp: SkillConditionComposite, - asset_action_cond: ActionConditionAsset, asset_buff_count: BuffCountAsset + hit_attr: "HitAttrEntry", condition_comp: SkillConditionComposite, + asset_action_cond: "ActionConditionAsset", asset_buff_count: "BuffCountAsset" ) -> "BuffCountBoostData": """Get the buff count boost data of ``hit_attr``.""" if not hit_attr.boost_by_buff_count: @@ -100,7 +97,7 @@ def __hash__(self): return hash((self.rate_by_self * 1E5, self.rate_by_ally * 1E5,)) @staticmethod - def from_hit_units(hit_data_list: list[DamagingHitData]) -> "BuffZoneBoostData": + def from_hit_units(hit_data_list: list["DamagingHitData"]) -> "BuffZoneBoostData": """``hit_data_list`` to a buff zone boosting data.""" rate_by_self = sum(hit_data.mod_on_self_buff_zone for hit_data in hit_data_list) rate_by_ally = sum(hit_data.mod_on_ally_buff_zone for hit_data in hit_data_list) diff --git a/dlparse/model/effect_ability.py b/dlparse/model/effect_ability.py new file mode 100644 index 00000000..8760c0c4 --- /dev/null +++ b/dlparse/model/effect_ability.py @@ -0,0 +1,79 @@ +"""Class for an ability effect unit.""" +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from dlparse.enums import AbilityVariantType, BuffParameter, HitTargetSimple, SkillConditionComposite, Status +from dlparse.errors import AbilityVariantUnconvertibleError +from .effect_base import EffectUnitBase + +if TYPE_CHECKING: + from dlparse.mono.asset import AbilityLimitGroupAsset, AbilityVariantEntry + +__all__ = ("AbilityVariantEffectUnit",) + + +@dataclass(eq=False) +class AbilityVariantEffectUnit(EffectUnitBase): + """The smallest unit of an ability effect coming from an ability variant.""" + + source_ability_id: int + condition_comp: SkillConditionComposite + + rate_max: float + + def __hash__(self): + # x 1E5 for handling floating errors + return hash((self.source_ability_id, self.parameter, int(self.rate * 1E5))) + + def __lt__(self, other): + if not isinstance(other, self.__class__): + raise TypeError(f"Unable to compare {type(self.__class__)} with {type(other)}") + + return ((self.source_ability_id, self.condition_comp, int(self.parameter.value), self.rate) + < (other.source_ability_id, other.condition_comp, int(other.parameter.value), other.rate)) + + @staticmethod + def _from_sp_charge( + ability_variant: "AbilityVariantEntry", ability_id: int, condition_comp: SkillConditionComposite, /, + asset_ability_limit: "AbilityLimitGroupAsset" + ) -> set["AbilityVariantEffectUnit"]: + charge_params = { + BuffParameter.SP_CHARGE_PCT_S1, BuffParameter.SP_CHARGE_PCT_S2, + BuffParameter.SP_CHARGE_PCT_S3, BuffParameter.SP_CHARGE_PCT_S4 + } + + return { + AbilityVariantEffectUnit( + source_ability_id=ability_id, + condition_comp=condition_comp, + parameter=param, + probability_pct=100, + rate=ability_variant.up_value / 100, # Original data is percentage + rate_max=asset_ability_limit.get_max_value(ability_variant.limited_group_id), + target=HitTargetSimple.SELF, + status=Status.NONE, + duration_time=0, + duration_count=0, + max_stack_count=0, + slip_damage_mod=0, + slip_interval=0, + ) for param in charge_params + } + + @classmethod + def from_ability_variant( + cls, ability_variant: "AbilityVariantEntry", ability_id: int, condition_comp: SkillConditionComposite, /, + asset_ability_limit: "AbilityLimitGroupAsset" + ) -> set["AbilityVariantEffectUnit"]: + """ + Get the ability variant effect units of this ability variant. + + :raises AbilityVariantUnconvertibleError: if the variant type is not handled / unconvertible + """ + if ability_variant.type_enum == AbilityVariantType.SP_CHARGE: + return cls._from_sp_charge( + ability_variant, ability_id, condition_comp, + asset_ability_limit=asset_ability_limit + ) + + raise AbilityVariantUnconvertibleError(ability_id, ability_variant.type_id) diff --git a/dlparse/model/effect_action_cond.py b/dlparse/model/effect_action_cond.py index 19ddece1..b41fcad0 100644 --- a/dlparse/model/effect_action_cond.py +++ b/dlparse/model/effect_action_cond.py @@ -1,61 +1,26 @@ """Classes for a single ability condition effect.""" from dataclasses import dataclass, field -from dlparse.enums import BuffParameter, HitTargetSimple, Status +from .effect_base import EffectUnitBase __all__ = ("ActionConditionEffectUnit", "AfflictionEffectUnit") -@dataclass -class ActionConditionEffectUnit: - """The atomic unit of an effect of an action condition.""" +@dataclass(eq=False) +class ActionConditionEffectUnit(EffectUnitBase): + """The smallest unit of an effect of an action condition.""" time: float - status: Status - - target: HitTargetSimple - parameter: BuffParameter - probability_pct: float # 90 = 90% - rate: float - - slip_interval: float - slip_damage_mod: float - - duration_time: float - duration_count: float - hit_attr_label: str action_cond_id: int - max_stack_count: int - """ - Maximum count of the buffs stackable. - - ``0`` means not applicable (``duration_count`` = 0, most likely is a buff limited by time duration). - - ``1`` means unstackable. - - Any positive number means the maximum count of stacks possible. - """ - - @property - def stackable(self): - """Check if the effect unit is stackable.""" - return self.max_stack_count != 1 - def __hash__(self): # Same hit attribute label may be used multiple times at different time # Action condition ID not included because it's bound with hit attribute label # x 1E5 for handling floating errors return hash((int(self.time * 1E5), self.hit_attr_label)) - def __eq__(self, other): - if not isinstance(other, self.__class__): - return False - - return hash(self) == hash(other) - def __lt__(self, other): if not isinstance(other, self.__class__): raise ValueError(f"Cannot compare `ActionConditionEffectUnit` with {type(other)}") diff --git a/dlparse/model/effect_base.py b/dlparse/model/effect_base.py new file mode 100644 index 00000000..e42ad56c --- /dev/null +++ b/dlparse/model/effect_base.py @@ -0,0 +1,45 @@ +"""Base class of the effect unit.""" +from abc import ABC, abstractmethod +from dataclasses import dataclass + +from dlparse.enums import BuffParameter, HitTargetSimple, Status + +__all__ = ("EffectUnitBase",) + + +@dataclass +class EffectUnitBase(ABC): + """Base class of the effect units.""" + + status: Status + + target: HitTargetSimple + parameter: BuffParameter + probability_pct: float # 90 = 90% + rate: float + + slip_interval: float + slip_damage_mod: float + + duration_time: float + duration_count: float + + max_stack_count: int + """ + Maximum count of the buffs stackable. + + ``0`` means not applicable (``duration_count`` = 0, most likely is a buff limited by time duration). + + ``1`` means unstackable. + + Any positive number means the maximum count of stacks possible. + """ + + @property + def stackable(self): + """Check if the effect unit is stackable.""" + return self.max_stack_count != 1 + + @abstractmethod + def __hash__(self): + raise NotImplementedError() diff --git a/dlparse/mono/asset/master/__init__.py b/dlparse/mono/asset/master/__init__.py index 1ac5fa7b..54c6daa9 100644 --- a/dlparse/mono/asset/master/__init__.py +++ b/dlparse/mono/asset/master/__init__.py @@ -1,5 +1,6 @@ """Classes for the master assets.""" -from .ability import AbilityAsset, AbilityEntry +from .ability import AbilityAsset, AbilityEntry, AbilityVariantEntry +from .ability_limit_group import AbilityLimitGroupAsset, AbilityLimitGroupEntry from .action_condition import ActionConditionAsset, ActionConditionEntry from .action_hit_attr import HitAttrAsset, HitAttrEntry from .buff_count import BuffCountAsset, BuffCountEntry diff --git a/dlparse/mono/asset/master/ability.py b/dlparse/mono/asset/master/ability.py index d296536c..f870d078 100644 --- a/dlparse/mono/asset/master/ability.py +++ b/dlparse/mono/asset/master/ability.py @@ -1,12 +1,16 @@ """Classes for handling the ability data.""" from dataclasses import dataclass, field -from typing import Optional, TextIO, Union +from typing import Optional, TextIO, TypeVar, Union -from dlparse.enums import AbilityCondition, AbilityVariantType, SkillCondition, SkillNumber -from dlparse.errors import AbilityConditionUnconvertibleError +from dlparse.enums import AbilityCondition, AbilityVariantType, SkillCondition, SkillConditionComposite, SkillNumber +from dlparse.errors import AbilityConditionUnconvertibleError, AbilityOnSkillUnconvertibleError +from dlparse.model import AbilityVariantEffectUnit, EffectUnitBase from dlparse.mono.asset.base import MasterAssetBase, MasterEntryBase, MasterParserBase +from .ability_limit_group import AbilityLimitGroupAsset -__all__ = ("AbilityEntry", "AbilityAsset", "AbilityParser") +__all__ = ("AbilityVariantEntry", "AbilityEntry", "AbilityAsset", "AbilityParser") + +T = TypeVar("T", bound=EffectUnitBase) @dataclass @@ -71,18 +75,27 @@ def to_skill_condition(self) -> SkillCondition: if self.condition_type in (AbilityCondition.SELF_HP_LT, AbilityCondition.SELF_HP_LT_2): return self._skill_cond_self_hp_lt() + # Quest start + if self.condition_type == AbilityCondition.QUEST_START: + return SkillCondition.QUEST_START + # User energized if self.condition_type == AbilityCondition.ENERGIZED_MOMENT: return SkillCondition.SELF_ENERGIZED raise AbilityConditionUnconvertibleError(self.condition_code, self.val_1, self.val_2) + @property + def is_unknown_condition(self) -> bool: + """Check if the condition type is unknown.""" + return self.condition_type == AbilityCondition.UNKNOWN + @dataclass class AbilityVariantEntry: """A single ability variant class. This class is for a group of fields in :class:`AbilityEntry`.""" - type_enum: AbilityVariantType + type_id: int id_a: int id_b: int id_c: int @@ -91,6 +104,8 @@ class AbilityVariantEntry: target_action_id: int up_value: float + type_enum: AbilityVariantType = field(init=False) + # K = min combo count; V = damage boost rate # - Highest combo first _combo_boost_data: list[tuple[int, float]] = field(default_factory=list) @@ -98,6 +113,8 @@ class AbilityVariantEntry: _skill_boost_data: list[int] = field(default_factory=list) def __post_init__(self): + self.type_enum = AbilityVariantType(self.type_id) + if self.type_enum == AbilityVariantType.DMG_UP_ON_COMBO: # Variant type is boost by combo for entry in self.id_str.split("/"): @@ -115,6 +132,21 @@ def is_not_used(self) -> bool: """Check if the variant is not used.""" return self.type_enum == AbilityVariantType.NOT_USED + @property + def is_unknown_type(self): + """Check if the variant type is unknown.""" + return self.type_enum == AbilityVariantType.UNKNOWN + + @property + def is_boosted_by_combo(self) -> bool: + """Check if the variant type is to boost the damage according to the combo count.""" + return self.type_enum == AbilityVariantType.DMG_UP_ON_COMBO + + @property + def is_boosted_by_gauge_status(self) -> bool: + """Check if the damage will be boosted according to the gauge status.""" + return self.type_enum == AbilityVariantType.GAUGE_STATUS + @property def assigned_hit_label(self) -> Optional[str]: """Get the assigned hit label. Return ``None`` if unavailable.""" @@ -138,16 +170,6 @@ def enhanced_skill(self) -> Optional[tuple[int, SkillNumber]]: if self.type_enum == AbilityVariantType.ENHANCE_SKILL else None ) - @property - def is_boosted_by_combo(self) -> bool: - """Check if the variant type is to boost the damage according to the combo count.""" - return self.type_enum == AbilityVariantType.DMG_UP_ON_COMBO - - @property - def is_boosted_by_gauge_status(self) -> bool: - """Check if the damage will be boosted according to the gauge status.""" - return self.type_enum == AbilityVariantType.GAUGE_STATUS - def get_boost_by_combo(self, combo_count: int) -> float: """ Get the total damage boost rate when the user combo count is ``combo_count``. @@ -182,6 +204,8 @@ class AbilityEntry(MasterEntryBase): condition: AbilityConditionEntry + on_skill: int + variant_1: AbilityVariantEntry variant_2: AbilityVariantEntry variant_3: AbilityVariantEntry @@ -223,6 +247,29 @@ def variants(self) -> list[AbilityVariantEntry]: """ return [variant for variant in (self.variant_1, self.variant_2, self.variant_3) if not variant.is_not_used] + @property + def on_skill_condition(self) -> SkillCondition: + """ + Convert the on skill field to its corresponding skill condition. + + :raises AbilityOnSkillUnconvertibleError: unable to convert on skill condition to skill condition + """ + # Value of `3` is a legacy one, usage unknown, currently no units are using it (2020/12/18) + + if self.on_skill == 0: + return SkillCondition.NONE + + if self.on_skill == 1: + return SkillCondition.SKILL_USED_S1 + + if self.on_skill == 2: + return SkillCondition.SKILL_USED_S2 + + if self.on_skill == 99: + return SkillCondition.SKILL_USED_ALL + + raise AbilityOnSkillUnconvertibleError(self.id, self.on_skill) + @property def is_boost_by_combo(self) -> bool: """Check if the damage will be boosted according to the current combo count.""" @@ -233,6 +280,21 @@ def is_boost_by_gauge_status(self) -> bool: """Check if the damage will be boosted according to the gauge status.""" return any(variant.is_boosted_by_gauge_status for variant in self.variants) + @property + def is_unknown_condition(self) -> bool: + """Check if the ability condition is unknown.""" + return self.condition.is_unknown_condition + + @property + def has_unknown_variant(self) -> bool: + """Check if any of the ability variants is unknown.""" + return any(variant.is_unknown_type for variant in self.variants) + + @property + def has_unknown_elements(self) -> bool: + """Check if the ability data contains any unknown variants or condition.""" + return self.is_unknown_condition or self.has_unknown_variant + def get_variants(self, ability_asset: "AbilityAsset") -> list[AbilityVariantEntry]: """Get all variants bound to the ability.""" variants_traverse: list[AbilityVariantEntry] = self.variants @@ -276,6 +338,28 @@ def get_boost_by_gauge_filled_dmg(self, gauge_filled: int) -> float: """ return sum(variant.get_boost_by_gauge_filled_dmg(gauge_filled) for variant in self.variants) + def to_effect_units(self, asset_ability_limit: AbilityLimitGroupAsset) -> set[T]: + """Convert the current ability effects (usually in the variants) to effect units.""" + effect_units: set[T] = set() + + for variant in self.variants: + if variant.type_enum == AbilityVariantType.OTHER_ABILITY: + continue # Refer to the other ability, no variant effect + + # Get the conditions + conditions: list[SkillCondition] = [] + if on_skill_cond := self.on_skill_condition: + conditions.append(on_skill_cond) + if ability_cond := self.condition.to_skill_condition(): + conditions.append(ability_cond) + + effect_units.update(AbilityVariantEffectUnit.from_ability_variant( + variant, self.id, SkillConditionComposite(conditions), + asset_ability_limit=asset_ability_limit, + )) + + return effect_units + @staticmethod def parse_raw(data: dict[str, Union[str, int]]) -> "AbilityEntry": return AbilityEntry( @@ -285,19 +369,20 @@ def parse_raw(data: dict[str, Union[str, int]]) -> "AbilityEntry": condition=AbilityConditionEntry( data["_ConditionType"], data["_ConditionValue"], data["_ConditionValue2"] ), + on_skill=data["_OnSkill"], variant_1=AbilityVariantEntry( - AbilityVariantType(data["_AbilityType1"]), + data["_AbilityType1"], data["_VariousId1a"], data["_VariousId1b"], data["_VariousId1c"], data["_VariousId1str"], data["_AbilityLimitedGroupId1"], data["_TargetAction1"], data["_AbilityType1UpValue"] ), variant_2=AbilityVariantEntry( - AbilityVariantType(data["_AbilityType2"]), + data["_AbilityType2"], data["_VariousId2a"], data["_VariousId2b"], data["_VariousId2c"], data["_VariousId2str"], data["_AbilityLimitedGroupId2"], data["_TargetAction2"], data["_AbilityType2UpValue"]), variant_3=AbilityVariantEntry( - AbilityVariantType(data["_AbilityType3"]), + data["_AbilityType3"], data["_VariousId3a"], data["_VariousId3b"], data["_VariousId3c"], data["_VariousId3str"], data["_AbilityLimitedGroupId3"], data["_TargetAction3"], data["_AbilityType3UpValue"] diff --git a/dlparse/mono/asset/master/ability_limit_group.py b/dlparse/mono/asset/master/ability_limit_group.py new file mode 100644 index 00000000..3d8512d1 --- /dev/null +++ b/dlparse/mono/asset/master/ability_limit_group.py @@ -0,0 +1,55 @@ +"""Classes for handling the ability limit group data asset.""" +from dataclasses import dataclass +from typing import Optional, TextIO, Union + +from dlparse.errors import AbilityLimitDataNotFoundError +from dlparse.mono.asset.base import MasterAssetBase, MasterEntryBase, MasterParserBase + +__all__ = ("AbilityLimitGroupEntry", "AbilityLimitGroupAsset", "AbilityLimitGroupParser") + + +@dataclass +class AbilityLimitGroupEntry(MasterEntryBase): + """Single entry of a cheat detection data.""" + + max_value: float + + @staticmethod + def parse_raw(data: dict[str, Union[str, int]]) -> "AbilityLimitGroupEntry": + return AbilityLimitGroupEntry( + id=data["_Id"], + max_value=data["_MaxLimitedValue"] + ) + + +class AbilityLimitGroupAsset(MasterAssetBase[AbilityLimitGroupEntry]): + """Ability limit group asset class.""" + + asset_file_name = "AbilityLimitedGroup.json" + + def __init__( + self, file_location: Optional[str] = None, /, + asset_dir: Optional[str] = None, file_like: Optional[TextIO] = None + ): + super().__init__(AbilityLimitGroupParser, file_location, asset_dir=asset_dir, file_like=file_like) + + def get_max_value(self, data_id: int) -> float: + """ + Get the max value of ``data_id``. + + :raises AbilityLimitDataNotFoundError: if the ability limit data is not found + """ + if data := self.get_data_by_id(data_id): + return data.max_value + + raise AbilityLimitDataNotFoundError(data_id) + + +class AbilityLimitGroupParser(MasterParserBase[AbilityLimitGroupEntry]): + """Class to parse the ability limit group file.""" + + @classmethod + def parse_file(cls, file_like: TextIO) -> dict[int, AbilityLimitGroupEntry]: + entries = cls.get_entries_dict(file_like) + + return {key: AbilityLimitGroupEntry.parse_raw(value) for key, value in entries.items()} diff --git a/dlparse/mono/manager.py b/dlparse/mono/manager.py index 9882ac67..c2a3391b 100644 --- a/dlparse/mono/manager.py +++ b/dlparse/mono/manager.py @@ -3,8 +3,8 @@ from dlparse.transformer import AbilityTransformer, SkillTransformer from .asset import ( - AbilityAsset, ActionConditionAsset, ActionPartsListAsset, BuffCountAsset, CharaDataAsset, CharaModeAsset, - DragonDataAsset, HitAttrAsset, PlayerActionInfoAsset, SkillChainAsset, SkillDataAsset, TextAsset, + AbilityAsset, AbilityLimitGroupAsset, ActionConditionAsset, ActionPartsListAsset, BuffCountAsset, CharaDataAsset, + CharaModeAsset, DragonDataAsset, HitAttrAsset, PlayerActionInfoAsset, SkillChainAsset, SkillDataAsset, TextAsset, ) from .loader import ActionFileLoader @@ -20,6 +20,7 @@ def __init__( ): # Assets self._asset_ability_data: AbilityAsset = AbilityAsset(asset_dir=master_asset_dir) + self._asset_ability_limit: AbilityLimitGroupAsset = AbilityLimitGroupAsset(asset_dir=master_asset_dir) self._asset_action_cond: ActionConditionAsset = ActionConditionAsset(asset_dir=master_asset_dir) self._asset_buff_count: BuffCountAsset = BuffCountAsset(asset_dir=master_asset_dir) self._asset_chara_data: CharaDataAsset = CharaDataAsset(asset_dir=master_asset_dir) @@ -45,6 +46,11 @@ def asset_ability_data(self) -> AbilityAsset: """Get the ability data asset.""" return self._asset_ability_data + @property + def asset_ability_limit(self) -> AbilityLimitGroupAsset: + """Get the ability limit data asset.""" + return self._asset_ability_limit + @property def asset_action_cond(self) -> ActionConditionAsset: """Get the action condition data asset.""" diff --git a/dlparse/transformer/ability.py b/dlparse/transformer/ability.py index 83de8f89..dddbf06c 100644 --- a/dlparse/transformer/ability.py +++ b/dlparse/transformer/ability.py @@ -4,7 +4,7 @@ from dlparse.model import AbilityData if TYPE_CHECKING: - from dlparse.mono.asset import AbilityAsset + from dlparse.mono.asset import AbilityAsset, AbilityLimitGroupAsset from dlparse.mono.manager import AssetManager __all__ = ("AbilityTransformer",) @@ -17,9 +17,10 @@ class AbilityTransformer: def __init__(self, asset_manager: "AssetManager"): self._ability_data: "AbilityAsset" = asset_manager.asset_ability_data + self._ability_limit: "AbilityLimitGroupAsset" = asset_manager.asset_ability_limit def transform_ability(self, ability_id: int) -> AbilityData: """Transform ``ability_id`` to an ability data.""" ability_data = self._ability_data.get_data_by_id(ability_id) - return AbilityData(ability_data.get_all_ability(self._ability_data)) + return AbilityData(self._ability_limit, ability_data.get_all_ability(self._ability_data)) diff --git a/notes/enums/AbilityVariant.md b/notes/enums/AbilityVariant.md index 8f64d692..bd507976 100644 --- a/notes/enums/AbilityVariant.md +++ b/notes/enums/AbilityVariant.md @@ -31,13 +31,13 @@ Each variant has at most 1 string affiliated. Empty string `""` for not used. Field: `_VariousIdNstr`. For example, `_VariousId1str` for the string affiliated of the 1st variant. -### Variant Limited Group +### Variant Limit Group Each variant has at most 1 group ID affliated. `0` for not used. Field: `_AbilityLimitedGroupIdN`. For example, `_AbilityLimitedGroupId1` for the 1st variant. -> Used for limiting the up value. +> This is used for limiting the value. More information of the group ID can be found in `AbilityLimitedGroup.json`. ### Variant Target Action @@ -76,7 +76,7 @@ Field: `_AbilityTypeNUpValue`. For example, `_AbilityType1UpValue` for the 1st v 14. ChangeState 15. ResistInstantDeath 16. DebuffGrantUp -17. SpCharge +17. SpCharge 18. BuffExtension 19. DebuffExtension 20. AbnormalKiller @@ -161,6 +161,20 @@ The hit attribute to be called (if given). A value of `BUF_222_LV01` means that to call the hit attribute `BUF_222_LV01` once the condition satisifes. +### `17` - `SpCharge` + +Charge all SP gauges. + +### Variant Limit Group + +ID of the value limiting group. + +# Variant Up Value + +The percentage of the SP to charge for all skills. + +A value of `100` means to charge all skills with 100% SP (immediately ready the skill). + ### `40` - `ActiveGaugeStatusUp` Get the status up information according to the user gauge status. diff --git a/tests/test_anamoly_check/test_skills.py b/tests/test_anamoly_check/test_transform.py similarity index 88% rename from tests/test_anamoly_check/test_skills.py rename to tests/test_anamoly_check/test_transform.py index 907cef97..40075eb1 100644 --- a/tests/test_anamoly_check/test_skills.py +++ b/tests/test_anamoly_check/test_transform.py @@ -5,7 +5,7 @@ from dlparse.model import AttackingSkillDataEntry, SupportiveSkillEntry from dlparse.mono.asset import CharaDataEntry from dlparse.mono.manager import AssetManager -from dlparse.transformer import AbilityTransformer, SkillTransformer +from dlparse.transformer import SkillTransformer from tests.expected_skills_lookup import skill_ids_atk, skill_ids_sup allowed_no_base_mods_sid = { @@ -57,7 +57,7 @@ def test_transform_all_attack_skills(transformer_skill: SkillTransformer, asset_ assert len(no_base_mods_sid) == 0, f"Skills without any modifiers included: {no_base_mods_sid}" -@pytest.mark.skip("Temporarily skipping total supportive skill transforming check. " +@pytest.mark.skip("Temporarily skipping exhaustive supportive skill transforming check. " "Remove when start working on supportive skills.") def test_transform_all_supportive_skills( transformer_skill: SkillTransformer, asset_manager: AssetManager @@ -100,10 +100,3 @@ def test_transform_all_supportive_skills( assert len(skill_ids_missing) == 0, f"Missing attacking skills (could be more): {set(skill_ids_missing.keys())}" assert len(skill_no_buff) == 0, f"Skills without any buffs: {skill_no_buff}" - - -def test_transform_all_character_ability(transformer_ability: AbilityTransformer, asset_manager: AssetManager): - for chara_data in asset_manager.asset_chara_data: - for ability_id in chara_data.ability_ids_all_level: - # FIXME: Check for any unknown conditions - ability_data = transformer_ability.transform_ability(ability_id) diff --git a/tests/test_transformer/test_ability/test_main.py b/tests/test_transformer/test_ability/test_main.py index f61c84a9..a0448deb 100644 --- a/tests/test_transformer/test_ability/test_main.py +++ b/tests/test_transformer/test_ability/test_main.py @@ -1,4 +1,6 @@ +from dlparse.enums import BuffParameter, SkillCondition, SkillConditionComposite from dlparse.transformer import AbilityTransformer +from tests.utils import AbilityEffectInfo, check_ability_effect_unit_match def test_all_skill_prep(transformer_ability: AbilityTransformer): @@ -7,9 +9,18 @@ def test_all_skill_prep(transformer_ability: AbilityTransformer): ability_data = transformer_ability.transform_ability(721) - # FIXME: Add some tests to imply the possible usages - # - Keep condition values, only transform "onSkill" to be also the condition - # - Transform effects - # FIXME: [PRIO] Steps for dev - # - try transform all - # - find the best internal data structure for exporting + cond_quest_start = SkillConditionComposite(SkillCondition.QUEST_START) + cond_skill_used = SkillConditionComposite(SkillCondition.SKILL_USED_ALL) + + expected_info = { + AbilityEffectInfo(721, cond_quest_start, BuffParameter.SP_CHARGE_PCT_S1, 1), + AbilityEffectInfo(721, cond_quest_start, BuffParameter.SP_CHARGE_PCT_S2, 1), + AbilityEffectInfo(721, cond_quest_start, BuffParameter.SP_CHARGE_PCT_S3, 1), + AbilityEffectInfo(721, cond_quest_start, BuffParameter.SP_CHARGE_PCT_S4, 1), + AbilityEffectInfo(723, cond_skill_used, BuffParameter.SP_CHARGE_PCT_S1, 0.05), + AbilityEffectInfo(723, cond_skill_used, BuffParameter.SP_CHARGE_PCT_S2, 0.05), + AbilityEffectInfo(723, cond_skill_used, BuffParameter.SP_CHARGE_PCT_S3, 0.05), + AbilityEffectInfo(723, cond_skill_used, BuffParameter.SP_CHARGE_PCT_S4, 0.05), + } + + check_ability_effect_unit_match(ability_data.effect_units, expected_info) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 353e3118..90f66863 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -1,5 +1,6 @@ """Miscellaneous test utils.""" from .misc import approx_matrix +from .unit_ability import AbilityEffectInfo, check_ability_effect_unit_match from .unit_affliction import AfflictionInfo, check_affliction_unit_match from .unit_buff import BuffEffectInfo, check_buff_unit_match from .unit_debuff import DebuffInfo, check_debuff_unit_match diff --git a/tests/utils/unit_ability.py b/tests/utils/unit_ability.py new file mode 100644 index 00000000..4f999bbc --- /dev/null +++ b/tests/utils/unit_ability.py @@ -0,0 +1,42 @@ +"""Implementations for checking the affliction effects.""" +from dataclasses import dataclass +from typing import Any + +from dlparse.enums import BuffParameter, SkillConditionComposite +from dlparse.model import AbilityVariantEffectUnit +from .unit_base import AbilityInfoBase, check_info_list_match + +__all__ = ("AbilityEffectInfo", "check_ability_effect_unit_match") + + +@dataclass +class AbilityEffectInfo(AbilityInfoBase): + """A single affliction info entry.""" + + condition_comp: SkillConditionComposite + parameter: BuffParameter + rate: float + + def __hash__(self): + # x 1E5 for error tolerance + return hash((self.condition_comp, self.parameter, int(self.rate * 1E5))) + + def __lt__(self, other): + if not isinstance(other, self.__class__): + raise TypeError(f"Unable to compare {type(self.__class__)} with {type(other)}") + + return ((self.source_ability_id, int(self.parameter.value), self.rate) + < (other.source_ability_id, int(other.parameter.value), other.rate)) + + +def check_ability_effect_unit_match( + actual_units: set[AbilityVariantEffectUnit], expected_info: set[AbilityEffectInfo], /, + message: Any = None +): + """Check if the info of the affliction units match.""" + actual_info = [ + AbilityEffectInfo(unit.source_ability_id, unit.condition_comp, unit.parameter, unit.rate) + for unit in actual_units + ] + + check_info_list_match(actual_info, expected_info, message=message) diff --git a/tests/utils/unit_base.py b/tests/utils/unit_base.py index ca8d5899..2843f5a7 100644 --- a/tests/utils/unit_base.py +++ b/tests/utils/unit_base.py @@ -1,25 +1,36 @@ """Base functions for checking the units.""" -from abc import abstractmethod +from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, TypeVar +from typing import Any, Iterable, TypeVar +__all__ = ("InfoBase", "BuffInfoBase", "AbilityInfoBase", "check_info_list_match") -@dataclass -class BuffInfoBase: - """Base class for buff info.""" - hit_label: str +@dataclass +class InfoBase: + """Base class for a partial info to match.""" @abstractmethod def __hash__(self): raise NotImplementedError() + @abstractmethod + def __lt__(self, other): + raise NotImplementedError() + def __eq__(self, other): if not isinstance(other, self.__class__): return False return hash(self) == hash(other) + +@dataclass +class BuffInfoBase(InfoBase, ABC): + """Base class for a buff info.""" + + hit_label: str + def __lt__(self, other): if not isinstance(other, self.__class__): raise TypeError(f"Unable to compare {type(self.__class__)} with {type(other)}") @@ -27,14 +38,30 @@ def __lt__(self, other): return self.hit_label < other.hit_label -T = TypeVar("T", bound=BuffInfoBase) +@dataclass +class AbilityInfoBase(InfoBase, ABC): + """Base class for an ability info.""" + + source_ability_id: int + def __lt__(self, other): + if not isinstance(other, self.__class__): + raise TypeError(f"Unable to compare {type(self.__class__)} with {type(other)}") -def check_info_list_match(actual_info: list[T], expected_info: list[T], /, message: Any = None): + return self.source_ability_id < other.source_ability_id + + +T = TypeVar("T", bound=InfoBase) + + +def check_info_list_match(actual_info: Iterable[T], expected_info: Iterable[T], /, message: Any = None): """Check if both lists of the info match.""" + expected_info: list[T] = list(sorted(expected_info)) + actual_info: list[T] = list(sorted(actual_info)) + # Message is hard-coded to let PyCharm display diff comparison tool - expr_expected = "\\n".join([str(info) for info in sorted(expected_info)]) - expr_actual = "\\n".join([str(info) for info in sorted(actual_info)]) + expr_expected = "\\n".join([str(info) for info in expected_info]) + expr_actual = "\\n".join([str(info) for info in actual_info]) assert_expr = f"assert [{expr_actual}] == [{expr_expected}]"