From 6b1e17834cb7c0148be928fbeb92f40a63f1f990 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Fri, 29 May 2026 20:51:36 +0200 Subject: [PATCH 01/39] update1 ProfileModel rejected Seals and Charms because those sections were not defined. TTS could classify HoradricSeal/Charm, but read_descr treated them as unsupported non-equipment and returned None. Changed files: src/config/profile_models.py src/item/data/item_type.py src/item/descr/read_descr_tts.py src/item/filter.py tests/config/models_test.py tests/item/read_descr_tts_test.py tests/item/filter/filter_test.py --- src/config/profile_models.py | 58 ++++++++++++++++++++++++------- src/item/data/item_type.py | 4 +++ src/item/descr/read_descr_tts.py | 5 ++- src/item/filter.py | 56 ++++++++++++++++++++++++----- tests/config/models_test.py | 44 +++++++++++++++++++++++ tests/item/filter/filter_test.py | 23 ++++++++++-- tests/item/read_descr_tts_test.py | 27 ++++++++++++++ 7 files changed, 191 insertions(+), 26 deletions(-) diff --git a/src/config/profile_models.py b/src/config/profile_models.py index f75b3f31..4ffbd6cf 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -9,6 +9,7 @@ from src.config.helper import check_greater_than_zero, validate_percent from src.item.data.item_type import ItemType # noqa: TC001 from src.item.data.rarity import ItemRarity +from src.scripts import correct_name MODULE_LOGGER = logging.getLogger(__name__) @@ -19,6 +20,26 @@ def _parse_item_type_or_rarities(data: str | list[str]) -> list[str]: return data +def _is_item_rarity(data: str) -> bool: + return any(rarity.value.lower() == data.lower() for rarity in ItemRarity) + + +def _parse_name_or_rarities(data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]: + if isinstance(data, dict): + return data + if isinstance(data, str): + if _is_item_rarity(data): + return {"rarities": [data]} + return {"name": data} + if isinstance(data, list): + if not data: + msg = "list cannot be empty" + raise ValueError(msg) + return {"rarities": data} + msg = "must be str or list" + raise ValueError(msg) + + class AffixAspectFilterModel(BaseModel): model_config = ConfigDict(extra="forbid") name: str @@ -295,19 +316,28 @@ def name_must_exist(cls, name: str) -> str: @model_validator(mode="before") @classmethod def parse_data(cls, data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]: - if isinstance(data, dict): - return data - if isinstance(data, str): - if any(rarity.value.lower() == data.lower() for rarity in ItemRarity): - return {"rarities": [data]} - return {"name": data} - if isinstance(data, list): - if not data: - msg = "list cannot be empty" - raise ValueError(msg) - return {"rarities": data} - msg = "must be str or list" - raise ValueError(msg) + return _parse_name_or_rarities(data) + + @field_validator("rarities", mode="before") + @classmethod + def parse_rarities(cls, data: str | list[str]) -> list[str]: + return _parse_item_type_or_rarities(data) + + +class NameRarityFilterModel(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str | None = None + rarities: list[ItemRarity] = [] + + @field_validator("name") + @classmethod + def normalize_name(cls, name: str | None) -> str | None: + return correct_name(name) + + @model_validator(mode="before") + @classmethod + def parse_data(cls, data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]: + return _parse_name_or_rarities(data) @field_validator("rarities", mode="before") @classmethod @@ -319,8 +349,10 @@ class ProfileModel(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) affixes: list[DynamicItemFilterModel] = Field(default=[], alias="Affixes") aspect_upgrades: list[str] = Field(default=[], alias="AspectUpgrades") + charms: list[NameRarityFilterModel] = Field(default=[], alias="Charms") global_uniques: list[GlobalUniqueModel] = Field(default=[], alias="GlobalUniques") name: str + seals: list[NameRarityFilterModel] = Field(default=[], alias="Seals") sigils: SigilFilterModel = Field( default=SigilFilterModel(blacklist=[], whitelist=[], priority=SigilPriority.blacklist), alias="Sigils" ) diff --git a/src/item/data/item_type.py b/src/item/data/item_type.py index a9f7b5e6..51076577 100644 --- a/src/item/data/item_type.py +++ b/src/item/data/item_type.py @@ -75,6 +75,10 @@ def is_sigil(item_type: ItemType) -> bool: return item_type in [ItemType.Sigil, ItemType.EscalationSigil] +def is_horadric_spellcraft(item_type: ItemType) -> bool: + return item_type in [ItemType.HoradricSeal, ItemType.Charm] + + def is_jewelry(item_type: ItemType) -> bool: return item_type in [ItemType.Amulet, ItemType.Ring] diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index 2836b5f6..eb437cd4 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -14,6 +14,7 @@ ItemType, is_armor, is_consumable, + is_horadric_spellcraft, is_jewelry, is_non_sigil_mapping, is_sigil, @@ -276,7 +277,7 @@ def _create_base_item_from_tts(tts_item: list[str]) -> Item | None: starting_item_type_index = 1 if item.rarity == ItemRarity.Mythic: starting_item_type_index = 2 - elif item.rarity == ItemRarity.Common: + elif item.rarity == ItemRarity.Common and search_string_split[0] != ItemRarity.Common.value: starting_item_type_index = 0 item.item_type = _get_item_type(" ".join(search_string_split[starting_item_type_index:])) item.name = correct_name(tts_item[0]) @@ -451,6 +452,7 @@ def read_descr_mixed(img_item_descr: np.ndarray) -> Item | None: if any([ is_consumable(item.item_type), is_non_sigil_mapping(item.item_type), + is_horadric_spellcraft(item.item_type), is_sigil(item.item_type), is_socketable(item.item_type), item.item_type in [ItemType.Material, ItemType.Tribute], @@ -501,6 +503,7 @@ def read_descr() -> Item | None: if any([ is_consumable(item.item_type), is_non_sigil_mapping(item.item_type), + is_horadric_spellcraft(item.item_type), is_socketable(item.item_type), item.item_type in [ItemType.Material, ItemType.Tribute, ItemType.Cache, ItemType.LairBossKey], item.seasonal_attribute == SeasonalAttribute.sanctified, diff --git a/src/item/filter.py b/src/item/filter.py index f48e7c3f..5ea0f94a 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -17,6 +17,7 @@ AffixFilterModel, DynamicItemFilterModel, GlobalUniqueModel, + NameRarityFilterModel, ProfileModel, SigilConditionModel, SigilFilterModel, @@ -67,6 +68,8 @@ class Filter: aspect_upgrade_filters = {} paragon_filters = {} global_unique_filters = {} + seal_filters = {} + charm_filters = {} sigil_filters = {} tribute_filters = {} @@ -236,31 +239,52 @@ def _check_sigil(self, item: Item) -> FilterResult: res.matched.append(MatchedFilter(f"{profile_name}")) return res - def _check_tribute(self, item: Item) -> FilterResult: + def _check_name_rarity_filters( + self, + item: Item, + item_filters: dict[str, list[NameRarityFilterModel | TributeFilterModel]], + section_name: str, + mythic_name: str, + ) -> FilterResult: res = FilterResult(keep=False, matched=[]) - if not self.tribute_filters.items(): - LOGGER.info(f"{item.original_name} -- Matched Tributes") + if not item_filters.items(): + LOGGER.info(f"{item.original_name} -- Matched {section_name}") res.keep = True - res.matched.append(MatchedFilter("Tributes not filtered")) + res.matched.append(MatchedFilter(f"{section_name} not filtered")) if item.rarity == ItemRarity.Mythic: - LOGGER.info(f"{item.original_name} -- Matched mythic tribute, always kept") + LOGGER.info(f"{item.original_name} -- Matched mythic {section_name.lower()}, always kept") res.keep = True - res.matched.append(MatchedFilter("Mythic Tribute")) + res.matched.append(MatchedFilter(mythic_name)) - for profile_name, profile_filter in self.tribute_filters.items(): + for profile_name, profile_filter in item_filters.items(): for filter_item in profile_filter: - if filter_item.name and not item.name.startswith(filter_item.name): + if filter_item.name and not (item.name or "").startswith(filter_item.name): continue if filter_item.rarities and item.rarity not in filter_item.rarities: continue - LOGGER.info(f"{item.original_name} -- Matched {profile_name}.Tributes") + LOGGER.info(f"{item.original_name} -- Matched {profile_name}.{section_name}") res.keep = True res.matched.append(MatchedFilter(f"{profile_name}")) return res + def _check_tribute(self, item: Item) -> FilterResult: + return self._check_name_rarity_filters( + item=item, item_filters=self.tribute_filters, section_name="Tributes", mythic_name="Mythic Tribute" + ) + + def _check_seal(self, item: Item) -> FilterResult: + return self._check_name_rarity_filters( + item=item, item_filters=self.seal_filters, section_name="Seals", mythic_name="Mythic Seal" + ) + + def _check_charm(self, item: Item) -> FilterResult: + return self._check_name_rarity_filters( + item=item, item_filters=self.charm_filters, section_name="Charms", mythic_name="Mythic Charm" + ) + def _check_global_unique_filter(self, item: Item) -> FilterResult: res = FilterResult(keep=False, matched=[]) @@ -479,6 +503,8 @@ def load_files(self): self.affix_filters: dict[str, list[DynamicItemFilterModel]] = {} self.aspect_upgrade_filters: dict[str, list[str]] = {} self.paragon_filters: dict[str, object] = {} + self.seal_filters: dict[str, list[NameRarityFilterModel]] = {} + self.charm_filters: dict[str, list[NameRarityFilterModel]] = {} self.sigil_filters: dict[str, SigilFilterModel] = {} self.tribute_filters: dict[str, list[TributeFilterModel]] = {} self.global_unique_filters: dict[str, list[GlobalUniqueModel]] = {} @@ -539,6 +565,12 @@ def load_files(self): if data.aspect_upgrades: self.aspect_upgrade_filters[data.name] = data.aspect_upgrades sections.append(ASPECT_UPGRADES_LABEL) + if data.seals: + self.seal_filters[data.name] = data.seals + sections.append("Seals") + if data.charms: + self.charm_filters[data.name] = data.charms + sections.append("Charms") if data.sigils and (data.sigils.blacklist or data.sigils.whitelist): self.sigil_filters[data.name] = data.sigils sections.append("Sigils") @@ -575,6 +607,12 @@ def should_keep(self, item: Item) -> FilterResult: if item.item_type == ItemType.Tribute: return self._check_tribute(item) + if item.item_type == ItemType.HoradricSeal: + return self._check_seal(item) + + if item.item_type == ItemType.Charm: + return self._check_charm(item) + if item.item_type is None or item.power is None: return res diff --git a/tests/config/models_test.py b/tests/config/models_test.py index 4d04532c..d338e167 100644 --- a/tests/config/models_test.py +++ b/tests/config/models_test.py @@ -23,6 +23,7 @@ GlobalUniqueModel, ItemFilterModel, ItemRarity, + NameRarityFilterModel, ProfileModel, SigilConditionModel, SigilFilterModel, @@ -590,6 +591,31 @@ def test_rarities_parse_list(self) -> None: assert ItemRarity.Legendary in model.rarities +class TestNameRarityFilterModel: + """Test generic name and rarity filters.""" + + def test_name_is_normalized(self) -> None: + """Test item names are normalized for matching TTS item names.""" + model = NameRarityFilterModel.model_validate("Faint Charm") + assert model.name == "faint_charm" + + def test_parse_from_string_rarity(self) -> None: + """Test parsing every rarity string.""" + for rarity in ItemRarity: + model = NameRarityFilterModel.model_validate(rarity.value) + assert rarity in model.rarities + + def test_parse_from_list(self) -> None: + """Test parsing from a rarity list.""" + model = NameRarityFilterModel.model_validate([ItemRarity.Legendary.value, ItemRarity.Unique.value]) + assert model.rarities == [ItemRarity.Legendary, ItemRarity.Unique] + + def test_parse_empty_list_fails(self) -> None: + """Test that empty lists fail.""" + with pytest.raises(ValidationError, match="list cannot be empty"): + NameRarityFilterModel.model_validate([]) + + class TestSigilConditionModel: """Test SigilConditionModel.""" @@ -669,6 +695,8 @@ def test_camelcase_input(self) -> None: GlobalUniques=[GlobalUniqueModel(minPower=800)], Sigils={"blacklist": [], "whitelist": [], "priority": "blacklist"}, Tributes=[], + Seals=["legendary"], + Charms=["rare"], Paragon=None, ) assert model.name == "test_profile" @@ -676,6 +704,8 @@ def test_camelcase_input(self) -> None: assert model.aspect_upgrades == [] assert len(model.global_uniques) == 1 assert model.global_uniques[0].min_power == 800 + assert model.seals[0].rarities == [ItemRarity.Legendary] + assert model.charms[0].rarities == [ItemRarity.Rare] def test_snake_case_input(self) -> None: """Test loading with snake_case (new format).""" @@ -686,6 +716,8 @@ def test_snake_case_input(self) -> None: global_uniques=[GlobalUniqueModel(min_power=900)], sigils={"blacklist": [], "whitelist": [], "priority": "blacklist"}, tributes=[], + seals=["faint seal"], + charms=["faint charm"], paragon=None, ) assert model.name == "test_profile" @@ -693,6 +725,8 @@ def test_snake_case_input(self) -> None: assert model.aspect_upgrades == [] assert len(model.global_uniques) == 1 assert model.global_uniques[0].min_power == 900 + assert model.seals[0].name == "faint_seal" + assert model.charms[0].name == "faint_charm" def test_mixed_naming(self) -> None: """Test mixing both naming conventions.""" @@ -747,6 +781,8 @@ def test_export_snake_case(self) -> None: assert "affixes" in exported assert "aspect_upgrades" in exported assert "global_uniques" in exported + assert "seals" in exported + assert "charms" in exported assert "sigils" in exported assert "tributes" in exported assert "paragon" in exported @@ -758,6 +794,8 @@ def test_export_snake_case(self) -> None: assert "Affixes" not in exported assert "AspectUpgrades" not in exported assert "GlobalUniques" not in exported + assert "Seals" not in exported + assert "Charms" not in exported assert "minPower" not in exported["global_uniques"][0] def test_export_camelcase(self) -> None: @@ -769,6 +807,8 @@ def test_export_camelcase(self) -> None: assert "Affixes" in exported assert "AspectUpgrades" in exported assert "GlobalUniques" in exported + assert "Seals" in exported + assert "Charms" in exported assert "Sigils" in exported assert "Tributes" in exported assert "Paragon" in exported @@ -780,6 +820,8 @@ def test_export_camelcase(self) -> None: assert "affixes" not in exported assert "aspect_upgrades" not in exported assert "global_uniques" not in exported + assert "seals" not in exported + assert "charms" not in exported assert "min_power" not in exported["GlobalUniques"][0] def test_defaults(self) -> None: @@ -790,6 +832,8 @@ def test_defaults(self) -> None: assert model.affixes == [] assert model.aspect_upgrades == [] assert model.global_uniques == [] + assert model.seals == [] + assert model.charms == [] assert model.tributes == [] assert model.paragon is None assert model.sigils.blacklist == [] diff --git a/tests/item/filter/filter_test.py b/tests/item/filter/filter_test.py index b1af8d46..f148b6de 100644 --- a/tests/item/filter/filter_test.py +++ b/tests/item/filter/filter_test.py @@ -6,9 +6,12 @@ from natsort import natsorted from src.config.loader import IniConfigLoader -from src.config.profile_models import SigilPriority +from src.config.profile_models import NameRarityFilterModel, SigilPriority from src.config.settings_models import AspectFilterType +from src.item.data.item_type import ItemType +from src.item.data.rarity import ItemRarity from src.item.filter import Filter, FilterResult +from src.item.models import Item from tests.item.filter.data import filters from tests.item.filter.data.affixes import affixes from tests.item.filter.data.aspects import aspects @@ -19,8 +22,6 @@ if typing.TYPE_CHECKING: from pytest_mock import MockerFixture - from src.item.models import Item - def _create_mocked_filter(mocker: MockerFixture) -> Filter: filter_obj = Filter() @@ -29,6 +30,8 @@ def _create_mocked_filter(mocker: MockerFixture) -> Filter: filter_obj.aspect_upgrade_filters = {} filter_obj.paragon_filters = {} filter_obj.global_unique_filters = {} + filter_obj.seal_filters = {} + filter_obj.charm_filters = {} filter_obj.sigil_filters = {} filter_obj.tribute_filters = {} filter_obj.files_loaded = True @@ -103,6 +106,20 @@ def test_tributes(_name: str, result: list[str], item: Item, mocker: MockerFixtu assert natsorted([match.profile for match in test_filter.should_keep(item).matched]) == natsorted(result) +@pytest.mark.parametrize( + ("item", "filter_attr"), + [ + (Item(item_type=ItemType.HoradricSeal, name="faint_seal", rarity=ItemRarity.Legendary), "seal_filters"), + (Item(item_type=ItemType.Charm, name="faint_charm", rarity=ItemRarity.Rare), "charm_filters"), + ], +) +def test_horadric_spellcraft_sections(item: Item, filter_attr: str, mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + setattr(test_filter, filter_attr, {"spellcraft": [NameRarityFilterModel(name=item.name)]}) + + assert test_filter.should_keep(item).matched[0].profile == "spellcraft" + + @pytest.mark.parametrize( ("_name", "result", "item"), natsorted(uniques_with_affixes), diff --git a/tests/item/read_descr_tts_test.py b/tests/item/read_descr_tts_test.py index 2df54532..cd29394f 100644 --- a/tests/item/read_descr_tts_test.py +++ b/tests/item/read_descr_tts_test.py @@ -1,8 +1,22 @@ +import pytest + import src.tts +from src.item.data.item_type import ItemType +from src.item.data.rarity import ItemRarity from src.item.descr.read_descr_tts import read_descr +from src.item.models import Item LOOT_FILTER_TTS = ["SELECT ALL", "Checkbox Disabled", "Item Power Range", "Left mouse button"] +RARITY_TTS_LINES = [ + (ItemRarity.Common, "Common {item_type}"), + (ItemRarity.Magic, "Magic {item_type}"), + (ItemRarity.Rare, "Rare {item_type}"), + (ItemRarity.Legendary, "Legendary {item_type}"), + (ItemRarity.Unique, "Unique {item_type}"), + (ItemRarity.Mythic, "Mythic Unique {item_type}"), +] + def test_loot_filter_controls_are_not_tts_item_start(): assert src.tts.find_item_start(LOOT_FILTER_TTS) is None @@ -12,3 +26,16 @@ def test_loot_filter_controls_do_not_raise_tts_parser_error(): src.tts.LAST_ITEM = LOOT_FILTER_TTS assert read_descr() is None + + +@pytest.mark.parametrize("item_type", [ItemType.HoradricSeal, ItemType.Charm]) +@pytest.mark.parametrize(("rarity", "type_line_template"), RARITY_TTS_LINES) +def test_horadric_spellcraft_items_parse_at_all_rarities( + item_type: ItemType, rarity: ItemRarity, type_line_template: str +): + item_name = f"TEST {item_type.value.upper()}" + src.tts.LAST_ITEM = [item_name, type_line_template.format(item_type=item_type.value.title()), "Right mouse button"] + + assert read_descr() == Item( + item_type=item_type, name=f"test_{item_type.value.replace(' ', '_')}", original_name=item_name, rarity=rarity + ) From 04893ce12d30cbfbcd44db41e9945a932bcbb604 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Fri, 29 May 2026 21:10:54 +0200 Subject: [PATCH 02/39] update2 --- src/scripts/vision_mode_with_highlighting.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/scripts/vision_mode_with_highlighting.py b/src/scripts/vision_mode_with_highlighting.py index adc98831..92fc2a65 100644 --- a/src/scripts/vision_mode_with_highlighting.py +++ b/src/scripts/vision_mode_with_highlighting.py @@ -372,6 +372,8 @@ def evaluate_item_and_queue_draw(self, item_descr: Item): item_descr_with_loc = item_descr else: item_descr_with_loc = src.item.descr.read_descr_tts.read_descr_mixed(cropped_descr) + if item_descr_with_loc is None: + item_descr_with_loc = item_descr res = Filter().should_keep(item_descr_with_loc) match = res.keep From 2a0b7cf0ee5e655f4ed8dc06625d41434115add6 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sat, 30 May 2026 09:48:40 +0200 Subject: [PATCH 03/39] update3 --- src/item/data/item_type.py | 2 +- src/item/descr/read_descr_tts.py | 6 +++--- tests/item/filter/filter_test.py | 2 +- tests/item/read_descr_tts_test.py | 4 +--- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/item/data/item_type.py b/src/item/data/item_type.py index 51076577..7205e07f 100644 --- a/src/item/data/item_type.py +++ b/src/item/data/item_type.py @@ -75,7 +75,7 @@ def is_sigil(item_type: ItemType) -> bool: return item_type in [ItemType.Sigil, ItemType.EscalationSigil] -def is_horadric_spellcraft(item_type: ItemType) -> bool: +def is_seal_or_charm(item_type: ItemType) -> bool: return item_type in [ItemType.HoradricSeal, ItemType.Charm] diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index eb437cd4..37e16a4a 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -14,9 +14,9 @@ ItemType, is_armor, is_consumable, - is_horadric_spellcraft, is_jewelry, is_non_sigil_mapping, + is_seal_or_charm, is_sigil, is_socketable, is_weapon, @@ -452,7 +452,7 @@ def read_descr_mixed(img_item_descr: np.ndarray) -> Item | None: if any([ is_consumable(item.item_type), is_non_sigil_mapping(item.item_type), - is_horadric_spellcraft(item.item_type), + is_seal_or_charm(item.item_type), is_sigil(item.item_type), is_socketable(item.item_type), item.item_type in [ItemType.Material, ItemType.Tribute], @@ -503,7 +503,7 @@ def read_descr() -> Item | None: if any([ is_consumable(item.item_type), is_non_sigil_mapping(item.item_type), - is_horadric_spellcraft(item.item_type), + is_seal_or_charm(item.item_type), is_socketable(item.item_type), item.item_type in [ItemType.Material, ItemType.Tribute, ItemType.Cache, ItemType.LairBossKey], item.seasonal_attribute == SeasonalAttribute.sanctified, diff --git a/tests/item/filter/filter_test.py b/tests/item/filter/filter_test.py index f148b6de..9caaad3c 100644 --- a/tests/item/filter/filter_test.py +++ b/tests/item/filter/filter_test.py @@ -113,7 +113,7 @@ def test_tributes(_name: str, result: list[str], item: Item, mocker: MockerFixtu (Item(item_type=ItemType.Charm, name="faint_charm", rarity=ItemRarity.Rare), "charm_filters"), ], ) -def test_horadric_spellcraft_sections(item: Item, filter_attr: str, mocker: MockerFixture): +def test_seal_or_charm_sections(item: Item, filter_attr: str, mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) setattr(test_filter, filter_attr, {"spellcraft": [NameRarityFilterModel(name=item.name)]}) diff --git a/tests/item/read_descr_tts_test.py b/tests/item/read_descr_tts_test.py index cd29394f..c2a28ef4 100644 --- a/tests/item/read_descr_tts_test.py +++ b/tests/item/read_descr_tts_test.py @@ -30,9 +30,7 @@ def test_loot_filter_controls_do_not_raise_tts_parser_error(): @pytest.mark.parametrize("item_type", [ItemType.HoradricSeal, ItemType.Charm]) @pytest.mark.parametrize(("rarity", "type_line_template"), RARITY_TTS_LINES) -def test_horadric_spellcraft_items_parse_at_all_rarities( - item_type: ItemType, rarity: ItemRarity, type_line_template: str -): +def test_seal_or_charm_items_parse_at_all_rarities(item_type: ItemType, rarity: ItemRarity, type_line_template: str): item_name = f"TEST {item_type.value.upper()}" src.tts.LAST_ITEM = [item_name, type_line_template.format(item_type=item_type.value.title()), "Right mouse button"] From 673d6dd81d1389f2c2c2325f8d6d7fe98db99665 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sat, 30 May 2026 09:53:20 +0200 Subject: [PATCH 04/39] update4 --- src/item/filter.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/item/filter.py b/src/item/filter.py index 5ea0f94a..e4297a98 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -240,11 +240,7 @@ def _check_sigil(self, item: Item) -> FilterResult: return res def _check_name_rarity_filters( - self, - item: Item, - item_filters: dict[str, list[NameRarityFilterModel | TributeFilterModel]], - section_name: str, - mythic_name: str, + self, item: Item, item_filters: dict[str, list[NameRarityFilterModel]], section_name: str, mythic_name: str ) -> FilterResult: res = FilterResult(keep=False, matched=[]) if not item_filters.items(): @@ -271,9 +267,29 @@ def _check_name_rarity_filters( return res def _check_tribute(self, item: Item) -> FilterResult: - return self._check_name_rarity_filters( - item=item, item_filters=self.tribute_filters, section_name="Tributes", mythic_name="Mythic Tribute" - ) + res = FilterResult(keep=False, matched=[]) + if not self.tribute_filters.items(): + LOGGER.info(f"{item.original_name} -- Matched Tributes") + res.keep = True + res.matched.append(MatchedFilter("Tributes not filtered")) + + if item.rarity == ItemRarity.Mythic: + LOGGER.info(f"{item.original_name} -- Matched mythic tribute, always kept") + res.keep = True + res.matched.append(MatchedFilter("Mythic Tribute")) + + for profile_name, profile_filter in self.tribute_filters.items(): + for filter_item in profile_filter: + if filter_item.name and not item.name.startswith(filter_item.name): + continue + + if filter_item.rarities and item.rarity not in filter_item.rarities: + continue + + LOGGER.info(f"{item.original_name} -- Matched {profile_name}.Tributes") + res.keep = True + res.matched.append(MatchedFilter(f"{profile_name}")) + return res def _check_seal(self, item: Item) -> FilterResult: return self._check_name_rarity_filters( From afbbc31bc9d75725c38301821bb0a1a44dc0aaf9 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sat, 30 May 2026 10:56:30 +0200 Subject: [PATCH 05/39] update5 --- src/scripts/vision_mode_with_highlighting.py | 2 -- tests/item/read_descr_tts_test.py | 9 ++++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/scripts/vision_mode_with_highlighting.py b/src/scripts/vision_mode_with_highlighting.py index 92fc2a65..adc98831 100644 --- a/src/scripts/vision_mode_with_highlighting.py +++ b/src/scripts/vision_mode_with_highlighting.py @@ -372,8 +372,6 @@ def evaluate_item_and_queue_draw(self, item_descr: Item): item_descr_with_loc = item_descr else: item_descr_with_loc = src.item.descr.read_descr_tts.read_descr_mixed(cropped_descr) - if item_descr_with_loc is None: - item_descr_with_loc = item_descr res = Filter().should_keep(item_descr_with_loc) match = res.keep diff --git a/tests/item/read_descr_tts_test.py b/tests/item/read_descr_tts_test.py index c2a28ef4..92370105 100644 --- a/tests/item/read_descr_tts_test.py +++ b/tests/item/read_descr_tts_test.py @@ -1,9 +1,10 @@ +import numpy as np import pytest import src.tts from src.item.data.item_type import ItemType from src.item.data.rarity import ItemRarity -from src.item.descr.read_descr_tts import read_descr +from src.item.descr.read_descr_tts import read_descr, read_descr_mixed from src.item.models import Item LOOT_FILTER_TTS = ["SELECT ALL", "Checkbox Disabled", "Item Power Range", "Left mouse button"] @@ -33,7 +34,9 @@ def test_loot_filter_controls_do_not_raise_tts_parser_error(): def test_seal_or_charm_items_parse_at_all_rarities(item_type: ItemType, rarity: ItemRarity, type_line_template: str): item_name = f"TEST {item_type.value.upper()}" src.tts.LAST_ITEM = [item_name, type_line_template.format(item_type=item_type.value.title()), "Right mouse button"] - - assert read_descr() == Item( + expected_item = Item( item_type=item_type, name=f"test_{item_type.value.replace(' ', '_')}", original_name=item_name, rarity=rarity ) + + assert read_descr() == expected_item + assert read_descr_mixed(np.empty((1, 1, 3), dtype=np.uint8)) == expected_item From 2e4b5566337a678d272ee70a3a28ecb15640e341 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 31 May 2026 16:49:08 +0200 Subject: [PATCH 06/39] update6 big update --- src/config/profile_models.py | 27 ++- src/gui/importer/d4builds.py | 35 +++- src/gui/importer/gui_common.py | 27 ++- src/gui/importer/maxroll.py | 38 +++- src/gui/importer/mobalytics.py | 40 +++- src/gui/profile_editor/profile_editor.py | 9 + src/gui/profile_editor/spellcraft_tab.py | 231 +++++++++++++++++++++++ src/item/descr/read_descr_tts.py | 55 +++++- src/item/filter.py | 44 +++-- tests/config/models_test.py | 18 +- tests/item/filter/filter_test.py | 66 ++++++- tests/item/read_descr_tts_test.py | 106 ++++++++++- 12 files changed, 642 insertions(+), 54 deletions(-) create mode 100644 src/gui/profile_editor/spellcraft_tab.py diff --git a/src/config/profile_models.py b/src/config/profile_models.py index 4ffbd6cf..176e1fde 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -228,6 +228,29 @@ def unique_aspect_names_must_be_unique(self) -> ItemFilterModel: DynamicItemFilterModel = RootModel[dict[str, ItemFilterModel]] +class SpellcraftFilterModel(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + affix_pool: list[AffixFilterCountModel] = Field(default=[], alias="affixPool") + min_greater_affix_count: int = Field(default=0, alias="minGreaterAffixCount") + rarities: list[ItemRarity] = [] + + @field_validator("min_greater_affix_count") + @classmethod + def min_greater_affix_in_range(cls, v: int) -> int: + if not 0 <= v <= 4: + msg = "must be in [0, 4]" + raise ValueError(msg) + return v + + @field_validator("rarities", mode="before") + @classmethod + def parse_rarities(cls, data: str | list[str]) -> list[str]: + return _parse_item_type_or_rarities(data) + + +DynamicSpellcraftFilterModel = RootModel[dict[str, SpellcraftFilterModel]] + + class SigilPriority(enum.StrEnum): blacklist = enum.auto() whitelist = enum.auto() @@ -349,10 +372,10 @@ class ProfileModel(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) affixes: list[DynamicItemFilterModel] = Field(default=[], alias="Affixes") aspect_upgrades: list[str] = Field(default=[], alias="AspectUpgrades") - charms: list[NameRarityFilterModel] = Field(default=[], alias="Charms") + charms: list[DynamicSpellcraftFilterModel] = Field(default=[], alias="Charms") global_uniques: list[GlobalUniqueModel] = Field(default=[], alias="GlobalUniques") name: str - seals: list[NameRarityFilterModel] = Field(default=[], alias="Seals") + seals: list[DynamicSpellcraftFilterModel] = Field(default=[], alias="Seals") sigils: SigilFilterModel = Field( default=SigilFilterModel(blacklist=[], whitelist=[], priority=SigilPriority.blacklist), alias="Sigils" ) diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index 910cd433..53c0fa03 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -21,6 +21,7 @@ add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, + create_spellcraft_filter, fix_offhand_type, fix_weapon_type, get_class_name, @@ -95,6 +96,8 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): raise D4BuildsError(msg) slot_to_unique_name_map = _get_item_slots(data=data) finished_filters = [] + charm_filters = [] + seal_filters = [] mythic_names = [] aspect_upgrade_filters = _get_legendary_aspects(data=data) for item in items[0]: @@ -177,6 +180,16 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): else: item_filter.item_type = [item_type] + if item_type in [ItemType.HoradricSeal, ItemType.Charm]: + spellcraft_filters = charm_filters if item_type == ItemType.Charm else seal_filters + filter_name = _unique_filter_name(item_type.name, spellcraft_filters) + spellcraft_filters.append({ + filter_name: create_spellcraft_filter( + affixes=affixes, rarity=rarity, require_gas=config.require_greater_affixes + ) + }) + continue + # We don't bother importing affixes for mythics if rarity != ItemRarity.Mythic: item_filter.affix_pool = [ @@ -192,15 +205,16 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): ] item_filter.min_power = 100 filter_name_template = item_filter.item_type[0].name if item_type else slot.replace(" ", "") - filter_name = filter_name_template - i = 2 - while any(filter_name == next(iter(x)) for x in finished_filters): - filter_name = f"{filter_name_template}{i}" - i += 1 + filter_name = _unique_filter_name(filter_name_template, finished_filters) finished_filters.append({filter_name: item_filter}) # Place all mythics in a single filter add_mythics_to_filters(mythic_names, finished_filters) - profile = ProfileModel(name="imported profile", Affixes=sort_profile_filters(finished_filters)) + profile = ProfileModel( + name="imported profile", + Affixes=sort_profile_filters(finished_filters), + Charms=sort_profile_filters(charm_filters), + Seals=sort_profile_filters(seal_filters), + ) if config.import_aspect_upgrades and aspect_upgrade_filters: profile.aspect_upgrades = aspect_upgrade_filters @@ -324,6 +338,15 @@ def _get_affix_name(stat: lxml.html.HtmlElement) -> str: return "" +def _unique_filter_name(filter_name_template: str, filters: list[dict]) -> str: + filter_name = filter_name_template + i = 2 + while any(filter_name == next(iter(existing_filter)) for existing_filter in filters): + filter_name = f"{filter_name_template}{i}" + i += 1 + return filter_name + + if __name__ == "__main__": src.logger.setup() URLS = ["https://d4builds.gg/builds/whirlwind-barbarian-endgame/?var=4"] diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py index 2835a09e..a917d27d 100644 --- a/src/gui/importer/gui_common.py +++ b/src/gui/importer/gui_common.py @@ -18,8 +18,16 @@ from src import __version__ from src.config.loader import IniConfigLoader -from src.config.profile_models import AspectUniqueFilterModel, ItemFilterModel, ProfileModel +from src.config.profile_models import ( + AffixFilterCountModel, + AffixFilterModel, + AspectUniqueFilterModel, + ItemFilterModel, + ProfileModel, + SpellcraftFilterModel, +) from src.config.settings_models import BrowserType +from src.item.data.affix import Affix, AffixType from src.item.data.item_type import ItemType if TYPE_CHECKING: @@ -188,6 +196,23 @@ def update_mingreateraffixcount(item_filter: ItemFilterModel, require_gas: bool) item_filter.min_greater_affix_count = 0 +def create_spellcraft_filter(affixes: list[Affix], rarity, require_gas: bool) -> SpellcraftFilterModel: + spellcraft_filter = SpellcraftFilterModel( + affix_pool=[ + AffixFilterCountModel( + count=[ + AffixFilterModel(name=affix.name, want_greater=affix.type == AffixType.greater) for affix in affixes + ], + min_count=min(3, len(affixes)), + ) + ], + rarities=[rarity] if rarity is not None else [], + ) + if require_gas: + spellcraft_filter.min_greater_affix_count = len([affix for affix in affixes if affix.type == AffixType.greater]) + return spellcraft_filter + + def add_mythics_to_filters(mythic_names, finished_filters): if mythic_names: mythic_filter = ItemFilterModel() diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 8fd5fe21..81a3c2f6 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -17,6 +17,7 @@ add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, + create_spellcraft_filter, fix_offhand_type, fix_weapon_type, get_with_retry, @@ -90,6 +91,8 @@ def import_maxroll(config: ImportConfig): if variant_name: build_name += f"_{variant_name}" finished_filters = [] + charm_filters = [] + seal_filters = [] aspect_upgrade_filters = [] mythic_names = [] for item_id in active_profile["items"].values(): @@ -109,9 +112,24 @@ def import_maxroll(config: ImportConfig): continue if item_type in [ItemType.HoradricSeal, ItemType.Charm]: - LOGGER.warning( - f"Seals and Charms are not currently supported, skipping {resolved_item.get('name', '(could not determine item name)')}." + spellcraft_affixes = _find_item_affixes( + mapping_data=mapping_data, + item_affixes=resolved_item["explicits"], + item_type=item_type, + import_greater_affixes=config.import_greater_affixes, ) + if not spellcraft_affixes: + LOGGER.warning( + f"Skipping {resolved_item.get('name', '(could not determine item name)')} because it had no supported affixes." + ) + continue + spellcraft_filters = charm_filters if item_type == ItemType.Charm else seal_filters + filter_name = _unique_filter_name(item_type.name, spellcraft_filters) + spellcraft_filters.append({ + filter_name: create_spellcraft_filter( + affixes=spellcraft_affixes, rarity=rarity, require_gas=config.require_greater_affixes + ) + }) continue item_filter.item_type = [item_type] @@ -172,7 +190,12 @@ def import_maxroll(config: ImportConfig): # Place all mythics in a single filter add_mythics_to_filters(mythic_names, finished_filters) - profile = ProfileModel(name="imported profile", Affixes=sort_profile_filters(finished_filters)) + profile = ProfileModel( + name="imported profile", + Affixes=sort_profile_filters(finished_filters), + Charms=sort_profile_filters(charm_filters), + Seals=sort_profile_filters(seal_filters), + ) if config.import_aspect_upgrades and aspect_upgrade_filters: profile.aspect_upgrades = aspect_upgrade_filters @@ -306,6 +329,15 @@ def _find_item_affixes( return res +def _unique_filter_name(filter_name_template: str, filters: list[dict]) -> str: + filter_name = filter_name_template + i = 2 + while any(filter_name == next(iter(existing_filter)) for existing_filter in filters): + filter_name = f"{filter_name_template}{i}" + i += 1 + return filter_name + + def _find_skill_rank_affix_description(mapping_data: dict, affix_key: str, attribute: dict) -> str: if attribute.get("formula") not in SKILL_RANK_BONUS_FORMULAS: return "" diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index 6736ef26..fa34700b 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -24,6 +24,7 @@ add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, + create_spellcraft_filter, fix_offhand_type, fix_weapon_type, match_to_enum, @@ -128,6 +129,8 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): LOGGER.error(msg := "No items found") raise MobalyticsError(msg) finished_filters = [] + charm_filters = [] + seal_filters = [] mythic_names = [] aspect_upgrade_filters = [] for item in items: @@ -135,7 +138,7 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): entity_type = jsonpath.findall(".gameEntity.type", item)[0] mythic_result = jsonpath.findall(".gameEntity.entity.mythic", item) is_mythic = mythic_result[0] if mythic_result else False - if entity_type not in ["aspects", "uniqueItems"]: + if entity_type not in ["aspects", "uniqueItems", "charms", "seals", "items"]: continue if not (item_name := str(jsonpath.findall(".gameEntity.entity.title", item)[0])): LOGGER.error(msg := "No item name found") @@ -210,6 +213,19 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): affixes = _convert_raw_to_affixes(raw_affixes, config.import_greater_affixes) inherents = _convert_raw_to_affixes(raw_inherents) + if item_type in [ItemType.HoradricSeal, ItemType.Charm]: + if not affixes: + LOGGER.warning(f"Skipping {item_name} because it had no supported affixes.") + continue + spellcraft_filters = charm_filters if item_type == ItemType.Charm else seal_filters + filter_name = _unique_filter_name(item_type.name, spellcraft_filters) + spellcraft_filters.append({ + filter_name: create_spellcraft_filter( + affixes=affixes, rarity=None, require_gas=config.require_greater_affixes + ) + }) + continue + if not is_mythic: item_filter.affix_pool = [ AffixFilterCountModel( @@ -224,16 +240,17 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): AffixFilterCountModel(count=[AffixFilterModel(name=x.name) for x in inherents]) ] filter_name_template = item_filter.item_type[0].name if item_type else slot_type.replace(" ", "") - filter_name = filter_name_template - i = 2 - while any(filter_name == next(iter(x)) for x in finished_filters): - filter_name = f"{filter_name_template}{i}" - i += 1 + filter_name = _unique_filter_name(filter_name_template, finished_filters) finished_filters.append({filter_name: item_filter}) # Place all mythics in a single filter add_mythics_to_filters(mythic_names, finished_filters) - profile = ProfileModel(name="imported profile", Affixes=sort_profile_filters(finished_filters)) + profile = ProfileModel( + name="imported profile", + Affixes=sort_profile_filters(finished_filters), + Charms=sort_profile_filters(charm_filters), + Seals=sort_profile_filters(seal_filters), + ) if config.import_aspect_upgrades and aspect_upgrade_filters: profile.aspect_upgrades = aspect_upgrade_filters @@ -353,6 +370,15 @@ def _convert_raw_to_affixes(raw_stats: list[dict], import_greater_affixes=False) return result +def _unique_filter_name(filter_name_template: str, filters: list[dict]) -> str: + filter_name = filter_name_template + i = 2 + while any(filter_name == next(iter(existing_filter)) for existing_filter in filters): + filter_name = f"{filter_name_template}{i}" + i += 1 + return filter_name + + if __name__ == "__main__": src.logger.setup() diff --git a/src/gui/profile_editor/profile_editor.py b/src/gui/profile_editor/profile_editor.py index 67d51fd8..e72af096 100644 --- a/src/gui/profile_editor/profile_editor.py +++ b/src/gui/profile_editor/profile_editor.py @@ -9,6 +9,7 @@ from src.gui.profile_editor.aspect_upgrades_tab import ASPECT_UPGRADES_TABNAME, AspectUpgradesTab from src.gui.profile_editor.global_uniques_tab import UNIQUES_TABNAME, UniquesTab from src.gui.profile_editor.sigils_tab import SIGILS_TABNAME, SigilsTab +from src.gui.profile_editor.spellcraft_tab import CHARMS_TABNAME, SEALS_TABNAME, SpellcraftTab from src.gui.profile_editor.tributes_tab import TRIBUTES_TABNAME, TributesTab LOGGER = logging.getLogger(__name__) @@ -25,6 +26,8 @@ def __init__(self, profile_model: ProfileModel, parent=None): # Create main tabs self.affixes_tab = AffixesTab(self.profile_model.affixes) self.aspect_upgrades_tab = AspectUpgradesTab(self.profile_model.aspect_upgrades) + self.seals_tab = SpellcraftTab(self.profile_model.seals, SEALS_TABNAME) + self.charms_tab = SpellcraftTab(self.profile_model.charms, CHARMS_TABNAME) self.sigils_tab = SigilsTab(self.profile_model.sigils) self.tributes_tab = TributesTab(self.profile_model.tributes) self.uniques_tab = UniquesTab(self.profile_model.global_uniques) @@ -33,6 +36,8 @@ def __init__(self, profile_model: ProfileModel, parent=None): # Add tabs with icons self.addTab(self.affixes_tab, AFFIXES_TABNAME) self.addTab(self.aspect_upgrades_tab, ASPECT_UPGRADES_TABNAME) + self.addTab(self.seals_tab, SEALS_TABNAME) + self.addTab(self.charms_tab, CHARMS_TABNAME) self.addTab(self.sigils_tab, SIGILS_TABNAME) self.addTab(self.tributes_tab, TRIBUTES_TABNAME) self.addTab(self.uniques_tab, UNIQUES_TABNAME) @@ -48,6 +53,10 @@ def tab_changed(self, index): self.affixes_tab.load() elif self.tabText(index) == ASPECT_UPGRADES_TABNAME: self.aspect_upgrades_tab.load() + elif self.tabText(index) == SEALS_TABNAME: + self.seals_tab.load() + elif self.tabText(index) == CHARMS_TABNAME: + self.charms_tab.load() elif self.tabText(index) == SIGILS_TABNAME: self.sigils_tab.load() elif self.tabText(index) == TRIBUTES_TABNAME: diff --git a/src/gui/profile_editor/spellcraft_tab.py b/src/gui/profile_editor/spellcraft_tab.py new file mode 100644 index 00000000..cbc8d672 --- /dev/null +++ b/src/gui/profile_editor/spellcraft_tab.py @@ -0,0 +1,231 @@ +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtWidgets import ( + QCheckBox, + QDialog, + QFormLayout, + QHBoxLayout, + QInputDialog, + QMessageBox, + QPushButton, + QScrollArea, + QSpinBox, + QTabWidget, + QToolBar, + QVBoxLayout, + QWidget, +) + +from src.config.profile_models import ( + AffixFilterCountModel, + AffixFilterModel, + DynamicSpellcraftFilterModel, + SpellcraftFilterModel, +) +from src.dataloader import Dataloader +from src.gui.models.collapsible_widget import Container +from src.gui.models.dialog import DeleteAffixPool, DeleteItem +from src.gui.profile_editor.affixes_tab import AffixPoolWidget +from src.item.data.rarity import ItemRarity + +SEALS_TABNAME = "Seals" +CHARMS_TABNAME = "Charms" + + +class SpellcraftRuleEditor(QWidget): + def __init__(self, dynamic_filter: DynamicSpellcraftFilterModel, parent=None): + super().__init__(parent) + for rule_name, config in dynamic_filter.root.items(): + self.rule_name = rule_name + self.config = config + self.setup_ui() + + def setup_ui(self): + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + + content_widget = QWidget() + self.content_layout = QVBoxLayout(content_widget) + self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + general_form = QFormLayout() + self.min_greater = QSpinBox() + self.min_greater.setRange(0, 4) + self.min_greater.setValue(self.config.min_greater_affix_count) + self.min_greater.valueChanged.connect(self.update_min_greater_affix) + general_form.addRow("Min Greater Affixes:", self.min_greater) + + rarity_layout = QHBoxLayout() + self.rarity_checkboxes = {} + selected_rarities = set(self.config.rarities) + for rarity in ItemRarity: + checkbox = QCheckBox(rarity.name) + checkbox.setChecked(rarity in selected_rarities) + checkbox.stateChanged.connect(self.update_rarities) + self.rarity_checkboxes[rarity] = checkbox + rarity_layout.addWidget(checkbox) + rarity_layout.addStretch() + general_form.addRow("Rarities:", rarity_layout) + self.content_layout.addLayout(general_form) + + pool_btn_layout = QHBoxLayout() + add_affix_pool_btn = QPushButton("Add Affix Pool") + add_affix_pool_btn.clicked.connect(self.add_affix_pool) + remove_affix_pool_btn = QPushButton("Remove Affix Pool") + remove_affix_pool_btn.clicked.connect(self.remove_selected) + pool_btn_layout.addWidget(add_affix_pool_btn) + pool_btn_layout.addWidget(remove_affix_pool_btn) + + self.affix_pool_container = Container("Affix Pool") + self.affix_pool_layout = QVBoxLayout(self.affix_pool_container.content_widget) + self.affix_pool_container.first_expansion.connect(self.init_affix_pool) + + self.content_layout.addWidget(self.affix_pool_container) + self.content_layout.addLayout(pool_btn_layout) + + scroll_area.setWidget(content_widget) + main_layout = QVBoxLayout(self) + main_layout.addWidget(scroll_area) + self.setLayout(main_layout) + + QTimer.singleShot(100, self.affix_pool_container.expand) + + def init_affix_pool(self): + for pool in self.config.affix_pool: + self.add_affix_pool_item(pool) + + def add_affix_pool_item(self, pool: AffixFilterCountModel): + nb_count = self.affix_pool_layout.count() + container = Container(f"Count {nb_count}", color_background=True) + container_layout = QVBoxLayout(container.content_widget) + widget = AffixPoolWidget(pool) + container_layout.addWidget(widget) + self.affix_pool_layout.addWidget(container) + QTimer.singleShot(50, container.expand) + + def add_affix_pool(self): + default_affix = AffixFilterModel(name=next(iter(Dataloader().affix_dict.keys())), value=None) + new_pool = AffixFilterCountModel(count=[default_affix], min_count=1, max_count=3) + self.config.affix_pool.append(new_pool) + self.add_affix_pool_item(new_pool) + + def remove_selected(self): + dialog = DeleteAffixPool(self.affix_pool_layout.count()) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + to_delete = dialog.get_value() + to_delete_list = [] + for i in range(self.affix_pool_layout.count()): + item = self.affix_pool_layout.itemAt(i) + if item and item.widget() is not None and item.widget().header.name in to_delete: + to_delete_list.append((item.widget(), i)) + to_delete_list.reverse() + for widget, index in to_delete_list: + widget.setParent(None) + self.config.affix_pool.pop(index) + self.reorganize_pool() + + def reorganize_pool(self): + for i in range(self.affix_pool_layout.count()): + item = self.affix_pool_layout.itemAt(i) + if item and item.widget() is not None: + item.widget().header.set_name(f"Count {i}") + + def update_min_greater_affix(self): + self.config.min_greater_affix_count = self.min_greater.value() + + def update_rarities(self): + self.config.rarities = [ + rarity for rarity, checkbox in self.rarity_checkboxes.items() if checkbox.isChecked() + ] + + +class SpellcraftTab(QWidget): + def __init__(self, filters: list[DynamicSpellcraftFilterModel], section_name: str, parent=None): + super().__init__(parent) + self.filters = filters + self.section_name = section_name + self.loaded = False + + def load(self): + if not self.loaded: + self.setup_ui() + self.loaded = True + + def setup_ui(self): + self.main_layout = QVBoxLayout(self) + self.main_layout.setContentsMargins(0, 20, 0, 20) + + self.tab_widget = QTabWidget(self) + self.tab_widget.setTabsClosable(True) + self.tab_widget.tabCloseRequested.connect(self.close_tab) + + self.toolbar = QToolBar(f"{self.section_name}Toolbar", self) + self.toolbar.setMinimumHeight(50) + self.toolbar.setContentsMargins(10, 10, 10, 10) + self.toolbar.setMovable(False) + + self.rule_names = [] + for spellcraft_filter in self.filters: + for rule_name in spellcraft_filter.root: + if rule_name in self.rule_names: + QMessageBox.warning( + self, "Warning", f"Rule name already exists. Please rename {rule_name} in the profile file." + ) + continue + self.rule_names.append(rule_name) + self.tab_widget.addTab(SpellcraftRuleEditor(spellcraft_filter), rule_name) + + add_rule_button = QPushButton("Create Item") + add_rule_button.clicked.connect(self.add_rule) + remove_rule_button = QPushButton("Remove Item") + remove_rule_button.clicked.connect(self.remove_rule) + + self.toolbar.addWidget(add_rule_button) + self.toolbar.addWidget(remove_rule_button) + self.main_layout.addWidget(self.toolbar) + self.main_layout.addWidget(self.tab_widget) + + def add_rule(self): + rule_name, ok = QInputDialog.getText(self, f"Create {self.section_name} Rule", "Rule Name:") + if not ok: + return + rule_name = rule_name.strip() + if not rule_name: + QMessageBox.warning(self, "Warning", "Rule name cannot be empty.") + return + if rule_name in self.rule_names: + QMessageBox.warning(self, "Warning", "Rule name already exists.") + return + + new_filter = DynamicSpellcraftFilterModel(root={rule_name: self._default_filter()}) + self.filters.append(new_filter) + self.rule_names.append(rule_name) + self.tab_widget.addTab(SpellcraftRuleEditor(new_filter), rule_name) + + def close_tab(self, index): + self.rule_names.pop(index) + self.tab_widget.removeTab(index) + self.filters.pop(index) + + def remove_rule(self): + dialog = DeleteItem(self.rule_names, self) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + rule_names_to_delete = dialog.get_value() + for rule_name in rule_names_to_delete: + index = self.rule_names.index(rule_name) + self.rule_names.remove(rule_name) + self.tab_widget.removeTab(index) + self.filters.pop(index) + + @staticmethod + def _default_filter() -> SpellcraftFilterModel: + return SpellcraftFilterModel( + affix_pool=[ + AffixFilterCountModel( + count=[AffixFilterModel(name=next(iter(Dataloader().affix_dict.keys())))], + min_count=1, + max_count=3, + ) + ] + ) diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index 37e16a4a..06a0adfd 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -24,7 +24,7 @@ from src.item.data.rarity import ItemRarity from src.item.data.seasonal_attribute import SeasonalAttribute from src.item.descr import keep_letters_and_spaces -from src.item.descr.text import find_number +from src.item.descr.text import clean_str, find_number from src.item.descr.texture import find_affix_bullets, find_aspect_bullet, find_seperator_short, find_seperators_long from src.item.models import Item from src.scripts import correct_name @@ -55,6 +55,17 @@ _REPLACE_COMPARE_RE = re.compile(r"\(.*\)") _AFFIX_REPLACEMENTS = ["%", "+", ",", "[+]", "[x]", "per 5 Seconds"] +_AFFIX_STOP_MARKERS = ( + "empty socket", + "requires level", + "properties lost when equipped", + "rampage:", + "feast:", + "hunger:", + "right mouse button", + "left mouse button", + "action button", +) LOGGER = logging.getLogger(__name__) @@ -70,7 +81,10 @@ def _get_affix_counts(tts_section: list[str], item: Item, start: int) -> tuple[i elif item.rarity == ItemRarity.Common: affixes_num = 0 - if item.rarity in [ItemRarity.Unique, ItemRarity.Mythic]: + if is_seal_or_charm(item.item_type): + return inherent_num, _get_spellcraft_affix_count(tts_section, start) + + if item.rarity in [ItemRarity.Unique, ItemRarity.Mythic] and not is_seal_or_charm(item.item_type): # Uniques can have variable amounts of inherents. unique_inherents = Dataloader().aspect_unique_dict.get(item.name)["num_inherents"] if unique_inherents is not None: @@ -78,9 +92,11 @@ def _get_affix_counts(tts_section: list[str], item: Item, start: int) -> tuple[i # Rares have either 3 or 4 affixes so we have to do special handling to figure out where exactly the affixes end. # This will also grab up slotted gems but we really don't have much choice - if item.rarity in [ItemRarity.Magic, ItemRarity.Rare] and not any( - tts_section[start + inherent_num + affixes_num].lower().startswith(x) - for x in ["empty socket", "requires level", "properties lost when equipped", "rampage:", "feast:", "hunger:"] + next_line_index = start + inherent_num + affixes_num + if ( + item.rarity in [ItemRarity.Magic, ItemRarity.Rare] + and next_line_index < len(tts_section) + and not any(tts_section[next_line_index].lower().startswith(x) for x in _AFFIX_STOP_MARKERS) ): affixes_num = affixes_num + 1 elif item.rarity == ItemRarity.Legendary and tts_section[start + inherent_num + affixes_num - 1].lower().startswith( @@ -95,6 +111,21 @@ def _get_affix_counts(tts_section: list[str], item: Item, start: int) -> tuple[i return inherent_num, affixes_num +def _get_spellcraft_affix_count(tts_section: list[str], start: int) -> int: + affixes_num = 0 + for line in tts_section[start:]: + if line.lower().startswith(_AFFIX_STOP_MARKERS): + break + if not _is_spellcraft_affix_line(line): + break + affixes_num += 1 + return affixes_num + + +def _is_spellcraft_affix_line(line: str) -> bool: + return clean_str(line) in Dataloader().affix_dict.values() + + def _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item: starting_index = _get_affix_starting_location_from_tts_section(tts_section, item) inherent_num, affixes_num = _get_affix_counts(tts_section, item, starting_index) @@ -313,6 +344,8 @@ def _get_affix_starting_location_from_tts_section(tts_section: list[str], item: start = _get_index_of_armor_dps_or_all_resist(tts_section, "armor") + 2 elif is_armor(item.item_type): start = _get_index_of_armor_dps_or_all_resist(tts_section, "armor") + elif is_seal_or_charm(item.item_type): + start = 1 start += 1 return start @@ -332,7 +365,9 @@ def _get_affixes_from_tts_section(tts_section: list[str], start: int, length: in def _get_aspect_from_tts_section(tts_section: list[str], item: Item, start: int, num_affixes: int): # Grab the aspect as well in this case - if item.rarity in [ItemRarity.Mythic, ItemRarity.Unique, ItemRarity.Legendary]: + if item.rarity in [ItemRarity.Mythic, ItemRarity.Unique, ItemRarity.Legendary] and not is_seal_or_charm( + item.item_type + ): aspect_index = start + num_affixes return tts_section[aspect_index] @@ -429,6 +464,8 @@ def _get_item_rarity(data: str) -> ItemRarity | None: def _get_item_type(data: str): + if data.endswith(f" {ItemType.Charm.value}"): + return ItemType.Charm return next((it for it in ItemType if it.value == data.lower()), None) @@ -449,10 +486,11 @@ def read_descr_mixed(img_item_descr: np.ndarray) -> Item | None: return None if (item := _create_base_item_from_tts(tts_section)) is None: return None + if is_seal_or_charm(item.item_type): + return _add_affixes_from_tts(tts_section, item) if any([ is_consumable(item.item_type), is_non_sigil_mapping(item.item_type), - is_seal_or_charm(item.item_type), is_sigil(item.item_type), is_socketable(item.item_type), item.item_type in [ItemType.Material, ItemType.Tribute], @@ -500,10 +538,11 @@ def read_descr() -> Item | None: if item.item_type == ItemType.Cosmetic: item.cosmetic_upgrade = True return item + if is_seal_or_charm(item.item_type): + return _add_affixes_from_tts(tts_section, item) if any([ is_consumable(item.item_type), is_non_sigil_mapping(item.item_type), - is_seal_or_charm(item.item_type), is_socketable(item.item_type), item.item_type in [ItemType.Material, ItemType.Tribute, ItemType.Cache, ItemType.LairBossKey], item.seasonal_attribute == SeasonalAttribute.sanctified, diff --git a/src/item/filter.py b/src/item/filter.py index e4297a98..41f57e8b 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -16,8 +16,8 @@ AffixFilterCountModel, AffixFilterModel, DynamicItemFilterModel, + DynamicSpellcraftFilterModel, GlobalUniqueModel, - NameRarityFilterModel, ProfileModel, SigilConditionModel, SigilFilterModel, @@ -239,8 +239,12 @@ def _check_sigil(self, item: Item) -> FilterResult: res.matched.append(MatchedFilter(f"{profile_name}")) return res - def _check_name_rarity_filters( - self, item: Item, item_filters: dict[str, list[NameRarityFilterModel]], section_name: str, mythic_name: str + def _check_spellcraft_filters( + self, + item: Item, + item_filters: dict[str, list[DynamicSpellcraftFilterModel]], + section_name: str, + mythic_name: str, ) -> FilterResult: res = FilterResult(keep=False, matched=[]) if not item_filters.items(): @@ -253,17 +257,35 @@ def _check_name_rarity_filters( res.keep = True res.matched.append(MatchedFilter(mythic_name)) + non_tempered_affixes = [affix for affix in item.affixes if affix.type != AffixType.tempered] for profile_name, profile_filter in item_filters.items(): for filter_item in profile_filter: - if filter_item.name and not (item.name or "").startswith(filter_item.name): + filter_name = next(iter(filter_item.root.keys())) + filter_spec = filter_item.root[filter_name] + + if filter_spec.rarities and item.rarity not in filter_spec.rarities: continue - if filter_item.rarities and item.rarity not in filter_item.rarities: + if not self._match_greater_affix_count( + expected_min_count=filter_spec.min_greater_affix_count, item_affixes=non_tempered_affixes + ): continue - LOGGER.info(f"{item.original_name} -- Matched {profile_name}.{section_name}") + matched_affixes = [] + if filter_spec.affix_pool: + matched_affixes = self._match_affixes_count( + expected_affixes=filter_spec.affix_pool, + item_affixes=non_tempered_affixes, + min_greater_affix_count=filter_spec.min_greater_affix_count, + ) + if not matched_affixes: + continue + + LOGGER.info( + f"{item.original_name} -- Matched {profile_name}.{section_name}.{filter_name}: {[affix.name for affix in matched_affixes]}" + ) res.keep = True - res.matched.append(MatchedFilter(f"{profile_name}")) + res.matched.append(MatchedFilter(f"{profile_name}.{section_name}.{filter_name}", matched_affixes)) return res def _check_tribute(self, item: Item) -> FilterResult: @@ -292,12 +314,12 @@ def _check_tribute(self, item: Item) -> FilterResult: return res def _check_seal(self, item: Item) -> FilterResult: - return self._check_name_rarity_filters( + return self._check_spellcraft_filters( item=item, item_filters=self.seal_filters, section_name="Seals", mythic_name="Mythic Seal" ) def _check_charm(self, item: Item) -> FilterResult: - return self._check_name_rarity_filters( + return self._check_spellcraft_filters( item=item, item_filters=self.charm_filters, section_name="Charms", mythic_name="Mythic Charm" ) @@ -519,8 +541,8 @@ def load_files(self): self.affix_filters: dict[str, list[DynamicItemFilterModel]] = {} self.aspect_upgrade_filters: dict[str, list[str]] = {} self.paragon_filters: dict[str, object] = {} - self.seal_filters: dict[str, list[NameRarityFilterModel]] = {} - self.charm_filters: dict[str, list[NameRarityFilterModel]] = {} + self.seal_filters: dict[str, list[DynamicSpellcraftFilterModel]] = {} + self.charm_filters: dict[str, list[DynamicSpellcraftFilterModel]] = {} self.sigil_filters: dict[str, SigilFilterModel] = {} self.tribute_filters: dict[str, list[TributeFilterModel]] = {} self.global_unique_filters: dict[str, list[GlobalUniqueModel]] = {} diff --git a/tests/config/models_test.py b/tests/config/models_test.py index d338e167..3e0c4eac 100644 --- a/tests/config/models_test.py +++ b/tests/config/models_test.py @@ -20,6 +20,7 @@ AffixFilterCountModel, AffixFilterModel, AspectUniqueFilterModel, + DynamicSpellcraftFilterModel, GlobalUniqueModel, ItemFilterModel, ItemRarity, @@ -695,8 +696,8 @@ def test_camelcase_input(self) -> None: GlobalUniques=[GlobalUniqueModel(minPower=800)], Sigils={"blacklist": [], "whitelist": [], "priority": "blacklist"}, Tributes=[], - Seals=["legendary"], - Charms=["rare"], + Seals=[{"Cooldown": {"affixPool": [{"count": ["cooldown_reduction"]}], "rarities": ["legendary"]}}], + Charms=[{"Life": {"affixPool": [{"count": ["maximum_life"]}], "rarities": ["rare"]}}], Paragon=None, ) assert model.name == "test_profile" @@ -704,8 +705,8 @@ def test_camelcase_input(self) -> None: assert model.aspect_upgrades == [] assert len(model.global_uniques) == 1 assert model.global_uniques[0].min_power == 800 - assert model.seals[0].rarities == [ItemRarity.Legendary] - assert model.charms[0].rarities == [ItemRarity.Rare] + assert model.seals[0].root["Cooldown"].rarities == [ItemRarity.Legendary] + assert model.charms[0].root["Life"].rarities == [ItemRarity.Rare] def test_snake_case_input(self) -> None: """Test loading with snake_case (new format).""" @@ -716,8 +717,8 @@ def test_snake_case_input(self) -> None: global_uniques=[GlobalUniqueModel(min_power=900)], sigils={"blacklist": [], "whitelist": [], "priority": "blacklist"}, tributes=[], - seals=["faint seal"], - charms=["faint charm"], + seals=[{"Cooldown": {"affix_pool": [{"count": ["cooldown_reduction"]}]}}], + charms=[{"Life": {"affix_pool": [{"count": ["maximum_life"]}]}}], paragon=None, ) assert model.name == "test_profile" @@ -725,8 +726,9 @@ def test_snake_case_input(self) -> None: assert model.aspect_upgrades == [] assert len(model.global_uniques) == 1 assert model.global_uniques[0].min_power == 900 - assert model.seals[0].name == "faint_seal" - assert model.charms[0].name == "faint_charm" + assert isinstance(model.seals[0], DynamicSpellcraftFilterModel) + assert model.seals[0].root["Cooldown"].affix_pool[0].count[0].name == "cooldown_reduction" + assert model.charms[0].root["Life"].affix_pool[0].count[0].name == "maximum_life" def test_mixed_naming(self) -> None: """Test mixing both naming conventions.""" diff --git a/tests/item/filter/filter_test.py b/tests/item/filter/filter_test.py index 9caaad3c..31d747ac 100644 --- a/tests/item/filter/filter_test.py +++ b/tests/item/filter/filter_test.py @@ -6,8 +6,9 @@ from natsort import natsorted from src.config.loader import IniConfigLoader -from src.config.profile_models import NameRarityFilterModel, SigilPriority +from src.config.profile_models import DynamicSpellcraftFilterModel, SigilPriority, SpellcraftFilterModel from src.config.settings_models import AspectFilterType +from src.item.data.affix import Affix, AffixType from src.item.data.item_type import ItemType from src.item.data.rarity import ItemRarity from src.item.filter import Filter, FilterResult @@ -106,18 +107,71 @@ def test_tributes(_name: str, result: list[str], item: Item, mocker: MockerFixtu assert natsorted([match.profile for match in test_filter.should_keep(item).matched]) == natsorted(result) +@pytest.mark.parametrize( + ("item", "filter_attr", "section_name"), + [ + ( + Item( + item_type=ItemType.HoradricSeal, + name="unimportant_seal_name", + rarity=ItemRarity.Legendary, + affixes=[Affix(name="cooldown_reduction")], + ), + "seal_filters", + "Seals", + ), + ( + Item( + item_type=ItemType.Charm, + name="unimportant_charm_name", + rarity=ItemRarity.Rare, + affixes=[Affix(name="maximum_life")], + ), + "charm_filters", + "Charms", + ), + ], +) +def test_seal_or_charm_sections(item: Item, filter_attr: str, section_name: str, mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + spellcraft_filter = SpellcraftFilterModel(affix_pool=[{"count": [item.affixes[0].name]}], rarities=[item.rarity]) + setattr( + test_filter, filter_attr, {"spellcraft": [DynamicSpellcraftFilterModel(root={"wanted": spellcraft_filter})]} + ) + + match = test_filter.should_keep(item).matched[0] + assert match.profile == f"spellcraft.{section_name}.wanted" + assert match.matched_affixes == item.affixes + + @pytest.mark.parametrize( ("item", "filter_attr"), [ - (Item(item_type=ItemType.HoradricSeal, name="faint_seal", rarity=ItemRarity.Legendary), "seal_filters"), - (Item(item_type=ItemType.Charm, name="faint_charm", rarity=ItemRarity.Rare), "charm_filters"), + ( + Item( + item_type=ItemType.HoradricSeal, + rarity=ItemRarity.Mythic, + affixes=[Affix(name="cooldown_reduction", type=AffixType.greater)], + ), + "seal_filters", + ), + ( + Item( + item_type=ItemType.Charm, + rarity=ItemRarity.Mythic, + affixes=[Affix(name="maximum_life", type=AffixType.greater)], + ), + "charm_filters", + ), ], ) -def test_seal_or_charm_sections(item: Item, filter_attr: str, mocker: MockerFixture): +def test_mythic_seal_or_charm_always_kept(item: Item, filter_attr: str, mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) - setattr(test_filter, filter_attr, {"spellcraft": [NameRarityFilterModel(name=item.name)]}) + spellcraft_filter = SpellcraftFilterModel(affix_pool=[{"count": ["movement_speed"]}], rarities=[ItemRarity.Common]) + setattr(test_filter, filter_attr, {"spellcraft": [DynamicSpellcraftFilterModel(root={"wrong": spellcraft_filter})]}) - assert test_filter.should_keep(item).matched[0].profile == "spellcraft" + assert test_filter.should_keep(item).keep + assert test_filter.should_keep(item).matched[0].profile.startswith("Mythic") @pytest.mark.parametrize( diff --git a/tests/item/read_descr_tts_test.py b/tests/item/read_descr_tts_test.py index 92370105..70fe580f 100644 --- a/tests/item/read_descr_tts_test.py +++ b/tests/item/read_descr_tts_test.py @@ -2,6 +2,7 @@ import pytest import src.tts +from src.item.data.affix import Affix from src.item.data.item_type import ItemType from src.item.data.rarity import ItemRarity from src.item.descr.read_descr_tts import read_descr, read_descr_mixed @@ -18,6 +19,26 @@ (ItemRarity.Mythic, "Mythic Unique {item_type}"), ] +AFFIX_TTS_LINES = [ + "10% Cooldown Reduction [5 - 15]", + "100 Maximum Life [50 - 150]", + "20% Critical Strike Chance [10 - 30]", + "12% Movement Speed [5 - 20]", +] + +EXPECTED_AFFIXES = [ + Affix(text="10% Cooldown Reduction [5 - 15]", name="cooldown_reduction", value=10.0, min_value=5.0, max_value=15.0), + Affix(text="100 Maximum Life [50 - 150]", name="maximum_life", value=100.0, min_value=50.0, max_value=150.0), + Affix( + text="20% Critical Strike Chance [10 - 30]", + name="critical_strike_chance", + value=20.0, + min_value=10.0, + max_value=30.0, + ), + Affix(text="12% Movement Speed [5 - 20]", name="movement_speed", value=12.0, min_value=5.0, max_value=20.0), +] + def test_loot_filter_controls_are_not_tts_item_start(): assert src.tts.find_item_start(LOOT_FILTER_TTS) is None @@ -33,10 +54,91 @@ def test_loot_filter_controls_do_not_raise_tts_parser_error(): @pytest.mark.parametrize(("rarity", "type_line_template"), RARITY_TTS_LINES) def test_seal_or_charm_items_parse_at_all_rarities(item_type: ItemType, rarity: ItemRarity, type_line_template: str): item_name = f"TEST {item_type.value.upper()}" - src.tts.LAST_ITEM = [item_name, type_line_template.format(item_type=item_type.value.title()), "Right mouse button"] + expected_affix_count = _expected_affix_count(rarity) + src.tts.LAST_ITEM = [ + item_name, + type_line_template.format(item_type=item_type.value.title()), + *AFFIX_TTS_LINES[:expected_affix_count], + "Right mouse button", + ] expected_item = Item( - item_type=item_type, name=f"test_{item_type.value.replace(' ', '_')}", original_name=item_name, rarity=rarity + affixes=EXPECTED_AFFIXES[:expected_affix_count], + item_type=item_type, + name=f"test_{item_type.value.replace(' ', '_')}", + original_name=item_name, + rarity=rarity, ) assert read_descr() == expected_item assert read_descr_mixed(np.empty((1, 1, 3), dtype=np.uint8)) == expected_item + + +@pytest.mark.parametrize("item_type", [ItemType.HoradricSeal, ItemType.Charm]) +def test_seal_or_charm_items_parse_variable_affix_counts(item_type: ItemType): + item_name = f"VARIABLE {item_type.value.upper()}" + src.tts.LAST_ITEM = [ + item_name, + f"Legendary {item_type.value.title()}", + "10% Cooldown Reduction [5 - 15]", + "100 Maximum Life [50 - 150]", + "Right mouse button", + ] + expected_item = Item( + affixes=EXPECTED_AFFIXES[:2], + item_type=item_type, + name=f"variable_{item_type.value.replace(' ', '_')}", + original_name=item_name, + rarity=ItemRarity.Legendary, + ) + + assert read_descr() == expected_item + + +def test_horadric_charm_type_line_parses_as_charm(): + src.tts.LAST_ITEM = [ + "DIVINE CHARM OF RESTORATION", + "Legendary Horadric Charm", + "10% Cooldown Reduction [5 - 15]", + "Right mouse button", + ] + expected_item = Item( + affixes=EXPECTED_AFFIXES[:1], + item_type=ItemType.Charm, + name="divine_charm_of_restoration", + original_name="DIVINE CHARM OF RESTORATION", + rarity=ItemRarity.Legendary, + ) + + assert read_descr() == expected_item + + +def test_set_charm_stops_affixes_before_set_bonus_text(): + src.tts.LAST_ITEM = [ + "LINTA OF THE FROZEN SEA", + "Set Charm", + "Lucky Hit: Up to a 40% Chance to Deal +650 Poison Damage", + "+7.0% Potion Healing", + "Breath of the Frozen Sea", + "Phoba of the Frozen Sea", + "Breath of the Frozen Sea (0/5). (2) Set:. Frost Skills deal 70% of their direct damage as bonus Frostbite over 12 seconds.. (3) Set:. You cannot be Chilled or Frozen.. Your Maximum Life and Barrier generation is increased by 20%.. (5) Set:. Frost Skill damage is increased by 200%.. Freezing enemies consumes all Frostbite on them, dealing its remaining damage instantly.", + "Requires Level 70Sorcerer. Only. Unique Equipped. Lord of Hatred Item", + "Right mouse button", + ] + item = read_descr() + + assert item.item_type == ItemType.Charm + assert item.name == "linta_of_the_frozen_sea" + assert [affix.name for affix in item.affixes] == [ + "lucky_hit_up_to_a_chance_to_deal_poison_damage", + "potion_healing", + ] + + +def _expected_affix_count(rarity: ItemRarity) -> int: + if rarity == ItemRarity.Common: + return 0 + if rarity == ItemRarity.Magic: + return 1 + if rarity == ItemRarity.Rare: + return 3 + return 4 From d63e90f81bea8d62eb3acde269d9c99ebcb94e5a Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 31 May 2026 17:01:31 +0200 Subject: [PATCH 07/39] update7 --- src/gui/profile_editor/spellcraft_tab.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/gui/profile_editor/spellcraft_tab.py b/src/gui/profile_editor/spellcraft_tab.py index cbc8d672..c482e51f 100644 --- a/src/gui/profile_editor/spellcraft_tab.py +++ b/src/gui/profile_editor/spellcraft_tab.py @@ -134,9 +134,7 @@ def update_min_greater_affix(self): self.config.min_greater_affix_count = self.min_greater.value() def update_rarities(self): - self.config.rarities = [ - rarity for rarity, checkbox in self.rarity_checkboxes.items() if checkbox.isChecked() - ] + self.config.rarities = [rarity for rarity, checkbox in self.rarity_checkboxes.items() if checkbox.isChecked()] class SpellcraftTab(QWidget): @@ -223,9 +221,7 @@ def _default_filter() -> SpellcraftFilterModel: return SpellcraftFilterModel( affix_pool=[ AffixFilterCountModel( - count=[AffixFilterModel(name=next(iter(Dataloader().affix_dict.keys())))], - min_count=1, - max_count=3, + count=[AffixFilterModel(name=next(iter(Dataloader().affix_dict.keys())))], min_count=1, max_count=3 ) ] ) From 86a4de59a6d77f7c21552c0a631d0df62957535e Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 31 May 2026 18:49:40 +0200 Subject: [PATCH 08/39] update7 Charms and Seals shared a generic spellcraft filter model, so charm identity fields and seal slot count could not be represented or matched. Charm set data also was not exposed on parsed Items. Changed files: src/config/profile_models.py: added separate CharmFilterModel and SealFilterModel. src/item/filter.py: added charm set/uniqueAspect matching and seal slotCount matching. src/item/descr/read_descr_tts.py, src/item/models.py, src/dataloader.py: parse/store charm set names using sets.json. Importer/editor files updated to create the correct charm/seal model types. Targeted tests updated for model validation, matching, and charm set parsing. --- src/config/profile_models.py | 43 ++++++++- src/dataloader.py | 6 ++ src/gui/importer/d4builds.py | 8 +- src/gui/importer/gui_common.py | 6 +- src/gui/importer/maxroll.py | 8 +- src/gui/importer/mobalytics.py | 8 +- src/gui/profile_editor/profile_editor.py | 14 ++- src/gui/profile_editor/spellcraft_tab.py | 22 ++++- src/item/descr/read_descr_tts.py | 12 +++ src/item/filter.py | 40 ++++++-- src/item/models.py | 4 + tests/config/models_test.py | 35 ++++++- tests/item/filter/filter_test.py | 117 ++++++++++++++++++++--- tests/item/read_descr_tts_test.py | 1 + 14 files changed, 290 insertions(+), 34 deletions(-) diff --git a/src/config/profile_models.py b/src/config/profile_models.py index 176e1fde..54b077ef 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -248,7 +248,46 @@ def parse_rarities(cls, data: str | list[str]) -> list[str]: return _parse_item_type_or_rarities(data) +class CharmFilterModel(SpellcraftFilterModel): + set_name: str | None = Field(default=None, alias="set") + unique_aspect: str | None = Field(default=None, alias="uniqueAspect") + + @field_validator("set_name") + @classmethod + def set_must_exist(cls, name: str | None) -> str | None: + if not name: + return None + + # This on module level would be a circular import, so we do it lazy for now + from src.dataloader import Dataloader # noqa: PLC0415 + + name = correct_name(name) + if name not in Dataloader().set_list: + msg = f"set {name} does not exist" + raise ValueError(msg) + return name + + @field_validator("unique_aspect") + @classmethod + def normalize_unique_aspect(cls, name: str | None) -> str | None: + return correct_name(name) + + +class SealFilterModel(SpellcraftFilterModel): + slot_count: int = Field(default=0, alias="slotCount") + + @field_validator("slot_count") + @classmethod + def slot_count_in_range(cls, v: int) -> int: + if not 0 <= v <= 4: + msg = "must be in [0, 4]" + raise ValueError(msg) + return v + + DynamicSpellcraftFilterModel = RootModel[dict[str, SpellcraftFilterModel]] +DynamicCharmFilterModel = RootModel[dict[str, CharmFilterModel]] +DynamicSealFilterModel = RootModel[dict[str, SealFilterModel]] class SigilPriority(enum.StrEnum): @@ -372,10 +411,10 @@ class ProfileModel(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) affixes: list[DynamicItemFilterModel] = Field(default=[], alias="Affixes") aspect_upgrades: list[str] = Field(default=[], alias="AspectUpgrades") - charms: list[DynamicSpellcraftFilterModel] = Field(default=[], alias="Charms") + charms: list[DynamicCharmFilterModel] = Field(default=[], alias="Charms") global_uniques: list[GlobalUniqueModel] = Field(default=[], alias="GlobalUniques") name: str - seals: list[DynamicSpellcraftFilterModel] = Field(default=[], alias="Seals") + seals: list[DynamicSealFilterModel] = Field(default=[], alias="Seals") sigils: SigilFilterModel = Field( default=SigilFilterModel(blacklist=[], whitelist=[], priority=SigilPriority.blacklist), alias="Sigils" ) diff --git a/src/dataloader.py b/src/dataloader.py index bb923784..e52e74c0 100644 --- a/src/dataloader.py +++ b/src/dataloader.py @@ -23,6 +23,7 @@ class Dataloader: filter_after_keyword = [] filter_words = [] item_types_dict = {} + set_list = [] tooltips = {} tribute_dict = {} @@ -95,3 +96,8 @@ def load_data(self): encoding="utf-8" ) as f: self.aspect_unique_dict = json.load(f) + + with pathlib.Path(BASE_DIR / f"assets/lang/{IniConfigLoader().general.language}/sets.json").open( + encoding="utf-8" + ) as f: + self.set_list = json.load(f) diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index 53c0fa03..34be4183 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -13,8 +13,10 @@ AffixFilterCountModel, AffixFilterModel, AspectUniqueFilterModel, + CharmFilterModel, ItemFilterModel, ProfileModel, + SealFilterModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( @@ -183,9 +185,13 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): if item_type in [ItemType.HoradricSeal, ItemType.Charm]: spellcraft_filters = charm_filters if item_type == ItemType.Charm else seal_filters filter_name = _unique_filter_name(item_type.name, spellcraft_filters) + spellcraft_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel spellcraft_filters.append({ filter_name: create_spellcraft_filter( - affixes=affixes, rarity=rarity, require_gas=config.require_greater_affixes + affixes=affixes, + rarity=rarity, + require_gas=config.require_greater_affixes, + model_type=spellcraft_model, ) }) continue diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py index a917d27d..d97c6bb3 100644 --- a/src/gui/importer/gui_common.py +++ b/src/gui/importer/gui_common.py @@ -196,8 +196,10 @@ def update_mingreateraffixcount(item_filter: ItemFilterModel, require_gas: bool) item_filter.min_greater_affix_count = 0 -def create_spellcraft_filter(affixes: list[Affix], rarity, require_gas: bool) -> SpellcraftFilterModel: - spellcraft_filter = SpellcraftFilterModel( +def create_spellcraft_filter( + affixes: list[Affix], rarity, require_gas: bool, model_type: type[SpellcraftFilterModel] = SpellcraftFilterModel +) -> SpellcraftFilterModel: + spellcraft_filter = model_type( affix_pool=[ AffixFilterCountModel( count=[ diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 81a3c2f6..81464152 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -9,8 +9,10 @@ AffixFilterCountModel, AffixFilterModel, AspectUniqueFilterModel, + CharmFilterModel, ItemFilterModel, ProfileModel, + SealFilterModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( @@ -125,9 +127,13 @@ def import_maxroll(config: ImportConfig): continue spellcraft_filters = charm_filters if item_type == ItemType.Charm else seal_filters filter_name = _unique_filter_name(item_type.name, spellcraft_filters) + spellcraft_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel spellcraft_filters.append({ filter_name: create_spellcraft_filter( - affixes=spellcraft_affixes, rarity=rarity, require_gas=config.require_greater_affixes + affixes=spellcraft_affixes, + rarity=rarity, + require_gas=config.require_greater_affixes, + model_type=spellcraft_model, ) }) continue diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index fa34700b..443bac61 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -16,8 +16,10 @@ AffixFilterCountModel, AffixFilterModel, AspectUniqueFilterModel, + CharmFilterModel, ItemFilterModel, ProfileModel, + SealFilterModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( @@ -219,9 +221,13 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): continue spellcraft_filters = charm_filters if item_type == ItemType.Charm else seal_filters filter_name = _unique_filter_name(item_type.name, spellcraft_filters) + spellcraft_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel spellcraft_filters.append({ filter_name: create_spellcraft_filter( - affixes=affixes, rarity=None, require_gas=config.require_greater_affixes + affixes=affixes, + rarity=None, + require_gas=config.require_greater_affixes, + model_type=spellcraft_model, ) }) continue diff --git a/src/gui/profile_editor/profile_editor.py b/src/gui/profile_editor/profile_editor.py index e72af096..72d1f3b6 100644 --- a/src/gui/profile_editor/profile_editor.py +++ b/src/gui/profile_editor/profile_editor.py @@ -3,7 +3,13 @@ from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtWidgets import QMessageBox, QTabWidget -from src.config.profile_models import ProfileModel +from src.config.profile_models import ( + CharmFilterModel, + DynamicCharmFilterModel, + DynamicSealFilterModel, + ProfileModel, + SealFilterModel, +) from src.gui.importer.gui_common import save_as_profile from src.gui.profile_editor.affixes_tab import AFFIXES_TABNAME, AffixesTab from src.gui.profile_editor.aspect_upgrades_tab import ASPECT_UPGRADES_TABNAME, AspectUpgradesTab @@ -26,8 +32,10 @@ def __init__(self, profile_model: ProfileModel, parent=None): # Create main tabs self.affixes_tab = AffixesTab(self.profile_model.affixes) self.aspect_upgrades_tab = AspectUpgradesTab(self.profile_model.aspect_upgrades) - self.seals_tab = SpellcraftTab(self.profile_model.seals, SEALS_TABNAME) - self.charms_tab = SpellcraftTab(self.profile_model.charms, CHARMS_TABNAME) + self.seals_tab = SpellcraftTab(self.profile_model.seals, SEALS_TABNAME, DynamicSealFilterModel, SealFilterModel) + self.charms_tab = SpellcraftTab( + self.profile_model.charms, CHARMS_TABNAME, DynamicCharmFilterModel, CharmFilterModel + ) self.sigils_tab = SigilsTab(self.profile_model.sigils) self.tributes_tab = TributesTab(self.profile_model.tributes) self.uniques_tab = UniquesTab(self.profile_model.global_uniques) diff --git a/src/gui/profile_editor/spellcraft_tab.py b/src/gui/profile_editor/spellcraft_tab.py index c482e51f..85866f46 100644 --- a/src/gui/profile_editor/spellcraft_tab.py +++ b/src/gui/profile_editor/spellcraft_tab.py @@ -18,7 +18,11 @@ from src.config.profile_models import ( AffixFilterCountModel, AffixFilterModel, + CharmFilterModel, + DynamicCharmFilterModel, + DynamicSealFilterModel, DynamicSpellcraftFilterModel, + SealFilterModel, SpellcraftFilterModel, ) from src.dataloader import Dataloader @@ -138,10 +142,19 @@ def update_rarities(self): class SpellcraftTab(QWidget): - def __init__(self, filters: list[DynamicSpellcraftFilterModel], section_name: str, parent=None): + def __init__( + self, + filters: list[DynamicSpellcraftFilterModel], + section_name: str, + dynamic_model: type[DynamicSpellcraftFilterModel | DynamicCharmFilterModel | DynamicSealFilterModel], + filter_model: type[SpellcraftFilterModel | CharmFilterModel | SealFilterModel], + parent=None, + ): super().__init__(parent) self.filters = filters self.section_name = section_name + self.dynamic_model = dynamic_model + self.filter_model = filter_model self.loaded = False def load(self): @@ -195,7 +208,7 @@ def add_rule(self): QMessageBox.warning(self, "Warning", "Rule name already exists.") return - new_filter = DynamicSpellcraftFilterModel(root={rule_name: self._default_filter()}) + new_filter = self.dynamic_model(root={rule_name: self._default_filter()}) self.filters.append(new_filter) self.rule_names.append(rule_name) self.tab_widget.addTab(SpellcraftRuleEditor(new_filter), rule_name) @@ -216,9 +229,8 @@ def remove_rule(self): self.tab_widget.removeTab(index) self.filters.pop(index) - @staticmethod - def _default_filter() -> SpellcraftFilterModel: - return SpellcraftFilterModel( + def _default_filter(self) -> SpellcraftFilterModel: + return self.filter_model( affix_pool=[ AffixFilterCountModel( count=[AffixFilterModel(name=next(iter(Dataloader().affix_dict.keys())))], min_count=1, max_count=3 diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index 06a0adfd..6af9ec8d 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -130,6 +130,7 @@ def _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item: starting_index = _get_affix_starting_location_from_tts_section(tts_section, item) inherent_num, affixes_num = _get_affix_counts(tts_section, item, starting_index) affixes = _get_affixes_from_tts_section(tts_section, starting_index, inherent_num + affixes_num) + item.set_name = _get_charm_set_from_tts_section(tts_section, item, starting_index, len(affixes)) aspect_text = _get_aspect_from_tts_section(tts_section, item, starting_index, len(affixes)) for i, affix_text in enumerate(affixes): if i < inherent_num: @@ -374,6 +375,17 @@ def _get_aspect_from_tts_section(tts_section: list[str], item: Item, start: int, return None +def _get_charm_set_from_tts_section(tts_section: list[str], item: Item, start: int, num_affixes: int) -> str | None: + if item.item_type != ItemType.Charm: + return None + + for line in tts_section[start + num_affixes :]: + set_name = correct_name(line.split("(", maxsplit=1)[0]) + if set_name in Dataloader().set_list: + return set_name + return None + + def _get_affix_from_text(text: str) -> Affix: result = Affix(text=text) for x in _AFFIX_REPLACEMENTS: diff --git a/src/item/filter.py b/src/item/filter.py index 41f57e8b..ef8c661b 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -15,10 +15,13 @@ AffixAspectFilterModel, AffixFilterCountModel, AffixFilterModel, + CharmFilterModel, + DynamicCharmFilterModel, DynamicItemFilterModel, - DynamicSpellcraftFilterModel, + DynamicSealFilterModel, GlobalUniqueModel, ProfileModel, + SealFilterModel, SigilConditionModel, SigilFilterModel, SigilPriority, @@ -242,9 +245,10 @@ def _check_sigil(self, item: Item) -> FilterResult: def _check_spellcraft_filters( self, item: Item, - item_filters: dict[str, list[DynamicSpellcraftFilterModel]], + item_filters: dict[str, list[DynamicCharmFilterModel] | list[DynamicSealFilterModel]], section_name: str, mythic_name: str, + extra_match=None, ) -> FilterResult: res = FilterResult(keep=False, matched=[]) if not item_filters.items(): @@ -266,6 +270,9 @@ def _check_spellcraft_filters( if filter_spec.rarities and item.rarity not in filter_spec.rarities: continue + if extra_match and not extra_match(item, filter_spec): + continue + if not self._match_greater_affix_count( expected_min_count=filter_spec.min_greater_affix_count, item_affixes=non_tempered_affixes ): @@ -288,6 +295,19 @@ def _check_spellcraft_filters( res.matched.append(MatchedFilter(f"{profile_name}.{section_name}.{filter_name}", matched_affixes)) return res + @staticmethod + def _match_charm_filter(item: Item, filter_spec: CharmFilterModel) -> bool: + identity_fields = [filter_spec.set_name, filter_spec.unique_aspect] + if not any(identity_fields): + return True + return (filter_spec.set_name is not None and filter_spec.set_name == item.set_name) or ( + filter_spec.unique_aspect is not None and filter_spec.unique_aspect == item.name + ) + + @staticmethod + def _match_seal_filter(item: Item, filter_spec: SealFilterModel) -> bool: + return filter_spec.slot_count == 0 or filter_spec.slot_count == len(item.affixes) + def _check_tribute(self, item: Item) -> FilterResult: res = FilterResult(keep=False, matched=[]) if not self.tribute_filters.items(): @@ -315,12 +335,20 @@ def _check_tribute(self, item: Item) -> FilterResult: def _check_seal(self, item: Item) -> FilterResult: return self._check_spellcraft_filters( - item=item, item_filters=self.seal_filters, section_name="Seals", mythic_name="Mythic Seal" + item=item, + item_filters=self.seal_filters, + section_name="Seals", + mythic_name="Mythic Seal", + extra_match=self._match_seal_filter, ) def _check_charm(self, item: Item) -> FilterResult: return self._check_spellcraft_filters( - item=item, item_filters=self.charm_filters, section_name="Charms", mythic_name="Mythic Charm" + item=item, + item_filters=self.charm_filters, + section_name="Charms", + mythic_name="Mythic Charm", + extra_match=self._match_charm_filter, ) def _check_global_unique_filter(self, item: Item) -> FilterResult: @@ -541,8 +569,8 @@ def load_files(self): self.affix_filters: dict[str, list[DynamicItemFilterModel]] = {} self.aspect_upgrade_filters: dict[str, list[str]] = {} self.paragon_filters: dict[str, object] = {} - self.seal_filters: dict[str, list[DynamicSpellcraftFilterModel]] = {} - self.charm_filters: dict[str, list[DynamicSpellcraftFilterModel]] = {} + self.seal_filters: dict[str, list[DynamicSealFilterModel]] = {} + self.charm_filters: dict[str, list[DynamicCharmFilterModel]] = {} self.sigil_filters: dict[str, SigilFilterModel] = {} self.tribute_filters: dict[str, list[TributeFilterModel]] = {} self.global_unique_filters: dict[str, list[GlobalUniqueModel]] = {} diff --git a/src/item/models.py b/src/item/models.py index 641acb1d..c3261993 100644 --- a/src/item/models.py +++ b/src/item/models.py @@ -29,6 +29,7 @@ class Item: power: int | None = None rarity: ItemRarity | None = None seasonal_attribute: SeasonalAttribute | None = None + set_name: str | None = None def __eq__(self, other): if not isinstance(other, Item): @@ -65,6 +66,8 @@ def __eq__(self, other): res = False if self.seasonal_attribute != other.seasonal_attribute: res = False + if self.set_name != other.set_name: + res = False return res @@ -81,5 +84,6 @@ def default(self, o): "name": o.name or None, "power": o.power or None, "rarity": o.rarity.value if o.rarity else None, + "set_name": o.set_name or None, } return super().default(o) diff --git a/tests/config/models_test.py b/tests/config/models_test.py index 3e0c4eac..e7dc897e 100644 --- a/tests/config/models_test.py +++ b/tests/config/models_test.py @@ -20,12 +20,15 @@ AffixFilterCountModel, AffixFilterModel, AspectUniqueFilterModel, - DynamicSpellcraftFilterModel, + CharmFilterModel, + DynamicCharmFilterModel, + DynamicSealFilterModel, GlobalUniqueModel, ItemFilterModel, ItemRarity, NameRarityFilterModel, ProfileModel, + SealFilterModel, SigilConditionModel, SigilFilterModel, TributeFilterModel, @@ -617,6 +620,33 @@ def test_parse_empty_list_fails(self) -> None: NameRarityFilterModel.model_validate([]) +class TestCharmFilterModel: + def test_set_name_is_validated_and_normalized(self) -> None: + model = CharmFilterModel(set="Breath of the Frozen Sea") + + assert model.set_name == "breath_of_the_frozen_sea" + + def test_invalid_set_fails(self) -> None: + with pytest.raises(ValidationError, match="set invalid_set does not exist"): + CharmFilterModel(set="invalid set") + + def test_unique_aspect_is_normalized(self) -> None: + model = CharmFilterModel(uniqueAspect="Linta of the Frozen Sea") + + assert model.unique_aspect == "linta_of_the_frozen_sea" + + +class TestSealFilterModel: + def test_slot_count_alias(self) -> None: + model = SealFilterModel(slotCount=3) + + assert model.slot_count == 3 + + def test_slot_count_out_of_range_fails(self) -> None: + with pytest.raises(ValidationError, match="must be in \\[0, 4\\]"): + SealFilterModel(slotCount=5) + + class TestSigilConditionModel: """Test SigilConditionModel.""" @@ -726,7 +756,8 @@ def test_snake_case_input(self) -> None: assert model.aspect_upgrades == [] assert len(model.global_uniques) == 1 assert model.global_uniques[0].min_power == 900 - assert isinstance(model.seals[0], DynamicSpellcraftFilterModel) + assert isinstance(model.seals[0], DynamicSealFilterModel) + assert isinstance(model.charms[0], DynamicCharmFilterModel) assert model.seals[0].root["Cooldown"].affix_pool[0].count[0].name == "cooldown_reduction" assert model.charms[0].root["Life"].affix_pool[0].count[0].name == "maximum_life" diff --git a/tests/item/filter/filter_test.py b/tests/item/filter/filter_test.py index 31d747ac..cd17b5f9 100644 --- a/tests/item/filter/filter_test.py +++ b/tests/item/filter/filter_test.py @@ -6,7 +6,13 @@ from natsort import natsorted from src.config.loader import IniConfigLoader -from src.config.profile_models import DynamicSpellcraftFilterModel, SigilPriority, SpellcraftFilterModel +from src.config.profile_models import ( + CharmFilterModel, + DynamicCharmFilterModel, + DynamicSealFilterModel, + SealFilterModel, + SigilPriority, +) from src.config.settings_models import AspectFilterType from src.item.data.affix import Affix, AffixType from src.item.data.item_type import ItemType @@ -108,7 +114,7 @@ def test_tributes(_name: str, result: list[str], item: Item, mocker: MockerFixtu @pytest.mark.parametrize( - ("item", "filter_attr", "section_name"), + ("item", "filter_attr", "section_name", "filter_model", "dynamic_model"), [ ( Item( @@ -119,6 +125,8 @@ def test_tributes(_name: str, result: list[str], item: Item, mocker: MockerFixtu ), "seal_filters", "Seals", + SealFilterModel, + DynamicSealFilterModel, ), ( Item( @@ -129,23 +137,104 @@ def test_tributes(_name: str, result: list[str], item: Item, mocker: MockerFixtu ), "charm_filters", "Charms", + CharmFilterModel, + DynamicCharmFilterModel, ), ], ) -def test_seal_or_charm_sections(item: Item, filter_attr: str, section_name: str, mocker: MockerFixture): +def test_seal_or_charm_sections( + item: Item, filter_attr: str, section_name: str, filter_model, dynamic_model, mocker: MockerFixture +): test_filter = _create_mocked_filter(mocker) - spellcraft_filter = SpellcraftFilterModel(affix_pool=[{"count": [item.affixes[0].name]}], rarities=[item.rarity]) - setattr( - test_filter, filter_attr, {"spellcraft": [DynamicSpellcraftFilterModel(root={"wanted": spellcraft_filter})]} - ) + spellcraft_filter = filter_model(affix_pool=[{"count": [item.affixes[0].name]}], rarities=[item.rarity]) + setattr(test_filter, filter_attr, {"spellcraft": [dynamic_model(root={"wanted": spellcraft_filter})]}) match = test_filter.should_keep(item).matched[0] assert match.profile == f"spellcraft.{section_name}.wanted" assert match.matched_affixes == item.affixes +def test_charm_filter_matches_set_name(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.Charm, + name="linta_of_the_frozen_sea", + rarity=ItemRarity.Legendary, + set_name="breath_of_the_frozen_sea", + affixes=[Affix(name="potion_healing")], + ) + charm_filter = CharmFilterModel(set="Breath of the Frozen Sea") + test_filter.charm_filters = {"spellcraft": [DynamicCharmFilterModel(root={"wanted": charm_filter})]} + + match = test_filter.should_keep(item).matched[0] + + assert match.profile == "spellcraft.Charms.wanted" + + +def test_charm_filter_matches_unique_aspect_name(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.Charm, + name="linta_of_the_frozen_sea", + rarity=ItemRarity.Legendary, + set_name="breath_of_the_frozen_sea", + affixes=[Affix(name="potion_healing")], + ) + charm_filter = CharmFilterModel(uniqueAspect="Linta of the Frozen Sea") + test_filter.charm_filters = {"spellcraft": [DynamicCharmFilterModel(root={"wanted": charm_filter})]} + + match = test_filter.should_keep(item).matched[0] + + assert match.profile == "spellcraft.Charms.wanted" + + +def test_charm_filter_rejects_wrong_set_or_unique_aspect(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.Charm, + name="linta_of_the_frozen_sea", + rarity=ItemRarity.Legendary, + set_name="breath_of_the_frozen_sea", + affixes=[Affix(name="potion_healing")], + ) + charm_filter = CharmFilterModel(set="applied_alchemy", uniqueAspect="another_charm") + test_filter.charm_filters = {"spellcraft": [DynamicCharmFilterModel(root={"wrong": charm_filter})]} + + assert test_filter.should_keep(item).matched == [] + + +def test_seal_filter_matches_slot_count(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.HoradricSeal, + name="unimportant_seal_name", + rarity=ItemRarity.Legendary, + affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_life")], + ) + seal_filter = SealFilterModel(slotCount=2) + test_filter.seal_filters = {"spellcraft": [DynamicSealFilterModel(root={"wanted": seal_filter})]} + + match = test_filter.should_keep(item).matched[0] + + assert match.profile == "spellcraft.Seals.wanted" + + +def test_seal_filter_rejects_wrong_slot_count(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.HoradricSeal, + name="unimportant_seal_name", + rarity=ItemRarity.Legendary, + affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_life")], + ) + seal_filter = SealFilterModel(slotCount=3) + test_filter.seal_filters = {"spellcraft": [DynamicSealFilterModel(root={"wrong": seal_filter})]} + + assert test_filter.should_keep(item).matched == [] + + @pytest.mark.parametrize( - ("item", "filter_attr"), + ("item", "filter_attr", "filter_model", "dynamic_model"), [ ( Item( @@ -154,6 +243,8 @@ def test_seal_or_charm_sections(item: Item, filter_attr: str, section_name: str, affixes=[Affix(name="cooldown_reduction", type=AffixType.greater)], ), "seal_filters", + SealFilterModel, + DynamicSealFilterModel, ), ( Item( @@ -162,13 +253,17 @@ def test_seal_or_charm_sections(item: Item, filter_attr: str, section_name: str, affixes=[Affix(name="maximum_life", type=AffixType.greater)], ), "charm_filters", + CharmFilterModel, + DynamicCharmFilterModel, ), ], ) -def test_mythic_seal_or_charm_always_kept(item: Item, filter_attr: str, mocker: MockerFixture): +def test_mythic_seal_or_charm_always_kept( + item: Item, filter_attr: str, filter_model, dynamic_model, mocker: MockerFixture +): test_filter = _create_mocked_filter(mocker) - spellcraft_filter = SpellcraftFilterModel(affix_pool=[{"count": ["movement_speed"]}], rarities=[ItemRarity.Common]) - setattr(test_filter, filter_attr, {"spellcraft": [DynamicSpellcraftFilterModel(root={"wrong": spellcraft_filter})]}) + spellcraft_filter = filter_model(affix_pool=[{"count": ["movement_speed"]}], rarities=[ItemRarity.Common]) + setattr(test_filter, filter_attr, {"spellcraft": [dynamic_model(root={"wrong": spellcraft_filter})]}) assert test_filter.should_keep(item).keep assert test_filter.should_keep(item).matched[0].profile.startswith("Mythic") diff --git a/tests/item/read_descr_tts_test.py b/tests/item/read_descr_tts_test.py index 70fe580f..7e0dfcbd 100644 --- a/tests/item/read_descr_tts_test.py +++ b/tests/item/read_descr_tts_test.py @@ -128,6 +128,7 @@ def test_set_charm_stops_affixes_before_set_bonus_text(): assert item.item_type == ItemType.Charm assert item.name == "linta_of_the_frozen_sea" + assert item.set_name == "breath_of_the_frozen_sea" assert [affix.name for affix in item.affixes] == [ "lucky_hit_up_to_a_chance_to_deal_poison_damage", "potion_healing", From 7eefefcef5b5170d3ac05886ecf15e44efcee95f Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 31 May 2026 19:14:05 +0200 Subject: [PATCH 09/39] update9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Seals only supported slotCount; there was no separate field for “boosts this set”. Seal TTS also did not store the detected boosted set on the parsed Item. Changed files: src/config/profile_models.py: added SealFilterModel.boostedSet, validated against sets.json. src/item/models.py: added boosted_set_name. src/item/descr/read_descr_tts.py: set detection now supports both charms and seals; seals populate boosted_set_name. src/item/filter.py: seal matching now checks boostedSet alongside existing criteria. Updated targeted tests in tests/config/models_test.py, tests/item/filter/filter_test.py, tests/item/read_descr_tts_test.py. Seals: - CrucibleFury: boostedSet: berserkers_crucible affixPool: - count: - maximum_fury --- src/config/profile_models.py | 32 +++++++++++++++++++----------- src/item/descr/read_descr_tts.py | 17 ++++++++++------ src/item/filter.py | 2 ++ src/item/models.py | 4 ++++ tests/config/models_test.py | 9 +++++++++ tests/item/filter/filter_test.py | 33 +++++++++++++++++++++++++++++++ tests/item/read_descr_tts_test.py | 21 ++++++++++++++++++++ 7 files changed, 101 insertions(+), 17 deletions(-) diff --git a/src/config/profile_models.py b/src/config/profile_models.py index 54b077ef..b94915f7 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -40,6 +40,20 @@ def _parse_name_or_rarities(data: str | list[str] | dict[str, str | list[str]]) raise ValueError(msg) +def _normalize_existing_set_name(name: str | None, field_name: str) -> str | None: + if not name: + return None + + # This on module level would be a circular import, so we do it lazy for now + from src.dataloader import Dataloader # noqa: PLC0415 + + name = correct_name(name) + if name not in Dataloader().set_list: + msg = f"{field_name} {name} does not exist" + raise ValueError(msg) + return name + + class AffixAspectFilterModel(BaseModel): model_config = ConfigDict(extra="forbid") name: str @@ -255,17 +269,7 @@ class CharmFilterModel(SpellcraftFilterModel): @field_validator("set_name") @classmethod def set_must_exist(cls, name: str | None) -> str | None: - if not name: - return None - - # This on module level would be a circular import, so we do it lazy for now - from src.dataloader import Dataloader # noqa: PLC0415 - - name = correct_name(name) - if name not in Dataloader().set_list: - msg = f"set {name} does not exist" - raise ValueError(msg) - return name + return _normalize_existing_set_name(name, "set") @field_validator("unique_aspect") @classmethod @@ -274,8 +278,14 @@ def normalize_unique_aspect(cls, name: str | None) -> str | None: class SealFilterModel(SpellcraftFilterModel): + boosted_set: str | None = Field(default=None, alias="boostedSet") slot_count: int = Field(default=0, alias="slotCount") + @field_validator("boosted_set") + @classmethod + def boosted_set_must_exist(cls, name: str | None) -> str | None: + return _normalize_existing_set_name(name, "boostedSet") + @field_validator("slot_count") @classmethod def slot_count_in_range(cls, v: int) -> int: diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index 6af9ec8d..2736b233 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -130,7 +130,10 @@ def _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item: starting_index = _get_affix_starting_location_from_tts_section(tts_section, item) inherent_num, affixes_num = _get_affix_counts(tts_section, item, starting_index) affixes = _get_affixes_from_tts_section(tts_section, starting_index, inherent_num + affixes_num) - item.set_name = _get_charm_set_from_tts_section(tts_section, item, starting_index, len(affixes)) + if item.item_type == ItemType.Charm: + item.set_name = _get_set_from_tts_section(tts_section, starting_index, len(affixes)) + elif item.item_type == ItemType.HoradricSeal: + item.boosted_set_name = _get_set_from_tts_section(tts_section, starting_index, len(affixes)) aspect_text = _get_aspect_from_tts_section(tts_section, item, starting_index, len(affixes)) for i, affix_text in enumerate(affixes): if i < inherent_num: @@ -375,17 +378,19 @@ def _get_aspect_from_tts_section(tts_section: list[str], item: Item, start: int, return None -def _get_charm_set_from_tts_section(tts_section: list[str], item: Item, start: int, num_affixes: int) -> str | None: - if item.item_type != ItemType.Charm: - return None - +def _get_set_from_tts_section(tts_section: list[str], start: int, num_affixes: int) -> str | None: for line in tts_section[start + num_affixes :]: - set_name = correct_name(line.split("(", maxsplit=1)[0]) + set_name = _get_set_name_from_line(line) if set_name in Dataloader().set_list: return set_name return None +def _get_set_name_from_line(line: str) -> str | None: + normalized_line = correct_name(line) + return next((set_name for set_name in Dataloader().set_list if set_name in normalized_line), None) + + def _get_affix_from_text(text: str) -> Affix: result = Affix(text=text) for x in _AFFIX_REPLACEMENTS: diff --git a/src/item/filter.py b/src/item/filter.py index ef8c661b..31a5da0c 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -306,6 +306,8 @@ def _match_charm_filter(item: Item, filter_spec: CharmFilterModel) -> bool: @staticmethod def _match_seal_filter(item: Item, filter_spec: SealFilterModel) -> bool: + if filter_spec.boosted_set is not None and filter_spec.boosted_set != item.boosted_set_name: + return False return filter_spec.slot_count == 0 or filter_spec.slot_count == len(item.affixes) def _check_tribute(self, item: Item) -> FilterResult: diff --git a/src/item/models.py b/src/item/models.py index c3261993..c7700296 100644 --- a/src/item/models.py +++ b/src/item/models.py @@ -19,6 +19,7 @@ class Item: affixes: list[Affix] = field(default_factory=list) aspect: Aspect | None = None + boosted_set_name: str | None = None codex_upgrade: bool = False cosmetic_upgrade: bool = False inherent: list[Affix] = field(default_factory=list) @@ -41,6 +42,8 @@ def __eq__(self, other): if self.aspect != other.aspect: # LOGGER.debug("Aspect not the same") res = False + if self.boosted_set_name != other.boosted_set_name: + res = False if self.codex_upgrade != other.codex_upgrade: # LOGGER.debug("Codex upgrade not the same") res = False @@ -77,6 +80,7 @@ def default(self, o): return { "affixes": [affix.__dict__ for affix in o.affixes], "aspect": o.aspect.__dict__ if o.aspect else None, + "boosted_set_name": o.boosted_set_name or None, "codex_upgrade": o.codex_upgrade, "cosmetic_upgrade": o.cosmetic_upgrade, "inherent": [affix.__dict__ for affix in o.inherent], diff --git a/tests/config/models_test.py b/tests/config/models_test.py index e7dc897e..8cb0c075 100644 --- a/tests/config/models_test.py +++ b/tests/config/models_test.py @@ -637,6 +637,15 @@ def test_unique_aspect_is_normalized(self) -> None: class TestSealFilterModel: + def test_boosted_set_alias_is_validated_and_normalized(self) -> None: + model = SealFilterModel(boostedSet="Berserker's Crucible") + + assert model.boosted_set == "berserkers_crucible" + + def test_invalid_boosted_set_fails(self) -> None: + with pytest.raises(ValidationError, match="boostedSet invalid_set does not exist"): + SealFilterModel(boostedSet="invalid set") + def test_slot_count_alias(self) -> None: model = SealFilterModel(slotCount=3) diff --git a/tests/item/filter/filter_test.py b/tests/item/filter/filter_test.py index cd17b5f9..11ee37fb 100644 --- a/tests/item/filter/filter_test.py +++ b/tests/item/filter/filter_test.py @@ -219,6 +219,39 @@ def test_seal_filter_matches_slot_count(mocker: MockerFixture): assert match.profile == "spellcraft.Seals.wanted" +def test_seal_filter_matches_boosted_set(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.HoradricSeal, + name="unimportant_seal_name", + rarity=ItemRarity.Legendary, + boosted_set_name="berserkers_crucible", + affixes=[Affix(name="maximum_fury")], + ) + seal_filter = SealFilterModel(boostedSet="Berserker's Crucible", affixPool=[{"count": ["maximum_fury"]}]) + test_filter.seal_filters = {"spellcraft": [DynamicSealFilterModel(root={"wanted": seal_filter})]} + + match = test_filter.should_keep(item).matched[0] + + assert match.profile == "spellcraft.Seals.wanted" + assert match.matched_affixes == item.affixes + + +def test_seal_filter_rejects_wrong_boosted_set(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.HoradricSeal, + name="unimportant_seal_name", + rarity=ItemRarity.Legendary, + boosted_set_name="berserkers_crucible", + affixes=[Affix(name="maximum_fury")], + ) + seal_filter = SealFilterModel(boostedSet="cathans_dauntless_faith", affixPool=[{"count": ["maximum_fury"]}]) + test_filter.seal_filters = {"spellcraft": [DynamicSealFilterModel(root={"wrong": seal_filter})]} + + assert test_filter.should_keep(item).matched == [] + + def test_seal_filter_rejects_wrong_slot_count(mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) item = Item( diff --git a/tests/item/read_descr_tts_test.py b/tests/item/read_descr_tts_test.py index 7e0dfcbd..eeb9bdff 100644 --- a/tests/item/read_descr_tts_test.py +++ b/tests/item/read_descr_tts_test.py @@ -112,6 +112,27 @@ def test_horadric_charm_type_line_parses_as_charm(): assert read_descr() == expected_item +def test_seal_boosted_set_parses_from_tts(): + src.tts.LAST_ITEM = [ + "INIMICAL SEAL OF FURY", + "Legendary Horadric Seal", + "10% Cooldown Reduction [5 - 15]", + "100 Maximum Life [50 - 150]", + "Boosts Berserker's Crucible", + "Right mouse button", + ] + expected_item = Item( + affixes=EXPECTED_AFFIXES[:2], + boosted_set_name="berserkers_crucible", + item_type=ItemType.HoradricSeal, + name="inimical_seal_of_fury", + original_name="INIMICAL SEAL OF FURY", + rarity=ItemRarity.Legendary, + ) + + assert read_descr() == expected_item + + def test_set_charm_stops_affixes_before_set_bonus_text(): src.tts.LAST_ITEM = [ "LINTA OF THE FROZEN SEA", From cf3fec5a02723fb1ff7816f4e38641107a20afc6 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Tue, 2 Jun 2026 18:58:02 +0200 Subject: [PATCH 10/39] update10 read comments --- src/config/helper.py | 7 +++ src/config/profile_models.py | 46 ++++----------- src/gui/importer/d4builds.py | 14 ++--- src/gui/importer/gui_common.py | 14 ++--- src/gui/importer/maxroll.py | 20 +++---- src/gui/importer/mobalytics.py | 14 ++--- src/gui/profile_editor/profile_editor.py | 6 +- .../{spellcraft_tab.py => seal_charm_tab.py} | 26 ++++----- src/item/data/rarity.py | 1 + src/item/descr/read_descr_tts.py | 8 +-- src/item/filter.py | 15 ++--- tests/config/models_test.py | 9 --- tests/item/filter/filter_test.py | 56 +++++-------------- tests/item/read_descr_tts_test.py | 2 + 14 files changed, 92 insertions(+), 146 deletions(-) rename src/gui/profile_editor/{spellcraft_tab.py => seal_charm_tab.py} (91%) diff --git a/src/config/helper.py b/src/config/helper.py index 94b0ef21..794fa686 100644 --- a/src/config/helper.py +++ b/src/config/helper.py @@ -20,6 +20,13 @@ def validate_percent(v: int) -> int: return v +def validate_greater_affix_count(v: int) -> int: + if not 0 <= v <= 4: + msg = "must be in [0, 4]" + raise ValueError(msg) + return v + + def validate_hotkey(k: str) -> str: keyboard.parse_hotkey(k) return k diff --git a/src/config/profile_models.py b/src/config/profile_models.py index b94915f7..c9973b37 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator -from src.config.helper import check_greater_than_zero, validate_percent +from src.config.helper import check_greater_than_zero, validate_greater_affix_count, validate_percent from src.item.data.item_type import ItemType # noqa: TC001 from src.item.data.rarity import ItemRarity from src.scripts import correct_name @@ -20,15 +20,11 @@ def _parse_item_type_or_rarities(data: str | list[str]) -> list[str]: return data -def _is_item_rarity(data: str) -> bool: - return any(rarity.value.lower() == data.lower() for rarity in ItemRarity) - - -def _parse_name_or_rarities(data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]: +def _coerce_name_rarity_filter_data(data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]: if isinstance(data, dict): return data if isinstance(data, str): - if _is_item_rarity(data): + if any(rarity.value.lower() == data.lower() for rarity in ItemRarity): return {"rarities": [data]} return {"name": data} if isinstance(data, list): @@ -184,10 +180,7 @@ def check_min_power(cls, v: int) -> int: @field_validator("min_greater_affix_count") @classmethod def count_validator(cls, v: int) -> int: - if not 0 <= v <= 4: - msg = "must be in [0, 4]" - raise ValueError(msg) - return v + return validate_greater_affix_count(v) @field_validator("min_percent_of_aspect") @classmethod @@ -212,10 +205,7 @@ def check_min_power(cls, v: int) -> int: @field_validator("min_greater_affix_count") @classmethod def min_greater_affix_in_range(cls, v: int) -> int: - if not 0 <= v <= 4: - msg = "must be in [0, 4]" - raise ValueError(msg) - return v + return validate_greater_affix_count(v) @field_validator("item_type", mode="before") @classmethod @@ -242,7 +232,7 @@ def unique_aspect_names_must_be_unique(self) -> ItemFilterModel: DynamicItemFilterModel = RootModel[dict[str, ItemFilterModel]] -class SpellcraftFilterModel(BaseModel): +class SealCharmFilterModel(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) affix_pool: list[AffixFilterCountModel] = Field(default=[], alias="affixPool") min_greater_affix_count: int = Field(default=0, alias="minGreaterAffixCount") @@ -251,10 +241,7 @@ class SpellcraftFilterModel(BaseModel): @field_validator("min_greater_affix_count") @classmethod def min_greater_affix_in_range(cls, v: int) -> int: - if not 0 <= v <= 4: - msg = "must be in [0, 4]" - raise ValueError(msg) - return v + return validate_greater_affix_count(v) @field_validator("rarities", mode="before") @classmethod @@ -262,7 +249,7 @@ def parse_rarities(cls, data: str | list[str]) -> list[str]: return _parse_item_type_or_rarities(data) -class CharmFilterModel(SpellcraftFilterModel): +class CharmFilterModel(SealCharmFilterModel): set_name: str | None = Field(default=None, alias="set") unique_aspect: str | None = Field(default=None, alias="uniqueAspect") @@ -277,25 +264,16 @@ def normalize_unique_aspect(cls, name: str | None) -> str | None: return correct_name(name) -class SealFilterModel(SpellcraftFilterModel): +class SealFilterModel(SealCharmFilterModel): boosted_set: str | None = Field(default=None, alias="boostedSet") - slot_count: int = Field(default=0, alias="slotCount") @field_validator("boosted_set") @classmethod def boosted_set_must_exist(cls, name: str | None) -> str | None: return _normalize_existing_set_name(name, "boostedSet") - @field_validator("slot_count") - @classmethod - def slot_count_in_range(cls, v: int) -> int: - if not 0 <= v <= 4: - msg = "must be in [0, 4]" - raise ValueError(msg) - return v - -DynamicSpellcraftFilterModel = RootModel[dict[str, SpellcraftFilterModel]] +DynamicSealCharmFilterModel = RootModel[dict[str, SealCharmFilterModel]] DynamicCharmFilterModel = RootModel[dict[str, CharmFilterModel]] DynamicSealFilterModel = RootModel[dict[str, SealFilterModel]] @@ -388,7 +366,7 @@ def name_must_exist(cls, name: str) -> str: @model_validator(mode="before") @classmethod def parse_data(cls, data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]: - return _parse_name_or_rarities(data) + return _coerce_name_rarity_filter_data(data) @field_validator("rarities", mode="before") @classmethod @@ -409,7 +387,7 @@ def normalize_name(cls, name: str | None) -> str | None: @model_validator(mode="before") @classmethod def parse_data(cls, data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]: - return _parse_name_or_rarities(data) + return _coerce_name_rarity_filter_data(data) @field_validator("rarities", mode="before") @classmethod diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index 34be4183..8b683a3f 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -23,7 +23,7 @@ add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, - create_spellcraft_filter, + create_seal_charm_filter, fix_offhand_type, fix_weapon_type, get_class_name, @@ -183,15 +183,15 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): item_filter.item_type = [item_type] if item_type in [ItemType.HoradricSeal, ItemType.Charm]: - spellcraft_filters = charm_filters if item_type == ItemType.Charm else seal_filters - filter_name = _unique_filter_name(item_type.name, spellcraft_filters) - spellcraft_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel - spellcraft_filters.append({ - filter_name: create_spellcraft_filter( + seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters + filter_name = _unique_filter_name(item_type.name, seal_charm_filters) + seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel + seal_charm_filters.append({ + filter_name: create_seal_charm_filter( affixes=affixes, rarity=rarity, require_gas=config.require_greater_affixes, - model_type=spellcraft_model, + model_type=seal_charm_model, ) }) continue diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py index d97c6bb3..230c6abb 100644 --- a/src/gui/importer/gui_common.py +++ b/src/gui/importer/gui_common.py @@ -24,7 +24,7 @@ AspectUniqueFilterModel, ItemFilterModel, ProfileModel, - SpellcraftFilterModel, + SealCharmFilterModel, ) from src.config.settings_models import BrowserType from src.item.data.affix import Affix, AffixType @@ -196,10 +196,10 @@ def update_mingreateraffixcount(item_filter: ItemFilterModel, require_gas: bool) item_filter.min_greater_affix_count = 0 -def create_spellcraft_filter( - affixes: list[Affix], rarity, require_gas: bool, model_type: type[SpellcraftFilterModel] = SpellcraftFilterModel -) -> SpellcraftFilterModel: - spellcraft_filter = model_type( +def create_seal_charm_filter( + affixes: list[Affix], rarity, require_gas: bool, model_type: type[SealCharmFilterModel] = SealCharmFilterModel +) -> SealCharmFilterModel: + seal_charm_filter = model_type( affix_pool=[ AffixFilterCountModel( count=[ @@ -211,8 +211,8 @@ def create_spellcraft_filter( rarities=[rarity] if rarity is not None else [], ) if require_gas: - spellcraft_filter.min_greater_affix_count = len([affix for affix in affixes if affix.type == AffixType.greater]) - return spellcraft_filter + seal_charm_filter.min_greater_affix_count = len([affix for affix in affixes if affix.type == AffixType.greater]) + return seal_charm_filter def add_mythics_to_filters(mythic_names, finished_filters): diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 81464152..a23518da 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -19,7 +19,7 @@ add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, - create_spellcraft_filter, + create_seal_charm_filter, fix_offhand_type, fix_weapon_type, get_with_retry, @@ -114,26 +114,26 @@ def import_maxroll(config: ImportConfig): continue if item_type in [ItemType.HoradricSeal, ItemType.Charm]: - spellcraft_affixes = _find_item_affixes( + seal_charm_affixes = _find_item_affixes( mapping_data=mapping_data, item_affixes=resolved_item["explicits"], item_type=item_type, import_greater_affixes=config.import_greater_affixes, ) - if not spellcraft_affixes: + if not seal_charm_affixes: LOGGER.warning( f"Skipping {resolved_item.get('name', '(could not determine item name)')} because it had no supported affixes." ) continue - spellcraft_filters = charm_filters if item_type == ItemType.Charm else seal_filters - filter_name = _unique_filter_name(item_type.name, spellcraft_filters) - spellcraft_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel - spellcraft_filters.append({ - filter_name: create_spellcraft_filter( - affixes=spellcraft_affixes, + seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters + filter_name = _unique_filter_name(item_type.name, seal_charm_filters) + seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel + seal_charm_filters.append({ + filter_name: create_seal_charm_filter( + affixes=seal_charm_affixes, rarity=rarity, require_gas=config.require_greater_affixes, - model_type=spellcraft_model, + model_type=seal_charm_model, ) }) continue diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index 443bac61..8d1d3b3c 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -26,7 +26,7 @@ add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, - create_spellcraft_filter, + create_seal_charm_filter, fix_offhand_type, fix_weapon_type, match_to_enum, @@ -219,15 +219,15 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): if not affixes: LOGGER.warning(f"Skipping {item_name} because it had no supported affixes.") continue - spellcraft_filters = charm_filters if item_type == ItemType.Charm else seal_filters - filter_name = _unique_filter_name(item_type.name, spellcraft_filters) - spellcraft_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel - spellcraft_filters.append({ - filter_name: create_spellcraft_filter( + seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters + filter_name = _unique_filter_name(item_type.name, seal_charm_filters) + seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel + seal_charm_filters.append({ + filter_name: create_seal_charm_filter( affixes=affixes, rarity=None, require_gas=config.require_greater_affixes, - model_type=spellcraft_model, + model_type=seal_charm_model, ) }) continue diff --git a/src/gui/profile_editor/profile_editor.py b/src/gui/profile_editor/profile_editor.py index 72d1f3b6..26c0c41b 100644 --- a/src/gui/profile_editor/profile_editor.py +++ b/src/gui/profile_editor/profile_editor.py @@ -14,8 +14,8 @@ from src.gui.profile_editor.affixes_tab import AFFIXES_TABNAME, AffixesTab from src.gui.profile_editor.aspect_upgrades_tab import ASPECT_UPGRADES_TABNAME, AspectUpgradesTab from src.gui.profile_editor.global_uniques_tab import UNIQUES_TABNAME, UniquesTab +from src.gui.profile_editor.seal_charm_tab import CHARMS_TABNAME, SEALS_TABNAME, SealCharmTab from src.gui.profile_editor.sigils_tab import SIGILS_TABNAME, SigilsTab -from src.gui.profile_editor.spellcraft_tab import CHARMS_TABNAME, SEALS_TABNAME, SpellcraftTab from src.gui.profile_editor.tributes_tab import TRIBUTES_TABNAME, TributesTab LOGGER = logging.getLogger(__name__) @@ -32,8 +32,8 @@ def __init__(self, profile_model: ProfileModel, parent=None): # Create main tabs self.affixes_tab = AffixesTab(self.profile_model.affixes) self.aspect_upgrades_tab = AspectUpgradesTab(self.profile_model.aspect_upgrades) - self.seals_tab = SpellcraftTab(self.profile_model.seals, SEALS_TABNAME, DynamicSealFilterModel, SealFilterModel) - self.charms_tab = SpellcraftTab( + self.seals_tab = SealCharmTab(self.profile_model.seals, SEALS_TABNAME, DynamicSealFilterModel, SealFilterModel) + self.charms_tab = SealCharmTab( self.profile_model.charms, CHARMS_TABNAME, DynamicCharmFilterModel, CharmFilterModel ) self.sigils_tab = SigilsTab(self.profile_model.sigils) diff --git a/src/gui/profile_editor/spellcraft_tab.py b/src/gui/profile_editor/seal_charm_tab.py similarity index 91% rename from src/gui/profile_editor/spellcraft_tab.py rename to src/gui/profile_editor/seal_charm_tab.py index 85866f46..6370d88a 100644 --- a/src/gui/profile_editor/spellcraft_tab.py +++ b/src/gui/profile_editor/seal_charm_tab.py @@ -20,10 +20,10 @@ AffixFilterModel, CharmFilterModel, DynamicCharmFilterModel, + DynamicSealCharmFilterModel, DynamicSealFilterModel, - DynamicSpellcraftFilterModel, + SealCharmFilterModel, SealFilterModel, - SpellcraftFilterModel, ) from src.dataloader import Dataloader from src.gui.models.collapsible_widget import Container @@ -35,8 +35,8 @@ CHARMS_TABNAME = "Charms" -class SpellcraftRuleEditor(QWidget): - def __init__(self, dynamic_filter: DynamicSpellcraftFilterModel, parent=None): +class SealCharmRuleEditor(QWidget): + def __init__(self, dynamic_filter: DynamicSealCharmFilterModel, parent=None): super().__init__(parent) for rule_name, config in dynamic_filter.root.items(): self.rule_name = rule_name @@ -141,13 +141,13 @@ def update_rarities(self): self.config.rarities = [rarity for rarity, checkbox in self.rarity_checkboxes.items() if checkbox.isChecked()] -class SpellcraftTab(QWidget): +class SealCharmTab(QWidget): def __init__( self, - filters: list[DynamicSpellcraftFilterModel], + filters: list[DynamicSealCharmFilterModel], section_name: str, - dynamic_model: type[DynamicSpellcraftFilterModel | DynamicCharmFilterModel | DynamicSealFilterModel], - filter_model: type[SpellcraftFilterModel | CharmFilterModel | SealFilterModel], + dynamic_model: type[DynamicSealCharmFilterModel | DynamicCharmFilterModel | DynamicSealFilterModel], + filter_model: type[SealCharmFilterModel | CharmFilterModel | SealFilterModel], parent=None, ): super().__init__(parent) @@ -176,15 +176,15 @@ def setup_ui(self): self.toolbar.setMovable(False) self.rule_names = [] - for spellcraft_filter in self.filters: - for rule_name in spellcraft_filter.root: + for seal_charm_filter in self.filters: + for rule_name in seal_charm_filter.root: if rule_name in self.rule_names: QMessageBox.warning( self, "Warning", f"Rule name already exists. Please rename {rule_name} in the profile file." ) continue self.rule_names.append(rule_name) - self.tab_widget.addTab(SpellcraftRuleEditor(spellcraft_filter), rule_name) + self.tab_widget.addTab(SealCharmRuleEditor(seal_charm_filter), rule_name) add_rule_button = QPushButton("Create Item") add_rule_button.clicked.connect(self.add_rule) @@ -211,7 +211,7 @@ def add_rule(self): new_filter = self.dynamic_model(root={rule_name: self._default_filter()}) self.filters.append(new_filter) self.rule_names.append(rule_name) - self.tab_widget.addTab(SpellcraftRuleEditor(new_filter), rule_name) + self.tab_widget.addTab(SealCharmRuleEditor(new_filter), rule_name) def close_tab(self, index): self.rule_names.pop(index) @@ -229,7 +229,7 @@ def remove_rule(self): self.tab_widget.removeTab(index) self.filters.pop(index) - def _default_filter(self) -> SpellcraftFilterModel: + def _default_filter(self) -> SealCharmFilterModel: return self.filter_model( affix_pool=[ AffixFilterCountModel( diff --git a/src/item/data/rarity.py b/src/item/data/rarity.py index 6c48fce6..62256d3b 100644 --- a/src/item/data/rarity.py +++ b/src/item/data/rarity.py @@ -7,4 +7,5 @@ class ItemRarity(Enum): Magic = "magic" Mythic = "mythic" Rare = "rare" + Set = "set" Unique = "unique" diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index 2736b233..16ea9c42 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -82,7 +82,7 @@ def _get_affix_counts(tts_section: list[str], item: Item, start: int) -> tuple[i affixes_num = 0 if is_seal_or_charm(item.item_type): - return inherent_num, _get_spellcraft_affix_count(tts_section, start) + return inherent_num, _get_seal_charm_affix_count(tts_section, start) if item.rarity in [ItemRarity.Unique, ItemRarity.Mythic] and not is_seal_or_charm(item.item_type): # Uniques can have variable amounts of inherents. @@ -111,18 +111,18 @@ def _get_affix_counts(tts_section: list[str], item: Item, start: int) -> tuple[i return inherent_num, affixes_num -def _get_spellcraft_affix_count(tts_section: list[str], start: int) -> int: +def _get_seal_charm_affix_count(tts_section: list[str], start: int) -> int: affixes_num = 0 for line in tts_section[start:]: if line.lower().startswith(_AFFIX_STOP_MARKERS): break - if not _is_spellcraft_affix_line(line): + if not _is_seal_charm_affix_line(line): break affixes_num += 1 return affixes_num -def _is_spellcraft_affix_line(line: str) -> bool: +def _is_seal_charm_affix_line(line: str) -> bool: return clean_str(line) in Dataloader().affix_dict.values() diff --git a/src/item/filter.py b/src/item/filter.py index 31a5da0c..59a5239a 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -242,7 +242,7 @@ def _check_sigil(self, item: Item) -> FilterResult: res.matched.append(MatchedFilter(f"{profile_name}")) return res - def _check_spellcraft_filters( + def _check_seal_charm_filters( self, item: Item, item_filters: dict[str, list[DynamicCharmFilterModel] | list[DynamicSealFilterModel]], @@ -261,7 +261,6 @@ def _check_spellcraft_filters( res.keep = True res.matched.append(MatchedFilter(mythic_name)) - non_tempered_affixes = [affix for affix in item.affixes if affix.type != AffixType.tempered] for profile_name, profile_filter in item_filters.items(): for filter_item in profile_filter: filter_name = next(iter(filter_item.root.keys())) @@ -274,7 +273,7 @@ def _check_spellcraft_filters( continue if not self._match_greater_affix_count( - expected_min_count=filter_spec.min_greater_affix_count, item_affixes=non_tempered_affixes + expected_min_count=filter_spec.min_greater_affix_count, item_affixes=item.affixes ): continue @@ -282,7 +281,7 @@ def _check_spellcraft_filters( if filter_spec.affix_pool: matched_affixes = self._match_affixes_count( expected_affixes=filter_spec.affix_pool, - item_affixes=non_tempered_affixes, + item_affixes=item.affixes, min_greater_affix_count=filter_spec.min_greater_affix_count, ) if not matched_affixes: @@ -306,9 +305,7 @@ def _match_charm_filter(item: Item, filter_spec: CharmFilterModel) -> bool: @staticmethod def _match_seal_filter(item: Item, filter_spec: SealFilterModel) -> bool: - if filter_spec.boosted_set is not None and filter_spec.boosted_set != item.boosted_set_name: - return False - return filter_spec.slot_count == 0 or filter_spec.slot_count == len(item.affixes) + return filter_spec.boosted_set is None or filter_spec.boosted_set == item.boosted_set_name def _check_tribute(self, item: Item) -> FilterResult: res = FilterResult(keep=False, matched=[]) @@ -336,7 +333,7 @@ def _check_tribute(self, item: Item) -> FilterResult: return res def _check_seal(self, item: Item) -> FilterResult: - return self._check_spellcraft_filters( + return self._check_seal_charm_filters( item=item, item_filters=self.seal_filters, section_name="Seals", @@ -345,7 +342,7 @@ def _check_seal(self, item: Item) -> FilterResult: ) def _check_charm(self, item: Item) -> FilterResult: - return self._check_spellcraft_filters( + return self._check_seal_charm_filters( item=item, item_filters=self.charm_filters, section_name="Charms", diff --git a/tests/config/models_test.py b/tests/config/models_test.py index 8cb0c075..8dc7e25d 100644 --- a/tests/config/models_test.py +++ b/tests/config/models_test.py @@ -646,15 +646,6 @@ def test_invalid_boosted_set_fails(self) -> None: with pytest.raises(ValidationError, match="boostedSet invalid_set does not exist"): SealFilterModel(boostedSet="invalid set") - def test_slot_count_alias(self) -> None: - model = SealFilterModel(slotCount=3) - - assert model.slot_count == 3 - - def test_slot_count_out_of_range_fails(self) -> None: - with pytest.raises(ValidationError, match="must be in \\[0, 4\\]"): - SealFilterModel(slotCount=5) - class TestSigilConditionModel: """Test SigilConditionModel.""" diff --git a/tests/item/filter/filter_test.py b/tests/item/filter/filter_test.py index 11ee37fb..3cbd28a9 100644 --- a/tests/item/filter/filter_test.py +++ b/tests/item/filter/filter_test.py @@ -146,11 +146,11 @@ def test_seal_or_charm_sections( item: Item, filter_attr: str, section_name: str, filter_model, dynamic_model, mocker: MockerFixture ): test_filter = _create_mocked_filter(mocker) - spellcraft_filter = filter_model(affix_pool=[{"count": [item.affixes[0].name]}], rarities=[item.rarity]) - setattr(test_filter, filter_attr, {"spellcraft": [dynamic_model(root={"wanted": spellcraft_filter})]}) + seal_charm_filter = filter_model(affix_pool=[{"count": [item.affixes[0].name]}], rarities=[item.rarity]) + setattr(test_filter, filter_attr, {"seal_charm": [dynamic_model(root={"wanted": seal_charm_filter})]}) match = test_filter.should_keep(item).matched[0] - assert match.profile == f"spellcraft.{section_name}.wanted" + assert match.profile == f"seal_charm.{section_name}.wanted" assert match.matched_affixes == item.affixes @@ -164,11 +164,11 @@ def test_charm_filter_matches_set_name(mocker: MockerFixture): affixes=[Affix(name="potion_healing")], ) charm_filter = CharmFilterModel(set="Breath of the Frozen Sea") - test_filter.charm_filters = {"spellcraft": [DynamicCharmFilterModel(root={"wanted": charm_filter})]} + test_filter.charm_filters = {"seal_charm": [DynamicCharmFilterModel(root={"wanted": charm_filter})]} match = test_filter.should_keep(item).matched[0] - assert match.profile == "spellcraft.Charms.wanted" + assert match.profile == "seal_charm.Charms.wanted" def test_charm_filter_matches_unique_aspect_name(mocker: MockerFixture): @@ -181,11 +181,11 @@ def test_charm_filter_matches_unique_aspect_name(mocker: MockerFixture): affixes=[Affix(name="potion_healing")], ) charm_filter = CharmFilterModel(uniqueAspect="Linta of the Frozen Sea") - test_filter.charm_filters = {"spellcraft": [DynamicCharmFilterModel(root={"wanted": charm_filter})]} + test_filter.charm_filters = {"seal_charm": [DynamicCharmFilterModel(root={"wanted": charm_filter})]} match = test_filter.should_keep(item).matched[0] - assert match.profile == "spellcraft.Charms.wanted" + assert match.profile == "seal_charm.Charms.wanted" def test_charm_filter_rejects_wrong_set_or_unique_aspect(mocker: MockerFixture): @@ -198,27 +198,11 @@ def test_charm_filter_rejects_wrong_set_or_unique_aspect(mocker: MockerFixture): affixes=[Affix(name="potion_healing")], ) charm_filter = CharmFilterModel(set="applied_alchemy", uniqueAspect="another_charm") - test_filter.charm_filters = {"spellcraft": [DynamicCharmFilterModel(root={"wrong": charm_filter})]} + test_filter.charm_filters = {"seal_charm": [DynamicCharmFilterModel(root={"wrong": charm_filter})]} assert test_filter.should_keep(item).matched == [] -def test_seal_filter_matches_slot_count(mocker: MockerFixture): - test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.HoradricSeal, - name="unimportant_seal_name", - rarity=ItemRarity.Legendary, - affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_life")], - ) - seal_filter = SealFilterModel(slotCount=2) - test_filter.seal_filters = {"spellcraft": [DynamicSealFilterModel(root={"wanted": seal_filter})]} - - match = test_filter.should_keep(item).matched[0] - - assert match.profile == "spellcraft.Seals.wanted" - - def test_seal_filter_matches_boosted_set(mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) item = Item( @@ -229,11 +213,11 @@ def test_seal_filter_matches_boosted_set(mocker: MockerFixture): affixes=[Affix(name="maximum_fury")], ) seal_filter = SealFilterModel(boostedSet="Berserker's Crucible", affixPool=[{"count": ["maximum_fury"]}]) - test_filter.seal_filters = {"spellcraft": [DynamicSealFilterModel(root={"wanted": seal_filter})]} + test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} match = test_filter.should_keep(item).matched[0] - assert match.profile == "spellcraft.Seals.wanted" + assert match.profile == "seal_charm.Seals.wanted" assert match.matched_affixes == item.affixes @@ -247,21 +231,7 @@ def test_seal_filter_rejects_wrong_boosted_set(mocker: MockerFixture): affixes=[Affix(name="maximum_fury")], ) seal_filter = SealFilterModel(boostedSet="cathans_dauntless_faith", affixPool=[{"count": ["maximum_fury"]}]) - test_filter.seal_filters = {"spellcraft": [DynamicSealFilterModel(root={"wrong": seal_filter})]} - - assert test_filter.should_keep(item).matched == [] - - -def test_seal_filter_rejects_wrong_slot_count(mocker: MockerFixture): - test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.HoradricSeal, - name="unimportant_seal_name", - rarity=ItemRarity.Legendary, - affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_life")], - ) - seal_filter = SealFilterModel(slotCount=3) - test_filter.seal_filters = {"spellcraft": [DynamicSealFilterModel(root={"wrong": seal_filter})]} + test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wrong": seal_filter})]} assert test_filter.should_keep(item).matched == [] @@ -295,8 +265,8 @@ def test_mythic_seal_or_charm_always_kept( item: Item, filter_attr: str, filter_model, dynamic_model, mocker: MockerFixture ): test_filter = _create_mocked_filter(mocker) - spellcraft_filter = filter_model(affix_pool=[{"count": ["movement_speed"]}], rarities=[ItemRarity.Common]) - setattr(test_filter, filter_attr, {"spellcraft": [dynamic_model(root={"wrong": spellcraft_filter})]}) + seal_charm_filter = filter_model(affix_pool=[{"count": ["movement_speed"]}], rarities=[ItemRarity.Common]) + setattr(test_filter, filter_attr, {"seal_charm": [dynamic_model(root={"wrong": seal_charm_filter})]}) assert test_filter.should_keep(item).keep assert test_filter.should_keep(item).matched[0].profile.startswith("Mythic") diff --git a/tests/item/read_descr_tts_test.py b/tests/item/read_descr_tts_test.py index eeb9bdff..af06e80b 100644 --- a/tests/item/read_descr_tts_test.py +++ b/tests/item/read_descr_tts_test.py @@ -15,6 +15,7 @@ (ItemRarity.Magic, "Magic {item_type}"), (ItemRarity.Rare, "Rare {item_type}"), (ItemRarity.Legendary, "Legendary {item_type}"), + (ItemRarity.Set, "Set {item_type}"), (ItemRarity.Unique, "Unique {item_type}"), (ItemRarity.Mythic, "Mythic Unique {item_type}"), ] @@ -149,6 +150,7 @@ def test_set_charm_stops_affixes_before_set_bonus_text(): assert item.item_type == ItemType.Charm assert item.name == "linta_of_the_frozen_sea" + assert item.rarity == ItemRarity.Set assert item.set_name == "breath_of_the_frozen_sea" assert [affix.name for affix in item.affixes] == [ "lucky_hit_up_to_a_chance_to_deal_poison_damage", From f4269be4b56caf68f0ea7b9ae9feaf2909436113 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Tue, 2 Jun 2026 19:20:08 +0200 Subject: [PATCH 11/39] update11 --- tests/item/read_descr_season_13_tts_test.py | 69 +++++++++ tests/item/read_descr_tts_test.py | 156 +------------------- 2 files changed, 70 insertions(+), 155 deletions(-) diff --git a/tests/item/read_descr_season_13_tts_test.py b/tests/item/read_descr_season_13_tts_test.py index 289257e8..ea26611c 100644 --- a/tests/item/read_descr_season_13_tts_test.py +++ b/tests/item/read_descr_season_13_tts_test.py @@ -387,6 +387,75 @@ seasonal_attribute=None, ), ), + ( + [ + "INIMICAL SEAL OF FURY", + "Legendary Horadric Seal", + "5 Maximum Fury [5]", + "Boosts Berserker's Crucible", + "Right mouse button", + ], + Item( + affixes=[ + Affix( + max_value=5.0, + min_value=5.0, + name="maximum_fury", + text="5 Maximum Fury [5]", + type=AffixType.normal, + value=5.0, + ) + ], + aspect=None, + boosted_set_name="berserkers_crucible", + codex_upgrade=False, + cosmetic_upgrade=False, + inherent=[], + is_in_shop=False, + item_type=ItemType.HoradricSeal, + name="inimical_seal_of_fury", + original_name="INIMICAL SEAL OF FURY", + power=None, + rarity=ItemRarity.Legendary, + seasonal_attribute=None, + ), + ), + ( + [ + "LINTA OF THE FROZEN SEA", + "Set Charm", + "Lucky Hit: Up to a 40% Chance to Deal +650 Poison Damage", + "+7.0% Potion Healing", + "Breath of the Frozen Sea", + "Phoba of the Frozen Sea", + "Breath of the Frozen Sea (0/5). (2) Set:. Frost Skills deal 70% of their direct damage as bonus Frostbite over 12 seconds.. (3) Set:. You cannot be Chilled or Frozen.. Your Maximum Life and Barrier generation is increased by 20%.. (5) Set:. Frost Skill damage is increased by 200%.. Freezing enemies consumes all Frostbite on them, dealing its remaining damage instantly.", + "Requires Level 70Sorcerer. Only. Unique Equipped. Lord of Hatred Item", + "Right mouse button", + ], + Item( + affixes=[ + Affix( + name="lucky_hit_up_to_a_chance_to_deal_poison_damage", + text="Lucky Hit: Up to a 40% Chance to Deal +650 Poison Damage", + type=AffixType.greater, + value=40.0, + ), + Affix(name="potion_healing", text="+7.0% Potion Healing", type=AffixType.greater, value=7.0), + ], + aspect=None, + codex_upgrade=False, + cosmetic_upgrade=False, + inherent=[], + is_in_shop=False, + item_type=ItemType.Charm, + name="linta_of_the_frozen_sea", + original_name="LINTA OF THE FROZEN SEA", + power=None, + rarity=ItemRarity.Set, + seasonal_attribute=None, + set_name="breath_of_the_frozen_sea", + ), + ), ] diff --git a/tests/item/read_descr_tts_test.py b/tests/item/read_descr_tts_test.py index af06e80b..2df54532 100644 --- a/tests/item/read_descr_tts_test.py +++ b/tests/item/read_descr_tts_test.py @@ -1,45 +1,8 @@ -import numpy as np -import pytest - import src.tts -from src.item.data.affix import Affix -from src.item.data.item_type import ItemType -from src.item.data.rarity import ItemRarity -from src.item.descr.read_descr_tts import read_descr, read_descr_mixed -from src.item.models import Item +from src.item.descr.read_descr_tts import read_descr LOOT_FILTER_TTS = ["SELECT ALL", "Checkbox Disabled", "Item Power Range", "Left mouse button"] -RARITY_TTS_LINES = [ - (ItemRarity.Common, "Common {item_type}"), - (ItemRarity.Magic, "Magic {item_type}"), - (ItemRarity.Rare, "Rare {item_type}"), - (ItemRarity.Legendary, "Legendary {item_type}"), - (ItemRarity.Set, "Set {item_type}"), - (ItemRarity.Unique, "Unique {item_type}"), - (ItemRarity.Mythic, "Mythic Unique {item_type}"), -] - -AFFIX_TTS_LINES = [ - "10% Cooldown Reduction [5 - 15]", - "100 Maximum Life [50 - 150]", - "20% Critical Strike Chance [10 - 30]", - "12% Movement Speed [5 - 20]", -] - -EXPECTED_AFFIXES = [ - Affix(text="10% Cooldown Reduction [5 - 15]", name="cooldown_reduction", value=10.0, min_value=5.0, max_value=15.0), - Affix(text="100 Maximum Life [50 - 150]", name="maximum_life", value=100.0, min_value=50.0, max_value=150.0), - Affix( - text="20% Critical Strike Chance [10 - 30]", - name="critical_strike_chance", - value=20.0, - min_value=10.0, - max_value=30.0, - ), - Affix(text="12% Movement Speed [5 - 20]", name="movement_speed", value=12.0, min_value=5.0, max_value=20.0), -] - def test_loot_filter_controls_are_not_tts_item_start(): assert src.tts.find_item_start(LOOT_FILTER_TTS) is None @@ -49,120 +12,3 @@ def test_loot_filter_controls_do_not_raise_tts_parser_error(): src.tts.LAST_ITEM = LOOT_FILTER_TTS assert read_descr() is None - - -@pytest.mark.parametrize("item_type", [ItemType.HoradricSeal, ItemType.Charm]) -@pytest.mark.parametrize(("rarity", "type_line_template"), RARITY_TTS_LINES) -def test_seal_or_charm_items_parse_at_all_rarities(item_type: ItemType, rarity: ItemRarity, type_line_template: str): - item_name = f"TEST {item_type.value.upper()}" - expected_affix_count = _expected_affix_count(rarity) - src.tts.LAST_ITEM = [ - item_name, - type_line_template.format(item_type=item_type.value.title()), - *AFFIX_TTS_LINES[:expected_affix_count], - "Right mouse button", - ] - expected_item = Item( - affixes=EXPECTED_AFFIXES[:expected_affix_count], - item_type=item_type, - name=f"test_{item_type.value.replace(' ', '_')}", - original_name=item_name, - rarity=rarity, - ) - - assert read_descr() == expected_item - assert read_descr_mixed(np.empty((1, 1, 3), dtype=np.uint8)) == expected_item - - -@pytest.mark.parametrize("item_type", [ItemType.HoradricSeal, ItemType.Charm]) -def test_seal_or_charm_items_parse_variable_affix_counts(item_type: ItemType): - item_name = f"VARIABLE {item_type.value.upper()}" - src.tts.LAST_ITEM = [ - item_name, - f"Legendary {item_type.value.title()}", - "10% Cooldown Reduction [5 - 15]", - "100 Maximum Life [50 - 150]", - "Right mouse button", - ] - expected_item = Item( - affixes=EXPECTED_AFFIXES[:2], - item_type=item_type, - name=f"variable_{item_type.value.replace(' ', '_')}", - original_name=item_name, - rarity=ItemRarity.Legendary, - ) - - assert read_descr() == expected_item - - -def test_horadric_charm_type_line_parses_as_charm(): - src.tts.LAST_ITEM = [ - "DIVINE CHARM OF RESTORATION", - "Legendary Horadric Charm", - "10% Cooldown Reduction [5 - 15]", - "Right mouse button", - ] - expected_item = Item( - affixes=EXPECTED_AFFIXES[:1], - item_type=ItemType.Charm, - name="divine_charm_of_restoration", - original_name="DIVINE CHARM OF RESTORATION", - rarity=ItemRarity.Legendary, - ) - - assert read_descr() == expected_item - - -def test_seal_boosted_set_parses_from_tts(): - src.tts.LAST_ITEM = [ - "INIMICAL SEAL OF FURY", - "Legendary Horadric Seal", - "10% Cooldown Reduction [5 - 15]", - "100 Maximum Life [50 - 150]", - "Boosts Berserker's Crucible", - "Right mouse button", - ] - expected_item = Item( - affixes=EXPECTED_AFFIXES[:2], - boosted_set_name="berserkers_crucible", - item_type=ItemType.HoradricSeal, - name="inimical_seal_of_fury", - original_name="INIMICAL SEAL OF FURY", - rarity=ItemRarity.Legendary, - ) - - assert read_descr() == expected_item - - -def test_set_charm_stops_affixes_before_set_bonus_text(): - src.tts.LAST_ITEM = [ - "LINTA OF THE FROZEN SEA", - "Set Charm", - "Lucky Hit: Up to a 40% Chance to Deal +650 Poison Damage", - "+7.0% Potion Healing", - "Breath of the Frozen Sea", - "Phoba of the Frozen Sea", - "Breath of the Frozen Sea (0/5). (2) Set:. Frost Skills deal 70% of their direct damage as bonus Frostbite over 12 seconds.. (3) Set:. You cannot be Chilled or Frozen.. Your Maximum Life and Barrier generation is increased by 20%.. (5) Set:. Frost Skill damage is increased by 200%.. Freezing enemies consumes all Frostbite on them, dealing its remaining damage instantly.", - "Requires Level 70Sorcerer. Only. Unique Equipped. Lord of Hatred Item", - "Right mouse button", - ] - item = read_descr() - - assert item.item_type == ItemType.Charm - assert item.name == "linta_of_the_frozen_sea" - assert item.rarity == ItemRarity.Set - assert item.set_name == "breath_of_the_frozen_sea" - assert [affix.name for affix in item.affixes] == [ - "lucky_hit_up_to_a_chance_to_deal_poison_damage", - "potion_healing", - ] - - -def _expected_affix_count(rarity: ItemRarity) -> int: - if rarity == ItemRarity.Common: - return 0 - if rarity == ItemRarity.Magic: - return 1 - if rarity == ItemRarity.Rare: - return 3 - return 4 From 79c01c9787d743a5a8f0fa64b476346ca52fc7d2 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Tue, 2 Jun 2026 21:35:47 +0200 Subject: [PATCH 12/39] update12 --- src/config/profile_models.py | 39 ++++ src/gui/profile_editor/seal_charm_tab.py | 230 +++++++++++++++++++- src/item/descr/read_descr_tts.py | 67 +++++- src/item/filter.py | 41 +++- src/item/models.py | 19 ++ tests/config/models_test.py | 38 ++++ tests/item/filter/filter_test.py | 160 +++++++++++++- tests/item/read_descr_season_13_tts_test.py | 65 +++++- 8 files changed, 647 insertions(+), 12 deletions(-) diff --git a/src/config/profile_models.py b/src/config/profile_models.py index c9973b37..11bd62ec 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -264,14 +264,53 @@ def normalize_unique_aspect(cls, name: str | None) -> str | None: return correct_name(name) +class BoostedSetFilterModel(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + affix: AffixFilterModel | None = None + required: bool = False + set_name: str = Field(alias="set") + + @field_validator("set_name") + @classmethod + def set_must_exist(cls, name: str) -> str: + normalized_name = _normalize_existing_set_name(name, "set") + if normalized_name is None: + msg = "set must not be empty" + raise ValueError(msg) + return normalized_name + + @model_validator(mode="after") + def required_boosted_affix_must_have_affix(self) -> BoostedSetFilterModel: + if self.required and self.affix is None: + msg = "required boostedSets entries need affix" + raise ValueError(msg) + return self + + class SealFilterModel(SealCharmFilterModel): + boosted_affix: AffixFilterModel | None = Field(default=None, alias="boostedAffix") + boosted_affix_required: bool = Field(default=False, alias="boostedAffixRequired") boosted_set: str | None = Field(default=None, alias="boostedSet") + boosted_sets: list[BoostedSetFilterModel] = Field(default=[], alias="boostedSets") + charm_slots: int = Field(default=0, alias="charmSlots") @field_validator("boosted_set") @classmethod def boosted_set_must_exist(cls, name: str | None) -> str | None: return _normalize_existing_set_name(name, "boostedSet") + @field_validator("charm_slots") + @classmethod + def charm_slots_must_be_positive(cls, v: int) -> int: + return check_greater_than_zero(v) + + @model_validator(mode="after") + def required_boosted_affix_must_have_affix(self) -> SealFilterModel: + if self.boosted_affix_required and (self.boosted_affix is None or self.boosted_set is None): + msg = "boostedAffixRequired needs boostedSet and boostedAffix" + raise ValueError(msg) + return self + DynamicSealCharmFilterModel = RootModel[dict[str, SealCharmFilterModel]] DynamicCharmFilterModel = RootModel[dict[str, CharmFilterModel]] diff --git a/src/gui/profile_editor/seal_charm_tab.py b/src/gui/profile_editor/seal_charm_tab.py index 6370d88a..a9631f45 100644 --- a/src/gui/profile_editor/seal_charm_tab.py +++ b/src/gui/profile_editor/seal_charm_tab.py @@ -1,10 +1,16 @@ -from PyQt6.QtCore import Qt, QTimer +from functools import partial + +from PyQt6.QtCore import QSignalBlocker, Qt, QTimer +from PyQt6.QtGui import QDoubleValidator, QIntValidator from PyQt6.QtWidgets import ( QCheckBox, + QComboBox, + QCompleter, QDialog, QFormLayout, QHBoxLayout, QInputDialog, + QLineEdit, QMessageBox, QPushButton, QScrollArea, @@ -18,6 +24,7 @@ from src.config.profile_models import ( AffixFilterCountModel, AffixFilterModel, + BoostedSetFilterModel, CharmFilterModel, DynamicCharmFilterModel, DynamicSealCharmFilterModel, @@ -27,12 +34,16 @@ ) from src.dataloader import Dataloader from src.gui.models.collapsible_widget import Container -from src.gui.models.dialog import DeleteAffixPool, DeleteItem +from src.gui.models.dialog import DeleteAffixPool, DeleteItem, IgnoreScrollWheelComboBox from src.gui.profile_editor.affixes_tab import AffixPoolWidget from src.item.data.rarity import ItemRarity +from src.scripts import correct_name SEALS_TABNAME = "Seals" CHARMS_TABNAME = "Charms" +AFFIX_VALUE_MODE = "Value" +AFFIX_PERCENT_MODE = "Min %" +BOOSTED_SET_SLOT_COUNT = 2 class SealCharmRuleEditor(QWidget): @@ -69,6 +80,8 @@ def setup_ui(self): rarity_layout.addWidget(checkbox) rarity_layout.addStretch() general_form.addRow("Rarities:", rarity_layout) + if isinstance(self.config, SealFilterModel): + self.add_boosted_set_fields(general_form) self.content_layout.addLayout(general_form) pool_btn_layout = QHBoxLayout() @@ -93,6 +106,98 @@ def setup_ui(self): QTimer.singleShot(100, self.affix_pool_container.expand) + def add_boosted_set_fields(self, form: QFormLayout): + charm_slots_layout = QHBoxLayout() + self.charm_slot_checkboxes = {} + for slots in range(1, 7): + checkbox = QCheckBox(str(slots)) + checkbox.setChecked(self.config.charm_slots == slots) + checkbox.clicked.connect(partial(self.update_charm_slots, slots)) + self.charm_slot_checkboxes[slots] = checkbox + charm_slots_layout.addWidget(checkbox) + charm_slots_layout.addStretch() + form.addRow("Charm Slots:", charm_slots_layout) + + boosted_set_filters = list(self.config.boosted_sets) + if self.config.boosted_set: + boosted_set_filters.append( + BoostedSetFilterModel( + set=self.config.boosted_set, + affix=self.config.boosted_affix, + required=self.config.boosted_affix_required, + ) + ) + self.config.boosted_set = None + self.config.boosted_affix = None + self.config.boosted_affix_required = False + self.config.boosted_sets = boosted_set_filters + + self.boosted_set_combos = [] + self.boosted_affix_combos = [] + self.boosted_affix_required_checkboxes = [] + self.boosted_affix_modes = [] + self.boosted_affix_values = [] + + for index in range(BOOSTED_SET_SLOT_COUNT): + boosted_set_filter = boosted_set_filters[index] if index < len(boosted_set_filters) else None + + boosted_set_combo = IgnoreScrollWheelComboBox() + boosted_set_combo.setEditable(True) + boosted_set_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + boosted_set_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + boosted_set_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains) + boosted_set_combo.addItems(["", *sorted(Dataloader().set_list)]) + if boosted_set_filter: + boosted_set_combo.setCurrentText(boosted_set_filter.set_name) + + boosted_affix_combo = IgnoreScrollWheelComboBox() + boosted_affix_combo.setEditable(True) + boosted_affix_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + boosted_affix_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + boosted_affix_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains) + boosted_affix_combo.addItems(["", *sorted(Dataloader().affix_dict.values())]) + if ( + boosted_set_filter + and boosted_set_filter.affix + and boosted_set_filter.affix.name in Dataloader().affix_dict + ): + boosted_affix_combo.setCurrentText(Dataloader().affix_dict[boosted_set_filter.affix.name]) + + boosted_affix_required = QCheckBox() + boosted_affix_required.setChecked(bool(boosted_set_filter and boosted_set_filter.required)) + + boosted_affix_mode = IgnoreScrollWheelComboBox() + boosted_affix_mode.setFixedSize(100, boosted_affix_mode.sizeHint().height()) + boosted_affix_mode.addItems([AFFIX_VALUE_MODE, AFFIX_PERCENT_MODE]) + if boosted_set_filter and boosted_set_filter.affix and boosted_set_filter.affix.min_percent_of_affix: + boosted_affix_mode.setCurrentText(AFFIX_PERCENT_MODE) + + boosted_affix_value = QLineEdit() + boosted_affix_value.setFixedSize(100, boosted_affix_value.sizeHint().height()) + + self.boosted_set_combos.append(boosted_set_combo) + self.boosted_affix_combos.append(boosted_affix_combo) + self.boosted_affix_required_checkboxes.append(boosted_affix_required) + self.boosted_affix_modes.append(boosted_affix_mode) + self.boosted_affix_values.append(boosted_affix_value) + + form.addRow(f"Boosted Set {index + 1}:", boosted_set_combo) + form.addRow(f"Boosted Affix {index + 1}:", boosted_affix_combo) + form.addRow(f"Require Boosted Affix {index + 1}:", boosted_affix_required) + + boosted_affix_threshold_layout = QHBoxLayout() + boosted_affix_threshold_layout.addWidget(boosted_affix_mode) + boosted_affix_threshold_layout.addWidget(boosted_affix_value) + boosted_affix_threshold_layout.addStretch() + form.addRow(f"Boosted Affix Threshold {index + 1}:", boosted_affix_threshold_layout) + + boosted_set_combo.currentTextChanged.connect(partial(self.update_boosted_set, index)) + boosted_affix_combo.currentTextChanged.connect(partial(self.update_boosted_affix, index)) + boosted_affix_required.clicked.connect(partial(self.update_boosted_affix_required, index)) + boosted_affix_mode.currentTextChanged.connect(partial(self.update_boosted_affix_mode, index)) + boosted_affix_value.textChanged.connect(partial(self.update_boosted_affix_value, index)) + self.refresh_boosted_affix_controls(index) + def init_affix_pool(self): for pool in self.config.affix_pool: self.add_affix_pool_item(pool) @@ -140,6 +245,127 @@ def update_min_greater_affix(self): def update_rarities(self): self.config.rarities = [rarity for rarity, checkbox in self.rarity_checkboxes.items() if checkbox.isChecked()] + def update_charm_slots(self, slots: int, checked: bool): + if not checked: + if self.config.charm_slots == slots: + self.config.charm_slots = 0 + return + + self.config.charm_slots = slots + for other_slots, checkbox in self.charm_slot_checkboxes.items(): + if other_slots == slots: + continue + with QSignalBlocker(checkbox): + checkbox.setChecked(False) + + def update_boosted_set(self, index: int, _current_text=None): + self.sync_boosted_sets_from_controls() + self.refresh_boosted_affix_controls(index) + + def update_boosted_affix(self, index: int, _current_text=None): + self.sync_boosted_sets_from_controls() + self.refresh_boosted_affix_controls(index) + + def update_boosted_affix_required(self, index: int, checked: bool): + self.boosted_affix_required_checkboxes[index].setChecked(checked) + self.sync_boosted_sets_from_controls() + + def update_boosted_affix_mode(self, index: int, _current_text=None): + self.sync_boosted_sets_from_controls() + self.refresh_boosted_affix_controls(index) + + def update_boosted_affix_value(self, index: int, value): + if self.boosted_affix_modes[index].currentText() == AFFIX_PERCENT_MODE: + try: + percent = int(value) if value else 0 + except ValueError: + return + if not 0 <= percent <= 100: + QMessageBox.warning(self, "Warning", "Min % must be between 0 and 100.") + self.refresh_boosted_affix_controls(index) + return + + self.sync_boosted_sets_from_controls() + + def sync_boosted_sets_from_controls(self): + boosted_sets = [] + for index in range(BOOSTED_SET_SLOT_COUNT): + set_name = correct_name(self.boosted_set_combos[index].currentText()) + if not set_name or set_name not in Dataloader().set_list: + continue + + boosted_sets.append( + BoostedSetFilterModel( + set=set_name, + affix=self.boosted_affix_from_controls(index), + required=self.boosted_affix_required_checkboxes[index].isChecked() + and self.boosted_affix_from_controls(index) is not None, + ) + ) + + self.config.boosted_set = None + self.config.boosted_affix = None + self.config.boosted_affix_required = False + self.config.boosted_sets = boosted_sets + + def boosted_affix_from_controls(self, index: int) -> AffixFilterModel | None: + current_text = self.boosted_affix_combos[index].currentText() + if not current_text.strip(): + return None + + reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()} + affix_name = reverse_dict.get(current_text) or correct_name(current_text) + if affix_name not in Dataloader().affix_dict: + return None + + affix = AffixFilterModel(name=affix_name, value=None) + value = self.boosted_affix_values[index].text() + if self.boosted_affix_modes[index].currentText() == AFFIX_PERCENT_MODE: + try: + affix.min_percent_of_affix = int(value) if value else 0 + except ValueError: + return affix + affix.value = None + return affix + + try: + affix.value = float(value) if value else None + except ValueError: + return affix + affix.min_percent_of_affix = 0 + return affix + + def refresh_boosted_affix_controls(self, index: int): + set_name = correct_name(self.boosted_set_combos[index].currentText()) + affix = self.boosted_affix_from_controls(index) + affix_selected = affix is not None + can_require_affix = bool(set_name and set_name in Dataloader().set_list and affix_selected) + + if not can_require_affix: + with QSignalBlocker(self.boosted_affix_required_checkboxes[index]): + self.boosted_affix_required_checkboxes[index].setChecked(False) + + self.boosted_affix_required_checkboxes[index].setEnabled(can_require_affix) + self.boosted_affix_modes[index].setEnabled(affix_selected) + self.boosted_affix_values[index].setEnabled(affix_selected) + + if not affix_selected: + with QSignalBlocker(self.boosted_affix_values[index]): + self.boosted_affix_values[index].clear() + return + + if self.boosted_affix_modes[index].currentText() == AFFIX_PERCENT_MODE: + self.boosted_affix_values[index].setPlaceholderText("Percent (0-100)") + self.boosted_affix_values[index].setValidator(QIntValidator(0, 100, self.boosted_affix_values[index])) + display_value = "" if affix.min_percent_of_affix == 0 else str(affix.min_percent_of_affix) + else: + self.boosted_affix_values[index].setPlaceholderText("Value (optional)") + self.boosted_affix_values[index].setValidator(QDoubleValidator(self.boosted_affix_values[index])) + display_value = "" if affix.value is None else str(affix.value) + + with QSignalBlocker(self.boosted_affix_values[index]): + self.boosted_affix_values[index].setText(display_value) + class SealCharmTab(QWidget): def __init__( diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index 16ea9c42..26cc95c6 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -26,7 +26,7 @@ from src.item.descr import keep_letters_and_spaces from src.item.descr.text import clean_str, find_number from src.item.descr.texture import find_affix_bullets, find_aspect_bullet, find_seperator_short, find_seperators_long -from src.item.models import Item +from src.item.models import BoostedSet, Item from src.scripts import correct_name from src.tts import ItemIdentifiers from src.utils.window import screenshot @@ -53,6 +53,7 @@ _FOR_SECONDS_RE = re.compile(r"for (?P\d+(?:\.\d+)?) Seconds") _REPLACE_COMPARE_RE = re.compile(r"\(.*\)") +_CHARM_SLOTS_RE = re.compile(r"unlocks (?P\d+) charm slots", re.IGNORECASE) _AFFIX_REPLACEMENTS = ["%", "+", ",", "[+]", "[x]", "per 5 Seconds"] _AFFIX_STOP_MARKERS = ( @@ -123,17 +124,18 @@ def _get_seal_charm_affix_count(tts_section: list[str], start: int) -> int: def _is_seal_charm_affix_line(line: str) -> bool: - return clean_str(line) in Dataloader().affix_dict.values() + return _CHARM_SLOTS_RE.search(line) is not None or clean_str(line) in Dataloader().affix_dict.values() def _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item: starting_index = _get_affix_starting_location_from_tts_section(tts_section, item) inherent_num, affixes_num = _get_affix_counts(tts_section, item, starting_index) affixes = _get_affixes_from_tts_section(tts_section, starting_index, inherent_num + affixes_num) + boosted_sets = [] if item.item_type == ItemType.Charm: item.set_name = _get_set_from_tts_section(tts_section, starting_index, len(affixes)) elif item.item_type == ItemType.HoradricSeal: - item.boosted_set_name = _get_set_from_tts_section(tts_section, starting_index, len(affixes)) + boosted_sets = _get_boosted_sets_from_tts_section(tts_section, starting_index, len(affixes)) aspect_text = _get_aspect_from_tts_section(tts_section, item, starting_index, len(affixes)) for i, affix_text in enumerate(affixes): if i < inherent_num: @@ -141,9 +143,15 @@ def _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item: affix.type = AffixType.inherent item.inherent.append(affix) elif i < inherent_num + affixes_num: + if charm_slots_match := _CHARM_SLOTS_RE.search(affix_text): + item.charm_slots = int(charm_slots_match.group("slots")) + continue affix = _get_affix_from_text(affix_text) item.affixes.append(affix) + if item.item_type == ItemType.HoradricSeal: + _add_boosted_sets_to_item(item, boosted_sets) + if aspect_text: if item.rarity == ItemRarity.Mythic: item.aspect = Aspect(name=item.name, text=aspect_text, value=find_number(aspect_text)) @@ -164,6 +172,11 @@ def _add_affixes_from_tts_mixed( starting_index = _get_affix_starting_location_from_tts_section(tts_section, item) inherent_num, affixes_num = _get_affix_counts(tts_section, item, starting_index) affixes = _get_affixes_from_tts_section(tts_section, starting_index, inherent_num + affixes_num) + boosted_sets = [] + if item.item_type == ItemType.Charm: + item.set_name = _get_set_from_tts_section(tts_section, starting_index, len(affixes)) + elif item.item_type == ItemType.HoradricSeal: + boosted_sets = _get_boosted_sets_from_tts_section(tts_section, starting_index, len(affixes)) aspect_text = _get_aspect_from_tts_section(tts_section, item, starting_index, len(affixes)) # With advanced item compare on we'll actually find more bullets than we need, so we don't rely on them for @@ -178,6 +191,9 @@ def _add_affixes_from_tts_mixed( affix.loc = affix_bullets[i].center item.inherent.append(affix) elif i < inherent_num + affixes_num: + if charm_slots_match := _CHARM_SLOTS_RE.search(affix_text): + item.charm_slots = int(charm_slots_match.group("slots")) + continue affix = _get_affix_from_text(affix_text) affix.loc = affix_bullets[i].center if affix_bullets[i].name.startswith("greater_affix"): @@ -188,6 +204,9 @@ def _add_affixes_from_tts_mixed( affix.type = AffixType.normal item.affixes.append(affix) + if item.item_type == ItemType.HoradricSeal: + _add_boosted_sets_to_item(item, boosted_sets) + if aspect_text: if item.rarity == ItemRarity.Mythic: item.aspect = Aspect(name=item.name, text=aspect_text, value=find_number(aspect_text)) @@ -379,11 +398,49 @@ def _get_aspect_from_tts_section(tts_section: list[str], item: Item, start: int, def _get_set_from_tts_section(tts_section: list[str], start: int, num_affixes: int) -> str | None: + return next(iter(_get_sets_from_tts_section(tts_section, start, num_affixes)), None) + + +def _get_sets_from_tts_section(tts_section: list[str], start: int, num_affixes: int) -> list[str]: + set_names = [] for line in tts_section[start + num_affixes :]: set_name = _get_set_name_from_line(line) if set_name in Dataloader().set_list: - return set_name - return None + set_names.append(set_name) + return set_names + + +def _get_boosted_sets_from_tts_section(tts_section: list[str], start: int, num_affixes: int) -> list[BoostedSet]: + boosted_sets = [] + index = start + num_affixes + while index < len(tts_section): + set_name = _get_set_name_from_line(tts_section[index]) + if set_name not in Dataloader().set_list: + index += 1 + continue + + affix = None + next_index = index + 1 + if next_index < len(tts_section) and _is_boosted_set_affix_line(tts_section[next_index]): + affix = _get_affix_from_text(tts_section[next_index]) + affix.type = AffixType.normal + index = next_index + boosted_sets.append(BoostedSet(name=set_name, affix=affix)) + index += 1 + return boosted_sets + + +def _is_boosted_set_affix_line(line: str) -> bool: + return ( + not line.lower().startswith(_AFFIX_STOP_MARKERS) + and _get_set_name_from_line(line) is None + and clean_str(line) in Dataloader().affix_dict.values() + ) + + +def _add_boosted_sets_to_item(item: Item, boosted_sets: list[BoostedSet]) -> None: + item.boosted_sets = boosted_sets + item.boosted_set_name = item.boosted_sets[0].name if item.boosted_sets else None def _get_set_name_from_line(line: str) -> str | None: diff --git a/src/item/filter.py b/src/item/filter.py index 59a5239a..57c8bc81 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -15,6 +15,7 @@ AffixAspectFilterModel, AffixFilterCountModel, AffixFilterModel, + BoostedSetFilterModel, CharmFilterModel, DynamicCharmFilterModel, DynamicItemFilterModel, @@ -303,9 +304,43 @@ def _match_charm_filter(item: Item, filter_spec: CharmFilterModel) -> bool: filter_spec.unique_aspect is not None and filter_spec.unique_aspect == item.name ) - @staticmethod - def _match_seal_filter(item: Item, filter_spec: SealFilterModel) -> bool: - return filter_spec.boosted_set is None or filter_spec.boosted_set == item.boosted_set_name + def _match_seal_filter(self, item: Item, filter_spec: SealFilterModel) -> bool: + if filter_spec.charm_slots and filter_spec.charm_slots != item.charm_slots: + return False + + boosted_set_filters = list(filter_spec.boosted_sets) + if filter_spec.boosted_set is not None: + boosted_set_filters.append( + BoostedSetFilterModel( + set=filter_spec.boosted_set, + affix=filter_spec.boosted_affix, + required=filter_spec.boosted_affix_required, + ) + ) + + if not boosted_set_filters: + return True + + return all( + self._match_boosted_set_filter(item, boosted_set_filter) for boosted_set_filter in boosted_set_filters + ) + + def _match_boosted_set_filter(self, item: Item, filter_spec: BoostedSetFilterModel) -> bool: + if not item.boosted_sets: + return filter_spec.set_name == item.boosted_set_name and not filter_spec.required + + for boosted_set in item.boosted_sets: + if boosted_set.name != filter_spec.set_name: + continue + if not filter_spec.required: + return True + if ( + boosted_set.affix is not None + and filter_spec.affix is not None + and self._match_item_aspect_or_affix(filter_spec.affix, boosted_set.affix) + ): + return True + return False def _check_tribute(self, item: Item) -> FilterResult: res = FilterResult(keep=False, matched=[]) diff --git a/src/item/models.py b/src/item/models.py index c7700296..ae6612f5 100644 --- a/src/item/models.py +++ b/src/item/models.py @@ -13,6 +13,14 @@ LOGGER = logging.getLogger(__name__) +@dataclass +class BoostedSet: + __hash__ = None + + affix: Affix | None = None + name: str = "" + + @dataclass class Item: __hash__ = None @@ -20,6 +28,8 @@ class Item: affixes: list[Affix] = field(default_factory=list) aspect: Aspect | None = None boosted_set_name: str | None = None + boosted_sets: list[BoostedSet] = field(default_factory=list) + charm_slots: int | None = None codex_upgrade: bool = False cosmetic_upgrade: bool = False inherent: list[Affix] = field(default_factory=list) @@ -44,6 +54,10 @@ def __eq__(self, other): res = False if self.boosted_set_name != other.boosted_set_name: res = False + if self.boosted_sets != other.boosted_sets: + res = False + if self.charm_slots != other.charm_slots: + res = False if self.codex_upgrade != other.codex_upgrade: # LOGGER.debug("Codex upgrade not the same") res = False @@ -81,6 +95,11 @@ def default(self, o): "affixes": [affix.__dict__ for affix in o.affixes], "aspect": o.aspect.__dict__ if o.aspect else None, "boosted_set_name": o.boosted_set_name or None, + "boosted_sets": [ + {"affix": boosted_set.affix.__dict__ if boosted_set.affix else None, "name": boosted_set.name} + for boosted_set in o.boosted_sets + ], + "charm_slots": o.charm_slots, "codex_upgrade": o.codex_upgrade, "cosmetic_upgrade": o.cosmetic_upgrade, "inherent": [affix.__dict__ for affix in o.inherent], diff --git a/tests/config/models_test.py b/tests/config/models_test.py index 8dc7e25d..b341d909 100644 --- a/tests/config/models_test.py +++ b/tests/config/models_test.py @@ -642,6 +642,44 @@ def test_boosted_set_alias_is_validated_and_normalized(self) -> None: assert model.boosted_set == "berserkers_crucible" + def test_required_boosted_affix_is_validated(self) -> None: + model = SealFilterModel( + boostedSet="Berserker's Crucible", boostedAffix="maximum_fury", boostedAffixRequired=True + ) + + assert model.boosted_affix.name == "maximum_fury" + assert model.boosted_affix_required + + def test_boosted_sets_are_validated_and_normalized(self) -> None: + model = SealFilterModel( + boostedSets=[ + {"set": "Berserker's Crucible", "affix": "maximum_fury", "required": True}, + {"set": "Cathan's Dauntless Faith", "affix": "cooldown_reduction"}, + ] + ) + + assert model.boosted_sets[0].set_name == "berserkers_crucible" + assert model.boosted_sets[0].affix.name == "maximum_fury" + assert model.boosted_sets[0].required + assert model.boosted_sets[1].set_name == "cathans_dauntless_faith" + assert model.boosted_sets[1].affix.name == "cooldown_reduction" + assert not model.boosted_sets[1].required + + def test_charm_slots_alias_is_validated(self) -> None: + model = SealFilterModel(charmSlots=6) + + assert model.charm_slots == 6 + + def test_required_boosted_affix_needs_set_and_affix(self) -> None: + with pytest.raises(ValidationError, match="boostedAffixRequired needs boostedSet and boostedAffix"): + SealFilterModel(boostedAffixRequired=True) + + with pytest.raises(ValidationError, match="required boostedSets entries need affix"): + SealFilterModel(boostedSets=[{"set": "Berserker's Crucible", "required": True}]) + + with pytest.raises(ValidationError, match="must be greater than zero"): + SealFilterModel(charmSlots=-1) + def test_invalid_boosted_set_fails(self) -> None: with pytest.raises(ValidationError, match="boostedSet invalid_set does not exist"): SealFilterModel(boostedSet="invalid set") diff --git a/tests/item/filter/filter_test.py b/tests/item/filter/filter_test.py index 3cbd28a9..452b2ab8 100644 --- a/tests/item/filter/filter_test.py +++ b/tests/item/filter/filter_test.py @@ -18,7 +18,7 @@ from src.item.data.item_type import ItemType from src.item.data.rarity import ItemRarity from src.item.filter import Filter, FilterResult -from src.item.models import Item +from src.item.models import BoostedSet, Item from tests.item.filter.data import filters from tests.item.filter.data.affixes import affixes from tests.item.filter.data.aspects import aspects @@ -221,6 +221,164 @@ def test_seal_filter_matches_boosted_set(mocker: MockerFixture): assert match.matched_affixes == item.affixes +def test_seal_filter_matches_any_boosted_set(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.HoradricSeal, + name="unimportant_seal_name", + rarity=ItemRarity.Legendary, + boosted_sets=[ + BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction")), + BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury")), + ], + affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_fury")], + ) + seal_filter = SealFilterModel(boostedSet="Berserker's Crucible") + test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} + + match = test_filter.should_keep(item).matched[0] + + assert match.profile == "seal_charm.Seals.wanted" + + +def test_seal_filter_matches_charm_slots_and_boosted_set(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.HoradricSeal, + name="unimportant_seal_name", + rarity=ItemRarity.Legendary, + charm_slots=6, + boosted_sets=[BoostedSet(name="habacalvas_cauldron", affix=Affix(name="life_on_hit", value=255.0))], + affixes=[Affix(name="resource_cost_reduction", value=7.5)], + ) + seal_filter = SealFilterModel(charmSlots=6, boostedSets=[{"set": "Habacalva's Cauldron"}]) + test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} + + match = test_filter.should_keep(item).matched[0] + + assert match.profile == "seal_charm.Seals.wanted" + + +def test_seal_filter_rejects_wrong_charm_slots(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.HoradricSeal, + name="unimportant_seal_name", + rarity=ItemRarity.Legendary, + charm_slots=5, + boosted_sets=[BoostedSet(name="habacalvas_cauldron", affix=Affix(name="life_on_hit", value=255.0))], + affixes=[Affix(name="resource_cost_reduction", value=7.5)], + ) + seal_filter = SealFilterModel(charmSlots=6, boostedSets=[{"set": "Habacalva's Cauldron"}]) + test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wrong": seal_filter})]} + + assert test_filter.should_keep(item).matched == [] + + +def test_seal_filter_matches_required_boosted_affix(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.HoradricSeal, + name="unimportant_seal_name", + rarity=ItemRarity.Legendary, + boosted_sets=[ + BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction")), + BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury")), + ], + affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_fury")], + ) + seal_filter = SealFilterModel( + boostedSet="Berserker's Crucible", boostedAffix="maximum_fury", boostedAffixRequired=True + ) + test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} + + match = test_filter.should_keep(item).matched[0] + + assert match.profile == "seal_charm.Seals.wanted" + + +def test_seal_filter_matches_two_boosted_sets_with_required_affixes(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.HoradricSeal, + name="unimportant_seal_name", + rarity=ItemRarity.Legendary, + boosted_sets=[ + BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction")), + BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury")), + ], + affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_fury")], + ) + seal_filter = SealFilterModel( + boostedSets=[ + {"set": "Berserker's Crucible", "affix": "maximum_fury", "required": True}, + {"set": "Cathan's Dauntless Faith", "affix": "cooldown_reduction", "required": True}, + ] + ) + test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} + + match = test_filter.should_keep(item).matched[0] + + assert match.profile == "seal_charm.Seals.wanted" + + +def test_seal_filter_rejects_missing_second_boosted_set(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.HoradricSeal, + name="unimportant_seal_name", + rarity=ItemRarity.Legendary, + boosted_sets=[BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury"))], + affixes=[Affix(name="maximum_fury")], + ) + seal_filter = SealFilterModel( + boostedSets=[ + {"set": "Berserker's Crucible", "affix": "maximum_fury", "required": True}, + {"set": "Cathan's Dauntless Faith", "affix": "cooldown_reduction", "required": True}, + ] + ) + test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wrong": seal_filter})]} + + assert test_filter.should_keep(item).matched == [] + + +def test_seal_filter_ignores_boosted_affix_when_not_required(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.HoradricSeal, + name="unimportant_seal_name", + rarity=ItemRarity.Legendary, + boosted_sets=[BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury"))], + affixes=[Affix(name="maximum_fury")], + ) + seal_filter = SealFilterModel(boostedSet="Berserker's Crucible", boostedAffix="cooldown_reduction") + test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} + + match = test_filter.should_keep(item).matched[0] + + assert match.profile == "seal_charm.Seals.wanted" + + +def test_seal_filter_rejects_wrong_required_boosted_affix(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.HoradricSeal, + name="unimportant_seal_name", + rarity=ItemRarity.Legendary, + boosted_sets=[ + BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction")), + BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury")), + ], + affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_fury")], + ) + seal_filter = SealFilterModel( + boostedSet="Berserker's Crucible", boostedAffix="cooldown_reduction", boostedAffixRequired=True + ) + test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wrong": seal_filter})]} + + assert test_filter.should_keep(item).matched == [] + + def test_seal_filter_rejects_wrong_boosted_set(mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) item = Item( diff --git a/tests/item/read_descr_season_13_tts_test.py b/tests/item/read_descr_season_13_tts_test.py index ea26611c..9dc32d0f 100644 --- a/tests/item/read_descr_season_13_tts_test.py +++ b/tests/item/read_descr_season_13_tts_test.py @@ -6,7 +6,7 @@ from src.item.data.item_type import ItemType from src.item.data.rarity import ItemRarity from src.item.descr.read_descr_tts import read_descr -from src.item.models import Item +from src.item.models import BoostedSet, Item items = [ ( @@ -408,6 +408,7 @@ ], aspect=None, boosted_set_name="berserkers_crucible", + boosted_sets=[BoostedSet(affix=None, name="berserkers_crucible")], codex_upgrade=False, cosmetic_upgrade=False, inherent=[], @@ -420,6 +421,68 @@ seasonal_attribute=None, ), ), + ( + [ + "EFFICIENT HORADRIC SEAL OF FERVOR", + "Legendary Horadric Seal", + "Unlocks 5 Charm Slots", + "7.5% Resource Cost Reduction [7.5]", + "Habacalva's Cauldron:", + "+255 Life On Hit", + "Tal Rasha's Threefold Way:", + "+2 to Ball Lightning", + "Right mouse button", + ], + Item( + affixes=[ + Affix( + max_value=7.5, + min_value=7.5, + name="resource_cost_reduction", + text="7.5% Resource Cost Reduction [7.5]", + type=AffixType.normal, + value=7.5, + ) + ], + aspect=None, + boosted_set_name="habacalvas_cauldron", + boosted_sets=[ + BoostedSet( + affix=Affix( + max_value=None, + min_value=None, + name="life_on_hit", + text="+255 Life On Hit", + type=AffixType.normal, + value=255.0, + ), + name="habacalvas_cauldron", + ), + BoostedSet( + affix=Affix( + max_value=None, + min_value=None, + name="to_ball_lightning", + text="+2 to Ball Lightning", + type=AffixType.normal, + value=2.0, + ), + name="tal_rashas_threefold_way", + ), + ], + charm_slots=5, + codex_upgrade=False, + cosmetic_upgrade=False, + inherent=[], + is_in_shop=False, + item_type=ItemType.HoradricSeal, + name="efficient_horadric_seal_of_fervor", + original_name="EFFICIENT HORADRIC SEAL OF FERVOR", + power=None, + rarity=ItemRarity.Legendary, + seasonal_attribute=None, + ), + ), ( [ "LINTA OF THE FROZEN SEA", From 833fd270fb752daa0921a6fc81ba5c2c77b1b2b2 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Wed, 3 Jun 2026 17:38:37 +0200 Subject: [PATCH 13/39] update13 --- .../item_descr/boosted_bullet_point.png | Bin 0 -> 945 bytes .../boosted_bullet_point_medium.png | Bin 0 -> 1084 bytes src/config/profile_models.py | 7 +- src/gui/profile_editor/seal_charm_tab.py | 4 +- src/item/descr/read_descr_tts.py | 73 ++++++++- src/item/descr/texture.py | 11 +- src/item/filter.py | 58 ++++++- src/item/models.py | 3 + tests/config/models_test.py | 8 +- tests/item/filter/filter_test.py | 49 +++++- tests/item/read_descr_tts_test.py | 147 +++++++++++++++++- 11 files changed, 338 insertions(+), 22 deletions(-) create mode 100644 assets/templates/item_descr/boosted_bullet_point.png create mode 100644 assets/templates/item_descr/boosted_bullet_point_medium.png diff --git a/assets/templates/item_descr/boosted_bullet_point.png b/assets/templates/item_descr/boosted_bullet_point.png new file mode 100644 index 0000000000000000000000000000000000000000..1a7341389459e6bd33290056c1236f730484e1ea GIT binary patch literal 945 zcmV;i15W&jP)*=I@7PW6%y{QIca*S(J_K#lCi-yv2x-wKS1m#)h=SU*bH+%BvQZ3z zRza&iFj}`a(s8UL)X^Y_3e$A55=_%(?*E+keH*Oj;hOeJxPj&flu&Z54o0r3bp5bK z;_N);7ZThl51_w*yleA+pXr*1RRKj2=*UEql%CQ0N0+nl8_MGpt-!c6N@>R)65PY2ZvhC$qBgQ z<1<*uxZXpI(Ty&I)n+gt#+YKv>RL)8*sG5*&_6u`uJ0~?VO$Q(BGLd7Qk zcHDO2k{rKK1xi*8u%cyhf~PN$yEivCc5!G{Qi_C`QDVxyh1a)xRya^KaNx_dL>=y$ zLa1}SXNz3AW_)hEf5Q+Dp3oR$q80!WMRP8?Z#%N#@Eor6`hQw~;1pw3xq2~8jEWu{a5zv@pBnYC5d`Am zHGFaw99h+=>w4cr&W0mDavR)y=DdA#IXr!@ zukTWkZuap0$^7VQY`q%|!FylhpF)=afSrLB00(GhuwD1r{T%>;{|P`q0nA^=;qRa> zQ6vasAcs30E5%Gxqv2|cy~PmXnx;L_mZ9b76I{Jj=C1gK8vq$Z+!<2}L=gWMe4E{0 Tq`WCU00000NkvXXu0mjfB743t literal 0 HcmV?d00001 diff --git a/assets/templates/item_descr/boosted_bullet_point_medium.png b/assets/templates/item_descr/boosted_bullet_point_medium.png new file mode 100644 index 0000000000000000000000000000000000000000..e95383b8b8e5ee931cbc52dfc82333a571923d0f GIT binary patch literal 1084 zcmV-C1jGA@P)lvguz>r|(FuFNG(p4p-Tufqw zSuU9X2_YN^0(Wp}yG2B}f#wL5P;#RVMh;ZEdQc;AeiD<@3GNh$A&6xINSGlAxU}6( z_$(6YRMkfZW$OwBYxoQRM*tLa&RmN}egoY?2r&q}0N}EAw*i!(MT^_mM#8mIFr6)h zxS%Qy7RZIkvZf#Nk_fTyI2ob{O^X9u+Sd(-n-#S;x1y*V{iQkmyEAmT+Dq%o&_W*g zHn|mp^;CHEF;g=f}C?18;K6RdDlL>kVt{R9Hn#u(=4|}jatg~Ran(0 zf!KQ-F-8&`Zg&?gj||#V_a(yroN7KDuR=%^sFzq*-l$n|2MJSsVU^$869NDE4?BEO zs=ABAy{)E{u@N{B2@&i$in@xB8h~p@T}em?ZqQub*@l@M%xPx(PoS%=IoQ{$&CKWs z-0}H2OlREgAjarM7s47d7!YGjF=llwr4IJS6Abh(&f!LrIM~x_*Y@%T*>@6=29S{A zT+wQFY+%WbIj1J%#N{ebvTA@8Eu$kmJHf9fgah4+pI)x(hH&7dq!bA=qr{Xu({F9@ ztZ<;JvG1ESOC9c-LTGZcbE8b$vV-T9{jEjMt_*AXxc`*K7!$PskSLmS(cN2+4Tr{Y zz0)NmESsUKVw3f?4V;=OpPX|J_Ou4Ne8+l!|D*_A1fUf6itLo~`oq3>9`>F@R~5(# zn$?_SQQNhNKV2_}F9r^@drH$8+XOZIa21#S!j;=dDH>FyOY?Mg#1I0V5^_@#{h>iV zc@O7T_;TE?P6hUNFLBGGBLbKMmB^lBUZom@xx-M9V-PW@qsisnZI~(e&)#FGQ)FLv zi{vx+a_vw<;7%uge1@^AT)mhkMnwk>I2(=@wB zayA_K6=!cqjADvereY`*2F`+;4=>Oc*P>2%`zu5V4sxK~4T!1*Yj$l$qd0I1ghUaw z^2+)&w1n>+bAbbe|(2Ix``}Wee=Vw)8DRm*D198NcqA?*L>Fac4{^5J5b~z@grj zBdMFak^+Yi5<)3O&D}M`if~fhNX%i75Td}X5cn7FI5;OrKbn&O0000 str | None: @field_validator("charm_slots") @classmethod - def charm_slots_must_be_positive(cls, v: int) -> int: - return check_greater_than_zero(v) + def charm_slots_in_valid_range(cls, v: int) -> int: + if v != 0 and not (3 <= v <= 6): + msg = "charm_slots must be between 3 and 6" + raise ValueError(msg) + return v @model_validator(mode="after") def required_boosted_affix_must_have_affix(self) -> SealFilterModel: diff --git a/src/gui/profile_editor/seal_charm_tab.py b/src/gui/profile_editor/seal_charm_tab.py index a9631f45..90fd640e 100644 --- a/src/gui/profile_editor/seal_charm_tab.py +++ b/src/gui/profile_editor/seal_charm_tab.py @@ -109,14 +109,14 @@ def setup_ui(self): def add_boosted_set_fields(self, form: QFormLayout): charm_slots_layout = QHBoxLayout() self.charm_slot_checkboxes = {} - for slots in range(1, 7): + for slots in range(3, 7): checkbox = QCheckBox(str(slots)) checkbox.setChecked(self.config.charm_slots == slots) checkbox.clicked.connect(partial(self.update_charm_slots, slots)) self.charm_slot_checkboxes[slots] = checkbox charm_slots_layout.addWidget(checkbox) charm_slots_layout.addStretch() - form.addRow("Charm Slots:", charm_slots_layout) + form.addRow("Min Charm Slots:", charm_slots_layout) boosted_set_filters = list(self.config.boosted_sets) if self.config.boosted_set: diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index 26cc95c6..1167205a 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -7,6 +7,7 @@ import src.tts from src import TP +from src.config.ui import ResManager from src.dataloader import Dataloader from src.item.data.affix import Affix, AffixType from src.item.data.aspect import Aspect @@ -193,6 +194,8 @@ def _add_affixes_from_tts_mixed( elif i < inherent_num + affixes_num: if charm_slots_match := _CHARM_SLOTS_RE.search(affix_text): item.charm_slots = int(charm_slots_match.group("slots")) + if i < len(affix_bullets): + item.charm_slots_loc = affix_bullets[i].center continue affix = _get_affix_from_text(affix_text) affix.loc = affix_bullets[i].center @@ -204,8 +207,49 @@ def _add_affixes_from_tts_mixed( affix.type = AffixType.normal item.affixes.append(affix) + extra_idx = len(affixes) + last_known_bullet_loc = ( + affix_bullets[min(len(affixes), len(affix_bullets)) - 1].center if affixes and affix_bullets else None + ) + line_height = ResManager().offsets.item_descr_line_height if item.item_type == ItemType.HoradricSeal: _add_boosted_sets_to_item(item, boosted_sets) + previous_boosted_set_loc = None + for boosted_set in item.boosted_sets: + if previous_boosted_set_loc is not None: + min_next_boosted_set_y = previous_boosted_set_loc[1] + int(line_height * 1.2) + elif last_known_bullet_loc is not None: + min_next_boosted_set_y = last_known_bullet_loc[1] + line_height // 2 + else: + min_next_boosted_set_y = 0 + while extra_idx < len(affix_bullets) and ( + not affix_bullets[extra_idx].name.startswith("boosted_bullet_point") + or affix_bullets[extra_idx].center[1] < min_next_boosted_set_y + ): + extra_idx += 1 + if extra_idx < len(affix_bullets): + boosted_set.loc = affix_bullets[extra_idx].center + last_known_bullet_loc = boosted_set.loc + previous_boosted_set_loc = boosted_set.loc + extra_idx += 1 + elif previous_boosted_set_loc is not None: + boosted_set.loc = (previous_boosted_set_loc[0], previous_boosted_set_loc[1] + line_height * 2) + last_known_bullet_loc = boosted_set.loc + previous_boosted_set_loc = boosted_set.loc + elif last_known_bullet_loc is not None: + last_known_bullet_loc = (last_known_bullet_loc[0], last_known_bullet_loc[1] + line_height) + boosted_set.loc = last_known_bullet_loc + previous_boosted_set_loc = boosted_set.loc + if boosted_set.affix is not None and boosted_set.loc is not None: + last_known_bullet_loc = (boosted_set.loc[0], boosted_set.loc[1] + line_height) + elif item.item_type == ItemType.Charm and item.set_name: + if extra_idx < len(affix_bullets): + item.set_name_loc = affix_bullets[extra_idx].center + last_known_bullet_loc = item.set_name_loc + extra_idx += 1 + elif last_known_bullet_loc is not None: + last_known_bullet_loc = (last_known_bullet_loc[0], last_known_bullet_loc[1] + line_height) + item.set_name_loc = last_known_bullet_loc if aspect_text: if item.rarity == ItemRarity.Mythic: @@ -214,8 +258,12 @@ def _add_affixes_from_tts_mixed( item.aspect = _get_aspect_from_text(aspect_text, item.name) else: item.aspect = _get_aspect_from_name(aspect_text, item.name) - if item.aspect and aspect_bullet: - item.aspect.loc = aspect_bullet.center + if item.aspect: + if aspect_bullet: + item.aspect.loc = aspect_bullet.center + elif is_seal_or_charm(item.item_type) and extra_idx < len(affix_bullets): + item.aspect.loc = affix_bullets[extra_idx].center + extra_idx += 1 return item @@ -560,8 +608,6 @@ def read_descr_mixed(img_item_descr: np.ndarray) -> Item | None: return None if (item := _create_base_item_from_tts(tts_section)) is None: return None - if is_seal_or_charm(item.item_type): - return _add_affixes_from_tts(tts_section, item) if any([ is_consumable(item.item_type), is_non_sigil_mapping(item.item_type), @@ -570,13 +616,28 @@ def read_descr_mixed(img_item_descr: np.ndarray) -> Item | None: item.item_type in [ItemType.Material, ItemType.Tribute], ]): return item - if all([not is_armor(item.item_type), not is_jewelry(item.item_type), not is_weapon(item.item_type)]): + if all([ + not is_armor(item.item_type), + not is_jewelry(item.item_type), + not is_weapon(item.item_type), + not is_seal_or_charm(item.item_type), + ]): return None if (sep_short_match := find_seperator_short(img_item_descr)) is None: LOGGER.warning("Could not detect item_seperator_short.") screenshot("failed_seperator_short", img=img_item_descr) + if is_seal_or_charm(item.item_type): + return _add_affixes_from_tts(tts_section, item) return None + + affix_bullets = find_affix_bullets( + img_item_descr, sep_short_match, is_seal_or_charm=is_seal_or_charm(item.item_type) + ) + + if is_seal_or_charm(item.item_type): + return _add_affixes_from_tts_mixed(tts_section, item, affix_bullets, img_item_descr, aspect_bullet=None) + futures = { "sep_long": TP.submit(find_seperators_long, img_item_descr, sep_short_match), "aspect_bullet": ( @@ -586,8 +647,6 @@ def read_descr_mixed(img_item_descr: np.ndarray) -> Item | None: ), } - affix_bullets = find_affix_bullets(img_item_descr, sep_short_match) - if item.rarity == ItemRarity.Unique and item.name not in Dataloader().aspect_unique_dict: msg = ( f"Unrecognized unique {item.name}. This most likely means the name of it reported " diff --git a/src/item/descr/texture.py b/src/item/descr/texture.py index 3c35d1f0..b63c380b 100644 --- a/src/item/descr/texture.py +++ b/src/item/descr/texture.py @@ -84,7 +84,9 @@ def _find_bullets( return sorted(filtered_matches, key=lambda match: match.center[1]) -def find_affix_bullets(img_item_descr: np.ndarray, sep_short_match: TemplateMatch) -> list[TemplateMatch]: +def find_affix_bullets( + img_item_descr: np.ndarray, sep_short_match: TemplateMatch, is_seal_or_charm: bool = False +) -> list[TemplateMatch]: affix_icons = [f"affix_bullet_point_{x}" for x in range(1, 3)] rerolled_icons = [f"rerolled_bullet_point_{x}" for x in range(1, 3)] tempered_icons = [f"tempered_affix_bullet_point_{x}" for x in range(1, 7)] @@ -99,6 +101,13 @@ def find_affix_bullets(img_item_descr: np.ndarray, sep_short_match: TemplateMatc + rerolled_icons + tempered_icons ) + if is_seal_or_charm: + template_list += [ + "legendary_bullet_point", + "unique_bullet_point", + "mythic_bullet_point", + "boosted_bullet_point", + ] all_templates = [f"{x}_medium" for x in template_list] + template_list search_threshold = 0.80 if ResManager().resolution[1] <= 1200: diff --git a/src/item/filter.py b/src/item/filter.py index 57c8bc81..523c91d9 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -288,11 +288,65 @@ def _check_seal_charm_filters( if not matched_affixes: continue + if ( + getattr(filter_spec, "charm_slots", 0) > 0 + and item.charm_slots is not None + and item.charm_slots >= filter_spec.charm_slots + and getattr(item, "charm_slots_loc", None) + ): + matched_affixes.append(Affix(name="charm_slots", loc=item.charm_slots_loc)) + + boosted_set_filters = [] + if getattr(filter_spec, "boosted_sets", None): + boosted_set_filters.extend(filter_spec.boosted_sets) + if getattr(filter_spec, "boosted_set", None) is not None: + boosted_set_filters.append( + BoostedSetFilterModel( + set=filter_spec.boosted_set, + affix=filter_spec.boosted_affix, + required=filter_spec.boosted_affix_required, + ) + ) + for bsf in boosted_set_filters: + for bs in item.boosted_sets: + if bs.name == bsf.set_name: + if not bsf.required and bs.loc: + matched_affixes.append(Affix(name=bs.name, loc=bs.loc)) + elif ( + bs.affix is not None + and bsf.affix is not None + and self._match_item_aspect_or_affix(bsf.affix, bs.affix) + and bs.loc + ): + matched_affixes.append(Affix(name=f"{bs.name} ({bs.affix.name})", loc=bs.loc)) + + if item.item_type == ItemType.HoradricSeal: + matched_locs = {affix.loc for affix in matched_affixes if affix.loc} + for boosted_set in item.boosted_sets: + if boosted_set.loc and boosted_set.loc not in matched_locs: + matched_affixes.append(Affix(name=boosted_set.name, loc=boosted_set.loc)) + matched_locs.add(boosted_set.loc) + + if ( + getattr(filter_spec, "set_name", None) is not None + and item.set_name == filter_spec.set_name + and getattr(item, "set_name_loc", None) + ): + matched_affixes.append(Affix(name=item.set_name, loc=item.set_name_loc)) + + aspect_match = ( + getattr(filter_spec, "unique_aspect", None) is not None and item.name == filter_spec.unique_aspect + ) + LOGGER.info( f"{item.original_name} -- Matched {profile_name}.{section_name}.{filter_name}: {[affix.name for affix in matched_affixes]}" ) res.keep = True - res.matched.append(MatchedFilter(f"{profile_name}.{section_name}.{filter_name}", matched_affixes)) + res.matched.append( + MatchedFilter( + f"{profile_name}.{section_name}.{filter_name}", matched_affixes, aspect_match=aspect_match + ) + ) return res @staticmethod @@ -305,7 +359,7 @@ def _match_charm_filter(item: Item, filter_spec: CharmFilterModel) -> bool: ) def _match_seal_filter(self, item: Item, filter_spec: SealFilterModel) -> bool: - if filter_spec.charm_slots and filter_spec.charm_slots != item.charm_slots: + if filter_spec.charm_slots and (item.charm_slots is None or item.charm_slots < filter_spec.charm_slots): return False boosted_set_filters = list(filter_spec.boosted_sets) diff --git a/src/item/models.py b/src/item/models.py index ae6612f5..d8166e1f 100644 --- a/src/item/models.py +++ b/src/item/models.py @@ -19,6 +19,7 @@ class BoostedSet: affix: Affix | None = None name: str = "" + loc: tuple[int, int] | None = None @dataclass @@ -30,6 +31,7 @@ class Item: boosted_set_name: str | None = None boosted_sets: list[BoostedSet] = field(default_factory=list) charm_slots: int | None = None + charm_slots_loc: tuple[int, int] | None = None codex_upgrade: bool = False cosmetic_upgrade: bool = False inherent: list[Affix] = field(default_factory=list) @@ -41,6 +43,7 @@ class Item: rarity: ItemRarity | None = None seasonal_attribute: SeasonalAttribute | None = None set_name: str | None = None + set_name_loc: tuple[int, int] | None = None def __eq__(self, other): if not isinstance(other, Item): diff --git a/tests/config/models_test.py b/tests/config/models_test.py index b341d909..72e31193 100644 --- a/tests/config/models_test.py +++ b/tests/config/models_test.py @@ -677,9 +677,15 @@ def test_required_boosted_affix_needs_set_and_affix(self) -> None: with pytest.raises(ValidationError, match="required boostedSets entries need affix"): SealFilterModel(boostedSets=[{"set": "Berserker's Crucible", "required": True}]) - with pytest.raises(ValidationError, match="must be greater than zero"): + with pytest.raises(ValidationError, match="charm_slots must be between 3 and 6"): SealFilterModel(charmSlots=-1) + with pytest.raises(ValidationError, match="charm_slots must be between 3 and 6"): + SealFilterModel(charmSlots=2) + + with pytest.raises(ValidationError, match="charm_slots must be between 3 and 6"): + SealFilterModel(charmSlots=7) + def test_invalid_boosted_set_fails(self) -> None: with pytest.raises(ValidationError, match="boostedSet invalid_set does not exist"): SealFilterModel(boostedSet="invalid set") diff --git a/tests/item/filter/filter_test.py b/tests/item/filter/filter_test.py index 452b2ab8..9efbe76a 100644 --- a/tests/item/filter/filter_test.py +++ b/tests/item/filter/filter_test.py @@ -161,6 +161,7 @@ def test_charm_filter_matches_set_name(mocker: MockerFixture): name="linta_of_the_frozen_sea", rarity=ItemRarity.Legendary, set_name="breath_of_the_frozen_sea", + set_name_loc=(10, 20), affixes=[Affix(name="potion_healing")], ) charm_filter = CharmFilterModel(set="Breath of the Frozen Sea") @@ -169,6 +170,7 @@ def test_charm_filter_matches_set_name(mocker: MockerFixture): match = test_filter.should_keep(item).matched[0] assert match.profile == "seal_charm.Charms.wanted" + assert [(affix.name, affix.loc) for affix in match.matched_affixes] == [("breath_of_the_frozen_sea", (10, 20))] def test_charm_filter_matches_unique_aspect_name(mocker: MockerFixture): @@ -248,7 +250,11 @@ def test_seal_filter_matches_charm_slots_and_boosted_set(mocker: MockerFixture): name="unimportant_seal_name", rarity=ItemRarity.Legendary, charm_slots=6, - boosted_sets=[BoostedSet(name="habacalvas_cauldron", affix=Affix(name="life_on_hit", value=255.0))], + charm_slots_loc=(10, 20), + boosted_sets=[ + BoostedSet(name="habacalvas_cauldron", affix=Affix(name="life_on_hit", value=255.0), loc=(30, 40)), + BoostedSet(name="tal_rashas_threefold_way", affix=Affix(name="to_ball_lightning", value=2.0), loc=(70, 80)), + ], affixes=[Affix(name="resource_cost_reduction", value=7.5)], ) seal_filter = SealFilterModel(charmSlots=6, boostedSets=[{"set": "Habacalva's Cauldron"}]) @@ -257,9 +263,14 @@ def test_seal_filter_matches_charm_slots_and_boosted_set(mocker: MockerFixture): match = test_filter.should_keep(item).matched[0] assert match.profile == "seal_charm.Seals.wanted" + assert [(affix.name, affix.loc) for affix in match.matched_affixes] == [ + ("charm_slots", (10, 20)), + ("habacalvas_cauldron", (30, 40)), + ("tal_rashas_threefold_way", (70, 80)), + ] -def test_seal_filter_rejects_wrong_charm_slots(mocker: MockerFixture): +def test_seal_filter_rejects_insufficient_charm_slots(mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) item = Item( item_type=ItemType.HoradricSeal, @@ -275,6 +286,24 @@ def test_seal_filter_rejects_wrong_charm_slots(mocker: MockerFixture): assert test_filter.should_keep(item).matched == [] +def test_seal_filter_matches_more_charm_slots_than_minimum(mocker: MockerFixture): + test_filter = _create_mocked_filter(mocker) + item = Item( + item_type=ItemType.HoradricSeal, + name="unimportant_seal_name", + rarity=ItemRarity.Legendary, + charm_slots=6, + boosted_sets=[BoostedSet(name="habacalvas_cauldron", affix=Affix(name="life_on_hit", value=255.0))], + affixes=[Affix(name="resource_cost_reduction", value=7.5)], + ) + seal_filter = SealFilterModel(charmSlots=5, boostedSets=[{"set": "Habacalva's Cauldron"}]) + test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} + + match = test_filter.should_keep(item).matched[0] + + assert match.profile == "seal_charm.Seals.wanted" + + def test_seal_filter_matches_required_boosted_affix(mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) item = Item( @@ -282,8 +311,8 @@ def test_seal_filter_matches_required_boosted_affix(mocker: MockerFixture): name="unimportant_seal_name", rarity=ItemRarity.Legendary, boosted_sets=[ - BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction")), - BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury")), + BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction"), loc=(10, 20)), + BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury"), loc=(50, 60)), ], affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_fury")], ) @@ -295,6 +324,10 @@ def test_seal_filter_matches_required_boosted_affix(mocker: MockerFixture): match = test_filter.should_keep(item).matched[0] assert match.profile == "seal_charm.Seals.wanted" + assert [(affix.name, affix.loc) for affix in match.matched_affixes] == [ + ("berserkers_crucible (maximum_fury)", (50, 60)), + ("cathans_dauntless_faith", (10, 20)), + ] def test_seal_filter_matches_two_boosted_sets_with_required_affixes(mocker: MockerFixture): @@ -304,8 +337,8 @@ def test_seal_filter_matches_two_boosted_sets_with_required_affixes(mocker: Mock name="unimportant_seal_name", rarity=ItemRarity.Legendary, boosted_sets=[ - BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction")), - BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury")), + BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction"), loc=(10, 20)), + BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury"), loc=(50, 60)), ], affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_fury")], ) @@ -320,6 +353,10 @@ def test_seal_filter_matches_two_boosted_sets_with_required_affixes(mocker: Mock match = test_filter.should_keep(item).matched[0] assert match.profile == "seal_charm.Seals.wanted" + assert [(affix.name, affix.loc) for affix in match.matched_affixes] == [ + ("berserkers_crucible (maximum_fury)", (50, 60)), + ("cathans_dauntless_faith (cooldown_reduction)", (10, 20)), + ] def test_seal_filter_rejects_missing_second_boosted_set(mocker: MockerFixture): diff --git a/tests/item/read_descr_tts_test.py b/tests/item/read_descr_tts_test.py index 2df54532..22bd3f04 100644 --- a/tests/item/read_descr_tts_test.py +++ b/tests/item/read_descr_tts_test.py @@ -1,5 +1,12 @@ +import numpy as np + import src.tts -from src.item.descr.read_descr_tts import read_descr +from src.config.ui import ResManager +from src.item.data.item_type import ItemType +from src.item.data.rarity import ItemRarity +from src.item.descr.read_descr_tts import _add_affixes_from_tts_mixed, read_descr +from src.item.models import Item +from src.template_finder import TemplateMatch LOOT_FILTER_TTS = ["SELECT ALL", "Checkbox Disabled", "Item Power Range", "Left mouse button"] @@ -12,3 +19,141 @@ def test_loot_filter_controls_do_not_raise_tts_parser_error(): src.tts.LAST_ITEM = LOOT_FILTER_TTS assert read_descr() is None + + +def test_seal_boosted_set_locations_fall_back_to_line_offsets(): + ResManager().set_resolution("3840x2160") + item = Item( + item_type=ItemType.HoradricSeal, + name="efficient_horadric_seal_of_fervor", + original_name="EFFICIENT HORADRIC SEAL OF FERVOR", + rarity=ItemRarity.Legendary, + ) + tts_section = [ + "EFFICIENT HORADRIC SEAL OF FERVOR", + "Legendary Horadric Seal", + "Unlocks 5 Charm Slots", + "7.5% Resource Cost Reduction [7.5]", + "Habacalva's Cauldron:", + "+255 Life On Hit", + "Tal Rasha's Threefold Way:", + "+2 to Ball Lightning", + "Right mouse button", + ] + affix_bullets = [ + TemplateMatch(center=(50, 100), name="affix_bullet_point_1"), + TemplateMatch(center=(50, 150), name="affix_bullet_point_1"), + ] + + result = _add_affixes_from_tts_mixed(tts_section, item, affix_bullets, np.zeros((1, 1, 3)), aspect_bullet=None) + + assert result.charm_slots_loc == (50, 100) + assert result.affixes[0].loc == (50, 150) + assert [ + (boosted_set.name, boosted_set.loc, boosted_set.affix.loc if boosted_set.affix else None) + for boosted_set in result.boosted_sets + ] == [("habacalvas_cauldron", (50, 200), None), ("tal_rashas_threefold_way", (50, 300), None)] + + +def test_seal_boosted_affix_locations_do_not_consume_set_bullets(): + ResManager().set_resolution("3840x2160") + item = Item( + item_type=ItemType.HoradricSeal, + name="efficient_horadric_seal_of_fervor", + original_name="EFFICIENT HORADRIC SEAL OF FERVOR", + rarity=ItemRarity.Legendary, + ) + tts_section = [ + "EFFICIENT HORADRIC SEAL OF FERVOR", + "Legendary Horadric Seal", + "Unlocks 5 Charm Slots", + "7.5% Resource Cost Reduction [7.5]", + "Habacalva's Cauldron:", + "+255 Life On Hit", + "Tal Rasha's Threefold Way:", + "+2 to Ball Lightning", + "Right mouse button", + ] + affix_bullets = [ + TemplateMatch(center=(50, 100), name="affix_bullet_point_1"), + TemplateMatch(center=(50, 150), name="affix_bullet_point_1"), + TemplateMatch(center=(50, 200), name="boosted_bullet_point"), + TemplateMatch(center=(50, 300), name="boosted_bullet_point"), + ] + + result = _add_affixes_from_tts_mixed(tts_section, item, affix_bullets, np.zeros((1, 1, 3)), aspect_bullet=None) + + assert [ + (boosted_set.name, boosted_set.loc, boosted_set.affix.loc if boosted_set.affix else None) + for boosted_set in result.boosted_sets + ] == [("habacalvas_cauldron", (50, 200), None), ("tal_rashas_threefold_way", (50, 300), None)] + + +def test_seal_boosted_set_locations_skip_affix_line_false_matches(): + ResManager().set_resolution("3840x2160") + item = Item( + item_type=ItemType.HoradricSeal, + name="efficient_horadric_seal_of_fervor", + original_name="EFFICIENT HORADRIC SEAL OF FERVOR", + rarity=ItemRarity.Legendary, + ) + tts_section = [ + "EFFICIENT HORADRIC SEAL OF FERVOR", + "Legendary Horadric Seal", + "Unlocks 5 Charm Slots", + "7.5% Resource Cost Reduction [7.5]", + "Habacalva's Cauldron:", + "+255 Life On Hit", + "Tal Rasha's Threefold Way:", + "+2 to Ball Lightning", + "Right mouse button", + ] + affix_bullets = [ + TemplateMatch(center=(50, 100), name="affix_bullet_point_1"), + TemplateMatch(center=(50, 150), name="affix_bullet_point_1"), + TemplateMatch(center=(50, 200), name="boosted_bullet_point"), + TemplateMatch(center=(50, 250), name="boosted_bullet_point"), + TemplateMatch(center=(50, 300), name="boosted_bullet_point"), + ] + + result = _add_affixes_from_tts_mixed(tts_section, item, affix_bullets, np.zeros((1, 1, 3)), aspect_bullet=None) + + assert [(boosted_set.name, boosted_set.loc) for boosted_set in result.boosted_sets] == [ + ("habacalvas_cauldron", (50, 200)), + ("tal_rashas_threefold_way", (50, 300)), + ] + + +def test_seal_boosted_set_locations_use_actual_second_set_bullet(): + ResManager().set_resolution("3840x2160") + item = Item( + item_type=ItemType.HoradricSeal, + name="efficient_horadric_seal_of_fervor", + original_name="EFFICIENT HORADRIC SEAL OF FERVOR", + rarity=ItemRarity.Legendary, + ) + tts_section = [ + "EFFICIENT HORADRIC SEAL OF FERVOR", + "Legendary Horadric Seal", + "Unlocks 5 Charm Slots", + "7.5% Resource Cost Reduction [7.5]", + "Habacalva's Cauldron:", + "+255 Life On Hit", + "Tal Rasha's Threefold Way:", + "+2 to Ball Lightning", + "Right mouse button", + ] + affix_bullets = [ + TemplateMatch(center=(50, 100), name="affix_bullet_point_1"), + TemplateMatch(center=(50, 150), name="affix_bullet_point_1"), + TemplateMatch(center=(50, 200), name="boosted_bullet_point"), + TemplateMatch(center=(50, 250), name="boosted_bullet_point"), + TemplateMatch(center=(50, 275), name="boosted_bullet_point"), + ] + + result = _add_affixes_from_tts_mixed(tts_section, item, affix_bullets, np.zeros((1, 1, 3)), aspect_bullet=None) + + assert [(boosted_set.name, boosted_set.loc) for boosted_set in result.boosted_sets] == [ + ("habacalvas_cauldron", (50, 200)), + ("tal_rashas_threefold_way", (50, 275)), + ] From 1e29bc9884fca0973a57ed81c59d7efe9cd668f6 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Wed, 3 Jun 2026 17:52:10 +0200 Subject: [PATCH 14/39] Update ui_test.py --- tests/config/ui_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/config/ui_test.py b/tests/config/ui_test.py index 84f97a99..b43fe04e 100644 --- a/tests/config/ui_test.py +++ b/tests/config/ui_test.py @@ -41,4 +41,4 @@ def test_colors(): def test_templates(): - assert len(ResManager().templates) == 61 + assert len(ResManager().templates) == 63 From 706149239ad834bb0fd3281a7285bf8eb967aa41 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Wed, 3 Jun 2026 20:41:15 +0200 Subject: [PATCH 15/39] update14 read comments :) --- src/config/profile_models.py | 76 ++++++---------------- src/gui/profile_editor/seal_charm_tab.py | 54 ++++++++++------ src/item/descr/read_descr_tts.py | 81 ++++++++++++------------ src/item/filter.py | 36 +++-------- tests/config/models_test.py | 75 +++++++--------------- tests/item/filter/filter_test.py | 31 ++++++--- 6 files changed, 146 insertions(+), 207 deletions(-) diff --git a/src/config/profile_models.py b/src/config/profile_models.py index 8c3cfdd4..33e70713 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -4,7 +4,7 @@ import logging import sys -from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator from src.config.helper import check_greater_than_zero, validate_greater_affix_count, validate_percent from src.item.data.item_type import ItemType # noqa: TC001 @@ -20,22 +20,6 @@ def _parse_item_type_or_rarities(data: str | list[str]) -> list[str]: return data -def _coerce_name_rarity_filter_data(data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]: - if isinstance(data, dict): - return data - if isinstance(data, str): - if any(rarity.value.lower() == data.lower() for rarity in ItemRarity): - return {"rarities": [data]} - return {"name": data} - if isinstance(data, list): - if not data: - msg = "list cannot be empty" - raise ValueError(msg) - return {"rarities": data} - msg = "must be str or list" - raise ValueError(msg) - - def _normalize_existing_set_name(name: str | None, field_name: str) -> str | None: if not name: return None @@ -288,32 +272,17 @@ def required_boosted_affix_must_have_affix(self) -> BoostedSetFilterModel: class SealFilterModel(SealCharmFilterModel): - boosted_affix: AffixFilterModel | None = Field(default=None, alias="boostedAffix") - boosted_affix_required: bool = Field(default=False, alias="boostedAffixRequired") - boosted_set: str | None = Field(default=None, alias="boostedSet") boosted_sets: list[BoostedSetFilterModel] = Field(default=[], alias="boostedSets") - charm_slots: int = Field(default=0, alias="charmSlots") - - @field_validator("boosted_set") - @classmethod - def boosted_set_must_exist(cls, name: str | None) -> str | None: - return _normalize_existing_set_name(name, "boostedSet") + slots: int = Field(default=3, validation_alias=AliasChoices("slots", "charmSlots", "charm_slots")) - @field_validator("charm_slots") + @field_validator("slots") @classmethod - def charm_slots_in_valid_range(cls, v: int) -> int: + def slots_in_valid_range(cls, v: int) -> int: if v != 0 and not (3 <= v <= 6): - msg = "charm_slots must be between 3 and 6" + msg = "slots must be 0 or between 3 and 6" raise ValueError(msg) return v - @model_validator(mode="after") - def required_boosted_affix_must_have_affix(self) -> SealFilterModel: - if self.boosted_affix_required and (self.boosted_affix is None or self.boosted_set is None): - msg = "boostedAffixRequired needs boostedSet and boostedAffix" - raise ValueError(msg) - return self - DynamicSealCharmFilterModel = RootModel[dict[str, SealCharmFilterModel]] DynamicCharmFilterModel = RootModel[dict[str, CharmFilterModel]] @@ -408,28 +377,19 @@ def name_must_exist(cls, name: str) -> str: @model_validator(mode="before") @classmethod def parse_data(cls, data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]: - return _coerce_name_rarity_filter_data(data) - - @field_validator("rarities", mode="before") - @classmethod - def parse_rarities(cls, data: str | list[str]) -> list[str]: - return _parse_item_type_or_rarities(data) - - -class NameRarityFilterModel(BaseModel): - model_config = ConfigDict(extra="forbid") - name: str | None = None - rarities: list[ItemRarity] = [] - - @field_validator("name") - @classmethod - def normalize_name(cls, name: str | None) -> str | None: - return correct_name(name) - - @model_validator(mode="before") - @classmethod - def parse_data(cls, data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]: - return _coerce_name_rarity_filter_data(data) + if isinstance(data, dict): + return data + if isinstance(data, str): + if any(rarity.value.lower() == data.lower() for rarity in ItemRarity): + return {"rarities": [data]} + return {"name": data} + if isinstance(data, list): + if not data: + msg = "list cannot be empty" + raise ValueError(msg) + return {"rarities": data} + msg = "must be str or list" + raise ValueError(msg) @field_validator("rarities", mode="before") @classmethod diff --git a/src/gui/profile_editor/seal_charm_tab.py b/src/gui/profile_editor/seal_charm_tab.py index 90fd640e..dee807a3 100644 --- a/src/gui/profile_editor/seal_charm_tab.py +++ b/src/gui/profile_editor/seal_charm_tab.py @@ -82,6 +82,8 @@ def setup_ui(self): general_form.addRow("Rarities:", rarity_layout) if isinstance(self.config, SealFilterModel): self.add_boosted_set_fields(general_form) + elif isinstance(self.config, CharmFilterModel): + self.add_charm_fields(general_form) self.content_layout.addLayout(general_form) pool_btn_layout = QHBoxLayout() @@ -106,12 +108,35 @@ def setup_ui(self): QTimer.singleShot(100, self.affix_pool_container.expand) + def add_charm_fields(self, form: QFormLayout): + self.set_name_combo = IgnoreScrollWheelComboBox() + self.set_name_combo.setEditable(True) + self.set_name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + self.set_name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + self.set_name_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains) + self.set_name_combo.addItems(["", *sorted(Dataloader().set_list)]) + if self.config.set_name: + self.set_name_combo.setCurrentText(self.config.set_name) + self.set_name_combo.currentTextChanged.connect(self.update_set_name) + form.addRow("Set:", self.set_name_combo) + + self.unique_aspect_combo = IgnoreScrollWheelComboBox() + self.unique_aspect_combo.setEditable(True) + self.unique_aspect_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + self.unique_aspect_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + self.unique_aspect_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains) + self.unique_aspect_combo.addItems(["", *sorted(Dataloader().aspect_unique_dict.keys())]) + if self.config.unique_aspect: + self.unique_aspect_combo.setCurrentText(self.config.unique_aspect) + self.unique_aspect_combo.currentTextChanged.connect(self.update_unique_aspect) + form.addRow("Unique Aspect:", self.unique_aspect_combo) + def add_boosted_set_fields(self, form: QFormLayout): charm_slots_layout = QHBoxLayout() self.charm_slot_checkboxes = {} for slots in range(3, 7): checkbox = QCheckBox(str(slots)) - checkbox.setChecked(self.config.charm_slots == slots) + checkbox.setChecked(self.config.slots == slots) checkbox.clicked.connect(partial(self.update_charm_slots, slots)) self.charm_slot_checkboxes[slots] = checkbox charm_slots_layout.addWidget(checkbox) @@ -119,18 +144,6 @@ def add_boosted_set_fields(self, form: QFormLayout): form.addRow("Min Charm Slots:", charm_slots_layout) boosted_set_filters = list(self.config.boosted_sets) - if self.config.boosted_set: - boosted_set_filters.append( - BoostedSetFilterModel( - set=self.config.boosted_set, - affix=self.config.boosted_affix, - required=self.config.boosted_affix_required, - ) - ) - self.config.boosted_set = None - self.config.boosted_affix = None - self.config.boosted_affix_required = False - self.config.boosted_sets = boosted_set_filters self.boosted_set_combos = [] self.boosted_affix_combos = [] @@ -245,13 +258,19 @@ def update_min_greater_affix(self): def update_rarities(self): self.config.rarities = [rarity for rarity, checkbox in self.rarity_checkboxes.items() if checkbox.isChecked()] + def update_set_name(self, text: str): + self.config.set_name = correct_name(text) if text.strip() else None + + def update_unique_aspect(self, text: str): + self.config.unique_aspect = correct_name(text) if text.strip() else None + def update_charm_slots(self, slots: int, checked: bool): if not checked: - if self.config.charm_slots == slots: - self.config.charm_slots = 0 + if self.config.slots == slots: + self.config.slots = 0 return - self.config.charm_slots = slots + self.config.slots = slots for other_slots, checkbox in self.charm_slot_checkboxes.items(): if other_slots == slots: continue @@ -303,9 +322,6 @@ def sync_boosted_sets_from_controls(self): ) ) - self.config.boosted_set = None - self.config.boosted_affix = None - self.config.boosted_affix_required = False self.config.boosted_sets = boosted_sets def boosted_affix_from_controls(self, index: int) -> AffixFilterModel | None: diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index 1167205a..7f3cfd5f 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -25,7 +25,7 @@ from src.item.data.rarity import ItemRarity from src.item.data.seasonal_attribute import SeasonalAttribute from src.item.descr import keep_letters_and_spaces -from src.item.descr.text import clean_str, find_number +from src.item.descr.text import find_number from src.item.descr.texture import find_affix_bullets, find_aspect_bullet, find_seperator_short, find_seperators_long from src.item.models import BoostedSet, Item from src.scripts import correct_name @@ -86,7 +86,7 @@ def _get_affix_counts(tts_section: list[str], item: Item, start: int) -> tuple[i if is_seal_or_charm(item.item_type): return inherent_num, _get_seal_charm_affix_count(tts_section, start) - if item.rarity in [ItemRarity.Unique, ItemRarity.Mythic] and not is_seal_or_charm(item.item_type): + if item.rarity in [ItemRarity.Unique, ItemRarity.Mythic]: # Uniques can have variable amounts of inherents. unique_inherents = Dataloader().aspect_unique_dict.get(item.name)["num_inherents"] if unique_inherents is not None: @@ -116,20 +116,32 @@ def _get_affix_counts(tts_section: list[str], item: Item, start: int) -> tuple[i def _get_seal_charm_affix_count(tts_section: list[str], start: int) -> int: affixes_num = 0 for line in tts_section[start:]: - if line.lower().startswith(_AFFIX_STOP_MARKERS): - break - if not _is_seal_charm_affix_line(line): + if line.lower().startswith(_AFFIX_STOP_MARKERS) or _get_set_name_from_line(line) is not None: break affixes_num += 1 return affixes_num -def _is_seal_charm_affix_line(line: str) -> bool: - return _CHARM_SLOTS_RE.search(line) is not None or clean_str(line) in Dataloader().affix_dict.values() +def _read_charm_slots_from_tts_section( + tts_section: list[str], item: Item, starting_index: int, affix_bullets: list[TemplateMatch] | None = None +) -> tuple[int, int]: + if item.item_type != ItemType.HoradricSeal or starting_index >= len(tts_section): + return starting_index, 0 + + charm_slots_match = _CHARM_SLOTS_RE.search(tts_section[starting_index]) + if charm_slots_match is None: + return starting_index, 0 + + item.charm_slots = int(charm_slots_match.group("slots")) + if affix_bullets: + item.charm_slots_loc = affix_bullets[0].center + return starting_index + 1, 1 + return starting_index + 1, 0 def _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item: starting_index = _get_affix_starting_location_from_tts_section(tts_section, item) + starting_index, _affix_bullet_start = _read_charm_slots_from_tts_section(tts_section, item, starting_index) inherent_num, affixes_num = _get_affix_counts(tts_section, item, starting_index) affixes = _get_affixes_from_tts_section(tts_section, starting_index, inherent_num + affixes_num) boosted_sets = [] @@ -144,9 +156,6 @@ def _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item: affix.type = AffixType.inherent item.inherent.append(affix) elif i < inherent_num + affixes_num: - if charm_slots_match := _CHARM_SLOTS_RE.search(affix_text): - item.charm_slots = int(charm_slots_match.group("slots")) - continue affix = _get_affix_from_text(affix_text) item.affixes.append(affix) @@ -171,6 +180,9 @@ def _add_affixes_from_tts_mixed( aspect_bullet: TemplateMatch | None, ) -> Item: starting_index = _get_affix_starting_location_from_tts_section(tts_section, item) + starting_index, affix_bullet_start = _read_charm_slots_from_tts_section( + tts_section, item, starting_index, affix_bullets + ) inherent_num, affixes_num = _get_affix_counts(tts_section, item, starting_index) affixes = _get_affixes_from_tts_section(tts_section, starting_index, inherent_num + affixes_num) boosted_sets = [] @@ -182,34 +194,30 @@ def _add_affixes_from_tts_mixed( # With advanced item compare on we'll actually find more bullets than we need, so we don't rely on them for # number of affixes - if len(affixes) > len(affix_bullets): + if affix_bullet_start + len(affixes) > len(affix_bullets): _raise_index_error(affixes, affix_bullets, item, img_item_descr) for i, affix_text in enumerate(affixes): + bullet_index = affix_bullet_start + i if i < inherent_num: affix = _get_affix_from_text(affix_text) affix.type = AffixType.inherent - affix.loc = affix_bullets[i].center + affix.loc = affix_bullets[bullet_index].center item.inherent.append(affix) elif i < inherent_num + affixes_num: - if charm_slots_match := _CHARM_SLOTS_RE.search(affix_text): - item.charm_slots = int(charm_slots_match.group("slots")) - if i < len(affix_bullets): - item.charm_slots_loc = affix_bullets[i].center - continue affix = _get_affix_from_text(affix_text) - affix.loc = affix_bullets[i].center - if affix_bullets[i].name.startswith("greater_affix"): + affix.loc = affix_bullets[bullet_index].center + if affix_bullets[bullet_index].name.startswith("greater_affix"): affix.type = AffixType.greater - elif affix_bullets[i].name.startswith("rerolled"): + elif affix_bullets[bullet_index].name.startswith("rerolled"): affix.type = AffixType.rerolled else: affix.type = AffixType.normal item.affixes.append(affix) - extra_idx = len(affixes) + extra_idx = affix_bullet_start + len(affixes) last_known_bullet_loc = ( - affix_bullets[min(len(affixes), len(affix_bullets)) - 1].center if affixes and affix_bullets else None + affix_bullets[min(extra_idx, len(affix_bullets)) - 1].center if extra_idx and affix_bullets else None ) line_height = ResManager().offsets.item_descr_line_height if item.item_type == ItemType.HoradricSeal: @@ -375,12 +383,13 @@ def _create_base_item_from_tts(tts_item: list[str]) -> Item | None: search_string = tts_item[1].lower().replace("ancestral", "").replace("bloodied", "").strip() search_string = _REPLACE_COMPARE_RE.sub("", search_string).strip() search_string_split = search_string.split(" ") - item.rarity = _get_item_rarity(search_string_split[0]) - starting_item_type_index = 1 + rarity_token = search_string_split[0] + item.rarity = _get_item_rarity(rarity_token) + starting_item_type_index = 0 if item.rarity == ItemRarity.Mythic: starting_item_type_index = 2 - elif item.rarity == ItemRarity.Common and search_string_split[0] != ItemRarity.Common.value: - starting_item_type_index = 0 + elif rarity_token == item.rarity.value: + starting_item_type_index = 1 item.item_type = _get_item_type(" ".join(search_string_split[starting_item_type_index:])) item.name = correct_name(tts_item[0]) if item.name in Dataloader().bad_tts_uniques: @@ -436,9 +445,7 @@ def _get_affixes_from_tts_section(tts_section: list[str], start: int, length: in def _get_aspect_from_tts_section(tts_section: list[str], item: Item, start: int, num_affixes: int): # Grab the aspect as well in this case - if item.rarity in [ItemRarity.Mythic, ItemRarity.Unique, ItemRarity.Legendary] and not is_seal_or_charm( - item.item_type - ): + if item.rarity in [ItemRarity.Mythic, ItemRarity.Unique, ItemRarity.Legendary]: aspect_index = start + num_affixes return tts_section[aspect_index] @@ -469,7 +476,11 @@ def _get_boosted_sets_from_tts_section(tts_section: list[str], start: int, num_a affix = None next_index = index + 1 - if next_index < len(tts_section) and _is_boosted_set_affix_line(tts_section[next_index]): + if ( + next_index < len(tts_section) + and not tts_section[next_index].lower().startswith(_AFFIX_STOP_MARKERS) + and _get_set_name_from_line(tts_section[next_index]) is None + ): affix = _get_affix_from_text(tts_section[next_index]) affix.type = AffixType.normal index = next_index @@ -478,14 +489,6 @@ def _get_boosted_sets_from_tts_section(tts_section: list[str], start: int, num_a return boosted_sets -def _is_boosted_set_affix_line(line: str) -> bool: - return ( - not line.lower().startswith(_AFFIX_STOP_MARKERS) - and _get_set_name_from_line(line) is None - and clean_str(line) in Dataloader().affix_dict.values() - ) - - def _add_boosted_sets_to_item(item: Item, boosted_sets: list[BoostedSet]) -> None: item.boosted_sets = boosted_sets item.boosted_set_name = item.boosted_sets[0].name if item.boosted_sets else None @@ -586,8 +589,6 @@ def _get_item_rarity(data: str) -> ItemRarity | None: def _get_item_type(data: str): - if data.endswith(f" {ItemType.Charm.value}"): - return ItemType.Charm return next((it for it in ItemType if it.value == data.lower()), None) diff --git a/src/item/filter.py b/src/item/filter.py index 523c91d9..090921dc 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -19,6 +19,7 @@ CharmFilterModel, DynamicCharmFilterModel, DynamicItemFilterModel, + DynamicSealCharmFilterModel, DynamicSealFilterModel, GlobalUniqueModel, ProfileModel, @@ -246,7 +247,7 @@ def _check_sigil(self, item: Item) -> FilterResult: def _check_seal_charm_filters( self, item: Item, - item_filters: dict[str, list[DynamicCharmFilterModel] | list[DynamicSealFilterModel]], + item_filters: dict[str, list[DynamicSealCharmFilterModel]], section_name: str, mythic_name: str, extra_match=None, @@ -289,25 +290,14 @@ def _check_seal_charm_filters( continue if ( - getattr(filter_spec, "charm_slots", 0) > 0 + getattr(filter_spec, "slots", 0) > 0 and item.charm_slots is not None - and item.charm_slots >= filter_spec.charm_slots + and item.charm_slots >= filter_spec.slots and getattr(item, "charm_slots_loc", None) ): matched_affixes.append(Affix(name="charm_slots", loc=item.charm_slots_loc)) - boosted_set_filters = [] - if getattr(filter_spec, "boosted_sets", None): - boosted_set_filters.extend(filter_spec.boosted_sets) - if getattr(filter_spec, "boosted_set", None) is not None: - boosted_set_filters.append( - BoostedSetFilterModel( - set=filter_spec.boosted_set, - affix=filter_spec.boosted_affix, - required=filter_spec.boosted_affix_required, - ) - ) - for bsf in boosted_set_filters: + for bsf in getattr(filter_spec, "boosted_sets", []): for bs in item.boosted_sets: if bs.name == bsf.set_name: if not bsf.required and bs.loc: @@ -359,24 +349,14 @@ def _match_charm_filter(item: Item, filter_spec: CharmFilterModel) -> bool: ) def _match_seal_filter(self, item: Item, filter_spec: SealFilterModel) -> bool: - if filter_spec.charm_slots and (item.charm_slots is None or item.charm_slots < filter_spec.charm_slots): + if filter_spec.slots and (item.charm_slots is None or item.charm_slots < filter_spec.slots): return False - boosted_set_filters = list(filter_spec.boosted_sets) - if filter_spec.boosted_set is not None: - boosted_set_filters.append( - BoostedSetFilterModel( - set=filter_spec.boosted_set, - affix=filter_spec.boosted_affix, - required=filter_spec.boosted_affix_required, - ) - ) - - if not boosted_set_filters: + if not filter_spec.boosted_sets: return True return all( - self._match_boosted_set_filter(item, boosted_set_filter) for boosted_set_filter in boosted_set_filters + self._match_boosted_set_filter(item, boosted_set_filter) for boosted_set_filter in filter_spec.boosted_sets ) def _match_boosted_set_filter(self, item: Item, filter_spec: BoostedSetFilterModel) -> bool: diff --git a/tests/config/models_test.py b/tests/config/models_test.py index 72e31193..acfcdc5c 100644 --- a/tests/config/models_test.py +++ b/tests/config/models_test.py @@ -26,7 +26,6 @@ GlobalUniqueModel, ItemFilterModel, ItemRarity, - NameRarityFilterModel, ProfileModel, SealFilterModel, SigilConditionModel, @@ -595,31 +594,6 @@ def test_rarities_parse_list(self) -> None: assert ItemRarity.Legendary in model.rarities -class TestNameRarityFilterModel: - """Test generic name and rarity filters.""" - - def test_name_is_normalized(self) -> None: - """Test item names are normalized for matching TTS item names.""" - model = NameRarityFilterModel.model_validate("Faint Charm") - assert model.name == "faint_charm" - - def test_parse_from_string_rarity(self) -> None: - """Test parsing every rarity string.""" - for rarity in ItemRarity: - model = NameRarityFilterModel.model_validate(rarity.value) - assert rarity in model.rarities - - def test_parse_from_list(self) -> None: - """Test parsing from a rarity list.""" - model = NameRarityFilterModel.model_validate([ItemRarity.Legendary.value, ItemRarity.Unique.value]) - assert model.rarities == [ItemRarity.Legendary, ItemRarity.Unique] - - def test_parse_empty_list_fails(self) -> None: - """Test that empty lists fail.""" - with pytest.raises(ValidationError, match="list cannot be empty"): - NameRarityFilterModel.model_validate([]) - - class TestCharmFilterModel: def test_set_name_is_validated_and_normalized(self) -> None: model = CharmFilterModel(set="Breath of the Frozen Sea") @@ -637,19 +611,6 @@ def test_unique_aspect_is_normalized(self) -> None: class TestSealFilterModel: - def test_boosted_set_alias_is_validated_and_normalized(self) -> None: - model = SealFilterModel(boostedSet="Berserker's Crucible") - - assert model.boosted_set == "berserkers_crucible" - - def test_required_boosted_affix_is_validated(self) -> None: - model = SealFilterModel( - boostedSet="Berserker's Crucible", boostedAffix="maximum_fury", boostedAffixRequired=True - ) - - assert model.boosted_affix.name == "maximum_fury" - assert model.boosted_affix_required - def test_boosted_sets_are_validated_and_normalized(self) -> None: model = SealFilterModel( boostedSets=[ @@ -665,30 +626,38 @@ def test_boosted_sets_are_validated_and_normalized(self) -> None: assert model.boosted_sets[1].affix.name == "cooldown_reduction" assert not model.boosted_sets[1].required - def test_charm_slots_alias_is_validated(self) -> None: - model = SealFilterModel(charmSlots=6) + def test_slots_default_is_three(self) -> None: + model = SealFilterModel() + + assert model.slots == 3 - assert model.charm_slots == 6 + def test_slots_is_validated(self) -> None: + model = SealFilterModel(slots=6) - def test_required_boosted_affix_needs_set_and_affix(self) -> None: - with pytest.raises(ValidationError, match="boostedAffixRequired needs boostedSet and boostedAffix"): - SealFilterModel(boostedAffixRequired=True) + assert model.slots == 6 + assert model.model_dump()["slots"] == 6 + def test_legacy_charm_slots_aliases_are_validated(self) -> None: + assert SealFilterModel(charmSlots=5).slots == 5 + assert SealFilterModel(charm_slots=6).slots == 6 + + def test_required_boosted_set_affix_needs_affix(self) -> None: with pytest.raises(ValidationError, match="required boostedSets entries need affix"): SealFilterModel(boostedSets=[{"set": "Berserker's Crucible", "required": True}]) - with pytest.raises(ValidationError, match="charm_slots must be between 3 and 6"): - SealFilterModel(charmSlots=-1) + def test_invalid_slot_count_fails(self) -> None: + with pytest.raises(ValidationError, match="slots must be 0 or between 3 and 6"): + SealFilterModel(slots=-1) - with pytest.raises(ValidationError, match="charm_slots must be between 3 and 6"): - SealFilterModel(charmSlots=2) + with pytest.raises(ValidationError, match="slots must be 0 or between 3 and 6"): + SealFilterModel(slots=2) - with pytest.raises(ValidationError, match="charm_slots must be between 3 and 6"): - SealFilterModel(charmSlots=7) + with pytest.raises(ValidationError, match="slots must be 0 or between 3 and 6"): + SealFilterModel(slots=7) def test_invalid_boosted_set_fails(self) -> None: - with pytest.raises(ValidationError, match="boostedSet invalid_set does not exist"): - SealFilterModel(boostedSet="invalid set") + with pytest.raises(ValidationError, match="set invalid_set does not exist"): + SealFilterModel(boostedSets=[{"set": "invalid set"}]) class TestSigilConditionModel: diff --git a/tests/item/filter/filter_test.py b/tests/item/filter/filter_test.py index 9efbe76a..32a5acfb 100644 --- a/tests/item/filter/filter_test.py +++ b/tests/item/filter/filter_test.py @@ -121,6 +121,7 @@ def test_tributes(_name: str, result: list[str], item: Item, mocker: MockerFixtu item_type=ItemType.HoradricSeal, name="unimportant_seal_name", rarity=ItemRarity.Legendary, + charm_slots=3, affixes=[Affix(name="cooldown_reduction")], ), "seal_filters", @@ -211,10 +212,13 @@ def test_seal_filter_matches_boosted_set(mocker: MockerFixture): item_type=ItemType.HoradricSeal, name="unimportant_seal_name", rarity=ItemRarity.Legendary, + charm_slots=3, boosted_set_name="berserkers_crucible", affixes=[Affix(name="maximum_fury")], ) - seal_filter = SealFilterModel(boostedSet="Berserker's Crucible", affixPool=[{"count": ["maximum_fury"]}]) + seal_filter = SealFilterModel( + boostedSets=[{"set": "Berserker's Crucible"}], affixPool=[{"count": ["maximum_fury"]}] + ) test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} match = test_filter.should_keep(item).matched[0] @@ -229,13 +233,14 @@ def test_seal_filter_matches_any_boosted_set(mocker: MockerFixture): item_type=ItemType.HoradricSeal, name="unimportant_seal_name", rarity=ItemRarity.Legendary, + charm_slots=3, boosted_sets=[ BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction")), BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury")), ], affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_fury")], ) - seal_filter = SealFilterModel(boostedSet="Berserker's Crucible") + seal_filter = SealFilterModel(boostedSets=[{"set": "Berserker's Crucible"}]) test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} match = test_filter.should_keep(item).matched[0] @@ -257,7 +262,7 @@ def test_seal_filter_matches_charm_slots_and_boosted_set(mocker: MockerFixture): ], affixes=[Affix(name="resource_cost_reduction", value=7.5)], ) - seal_filter = SealFilterModel(charmSlots=6, boostedSets=[{"set": "Habacalva's Cauldron"}]) + seal_filter = SealFilterModel(slots=6, boostedSets=[{"set": "Habacalva's Cauldron"}]) test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} match = test_filter.should_keep(item).matched[0] @@ -280,7 +285,7 @@ def test_seal_filter_rejects_insufficient_charm_slots(mocker: MockerFixture): boosted_sets=[BoostedSet(name="habacalvas_cauldron", affix=Affix(name="life_on_hit", value=255.0))], affixes=[Affix(name="resource_cost_reduction", value=7.5)], ) - seal_filter = SealFilterModel(charmSlots=6, boostedSets=[{"set": "Habacalva's Cauldron"}]) + seal_filter = SealFilterModel(slots=6, boostedSets=[{"set": "Habacalva's Cauldron"}]) test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wrong": seal_filter})]} assert test_filter.should_keep(item).matched == [] @@ -296,7 +301,7 @@ def test_seal_filter_matches_more_charm_slots_than_minimum(mocker: MockerFixture boosted_sets=[BoostedSet(name="habacalvas_cauldron", affix=Affix(name="life_on_hit", value=255.0))], affixes=[Affix(name="resource_cost_reduction", value=7.5)], ) - seal_filter = SealFilterModel(charmSlots=5, boostedSets=[{"set": "Habacalva's Cauldron"}]) + seal_filter = SealFilterModel(slots=5, boostedSets=[{"set": "Habacalva's Cauldron"}]) test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} match = test_filter.should_keep(item).matched[0] @@ -310,6 +315,7 @@ def test_seal_filter_matches_required_boosted_affix(mocker: MockerFixture): item_type=ItemType.HoradricSeal, name="unimportant_seal_name", rarity=ItemRarity.Legendary, + charm_slots=3, boosted_sets=[ BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction"), loc=(10, 20)), BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury"), loc=(50, 60)), @@ -317,7 +323,7 @@ def test_seal_filter_matches_required_boosted_affix(mocker: MockerFixture): affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_fury")], ) seal_filter = SealFilterModel( - boostedSet="Berserker's Crucible", boostedAffix="maximum_fury", boostedAffixRequired=True + boostedSets=[{"set": "Berserker's Crucible", "affix": "maximum_fury", "required": True}] ) test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} @@ -336,6 +342,7 @@ def test_seal_filter_matches_two_boosted_sets_with_required_affixes(mocker: Mock item_type=ItemType.HoradricSeal, name="unimportant_seal_name", rarity=ItemRarity.Legendary, + charm_slots=3, boosted_sets=[ BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction"), loc=(10, 20)), BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury"), loc=(50, 60)), @@ -365,6 +372,7 @@ def test_seal_filter_rejects_missing_second_boosted_set(mocker: MockerFixture): item_type=ItemType.HoradricSeal, name="unimportant_seal_name", rarity=ItemRarity.Legendary, + charm_slots=3, boosted_sets=[BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury"))], affixes=[Affix(name="maximum_fury")], ) @@ -385,10 +393,11 @@ def test_seal_filter_ignores_boosted_affix_when_not_required(mocker: MockerFixtu item_type=ItemType.HoradricSeal, name="unimportant_seal_name", rarity=ItemRarity.Legendary, + charm_slots=3, boosted_sets=[BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury"))], affixes=[Affix(name="maximum_fury")], ) - seal_filter = SealFilterModel(boostedSet="Berserker's Crucible", boostedAffix="cooldown_reduction") + seal_filter = SealFilterModel(boostedSets=[{"set": "Berserker's Crucible", "affix": "cooldown_reduction"}]) test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} match = test_filter.should_keep(item).matched[0] @@ -402,6 +411,7 @@ def test_seal_filter_rejects_wrong_required_boosted_affix(mocker: MockerFixture) item_type=ItemType.HoradricSeal, name="unimportant_seal_name", rarity=ItemRarity.Legendary, + charm_slots=3, boosted_sets=[ BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction")), BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury")), @@ -409,7 +419,7 @@ def test_seal_filter_rejects_wrong_required_boosted_affix(mocker: MockerFixture) affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_fury")], ) seal_filter = SealFilterModel( - boostedSet="Berserker's Crucible", boostedAffix="cooldown_reduction", boostedAffixRequired=True + boostedSets=[{"set": "Berserker's Crucible", "affix": "cooldown_reduction", "required": True}] ) test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wrong": seal_filter})]} @@ -422,10 +432,13 @@ def test_seal_filter_rejects_wrong_boosted_set(mocker: MockerFixture): item_type=ItemType.HoradricSeal, name="unimportant_seal_name", rarity=ItemRarity.Legendary, + charm_slots=3, boosted_set_name="berserkers_crucible", affixes=[Affix(name="maximum_fury")], ) - seal_filter = SealFilterModel(boostedSet="cathans_dauntless_faith", affixPool=[{"count": ["maximum_fury"]}]) + seal_filter = SealFilterModel( + boostedSets=[{"set": "cathans_dauntless_faith"}], affixPool=[{"count": ["maximum_fury"]}] + ) test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wrong": seal_filter})]} assert test_filter.should_keep(item).matched == [] From 78a9491539e16d9e547f56f5588217d4539f1d4c Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Thu, 4 Jun 2026 00:20:49 +0200 Subject: [PATCH 16/39] update15 --- assets/lang/enUS/affixes.json | 2 ++ src/item/descr/read_descr_tts.py | 13 +++++++- src/tools/data/custom_affixes_enUS.json | 5 ++- tests/item/read_descr_season_13_tts_test.py | 37 +++++++++++++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/assets/lang/enUS/affixes.json b/assets/lang/enUS/affixes.json index 1b19fc31..034ffcef 100644 --- a/assets/lang/enUS/affixes.json +++ b/assets/lang/enUS/affixes.json @@ -117,6 +117,7 @@ "chance_when_struck_to_gain_life_as_barrier_for_seconds": "chance when struck to gain life as barrier for seconds", "charge_cooldown_reduction": "charge cooldown reduction", "charge_damage": "charge damage", + "charm_slot": "charm slot", "chill_slow_potency": "chill slow potency", "clash_resource_generation": "clash resource generation", "cold_damage": "cold damage", @@ -141,6 +142,7 @@ "corrupting_damage": "corrupting damage", "counterattack_charges": "counterattack charges", "crackling_energy_damage": "crackling energy damage", + "crafting_material_drop_rate": "crafting material drop rate", "critical_strike_and_vulnerable_damage": "critical strike and vulnerable damage", "critical_strike_chance": "critical strike chance", "critical_strike_chance_against_chilled_enemies": "critical strike chance against chilled enemies", diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index 7f3cfd5f..aab536d0 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -84,7 +84,18 @@ def _get_affix_counts(tts_section: list[str], item: Item, start: int) -> tuple[i affixes_num = 0 if is_seal_or_charm(item.item_type): - return inherent_num, _get_seal_charm_affix_count(tts_section, start) + total = _get_seal_charm_affix_count(tts_section, start) + if item.rarity in [ItemRarity.Unique, ItemRarity.Mythic]: + # Unique/mythic charms include a unique power (aspect) line and + # possibly flavor text that _get_seal_charm_affix_count incorrectly + # counts as affixes. Strip the trailing flavor text (no digits) and + # subtract 1 for the aspect. + lines = tts_section[start : start + total] + if total > 0 and not _has_numbers(lines[-1]): + total -= 1 + if total > 0: + total -= 1 + return inherent_num, total if item.rarity in [ItemRarity.Unique, ItemRarity.Mythic]: # Uniques can have variable amounts of inherents. diff --git a/src/tools/data/custom_affixes_enUS.json b/src/tools/data/custom_affixes_enUS.json index 0967ef42..09dca1dc 100644 --- a/src/tools/data/custom_affixes_enUS.json +++ b/src/tools/data/custom_affixes_enUS.json @@ -1 +1,4 @@ -{} +{ + "charm_slot": "charm slot", + "crafting_material_drop_rate": "crafting material drop rate" +} diff --git a/tests/item/read_descr_season_13_tts_test.py b/tests/item/read_descr_season_13_tts_test.py index 9dc32d0f..6cd3abc1 100644 --- a/tests/item/read_descr_season_13_tts_test.py +++ b/tests/item/read_descr_season_13_tts_test.py @@ -519,6 +519,43 @@ set_name="breath_of_the_frozen_sea", ), ), + ( + [ + "EMBERFURY", + "Unique Charm", + "+8.1% Crafting Material Drop Rate", + "+2 to Shock Skills", + "Overpower increases your Pyromancy Skill damage by 22% and size, Mana cost and Cooldowns by 10%.", + "The greatest power is often the shortest lived.", + "Requires Level 70", + "Right mouse button", + ], + Item( + affixes=[ + Affix( + name="crafting_material_drop_rate", + text="+8.1% Crafting Material Drop Rate", + type=AffixType.greater, + value=8.1, + ), + Affix(name="to_shock_skills", text="+2 to Shock Skills", type=AffixType.greater, value=2.0), + ], + aspect=Aspect( + name="emberfury", + text="Overpower increases your Pyromancy Skill damage by 22% and size, Mana cost and Cooldowns by 10%.", + ), + codex_upgrade=False, + cosmetic_upgrade=False, + inherent=[], + is_in_shop=False, + item_type=ItemType.Charm, + name="emberfury", + original_name="EMBERFURY", + power=None, + rarity=ItemRarity.Unique, + seasonal_attribute=None, + ), + ), ] From f1596d877e79f8dcaa4c69656917ab86e1e98444 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Thu, 4 Jun 2026 00:58:50 +0200 Subject: [PATCH 17/39] update16 --- assets/lang/enUS/affixes.json | 1 + assets/lang/enUS/corrections.json | 1 + src/config/data.py | 7 ++++ src/item/descr/read_descr_tts.py | 29 +++++++++------ src/scripts/__init__.py | 10 ++++-- src/tools/data/custom_affixes_enUS.json | 3 +- tests/item/read_descr_season_13_tts_test.py | 39 +++++++++++++++++++++ 7 files changed, 76 insertions(+), 14 deletions(-) diff --git a/assets/lang/enUS/affixes.json b/assets/lang/enUS/affixes.json index 034ffcef..1ece86e2 100644 --- a/assets/lang/enUS/affixes.json +++ b/assets/lang/enUS/affixes.json @@ -429,6 +429,7 @@ "main_hand_weapon_damage": "main hand weapon damage", "mana_cost_reduction": "mana cost reduction", "mana_on_kill": "mana on kill", + "mana_per_second": "mana per second", "mana_regeneration": "mana regeneration", "marksman_attack_speed_per_precison_stack": "marksman attack speed per precison stack", "marksman_critical_strike_chance": "marksman critical strike chance", diff --git a/assets/lang/enUS/corrections.json b/assets/lang/enUS/corrections.json index dda4f2b2..4bc7a60e 100644 --- a/assets/lang/enUS/corrections.json +++ b/assets/lang/enUS/corrections.json @@ -13,6 +13,7 @@ "@arbarian": "(barbarian", "garbarian": "barbarian", "gorcerer": "sorcerer", + "lighting": "lightning", "mruid": "(druid", "omuid": "(druid", "seythe": "scythe", diff --git a/src/config/data.py b/src/config/data.py index 5ed90888..ed634463 100644 --- a/src/config/data.py +++ b/src/config/data.py @@ -49,6 +49,13 @@ (3730, 1220), (3304, 1218), (3416, 1218), + # Charm slots + (3127, 468), # Top + (3393, 615), # Top-Right + (3393, 911), # Bottom-Right + (3126, 1066), # Bottom (Locked in screenshot) + (2865, 915), # Bottom-Left + (2861, 622), # Top-Left ], window_dimensions=(3840, 2160), ), diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index aab536d0..f98dd695 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -455,10 +455,13 @@ def _get_affixes_from_tts_section(tts_section: list[str], start: int, length: in def _get_aspect_from_tts_section(tts_section: list[str], item: Item, start: int, num_affixes: int): + if item.item_type == ItemType.HoradricSeal and item.rarity not in [ItemRarity.Unique, ItemRarity.Mythic]: + return None # Grab the aspect as well in this case if item.rarity in [ItemRarity.Mythic, ItemRarity.Unique, ItemRarity.Legendary]: aspect_index = start + num_affixes - return tts_section[aspect_index] + if aspect_index < len(tts_section): + return tts_section[aspect_index] return None @@ -480,21 +483,27 @@ def _get_boosted_sets_from_tts_section(tts_section: list[str], start: int, num_a boosted_sets = [] index = start + num_affixes while index < len(tts_section): - set_name = _get_set_name_from_line(tts_section[index]) + line = tts_section[index] + set_name = _get_set_name_from_line(line) if set_name not in Dataloader().set_list: index += 1 continue affix = None - next_index = index + 1 - if ( - next_index < len(tts_section) - and not tts_section[next_index].lower().startswith(_AFFIX_STOP_MARKERS) - and _get_set_name_from_line(tts_section[next_index]) is None - ): - affix = _get_affix_from_text(tts_section[next_index]) + parts = line.split(":", maxsplit=1) + if len(parts) > 1 and any(char.isdigit() for char in parts[1]): + affix = _get_affix_from_text(parts[1].strip()) affix.type = AffixType.normal - index = next_index + else: + next_index = index + 1 + if ( + next_index < len(tts_section) + and not tts_section[next_index].lower().startswith(_AFFIX_STOP_MARKERS) + and _get_set_name_from_line(tts_section[next_index]) is None + ): + affix = _get_affix_from_text(tts_section[next_index]) + affix.type = AffixType.normal + index = next_index boosted_sets.append(BoostedSet(name=set_name, affix=affix)) index += 1 return boosted_sets diff --git a/src/scripts/__init__.py b/src/scripts/__init__.py index c1f5326d..eee59460 100644 --- a/src/scripts/__init__.py +++ b/src/scripts/__init__.py @@ -1,10 +1,14 @@ def correct_name(name: str) -> str | None: if name: + name = name.strip().lower() + from src.dataloader import Dataloader # noqa: PLC0415 + + for err, corr in Dataloader().error_map.items(): + name = name.replace(err, corr) + return ( name - .strip() - .replace(" (CRUCIBLE)", "") # S12 Crucible items are identical to regular uniques - .lower() + .replace(" (crucible)", "") # S12 Crucible items are identical to regular uniques .replace("'", "") .replace(" ", "_") .replace("\xa0", "_") diff --git a/src/tools/data/custom_affixes_enUS.json b/src/tools/data/custom_affixes_enUS.json index 09dca1dc..0fa71916 100644 --- a/src/tools/data/custom_affixes_enUS.json +++ b/src/tools/data/custom_affixes_enUS.json @@ -1,4 +1,5 @@ { "charm_slot": "charm slot", - "crafting_material_drop_rate": "crafting material drop rate" + "crafting_material_drop_rate": "crafting material drop rate", + "mana_per_second": "mana per second" } diff --git a/tests/item/read_descr_season_13_tts_test.py b/tests/item/read_descr_season_13_tts_test.py index 6cd3abc1..7b6096de 100644 --- a/tests/item/read_descr_season_13_tts_test.py +++ b/tests/item/read_descr_season_13_tts_test.py @@ -556,6 +556,45 @@ seasonal_attribute=None, ), ), + ( + [ + "SWIFT HORADRIC SEAL OF CURRENT", + "Legendary Horadric Seal", + "Unlocks 5 Charm Slots", + "10.0% Cooldown Reduction", + "Cains Wild Lighting:. +4 Mana per Second", + "Tal Rasha's Threefold Way: +9% Maximum Mana", + "Right mouse button", + ], + Item( + affixes=[ + Affix(name="cooldown_reduction", text="10.0% Cooldown Reduction", type=AffixType.greater, value=10.0) + ], + aspect=None, + boosted_set_name="cains_wild_lightning", + boosted_sets=[ + BoostedSet( + name="cains_wild_lightning", + affix=Affix(name="mana_per_second", text=". +4 Mana per Second", type=AffixType.normal, value=4.0), + ), + BoostedSet( + name="tal_rashas_threefold_way", + affix=Affix(name="maximum_mana", text="+9% Maximum Mana", type=AffixType.normal, value=9.0), + ), + ], + charm_slots=5, + codex_upgrade=False, + cosmetic_upgrade=False, + inherent=[], + is_in_shop=False, + item_type=ItemType.HoradricSeal, + name="swift_horadric_seal_of_current", + original_name="SWIFT HORADRIC SEAL OF CURRENT", + power=None, + rarity=ItemRarity.Legendary, + seasonal_attribute=None, + ), + ), ] From 29fccd0d2b31218fe63b99df4ae061bf76b4ad50 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Thu, 4 Jun 2026 14:46:40 +0200 Subject: [PATCH 18/39] update17 verified with uv run python -m src.tools.gen_data d4data --- assets/lang/enUS/How to add to these files.md | 19 +++- src/tools/data/custom_affixes_enUS.json | 5 - src/tools/data/custom_enUS.json | 12 ++ src/tools/data/custom_sigils_enUS.json | 5 - src/tools/gen_data.py | 104 +++++++++++++----- 5 files changed, 104 insertions(+), 41 deletions(-) delete mode 100644 src/tools/data/custom_affixes_enUS.json create mode 100644 src/tools/data/custom_enUS.json delete mode 100644 src/tools/data/custom_sigils_enUS.json diff --git a/assets/lang/enUS/How to add to these files.md b/assets/lang/enUS/How to add to these files.md index efb02134..798cd031 100644 --- a/assets/lang/enUS/How to add to these files.md +++ b/assets/lang/enUS/How to add to these files.md @@ -8,9 +8,24 @@ If you want to add data to these files, do the following steps: 1. Run [gen_data.py](/src/tools/gen_data.py). You provide the paths of the above two downloads. For example, you might run: `python gen_data.py C:\Users\you\code\d4data C:\Users\you\code\Diablo4Companion` -If you do not see the new data you're expecting to see, you need to add it to the appropriate custom\_\* file. These files store any additional data that we were not able to find in d4data for any reason. +If you do not see the new data you're expecting to see, you need to add it to the custom override file at [src/tools/data/custom_enUS.json](/src/tools/data/custom_enUS.json). This file stores any additional data that we were not able to find in d4data for any reason. -You can find the custom files in [src/tools/data](/src/tools/data). For example, if you need to add a new aspect, you can add it to custom_aspects_enUS.json. +The custom file is a single JSON object with sections named after the target file. For example: + +```json +{ + "affixes": { "my_affix": "my affix description" }, + "aspects": ["my_new_aspect"], + "sigils": { "dungeons": { "my_dungeon": "my dungeon" } }, + "uniques": { "my_unique": { "num_inherents": 2 } }, + "sets": ["my_set_name"], + "tributes": { "my_tribute": "my tribute" }, + "item_types": { "MyType": "my type" }, + "tooltips": { "MyTooltip": "my tooltip" } +} +``` + +Each section must match the structure of the target file it extends (list or object). Sections that are not present are silently skipped. The only exception is corrections.json, which can be modified directly. If you find a bad TTS name for a unique that is where you fix it. diff --git a/src/tools/data/custom_affixes_enUS.json b/src/tools/data/custom_affixes_enUS.json deleted file mode 100644 index 0fa71916..00000000 --- a/src/tools/data/custom_affixes_enUS.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "charm_slot": "charm slot", - "crafting_material_drop_rate": "crafting material drop rate", - "mana_per_second": "mana per second" -} diff --git a/src/tools/data/custom_enUS.json b/src/tools/data/custom_enUS.json new file mode 100644 index 00000000..a6d80a21 --- /dev/null +++ b/src/tools/data/custom_enUS.json @@ -0,0 +1,12 @@ +{ + "affixes": { + "charm_slot": "charm slot", + "crafting_material_drop_rate": "crafting material drop rate", + "mana_per_second": "mana per second" + }, + "sigils": { + "dungeons": {}, + "major": {}, + "positive": {} + } +} diff --git a/src/tools/data/custom_sigils_enUS.json b/src/tools/data/custom_sigils_enUS.json deleted file mode 100644 index 80d3ff63..00000000 --- a/src/tools/data/custom_sigils_enUS.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "dungeons": {}, - "major": {}, - "positive": {} -} diff --git a/src/tools/gen_data.py b/src/tools/gen_data.py index 21a078d3..f9940f22 100644 --- a/src/tools/gen_data.py +++ b/src/tools/gen_data.py @@ -384,26 +384,80 @@ def generate_affixes(d4data_dir: Path, language: str, output_file: Path | None = key, value = normalised affix_dict[key] = value - merge_custom_affixes(affix_dict, language) + merge_custom_data(affix_dict, "affixes", language) output_path = output_file or D4LF_BASE_DIR / f"assets/lang/{language}/affixes.json" with output_path.open("w", encoding="utf-8") as json_file: json.dump(affix_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True) json_file.write("\n") -def merge_custom_affixes(affix_dict: dict[str, str], language: str): - custom_affixes_file = D4LF_BASE_DIR / f"src/tools/data/custom_affixes_{language}.json" - with custom_affixes_file.open(encoding="utf-8") as file: - data = json.load(file) - for key, value in data.items(): - if key in affix_dict: - if affix_dict[key] == value: - print(f"Affix {key} already exists in affixes.json. Can be deleted from custom json") +def merge_custom_data(data: dict | list, name: str, language: str): + """Merge entries from a custom override file into the generated data. + + Reads the *name* section from a single ``src/tools/data/custom_.json`` + file. The file groups all custom overrides by target (e.g. ``"affixes"``, + ``"sigils"``, ``"aspects"``). + + Supports three data shapes: + - **list**: custom entries are appended (duplicates skipped with a warning). + - **flat dict**: custom key/value pairs are merged (conflicts logged). + - **nested dict** (dict of dicts): merges one level deep (e.g. sigils). + + If the file does not exist or the section is missing, the call is a no-op. + """ + custom_file = D4LF_BASE_DIR / f"src/tools/data/custom_{language}.json" + if not custom_file.exists(): + return + with custom_file.open(encoding="utf-8") as file: + all_custom = json.load(file) + + custom = all_custom.get(name) + if custom is None: + return + + if isinstance(data, list): + _merge_list(data, custom, name) + elif isinstance(data, dict) and custom and all(isinstance(v, dict) for v in custom.values()): + _merge_nested_dict(data, custom, name) + elif isinstance(data, dict): + _merge_flat_dict(data, custom, name) + + +def _merge_list(data: list, custom: list, name: str): + existing = set(data) + for entry in custom: + if entry in existing: + print(f"{name}: '{entry}' already exists. Can be deleted from custom json") + else: + data.append(entry) + + +def _merge_flat_dict(data: dict, custom: dict, name: str): + for key, value in custom.items(): + if key in data: + if data[key] == value: + print(f"{name}: '{key}' already exists. Can be deleted from custom json") + else: + print(f"{name}: '{key}' already exists but with different value") + data[key] = value + else: + data[key] = value + + +def _merge_nested_dict(data: dict, custom: dict, name: str): + for section, entries in custom.items(): + if section not in data: + data[section] = entries + continue + for key, value in entries.items(): + if key in data[section]: + if data[section][key] == value: + print(f"{name}: '{key}' in '{section}' already exists. Can be deleted from custom json") else: - print(f"Affix {key} already exists in affixes.json but with different value") - affix_dict[key] = value + print(f"{name}: '{key}' in '{section}' already exists but with different value") + data[section][key] = value else: - affix_dict[key] = value + data[section][key] = value def get_string_list_name(string_list_file: Path) -> str | None: @@ -464,6 +518,7 @@ def main(d4data_dir: Path): ) tribute_dict[tribute_name.replace(" ", "_").replace("(", "").replace(")", "")] = tribute_name + merge_custom_data(tribute_dict, "tributes", language) with Path(D4LF_BASE_DIR / f"assets/lang/{language}/tributes.json").open("w", encoding="utf-8") as json_file: json.dump(tribute_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True) json_file.write("\n") @@ -486,6 +541,7 @@ def main(d4data_dir: Path): name_str: str = check_ms(data["arStrings"][name_idx]["szText"]).lower().strip() if item_type in whitelist_types: item_typ_dict[item_type] = name_str + merge_custom_data(item_typ_dict, "item_types", language) with Path(D4LF_BASE_DIR / f"assets/lang/{language}/item_types.json").open("w", encoding="utf-8") as json_file: json.dump(item_typ_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True) json_file.write("\n") @@ -499,6 +555,7 @@ def main(d4data_dir: Path): for ar_string in data["arStrings"]: if ar_string["szLabel"] == "ItemPower": tooltip_dict["ItemPower"] = remove_content_in_braces(check_ms(ar_string["szText"].lower())) + merge_custom_data(tooltip_dict, "tooltips", language) with Path(D4LF_BASE_DIR / f"assets/lang/{language}/tooltips.json").open("w", encoding="utf-8") as json_file: json.dump(tooltip_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True) json_file.write("\n") @@ -534,8 +591,9 @@ def generate_aspects(d4data_dir, language): continue aspects_list.append(aspect_name_clean) + merge_custom_data(aspects_list, "aspects", language) + aspects_list.sort() with Path(D4LF_BASE_DIR / f"assets/lang/{language}/aspects.json").open("w", encoding="utf-8") as json_file: - aspects_list.sort() json.dump(aspects_list, json_file, indent=4, ensure_ascii=False, sort_keys=True) json_file.write("\n") @@ -589,22 +647,7 @@ def generate_sigils(d4data_dir, language): if rarity: sigil_rarity_dict[sigil_name_key] = rarity - # Add any sigils we might be missing. Right now, that's none, but we leave the option for the future - with Path(D4LF_BASE_DIR / f"src/tools/data/custom_sigils_{language}.json").open(encoding="utf-8") as file: - data = json.load(file) - for key, values in data.items(): - if key in sigil_dict: - for key2, value2 in values.items(): - if key2 in sigil_dict[key]: - if sigil_dict[key][key2] == value2: - print(f"Sigil {key2} already exists in sigils.json. Can be deleted from custom json") - else: - print(f"Sigil {key2} already exists in sigils.json but with different value") - sigil_dict[key][key2] = value2 - else: - sigil_dict[key][key2] = value2 - else: - sigil_dict[key] = values + merge_custom_data(sigil_dict, "sigils", language) sigil_dict["rarities"] = sigil_rarity_dict @@ -671,6 +714,7 @@ def generate_uniques(d4data_dir, language): unique_dict[name_clean] = {"num_inherents": num_inherents} + merge_custom_data(unique_dict, "uniques", language) with Path(D4LF_BASE_DIR / f"assets/lang/{language}/uniques.json").open("w", encoding="utf-8") as json_file: json.dump(unique_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True) json_file.write("\n") @@ -705,6 +749,8 @@ def generate_sets(d4data_dir, language): sets_list.append(set_name_clean) sets_list = sorted(set(sets_list)) + merge_custom_data(sets_list, "sets", language) + sets_list.sort() with Path(D4LF_BASE_DIR / f"assets/lang/{language}/sets.json").open("w", encoding="utf-8") as json_file: json.dump(sets_list, json_file, indent=4, ensure_ascii=False, sort_keys=True) json_file.write("\n") From 615cd4e6b611efb91eb61b8bb4d03ee7997db74b Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Thu, 4 Jun 2026 15:00:29 +0200 Subject: [PATCH 19/39] update for the common seal --- src/item/descr/read_descr_tts.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index f98dd695..d8073c5e 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -61,6 +61,8 @@ "empty socket", "requires level", "properties lost when equipped", + "cannot salvage", + "sell value", "rampage:", "feast:", "hunger:", From d4ac0f898fa6c34d9fc21699cc2afd530ce9e1cf Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 7 Jun 2026 22:37:03 +0200 Subject: [PATCH 20/39] update 18 getting new data from d4data seals_affixes and charms_affixes all verfied via uv run python -m src.tools.gen_data d4data --- assets/lang/enUS/How to add to these files.md | 22 +- assets/lang/enUS/affixes.json | 11 - assets/lang/enUS/charms_affixes.json | 302 ++++++++++++++++++ assets/lang/enUS/seals_affixes.json | 288 +++++++++++++++++ src/config/profile_models.py | 6 +- src/dataloader.py | 12 + src/gui/profile_editor/affixes_tab.py | 35 +- src/gui/profile_editor/seal_charm_tab.py | 10 +- src/item/descr/read_descr_tts.py | 18 +- src/tools/data/custom_enUS.json | 8 +- src/tools/gen_data.py | 58 +++- src/tools/gen_data_helpers.py | 4 +- 12 files changed, 726 insertions(+), 48 deletions(-) create mode 100644 assets/lang/enUS/charms_affixes.json create mode 100644 assets/lang/enUS/seals_affixes.json diff --git a/assets/lang/enUS/How to add to these files.md b/assets/lang/enUS/How to add to these files.md index 798cd031..a421445b 100644 --- a/assets/lang/enUS/How to add to these files.md +++ b/assets/lang/enUS/How to add to these files.md @@ -14,14 +14,20 @@ The custom file is a single JSON object with sections named after the target fil ```json { - "affixes": { "my_affix": "my affix description" }, - "aspects": ["my_new_aspect"], - "sigils": { "dungeons": { "my_dungeon": "my dungeon" } }, - "uniques": { "my_unique": { "num_inherents": 2 } }, - "sets": ["my_set_name"], - "tributes": { "my_tribute": "my tribute" }, - "item_types": { "MyType": "my type" }, - "tooltips": { "MyTooltip": "my tooltip" } + "affixes": { "mana_per_second": "mana per second" }, + "charms_affixes": { "lucky_hit_up_to_a_chance_to_deal_holy_damage": "lucky hit up to a chance to deal holy damage" }, + "seals_affixes": { "maximum_resolve": "maximum resolve" }, + "aspects": ["aspect_of_inner_calm"], + "sigils": { + "dungeons": { "aldurwood": "aldurwood" }, + "major": { "death_pulse": "death pulse" }, + "positive": { "reduce_cooldowns_on_kill": "reduce cooldowns on kill" } + }, + "uniques": { "harlequin_crest": { "num_inherents": 2 } }, + "sets": ["arms_of_arreat"], + "tributes": { "tribute_of_radiance": "tribute of radiance" }, + "item_types": { "HoradricSeal": "horadric seal" }, + "tooltips": { "ItemPower": "item power" } } ``` diff --git a/assets/lang/enUS/affixes.json b/assets/lang/enUS/affixes.json index 1ece86e2..e0223753 100644 --- a/assets/lang/enUS/affixes.json +++ b/assets/lang/enUS/affixes.json @@ -16,7 +16,6 @@ "armor_and_resistance_to_all_elements_while_you_have_three_or_more_growth_&_decay_witch_powers_equipped_you_gain_maximum_life_and_unhindered": "armor and resistance to all elements while you have three or more growth & decay witch powers equipped you gain maximum life, and unhindered", "armor_in_arbiter_form": "armor in arbiter form", "armor_while_in_human_form": "armor while in human form", - "at_level_)": "at level )", "attack_speed": "attack speed", "attack_speed_for_seconds_after_casting_a_defensive_skill": "attack speed for seconds after casting a defensive skill", "attack_speed_for_seconds_after_dodging_an_attack": "attack speed for seconds after dodging an attack", @@ -83,16 +82,13 @@ "chance_for_brandish_to_deal_double_damage": "chance for brandish to deal double damage", "chance_for_clash_to_deal_double_damage": "chance for clash to deal double damage", "chance_for_concussive_stomp_to_extra_hit": "chance for concussive stomp to extra hit", - "chance_for_core_skills_to_hit_twice": "chance for core skills to hit twice", "chance_for_corpse_explosion_to_deal_double_damage": "chance for corpse explosion to deal double damage", "chance_for_incinerate_to_deal_double_damage": "chance for incinerate to deal double damage", "chance_for_judgement_to_deal_double_damage": "chance for judgement to deal double damage", "chance_for_minion_attacks_to_fortify_you_for_maximum_life": "chance for minion attacks to fortify you for maximum life", "chance_for_payback_to_deal_double_damage": "chance for payback to deal double damage", - "chance_for_pestilent_swarm_to_deal_double_damage": "chance for pestilent swarm to deal double damage", "chance_for_potency_skills_to_deal_double_damage": "chance for potency skills to deal double damage", "chance_for_projectiles_to_cast_twice": "chance for projectiles to cast twice", - "chance_for_rapid_fire_projectiles_to_cast_twice": "chance for rapid fire projectiles to cast twice", "chance_for_ravens_to_deal_double_damage": "chance for ravens to deal double damage", "chance_for_retribution_to_deal_double_damage": "chance for retribution to deal double damage", "chance_for_rock_splitter_to_deal_double_damage": "chance for rock splitter to deal double damage", @@ -102,7 +98,6 @@ "chance_for_shield_charge_to_deal_double_damage": "chance for shield charge to deal double damage", "chance_for_soar_to_deal_double_damage": "chance for soar to deal double damage", "chance_for_soulrift_to_deal_double_damage": "chance for soulrift to deal double damage", - "chance_for_spear_of_the_heavens_to_deal_double_damage": "chance for spear of the heavens to deal double damage", "chance_for_the_devourer_to_deal_double_damage": "chance for the devourer to deal double damage", "chance_for_the_hunter_to_deal_double_damage": "chance for the hunter to deal double damage", "chance_for_the_protector_to_deal_double_damage": "chance for the protector to deal double damage", @@ -311,7 +306,6 @@ "fury_on_kill": "fury on kill", "fury_regeneration": "fury regeneration", "gem_strength_in_this_item": "gem strength in this item", - "gold_drop_rate": "gold drop rate", "golem_active_cooldown_reduction": "golem active cooldown reduction", "golem_damage": "golem damage", "golems_inherit_of_your_thorns": "golems inherit of your thorns", @@ -401,11 +395,9 @@ "lucky_hit_up_to_a_bleeding_damage_over_seconds": "lucky hit up to a bleeding damage over seconds", "lucky_hit_up_to_a_chance_to_apply_a_random_crowd_control_effect_for_seconds": "lucky hit up to a chance to apply a random crowd control effect for seconds", "lucky_hit_up_to_a_chance_to_become_berserking": "lucky hit up to a chance to become berserking", - "lucky_hit_up_to_a_chance_to_chill_for_seconds": "lucky hit up to a chance to chill for seconds", "lucky_hit_up_to_a_chance_to_daze_for_seconds": "lucky hit up to a chance to daze for seconds", "lucky_hit_up_to_a_chance_to_deal_cold_damage": "lucky hit up to a chance to deal cold damage", "lucky_hit_up_to_a_chance_to_deal_fire_damage": "lucky hit up to a chance to deal fire damage", - "lucky_hit_up_to_a_chance_to_deal_holy_damage": "lucky hit up to a chance to deal holy damage", "lucky_hit_up_to_a_chance_to_deal_lightning_damage": "lucky hit up to a chance to deal lightning damage", "lucky_hit_up_to_a_chance_to_deal_physical_damage": "lucky hit up to a chance to deal physical damage", "lucky_hit_up_to_a_chance_to_deal_poison_damage": "lucky hit up to a chance to deal poison damage", @@ -416,12 +408,10 @@ "lucky_hit_up_to_a_chance_to_gain_a_stack_of_frenzy": "lucky hit up to a chance to gain a stack of frenzy", "lucky_hit_up_to_a_chance_to_heal_life": "lucky hit up to a chance to heal life", "lucky_hit_up_to_a_chance_to_immobilize_for_seconds": "lucky hit up to a chance to immobilize for seconds", - "lucky_hit_up_to_a_chance_to_knockback_for_seconds": "lucky hit up to a chance to knockback for seconds", "lucky_hit_up_to_a_chance_to_make_enemies_vulnerable_for_seconds": "lucky hit up to a chance to make enemies vulnerable for seconds", "lucky_hit_up_to_a_chance_to_restore_primary_resource": "lucky hit up to a chance to restore primary resource", "lucky_hit_up_to_a_chance_to_slow_for_seconds": "lucky hit up to a chance to slow for seconds", "lucky_hit_up_to_a_chance_to_stun_for_seconds": "lucky hit up to a chance to stun for seconds", - "lucky_hit_up_to_a_chance_to_taunt_for_seconds": "lucky hit up to a chance to taunt for seconds", "lucky_hit_up_to_a_chance_to_weaken_for_seconds": "lucky hit up to a chance to weaken for seconds", "lucky_hit_up_to_a_damage_for_seconds": "lucky hit up to a damage for seconds", "lunging_strike_healing": "lunging strike healing", @@ -488,7 +478,6 @@ "potency_damage": "potency damage", "potion_capacity": "potion capacity", "potion_drop_rate": "potion drop rate", - "potion_healing": "potion healing", "primary_centipede_spirit_hall_damage": "primary centipede spirit hall damage", "primary_eagle_spirit_hall_damage": "primary eagle spirit hall damage", "primary_gorilla_spirit_hall_damage": "primary gorilla spirit hall damage", diff --git a/assets/lang/enUS/charms_affixes.json b/assets/lang/enUS/charms_affixes.json new file mode 100644 index 00000000..d3b41f1e --- /dev/null +++ b/assets/lang/enUS/charms_affixes.json @@ -0,0 +1,302 @@ +{ + "all_stats": "all stats", + "all_stats_per_ferocity_or_resolve_stack": "all stats per ferocity or resolve stack", + "attack_speed": "attack speed", + "barrier_generation": "barrier generation", + "bonus_kill_experience": "bonus kill experience", + "call_of_the_ancients_cooldown_reduction": "call of the ancients cooldown reduction", + "chance_for_arbiter_to_deal_double_damage": "chance for arbiter to deal double damage", + "chance_for_basic_skills_to_deal_double_damage": "chance for basic skills to deal double damage", + "chance_for_core_skills_to_hit_twice": "chance for core skills to hit twice", + "chance_for_judgement_to_deal_double_damage": "chance for judgement to deal double damage", + "chance_for_pestilent_swarm_to_deal_double_damage": "chance for pestilent swarm to deal double damage", + "chance_for_potency_skills_to_deal_double_damage": "chance for potency skills to deal double damage", + "chance_for_rapid_fire_projectiles_to_cast_twice": "chance for rapid fire projectiles to cast twice", + "chance_for_retribution_to_deal_double_damage": "chance for retribution to deal double damage", + "chance_for_spear_of_the_heavens_to_deal_double_damage": "chance for spear of the heavens to deal double damage", + "cold_resistance": "cold resistance", + "cooldown_reduction": "cooldown reduction", + "crafting_material_drop_rate": "crafting material drop rate", + "critical_strike_chance": "critical strike chance", + "critical_strike_chance_against_chilled_enemies": "critical strike chance against chilled enemies", + "critical_strike_chance_against_feared_enemies": "critical strike chance against feared enemies", + "critical_strike_damage": "critical strike damage", + "damage_for_seconds_after_dodging_an_attack": "damage for seconds after dodging an attack", + "damage_over_time": "damage over time", + "damage_per_combo_point_spent": "damage per combo point spent", + "damage_reduction_while_injured": "damage reduction while injured", + "damage_reduction_while_unstoppable": "damage reduction while unstoppable", + "damage_to_elites": "damage to elites", + "damage_to_trapped_enemies": "damage to trapped enemies", + "damage_while_berserking": "damage while berserking", + "dexterity": "dexterity", + "fire_and_cold_damage": "fire and cold damage", + "fire_damage": "fire damage", + "fire_resistance": "fire resistance", + "fortify_generation": "fortify generation", + "fury_regeneration": "fury regeneration", + "gold_drop_rate": "gold drop rate", + "healing_received": "healing received", + "impairment_reduction": "impairment reduction", + "intelligence": "intelligence", + "lightning_resistance": "lightning resistance", + "lucky_hit_chance": "lucky hit chance", + "lucky_hit_up_to_a_chance_to_chill_for_seconds": "lucky hit up to a chance to chill for seconds", + "lucky_hit_up_to_a_chance_to_daze_for_seconds": "lucky hit up to a chance to daze for seconds", + "lucky_hit_up_to_a_chance_to_deal_cold_damage": "lucky hit up to a chance to deal cold damage", + "lucky_hit_up_to_a_chance_to_deal_fire_damage": "lucky hit up to a chance to deal fire damage", + "lucky_hit_up_to_a_chance_to_deal_holy_damage": "lucky hit up to a chance to deal holy damage", + "lucky_hit_up_to_a_chance_to_deal_lightning_damage": "lucky hit up to a chance to deal lightning damage", + "lucky_hit_up_to_a_chance_to_deal_physical_damage": "lucky hit up to a chance to deal physical damage", + "lucky_hit_up_to_a_chance_to_deal_poison_damage": "lucky hit up to a chance to deal poison damage", + "lucky_hit_up_to_a_chance_to_deal_shadow_damage": "lucky hit up to a chance to deal shadow damage", + "lucky_hit_up_to_a_chance_to_fear_for_seconds": "lucky hit up to a chance to fear for seconds", + "lucky_hit_up_to_a_chance_to_freeze_for_seconds": "lucky hit up to a chance to freeze for seconds", + "lucky_hit_up_to_a_chance_to_immobilize_for_seconds": "lucky hit up to a chance to immobilize for seconds", + "lucky_hit_up_to_a_chance_to_knockback_for_seconds": "lucky hit up to a chance to knockback for seconds", + "lucky_hit_up_to_a_chance_to_make_enemies_vulnerable_for_seconds": "lucky hit up to a chance to make enemies vulnerable for seconds", + "lucky_hit_up_to_a_chance_to_slow_for_seconds": "lucky hit up to a chance to slow for seconds", + "lucky_hit_up_to_a_chance_to_stun_for_seconds": "lucky hit up to a chance to stun for seconds", + "lucky_hit_up_to_a_chance_to_taunt_for_seconds": "lucky hit up to a chance to taunt for seconds", + "maximum_life": "maximum life", + "maximum_resolve_stacks": "maximum resolve stacks", + "maximum_resource": "maximum resource", + "mobility_cooldown_reduction": "mobility cooldown reduction", + "movement_speed": "movement speed", + "movement_speed_for_seconds_after_killing_an_elite": "movement speed for seconds after killing an elite", + "physical_damage": "physical damage", + "physical_resistance": "physical resistance", + "poison_damage_over_time_duration": "poison damage over time duration", + "poison_resistance": "poison resistance", + "potion_healing": "potion healing", + "resistance_to_all_elements": "resistance to all elements", + "resource_generation": "resource generation", + "shadow_resistance": "shadow resistance", + "strength": "strength", + "stun_grenade_size": "stun grenade size", + "subterfuge_cooldown_reduction": "subterfuge cooldown reduction", + "thorns": "thorns", + "to_abyss_skills": "to abyss skills", + "to_advance": "to advance", + "to_aegis": "to aegis", + "to_agility_skills": "to agility skills", + "to_arc_lash": "to arc lash", + "to_archfiend_skills": "to archfiend skills", + "to_armored_hide": "to armored hide", + "to_aura_skills": "to aura skills", + "to_ball_lightning": "to ball lightning", + "to_barrage": "to barrage", + "to_bash": "to bash", + "to_basic_skills": "to basic skills", + "to_blade_shift": "to blade shift", + "to_blazing_scream": "to blazing scream", + "to_blessed_hammer": "to blessed hammer", + "to_blessed_shield": "to blessed shield", + "to_blight": "to blight", + "to_blizzard": "to blizzard", + "to_blood_howl": "to blood howl", + "to_blood_lance": "to blood lance", + "to_blood_mist": "to blood mist", + "to_blood_skills": "to blood skills", + "to_blood_surge": "to blood surge", + "to_bombardment": "to bombardment", + "to_bone_prison": "to bone prison", + "to_bone_skills": "to bone skills", + "to_bone_spear": "to bone spear", + "to_bone_spirit": "to bone spirit", + "to_bone_splinters": "to bone splinters", + "to_boulder": "to boulder", + "to_brandish": "to brandish", + "to_brawling_skills": "to brawling skills", + "to_caltrops": "to caltrops", + "to_centipede_skills": "to centipede skills", + "to_chain_lightning": "to chain lightning", + "to_challenging_shout": "to challenging shout", + "to_charge": "to charge", + "to_charged_bolts": "to charged bolts", + "to_clash": "to clash", + "to_claw": "to claw", + "to_cold_imbuement": "to cold imbuement", + "to_combat_skills": "to combat skills", + "to_command_fallen": "to command fallen", + "to_companion_skills": "to companion skills", + "to_concealment": "to concealment", + "to_concussive_stomp": "to concussive stomp", + "to_condemn": "to condemn", + "to_conjuration_skills": "to conjuration skills", + "to_consecration": "to consecration", + "to_core_skills": "to core skills", + "to_corpse_explosion": "to corpse explosion", + "to_corpse_skills": "to corpse skills", + "to_corpse_tendrils": "to corpse tendrils", + "to_counterattack": "to counterattack", + "to_crushing_hand": "to crushing hand", + "to_curse_skills": "to curse skills", + "to_cutthroat_skills": "to cutthroat skills", + "to_cyclone_armor": "to cyclone armor", + "to_dance_of_knives": "to dance of knives", + "to_dark_prison": "to dark prison", + "to_dark_shroud": "to dark shroud", + "to_darkness_skills": "to darkness skills", + "to_dash": "to dash", + "to_death_blow": "to death blow", + "to_debilitating_roar": "to debilitating roar", + "to_decompose": "to decompose", + "to_decrepify": "to decrepify", + "to_defensive_skills": "to defensive skills", + "to_defiance_aura": "to defiance aura", + "to_demonology_skills": "to demonology skills", + "to_disciple_skills": "to disciple skills", + "to_divine_lance": "to divine lance", + "to_doom": "to doom", + "to_double_swing": "to double swing", + "to_dread_claws": "to dread claws", + "to_eagle_skills": "to eagle skills", + "to_earth_skills": "to earth skills", + "to_earth_spike": "to earth spike", + "to_earthen_bulwark": "to earthen bulwark", + "to_falling_star": "to falling star", + "to_familiar": "to familiar", + "to_fanaticism_aura": "to fanaticism aura", + "to_fire_bolt": "to fire bolt", + "to_fireball": "to fireball", + "to_firewall": "to firewall", + "to_flame_shield": "to flame shield", + "to_flay": "to flay", + "to_flurry": "to flurry", + "to_focus_skills": "to focus skills", + "to_forceful_arrow": "to forceful arrow", + "to_frenzy": "to frenzy", + "to_frost_bolt": "to frost bolt", + "to_frost_nova": "to frost nova", + "to_frost_skills": "to frost skills", + "to_frozen_orb": "to frozen orb", + "to_golem": "to golem", + "to_gorilla_skills": "to gorilla skills", + "to_grenade_skills": "to grenade skills", + "to_ground_stomp": "to ground stomp", + "to_hammer_of_the_ancients": "to hammer of the ancients", + "to_heartseeker": "to heartseeker", + "to_hell_fracture": "to hell fracture", + "to_hellfire_skills": "to hellfire skills", + "to_hellion_sting": "to hellion sting", + "to_hemorrhage": "to hemorrhage", + "to_holy_bolt": "to holy bolt", + "to_holy_light_aura": "to holy light aura", + "to_hurricane": "to hurricane", + "to_hydra": "to hydra", + "to_ice_armor": "to ice armor", + "to_ice_blades": "to ice blades", + "to_ice_shards": "to ice shards", + "to_imbuement_skills": "to imbuement skills", + "to_incinerate": "to incinerate", + "to_infernal_breath": "to infernal breath", + "to_invigorating_strike": "to invigorating strike", + "to_iron_maiden": "to iron maiden", + "to_iron_skin": "to iron skin", + "to_jaguar_skills": "to jaguar skills", + "to_judicator_skills": "to judicator skills", + "to_juggernaut_skills": "to juggernaut skills", + "to_justice_skills": "to justice skills", + "to_kick": "to kick", + "to_landslide": "to landslide", + "to_leap": "to leap", + "to_lightning_spear": "to lightning spear", + "to_lightning_storm": "to lightning storm", + "to_lunging_strike": "to lunging strike", + "to_macabre_skills": "to macabre skills", + "to_marksman_skills": "to marksman skills", + "to_martial_skills": "to martial skills", + "to_mastery_skills": "to mastery skills", + "to_maul": "to maul", + "to_meteor": "to meteor", + "to_mighty_throw": "to mighty throw", + "to_minion_skills": "to minion skills", + "to_molten_bomb": "to molten bomb", + "to_nature_magic_skills": "to nature magic skills", + "to_nether_step": "to nether step", + "to_occult_skills": "to occult skills", + "to_payback": "to payback", + "to_penetrating_shot": "to penetrating shot", + "to_poison_creeper": "to poison creeper", + "to_poison_imbuement": "to poison imbuement", + "to_poison_trap": "to poison trap", + "to_potency_skills": "to potency skills", + "to_profane_sentinel": "to profane sentinel", + "to_pulverize": "to pulverize", + "to_puncture": "to puncture", + "to_purify": "to purify", + "to_pyromancy_skills": "to pyromancy skills", + "to_quill_volley": "to quill volley", + "to_rabies": "to rabies", + "to_rake": "to rake", + "to_rally": "to rally", + "to_rallying_cry": "to rallying cry", + "to_rampage": "to rampage", + "to_rapid_fire": "to rapid fire", + "to_ravager": "to ravager", + "to_ravens": "to ravens", + "to_razor_wings": "to razor wings", + "to_reap": "to reap", + "to_rend": "to rend", + "to_rock_splitter": "to rock splitter", + "to_rupture": "to rupture", + "to_rushing_claw": "to rushing claw", + "to_scourge": "to scourge", + "to_sever": "to sever", + "to_shadow_imbuement": "to shadow imbuement", + "to_shadow_step": "to shadow step", + "to_shapeshifting_skills": "to shapeshifting skills", + "to_shield_bash": "to shield bash", + "to_shield_charge": "to shield charge", + "to_shock_skills": "to shock skills", + "to_shred": "to shred", + "to_sigil_of_chaos": "to sigil of chaos", + "to_sigil_of_subversion": "to sigil of subversion", + "to_sigil_of_summons": "to sigil of summons", + "to_skeleton_mage": "to skeleton mage", + "to_skeleton_warrior": "to skeleton warrior", + "to_slashing_skills": "to slashing skills", + "to_smoke_grenade": "to smoke grenade", + "to_soar": "to soar", + "to_spark": "to spark", + "to_spear_of_the_heavens": "to spear of the heavens", + "to_steel_grasp": "to steel grasp", + "to_stinger": "to stinger", + "to_stone_burst": "to stone burst", + "to_storm_skills": "to storm skills", + "to_storm_strike": "to storm strike", + "to_subterfuge_skills": "to subterfuge skills", + "to_teleport": "to teleport", + "to_thrash": "to thrash", + "to_thunderspike": "to thunderspike", + "to_tornado": "to tornado", + "to_tortured_wretch": "to tortured wretch", + "to_touch_of_death": "to touch of death", + "to_toxic_skin": "to toxic skin", + "to_trample": "to trample", + "to_trap_skills": "to trap skills", + "to_twisting_blades": "to twisting blades", + "to_tyrants_grasp": "to tyrants grasp", + "to_umbral_chains": "to umbral chains", + "to_upheaval": "to upheaval", + "to_valor_skills": "to valor skills", + "to_vortex": "to vortex", + "to_wall_of_agony": "to wall of agony", + "to_war_cry": "to war cry", + "to_weapon_mastery_skills": "to weapon mastery skills", + "to_werebear_skills": "to werebear skills", + "to_werewolf_skills": "to werewolf skills", + "to_whirlwind": "to whirlwind", + "to_wind_shear": "to wind shear", + "to_withering_fist": "to withering fist", + "to_wolves": "to wolves", + "to_wrath_skills": "to wrath skills", + "to_zeal": "to zeal", + "to_zealot_skills": "to zealot skills", + "total_armor": "total armor", + "ultimate_cooldown_reduction": "ultimate cooldown reduction", + "ultimate_damage": "ultimate damage", + "vulnerable_damage": "vulnerable damage", + "willpower": "willpower" +} diff --git a/assets/lang/enUS/seals_affixes.json b/assets/lang/enUS/seals_affixes.json new file mode 100644 index 00000000..7210d7f1 --- /dev/null +++ b/assets/lang/enUS/seals_affixes.json @@ -0,0 +1,288 @@ +{ + "abyss_skill_damage": "abyss skill damage", + "agility_skill_damage": "agility skill damage", + "agility_skills": "agility skills", + "all_stats": "all stats", + "ancient_skill_cooldown_reduction": "ancient skill cooldown reduction", + "ancient_skill_cost_reduction": "ancient skill cost reduction", + "ancient_skill_damage": "ancient skill damage", + "armor": "armor", + "armor_while_in_werebear_form": "armor while in werebear form", + "armor_while_in_werewolf_form": "armor while in werewolf form", + "attack_speed": "attack speed", + "attack_speed_while_berserking": "attack speed while berserking", + "attacks_reduce_evades_cooldown_by_seconds": "attacks reduce evades cooldown by seconds", + "aura_potency": "aura potency", + "aura_skills": "aura skills", + "barrier_generation": "barrier generation", + "basic_skill_damage": "basic skill damage", + "berserking_duration": "berserking duration", + "bleeding_damage": "bleeding damage", + "block_chance": "block chance", + "blocked_damage_reduction": "blocked damage reduction", + "blocking_has_chance_to_pulse_your_thorns": "blocking has chance to pulse your thorns", + "blood_hunt_also_increases_your_damage_by": "blood hunt also increases your damage by", + "blood_skill_cooldown_reduction": "blood skill cooldown reduction", + "blood_skill_damage": "blood skill damage", + "bloodletters_flow_chance_to_rupture_enemies": "bloodletters flow chance to rupture enemies", + "bone_skill_cooldown_reduction": "bone skill cooldown reduction", + "bone_skill_damage": "bone skill damage", + "bonus_experience_from_elites": "bonus experience from elites", + "bonus_kill_experience_(_at_level_)": "bonus kill experience ( at level )", + "cannot_have_more_than_sockets_but_can_equip_unique_charms": "cannot have more than sockets, but can equip unique charms", + "centipede_damage": "centipede damage", + "chance_to_twist_enemies_into_lesser_demons": "chance to twist enemies into lesser demons", + "charm_set_spiritborn_of_the_toxic_damage_to_weakened_enemies": "charm set spiritborn of the toxic damage to weakened enemies", + "charm_set_spiritborn_of_the_toxic_poison_damage": "charm set spiritborn of the toxic poison damage", + "charm_slot": "charm slot", + "chill_application": "chill application", + "cold_damage": "cold damage", + "cold_skill_damage": "cold skill damage", + "companion_skill_cooldown_reduction": "companion skill cooldown reduction", + "companion_skill_damage": "companion skill damage", + "cooldown_reduction": "cooldown reduction", + "cooldown_reduction_to_abyss_skills": "cooldown reduction to abyss skills", + "cooldown_reduction_to_weapon_mastery_skills": "cooldown reduction to weapon mastery skills", + "core_skill_damage": "core skill damage", + "corpse_skill_damage": "corpse skill damage", + "critical_strike_chance": "critical strike chance", + "critical_strike_damage": "critical strike damage", + "critical_strikes_with_your_earth_skills_grants_fortify_for_of_your_maximum_life": "critical strikes with your earth skills grants fortify for of your maximum life", + "crowd_control_duration": "crowd control duration", + "cutthroat_skill_cooldown_reduction": "cutthroat skill cooldown reduction", + "cutthroat_skill_damage": "cutthroat skill damage", + "damage": "damage", + "damage_against_burning_enemies": "damage against burning enemies", + "damage_against_cursed_enemies": "damage against cursed enemies", + "damage_for_seconds_after_picking_up_a_blood_orb": "damage for seconds after picking up a blood orb", + "damage_over_time": "damage over time", + "damage_reduction": "damage reduction", + "damage_reduction_from_bleeding_enemies": "damage reduction from bleeding enemies", + "damage_reduction_from_elites": "damage reduction from elites", + "damage_reduction_while_berserking": "damage reduction while berserking", + "damage_reduction_while_in_demonform": "damage reduction while in demonform", + "damage_reduction_while_in_shadowform": "damage reduction while in shadowform", + "damage_reduction_while_moving": "damage reduction while moving", + "damage_to_crowd_controlled_enemies": "damage to crowd controlled enemies", + "damage_to_elites": "damage to elites", + "damage_to_judged_enemies": "damage to judged enemies", + "damage_to_weakened_enemies": "damage to weakened enemies", + "damage_when_swapping_weapons": "damage when swapping weapons", + "damage_while_berserking": "damage while berserking", + "damage_while_call_of_the_ancients_is_active": "damage while call of the ancients is active", + "damage_while_fortified": "damage while fortified", + "damage_while_in_shadowform": "damage while in shadowform", + "damage_while_in_werebear_form": "damage while in werebear form", + "damage_while_in_werewolf_form": "damage while in werewolf form", + "dancing_bolts_gain_critical_strike_chance": "dancing bolts gain critical strike chance", + "darkness_skill_cooldown_reduction": "darkness skill cooldown reduction", + "darkness_skill_damage": "darkness skill damage", + "defensive_cooldown_reduction": "defensive cooldown reduction", + "demonform_damage_bonus": "demonform damage bonus", + "demonology_skill_damage": "demonology skill damage", + "dexterity": "dexterity", + "disciple_skill_cooldown_reduction": "disciple skill cooldown reduction", + "disciple_skill_damage": "disciple skill damage", + "dodge_chance": "dodge chance", + "dominance_generation": "dominance generation", + "eagle_skill_damage": "eagle skill damage", + "earth_skill_damage": "earth skill damage", + "enchantment_damage": "enchantment damage", + "enemies_are_petrified_for_seconds": "enemies are petrified for seconds", + "enemies_poisoned_for_more_than_their_remaining_life_are_instantly_killed_seconds_faster": "enemies poisoned for more than their remaining life are instantly killed seconds faster", + "essence_on_kill": "essence on kill", + "explosions_caused_by_your_feral_rampage_grant_stack_of_ferocity": "explosions caused by your feral rampage grant stack of ferocity", + "faith_regeneration_per_second": "faith regeneration per second", + "feral_rage_triggers_second_faster": "feral rage triggers second faster", + "fire_damage": "fire damage", + "fortification_generation": "fortification generation", + "frost_cooldown_reduction": "frost cooldown reduction", + "fury_generation": "fury generation", + "gain_overpower_each_time_you_petrify_an_enemy": "gain overpower each time you petrify an enemy", + "gain_thorns_while_in_werebear_form": "gain thorns while in werebear form", + "gain_werewolf_skill_cost_reduction_while_you_have_an_active_set_bonus": "gain werewolf skill cost reduction while you have an active set bonus", + "gold_drop_rate": "gold drop rate", + "gorilla_skill_damage": "gorilla skill damage", + "healing_received": "healing received", + "hellfire_skill_damage": "hellfire skill damage", + "holy_damage": "holy damage", + "imbueable_skill_cooldown_reduction": "imbueable skill cooldown reduction", + "imbuement_skills": "imbuement skills", + "impairment_reduction": "impairment reduction", + "incarnate_skill_damage": "incarnate skill damage", + "intelligence": "intelligence", + "jaguar_skill_cast_speed": "jaguar skill cast speed", + "jaguar_skill_critical_strike_chance": "jaguar skill critical strike chance", + "jaguar_skill_damage": "jaguar skill damage", + "judicator_skill_cooldown_reduction": "judicator skill cooldown reduction", + "judicator_skill_damage": "judicator skill damage", + "juggernaut_skill_cooldown_reduction": "juggernaut skill cooldown reduction", + "juggernaut_skill_damage": "juggernaut skill damage", + "killing_a_blood_hunted_target_extends_the_duration_of_your_feral_rage_by_seconds": "killing a blood hunted target extends the duration of your feral rage by seconds", + "killing_a_blood_hunted_target_grants_stack_of_ferocity": "killing a blood hunted target grants stack of ferocity", + "killing_a_petrified_enemy_restores_of_your_maximum_life": "killing a petrified enemy restores of your maximum life", + "killing_an_enemy_extends_your_wild_lightning_by_seconds": "killing an enemy extends your wild lightning by seconds", + "lesser_demon_skill_damage": "lesser demon skill damage", + "life_on_hit": "life on hit", + "life_on_kill": "life on kill", + "lightning_damage": "lightning damage", + "lightning_strikes_grant_you_attack_speed_stacking_up_to_times": "lightning strikes grant you attack speed, stacking up to times", + "lucky_hit_chance": "lucky hit chance", + "lucky_hit_up_to_a_chance_to_become_berserking": "lucky hit up to a chance to become berserking", + "macabre_skill_cooldown_reduction": "macabre skill cooldown reduction", + "macabre_skill_damage": "macabre skill damage", + "mana_per_second": "mana per second", + "mana_when_a_mastery_skill_is_triggered": "mana when a mastery skill is triggered", + "marksman_skill_cooldown_reduction": "marksman skill cooldown reduction", + "marksman_skill_damage": "marksman skill damage", + "marksman_skills": "marksman skills", + "maximum_energy": "maximum energy", + "maximum_essence": "maximum essence", + "maximum_faith": "maximum faith", + "maximum_ferocity": "maximum ferocity", + "maximum_fury": "maximum fury", + "maximum_life": "maximum life", + "maximum_mana": "maximum mana", + "maximum_overpower": "maximum overpower", + "maximum_overpower_stacks": "maximum overpower stacks", + "maximum_resistance_to_all_elements": "maximum resistance to all elements", + "maximum_resolve": "maximum resolve", + "maximum_resolve_stacks": "maximum resolve stacks", + "maximum_resource": "maximum resource", + "maximum_spirit": "maximum spirit", + "maximum_wrath": "maximum wrath", + "minion_attack_speed": "minion attack speed", + "minion_damage": "minion damage", + "movement_speed": "movement speed", + "movement_speed_for_seconds_after_killing_an_elite": "movement speed for seconds after killing an elite", + "movement_speed_while_berserking": "movement speed while berserking", + "movement_speed_while_in_werewolf_form": "movement speed while in werewolf form", + "movement_speed_while_quintessence_is_active": "movement speed while quintessence is active", + "nature_magic_skill_damage": "nature magic skill damage", + "nonphysical_damage": "nonphysical damage", + "occult_skill_damage": "occult skill damage", + "physical_damage": "physical damage", + "poison_damage": "poison damage", + "potion_capacity": "potion capacity", + "primary_resource": "primary resource", + "prowess_duration": "prowess duration", + "pyromancy_cooldown_reduction": "pyromancy cooldown reduction", + "pyromancy_skill_damage": "pyromancy skill damage", + "ranks_to_abyss_skills": "ranks to abyss skills", + "ranks_to_hellfire_skills": "ranks to hellfire skills", + "ranks_to_lesser_demon_skills": "ranks to lesser demon skills", + "ranks_to_occult_skills": "ranks to occult skills", + "ranks_to_sigil_of_chaos": "ranks to sigil of chaos", + "ranks_to_sigil_of_subversion": "ranks to sigil of subversion", + "ranks_to_sigil_of_summons": "ranks to sigil of summons", + "reduces_the_number_of_charms_needed_for_set_bonuses_by_(to_a_minimum_of_)": "reduces the number of charms needed for set bonuses by (to a minimum of )", + "resistance_to_all_elements": "resistance to all elements", + "resource_cost_reduction": "resource cost reduction", + "resource_generation": "resource generation", + "rupture_damage": "rupture damage", + "seconds_rampage_duration": "seconds rampage duration", + "shadow_damage": "shadow damage", + "shock_cooldown_reduction": "shock cooldown reduction", + "shock_skill_damage": "shock skill damage", + "sigil_skill_damage": "sigil skill damage", + "soul_shard_skill_cost_reduction": "soul shard skill cost reduction", + "soul_shard_skill_damage": "soul shard skill damage", + "stoicism_also_grants_damage_reduction_per_stack": "stoicism also grants damage reduction per stack", + "storm_skill_damage": "storm skill damage", + "strength": "strength", + "thorns": "thorns", + "to_all_skills": "to all skills", + "to_ancient_skills": "to ancient skills", + "to_army_of_the_dead": "to army of the dead", + "to_ball_lightning": "to ball lightning", + "to_blizzard": "to blizzard", + "to_call_of_the_ancients": "to call of the ancients", + "to_cyclone_armor": "to cyclone armor", + "to_defiance_aura": "to defiance aura", + "to_familiar": "to familiar", + "to_fanaticism_aura": "to fanaticism aura", + "to_holy_light_aura": "to holy light aura", + "to_meteor": "to meteor", + "to_nearby_enemies": "to nearby enemies", + "to_poison_creeper": "to poison creeper", + "to_ravens": "to ravens", + "to_scourge": "to scourge", + "to_skeleton_mage": "to skeleton mage", + "to_skeleton_warrior": "to skeleton warrior", + "to_spawn_stun_grenades_after_casting_nontrap_skills": "to spawn stun grenades after casting nontrap skills", + "to_wolves": "to wolves", + "to_wrath_of_the_berserker": "to wrath of the berserker", + "total_armor": "total armor", + "trap_skill_damage": "trap skill damage", + "trap_skills": "trap skills", + "ultimate_skill_cooldown_reduction": "ultimate skill cooldown reduction", + "ultimate_skill_damage": "ultimate skill damage", + "ultimate_skills": "ultimate skills", + "versatile_skill_damage": "versatile skill damage", + "vulnerable_damage": "vulnerable damage", + "weapon_mastery_skill_damage": "weapon mastery skill damage", + "werebear_skill_damage": "werebear skill damage", + "werebear_skills_deal_more_damage_while_you_have_an_active_set_bonus": "werebear skills deal more damage while you have an active set bonus", + "werewolf_form_grants_damage_while_you_have_an_active_set_bonus": "werewolf form grants damage while you have an active set bonus", + "werewolf_skill_damage": "werewolf skill damage", + "when_you_gain_a_stack_of_stoicism_gain_damage_for_second": "when you gain a stack of stoicism, gain damage for second", + "while_at_least_might_charms_equipped_all_your_damage_bonuses_are_equal_to_your_highest_damage_type_bonus": "while at least might charms equipped, all your damage bonuses are equal to your highest damage type bonus", + "while_at_least_might_charms_equipped_you_are_immune_to_chill_and_freeze_and_gain_increased_frost_and_lightning_resistance": "while at least might charms equipped, you are immune to chill and freeze, and gain increased frost and lightning resistance", + "while_at_least_protection_charms_equipped_direct_damage_you_would_take_over_of_your_maximum_life_is_staggered_out_over_seconds": "while at least protection charms equipped, direct damage you would take over of your maximum life is staggered out over seconds", + "while_at_least_protection_charms_equipped_gain_increased_armor_from_resolve_but_you_cannot_have_more_than_resolve_stacks": "while at least protection charms equipped, gain increased armor from resolve but you cannot have more than resolve stacks", + "while_at_least_protection_charms_equipped_nearby_vulnerable_enemies_are_also_weakened_and_weakened_enemies_are_also_vulnerable": "while at least protection charms equipped, nearby vulnerable enemies are also weakened, and weakened enemies are also vulnerable", + "while_at_least_protection_charms_equipped_you_are_immune_to_vulnerable_and_gain_increased_physical_and_fire_resistance": "while at least protection charms equipped, you are immune to vulnerable, and gain increased physical and fire resistance", + "while_at_least_wisdom_charms_equipped_disable_frozen_explosive_shock_lance_and_suppressor_affixes_on_nearby_elite_monsters": "while at least wisdom charms equipped, disable frozen, explosive, shock lance, and suppressor affixes on nearby elite monsters", + "while_at_least_wisdom_charms_equipped_you_are_immune_to_blind_and_gain_increased_poison_and_shadow_resistance": "while at least wisdom charms equipped, you are immune to blind, and gain increased poison and shadow resistance", + "while_at_least_wisdom_charms_equipped_you_gain_of_your_maximum_primary_resource_per_second_you_lose_all_primary_resource_when_it_reaches_full_but_gain_increased_damage_for_seconds": "while at least wisdom charms equipped, you gain of your maximum primary resource per second you lose all primary resource when it reaches full but gain increased damage for seconds", + "while_at_least_wisdom_charms_equipped_your_hit_on_elite_enemies_creates_an_attackable_shade_totem_for_seconds_any_base_damage_you_deal_to_it_is_replicated_onto_the_same_enemy_this_effect_has_seconds_cooldown": "while at least wisdom charms equipped, your hit on elite enemies creates an attackable shade totem for seconds any base damage you deal to it is replicated onto the same enemy this effect has seconds cooldown", + "while_bravery_charm_equipped_every_critical_strike_grants_you_critical_strike_damage_for_seconds_up_to": "while bravery charm equipped, every critical strike grants you critical strike damage for seconds, up to", + "while_bravery_charm_equipped_for_every_critical_chance_you_gain_critical_strike_damage": "while bravery charm equipped, for every critical chance you gain critical strike damage", + "while_bravery_charm_equipped_your_critical_strikes_have_chance_to_instantly_kill_injured_nonelite_enemies": "while bravery charm equipped, your critical strikes have chance to instantly kill injured nonelite enemies", + "while_clarity_charm_equipped_spending_primary_resource_increases_your_damage_by_for_seconds_up_to": "while clarity charm equipped, spending primary resource increases your damage by for seconds, up to", + "while_clarity_charm_equipped_you_gain_increased_critical_strike_damage_for_seconds_after_spending_of_your_maximum_primary_resource": "while clarity charm equipped, you gain increased critical strike damage for seconds after spending of your maximum primary resource", + "while_clarity_charm_equipped_your_damage_over_time_effects_deal_increased_damage_for_each_point_of_your_current_primary_resource": "while clarity charm equipped, your damage over time effects deal increased damage for each point of your current primary resource", + "while_determination_charm_equipped_gain_ranks_to_all_your_defensive_skills": "while determination charm equipped, gain ranks to all your defensive skills", + "while_determination_charm_equipped_you_gain_increased_damage_for_seconds_when_losing_a_resolve_stack_up_to": "while determination charm equipped, you gain increased damage for seconds when losing a resolve stack, up to", + "while_determination_charm_equipped_you_have_increased_armor_while_injured": "while determination charm equipped, you have increased armor while injured", + "while_elegance_charm_equipped_casting_a_ultimate_skill_resets_cooldowns_of_all_your_other_skills": "while elegance charm equipped, casting a ultimate skill resets cooldowns of all your other skills", + "while_elegance_charm_equipped_using_a_cooldown_increases_your_resource_generation_by_for_seconds_up_to": "while elegance charm equipped, using a cooldown increases your resource generation by for seconds, up to", + "while_elegance_charm_equipped_using_a_cooldown_reduces_the_cooldown_of_your_reinforcement_mercenary_by_second": "while elegance charm equipped, using a cooldown reduces the cooldown of your reinforcement mercenary by second", + "while_grace_charm_equipped_all_your_resistances_are_evenly_distributed": "while grace charm equipped, all your resistances are evenly distributed", + "while_grace_charm_equipped_removes_all_damage_over_time_and_crowd_control_effects_from_you_every_seconds_while_moving": "while grace charm equipped, removes all damage over time and crowd control effects from you every seconds while moving", + "while_grace_charm_equipped_you_gain_ferocity_stack_for_every_meters_you_travel": "while grace charm equipped, you gain ferocity stack for every meters you travel", + "while_haste_charm_equipped_casting_a_skill_increases_your_attack_speed_by_for_seconds_stacking_up_to": "while haste charm equipped, casting a skill increases your attack speed by for seconds, stacking up to", + "while_haste_charm_equipped_gain_ranks_to_all_your_mobility_skills": "while haste charm equipped, gain ranks to all your mobility skills", + "while_haste_charm_equipped_you_deal_increased_damage_to_enemies_the_further_they_are_from_you_up_to": "while haste charm equipped, you deal increased damage to enemies the further they are from you, up to", + "while_in_a_feral_rage_your_werewolf_skills_gain_attack_speed": "while in a feral rage, your werewolf skills gain attack speed", + "while_lucky_charm_equipped_you_gain_increased_movement_speed_if_there_are_less_than_enemies_nearby_or_increased_damage_otherwise": "while lucky charm equipped, you gain increased movement speed if there are less than enemies nearby, or increased damage otherwise", + "while_lucky_charm_equipped_you_gain_of_your_maximum_primary_resource_when_you_block_or_dodge_can_only_happen_once_every_second": "while lucky charm equipped, you gain of your maximum primary resource when you block or dodge can only happen once every second", + "while_lucky_charm_equipped_your_evade_decreases_the_cooldown_of_a_random_skill_your_equipped_by_seconds": "while lucky charm equipped, your evade decreases the cooldown of a random skill your equipped by seconds", + "while_malice_charm_equipped_enemies_affected_by_your_damage_over_time_effect_have_reduced_attack_and_movement_speed": "while malice charm equipped, enemies affected by your damage over time effect have reduced attack and movement speed", + "while_malice_charm_equipped_you_deal_increased_damage_to_enemies_affected_by_your_damage_over_time_effect": "while malice charm equipped, you deal increased damage to enemies affected by your damage over time effect", + "while_malice_charm_equipped_your_core_skills_have_increased_area_size": "while malice charm equipped, your core skills have increased area size", + "while_renew_charm_equipped_you_gain_healing_potion_charge_every_seconds": "while renew charm equipped, you gain healing potion charge every seconds", + "while_renew_charm_equipped_you_gain_increased_armor_for_seconds_after_drinking_a_healing_potion": "while renew charm equipped, you gain increased armor for seconds after drinking a healing potion", + "while_renew_charm_equipped_your_healing_potion_instantly_heals_you_for_of_its_healing_amount": "while renew charm equipped, your healing potion instantly heals you for of its healing amount", + "while_standing_still_you_deal_increased_damage": "while standing still, you deal increased damage", + "while_tenacity_charm_equipped_casting_defensive_skills_also_clear_all_damage_over_time_effect_from_you": "while tenacity charm equipped, casting defensive skills also clear all damage over time effect from you", + "while_tenacity_charm_equipped_you_gain_increased_damage_while_you_have_barrier_active": "while tenacity charm equipped, you gain increased damage while you have barrier active", + "while_tenacity_charm_equipped_you_gain_of_your_maximum_life_as_barrier_for_seconds_when_you_block_or_dodge_can_only_happen_once_every_second": "while tenacity charm equipped, you gain of your maximum life as barrier for seconds when you block or dodge can only happen once every second", + "while_vitality_charm_equipped_any_overheal_you_received_adds_the_same_amount_to_your_barrier_for_seconds_up_to_of_your_maximum_life": "while vitality charm equipped, any overheal you received adds the same amount to your barrier for seconds, up to of your maximum life", + "while_vitality_charm_equipped_casting_defensive_skill_heals_you_for_of_your_maximum_life": "while vitality charm equipped, casting defensive skill heals you for of your maximum life", + "while_vitality_charm_equipped_you_gain_double_offering_from_helm_runes": "while vitality charm equipped, you gain double offering from helm runes", + "while_wrath_charm_equipped_casting_basic_skill_at_maximum_resource_makes_the_targets_vulnerable_for_seconds": "while wrath charm equipped, casting basic skill at maximum resource makes the targets vulnerable for seconds", + "while_wrath_charm_equipped_spending_primary_resource_creates_an_explosion_that_deals_damage_of_the_type_with_the_highest_bonus_to_all_surrounding_enemies": "while wrath charm equipped, spending primary resource creates an explosion that deals damage of the type with the highest bonus to all surrounding enemies", + "while_wrath_charm_equipped_your_direct_damage_against_a_vulnerable_enemy_is_increased_by_and_removes_the_vulnerable_state": "while wrath charm equipped, your direct damage against a vulnerable enemy is increased by and removes the vulnerable state", + "wild_lightning_summons_extra_dancing_bolts": "wild lightning summons extra dancing bolts", + "willpower": "willpower", + "wrath_generation": "wrath generation", + "you_deal_more_damage_to_fosillized_enemies": "you deal more damage to fosillized enemies", + "you_deal_more_lightning_damage_while_you_have_a_set_bonus_active": "you deal more lightning damage while you have a set bonus active", + "you_gain_movement_speed_while_rampaging": "you gain movement speed while rampaging", + "your_earth_skills_deal_more_damage_to_crowd_controlled_enemies": "your earth skills deal more damage to crowd controlled enemies", + "your_rock_fragments_deal_more_damage": "your rock fragments deal more damage", + "your_storm_skills_cost_more_spirit_but_deal_more_damage": "your storm skills cost more spirit, but deal more damage", + "zealot_skill_cooldown_reduction": "zealot skill cooldown reduction", + "zealot_skill_damage": "zealot skill damage" +} diff --git a/src/config/profile_models.py b/src/config/profile_models.py index 33e70713..792b5948 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -71,7 +71,11 @@ def name_must_exist(cls, name: str) -> str: # This on module level would be a circular import, so we do it lazy for now from src.dataloader import Dataloader # noqa: PLC0415 - if name not in Dataloader().affix_dict: + if ( + name not in Dataloader().affix_dict + and name not in Dataloader().charm_affix_dict + and name not in Dataloader().seal_affix_dict + ): msg = f"affix {name} does not exist" raise ValueError(msg) return name diff --git a/src/dataloader.py b/src/dataloader.py index e52e74c0..d27d47a7 100644 --- a/src/dataloader.py +++ b/src/dataloader.py @@ -14,6 +14,8 @@ class Dataloader: affix_dict = {} + charm_affix_dict = {} + seal_affix_dict = {} affix_sigil_dict = {} affix_sigil_dict_all = {} aspect_list = [] @@ -45,6 +47,16 @@ def load_data(self): ) as f: self.affix_dict: dict = json.load(f) + with pathlib.Path(BASE_DIR / f"assets/lang/{IniConfigLoader().general.language}/seals_affixes.json").open( + encoding="utf-8" + ) as f: + self.seal_affix_dict: dict = json.load(f) + + with pathlib.Path(BASE_DIR / f"assets/lang/{IniConfigLoader().general.language}/charms_affixes.json").open( + encoding="utf-8" + ) as f: + self.charm_affix_dict: dict = json.load(f) + with pathlib.Path(BASE_DIR / f"assets/lang/{IniConfigLoader().general.language}/aspects.json").open( encoding="utf-8" ) as f: diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index 4f6bdcfd..5ebdd6ab 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -31,7 +31,9 @@ AffixFilterCountModel, AffixFilterModel, AspectUniqueFilterModel, + CharmFilterModel, DynamicItemFilterModel, + SealFilterModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import MAX_POWER @@ -62,6 +64,18 @@ def _item_type_summary(item_types: list[ItemType]) -> str: return ", ".join(item_type.value for item_type in item_types) +def _affix_dict_for_widget(widget: QWidget) -> dict[str, str]: + curr = widget + while curr: + config = getattr(curr, "config", None) + if isinstance(config, SealFilterModel): + return Dataloader().seal_affix_dict + if isinstance(config, CharmFilterModel): + return Dataloader().charm_affix_dict + curr = curr.parent() + return Dataloader().affix_dict + + class ItemTypePicker(QDialog): def __init__(self, parent: QWidget, item_types: list[ItemType], selected_item_types: list[ItemType]): super().__init__(parent) @@ -732,13 +746,17 @@ def _refresh_widget_style(self, widget): def add_affix_item(self, affix: AffixFilterModel): item = QListWidgetItem() - widget = AffixWidget(affix) + widget = AffixWidget(affix, self) item.setSizeHint(widget.sizeHint()) self.affix_list.addItem(item) self.affix_list.setItemWidget(item, widget) + def get_affix_dict(self): + return _affix_dict_for_widget(self) + def add_affix(self): - new_affix = AffixFilterModel(name=next(iter(Dataloader().affix_dict.keys())), value=None) + affix_dict = self.get_affix_dict() + new_affix = AffixFilterModel(name=next(iter(affix_dict.keys())), value=None) self.pool.count.append(new_affix) self.add_affix_item(new_affix) @@ -780,16 +798,20 @@ def setup_ui(self): self.setLayout(layout) + def get_affix_dict(self): + return _affix_dict_for_widget(self) + def create_affix_name_combobox(self): self.name_combo = IgnoreScrollWheelComboBox() self.name_combo.setEditable(True) self.name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) self.name_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains) - self.name_combo.addItems(sorted(Dataloader().affix_dict.values())) + affix_dict = self.get_affix_dict() + self.name_combo.addItems(sorted(affix_dict.values())) self.name_combo.setMaximumWidth(600) - if self.affix.name in Dataloader().affix_dict: - self.name_combo.setCurrentText(Dataloader().affix_dict[self.affix.name]) + if self.affix.name in affix_dict: + self.name_combo.setCurrentText(affix_dict[self.affix.name]) # currentIndexChanged misses some editable-combobox keyboard flows. self.name_combo.currentTextChanged.connect(self.update_name) @@ -831,7 +853,8 @@ def create_value_input(self): def update_name(self, current_text=None): """Update the model only when the editable combobox contains a valid affix.""" - reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()} + affix_dict = self.get_affix_dict() + reverse_dict = {v: k for k, v in affix_dict.items()} affix_name = reverse_dict.get(current_text or self.name_combo.currentText()) if affix_name is None: return diff --git a/src/gui/profile_editor/seal_charm_tab.py b/src/gui/profile_editor/seal_charm_tab.py index dee807a3..dd67f231 100644 --- a/src/gui/profile_editor/seal_charm_tab.py +++ b/src/gui/profile_editor/seal_charm_tab.py @@ -225,7 +225,10 @@ def add_affix_pool_item(self, pool: AffixFilterCountModel): QTimer.singleShot(50, container.expand) def add_affix_pool(self): - default_affix = AffixFilterModel(name=next(iter(Dataloader().affix_dict.keys())), value=None) + affix_dict = ( + Dataloader().seal_affix_dict if isinstance(self.config, SealFilterModel) else Dataloader().charm_affix_dict + ) + default_affix = AffixFilterModel(name=next(iter(affix_dict.keys())), value=None) new_pool = AffixFilterCountModel(count=[default_affix], min_count=1, max_count=3) self.config.affix_pool.append(new_pool) self.add_affix_pool_item(new_pool) @@ -472,10 +475,13 @@ def remove_rule(self): self.filters.pop(index) def _default_filter(self) -> SealCharmFilterModel: + affix_dict = ( + Dataloader().seal_affix_dict if self.filter_model == SealFilterModel else Dataloader().charm_affix_dict + ) return self.filter_model( affix_pool=[ AffixFilterCountModel( - count=[AffixFilterModel(name=next(iter(Dataloader().affix_dict.keys())))], min_count=1, max_count=3 + count=[AffixFilterModel(name=next(iter(affix_dict.keys())))], min_count=1, max_count=3 ) ] ) diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index d8073c5e..338a4977 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -165,11 +165,11 @@ def _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item: aspect_text = _get_aspect_from_tts_section(tts_section, item, starting_index, len(affixes)) for i, affix_text in enumerate(affixes): if i < inherent_num: - affix = _get_affix_from_text(affix_text) + affix = _get_affix_from_text(affix_text, item.item_type) affix.type = AffixType.inherent item.inherent.append(affix) elif i < inherent_num + affixes_num: - affix = _get_affix_from_text(affix_text) + affix = _get_affix_from_text(affix_text, item.item_type) item.affixes.append(affix) if item.item_type == ItemType.HoradricSeal: @@ -213,12 +213,12 @@ def _add_affixes_from_tts_mixed( for i, affix_text in enumerate(affixes): bullet_index = affix_bullet_start + i if i < inherent_num: - affix = _get_affix_from_text(affix_text) + affix = _get_affix_from_text(affix_text, item.item_type) affix.type = AffixType.inherent affix.loc = affix_bullets[bullet_index].center item.inherent.append(affix) elif i < inherent_num + affixes_num: - affix = _get_affix_from_text(affix_text) + affix = _get_affix_from_text(affix_text, item.item_type) affix.loc = affix_bullets[bullet_index].center if affix_bullets[bullet_index].name.startswith("greater_affix"): affix.type = AffixType.greater @@ -521,7 +521,7 @@ def _get_set_name_from_line(line: str) -> str | None: return next((set_name for set_name in Dataloader().set_list if set_name in normalized_line), None) -def _get_affix_from_text(text: str) -> Affix: +def _get_affix_from_text(text: str, item_type: ItemType | None = None) -> Affix: result = Affix(text=text) for x in _AFFIX_REPLACEMENTS: text = text.replace(x, "") @@ -560,9 +560,15 @@ def _get_affix_from_text(text: str) -> Affix: if matched_groups.get("onlyvalue") is not None: result.min_value = float(matched_groups.get("onlyvalue")) result.max_value = float(matched_groups.get("onlyvalue")) + affix_dict = Dataloader().affix_dict + if item_type == ItemType.HoradricSeal: + affix_dict = Dataloader().affix_dict | Dataloader().seal_affix_dict + elif item_type == ItemType.Charm: + affix_dict = Dataloader().affix_dict | Dataloader().charm_affix_dict + result.name = rapidfuzz.process.extractOne( keep_letters_and_spaces(_REPLACE_COMPARE_RE.sub("", result.text).strip()), - list(Dataloader().affix_dict), + list(affix_dict), scorer=rapidfuzz.distance.Levenshtein.distance, )[0] return result diff --git a/src/tools/data/custom_enUS.json b/src/tools/data/custom_enUS.json index a6d80a21..469c8467 100644 --- a/src/tools/data/custom_enUS.json +++ b/src/tools/data/custom_enUS.json @@ -1,9 +1,7 @@ { - "affixes": { - "charm_slot": "charm slot", - "crafting_material_drop_rate": "crafting material drop rate", - "mana_per_second": "mana per second" - }, + "affixes": {}, + "charms_affixes": {}, + "seals_affixes": {}, "sigils": { "dungeons": {}, "major": {}, diff --git a/src/tools/gen_data.py b/src/tools/gen_data.py index f9940f22..2df1e7e5 100644 --- a/src/tools/gen_data.py +++ b/src/tools/gen_data.py @@ -50,6 +50,7 @@ def remove_content_in_braces(input_string) -> str: result = re.sub(pattern, "", input_string) pattern = r"\[.*?\]" result = re.sub(pattern, "", result) + result = re.sub(r"\([^()]*#%[^()]*\)", "", result) result = re.sub(r"#%.*?#%", "", result) result = re.sub(r"\|.*?:", "|:", result) result = result.replace("|", "") @@ -334,6 +335,22 @@ def normalise_affix_description(description: str) -> tuple[str, str] | None: return desc.replace(",", "").replace(" ", "_"), desc +def affix_string_description( + affix_name: str, string_list_dir: Path, strip_prefix_pattern: str | None = None +) -> str | None: + string_list_file = string_list_dir / f"Affix_{affix_name}.stl.json" + if not string_list_file.exists(): + return None + + description = string_list_map(string_list_file).get("Desc", "") + if not description: + return None + + if strip_prefix_pattern is not None: + return re.sub(strip_prefix_pattern, "", description, count=1) + return description + + def generate_affixes(d4data_dir: Path, language: str, output_file: Path | None = None): print(f"Gen Affixes for {language} (This one takes a while)") core_toc = load_json_file(d4data_dir / "json/base/CoreTOC.dat.json") @@ -361,12 +378,16 @@ def generate_affixes(d4data_dir: Path, language: str, output_file: Path | None = context["skill_tags_by_sno"] = {int(key) % (2**32): value for key, value in gbid.get("56", {}).items()} affix_dict = {} + seal_dict = {} + charm_dict = {} affix_pattern = "json/base/meta/Affix/*.json" affix_files = sorted(d4data_dir.glob(affix_pattern, case_sensitive=False)) for affix_file in affix_files: affix_data = load_json_file(affix_file) affix_name = Path(affix_data["__fileName__"]).stem - if affix_data.get("eMagicType") != 0: + is_seal_affix = affix_name.startswith("Talisman_SealAffix_") + is_charm_affix = affix_name.startswith("Talisman_Charm_") + if affix_data.get("eMagicType") != 0 and not is_seal_affix: continue if affix_name.startswith("zz"): continue @@ -374,22 +395,45 @@ def generate_affixes(d4data_dir: Path, language: str, output_file: Path | None = continue if affix_name.casefold() == "2HStaff_Unique_AF_001_Int_Decrease".casefold(): continue - if not affix_data.get("ptItemAffixAttributes"): - continue - - description = companion_style_affix_description(affix_data, context, d4data_dir, language) + description = None + if is_seal_affix: + description = affix_string_description(affix_name, string_list_dir, r"^\{c_set\}.*?\{/c\}:\s*") + elif is_charm_affix: + description = affix_string_description(affix_name, string_list_dir) + if description is None: + if affix_data.get("eMagicType") != 0 or not affix_data.get("ptItemAffixAttributes"): + continue + description = companion_style_affix_description(affix_data, context, d4data_dir, language) normalised = normalise_affix_description(description) if normalised is None: continue key, value = normalised - affix_dict[key] = value + if is_seal_affix: + seal_dict[key] = value + elif is_charm_affix: + charm_dict[key] = value + else: + affix_dict[key] = value merge_custom_data(affix_dict, "affixes", language) + merge_custom_data(seal_dict, "seals_affixes", language) + merge_custom_data(charm_dict, "charms_affixes", language) + output_path = output_file or D4LF_BASE_DIR / f"assets/lang/{language}/affixes.json" with output_path.open("w", encoding="utf-8") as json_file: json.dump(affix_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True) json_file.write("\n") + seal_output_path = D4LF_BASE_DIR / f"assets/lang/{language}/seals_affixes.json" + with seal_output_path.open("w", encoding="utf-8") as json_file: + json.dump(seal_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True) + json_file.write("\n") + + charm_output_path = D4LF_BASE_DIR / f"assets/lang/{language}/charms_affixes.json" + with charm_output_path.open("w", encoding="utf-8") as json_file: + json.dump(charm_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True) + json_file.write("\n") + def merge_custom_data(data: dict | list, name: str, language: str): """Merge entries from a custom override file into the generated data. @@ -477,6 +521,8 @@ def main(d4data_dir: Path): for lang in lang_arr: file_names = [ f"assets/lang/{lang}/affixes.json", + f"assets/lang/{lang}/seals_affixes.json", + f"assets/lang/{lang}/charms_affixes.json", f"assets/lang/{lang}/aspects.json", f"assets/lang/{lang}/sets.json", f"assets/lang/{lang}/uniques.json", diff --git a/src/tools/gen_data_helpers.py b/src/tools/gen_data_helpers.py index 037ad888..1c2e00ab 100644 --- a/src/tools/gen_data_helpers.py +++ b/src/tools/gen_data_helpers.py @@ -203,6 +203,7 @@ "Barrier_When_Struck_Percent_Chance": "Barrier_When_Struck_Chance", "Fortified_When_Struck_Percent_Chance": "Fortified_When_Struck_Chance", "Fortified_When_Struck_Amount": "Fortified_When_Struck_Chance", + "Item_Find": "Material_Find", "On_Hit_Damage_Bonus_Proc_Chance": "On_Hit_Damage_Bonus_Combined", "On_Hit_Damage_Bonus_Percent": "On_Hit_Damage_Bonus_Combined", "On_Hit_Damage_Bonus_Duration": "On_Hit_Damage_Bonus_Combined", @@ -220,8 +221,6 @@ ("S12_KillStreak_Feast_Evade", "S12_KillStreak_Feast_Evade"), ("S12_KillStreak_Hunger_KillstreakRep", "S12_KillStreak_Hunger_KillstreakRep"), ("S12_KillStreak_Hunger_KillstreakXP", "S12_KillStreak_Hunger_KillstreakXP"), - ("Talisman_Charm_CraftingMaterialFind", "Item_Find"), - ("Talisman_Charm_CraftingMaterialFind_MagicOnly", "Item_Find"), ("Tempered_Damage_Generic_LuckHit_Weakened_Tier1", "On_Hit_Weakened_Proc_Chance"), ("Tempered_Damage_Generic_LuckHit_Weakened_Tier1", "On_Hit_Weakened_Proc_Duration_Seconds"), ("Tempered_Damage_Generic_LuckHit_Weakened_Tier2", "On_Hit_Weakened_Proc_Chance"), @@ -230,7 +229,6 @@ ("Tempered_Damage_Generic_LuckHit_Weakened_Tier3", "On_Hit_Weakened_Proc_Duration_Seconds"), ("Test_Warlock_SummonConversationChanceIncrease", "Warlock_SummonConversationChanceIncrease"), ("UBERUNIQUE_DamageHealthy_ShatteredVow", "Damage_Bonus_To_High_Health"), - ("UtilityFind", "Item_Find"), ("X2_LifePerHit_2H", "Flat_Hitpoints_On_Hit_Unscaled_By_Player_Health"), ("X2_LifePerHit_Greater", "Flat_Hitpoints_On_Hit_Unscaled_By_Player_Health"), ("X2_Transfiguration_IncreasedRuneOffering", "Condition_Rune_Scalar"), From 8e5c298a1b7a22c2e588d254a124fc17f1d33b5b Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Tue, 9 Jun 2026 20:52:21 +0200 Subject: [PATCH 21/39] update19 verified via uv run python -m src.tools.gen_data d4data --- assets/lang/enUS/affixes.json | 2 - assets/lang/enUS/seals_affixes.json | 523 +++++++++++++++------------- src/tools/gen_data.py | 15 +- tests/tools/gen_data_test.py | 27 ++ 4 files changed, 321 insertions(+), 246 deletions(-) create mode 100644 tests/tools/gen_data_test.py diff --git a/assets/lang/enUS/affixes.json b/assets/lang/enUS/affixes.json index e0223753..2a034a5b 100644 --- a/assets/lang/enUS/affixes.json +++ b/assets/lang/enUS/affixes.json @@ -112,7 +112,6 @@ "chance_when_struck_to_gain_life_as_barrier_for_seconds": "chance when struck to gain life as barrier for seconds", "charge_cooldown_reduction": "charge cooldown reduction", "charge_damage": "charge damage", - "charm_slot": "charm slot", "chill_slow_potency": "chill slow potency", "clash_resource_generation": "clash resource generation", "cold_damage": "cold damage", @@ -419,7 +418,6 @@ "main_hand_weapon_damage": "main hand weapon damage", "mana_cost_reduction": "mana cost reduction", "mana_on_kill": "mana on kill", - "mana_per_second": "mana per second", "mana_regeneration": "mana regeneration", "marksman_attack_speed_per_precison_stack": "marksman attack speed per precison stack", "marksman_critical_strike_chance": "marksman critical strike chance", diff --git a/assets/lang/enUS/seals_affixes.json b/assets/lang/enUS/seals_affixes.json index 7210d7f1..64e01061 100644 --- a/assets/lang/enUS/seals_affixes.json +++ b/assets/lang/enUS/seals_affixes.json @@ -1,288 +1,329 @@ { - "abyss_skill_damage": "abyss skill damage", - "agility_skill_damage": "agility skill damage", - "agility_skills": "agility skills", + "adept_action_damage_reduction_while_moving": "adept action damage reduction while moving", + "adept_action_while_standing_still_you_deal_increased_damage": "adept action while standing still, you deal increased damage", "all_stats": "all stats", - "ancient_skill_cooldown_reduction": "ancient skill cooldown reduction", - "ancient_skill_cost_reduction": "ancient skill cost reduction", - "ancient_skill_damage": "ancient skill damage", - "armor": "armor", - "armor_while_in_werebear_form": "armor while in werebear form", - "armor_while_in_werewolf_form": "armor while in werewolf form", + "applied_alchemy_damage_to_crowd_controlled_enemies": "applied alchemy damage to crowd controlled enemies", + "applied_alchemy_physical_damage": "applied alchemy physical damage", + "applied_alchemy_to_spawn_stun_grenades_after_casting_nontrap_skills": "applied alchemy to spawn stun grenades after casting nontrap skills", + "applied_alchemy_trap_skill_damage": "applied alchemy trap skill damage", + "applied_alchemy_trap_skills": "applied alchemy trap skills", + "applied_alchemy_vulnerable_damage": "applied alchemy vulnerable damage", + "arms_of_arreat_barrier_generation": "arms of arreat barrier generation", + "arms_of_arreat_basic_skill_damage": "arms of arreat basic skill damage", + "arms_of_arreat_cooldown_reduction_to_weapon_mastery_skills": "arms of arreat cooldown reduction to weapon mastery skills", + "arms_of_arreat_damage_when_swapping_weapons": "arms of arreat damage when swapping weapons", + "arms_of_arreat_maximum_resolve": "arms of arreat maximum resolve", + "arms_of_arreat_weapon_mastery_skill_damage": "arms of arreat weapon mastery skill damage", + "art_of_the_bone_weaver_armor": "art of the bone weaver armor", + "art_of_the_bone_weaver_bone_skill_cooldown_reduction": "art of the bone weaver bone skill cooldown reduction", + "art_of_the_bone_weaver_bone_skill_damage": "art of the bone weaver bone skill damage", + "art_of_the_bone_weaver_core_skill_damage": "art of the bone weaver core skill damage", + "art_of_the_bone_weaver_maximum_essence": "art of the bone weaver maximum essence", + "art_of_the_bone_weaver_movement_speed_while_quintessence_is_active": "art of the bone weaver movement speed while quintessence is active", "attack_speed": "attack speed", - "attack_speed_while_berserking": "attack speed while berserking", - "attacks_reduce_evades_cooldown_by_seconds": "attacks reduce evades cooldown by seconds", - "aura_potency": "aura potency", - "aura_skills": "aura skills", + "balazans_bite_centipede_damage": "balazans bite centipede damage", + "balazans_bite_damage_over_time": "balazans bite damage over time", + "balazans_bite_damage_to_weakened_enemies": "balazans bite damage to weakened enemies", + "balazans_bite_enemies_poisoned_for_more_than_their_remaining_life_are_instantly_killed_seconds_faster": "balazans bite enemies poisoned for more than their remaining life are instantly killed seconds faster", + "balazans_bite_healing_received": "balazans bite healing received", + "balazans_bite_poison_damage": "balazans bite poison damage", + "balazans_bite_to_scourge": "balazans bite to scourge", "barrier_generation": "barrier generation", - "basic_skill_damage": "basic skill damage", - "berserking_duration": "berserking duration", - "bleeding_damage": "bleeding damage", - "block_chance": "block chance", - "blocked_damage_reduction": "blocked damage reduction", - "blocking_has_chance_to_pulse_your_thorns": "blocking has chance to pulse your thorns", + "berserkers_crucible_attack_speed_while_berserking": "berserkers crucible attack speed while berserking", + "berserkers_crucible_berserking_duration": "berserkers crucible berserking duration", + "berserkers_crucible_damage_reduction_while_berserking": "berserkers crucible damage reduction while berserking", + "berserkers_crucible_damage_while_berserking": "berserkers crucible damage while berserking", + "berserkers_crucible_lucky_hit_up_to_a_chance_to_become_berserking": "berserkers crucible lucky hit up to a chance to become berserking", + "berserkers_crucible_movement_speed_while_berserking": "berserkers crucible movement speed while berserking", + "berserkers_crucible_to_wrath_of_the_berserker": "berserkers crucible to wrath of the berserker", + "bliss_of_the_multitude_centipede_damage": "bliss of the multitude centipede damage", + "bliss_of_the_multitude_eagle_skill_damage": "bliss of the multitude eagle skill damage", + "bliss_of_the_multitude_gorilla_skill_damage": "bliss of the multitude gorilla skill damage", + "bliss_of_the_multitude_incarnate_skill_damage": "bliss of the multitude incarnate skill damage", + "bliss_of_the_multitude_jaguar_skill_damage": "bliss of the multitude jaguar skill damage", + "bliss_of_the_multitude_ultimate_skill_damage": "bliss of the multitude ultimate skill damage", "blood_hunt_also_increases_your_damage_by": "blood hunt also increases your damage by", - "blood_skill_cooldown_reduction": "blood skill cooldown reduction", - "blood_skill_damage": "blood skill damage", + "bloodletters_flow_bleeding_damage": "bloodletters flow bleeding damage", "bloodletters_flow_chance_to_rupture_enemies": "bloodletters flow chance to rupture enemies", - "bone_skill_cooldown_reduction": "bone skill cooldown reduction", - "bone_skill_damage": "bone skill damage", - "bonus_experience_from_elites": "bonus experience from elites", - "bonus_kill_experience_(_at_level_)": "bonus kill experience ( at level )", + "bloodletters_flow_damage_reduction_from_bleeding_enemies": "bloodletters flow damage reduction from bleeding enemies", + "bloodletters_flow_healing_received": "bloodletters flow healing received", + "bloodletters_flow_maximum_life": "bloodletters flow maximum life", + "bloodletters_flow_rupture_damage": "bloodletters flow rupture damage", + "bloodletters_flow_vulnerable_damage": "bloodletters flow vulnerable damage", + "bone_breaker_maximum_overpower": "bone breaker maximum overpower", + "bone_breaker_resource_generation": "bone breaker resource generation", + "breath_of_the_frozen_sea_barrier_generation": "breath of the frozen sea barrier generation", + "breath_of_the_frozen_sea_chill_application": "breath of the frozen sea chill application", + "breath_of_the_frozen_sea_cold_damage": "breath of the frozen sea cold damage", + "breath_of_the_frozen_sea_cold_skill_damage": "breath of the frozen sea cold skill damage", + "breath_of_the_frozen_sea_damage_to_crowd_controlled_enemies": "breath of the frozen sea damage to crowd controlled enemies", + "breath_of_the_frozen_sea_frost_cooldown_reduction": "breath of the frozen sea frost cooldown reduction", + "bulkathos_pride_ancient_skill_cooldown_reduction": "bulkathos pride ancient skill cooldown reduction", + "bulkathos_pride_ancient_skill_cost_reduction": "bulkathos pride ancient skill cost reduction", + "bulkathos_pride_ancient_skill_damage": "bulkathos pride ancient skill damage", + "bulkathos_pride_damage_while_call_of_the_ancients_is_active": "bulkathos pride damage while call of the ancients is active", + "bulkathos_pride_to_ancient_skills": "bulkathos pride to ancient skills", + "bulkathos_pride_to_call_of_the_ancients": "bulkathos pride to call of the ancients", + "cains_wild_lighting_critical_strike_chance": "cains wild lighting critical strike chance", + "cains_wild_lighting_lightning_damage": "cains wild lighting lightning damage", + "cains_wild_lighting_mana_per_second": "cains wild lighting mana per second", + "cains_wild_lighting_movement_speed": "cains wild lighting movement speed", + "cains_wild_lighting_shock_cooldown_reduction": "cains wild lighting shock cooldown reduction", + "cains_wild_lighting_shock_skill_damage": "cains wild lighting shock skill damage", "cannot_have_more_than_sockets_but_can_equip_unique_charms": "cannot have more than sockets, but can equip unique charms", - "centipede_damage": "centipede damage", - "chance_to_twist_enemies_into_lesser_demons": "chance to twist enemies into lesser demons", + "cathans_dauntless_faith_attack_speed": "cathans dauntless faith attack speed", + "cathans_dauntless_faith_critical_strike_chance": "cathans dauntless faith critical strike chance", + "cathans_dauntless_faith_damage_to_weakened_enemies": "cathans dauntless faith damage to weakened enemies", + "cathans_dauntless_faith_physical_damage": "cathans dauntless faith physical damage", + "cathans_dauntless_faith_zealot_skill_cooldown_reduction": "cathans dauntless faith zealot skill cooldown reduction", + "cathans_dauntless_faith_zealot_skill_damage": "cathans dauntless faith zealot skill damage", + "cathans_iron_conviction_aura_potency": "cathans iron conviction aura potency", + "cathans_iron_conviction_aura_skills": "cathans iron conviction aura skills", + "cathans_iron_conviction_faith_regeneration_per_second": "cathans iron conviction faith regeneration per second", + "cathans_iron_conviction_maximum_faith": "cathans iron conviction maximum faith", + "cathans_iron_conviction_to_defiance_aura": "cathans iron conviction to defiance aura", + "cathans_iron_conviction_to_fanaticism_aura": "cathans iron conviction to fanaticism aura", + "cathans_iron_conviction_to_holy_light_aura": "cathans iron conviction to holy light aura", + "cathans_righteous_will_crowd_control_duration": "cathans righteous will crowd control duration", + "cathans_righteous_will_damage_to_crowd_controlled_enemies": "cathans righteous will damage to crowd controlled enemies", + "cathans_righteous_will_juggernaut_skill_cooldown_reduction": "cathans righteous will juggernaut skill cooldown reduction", + "cathans_righteous_will_juggernaut_skill_damage": "cathans righteous will juggernaut skill damage", + "cathans_righteous_will_maximum_resolve_stacks": "cathans righteous will maximum resolve stacks", + "cathans_righteous_will_thorns": "cathans righteous will thorns", + "chains_of_horazon_attack_speed": "chains of horazon attack speed", + "chains_of_horazon_dominance_generation": "chains of horazon dominance generation", + "chains_of_horazon_maximum_wrath": "chains of horazon maximum wrath", + "chains_of_horazon_soul_shard_skill_cost_reduction": "chains of horazon soul shard skill cost reduction", + "chains_of_horazon_soul_shard_skill_damage": "chains of horazon soul shard skill damage", + "chains_of_horazon_wrath_generation": "chains of horazon wrath generation", "charm_set_spiritborn_of_the_toxic_damage_to_weakened_enemies": "charm set spiritborn of the toxic damage to weakened enemies", "charm_set_spiritborn_of_the_toxic_poison_damage": "charm set spiritborn of the toxic poison damage", "charm_slot": "charm slot", - "chill_application": "chill application", - "cold_damage": "cold damage", - "cold_skill_damage": "cold skill damage", - "companion_skill_cooldown_reduction": "companion skill cooldown reduction", - "companion_skill_damage": "companion skill damage", "cooldown_reduction": "cooldown reduction", - "cooldown_reduction_to_abyss_skills": "cooldown reduction to abyss skills", - "cooldown_reduction_to_weapon_mastery_skills": "cooldown reduction to weapon mastery skills", - "core_skill_damage": "core skill damage", - "corpse_skill_damage": "corpse skill damage", "critical_strike_chance": "critical strike chance", "critical_strike_damage": "critical strike damage", "critical_strikes_with_your_earth_skills_grants_fortify_for_of_your_maximum_life": "critical strikes with your earth skills grants fortify for of your maximum life", - "crowd_control_duration": "crowd control duration", - "cutthroat_skill_cooldown_reduction": "cutthroat skill cooldown reduction", - "cutthroat_skill_damage": "cutthroat skill damage", + "cruel_fate_lucky_hit_chance": "cruel fate lucky hit chance", + "cruel_fate_vulnerable_damage": "cruel fate vulnerable damage", "damage": "damage", - "damage_against_burning_enemies": "damage against burning enemies", - "damage_against_cursed_enemies": "damage against cursed enemies", - "damage_for_seconds_after_picking_up_a_blood_orb": "damage for seconds after picking up a blood orb", - "damage_over_time": "damage over time", - "damage_reduction": "damage reduction", - "damage_reduction_from_bleeding_enemies": "damage reduction from bleeding enemies", - "damage_reduction_from_elites": "damage reduction from elites", - "damage_reduction_while_berserking": "damage reduction while berserking", - "damage_reduction_while_in_demonform": "damage reduction while in demonform", - "damage_reduction_while_in_shadowform": "damage reduction while in shadowform", - "damage_reduction_while_moving": "damage reduction while moving", - "damage_to_crowd_controlled_enemies": "damage to crowd controlled enemies", - "damage_to_elites": "damage to elites", - "damage_to_judged_enemies": "damage to judged enemies", - "damage_to_weakened_enemies": "damage to weakened enemies", - "damage_when_swapping_weapons": "damage when swapping weapons", - "damage_while_berserking": "damage while berserking", - "damage_while_call_of_the_ancients_is_active": "damage while call of the ancients is active", - "damage_while_fortified": "damage while fortified", - "damage_while_in_shadowform": "damage while in shadowform", - "damage_while_in_werebear_form": "damage while in werebear form", - "damage_while_in_werewolf_form": "damage while in werewolf form", "dancing_bolts_gain_critical_strike_chance": "dancing bolts gain critical strike chance", - "darkness_skill_cooldown_reduction": "darkness skill cooldown reduction", - "darkness_skill_damage": "darkness skill damage", - "defensive_cooldown_reduction": "defensive cooldown reduction", - "demonform_damage_bonus": "demonform damage bonus", - "demonology_skill_damage": "demonology skill damage", + "dark_pact_damage": "dark pact damage", + "dark_pact_damage_over_time": "dark pact damage over time", + "dark_pact_lucky_hit_chance": "dark pact lucky hit chance", + "dark_pact_nonphysical_damage": "dark pact nonphysical damage", + "deceptive_insight_primary_resource": "deceptive insight primary resource", + "deceptive_insight_resource_cost_reduction": "deceptive insight resource cost reduction", "dexterity": "dexterity", - "disciple_skill_cooldown_reduction": "disciple skill cooldown reduction", - "disciple_skill_damage": "disciple skill damage", - "dodge_chance": "dodge chance", - "dominance_generation": "dominance generation", - "eagle_skill_damage": "eagle skill damage", - "earth_skill_damage": "earth skill damage", - "enchantment_damage": "enchantment damage", - "enemies_are_petrified_for_seconds": "enemies are petrified for seconds", - "enemies_poisoned_for_more_than_their_remaining_life_are_instantly_killed_seconds_faster": "enemies poisoned for more than their remaining life are instantly killed seconds faster", - "essence_on_kill": "essence on kill", "explosions_caused_by_your_feral_rampage_grant_stack_of_ferocity": "explosions caused by your feral rampage grant stack of ferocity", - "faith_regeneration_per_second": "faith regeneration per second", - "feral_rage_triggers_second_faster": "feral rage triggers second faster", - "fire_damage": "fire damage", - "fortification_generation": "fortification generation", - "frost_cooldown_reduction": "frost cooldown reduction", - "fury_generation": "fury generation", + "flesh_of_abaddon_damage_reduction_while_in_demonform": "flesh of abaddon damage reduction while in demonform", + "flesh_of_abaddon_demonform_damage_bonus": "flesh of abaddon demonform damage bonus", + "flesh_of_abaddon_fire_damage": "flesh of abaddon fire damage", + "flesh_of_abaddon_hellfire_skill_damage": "flesh of abaddon hellfire skill damage", + "flesh_of_abaddon_maximum_life": "flesh of abaddon maximum life", + "flesh_of_abaddon_ranks_to_hellfire_skills": "flesh of abaddon ranks to hellfire skills", + "fulcrum_of_mefis_attack_speed": "fulcrum of mefis attack speed", + "fulcrum_of_mefis_chance_to_twist_enemies_into_lesser_demons": "fulcrum of mefis chance to twist enemies into lesser demons", + "fulcrum_of_mefis_demonology_skill_damage": "fulcrum of mefis demonology skill damage", + "fulcrum_of_mefis_lesser_demon_skill_damage": "fulcrum of mefis lesser demon skill damage", + "fulcrum_of_mefis_maximum_wrath": "fulcrum of mefis maximum wrath", + "fulcrum_of_mefis_ranks_to_lesser_demon_skills": "fulcrum of mefis ranks to lesser demon skills", "gain_overpower_each_time_you_petrify_an_enemy": "gain overpower each time you petrify an enemy", "gain_thorns_while_in_werebear_form": "gain thorns while in werebear form", "gain_werewolf_skill_cost_reduction_while_you_have_an_active_set_bonus": "gain werewolf skill cost reduction while you have an active set bonus", - "gold_drop_rate": "gold drop rate", - "gorilla_skill_damage": "gorilla skill damage", + "habacalvas_cauldron_damage_against_burning_enemies": "habacalvas cauldron damage against burning enemies", + "habacalvas_cauldron_fire_damage": "habacalvas cauldron fire damage", + "habacalvas_cauldron_healing_received": "habacalvas cauldron healing received", + "habacalvas_cauldron_life_on_hit": "habacalvas cauldron life on hit", + "habacalvas_cauldron_pyromancy_cooldown_reduction": "habacalvas cauldron pyromancy cooldown reduction", + "habacalvas_cauldron_pyromancy_skill_damage": "habacalvas cauldron pyromancy skill damage", "healing_received": "healing received", - "hellfire_skill_damage": "hellfire skill damage", - "holy_damage": "holy damage", - "imbueable_skill_cooldown_reduction": "imbueable skill cooldown reduction", - "imbuement_skills": "imbuement skills", + "heavens_radiant_fire_damage_to_judged_enemies": "heavens radiant fire damage to judged enemies", + "heavens_radiant_fire_healing_received": "heavens radiant fire healing received", + "heavens_radiant_fire_holy_damage": "heavens radiant fire holy damage", + "heavens_radiant_fire_judicator_skill_cooldown_reduction": "heavens radiant fire judicator skill cooldown reduction", + "heavens_radiant_fire_judicator_skill_damage": "heavens radiant fire judicator skill damage", + "heavens_radiant_fire_maximum_life": "heavens radiant fire maximum life", "impairment_reduction": "impairment reduction", - "incarnate_skill_damage": "incarnate skill damage", "intelligence": "intelligence", - "jaguar_skill_cast_speed": "jaguar skill cast speed", - "jaguar_skill_critical_strike_chance": "jaguar skill critical strike chance", - "jaguar_skill_damage": "jaguar skill damage", - "judicator_skill_cooldown_reduction": "judicator skill cooldown reduction", - "judicator_skill_damage": "judicator skill damage", - "juggernaut_skill_cooldown_reduction": "juggernaut skill cooldown reduction", - "juggernaut_skill_damage": "juggernaut skill damage", "killing_a_blood_hunted_target_extends_the_duration_of_your_feral_rage_by_seconds": "killing a blood hunted target extends the duration of your feral rage by seconds", "killing_a_blood_hunted_target_grants_stack_of_ferocity": "killing a blood hunted target grants stack of ferocity", "killing_a_petrified_enemy_restores_of_your_maximum_life": "killing a petrified enemy restores of your maximum life", "killing_an_enemy_extends_your_wild_lightning_by_seconds": "killing an enemy extends your wild lightning by seconds", - "lesser_demon_skill_damage": "lesser demon skill damage", - "life_on_hit": "life on hit", - "life_on_kill": "life on kill", - "lightning_damage": "lightning damage", + "kwatlis_grace_attacks_reduce_evades_cooldown_by_seconds": "kwatlis grace attacks reduce evades cooldown by seconds", + "kwatlis_grace_damage_reduction_while_moving": "kwatlis grace damage reduction while moving", + "kwatlis_grace_eagle_skill_damage": "kwatlis grace eagle skill damage", + "kwatlis_grace_lightning_damage": "kwatlis grace lightning damage", + "kwatlis_grace_movement_speed": "kwatlis grace movement speed", + "kwatlis_grace_vulnerable_damage": "kwatlis grace vulnerable damage", + "legacy_of_the_sightless_maximum_energy": "legacy of the sightless maximum energy", + "legacy_of_the_sightless_maximum_life": "legacy of the sightless maximum life", + "legacy_of_the_sightless_maximum_overpower_stacks": "legacy of the sightless maximum overpower stacks", + "legacy_of_the_sightless_ultimate_skill_cooldown_reduction": "legacy of the sightless ultimate skill cooldown reduction", + "legacy_of_the_sightless_ultimate_skill_damage": "legacy of the sightless ultimate skill damage", + "legacy_of_the_sightless_ultimate_skills": "legacy of the sightless ultimate skills", + "lethality_critical_strike_chance": "lethality critical strike chance", + "lethality_critical_strike_damage": "lethality critical strike damage", "lightning_strikes_grant_you_attack_speed_stacking_up_to_times": "lightning strikes grant you attack speed, stacking up to times", - "lucky_hit_chance": "lucky hit chance", - "lucky_hit_up_to_a_chance_to_become_berserking": "lucky hit up to a chance to become berserking", - "macabre_skill_cooldown_reduction": "macabre skill cooldown reduction", - "macabre_skill_damage": "macabre skill damage", - "mana_per_second": "mana per second", - "mana_when_a_mastery_skill_is_triggered": "mana when a mastery skill is triggered", - "marksman_skill_cooldown_reduction": "marksman skill cooldown reduction", - "marksman_skill_damage": "marksman skill damage", - "marksman_skills": "marksman skills", - "maximum_energy": "maximum energy", - "maximum_essence": "maximum essence", - "maximum_faith": "maximum faith", - "maximum_ferocity": "maximum ferocity", - "maximum_fury": "maximum fury", + "lights_epiphany_disciple_skill_cooldown_reduction": "lights epiphany disciple skill cooldown reduction", + "lights_epiphany_disciple_skill_damage": "lights epiphany disciple skill damage", + "lights_epiphany_movement_speed": "lights epiphany movement speed", + "lights_epiphany_ultimate_skill_damage": "lights epiphany ultimate skill damage", + "lights_epiphany_ultimate_skills": "lights epiphany ultimate skills", + "lights_epiphany_vulnerable_damage": "lights epiphany vulnerable damage", + "mastery_basic_skill_damage": "mastery basic skill damage", + "mastery_core_skill_damage": "mastery core skill damage", + "mastery_to_all_skills": "mastery to all skills", "maximum_life": "maximum life", - "maximum_mana": "maximum mana", - "maximum_overpower": "maximum overpower", - "maximum_overpower_stacks": "maximum overpower stacks", - "maximum_resistance_to_all_elements": "maximum resistance to all elements", - "maximum_resolve": "maximum resolve", - "maximum_resolve_stacks": "maximum resolve stacks", "maximum_resource": "maximum resource", - "maximum_spirit": "maximum spirit", - "maximum_wrath": "maximum wrath", - "minion_attack_speed": "minion attack speed", - "minion_damage": "minion damage", - "movement_speed": "movement speed", - "movement_speed_for_seconds_after_killing_an_elite": "movement speed for seconds after killing an elite", - "movement_speed_while_berserking": "movement speed while berserking", - "movement_speed_while_in_werewolf_form": "movement speed while in werewolf form", - "movement_speed_while_quintessence_is_active": "movement speed while quintessence is active", - "nature_magic_skill_damage": "nature magic skill damage", - "nonphysical_damage": "nonphysical damage", - "occult_skill_damage": "occult skill damage", - "physical_damage": "physical damage", - "poison_damage": "poison damage", - "potion_capacity": "potion capacity", - "primary_resource": "primary resource", - "prowess_duration": "prowess duration", - "pyromancy_cooldown_reduction": "pyromancy cooldown reduction", - "pyromancy_skill_damage": "pyromancy skill damage", - "ranks_to_abyss_skills": "ranks to abyss skills", - "ranks_to_hellfire_skills": "ranks to hellfire skills", - "ranks_to_lesser_demon_skills": "ranks to lesser demon skills", - "ranks_to_occult_skills": "ranks to occult skills", - "ranks_to_sigil_of_chaos": "ranks to sigil of chaos", - "ranks_to_sigil_of_subversion": "ranks to sigil of subversion", - "ranks_to_sigil_of_summons": "ranks to sigil of summons", + "might_of_the_den_mother_armor_while_in_werebear_form": "might of the den mother armor while in werebear form", + "might_of_the_den_mother_attack_speed_while_berserking": "might of the den mother attack speed while berserking", + "might_of_the_den_mother_damage_while_berserking": "might of the den mother damage while berserking", + "might_of_the_den_mother_damage_while_in_werebear_form": "might of the den mother damage while in werebear form", + "might_of_the_den_mother_seconds_rampage_duration": "might of the den mother seconds rampage duration", + "might_of_the_den_mother_werebear_skill_damage": "might of the den mother werebear skill damage", + "nafains_bestiary_companion_skill_cooldown_reduction": "nafains bestiary companion skill cooldown reduction", + "nafains_bestiary_companion_skill_damage": "nafains bestiary companion skill damage", + "nafains_bestiary_to_poison_creeper": "nafains bestiary to poison creeper", + "nafains_bestiary_to_ravens": "nafains bestiary to ravens", + "nafains_bestiary_to_wolves": "nafains bestiary to wolves", + "nafains_bestiary_versatile_skill_damage": "nafains bestiary versatile skill damage", + "nilfurs_narrow_eye_basic_skill_damage": "nilfurs narrow eye basic skill damage", + "nilfurs_narrow_eye_lucky_hit_chance": "nilfurs narrow eye lucky hit chance", + "nilfurs_narrow_eye_marksman_skill_cooldown_reduction": "nilfurs narrow eye marksman skill cooldown reduction", + "nilfurs_narrow_eye_marksman_skill_damage": "nilfurs narrow eye marksman skill damage", + "nilfurs_narrow_eye_marksman_skills": "nilfurs narrow eye marksman skills", + "nilfurs_narrow_eye_movement_speed": "nilfurs narrow eye movement speed", + "peace_of_the_black_shroud_cold_damage": "peace of the black shroud cold damage", + "peace_of_the_black_shroud_damage_to_crowd_controlled_enemies": "peace of the black shroud damage to crowd controlled enemies", + "peace_of_the_black_shroud_darkness_skill_cooldown_reduction": "peace of the black shroud darkness skill cooldown reduction", + "peace_of_the_black_shroud_darkness_skill_damage": "peace of the black shroud darkness skill damage", + "peace_of_the_black_shroud_resistance_to_all_elements": "peace of the black shroud resistance to all elements", + "peace_of_the_black_shroud_shadow_damage": "peace of the black shroud shadow damage", + "practiced_technique_all_stats": "practiced technique all stats", + "practiced_technique_bonus_kill_experience_(_at_level_)": "practiced technique bonus kill experience ( at level )", + "practiced_technique_dexterity": "practiced technique dexterity", + "practiced_technique_gold_drop_rate": "practiced technique gold drop rate", + "practiced_technique_intelligence": "practiced technique intelligence", + "practiced_technique_movement_speed": "practiced technique movement speed", + "practiced_technique_potion_capacity": "practiced technique potion capacity", + "practiced_technique_strength": "practiced technique strength", + "practiced_technique_willpower": "practiced technique willpower", + "radaments_desecration_corpse_skill_damage": "radaments desecration corpse skill damage", + "radaments_desecration_damage_against_cursed_enemies": "radaments desecration damage against cursed enemies", + "radaments_desecration_essence_on_kill": "radaments desecration essence on kill", + "radaments_desecration_macabre_skill_cooldown_reduction": "radaments desecration macabre skill cooldown reduction", + "radaments_desecration_macabre_skill_damage": "radaments desecration macabre skill damage", + "radaments_desecration_ultimate_skill_damage": "radaments desecration ultimate skill damage", + "rathmas_walking_touch_minion_attack_speed": "rathmas walking touch minion attack speed", + "rathmas_walking_touch_minion_damage": "rathmas walking touch minion damage", + "rathmas_walking_touch_physical_damage": "rathmas walking touch physical damage", + "rathmas_walking_touch_to_army_of_the_dead": "rathmas walking touch to army of the dead", + "rathmas_walking_touch_to_skeleton_mage": "rathmas walking touch to skeleton mage", + "rathmas_walking_touch_to_skeleton_warrior": "rathmas walking touch to skeleton warrior", "reduces_the_number_of_charms_needed_for_set_bonuses_by_(to_a_minimum_of_)": "reduces the number of charms needed for set bonuses by (to a minimum of )", "resistance_to_all_elements": "resistance to all elements", "resource_cost_reduction": "resource cost reduction", - "resource_generation": "resource generation", - "rupture_damage": "rupture damage", - "seconds_rampage_duration": "seconds rampage duration", - "shadow_damage": "shadow damage", - "shock_cooldown_reduction": "shock cooldown reduction", - "shock_skill_damage": "shock skill damage", - "sigil_skill_damage": "sigil skill damage", - "soul_shard_skill_cost_reduction": "soul shard skill cost reduction", - "soul_shard_skill_damage": "soul shard skill damage", + "rezokas_rage_dodge_chance": "rezokas rage dodge chance", + "rezokas_rage_jaguar_skill_cast_speed": "rezokas rage jaguar skill cast speed", + "rezokas_rage_jaguar_skill_critical_strike_chance": "rezokas rage jaguar skill critical strike chance", + "rezokas_rage_jaguar_skill_damage": "rezokas rage jaguar skill damage", + "rezokas_rage_maximum_ferocity": "rezokas rage maximum ferocity", + "rezokas_rage_prowess_duration": "rezokas rage prowess duration", + "rite_of_the_nameless_occult_skill_damage": "rite of the nameless occult skill damage", + "rite_of_the_nameless_ranks_to_occult_skills": "rite of the nameless ranks to occult skills", + "rite_of_the_nameless_ranks_to_sigil_of_chaos": "rite of the nameless ranks to sigil of chaos", + "rite_of_the_nameless_ranks_to_sigil_of_subversion": "rite of the nameless ranks to sigil of subversion", + "rite_of_the_nameless_ranks_to_sigil_of_summons": "rite of the nameless ranks to sigil of summons", + "rite_of_the_nameless_sigil_skill_damage": "rite of the nameless sigil skill damage", + "rush_of_the_red_wolf_moon_armor_while_in_werewolf_form": "rush of the red wolf moon armor while in werewolf form", + "rush_of_the_red_wolf_moon_damage_over_time": "rush of the red wolf moon damage over time", + "rush_of_the_red_wolf_moon_damage_while_in_werewolf_form": "rush of the red wolf moon damage while in werewolf form", + "rush_of_the_red_wolf_moon_feral_rage_triggers_second_faster": "rush of the red wolf moon feral rage triggers second faster", + "rush_of_the_red_wolf_moon_movement_speed_while_in_werewolf_form": "rush of the red wolf moon movement speed while in werewolf form", + "rush_of_the_red_wolf_moon_werewolf_skill_damage": "rush of the red wolf moon werewolf skill damage", + "sescherons_fury_attack_speed": "sescherons fury attack speed", + "sescherons_fury_damage": "sescherons fury damage", + "sescherons_fury_damage_reduction": "sescherons fury damage reduction", + "sescherons_fury_fury_generation": "sescherons fury fury generation", + "sescherons_fury_maximum_fury": "sescherons fury maximum fury", + "sescherons_fury_maximum_life": "sescherons fury maximum life", + "shadow_of_harash_abyss_skill_damage": "shadow of harash abyss skill damage", + "shadow_of_harash_cooldown_reduction_to_abyss_skills": "shadow of harash cooldown reduction to abyss skills", + "shadow_of_harash_damage_reduction_while_in_shadowform": "shadow of harash damage reduction while in shadowform", + "shadow_of_harash_damage_while_in_shadowform": "shadow of harash damage while in shadowform", + "shadow_of_harash_ranks_to_abyss_skills": "shadow of harash ranks to abyss skills", + "shadow_of_harash_shadow_damage": "shadow of harash shadow damage", + "slaughter_bonus_experience_from_elites": "slaughter bonus experience from elites", + "slaughter_core_skill_damage": "slaughter core skill damage", + "slaughter_damage": "slaughter damage", + "slaughter_damage_reduction_from_elites": "slaughter damage reduction from elites", + "slaughter_damage_to_elites": "slaughter damage to elites", + "slaughter_movement_speed_for_seconds_after_killing_an_elite": "slaughter movement speed for seconds after killing an elite", + "song_of_the_old_mountain_damage_to_crowd_controlled_enemies": "song of the old mountain damage to crowd controlled enemies", + "song_of_the_old_mountain_damage_while_fortified": "song of the old mountain damage while fortified", + "song_of_the_old_mountain_earth_skill_damage": "song of the old mountain earth skill damage", + "song_of_the_old_mountain_enemies_are_petrified_for_seconds": "song of the old mountain enemies are petrified for seconds", + "song_of_the_old_mountain_nature_magic_skill_damage": "song of the old mountain nature magic skill damage", + "song_of_the_old_mountain_physical_damage": "song of the old mountain physical damage", + "spellbound_steel_cold_damage": "spellbound steel cold damage", + "spellbound_steel_imbueable_skill_cooldown_reduction": "spellbound steel imbueable skill cooldown reduction", + "spellbound_steel_imbuement_skills": "spellbound steel imbuement skills", + "spellbound_steel_poison_damage": "spellbound steel poison damage", + "spellbound_steel_resistance_to_all_elements": "spellbound steel resistance to all elements", + "spellbound_steel_shadow_damage": "spellbound steel shadow damage", "stoicism_also_grants_damage_reduction_per_stack": "stoicism also grants damage reduction per stack", - "storm_skill_damage": "storm skill damage", + "storm_shepherds_call_lightning_damage": "storm shepherds call lightning damage", + "storm_shepherds_call_maximum_spirit": "storm shepherds call maximum spirit", + "storm_shepherds_call_nature_magic_skill_damage": "storm shepherds call nature magic skill damage", + "storm_shepherds_call_physical_damage": "storm shepherds call physical damage", + "storm_shepherds_call_storm_skill_damage": "storm shepherds call storm skill damage", + "storm_shepherds_call_to_cyclone_armor": "storm shepherds call to cyclone armor", "strength": "strength", - "thorns": "thorns", - "to_all_skills": "to all skills", - "to_ancient_skills": "to ancient skills", - "to_army_of_the_dead": "to army of the dead", - "to_ball_lightning": "to ball lightning", - "to_blizzard": "to blizzard", - "to_call_of_the_ancients": "to call of the ancients", - "to_cyclone_armor": "to cyclone armor", - "to_defiance_aura": "to defiance aura", - "to_familiar": "to familiar", - "to_fanaticism_aura": "to fanaticism aura", - "to_holy_light_aura": "to holy light aura", - "to_meteor": "to meteor", - "to_nearby_enemies": "to nearby enemies", - "to_poison_creeper": "to poison creeper", - "to_ravens": "to ravens", - "to_scourge": "to scourge", - "to_skeleton_mage": "to skeleton mage", - "to_skeleton_warrior": "to skeleton warrior", - "to_spawn_stun_grenades_after_casting_nontrap_skills": "to spawn stun grenades after casting nontrap skills", - "to_wolves": "to wolves", - "to_wrath_of_the_berserker": "to wrath of the berserker", + "survival_all_stats": "survival all stats", + "survival_armor": "survival armor", + "survival_maximum_life": "survival maximum life", + "survival_maximum_resistance_to_all_elements": "survival maximum resistance to all elements", + "survivor_healing_received": "survivor healing received", + "survivor_resistance_to_all_elements": "survivor resistance to all elements", + "tal_rashas_threefold_way_damage": "tal rashas threefold way damage", + "tal_rashas_threefold_way_mana_when_a_mastery_skill_is_triggered": "tal rashas threefold way mana when a mastery skill is triggered", + "tal_rashas_threefold_way_maximum_mana": "tal rashas threefold way maximum mana", + "tal_rashas_threefold_way_to_ball_lightning": "tal rashas threefold way to ball lightning", + "tal_rashas_threefold_way_to_blizzard": "tal rashas threefold way to blizzard", + "tal_rashas_threefold_way_to_meteor": "tal rashas threefold way to meteor", + "tirajs_uncanny_insight_core_skill_damage": "tirajs uncanny insight core skill damage", + "tirajs_uncanny_insight_defensive_cooldown_reduction": "tirajs uncanny insight defensive cooldown reduction", + "tirajs_uncanny_insight_enchantment_damage": "tirajs uncanny insight enchantment damage", + "tirajs_uncanny_insight_maximum_life": "tirajs uncanny insight maximum life", + "tirajs_uncanny_insight_resistance_to_all_elements": "tirajs uncanny insight resistance to all elements", + "tirajs_uncanny_insight_to_familiar": "tirajs uncanny insight to familiar", "total_armor": "total armor", - "trap_skill_damage": "trap skill damage", - "trap_skills": "trap skills", - "ultimate_skill_cooldown_reduction": "ultimate skill cooldown reduction", - "ultimate_skill_damage": "ultimate skill damage", - "ultimate_skills": "ultimate skills", - "versatile_skill_damage": "versatile skill damage", - "vulnerable_damage": "vulnerable damage", - "weapon_mastery_skill_damage": "weapon mastery skill damage", - "werebear_skill_damage": "werebear skill damage", + "way_of_the_blurring_blade_agility_skill_damage": "way of the blurring blade agility skill damage", + "way_of_the_blurring_blade_agility_skills": "way of the blurring blade agility skills", + "way_of_the_blurring_blade_critical_strike_damage": "way of the blurring blade critical strike damage", + "way_of_the_blurring_blade_cutthroat_skill_cooldown_reduction": "way of the blurring blade cutthroat skill cooldown reduction", + "way_of_the_blurring_blade_cutthroat_skill_damage": "way of the blurring blade cutthroat skill damage", + "way_of_the_blurring_blade_to_nearby_enemies": "way of the blurring blade to nearby enemies", "werebear_skills_deal_more_damage_while_you_have_an_active_set_bonus": "werebear skills deal more damage while you have an active set bonus", "werewolf_form_grants_damage_while_you_have_an_active_set_bonus": "werewolf form grants damage while you have an active set bonus", - "werewolf_skill_damage": "werewolf skill damage", - "when_you_gain_a_stack_of_stoicism_gain_damage_for_second": "when you gain a stack of stoicism, gain damage for second", - "while_at_least_might_charms_equipped_all_your_damage_bonuses_are_equal_to_your_highest_damage_type_bonus": "while at least might charms equipped, all your damage bonuses are equal to your highest damage type bonus", - "while_at_least_might_charms_equipped_you_are_immune_to_chill_and_freeze_and_gain_increased_frost_and_lightning_resistance": "while at least might charms equipped, you are immune to chill and freeze, and gain increased frost and lightning resistance", - "while_at_least_protection_charms_equipped_direct_damage_you_would_take_over_of_your_maximum_life_is_staggered_out_over_seconds": "while at least protection charms equipped, direct damage you would take over of your maximum life is staggered out over seconds", - "while_at_least_protection_charms_equipped_gain_increased_armor_from_resolve_but_you_cannot_have_more_than_resolve_stacks": "while at least protection charms equipped, gain increased armor from resolve but you cannot have more than resolve stacks", - "while_at_least_protection_charms_equipped_nearby_vulnerable_enemies_are_also_weakened_and_weakened_enemies_are_also_vulnerable": "while at least protection charms equipped, nearby vulnerable enemies are also weakened, and weakened enemies are also vulnerable", - "while_at_least_protection_charms_equipped_you_are_immune_to_vulnerable_and_gain_increased_physical_and_fire_resistance": "while at least protection charms equipped, you are immune to vulnerable, and gain increased physical and fire resistance", - "while_at_least_wisdom_charms_equipped_disable_frozen_explosive_shock_lance_and_suppressor_affixes_on_nearby_elite_monsters": "while at least wisdom charms equipped, disable frozen, explosive, shock lance, and suppressor affixes on nearby elite monsters", - "while_at_least_wisdom_charms_equipped_you_are_immune_to_blind_and_gain_increased_poison_and_shadow_resistance": "while at least wisdom charms equipped, you are immune to blind, and gain increased poison and shadow resistance", - "while_at_least_wisdom_charms_equipped_you_gain_of_your_maximum_primary_resource_per_second_you_lose_all_primary_resource_when_it_reaches_full_but_gain_increased_damage_for_seconds": "while at least wisdom charms equipped, you gain of your maximum primary resource per second you lose all primary resource when it reaches full but gain increased damage for seconds", - "while_at_least_wisdom_charms_equipped_your_hit_on_elite_enemies_creates_an_attackable_shade_totem_for_seconds_any_base_damage_you_deal_to_it_is_replicated_onto_the_same_enemy_this_effect_has_seconds_cooldown": "while at least wisdom charms equipped, your hit on elite enemies creates an attackable shade totem for seconds any base damage you deal to it is replicated onto the same enemy this effect has seconds cooldown", - "while_bravery_charm_equipped_every_critical_strike_grants_you_critical_strike_damage_for_seconds_up_to": "while bravery charm equipped, every critical strike grants you critical strike damage for seconds, up to", - "while_bravery_charm_equipped_for_every_critical_chance_you_gain_critical_strike_damage": "while bravery charm equipped, for every critical chance you gain critical strike damage", - "while_bravery_charm_equipped_your_critical_strikes_have_chance_to_instantly_kill_injured_nonelite_enemies": "while bravery charm equipped, your critical strikes have chance to instantly kill injured nonelite enemies", - "while_clarity_charm_equipped_spending_primary_resource_increases_your_damage_by_for_seconds_up_to": "while clarity charm equipped, spending primary resource increases your damage by for seconds, up to", - "while_clarity_charm_equipped_you_gain_increased_critical_strike_damage_for_seconds_after_spending_of_your_maximum_primary_resource": "while clarity charm equipped, you gain increased critical strike damage for seconds after spending of your maximum primary resource", - "while_clarity_charm_equipped_your_damage_over_time_effects_deal_increased_damage_for_each_point_of_your_current_primary_resource": "while clarity charm equipped, your damage over time effects deal increased damage for each point of your current primary resource", - "while_determination_charm_equipped_gain_ranks_to_all_your_defensive_skills": "while determination charm equipped, gain ranks to all your defensive skills", - "while_determination_charm_equipped_you_gain_increased_damage_for_seconds_when_losing_a_resolve_stack_up_to": "while determination charm equipped, you gain increased damage for seconds when losing a resolve stack, up to", - "while_determination_charm_equipped_you_have_increased_armor_while_injured": "while determination charm equipped, you have increased armor while injured", - "while_elegance_charm_equipped_casting_a_ultimate_skill_resets_cooldowns_of_all_your_other_skills": "while elegance charm equipped, casting a ultimate skill resets cooldowns of all your other skills", - "while_elegance_charm_equipped_using_a_cooldown_increases_your_resource_generation_by_for_seconds_up_to": "while elegance charm equipped, using a cooldown increases your resource generation by for seconds, up to", - "while_elegance_charm_equipped_using_a_cooldown_reduces_the_cooldown_of_your_reinforcement_mercenary_by_second": "while elegance charm equipped, using a cooldown reduces the cooldown of your reinforcement mercenary by second", - "while_grace_charm_equipped_all_your_resistances_are_evenly_distributed": "while grace charm equipped, all your resistances are evenly distributed", - "while_grace_charm_equipped_removes_all_damage_over_time_and_crowd_control_effects_from_you_every_seconds_while_moving": "while grace charm equipped, removes all damage over time and crowd control effects from you every seconds while moving", - "while_grace_charm_equipped_you_gain_ferocity_stack_for_every_meters_you_travel": "while grace charm equipped, you gain ferocity stack for every meters you travel", - "while_haste_charm_equipped_casting_a_skill_increases_your_attack_speed_by_for_seconds_stacking_up_to": "while haste charm equipped, casting a skill increases your attack speed by for seconds, stacking up to", - "while_haste_charm_equipped_gain_ranks_to_all_your_mobility_skills": "while haste charm equipped, gain ranks to all your mobility skills", - "while_haste_charm_equipped_you_deal_increased_damage_to_enemies_the_further_they_are_from_you_up_to": "while haste charm equipped, you deal increased damage to enemies the further they are from you, up to", - "while_in_a_feral_rage_your_werewolf_skills_gain_attack_speed": "while in a feral rage, your werewolf skills gain attack speed", - "while_lucky_charm_equipped_you_gain_increased_movement_speed_if_there_are_less_than_enemies_nearby_or_increased_damage_otherwise": "while lucky charm equipped, you gain increased movement speed if there are less than enemies nearby, or increased damage otherwise", - "while_lucky_charm_equipped_you_gain_of_your_maximum_primary_resource_when_you_block_or_dodge_can_only_happen_once_every_second": "while lucky charm equipped, you gain of your maximum primary resource when you block or dodge can only happen once every second", - "while_lucky_charm_equipped_your_evade_decreases_the_cooldown_of_a_random_skill_your_equipped_by_seconds": "while lucky charm equipped, your evade decreases the cooldown of a random skill your equipped by seconds", - "while_malice_charm_equipped_enemies_affected_by_your_damage_over_time_effect_have_reduced_attack_and_movement_speed": "while malice charm equipped, enemies affected by your damage over time effect have reduced attack and movement speed", - "while_malice_charm_equipped_you_deal_increased_damage_to_enemies_affected_by_your_damage_over_time_effect": "while malice charm equipped, you deal increased damage to enemies affected by your damage over time effect", - "while_malice_charm_equipped_your_core_skills_have_increased_area_size": "while malice charm equipped, your core skills have increased area size", - "while_renew_charm_equipped_you_gain_healing_potion_charge_every_seconds": "while renew charm equipped, you gain healing potion charge every seconds", - "while_renew_charm_equipped_you_gain_increased_armor_for_seconds_after_drinking_a_healing_potion": "while renew charm equipped, you gain increased armor for seconds after drinking a healing potion", - "while_renew_charm_equipped_your_healing_potion_instantly_heals_you_for_of_its_healing_amount": "while renew charm equipped, your healing potion instantly heals you for of its healing amount", - "while_standing_still_you_deal_increased_damage": "while standing still, you deal increased damage", - "while_tenacity_charm_equipped_casting_defensive_skills_also_clear_all_damage_over_time_effect_from_you": "while tenacity charm equipped, casting defensive skills also clear all damage over time effect from you", - "while_tenacity_charm_equipped_you_gain_increased_damage_while_you_have_barrier_active": "while tenacity charm equipped, you gain increased damage while you have barrier active", - "while_tenacity_charm_equipped_you_gain_of_your_maximum_life_as_barrier_for_seconds_when_you_block_or_dodge_can_only_happen_once_every_second": "while tenacity charm equipped, you gain of your maximum life as barrier for seconds when you block or dodge can only happen once every second", - "while_vitality_charm_equipped_any_overheal_you_received_adds_the_same_amount_to_your_barrier_for_seconds_up_to_of_your_maximum_life": "while vitality charm equipped, any overheal you received adds the same amount to your barrier for seconds, up to of your maximum life", - "while_vitality_charm_equipped_casting_defensive_skill_heals_you_for_of_your_maximum_life": "while vitality charm equipped, casting defensive skill heals you for of your maximum life", - "while_vitality_charm_equipped_you_gain_double_offering_from_helm_runes": "while vitality charm equipped, you gain double offering from helm runes", - "while_wrath_charm_equipped_casting_basic_skill_at_maximum_resource_makes_the_targets_vulnerable_for_seconds": "while wrath charm equipped, casting basic skill at maximum resource makes the targets vulnerable for seconds", - "while_wrath_charm_equipped_spending_primary_resource_creates_an_explosion_that_deals_damage_of_the_type_with_the_highest_bonus_to_all_surrounding_enemies": "while wrath charm equipped, spending primary resource creates an explosion that deals damage of the type with the highest bonus to all surrounding enemies", - "while_wrath_charm_equipped_your_direct_damage_against_a_vulnerable_enemy_is_increased_by_and_removes_the_vulnerable_state": "while wrath charm equipped, your direct damage against a vulnerable enemy is increased by and removes the vulnerable state", "wild_lightning_summons_extra_dancing_bolts": "wild lightning summons extra dancing bolts", "willpower": "willpower", - "wrath_generation": "wrath generation", + "word_of_the_blood_binder_blood_skill_cooldown_reduction": "word of the blood binder blood skill cooldown reduction", + "word_of_the_blood_binder_blood_skill_damage": "word of the blood binder blood skill damage", + "word_of_the_blood_binder_damage_for_seconds_after_picking_up_a_blood_orb": "word of the blood binder damage for seconds after picking up a blood orb", + "word_of_the_blood_binder_fortification_generation": "word of the blood binder fortification generation", + "word_of_the_blood_binder_life_on_kill": "word of the blood binder life on kill", + "word_of_the_blood_binder_maximum_life": "word of the blood binder maximum life", + "wumbas_embrace_block_chance": "wumbas embrace block chance", + "wumbas_embrace_blocked_damage_reduction": "wumbas embrace blocked damage reduction", + "wumbas_embrace_blocking_has_chance_to_pulse_your_thorns": "wumbas embrace blocking has chance to pulse your thorns", + "wumbas_embrace_gorilla_skill_damage": "wumbas embrace gorilla skill damage", + "wumbas_embrace_maximum_resolve": "wumbas embrace maximum resolve", + "wumbas_embrace_thorns": "wumbas embrace thorns", "you_deal_more_damage_to_fosillized_enemies": "you deal more damage to fosillized enemies", "you_deal_more_lightning_damage_while_you_have_a_set_bonus_active": "you deal more lightning damage while you have a set bonus active", "you_gain_movement_speed_while_rampaging": "you gain movement speed while rampaging", "your_earth_skills_deal_more_damage_to_crowd_controlled_enemies": "your earth skills deal more damage to crowd controlled enemies", "your_rock_fragments_deal_more_damage": "your rock fragments deal more damage", - "your_storm_skills_cost_more_spirit_but_deal_more_damage": "your storm skills cost more spirit, but deal more damage", - "zealot_skill_cooldown_reduction": "zealot skill cooldown reduction", - "zealot_skill_damage": "zealot skill damage" + "your_storm_skills_cost_more_spirit_but_deal_more_damage": "your storm skills cost more spirit, but deal more damage" } diff --git a/src/tools/gen_data.py b/src/tools/gen_data.py index 2df1e7e5..1bce03a6 100644 --- a/src/tools/gen_data.py +++ b/src/tools/gen_data.py @@ -32,6 +32,11 @@ D4LF_BASE_DIR = Path(__file__).parent.parent.parent +EXCLUDED_SEAL_AFFIX_KEYS = { + "when_you_gain_a_stack_of_stoicism_gain_damage_for_second", + "while_in_a_feral_rage_your_werewolf_skills_gain_attack_speed", +} + class AffixGenerationContext(TypedDict): attribute_descriptions: dict[str, str] @@ -396,9 +401,7 @@ def generate_affixes(d4data_dir: Path, language: str, output_file: Path | None = if affix_name.casefold() == "2HStaff_Unique_AF_001_Int_Decrease".casefold(): continue description = None - if is_seal_affix: - description = affix_string_description(affix_name, string_list_dir, r"^\{c_set\}.*?\{/c\}:\s*") - elif is_charm_affix: + if is_seal_affix or is_charm_affix: description = affix_string_description(affix_name, string_list_dir) if description is None: if affix_data.get("eMagicType") != 0 or not affix_data.get("ptItemAffixAttributes"): @@ -408,6 +411,12 @@ def generate_affixes(d4data_dir: Path, language: str, output_file: Path | None = if normalised is None: continue key, value = normalised + if is_seal_affix and ( + key in EXCLUDED_SEAL_AFFIX_KEYS + or (key.startswith("while_at_least_") and "_charms_equipped_" in key) + or "_charm_equipped_" in key + ): + continue if is_seal_affix: seal_dict[key] = value elif is_charm_affix: diff --git a/tests/tools/gen_data_test.py b/tests/tools/gen_data_test.py new file mode 100644 index 00000000..e2b24cde --- /dev/null +++ b/tests/tools/gen_data_test.py @@ -0,0 +1,27 @@ +from src.tools import gen_data + + +def test_set_tagged_seal_affix_normalises_with_set_name() -> None: + description = "{c_set}Arms of Arreat{/c}: +{c_number}[Affix_Flat_Value_1]{/c} maximum Resolve" + + assert gen_data.normalise_affix_description(description) == ( + "arms_of_arreat_maximum_resolve", + "arms of arreat maximum resolve", + ) + + +def test_excluded_seal_affix_patterns_match_charm_set_powers() -> None: + excluded_keys = [ + "when_you_gain_a_stack_of_stoicism_gain_damage_for_second", + "while_at_least_might_charms_equipped_all_your_damage_bonuses_are_equal_to_your_highest_damage_type_bonus", + "while_bravery_charm_equipped_every_critical_strike_grants_you_critical_strike_damage_for_seconds_up_to", + "while_in_a_feral_rage_your_werewolf_skills_gain_attack_speed", + ] + + assert [ + key + for key in excluded_keys + if key in gen_data.EXCLUDED_SEAL_AFFIX_KEYS + or (key.startswith("while_at_least_") and "_charms_equipped_" in key) + or "_charm_equipped_" in key + ] == excluded_keys From 5ca8ff26af6c62f5e517ded993f49d4e9134eba9 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Tue, 9 Jun 2026 21:16:39 +0200 Subject: [PATCH 22/39] Create __init__.py --- tests/tools/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/tools/__init__.py diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/tools/__init__.py @@ -0,0 +1 @@ + From 67677ed29d257b3f98505cc3e6eeacd10470c1b7 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Tue, 9 Jun 2026 21:23:18 +0200 Subject: [PATCH 23/39] Update __init__.py --- tests/tools/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py index 8b137891..e69de29b 100644 --- a/tests/tools/__init__.py +++ b/tests/tools/__init__.py @@ -1 +0,0 @@ - From b5484eca419e9e76a0ff0d684318c64540bcf052 Mon Sep 17 00:00:00 2001 From: cjshrader Date: Thu, 11 Jun 2026 21:44:48 -0400 Subject: [PATCH 24/39] Rework of the base seal/charm model and cleaned up a bunch of extra code that didn't appear to be needed. --- assets/lang/enUS/corrections.json | 29 +- assets/lang/enUS/seals_affixes.json | 13 +- .../item_descr/boosted_bullet_point.png | Bin 945 -> 0 bytes .../boosted_bullet_point_medium.png | Bin 1084 -> 0 bytes .../item_descr/seal_set_bullet_point.png | Bin 0 -> 3240 bytes .../seal_set_bullet_point_medium.png | Bin 0 -> 1961 bytes src/config/profile_models.py | 66 +-- src/dataloader.py | 2 - src/gui/importer/d4builds.py | 4 +- src/gui/importer/maxroll.py | 4 +- src/gui/importer/mobalytics.py | 4 +- src/gui/profile_editor/affixes_tab.py | 8 +- src/gui/profile_editor/profile_editor.py | 19 +- src/gui/profile_editor/seal_charm_tab.py | 487 ------------------ src/gui/profile_editor/sigils_tab.py | 2 +- src/item/descr/read_descr_tts.py | 277 +++------- src/item/descr/texture.py | 13 +- src/item/filter.py | 227 +++----- src/item/models.py | 31 +- src/scripts/__init__.py | 8 +- src/scripts/vision_mode_fast.py | 2 + src/scripts/vision_mode_with_highlighting.py | 5 +- src/tools/gen_data.py | 3 + tests/config/models_test.py | 69 +-- tests/item/filter/filter_test.py | 293 ++--------- tests/item/read_descr_season_13_tts_test.py | 408 +++++++++++---- tests/item/read_descr_tts_test.py | 147 +----- 27 files changed, 565 insertions(+), 1556 deletions(-) delete mode 100644 assets/templates/item_descr/boosted_bullet_point.png delete mode 100644 assets/templates/item_descr/boosted_bullet_point_medium.png create mode 100644 assets/templates/item_descr/seal_set_bullet_point.png create mode 100644 assets/templates/item_descr/seal_set_bullet_point_medium.png delete mode 100644 src/gui/profile_editor/seal_charm_tab.py diff --git a/assets/lang/enUS/corrections.json b/assets/lang/enUS/corrections.json index 4bc7a60e..31c0ca39 100644 --- a/assets/lang/enUS/corrections.json +++ b/assets/lang/enUS/corrections.json @@ -1,40 +1,13 @@ { "bad_tts_uniques": { "bane_ofahjad-den": "bane_of_ahjad-den", + "cains_wild_lighting": "cains_wild_lightning", "galvanicazurite": "galvanic_azurite", "grandfather": "the_grandfather", "kilt_ofblackwing": "kilt_of_blackwing", "mjᅢヨlnic_ryng": "mjölnic_ryng", "sunstainedwar-crozier": "sunstained_war-crozier" }, - "error_map": { - " arbarian": " barbarian", - "(arbarian": "(barbarian", - "@arbarian": "(barbarian", - "garbarian": "barbarian", - "gorcerer": "sorcerer", - "lighting": "lightning", - "mruid": "(druid", - "omuid": "(druid", - "seythe": "scythe", - "thoms": "thorns", - "tier s": "tier 5", - "tier1": "tier 1", - "tier2": "tier 2", - "tier3": "tier 3", - "tier4": "tier 4", - "tier5": "tier 5", - "tier6": "tier 6", - "tier7": "tier 7", - "tier8": "tier 8", - "tier9": "tier 9", - "tmlelligence": "intelligence", - "tomado": "tornado", - "ttem": "item", - "two- handed": "two-handed", - "two-handed!": "two-handed", - "two-handed.": "two-handed" - }, "filter_after_keyword": [ " cts ", "account", diff --git a/assets/lang/enUS/seals_affixes.json b/assets/lang/enUS/seals_affixes.json index 64e01061..c3d9a227 100644 --- a/assets/lang/enUS/seals_affixes.json +++ b/assets/lang/enUS/seals_affixes.json @@ -64,13 +64,12 @@ "bulkathos_pride_damage_while_call_of_the_ancients_is_active": "bulkathos pride damage while call of the ancients is active", "bulkathos_pride_to_ancient_skills": "bulkathos pride to ancient skills", "bulkathos_pride_to_call_of_the_ancients": "bulkathos pride to call of the ancients", - "cains_wild_lighting_critical_strike_chance": "cains wild lighting critical strike chance", - "cains_wild_lighting_lightning_damage": "cains wild lighting lightning damage", - "cains_wild_lighting_mana_per_second": "cains wild lighting mana per second", - "cains_wild_lighting_movement_speed": "cains wild lighting movement speed", - "cains_wild_lighting_shock_cooldown_reduction": "cains wild lighting shock cooldown reduction", - "cains_wild_lighting_shock_skill_damage": "cains wild lighting shock skill damage", - "cannot_have_more_than_sockets_but_can_equip_unique_charms": "cannot have more than sockets, but can equip unique charms", + "cains_wild_lightning_critical_strike_chance": "cains wild lightning critical strike chance", + "cains_wild_lightning_lightning_damage": "cains wild lightning lightning damage", + "cains_wild_lightning_mana_per_second": "cains wild lightning mana per second", + "cains_wild_lightning_movement_speed": "cains wild lightning movement speed", + "cains_wild_lightning_shock_cooldown_reduction": "cains wild lightning shock cooldown reduction", + "cains_wild_lightning_shock_skill_damage": "cains wild lightning shock skill damage", "cathans_dauntless_faith_attack_speed": "cathans dauntless faith attack speed", "cathans_dauntless_faith_critical_strike_chance": "cathans dauntless faith critical strike chance", "cathans_dauntless_faith_damage_to_weakened_enemies": "cathans dauntless faith damage to weakened enemies", diff --git a/assets/templates/item_descr/boosted_bullet_point.png b/assets/templates/item_descr/boosted_bullet_point.png deleted file mode 100644 index 1a7341389459e6bd33290056c1236f730484e1ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 945 zcmV;i15W&jP)*=I@7PW6%y{QIca*S(J_K#lCi-yv2x-wKS1m#)h=SU*bH+%BvQZ3z zRza&iFj}`a(s8UL)X^Y_3e$A55=_%(?*E+keH*Oj;hOeJxPj&flu&Z54o0r3bp5bK z;_N);7ZThl51_w*yleA+pXr*1RRKj2=*UEql%CQ0N0+nl8_MGpt-!c6N@>R)65PY2ZvhC$qBgQ z<1<*uxZXpI(Ty&I)n+gt#+YKv>RL)8*sG5*&_6u`uJ0~?VO$Q(BGLd7Qk zcHDO2k{rKK1xi*8u%cyhf~PN$yEivCc5!G{Qi_C`QDVxyh1a)xRya^KaNx_dL>=y$ zLa1}SXNz3AW_)hEf5Q+Dp3oR$q80!WMRP8?Z#%N#@Eor6`hQw~;1pw3xq2~8jEWu{a5zv@pBnYC5d`Am zHGFaw99h+=>w4cr&W0mDavR)y=DdA#IXr!@ zukTWkZuap0$^7VQY`q%|!FylhpF)=afSrLB00(GhuwD1r{T%>;{|P`q0nA^=;qRa> zQ6vasAcs30E5%Gxqv2|cy~PmXnx;L_mZ9b76I{Jj=C1gK8vq$Z+!<2}L=gWMe4E{0 Tq`WCU00000NkvXXu0mjfB743t diff --git a/assets/templates/item_descr/boosted_bullet_point_medium.png b/assets/templates/item_descr/boosted_bullet_point_medium.png deleted file mode 100644 index e95383b8b8e5ee931cbc52dfc82333a571923d0f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1084 zcmV-C1jGA@P)lvguz>r|(FuFNG(p4p-Tufqw zSuU9X2_YN^0(Wp}yG2B}f#wL5P;#RVMh;ZEdQc;AeiD<@3GNh$A&6xINSGlAxU}6( z_$(6YRMkfZW$OwBYxoQRM*tLa&RmN}egoY?2r&q}0N}EAw*i!(MT^_mM#8mIFr6)h zxS%Qy7RZIkvZf#Nk_fTyI2ob{O^X9u+Sd(-n-#S;x1y*V{iQkmyEAmT+Dq%o&_W*g zHn|mp^;CHEF;g=f}C?18;K6RdDlL>kVt{R9Hn#u(=4|}jatg~Ran(0 zf!KQ-F-8&`Zg&?gj||#V_a(yroN7KDuR=%^sFzq*-l$n|2MJSsVU^$869NDE4?BEO zs=ABAy{)E{u@N{B2@&i$in@xB8h~p@T}em?ZqQub*@l@M%xPx(PoS%=IoQ{$&CKWs z-0}H2OlREgAjarM7s47d7!YGjF=llwr4IJS6Abh(&f!LrIM~x_*Y@%T*>@6=29S{A zT+wQFY+%WbIj1J%#N{ebvTA@8Eu$kmJHf9fgah4+pI)x(hH&7dq!bA=qr{Xu({F9@ ztZ<;JvG1ESOC9c-LTGZcbE8b$vV-T9{jEjMt_*AXxc`*K7!$PskSLmS(cN2+4Tr{Y zz0)NmESsUKVw3f?4V;=OpPX|J_Ou4Ne8+l!|D*_A1fUf6itLo~`oq3>9`>F@R~5(# zn$?_SQQNhNKV2_}F9r^@drH$8+XOZIa21#S!j;=dDH>FyOY?Mg#1I0V5^_@#{h>iV zc@O7T_;TE?P6hUNFLBGGBLbKMmB^lBUZom@xx-M9V-PW@qsisnZI~(e&)#FGQ)FLv zi{vx+a_vw<;7%uge1@^AT)mhkMnwk>I2(=@wB zayA_K6=!cqjADvereY`*2F`+;4=>Oc*P>2%`zu5V4sxK~4T!1*Yj$l$qd0I1ghUaw z^2+)&w1n>+bAbbe|(2Ix``}Wee=Vw)8DRm*D198NcqA?*L>Fac4{^5J5b~z@grj zBdMFak^+Yi5<)3O&D}M`if~fhNX%i75Td}X5cn7FI5;OrKbn&O0000Qu}6j2BOyuUiYrC!S*tZ_oHnP_XpPjU zonEWxsZm9Pw%oNsYZRqk?!C`D{`ft=`Fwxh=lkE2YHw>Lz$e8A000E6(H0IzUisK~ zxQ|Y{@t+Tm90%FK$_(&u1oY=o!WDqBK>+|w+5G$XlSer(3GGS-00jGvjROe5#2sBo z4#K%mTx>2G_!7h5cmmPK4^9gs9ZdrOMy50p-Z#XL0`~C>2nsh=S?hSJ0uCY=t2pb} zXxNa<{Q`r~*CPEcU$e#eUJLO>5>!l01o(_-21f{CeiS^I78V*#HlP{H{|#(#6d#un z@+L-+1b+hu3(J2djxuBUKnjIqfIvh=MZu%A;l#)QgeDS+L}+Lsw6tJH6EO16;S@X# z7EV_92f)IQ>>C+Gq687c!N-7jA0m}vEKj8d5e)qC{yIJcf)30FuT6kyY7+1;JV9F% zM$pzK=;>(cYx!&X$p5pR66F8i{o&+)WH=H8aoj;@!ZnU5AIAoEkwJb(ERI*2Xd3<9 z|G%0M;+V+)5HtCA<>;+P(SMXVmgzt0`h_1!8g-N9+|u%N`Iq6YfVGyr{+Z=06lnKZ)X1CV&n;7K@0cdd-PeXA z`Z>;?+ z6pI7K=?YAs0H?%qwpv65?cYM2QP_bpH-7({?UhxX`@H&(SJX#16c-_@RL}1nT`vU` zm4DWVUrpV3p|;NUM4za_VAA9Po0_2ebEeeDi7p!jcDwRyhMOw8Kz)Q?_U_jKJjw#q zQV(_7zt5??ZlK5`3pb2u58D5n*Eq+?gXFDEo%c6i5qtmouZSXaGP`$cD&gQ8Grd`x0oJ7X@ z3?>6_^Ve**8)nNobZGv(2N;*5z_t*`bKb#bjdN`k=6^sdVvX@J>k830LEkyW7jO_eb zLfSo-!UI)uu%+{`*BkqiqwohgV>+Mf)KyiA67`6xl2*89NI4a|I@Q&jEf2f(p&0Hs-d?Yr>g^yYS6Y1>EOH-OGzwB zHtAQiI^Tkxo_F*Kd0uia$Rq;iva6v&29I0N@jP#y$Xz2O zeW*G$Wx1Oo{^VDVM&F+jZVp9rveJMAT6xkZ8vp8y9*1^q^p=ec?%E24If9$U z6JnOelMmjAntSw}@oESSjUS)J_E=LG=5fJ5L@cfu$;nl#CO=b}vZU|IE*`%4NbPG)D(GZ$3Mn#@|#FkD_TUJQv_ zfhGCkJ?MqSwTuw=tU!6CFr&$lLVN&CS8992Jj^+{XISI1N1s!2%;>f2Hz#M*cfcsU zwuO<_WXGV0!Hl6%1k=t@09DkL=dM<%=LIUCgJnev*e7y1S{f)}?fKG1|Cr znS$I;gi&Sm{vVoks|gNe4yrAP%^LfVI-d*qtoE~Y+xo`v5ky1ZfeeKEU6)WV)w+LF z&(cncE_hBLz~RBGA`I1KC9%aXGlr3C+mJCT>nVmyRB`hDV~z1*zDhXwkr~7FW_$>) z$Fo>`+Ej(!7b1;uz~@)D`JC2%ycL^W@=|}b2UO}MZgSX8BFU9Ao`yVoXA=AD3e9JI zJ0im{!1uDI=sY8+vo%*p_{qa`$7F~@@I_B(OY@k`ZD~KYZ4%e}Ni}wTwxSM0D=V|? zsYkNH_rwF=fPB$d8NvXA(q;ATNdaV%R!MyyjsVT8fZieY2Yu_~Q_In+#mT^`O89S) z#3@FXZ+p^|EDuHK2Z}XOR#bj|1f*HG{7u~s#4&!@0Slyln4FYdD(iablt97c`fD@m zhZGaiZBk2pMdkY0p4&HWtVlC8GCB+sBs0eY44cxHJh~g`to^A8*6lY70N|_V-=p|x z+33M~W?i=RtWd+N;r^k5)}U~lcJ+yq%(uo%UG64t;b%mZQ#bA+qzoOgYF(yWrUHL*v%=2l4}feIt|rbmE;e)ybtxM@auzmV|s>QZ@oHSN;Q z^BRc?5g;zU-n#fnV2(?5G}=+BPN(T7)f$$UW-!0e{H@D`x)<@@_R2uE(d?(fn@V2M z*I5o$w{C9D&18l}2P1XUe-~wmzl6hv;!l7eO6Mu4vJanWEgEUrTvI0Ul~PsZGu(dY zBEH$_tMC}HeEC{>TCQlIh}QvNp3&r7K8W1eRBitzR^n|USJOECF)?i0^WLT1uiEP` z?f4#~gT?QtgmFHowJ|;|O#3QSYBF2(b^3Yi+Lk!syHSB4eATc)9(N%EK6j%tdl#jB&-u?Ma$hC-HKF5hP@T4@BG28)z*Yw-p|2CB#G-rimqSm+(t z{UY5s!JN+5QZ;j%ZCi_RDVh!yC(n2DhNP3c2nk=kwwz4t`6zZNzciA55%X-Osk%~vvpHsc zX?<6FeE=KKtvy>&+}Ok`Ys_O`>Tz{M#%`hEj#3_4^xM^iMQVUslIZs&rgJ_#|8_7b zc6{dtaZ&Om>JPSOog2{A@Z39Qt?SOc(g^K62f)s>&j)@xB)@BkrC@j%_vX;4gZaCU zqF1Bdx+}-e&C^!-_Se>6=UtK**z;ZP_uPcq>RNk-Rl&V1C>B$>7F{O}!iQP!F1{J) z-Icl`F5My+&-fPIxutS~bKwdcxi%Z$Ej5v;X!aa~5&9Xe{Zu)GnH`<4VM14Qbih<5 z*APVr?K~TvKGg2zQ9-o_FR)b*ywJKARJ6iqT1S^HSw!S_Zq+hWG7+Y8@v~x|z3u6t eU{iNu)b((@zBis#=ih*D$z** literal 0 HcmV?d00001 diff --git a/assets/templates/item_descr/seal_set_bullet_point_medium.png b/assets/templates/item_descr/seal_set_bullet_point_medium.png new file mode 100644 index 0000000000000000000000000000000000000000..bd3c79957d6020407fe7be52908e4f9283a0ad3a GIT binary patch literal 1961 zcmY*adpHw%AD=>Eqb#>k#B6#F+Ze?#8*`b-DNf{yaxQ#Lux{%{B zgtJoWWo}70oRqz}?WEEvxpbi(s&_l5=e+Ox$M^TW{yyK&@AJ>EAdKV>(uL>(000m% zfDo?o5Vh%OtImeN;8B&+NDcS*1~iXa{Gm!TS@;k<0MJ^jw@KAf<=eRdhf@In{T{Vx z7}*78sV?NRBgi~*NHB($oP?m#lVceOK@wLr4FGs}3Aj{R0)uB2%V4oNp77<)F1Qt& z?g@`{2|!Zf;1F6Vl1aK{er!n$F=- z1rD54o9_SwMk+0Z&E>I^IaX>wYHV^E&(k_BjZMcesZ5txI^D$~mWrl3I6Bg)4pchY z(SeRe(+{{fx;il(W39ih=dqdp-Oow=E`v%Cq`HH2M4;4^)iEYCh0Rd0P_M)~di>e{ zznTY9P2_)wVgIVA-l~fJRz@w;-|8|rDoOb&?d;1XdH{e{BawiQ2nb3#Khkn2(kQ=X zj?{MMB!VK;we#Gr^%p#f`S29QLytko|UGU=#rCn|Ln7y_q@;fZMTT% z0+T(91WaiH$&ZZ7!)t4gAl5xu%QA~alS>_CWvSx!_Q}ci6A5p|UIz|YX+a6zOq47u z0NV!Ib2!(3tspi}XZR9CKGEmZ4DUKQ)p2`gZC|2<8c5Nb-W~iY=9Thw;065H)U;2D?!lsqK35q2hAoy{Lh+3l=(o`*z?T61J^1(V;ue;UDlrKhVIg8MdE4 z+M>(`UDLZi4dXTBb#ER#G|}8?wc3y=Kj?2rl#UeEZ0_Zvhk>Q9u4P2FJ@zy7;Dv=rI&6sVc!u}<69t1jp zFUHfBjTJKA_4v;HX}V`mZQtctu{0_L4l|1`$`v=_N{Fqb#TOTeTLuM!miMh2ghr6h zgGsLw25^A>^5UF}cpr|l`$qx{N*W3fJKNY^huG^CEg4seQ1O`hBAi(98YW3M=-15Y zy~#jbW@O_IaV=3s@w}%cX}z355OkXQzQn$wmh2P1IBjisB{@@=fxQ_jksqBUjz;G; z$e-#kwKSX3+NSTBpVkDMfy{tGXy=gO`tI`6%AowR{PnZI`9-6#^{$(q+X^C)wyb9F zA>0Q@t%O3mAAW-d_JrW$w>S7_d_H7lDPbfpJjs3vqiEOb%jXSty(}Bk3!SSm+*Hc? z=n{8Ixzv`B)RDc=rDOhRCa-E-vp>h=L`A?rOw5okh?}^8B8~lu{`K8Rv*@Q- z;i2a~_py5&4aq5SrHTn*wAXEu>*+SH2}$P$ar!By_NHe#(EAbhYBoR4D09-4{heUF zkOT+GK*48M!<`wHk@=7B{qdAJ2*|+W5x}~`}o!ZsHFw^V+ixS{-V80ps_h?fl z8W+%`B_k#7d+?|jlK)TnE^i^SfS}#n z*YvHzBaMX@`Q0OqXex7a6DS>VZ-X10eIhQTlnRRrWzS&MU#7d)Nj*3kBq35n`M2o; z?w#UmbDFH@2a}1ixE>Gl>(!5Is+X$nrrlm$WSLK~o~xNr@kopMQm;g9chx=O6vFA$4^cyVCyxDmo^0&Tg?tu$Xhl=}Uhtd%x^b^mb=d zh1K#*g>qEqWY>9%qq!kD^V$oD-WJ4KW8`7eVY?z0!vi?mqcF@q1iho`#%u>XZ`>H literal 0 HcmV?d00001 diff --git a/src/config/profile_models.py b/src/config/profile_models.py index 792b5948..120b915b 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -4,7 +4,7 @@ import logging import sys -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator from src.config.helper import check_greater_than_zero, validate_greater_affix_count, validate_percent from src.item.data.item_type import ItemType # noqa: TC001 @@ -20,7 +20,7 @@ def _parse_item_type_or_rarities(data: str | list[str]) -> list[str]: return data -def _normalize_existing_set_name(name: str | None, field_name: str) -> str | None: +def _validate_set_name(name: str | None, field_name: str) -> str | None: if not name: return None @@ -62,8 +62,8 @@ def parse_data(cls, data: str | list[str] | list[str | float] | dict[str, str | class AffixFilterModel(AffixAspectFilterModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) - want_greater: bool = False min_percent_of_affix: int = Field(default=0, alias="minPercentOfAffix") + want_greater: bool = False @field_validator("name") @classmethod @@ -90,6 +90,7 @@ def value_and_percent_are_mutually_exclusive(self) -> AffixFilterModel: if self.value and self.min_percent_of_affix: msg = "value and minPercentOfAffix cannot both be set" raise ValueError(msg) + return self @@ -238,59 +239,34 @@ def parse_rarities(cls, data: str | list[str]) -> list[str]: class CharmFilterModel(SealCharmFilterModel): - set_name: str | None = Field(default=None, alias="set") - unique_aspect: str | None = Field(default=None, alias="uniqueAspect") - - @field_validator("set_name") - @classmethod - def set_must_exist(cls, name: str | None) -> str | None: - return _normalize_existing_set_name(name, "set") - - @field_validator("unique_aspect") - @classmethod - def normalize_unique_aspect(cls, name: str | None) -> str | None: - return correct_name(name) - - -class BoostedSetFilterModel(BaseModel): - model_config = ConfigDict(extra="forbid", populate_by_name=True) - affix: AffixFilterModel | None = None - required: bool = False - set_name: str = Field(alias="set") + model_config = ConfigDict(populate_by_name=True) + set: list[str] = Field(default=[], alias="set") + unique_aspect: list[AspectUniqueFilterModel] = Field(default=[], alias="uniqueAspect") - @field_validator("set_name") + @field_validator("set") @classmethod - def set_must_exist(cls, name: str) -> str: - normalized_name = _normalize_existing_set_name(name, "set") - if normalized_name is None: - msg = "set must not be empty" - raise ValueError(msg) - return normalized_name + def set_must_exist(cls, sets: list[str]) -> list[str]: + return [_validate_set_name(name, "set") for name in sets] @model_validator(mode="after") - def required_boosted_affix_must_have_affix(self) -> BoostedSetFilterModel: - if self.required and self.affix is None: - msg = "required boostedSets entries need affix" + def set_and_unique_aspects_must_be_unique(self) -> CharmFilterModel: + if len({aspect.name for aspect in self.unique_aspect}) != len(self.unique_aspect): + msg = "uniqueAspect names must be unique" raise ValueError(msg) - return self + if len(set(self.set)) != len(self.set): + msg = "set names must be unique" + raise ValueError(msg) -class SealFilterModel(SealCharmFilterModel): - boosted_sets: list[BoostedSetFilterModel] = Field(default=[], alias="boostedSets") - slots: int = Field(default=3, validation_alias=AliasChoices("slots", "charmSlots", "charm_slots")) - - @field_validator("slots") - @classmethod - def slots_in_valid_range(cls, v: int) -> int: - if v != 0 and not (3 <= v <= 6): - msg = "slots must be 0 or between 3 and 6" + if self.set and self.unique_aspect: + msg = "can't define both set and unique aspect" raise ValueError(msg) - return v + + return self DynamicSealCharmFilterModel = RootModel[dict[str, SealCharmFilterModel]] DynamicCharmFilterModel = RootModel[dict[str, CharmFilterModel]] -DynamicSealFilterModel = RootModel[dict[str, SealFilterModel]] class SigilPriority(enum.StrEnum): @@ -408,7 +384,7 @@ class ProfileModel(BaseModel): charms: list[DynamicCharmFilterModel] = Field(default=[], alias="Charms") global_uniques: list[GlobalUniqueModel] = Field(default=[], alias="GlobalUniques") name: str - seals: list[DynamicSealFilterModel] = Field(default=[], alias="Seals") + seals: list[DynamicSealCharmFilterModel] = Field(default=[], alias="Seals") sigils: SigilFilterModel = Field( default=SigilFilterModel(blacklist=[], whitelist=[], priority=SigilPriority.blacklist), alias="Sigils" ) diff --git a/src/dataloader.py b/src/dataloader.py index d27d47a7..5afc504a 100644 --- a/src/dataloader.py +++ b/src/dataloader.py @@ -21,7 +21,6 @@ class Dataloader: aspect_list = [] aspect_unique_dict = {} bad_tts_uniques = {} - error_map = {} filter_after_keyword = [] filter_words = [] item_types_dict = {} @@ -66,7 +65,6 @@ def load_data(self): encoding="utf-8" ) as f: data = json.load(f) - self.error_map = data["error_map"] self.filter_after_keyword = data["filter_after_keyword"] self.filter_words = data["filter_words"] self.bad_tts_uniques = data["bad_tts_uniques"] diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index 8b683a3f..b7ce982b 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -16,7 +16,7 @@ CharmFilterModel, ItemFilterModel, ProfileModel, - SealFilterModel, + SealCharmFilterModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( @@ -185,7 +185,7 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): if item_type in [ItemType.HoradricSeal, ItemType.Charm]: seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters filter_name = _unique_filter_name(item_type.name, seal_charm_filters) - seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel + seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealCharmFilterModel seal_charm_filters.append({ filter_name: create_seal_charm_filter( affixes=affixes, diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index a23518da..414b17ad 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -12,7 +12,7 @@ CharmFilterModel, ItemFilterModel, ProfileModel, - SealFilterModel, + SealCharmFilterModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( @@ -127,7 +127,7 @@ def import_maxroll(config: ImportConfig): continue seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters filter_name = _unique_filter_name(item_type.name, seal_charm_filters) - seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel + seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealCharmFilterModel seal_charm_filters.append({ filter_name: create_seal_charm_filter( affixes=seal_charm_affixes, diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index 8d1d3b3c..a9946f72 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -19,7 +19,7 @@ CharmFilterModel, ItemFilterModel, ProfileModel, - SealFilterModel, + SealCharmFilterModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( @@ -221,7 +221,7 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): continue seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters filter_name = _unique_filter_name(item_type.name, seal_charm_filters) - seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel + seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealCharmFilterModel seal_charm_filters.append({ filter_name: create_seal_charm_filter( affixes=affixes, diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index 5ebdd6ab..1cb4e6aa 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -33,7 +33,7 @@ AspectUniqueFilterModel, CharmFilterModel, DynamicItemFilterModel, - SealFilterModel, + SealCharmFilterModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import MAX_POWER @@ -68,7 +68,7 @@ def _affix_dict_for_widget(widget: QWidget) -> dict[str, str]: curr = widget while curr: config = getattr(curr, "config", None) - if isinstance(config, SealFilterModel): + if isinstance(config, SealCharmFilterModel): return Dataloader().seal_affix_dict if isinstance(config, CharmFilterModel): return Dataloader().charm_affix_dict @@ -324,7 +324,7 @@ def _unique_aspects_title(self): return f"{UNIQUE_ASPECTS_TITLE} - {aspect_names}" def refresh_unique_aspects_title(self): - self.unique_aspect_container.header.set_name(self._unique_aspects_title()) + self.unique_aspect_container.header.set(self._unique_aspects_title()) def init_unique_aspects(self): for unique_aspect in self.config.unique_aspect: @@ -437,7 +437,7 @@ def reorganize_pool(self, layout_widget: QVBoxLayout): for i in range(layout_widget.count()): item = layout_widget.itemAt(i) if item and item.widget() is not None: - item.widget().header.set_name(f"Count {i}") + item.widget().header.set(f"Count {i}") def refresh_item_type_summary(self): self.item_type_line_edit.setText(_item_type_summary(self.config.item_type)) diff --git a/src/gui/profile_editor/profile_editor.py b/src/gui/profile_editor/profile_editor.py index 26c0c41b..67d51fd8 100644 --- a/src/gui/profile_editor/profile_editor.py +++ b/src/gui/profile_editor/profile_editor.py @@ -3,18 +3,11 @@ from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtWidgets import QMessageBox, QTabWidget -from src.config.profile_models import ( - CharmFilterModel, - DynamicCharmFilterModel, - DynamicSealFilterModel, - ProfileModel, - SealFilterModel, -) +from src.config.profile_models import ProfileModel from src.gui.importer.gui_common import save_as_profile from src.gui.profile_editor.affixes_tab import AFFIXES_TABNAME, AffixesTab from src.gui.profile_editor.aspect_upgrades_tab import ASPECT_UPGRADES_TABNAME, AspectUpgradesTab from src.gui.profile_editor.global_uniques_tab import UNIQUES_TABNAME, UniquesTab -from src.gui.profile_editor.seal_charm_tab import CHARMS_TABNAME, SEALS_TABNAME, SealCharmTab from src.gui.profile_editor.sigils_tab import SIGILS_TABNAME, SigilsTab from src.gui.profile_editor.tributes_tab import TRIBUTES_TABNAME, TributesTab @@ -32,10 +25,6 @@ def __init__(self, profile_model: ProfileModel, parent=None): # Create main tabs self.affixes_tab = AffixesTab(self.profile_model.affixes) self.aspect_upgrades_tab = AspectUpgradesTab(self.profile_model.aspect_upgrades) - self.seals_tab = SealCharmTab(self.profile_model.seals, SEALS_TABNAME, DynamicSealFilterModel, SealFilterModel) - self.charms_tab = SealCharmTab( - self.profile_model.charms, CHARMS_TABNAME, DynamicCharmFilterModel, CharmFilterModel - ) self.sigils_tab = SigilsTab(self.profile_model.sigils) self.tributes_tab = TributesTab(self.profile_model.tributes) self.uniques_tab = UniquesTab(self.profile_model.global_uniques) @@ -44,8 +33,6 @@ def __init__(self, profile_model: ProfileModel, parent=None): # Add tabs with icons self.addTab(self.affixes_tab, AFFIXES_TABNAME) self.addTab(self.aspect_upgrades_tab, ASPECT_UPGRADES_TABNAME) - self.addTab(self.seals_tab, SEALS_TABNAME) - self.addTab(self.charms_tab, CHARMS_TABNAME) self.addTab(self.sigils_tab, SIGILS_TABNAME) self.addTab(self.tributes_tab, TRIBUTES_TABNAME) self.addTab(self.uniques_tab, UNIQUES_TABNAME) @@ -61,10 +48,6 @@ def tab_changed(self, index): self.affixes_tab.load() elif self.tabText(index) == ASPECT_UPGRADES_TABNAME: self.aspect_upgrades_tab.load() - elif self.tabText(index) == SEALS_TABNAME: - self.seals_tab.load() - elif self.tabText(index) == CHARMS_TABNAME: - self.charms_tab.load() elif self.tabText(index) == SIGILS_TABNAME: self.sigils_tab.load() elif self.tabText(index) == TRIBUTES_TABNAME: diff --git a/src/gui/profile_editor/seal_charm_tab.py b/src/gui/profile_editor/seal_charm_tab.py deleted file mode 100644 index dd67f231..00000000 --- a/src/gui/profile_editor/seal_charm_tab.py +++ /dev/null @@ -1,487 +0,0 @@ -from functools import partial - -from PyQt6.QtCore import QSignalBlocker, Qt, QTimer -from PyQt6.QtGui import QDoubleValidator, QIntValidator -from PyQt6.QtWidgets import ( - QCheckBox, - QComboBox, - QCompleter, - QDialog, - QFormLayout, - QHBoxLayout, - QInputDialog, - QLineEdit, - QMessageBox, - QPushButton, - QScrollArea, - QSpinBox, - QTabWidget, - QToolBar, - QVBoxLayout, - QWidget, -) - -from src.config.profile_models import ( - AffixFilterCountModel, - AffixFilterModel, - BoostedSetFilterModel, - CharmFilterModel, - DynamicCharmFilterModel, - DynamicSealCharmFilterModel, - DynamicSealFilterModel, - SealCharmFilterModel, - SealFilterModel, -) -from src.dataloader import Dataloader -from src.gui.models.collapsible_widget import Container -from src.gui.models.dialog import DeleteAffixPool, DeleteItem, IgnoreScrollWheelComboBox -from src.gui.profile_editor.affixes_tab import AffixPoolWidget -from src.item.data.rarity import ItemRarity -from src.scripts import correct_name - -SEALS_TABNAME = "Seals" -CHARMS_TABNAME = "Charms" -AFFIX_VALUE_MODE = "Value" -AFFIX_PERCENT_MODE = "Min %" -BOOSTED_SET_SLOT_COUNT = 2 - - -class SealCharmRuleEditor(QWidget): - def __init__(self, dynamic_filter: DynamicSealCharmFilterModel, parent=None): - super().__init__(parent) - for rule_name, config in dynamic_filter.root.items(): - self.rule_name = rule_name - self.config = config - self.setup_ui() - - def setup_ui(self): - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - - content_widget = QWidget() - self.content_layout = QVBoxLayout(content_widget) - self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - - general_form = QFormLayout() - self.min_greater = QSpinBox() - self.min_greater.setRange(0, 4) - self.min_greater.setValue(self.config.min_greater_affix_count) - self.min_greater.valueChanged.connect(self.update_min_greater_affix) - general_form.addRow("Min Greater Affixes:", self.min_greater) - - rarity_layout = QHBoxLayout() - self.rarity_checkboxes = {} - selected_rarities = set(self.config.rarities) - for rarity in ItemRarity: - checkbox = QCheckBox(rarity.name) - checkbox.setChecked(rarity in selected_rarities) - checkbox.stateChanged.connect(self.update_rarities) - self.rarity_checkboxes[rarity] = checkbox - rarity_layout.addWidget(checkbox) - rarity_layout.addStretch() - general_form.addRow("Rarities:", rarity_layout) - if isinstance(self.config, SealFilterModel): - self.add_boosted_set_fields(general_form) - elif isinstance(self.config, CharmFilterModel): - self.add_charm_fields(general_form) - self.content_layout.addLayout(general_form) - - pool_btn_layout = QHBoxLayout() - add_affix_pool_btn = QPushButton("Add Affix Pool") - add_affix_pool_btn.clicked.connect(self.add_affix_pool) - remove_affix_pool_btn = QPushButton("Remove Affix Pool") - remove_affix_pool_btn.clicked.connect(self.remove_selected) - pool_btn_layout.addWidget(add_affix_pool_btn) - pool_btn_layout.addWidget(remove_affix_pool_btn) - - self.affix_pool_container = Container("Affix Pool") - self.affix_pool_layout = QVBoxLayout(self.affix_pool_container.content_widget) - self.affix_pool_container.first_expansion.connect(self.init_affix_pool) - - self.content_layout.addWidget(self.affix_pool_container) - self.content_layout.addLayout(pool_btn_layout) - - scroll_area.setWidget(content_widget) - main_layout = QVBoxLayout(self) - main_layout.addWidget(scroll_area) - self.setLayout(main_layout) - - QTimer.singleShot(100, self.affix_pool_container.expand) - - def add_charm_fields(self, form: QFormLayout): - self.set_name_combo = IgnoreScrollWheelComboBox() - self.set_name_combo.setEditable(True) - self.set_name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) - self.set_name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) - self.set_name_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains) - self.set_name_combo.addItems(["", *sorted(Dataloader().set_list)]) - if self.config.set_name: - self.set_name_combo.setCurrentText(self.config.set_name) - self.set_name_combo.currentTextChanged.connect(self.update_set_name) - form.addRow("Set:", self.set_name_combo) - - self.unique_aspect_combo = IgnoreScrollWheelComboBox() - self.unique_aspect_combo.setEditable(True) - self.unique_aspect_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) - self.unique_aspect_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) - self.unique_aspect_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains) - self.unique_aspect_combo.addItems(["", *sorted(Dataloader().aspect_unique_dict.keys())]) - if self.config.unique_aspect: - self.unique_aspect_combo.setCurrentText(self.config.unique_aspect) - self.unique_aspect_combo.currentTextChanged.connect(self.update_unique_aspect) - form.addRow("Unique Aspect:", self.unique_aspect_combo) - - def add_boosted_set_fields(self, form: QFormLayout): - charm_slots_layout = QHBoxLayout() - self.charm_slot_checkboxes = {} - for slots in range(3, 7): - checkbox = QCheckBox(str(slots)) - checkbox.setChecked(self.config.slots == slots) - checkbox.clicked.connect(partial(self.update_charm_slots, slots)) - self.charm_slot_checkboxes[slots] = checkbox - charm_slots_layout.addWidget(checkbox) - charm_slots_layout.addStretch() - form.addRow("Min Charm Slots:", charm_slots_layout) - - boosted_set_filters = list(self.config.boosted_sets) - - self.boosted_set_combos = [] - self.boosted_affix_combos = [] - self.boosted_affix_required_checkboxes = [] - self.boosted_affix_modes = [] - self.boosted_affix_values = [] - - for index in range(BOOSTED_SET_SLOT_COUNT): - boosted_set_filter = boosted_set_filters[index] if index < len(boosted_set_filters) else None - - boosted_set_combo = IgnoreScrollWheelComboBox() - boosted_set_combo.setEditable(True) - boosted_set_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) - boosted_set_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) - boosted_set_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains) - boosted_set_combo.addItems(["", *sorted(Dataloader().set_list)]) - if boosted_set_filter: - boosted_set_combo.setCurrentText(boosted_set_filter.set_name) - - boosted_affix_combo = IgnoreScrollWheelComboBox() - boosted_affix_combo.setEditable(True) - boosted_affix_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) - boosted_affix_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) - boosted_affix_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains) - boosted_affix_combo.addItems(["", *sorted(Dataloader().affix_dict.values())]) - if ( - boosted_set_filter - and boosted_set_filter.affix - and boosted_set_filter.affix.name in Dataloader().affix_dict - ): - boosted_affix_combo.setCurrentText(Dataloader().affix_dict[boosted_set_filter.affix.name]) - - boosted_affix_required = QCheckBox() - boosted_affix_required.setChecked(bool(boosted_set_filter and boosted_set_filter.required)) - - boosted_affix_mode = IgnoreScrollWheelComboBox() - boosted_affix_mode.setFixedSize(100, boosted_affix_mode.sizeHint().height()) - boosted_affix_mode.addItems([AFFIX_VALUE_MODE, AFFIX_PERCENT_MODE]) - if boosted_set_filter and boosted_set_filter.affix and boosted_set_filter.affix.min_percent_of_affix: - boosted_affix_mode.setCurrentText(AFFIX_PERCENT_MODE) - - boosted_affix_value = QLineEdit() - boosted_affix_value.setFixedSize(100, boosted_affix_value.sizeHint().height()) - - self.boosted_set_combos.append(boosted_set_combo) - self.boosted_affix_combos.append(boosted_affix_combo) - self.boosted_affix_required_checkboxes.append(boosted_affix_required) - self.boosted_affix_modes.append(boosted_affix_mode) - self.boosted_affix_values.append(boosted_affix_value) - - form.addRow(f"Boosted Set {index + 1}:", boosted_set_combo) - form.addRow(f"Boosted Affix {index + 1}:", boosted_affix_combo) - form.addRow(f"Require Boosted Affix {index + 1}:", boosted_affix_required) - - boosted_affix_threshold_layout = QHBoxLayout() - boosted_affix_threshold_layout.addWidget(boosted_affix_mode) - boosted_affix_threshold_layout.addWidget(boosted_affix_value) - boosted_affix_threshold_layout.addStretch() - form.addRow(f"Boosted Affix Threshold {index + 1}:", boosted_affix_threshold_layout) - - boosted_set_combo.currentTextChanged.connect(partial(self.update_boosted_set, index)) - boosted_affix_combo.currentTextChanged.connect(partial(self.update_boosted_affix, index)) - boosted_affix_required.clicked.connect(partial(self.update_boosted_affix_required, index)) - boosted_affix_mode.currentTextChanged.connect(partial(self.update_boosted_affix_mode, index)) - boosted_affix_value.textChanged.connect(partial(self.update_boosted_affix_value, index)) - self.refresh_boosted_affix_controls(index) - - def init_affix_pool(self): - for pool in self.config.affix_pool: - self.add_affix_pool_item(pool) - - def add_affix_pool_item(self, pool: AffixFilterCountModel): - nb_count = self.affix_pool_layout.count() - container = Container(f"Count {nb_count}", color_background=True) - container_layout = QVBoxLayout(container.content_widget) - widget = AffixPoolWidget(pool) - container_layout.addWidget(widget) - self.affix_pool_layout.addWidget(container) - QTimer.singleShot(50, container.expand) - - def add_affix_pool(self): - affix_dict = ( - Dataloader().seal_affix_dict if isinstance(self.config, SealFilterModel) else Dataloader().charm_affix_dict - ) - default_affix = AffixFilterModel(name=next(iter(affix_dict.keys())), value=None) - new_pool = AffixFilterCountModel(count=[default_affix], min_count=1, max_count=3) - self.config.affix_pool.append(new_pool) - self.add_affix_pool_item(new_pool) - - def remove_selected(self): - dialog = DeleteAffixPool(self.affix_pool_layout.count()) - if dialog.exec() != QDialog.DialogCode.Accepted: - return - to_delete = dialog.get_value() - to_delete_list = [] - for i in range(self.affix_pool_layout.count()): - item = self.affix_pool_layout.itemAt(i) - if item and item.widget() is not None and item.widget().header.name in to_delete: - to_delete_list.append((item.widget(), i)) - to_delete_list.reverse() - for widget, index in to_delete_list: - widget.setParent(None) - self.config.affix_pool.pop(index) - self.reorganize_pool() - - def reorganize_pool(self): - for i in range(self.affix_pool_layout.count()): - item = self.affix_pool_layout.itemAt(i) - if item and item.widget() is not None: - item.widget().header.set_name(f"Count {i}") - - def update_min_greater_affix(self): - self.config.min_greater_affix_count = self.min_greater.value() - - def update_rarities(self): - self.config.rarities = [rarity for rarity, checkbox in self.rarity_checkboxes.items() if checkbox.isChecked()] - - def update_set_name(self, text: str): - self.config.set_name = correct_name(text) if text.strip() else None - - def update_unique_aspect(self, text: str): - self.config.unique_aspect = correct_name(text) if text.strip() else None - - def update_charm_slots(self, slots: int, checked: bool): - if not checked: - if self.config.slots == slots: - self.config.slots = 0 - return - - self.config.slots = slots - for other_slots, checkbox in self.charm_slot_checkboxes.items(): - if other_slots == slots: - continue - with QSignalBlocker(checkbox): - checkbox.setChecked(False) - - def update_boosted_set(self, index: int, _current_text=None): - self.sync_boosted_sets_from_controls() - self.refresh_boosted_affix_controls(index) - - def update_boosted_affix(self, index: int, _current_text=None): - self.sync_boosted_sets_from_controls() - self.refresh_boosted_affix_controls(index) - - def update_boosted_affix_required(self, index: int, checked: bool): - self.boosted_affix_required_checkboxes[index].setChecked(checked) - self.sync_boosted_sets_from_controls() - - def update_boosted_affix_mode(self, index: int, _current_text=None): - self.sync_boosted_sets_from_controls() - self.refresh_boosted_affix_controls(index) - - def update_boosted_affix_value(self, index: int, value): - if self.boosted_affix_modes[index].currentText() == AFFIX_PERCENT_MODE: - try: - percent = int(value) if value else 0 - except ValueError: - return - if not 0 <= percent <= 100: - QMessageBox.warning(self, "Warning", "Min % must be between 0 and 100.") - self.refresh_boosted_affix_controls(index) - return - - self.sync_boosted_sets_from_controls() - - def sync_boosted_sets_from_controls(self): - boosted_sets = [] - for index in range(BOOSTED_SET_SLOT_COUNT): - set_name = correct_name(self.boosted_set_combos[index].currentText()) - if not set_name or set_name not in Dataloader().set_list: - continue - - boosted_sets.append( - BoostedSetFilterModel( - set=set_name, - affix=self.boosted_affix_from_controls(index), - required=self.boosted_affix_required_checkboxes[index].isChecked() - and self.boosted_affix_from_controls(index) is not None, - ) - ) - - self.config.boosted_sets = boosted_sets - - def boosted_affix_from_controls(self, index: int) -> AffixFilterModel | None: - current_text = self.boosted_affix_combos[index].currentText() - if not current_text.strip(): - return None - - reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()} - affix_name = reverse_dict.get(current_text) or correct_name(current_text) - if affix_name not in Dataloader().affix_dict: - return None - - affix = AffixFilterModel(name=affix_name, value=None) - value = self.boosted_affix_values[index].text() - if self.boosted_affix_modes[index].currentText() == AFFIX_PERCENT_MODE: - try: - affix.min_percent_of_affix = int(value) if value else 0 - except ValueError: - return affix - affix.value = None - return affix - - try: - affix.value = float(value) if value else None - except ValueError: - return affix - affix.min_percent_of_affix = 0 - return affix - - def refresh_boosted_affix_controls(self, index: int): - set_name = correct_name(self.boosted_set_combos[index].currentText()) - affix = self.boosted_affix_from_controls(index) - affix_selected = affix is not None - can_require_affix = bool(set_name and set_name in Dataloader().set_list and affix_selected) - - if not can_require_affix: - with QSignalBlocker(self.boosted_affix_required_checkboxes[index]): - self.boosted_affix_required_checkboxes[index].setChecked(False) - - self.boosted_affix_required_checkboxes[index].setEnabled(can_require_affix) - self.boosted_affix_modes[index].setEnabled(affix_selected) - self.boosted_affix_values[index].setEnabled(affix_selected) - - if not affix_selected: - with QSignalBlocker(self.boosted_affix_values[index]): - self.boosted_affix_values[index].clear() - return - - if self.boosted_affix_modes[index].currentText() == AFFIX_PERCENT_MODE: - self.boosted_affix_values[index].setPlaceholderText("Percent (0-100)") - self.boosted_affix_values[index].setValidator(QIntValidator(0, 100, self.boosted_affix_values[index])) - display_value = "" if affix.min_percent_of_affix == 0 else str(affix.min_percent_of_affix) - else: - self.boosted_affix_values[index].setPlaceholderText("Value (optional)") - self.boosted_affix_values[index].setValidator(QDoubleValidator(self.boosted_affix_values[index])) - display_value = "" if affix.value is None else str(affix.value) - - with QSignalBlocker(self.boosted_affix_values[index]): - self.boosted_affix_values[index].setText(display_value) - - -class SealCharmTab(QWidget): - def __init__( - self, - filters: list[DynamicSealCharmFilterModel], - section_name: str, - dynamic_model: type[DynamicSealCharmFilterModel | DynamicCharmFilterModel | DynamicSealFilterModel], - filter_model: type[SealCharmFilterModel | CharmFilterModel | SealFilterModel], - parent=None, - ): - super().__init__(parent) - self.filters = filters - self.section_name = section_name - self.dynamic_model = dynamic_model - self.filter_model = filter_model - self.loaded = False - - def load(self): - if not self.loaded: - self.setup_ui() - self.loaded = True - - def setup_ui(self): - self.main_layout = QVBoxLayout(self) - self.main_layout.setContentsMargins(0, 20, 0, 20) - - self.tab_widget = QTabWidget(self) - self.tab_widget.setTabsClosable(True) - self.tab_widget.tabCloseRequested.connect(self.close_tab) - - self.toolbar = QToolBar(f"{self.section_name}Toolbar", self) - self.toolbar.setMinimumHeight(50) - self.toolbar.setContentsMargins(10, 10, 10, 10) - self.toolbar.setMovable(False) - - self.rule_names = [] - for seal_charm_filter in self.filters: - for rule_name in seal_charm_filter.root: - if rule_name in self.rule_names: - QMessageBox.warning( - self, "Warning", f"Rule name already exists. Please rename {rule_name} in the profile file." - ) - continue - self.rule_names.append(rule_name) - self.tab_widget.addTab(SealCharmRuleEditor(seal_charm_filter), rule_name) - - add_rule_button = QPushButton("Create Item") - add_rule_button.clicked.connect(self.add_rule) - remove_rule_button = QPushButton("Remove Item") - remove_rule_button.clicked.connect(self.remove_rule) - - self.toolbar.addWidget(add_rule_button) - self.toolbar.addWidget(remove_rule_button) - self.main_layout.addWidget(self.toolbar) - self.main_layout.addWidget(self.tab_widget) - - def add_rule(self): - rule_name, ok = QInputDialog.getText(self, f"Create {self.section_name} Rule", "Rule Name:") - if not ok: - return - rule_name = rule_name.strip() - if not rule_name: - QMessageBox.warning(self, "Warning", "Rule name cannot be empty.") - return - if rule_name in self.rule_names: - QMessageBox.warning(self, "Warning", "Rule name already exists.") - return - - new_filter = self.dynamic_model(root={rule_name: self._default_filter()}) - self.filters.append(new_filter) - self.rule_names.append(rule_name) - self.tab_widget.addTab(SealCharmRuleEditor(new_filter), rule_name) - - def close_tab(self, index): - self.rule_names.pop(index) - self.tab_widget.removeTab(index) - self.filters.pop(index) - - def remove_rule(self): - dialog = DeleteItem(self.rule_names, self) - if dialog.exec() != QDialog.DialogCode.Accepted: - return - rule_names_to_delete = dialog.get_value() - for rule_name in rule_names_to_delete: - index = self.rule_names.index(rule_name) - self.rule_names.remove(rule_name) - self.tab_widget.removeTab(index) - self.filters.pop(index) - - def _default_filter(self) -> SealCharmFilterModel: - affix_dict = ( - Dataloader().seal_affix_dict if self.filter_model == SealFilterModel else Dataloader().charm_affix_dict - ) - return self.filter_model( - affix_pool=[ - AffixFilterCountModel( - count=[AffixFilterModel(name=next(iter(affix_dict.keys())))], min_count=1, max_count=3 - ) - ] - ) diff --git a/src/gui/profile_editor/sigils_tab.py b/src/gui/profile_editor/sigils_tab.py index 7c09a456..9c8ee711 100644 --- a/src/gui/profile_editor/sigils_tab.py +++ b/src/gui/profile_editor/sigils_tab.py @@ -135,7 +135,7 @@ def update_sigil_dungeon(self, classic=True): new_name = self.sigil_name_combo.currentText() self.old_name = self.sigil_name self.sigil_name = new_name - self.header.set_name(new_name) + self.header.set(new_name) reverse_dict = {v: k for k, v in Dataloader().affix_sigil_dict_all["dungeons"].items()} self.sigil.name = reverse_dict.get(new_name) if classic: diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index 338a4977..f43d5d88 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -7,7 +7,6 @@ import src.tts from src import TP -from src.config.ui import ResManager from src.dataloader import Dataloader from src.item.data.affix import Affix, AffixType from src.item.data.aspect import Aspect @@ -27,7 +26,7 @@ from src.item.descr import keep_letters_and_spaces from src.item.descr.text import find_number from src.item.descr.texture import find_affix_bullets, find_aspect_bullet, find_seperator_short, find_seperators_long -from src.item.models import BoostedSet, Item +from src.item.models import Item from src.scripts import correct_name from src.tts import ItemIdentifiers from src.utils.window import screenshot @@ -54,7 +53,7 @@ _FOR_SECONDS_RE = re.compile(r"for (?P\d+(?:\.\d+)?) Seconds") _REPLACE_COMPARE_RE = re.compile(r"\(.*\)") -_CHARM_SLOTS_RE = re.compile(r"unlocks (?P\d+) charm slots", re.IGNORECASE) +_GET_FIRST_NUMBER_RE = re.compile(r"\d+") _AFFIX_REPLACEMENTS = ["%", "+", ",", "[+]", "[x]", "per 5 Seconds"] _AFFIX_STOP_MARKERS = ( @@ -78,26 +77,18 @@ def _get_affix_counts(tts_section: list[str], item: Item, start: int) -> tuple[i inherent_num = 0 affixes_num = 4 # We assume these objects have the minimum number of affixes and then try to determine if they have more. - if item.rarity == ItemRarity.Rare: - affixes_num = 3 + if item.rarity == ItemRarity.Common: + affixes_num = 0 elif item.rarity == ItemRarity.Magic: affixes_num = 1 - elif item.rarity == ItemRarity.Common: - affixes_num = 0 - - if is_seal_or_charm(item.item_type): - total = _get_seal_charm_affix_count(tts_section, start) - if item.rarity in [ItemRarity.Unique, ItemRarity.Mythic]: - # Unique/mythic charms include a unique power (aspect) line and - # possibly flavor text that _get_seal_charm_affix_count incorrectly - # counts as affixes. Strip the trailing flavor text (no digits) and - # subtract 1 for the aspect. - lines = tts_section[start : start + total] - if total > 0 and not _has_numbers(lines[-1]): - total -= 1 - if total > 0: - total -= 1 - return inherent_num, total + elif item.rarity == ItemRarity.Rare: + affixes_num = 2 if is_seal_or_charm(item.item_type) else 3 + elif item.rarity == ItemRarity.Legendary: + affixes_num = 3 if is_seal_or_charm(item.item_type) else 4 + elif item.rarity == ItemRarity.Set: + affixes_num = 2 + elif item.rarity == ItemRarity.Unique: + affixes_num = 2 if is_seal_or_charm(item.item_type) else 4 if item.rarity in [ItemRarity.Unique, ItemRarity.Mythic]: # Uniques can have variable amounts of inherents. @@ -126,43 +117,11 @@ def _get_affix_counts(tts_section: list[str], item: Item, start: int) -> tuple[i return inherent_num, affixes_num -def _get_seal_charm_affix_count(tts_section: list[str], start: int) -> int: - affixes_num = 0 - for line in tts_section[start:]: - if line.lower().startswith(_AFFIX_STOP_MARKERS) or _get_set_name_from_line(line) is not None: - break - affixes_num += 1 - return affixes_num - - -def _read_charm_slots_from_tts_section( - tts_section: list[str], item: Item, starting_index: int, affix_bullets: list[TemplateMatch] | None = None -) -> tuple[int, int]: - if item.item_type != ItemType.HoradricSeal or starting_index >= len(tts_section): - return starting_index, 0 - - charm_slots_match = _CHARM_SLOTS_RE.search(tts_section[starting_index]) - if charm_slots_match is None: - return starting_index, 0 - - item.charm_slots = int(charm_slots_match.group("slots")) - if affix_bullets: - item.charm_slots_loc = affix_bullets[0].center - return starting_index + 1, 1 - return starting_index + 1, 0 - - def _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item: starting_index = _get_affix_starting_location_from_tts_section(tts_section, item) - starting_index, _affix_bullet_start = _read_charm_slots_from_tts_section(tts_section, item, starting_index) inherent_num, affixes_num = _get_affix_counts(tts_section, item, starting_index) affixes = _get_affixes_from_tts_section(tts_section, starting_index, inherent_num + affixes_num) - boosted_sets = [] - if item.item_type == ItemType.Charm: - item.set_name = _get_set_from_tts_section(tts_section, starting_index, len(affixes)) - elif item.item_type == ItemType.HoradricSeal: - boosted_sets = _get_boosted_sets_from_tts_section(tts_section, starting_index, len(affixes)) - aspect_text = _get_aspect_from_tts_section(tts_section, item, starting_index, len(affixes)) + aspect_or_set_text = _get_aspect_or_set_from_tts_section(tts_section, item, starting_index, len(affixes)) for i, affix_text in enumerate(affixes): if i < inherent_num: affix = _get_affix_from_text(affix_text, item.item_type) @@ -172,16 +131,15 @@ def _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item: affix = _get_affix_from_text(affix_text, item.item_type) item.affixes.append(affix) - if item.item_type == ItemType.HoradricSeal: - _add_boosted_sets_to_item(item, boosted_sets) - - if aspect_text: + if aspect_or_set_text: if item.rarity == ItemRarity.Mythic: - item.aspect = Aspect(name=item.name, text=aspect_text, value=find_number(aspect_text)) + item.aspect = Aspect(name=item.name, text=aspect_or_set_text, value=find_number(aspect_or_set_text)) elif item.rarity == ItemRarity.Unique: - item.aspect = _get_aspect_from_text(aspect_text, item.name) + item.aspect = _get_aspect_from_text(aspect_or_set_text, item.name) + elif item.rarity == ItemRarity.Set: + item.set = _get_set_from_text(aspect_or_set_text) else: - item.aspect = _get_aspect_from_name(aspect_text, item.name) + item.aspect = _get_aspect_from_name(aspect_or_set_text, item.name) return item @@ -193,98 +151,47 @@ def _add_affixes_from_tts_mixed( aspect_bullet: TemplateMatch | None, ) -> Item: starting_index = _get_affix_starting_location_from_tts_section(tts_section, item) - starting_index, affix_bullet_start = _read_charm_slots_from_tts_section( - tts_section, item, starting_index, affix_bullets - ) inherent_num, affixes_num = _get_affix_counts(tts_section, item, starting_index) affixes = _get_affixes_from_tts_section(tts_section, starting_index, inherent_num + affixes_num) - boosted_sets = [] - if item.item_type == ItemType.Charm: - item.set_name = _get_set_from_tts_section(tts_section, starting_index, len(affixes)) - elif item.item_type == ItemType.HoradricSeal: - boosted_sets = _get_boosted_sets_from_tts_section(tts_section, starting_index, len(affixes)) - aspect_text = _get_aspect_from_tts_section(tts_section, item, starting_index, len(affixes)) + aspect_or_set_text = _get_aspect_or_set_from_tts_section(tts_section, item, starting_index, len(affixes)) + + # A seal will always have one extra bullet that represents the number of slots + if item.item_type == ItemType.HoradricSeal: + affix_bullets.pop(0) # With advanced item compare on we'll actually find more bullets than we need, so we don't rely on them for # number of affixes - if affix_bullet_start + len(affixes) > len(affix_bullets): + if len(affixes) > len(affix_bullets): _raise_index_error(affixes, affix_bullets, item, img_item_descr) for i, affix_text in enumerate(affixes): - bullet_index = affix_bullet_start + i if i < inherent_num: affix = _get_affix_from_text(affix_text, item.item_type) affix.type = AffixType.inherent - affix.loc = affix_bullets[bullet_index].center + affix.loc = affix_bullets[i].center item.inherent.append(affix) elif i < inherent_num + affixes_num: affix = _get_affix_from_text(affix_text, item.item_type) - affix.loc = affix_bullets[bullet_index].center - if affix_bullets[bullet_index].name.startswith("greater_affix"): + affix.loc = affix_bullets[i].center + if affix_bullets[i].name.startswith("greater_affix"): affix.type = AffixType.greater - elif affix_bullets[bullet_index].name.startswith("rerolled"): + elif affix_bullets[i].name.startswith("rerolled"): affix.type = AffixType.rerolled else: affix.type = AffixType.normal item.affixes.append(affix) - extra_idx = affix_bullet_start + len(affixes) - last_known_bullet_loc = ( - affix_bullets[min(extra_idx, len(affix_bullets)) - 1].center if extra_idx and affix_bullets else None - ) - line_height = ResManager().offsets.item_descr_line_height - if item.item_type == ItemType.HoradricSeal: - _add_boosted_sets_to_item(item, boosted_sets) - previous_boosted_set_loc = None - for boosted_set in item.boosted_sets: - if previous_boosted_set_loc is not None: - min_next_boosted_set_y = previous_boosted_set_loc[1] + int(line_height * 1.2) - elif last_known_bullet_loc is not None: - min_next_boosted_set_y = last_known_bullet_loc[1] + line_height // 2 - else: - min_next_boosted_set_y = 0 - while extra_idx < len(affix_bullets) and ( - not affix_bullets[extra_idx].name.startswith("boosted_bullet_point") - or affix_bullets[extra_idx].center[1] < min_next_boosted_set_y - ): - extra_idx += 1 - if extra_idx < len(affix_bullets): - boosted_set.loc = affix_bullets[extra_idx].center - last_known_bullet_loc = boosted_set.loc - previous_boosted_set_loc = boosted_set.loc - extra_idx += 1 - elif previous_boosted_set_loc is not None: - boosted_set.loc = (previous_boosted_set_loc[0], previous_boosted_set_loc[1] + line_height * 2) - last_known_bullet_loc = boosted_set.loc - previous_boosted_set_loc = boosted_set.loc - elif last_known_bullet_loc is not None: - last_known_bullet_loc = (last_known_bullet_loc[0], last_known_bullet_loc[1] + line_height) - boosted_set.loc = last_known_bullet_loc - previous_boosted_set_loc = boosted_set.loc - if boosted_set.affix is not None and boosted_set.loc is not None: - last_known_bullet_loc = (boosted_set.loc[0], boosted_set.loc[1] + line_height) - elif item.item_type == ItemType.Charm and item.set_name: - if extra_idx < len(affix_bullets): - item.set_name_loc = affix_bullets[extra_idx].center - last_known_bullet_loc = item.set_name_loc - extra_idx += 1 - elif last_known_bullet_loc is not None: - last_known_bullet_loc = (last_known_bullet_loc[0], last_known_bullet_loc[1] + line_height) - item.set_name_loc = last_known_bullet_loc - - if aspect_text: + if aspect_or_set_text: if item.rarity == ItemRarity.Mythic: - item.aspect = Aspect(name=item.name, text=aspect_text, value=find_number(aspect_text)) + item.aspect = Aspect(name=item.name, text=aspect_or_set_text, value=find_number(aspect_or_set_text)) elif item.rarity == ItemRarity.Unique: - item.aspect = _get_aspect_from_text(aspect_text, item.name) + item.aspect = _get_aspect_from_text(aspect_or_set_text, item.name) + elif item.rarity == ItemRarity.Set: + item.set = _get_set_from_text(aspect_or_set_text) else: - item.aspect = _get_aspect_from_name(aspect_text, item.name) - if item.aspect: - if aspect_bullet: - item.aspect.loc = aspect_bullet.center - elif is_seal_or_charm(item.item_type) and extra_idx < len(affix_bullets): - item.aspect.loc = affix_bullets[extra_idx].center - extra_idx += 1 + item.aspect = _get_aspect_from_name(aspect_or_set_text, item.name) + if item.aspect and aspect_bullet: + item.aspect.loc = aspect_bullet.center return item @@ -385,7 +292,6 @@ def _create_base_item_from_tts(tts_item: list[str]) -> Item | None: search_string_split = tts_item[1].split(" ") item.rarity = _get_item_rarity(search_string_split[0]) return item - if "bloodied" in tts_item[1].lower(): item.seasonal_attribute = SeasonalAttribute.bloodied @@ -396,13 +302,12 @@ def _create_base_item_from_tts(tts_item: list[str]) -> Item | None: search_string = tts_item[1].lower().replace("ancestral", "").replace("bloodied", "").strip() search_string = _REPLACE_COMPARE_RE.sub("", search_string).strip() search_string_split = search_string.split(" ") - rarity_token = search_string_split[0] - item.rarity = _get_item_rarity(rarity_token) - starting_item_type_index = 0 + item.rarity = _get_item_rarity(search_string_split[0]) + starting_item_type_index = 1 if item.rarity == ItemRarity.Mythic: starting_item_type_index = 2 - elif rarity_token == item.rarity.value: - starting_item_type_index = 1 + elif item.rarity == ItemRarity.Common: + starting_item_type_index = 0 item.item_type = _get_item_type(" ".join(search_string_split[starting_item_type_index:])) item.name = correct_name(tts_item[0]) if item.name in Dataloader().bad_tts_uniques: @@ -437,8 +342,10 @@ def _get_affix_starting_location_from_tts_section(tts_section: list[str], item: start = _get_index_of_armor_dps_or_all_resist(tts_section, "armor") + 2 elif is_armor(item.item_type): start = _get_index_of_armor_dps_or_all_resist(tts_section, "armor") - elif is_seal_or_charm(item.item_type): - start = 1 + elif item.item_type == ItemType.HoradricSeal: + return 3 + elif item.item_type == ItemType.Charm: + return 2 start += 1 return start @@ -456,73 +363,34 @@ def _get_affixes_from_tts_section(tts_section: list[str], start: int, length: in return tts_section[start : start + length] -def _get_aspect_from_tts_section(tts_section: list[str], item: Item, start: int, num_affixes: int): - if item.item_type == ItemType.HoradricSeal and item.rarity not in [ItemRarity.Unique, ItemRarity.Mythic]: +def _get_aspect_or_set_from_tts_section(tts_section: list[str], item: Item, start: int, num_affixes: int): + if item.item_type == ItemType.HoradricSeal and item.rarity == ItemRarity.Legendary: return None - # Grab the aspect as well in this case + # Grab the aspect/set as well in this case if item.rarity in [ItemRarity.Mythic, ItemRarity.Unique, ItemRarity.Legendary]: aspect_index = start + num_affixes - if aspect_index < len(tts_section): - return tts_section[aspect_index] + return tts_section[aspect_index] + if item.rarity == ItemRarity.Set: + for line in tts_section[start + num_affixes :]: + set_name = _get_set_from_text(line) + if set_name: + return set_name return None -def _get_set_from_tts_section(tts_section: list[str], start: int, num_affixes: int) -> str | None: - return next(iter(_get_sets_from_tts_section(tts_section, start, num_affixes)), None) - - -def _get_sets_from_tts_section(tts_section: list[str], start: int, num_affixes: int) -> list[str]: - set_names = [] - for line in tts_section[start + num_affixes :]: - set_name = _get_set_name_from_line(line) - if set_name in Dataloader().set_list: - set_names.append(set_name) - return set_names - - -def _get_boosted_sets_from_tts_section(tts_section: list[str], start: int, num_affixes: int) -> list[BoostedSet]: - boosted_sets = [] - index = start + num_affixes - while index < len(tts_section): - line = tts_section[index] - set_name = _get_set_name_from_line(line) - if set_name not in Dataloader().set_list: - index += 1 - continue - - affix = None - parts = line.split(":", maxsplit=1) - if len(parts) > 1 and any(char.isdigit() for char in parts[1]): - affix = _get_affix_from_text(parts[1].strip()) - affix.type = AffixType.normal - else: - next_index = index + 1 - if ( - next_index < len(tts_section) - and not tts_section[next_index].lower().startswith(_AFFIX_STOP_MARKERS) - and _get_set_name_from_line(tts_section[next_index]) is None - ): - affix = _get_affix_from_text(tts_section[next_index]) - affix.type = AffixType.normal - index = next_index - boosted_sets.append(BoostedSet(name=set_name, affix=affix)) - index += 1 - return boosted_sets - - -def _add_boosted_sets_to_item(item: Item, boosted_sets: list[BoostedSet]) -> None: - item.boosted_sets = boosted_sets - item.boosted_set_name = item.boosted_sets[0].name if item.boosted_sets else None - - -def _get_set_name_from_line(line: str) -> str | None: - normalized_line = correct_name(line) - return next((set_name for set_name in Dataloader().set_list if set_name in normalized_line), None) +def _get_set_from_text(set_text: str) -> str | None: + set_name = correct_name(set_text) + if set_name in Dataloader().bad_tts_uniques: + set_name = Dataloader().bad_tts_uniques[set_name] + if set_name in Dataloader().set_list: + return set_name + return None def _get_affix_from_text(text: str, item_type: ItemType | None = None) -> Affix: result = Affix(text=text) + for x in _AFFIX_REPLACEMENTS: text = text.replace(x, "") text = _REPLACE_COMPARE_RE.sub("", text).strip() @@ -560,6 +428,10 @@ def _get_affix_from_text(text: str, item_type: ItemType | None = None) -> Affix: if matched_groups.get("onlyvalue") is not None: result.min_value = float(matched_groups.get("onlyvalue")) result.max_value = float(matched_groups.get("onlyvalue")) + + if "Charm Slot" in text: # These are never greater even if they look like they are greater + result.type = AffixType.normal + affix_dict = Dataloader().affix_dict if item_type == ItemType.HoradricSeal: affix_dict = Dataloader().affix_dict | Dataloader().seal_affix_dict @@ -656,17 +528,7 @@ def read_descr_mixed(img_item_descr: np.ndarray) -> Item | None: if (sep_short_match := find_seperator_short(img_item_descr)) is None: LOGGER.warning("Could not detect item_seperator_short.") screenshot("failed_seperator_short", img=img_item_descr) - if is_seal_or_charm(item.item_type): - return _add_affixes_from_tts(tts_section, item) return None - - affix_bullets = find_affix_bullets( - img_item_descr, sep_short_match, is_seal_or_charm=is_seal_or_charm(item.item_type) - ) - - if is_seal_or_charm(item.item_type): - return _add_affixes_from_tts_mixed(tts_section, item, affix_bullets, img_item_descr, aspect_bullet=None) - futures = { "sep_long": TP.submit(find_seperators_long, img_item_descr, sep_short_match), "aspect_bullet": ( @@ -676,6 +538,8 @@ def read_descr_mixed(img_item_descr: np.ndarray) -> Item | None: ), } + affix_bullets = find_affix_bullets(img_item_descr, sep_short_match) + if item.rarity == ItemRarity.Unique and item.name not in Dataloader().aspect_unique_dict: msg = ( f"Unrecognized unique {item.name}. This most likely means the name of it reported " @@ -700,8 +564,7 @@ def read_descr() -> Item | None: if item.item_type == ItemType.Cosmetic: item.cosmetic_upgrade = True return item - if is_seal_or_charm(item.item_type): - return _add_affixes_from_tts(tts_section, item) + if any([ is_consumable(item.item_type), is_non_sigil_mapping(item.item_type), @@ -715,7 +578,7 @@ def read_descr() -> Item | None: not is_armor(item.item_type), not is_jewelry(item.item_type), not is_weapon(item.item_type), - item.item_type != ItemType.Shield, + not is_seal_or_charm(item.item_type), ]): return None diff --git a/src/item/descr/texture.py b/src/item/descr/texture.py index b63c380b..fb89e750 100644 --- a/src/item/descr/texture.py +++ b/src/item/descr/texture.py @@ -84,9 +84,7 @@ def _find_bullets( return sorted(filtered_matches, key=lambda match: match.center[1]) -def find_affix_bullets( - img_item_descr: np.ndarray, sep_short_match: TemplateMatch, is_seal_or_charm: bool = False -) -> list[TemplateMatch]: +def find_affix_bullets(img_item_descr: np.ndarray, sep_short_match: TemplateMatch) -> list[TemplateMatch]: affix_icons = [f"affix_bullet_point_{x}" for x in range(1, 3)] rerolled_icons = [f"rerolled_bullet_point_{x}" for x in range(1, 3)] tempered_icons = [f"tempered_affix_bullet_point_{x}" for x in range(1, 7)] @@ -96,18 +94,13 @@ def find_affix_bullets( "greater_affix_bullet_point_masterworked", "masterworking_affix_bullet", "masterworking_affix_bullet_2", + "seal_set_bullet_point", ] + affix_icons + rerolled_icons + tempered_icons ) - if is_seal_or_charm: - template_list += [ - "legendary_bullet_point", - "unique_bullet_point", - "mythic_bullet_point", - "boosted_bullet_point", - ] + all_templates = [f"{x}_medium" for x in template_list] + template_list search_threshold = 0.80 if ResManager().resolution[1] <= 1200: diff --git a/src/item/filter.py b/src/item/filter.py index 090921dc..f90c8765 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -15,15 +15,13 @@ AffixAspectFilterModel, AffixFilterCountModel, AffixFilterModel, - BoostedSetFilterModel, + AspectUniqueFilterModel, CharmFilterModel, DynamicCharmFilterModel, DynamicItemFilterModel, DynamicSealCharmFilterModel, - DynamicSealFilterModel, GlobalUniqueModel, ProfileModel, - SealFilterModel, SigilConditionModel, SigilFilterModel, SigilPriority, @@ -47,6 +45,7 @@ class MatchedFilter: profile: str matched_affixes: list[Affix] = field(default_factory=list) aspect_match: bool = False + set_match: bool = False @dataclass @@ -69,7 +68,7 @@ def construct_mapping(self, node: MappingNode, deep=False): class Filter: - affix_filters = {} + item_filters = {} aspect_upgrade_filters = {} paragon_filters = {} global_unique_filters = {} @@ -91,12 +90,32 @@ def __new__(cls): cls._instance = super().__new__(cls) return cls._instance - def _check_affixes(self, item: Item) -> FilterResult: + def _check_unique_aspects_for_item(self, item: Item, unique_aspects: list[AspectUniqueFilterModel]) -> bool: + # check the unique aspect. The model enforces name uniqueness so we can safely grab the first one that matches + matched_unique_aspect = None + for unique_aspect in unique_aspects: + if self._match_item_aspect_or_affix(expected_aspect=unique_aspect, item_aspect=item.aspect): + matched_unique_aspect = unique_aspect + break + if unique_aspects and not matched_unique_aspect: + return False + # If the item is unique but doesn't match a unique aspect we continue. We don't check affixes + if item.rarity in [ItemRarity.Unique, ItemRarity.Mythic] and not matched_unique_aspect: + return False + # check the aspect matches the min percent. We only check the one that passed the previous check + return not ( + matched_unique_aspect + and not self._match_item_roll_is_in_percent_range( + expected_percent=matched_unique_aspect.min_percent_of_aspect, item_aspect_or_affix=item.aspect + ) + ) + + def _check_item_filters(self, item: Item) -> FilterResult: res = FilterResult(keep=False, matched=[]) - if not self.affix_filters: + if not self.item_filters: return FilterResult(keep=False, matched=[]) non_tempered_affixes = [affix for affix in item.affixes if affix.type != AffixType.tempered] - for profile_name, profile_filter in self.affix_filters.items(): + for profile_name, profile_filter in self.item_filters.items(): for filter_item in profile_filter: filter_name = next(iter(filter_item.root.keys())) filter_spec = filter_item.root[filter_name] @@ -111,21 +130,7 @@ def _check_affixes(self, item: Item) -> FilterResult: expected_min_count=filter_spec.min_greater_affix_count, item_affixes=non_tempered_affixes ): continue - # check the unique aspect. The model enforces name uniqueness so we can safely grab the first one that matches - matched_unique_aspect = None - for unique_aspect in filter_spec.unique_aspect: - if self._match_item_aspect_or_affix(expected_aspect=unique_aspect, item_aspect=item.aspect): - matched_unique_aspect = unique_aspect - break - if filter_spec.unique_aspect and not matched_unique_aspect: - continue - # If the item is unique but doesn't match a unique aspect we continue. We don't check affixes - if item.rarity in [ItemRarity.Unique, ItemRarity.Mythic] and not matched_unique_aspect: - continue - # check the aspect matches the min percent. We only check the one that passed the previous check - if matched_unique_aspect and not self._match_item_roll_is_in_percent_range( - expected_percent=matched_unique_aspect.min_percent_of_aspect, item_aspect_or_affix=item.aspect - ): + if not self._check_unique_aspects_for_item(item, filter_spec.unique_aspect): continue # check affixes matched_affixes = [] @@ -164,7 +169,7 @@ def _check_affixes(self, item: Item) -> FilterResult: ) return res - def _check_legendary_aspect(self, item: Item) -> FilterResult: + def _check_aspect_upgrades(self, item: Item) -> FilterResult: res = FilterResult(keep=False, matched=[]) if item.codex_upgrade and self.aspect_upgrade_filters: @@ -246,36 +251,23 @@ def _check_sigil(self, item: Item) -> FilterResult: def _check_seal_charm_filters( self, - item: Item, - item_filters: dict[str, list[DynamicSealCharmFilterModel]], + seal_or_charm: Item, + seal_or_charm_filters: dict[str, list[DynamicSealCharmFilterModel]], section_name: str, mythic_name: str, - extra_match=None, ) -> FilterResult: res = FilterResult(keep=False, matched=[]) - if not item_filters.items(): - LOGGER.info(f"{item.original_name} -- Matched {section_name}") - res.keep = True - res.matched.append(MatchedFilter(f"{section_name} not filtered")) - if item.rarity == ItemRarity.Mythic: - LOGGER.info(f"{item.original_name} -- Matched mythic {section_name.lower()}, always kept") - res.keep = True - res.matched.append(MatchedFilter(mythic_name)) - - for profile_name, profile_filter in item_filters.items(): + for profile_name, profile_filter in seal_or_charm_filters.items(): for filter_item in profile_filter: filter_name = next(iter(filter_item.root.keys())) filter_spec = filter_item.root[filter_name] - if filter_spec.rarities and item.rarity not in filter_spec.rarities: - continue - - if extra_match and not extra_match(item, filter_spec): + if filter_spec.rarities and seal_or_charm.rarity not in filter_spec.rarities: continue if not self._match_greater_affix_count( - expected_min_count=filter_spec.min_greater_affix_count, item_affixes=item.affixes + expected_min_count=filter_spec.min_greater_affix_count, item_affixes=seal_or_charm.affixes ): continue @@ -283,98 +275,55 @@ def _check_seal_charm_filters( if filter_spec.affix_pool: matched_affixes = self._match_affixes_count( expected_affixes=filter_spec.affix_pool, - item_affixes=item.affixes, + item_affixes=seal_or_charm.affixes, min_greater_affix_count=filter_spec.min_greater_affix_count, ) if not matched_affixes: continue - if ( - getattr(filter_spec, "slots", 0) > 0 - and item.charm_slots is not None - and item.charm_slots >= filter_spec.slots - and getattr(item, "charm_slots_loc", None) - ): - matched_affixes.append(Affix(name="charm_slots", loc=item.charm_slots_loc)) - - for bsf in getattr(filter_spec, "boosted_sets", []): - for bs in item.boosted_sets: - if bs.name == bsf.set_name: - if not bsf.required and bs.loc: - matched_affixes.append(Affix(name=bs.name, loc=bs.loc)) - elif ( - bs.affix is not None - and bsf.affix is not None - and self._match_item_aspect_or_affix(bsf.affix, bs.affix) - and bs.loc - ): - matched_affixes.append(Affix(name=f"{bs.name} ({bs.affix.name})", loc=bs.loc)) - - if item.item_type == ItemType.HoradricSeal: - matched_locs = {affix.loc for affix in matched_affixes if affix.loc} - for boosted_set in item.boosted_sets: - if boosted_set.loc and boosted_set.loc not in matched_locs: - matched_affixes.append(Affix(name=boosted_set.name, loc=boosted_set.loc)) - matched_locs.add(boosted_set.loc) - - if ( - getattr(filter_spec, "set_name", None) is not None - and item.set_name == filter_spec.set_name - and getattr(item, "set_name_loc", None) - ): - matched_affixes.append(Affix(name=item.set_name, loc=item.set_name_loc)) - - aspect_match = ( - getattr(filter_spec, "unique_aspect", None) is not None and item.name == filter_spec.unique_aspect - ) + # For charms we check the set or aspect + matched_aspect = False + matched_set = False + if isinstance(filter_spec, CharmFilterModel): + # You can't have both a unique aspect and a set + if filter_spec.unique_aspect: + if not self._check_unique_aspects_for_item(seal_or_charm, filter_spec.unique_aspect): + continue + matched_aspect = True + elif ( + seal_or_charm.rarity in [ItemRarity.Unique, ItemRarity.Mythic] and not filter_spec.unique_aspect + ): + continue + elif filter_spec.set: + if seal_or_charm.set not in filter_spec.set: + continue + if not seal_or_charm.set: # This would mean there's no set but a set is expected + continue + matched_set = True LOGGER.info( - f"{item.original_name} -- Matched {profile_name}.{section_name}.{filter_name}: {[affix.name for affix in matched_affixes]}" + f"{seal_or_charm.original_name} -- Matched {profile_name}.{section_name}.{filter_name}: {[affix.name for affix in matched_affixes]}" ) + if matched_aspect or matched_set: + LOGGER.info( + f"{seal_or_charm.original_name} -- Matched {profile_name}.{section_name}.{filter_name}: {'Unique aspect' if matched_aspect else 'Set'}" + ) res.keep = True res.matched.append( MatchedFilter( - f"{profile_name}.{section_name}.{filter_name}", matched_affixes, aspect_match=aspect_match + f"{profile_name}.{section_name}.{filter_name}", + matched_affixes, + aspect_match=matched_aspect, + set_match=matched_set, ) ) - return res - - @staticmethod - def _match_charm_filter(item: Item, filter_spec: CharmFilterModel) -> bool: - identity_fields = [filter_spec.set_name, filter_spec.unique_aspect] - if not any(identity_fields): - return True - return (filter_spec.set_name is not None and filter_spec.set_name == item.set_name) or ( - filter_spec.unique_aspect is not None and filter_spec.unique_aspect == item.name - ) - - def _match_seal_filter(self, item: Item, filter_spec: SealFilterModel) -> bool: - if filter_spec.slots and (item.charm_slots is None or item.charm_slots < filter_spec.slots): - return False - - if not filter_spec.boosted_sets: - return True - - return all( - self._match_boosted_set_filter(item, boosted_set_filter) for boosted_set_filter in filter_spec.boosted_sets - ) - def _match_boosted_set_filter(self, item: Item, filter_spec: BoostedSetFilterModel) -> bool: - if not item.boosted_sets: - return filter_spec.set_name == item.boosted_set_name and not filter_spec.required + if not res.keep and seal_or_charm.rarity == ItemRarity.Mythic: + LOGGER.info(f"{seal_or_charm.original_name} -- Matched mythic {section_name.lower()}, always kept") + res.keep = True + res.matched.append(MatchedFilter(mythic_name)) - for boosted_set in item.boosted_sets: - if boosted_set.name != filter_spec.set_name: - continue - if not filter_spec.required: - return True - if ( - boosted_set.affix is not None - and filter_spec.affix is not None - and self._match_item_aspect_or_affix(filter_spec.affix, boosted_set.affix) - ): - return True - return False + return res def _check_tribute(self, item: Item) -> FilterResult: res = FilterResult(keep=False, matched=[]) @@ -401,24 +350,6 @@ def _check_tribute(self, item: Item) -> FilterResult: res.matched.append(MatchedFilter(f"{profile_name}")) return res - def _check_seal(self, item: Item) -> FilterResult: - return self._check_seal_charm_filters( - item=item, - item_filters=self.seal_filters, - section_name="Seals", - mythic_name="Mythic Seal", - extra_match=self._match_seal_filter, - ) - - def _check_charm(self, item: Item) -> FilterResult: - return self._check_seal_charm_filters( - item=item, - item_filters=self.charm_filters, - section_name="Charms", - mythic_name="Mythic Charm", - extra_match=self._match_charm_filter, - ) - def _check_global_unique_filter(self, item: Item) -> FilterResult: res = FilterResult(keep=False, matched=[]) @@ -634,10 +565,10 @@ def _match_item_type(expected_item_types: list[ItemType], item_type: ItemType) - def load_files(self): self.files_loaded = True - self.affix_filters: dict[str, list[DynamicItemFilterModel]] = {} + self.item_filters: dict[str, list[DynamicItemFilterModel]] = {} self.aspect_upgrade_filters: dict[str, list[str]] = {} self.paragon_filters: dict[str, object] = {} - self.seal_filters: dict[str, list[DynamicSealFilterModel]] = {} + self.seal_filters: dict[str, list[DynamicSealCharmFilterModel]] = {} self.charm_filters: dict[str, list[DynamicCharmFilterModel]] = {} self.sigil_filters: dict[str, SigilFilterModel] = {} self.tribute_filters: dict[str, list[TributeFilterModel]] = {} @@ -694,7 +625,7 @@ def load_files(self): sections: list[str] = [] if data.affixes: - self.affix_filters[data.name] = data.affixes + self.item_filters[data.name] = data.affixes sections.append("Affixes") if data.aspect_upgrades: self.aspect_upgrade_filters[data.name] = data.aspect_upgrades @@ -742,19 +673,29 @@ def should_keep(self, item: Item) -> FilterResult: return self._check_tribute(item) if item.item_type == ItemType.HoradricSeal: - return self._check_seal(item) + return self._check_seal_charm_filters( + seal_or_charm=item, + seal_or_charm_filters=self.seal_filters, + section_name="Seals", + mythic_name="Mythic Seal", + ) if item.item_type == ItemType.Charm: - return self._check_charm(item) + return self._check_seal_charm_filters( + seal_or_charm=item, + seal_or_charm_filters=self.charm_filters, + section_name="Charms", + mythic_name="Mythic Charm", + ) if item.item_type is None or item.power is None: return res - keep_affixes = self._check_affixes(item) + keep_affixes = self._check_item_filters(item) if keep_affixes.keep: return keep_affixes if item.rarity == ItemRarity.Legendary: - res = self._check_legendary_aspect(item) + res = self._check_aspect_upgrades(item) elif item.rarity == ItemRarity.Unique: res = self._check_global_unique_filter(item) elif item.rarity == ItemRarity.Mythic: diff --git a/src/item/models.py b/src/item/models.py index d8166e1f..a28899de 100644 --- a/src/item/models.py +++ b/src/item/models.py @@ -13,25 +13,12 @@ LOGGER = logging.getLogger(__name__) -@dataclass -class BoostedSet: - __hash__ = None - - affix: Affix | None = None - name: str = "" - loc: tuple[int, int] | None = None - - @dataclass class Item: __hash__ = None affixes: list[Affix] = field(default_factory=list) aspect: Aspect | None = None - boosted_set_name: str | None = None - boosted_sets: list[BoostedSet] = field(default_factory=list) - charm_slots: int | None = None - charm_slots_loc: tuple[int, int] | None = None codex_upgrade: bool = False cosmetic_upgrade: bool = False inherent: list[Affix] = field(default_factory=list) @@ -42,8 +29,7 @@ class Item: power: int | None = None rarity: ItemRarity | None = None seasonal_attribute: SeasonalAttribute | None = None - set_name: str | None = None - set_name_loc: tuple[int, int] | None = None + set: str | None = None def __eq__(self, other): if not isinstance(other, Item): @@ -55,12 +41,6 @@ def __eq__(self, other): if self.aspect != other.aspect: # LOGGER.debug("Aspect not the same") res = False - if self.boosted_set_name != other.boosted_set_name: - res = False - if self.boosted_sets != other.boosted_sets: - res = False - if self.charm_slots != other.charm_slots: - res = False if self.codex_upgrade != other.codex_upgrade: # LOGGER.debug("Codex upgrade not the same") res = False @@ -86,7 +66,7 @@ def __eq__(self, other): res = False if self.seasonal_attribute != other.seasonal_attribute: res = False - if self.set_name != other.set_name: + if self.set != other.set: res = False return res @@ -97,11 +77,6 @@ def default(self, o): return { "affixes": [affix.__dict__ for affix in o.affixes], "aspect": o.aspect.__dict__ if o.aspect else None, - "boosted_set_name": o.boosted_set_name or None, - "boosted_sets": [ - {"affix": boosted_set.affix.__dict__ if boosted_set.affix else None, "name": boosted_set.name} - for boosted_set in o.boosted_sets - ], "charm_slots": o.charm_slots, "codex_upgrade": o.codex_upgrade, "cosmetic_upgrade": o.cosmetic_upgrade, @@ -110,6 +85,6 @@ def default(self, o): "name": o.name or None, "power": o.power or None, "rarity": o.rarity.value if o.rarity else None, - "set_name": o.set_name or None, + "set_name": o.set or None, } return super().default(o) diff --git a/src/scripts/__init__.py b/src/scripts/__init__.py index eee59460..c8f963f6 100644 --- a/src/scripts/__init__.py +++ b/src/scripts/__init__.py @@ -1,13 +1,9 @@ def correct_name(name: str) -> str | None: if name: - name = name.strip().lower() - from src.dataloader import Dataloader # noqa: PLC0415 - - for err, corr in Dataloader().error_map.items(): - name = name.replace(err, corr) - return ( name + .strip() + .lower() .replace(" (crucible)", "") # S12 Crucible items are identical to regular uniques .replace("'", "") .replace(" ", "_") diff --git a/src/scripts/vision_mode_fast.py b/src/scripts/vision_mode_fast.py index e40b30cb..3ac62035 100644 --- a/src/scripts/vision_mode_fast.py +++ b/src/scripts/vision_mode_fast.py @@ -170,6 +170,8 @@ def create_match_text(matches: list[MatchedFilter]): match_list = [f" - {ma.name}" for ma in match.matched_affixes] if match.aspect_match: match_list.append(" - Aspect") + if match.set_match: + match_list.append(" - Set") result.append(f"{match.profile}\n" + "\n".join(match_list)) return result diff --git a/src/scripts/vision_mode_with_highlighting.py b/src/scripts/vision_mode_with_highlighting.py index adc98831..3750494f 100644 --- a/src/scripts/vision_mode_with_highlighting.py +++ b/src/scripts/vision_mode_with_highlighting.py @@ -217,7 +217,10 @@ def draw_match_outline(self, item_roi, should_keep_res, item_descr): # show all info strings of the profiles text_y = h for match in reversed(should_keep_res.matched): - text_y = self.draw_text(self.canvas, match.profile, get_filter_colors().matched, text_y, 5, w // 2) + text = match.profile + if match.set_match: + text = text + " (incl. Set)" + text_y = self.draw_text(self.canvas, text, get_filter_colors().matched, text_y, 5, w // 2) # Show matched bullets if item_descr and len(should_keep_res.matched) > 0: bullet_width = self.thick * 3 diff --git a/src/tools/gen_data.py b/src/tools/gen_data.py index 1bce03a6..43d8c107 100644 --- a/src/tools/gen_data.py +++ b/src/tools/gen_data.py @@ -35,6 +35,7 @@ EXCLUDED_SEAL_AFFIX_KEYS = { "when_you_gain_a_stack_of_stoicism_gain_damage_for_second", "while_in_a_feral_rage_your_werewolf_skills_gain_attack_speed", + "cannot_have_more_than_sockets_but_can_equip_unique_charms", } @@ -333,6 +334,8 @@ def companion_style_affix_description( def normalise_affix_description(description: str) -> tuple[str, str] | None: desc = description.lower().strip().replace("'", "").replace("’", "").replace("’", "").replace(".", "") + # A little hacky but we'll fix this bad data here. If we find more we'll make a better solution + desc = desc.replace("lighting", "lightning") desc = remove_content_in_braces(desc) desc = desc.removeprefix("x ") if len(desc) <= 2: diff --git a/tests/config/models_test.py b/tests/config/models_test.py index acfcdc5c..258a016a 100644 --- a/tests/config/models_test.py +++ b/tests/config/models_test.py @@ -22,12 +22,11 @@ AspectUniqueFilterModel, CharmFilterModel, DynamicCharmFilterModel, - DynamicSealFilterModel, + DynamicSealCharmFilterModel, GlobalUniqueModel, ItemFilterModel, ItemRarity, ProfileModel, - SealFilterModel, SigilConditionModel, SigilFilterModel, TributeFilterModel, @@ -582,13 +581,13 @@ def test_invalid_tribute_name_fails(self) -> None: def test_rarities_parse_string(self) -> None: """Test rarities field parsing from string (line 315).""" - model = TributeFilterModel(rarities=ItemRarity.Legendary.value) + model = TributeFilterModel(rarities=[ItemRarity.Legendary]) # Verify it's an ItemRarity enum assert ItemRarity.Legendary in model.rarities def test_rarities_parse_list(self) -> None: """Test rarities field parsing from list (line 315).""" - model = TributeFilterModel(rarities=[ItemRarity.Legendary.value]) + model = TributeFilterModel(rarities=[ItemRarity.Legendary]) assert len(model.rarities) == 1 # Verify it's an ItemRarity enum assert ItemRarity.Legendary in model.rarities @@ -596,68 +595,18 @@ def test_rarities_parse_list(self) -> None: class TestCharmFilterModel: def test_set_name_is_validated_and_normalized(self) -> None: - model = CharmFilterModel(set="Breath of the Frozen Sea") + model = CharmFilterModel(set=["Breath of the Frozen Sea"]) - assert model.set_name == "breath_of_the_frozen_sea" + assert model.set == ["breath_of_the_frozen_sea"] def test_invalid_set_fails(self) -> None: with pytest.raises(ValidationError, match="set invalid_set does not exist"): - CharmFilterModel(set="invalid set") + CharmFilterModel(set=["invalid set"]) def test_unique_aspect_is_normalized(self) -> None: - model = CharmFilterModel(uniqueAspect="Linta of the Frozen Sea") + model = CharmFilterModel(uniqueAspect=[AspectUniqueFilterModel(name="Fractured Winterglass")]) - assert model.unique_aspect == "linta_of_the_frozen_sea" - - -class TestSealFilterModel: - def test_boosted_sets_are_validated_and_normalized(self) -> None: - model = SealFilterModel( - boostedSets=[ - {"set": "Berserker's Crucible", "affix": "maximum_fury", "required": True}, - {"set": "Cathan's Dauntless Faith", "affix": "cooldown_reduction"}, - ] - ) - - assert model.boosted_sets[0].set_name == "berserkers_crucible" - assert model.boosted_sets[0].affix.name == "maximum_fury" - assert model.boosted_sets[0].required - assert model.boosted_sets[1].set_name == "cathans_dauntless_faith" - assert model.boosted_sets[1].affix.name == "cooldown_reduction" - assert not model.boosted_sets[1].required - - def test_slots_default_is_three(self) -> None: - model = SealFilterModel() - - assert model.slots == 3 - - def test_slots_is_validated(self) -> None: - model = SealFilterModel(slots=6) - - assert model.slots == 6 - assert model.model_dump()["slots"] == 6 - - def test_legacy_charm_slots_aliases_are_validated(self) -> None: - assert SealFilterModel(charmSlots=5).slots == 5 - assert SealFilterModel(charm_slots=6).slots == 6 - - def test_required_boosted_set_affix_needs_affix(self) -> None: - with pytest.raises(ValidationError, match="required boostedSets entries need affix"): - SealFilterModel(boostedSets=[{"set": "Berserker's Crucible", "required": True}]) - - def test_invalid_slot_count_fails(self) -> None: - with pytest.raises(ValidationError, match="slots must be 0 or between 3 and 6"): - SealFilterModel(slots=-1) - - with pytest.raises(ValidationError, match="slots must be 0 or between 3 and 6"): - SealFilterModel(slots=2) - - with pytest.raises(ValidationError, match="slots must be 0 or between 3 and 6"): - SealFilterModel(slots=7) - - def test_invalid_boosted_set_fails(self) -> None: - with pytest.raises(ValidationError, match="set invalid_set does not exist"): - SealFilterModel(boostedSets=[{"set": "invalid set"}]) + assert model.unique_aspect == [AspectUniqueFilterModel(name="fractured_winterglass")] class TestSigilConditionModel: @@ -769,7 +718,7 @@ def test_snake_case_input(self) -> None: assert model.aspect_upgrades == [] assert len(model.global_uniques) == 1 assert model.global_uniques[0].min_power == 900 - assert isinstance(model.seals[0], DynamicSealFilterModel) + assert isinstance(model.seals[0], DynamicSealCharmFilterModel) assert isinstance(model.charms[0], DynamicCharmFilterModel) assert model.seals[0].root["Cooldown"].affix_pool[0].count[0].name == "cooldown_reduction" assert model.charms[0].root["Life"].affix_pool[0].count[0].name == "maximum_life" diff --git a/tests/item/filter/filter_test.py b/tests/item/filter/filter_test.py index 32a5acfb..d56dcb74 100644 --- a/tests/item/filter/filter_test.py +++ b/tests/item/filter/filter_test.py @@ -5,12 +5,14 @@ import pytest from natsort import natsorted +from item.data.aspect import Aspect from src.config.loader import IniConfigLoader from src.config.profile_models import ( + AspectUniqueFilterModel, CharmFilterModel, DynamicCharmFilterModel, - DynamicSealFilterModel, - SealFilterModel, + DynamicSealCharmFilterModel, + SealCharmFilterModel, SigilPriority, ) from src.config.settings_models import AspectFilterType @@ -18,7 +20,7 @@ from src.item.data.item_type import ItemType from src.item.data.rarity import ItemRarity from src.item.filter import Filter, FilterResult -from src.item.models import BoostedSet, Item +from src.item.models import Item from tests.item.filter.data import filters from tests.item.filter.data.affixes import affixes from tests.item.filter.data.aspects import aspects @@ -33,7 +35,7 @@ def _create_mocked_filter(mocker: MockerFixture) -> Filter: filter_obj = Filter() # Filter is singleton so we need to reset the filters to be safe - filter_obj.affix_filters = {} + filter_obj.item_filters = {} filter_obj.aspect_upgrade_filters = {} filter_obj.paragon_filters = {} filter_obj.global_unique_filters = {} @@ -51,7 +53,7 @@ def _create_mocked_filter(mocker: MockerFixture) -> Filter: ) def test_affixes(_name: str, result: list[str], item: Item, mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) - test_filter.affix_filters = {filters.affix.name: filters.affix.affixes} + test_filter.item_filters = {filters.affix.name: filters.affix.affixes} assert natsorted([match.profile for match in test_filter.should_keep(item).matched]) == natsorted(result) @@ -62,7 +64,7 @@ def test_aspects(_name: str, result: list[str], item: Item, mocker: MockerFixtur test_filter = _create_mocked_filter(mocker) general_mock = mocker.patch.object(IniConfigLoader(), "_general") general_mock.keep_aspects = AspectFilterType.upgrade - mocker.patch.object(test_filter, "_check_affixes", return_value=FilterResult(keep=False, matched=[])) + mocker.patch.object(test_filter, "_check_item_filters", return_value=FilterResult(keep=False, matched=[])) test_filter.aspect_upgrade_filters = {filters.aspects_filters.name: filters.aspects_filters.aspect_upgrades} assert natsorted([match.profile for match in test_filter.should_keep(item).matched]) == natsorted(result) @@ -121,13 +123,12 @@ def test_tributes(_name: str, result: list[str], item: Item, mocker: MockerFixtu item_type=ItemType.HoradricSeal, name="unimportant_seal_name", rarity=ItemRarity.Legendary, - charm_slots=3, affixes=[Affix(name="cooldown_reduction")], ), "seal_filters", "Seals", - SealFilterModel, - DynamicSealFilterModel, + SealCharmFilterModel, + DynamicSealCharmFilterModel, ), ( Item( @@ -161,285 +162,79 @@ def test_charm_filter_matches_set_name(mocker: MockerFixture): item_type=ItemType.Charm, name="linta_of_the_frozen_sea", rarity=ItemRarity.Legendary, - set_name="breath_of_the_frozen_sea", - set_name_loc=(10, 20), + set="breath_of_the_frozen_sea", affixes=[Affix(name="potion_healing")], ) - charm_filter = CharmFilterModel(set="Breath of the Frozen Sea") + charm_filter = CharmFilterModel(set=["Breath of the Frozen Sea"]) test_filter.charm_filters = {"seal_charm": [DynamicCharmFilterModel(root={"wanted": charm_filter})]} match = test_filter.should_keep(item).matched[0] assert match.profile == "seal_charm.Charms.wanted" - assert [(affix.name, affix.loc) for affix in match.matched_affixes] == [("breath_of_the_frozen_sea", (10, 20))] + assert match.set_match def test_charm_filter_matches_unique_aspect_name(mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) item = Item( item_type=ItemType.Charm, - name="linta_of_the_frozen_sea", - rarity=ItemRarity.Legendary, - set_name="breath_of_the_frozen_sea", + name="fractured_winterglass", + rarity=ItemRarity.Unique, + aspect=Aspect(name="fractured_winterglass"), + set=None, affixes=[Affix(name="potion_healing")], ) - charm_filter = CharmFilterModel(uniqueAspect="Linta of the Frozen Sea") + charm_filter = CharmFilterModel(uniqueAspect=[AspectUniqueFilterModel(name="fractured_winterglass")]) test_filter.charm_filters = {"seal_charm": [DynamicCharmFilterModel(root={"wanted": charm_filter})]} match = test_filter.should_keep(item).matched[0] assert match.profile == "seal_charm.Charms.wanted" + assert match.aspect_match -def test_charm_filter_rejects_wrong_set_or_unique_aspect(mocker: MockerFixture): +def test_charm_filter_rejects_wrong_unique_aspect(mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) item = Item( item_type=ItemType.Charm, name="linta_of_the_frozen_sea", - rarity=ItemRarity.Legendary, - set_name="breath_of_the_frozen_sea", + rarity=ItemRarity.Unique, + aspect=Aspect(name="flickerstep"), + set=None, affixes=[Affix(name="potion_healing")], ) - charm_filter = CharmFilterModel(set="applied_alchemy", uniqueAspect="another_charm") + charm_filter = CharmFilterModel(uniqueAspect=[AspectUniqueFilterModel(name="fractured_winterglass")]) test_filter.charm_filters = {"seal_charm": [DynamicCharmFilterModel(root={"wrong": charm_filter})]} assert test_filter.should_keep(item).matched == [] -def test_seal_filter_matches_boosted_set(mocker: MockerFixture): - test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.HoradricSeal, - name="unimportant_seal_name", - rarity=ItemRarity.Legendary, - charm_slots=3, - boosted_set_name="berserkers_crucible", - affixes=[Affix(name="maximum_fury")], - ) - seal_filter = SealFilterModel( - boostedSets=[{"set": "Berserker's Crucible"}], affixPool=[{"count": ["maximum_fury"]}] - ) - test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} - - match = test_filter.should_keep(item).matched[0] - - assert match.profile == "seal_charm.Seals.wanted" - assert match.matched_affixes == item.affixes - - -def test_seal_filter_matches_any_boosted_set(mocker: MockerFixture): - test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.HoradricSeal, - name="unimportant_seal_name", - rarity=ItemRarity.Legendary, - charm_slots=3, - boosted_sets=[ - BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction")), - BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury")), - ], - affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_fury")], - ) - seal_filter = SealFilterModel(boostedSets=[{"set": "Berserker's Crucible"}]) - test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} - - match = test_filter.should_keep(item).matched[0] - - assert match.profile == "seal_charm.Seals.wanted" - - -def test_seal_filter_matches_charm_slots_and_boosted_set(mocker: MockerFixture): - test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.HoradricSeal, - name="unimportant_seal_name", - rarity=ItemRarity.Legendary, - charm_slots=6, - charm_slots_loc=(10, 20), - boosted_sets=[ - BoostedSet(name="habacalvas_cauldron", affix=Affix(name="life_on_hit", value=255.0), loc=(30, 40)), - BoostedSet(name="tal_rashas_threefold_way", affix=Affix(name="to_ball_lightning", value=2.0), loc=(70, 80)), - ], - affixes=[Affix(name="resource_cost_reduction", value=7.5)], - ) - seal_filter = SealFilterModel(slots=6, boostedSets=[{"set": "Habacalva's Cauldron"}]) - test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} - - match = test_filter.should_keep(item).matched[0] - - assert match.profile == "seal_charm.Seals.wanted" - assert [(affix.name, affix.loc) for affix in match.matched_affixes] == [ - ("charm_slots", (10, 20)), - ("habacalvas_cauldron", (30, 40)), - ("tal_rashas_threefold_way", (70, 80)), - ] - - -def test_seal_filter_rejects_insufficient_charm_slots(mocker: MockerFixture): - test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.HoradricSeal, - name="unimportant_seal_name", - rarity=ItemRarity.Legendary, - charm_slots=5, - boosted_sets=[BoostedSet(name="habacalvas_cauldron", affix=Affix(name="life_on_hit", value=255.0))], - affixes=[Affix(name="resource_cost_reduction", value=7.5)], - ) - seal_filter = SealFilterModel(slots=6, boostedSets=[{"set": "Habacalva's Cauldron"}]) - test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wrong": seal_filter})]} - - assert test_filter.should_keep(item).matched == [] - - -def test_seal_filter_matches_more_charm_slots_than_minimum(mocker: MockerFixture): - test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.HoradricSeal, - name="unimportant_seal_name", - rarity=ItemRarity.Legendary, - charm_slots=6, - boosted_sets=[BoostedSet(name="habacalvas_cauldron", affix=Affix(name="life_on_hit", value=255.0))], - affixes=[Affix(name="resource_cost_reduction", value=7.5)], - ) - seal_filter = SealFilterModel(slots=5, boostedSets=[{"set": "Habacalva's Cauldron"}]) - test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} - - match = test_filter.should_keep(item).matched[0] - - assert match.profile == "seal_charm.Seals.wanted" - - -def test_seal_filter_matches_required_boosted_affix(mocker: MockerFixture): +def test_charm_filter_rejects_wrong_set(mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) item = Item( - item_type=ItemType.HoradricSeal, - name="unimportant_seal_name", - rarity=ItemRarity.Legendary, - charm_slots=3, - boosted_sets=[ - BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction"), loc=(10, 20)), - BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury"), loc=(50, 60)), - ], - affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_fury")], - ) - seal_filter = SealFilterModel( - boostedSets=[{"set": "Berserker's Crucible", "affix": "maximum_fury", "required": True}] - ) - test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} - - match = test_filter.should_keep(item).matched[0] - - assert match.profile == "seal_charm.Seals.wanted" - assert [(affix.name, affix.loc) for affix in match.matched_affixes] == [ - ("berserkers_crucible (maximum_fury)", (50, 60)), - ("cathans_dauntless_faith", (10, 20)), - ] - - -def test_seal_filter_matches_two_boosted_sets_with_required_affixes(mocker: MockerFixture): - test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.HoradricSeal, - name="unimportant_seal_name", - rarity=ItemRarity.Legendary, - charm_slots=3, - boosted_sets=[ - BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction"), loc=(10, 20)), - BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury"), loc=(50, 60)), - ], - affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_fury")], - ) - seal_filter = SealFilterModel( - boostedSets=[ - {"set": "Berserker's Crucible", "affix": "maximum_fury", "required": True}, - {"set": "Cathan's Dauntless Faith", "affix": "cooldown_reduction", "required": True}, - ] - ) - test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} - - match = test_filter.should_keep(item).matched[0] - - assert match.profile == "seal_charm.Seals.wanted" - assert [(affix.name, affix.loc) for affix in match.matched_affixes] == [ - ("berserkers_crucible (maximum_fury)", (50, 60)), - ("cathans_dauntless_faith (cooldown_reduction)", (10, 20)), - ] - - -def test_seal_filter_rejects_missing_second_boosted_set(mocker: MockerFixture): - test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.HoradricSeal, - name="unimportant_seal_name", - rarity=ItemRarity.Legendary, - charm_slots=3, - boosted_sets=[BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury"))], - affixes=[Affix(name="maximum_fury")], - ) - seal_filter = SealFilterModel( - boostedSets=[ - {"set": "Berserker's Crucible", "affix": "maximum_fury", "required": True}, - {"set": "Cathan's Dauntless Faith", "affix": "cooldown_reduction", "required": True}, - ] - ) - test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wrong": seal_filter})]} - - assert test_filter.should_keep(item).matched == [] - - -def test_seal_filter_ignores_boosted_affix_when_not_required(mocker: MockerFixture): - test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.HoradricSeal, - name="unimportant_seal_name", - rarity=ItemRarity.Legendary, - charm_slots=3, - boosted_sets=[BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury"))], - affixes=[Affix(name="maximum_fury")], - ) - seal_filter = SealFilterModel(boostedSets=[{"set": "Berserker's Crucible", "affix": "cooldown_reduction"}]) - test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wanted": seal_filter})]} - - match = test_filter.should_keep(item).matched[0] - - assert match.profile == "seal_charm.Seals.wanted" - - -def test_seal_filter_rejects_wrong_required_boosted_affix(mocker: MockerFixture): - test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.HoradricSeal, - name="unimportant_seal_name", - rarity=ItemRarity.Legendary, - charm_slots=3, - boosted_sets=[ - BoostedSet(name="cathans_dauntless_faith", affix=Affix(name="cooldown_reduction")), - BoostedSet(name="berserkers_crucible", affix=Affix(name="maximum_fury")), - ], - affixes=[Affix(name="cooldown_reduction"), Affix(name="maximum_fury")], - ) - seal_filter = SealFilterModel( - boostedSets=[{"set": "Berserker's Crucible", "affix": "cooldown_reduction", "required": True}] + item_type=ItemType.Charm, + name="linta_of_the_frozen_sea", + rarity=ItemRarity.Set, + set="breath_of_the_frozen_sea", + affixes=[Affix(name="potion_healing")], ) - test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wrong": seal_filter})]} + charm_filter = CharmFilterModel(set=["applied_alchemy"]) + test_filter.charm_filters = {"seal_charm": [DynamicCharmFilterModel(root={"wrong": charm_filter})]} assert test_filter.should_keep(item).matched == [] -def test_seal_filter_rejects_wrong_boosted_set(mocker: MockerFixture): +def test_charm_filter_rejects_no_set(mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) item = Item( - item_type=ItemType.HoradricSeal, - name="unimportant_seal_name", - rarity=ItemRarity.Legendary, - charm_slots=3, - boosted_set_name="berserkers_crucible", - affixes=[Affix(name="maximum_fury")], - ) - seal_filter = SealFilterModel( - boostedSets=[{"set": "cathans_dauntless_faith"}], affixPool=[{"count": ["maximum_fury"]}] + item_type=ItemType.Charm, + name="linta_of_the_frozen_sea", + rarity=ItemRarity.Rare, + set=None, + affixes=[Affix(name="potion_healing")], ) - test_filter.seal_filters = {"seal_charm": [DynamicSealFilterModel(root={"wrong": seal_filter})]} + charm_filter = CharmFilterModel(set=["applied_alchemy"]) + test_filter.charm_filters = {"seal_charm": [DynamicCharmFilterModel(root={"wrong": charm_filter})]} assert test_filter.should_keep(item).matched == [] @@ -454,8 +249,8 @@ def test_seal_filter_rejects_wrong_boosted_set(mocker: MockerFixture): affixes=[Affix(name="cooldown_reduction", type=AffixType.greater)], ), "seal_filters", - SealFilterModel, - DynamicSealFilterModel, + SealCharmFilterModel, + DynamicSealCharmFilterModel, ), ( Item( @@ -473,7 +268,7 @@ def test_mythic_seal_or_charm_always_kept( item: Item, filter_attr: str, filter_model, dynamic_model, mocker: MockerFixture ): test_filter = _create_mocked_filter(mocker) - seal_charm_filter = filter_model(affix_pool=[{"count": ["movement_speed"]}], rarities=[ItemRarity.Common]) + seal_charm_filter = filter_model(affixPool=[{"count": ["movement_speed"]}], rarities=[ItemRarity.Common]) setattr(test_filter, filter_attr, {"seal_charm": [dynamic_model(root={"wrong": seal_charm_filter})]}) assert test_filter.should_keep(item).keep @@ -487,7 +282,7 @@ def test_mythic_seal_or_charm_always_kept( ) def test_uniques_with_affixes(_name: str, result: list[str], item: Item, mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) - test_filter.affix_filters = {filters.unique_affixes.name: filters.unique_affixes.affixes} + test_filter.item_filters = {filters.unique_affixes.name: filters.unique_affixes.affixes} assert natsorted([match.profile for match in test_filter.should_keep(item).matched]) == natsorted(result) diff --git a/tests/item/read_descr_season_13_tts_test.py b/tests/item/read_descr_season_13_tts_test.py index 7b6096de..e55b30aa 100644 --- a/tests/item/read_descr_season_13_tts_test.py +++ b/tests/item/read_descr_season_13_tts_test.py @@ -6,7 +6,7 @@ from src.item.data.item_type import ItemType from src.item.data.rarity import ItemRarity from src.item.descr.read_descr_tts import read_descr -from src.item.models import BoostedSet, Item +from src.item.models import Item items = [ ( @@ -387,123 +387,282 @@ seasonal_attribute=None, ), ), + # Rare seal ( [ - "INIMICAL SEAL OF FURY", + "VIGOROUS HORADRIC SEAL OF DETERMINATION", + "Rare Horadric Seal", + "Unlocks 4 Charm Slots", + "8.0% Maximum Life [6.5 - 8.0]% (+8.0%)", + "Arms of Arreat:. +3 [2 - 3] maximum Resolve", + "Properties lost when equipped:", + "Unlocks 2 Charm Slots", + "+7.5% Resistance to All Elements", + "Seal Power", + "Seal Power", + "Requires Level 70. Lord of Hatred Item", + "Sell Value: 1,340,759 Gold", + "Right mouse button", + ], + Item( + affixes=[ + Affix( + max_value=8.0, + min_value=6.5, + name="maximum_life", + text="8.0% Maximum Life [6.5 - 8.0]% (+8.0%)", + type=AffixType.normal, + value=8.0, + ), + Affix( + max_value=3.0, + min_value=2.0, + name="arms_of_arreat_maximum_resolve", + text="Arms of Arreat:. +3 [2 - 3] maximum Resolve", + type=AffixType.normal, + value=3.0, + ), + ], + aspect=None, + codex_upgrade=False, + cosmetic_upgrade=False, + inherent=[], + is_in_shop=False, + item_type=ItemType.HoradricSeal, + name="vigorous_horadric_seal_of_determination", + original_name="VIGOROUS HORADRIC SEAL OF DETERMINATION", + power=None, + rarity=ItemRarity.Rare, + seasonal_attribute=None, + set=None, + ), + ), + # Legendary seal with multiple affixes + ( + [ + "RESISTANT HORADRIC SEAL OF CURRENT", "Legendary Horadric Seal", - "5 Maximum Fury [5]", - "Boosts Berserker's Crucible", + "Unlocks 5 Charm Slots", + "+8.5% Resistance to All Elements [7.5 - 10.0]%", + "Cains Wild Lighting:. +3 [3 - 4] Mana per Second", + "Tal Rashas Threefold Way:. +3 [2 - 3] to Ball Lightning", + "Requires Level 50. Lord of Hatred Item", + "Sell Value: 13,380,834 Gold", "Right mouse button", ], Item( affixes=[ Affix( - max_value=5.0, - min_value=5.0, - name="maximum_fury", - text="5 Maximum Fury [5]", + max_value=10.0, + min_value=7.5, + name="resistance_to_all_elements", + text="+8.5% Resistance to All Elements [7.5 - 10.0]%", type=AffixType.normal, - value=5.0, - ) + value=8.5, + ), + Affix( + max_value=4.0, + min_value=3.0, + name="cains_wild_lightning_mana_per_second", + text="Cains Wild Lighting:. +3 [3 - 4] Mana per Second", + type=AffixType.normal, + value=3.0, + ), + Affix( + max_value=3.0, + min_value=2.0, + name="tal_rashas_threefold_way_to_ball_lightning", + text="Tal Rashas Threefold Way:. +3 [2 - 3] to Ball Lightning", + type=AffixType.normal, + value=3.0, + ), ], aspect=None, - boosted_set_name="berserkers_crucible", - boosted_sets=[BoostedSet(affix=None, name="berserkers_crucible")], codex_upgrade=False, cosmetic_upgrade=False, inherent=[], is_in_shop=False, item_type=ItemType.HoradricSeal, - name="inimical_seal_of_fury", - original_name="INIMICAL SEAL OF FURY", + name="resistant_horadric_seal_of_current", + original_name="RESISTANT HORADRIC SEAL OF CURRENT", power=None, rarity=ItemRarity.Legendary, seasonal_attribute=None, + set=None, ), ), + # Legendary seal with +1 charm slot ( [ - "EFFICIENT HORADRIC SEAL OF FERVOR", + "RESISTANT HORADRIC SEAL OF GLORY", "Legendary Horadric Seal", - "Unlocks 5 Charm Slots", - "7.5% Resource Cost Reduction [7.5]", - "Habacalva's Cauldron:", - "+255 Life On Hit", - "Tal Rasha's Threefold Way:", - "+2 to Ball Lightning", + "Unlocks 6 Charm Slots", + "+7.5% Resistance to All Elements [7.5 - 10.0]%", + "Sescherons Fury:. +8% [7 - 11]% Damage Reduction", + "+1 Charm Slot", + "Requires Level 50. Lord of Hatred Item", + "Sell Value: 13,386,186 Gold", "Right mouse button", ], Item( affixes=[ Affix( - max_value=7.5, + max_value=10.0, min_value=7.5, - name="resource_cost_reduction", - text="7.5% Resource Cost Reduction [7.5]", + name="resistance_to_all_elements", + text="+7.5% Resistance to All Elements [7.5 - 10.0]%", type=AffixType.normal, value=7.5, + ), + Affix( + max_value=11.0, + min_value=7.0, + name="sescherons_fury_damage_reduction", + text="Sescherons Fury:. +8% [7 - 11]% Damage Reduction", + type=AffixType.normal, + value=8.0, + ), + Affix( + max_value=None, + min_value=None, + name="charm_slot", + text="+1 Charm Slot", + type=AffixType.normal, + value=1.0, + ), + ], + aspect=None, + codex_upgrade=False, + cosmetic_upgrade=False, + inherent=[], + is_in_shop=False, + item_type=ItemType.HoradricSeal, + name="resistant_horadric_seal_of_glory", + original_name="RESISTANT HORADRIC SEAL OF GLORY", + power=None, + rarity=ItemRarity.Legendary, + seasonal_attribute=None, + set=None, + ), + ), + # Magic charm + ( + [ + "SKILLFUL CHARM", + "Ancestral Magic Charm", + "+2 to Basic Skills [2 - 3]", + "Requires Level 70. Lord of Hatred Item", + "Sell Value: 14,789 Gold", + "Right mouse button", + ], + Item( + affixes=[ + Affix( + max_value=3.0, + min_value=2.0, + name="to_basic_skills", + text="+2 to Basic Skills [2 - 3]", + type=AffixType.normal, + value=2.0, ) ], aspect=None, - boosted_set_name="habacalvas_cauldron", - boosted_sets=[ - BoostedSet( - affix=Affix( - max_value=None, - min_value=None, - name="life_on_hit", - text="+255 Life On Hit", - type=AffixType.normal, - value=255.0, - ), - name="habacalvas_cauldron", - ), - BoostedSet( - affix=Affix( - max_value=None, - min_value=None, - name="to_ball_lightning", - text="+2 to Ball Lightning", - type=AffixType.normal, - value=2.0, - ), - name="tal_rashas_threefold_way", + codex_upgrade=False, + cosmetic_upgrade=False, + inherent=[], + is_in_shop=False, + item_type=ItemType.Charm, + name="skillful_charm", + original_name="SKILLFUL CHARM", + power=None, + rarity=ItemRarity.Magic, + seasonal_attribute=None, + set=None, + ), + ), + # Rare charm + ( + [ + "SPEEDY CHARM OF GREED", + "Ancestral Rare Charm", + "+7% Gold Drop Rate +[5 - 9]%", + "+14% Movement Speed [13 - 16]%", + "Requires Level 70. Lord of Hatred Item", + "Sell Value: 150,843 Gold", + "Right mouse button", + ], + Item( + affixes=[ + Affix( + max_value=9.0, + min_value=5.0, + name="gold_drop_rate", + text="+7% Gold Drop Rate +[5 - 9]%", + type=AffixType.normal, + value=7.0, + ), + Affix( + max_value=16.0, + min_value=13.0, + name="movement_speed", + text="+14% Movement Speed [13 - 16]%", + type=AffixType.normal, + value=14.0, ), ], - charm_slots=5, + aspect=None, codex_upgrade=False, cosmetic_upgrade=False, inherent=[], is_in_shop=False, - item_type=ItemType.HoradricSeal, - name="efficient_horadric_seal_of_fervor", - original_name="EFFICIENT HORADRIC SEAL OF FERVOR", + item_type=ItemType.Charm, + name="speedy_charm_of_greed", + original_name="SPEEDY CHARM OF GREED", power=None, - rarity=ItemRarity.Legendary, + rarity=ItemRarity.Rare, seasonal_attribute=None, + set=None, ), ), + # Set Charm ( [ - "LINTA OF THE FROZEN SEA", + "MLOR OF SESCHERONS FURY", "Set Charm", - "Lucky Hit: Up to a 40% Chance to Deal +650 Poison Damage", - "+7.0% Potion Healing", - "Breath of the Frozen Sea", - "Phoba of the Frozen Sea", - "Breath of the Frozen Sea (0/5). (2) Set:. Frost Skills deal 70% of their direct damage as bonus Frostbite over 12 seconds.. (3) Set:. You cannot be Chilled or Frozen.. Your Maximum Life and Barrier generation is increased by 20%.. (5) Set:. Frost Skill damage is increased by 200%.. Freezing enemies consumes all Frostbite on them, dealing its remaining damage instantly.", - "Requires Level 70Sorcerer. Only. Unique Equipped. Lord of Hatred Item", + "8.0% Maximum Life [6.5 - 8.0]%", + "Lucky Hit: Up to a +4.0% Chance to Daze for 2 Seconds [3.0 - 4.0]%", + "Sescherons Fury", + "Phoba of Sescherons Fury", + "Fer of Sescherons Fury", + "Mlor of Sescherons Fury", + "Linta of Sescherons Fury", + "Berú of Sescherons Fury", + "Sescherons Fury (5/5). (2) Set:. While at or above 100 Fury, you become Colossal, losing 50% Cast Speed but gaining 20% Character and Skill Size and 120%[x] increased damage.. (3) Set:. You gain an additional 10 Fury when hit and 30% Maximum Life.. (5) Set:. Your Maximum Fury is increased by 50. . Colossal further increases Skill size to 50%, and damage to 600%[x].", + "Barbarian female—exhumed amidst the corpses of two-score Khazra, presumably killed by this female single-handed before she fell. Impaled by five spears. -Jered Cain, notes from Bear Tribe dig site", + "Requires Level 70Barbarian. Only. Unique Equipped. Lord of Hatred Item", + "Sell Value: 1,340,759 Gold", + "Mousewheel scroll down", + "Scroll Down", "Right mouse button", ], Item( affixes=[ Affix( - name="lucky_hit_up_to_a_chance_to_deal_poison_damage", - text="Lucky Hit: Up to a 40% Chance to Deal +650 Poison Damage", - type=AffixType.greater, - value=40.0, + max_value=8.0, + min_value=6.5, + name="maximum_life", + text="8.0% Maximum Life [6.5 - 8.0]%", + type=AffixType.normal, + value=8.0, + ), + Affix( + max_value=4.0, + min_value=3.0, + name="lucky_hit_up_to_a_chance_to_daze_for_seconds", + text="Lucky Hit: Up to a +4.0% Chance to Daze for 2 Seconds [3.0 - 4.0]%", + type=AffixType.normal, + value=4.0, ), - Affix(name="potion_healing", text="+7.0% Potion Healing", type=AffixType.greater, value=7.0), ], aspect=None, codex_upgrade=False, @@ -511,88 +670,121 @@ inherent=[], is_in_shop=False, item_type=ItemType.Charm, - name="linta_of_the_frozen_sea", - original_name="LINTA OF THE FROZEN SEA", + name="mlor_of_sescherons_fury", + original_name="MLOR OF SESCHERONS FURY", power=None, rarity=ItemRarity.Set, seasonal_attribute=None, - set_name="breath_of_the_frozen_sea", + set="sescherons_fury", ), ), + # Set Charm with compare screen up which annoyingly makes it difficult to find the set ( [ - "EMBERFURY", - "Unique Charm", - "+8.1% Crafting Material Drop Rate", - "+2 to Shock Skills", - "Overpower increases your Pyromancy Skill damage by 22% and size, Mana cost and Cooldowns by 10%.", - "The greatest power is often the shortest lived.", - "Requires Level 70", + "FER OF SESCHERONS FURY", + "Set Charm", + "+166 Fire Resistance [165 - 210] (+166)", + "+2 to Combat Skills [1 - 2] (+2)", + "Properties lost when equipped:", + "Lucky Hit: Up to a +1.0% Chance to Daze for 2 Seconds", + "+4.0% Total Armor", + "Sescherons Fury", + "Phoba of Sescherons Fury", + "Fer of Sescherons Fury", + "Mlor of Sescherons Fury", + "Linta of Sescherons Fury", + "Berú of Sescherons Fury", + "Sescherons Fury (5/5). (2) Set:. While at or above 100 Fury, you become Colossal, losing 50% Cast Speed but gaining 20% Character and Skill Size and 120%[x] increased damage.. (3) Set:. You gain an additional 10 Fury when hit and 30% Maximum Life.. (5) Set:. Your Maximum Fury is increased by 50. . Colossal further increases Skill size to 50%, and damage to 600%[x].", + "Barbarian female—exhumed amidst the corpses of two-score Khazra, presumably killed by this female single-handed before she fell. Impaled by five spears. -Jered Cain, notes from Bear Tribe dig site", + "Requires Level 42Barbarian. Only. Unique Equipped. Lord of Hatred Item", + "Sell Value: 457,819 Gold", + "Mousewheel scroll down", + "Scroll Down", "Right mouse button", ], Item( affixes=[ Affix( - name="crafting_material_drop_rate", - text="+8.1% Crafting Material Drop Rate", - type=AffixType.greater, - value=8.1, + max_value=210.0, + min_value=165.0, + name="fire_resistance", + text="+166 Fire Resistance [165 - 210] (+166)", + type=AffixType.normal, + value=166.0, + ), + Affix( + max_value=2.0, + min_value=1.0, + name="to_combat_skills", + text="+2 to Combat Skills [1 - 2] (+2)", + type=AffixType.normal, + value=2.0, ), - Affix(name="to_shock_skills", text="+2 to Shock Skills", type=AffixType.greater, value=2.0), ], - aspect=Aspect( - name="emberfury", - text="Overpower increases your Pyromancy Skill damage by 22% and size, Mana cost and Cooldowns by 10%.", - ), + aspect=None, codex_upgrade=False, cosmetic_upgrade=False, inherent=[], is_in_shop=False, item_type=ItemType.Charm, - name="emberfury", - original_name="EMBERFURY", + name="fer_of_sescherons_fury", + original_name="FER OF SESCHERONS FURY", power=None, - rarity=ItemRarity.Unique, + rarity=ItemRarity.Set, seasonal_attribute=None, + set="sescherons_fury", ), ), + # Unique charm ( [ - "SWIFT HORADRIC SEAL OF CURRENT", - "Legendary Horadric Seal", - "Unlocks 5 Charm Slots", - "10.0% Cooldown Reduction", - "Cains Wild Lighting:. +4 Mana per Second", - "Tal Rasha's Threefold Way: +9% Maximum Mana", + "TUSKHELM OF JORITZ THE MIGHTY", + "Unique Charm", + "+486 Cold Resistance [416 - 523]", + "Lucky Hit: Up to a 40% Chance to Deal +950 Physical Damage [550 - 1,000]", + "When you gain Berserking while already Berserk, you become more enraged granting 48%[x] [40 - 50]% increased damage, 6 Fury per second, and 10% Cooldown Reduction.", + "As he fought with side by side with Raekor to liberate the labor camp, Joritz claimed this dented helm from a fallen foe. As his legend grew, its unique shape became synonymous with his great deeds.", + "Requires Level 70Barbarian. Only. Unique Equipped. Lord of Hatred Item", + "Sell Value: 8,033,852 Gold", "Right mouse button", ], Item( affixes=[ - Affix(name="cooldown_reduction", text="10.0% Cooldown Reduction", type=AffixType.greater, value=10.0) - ], - aspect=None, - boosted_set_name="cains_wild_lightning", - boosted_sets=[ - BoostedSet( - name="cains_wild_lightning", - affix=Affix(name="mana_per_second", text=". +4 Mana per Second", type=AffixType.normal, value=4.0), + Affix( + max_value=523.0, + min_value=416.0, + name="cold_resistance", + text="+486 Cold Resistance [416 - 523]", + type=AffixType.normal, + value=486.0, ), - BoostedSet( - name="tal_rashas_threefold_way", - affix=Affix(name="maximum_mana", text="+9% Maximum Mana", type=AffixType.normal, value=9.0), + Affix( + max_value=1000.0, + min_value=550.0, + name="lucky_hit_up_to_a_chance_to_deal_physical_damage", + text="Lucky Hit: Up to a 40% Chance to Deal +950 Physical Damage [550 - 1,000]", + type=AffixType.normal, + value=950.0, ), ], - charm_slots=5, + aspect=Aspect( + name="tuskhelm_of_joritz_the_mighty", + min_value=40.0, + max_value=50.0, + text="When you gain Berserking while already Berserk, you become more enraged granting 48%[x] [40 - 50]% increased damage, 6 Fury per second, and 10% Cooldown Reduction.", + value=48.0, + ), codex_upgrade=False, cosmetic_upgrade=False, inherent=[], is_in_shop=False, - item_type=ItemType.HoradricSeal, - name="swift_horadric_seal_of_current", - original_name="SWIFT HORADRIC SEAL OF CURRENT", + item_type=ItemType.Charm, + name="tuskhelm_of_joritz_the_mighty", + original_name="TUSKHELM OF JORITZ THE MIGHTY", power=None, - rarity=ItemRarity.Legendary, + rarity=ItemRarity.Unique, seasonal_attribute=None, + set=None, ), ), ] diff --git a/tests/item/read_descr_tts_test.py b/tests/item/read_descr_tts_test.py index 22bd3f04..2df54532 100644 --- a/tests/item/read_descr_tts_test.py +++ b/tests/item/read_descr_tts_test.py @@ -1,12 +1,5 @@ -import numpy as np - import src.tts -from src.config.ui import ResManager -from src.item.data.item_type import ItemType -from src.item.data.rarity import ItemRarity -from src.item.descr.read_descr_tts import _add_affixes_from_tts_mixed, read_descr -from src.item.models import Item -from src.template_finder import TemplateMatch +from src.item.descr.read_descr_tts import read_descr LOOT_FILTER_TTS = ["SELECT ALL", "Checkbox Disabled", "Item Power Range", "Left mouse button"] @@ -19,141 +12,3 @@ def test_loot_filter_controls_do_not_raise_tts_parser_error(): src.tts.LAST_ITEM = LOOT_FILTER_TTS assert read_descr() is None - - -def test_seal_boosted_set_locations_fall_back_to_line_offsets(): - ResManager().set_resolution("3840x2160") - item = Item( - item_type=ItemType.HoradricSeal, - name="efficient_horadric_seal_of_fervor", - original_name="EFFICIENT HORADRIC SEAL OF FERVOR", - rarity=ItemRarity.Legendary, - ) - tts_section = [ - "EFFICIENT HORADRIC SEAL OF FERVOR", - "Legendary Horadric Seal", - "Unlocks 5 Charm Slots", - "7.5% Resource Cost Reduction [7.5]", - "Habacalva's Cauldron:", - "+255 Life On Hit", - "Tal Rasha's Threefold Way:", - "+2 to Ball Lightning", - "Right mouse button", - ] - affix_bullets = [ - TemplateMatch(center=(50, 100), name="affix_bullet_point_1"), - TemplateMatch(center=(50, 150), name="affix_bullet_point_1"), - ] - - result = _add_affixes_from_tts_mixed(tts_section, item, affix_bullets, np.zeros((1, 1, 3)), aspect_bullet=None) - - assert result.charm_slots_loc == (50, 100) - assert result.affixes[0].loc == (50, 150) - assert [ - (boosted_set.name, boosted_set.loc, boosted_set.affix.loc if boosted_set.affix else None) - for boosted_set in result.boosted_sets - ] == [("habacalvas_cauldron", (50, 200), None), ("tal_rashas_threefold_way", (50, 300), None)] - - -def test_seal_boosted_affix_locations_do_not_consume_set_bullets(): - ResManager().set_resolution("3840x2160") - item = Item( - item_type=ItemType.HoradricSeal, - name="efficient_horadric_seal_of_fervor", - original_name="EFFICIENT HORADRIC SEAL OF FERVOR", - rarity=ItemRarity.Legendary, - ) - tts_section = [ - "EFFICIENT HORADRIC SEAL OF FERVOR", - "Legendary Horadric Seal", - "Unlocks 5 Charm Slots", - "7.5% Resource Cost Reduction [7.5]", - "Habacalva's Cauldron:", - "+255 Life On Hit", - "Tal Rasha's Threefold Way:", - "+2 to Ball Lightning", - "Right mouse button", - ] - affix_bullets = [ - TemplateMatch(center=(50, 100), name="affix_bullet_point_1"), - TemplateMatch(center=(50, 150), name="affix_bullet_point_1"), - TemplateMatch(center=(50, 200), name="boosted_bullet_point"), - TemplateMatch(center=(50, 300), name="boosted_bullet_point"), - ] - - result = _add_affixes_from_tts_mixed(tts_section, item, affix_bullets, np.zeros((1, 1, 3)), aspect_bullet=None) - - assert [ - (boosted_set.name, boosted_set.loc, boosted_set.affix.loc if boosted_set.affix else None) - for boosted_set in result.boosted_sets - ] == [("habacalvas_cauldron", (50, 200), None), ("tal_rashas_threefold_way", (50, 300), None)] - - -def test_seal_boosted_set_locations_skip_affix_line_false_matches(): - ResManager().set_resolution("3840x2160") - item = Item( - item_type=ItemType.HoradricSeal, - name="efficient_horadric_seal_of_fervor", - original_name="EFFICIENT HORADRIC SEAL OF FERVOR", - rarity=ItemRarity.Legendary, - ) - tts_section = [ - "EFFICIENT HORADRIC SEAL OF FERVOR", - "Legendary Horadric Seal", - "Unlocks 5 Charm Slots", - "7.5% Resource Cost Reduction [7.5]", - "Habacalva's Cauldron:", - "+255 Life On Hit", - "Tal Rasha's Threefold Way:", - "+2 to Ball Lightning", - "Right mouse button", - ] - affix_bullets = [ - TemplateMatch(center=(50, 100), name="affix_bullet_point_1"), - TemplateMatch(center=(50, 150), name="affix_bullet_point_1"), - TemplateMatch(center=(50, 200), name="boosted_bullet_point"), - TemplateMatch(center=(50, 250), name="boosted_bullet_point"), - TemplateMatch(center=(50, 300), name="boosted_bullet_point"), - ] - - result = _add_affixes_from_tts_mixed(tts_section, item, affix_bullets, np.zeros((1, 1, 3)), aspect_bullet=None) - - assert [(boosted_set.name, boosted_set.loc) for boosted_set in result.boosted_sets] == [ - ("habacalvas_cauldron", (50, 200)), - ("tal_rashas_threefold_way", (50, 300)), - ] - - -def test_seal_boosted_set_locations_use_actual_second_set_bullet(): - ResManager().set_resolution("3840x2160") - item = Item( - item_type=ItemType.HoradricSeal, - name="efficient_horadric_seal_of_fervor", - original_name="EFFICIENT HORADRIC SEAL OF FERVOR", - rarity=ItemRarity.Legendary, - ) - tts_section = [ - "EFFICIENT HORADRIC SEAL OF FERVOR", - "Legendary Horadric Seal", - "Unlocks 5 Charm Slots", - "7.5% Resource Cost Reduction [7.5]", - "Habacalva's Cauldron:", - "+255 Life On Hit", - "Tal Rasha's Threefold Way:", - "+2 to Ball Lightning", - "Right mouse button", - ] - affix_bullets = [ - TemplateMatch(center=(50, 100), name="affix_bullet_point_1"), - TemplateMatch(center=(50, 150), name="affix_bullet_point_1"), - TemplateMatch(center=(50, 200), name="boosted_bullet_point"), - TemplateMatch(center=(50, 250), name="boosted_bullet_point"), - TemplateMatch(center=(50, 275), name="boosted_bullet_point"), - ] - - result = _add_affixes_from_tts_mixed(tts_section, item, affix_bullets, np.zeros((1, 1, 3)), aspect_bullet=None) - - assert [(boosted_set.name, boosted_set.loc) for boosted_set in result.boosted_sets] == [ - ("habacalvas_cauldron", (50, 200)), - ("tal_rashas_threefold_way", (50, 275)), - ] From f124c0c834c84e122d9aa9d1c26b0b2c02443d76 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Fri, 12 Jun 2026 16:04:55 +0200 Subject: [PATCH 25/39] update20 --- src/gui/importer/d4builds.py | 4 +- src/gui/importer/gui_common.py | 10 +- src/gui/importer/maxroll.py | 4 +- src/gui/importer/mobalytics.py | 4 +- src/gui/profile_editor/affixes_tab.py | 4 +- src/item/filter.py | 6 +- tests/config/models_test.py | 23 ++- tests/item/filter/data/charms.py | 128 +++++++++++++++++ tests/item/filter/data/filters.py | 57 ++++++++ tests/item/filter/data/seals.py | 53 +++++++ tests/item/filter/filter_test.py | 192 ++++---------------------- 11 files changed, 302 insertions(+), 183 deletions(-) create mode 100644 tests/item/filter/data/charms.py create mode 100644 tests/item/filter/data/seals.py diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index b7ce982b..8b683a3f 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -16,7 +16,7 @@ CharmFilterModel, ItemFilterModel, ProfileModel, - SealCharmFilterModel, + SealFilterModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( @@ -185,7 +185,7 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): if item_type in [ItemType.HoradricSeal, ItemType.Charm]: seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters filter_name = _unique_filter_name(item_type.name, seal_charm_filters) - seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealCharmFilterModel + seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel seal_charm_filters.append({ filter_name: create_seal_charm_filter( affixes=affixes, diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py index 230c6abb..ddb61f39 100644 --- a/src/gui/importer/gui_common.py +++ b/src/gui/importer/gui_common.py @@ -22,9 +22,10 @@ AffixFilterCountModel, AffixFilterModel, AspectUniqueFilterModel, + CharmFilterModel, ItemFilterModel, ProfileModel, - SealCharmFilterModel, + SealFilterModel, ) from src.config.settings_models import BrowserType from src.item.data.affix import Affix, AffixType @@ -197,8 +198,11 @@ def update_mingreateraffixcount(item_filter: ItemFilterModel, require_gas: bool) def create_seal_charm_filter( - affixes: list[Affix], rarity, require_gas: bool, model_type: type[SealCharmFilterModel] = SealCharmFilterModel -) -> SealCharmFilterModel: + affixes: list[Affix], + rarity, + require_gas: bool, + model_type: type[CharmFilterModel | SealFilterModel] = SealFilterModel, +) -> CharmFilterModel | SealFilterModel: seal_charm_filter = model_type( affix_pool=[ AffixFilterCountModel( diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 414b17ad..a23518da 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -12,7 +12,7 @@ CharmFilterModel, ItemFilterModel, ProfileModel, - SealCharmFilterModel, + SealFilterModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( @@ -127,7 +127,7 @@ def import_maxroll(config: ImportConfig): continue seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters filter_name = _unique_filter_name(item_type.name, seal_charm_filters) - seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealCharmFilterModel + seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel seal_charm_filters.append({ filter_name: create_seal_charm_filter( affixes=seal_charm_affixes, diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index a9946f72..8d1d3b3c 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -19,7 +19,7 @@ CharmFilterModel, ItemFilterModel, ProfileModel, - SealCharmFilterModel, + SealFilterModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( @@ -221,7 +221,7 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): continue seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters filter_name = _unique_filter_name(item_type.name, seal_charm_filters) - seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealCharmFilterModel + seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel seal_charm_filters.append({ filter_name: create_seal_charm_filter( affixes=affixes, diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index 1cb4e6aa..d3caf8e2 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -33,7 +33,7 @@ AspectUniqueFilterModel, CharmFilterModel, DynamicItemFilterModel, - SealCharmFilterModel, + SealFilterModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import MAX_POWER @@ -68,7 +68,7 @@ def _affix_dict_for_widget(widget: QWidget) -> dict[str, str]: curr = widget while curr: config = getattr(curr, "config", None) - if isinstance(config, SealCharmFilterModel): + if isinstance(config, SealFilterModel): return Dataloader().seal_affix_dict if isinstance(config, CharmFilterModel): return Dataloader().charm_affix_dict diff --git a/src/item/filter.py b/src/item/filter.py index f90c8765..61179f66 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -19,7 +19,7 @@ CharmFilterModel, DynamicCharmFilterModel, DynamicItemFilterModel, - DynamicSealCharmFilterModel, + DynamicSealFilterModel, GlobalUniqueModel, ProfileModel, SigilConditionModel, @@ -252,7 +252,7 @@ def _check_sigil(self, item: Item) -> FilterResult: def _check_seal_charm_filters( self, seal_or_charm: Item, - seal_or_charm_filters: dict[str, list[DynamicSealCharmFilterModel]], + seal_or_charm_filters: dict[str, list[DynamicSealFilterModel | DynamicCharmFilterModel]], section_name: str, mythic_name: str, ) -> FilterResult: @@ -568,7 +568,7 @@ def load_files(self): self.item_filters: dict[str, list[DynamicItemFilterModel]] = {} self.aspect_upgrade_filters: dict[str, list[str]] = {} self.paragon_filters: dict[str, object] = {} - self.seal_filters: dict[str, list[DynamicSealCharmFilterModel]] = {} + self.seal_filters: dict[str, list[DynamicSealFilterModel]] = {} self.charm_filters: dict[str, list[DynamicCharmFilterModel]] = {} self.sigil_filters: dict[str, SigilFilterModel] = {} self.tribute_filters: dict[str, list[TributeFilterModel]] = {} diff --git a/tests/config/models_test.py b/tests/config/models_test.py index 258a016a..75ca22ee 100644 --- a/tests/config/models_test.py +++ b/tests/config/models_test.py @@ -22,7 +22,7 @@ AspectUniqueFilterModel, CharmFilterModel, DynamicCharmFilterModel, - DynamicSealCharmFilterModel, + DynamicSealFilterModel, GlobalUniqueModel, ItemFilterModel, ItemRarity, @@ -608,6 +608,25 @@ def test_unique_aspect_is_normalized(self) -> None: assert model.unique_aspect == [AspectUniqueFilterModel(name="fractured_winterglass")] + def test_duplicate_unique_aspects_fail(self) -> None: + with pytest.raises(ValidationError, match="uniqueAspect names must be unique"): + CharmFilterModel( + uniqueAspect=[ + AspectUniqueFilterModel(name="tuskhelm_of_joritz_the_mighty"), + AspectUniqueFilterModel(name="Tuskhelm of Joritz the Mighty"), + ] + ) + + def test_duplicate_sets_fail(self) -> None: + with pytest.raises(ValidationError, match="set names must be unique"): + CharmFilterModel(set=["Sescherons Fury", "sescherons_fury"]) + + def test_set_and_unique_aspect_are_mutually_exclusive(self) -> None: + with pytest.raises(ValidationError, match="can't define both set and unique aspect"): + CharmFilterModel( + set=["Sescherons Fury"], uniqueAspect=[AspectUniqueFilterModel(name="tuskhelm_of_joritz_the_mighty")] + ) + class TestSigilConditionModel: """Test SigilConditionModel.""" @@ -718,7 +737,7 @@ def test_snake_case_input(self) -> None: assert model.aspect_upgrades == [] assert len(model.global_uniques) == 1 assert model.global_uniques[0].min_power == 900 - assert isinstance(model.seals[0], DynamicSealCharmFilterModel) + assert isinstance(model.seals[0], DynamicSealFilterModel) assert isinstance(model.charms[0], DynamicCharmFilterModel) assert model.seals[0].root["Cooldown"].affix_pool[0].count[0].name == "cooldown_reduction" assert model.charms[0].root["Life"].affix_pool[0].count[0].name == "maximum_life" diff --git a/tests/item/filter/data/charms.py b/tests/item/filter/data/charms.py new file mode 100644 index 00000000..ce3dfe98 --- /dev/null +++ b/tests/item/filter/data/charms.py @@ -0,0 +1,128 @@ +from src.item.data.affix import Affix, AffixType +from src.item.data.aspect import Aspect +from src.item.data.item_type import ItemType +from src.item.data.rarity import ItemRarity +from src.item.models import Item + +charms = [ + ( + "magic charm affix and rarity match", + ["seal_charm.Charms.basic_magic"], + Item( + item_type=ItemType.Charm, + name="skillful_charm", + original_name="SKILLFUL CHARM", + rarity=ItemRarity.Magic, + affixes=[Affix(name="to_basic_skills", value=2.0, min_value=2.0, max_value=3.0)], + ), + ), + ( + "rare charm affix and rarity match", + ["seal_charm.Charms.speed"], + Item( + item_type=ItemType.Charm, + name="speedy_charm_of_greed", + original_name="SPEEDY CHARM OF GREED", + rarity=ItemRarity.Rare, + affixes=[ + Affix(name="gold_drop_rate", value=7.0, min_value=5.0, max_value=9.0), + Affix(name="movement_speed", value=14.0, min_value=13.0, max_value=16.0), + ], + ), + ), + ( + "set name match", + ["seal_charm.Charms.wanted_set"], + Item( + item_type=ItemType.Charm, + name="mlor_of_sescherons_fury", + original_name="MLOR OF SESCHERONS FURY", + rarity=ItemRarity.Set, + set="sescherons_fury", + affixes=[ + Affix(name="maximum_life", value=8.0, min_value=6.5, max_value=8.0), + Affix(name="lucky_hit_up_to_a_chance_to_daze_for_seconds", value=4.0, min_value=3.0, max_value=4.0), + ], + ), + ), + ( + "unique aspect name match", + ["seal_charm.Charms.wanted_unique_aspect"], + Item( + item_type=ItemType.Charm, + name="tuskhelm_of_joritz_the_mighty", + original_name="TUSKHELM OF JORITZ THE MIGHTY", + rarity=ItemRarity.Unique, + aspect=Aspect(name="tuskhelm_of_joritz_the_mighty", value=48.0, min_value=40.0, max_value=50.0), + set=None, + affixes=[ + Affix(name="cold_resistance", value=486.0, min_value=416.0, max_value=523.0), + Affix(name="lucky_hit_up_to_a_chance_to_deal_physical_damage", value=950.0, min_value=550.0, max_value=1000.0), + ], + ), + ), + ( + "wrong unique aspect rejected", + [], + Item( + item_type=ItemType.Charm, + name="tuskhelm_of_joritz_the_mighty", + original_name="TUSKHELM OF JORITZ THE MIGHTY", + rarity=ItemRarity.Unique, + aspect=Aspect(name="flickerstep"), + set=None, + affixes=[Affix(name="cold_resistance", value=486.0, min_value=416.0, max_value=523.0)], + ), + ), + ( + "unique charm without aspect rejected", + [], + Item( + item_type=ItemType.Charm, + name="tuskhelm_of_joritz_the_mighty", + original_name="TUSKHELM OF JORITZ THE MIGHTY", + rarity=ItemRarity.Unique, + aspect=None, + set=None, + affixes=[Affix(name="cold_resistance", value=486.0, min_value=416.0, max_value=523.0)], + ), + ), + ( + "wrong set rejected", + [], + Item( + item_type=ItemType.Charm, + name="fer_of_sescherons_fury", + original_name="FER OF SESCHERONS FURY", + rarity=ItemRarity.Set, + set="cains_wild_lightning", + affixes=[ + Affix(name="fire_resistance", value=166.0, min_value=165.0, max_value=210.0), + Affix(name="to_combat_skills", value=2.0, min_value=1.0, max_value=2.0), + ], + ), + ), + ( + "no set rejected", + [], + Item( + item_type=ItemType.Charm, + name="skillful_charm", + original_name="SKILLFUL CHARM", + rarity=ItemRarity.Rare, + set=None, + affixes=[Affix(name="to_basic_skills", value=2.0, min_value=2.0, max_value=3.0)], + ), + ), + ( + "mythic always kept", + ["Mythic Charm"], + Item( + item_type=ItemType.Charm, + name="tuskhelm_of_joritz_the_mighty", + rarity=ItemRarity.Mythic, + aspect=None, + affixes=[Affix(name="cold_resistance", type=AffixType.greater)], + ), + ), +] diff --git a/tests/item/filter/data/filters.py b/tests/item/filter/data/filters.py index 7b801daa..7afde6c5 100644 --- a/tests/item/filter/data/filters.py +++ b/tests/item/filter/data/filters.py @@ -2,9 +2,13 @@ AffixFilterCountModel, AffixFilterModel, AspectUniqueFilterModel, + CharmFilterModel, + DynamicCharmFilterModel, + DynamicSealFilterModel, GlobalUniqueModel, ItemFilterModel, ProfileModel, + SealFilterModel, SigilConditionModel, SigilFilterModel, TributeFilterModel, @@ -240,6 +244,59 @@ ), ) +seal_charm = ProfileModel( + name="seal_charm", + seals=[ + DynamicSealFilterModel( + root={ + "resistance": SealFilterModel( + affix_pool=[AffixFilterCountModel(count=[AffixFilterModel(name="resistance_to_all_elements")])], + rarities=[ItemRarity.Legendary], + ) + } + ), + DynamicSealFilterModel( + root={ + "greater_cains": SealFilterModel( + affix_pool=[ + AffixFilterCountModel( + count=[AffixFilterModel(name="cains_wild_lightning_mana_per_second", want_greater=True)] + ) + ], + min_greater_affix_count=1, + rarities=[ItemRarity.Legendary], + ) + } + ), + ], + charms=[ + DynamicCharmFilterModel( + root={ + "speed": CharmFilterModel( + affix_pool=[AffixFilterCountModel(count=[AffixFilterModel(name="movement_speed")])], + rarities=[ItemRarity.Rare], + ) + } + ), + DynamicCharmFilterModel( + root={ + "basic_magic": CharmFilterModel( + affix_pool=[AffixFilterCountModel(count=[AffixFilterModel(name="to_basic_skills")])], + rarities=[ItemRarity.Magic], + ) + } + ), + DynamicCharmFilterModel(root={"wanted_set": CharmFilterModel(set=["Sescherons Fury"])}), + DynamicCharmFilterModel( + root={ + "wanted_unique_aspect": CharmFilterModel( + uniqueAspect=[AspectUniqueFilterModel(name="tuskhelm_of_joritz_the_mighty")] + ) + } + ), + ], +) + # noinspection PyTypeChecker unique_affixes = ProfileModel( name="test", diff --git a/tests/item/filter/data/seals.py b/tests/item/filter/data/seals.py new file mode 100644 index 00000000..ec9d81a9 --- /dev/null +++ b/tests/item/filter/data/seals.py @@ -0,0 +1,53 @@ +from src.item.data.affix import Affix, AffixType +from src.item.data.item_type import ItemType +from src.item.data.rarity import ItemRarity +from src.item.models import Item + +seals = [ + ( + "legendary seal affix and rarity match", + ["seal_charm.Seals.resistance"], + Item( + item_type=ItemType.HoradricSeal, + name="resistant_horadric_seal_of_glory", + original_name="RESISTANT HORADRIC SEAL OF GLORY", + rarity=ItemRarity.Legendary, + affixes=[ + Affix(name="resistance_to_all_elements", value=7.5, min_value=7.5, max_value=10.0), + Affix(name="sescherons_fury_damage_reduction", value=8.0, min_value=7.0, max_value=11.0), + Affix(name="charm_slot", value=1.0), + ], + ), + ), + ( + "legendary seal greater affix count match", + ["seal_charm.Seals.greater_cains", "seal_charm.Seals.resistance"], + Item( + item_type=ItemType.HoradricSeal, + name="resistant_horadric_seal_of_current", + original_name="RESISTANT HORADRIC SEAL OF CURRENT", + rarity=ItemRarity.Legendary, + affixes=[ + Affix(name="resistance_to_all_elements", value=8.5, min_value=7.5, max_value=10.0), + Affix( + name="cains_wild_lightning_mana_per_second", + value=3.0, + min_value=3.0, + max_value=4.0, + type=AffixType.greater, + ), + Affix(name="tal_rashas_threefold_way_to_ball_lightning", value=3.0, min_value=2.0, max_value=3.0), + ], + ), + ), + ( + "mythic always kept", + ["Mythic Seal"], + Item( + item_type=ItemType.HoradricSeal, + name="resistant_horadric_seal_of_glory", + rarity=ItemRarity.Mythic, + affixes=[Affix(name="resistance_to_all_elements", type=AffixType.greater)], + ), + ), +] diff --git a/tests/item/filter/filter_test.py b/tests/item/filter/filter_test.py index d56dcb74..1b053116 100644 --- a/tests/item/filter/filter_test.py +++ b/tests/item/filter/filter_test.py @@ -5,25 +5,15 @@ import pytest from natsort import natsorted -from item.data.aspect import Aspect from src.config.loader import IniConfigLoader -from src.config.profile_models import ( - AspectUniqueFilterModel, - CharmFilterModel, - DynamicCharmFilterModel, - DynamicSealCharmFilterModel, - SealCharmFilterModel, - SigilPriority, -) +from src.config.profile_models import SigilPriority from src.config.settings_models import AspectFilterType -from src.item.data.affix import Affix, AffixType -from src.item.data.item_type import ItemType -from src.item.data.rarity import ItemRarity from src.item.filter import Filter, FilterResult -from src.item.models import Item from tests.item.filter.data import filters from tests.item.filter.data.affixes import affixes from tests.item.filter.data.aspects import aspects +from tests.item.filter.data.charms import charms +from tests.item.filter.data.seals import seals from tests.item.filter.data.sigils import sigil_jalal, sigil_priority, sigils from tests.item.filter.data.tributes import tributes from tests.item.filter.data.uniques import global_uniques, simple_mythics, uniques_with_affixes @@ -31,6 +21,8 @@ if typing.TYPE_CHECKING: from pytest_mock import MockerFixture + from src.item.models import Item + def _create_mocked_filter(mocker: MockerFixture) -> Filter: filter_obj = Filter() @@ -115,164 +107,30 @@ def test_tributes(_name: str, result: list[str], item: Item, mocker: MockerFixtu assert natsorted([match.profile for match in test_filter.should_keep(item).matched]) == natsorted(result) -@pytest.mark.parametrize( - ("item", "filter_attr", "section_name", "filter_model", "dynamic_model"), - [ - ( - Item( - item_type=ItemType.HoradricSeal, - name="unimportant_seal_name", - rarity=ItemRarity.Legendary, - affixes=[Affix(name="cooldown_reduction")], - ), - "seal_filters", - "Seals", - SealCharmFilterModel, - DynamicSealCharmFilterModel, - ), - ( - Item( - item_type=ItemType.Charm, - name="unimportant_charm_name", - rarity=ItemRarity.Rare, - affixes=[Affix(name="maximum_life")], - ), - "charm_filters", - "Charms", - CharmFilterModel, - DynamicCharmFilterModel, - ), - ], -) -def test_seal_or_charm_sections( - item: Item, filter_attr: str, section_name: str, filter_model, dynamic_model, mocker: MockerFixture -): - test_filter = _create_mocked_filter(mocker) - seal_charm_filter = filter_model(affix_pool=[{"count": [item.affixes[0].name]}], rarities=[item.rarity]) - setattr(test_filter, filter_attr, {"seal_charm": [dynamic_model(root={"wanted": seal_charm_filter})]}) - - match = test_filter.should_keep(item).matched[0] - assert match.profile == f"seal_charm.{section_name}.wanted" - assert match.matched_affixes == item.affixes - - -def test_charm_filter_matches_set_name(mocker: MockerFixture): - test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.Charm, - name="linta_of_the_frozen_sea", - rarity=ItemRarity.Legendary, - set="breath_of_the_frozen_sea", - affixes=[Affix(name="potion_healing")], - ) - charm_filter = CharmFilterModel(set=["Breath of the Frozen Sea"]) - test_filter.charm_filters = {"seal_charm": [DynamicCharmFilterModel(root={"wanted": charm_filter})]} - - match = test_filter.should_keep(item).matched[0] - - assert match.profile == "seal_charm.Charms.wanted" - assert match.set_match - - -def test_charm_filter_matches_unique_aspect_name(mocker: MockerFixture): +@pytest.mark.parametrize(("_name", "result", "item"), natsorted(seals), ids=[name for name, _, _ in natsorted(seals)]) +def test_seals(_name: str, result: list[str], item: Item, mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.Charm, - name="fractured_winterglass", - rarity=ItemRarity.Unique, - aspect=Aspect(name="fractured_winterglass"), - set=None, - affixes=[Affix(name="potion_healing")], - ) - charm_filter = CharmFilterModel(uniqueAspect=[AspectUniqueFilterModel(name="fractured_winterglass")]) - test_filter.charm_filters = {"seal_charm": [DynamicCharmFilterModel(root={"wanted": charm_filter})]} - - match = test_filter.should_keep(item).matched[0] + test_filter.seal_filters = {filters.seal_charm.name: filters.seal_charm.seals} + matches = test_filter.should_keep(item).matched + assert natsorted([match.profile for match in matches]) == natsorted(result) + for match in matches: + if match.profile.startswith("seal_charm.Seals."): + assert match.matched_affixes - assert match.profile == "seal_charm.Charms.wanted" - assert match.aspect_match - -def test_charm_filter_rejects_wrong_unique_aspect(mocker: MockerFixture): +@pytest.mark.parametrize(("_name", "result", "item"), natsorted(charms), ids=[name for name, _, _ in natsorted(charms)]) +def test_charms(_name: str, result: list[str], item: Item, mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.Charm, - name="linta_of_the_frozen_sea", - rarity=ItemRarity.Unique, - aspect=Aspect(name="flickerstep"), - set=None, - affixes=[Affix(name="potion_healing")], - ) - charm_filter = CharmFilterModel(uniqueAspect=[AspectUniqueFilterModel(name="fractured_winterglass")]) - test_filter.charm_filters = {"seal_charm": [DynamicCharmFilterModel(root={"wrong": charm_filter})]} - - assert test_filter.should_keep(item).matched == [] - - -def test_charm_filter_rejects_wrong_set(mocker: MockerFixture): - test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.Charm, - name="linta_of_the_frozen_sea", - rarity=ItemRarity.Set, - set="breath_of_the_frozen_sea", - affixes=[Affix(name="potion_healing")], - ) - charm_filter = CharmFilterModel(set=["applied_alchemy"]) - test_filter.charm_filters = {"seal_charm": [DynamicCharmFilterModel(root={"wrong": charm_filter})]} - - assert test_filter.should_keep(item).matched == [] - - -def test_charm_filter_rejects_no_set(mocker: MockerFixture): - test_filter = _create_mocked_filter(mocker) - item = Item( - item_type=ItemType.Charm, - name="linta_of_the_frozen_sea", - rarity=ItemRarity.Rare, - set=None, - affixes=[Affix(name="potion_healing")], - ) - charm_filter = CharmFilterModel(set=["applied_alchemy"]) - test_filter.charm_filters = {"seal_charm": [DynamicCharmFilterModel(root={"wrong": charm_filter})]} - - assert test_filter.should_keep(item).matched == [] - - -@pytest.mark.parametrize( - ("item", "filter_attr", "filter_model", "dynamic_model"), - [ - ( - Item( - item_type=ItemType.HoradricSeal, - rarity=ItemRarity.Mythic, - affixes=[Affix(name="cooldown_reduction", type=AffixType.greater)], - ), - "seal_filters", - SealCharmFilterModel, - DynamicSealCharmFilterModel, - ), - ( - Item( - item_type=ItemType.Charm, - rarity=ItemRarity.Mythic, - affixes=[Affix(name="maximum_life", type=AffixType.greater)], - ), - "charm_filters", - CharmFilterModel, - DynamicCharmFilterModel, - ), - ], -) -def test_mythic_seal_or_charm_always_kept( - item: Item, filter_attr: str, filter_model, dynamic_model, mocker: MockerFixture -): - test_filter = _create_mocked_filter(mocker) - seal_charm_filter = filter_model(affixPool=[{"count": ["movement_speed"]}], rarities=[ItemRarity.Common]) - setattr(test_filter, filter_attr, {"seal_charm": [dynamic_model(root={"wrong": seal_charm_filter})]}) - - assert test_filter.should_keep(item).keep - assert test_filter.should_keep(item).matched[0].profile.startswith("Mythic") + test_filter.charm_filters = {filters.seal_charm.name: filters.seal_charm.charms} + matches = test_filter.should_keep(item).matched + assert natsorted([match.profile for match in matches]) == natsorted(result) + for match in matches: + if match.profile in {"seal_charm.Charms.basic_magic", "seal_charm.Charms.speed"}: + assert match.matched_affixes + if match.profile == "seal_charm.Charms.wanted_set": + assert match.set_match + if match.profile == "seal_charm.Charms.wanted_unique_aspect": + assert match.aspect_match @pytest.mark.parametrize( From 7c753f700695530712696e765f82dd10ecf20606 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Fri, 12 Jun 2026 16:06:09 +0200 Subject: [PATCH 26/39] update21 --- src/config/profile_models.py | 12 ++++++++---- tests/item/filter/data/charms.py | 7 ++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/config/profile_models.py b/src/config/profile_models.py index 120b915b..32967c8c 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -221,7 +221,7 @@ def unique_aspect_names_must_be_unique(self) -> ItemFilterModel: DynamicItemFilterModel = RootModel[dict[str, ItemFilterModel]] -class SealCharmFilterModel(BaseModel): +class _BaseSealOrCharmFilterModel(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) affix_pool: list[AffixFilterCountModel] = Field(default=[], alias="affixPool") min_greater_affix_count: int = Field(default=0, alias="minGreaterAffixCount") @@ -238,7 +238,7 @@ def parse_rarities(cls, data: str | list[str]) -> list[str]: return _parse_item_type_or_rarities(data) -class CharmFilterModel(SealCharmFilterModel): +class CharmFilterModel(_BaseSealOrCharmFilterModel): model_config = ConfigDict(populate_by_name=True) set: list[str] = Field(default=[], alias="set") unique_aspect: list[AspectUniqueFilterModel] = Field(default=[], alias="uniqueAspect") @@ -265,8 +265,12 @@ def set_and_unique_aspects_must_be_unique(self) -> CharmFilterModel: return self -DynamicSealCharmFilterModel = RootModel[dict[str, SealCharmFilterModel]] +class SealFilterModel(_BaseSealOrCharmFilterModel): + pass + + DynamicCharmFilterModel = RootModel[dict[str, CharmFilterModel]] +DynamicSealFilterModel = RootModel[dict[str, SealFilterModel]] class SigilPriority(enum.StrEnum): @@ -384,7 +388,7 @@ class ProfileModel(BaseModel): charms: list[DynamicCharmFilterModel] = Field(default=[], alias="Charms") global_uniques: list[GlobalUniqueModel] = Field(default=[], alias="GlobalUniques") name: str - seals: list[DynamicSealCharmFilterModel] = Field(default=[], alias="Seals") + seals: list[DynamicSealFilterModel] = Field(default=[], alias="Seals") sigils: SigilFilterModel = Field( default=SigilFilterModel(blacklist=[], whitelist=[], priority=SigilPriority.blacklist), alias="Sigils" ) diff --git a/tests/item/filter/data/charms.py b/tests/item/filter/data/charms.py index ce3dfe98..558ec11a 100644 --- a/tests/item/filter/data/charms.py +++ b/tests/item/filter/data/charms.py @@ -57,7 +57,12 @@ set=None, affixes=[ Affix(name="cold_resistance", value=486.0, min_value=416.0, max_value=523.0), - Affix(name="lucky_hit_up_to_a_chance_to_deal_physical_damage", value=950.0, min_value=550.0, max_value=1000.0), + Affix( + name="lucky_hit_up_to_a_chance_to_deal_physical_damage", + value=950.0, + min_value=550.0, + max_value=1000.0, + ), ], ), ), From dadb291a6ea9b21a0cb04cc9bd6d6dbcf8ee63e0 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sat, 13 Jun 2026 00:16:18 +0200 Subject: [PATCH 27/39] update22 --- src/config/profile_models.py | 2 +- src/gui/importer/d4builds.py | 10 +--------- src/gui/importer/gui_common.py | 9 +++++++++ src/gui/importer/maxroll.py | 10 +--------- src/gui/importer/mobalytics.py | 10 +--------- src/item/descr/read_descr_tts.py | 4 ++-- src/item/filter.py | 4 ++-- src/item/models.py | 1 - tests/config/models_test.py | 4 ++++ tests/gui/importer/test_gui_common.py | 8 +++++++- 10 files changed, 28 insertions(+), 34 deletions(-) diff --git a/src/config/profile_models.py b/src/config/profile_models.py index 32967c8c..065ccd91 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -239,7 +239,7 @@ def parse_rarities(cls, data: str | list[str]) -> list[str]: class CharmFilterModel(_BaseSealOrCharmFilterModel): - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(extra="forbid", populate_by_name=True) set: list[str] = Field(default=[], alias="set") unique_aspect: list[AspectUniqueFilterModel] = Field(default=[], alias="uniqueAspect") diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index 8b683a3f..097b9ecb 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -20,6 +20,7 @@ ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( + _unique_filter_name, add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, @@ -344,15 +345,6 @@ def _get_affix_name(stat: lxml.html.HtmlElement) -> str: return "" -def _unique_filter_name(filter_name_template: str, filters: list[dict]) -> str: - filter_name = filter_name_template - i = 2 - while any(filter_name == next(iter(existing_filter)) for existing_filter in filters): - filter_name = f"{filter_name_template}{i}" - i += 1 - return filter_name - - if __name__ == "__main__": src.logger.setup() URLS = ["https://d4builds.gg/builds/whirlwind-barbarian-endgame/?var=4"] diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py index ddb61f39..8ede9972 100644 --- a/src/gui/importer/gui_common.py +++ b/src/gui/importer/gui_common.py @@ -219,6 +219,15 @@ def create_seal_charm_filter( return seal_charm_filter +def _unique_filter_name(filter_name_template: str, filters: list[dict]) -> str: + filter_name = filter_name_template + i = 2 + while any(filter_name == next(iter(existing_filter)) for existing_filter in filters): + filter_name = f"{filter_name_template}{i}" + i += 1 + return filter_name + + def add_mythics_to_filters(mythic_names, finished_filters): if mythic_names: mythic_filter = ItemFilterModel() diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index a23518da..74be0521 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -16,6 +16,7 @@ ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( + _unique_filter_name, add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, @@ -335,15 +336,6 @@ def _find_item_affixes( return res -def _unique_filter_name(filter_name_template: str, filters: list[dict]) -> str: - filter_name = filter_name_template - i = 2 - while any(filter_name == next(iter(existing_filter)) for existing_filter in filters): - filter_name = f"{filter_name_template}{i}" - i += 1 - return filter_name - - def _find_skill_rank_affix_description(mapping_data: dict, affix_key: str, attribute: dict) -> str: if attribute.get("formula") not in SKILL_RANK_BONUS_FORMULAS: return "" diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index 8d1d3b3c..c8eb51d7 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -23,6 +23,7 @@ ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( + _unique_filter_name, add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, @@ -376,15 +377,6 @@ def _convert_raw_to_affixes(raw_stats: list[dict], import_greater_affixes=False) return result -def _unique_filter_name(filter_name_template: str, filters: list[dict]) -> str: - filter_name = filter_name_template - i = 2 - while any(filter_name == next(iter(existing_filter)) for existing_filter in filters): - filter_name = f"{filter_name_template}{i}" - i += 1 - return filter_name - - if __name__ == "__main__": src.logger.setup() diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index f43d5d88..cb3cc260 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -137,7 +137,7 @@ def _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item: elif item.rarity == ItemRarity.Unique: item.aspect = _get_aspect_from_text(aspect_or_set_text, item.name) elif item.rarity == ItemRarity.Set: - item.set = _get_set_from_text(aspect_or_set_text) + item.set = aspect_or_set_text else: item.aspect = _get_aspect_from_name(aspect_or_set_text, item.name) return item @@ -187,7 +187,7 @@ def _add_affixes_from_tts_mixed( elif item.rarity == ItemRarity.Unique: item.aspect = _get_aspect_from_text(aspect_or_set_text, item.name) elif item.rarity == ItemRarity.Set: - item.set = _get_set_from_text(aspect_or_set_text) + item.set = aspect_or_set_text else: item.aspect = _get_aspect_from_name(aspect_or_set_text, item.name) if item.aspect and aspect_bullet: diff --git a/src/item/filter.py b/src/item/filter.py index 61179f66..c4bc4458 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -295,10 +295,10 @@ def _check_seal_charm_filters( ): continue elif filter_spec.set: - if seal_or_charm.set not in filter_spec.set: - continue if not seal_or_charm.set: # This would mean there's no set but a set is expected continue + if seal_or_charm.set not in filter_spec.set: + continue matched_set = True LOGGER.info( diff --git a/src/item/models.py b/src/item/models.py index a28899de..1f081163 100644 --- a/src/item/models.py +++ b/src/item/models.py @@ -77,7 +77,6 @@ def default(self, o): return { "affixes": [affix.__dict__ for affix in o.affixes], "aspect": o.aspect.__dict__ if o.aspect else None, - "charm_slots": o.charm_slots, "codex_upgrade": o.codex_upgrade, "cosmetic_upgrade": o.cosmetic_upgrade, "inherent": [affix.__dict__ for affix in o.inherent], diff --git a/tests/config/models_test.py b/tests/config/models_test.py index 75ca22ee..7931fe7d 100644 --- a/tests/config/models_test.py +++ b/tests/config/models_test.py @@ -594,6 +594,10 @@ def test_rarities_parse_list(self) -> None: class TestCharmFilterModel: + def test_extra_fields_are_forbidden(self) -> None: + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + CharmFilterModel(unexpected=True) + def test_set_name_is_validated_and_normalized(self) -> None: model = CharmFilterModel(set=["Breath of the Frozen Sea"]) diff --git a/tests/gui/importer/test_gui_common.py b/tests/gui/importer/test_gui_common.py index e8566d7e..791b3015 100644 --- a/tests/gui/importer/test_gui_common.py +++ b/tests/gui/importer/test_gui_common.py @@ -1,5 +1,5 @@ from src.config.profile_models import ProfileModel -from src.gui.importer.gui_common import _to_yaml_str, build_default_profile_file_name +from src.gui.importer.gui_common import _to_yaml_str, _unique_filter_name, build_default_profile_file_name def test_build_default_profile_file_name_maxroll() -> None: @@ -54,6 +54,12 @@ def test_build_default_profile_file_name_replaces_stale_season_marker_in_header( assert file_name == "maxroll_sorcerer_s12_crackling_energy_sorc" +def test_unique_filter_name_adds_suffix_for_existing_filter_names() -> None: + filter_name = _unique_filter_name("Charm", [{"Charm": object()}, {"Charm2": object()}]) + + assert filter_name == "Charm3" + + def test_to_yaml_str_sorts_aspect_upgrades_and_uses_block_style(mock_ini_loader) -> None: profile = ProfileModel(name="test", AspectUpgrades=["snowveiled", "accelerating"]) From 9234c0d6a7e60483d94375f3b9f471d376b2ce0b Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sat, 13 Jun 2026 11:11:48 +0200 Subject: [PATCH 28/39] Update charms.py --- tests/item/filter/data/charms.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/item/filter/data/charms.py b/tests/item/filter/data/charms.py index 558ec11a..dfa104e1 100644 --- a/tests/item/filter/data/charms.py +++ b/tests/item/filter/data/charms.py @@ -80,16 +80,16 @@ ), ), ( - "unique charm without aspect rejected", + "rare charm against unique aspect filter rejected", [], Item( item_type=ItemType.Charm, - name="tuskhelm_of_joritz_the_mighty", - original_name="TUSKHELM OF JORITZ THE MIGHTY", - rarity=ItemRarity.Unique, + name="speedy_charm_of_greed", + original_name="SPEEDY CHARM OF GREED", + rarity=ItemRarity.Rare, aspect=None, set=None, - affixes=[Affix(name="cold_resistance", value=486.0, min_value=416.0, max_value=523.0)], + affixes=[Affix(name="gold_drop_rate", value=7.0, min_value=5.0, max_value=9.0)], ), ), ( From 5fb28d12764fce693f9a7a6bf891b44dcf24910e Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sat, 13 Jun 2026 21:23:52 +0200 Subject: [PATCH 29/39] importer update1 --- src/gui/importer/d4builds.py | 203 ++++++++++++++++++++++++-- src/gui/importer/gui_common.py | 99 ++++++++++++- src/gui/importer/maxroll.py | 33 ++++- src/gui/importer/mobalytics.py | 144 +++++++++++++++--- tests/gui/importer/test_gui_common.py | 23 ++- 5 files changed, 459 insertions(+), 43 deletions(-) diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index 097b9ecb..38e13995 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -4,6 +4,9 @@ from typing import TYPE_CHECKING import lxml.html +import rapidfuzz +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support.wait import WebDriverWait @@ -25,9 +28,11 @@ add_to_profiles, build_default_profile_file_name, create_seal_charm_filter, + deduplicate_filters, fix_offhand_type, fix_weapon_type, get_class_name, + match_charm_to_set_or_unique, match_to_enum, retry_importer, save_as_profile, @@ -111,9 +116,16 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): if slot not in slot_to_unique_name_map: LOGGER.warning(f"Empty slots are not supported. Skipping: {slot}") continue - if not (stats := item.xpath(ITEM_STATS_XPATH)): + + slot_lower = slot.lower() + is_charm = "charm" in slot_lower + is_seal = "seal" in slot_lower + + stats = item.xpath(ITEM_STATS_XPATH) + if not stats and not (is_charm or is_seal): LOGGER.error(f"No stats found for {slot=}") continue + item_type = None rarity = None affixes = [] @@ -153,9 +165,12 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): substring in affix_name.lower() for substring in ["focus", "offhand", "shield", "totem"] ): # special line indicating the item type continue - affix_obj = Affix( - name=closest_match(clean_str(_corrections(input_str=affix_name)), Dataloader().affix_dict) - ) + combined_dict = Dataloader().affix_dict + if is_seal: + combined_dict = combined_dict | Dataloader().seal_affix_dict + elif is_charm: + combined_dict = combined_dict | Dataloader().charm_affix_dict + affix_obj = Affix(name=closest_match(clean_str(_corrections(input_str=affix_name)), combined_dict)) if affix_obj.name is None: LOGGER.error(f"Couldn't match {affix_name=}") continue @@ -163,14 +178,15 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): affix_obj.type = AffixType.greater affixes.append(affix_obj) - if not affixes: - continue - item_type = ( match_to_enum(enum_class=ItemType, target_string=re.sub(r"\d+", "", slot.replace(" ", ""))) if item_type is None else item_type ) + + if not affixes and item_type not in [ItemType.HoradricSeal, ItemType.Charm]: + continue + if item_type is None: if is_weapon: LOGGER.warning( @@ -187,12 +203,22 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters filter_name = _unique_filter_name(item_type.name, seal_charm_filters) seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel + # Extract unique aspect and set info for charms + charm_unique_aspect = None + charm_set_name = None + if item_type == ItemType.Charm and slot_to_unique_name_map.get(slot): + unique_name, unique_rarity = slot_to_unique_name_map[slot] + charm_unique_aspect, charm_set_name = match_charm_to_set_or_unique(unique_name) + if not affixes and not charm_unique_aspect and not charm_set_name: + continue seal_charm_filters.append({ filter_name: create_seal_charm_filter( affixes=affixes, rarity=rarity, require_gas=config.require_greater_affixes, model_type=seal_charm_model, + unique_aspect=charm_unique_aspect, + set_name=charm_set_name, ) }) continue @@ -216,11 +242,152 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): finished_filters.append({filter_name: item_filter}) # Place all mythics in a single filter add_mythics_to_filters(mythic_names, finished_filters) + + # Detect set name from active charms to assist in seal affix matching + guessed_set_name = None + try: + active_charms_elements = driver.find_elements( + By.XPATH, "//*[contains(@class, 'builder__charm') and contains(@class, 'active')]" + ) + for charm_el in active_charms_elements: + try: + imgs = charm_el.find_elements(By.TAG_NAME, "img") + if imgs: + alt = imgs[0].get_attribute("alt") or "" + src = imgs[0].get_attribute("src") or "" + _, set_name = match_charm_to_set_or_unique(alt, src) + if set_name: + guessed_set_name = set_name + break + except WebDriverException: + LOGGER.debug("Failed to inspect an active charm while detecting the set name.", exc_info=True) + except WebDriverException: + LOGGER.debug("Failed to find active charms while detecting the set name.", exc_info=True) + + # Parse active seals from the builder__charms section + try: + active_seals_elements = driver.find_elements( + By.XPATH, "//*[contains(@class, 'builder__seal') and contains(@class, 'active')]" + ) + for idx, seal_el in enumerate(active_seals_elements): + affixes = [] + try: + ActionChains(driver).move_to_element(seal_el).perform() + time.sleep(0.4) + tooltip_html = driver.find_element(By.CLASS_NAME, "seal__tooltip").get_attribute("outerHTML") + tooltip_data = lxml.html.fromstring(tooltip_html) + raw_affixes = [ + v.text_content().strip() + for v in tooltip_data.xpath(".//*[contains(@class, 'seal__tooltip__value__text')]") + ] + combined_dict = Dataloader().affix_dict | Dataloader().seal_affix_dict + for affix_name in raw_affixes: + affix_clean = clean_str(_corrections(input_str=affix_name)) + name_matched = None + if guessed_set_name: + # First check if the affix is a generic affix with an exact or very close match + best_global_key = closest_match(affix_clean, combined_dict) + is_exact_generic = False + if best_global_key and best_global_key != "damage": + global_display = combined_dict[best_global_key] + if rapidfuzz.distance.Levenshtein.distance(affix_clean, global_display) <= 2: + # Ensure it's not a set-specific affix of another set + is_set_specific = False + for set_name in Dataloader().set_list: + if best_global_key.startswith(set_name + "_"): + is_set_specific = True + break + if not is_set_specific: + is_exact_generic = True + name_matched = best_global_key + + if not is_exact_generic: + set_keys = { + k: v + for k, v in Dataloader().seal_affix_dict.items() + if k.startswith(guessed_set_name + "_") + } + potential_match = closest_match(affix_clean, set_keys) + if potential_match: + display_name = Dataloader().seal_affix_dict[potential_match] + if rapidfuzz.fuzz.token_set_ratio(affix_clean, display_name) >= 50: + name_matched = potential_match + if not name_matched: + name_matched = closest_match(affix_clean, combined_dict) + + if name_matched: + affixes.append(Affix(name=name_matched)) + else: + LOGGER.warning(f"Couldn't match seal affix: {affix_name}") + except Exception as e: # noqa: BLE001 + LOGGER.warning(f"Failed to hover/extract affixes for seal {idx}: {e}") + + seal_filters.append({ + _unique_filter_name("HoradricSeal", seal_filters): create_seal_charm_filter( + affixes=affixes, rarity=None, require_gas=False, model_type=SealFilterModel + ) + }) + except Exception: + LOGGER.exception("Failed to parse active seals") + + # Parse active charms from the builder__charms section + try: + active_charms_elements = driver.find_elements( + By.XPATH, "//*[contains(@class, 'builder__charm') and contains(@class, 'active')]" + ) + for idx, charm_el in enumerate(active_charms_elements): + alt = "" + src = "" + try: + imgs = charm_el.find_elements(By.TAG_NAME, "img") + if imgs: + alt = imgs[0].get_attribute("alt") or "" + src = imgs[0].get_attribute("src") or "" + except Exception as e: # noqa: BLE001 + LOGGER.warning(f"Failed to get img details for charm {idx}: {e}") + + affixes = [] + try: + ActionChains(driver).move_to_element(charm_el).perform() + time.sleep(0.4) + tooltip_html = driver.find_element(By.CLASS_NAME, "charm__tooltip").get_attribute("outerHTML") + tooltip_data = lxml.html.fromstring(tooltip_html) + raw_affixes = [ + v.text_content().strip() + for v in tooltip_data.xpath( + ".//*[contains(@class, 'charm__tooltip__value') and not(contains(@class, 'values'))]" + ) + ] + combined_dict = Dataloader().affix_dict | Dataloader().charm_affix_dict + for affix_name in raw_affixes: + name_matched = closest_match(clean_str(_corrections(input_str=affix_name)), combined_dict) + if name_matched: + affixes.append(Affix(name=name_matched)) + else: + LOGGER.warning(f"Couldn't match charm affix: {affix_name}") + except Exception as e: # noqa: BLE001 + LOGGER.warning(f"Failed to hover/extract affixes for charm {idx}: {e}") + + charm_unique_aspect, charm_set_name = match_charm_to_set_or_unique(alt, src) + if affixes or charm_unique_aspect or charm_set_name: + charm_filters.append({ + _unique_filter_name("Charm", charm_filters): create_seal_charm_filter( + affixes=affixes, + rarity=None, + require_gas=False, + model_type=CharmFilterModel, + unique_aspect=charm_unique_aspect, + set_name=charm_set_name, + ) + }) + except Exception: + LOGGER.exception("Failed to parse active charms") + profile = ProfileModel( name="imported profile", Affixes=sort_profile_filters(finished_filters), - Charms=sort_profile_filters(charm_filters), - Seals=sort_profile_filters(seal_filters), + Charms=sort_profile_filters(deduplicate_filters(charm_filters)), + Seals=sort_profile_filters(deduplicate_filters(seal_filters)), ) if config.import_aspect_upgrades and aspect_upgrade_filters: profile.aspect_upgrades = aspect_upgrade_filters @@ -265,7 +432,23 @@ def _corrections(input_str: str) -> str: def _extract_build_metadata(data: lxml.html.HtmlElement) -> tuple[str, str, str, str]: class_name = "Unknown" if header_nodes := data.xpath(CLASS_XPATH): - class_name = get_class_name(" ".join(header_nodes[0].text_content().split())) + text = " ".join(header_nodes[0].text_content().split()).strip() + if text: + class_name = get_class_name(text) + if class_name == "Unknown": + for selector in [ + "//*[contains(@class, 'builder__header__icon')]", + "//*[contains(@class, 'builder__header__description')]", + "//title", + ]: + for node in data.xpath(selector): + text = node.text_content() or node.get("class", "") or node.get("alt", "") + if (x := get_class_name(text)) != "Unknown": + class_name = x + break + if class_name != "Unknown": + break + build_header = "" if description_nodes := data.xpath(BUILD_DESCRIPTION_XPATH): build_header = " ".join(description_nodes[0].text_content().split()) diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py index 8ede9972..568b1b45 100644 --- a/src/gui/importer/gui_common.py +++ b/src/gui/importer/gui_common.py @@ -128,7 +128,9 @@ def format_number_as_short_string(n: int) -> str: def get_class_name(input_str: str) -> str: - input_str = input_str.lower() + input_str = input_str.strip().lower() + if not input_str: + return "Unknown" for class_name in PLAYER_CLASSES: if class_name in input_str: return class_name.title() @@ -202,23 +204,69 @@ def create_seal_charm_filter( rarity, require_gas: bool, model_type: type[CharmFilterModel | SealFilterModel] = SealFilterModel, + unique_aspect: str | None = None, + set_name: str | None = None, ) -> CharmFilterModel | SealFilterModel: - seal_charm_filter = model_type( - affix_pool=[ + kwargs = {"rarities": [rarity] if rarity is not None else []} + if affixes: + kwargs["affix_pool"] = [ AffixFilterCountModel( count=[ AffixFilterModel(name=affix.name, want_greater=affix.type == AffixType.greater) for affix in affixes ], min_count=min(3, len(affixes)), ) - ], - rarities=[rarity] if rarity is not None else [], - ) + ] + if model_type is CharmFilterModel: + if unique_aspect: + kwargs["unique_aspect"] = [AspectUniqueFilterModel(name=unique_aspect)] + if set_name: + kwargs["set"] = [set_name] + seal_charm_filter = model_type(**kwargs) if require_gas: seal_charm_filter.min_greater_affix_count = len([affix for affix in affixes if affix.type == AffixType.greater]) return seal_charm_filter +def match_charm_to_set_or_unique(charm_name: str, icon_url: str | None = None) -> tuple[str | None, str | None]: + if not charm_name: + return None, None + + from src.dataloader import Dataloader # noqa: PLC0415 + from src.scripts import correct_name # noqa: PLC0415 + + # 1. Clean the charm name and normalize it + name_clean = correct_name(charm_name) + if not name_clean: + return None, None + + # Check if the name matches a known unique charm (like "endurant_faith") + if name_clean in Dataloader().aspect_unique_dict: + return name_clean, None + + # 2. Try to match set name from the icon_url first + if icon_url: + icon_slug = icon_url.rsplit("/", maxsplit=1)[-1].split(".", maxsplit=1)[0].replace("-", "_") + if icon_slug in Dataloader().set_list: + return None, icon_slug + + # 3. Try to match the set name from the clean name + for set_name in Dataloader().set_list: + set_clean = set_name.replace("_", " ") + name_spaces = name_clean.replace("_", " ") + if set_name in name_clean or set_clean in name_spaces: + return None, set_name + + set_words = set_name.split("_") + name_words = name_clean.split("_") + # Find if any word (length > 3) matches + for sw in set_words: + if len(sw) > 3 and sw not in ["of", "the", "way", "will", "bite", "flow", "call"] and sw in name_words: + return None, set_name + + return name_clean, None + + def _unique_filter_name(filter_name_template: str, filters: list[dict]) -> str: filter_name = filter_name_template i = 2 @@ -228,6 +276,45 @@ def _unique_filter_name(filter_name_template: str, filters: list[dict]) -> str: return filter_name +def deduplicate_filters(filters: list[dict]) -> list[dict]: + """Merge identical charm/seal filters, naming duplicates with an (xN) count suffix. + + Filters are compared by their Pydantic model data (all fields except the dict key/name). + Identical filters are collapsed into a single entry. When N > 1, the key is rewritten + as ``BaseType(xN)`` (e.g. ``Charm(x3)``); single-occurrence filters keep their original + key unchanged. + """ + if not filters: + return filters + + # Build groups keyed by a hashable representation of the filter model + groups: list[tuple[str, object, int]] = [] # (base_name, filter_model, count) + for entry in filters: + name, model = next(iter(entry.items())) + model_data = model.model_dump() if hasattr(model, "model_dump") else model + # Check if this model already exists in groups + merged = False + for idx, (base_name, existing_model, count) in enumerate(groups): + existing_data = existing_model.model_dump() if hasattr(existing_model, "model_dump") else existing_model + if model_data == existing_data: + groups[idx] = (base_name, existing_model, count + 1) + merged = True + break + if not merged: + # Strip trailing digits to get the base type name (e.g. "Charm3" -> "Charm") + base = re.sub(r"\d+$", "", name) + groups.append((base, model, 1)) + + # Rebuild the list with deduplicated names + result: list[dict] = [] + used_names: list[dict] = [] + for base_name, model, count in groups: + key = f"{base_name}(x{count})" if count > 1 else _unique_filter_name(base_name, used_names) + result.append({key: model}) + used_names.append({key: model}) + return result + + def add_mythics_to_filters(mythic_names, finished_filters): if mythic_names: mythic_filter = ItemFilterModel() diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 74be0521..2141db7c 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -21,9 +21,11 @@ add_to_profiles, build_default_profile_file_name, create_seal_charm_filter, + deduplicate_filters, fix_offhand_type, fix_weapon_type, get_with_retry, + match_charm_to_set_or_unique, match_to_enum, retry_importer, save_as_profile, @@ -121,9 +123,17 @@ def import_maxroll(config: ImportConfig): item_type=item_type, import_greater_affixes=config.import_greater_affixes, ) - if not seal_charm_affixes: + # Extract unique aspect and set info for charms + charm_unique_aspect = None + charm_set_name = None + if item_type == ItemType.Charm and resolved_item_id in mapping_data["items"]: + item_data = mapping_data["items"][resolved_item_id] + charm_unique_aspect, charm_set_name = match_charm_to_set_or_unique(item_data.get("name")) + if item_data.get("magicType") == 3 and "set" in item_data and not charm_set_name: + charm_set_name = correct_name(item_data["set"]) + if not seal_charm_affixes and not charm_unique_aspect and not charm_set_name: LOGGER.warning( - f"Skipping {resolved_item.get('name', '(could not determine item name)')} because it had no supported affixes." + f"Skipping {resolved_item.get('name', '(could not determine item name)')} because it had no supported affixes, unique aspect, or set name." ) continue seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters @@ -135,6 +145,8 @@ def import_maxroll(config: ImportConfig): rarity=rarity, require_gas=config.require_greater_affixes, model_type=seal_charm_model, + unique_aspect=charm_unique_aspect, + set_name=charm_set_name, ) }) continue @@ -200,8 +212,8 @@ def import_maxroll(config: ImportConfig): profile = ProfileModel( name="imported profile", Affixes=sort_profile_filters(finished_filters), - Charms=sort_profile_filters(charm_filters), - Seals=sort_profile_filters(seal_filters), + Charms=sort_profile_filters(deduplicate_filters(charm_filters)), + Seals=sort_profile_filters(deduplicate_filters(seal_filters)), ) if config.import_aspect_upgrades and aspect_upgrade_filters: profile.aspect_upgrades = aspect_upgrade_filters @@ -244,13 +256,15 @@ def _attribute_description_corrections(input_str: str) -> str: def _find_item_rarity(resolved_item_id, mapping_data) -> ItemRarity: - # magic/rare = 0, legendary = 1, unique = 2, mythic = 4 + # magic/rare = 0, legendary = 1, unique = 2, set = 3, mythic = 4 if resolved_item_id in mapping_data["items"]: rarity_id = mapping_data["items"][resolved_item_id]["magicType"] if rarity_id == 1: return ItemRarity.Legendary if rarity_id == 2: return ItemRarity.Unique + if rarity_id == 3: + return ItemRarity.Set if rarity_id == 4: return ItemRarity.Mythic @@ -312,6 +326,8 @@ def _find_item_affixes( attr_desc = _find_skill_rank_affix_description( mapping_data=mapping_data, affix_key=affix_key, attribute=affix["attributes"][0] ) + if not attr_desc and affix.get("desc"): + attr_desc = affix["desc"] clean_desc = re.sub(r"\[.*?\]|[^a-zA-Z ]", "", attr_desc) clean_desc = clean_desc.replace("SecondSeconds", "seconds") if not clean_desc: @@ -320,7 +336,12 @@ def _find_item_affixes( ) continue - affix_obj = Affix(name=closest_match(clean_str(clean_desc), Dataloader().affix_dict)) + combined_dict = Dataloader().affix_dict + if item_type == ItemType.HoradricSeal: + combined_dict = combined_dict | Dataloader().seal_affix_dict + elif item_type == ItemType.Charm: + combined_dict = combined_dict | Dataloader().charm_affix_dict + affix_obj = Affix(name=closest_match(clean_str(clean_desc), combined_dict)) if import_greater_affixes and affix_id.get("greater", False): affix_obj.type = AffixType.greater if affix_obj.name is not None: diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index c8eb51d7..0056b98d 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -6,6 +6,7 @@ import jsonpath import lxml.html +import rapidfuzz from selenium.common.exceptions import WebDriverException from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as ec @@ -28,8 +29,10 @@ add_to_profiles, build_default_profile_file_name, create_seal_charm_filter, + deduplicate_filters, fix_offhand_type, fix_weapon_type, + match_charm_to_set_or_unique, match_to_enum, retry_importer, save_as_profile, @@ -39,7 +42,7 @@ from src.gui.importer.importer_config import ImportConfig from src.gui.importer.paragon_export import build_paragon_profile_payload, extract_mobalytics_paragon_steps from src.item.data.affix import Affix, AffixType -from src.item.data.item_type import WEAPON_TYPES, ItemType +from src.item.data.item_type import WEAPON_TYPES, ItemType, is_weapon from src.item.descr.text import clean_str, closest_match from src.scripts import correct_name @@ -131,6 +134,23 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): if not items: LOGGER.error(msg := "No items found") raise MobalyticsError(msg) + + # Detect set name from charms to assist in seal affix matching + guessed_set_name = None + for item in items: + entity_type = jsonpath.findall(".gameEntity.type", item)[0] + if entity_type == "charms": + title_result = jsonpath.findall(".gameEntity.entity.title", item) or jsonpath.findall( + ".gameEntity.title", item + ) + item_name = str(title_result[0]) if title_result else "" + icon_result = jsonpath.findall(".gameEntity.iconUrl", item) + icon_url = icon_result[0] if icon_result else None + _, set_name = match_charm_to_set_or_unique(item_name, icon_url) + if set_name: + guessed_set_name = set_name + break + finished_filters = [] charm_filters = [] seal_filters = [] @@ -143,14 +163,25 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): is_mythic = mythic_result[0] if mythic_result else False if entity_type not in ["aspects", "uniqueItems", "charms", "seals", "items"]: continue - if not (item_name := str(jsonpath.findall(".gameEntity.entity.title", item)[0])): + title_result = jsonpath.findall(".gameEntity.entity.title", item) or jsonpath.findall(".gameEntity.title", item) + if not title_result: + slot_result = jsonpath.findall(".gameSlotSlug", item) + LOGGER.warning( + f"Skipping {slot_result[0] if slot_result else '(unknown slot)'} ({entity_type}) because it has no title." + ) + continue + if not (item_name := str(title_result[0])): LOGGER.error(msg := "No item name found") raise MobalyticsError(msg) if not (slot_type := str(jsonpath.findall(".gameSlotSlug", item)[0])): LOGGER.error(msg := "No slot type found") raise MobalyticsError(msg) - raw_affixes = jsonpath.findall(".gameEntity.modifiers.gearStats[*]", item) + raw_affixes = ( + jsonpath.findall(".gameEntity.modifiers.gearStats[*]", item) + + jsonpath.findall(".gameEntity.modifiers.sealStats[*]", item) + + jsonpath.findall(".gameEntity.modifiers.charmStats[*]", item) + ) raw_inherents = jsonpath.findall(".gameEntity.modifiers.implicitStats[*]", item) raw_affixes = [x for x in raw_affixes if x is not None] raw_inherents = [x for x in raw_inherents if x is not None] @@ -170,7 +201,7 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): if legendary_aspect: aspect_upgrade_filters.append(legendary_aspect) - if not raw_affixes and not raw_inherents: + if entity_type not in ["charms", "seals"] and not raw_affixes and not raw_inherents: LOGGER.warning(f"Skipping {slot_type} because it had no stats provided.") continue @@ -196,11 +227,16 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): if not item_type and "offhand" in slot_type: item_type = fix_offhand_type("", class_name) - item_type = ( - match_to_enum(enum_class=ItemType, target_string=re.sub(r"\d+", "", slot_type)) - if item_type is None - else item_type - ) + if "seal" in slot_type.lower(): + item_type = ItemType.HoradricSeal + elif "charm" in slot_type.lower(): + item_type = ItemType.Charm + else: + item_type = ( + match_to_enum(enum_class=ItemType, target_string=re.sub(r"\d+", "", slot_type)) + if item_type is None + else item_type + ) if item_type is None: if is_weapon: LOGGER.warning( @@ -213,22 +249,33 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): else: item_filter.item_type = [item_type] - affixes = _convert_raw_to_affixes(raw_affixes, config.import_greater_affixes) - inherents = _convert_raw_to_affixes(raw_inherents) + affixes = _convert_raw_to_affixes( + raw_affixes, config.import_greater_affixes, item_type, guessed_set_name=guessed_set_name + ) + inherents = _convert_raw_to_affixes(raw_inherents, item_type=item_type, guessed_set_name=guessed_set_name) if item_type in [ItemType.HoradricSeal, ItemType.Charm]: - if not affixes: - LOGGER.warning(f"Skipping {item_name} because it had no supported affixes.") - continue seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters filter_name = _unique_filter_name(item_type.name, seal_charm_filters) seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel + # Extract unique aspect and set info for charms + charm_unique_aspect = None + charm_set_name = None + if item_type == ItemType.Charm: + icon_result = jsonpath.findall(".gameEntity.iconUrl", item) + icon_url = icon_result[0] if icon_result else None + charm_unique_aspect, charm_set_name = match_charm_to_set_or_unique(item_name, icon_url) + if not affixes and not charm_unique_aspect and not charm_set_name: + LOGGER.warning(f"Skipping {item_name} because it had no supported affixes, unique aspect, or set name.") + continue seal_charm_filters.append({ filter_name: create_seal_charm_filter( affixes=affixes, rarity=None, require_gas=config.require_greater_affixes, model_type=seal_charm_model, + unique_aspect=charm_unique_aspect, + set_name=charm_set_name, ) }) continue @@ -255,8 +302,8 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): profile = ProfileModel( name="imported profile", Affixes=sort_profile_filters(finished_filters), - Charms=sort_profile_filters(charm_filters), - Seals=sort_profile_filters(seal_filters), + Charms=sort_profile_filters(deduplicate_filters(charm_filters)), + Seals=sort_profile_filters(deduplicate_filters(seal_filters)), ) if config.import_aspect_upgrades and aspect_upgrade_filters: profile.aspect_upgrades = aspect_upgrade_filters @@ -361,13 +408,72 @@ def _get_legendary_aspect(name: str) -> str: return "" -def _convert_raw_to_affixes(raw_stats: list[dict], import_greater_affixes=False) -> list[Affix]: +def _convert_raw_to_affixes( + raw_stats: list[dict], + import_greater_affixes=False, + item_type: ItemType | None = None, + guessed_set_name: str | None = None, +) -> list[Affix]: result = [] + combined_dict = Dataloader().affix_dict + if item_type == ItemType.HoradricSeal: + combined_dict = combined_dict | Dataloader().seal_affix_dict + elif item_type == ItemType.Charm: + combined_dict = combined_dict | Dataloader().charm_affix_dict for stat in raw_stats: if stat: - affix_obj = Affix( - name=closest_match(clean_str(_corrections(input_str=stat["id"])), Dataloader().affix_dict) + stat_id = stat["id"] + + # Map database IDs to standard game affix names + # Element/vulnerable/critical damage multiplier stats are additive (non-multiplier) on non-weapons. + # Vulnerable and elemental damage stats are also additive on weapons, but critical strike damage is multiplier. + is_wpn = ( + item_type is not None + and is_weapon(item_type) + and item_type not in [ItemType.Focus, ItemType.OffHandTotem] ) + if stat_id == "critical-strike-damage-multiplier": + if not is_wpn: + stat_id = "critical-strike-damage" + elif stat_id == "all-damage-multiplier": + pass + elif stat_id.endswith("-damage-multiplier"): + # Vulnerable, elemental, physical, etc. are always additive in-game affixes + base_stat = stat_id[:-18] # strip "-damage-multiplier" + stat_id = f"{base_stat}-damage" + + stat_clean = clean_str(_corrections(input_str=stat_id.replace("-", " "))) + matched_name = None + if item_type == ItemType.HoradricSeal and guessed_set_name: + # First check if the stat is a generic affix with an exact or very close match + best_global_key = closest_match(stat_clean, combined_dict) + is_exact_generic = False + if best_global_key and best_global_key != "damage": + global_display = combined_dict[best_global_key] + if rapidfuzz.distance.Levenshtein.distance(stat_clean, global_display) <= 2: + # Ensure it's not a set-specific affix of another set + is_set_specific = False + for set_name in Dataloader().set_list: + if best_global_key.startswith(set_name + "_"): + is_set_specific = True + break + if not is_set_specific: + is_exact_generic = True + matched_name = best_global_key + + if not is_exact_generic: + set_keys = { + k: v for k, v in Dataloader().seal_affix_dict.items() if k.startswith(guessed_set_name + "_") + } + potential_match = closest_match(stat_clean, set_keys) + if potential_match: + display_name = Dataloader().seal_affix_dict[potential_match] + if rapidfuzz.fuzz.token_set_ratio(stat_clean, display_name) >= 50: + matched_name = potential_match + if matched_name is None: + matched_name = closest_match(stat_clean, combined_dict) + + affix_obj = Affix(name=matched_name) if affix_obj.name is None: LOGGER.error(f"Couldn't match {stat=}") continue diff --git a/tests/gui/importer/test_gui_common.py b/tests/gui/importer/test_gui_common.py index 791b3015..e47e8de4 100644 --- a/tests/gui/importer/test_gui_common.py +++ b/tests/gui/importer/test_gui_common.py @@ -1,5 +1,10 @@ -from src.config.profile_models import ProfileModel -from src.gui.importer.gui_common import _to_yaml_str, _unique_filter_name, build_default_profile_file_name +from src.config.profile_models import CharmFilterModel, ProfileModel +from src.gui.importer.gui_common import ( + _to_yaml_str, + _unique_filter_name, + build_default_profile_file_name, + deduplicate_filters, +) def test_build_default_profile_file_name_maxroll() -> None: @@ -67,3 +72,17 @@ def test_to_yaml_str_sorts_aspect_upgrades_and_uses_block_style(mock_ini_loader) assert "aspect_upgrades:\n- accelerating\n- snowveiled\n" in yaml_str assert "aspect_upgrades: [" not in yaml_str + + +def test_deduplicate_filters() -> None: + f1 = CharmFilterModel(set=["tal_rashas_threefold_way"]) + f2 = CharmFilterModel(set=["tal_rashas_threefold_way"]) + f3 = CharmFilterModel(set=["applied_alchemy"]) + + filters = [{"Charm": f1}, {"Charm": f2}, {"Charm": f3}] + + deduped = deduplicate_filters(filters) + assert len(deduped) == 2 + assert "Charm(x2)" in deduped[0] + assert deduped[0]["Charm(x2)"] == f1 + assert "Charm" in deduped[1] or "Charm2" in deduped[1] From eae9bd486b5d8387ecb2774fce39c24a6b47476a Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 14 Jun 2026 10:36:54 +0200 Subject: [PATCH 30/39] importer update2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gui_common.py — Removed rarity param from create_seal_charm_filter; set min_count=1 for seals; fixed deduplicate_filters to produce unique keys when multiple groups share the same (xN) suffix. maxroll.py — Removed rarity=rarity from call. d4builds.py — Removed rarity=rarity/rarity=None from 3 calls. mobalytics.py — Removed rarity=None from call. --- src/gui/importer/d4builds.py | 4 +--- src/gui/importer/gui_common.py | 16 ++++++++++++---- src/gui/importer/maxroll.py | 1 - src/gui/importer/mobalytics.py | 1 - 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index 38e13995..d3fc0bfa 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -214,7 +214,6 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): seal_charm_filters.append({ filter_name: create_seal_charm_filter( affixes=affixes, - rarity=rarity, require_gas=config.require_greater_affixes, model_type=seal_charm_model, unique_aspect=charm_unique_aspect, @@ -324,7 +323,7 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): seal_filters.append({ _unique_filter_name("HoradricSeal", seal_filters): create_seal_charm_filter( - affixes=affixes, rarity=None, require_gas=False, model_type=SealFilterModel + affixes=affixes, require_gas=False, model_type=SealFilterModel ) }) except Exception: @@ -373,7 +372,6 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): charm_filters.append({ _unique_filter_name("Charm", charm_filters): create_seal_charm_filter( affixes=affixes, - rarity=None, require_gas=False, model_type=CharmFilterModel, unique_aspect=charm_unique_aspect, diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py index 568b1b45..6d86e329 100644 --- a/src/gui/importer/gui_common.py +++ b/src/gui/importer/gui_common.py @@ -201,20 +201,19 @@ def update_mingreateraffixcount(item_filter: ItemFilterModel, require_gas: bool) def create_seal_charm_filter( affixes: list[Affix], - rarity, require_gas: bool, model_type: type[CharmFilterModel | SealFilterModel] = SealFilterModel, unique_aspect: str | None = None, set_name: str | None = None, ) -> CharmFilterModel | SealFilterModel: - kwargs = {"rarities": [rarity] if rarity is not None else []} + kwargs = {} if affixes: kwargs["affix_pool"] = [ AffixFilterCountModel( count=[ AffixFilterModel(name=affix.name, want_greater=affix.type == AffixType.greater) for affix in affixes ], - min_count=min(3, len(affixes)), + min_count=1 if model_type is SealFilterModel else min(3, len(affixes)), ) ] if model_type is CharmFilterModel: @@ -309,7 +308,16 @@ def deduplicate_filters(filters: list[dict]) -> list[dict]: result: list[dict] = [] used_names: list[dict] = [] for base_name, model, count in groups: - key = f"{base_name}(x{count})" if count > 1 else _unique_filter_name(base_name, used_names) + if count > 1: + candidate = f"{base_name}(x{count})" + # Ensure uniqueness when multiple groups share the same count suffix + suffix = 2 + while any(candidate == next(iter(existing)) for existing in used_names): + candidate = f"{base_name}{suffix}(x{count})" + suffix += 1 + key = candidate + else: + key = _unique_filter_name(base_name, used_names) result.append({key: model}) used_names.append({key: model}) return result diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 2141db7c..49f30fd8 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -142,7 +142,6 @@ def import_maxroll(config: ImportConfig): seal_charm_filters.append({ filter_name: create_seal_charm_filter( affixes=seal_charm_affixes, - rarity=rarity, require_gas=config.require_greater_affixes, model_type=seal_charm_model, unique_aspect=charm_unique_aspect, diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index 0056b98d..82ab9c7a 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -271,7 +271,6 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): seal_charm_filters.append({ filter_name: create_seal_charm_filter( affixes=affixes, - rarity=None, require_gas=config.require_greater_affixes, model_type=seal_charm_model, unique_aspect=charm_unique_aspect, From fa63dcc561243dc335fe3d05379dfa86a747ca5b Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 14 Jun 2026 11:39:54 +0200 Subject: [PATCH 31/39] comment resolving --- src/config/profile_models.py | 1 - src/gui/importer/d4builds.py | 182 ++------------------------ src/gui/importer/gui_common.py | 93 ++++++------- src/gui/importer/maxroll.py | 8 +- src/gui/importer/mobalytics.py | 58 +++----- src/item/descr/read_descr_tts.py | 1 - src/item/filter.py | 4 +- tests/gui/importer/test_gui_common.py | 22 +++- 8 files changed, 93 insertions(+), 276 deletions(-) diff --git a/src/config/profile_models.py b/src/config/profile_models.py index 065ccd91..bcfa252f 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -239,7 +239,6 @@ def parse_rarities(cls, data: str | list[str]) -> list[str]: class CharmFilterModel(_BaseSealOrCharmFilterModel): - model_config = ConfigDict(extra="forbid", populate_by_name=True) set: list[str] = Field(default=[], alias="set") unique_aspect: list[AspectUniqueFilterModel] = Field(default=[], alias="uniqueAspect") diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index d3fc0bfa..812bef23 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -4,9 +4,6 @@ from typing import TYPE_CHECKING import lxml.html -import rapidfuzz -from selenium.common.exceptions import WebDriverException -from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support.wait import WebDriverWait @@ -23,7 +20,6 @@ ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( - _unique_filter_name, add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, @@ -144,6 +140,11 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): ) is_weapon = "weapon" in slot.lower() + combined_dict = Dataloader().affix_dict + if is_seal: + combined_dict = combined_dict | Dataloader().seal_affix_dict + elif is_charm: + combined_dict = combined_dict | Dataloader().charm_affix_dict for stat in stats: if stat.xpath(TEMPERING_ICON_XPATH) or stat.xpath(SANCTIFIED_ICON_XPATH): continue @@ -165,11 +166,6 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): substring in affix_name.lower() for substring in ["focus", "offhand", "shield", "totem"] ): # special line indicating the item type continue - combined_dict = Dataloader().affix_dict - if is_seal: - combined_dict = combined_dict | Dataloader().seal_affix_dict - elif is_charm: - combined_dict = combined_dict | Dataloader().charm_affix_dict affix_obj = Affix(name=closest_match(clean_str(_corrections(input_str=affix_name)), combined_dict)) if affix_obj.name is None: LOGGER.error(f"Couldn't match {affix_name=}") @@ -201,7 +197,6 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): if item_type in [ItemType.HoradricSeal, ItemType.Charm]: seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters - filter_name = _unique_filter_name(item_type.name, seal_charm_filters) seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel # Extract unique aspect and set info for charms charm_unique_aspect = None @@ -211,15 +206,15 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): charm_unique_aspect, charm_set_name = match_charm_to_set_or_unique(unique_name) if not affixes and not charm_unique_aspect and not charm_set_name: continue - seal_charm_filters.append({ - filter_name: create_seal_charm_filter( + seal_charm_filters.append( + create_seal_charm_filter( affixes=affixes, require_gas=config.require_greater_affixes, model_type=seal_charm_model, unique_aspect=charm_unique_aspect, set_name=charm_set_name, ) - }) + ) continue # We don't bother importing affixes for mythics @@ -236,154 +231,14 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): AffixFilterCountModel(count=[AffixFilterModel(name=x.name) for x in inherents]) ] item_filter.min_power = 100 - filter_name_template = item_filter.item_type[0].name if item_type else slot.replace(" ", "") - filter_name = _unique_filter_name(filter_name_template, finished_filters) - finished_filters.append({filter_name: item_filter}) + finished_filters.append(item_filter) # Place all mythics in a single filter - add_mythics_to_filters(mythic_names, finished_filters) - - # Detect set name from active charms to assist in seal affix matching - guessed_set_name = None - try: - active_charms_elements = driver.find_elements( - By.XPATH, "//*[contains(@class, 'builder__charm') and contains(@class, 'active')]" - ) - for charm_el in active_charms_elements: - try: - imgs = charm_el.find_elements(By.TAG_NAME, "img") - if imgs: - alt = imgs[0].get_attribute("alt") or "" - src = imgs[0].get_attribute("src") or "" - _, set_name = match_charm_to_set_or_unique(alt, src) - if set_name: - guessed_set_name = set_name - break - except WebDriverException: - LOGGER.debug("Failed to inspect an active charm while detecting the set name.", exc_info=True) - except WebDriverException: - LOGGER.debug("Failed to find active charms while detecting the set name.", exc_info=True) - - # Parse active seals from the builder__charms section - try: - active_seals_elements = driver.find_elements( - By.XPATH, "//*[contains(@class, 'builder__seal') and contains(@class, 'active')]" - ) - for idx, seal_el in enumerate(active_seals_elements): - affixes = [] - try: - ActionChains(driver).move_to_element(seal_el).perform() - time.sleep(0.4) - tooltip_html = driver.find_element(By.CLASS_NAME, "seal__tooltip").get_attribute("outerHTML") - tooltip_data = lxml.html.fromstring(tooltip_html) - raw_affixes = [ - v.text_content().strip() - for v in tooltip_data.xpath(".//*[contains(@class, 'seal__tooltip__value__text')]") - ] - combined_dict = Dataloader().affix_dict | Dataloader().seal_affix_dict - for affix_name in raw_affixes: - affix_clean = clean_str(_corrections(input_str=affix_name)) - name_matched = None - if guessed_set_name: - # First check if the affix is a generic affix with an exact or very close match - best_global_key = closest_match(affix_clean, combined_dict) - is_exact_generic = False - if best_global_key and best_global_key != "damage": - global_display = combined_dict[best_global_key] - if rapidfuzz.distance.Levenshtein.distance(affix_clean, global_display) <= 2: - # Ensure it's not a set-specific affix of another set - is_set_specific = False - for set_name in Dataloader().set_list: - if best_global_key.startswith(set_name + "_"): - is_set_specific = True - break - if not is_set_specific: - is_exact_generic = True - name_matched = best_global_key - - if not is_exact_generic: - set_keys = { - k: v - for k, v in Dataloader().seal_affix_dict.items() - if k.startswith(guessed_set_name + "_") - } - potential_match = closest_match(affix_clean, set_keys) - if potential_match: - display_name = Dataloader().seal_affix_dict[potential_match] - if rapidfuzz.fuzz.token_set_ratio(affix_clean, display_name) >= 50: - name_matched = potential_match - if not name_matched: - name_matched = closest_match(affix_clean, combined_dict) - - if name_matched: - affixes.append(Affix(name=name_matched)) - else: - LOGGER.warning(f"Couldn't match seal affix: {affix_name}") - except Exception as e: # noqa: BLE001 - LOGGER.warning(f"Failed to hover/extract affixes for seal {idx}: {e}") - - seal_filters.append({ - _unique_filter_name("HoradricSeal", seal_filters): create_seal_charm_filter( - affixes=affixes, require_gas=False, model_type=SealFilterModel - ) - }) - except Exception: - LOGGER.exception("Failed to parse active seals") - - # Parse active charms from the builder__charms section - try: - active_charms_elements = driver.find_elements( - By.XPATH, "//*[contains(@class, 'builder__charm') and contains(@class, 'active')]" - ) - for idx, charm_el in enumerate(active_charms_elements): - alt = "" - src = "" - try: - imgs = charm_el.find_elements(By.TAG_NAME, "img") - if imgs: - alt = imgs[0].get_attribute("alt") or "" - src = imgs[0].get_attribute("src") or "" - except Exception as e: # noqa: BLE001 - LOGGER.warning(f"Failed to get img details for charm {idx}: {e}") - - affixes = [] - try: - ActionChains(driver).move_to_element(charm_el).perform() - time.sleep(0.4) - tooltip_html = driver.find_element(By.CLASS_NAME, "charm__tooltip").get_attribute("outerHTML") - tooltip_data = lxml.html.fromstring(tooltip_html) - raw_affixes = [ - v.text_content().strip() - for v in tooltip_data.xpath( - ".//*[contains(@class, 'charm__tooltip__value') and not(contains(@class, 'values'))]" - ) - ] - combined_dict = Dataloader().affix_dict | Dataloader().charm_affix_dict - for affix_name in raw_affixes: - name_matched = closest_match(clean_str(_corrections(input_str=affix_name)), combined_dict) - if name_matched: - affixes.append(Affix(name=name_matched)) - else: - LOGGER.warning(f"Couldn't match charm affix: {affix_name}") - except Exception as e: # noqa: BLE001 - LOGGER.warning(f"Failed to hover/extract affixes for charm {idx}: {e}") - - charm_unique_aspect, charm_set_name = match_charm_to_set_or_unique(alt, src) - if affixes or charm_unique_aspect or charm_set_name: - charm_filters.append({ - _unique_filter_name("Charm", charm_filters): create_seal_charm_filter( - affixes=affixes, - require_gas=False, - model_type=CharmFilterModel, - unique_aspect=charm_unique_aspect, - set_name=charm_set_name, - ) - }) - except Exception: - LOGGER.exception("Failed to parse active charms") + affix_filters = deduplicate_filters(finished_filters) + add_mythics_to_filters(mythic_names, affix_filters) profile = ProfileModel( name="imported profile", - Affixes=sort_profile_filters(finished_filters), + Affixes=sort_profile_filters(affix_filters), Charms=sort_profile_filters(deduplicate_filters(charm_filters)), Seals=sort_profile_filters(deduplicate_filters(seal_filters)), ) @@ -433,19 +288,6 @@ def _extract_build_metadata(data: lxml.html.HtmlElement) -> tuple[str, str, str, text = " ".join(header_nodes[0].text_content().split()).strip() if text: class_name = get_class_name(text) - if class_name == "Unknown": - for selector in [ - "//*[contains(@class, 'builder__header__icon')]", - "//*[contains(@class, 'builder__header__description')]", - "//title", - ]: - for node in data.xpath(selector): - text = node.text_content() or node.get("class", "") or node.get("alt", "") - if (x := get_class_name(text)) != "Unknown": - class_name = x - break - if class_name != "Unknown": - break build_header = "" if description_nodes := data.xpath(BUILD_DESCRIPTION_XPATH): diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py index 6d86e329..17130ef2 100644 --- a/src/gui/importer/gui_common.py +++ b/src/gui/importer/gui_common.py @@ -206,9 +206,9 @@ def create_seal_charm_filter( unique_aspect: str | None = None, set_name: str | None = None, ) -> CharmFilterModel | SealFilterModel: - kwargs = {} + affix_pool = [] if affixes: - kwargs["affix_pool"] = [ + affix_pool = [ AffixFilterCountModel( count=[ AffixFilterModel(name=affix.name, want_greater=affix.type == AffixType.greater) for affix in affixes @@ -217,17 +217,19 @@ def create_seal_charm_filter( ) ] if model_type is CharmFilterModel: - if unique_aspect: - kwargs["unique_aspect"] = [AspectUniqueFilterModel(name=unique_aspect)] - if set_name: - kwargs["set"] = [set_name] - seal_charm_filter = model_type(**kwargs) + seal_charm_filter = CharmFilterModel( + affix_pool=affix_pool, + unique_aspect=[AspectUniqueFilterModel(name=unique_aspect)] if unique_aspect else [], + set=[set_name] if set_name else [], + ) + else: + seal_charm_filter = SealFilterModel(affix_pool=affix_pool) if require_gas: seal_charm_filter.min_greater_affix_count = len([affix for affix in affixes if affix.type == AffixType.greater]) return seal_charm_filter -def match_charm_to_set_or_unique(charm_name: str, icon_url: str | None = None) -> tuple[str | None, str | None]: +def match_charm_to_set_or_unique(charm_name: str) -> tuple[str | None, str | None]: if not charm_name: return None, None @@ -239,34 +241,20 @@ def match_charm_to_set_or_unique(charm_name: str, icon_url: str | None = None) - if not name_clean: return None, None + dataloader = Dataloader() + # Check if the name matches a known unique charm (like "endurant_faith") - if name_clean in Dataloader().aspect_unique_dict: + if name_clean in dataloader.aspect_unique_dict: return name_clean, None - # 2. Try to match set name from the icon_url first - if icon_url: - icon_slug = icon_url.rsplit("/", maxsplit=1)[-1].split(".", maxsplit=1)[0].replace("-", "_") - if icon_slug in Dataloader().set_list: - return None, icon_slug - - # 3. Try to match the set name from the clean name - for set_name in Dataloader().set_list: - set_clean = set_name.replace("_", " ") - name_spaces = name_clean.replace("_", " ") - if set_name in name_clean or set_clean in name_spaces: - return None, set_name + # 2. Try to match the set name from the clean name + if name_clean in dataloader.set_list: + return None, name_clean - set_words = set_name.split("_") - name_words = name_clean.split("_") - # Find if any word (length > 3) matches - for sw in set_words: - if len(sw) > 3 and sw not in ["of", "the", "way", "will", "bite", "flow", "call"] and sw in name_words: - return None, set_name + return None, None - return name_clean, None - -def _unique_filter_name(filter_name_template: str, filters: list[dict]) -> str: +def unique_filter_name(filter_name_template: str, filters: list[dict]) -> str: filter_name = filter_name_template i = 2 while any(filter_name == next(iter(existing_filter)) for existing_filter in filters): @@ -275,38 +263,35 @@ def _unique_filter_name(filter_name_template: str, filters: list[dict]) -> str: return filter_name -def deduplicate_filters(filters: list[dict]) -> list[dict]: - """Merge identical charm/seal filters, naming duplicates with an (xN) count suffix. +def deduplicate_filters( + filters: list[ItemFilterModel | CharmFilterModel | SealFilterModel], +) -> list[dict[str, ItemFilterModel | CharmFilterModel | SealFilterModel]]: + """Merge identical filters, naming duplicates with an (xN) count suffix. - Filters are compared by their Pydantic model data (all fields except the dict key/name). - Identical filters are collapsed into a single entry. When N > 1, the key is rewritten - as ``BaseType(xN)`` (e.g. ``Charm(x3)``); single-occurrence filters keep their original - key unchanged. + Filters are compared by their Pydantic model data. + Identical filters are collapsed into a single entry. When N > 1, the key is rewritten as ``BaseType(xN)`` + (e.g. ``Charm(x3)``); single-occurrence filters keep their original key unchanged. """ if not filters: - return filters - - # Build groups keyed by a hashable representation of the filter model - groups: list[tuple[str, object, int]] = [] # (base_name, filter_model, count) - for entry in filters: - name, model = next(iter(entry.items())) - model_data = model.model_dump() if hasattr(model, "model_dump") else model - # Check if this model already exists in groups + return [] + + groups: list[tuple[str, ItemFilterModel | CharmFilterModel | SealFilterModel, int]] = [] + for filter_spec in filters: merged = False for idx, (base_name, existing_model, count) in enumerate(groups): - existing_data = existing_model.model_dump() if hasattr(existing_model, "model_dump") else existing_model - if model_data == existing_data: + if filter_spec == existing_model: groups[idx] = (base_name, existing_model, count + 1) merged = True break if not merged: - # Strip trailing digits to get the base type name (e.g. "Charm3" -> "Charm") - base = re.sub(r"\d+$", "", name) - groups.append((base, model, 1)) - - # Rebuild the list with deduplicated names - result: list[dict] = [] - used_names: list[dict] = [] + if isinstance(filter_spec, ItemFilterModel): + base_name = filter_spec.item_type[0].name if filter_spec.item_type else "Item" + else: + base_name = "Charm" if isinstance(filter_spec, CharmFilterModel) else "HoradricSeal" + groups.append((base_name, filter_spec, 1)) + + result: list[dict[str, ItemFilterModel | CharmFilterModel | SealFilterModel]] = [] + used_names: list[dict[str, ItemFilterModel | CharmFilterModel | SealFilterModel]] = [] for base_name, model, count in groups: if count > 1: candidate = f"{base_name}(x{count})" @@ -317,7 +302,7 @@ def deduplicate_filters(filters: list[dict]) -> list[dict]: suffix += 1 key = candidate else: - key = _unique_filter_name(base_name, used_names) + key = unique_filter_name(base_name, used_names) result.append({key: model}) used_names.append({key: model}) return result diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 49f30fd8..82afc839 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -16,7 +16,6 @@ ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( - _unique_filter_name, add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, @@ -137,17 +136,16 @@ def import_maxroll(config: ImportConfig): ) continue seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters - filter_name = _unique_filter_name(item_type.name, seal_charm_filters) seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel - seal_charm_filters.append({ - filter_name: create_seal_charm_filter( + seal_charm_filters.append( + create_seal_charm_filter( affixes=seal_charm_affixes, require_gas=config.require_greater_affixes, model_type=seal_charm_model, unique_aspect=charm_unique_aspect, set_name=charm_set_name, ) - }) + ) continue item_filter.item_type = [item_type] diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index 82ab9c7a..8e9552f2 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -24,7 +24,6 @@ ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( - _unique_filter_name, add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, @@ -37,6 +36,7 @@ retry_importer, save_as_profile, sort_profile_filters, + unique_filter_name, update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig @@ -135,28 +135,13 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): LOGGER.error(msg := "No items found") raise MobalyticsError(msg) - # Detect set name from charms to assist in seal affix matching - guessed_set_name = None - for item in items: - entity_type = jsonpath.findall(".gameEntity.type", item)[0] - if entity_type == "charms": - title_result = jsonpath.findall(".gameEntity.entity.title", item) or jsonpath.findall( - ".gameEntity.title", item - ) - item_name = str(title_result[0]) if title_result else "" - icon_result = jsonpath.findall(".gameEntity.iconUrl", item) - icon_url = icon_result[0] if icon_result else None - _, set_name = match_charm_to_set_or_unique(item_name, icon_url) - if set_name: - guessed_set_name = set_name - break - finished_filters = [] charm_filters = [] seal_filters = [] mythic_names = [] aspect_upgrade_filters = [] - for item in items: + guessed_set_name = None + for item in sorted(items, key=lambda item: jsonpath.findall(".gameEntity.type", item)[0] != "charms"): item_filter = ItemFilterModel() entity_type = jsonpath.findall(".gameEntity.type", item)[0] mythic_result = jsonpath.findall(".gameEntity.entity.mythic", item) @@ -164,17 +149,16 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): if entity_type not in ["aspects", "uniqueItems", "charms", "seals", "items"]: continue title_result = jsonpath.findall(".gameEntity.entity.title", item) or jsonpath.findall(".gameEntity.title", item) - if not title_result: + item_name = str(title_result[0]).strip() if title_result else "" + if not item_name: slot_result = jsonpath.findall(".gameSlotSlug", item) LOGGER.warning( f"Skipping {slot_result[0] if slot_result else '(unknown slot)'} ({entity_type}) because it has no title." ) continue - if not (item_name := str(title_result[0])): - LOGGER.error(msg := "No item name found") - raise MobalyticsError(msg) - if not (slot_type := str(jsonpath.findall(".gameSlotSlug", item)[0])): - LOGGER.error(msg := "No slot type found") + slot_result = jsonpath.findall(".gameSlotSlug", item) + if not slot_result or not (slot_type := str(slot_result[0]).strip()): + LOGGER.error(msg := f"No slot type found for {item_name}") raise MobalyticsError(msg) raw_affixes = ( @@ -256,27 +240,25 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): if item_type in [ItemType.HoradricSeal, ItemType.Charm]: seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters - filter_name = _unique_filter_name(item_type.name, seal_charm_filters) seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel # Extract unique aspect and set info for charms charm_unique_aspect = None charm_set_name = None if item_type == ItemType.Charm: - icon_result = jsonpath.findall(".gameEntity.iconUrl", item) - icon_url = icon_result[0] if icon_result else None - charm_unique_aspect, charm_set_name = match_charm_to_set_or_unique(item_name, icon_url) + charm_unique_aspect, charm_set_name = match_charm_to_set_or_unique(item_name) if not affixes and not charm_unique_aspect and not charm_set_name: LOGGER.warning(f"Skipping {item_name} because it had no supported affixes, unique aspect, or set name.") continue - seal_charm_filters.append({ - filter_name: create_seal_charm_filter( - affixes=affixes, - require_gas=config.require_greater_affixes, - model_type=seal_charm_model, - unique_aspect=charm_unique_aspect, - set_name=charm_set_name, - ) - }) + seal_charm_filter = create_seal_charm_filter( + affixes=affixes, + require_gas=config.require_greater_affixes, + model_type=seal_charm_model, + unique_aspect=charm_unique_aspect, + set_name=charm_set_name, + ) + seal_charm_filters.append(seal_charm_filter) + if isinstance(seal_charm_filter, CharmFilterModel) and not guessed_set_name and seal_charm_filter.set: + guessed_set_name = seal_charm_filter.set[0] continue if not is_mythic: @@ -293,7 +275,7 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): AffixFilterCountModel(count=[AffixFilterModel(name=x.name) for x in inherents]) ] filter_name_template = item_filter.item_type[0].name if item_type else slot_type.replace(" ", "") - filter_name = _unique_filter_name(filter_name_template, finished_filters) + filter_name = unique_filter_name(filter_name_template, finished_filters) finished_filters.append({filter_name: item_filter}) # Place all mythics in a single filter diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index cb3cc260..55cb674c 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -53,7 +53,6 @@ _FOR_SECONDS_RE = re.compile(r"for (?P\d+(?:\.\d+)?) Seconds") _REPLACE_COMPARE_RE = re.compile(r"\(.*\)") -_GET_FIRST_NUMBER_RE = re.compile(r"\d+") _AFFIX_REPLACEMENTS = ["%", "+", ",", "[+]", "[x]", "per 5 Seconds"] _AFFIX_STOP_MARKERS = ( diff --git a/src/item/filter.py b/src/item/filter.py index c4bc4458..d980db16 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -290,9 +290,7 @@ def _check_seal_charm_filters( if not self._check_unique_aspects_for_item(seal_or_charm, filter_spec.unique_aspect): continue matched_aspect = True - elif ( - seal_or_charm.rarity in [ItemRarity.Unique, ItemRarity.Mythic] and not filter_spec.unique_aspect - ): + elif seal_or_charm.rarity in [ItemRarity.Unique, ItemRarity.Mythic]: continue elif filter_spec.set: if not seal_or_charm.set: # This would mean there's no set but a set is expected diff --git a/tests/gui/importer/test_gui_common.py b/tests/gui/importer/test_gui_common.py index e47e8de4..ce342963 100644 --- a/tests/gui/importer/test_gui_common.py +++ b/tests/gui/importer/test_gui_common.py @@ -1,10 +1,11 @@ -from src.config.profile_models import CharmFilterModel, ProfileModel +from src.config.profile_models import CharmFilterModel, ItemFilterModel, ProfileModel from src.gui.importer.gui_common import ( _to_yaml_str, - _unique_filter_name, build_default_profile_file_name, deduplicate_filters, + unique_filter_name, ) +from src.item.data.item_type import ItemType def test_build_default_profile_file_name_maxroll() -> None: @@ -60,7 +61,7 @@ def test_build_default_profile_file_name_replaces_stale_season_marker_in_header( def test_unique_filter_name_adds_suffix_for_existing_filter_names() -> None: - filter_name = _unique_filter_name("Charm", [{"Charm": object()}, {"Charm2": object()}]) + filter_name = unique_filter_name("Charm", [{"Charm": object()}, {"Charm2": object()}]) assert filter_name == "Charm3" @@ -79,10 +80,23 @@ def test_deduplicate_filters() -> None: f2 = CharmFilterModel(set=["tal_rashas_threefold_way"]) f3 = CharmFilterModel(set=["applied_alchemy"]) - filters = [{"Charm": f1}, {"Charm": f2}, {"Charm": f3}] + filters = [f1, f2, f3] deduped = deduplicate_filters(filters) assert len(deduped) == 2 assert "Charm(x2)" in deduped[0] assert deduped[0]["Charm(x2)"] == f1 assert "Charm" in deduped[1] or "Charm2" in deduped[1] + + +def test_deduplicate_filters_supports_item_filters() -> None: + f1 = ItemFilterModel(item_type=[ItemType.Ring]) + f2 = ItemFilterModel(item_type=[ItemType.Ring]) + f3 = ItemFilterModel(item_type=[ItemType.Amulet]) + + deduped = deduplicate_filters([f1, f2, f3]) + + assert len(deduped) == 2 + assert "Ring(x2)" in deduped[0] + assert deduped[0]["Ring(x2)"] == f1 + assert "Amulet" in deduped[1] From 9a2ca3c22aee400fc922bfd238d04a820b88738d Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 14 Jun 2026 15:57:58 +0200 Subject: [PATCH 32/39] charm set name update charm set name was missing --- src/gui/importer/gui_common.py | 6 ++++-- tests/gui/importer/test_gui_common.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py index 17130ef2..65db256b 100644 --- a/src/gui/importer/gui_common.py +++ b/src/gui/importer/gui_common.py @@ -36,6 +36,7 @@ from selenium.webdriver.chromium.webdriver import ChromiumDriver + LOGGER = logging.getLogger(__name__) D = TypeVar("D", bound=WebDriver | WebElement) @@ -248,8 +249,9 @@ def match_charm_to_set_or_unique(charm_name: str) -> tuple[str | None, str | Non return name_clean, None # 2. Try to match the set name from the clean name - if name_clean in dataloader.set_list: - return None, name_clean + for set_name in sorted(dataloader.set_list, key=len, reverse=True): + if set_name in name_clean: + return None, set_name return None, None diff --git a/tests/gui/importer/test_gui_common.py b/tests/gui/importer/test_gui_common.py index ce342963..b31f1fb0 100644 --- a/tests/gui/importer/test_gui_common.py +++ b/tests/gui/importer/test_gui_common.py @@ -3,6 +3,7 @@ _to_yaml_str, build_default_profile_file_name, deduplicate_filters, + match_charm_to_set_or_unique, unique_filter_name, ) from src.item.data.item_type import ItemType @@ -100,3 +101,13 @@ def test_deduplicate_filters_supports_item_filters() -> None: assert "Ring(x2)" in deduped[0] assert deduped[0]["Ring(x2)"] == f1 assert "Amulet" in deduped[1] + + +def test_match_charm_to_set_or_unique() -> None: + unique, set_name = match_charm_to_set_or_unique("Berú of Balazan's Bite") + assert unique is None + assert set_name == "balazans_bite" + + unique, set_name = match_charm_to_set_or_unique("Protean Heart") + assert unique == "protean_heart" + assert set_name is None From 9be3b3d1123c6303c259c91dd4389334379aa098 Mon Sep 17 00:00:00 2001 From: cjshrader Date: Sun, 14 Jun 2026 18:11:03 -0400 Subject: [PATCH 33/39] Reworked Maxroll imports to fix missing attributes and better handling of seal affixes. Added unique aspect to seals but we are missing mythic data. --- src/config/profile_models.py | 14 +++-- src/gui/importer/gui_common.py | 12 ++-- src/gui/importer/maxroll.py | 108 +++++++++++++++++++++------------ src/item/filter.py | 23 ++++--- 4 files changed, 94 insertions(+), 63 deletions(-) diff --git a/src/config/profile_models.py b/src/config/profile_models.py index bcfa252f..0d1c39e0 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -226,6 +226,7 @@ class _BaseSealOrCharmFilterModel(BaseModel): affix_pool: list[AffixFilterCountModel] = Field(default=[], alias="affixPool") min_greater_affix_count: int = Field(default=0, alias="minGreaterAffixCount") rarities: list[ItemRarity] = [] + unique_aspect: list[AspectUniqueFilterModel] = Field(default=[], alias="uniqueAspect") @field_validator("min_greater_affix_count") @classmethod @@ -237,10 +238,17 @@ def min_greater_affix_in_range(cls, v: int) -> int: def parse_rarities(cls, data: str | list[str]) -> list[str]: return _parse_item_type_or_rarities(data) + @model_validator(mode="after") + def unique_aspects_must_be_unique(self) -> _BaseSealOrCharmFilterModel: + if len({aspect.name for aspect in self.unique_aspect}) != len(self.unique_aspect): + msg = "uniqueAspect names must be unique" + raise ValueError(msg) + + return self + class CharmFilterModel(_BaseSealOrCharmFilterModel): set: list[str] = Field(default=[], alias="set") - unique_aspect: list[AspectUniqueFilterModel] = Field(default=[], alias="uniqueAspect") @field_validator("set") @classmethod @@ -249,10 +257,6 @@ def set_must_exist(cls, sets: list[str]) -> list[str]: @model_validator(mode="after") def set_and_unique_aspects_must_be_unique(self) -> CharmFilterModel: - if len({aspect.name for aspect in self.unique_aspect}) != len(self.unique_aspect): - msg = "uniqueAspect names must be unique" - raise ValueError(msg) - if len(set(self.set)) != len(self.set): msg = "set names must be unique" raise ValueError(msg) diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py index 65db256b..817f6cdc 100644 --- a/src/gui/importer/gui_common.py +++ b/src/gui/importer/gui_common.py @@ -214,17 +214,15 @@ def create_seal_charm_filter( count=[ AffixFilterModel(name=affix.name, want_greater=affix.type == AffixType.greater) for affix in affixes ], - min_count=1 if model_type is SealFilterModel else min(3, len(affixes)), + minCount=1 if model_type is SealFilterModel else min(3, len(affixes)), ) ] if model_type is CharmFilterModel: - seal_charm_filter = CharmFilterModel( - affix_pool=affix_pool, - unique_aspect=[AspectUniqueFilterModel(name=unique_aspect)] if unique_aspect else [], - set=[set_name] if set_name else [], - ) + seal_charm_filter = CharmFilterModel(set=[set_name] if set_name else []) else: - seal_charm_filter = SealFilterModel(affix_pool=affix_pool) + seal_charm_filter = SealFilterModel() + seal_charm_filter.affix_pool = affix_pool + seal_charm_filter.unique_aspect = [AspectUniqueFilterModel(name=unique_aspect)] if unique_aspect else [] if require_gas: seal_charm_filter.min_greater_affix_count = len([affix for affix in affixes if affix.type == AffixType.greater]) return seal_charm_filter diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 82afc839..4791d514 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -24,7 +24,6 @@ fix_offhand_type, fix_weapon_type, get_with_retry, - match_charm_to_set_or_unique, match_to_enum, retry_importer, save_as_profile, @@ -43,7 +42,7 @@ LOGGER.propagate = True BUILD_GUIDE_BASE_URL = "https://maxroll.gg/d4/build-guides/" PLANNER_API_BASE_URL = "https://planners.maxroll.gg/profiles/d4/" -PLANNER_API_DATA_URL = "https://assets-ng.maxroll.gg/d4-tools/game/data.min.json?376b600d" +PLANNER_API_DATA_URL = "https://assets-ng.maxroll.gg/d4-tools/game/data.min.json?95bc2915" PLANNER_BASE_URL = "https://maxroll.gg/d4/planner/" SCRIPT_XPATH = "//div[@id='root']/script" BUILD_SCRIPT_PREFIX = "window.__remixContext = " @@ -115,6 +114,8 @@ def import_maxroll(config: ImportConfig): ) continue + # TODO I don't think this code needs to be siloed, I think it can be mostly part of the normal flow so we're not repeating work. It'd just require some refactoring + # TODO I'd make that change after the profile editor rework is done and we're importing individual mythics again if item_type in [ItemType.HoradricSeal, ItemType.Charm]: seal_charm_affixes = _find_item_affixes( mapping_data=mapping_data, @@ -123,14 +124,16 @@ def import_maxroll(config: ImportConfig): import_greater_affixes=config.import_greater_affixes, ) # Extract unique aspect and set info for charms - charm_unique_aspect = None + charm_or_seal_unique_aspect = None charm_set_name = None - if item_type == ItemType.Charm and resolved_item_id in mapping_data["items"]: - item_data = mapping_data["items"][resolved_item_id] - charm_unique_aspect, charm_set_name = match_charm_to_set_or_unique(item_data.get("name")) - if item_data.get("magicType") == 3 and "set" in item_data and not charm_set_name: - charm_set_name = correct_name(item_data["set"]) - if not seal_charm_affixes and not charm_unique_aspect and not charm_set_name: + if rarity in [ItemRarity.Unique, ItemRarity.Mythic]: + charm_or_seal_unique_aspect = correct_name( + _unique_name_special_handling(mapping_data["items"][resolved_item_id]["name"]) + ) + elif rarity == ItemRarity.Set: + set_key = mapping_data["items"][resolved_item_id]["set"] + charm_set_name = correct_name(mapping_data["itemSets"][set_key]["name"]) + if not seal_charm_affixes and not charm_or_seal_unique_aspect and not charm_set_name: LOGGER.warning( f"Skipping {resolved_item.get('name', '(could not determine item name)')} because it had no supported affixes, unique aspect, or set name." ) @@ -142,7 +145,7 @@ def import_maxroll(config: ImportConfig): affixes=seal_charm_affixes, require_gas=config.require_greater_affixes, model_type=seal_charm_model, - unique_aspect=charm_unique_aspect, + unique_aspect=charm_or_seal_unique_aspect, set_name=charm_set_name, ) ) @@ -190,7 +193,7 @@ def import_maxroll(config: ImportConfig): import_greater_affixes=config.import_greater_affixes, ) ], - min_count=1 if rarity == ItemRarity.Unique else 3, + minCount=1 if rarity == ItemRarity.Unique else 3, ) ] update_mingreateraffixcount(item_filter, config.require_greater_affixes) @@ -286,11 +289,22 @@ def _find_item_affixes( "GearAffix_DamageType_Greater", "GearAffix_Resource_On_Kill", "GearAffix_Resource_On_Kill_Warlock", + "GearAffix_Resistance_Single", ]: if affix["attributes"][0]["formula"] in ["GearAffix_DamageType", "GearAffix_DamageType_Greater"]: + param = str(affix["attributes"][0]["param"]) + if param in mapping_data["uiStrings"]["damageType"]: + attr_desc = mapping_data["uiStrings"]["damageType"][param] + " Damage Multiplier" + elif "desc" in affix: + # These are seal affixes and we have to get the skill from the description + pattern = r"\{c_important\}([^{}]+)\{/c\}\s*(.+)$" + match = re.search(pattern, affix["desc"]) + if match: + attr_desc = f"{match.group(1)} {match.group(2)}" + elif affix["attributes"][0]["formula"] == "GearAffix_Resistance_Single": attr_desc = ( mapping_data["uiStrings"]["damageType"][str(affix["attributes"][0]["param"])] - + " Damage Multiplier" + + " Resistance" ) elif affix["attributes"][0]["formula"] == "GearAffix_Resource_Per_Second": param = str(affix["attributes"][0]["param"]) @@ -323,8 +337,22 @@ def _find_item_affixes( attr_desc = _find_skill_rank_affix_description( mapping_data=mapping_data, affix_key=affix_key, attribute=affix["attributes"][0] ) - if not attr_desc and affix.get("desc"): - attr_desc = affix["desc"] + + # Below is handling for seal affixes tied to a set. We attach the set to the front. + # If this ends up not working for some reason, a second option is to take the key + # like "Talisman_SealAffix_Set_Barbarian_05_AncientSkillRankBonus" and convert it to + # "Talisman_Barbarian_05" and then find that in the mapping data. That will also give set name. + if "Talisman" and "Set" in affix_key: + pattern = r"\{c_set\}([^{}]+)\{/c\}" + match = re.search(pattern, affix["desc"]) if "desc" in affix else None + if match: + attr_desc = match.group(1) + " " + attr_desc + else: + LOGGER.warning( + f"We thought affix {attr_desc} was a seal-based affix activated by a set but we could not determine the set. The affix is skipped, please report a bug with a link to the build." + ) + continue + clean_desc = re.sub(r"\[.*?\]|[^a-zA-Z ]", "", attr_desc) clean_desc = clean_desc.replace("SecondSeconds", "seconds") if not clean_desc: @@ -333,12 +361,12 @@ def _find_item_affixes( ) continue - combined_dict = Dataloader().affix_dict + dict_to_use = Dataloader().affix_dict if item_type == ItemType.HoradricSeal: - combined_dict = combined_dict | Dataloader().seal_affix_dict + dict_to_use = Dataloader().seal_affix_dict elif item_type == ItemType.Charm: - combined_dict = combined_dict | Dataloader().charm_affix_dict - affix_obj = Affix(name=closest_match(clean_str(clean_desc), combined_dict)) + dict_to_use = Dataloader().charm_affix_dict + affix_obj = Affix(name=closest_match(clean_str(clean_desc), dict_to_use)) if import_greater_affixes and affix_id.get("greater", False): affix_obj.type = AffixType.greater if affix_obj.name is not None: @@ -413,26 +441,28 @@ def _find_legendary_aspect(mapping_data: dict, legendary_aspect: dict) -> str | def _attr_desc_special_handling(affix_id: str) -> str: match affix_id: - case 1014505 | 2051010: - return "evade grants movement speed for second" - case 2568489: - return "hunger increased reputation from kill streaks" - case 2568491: - return "hunger increased experience from kill streaks" - case 2057810: - return "damage reduction from bleeding enemies" - case 2067844: - return "maximum poison resistance" - case 2037914: - return "subterfuge cooldown reduction" - case 2123788: - return "chance for core skills to hit twice" - case 2119054: - return "chance for basic skills to deal double damage" - case 2119058: - return "basic lucky hit chance" - case 2052125: - return "non-physical damage" + case 2609197: + return "charm slot" + # case 1014505 | 2051010: + # return "evade grants movement speed for second" + # case 2568489: + # return "hunger increased reputation from kill streaks" + # case 2568491: + # return "hunger increased experience from kill streaks" + # case 2057810: + # return "damage reduction from bleeding enemies" + # case 2067844: + # return "maximum poison resistance" + # case 2037914: + # return "subterfuge cooldown reduction" + # case 2123788: + # return "chance for core skills to hit twice" + # case 2119054: + # return "chance for basic skills to deal double damage" + # case 2119058: + # return "basic lucky hit chance" + # case 2052125: + # return "non-physical damage" case _: return "" @@ -550,7 +580,7 @@ def _extract_active_guide_embed_tab_index(embed: lxml.html.HtmlElement) -> int | if __name__ == "__main__": src.logger.setup() - URLS = ["https://maxroll.gg/d4/planner/n51lwl0u#1"] + URLS = ["https://maxroll.gg/d4/planner/n51lwl0u#4"] for X in URLS: config = ImportConfig( url=X, diff --git a/src/item/filter.py b/src/item/filter.py index d980db16..d58362b5 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -284,20 +284,19 @@ def _check_seal_charm_filters( # For charms we check the set or aspect matched_aspect = False matched_set = False - if isinstance(filter_spec, CharmFilterModel): + + if not self._check_unique_aspects_for_item(seal_or_charm, filter_spec.unique_aspect): + continue + if filter_spec.unique_aspect: + matched_aspect = True + + if isinstance(filter_spec, CharmFilterModel) and filter_spec.set: # You can't have both a unique aspect and a set - if filter_spec.unique_aspect: - if not self._check_unique_aspects_for_item(seal_or_charm, filter_spec.unique_aspect): - continue - matched_aspect = True - elif seal_or_charm.rarity in [ItemRarity.Unique, ItemRarity.Mythic]: + if not seal_or_charm.set: # This would mean there's no set but a set is expected + continue + if seal_or_charm.set not in filter_spec.set: continue - elif filter_spec.set: - if not seal_or_charm.set: # This would mean there's no set but a set is expected - continue - if seal_or_charm.set not in filter_spec.set: - continue - matched_set = True + matched_set = True LOGGER.info( f"{seal_or_charm.original_name} -- Matched {profile_name}.{section_name}.{filter_name}: {[affix.name for affix in matched_affixes]}" From 66fc742f85e6af7077024976455128f1e4f50202 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Tue, 16 Jun 2026 21:26:52 +0200 Subject: [PATCH 34/39] mythic seals update adding mythic seals to uniques.py via d4data validation via: uv run python -m src.tools.gen_data d4data --- assets/lang/enUS/uniques.json | 9 +++++++++ src/tools/gen_data.py | 14 +++++++++----- tests/gui/importer/test_gui_common.py | 4 ++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/assets/lang/enUS/uniques.json b/assets/lang/enUS/uniques.json index c6e61f45..3dd06400 100644 --- a/assets/lang/enUS/uniques.json +++ b/assets/lang/enUS/uniques.json @@ -641,12 +641,21 @@ "sea_lords_fine_gloves": { "num_inherents": 0 }, + "seal_of_the_diamond_mind": { + "num_inherents": 0 + }, + "seal_of_the_golden_epiphany": { + "num_inherents": 0 + }, "seal_of_the_ophanim": { "num_inherents": 0 }, "seal_of_the_second_trumpet": { "num_inherents": 0 }, + "seal_of_the_severed_finger": { + "num_inherents": 0 + }, "seed_of_horazon": { "num_inherents": 0 }, diff --git a/src/tools/gen_data.py b/src/tools/gen_data.py index 43d8c107..e55510dc 100644 --- a/src/tools/gen_data.py +++ b/src/tools/gen_data.py @@ -722,7 +722,7 @@ def string_list_value(data, label): def generate_uniques(d4data_dir, language): - items_to_ignore = ["halo", "pact_amulet", "wilted_potential"] + items_to_ignore = ["halo", "pact_amulet", "wilted_potential", "mythic_unique_horadric_seal"] print(f"Gen Uniques for {language}") unique_dict = {} @@ -737,12 +737,16 @@ def generate_uniques(d4data_dir, language): num_inherents = 0 with Path(core_unique_file).open(encoding="utf-8") as unique_item_file: unique_item_data = json.load(unique_item_file) - if "arForcedAffixes" not in unique_item_data or not unique_item_data["arForcedAffixes"]: + item_type = ( + unique_item_data.get("snoItemType", {}).get("name", "") if unique_item_data.get("snoItemType") else "" + ) + if item_type != "HoradricSeal" and ( + "arForcedAffixes" not in unique_item_data or not unique_item_data["arForcedAffixes"] + ): continue - item_type = unique_item_data["snoItemType"]["name"] - inherent_affixes = unique_item_data["arInherentAffixes"] + inherent_affixes = unique_item_data.get("arInherentAffixes", []) - if item_type not in GEAR_TYPES and item_type != "FocusBookOffHand": + if item_type not in GEAR_TYPES and item_type not in ("FocusBookOffHand", "HoradricSeal"): continue # Some items, like Mortacrux, will list one inherent and then break it into two in the affix file. diff --git a/tests/gui/importer/test_gui_common.py b/tests/gui/importer/test_gui_common.py index 39a9cdeb..f9f848f1 100644 --- a/tests/gui/importer/test_gui_common.py +++ b/tests/gui/importer/test_gui_common.py @@ -111,8 +111,8 @@ def test_match_charm_to_set_or_unique() -> None: unique, set_name = match_charm_to_set_or_unique("Protean Heart") assert unique == "protean_heart" assert set_name is None - - + + def test_to_yaml_str_preserves_paragon_aliases(mock_ini_loader) -> None: profile = ProfileModel( name="test", From a4c6f9ec837a9b490a9f353c54244bea50367465 Mon Sep 17 00:00:00 2001 From: cjshrader Date: Tue, 16 Jun 2026 20:51:51 -0400 Subject: [PATCH 35/39] Small fixes based on comments --- src/gui/importer/maxroll.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 4791d514..55832901 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -342,7 +342,7 @@ def _find_item_affixes( # If this ends up not working for some reason, a second option is to take the key # like "Talisman_SealAffix_Set_Barbarian_05_AncientSkillRankBonus" and convert it to # "Talisman_Barbarian_05" and then find that in the mapping data. That will also give set name. - if "Talisman" and "Set" in affix_key: + if "Talisman" in affix_key and "Set" in affix_key: pattern = r"\{c_set\}([^{}]+)\{/c\}" match = re.search(pattern, affix["desc"]) if "desc" in affix else None if match: From a1383be0c9c8234dd24c28acd0d49e5a10ed9493 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Thu, 18 Jun 2026 22:16:44 +0200 Subject: [PATCH 36/39] mobalytics update --- src/gui/importer/mobalytics.py | 23 +++++++++++++++++++++- tests/gui/importer/test_mobalytics.py | 28 +++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index 8e9552f2..8a431111 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -140,7 +140,7 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): seal_filters = [] mythic_names = [] aspect_upgrade_filters = [] - guessed_set_name = None + guessed_set_name = _guess_mobalytics_charm_set_name(items) for item in sorted(items, key=lambda item: jsonpath.findall(".gameEntity.type", item)[0] != "charms"): item_filter = ItemFilterModel() entity_type = jsonpath.findall(".gameEntity.type", item)[0] @@ -321,6 +321,27 @@ def _corrections(input_str: str) -> str: return input_str +def _guess_mobalytics_charm_set_name(items: list[dict]) -> str | None: + set_names = [] + for item in items: + entity_type_result = jsonpath.findall(".gameEntity.type", item) + if not entity_type_result or entity_type_result[0] != "charms": + continue + title_result = jsonpath.findall(".gameEntity.entity.title", item) or jsonpath.findall(".gameEntity.title", item) + item_name = str(title_result[0]).strip() if title_result else "" + _, set_name = match_charm_to_set_or_unique(item_name) + if set_name and set_name not in set_names: + set_names.append(set_name) + + if len(set_names) > 1: + LOGGER.warning( + "Found multiple charm sets in Mobalytics build (%s); using %s for set-specific seal affixes.", + ", ".join(set_names), + set_names[0], + ) + return set_names[0] if set_names else None + + def _fix_input_url(url: str) -> str: return unquote(url) diff --git a/tests/gui/importer/test_mobalytics.py b/tests/gui/importer/test_mobalytics.py index a9e22fd8..353b1c11 100644 --- a/tests/gui/importer/test_mobalytics.py +++ b/tests/gui/importer/test_mobalytics.py @@ -8,11 +8,14 @@ from src.dataloader import Dataloader from src.gui.importer.importer_config import ImportConfig from src.gui.importer.mobalytics import ( + _convert_raw_to_affixes, _extract_mobalytics_preloaded_state, + _guess_mobalytics_charm_set_name, _log_mobalytics_page_diagnostics, import_mobalytics, ) from src.gui.importer.paragon_export import build_paragon_profile_payload, extract_mobalytics_paragon_steps +from src.item.data.item_type import ItemType if typing.TYPE_CHECKING: from pytest_mock import MockerFixture @@ -100,6 +103,31 @@ def test_log_mobalytics_page_diagnostics_reports_loaded_page_shape(caplog: pytes assert "self.__next_f, captcha" in caplog.text +def test_guess_mobalytics_charm_set_name_reads_charm_titles() -> None: + items = [ + {"gameEntity": {"type": "items", "entity": {"title": "Regular Item"}}}, + {"gameEntity": {"type": "charms", "entity": {"title": "Balazan's Bite"}}}, + ] + + assert _guess_mobalytics_charm_set_name(items) == "balazans_bite" + + +def test_convert_raw_to_affixes_uses_guessed_charm_set_for_seal_affixes() -> None: + affixes = _convert_raw_to_affixes( + raw_stats=[{"id": "maximum-resolve"}], item_type=ItemType.HoradricSeal, guessed_set_name="arms_of_arreat" + ) + + assert [affix.name for affix in affixes] == ["arms_of_arreat_maximum_resolve"] + + +def test_convert_raw_to_affixes_keeps_generic_seal_match_with_guessed_set() -> None: + affixes = _convert_raw_to_affixes( + raw_stats=[{"id": "cooldown-reduction"}], item_type=ItemType.HoradricSeal, guessed_set_name="arms_of_arreat" + ) + + assert [affix.name for affix in affixes] == ["cooldown_reduction"] + + @pytest.mark.parametrize("url", URLS) @pytest.mark.requests @pytest.mark.skipif(not IN_GITHUB_ACTIONS, reason="Importer tests are skipped if not run from Github Actions") From 1736151572ca125855a33301a442b975d19e5adf Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Thu, 18 Jun 2026 23:02:09 +0200 Subject: [PATCH 37/39] update d4builds update d4build charm and seals import. https://d4builds.gg/builds/whirlwind-barbarian-endgame/?var=5 problem: seal-no mythic name just a mythic aspect... what should we do ? --- src/gui/importer/d4builds.py | 195 +++++++++++++++++++++++++++- tests/gui/importer/test_d4builds.py | 66 ++++++++++ 2 files changed, 259 insertions(+), 2 deletions(-) diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index 812bef23..58cae92e 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -4,6 +4,9 @@ from typing import TYPE_CHECKING import lxml.html +import rapidfuzz +from selenium.common.exceptions import TimeoutException +from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support.wait import WebDriverWait @@ -72,6 +75,16 @@ TEMPERING_ICON_XPATH = ".//*[contains(@src, 'tempering_02.png')]" SANCTIFIED_ICON_XPATH = ".//*[contains(@src, 'sanctified_icon.png')]" UNIQUE_ICON_XPATH = ".//*[contains(@src, '/Uniques/')]" +ACTIVE_SEAL_CSS = ".builder__seal.active" +ACTIVE_CHARM_CSS = ".builder__charm.active" +SEAL_TOOLTIP_CSS = "[data-tippy-root] .seal__tooltip" +CHARM_TOOLTIP_CSS = "[data-tippy-root] .charm__tooltip" +SEAL_TOOLTIP_VALUE_XPATH = ".//*[contains(@class, 'seal__tooltip__value__text')]" +CHARM_TOOLTIP_NAME_XPATH = ".//*[contains(@class, 'charm__tooltip__name')]" +CHARM_TOOLTIP_VALUE_XPATH = ( + ".//*[contains(@class, 'charm__tooltip__values')]//*[contains(@class, 'charm__tooltip__value')]" +) +CHARM_TOOLTIP_SET_NAME_XPATH = ".//*[contains(@class, 'charm__tooltip__set__name')]" class D4BuildsError(Exception): @@ -100,8 +113,7 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): raise D4BuildsError(msg) slot_to_unique_name_map = _get_item_slots(data=data) finished_filters = [] - charm_filters = [] - seal_filters = [] + charm_filters, seal_filters = _extract_d4builds_seal_charm_filters(driver=driver, config=config) mythic_names = [] aspect_upgrade_filters = _get_legendary_aspects(data=data) for item in items[0]: @@ -126,6 +138,10 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): rarity = None affixes = [] inherents = [] + if is_seal: + item_type = ItemType.HoradricSeal + elif is_charm: + item_type = ItemType.Charm if slot_to_unique_name_map[slot]: unique_name, rarity = slot_to_unique_name_map[slot] @@ -279,9 +295,184 @@ def _corrections(input_str: str) -> str: return "armor" if "ranks to" in input_str or "ranks of" in input_str or "ranks" in input_str: return input_str.replace("ranks to", "to").replace("ranks of", "to").replace("ranks", "to") + if "charm slot" in input_str: + return "charm slot" return input_str +def _extract_d4builds_seal_charm_filters( + driver: ChromiumDriver, config: ImportConfig +) -> tuple[list[CharmFilterModel], list[SealFilterModel]]: + charm_filters = [] + seal_filters = [] + set_names = [] + + for charm_element in driver.find_elements(By.CSS_SELECTOR, ACTIVE_CHARM_CSS): + tooltip_html = _hover_and_get_tooltip_html(driver=driver, element=charm_element, tooltip_css=CHARM_TOOLTIP_CSS) + charm_filter, set_name = _create_charm_filter_from_tooltip_html( + tooltip_html=tooltip_html, require_gas=config.require_greater_affixes + ) + if charm_filter is not None: + charm_filters.append(charm_filter) + if set_name and set_name not in set_names: + set_names.append(set_name) + + if len(set_names) > 1: + LOGGER.warning( + "Found multiple charm sets in D4Builds build (%s); using %s for set-specific seal affixes.", + ", ".join(set_names), + set_names[0], + ) + guessed_set_name = set_names[0] if set_names else None + + for seal_element in driver.find_elements(By.CSS_SELECTOR, ACTIVE_SEAL_CSS): + tooltip_html = _hover_and_get_tooltip_html(driver=driver, element=seal_element, tooltip_css=SEAL_TOOLTIP_CSS) + seal_filter = _create_seal_filter_from_tooltip_html( + tooltip_html=tooltip_html, require_gas=config.require_greater_affixes, guessed_set_name=guessed_set_name + ) + if seal_filter is not None: + seal_filters.append(seal_filter) + + return charm_filters, seal_filters + + +def _hover_and_get_tooltip_html(driver: ChromiumDriver, element, tooltip_css: str) -> str: + driver.execute_script("document.querySelectorAll('[data-tippy-root]').forEach((node) => node.remove());") + ActionChains(driver).move_to_element(element).perform() + driver.execute_script( + "arguments[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));" + "arguments[0].dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));", + element, + ) + try: + tooltip = WebDriverWait(driver, 2).until(ec.presence_of_element_located((By.CSS_SELECTOR, tooltip_css))) + except TimeoutException: + LOGGER.warning("Unable to read D4Builds tooltip for selector %s.", tooltip_css) + return "" + return str(tooltip.get_attribute("outerHTML") or "") + + +def _create_seal_filter_from_tooltip_html( + tooltip_html: str, require_gas: bool, guessed_set_name: str | None = None +) -> SealFilterModel | None: + affixes = _affixes_from_tooltip_values( + texts=_tooltip_texts(tooltip_html=tooltip_html, value_xpath=SEAL_TOOLTIP_VALUE_XPATH), + item_type=ItemType.HoradricSeal, + guessed_set_name=guessed_set_name, + ) + if not affixes: + return None + return create_seal_charm_filter(affixes=affixes, require_gas=require_gas, model_type=SealFilterModel) + + +def _create_charm_filter_from_tooltip_html( + tooltip_html: str, require_gas: bool +) -> tuple[CharmFilterModel | None, str | None]: + tooltip = _tooltip_element(tooltip_html) + if tooltip is None: + return None, None + + name = _first_text(tooltip=tooltip, xpath=CHARM_TOOLTIP_NAME_XPATH) + set_name = correct_name(_first_text(tooltip=tooltip, xpath=CHARM_TOOLTIP_SET_NAME_XPATH)) + charm_unique_aspect = None + if not set_name: + charm_unique_aspect, set_name = match_charm_to_set_or_unique(name) + + affixes = _affixes_from_tooltip_values( + texts=_texts_from_nodes(tooltip.xpath(CHARM_TOOLTIP_VALUE_XPATH)), item_type=ItemType.Charm + ) + if not affixes and not charm_unique_aspect and not set_name: + return None, None + + return ( + create_seal_charm_filter( + affixes=affixes, + require_gas=require_gas, + model_type=CharmFilterModel, + unique_aspect=charm_unique_aspect, + set_name=set_name, + ), + set_name, + ) + + +def _affixes_from_tooltip_values( + texts: list[str], item_type: ItemType, guessed_set_name: str | None = None +) -> list[Affix]: + affixes = [] + for text in texts: + affix_name = _match_d4builds_tooltip_affix(text=text, item_type=item_type, guessed_set_name=guessed_set_name) + if affix_name is None: + LOGGER.error(f"Couldn't match D4Builds seal/charm tooltip affix {text=}") + continue + affixes.append(Affix(name=affix_name)) + return affixes + + +def _match_d4builds_tooltip_affix(text: str, item_type: ItemType, guessed_set_name: str | None = None) -> str | None: + stat_clean = clean_str(_corrections(input_str=text)) + combined_dict = Dataloader().affix_dict + if item_type == ItemType.HoradricSeal: + combined_dict = combined_dict | Dataloader().seal_affix_dict + elif item_type == ItemType.Charm: + combined_dict = combined_dict | Dataloader().charm_affix_dict + + if ( + item_type == ItemType.HoradricSeal + and guessed_set_name + and ( + matched_name := _match_d4builds_set_aware_seal_affix( + stat_clean=stat_clean, combined_dict=combined_dict, guessed_set_name=guessed_set_name + ) + ) + ): + return matched_name + + return closest_match(stat_clean, combined_dict) + + +def _match_d4builds_set_aware_seal_affix( + stat_clean: str, combined_dict: dict[str, str], guessed_set_name: str +) -> str | None: + best_global_key = closest_match(stat_clean, combined_dict) + if best_global_key and best_global_key != "damage": + global_display = combined_dict[best_global_key] + if rapidfuzz.distance.Levenshtein.distance(stat_clean, global_display) <= 2: + is_set_specific = any(best_global_key.startswith(f"{set_name}_") for set_name in Dataloader().set_list) + if not is_set_specific: + return best_global_key + + set_keys = {k: v for k, v in Dataloader().seal_affix_dict.items() if k.startswith(f"{guessed_set_name}_")} + if not set_keys: + return None + potential_match = closest_match(stat_clean, set_keys) + if not potential_match: + return None + display_name = Dataloader().seal_affix_dict[potential_match] + if rapidfuzz.fuzz.token_set_ratio(stat_clean, display_name) >= 50: + return potential_match + return None + + +def _tooltip_texts(tooltip_html: str, value_xpath: str) -> list[str]: + tooltip = _tooltip_element(tooltip_html) + return [] if tooltip is None else _texts_from_nodes(tooltip.xpath(value_xpath)) + + +def _tooltip_element(tooltip_html: str) -> lxml.html.HtmlElement | None: + if not tooltip_html: + return None + return lxml.html.fromstring(tooltip_html) + + +def _texts_from_nodes(nodes: list[lxml.html.HtmlElement]) -> list[str]: + return [text for node in nodes if (text := " ".join(node.text_content().split()))] + + +def _first_text(tooltip: lxml.html.HtmlElement, xpath: str) -> str: + return _texts_from_nodes(tooltip.xpath(xpath))[0] if tooltip.xpath(xpath) else "" + + def _extract_build_metadata(data: lxml.html.HtmlElement) -> tuple[str, str, str, str]: class_name = "Unknown" if header_nodes := data.xpath(CLASS_XPATH): diff --git a/tests/gui/importer/test_d4builds.py b/tests/gui/importer/test_d4builds.py index 87dea53a..2ec9a1cc 100644 --- a/tests/gui/importer/test_d4builds.py +++ b/tests/gui/importer/test_d4builds.py @@ -9,6 +9,7 @@ from src.gui.importer import paragon_export as paragon_export_module from src.gui.importer.importer_config import ImportConfig from src.gui.importer.paragon_export import build_paragon_profile_payload +from src.item.data.item_type import ItemType if typing.TYPE_CHECKING: from pytest_mock import MockerFixture @@ -110,6 +111,71 @@ def test_extract_d4builds_season_number_from_gear_dropdown() -> None: assert d4builds_module._extract_d4builds_season_number(data) == "12" +def test_create_seal_filter_from_tooltip_html_matches_tooltip_values() -> None: + tooltip_html = """ +
+

Seal

+
    +
  • + Critical Strike Damage +
  • +
  • + Attack Speed +
  • +
  • + +1 Unique Charm Slot +
  • +
+
+ """ + + seal_filter = d4builds_module._create_seal_filter_from_tooltip_html(tooltip_html=tooltip_html, require_gas=False) + + assert [affix.name for affix in seal_filter.affix_pool[0].count] == [ + "critical_strike_damage", + "attack_speed", + "charm_slot", + ] + + +def test_create_charm_filter_from_tooltip_html_reads_set_name_and_affixes() -> None: + tooltip_html = """ +
+

Fer of the Crucible

+
    +
  • Maximum Resource
  • +
+
+
Berserker's Crucible
+
+
+ """ + + charm_filter, set_name = d4builds_module._create_charm_filter_from_tooltip_html( + tooltip_html=tooltip_html, require_gas=False + ) + + assert set_name == "berserkers_crucible" + assert charm_filter.set == ["berserkers_crucible"] + assert [affix.name for affix in charm_filter.affix_pool[0].count] == ["maximum_resource"] + + +def test_match_d4builds_tooltip_affix_uses_guessed_charm_set_for_seal_affixes() -> None: + affix_name = d4builds_module._match_d4builds_tooltip_affix( + text="Maximum Resolve", item_type=ItemType.HoradricSeal, guessed_set_name="arms_of_arreat" + ) + + assert affix_name == "arms_of_arreat_maximum_resolve" + + +def test_match_d4builds_tooltip_affix_keeps_generic_seal_match_with_guessed_set() -> None: + affix_name = d4builds_module._match_d4builds_tooltip_affix( + text="Cooldown Reduction", item_type=ItemType.HoradricSeal, guessed_set_name="arms_of_arreat" + ) + + assert affix_name == "cooldown_reduction" + + def test_parse_d4builds_paragon_boards_produces_valid_typed_payload_input() -> None: class _FakeTextNode: def __init__(self, text: str): From 962e567809211076f2d3c0ccfcbc5b0e3e6ca443 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Fri, 19 Jun 2026 00:20:32 +0200 Subject: [PATCH 38/39] Update maxroll.py fix for "to_combat_skills" --- src/gui/importer/maxroll.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 55832901..9326883d 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -413,6 +413,8 @@ def _find_skill_rank_label_from_affix_key(affix_key: str) -> str: return "all" if match := SKILL_RANK_AFFIX_KEY_REGEX.search(affix_key): label = match.group("label") + if label == "Bludgeoning": + return "combat" label = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", label) label = re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", " ", label) return " ".join(label.split()) From be73fabcbb3100da077ea2198e08b5c40e1b4d33 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Fri, 19 Jun 2026 00:34:56 +0200 Subject: [PATCH 39/39] updated gui_common.py comment resolving change --- src/gui/importer/gui_common.py | 4 ++++ tests/gui/importer/test_gui_common.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py index c75f0d41..201d02a9 100644 --- a/src/gui/importer/gui_common.py +++ b/src/gui/importer/gui_common.py @@ -246,6 +246,10 @@ def match_charm_to_set_or_unique(charm_name: str) -> tuple[str | None, str | Non if name_clean in dataloader.aspect_unique_dict: return name_clean, None + # Check if the name matches a known set directly (if already normalized) + if name_clean in dataloader.set_list: + return None, name_clean + # 2. Try to match the set name from the clean name for set_name in sorted(dataloader.set_list, key=len, reverse=True): if set_name in name_clean: diff --git a/tests/gui/importer/test_gui_common.py b/tests/gui/importer/test_gui_common.py index f9f848f1..9bbcc69f 100644 --- a/tests/gui/importer/test_gui_common.py +++ b/tests/gui/importer/test_gui_common.py @@ -108,6 +108,11 @@ def test_match_charm_to_set_or_unique() -> None: assert unique is None assert set_name == "balazans_bite" + # Test already normalized set name + unique, set_name = match_charm_to_set_or_unique("balazans_bite") + assert unique is None + assert set_name == "balazans_bite" + unique, set_name = match_charm_to_set_or_unique("Protean Heart") assert unique == "protean_heart" assert set_name is None