Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9,975 changes: 9,975 additions & 0 deletions assets/lang/enUS/affix_metadata.json

Large diffs are not rendered by default.

590 changes: 590 additions & 0 deletions assets/lang/enUS/uniques.json

Large diffs are not rendered by default.

39 changes: 36 additions & 3 deletions src/config/profile_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ 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
required: bool = False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what this required flag is but it doesn't seem relevant to a profile editor and importer overhaul. I'm unclear how this works differently than adding another count block, which you also support.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The required option would let you set a specific affix to required which would be along with any count settings you might have.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The count block already accomplishes this, it's not good to add new functionality here where it can be avoided

min_percent_of_affix: int = Field(default=0, alias="minPercentOfAffix")

@field_validator("name")
Expand Down Expand Up @@ -94,12 +95,13 @@ def model_validator(self) -> AffixFilterCountModel:
self.max_count = len(self.count)
self.model_fields_set.remove("min_count")
self.model_fields_set.remove("max_count")

req_count = sum(1 for a in self.count if a.required)
self.min_count = max(self.min_count, req_count)

if self.min_count > self.max_count:
msg = "minCount must be smaller than maxCount"
raise ValueError(msg)
if not self.count:
msg = "count must not be empty"
raise ValueError(msg)
return self


Expand Down Expand Up @@ -140,6 +142,10 @@ class GlobalUniqueModel(BaseModel):
min_greater_affix_count: int = Field(default=0, alias="minGreaterAffixCount")
min_percent_of_aspect: int = Field(default=0, alias="minPercentOfAspect")
min_power: int = Field(default=0, alias="minPower")
item_type: list[ItemType] = Field(default=[], alias="itemType")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These make no sense here and were already removed previously and intentionally

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the code uses this field to immediately skip items that don't match the intended item category.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would a user want to say "Keep all unique helmets". It literally won't happen, which is why it was removed previously.

And the other functionality is similarly unnecessary or was added to the Affixes.

affix_pool: list[AffixFilterCountModel] = Field(default=[], alias="affixPool")
inherent_pool: list[AffixFilterCountModel] = Field(default=[], alias="inherentPool")
unique_aspect: list[AspectUniqueFilterModel] = Field(default=[], alias="uniqueAspect")

@field_validator("min_power")
@classmethod
Expand All @@ -159,6 +165,29 @@ def count_validator(cls, v: int) -> int:
def percent_validator(cls, v: int) -> int:
return validate_percent(v)

@field_validator("item_type", mode="before")
@classmethod
def parse_item_type(cls, data: str | list[str]) -> list[str]:
return _parse_item_type_or_rarities(data)

@field_validator("unique_aspect", mode="before")
@classmethod
def parse_unique_aspect(cls, data: dict | list[dict] | None) -> list[dict]:
if not data:
return []
if isinstance(data, dict):
return [data]
return data

@model_validator(mode="after")
def unique_aspect_names_must_be_unique(self) -> GlobalUniqueModel:
if len({aspect.name for aspect in self.unique_aspect}) != len(self.unique_aspect):
msg = "uniqueAspect names must be unique"
raise ValueError(msg)
if not self.affix_pool:
self.affix_pool = [AffixFilterCountModel(count=[], min_count=0)]
return self


class ItemFilterModel(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
Expand Down Expand Up @@ -201,6 +230,8 @@ def unique_aspect_names_must_be_unique(self) -> ItemFilterModel:
if len({aspect.name for aspect in self.unique_aspect}) != len(self.unique_aspect):
msg = "uniqueAspect names must be unique"
raise ValueError(msg)
if not self.affix_pool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

affix_pool can never be none, what is this trying to accomplish?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is a safeguard that ensures the filtering engine has a structured object to work with, even when the user provides minimal configuration. It turns "no affix requirements" into an explicit "zero-requirement match" so the program logic remains consistent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry I don't know what that's saying. affix_pool has a default value of []. There is no situation where it will need to be reinitialized. If there is a situation we probably need to fix a problem elsewhere

self.affix_pool = [AffixFilterCountModel(count=[], min_count=0)]
return self


Expand Down Expand Up @@ -321,11 +352,13 @@ class ProfileModel(BaseModel):
aspect_upgrades: list[str] = Field(default=[], alias="AspectUpgrades")
global_uniques: list[GlobalUniqueModel] = Field(default=[], alias="GlobalUniques")
name: str
class_name: str = Field(default="unknown", alias="ClassName")
sigils: SigilFilterModel = Field(
default=SigilFilterModel(blacklist=[], whitelist=[], priority=SigilPriority.blacklist), alias="Sigils"
)
tributes: list[TributeFilterModel] = Field(default=[], alias="Tributes")
paragon: dict[str, object] | list[dict[str, object]] | None = Field(default=None, alias="Paragon")
source_url: str = Field(default="", alias="SourceUrl")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you make sure anywhere the paragon board used a source_url is either removed or it uses this one? I think it only stored it for no particular reason and can probably just get removed


@model_validator(mode="before")
def aspects_must_exist(self) -> ProfileModel:
Expand Down
12 changes: 4 additions & 8 deletions src/gui/importer/d4builds.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
)
from src.dataloader import Dataloader
from src.gui.importer.gui_common import (
add_mythics_to_filters,
add_to_profiles,
build_default_profile_file_name,
fix_offhand_type,
Expand Down Expand Up @@ -95,7 +94,6 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None):
raise D4BuildsError(msg)
slot_to_unique_name_map = _get_item_slots(data=data)
finished_filters = []
mythic_names = []
aspect_upgrade_filters = _get_legendary_aspects(data=data)
for item in items[0]:
item_filter = ItemFilterModel()
Expand All @@ -115,9 +113,6 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None):

if slot_to_unique_name_map[slot]:
unique_name, rarity = slot_to_unique_name_map[slot]
if rarity == ItemRarity.Mythic:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't import affixes for mythics. This change alone has us import affixes. You can look at how the code was before I implemented multiple aspects if you want to see how to handle this

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And note this applies to all of the importers

mythic_names.append(unique_name)
continue
try:
item_filter.unique_aspect = [AspectUniqueFilterModel(name=unique_name)]
except Exception:
Expand Down Expand Up @@ -198,9 +193,9 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None):
filter_name = f"{filter_name_template}{i}"
i += 1
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), class_name=class_name, source_url=url
)
if config.import_aspect_upgrades and aspect_upgrade_filters:
profile.aspect_upgrades = aspect_upgrade_filters

Expand All @@ -210,6 +205,7 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None):
season_number=season_number,
build_header=build_header,
variant_name=variant_name,
filename_components=config.filename_components,
)

# Optionally embed Paragon data into the profile model before saving
Expand Down
61 changes: 46 additions & 15 deletions src/gui/importer/gui_common.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import datetime
import functools
import logging
Expand All @@ -18,7 +20,6 @@

from src import __version__
from src.config.loader import IniConfigLoader
from src.config.profile_models import AspectUniqueFilterModel, ItemFilterModel, ProfileModel
from src.config.settings_models import BrowserType
from src.item.data.item_type import ItemType

Expand All @@ -27,6 +28,8 @@

from selenium.webdriver.chromium.webdriver import ChromiumDriver

from src.config.profile_models import ItemFilterModel, ProfileModel

LOGGER = logging.getLogger(__name__)

D = TypeVar("D", bound=WebDriver | WebElement)
Expand Down Expand Up @@ -135,21 +138,57 @@ def normalize_profile_file_name(file_name: str) -> str:


def build_default_profile_file_name(
source_name: str, class_name: str = "", season_number: str = "", build_header: str = "", variant_name: str = ""
source_name: str,
class_name: str = "",
season_number: str = "",
build_header: str = "",
variant_name: str = "",
filename_components=None,
) -> str:
if filename_components is None:
# Default behavior (include all non-empty components)
filename_components = {
"include_source": True,
"include_season": True,
"include_class": True,
"include_header": True,
"include_subbuild": True,
}

normalized_source_name = _normalize_profile_name_part(source_name) or "imported"
clean_title = _clean_build_header(normalized_source_name, build_header, season_number)
normalized_class_name = _normalize_profile_name_part(class_name) or "unknown"
normalized_variant_name = _normalize_profile_name_part(variant_name)

# Normalize season number
season_match = re.search(r"\d+", str(season_number))
normalized_season_name = f"s{season_match.group(0)}" if season_match else ""
file_name_parts = [normalized_source_name, normalized_class_name]
if normalized_season_name:
normalized_season_name = (
f"s{season_match.group(0)}" if season_match and filename_components["include_season"] else ""
)

file_name_parts = []

# Include components based on user preferences
if filename_components["include_source"]:
file_name_parts.append(normalized_source_name)

if filename_components["include_class"] and normalized_class_name != "unknown":
file_name_parts.append(normalized_class_name)

if season_match and filename_components["include_season"]:
file_name_parts.append(normalized_season_name)
if clean_title:

# Include build header only if clean_title is non-empty and user wants it included
if clean_title and filename_components["include_header"]:
file_name_parts.append(clean_title)
if normalized_variant_name:

if normalized_variant_name and filename_components["include_subbuild"]:
file_name_parts.append(normalized_variant_name)

# Default fallback: include at least the source
if not file_name_parts:
return normalize_profile_file_name(normalized_source_name + "_imported")

return normalize_profile_file_name("_".join(file_name_parts))


Expand Down Expand Up @@ -188,14 +227,6 @@ def update_mingreateraffixcount(item_filter: ItemFilterModel, require_gas: bool)
item_filter.min_greater_affix_count = 0


def add_mythics_to_filters(mythic_names, finished_filters):
if mythic_names:
mythic_filter = ItemFilterModel()
for mythic_name in mythic_names:
mythic_filter.unique_aspect.append(AspectUniqueFilterModel(name=mythic_name))
finished_filters.append({"Mythics": mythic_filter})


def sort_profile_filters(filters: list[dict[str, ItemFilterModel]]) -> list[dict[str, ItemFilterModel]]:
return sorted(filters, key=_profile_filter_sort_key)

Expand Down
1 change: 1 addition & 0 deletions src/gui/importer/importer_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ class ImportConfig:
require_greater_affixes: bool
export_paragon: bool = False
custom_file_name: str | None = None
filename_components: dict = None
17 changes: 8 additions & 9 deletions src/gui/importer/maxroll.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
)
from src.dataloader import Dataloader
from src.gui.importer.gui_common import (
add_mythics_to_filters,
add_to_profiles,
build_default_profile_file_name,
fix_offhand_type,
Expand Down Expand Up @@ -91,7 +90,6 @@ def import_maxroll(config: ImportConfig):
build_name += f"_{variant_name}"
finished_filters = []
aspect_upgrade_filters = []
mythic_names = []
for item_id in active_profile["items"].values():
resolved_item = items[str(item_id)]
resolved_item_id = resolved_item["id"]
Expand Down Expand Up @@ -135,10 +133,6 @@ def import_maxroll(config: ImportConfig):
unique_name = mapping_data["items"][resolved_item_id]["name"]
try:
unique_name = _unique_name_special_handling(unique_name)
# We handle mythics at the end
if rarity == ItemRarity.Mythic:
mythic_names.append(unique_name)
continue
item_filter.unique_aspect = [AspectUniqueFilterModel(name=unique_name)]
except Exception:
LOGGER.exception(f"Unexpected error adding unique aspect for {unique_name}, please report a bug.")
Expand Down Expand Up @@ -170,9 +164,13 @@ def import_maxroll(config: ImportConfig):

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),
class_name=all_data["class"],
source_url=url,
)

if config.import_aspect_upgrades and aspect_upgrade_filters:
profile.aspect_upgrades = aspect_upgrade_filters

Expand All @@ -184,6 +182,7 @@ def import_maxroll(config: ImportConfig):
season_number=guide_season,
build_header=build_header,
variant_name=variant_name,
filename_components=config.filename_components,
)

# Optionally embed Paragon data into the profile model before saving
Expand Down
33 changes: 13 additions & 20 deletions src/gui/importer/mobalytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
)
from src.dataloader import Dataloader
from src.gui.importer.gui_common import (
add_mythics_to_filters,
add_to_profiles,
build_default_profile_file_name,
fix_offhand_type,
Expand Down Expand Up @@ -128,13 +127,10 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None):
LOGGER.error(msg := "No items found")
raise MobalyticsError(msg)
finished_filters = []
mythic_names = []
aspect_upgrade_filters = []
for item in items:
item_filter = ItemFilterModel()
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"]:
continue
if not (item_name := str(jsonpath.findall(".gameEntity.entity.title", item)[0])):
Expand All @@ -152,10 +148,6 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None):
is_unique = entity_type == "uniqueItems"
if is_unique:
try:
# We handle mythics at the end
if is_mythic:
mythic_names.append(item_name)
continue
item_filter.unique_aspect = [AspectUniqueFilterModel(name=item_name)]
except Exception:
LOGGER.exception(f"Unexpected error adding unique aspect for {item_name}, please report a bug.")
Expand Down Expand Up @@ -210,16 +202,15 @@ 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 not is_mythic:
item_filter.affix_pool = [
AffixFilterCountModel(
count=[AffixFilterModel(name=x.name, want_greater=x.type == AffixType.greater) for x in affixes],
min_count=1 if is_unique else 3,
)
]
update_mingreateraffixcount(item_filter, config.require_greater_affixes)
item_filter.affix_pool = [
AffixFilterCountModel(
count=[AffixFilterModel(name=x.name, want_greater=x.type == AffixType.greater) for x in affixes],
min_count=1 if is_unique else 3,
)
]
update_mingreateraffixcount(item_filter, config.require_greater_affixes)
item_filter.min_power = 100
if inherents and not is_mythic:
if inherents:
item_filter.inherent_pool = [
AffixFilterCountModel(count=[AffixFilterModel(name=x.name) for x in inherents])
]
Expand All @@ -231,9 +222,10 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None):
i += 1
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), class_name=class_name, source_url=url
)

if config.import_aspect_upgrades and aspect_upgrade_filters:
profile.aspect_upgrades = aspect_upgrade_filters

Expand All @@ -243,6 +235,7 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None):
season_number=season_number,
build_header=build_header,
variant_name=variant_name,
filename_components=config.filename_components,
)
# Optionally embed Paragon data into the profile model before saving
if config.export_paragon:
Expand Down
Loading
Loading