diff --git a/assets/lang/enUS/affix_metadata.json b/assets/lang/enUS/affix_metadata.json
new file mode 100644
index 00000000..928d7509
--- /dev/null
+++ b/assets/lang/enUS/affix_metadata.json
@@ -0,0 +1,9975 @@
+{
+ "abyss_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "advance_resource_generation": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "agility_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "all_damage_multiplier": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "all_stats": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Boots",
+ "Bow",
+ "ChestArmor",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "all_stats_per_ferocity_or_resolve_stack": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "ancient_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "arbiter_of_justice_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "archfiend_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "armor": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "armor_in_arbiter_form": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "armor_while_in_human_form": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "attack_speed_for_seconds_after_casting_a_defensive_skill": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "attack_speed_for_seconds_after_dodging_an_attack": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "attack_speed_while_berserking": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "attacks_reduce_evades_cooldown_by_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "attacks_reduce_ultimate_cooldown_by_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "ball_lightning_projectile_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "barrier_generation": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "basic_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "basic_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "basic_resource_generation": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "bleeding_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "blizzard_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "block_chance": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Quarterstaff",
+ "Ring",
+ "Shield",
+ "Staff"
+ ]
+ },
+ "blood_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "blood_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "blood_orbs_restore_essence": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "bone_critical_strike_chance": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "bone_critical_strike_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "bone_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "bone_spirit_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "bone_spirit_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "boulder_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "brandish_resource_generation": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "brawling_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "burning_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "casting_justice_skills_restores_primary_resource": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "casting_macabre_skills_restores_primary_resource": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "casting_ultimate_skills_restores_primary_resource": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "casting_valor_skills_restores_primary_resource": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "casting_wrath_skills_restores_primary_resource": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "cataclysm_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "centipede_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "chance_for_arbiter_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Boots",
+ "Bow",
+ "ChestArmor",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_army_of_the_dead_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_basic_skills_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_blood_lance_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_bone_storm_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_brandish_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_clash_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_concussive_stomp_to_extra_hit": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "chance_for_corpse_explosion_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_incinerate_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_judgement_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_payback_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "chance_for_pestilent_swarm_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "chance_for_potency_skills_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_ravens_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_retribution_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_rock_splitter_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "chance_for_rushing_claw_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "chance_for_sever_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_shield_bash_to_deal_double_damage": {
+ "slots": [
+ "Shield"
+ ]
+ },
+ "chance_for_shield_charge_to_deal_double_damage": {
+ "slots": [
+ "Shield"
+ ]
+ },
+ "chance_for_soar_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "chance_for_soulrift_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_spear_of_the_heavens_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_for_the_devourer_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "chance_for_the_hunter_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "chance_for_the_protector_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "chance_for_the_seeker_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "chance_for_thrash_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "chance_for_thunderspike_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "chance_for_vortex_to_extra_hit": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "chance_for_withering_fist_to_deal_double_damage": {
+ "slots": [
+ "Ring"
+ ]
+ },
+ "chance_for_zeal_to_deal_double_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_to_cluck_thrice": {
+ "slots": [
+ "Bow"
+ ]
+ },
+ "chance_when_struck_to_fortify_for_life": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "chance_when_struck_to_gain_life_as_barrier_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "charge_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "clash_resource_generation": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "cold_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "cold_damage_multiplier": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "cold_mage_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "cold_resistance": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "companion_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "conjuration_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "core_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "core_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "core_resource_cost_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "corpse_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "corpse_explosion_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "corpse_tendrils_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "corrupting_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "counterattack_charges": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "crackling_energy_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "critical_strike_and_vulnerable_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "critical_strike_chance": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "critical_strike_chance_against_chilled_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "critical_strike_chance_against_close_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "critical_strike_chance_against_crowd_controlled_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "critical_strike_chance_against_feared_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "critical_strike_chance_against_injured_enemies": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "critical_strike_chance_against_stunned_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "critical_strike_chance_to_each_enhanced_rapid_fire_bonus": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "critical_strike_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "critical_strike_damage_multiplier": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "cutthroat_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "cutthroat_critical_strike_chance": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "cutthroat_critical_strike_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "cutthroat_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "cyclone_armor_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "cyclone_armor_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_for_seconds_after_dodging_an_attack": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_for_seconds_after_gaining_resolve": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "damage_for_seconds_after_killing_an_elite": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_for_seconds_after_picking_up_a_blood_orb": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_on_next_attack_after_entering_stealth": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_over_time": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_over_time_duration": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_over_time_multiplier": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_per_combo_point_spent": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "damage_per_overpower_stack": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_for_each_active_ball_lightning": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_for_your_summons": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_from_bleeding_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_from_burning_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_from_close_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_from_corrupted_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_from_distant_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_from_elites": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_from_enemies_affected_by_blood_skills": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_from_enemies_affected_by_curse_skills": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_from_enemies_affected_by_trap_skills": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_from_poisoned_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_per_crackling_energy_charge": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "damage_reduction_while_fortified": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_while_healthy": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_while_injured": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_while_standing_still": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_while_unstoppable": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_reduction_while_you_have_a_barrier": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_angels_and_demons": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_bleeding_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_burning_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_chilled_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_close_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_corrupted_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_crowd_controlled_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Boots",
+ "Bow",
+ "ChestArmor",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_cursed_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_dazed_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_distant_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_elites": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_frozen_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_immobilized_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_injured_enemies": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "damage_to_judged_enemies": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "damage_to_knockeddown_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Boots",
+ "Bow",
+ "ChestArmor",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_poisoned_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_poultry": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_slowed_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Boots",
+ "Bow",
+ "ChestArmor",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_stunned_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_trapped_enemies": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_to_weakened_enemies": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "damage_when_spending_resolve": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Boots",
+ "Bow",
+ "ChestArmor",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_when_swapping_weapons": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_while_berserking": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_while_fortified": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_while_healthy": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_while_in_arbiter_form": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "damage_while_in_human_form": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_while_iron_maelstrom_is_active": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "damage_while_shadowform_is_active": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_while_shapeshifted": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_while_war_cry_is_active": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_while_wrath_of_the_berserker_is_active": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_with_dualwielded_weapons": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_with_ranged_weapons": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_with_twohanded_bludgeoning_weapons": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "damage_with_twohanded_slashing_weapons": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "darkness_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "dash_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "death_blow_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "defensive_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "defensive_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "demonform_damage_bonus": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "demonology_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "desecrated_ground_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "dexterity": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "disciple_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "drinking_a_potion_grants_movement_speed_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "dust_devil_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "eagle_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "earth_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "earth_critical_strike_chance": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "earth_critical_strike_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "earth_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "earthquake_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "enchantment_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "energy_cost_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "energy_on_kill": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "energy_regeneration": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "energy_when_a_stun_grenade_explodes": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "essence_cost_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "essence_on_hit": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "essence_on_kill": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "essence_per_enemy_drained_by_blood_surge": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "essence_regeneration": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "evade_grants_attack_speed_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "evade_grants_movement_speed_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "faith_on_kill": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "faith_regeneration": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "familiar_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "feast_every_kills_chains_hook_nearby_enemies": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "feast_every_kills_gain_berserking_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "feast_every_kills_release_a_bloodsplosion_for_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "feast_every_kills_reset_random_cooldowns": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "feast_every_kills_restore_of_your_maximum_primary_resource": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "feast_every_kills_savagely_bite_times_for_damage_and_apply_vulnerable": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "feast_every_kills_your_next_core_skill_cast_deals_additional_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "ferocity_potency": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "fire_and_cold_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "fire_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "fire_damage_multiplier": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "fire_resistance": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "fireball_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "fireball_projectile_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "focus_cooldown_reduction": {
+ "slots": [
+ "Focus"
+ ]
+ },
+ "focus_damage": {
+ "slots": [
+ "Focus"
+ ]
+ },
+ "fortify_generation": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "fortress_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "frost_critical_strike_chance": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "frost_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "fury_cost_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "fury_on_kill": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "fury_regeneration": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "golem_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "gorilla_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "grenade_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "ground_stomp_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "heavens_fury_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "hellfire_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "holy_bolt_resource_generation": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "holy_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Boots",
+ "Bow",
+ "ChestArmor",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "holy_damage_multiplier": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "human_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "hunger_after_you_cast_a_basic_skill_chance_for_kill_to_your_kill_streak": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "hunger_after_you_cast_a_cooldown_kill_to_your_kill_streak": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "hunger_after_you_kill_an_enemy_chance_for_kill_to_your_kill_streak": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "hunger_every_resource_chance_for_kill_to_your_kill_streak": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "hunger_increased_chance_for_additional_gold_during_kill_streaks": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "hunger_increased_chance_for_additional_salvage_materials_during_your_kill_streaks": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "hunger_increased_chance_for_feast_items_during_your_kill_streaks": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "hunger_increased_chance_for_hunger_items_during_kill_streaks": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "hunger_increased_chance_for_rampage_items_during_kill_streaks": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "hunger_increased_chance_for_runes_during_your_kill_streaks": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "hunger_lucky_hit_up_to_a_chance_for_kill_to_your_kill_streak": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "hurricane_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "hydra_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "hydra_resource_cost_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "ice_blades_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "ice_spike_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "imbued_critical_strike_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "imbued_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "imbuement_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "incarnate_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "indestructible": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "intelligence": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "invigorating_strike_energy_regeneration": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "iron_maelstrom_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "iron_maiden_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "iron_skin_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "jaguar_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "judicator_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "juggernaut_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "justice_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "justice_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "kick_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lacerate_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "leap_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "life_on_hit": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "life_on_kill": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "life_per_seconds": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "life_regeneration": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "life_steal": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "lightning_bolt_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lightning_critical_strike_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lightning_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lightning_damage_multiplier": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lightning_resistance": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "lightning_spear_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lucky_hit_chance": {
+ "slots": [
+ "Ring"
+ ]
+ },
+ "lucky_hit_critical_strikes_have_up_to_a_chance_to_daze_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lucky_hit_critical_strikes_have_up_to_a_chance_to_immobilize_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lucky_hit_critical_strikes_have_up_to_a_chance_to_slow_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lucky_hit_critical_strikes_have_up_to_a_chance_to_stun_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lucky_hit_up_to_a_chance_to_deal_cold_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lucky_hit_up_to_a_chance_to_deal_fire_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lucky_hit_up_to_a_chance_to_deal_holy_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lucky_hit_up_to_a_chance_to_deal_lightning_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lucky_hit_up_to_a_chance_to_deal_physical_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lucky_hit_up_to_a_chance_to_deal_poison_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lucky_hit_up_to_a_chance_to_deal_shadow_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lucky_hit_up_to_a_chance_to_heal_life": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "lucky_hit_up_to_a_chance_to_make_enemies_vulnerable_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lucky_hit_up_to_a_chance_to_restore_primary_resource": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "lucky_hit_up_to_a_chance_to_weaken_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lucky_hit_up_to_a_damage_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "lunging_strike_healing": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "macabre_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "main_hand_weapon_damage": {
+ "slots": [
+ "Shield"
+ ]
+ },
+ "mana_cost_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "mana_on_kill": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "mana_regeneration": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "marksman_attack_speed_per_precison_stack": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "marksman_critical_strike_chance": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "marksman_critical_strike_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "marksman_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "mastery_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "maximum_energy": {
+ "slots": [
+ "Axe"
+ ]
+ },
+ "maximum_essence": {
+ "slots": [
+ "Axe"
+ ]
+ },
+ "maximum_fury": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "maximum_life": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "maximum_mana": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "maximum_resolve_stacks": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "maximum_resource": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "maximum_spirit": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "maximum_vigor": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "mobility_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "mobility_skills_grant_movement_speed_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "movement_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "movement_speed_for_seconds_after_killing_an_elite": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "movement_speed_for_seconds_after_killing_an_enemy": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "movement_speed_for_seconds_after_picking_up_crackling_energy": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "movement_speed_while_berserking": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "movement_speed_while_cataclysm_is_active": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "movement_speed_while_hurricane_is_active": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "movement_speed_while_in_human_form": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "movement_speed_while_shapeshifted_into_a_werewolf": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "movement_speed_while_the_inner_sight_gauge_is_full": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "mystic_circle_potency": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "nonphysical_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "occult_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "overpower_critical_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "pestilent_swarm_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "physical_critical_strike_chance_against_elites": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "physical_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "physical_damage_multiplier": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "physical_resistance": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "pickup_radius": {
+ "slots": [
+ "Staff"
+ ]
+ },
+ "poison_creeper_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "poison_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "poison_damage_multiplier": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "poison_damage_over_time_duration": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "poison_resistance": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "poisoning_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "potency_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "potency_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "primary_centipede_spirit_hall_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "primary_eagle_spirit_hall_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "primary_gorilla_spirit_hall_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "primary_jaguar_spirit_hall_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "puncture_resource_generation": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "pyromancy_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "pyromancy_critical_strike_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "pyromancy_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "rabies_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "rain_of_arrows_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "rampage_attack_speed_per_kill_streak_tier": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "rampage_cooldown_reduction_per_kill_streak_tier": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "rampage_critical_strike_chance_per_kill_streak_tier": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "rampage_dexterity_per_kill_streak_tier": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "rampage_intelligence_per_kill_streak_tier": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "rampage_life_on_hit_per_kill_streak_tier": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "rampage_lucky_hit_chance_per_kill_streak_tier": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "rampage_maximum_life_per_kill_streak_tier": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "rampage_movement_speed_per_kill_streak_tier": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "rampage_resource_cost_reduction_per_kill_streak_tier": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "rampage_strength_per_kill_streak_tier": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "rampage_willpower_per_kill_streak_tier": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "ravager_on_kill_duration_extension": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "ravens_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "ravens_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "razor_wings_charges": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "resistance_to_all_elements": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Boots",
+ "Bow",
+ "ChestArmor",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "resolve_generated": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "resource_cost_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "resource_generation": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "resource_generation_and_maximum": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "resource_generation_while_wielding_a_scythe": {
+ "slots": [
+ "Scythe"
+ ]
+ },
+ "resource_generation_while_wielding_a_shield": {
+ "slots": [
+ "Shield"
+ ]
+ },
+ "resource_generation_with_dualwielded_weapons": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "resource_generation_with_polearms": {
+ "slots": [
+ "Polearm"
+ ]
+ },
+ "resource_generation_with_twohanded_bludgeoning_weapons": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "resource_generation_with_twohanded_slashing_weapons": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "resource_generation_with_twohanded_weapons": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "resource_on_hit": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "rock_splitter_resource_generation": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "rupture_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "rushing_claw_charges": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "scourge_poisoning_duration": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "shade_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "shadow_clone_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "shadow_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "shadow_damage_multiplier": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "shadow_resistance": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "shadow_step_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "shapeshifting_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "shield_charge_cooldown_reduction": {
+ "slots": [
+ "Shield"
+ ]
+ },
+ "shock_critical_strike_chance": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "shock_critical_strike_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "shock_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "shred_critical_strike_chance": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "sigil_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "skeleton_mage_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "smoke_grenade_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "soar_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "soar_deals_up_to_damage_based_on_distance_traveled": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "soar_grants_maximum_life_as_barrier_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "spirit_cost_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "spirit_on_kill": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "spirit_regeneration": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "steel_grasp_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "storm_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "storm_critical_strike_chance": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "storm_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "storm_feather_potency": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "storm_strike_chains_to_targets": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "strength": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "stun_grenade_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "summon_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "summon_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "summon_movement_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "teleport_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "the_devourer_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "the_hunter_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "the_protector_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "the_seeker_charges": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "the_seeker_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "thorns": {
+ "slots": [
+ "Shield"
+ ]
+ },
+ "thrash_resource_generation": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "thunderspike_resource_generation": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_armored_hide": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_blessed_shield": {
+ "slots": [
+ "Shield"
+ ]
+ },
+ "to_bone_spirit": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_bone_splinters": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_centipede_skills": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_concussive_stomp": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_core_skills": {
+ "slots": [
+ "Ring"
+ ]
+ },
+ "to_counterattack": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_crushing_hand": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_cyclone_armor": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_eagle_skills": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_flame_shield": {
+ "slots": [
+ "Shield"
+ ]
+ },
+ "to_focus_skills": {
+ "slots": [
+ "Focus"
+ ]
+ },
+ "to_gorilla_skills": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_ice_armor": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_invigorating_strike": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_jaguar_skills": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_lunging_strike": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_mighty_throw": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "to_payback": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_potency_skills": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_prime_bone_storms_damage_reduction": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "to_quill_volley": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_rake": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_ravager": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_razor_wings": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_rock_splitter": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_rushing_claw": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_scourge": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_shield_bash": {
+ "slots": [
+ "Shield"
+ ]
+ },
+ "to_shield_charge": {
+ "slots": [
+ "Shield"
+ ]
+ },
+ "to_soar": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_stinger": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_storm_strike": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_the_pack_leader_spirit_boons_lucky_hit_chance": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_thrash": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_thunderspike": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_touch_of_death": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_toxic_skin": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_vortex": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "to_weapon_mastery_skills": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "to_withering_fist": {
+ "slots": [
+ "Ring"
+ ]
+ },
+ "total_armor": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "total_armor_while_in_werebear_form": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "total_armor_while_in_werewolf_form": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "trample_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "trap_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "ultimate_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "valor_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "versatile_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "vigor_cost_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "vigor_on_kill": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "vigor_regeneration": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "vigor_when_resolve_is_lost": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "vulnerable_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Boots",
+ "Bow",
+ "ChestArmor",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "vulnerable_damage_multiplier": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "weapon_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "weapon_mastery_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "weapon_mastery_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "weapon_mastery_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "werebear_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "werewolf_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "werewolf_critical_strike_chance": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "werewolf_critical_strike_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "werewolf_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "while_injured_your_potion_also_grants_movement_speed_for_seconds": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "while_injured_your_potion_also_restores_resource": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "willpower": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "wing_strike_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "withering_fist_resource_generation": {
+ "slots": [
+ "Ring"
+ ]
+ },
+ "wolves_attack_speed": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "wolves_damage": {
+ "slots": [
+ "Amulet",
+ "Axe",
+ "Axe2H",
+ "Bow",
+ "Crossbow2H",
+ "Dagger",
+ "Flail",
+ "Focus",
+ "Glaive",
+ "Mace",
+ "Mace2H",
+ "OffHandTotem",
+ "Polearm",
+ "Quarterstaff",
+ "Ring",
+ "Scythe",
+ "Scythe2H",
+ "Shield",
+ "Staff",
+ "Sword",
+ "Sword2H",
+ "Wand"
+ ]
+ },
+ "wrath_every_kills": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "wrath_regeneration": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "zealot_critical_strike_chance": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "zealot_critical_strike_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "zealot_damage": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ },
+ "zenith_cooldown_reduction": {
+ "slots": [
+ "Amulet",
+ "Boots",
+ "ChestArmor",
+ "Gloves",
+ "Helm",
+ "Legs",
+ "Ring",
+ "Shield"
+ ]
+ }
+}
diff --git a/assets/lang/enUS/uniques.json b/assets/lang/enUS/uniques.json
index c6e61f45..b4285fd7 100644
--- a/assets/lang/enUS/uniques.json
+++ b/assets/lang/enUS/uniques.json
@@ -1,887 +1,1477 @@
{
"100000_steps": {
+ "class": "barbarian",
+ "item_type": "Boots",
"num_inherents": 0
},
"accord_of_the_wilds": {
+ "class": "druid",
+ "item_type": "Ring",
"num_inherents": 0
},
"aegroms_schism": {
+ "class": "all",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"ahavarion_spear_of_lycander": {
+ "class": "all",
+ "item_type": "Staff",
"num_inherents": 0
},
"airidahs_inexorable_will": {
+ "class": "druid",
+ "item_type": "Ring",
"num_inherents": 0
},
"anathema_of_the_primes": {
+ "class": "all",
+ "item_type": "Sword2H",
"num_inherents": 0
},
"ancients_oath": {
+ "class": "barbarian",
+ "item_type": "Axe2H",
"num_inherents": 0
},
"andariels_visage": {
+ "class": "all",
+ "item_type": "Helm",
"num_inherents": 0
},
"arcadia": {
+ "class": "all",
+ "item_type": "Legs",
"num_inherents": 0
},
"argent_veil": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"arreats_bearing": {
+ "class": "barbarian",
+ "item_type": "Legs",
"num_inherents": 0
},
"ashearas_khanjar": {
+ "class": "rogue",
+ "item_type": "Dagger",
"num_inherents": 0
},
"assassins_stride": {
+ "class": "rogue",
+ "item_type": "Boots",
"num_inherents": 0
},
"autumnal_crown": {
+ "class": "druid",
+ "item_type": "Helm",
"num_inherents": 0
},
"axial_conduit": {
+ "class": "sorcerer",
+ "item_type": "Legs",
"num_inherents": 0
},
"azurewrath": {
+ "class": "all",
+ "item_type": "Sword",
"num_inherents": 0
},
"balazans_maxtlatl": {
+ "class": "spiritborn",
+ "item_type": "Legs",
"num_inherents": 0
},
"band_of_first_breath": {
+ "class": "spiritborn",
+ "item_type": "Ring",
"num_inherents": 0
},
"bands_of_ichorous_rose": {
+ "class": "rogue",
+ "item_type": "Gloves",
"num_inherents": 0
},
"bane_of_ahjad-den": {
+ "class": "barbarian",
+ "item_type": "Gloves",
"num_inherents": 0
},
"banished_lords_talisman": {
+ "class": "all",
+ "item_type": "Amulet",
"num_inherents": 0
},
"bastion_of_sir_matthias": {
+ "class": "all",
+ "item_type": "Shield",
"num_inherents": 2
},
"battle_trance": {
+ "class": "barbarian",
+ "item_type": "Amulet",
"num_inherents": 0
},
"beastfall_boots": {
+ "class": "rogue",
+ "item_type": "Boots",
"num_inherents": 0
},
"bindings_of_attrition": {
+ "class": "all",
+ "item_type": "Legs",
"num_inherents": 0
},
"black_river": {
+ "class": "necromancer",
+ "item_type": "Scythe",
"num_inherents": 0
},
"blood-mad_idol": {
+ "class": "all",
+ "item_type": "Amulet",
"num_inherents": 0
},
"blood_artisans_cuirass": {
+ "class": "necromancer",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"blood_moon_breeches": {
+ "class": "necromancer",
+ "item_type": "Legs",
"num_inherents": 0
},
"blood_wake": {
+ "class": "necromancer",
+ "item_type": "Boots",
"num_inherents": 0
},
"bloodless_scream": {
+ "class": "necromancer",
+ "item_type": "Scythe2H",
"num_inherents": 0
},
"blue_rose": {
+ "class": "sorcerer",
+ "item_type": "Ring",
"num_inherents": 0
},
"bridle_of_torbaalos": {
+ "class": "all",
+ "item_type": "Helm",
"num_inherents": 0
},
"cage_of_madness": {
+ "class": "all",
+ "item_type": "Helm",
"num_inherents": 0
},
"cassias_grace": {
+ "class": "rogue",
+ "item_type": "Bow",
"num_inherents": 0
},
"cathedrals_song": {
+ "class": "all",
+ "item_type": "Shield",
"num_inherents": 2
},
"chainscourged_mail": {
+ "class": "barbarian",
+ "item_type": "Legs",
"num_inherents": 0
},
"cluckeye": {
+ "class": "all",
+ "item_type": "Bow",
"num_inherents": 0
},
"cluckonomicon": {
+ "class": "all",
+ "item_type": "Staff",
"num_inherents": 0
},
"condemnation": {
+ "class": "rogue",
+ "item_type": "Dagger",
"num_inherents": 0
},
"coop_de_grâce": {
+ "class": "all",
+ "item_type": "Polearm",
"num_inherents": 0
},
"cowl_of_malefic_torment": {
+ "class": "all",
+ "item_type": "Helm",
"num_inherents": 0
},
"cowl_of_the_nameless": {
+ "class": "rogue",
+ "item_type": "Helm",
"num_inherents": 0
},
"craze_of_the_dead_god": {
+ "class": "spiritborn",
+ "item_type": "Gloves",
"num_inherents": 0
},
"crown_of_lucion": {
+ "class": "all",
+ "item_type": "Helm",
"num_inherents": 0
},
"cruors_embrace": {
+ "class": "necromancer",
+ "item_type": "Gloves",
"num_inherents": 0
},
"dark_howl": {
+ "class": "druid",
+ "item_type": "Gloves",
"num_inherents": 0
},
"dark_stalkers_medallion": {
+ "class": "barbarian",
+ "item_type": "Amulet",
"num_inherents": 0
},
"dawnfire": {
+ "class": "all",
+ "item_type": "Gloves",
"num_inherents": 0
},
"deathgrip": {
+ "class": "necromancer",
+ "item_type": "Gloves",
"num_inherents": 0
},
"deathless_visage": {
+ "class": "necromancer",
+ "item_type": "Helm",
"num_inherents": 0
},
"deathmask_of_nirmitruq": {
+ "class": "rogue",
+ "item_type": "Helm",
"num_inherents": 0
},
"deaths_pavane": {
+ "class": "rogue",
+ "item_type": "Legs",
"num_inherents": 0
},
"deathspeakers_pendant": {
+ "class": "necromancer",
+ "item_type": "Amulet",
"num_inherents": 0
},
"desperate_march": {
+ "class": "rogue",
+ "item_type": "Boots",
"num_inherents": 0
},
"dirge_of_airidah": {
+ "class": "druid",
+ "item_type": "Ring",
"num_inherents": 0
},
"dirge_of_odium": {
+ "class": "all",
+ "item_type": "Axe2H",
"num_inherents": 0
},
"dolmen_stone": {
+ "class": "druid",
+ "item_type": "Amulet",
"num_inherents": 0
},
"doombringer": {
+ "class": "all",
+ "item_type": "Sword",
"num_inherents": 0
},
"drognans_anguish": {
+ "class": "sorcerer",
+ "item_type": "Ring",
"num_inherents": 0
},
"eaglehorn": {
+ "class": "rogue",
+ "item_type": "Bow",
"num_inherents": 0
},
"earthbreaker": {
+ "class": "druid",
+ "item_type": "Ring",
"num_inherents": 0
},
"ebonpiercer": {
+ "class": "necromancer",
+ "item_type": "Amulet",
"num_inherents": 0
},
"echo_of_kwatli": {
+ "class": "spiritborn",
+ "item_type": "Amulet",
"num_inherents": 0
},
"eggcecutioner": {
+ "class": "all",
+ "item_type": "Scythe2H",
"num_inherents": 0
},
"eggis": {
+ "class": "all",
+ "item_type": "Shield",
"num_inherents": 2
},
"eldruin_sword_of_justice": {
+ "class": "all",
+ "item_type": "Sword",
"num_inherents": 1
},
"elegy": {
+ "class": "all",
+ "item_type": "Sword",
"num_inherents": 0
},
"emberfury": {
+ "class": "sorcerer",
+ "item_type": "Amulet",
"num_inherents": 0
},
"emblem_of_staalbreak": {
+ "class": "barbarian",
+ "item_type": "Amulet",
"num_inherents": 0
},
"endurant_faith": {
+ "class": "all",
+ "item_type": "Gloves",
"num_inherents": 0
},
"esadoras_overflowing_cameo": {
+ "class": "sorcerer",
+ "item_type": "Amulet",
"num_inherents": 0
},
"esus_heirloom": {
+ "class": "sorcerer",
+ "item_type": "Boots",
"num_inherents": 0
},
"etnas_lost_dagger": {
+ "class": "rogue",
+ "item_type": "Dagger",
"num_inherents": 0
},
"eye_of_baal": {
+ "class": "all",
+ "item_type": "Focus",
"num_inherents": 0
},
"eyes_in_the_dark": {
+ "class": "rogue",
+ "item_type": "Legs",
"num_inherents": 0
},
"fang_of_the_vipermagi": {
+ "class": "sorcerer",
+ "item_type": "Dagger",
"num_inherents": 0
},
"fields_of_crimson": {
+ "class": "barbarian",
+ "item_type": "Sword2H",
"num_inherents": 0
},
"fist_of_the_iron_rose": {
+ "class": "rogue",
+ "item_type": "Gloves",
"num_inherents": 0
},
"fists_of_fate": {
+ "class": "all",
+ "item_type": "Gloves",
"num_inherents": 0
},
"flamescar": {
+ "class": "sorcerer",
+ "item_type": "Wand",
"num_inherents": 0
},
"flameweaver": {
+ "class": "sorcerer",
+ "item_type": "Gloves",
"num_inherents": 0
},
"fleshrender": {
+ "class": "druid",
+ "item_type": "Mace",
"num_inherents": 0
},
"fleshwrit_carapace": {
+ "class": "all",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"flickerstep": {
+ "class": "all",
+ "item_type": "Boots",
"num_inherents": 0
},
"footfalls_of_the_waning_world": {
+ "class": "all",
+ "item_type": "Boots",
"num_inherents": 0
},
"fractured_runestone": {
+ "class": "druid",
+ "item_type": "Ring",
"num_inherents": 0
},
"fractured_winterglass": {
+ "class": "sorcerer",
+ "item_type": "Amulet",
"num_inherents": 0
},
"frostburn": {
+ "class": "all",
+ "item_type": "Gloves",
"num_inherents": 0
},
"fury_of_the_wilds": {
+ "class": "druid",
+ "item_type": "Ring",
"num_inherents": 0
},
"galvanic_azurite": {
+ "class": "sorcerer",
+ "item_type": "Ring",
"num_inherents": 0
},
"gate_of_the_red_dawn": {
+ "class": "all",
+ "item_type": "Shield",
"num_inherents": 2
},
"gathlens_birthright": {
+ "class": "druid",
+ "item_type": "Helm",
"num_inherents": 0
},
"gauntlets_of_sheol": {
+ "class": "all",
+ "item_type": "Gloves",
"num_inherents": 0
},
"gift_of_frost": {
+ "class": "sorcerer",
+ "item_type": "Boots",
"num_inherents": 0
},
"gladiators_triumph": {
+ "class": "rogue",
+ "item_type": "Gloves",
"num_inherents": 0
},
"gloves_of_the_illuminator": {
+ "class": "sorcerer",
+ "item_type": "Gloves",
"num_inherents": 0
},
"godslayer_crown": {
+ "class": "all",
+ "item_type": "Helm",
"num_inherents": 0
},
"gohrs_devastating_grips": {
+ "class": "barbarian",
+ "item_type": "Gloves",
"num_inherents": 0
},
"gospel_of_the_devotee": {
+ "class": "necromancer",
+ "item_type": "FocusBookOffHand",
"num_inherents": 0
},
"grasp_of_shadow": {
+ "class": "rogue",
+ "item_type": "Gloves",
"num_inherents": 0
},
"gravewalkers_hand": {
+ "class": "necromancer",
+ "item_type": "Gloves",
"num_inherents": 0
},
"greatstaff_of_the_crone": {
+ "class": "druid",
+ "item_type": "Staff",
"num_inherents": 0
},
"greaves_of_the_empty_tomb": {
+ "class": "necromancer",
+ "item_type": "Boots",
"num_inherents": 0
},
"greenwalkers_oath": {
+ "class": "druid",
+ "item_type": "Boots",
"num_inherents": 0
},
"greenwalkers_signet": {
+ "class": "druid",
+ "item_type": "Ring",
"num_inherents": 0
},
"griswolds_opus": {
+ "class": "all",
+ "item_type": "Sword",
"num_inherents": 0
},
"hail_of_verglas": {
+ "class": "sorcerer",
+ "item_type": "Helm",
"num_inherents": 0
},
"hand_of_apotheosis": {
+ "class": "all",
+ "item_type": "Gloves",
"num_inherents": 0
},
"hands_of_the_worldbreaker": {
+ "class": "all",
+ "item_type": "Gloves",
"num_inherents": 0
},
"hangmans_hand": {
+ "class": "necromancer",
+ "item_type": "Gloves",
"num_inherents": 0
},
"harlequin_crest": {
+ "class": "all",
+ "item_type": "Helm",
"num_inherents": 0
},
"harmony_of_ebewaka": {
+ "class": "spiritborn",
+ "item_type": "Helm",
"num_inherents": 0
},
"heart_of_azgar": {
+ "class": "druid",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"hecaton_chasm": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"heir_of_perdition": {
+ "class": "all",
+ "item_type": "Helm",
"num_inherents": 0
},
"hellbrand_signet": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"hellhammer": {
+ "class": "barbarian",
+ "item_type": "Mace2H",
"num_inherents": 0
},
"hellhounds_sabatons": {
+ "class": "all",
+ "item_type": "Boots",
"num_inherents": 0
},
"herald_of_zakarum": {
+ "class": "all",
+ "item_type": "Shield",
"num_inherents": 3
},
"heralds_morningstar": {
+ "class": "all",
+ "item_type": "Mace",
"num_inherents": 0
},
"hesha_e_kesungi": {
+ "class": "spiritborn",
+ "item_type": "Gloves",
"num_inherents": 0
},
"hooves_of_the_mountain_god": {
+ "class": "barbarian",
+ "item_type": "Boots",
"num_inherents": 0
},
"howl_from_below": {
+ "class": "necromancer",
+ "item_type": "Gloves",
"num_inherents": 0
},
"hunters_zenith": {
+ "class": "druid",
+ "item_type": "Ring",
"num_inherents": 0
},
"iceheart_brais": {
+ "class": "sorcerer",
+ "item_type": "Legs",
"num_inherents": 0
},
"ifehs_dire_totem": {
+ "class": "druid",
+ "item_type": "OffHandTotem",
"num_inherents": 0
},
"indiras_memory": {
+ "class": "necromancer",
+ "item_type": "Legs",
"num_inherents": 0
},
"infernal_homunculus": {
+ "class": "all",
+ "item_type": "Focus",
"num_inherents": 0
},
"insatiable_fury": {
+ "class": "druid",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"jacinth_shell": {
+ "class": "spiritborn",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"judgment_of_auriel": {
+ "class": "all",
+ "item_type": "Amulet",
"num_inherents": 0
},
"judicants_glaivehelm": {
+ "class": "all",
+ "item_type": "Helm",
"num_inherents": 0
},
"kabraxis_will": {
+ "class": "all",
+ "item_type": "Legs",
"num_inherents": 0
},
"kessimes_legacy": {
+ "class": "necromancer",
+ "item_type": "Legs",
"num_inherents": 0
},
"khamsin_steppewalkers": {
+ "class": "druid",
+ "item_type": "Boots",
"num_inherents": 0
},
"kilt_of_blackwing": {
+ "class": "druid",
+ "item_type": "Legs",
"num_inherents": 0
},
"levin_grasp": {
+ "class": "sorcerer",
+ "item_type": "Gloves",
"num_inherents": 0
},
"lidless_wall": {
+ "class": "necromancer",
+ "item_type": "Shield",
"num_inherents": 2
},
"lights_rebuke": {
+ "class": "all",
+ "item_type": "Flail",
"num_inherents": 0
},
"litany_of_sable": {
+ "class": "all",
+ "item_type": "Dagger",
"num_inherents": 0
},
"locrans_talisman": {
+ "class": "all",
+ "item_type": "Amulet",
"num_inherents": 0
},
"loyaltys_mantle": {
+ "class": "spiritborn",
+ "item_type": "Helm",
"num_inherents": 0
},
"lurid_pact": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"mace_of_king_leoric": {
+ "class": "necromancer",
+ "item_type": "Mace",
"num_inherents": 0
},
"mad_wolfs_glee": {
+ "class": "druid",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"malefic_crescent": {
+ "class": "druid",
+ "item_type": "Amulet",
"num_inherents": 0
},
"mantle_of_mountains_fury": {
+ "class": "barbarian",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"mantle_of_the_grey": {
+ "class": "all",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"march_of_the_stalwart_soul": {
+ "class": "all",
+ "item_type": "Boots",
"num_inherents": 0
},
"mark_of_the_old_wolf": {
+ "class": "druid",
+ "item_type": "Ring",
"num_inherents": 0
},
"melted_heart_of_selig": {
+ "class": "all",
+ "item_type": "Amulet",
"num_inherents": 0
},
"might_of_qual-kehk": {
+ "class": "barbarian",
+ "item_type": "Gloves",
"num_inherents": 0
},
"might_of_the_ursine": {
+ "class": "druid",
+ "item_type": "Ring",
"num_inherents": 0
},
"misericorde": {
+ "class": "rogue",
+ "item_type": "Sword",
"num_inherents": 0
},
"mjölnic_ryng": {
+ "class": "druid",
+ "item_type": "Ring",
"num_inherents": 0
},
"molochs_beating_flame": {
+ "class": "all",
+ "item_type": "Amulet",
"num_inherents": 0
},
"molten_band": {
+ "class": "sorcerer",
+ "item_type": "Ring",
"num_inherents": 0
},
"morlu_fleshward": {
+ "class": "all",
+ "item_type": "Legs",
"num_inherents": 0
},
"mothers_embrace": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"mutilator_plate": {
+ "class": "necromancer",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"nails_of_the_gore-crowned": {
+ "class": "all",
+ "item_type": "Helm",
"num_inherents": 0
},
"nesekem_the_herald": {
+ "class": "spiritborn",
+ "item_type": "Glaive",
"num_inherents": 0
},
"night_terror": {
+ "class": "all",
+ "item_type": "Amulet",
"num_inherents": 0
},
"nomads_longing_heart": {
+ "class": "barbarian",
+ "item_type": "Amulet",
"num_inherents": 0
},
"okuns_catalyst": {
+ "class": "sorcerer",
+ "item_type": "FocusBookOffHand",
"num_inherents": 1
},
"omen_of_pain": {
+ "class": "necromancer",
+ "item_type": "Ring",
"num_inherents": 0
},
"onyx_soul": {
+ "class": "sorcerer",
+ "item_type": "FocusBookOffHand",
"num_inherents": 0
},
"ophidian_iris": {
+ "class": "sorcerer",
+ "item_type": "Amulet",
"num_inherents": 0
},
"orphan_maker": {
+ "class": "rogue",
+ "item_type": "Crossbow2H",
"num_inherents": 0
},
"orsivane": {
+ "class": "sorcerer",
+ "item_type": "Mace",
"num_inherents": 0
},
"overkill": {
+ "class": "barbarian",
+ "item_type": "Mace2H",
"num_inherents": 0
},
"pact_of_bone": {
+ "class": "necromancer",
+ "item_type": "Ring",
"num_inherents": 0
},
"paingorgers_gauntlets": {
+ "class": "all",
+ "item_type": "Gloves",
"num_inherents": 0
},
"path_of_the_emissary": {
+ "class": "spiritborn",
+ "item_type": "Boots",
"num_inherents": 0
},
"path_of_tragoul": {
+ "class": "necromancer",
+ "item_type": "Boots",
"num_inherents": 0
},
"peacemongers_signet": {
+ "class": "spiritborn",
+ "item_type": "Ring",
"num_inherents": 0
},
"penitent_greaves": {
+ "class": "all",
+ "item_type": "Boots",
"num_inherents": 0
},
"pitfighters_gull": {
+ "class": "rogue",
+ "item_type": "Ring",
"num_inherents": 0
},
"protean_heart": {
+ "class": "spiritborn",
+ "item_type": "Amulet",
"num_inherents": 0
},
"protection_of_the_prime": {
+ "class": "spiritborn",
+ "item_type": "Legs",
"num_inherents": 0
},
"purified_lightbringer": {
+ "class": "druid",
+ "item_type": "Mace2H",
"num_inherents": 0
},
"rage_of_harrogath": {
+ "class": "barbarian",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"raiment_of_the_infinite": {
+ "class": "sorcerer",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"raiment_of_the_sea": {
+ "class": "sorcerer",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"rakanoths_wake": {
+ "class": "all",
+ "item_type": "Boots",
"num_inherents": 0
},
"ramaladnis_magnum_opus": {
+ "class": "barbarian",
+ "item_type": "Sword",
"num_inherents": 0
},
"razorplate": {
+ "class": "all",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"red_blessing": {
+ "class": "necromancer",
+ "item_type": "Amulet",
"num_inherents": 0
},
"red_sermon": {
+ "class": "all",
+ "item_type": "Sword2H",
"num_inherents": 0
},
"rictus_of_terror": {
+ "class": "all",
+ "item_type": "Helm",
"num_inherents": 0
},
"rimeblood": {
+ "class": "sorcerer",
+ "item_type": "Gloves",
"num_inherents": 0
},
"ring_of_mendeln": {
+ "class": "necromancer",
+ "item_type": "Ring",
"num_inherents": 0
},
"ring_of_red_furor": {
+ "class": "barbarian",
+ "item_type": "Ring",
"num_inherents": 0
},
"ring_of_starless_skies": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"ring_of_the_midday_hunt": {
+ "class": "spiritborn",
+ "item_type": "Ring",
"num_inherents": 0
},
"ring_of_the_midnight_sun": {
+ "class": "spiritborn",
+ "item_type": "Ring",
"num_inherents": 0
},
"ring_of_the_ravenous": {
+ "class": "barbarian",
+ "item_type": "Ring",
"num_inherents": 0
},
"ring_of_the_sacrilegious_soul": {
+ "class": "necromancer",
+ "item_type": "Ring",
"num_inherents": 0
},
"ring_of_writhing_moon": {
+ "class": "spiritborn",
+ "item_type": "Ring",
"num_inherents": 0
},
"rod_of_kepeleke": {
+ "class": "spiritborn",
+ "item_type": "Quarterstaff",
"num_inherents": 0
},
"rotting_lightbringer": {
+ "class": "druid",
+ "item_type": "Mace2H",
"num_inherents": 0
},
"rustbitten_dirk": {
+ "class": "all",
+ "item_type": "Dagger",
"num_inherents": 0
},
"saboteurs_signet": {
+ "class": "rogue",
+ "item_type": "Ring",
"num_inherents": 0
},
"sabre_of_tsasgal": {
+ "class": "barbarian",
+ "item_type": "Sword",
"num_inherents": 0
},
"sanctis_of_kethamar": {
+ "class": "all",
+ "item_type": "Amulet",
"num_inherents": 0
},
"sanguivor_blade_of_zir": {
+ "class": "necromancer",
+ "item_type": "Sword2H",
"num_inherents": 0
},
"sashes_of_the_wretched": {
+ "class": "all",
+ "item_type": "Legs",
"num_inherents": 0
},
"scepter_of_the_three": {
+ "class": "all",
+ "item_type": "Mace2H",
"num_inherents": 0
},
"scorn_of_the_earth": {
+ "class": "spiritborn",
+ "item_type": "Boots",
"num_inherents": 0
},
"scoundrels_kiss": {
+ "class": "rogue",
+ "item_type": "Ring",
"num_inherents": 0
},
"scoundrels_leathers": {
+ "class": "rogue",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"scourge_of_duriel": {
+ "class": "all",
+ "item_type": "Flail",
"num_inherents": 0
},
"sea_lords_fine_gloves": {
+ "class": "rogue",
+ "item_type": "Gloves",
"num_inherents": 0
},
"seal_of_the_ophanim": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"seal_of_the_second_trumpet": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"seed_of_horazon": {
+ "class": "all",
+ "item_type": "Amulet",
"num_inherents": 0
},
"sepazontec": {
+ "class": "spiritborn",
+ "item_type": "Quarterstaff",
"num_inherents": 0
},
"shanars_resonance": {
+ "class": "sorcerer",
+ "item_type": "FocusBookOffHand",
"num_inherents": 0
},
"shard_of_verathiel": {
+ "class": "all",
+ "item_type": "Sword",
"num_inherents": 0
},
"shattered_vow": {
+ "class": "all",
+ "item_type": "Polearm",
"num_inherents": 0
},
"shroud_of_false_death": {
+ "class": "all",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"shroud_of_khanduras": {
+ "class": "rogue",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"shrouded_gift": {
+ "class": "rogue",
+ "item_type": "Legs",
"num_inherents": 0
},
"sidhe_bindings": {
+ "class": "sorcerer",
+ "item_type": "Gloves",
"num_inherents": 0
},
"signet_of_pelghain": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"sire_of_sin": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"skyhunter": {
+ "class": "rogue",
+ "item_type": "Bow",
"num_inherents": 0
},
"sliver_of_hate": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"soulbrand": {
+ "class": "all",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"spine_of_tathamet": {
+ "class": "all",
+ "item_type": "Mace",
"num_inherents": 0
},
"staff_of_endless_rage": {
+ "class": "sorcerer",
+ "item_type": "Staff",
"num_inherents": 0
},
"staff_of_lam_esen": {
+ "class": "sorcerer",
+ "item_type": "Staff",
"num_inherents": 0
},
"staff_of_zerae": {
+ "class": "sorcerer",
+ "item_type": "Staff",
"num_inherents": 0
},
"starfall_coronet": {
+ "class": "sorcerer",
+ "item_type": "Helm",
"num_inherents": 0
},
"stone_of_vehemen": {
+ "class": "druid",
+ "item_type": "OffHandTotem",
"num_inherents": 0
},
"storms_companion": {
+ "class": "druid",
+ "item_type": "Legs",
"num_inherents": 0
},
"strike_of_stormhorn": {
+ "class": "sorcerer",
+ "item_type": "FocusBookOffHand",
"num_inherents": 0
},
"sunbirds_gorget": {
+ "class": "spiritborn",
+ "item_type": "Amulet",
"num_inherents": 0
},
"sunbrand": {
+ "class": "all",
+ "item_type": "Flail",
"num_inherents": 0
},
"sundered_night": {
+ "class": "all",
+ "item_type": "Axe2H",
"num_inherents": 0
},
"sunstained_war-crozier": {
+ "class": "spiritborn",
+ "item_type": "Quarterstaff",
"num_inherents": 0
},
"supplication": {
+ "class": "all",
+ "item_type": "Sword",
"num_inherents": 0
},
"tal_rashas_iridescent_loop": {
+ "class": "sorcerer",
+ "item_type": "Ring",
"num_inherents": 0
},
"tassets_of_the_dawning_sky": {
+ "class": "all",
+ "item_type": "Legs",
"num_inherents": 0
},
"temerity": {
+ "class": "all",
+ "item_type": "Legs",
"num_inherents": 0
},
"tempest_roar": {
+ "class": "druid",
+ "item_type": "Helm",
"num_inherents": 0
},
"the_basilisk": {
+ "class": "druid",
+ "item_type": "Staff",
"num_inherents": 0
},
"the_blade_of_sight_aflame": {
+ "class": "all",
+ "item_type": "Sword",
"num_inherents": 0
},
"the_butchers_cleaver": {
+ "class": "all",
+ "item_type": "Axe",
"num_inherents": 0
},
"the_eightfold_idol": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"the_fecund_seal": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"the_gloom_ward": {
+ "class": "necromancer",
+ "item_type": "Shield",
"num_inherents": 2
},
"the_grandfather": {
+ "class": "all",
+ "item_type": "Sword2H",
"num_inherents": 1
},
"the_hand_of_naz": {
+ "class": "necromancer",
+ "item_type": "Gloves",
"num_inherents": 0
},
"the_hemat_stone": {
+ "class": "all",
+ "item_type": "Amulet",
"num_inherents": 0
},
"the_maestro": {
+ "class": "rogue",
+ "item_type": "Dagger",
"num_inherents": 0
},
"the_mortacrux": {
+ "class": "necromancer",
+ "item_type": "Dagger",
"num_inherents": 0
},
"the_oculus": {
+ "class": "sorcerer",
+ "item_type": "Wand",
"num_inherents": 0
},
"the_open_eye_of_gorgorra": {
+ "class": "barbarian",
+ "item_type": "Amulet",
"num_inherents": 0
},
"the_relentless_heart": {
+ "class": "barbarian",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"the_third_blade": {
+ "class": "barbarian",
+ "item_type": "Sword",
"num_inherents": 0
},
"the_umbracrux": {
+ "class": "rogue",
+ "item_type": "Dagger",
"num_inherents": 0
},
"the_undercrown": {
+ "class": "necromancer",
+ "item_type": "Helm",
"num_inherents": 0
},
"the_unmaker": {
+ "class": "necromancer",
+ "item_type": "Helm",
"num_inherents": 0
},
"thousand-eye_reaver": {
+ "class": "all",
+ "item_type": "Axe",
"num_inherents": 0
},
"thrice-woven_nightmare": {
+ "class": "all",
+ "item_type": "Gloves",
"num_inherents": 0
},
"thundergods_blessing": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"tibaults_will": {
+ "class": "all",
+ "item_type": "Legs",
"num_inherents": 0
},
"tuskhelm_of_joritz_the_mighty": {
+ "class": "barbarian",
+ "item_type": "Helm",
"num_inherents": 0
},
"twin_strikes": {
+ "class": "barbarian",
+ "item_type": "Gloves",
"num_inherents": 0
},
"tyraels_might": {
+ "class": "all",
+ "item_type": "ChestArmor",
"num_inherents": 1
},
"ugly_bastard_helm": {
+ "class": "barbarian",
+ "item_type": "Helm",
"num_inherents": 0
},
"unbroken_chain": {
+ "class": "barbarian",
+ "item_type": "Amulet",
"num_inherents": 0
},
"unsung_ascetics_wraps": {
+ "class": "druid",
+ "item_type": "Gloves",
"num_inherents": 0
},
"vasilys_prayer": {
+ "class": "druid",
+ "item_type": "Helm",
"num_inherents": 0
},
"vengeful_sinew": {
+ "class": "necromancer",
+ "item_type": "ChestArmor",
"num_inherents": 0
},
"vision_of_the_firestorm": {
+ "class": "sorcerer",
+ "item_type": "Helm",
"num_inherents": 0
},
"vox_omnium": {
+ "class": "sorcerer",
+ "item_type": "Staff",
"num_inherents": 0
},
"ward_of_the_white_dove": {
+ "class": "all",
+ "item_type": "Shield",
"num_inherents": 2
},
"waxing_gibbous": {
+ "class": "druid",
+ "item_type": "Axe",
"num_inherents": 0
},
"wendigo_brand": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"widows_web": {
+ "class": "spiritborn",
+ "item_type": "Amulet",
"num_inherents": 0
},
"wildheart_hunger": {
+ "class": "druid",
+ "item_type": "Boots",
"num_inherents": 0
},
"will_of_rathma": {
+ "class": "necromancer",
+ "item_type": "Amulet",
"num_inherents": 0
},
"will_of_stone": {
+ "class": "druid",
+ "item_type": "Helm",
"num_inherents": 0
},
"windforce": {
+ "class": "rogue",
+ "item_type": "Bow",
"num_inherents": 0
},
"word_of_hakan": {
+ "class": "rogue",
+ "item_type": "Amulet",
"num_inherents": 0
},
"wound_drinker": {
+ "class": "spiritborn",
+ "item_type": "Ring",
"num_inherents": 0
},
"wreath_of_auric_laurel": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"writhing_band_of_trickery": {
+ "class": "rogue",
+ "item_type": "Ring",
"num_inherents": 0
},
"wushe_nak_pa": {
+ "class": "spiritborn",
+ "item_type": "Glaive",
"num_inherents": 0
},
"wyrdskin": {
+ "class": "all",
+ "item_type": "Gloves",
"num_inherents": 0
},
"xfals_corroded_signet": {
+ "class": "all",
+ "item_type": "Ring",
"num_inherents": 0
},
"yens_blessing": {
+ "class": "all",
+ "item_type": "Boots",
"num_inherents": 0
}
}
diff --git a/src/config/profile_models.py b/src/config/profile_models.py
index f75b3f31..720cc97b 100644
--- a/src/config/profile_models.py
+++ b/src/config/profile_models.py
@@ -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
min_percent_of_affix: int = Field(default=0, alias="minPercentOfAffix")
@field_validator("name")
@@ -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
@@ -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")
+ 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
@@ -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)
@@ -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:
+ self.affix_pool = [AffixFilterCountModel(count=[], min_count=0)]
return self
@@ -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")
@model_validator(mode="before")
def aspects_must_exist(self) -> ProfileModel:
diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py
index 910cd433..68df398c 100644
--- a/src/gui/importer/d4builds.py
+++ b/src/gui/importer/d4builds.py
@@ -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,
@@ -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()
@@ -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:
- mythic_names.append(unique_name)
- continue
try:
item_filter.unique_aspect = [AspectUniqueFilterModel(name=unique_name)]
except Exception:
@@ -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
@@ -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
diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py
index 2835a09e..5a61d56d 100644
--- a/src/gui/importer/gui_common.py
+++ b/src/gui/importer/gui_common.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import datetime
import functools
import logging
@@ -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
@@ -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)
@@ -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))
@@ -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)
diff --git a/src/gui/importer/importer_config.py b/src/gui/importer/importer_config.py
index 26411a63..f7fb2385 100644
--- a/src/gui/importer/importer_config.py
+++ b/src/gui/importer/importer_config.py
@@ -10,3 +10,4 @@ class ImportConfig:
require_greater_affixes: bool
export_paragon: bool = False
custom_file_name: str | None = None
+ filename_components: dict = None
diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py
index 8fd5fe21..1522b7d3 100644
--- a/src/gui/importer/maxroll.py
+++ b/src/gui/importer/maxroll.py
@@ -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,
@@ -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"]
@@ -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.")
@@ -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
@@ -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
diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py
index 6736ef26..26511165 100644
--- a/src/gui/importer/mobalytics.py
+++ b/src/gui/importer/mobalytics.py
@@ -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,
@@ -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])):
@@ -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.")
@@ -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])
]
@@ -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
@@ -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:
diff --git a/src/gui/importer_window.py b/src/gui/importer_window.py
index d95214c2..b01bc13b 100644
--- a/src/gui/importer_window.py
+++ b/src/gui/importer_window.py
@@ -5,17 +5,7 @@
from PyQt6.QtCore import QObject, QPoint, QRunnable, QSettings, QSize, Qt, QThreadPool, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QIcon
-from PyQt6.QtWidgets import (
- QGridLayout,
- QHBoxLayout,
- QLabel,
- QLineEdit,
- QMainWindow,
- QPushButton,
- QTextEdit,
- QVBoxLayout,
- QWidget,
-)
+from PyQt6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QMainWindow, QPushButton, QTextEdit, QVBoxLayout, QWidget
from src.config.loader import IniConfigLoader
from src.gui.importer.d4builds import import_d4builds
@@ -72,18 +62,65 @@ def __init__(self, parent=None):
url_hbox.addWidget(self.input_box)
layout.addLayout(url_hbox)
- # Filename input
- filename_hbox = QHBoxLayout()
- filename_label = QLabel("Custom file name:")
- filename_hbox.addWidget(filename_label)
+ # Filename input with inline filename options row
self.filename_input_box = QLineEdit()
self.filename_input_box.setPlaceholderText("Leave blank for default filename")
- filename_hbox.addWidget(self.filename_input_box)
- layout.addLayout(filename_hbox)
- # Checkboxes
+ self.filename_label = QLabel("Custom file name:")
+ self.filename_label_layout = QHBoxLayout()
+ self.filename_label_layout.addWidget(self.filename_label)
+ self.filename_label_layout.addWidget(self.filename_input_box)
+
+ # Filename Options label and checkboxes in a single row
+ self.filename_options_label = QLabel("Filename Options:")
+ self.filename_options_label.setFixedWidth(120)
+
+ self.include_source_checkbox = CheckmarkCheckBox("Source")
+ self.include_source_checkbox.setToolTip("Include the build source (e.g., maxroll, d4builds, mobalytics)")
+ self.include_source_checkbox.setChecked(True)
+
+ self.include_season_checkbox = CheckmarkCheckBox("Season")
+ self.include_season_checkbox.setToolTip("Include the season number (e.g., s5)")
+ self.include_season_checkbox.setChecked(True)
+
+ self.include_class_checkbox = CheckmarkCheckBox("Class")
+ self.include_class_checkbox.setToolTip("Include the character class (e.g., Barbarian, Druid)")
+ self.include_class_checkbox.setChecked(True)
+
+ self.include_header_checkbox = CheckmarkCheckBox("Build Name")
+ self.include_header_checkbox.setToolTip("Include the main build name/guide title")
+ self.include_header_checkbox.setChecked(True)
+
+ self.include_subbuild_checkbox = CheckmarkCheckBox("Sub Build")
+ self.include_subbuild_checkbox.setToolTip("Include the sub-build/variant name")
+ self.include_subbuild_checkbox.setChecked(True)
+
+ self.filename_options_hbox = QHBoxLayout()
+ self.filename_options_hbox.addWidget(self.filename_options_label)
+ self.filename_options_hbox.addWidget(self.include_source_checkbox)
+ self.filename_options_hbox.addWidget(self.include_season_checkbox)
+ self.filename_options_hbox.addWidget(self.include_class_checkbox)
+ self.filename_options_hbox.addWidget(self.include_header_checkbox)
+ self.filename_options_hbox.addWidget(self.include_subbuild_checkbox)
+ self.filename_options_hbox.addStretch()
+
+ layout.addLayout(self.filename_label_layout)
+ layout.addLayout(self.filename_options_hbox)
+
+ # Generate button
+ button_hbox = QHBoxLayout()
+ self.generate_button = QPushButton("Generate")
+ self.generate_button.setEnabled(False)
+ self.generate_button.clicked.connect(self._generate_button_click)
+ button_hbox.addWidget(self.generate_button)
+ layout.addLayout(button_hbox)
+
+ # Import Options label and checkboxes in a single row
+ self.import_options_label = QLabel("Import Options:")
+ self.import_options_label.setFixedWidth(120)
+
self.import_aspect_upgrades_checkbox = self._generate_checkbox(
- "Import Aspect Upgrades",
+ "Aspect Upgrades",
"import_aspect_upgrades",
"If legendary aspects are in the build, do you want an aspect upgrades section generated for them?",
)
@@ -105,7 +142,7 @@ def __init__(self, parent=None):
)
self.export_paragon_checkbox = self._generate_checkbox(
- "Import Paragon",
+ "Paragon",
"export_paragon",
"Import Paragon boards into your profile for the integrated Paragon overlay.",
"false",
@@ -130,27 +167,16 @@ def disable_require_if_import_disabled():
# Connect toggle logic
self.import_gas_checkbox.stateChanged.connect(lambda: disable_require_if_import_disabled())
- # Use a grid layout to ensure checkboxes align vertically in columns
- checkbox_grid = QGridLayout()
- checkbox_grid.setContentsMargins(0, 10, 0, 10)
- checkbox_grid.setSpacing(10)
-
- checkbox_grid.addWidget(self.import_aspect_upgrades_checkbox, 0, 0)
- checkbox_grid.addWidget(self.import_gas_checkbox, 0, 1)
- checkbox_grid.addWidget(self.require_all_gas_checkbox, 0, 2)
+ self.import_options_hbox = QHBoxLayout()
+ self.import_options_hbox.addWidget(self.import_options_label)
+ self.import_options_hbox.addWidget(self.import_aspect_upgrades_checkbox)
+ self.import_options_hbox.addWidget(self.add_to_profiles_checkbox)
+ self.import_options_hbox.addWidget(self.import_gas_checkbox)
+ self.import_options_hbox.addWidget(self.require_all_gas_checkbox)
+ self.import_options_hbox.addWidget(self.export_paragon_checkbox)
+ self.import_options_hbox.addStretch()
- checkbox_grid.addWidget(self.export_paragon_checkbox, 1, 0)
- checkbox_grid.addWidget(self.add_to_profiles_checkbox, 1, 1)
-
- layout.addLayout(checkbox_grid)
-
- # Generate button
- button_hbox = QHBoxLayout()
- self.generate_button = QPushButton("Generate")
- self.generate_button.setEnabled(False)
- self.generate_button.clicked.connect(self._generate_button_click)
- button_hbox.addWidget(self.generate_button)
- layout.addLayout(button_hbox)
+ layout.addLayout(self.import_options_hbox)
# Log output
log_label = QLabel("Log:")
@@ -211,6 +237,16 @@ def _handle_text_changed(self, text):
"""Enable/disable generate button based on input."""
self.generate_button.setEnabled(bool(text.strip()))
+ def _get_filename_components(self) -> dict:
+ """Build and return the filename_components dict from checkbox states."""
+ return {
+ "include_source": self.include_source_checkbox.isChecked(),
+ "include_season": self.include_season_checkbox.isChecked(),
+ "include_class": self.include_class_checkbox.isChecked(),
+ "include_header": self.include_header_checkbox.isChecked(),
+ "include_subbuild": self.include_subbuild_checkbox.isChecked(),
+ }
+
def _generate_button_click(self):
self.log_output.clear()
"""Handle generate button click"""
@@ -228,6 +264,7 @@ def _generate_button_click(self):
self.require_all_gas_checkbox.isChecked(),
self.export_paragon_checkbox.isChecked(),
custom_filename,
+ self._get_filename_components() if not custom_filename else None,
)
if "maxroll" in url:
diff --git a/src/gui/models/activity_log_widget.py b/src/gui/models/activity_log_widget.py
index c745cc50..551c3606 100644
--- a/src/gui/models/activity_log_widget.py
+++ b/src/gui/models/activity_log_widget.py
@@ -1,13 +1,15 @@
from __future__ import annotations
import datetime
+import functools
import logging
from typing import TYPE_CHECKING
import yaml
-from PyQt6.QtCore import QMimeData, Qt
+from PyQt6.QtCore import QMimeData, QSettings, Qt
from PyQt6.QtGui import QDrag
from PyQt6.QtWidgets import (
+ QDialog,
QFrame,
QGraphicsOpacityEffect,
QGridLayout,
@@ -24,9 +26,17 @@
)
from src.config.loader import IniConfigLoader
-from src.config.profile_models import ProfileModel
+from src.config.profile_models import DynamicItemFilterModel, ItemFilterModel, ProfileModel
from src.config.settings_models import IS_HOTKEY_KEY
+from src.gui.importer.d4builds import import_d4builds
+from src.gui.importer.gui_common import add_to_profiles, save_as_profile
+from src.gui.importer.importer_config import ImportConfig
+from src.gui.importer.maxroll import import_maxroll
+from src.gui.importer.mobalytics import import_mobalytics
+from src.gui.importer_window import THREADPOOL, _Worker
from src.gui.models.checkmark_checkbox import CheckmarkCheckBox
+from src.gui.models.dialog import CreateProfileDialog
+from src.gui.profile_editor.paper_doll import BASE_GEAR_SLOTS, get_weapon_slots
from src.item.filter import _UniqueKeyLoader
if TYPE_CHECKING:
@@ -155,12 +165,14 @@ def __init__(self, parent=None):
action_layout = QHBoxLayout()
self.import_btn = QPushButton("Import Profile")
self.import_btn.setObjectName("primary")
+
+ self.create_profile_btn = QPushButton("Create Profile")
self.settings_btn = QPushButton("Settings")
self.minimize_to_tray_cb = CheckmarkCheckBox("Minimize to Tray")
self.minimize_to_tray_cb.setObjectName("switch")
- for btn in [self.import_btn, self.settings_btn]:
+ for btn in [self.import_btn, self.create_profile_btn, self.settings_btn]:
btn.setFixedHeight(34)
btn.setFixedWidth(130)
action_layout.addWidget(btn)
@@ -264,6 +276,11 @@ def refresh_profiles(self):
edit_btn.clicked.connect(lambda _, n=name: self._edit_profile(n))
header_hbox.addWidget(edit_btn)
+ refresh_btn = self._create_row_btn("Refresh")
+ refresh_btn.setToolTip("Refresh build from source URL")
+ refresh_btn.clicked.connect(lambda _, n=name: self._refresh_profile(n))
+ header_hbox.addWidget(refresh_btn)
+
delete_btn = self._create_row_btn("Delete")
delete_btn.setObjectName("delete-profile-btn")
delete_btn.setToolTip("Delete Profile")
@@ -337,6 +354,106 @@ def _delete_profile(self, name: str):
except Exception:
LOGGER.exception(f"Failed to delete profile {name}")
+ def _refresh_profile(self, name: str):
+ """Re-import the build from its source URL while merging manual rules."""
+ profiles_dir = self._config.user_dir / "profiles"
+ p_path = None
+ for ext in [".yaml", ".yml"]:
+ test_path = profiles_dir / f"{name}{ext}"
+ if test_path.exists():
+ p_path = test_path
+ break
+
+ if not p_path:
+ return
+
+ try:
+ with p_path.open(encoding="utf-8") as f:
+ config_data = yaml.load(stream=f, Loader=_UniqueKeyLoader)
+ old_model = ProfileModel(name=name, **config_data)
+ except Exception:
+ LOGGER.exception(f"Failed to load profile {name} for refresh")
+ return
+
+ url = old_model.source_url
+ if not url:
+ # Fallback for older profiles: try to extract from the top-level comment
+ with p_path.open(encoding="utf-8") as f:
+ first_line = f.readline()
+ if first_line.startswith("# http"):
+ url = first_line[2:].strip()
+
+ if not url:
+ QMessageBox.warning(self, "Refresh Profile", "No source URL found in this profile. It cannot be refreshed.")
+ return
+
+ msg = (
+ f"Are you sure you want to refresh '{name}' from its source URL?\n\n"
+ f"URL: {url}\n\n"
+ "Warning: This will update all items and aspects from the planner. "
+ "Your manual Sigil, Tribute, and Global Unique rules will be preserved."
+ )
+ if (
+ QMessageBox.question(
+ self, "Refresh Profile", msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
+ )
+ != QMessageBox.StandardButton.Yes
+ ):
+ return
+
+ # Imports needed for background worker
+
+ import_settings = QSettings("d4lf", "ImporterWindow")
+ importer_config = ImportConfig(
+ url=url,
+ import_aspect_upgrades=import_settings.value("import_aspect_upgrades", "true") == "true",
+ add_to_profiles=False,
+ import_greater_affixes=import_settings.value("import_gas", "true") == "true",
+ require_greater_affixes=import_settings.value("require_all_gas", "false") == "true",
+ export_paragon=import_settings.value("export_paragon", "false") == "true",
+ custom_file_name=name,
+ filename_components=None,
+ )
+
+ fn = import_mobalytics
+ if "maxroll" in url:
+ fn = import_maxroll
+ elif "d4builds" in url:
+ fn = import_d4builds
+
+ worker = _Worker(name=f"refresh-{name}", fn=fn, config=importer_config)
+ finish_callback = functools.partial(self._on_refresh_finished, name, old_model)
+ worker.signals.finished.connect(finish_callback)
+
+ THREADPOOL.start(worker)
+ QMessageBox.information(self, "Refresh Profile", f"Refreshing '{name}' in the background...")
+
+ def _on_refresh_finished(self, name: str, old_model: ProfileModel):
+ """Merge user-protected sections back into the refreshed profile."""
+ profiles_dir = self._config.user_dir / "profiles"
+ p_path = next(
+ (profiles_dir / f"{name}{ext}" for ext in [".yaml", ".yml"] if (profiles_dir / f"{name}{ext}").exists()),
+ None,
+ )
+
+ if p_path:
+ try:
+ with p_path.open(encoding="utf-8") as f:
+ new_config = yaml.load(stream=f, Loader=_UniqueKeyLoader)
+ new_model = ProfileModel(name=name, **new_config)
+
+ # Restore sections importers don't touch
+ new_model.sigils = old_model.sigils
+ new_model.tributes = old_model.tributes
+ new_model.global_uniques = old_model.global_uniques
+
+ save_as_profile(file_name=name, profile=new_model, url=new_model.source_url, exclude={"name"})
+ LOGGER.info(f"Refreshed profile '{name}' and restored manual rules.")
+ except Exception:
+ LOGGER.exception(f"Failed to merge manual rules into refreshed profile '{name}'")
+
+ self.refresh_profiles()
+
def _get_profile_summary(self, path: Path) -> str:
"""Peeks into the YAML using ProfileModel to build a summary tooltip."""
try:
@@ -351,6 +468,12 @@ def _get_profile_summary(self, path: Path) -> str:
model = ProfileModel(name=path.stem, **config)
summary = [f"Last Modified: {mtime}"]
+ if model.class_name and model.class_name != "unknown":
+ summary.append(f"👤 Class: {model.class_name.title()}")
+
+ if model.source_url:
+ summary.append(f"🔗 Source: {model.source_url}")
+
if model.affixes:
types = set()
for filter_dict in model.affixes:
@@ -496,8 +619,37 @@ def _connect_signals(self):
self.show_log_btn.clicked.connect(self._on_show_log_clicked)
if self._main_window:
self.import_btn.clicked.connect(self._main_window.open_import_dialog)
+ self.create_profile_btn.clicked.connect(self._create_profile)
self.settings_btn.clicked.connect(self._main_window.open_settings_dialog)
+ def _create_profile(self):
+ """Create a new empty profile and save it to disk."""
+ existing_profile_names = self._config.general.profiles
+ dialog = CreateProfileDialog(existing_profile_names, self)
+ if dialog.exec() == QDialog.DialogCode.Accepted:
+ profile_name, class_name = dialog.get_value()
+
+ # Create base items for each slot matching the current naming and class
+ all_slots = BASE_GEAR_SLOTS + get_weapon_slots(class_name)
+ initial_affixes = []
+ for slot_name, item_types, _ in all_slots:
+ # Initialize with minPower 100 as a standard baseline
+ item_filter = ItemFilterModel(item_type=item_types, min_power=100)
+ initial_affixes.append(DynamicItemFilterModel({slot_name: item_filter}))
+
+ new_profile_model = ProfileModel(name=profile_name, class_name=class_name, affixes=initial_affixes)
+ saved_file_name = save_as_profile(
+ file_name=profile_name,
+ profile=new_profile_model,
+ url="manually_created",
+ exclude={"name"},
+ backup_file=False,
+ )
+ add_to_profiles(saved_file_name)
+ self.refresh_profiles()
+ if self._main_window:
+ self._main_window.open_profile_editor(profile_name=saved_file_name)
+
def _on_config_changed(self, changed_keys: AbstractSet[str]):
"""Refresh the hotkey grid if any relevant settings changed."""
if any(k.startswith("advanced_options") for k in changed_keys):
diff --git a/src/gui/models/dialog.py b/src/gui/models/dialog.py
index 0b1c4678..16ddee93 100644
--- a/src/gui/models/dialog.py
+++ b/src/gui/models/dialog.py
@@ -1,6 +1,5 @@
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
- QCheckBox,
QComboBox,
QCompleter,
QDialog,
@@ -27,7 +26,8 @@
TributeFilterModel,
)
from src.dataloader import Dataloader
-from src.gui.importer.gui_common import MAX_POWER
+from src.gui.importer.gui_common import MAX_POWER, PLAYER_CLASSES, normalize_profile_file_name
+from src.gui.models.checkmark_checkbox import CheckmarkCheckBox
from src.gui.settings_tab import IgnoreScrollWheelComboBox
from src.item.data.item_type import ItemType
@@ -36,6 +36,16 @@ class IgnoreScrollWheelSpinBox(QSpinBox):
def __init__(self):
super().__init__()
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
+ self.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons)
+ self.setStyleSheet("""
+ QSpinBox {
+ background-color: #09090b;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ color: #e2e8f0;
+ }
+ QSpinBox:focus { border-color: #3b82f6; }
+ """)
def wheelEvent(self, event): # noqa: N802
if self.hasFocus():
@@ -48,11 +58,41 @@ class MinPowerDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Set Min Power")
- self.setFixedSize(250, 150)
- self.main_layout = QVBoxLayout()
+ self.setMinimumWidth(400)
+ self.setStyleSheet("""
+ QDialog { background-color: #1a1a1a; color: #e2e8f0; }
+ QLineEdit, QComboBox, QSpinBox {
+ background-color: #09090b;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ color: #e2e8f0;
+ padding: 4px;
+ }
+ QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; }
+ QPushButton {
+ background-color: #262626;
+ border: 1px solid #3f3f46;
+ color: #e2e8f0;
+ padding: 6px 12px;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #323232; border-color: #52525b; }
+ """)
+
+ self.main_layout = QVBoxLayout(self)
+
+ header = QLabel("Set Minimum Power")
+ header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;")
+ self.main_layout.addWidget(header)
+
+ desc = QLabel("Set the minimum item power for all filtered items in this profile.")
+ desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;")
+ desc.setWordWrap(True)
+ self.main_layout.addWidget(desc)
self.form_layout = QFormLayout()
- self.label = QLabel("Min Power:")
+ self.label = QLabel("Item Power:")
+ self.label.setStyleSheet("color: #e2e8f0;")
self.spinBox = IgnoreScrollWheelSpinBox()
self.spinBox.setRange(0, MAX_POWER)
self.spinBox.setValue(MAX_POWER)
@@ -68,7 +108,6 @@ def __init__(self, parent=None):
self.buttonLayout.addWidget(self.cancelButton)
self.main_layout.addLayout(self.buttonLayout)
- self.setLayout(self.main_layout)
def get_value(self):
return self.spinBox.value()
@@ -78,11 +117,41 @@ class MinGreaterDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Set Min Greater Affix")
- self.setFixedSize(250, 150)
- self.main_layout = QVBoxLayout()
+ self.setMinimumWidth(400)
+ self.setStyleSheet("""
+ QDialog { background-color: #1a1a1a; color: #e2e8f0; }
+ QLineEdit, QComboBox, QSpinBox {
+ background-color: #09090b;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ color: #e2e8f0;
+ padding: 4px;
+ }
+ QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; }
+ QPushButton {
+ background-color: #262626;
+ border: 1px solid #3f3f46;
+ color: #e2e8f0;
+ padding: 6px 12px;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #323232; border-color: #52525b; }
+ """)
+
+ self.main_layout = QVBoxLayout(self)
+
+ header = QLabel("Set Minimum Greater Affixes")
+ header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;")
+ self.main_layout.addWidget(header)
+
+ desc = QLabel("Set the minimum number of Greater Affixes required for all items in this profile.")
+ desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;")
+ desc.setWordWrap(True)
+ self.main_layout.addWidget(desc)
self.form_layout = QFormLayout()
- self.label = QLabel("Min Greater Affix:")
+ self.label = QLabel("GA Count:")
+ self.label.setStyleSheet("color: #e2e8f0;")
self.spinBox = IgnoreScrollWheelSpinBox()
self.spinBox.setRange(0, 4)
self.spinBox.setValue(0)
@@ -98,7 +167,6 @@ def __init__(self, parent=None):
self.buttonLayout.addWidget(self.cancelButton)
self.main_layout.addLayout(self.buttonLayout)
- self.setLayout(self.main_layout)
def get_value(self):
return self.spinBox.value()
@@ -108,11 +176,41 @@ class MinPercentDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Set Min Percent Of Affix")
- self.setFixedSize(250, 150)
- self.main_layout = QVBoxLayout()
+ self.setMinimumWidth(400)
+ self.setStyleSheet("""
+ QDialog { background-color: #1a1a1a; color: #e2e8f0; }
+ QLineEdit, QComboBox, QSpinBox {
+ background-color: #09090b;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ color: #e2e8f0;
+ padding: 4px;
+ }
+ QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; }
+ QPushButton {
+ background-color: #262626;
+ border: 1px solid #3f3f46;
+ color: #e2e8f0;
+ padding: 6px 12px;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #323232; border-color: #52525b; }
+ """)
+
+ self.main_layout = QVBoxLayout(self)
+
+ header = QLabel("Set Minimum Roll Percentage")
+ header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;")
+ self.main_layout.addWidget(header)
+
+ desc = QLabel("Update all affixes in this profile to use a minimum roll percentage threshold.")
+ desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;")
+ desc.setWordWrap(True)
+ self.main_layout.addWidget(desc)
self.form_layout = QFormLayout()
- self.label = QLabel("Min Percent Of Affix:")
+ self.label = QLabel("Roll %:")
+ self.label.setStyleSheet("color: #e2e8f0;")
self.spinBox = IgnoreScrollWheelSpinBox()
self.spinBox.setRange(0, 100)
self.spinBox.setValue(70)
@@ -128,7 +226,6 @@ def __init__(self, parent=None):
self.buttonLayout.addWidget(self.cancelButton)
self.main_layout.addLayout(self.buttonLayout)
- self.setLayout(self.main_layout)
def get_value(self):
return self.spinBox.value()
@@ -138,12 +235,42 @@ class CreateItem(QDialog):
def __init__(self, item_list: list[str], parent=None):
super().__init__(parent)
self.setWindowTitle("Create Item")
- self.setFixedSize(300, 150)
- self.main_layout = QVBoxLayout()
+ self.setMinimumWidth(400)
+ self.setStyleSheet("""
+ QDialog { background-color: #1a1a1a; color: #e2e8f0; }
+ QLineEdit, QComboBox {
+ background-color: #09090b;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ color: #e2e8f0;
+ padding: 4px;
+ }
+ QLineEdit:focus, QComboBox:focus { border-color: #3b82f6; }
+ QPushButton {
+ background-color: #262626;
+ border: 1px solid #3f3f46;
+ color: #e2e8f0;
+ padding: 6px 12px;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #323232; border-color: #52525b; }
+ """)
+
+ self.main_layout = QVBoxLayout(self)
+
+ header = QLabel("Create New Item Filter")
+ header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;")
+ self.main_layout.addWidget(header)
+
+ desc = QLabel("Enter a descriptive name for this item configuration (e.g., 'Tornado Gloves').")
+ desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;")
+ desc.setWordWrap(True)
+ self.main_layout.addWidget(desc)
self.form_layout = QFormLayout()
self.name_label = QLabel("Item Name:")
+ self.name_label.setStyleSheet("color: #e2e8f0;")
self.name_input = QLineEdit()
self.form_layout.addRow(self.name_label, self.name_input)
self.item_list = item_list
@@ -159,8 +286,6 @@ def __init__(self, item_list: list[str], parent=None):
self.main_layout.addLayout(self.form_layout)
self.main_layout.addLayout(self.buttonLayout)
- self.setLayout(self.main_layout)
-
def accept(self):
if not self.name_input.text():
QMessageBox.warning(self, "Warning", "Item name cannot be empty")
@@ -185,27 +310,131 @@ def get_value(self):
return DynamicItemFilterModel(**{item_name: item})
+class CreateProfileDialog(QDialog):
+ def __init__(self, existing_profile_names: list[str], parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Create New Profile")
+ self.setMinimumWidth(450)
+ self.setStyleSheet("""
+ QDialog { background-color: #1a1a1a; color: #e2e8f0; }
+ QLineEdit, QComboBox {
+ background-color: #09090b;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ color: #e2e8f0;
+ padding: 4px;
+ }
+ QLineEdit:focus, QComboBox:focus { border-color: #3b82f6; }
+ QPushButton {
+ background-color: #262626;
+ border: 1px solid #3f3f46;
+ color: #e2e8f0;
+ padding: 6px 12px;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #323232; border-color: #52525b; }
+ """)
+
+ self.existing_profile_names = existing_profile_names
+ self.main_layout = QVBoxLayout(self)
+
+ header = QLabel("Create New Profile")
+ header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;")
+ self.main_layout.addWidget(header)
+
+ desc = QLabel("Enter a name and select your character class to initialize a new filtering profile.")
+ desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;")
+ desc.setWordWrap(True)
+ self.main_layout.addWidget(desc)
+
+ self.form_layout = QFormLayout()
+
+ # Profile Name
+ self.name_label = QLabel("Profile Name:")
+ self.name_label.setStyleSheet("color: #e2e8f0;")
+ self.name_input = QLineEdit()
+ self.form_layout.addRow(self.name_label, self.name_input)
+
+ self.buttonLayout = QHBoxLayout()
+ self.okButton = QPushButton("OK")
+ self.okButton.clicked.connect(self.accept)
+
+ # Class Selection
+ self.class_label = QLabel("Class:")
+ self.class_input = QComboBox()
+ self.class_input.addItems(sorted([c.title() for c in PLAYER_CLASSES]))
+ self.form_layout.addRow(self.class_label, self.class_input)
+
+ self.cancelButton = QPushButton("Cancel")
+ self.cancelButton.clicked.connect(self.reject)
+
+ self.buttonLayout.addWidget(self.okButton)
+ self.buttonLayout.addWidget(self.cancelButton)
+
+ self.main_layout.addLayout(self.form_layout)
+ self.main_layout.addLayout(self.buttonLayout)
+
+ def accept(self):
+ profile_name = self.name_input.text().strip()
+ if not profile_name:
+ QMessageBox.warning(self, "Warning", "Profile name cannot be empty.")
+ return
+
+ normalized_name = normalize_profile_file_name(profile_name)
+
+ if normalized_name in [normalize_profile_file_name(n) for n in self.existing_profile_names]:
+ QMessageBox.warning(self, "Warning", f"A profile with the name '{profile_name}' already exists.")
+ return
+
+ super().accept()
+
+ def get_value(self) -> tuple[str, str]:
+ return self.name_input.text().strip(), self.class_input.currentText().lower()
+
+
class DeleteItem(QDialog):
def __init__(self, item_names, parent=None):
super().__init__(parent)
self.setWindowTitle("Delete Items")
- self.setFixedSize(300, 200)
- self.main_layout = QVBoxLayout()
+ self.setMinimumWidth(400)
+ self.setStyleSheet("""
+ QDialog { background-color: #1a1a1a; color: #e2e8f0; }
+ QPushButton {
+ background-color: #262626;
+ border: 1px solid #3f3f46;
+ color: #e2e8f0;
+ padding: 6px 12px;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #323232; border-color: #52525b; }
+ QScrollArea { background-color: #09090b; border: 1px solid #3f3f46; border-radius: 4px; }
+ """)
+
+ self.main_layout = QVBoxLayout(self)
self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ header = QLabel("Delete Item Filters")
+ header.setStyleSheet("font-size: 18px; font-weight: bold; color: #ef4444; margin-bottom: 5px;")
+ self.main_layout.addWidget(header)
+
+ desc = QLabel("Select the item configurations you wish to permanently remove.")
+ desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;")
+ desc.setWordWrap(True)
+ self.main_layout.addWidget(desc)
+
self.groupbox = QGroupBox("Items")
+ self.groupbox.setStyleSheet(
+ "QGroupBox { border: 1px solid #3f3f46; margin-top: 10px; padding-top: 10px; color: #e2e8f0; }"
+ )
scroll_area = QScrollArea(self)
scroll_widget = QWidget(scroll_area)
scrollable_layout = QVBoxLayout(scroll_widget)
+ scrollable_layout.setContentsMargins(10, 10, 10, 10)
self.groupbox_layout = QVBoxLayout()
- label = QLabel("Select items to delete:")
- label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
- self.groupbox_layout.addWidget(label)
-
self.checkbox_list = []
for name in item_names:
- checkbox = QCheckBox(name)
+ checkbox = CheckmarkCheckBox(name)
scrollable_layout.addWidget(checkbox)
self.checkbox_list.append(checkbox)
scroll_widget.setLayout(scrollable_layout)
@@ -224,8 +453,6 @@ def __init__(self, item_names, parent=None):
self.main_layout.addWidget(self.groupbox)
self.main_layout.addLayout(self.buttonLayout)
- self.setLayout(self.main_layout)
-
def get_value(self):
return [checkbox.text() for checkbox in self.checkbox_list if checkbox.isChecked()]
@@ -239,22 +466,45 @@ def __init__(self, nb_affix_pool, inherent: bool = False, parent=None):
else:
self.setWindowTitle("Delete Affix Pool")
self.groupbox = QGroupBox("Affix Pool")
- self.setFixedSize(300, 200)
- self.main_layout = QVBoxLayout()
+ self.setMinimumWidth(400)
+ self.setStyleSheet("""
+ QDialog { background-color: #1a1a1a; color: #e2e8f0; }
+ QPushButton {
+ background-color: #262626;
+ border: 1px solid #3f3f46;
+ color: #e2e8f0;
+ padding: 6px 12px;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #323232; border-color: #52525b; }
+ QScrollArea { background-color: #09090b; border: 1px solid #3f3f46; border-radius: 4px; }
+ """)
+
+ self.main_layout = QVBoxLayout(self)
self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ header = QLabel("Remove Affix Pools")
+ header.setStyleSheet("font-size: 18px; font-weight: bold; color: #ef4444; margin-bottom: 5px;")
+ self.main_layout.addWidget(header)
+
+ desc = QLabel("Select the specific affix pools you want to remove from this item.")
+ desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;")
+ desc.setWordWrap(True)
+ self.main_layout.addWidget(desc)
+
scroll_area = QScrollArea(self)
scroll_widget = QWidget(scroll_area)
scrollable_layout = QVBoxLayout(scroll_widget)
+ scrollable_layout.setContentsMargins(10, 10, 10, 10)
self.groupbox_layout = QVBoxLayout()
- label = QLabel("Select items to delete:")
- label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
- self.groupbox_layout.addWidget(label)
+ self.groupbox.setStyleSheet(
+ "QGroupBox { border: 1px solid #3f3f46; margin-top: 10px; padding-top: 10px; color: #e2e8f0; }"
+ )
self.checkbox_list = []
for i in range(nb_affix_pool):
- checkbox = QCheckBox(f"Count {i}")
+ checkbox = CheckmarkCheckBox(f"Count {i}")
scrollable_layout.addWidget(checkbox)
self.checkbox_list.append(checkbox)
scroll_widget.setLayout(scrollable_layout)
@@ -273,8 +523,6 @@ def __init__(self, nb_affix_pool, inherent: bool = False, parent=None):
self.main_layout.addWidget(self.groupbox)
self.main_layout.addLayout(self.buttonLayout)
- self.setLayout(self.main_layout)
-
def get_value(self):
return [checkbox.text() for checkbox in self.checkbox_list if checkbox.isChecked()]
@@ -361,7 +609,7 @@ def __init__(self, sigils: list[str], blacklist: bool = False, parent=None):
self.checkbox_list = []
for sigil in self.sigils:
- checkbox = QCheckBox(sigil)
+ checkbox = CheckmarkCheckBox(sigil)
scrollable_layout.addWidget(checkbox)
self.checkbox_list.append(checkbox)
scroll_widget.setLayout(scrollable_layout)
@@ -509,7 +757,7 @@ def __init__(self, tributes: list[str], parent=None):
self.checkbox_list = []
for tribute in self.tributes:
- checkbox = QCheckBox(Dataloader().tribute_dict[tribute]) if tribute else QCheckBox("None")
+ checkbox = CheckmarkCheckBox(Dataloader().tribute_dict[tribute]) if tribute else CheckmarkCheckBox("None")
scrollable_layout.addWidget(checkbox)
self.checkbox_list.append(checkbox)
scroll_widget.setLayout(scrollable_layout)
@@ -542,14 +790,45 @@ def __init__(self, aspect_upgrades: list[str], parent=None):
self.aspect_upgrades = aspect_upgrades
self.setWindowTitle("Add Aspect")
- self.setFixedSize(300, 150)
+ self.setMinimumWidth(450)
+ self.setStyleSheet("""
+ QDialog { background-color: #1a1a1a; color: #e2e8f0; }
+ QLineEdit, QComboBox {
+ background-color: #09090b;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ color: #e2e8f0;
+ padding: 4px;
+ }
+ QLineEdit:focus, QComboBox:focus { border-color: #3b82f6; }
+ QPushButton {
+ background-color: #262626;
+ border: 1px solid #3f3f46;
+ color: #e2e8f0;
+ padding: 6px 12px;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #323232; border-color: #52525b; }
+ """)
+
+ self.main_layout = QVBoxLayout(self)
+
+ header = QLabel("Add Aspect to Whitelist")
+ header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;")
+ self.main_layout.addWidget(header)
+
+ desc = QLabel("Select a legendary aspect to track for Codex of Power upgrades.")
+ desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;")
+ desc.setWordWrap(True)
+ self.main_layout.addWidget(desc)
- self.main_layout = QVBoxLayout()
self.form_layout = QFormLayout()
unchosen_aspect_ugprades = [x for x in Dataloader().aspect_list if x not in aspect_upgrades]
- self.name_label = QLabel("Aspect:")
+ self.name_label = QLabel("Aspect Name:")
+ self.name_label.setStyleSheet("color: #e2e8f0;")
+
self.name_input = IgnoreScrollWheelComboBox()
self.name_input.setEditable(True)
self.name_input.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
@@ -569,8 +848,6 @@ def __init__(self, aspect_upgrades: list[str], parent=None):
self.main_layout.addLayout(self.form_layout)
self.main_layout.addLayout(self.buttonLayout)
- self.setLayout(self.main_layout)
-
def get_value(self):
return self.name_input.currentText()
@@ -593,8 +870,8 @@ def __init__(self, parent=None):
self.checkbox_list = []
- checkbox_aspect = QCheckBox("Aspect")
- checkbox_affixe = QCheckBox("Affixes")
+ checkbox_aspect = CheckmarkCheckBox("Aspect")
+ checkbox_affixe = CheckmarkCheckBox("Affixes")
self.groupbox_layout.addWidget(checkbox_aspect)
self.groupbox_layout.addWidget(checkbox_affixe)
self.checkbox_list.append(checkbox_aspect)
diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py
index 4f6bdcfd..b5f201a9 100644
--- a/src/gui/profile_editor/affixes_tab.py
+++ b/src/gui/profile_editor/affixes_tab.py
@@ -1,9 +1,13 @@
+import contextlib
+import copy
+import json
import logging
+from pathlib import Path
+from typing import override
-from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, QTimer
-from PyQt6.QtGui import QDoubleValidator, QIntValidator
+from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, pyqtSignal
+from PyQt6.QtGui import QDoubleValidator, QIntValidator, QPainter
from PyQt6.QtWidgets import (
- QCheckBox,
QComboBox,
QCompleter,
QDialog,
@@ -15,14 +19,14 @@
QLabel,
QLineEdit,
QListWidget,
- QListWidgetItem,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
- QSpinBox,
+ QStyle,
+ QStyleOption,
+ QTabBar,
QTabWidget,
- QToolBar,
QVBoxLayout,
QWidget,
)
@@ -32,10 +36,11 @@
AffixFilterModel,
AspectUniqueFilterModel,
DynamicItemFilterModel,
+ ItemFilterModel,
)
from src.dataloader import Dataloader
from src.gui.importer.gui_common import MAX_POWER
-from src.gui.models.collapsible_widget import Container
+from src.gui.models.checkmark_checkbox import CheckmarkCheckBox
from src.gui.models.dialog import (
CreateItem,
DeleteAffixPool,
@@ -46,7 +51,7 @@
MinPercentDialog,
MinPowerDialog,
)
-from src.item.data.item_type import ItemType, is_armor, is_jewelry, is_weapon
+from src.item.data.item_type import ItemType, is_weapon
LOGGER = logging.getLogger(__name__)
@@ -54,6 +59,60 @@
AFFIX_VALUE_MODE = "Value"
AFFIX_PERCENT_MODE = "Min %"
UNIQUE_ASPECTS_TITLE = "Unique Aspects"
+MAX_DROPDOWN_TEXT_LENGTH = 50
+
+
+class TruncatingComboBox(IgnoreScrollWheelComboBox):
+ def __init__(self, max_length=MAX_DROPDOWN_TEXT_LENGTH, parent=None):
+ super().__init__(parent)
+ self.max_length = max_length
+
+ @override
+ def addItems(self, texts: list[str]):
+ display_texts = [self._get_display_text(t) for t in texts]
+ super().addItems(display_texts)
+ for i, text in enumerate(texts):
+ if len(text) > self.max_length:
+ self.setItemData(i, text, Qt.ItemDataRole.ToolTipRole)
+
+ @override
+ def setCurrentText(self, text: str):
+ super().setCurrentText(self._get_display_text(text))
+ self.setToolTip(text if len(text) > self.max_length else "")
+
+ def _get_display_text(self, text: str) -> str:
+ if len(text) > self.max_length:
+ return text[: self.max_length - 3] + "..."
+ return text
+
+
+class CharacterSpinBox(IgnoreScrollWheelSpinBox):
+ value_changed = pyqtSignal(int)
+
+ def __init__(self, value=0, min_val=0, max_val=100, step=1, parent=None):
+ super().__init__()
+ self.setRange(min_val, max_val)
+ self.setValue(value)
+ self.setSingleStep(step)
+ self.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.setFixedHeight(26)
+ self.valueChanged.connect(self.value_changed.emit)
+
+ def set_value(self, val: int):
+ self.setValue(val)
+
+ def set_range(self, min_val: int, max_val: int):
+ self.setRange(min_val, max_val)
+
+ def set_minimum(self, val: int):
+ self.setMinimum(val)
+
+ def set_maximum(self, val: int):
+ self.setMaximum(val)
+
+ @override
+ def setFixedWidth(self, w: int):
+ super().setFixedWidth(w)
def _item_type_summary(item_types: list[ItemType]) -> str:
@@ -62,12 +121,28 @@ def _item_type_summary(item_types: list[ItemType]) -> str:
return ", ".join(item_type.value for item_type in item_types)
+def _get_affix_metadata() -> dict:
+ """Helper to load affix metadata for slot filtering."""
+ try:
+ meta_path = Path("assets/lang/enUS/affix_metadata.json")
+ if not meta_path.exists():
+ LOGGER.warning(f"Affix metadata file not found: {meta_path}")
+ return {}
+ with meta_path.open("r", encoding="utf-8") as f:
+ return json.load(f)
+ except json.JSONDecodeError as e:
+ LOGGER.error(f"Error decoding affix metadata JSON from {meta_path}: {e}")
+ except OSError as e:
+ LOGGER.error(f"Error reading affix metadata file {meta_path}: {e}")
+ return {}
+
+
class ItemTypePicker(QDialog):
def __init__(self, parent: QWidget, item_types: list[ItemType], selected_item_types: list[ItemType]):
super().__init__(parent)
self.setWindowTitle("Select Item Types")
self.resize(650, 500)
- self.checkboxes: dict[ItemType, QCheckBox] = {}
+ self.checkboxes: dict[ItemType, CheckmarkCheckBox] = {}
selected_item_type_set = set(selected_item_types)
weapon_item_types = [
@@ -108,7 +183,7 @@ def _create_item_type_group(
content_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
for item_type in item_types:
- checkbox = QCheckBox(item_type.value)
+ checkbox = CheckmarkCheckBox(item_type.value)
checkbox.setChecked(item_type in selected_item_types)
self.checkboxes[item_type] = checkbox
content_layout.addWidget(checkbox)
@@ -125,10 +200,542 @@ def get_selected_item_types(self) -> list[ItemType]:
return [item_type for item_type, checkbox in self.checkboxes.items() if checkbox.isChecked()]
+class SelectionDialog(QDialog):
+ def __init__(self, parent: QWidget, title: str, items: list[str]):
+ super().__init__(parent)
+ self.setWindowTitle(title)
+ self.resize(400, 500)
+ layout = QVBoxLayout(self)
+
+ self.search_input = QLineEdit()
+ self.search_input.setPlaceholderText("Filter items...")
+ layout.addWidget(self.search_input)
+
+ self.list_widget = QListWidget()
+ self.list_widget.addItems(items)
+ layout.addWidget(self.list_widget)
+
+ self.search_input.textChanged.connect(self._filter_list)
+ self.list_widget.itemDoubleClicked.connect(self.accept)
+
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ buttons.accepted.connect(self.accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ def _filter_list(self, text: str):
+ for i in range(self.list_widget.count()):
+ item = self.list_widget.item(i)
+ item.setHidden(text.lower() not in item.text().lower())
+
+ def get_value(self) -> str | None:
+ selected = self.list_widget.selectedItems()
+ return selected[0].text() if selected else None
+
+
+def _create_delete_btn() -> QPushButton:
+ btn = QPushButton("−")
+ btn.setFixedWidth(30)
+ btn.setToolTip("Remove this entry")
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ btn.setStyleSheet("""
+ QPushButton {
+ color: #ef4444;
+ font-weight: bold;
+ font-size: 16px;
+ border: 1px solid #450a0a;
+ background-color: #1a0a0a;
+ }
+ QPushButton:hover { background-color: #450a0a; color: white; }
+ """)
+ return btn
+
+
+def _affix_summary(pool: AffixFilterCountModel) -> str:
+ names = []
+ for a in pool.count:
+ name = Dataloader().affix_dict.get(a.name, a.name)
+ if getattr(a, "required", False):
+ name = f'[REQ] {name}'
+ if a.want_greater:
+ name += " (GA)"
+ names.append(name)
+ return "
".join(names)
+
+
+def _affix_card_summary(model: AffixFilterModel) -> str:
+ name = Dataloader().affix_dict.get(model.name, model.name)
+ if getattr(model, "required", False):
+ name = f"[REQ] {name}"
+ if model.want_greater:
+ name += " (GA)"
+ return name
+
+
+def _create_summary_card_style() -> str:
+ return """
+ QWidget#SummaryCard {
+ border: 1px solid #2d2d2d;
+ border-left: 3px solid #3b82f6;
+ border-radius: 4px;
+ background-color: #1e1e1e;
+ margin-bottom: 4px;
+ }
+ QWidget#SummaryCard:hover {
+ background-color: #262626;
+ border-color: #404040;
+ border-left-color: #60a5fa;
+ }
+ """
+
+
+def _create_column_header(title: str, add_callback: callable, remove_callback: callable | None = None) -> QWidget:
+ header = QWidget()
+ header.setObjectName("ColumnHeader")
+ header.setStyleSheet(
+ "QWidget#ColumnHeader { background-color: #1e3a5f; border-top-left-radius: 8px; border-top-right-radius: 8px; }"
+ )
+ layout = QHBoxLayout(header)
+ layout.setContentsMargins(5, 5, 5, 5)
+
+ if remove_callback:
+ btn = _create_delete_btn()
+ btn.clicked.connect(remove_callback)
+ layout.addWidget(btn)
+ else:
+ layout.addSpacing(30)
+ layout.addStretch()
+
+ lbl = QLabel(title)
+ lbl.setStyleSheet(
+ "font-weight: bold; font-size: 15px; color: #e2e8f0; text-transform: uppercase; border: none; background: transparent;"
+ )
+ lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(lbl)
+ layout.addStretch()
+
+ btn = QPushButton("+")
+ btn.setFixedWidth(30)
+ btn.setToolTip(f"Add to {title}")
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ btn.setStyleSheet("""
+ QPushButton {
+ color: #22c55e;
+ font-weight: bold;
+ font-size: 16px;
+ border: 1px solid #064e3b;
+ background-color: #06201b;
+ }
+ QPushButton:hover { background-color: #064e3b; color: white; }
+ """)
+ btn.clicked.connect(add_callback)
+ layout.addWidget(btn)
+
+ return header
+
+
+def _create_column_footer(model: AffixFilterCountModel, on_change_cb: callable) -> QWidget:
+ footer = QWidget()
+ main_layout = QVBoxLayout(footer)
+ main_layout.setContentsMargins(5, 5, 5, 5)
+ main_layout.setSpacing(2)
+
+ flavor_lbl = QLabel("Set the quantity of affixes wanted for a match.")
+ flavor_lbl.setStyleSheet("color: #64748b; font-size: 12px; font-style: italic;")
+ flavor_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ main_layout.addWidget(flavor_lbl)
+
+ layout = QHBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(10)
+ main_layout.addLayout(layout)
+
+ layout.addStretch()
+
+ min_lbl = QLabel("Min:")
+ min_lbl.setStyleSheet("color: #94a3b8; font-size: 11px; border: none;")
+ layout.addWidget(min_lbl)
+
+ min_spin = CharacterSpinBox()
+ min_spin.set_range(0, 10)
+ min_spin.setFixedWidth(100)
+ min_spin.set_value(model.min_count)
+ min_spin.value_changed.connect(lambda v: (setattr(model, "min_count", v), on_change_cb()))
+ layout.addWidget(min_spin)
+
+ max_lbl = QLabel("Max:")
+ max_lbl.setStyleSheet("color: #94a3b8; font-size: 11px; border: none;")
+ layout.addWidget(max_lbl)
+
+ max_spin = CharacterSpinBox()
+ max_spin.set_range(0, 100)
+ max_spin.setFixedWidth(100)
+ max_spin.set_value(min(model.max_count, 100))
+ max_spin.value_changed.connect(
+ lambda v: (setattr(model, "max_count", v if v < 100 else 2147483647), on_change_cb())
+ )
+ layout.addWidget(max_spin)
+
+ layout.addStretch()
+
+ # Store references to update if model changes externally
+ footer.setProperty("min_spin", min_spin)
+ footer.setProperty("max_spin", max_spin)
+
+ return footer
+
+
+class UniqueAspectDialog(QDialog):
+ def __init__(
+ self,
+ parent: QWidget,
+ model: AspectUniqueFilterModel,
+ character_class: str = "all",
+ allowed_item_types: list[ItemType] | None = None,
+ ):
+ super().__init__(parent)
+ self.setWindowTitle("Configure Unique Aspect")
+ self.setMinimumWidth(550)
+ self.model = model
+ self.setStyleSheet("""
+ QDialog { background-color: #1a1a1a; color: #e2e8f0; }
+ QLineEdit, QComboBox, QSpinBox {
+ background-color: #09090b;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ color: #e2e8f0;
+ padding: 4px;
+ }
+ QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; }
+ QPushButton {
+ background-color: #262626;
+ border: 1px solid #3f3f46;
+ color: #e2e8f0;
+ padding: 6px 12px;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #323232; border-color: #52525b; }
+ """)
+
+ layout = QVBoxLayout(self)
+ header = QLabel("Unique Aspect Configuration")
+ header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;")
+ layout.addWidget(header)
+
+ desc = QLabel("Set the name and threshold value or percentage for this unique aspect.")
+ desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;")
+ desc.setWordWrap(True)
+ layout.addWidget(desc)
+
+ form = QFormLayout()
+
+ unique_dict = Dataloader().aspect_unique_dict
+ filtered_uniques = []
+
+ # Normalize class for internal lookup
+ search_class = character_class.lower()
+ if "warlock" in search_class:
+ search_class = "sorcerer"
+
+ for name, data in unique_dict.items():
+ # Class Filter: Keep if item is for 'all' or matches the current class
+ u_class = str(data.get("class", "all")).lower()
+ if search_class != "all" and u_class not in ("all", search_class):
+ continue
+
+ # Slot Filter: Keep if item type matches any of the allowed types for this filter
+ u_type = str(data.get("item_type"))
+ if allowed_item_types and u_type and not any(u_type in (t.name, t.value) for t in allowed_item_types):
+ continue
+
+ filtered_uniques.append(name)
+
+ if not filtered_uniques:
+ filtered_uniques = list(unique_dict.keys())
+
+ self.name_combo = TruncatingComboBox()
+ self.name_combo.setEditable(True)
+ self.name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
+ self.name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
+ self.name_combo.addItems(sorted(filtered_uniques))
+ if model.name in filtered_uniques:
+ self.name_combo.setCurrentText(model.name)
+ elif self.name_combo.count() > 0:
+ self.name_combo.setCurrentIndex(0)
+ form.addRow("Aspect:", self.name_combo)
+
+ self.mode_combo = IgnoreScrollWheelComboBox()
+ self.mode_combo.addItems([AFFIX_VALUE_MODE, AFFIX_PERCENT_MODE])
+ self.mode_combo.setCurrentText(AFFIX_PERCENT_MODE if model.min_percent_of_aspect else AFFIX_VALUE_MODE)
+ form.addRow("Mode:", self.mode_combo)
+
+ self.value_edit = QLineEdit()
+ if model.min_percent_of_aspect:
+ self.value_edit.setText(str(model.min_percent_of_aspect))
+ elif model.value is not None:
+ self.value_edit.setText(str(model.value))
+ form.addRow("Threshold:", self.value_edit)
+
+ layout.addLayout(form)
+
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ buttons.accepted.connect(self.save_and_accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ def save_and_accept(self):
+ name = self.name_combo.currentText()
+ if name in Dataloader().aspect_unique_dict:
+ self.model.name = name
+
+ mode = self.mode_combo.currentText()
+ val_str = self.value_edit.text()
+
+ if mode == AFFIX_PERCENT_MODE:
+ try:
+ self.model.min_percent_of_aspect = int(val_str) if val_str else 0
+ except ValueError:
+ self.model.min_percent_of_aspect = 0
+ self.model.value = None
+ else:
+ try:
+ self.model.value = float(val_str) if val_str else None
+ except ValueError:
+ self.model.value = None
+ self.model.min_percent_of_aspect = 0
+ self.accept()
+
+
+class AffixEditDialog(QDialog):
+ def __init__(self, parent: QWidget, model: AffixFilterModel, allowed_item_types: list[ItemType] | None = None):
+ super().__init__(parent)
+ self.setWindowTitle("Configure Affix")
+ self.setMinimumWidth(550)
+ self.model = model
+ self.setStyleSheet("""
+ QDialog { background-color: #1a1a1a; color: #e2e8f0; }
+ QLineEdit, QComboBox, QSpinBox {
+ background-color: #09090b;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ color: #e2e8f0;
+ padding: 4px;
+ }
+ QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; }
+ QPushButton {
+ background-color: #262626;
+ border: 1px solid #3f3f46;
+ color: #e2e8f0;
+ padding: 6px 12px;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #323232; border-color: #52525b; }
+ """)
+
+ layout = QVBoxLayout(self)
+ header = QLabel("Affix Configuration")
+ header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;")
+ layout.addWidget(header)
+
+ desc = QLabel("Configure the properties, GA requirements, and thresholds for this affix.")
+ desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;")
+ desc.setWordWrap(True)
+ layout.addWidget(desc)
+
+ form = QFormLayout()
+
+ affix_dict = Dataloader().affix_dict
+ filtered_affixes = sorted(affix_dict.values())
+
+ self.name_combo = TruncatingComboBox()
+ 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(filtered_affixes)
+ if model.name in affix_dict:
+ current_display = affix_dict[model.name]
+ self.name_combo.setCurrentText(current_display)
+ form.addRow("Affix:", self.name_combo)
+
+ options_layout = QHBoxLayout()
+ self.required_checkbox = CheckmarkCheckBox("Required")
+ self.required_checkbox.setChecked(getattr(model, "required", False))
+ self.greater_checkbox = CheckmarkCheckBox("GA")
+ self.greater_checkbox.setChecked(model.want_greater)
+ self.greater_checkbox.setProperty("greaterCheckbox", True) # noqa: FBT003
+ options_layout.addWidget(self.required_checkbox)
+ options_layout.addWidget(self.greater_checkbox)
+ options_layout.addStretch()
+ form.addRow("Options:", options_layout)
+
+ self.mode_combo = IgnoreScrollWheelComboBox()
+ self.mode_combo.addItems([AFFIX_VALUE_MODE, AFFIX_PERCENT_MODE])
+ self.mode_combo.setCurrentText(AFFIX_PERCENT_MODE if model.min_percent_of_affix else AFFIX_VALUE_MODE)
+ form.addRow("Mode:", self.mode_combo)
+
+ self.value_edit = QLineEdit()
+ if model.min_percent_of_affix:
+ self.value_edit.setText(str(model.min_percent_of_affix))
+ elif model.value is not None:
+ self.value_edit.setText(str(model.value))
+ form.addRow("Threshold:", self.value_edit)
+
+ layout.addLayout(form)
+
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ buttons.accepted.connect(self.save_and_accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ def save_and_accept(self):
+ reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()}
+ affix_id = reverse_dict.get(self.name_combo.currentText())
+ if affix_id:
+ self.model.name = affix_id
+
+ self.model.required = self.required_checkbox.isChecked()
+ self.model.want_greater = self.greater_checkbox.isChecked()
+
+ mode = self.mode_combo.currentText()
+ val_str = self.value_edit.text()
+
+ if mode == AFFIX_PERCENT_MODE:
+ try:
+ self.model.min_percent_of_affix = int(val_str) if val_str else 0
+ except ValueError:
+ self.model.min_percent_of_affix = 0
+ self.model.value = None
+ else:
+ try:
+ self.model.value = float(val_str) if val_str else None
+ except ValueError:
+ self.model.value = None
+ self.model.min_percent_of_affix = 0
+ self.accept()
+
+
+class AffixPoolDialog(QDialog):
+ def __init__(
+ self, parent: QWidget, pool: AffixFilterCountModel, title: str, allowed_item_types: list[ItemType] | None = None
+ ):
+ super().__init__(parent)
+ self.setWindowTitle(title)
+ self.setMinimumSize(700, 600)
+ self.pool = pool
+ self.setStyleSheet("""
+ QDialog { background-color: #1a1a1a; color: #e2e8f0; }
+ QLineEdit, QComboBox, QSpinBox {
+ background-color: #09090b;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ color: #e2e8f0;
+ padding: 4px;
+ }
+ QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; }
+ QPushButton {
+ background-color: #262626;
+ border: 1px solid #3f3f46;
+ color: #e2e8f0;
+ padding: 6px 12px;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #323232; border-color: #52525b; }
+ """)
+
+ layout = QVBoxLayout(self)
+ header = QLabel(title)
+ header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;")
+ layout.addWidget(header)
+
+ desc = QLabel("Manage the list of affixes in this pool and define how many matches are required.")
+ desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;")
+ desc.setWordWrap(True)
+ layout.addWidget(desc)
+
+ config_layout = QHBoxLayout()
+ self.min_count = CharacterSpinBox()
+ self.min_count.set_value(pool.min_count)
+ self.min_count.setFixedWidth(100)
+
+ self.max_count = CharacterSpinBox()
+ self.max_count.set_value(min(pool.max_count, 2147483647))
+ self.max_count.setFixedWidth(100)
+
+ config_layout.addWidget(QLabel("Min:"))
+ config_layout.addWidget(self.min_count)
+ config_layout.addSpacing(20)
+ config_layout.addWidget(QLabel("Max:"))
+ config_layout.addWidget(self.max_count)
+ config_layout.addStretch()
+ layout.addLayout(config_layout)
+
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
+ scroll.setStyleSheet("background: transparent;")
+ scroll.viewport().setStyleSheet("background: transparent;")
+
+ self.rows_container = QWidget()
+ self.rows_layout = QVBoxLayout(self.rows_container)
+ self.rows_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+
+ for affix in pool.count:
+ self.add_affix_row(affix, allowed_item_types)
+
+ scroll.setWidget(self.rows_container)
+ layout.addWidget(scroll)
+
+ add_btn = QPushButton("+ Add Affix to Pool")
+ add_btn.clicked.connect(lambda: self.add_affix(allowed_item_types))
+ layout.addWidget(add_btn)
+
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ buttons.accepted.connect(self.save_and_accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ def add_affix_row(self, model: AffixFilterModel, allowed_item_types: list[ItemType] | None = None):
+ widget = AffixWidget(model, allowed_item_types=allowed_item_types)
+ widget.delete_requested.connect(lambda: self.remove_affix_widget(widget))
+ self.rows_layout.addWidget(widget)
+
+ def add_affix(self, allowed_item_types: list[ItemType] | None = None):
+ affix_dict = Dataloader().affix_dict
+ filtered_affixes = sorted(affix_dict.values())
+
+ dialog = SelectionDialog(self, "Select Affix", filtered_affixes)
+ if dialog.exec() == QDialog.DialogCode.Accepted:
+ val = dialog.get_value()
+ if val:
+ reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()}
+ affix_id = reverse_dict.get(val)
+ new_model = AffixFilterModel(name=affix_id, value=None)
+ self.pool.count.append(new_model)
+ self.add_affix_row(new_model, allowed_item_types)
+
+ def remove_affix_widget(self, widget: AffixWidget):
+ if widget.affix in self.pool.count:
+ self.pool.count.remove(widget.affix)
+ widget.setParent(None)
+ widget.deleteLater()
+
+ def save_and_accept(self):
+ self.pool.min_count = self.min_count.value()
+ self.pool.max_count = self.max_count.value()
+ self.accept()
+
+
class AffixGroupEditor(QWidget):
+ duplicate_requested = pyqtSignal(DynamicItemFilterModel)
+
def __init__(self, dynamic_filter: DynamicItemFilterModel, parent=None):
super().__init__(parent)
self.settings = QSettings("d4lf", "profile_editor")
+ self.affix_column_widgets = []
+ self.affix_pool_layouts = []
+ self.affix_footers = []
+ self.dynamic_filter = dynamic_filter
for item_name, config in dynamic_filter.root.items():
self.item_name = item_name
self.config = config
@@ -137,60 +744,79 @@ def __init__(self, dynamic_filter: DynamicItemFilterModel, parent=None):
self.setup_ui()
def setup_ui(self):
- scroll_area = QScrollArea()
- scroll_area.setWidgetResizable(True)
- scroll_area.setFrameShape(QFrame.Shape.NoFrame)
-
- content_widget = QWidget()
- self.content_layout = QVBoxLayout(content_widget)
- self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
-
- general_form = QFormLayout()
-
- self.item_types = [
- item for item in ItemType.__members__.values() if is_armor(item) or is_jewelry(item) or is_weapon(item)
- ]
- self.item_type_line_edit = QLineEdit()
- self.item_type_line_edit.setReadOnly(True)
- self.item_type_line_edit.setMinimumWidth(360)
- self.item_type_line_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
- self.refresh_item_type_summary()
-
- item_type_layout = QHBoxLayout()
- item_type_layout.addWidget(self.item_type_line_edit)
- edit_item_types_btn = QPushButton("...")
- edit_item_types_btn.setMaximumWidth(40)
- edit_item_types_btn.clicked.connect(self.edit_item_types)
- item_type_layout.addWidget(edit_item_types_btn)
- item_type_layout.addStretch()
- general_form.addRow("Item Types:", item_type_layout)
-
+ self.content_layout = QVBoxLayout(self)
+ self.content_layout.setContentsMargins(0, 10, 0, 0)
+
+ # Row 1: Item Alias, Min Power, Duplicate Button
+ top_row_layout = QHBoxLayout()
+ top_row_layout.setContentsMargins(0, 0, 0, 0)
+
+ top_row_layout.addWidget(QLabel("Item Name / Alias:"))
+ self.alias_edit = QLineEdit()
+ self.alias_edit.setText(self.item_name)
+ self.alias_edit.setStyleSheet("""
+ QLineEdit {
+ background-color: #09090b;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ color: #e2e8f0;
+ }
+ QLineEdit:focus { border-color: #3b82f6; }
+ """)
+ self.alias_edit.setFixedWidth(200)
+ self.alias_edit.textChanged.connect(self.update_item_alias)
+ top_row_layout.addWidget(self.alias_edit)
+
+ top_row_layout.addSpacing(30)
+
+ top_row_layout.addWidget(QLabel("Minimum Power:"))
self.min_power = IgnoreScrollWheelSpinBox()
self.min_power.setMaximum(MAX_POWER)
self.min_power.setValue(self.config.min_power)
- self.min_power.setMaximumWidth(150)
+ self.min_power.setFixedWidth(80)
self.min_power.valueChanged.connect(self.update_min_power)
- general_form.addRow("Minimum Power:", self.min_power)
-
- min_greater_layout = QHBoxLayout()
-
- self.min_greater = QSpinBox()
- self.min_greater.setValue(self.config.min_greater_affix_count)
- self.min_greater.setMaximum(4)
- self.min_greater.setMinimum(0)
- self.min_greater.setMaximumWidth(80)
+ top_row_layout.addWidget(self.min_power)
+
+ top_row_layout.addStretch()
+
+ duplicate_btn = QPushButton("Duplicate Item")
+ duplicate_btn.setFixedWidth(120)
+ duplicate_btn.setStyleSheet("""
+ QPushButton {
+ background-color: #1e3a5f;
+ border: 1px solid #3b82f6;
+ color: #e2e8f0;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #2563eb; }
+ """)
+ duplicate_btn.clicked.connect(self._on_duplicate_clicked)
+ top_row_layout.addWidget(duplicate_btn)
+
+ self.content_layout.addLayout(top_row_layout)
+
+ # Row 2: Min Greater Affixes, Auto Sync, Add Pool Button
+ ga_row_layout = QHBoxLayout()
+ ga_row_layout.setContentsMargins(0, 5, 0, 10)
+
+ ga_row_layout.addWidget(QLabel("Min Greater Affixes:"))
+ self.min_greater = CharacterSpinBox()
+ self.min_greater.set_range(0, 4)
+ self.min_greater.set_value(self.config.min_greater_affix_count)
+ self.min_greater.setFixedWidth(100)
self.min_greater.setToolTip(
"Minimum number of checked affixes that must be Greater Affixes.\n"
"0 = Accept items even without GAs (for leveling)\n"
"1-4 = At least this many checked affixes must be GA"
)
- self.min_greater.valueChanged.connect(self.update_min_greater_affix)
+ self.min_greater.value_changed.connect(self.update_min_greater_affix_from_spin)
- self.auto_sync_checkbox = QCheckBox("Auto Sync")
+ self.auto_sync_checkbox = CheckmarkCheckBox("Auto Sync")
self.auto_sync_checkbox.setToolTip(
"When checked: Min Greater Affixes automatically matches the number of affixes marked as 'want greater'\n"
"When unchecked: You can manually set Min Greater Affixes to any value"
)
+ self.auto_sync_checkbox.setStyleSheet("background: transparent;")
self.auto_sync_checkbox.setChecked(
self.settings.value(f"auto_sync_ga_{self.item_name}", defaultValue=False, type=bool)
)
@@ -201,10 +827,24 @@ def setup_ui(self):
self._refresh_widget_style(self.greater_count_label)
self.update_greater_count_label()
- min_greater_layout.addWidget(self.min_greater)
- min_greater_layout.addWidget(self.auto_sync_checkbox)
- min_greater_layout.addWidget(self.greater_count_label)
- min_greater_layout.addStretch()
+ ga_row_layout.addWidget(self.min_greater)
+ ga_row_layout.addWidget(self.auto_sync_checkbox)
+ ga_row_layout.addWidget(self.greater_count_label)
+ ga_row_layout.addStretch()
+
+ add_pool_btn = QPushButton("Add Additional Affix Pool")
+ add_pool_btn.setFixedWidth(180)
+ add_pool_btn.setStyleSheet("""
+ QPushButton {
+ background-color: #06201b;
+ border: 1px solid #064e3b;
+ color: #22c55e;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #064e3b; color: white; }
+ """)
+ add_pool_btn.clicked.connect(self.add_additional_affix_pool_column)
+ ga_row_layout.addWidget(add_pool_btn)
self.min_greater.setEnabled(not self.auto_sync_checkbox.isChecked())
@@ -212,193 +852,103 @@ def setup_ui(self):
self.min_greater.setProperty("autoSyncSpin", True) # noqa: FBT003
self._refresh_widget_style(self.min_greater)
- general_form.addRow("Min Greater Affixes:", min_greater_layout)
-
- self.content_layout.addLayout(general_form)
- self.create_unique_aspect_container()
-
- pool_btn_layout = QHBoxLayout()
- add_affix_pool_btn = QPushButton("Add Affix Pool")
- add_affix_pool_btn.clicked.connect(self.add_affix_pool)
- add_inherent_pool_btn = QPushButton("Add Inherent Pool")
- add_inherent_pool_btn.clicked.connect(self.add_inherent_pool)
- remove_affix_pool_btn = QPushButton("Remove Affix Pool")
- remove_affix_pool_btn.clicked.connect(lambda: self.remove_selected(self.affix_pool_layout))
- remove_inherent_pool_btn = QPushButton("Remove Inherent Pool")
- remove_inherent_pool_btn.clicked.connect(lambda: self.remove_selected(self.inherent_pool_layout, inherent=True))
-
- pool_btn_layout.addWidget(add_affix_pool_btn)
- pool_btn_layout.addWidget(add_inherent_pool_btn)
- pool_btn_layout.addWidget(remove_affix_pool_btn)
- pool_btn_layout.addWidget(remove_inherent_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.inherent_pool_container = Container("Inherent Pool")
- self.inherent_pool_layout = QVBoxLayout(self.inherent_pool_container.content_widget)
- self.inherent_pool_container.first_expansion.connect(self.init_inherent_pool)
-
- self.content_layout.addWidget(self.affix_pool_container)
- self.content_layout.addWidget(self.inherent_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)
- QTimer.singleShot(100, self.inherent_pool_container.expand)
+ self.content_layout.addLayout(ga_row_layout)
- def create_unique_aspect_container(self):
- self.unique_aspect_container = Container(self._unique_aspects_title())
- self.unique_aspect_layout = QVBoxLayout(self.unique_aspect_container.content_widget)
- self.unique_aspect_container.first_expansion.connect(self.init_unique_aspects)
+ # 3-Column Layout
+ columns_layout = QHBoxLayout()
+ columns_layout.setContentsMargins(0, 0, 0, 0)
+ columns_layout.setSpacing(15)
+ self.columns_layout = columns_layout
- layout = QVBoxLayout()
- layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ # Column 1: Unique Aspects
+ self.aspect_col, self.aspect_rows_layout, _ = self._create_col_helper("Unique Aspects", self.add_unique_aspect)
+ columns_layout.addWidget(self.aspect_col)
- title_layout = QHBoxLayout()
- title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
-
- aspect_label = QLabel("Aspect")
- aspect_label.setProperty("affixHeaderLabel", True) # noqa: FBT003
- self._refresh_widget_style(aspect_label)
-
- mode_label = QLabel("Mode")
- mode_label.setProperty("affixHeaderLabel", True) # noqa: FBT003
- self._refresh_widget_style(mode_label)
-
- value_label = QLabel("Threshold")
- value_label.setProperty("affixHeaderLabel", True) # noqa: FBT003
- self._refresh_widget_style(value_label)
-
- title_layout.addSpacing(25)
- title_layout.addWidget(aspect_label)
- title_layout.addSpacing(440)
- title_layout.addWidget(mode_label)
- title_layout.addSpacing(85)
- title_layout.addWidget(value_label)
-
- self.unique_aspect_list = QListWidget()
- self.unique_aspect_list.setFixedHeight(180)
- self.unique_aspect_list.setAlternatingRowColors(True)
- self.unique_aspect_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
- self.unique_aspect_list.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
-
- unique_aspect_btn_layout = QHBoxLayout()
- add_unique_aspect_btn = QPushButton("Add Unique Aspect")
- add_unique_aspect_btn.clicked.connect(self.add_unique_aspect)
- unique_aspect_btn_layout.addWidget(add_unique_aspect_btn)
-
- remove_unique_aspect_btn = QPushButton("Remove Unique Aspect")
- remove_unique_aspect_btn.clicked.connect(self.remove_selected_unique_aspects)
- unique_aspect_btn_layout.addWidget(remove_unique_aspect_btn)
-
- layout.addLayout(unique_aspect_btn_layout)
- layout.addLayout(title_layout)
- layout.addWidget(self.unique_aspect_list)
+ # Column(s) 2: Affix Pool(s)
+ for pool in self.config.affix_pool:
+ self._add_affix_pool_column_widget(pool)
- self.unique_aspect_layout.addLayout(layout)
- self.content_layout.addWidget(self.unique_aspect_container)
+ self.content_layout.addLayout(columns_layout)
- def _unique_aspects_title(self):
- aspect_names = ", ".join(unique_aspect.name for unique_aspect in self.config.unique_aspect) or "None"
- return f"{UNIQUE_ASPECTS_TITLE} - {aspect_names}"
+ # Initialize content
+ self.init_unique_aspects()
+ self.init_affix_pool()
- def refresh_unique_aspects_title(self):
- self.unique_aspect_container.header.set_name(self._unique_aspects_title())
+ def _on_duplicate_clicked(self):
+ self.duplicate_requested.emit(self.dynamic_filter)
def init_unique_aspects(self):
- for unique_aspect in self.config.unique_aspect:
- self.add_unique_aspect_item(unique_aspect)
+ for aspect in self.config.unique_aspect:
+ self.add_unique_aspect_item(aspect)
+
+ def init_affix_pool(self):
+ for i, pool in enumerate(self.config.affix_pool):
+ for affix in pool.count:
+ self.add_affix_item(affix, pool_idx=i)
def _refresh_widget_style(self, widget):
widget.style().unpolish(widget)
widget.style().polish(widget)
- def add_unique_aspect_item(self, unique_aspect: AspectUniqueFilterModel):
- item = QListWidgetItem()
- widget = UniqueAspectWidget(unique_aspect)
- item_size = widget.sizeHint()
- item_size.setWidth(850)
- item.setSizeHint(item_size)
- self.unique_aspect_list.addItem(item)
- self.unique_aspect_list.setItemWidget(item, widget)
+ def add_unique_aspect_item(self, model: AspectUniqueFilterModel):
+ widget = UniqueAspectWidget(model)
+ widget.delete_requested.connect(lambda: self.remove_unique_aspect_widget(widget))
+ self.aspect_rows_layout.addWidget(widget)
+ return widget
def add_unique_aspect(self):
- existing_names = {unique_aspect.name for unique_aspect in self.config.unique_aspect}
- for aspect_name in Dataloader().aspect_unique_dict:
- if aspect_name in existing_names:
- continue
- new_unique_aspect = AspectUniqueFilterModel(name=aspect_name, value=None)
- self.config.unique_aspect.append(new_unique_aspect)
- self.add_unique_aspect_item(new_unique_aspect)
- self.refresh_unique_aspects_title()
- return
- QMessageBox.information(self, "Info", "All unique aspects have already been added.")
+ aspect_name = next(iter(Dataloader().aspect_unique_dict.keys()))
+ new_model = AspectUniqueFilterModel(name=aspect_name)
+ self.config.unique_aspect.append(new_model)
+ widget = self.add_unique_aspect_item(new_model)
+ if widget.open_config_dialog() == QDialog.DialogCode.Rejected:
+ self.remove_unique_aspect_widget(widget)
+
+ def remove_unique_aspect_widget(self, widget: UniqueAspectWidget):
+ if widget.unique_aspect in self.config.unique_aspect:
+ self.config.unique_aspect.remove(widget.unique_aspect)
+ widget.setParent(None)
+ widget.deleteLater()
+
+ def add_affix_item(self, model: AffixFilterModel, pool_idx: int = 0):
+ layout = self.affix_pool_layouts[pool_idx]
+ widget = AffixSummaryWidget(model)
+ widget.delete_requested.connect(lambda: self.remove_affix_item_widget(widget, pool_idx))
+ widget.config_changed.connect(self.update_greater_count_label)
+ layout.addWidget(widget)
+ return widget
+
+ def remove_affix_item_widget(self, widget, pool_idx: int = 0):
+ layout = self.affix_pool_layouts[pool_idx]
+ pool = self.config.affix_pool[pool_idx]
+ idx = layout.indexOf(widget)
+ if idx != -1:
+ pool.count.pop(idx)
+ widget.setParent(None)
+ widget.deleteLater()
+ self.update_greater_count_label()
- def remove_selected_unique_aspects(self):
- selected_rows = sorted(
- (self.unique_aspect_list.row(item) for item in self.unique_aspect_list.selectedItems()), reverse=True
- )
- for row in selected_rows:
- self.unique_aspect_list.takeItem(row)
- del self.config.unique_aspect[row]
- self.refresh_unique_aspects_title()
+ def add_affix_to_pool(self, pool_model: AffixFilterCountModel):
+ idx = self.config.affix_pool.index(pool_model)
+ common_affixes = ["Energy", "Strength", "Dexterity", "Vitality", "Intelligence"]
+ default_name = None
+ reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()}
+ for affix in common_affixes:
+ if affix in reverse_dict:
+ default_name = reverse_dict[affix]
+ break
+ if default_name is None:
+ default_name = next(iter(Dataloader().affix_dict.keys()))
- def init_affix_pool(self):
- """Initialize affix pool content on first expansion."""
- for pool in self.config.affix_pool:
- self.add_affix_pool_item(pool)
- QTimer.singleShot(50, self.update_greater_count_label)
-
- def init_inherent_pool(self):
- """Initialize inherent pool content on first expansion."""
- for pool in self.config.inherent_pool:
- self.add_affix_pool_item(pool, inherent=True)
- QTimer.singleShot(50, self.update_greater_count_label)
-
- def add_affix_pool_item(self, pool: AffixFilterCountModel, inherent: bool = False):
- if inherent:
- nb_count = self.inherent_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.inherent_pool_layout.addWidget(container)
- QTimer.singleShot(50, container.expand)
- else:
- 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)
+ default_affix = AffixFilterModel(name=default_name, value=None)
+ pool_model.count.append(default_affix)
+ widget = self.add_affix_item(default_affix, pool_idx=idx)
+ if widget.open_config_dialog() == QDialog.DialogCode.Rejected:
+ self.remove_affix_item_widget(widget, pool_idx=idx)
def add_affix_pool(self):
- default_affix = AffixFilterModel(
- name=next(iter(Dataloader().affix_dict.keys())), # First valid affix name
- 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 add_inherent_pool(self):
- default_affix = AffixFilterModel(
- name=next(iter(Dataloader().affix_dict.keys())), # First valid affix name
- value=None,
- )
-
- new_pool = AffixFilterCountModel(count=[default_affix], min_count=1, max_count=3)
- self.config.inherent_pool.append(new_pool)
- self.add_affix_pool_item(new_pool, inherent=True)
+ if self.config.affix_pool:
+ self.add_affix_to_pool(self.config.affix_pool[0])
def remove_selected(self, layout_widget: QVBoxLayout, inherent: bool = False):
nb_pool = layout_widget.count()
@@ -420,19 +970,102 @@ def remove_selected(self, layout_widget: QVBoxLayout, inherent: bool = False):
self.reorganize_pool(layout_widget)
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}")
+ pass
+
+ def _create_col_helper(self, title, add_cb, pool_model=None, remove_cb=None):
+ col_widget = QWidget()
+ col_layout = QVBoxLayout(col_widget)
+ col_layout.setContentsMargins(0, 0, 0, 0)
+ col_layout.setSpacing(0)
+
+ header = _create_column_header(title, add_cb, remove_cb)
+ col_layout.addWidget(header)
+
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
+ scroll.viewport().setAutoFillBackground(False)
+ scroll.setStyleSheet(
+ "QScrollArea { border: 1px solid #2d2d2d; border-left: none; border-bottom: none; background-color: #121212; }"
+ )
+
+ inner = QWidget()
+ inner_layout = QVBoxLayout(inner)
+ inner_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ scroll.setWidget(inner)
+ col_layout.addWidget(scroll)
+
+ footer = None
+ if pool_model is not None:
+ footer = _create_column_footer(pool_model, self.update_greater_count_label)
+ footer.setStyleSheet(
+ "background-color: #1a1a1a; border: 1px solid #2d2d2d; border-left: none; border-top: none;"
+ )
+ col_layout.addWidget(footer)
+
+ return col_widget, inner_layout, footer
+
+ def _add_affix_pool_column_widget(self, pool_model: AffixFilterCountModel):
+ def add_cb():
+ self.add_affix_to_pool(pool_model)
+
+ # Only provide a remove callback for additional pools (index > 0)
+ is_additional = self.config.affix_pool.index(pool_model) > 0
+ remove_cb = (lambda: self.remove_affix_pool_column(pool_model)) if is_additional else None
+ col_widget, inner_layout, footer = self._create_col_helper("Affix Pool", add_cb, pool_model, remove_cb)
+ self.columns_layout.addWidget(col_widget)
+
+ self.affix_column_widgets.append(col_widget)
+ self.affix_pool_layouts.append(inner_layout)
+ self.affix_footers.append(footer)
+
+ def add_additional_affix_pool_column(self):
+ new_pool = AffixFilterCountModel(count=[], min_count=1)
+ self.config.affix_pool.append(new_pool)
+ self._add_affix_pool_column_widget(new_pool)
+
+ def remove_affix_pool_column(self, pool_model: AffixFilterCountModel):
+ reply = QMessageBox.question(
+ self,
+ "Confirm Deletion",
+ "Are you sure you want to delete this entire affix pool?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ )
+ if reply == QMessageBox.StandardButton.Yes:
+ idx = self.config.affix_pool.index(pool_model)
+ self.config.affix_pool.pop(idx)
+
+ widget = self.affix_column_widgets.pop(idx)
+ self.affix_pool_layouts.pop(idx)
+ self.affix_footers.pop(idx)
- def refresh_item_type_summary(self):
- self.item_type_line_edit.setText(_item_type_summary(self.config.item_type))
+ widget.setParent(None)
+ widget.deleteLater()
+ self.update_greater_count_label()
- def edit_item_types(self):
- item_type_picker = ItemTypePicker(self, self.item_types, self.config.item_type)
- if item_type_picker.exec() == QDialog.DialogCode.Accepted:
- self.config.item_type = item_type_picker.get_selected_item_types()
- self.refresh_item_type_summary()
+ def update_item_alias(self, new_name: str):
+ new_name = new_name.strip()
+ if not new_name or new_name == self.item_name:
+ return
+
+ if self.item_name in self.dynamic_filter.root:
+ model = self.dynamic_filter.root.pop(self.item_name)
+ self.dynamic_filter.root[new_name] = model
+
+ old_name = self.item_name
+ self.item_name = new_name
+
+ p = self.parent()
+ while p:
+ if isinstance(p, AffixesTab):
+ if old_name in p.item_names:
+ idx = p.item_names.index(old_name)
+ p.item_names[idx] = new_name
+ p.item_data_map.pop(old_name, None)
+ p.item_data_map[new_name] = self.dynamic_filter
+ p.tab_widget.setTabText(idx, new_name)
+ break
+ p = p.parent()
def update_min_power(self):
self.config.min_power = self.min_power.value()
@@ -440,6 +1073,9 @@ def update_min_power(self):
def update_min_greater_affix(self):
self.config.min_greater_affix_count = self.min_greater.value()
+ def update_min_greater_affix_from_spin(self, value):
+ self.config.min_greater_affix_count = value
+
def toggle_auto_sync(self):
is_auto_sync = self.auto_sync_checkbox.isChecked()
@@ -452,64 +1088,49 @@ def toggle_auto_sync(self):
if is_auto_sync:
self.min_greater.setProperty("autoSyncSpin", True) # noqa: FBT003
self._refresh_widget_style(self.min_greater)
-
- self.affix_pool_container.expand()
- self.inherent_pool_container.expand()
-
count = self.count_want_greater_affixes()
- self.min_greater.setValue(count)
+ self.min_greater.set_value(count)
self.update_greater_count_label()
else:
self.min_greater.setProperty("autoSyncSpin", False) # noqa: FBT003
self._refresh_widget_style(self.min_greater)
+ self.update_greater_count_label()
+
def _update_auto_sync_count(self):
count = self.count_want_greater_affixes()
- self.min_greater.setValue(count)
+ self.min_greater.set_value(count)
self.update_greater_count_label()
def sync_min_greater_from_checkboxes(self):
if self.auto_sync_checkbox.isChecked():
count = self.count_want_greater_affixes()
- self.min_greater.setValue(count)
-
- def _ensure_pool_widgets_initialized(self):
- for container in (self.affix_pool_container, self.inherent_pool_container):
- was_visible = container.content_widget.isVisible()
- if container.header.first_expansion:
- container.expand()
- if not was_visible:
- container.collapse()
+ self.min_greater.set_value(count)
def iter_affix_widgets(self):
- self._ensure_pool_widgets_initialized()
-
- # Inherents do not participate in Greater Affix auto-sync or bulk Min % updates.
- for i in range(self.affix_pool_layout.count()):
- container = self.affix_pool_layout.itemAt(i).widget()
- if container is None or not hasattr(container, "content_widget"):
- continue
- pool_item = container.content_widget.layout().itemAt(0)
- if pool_item is None:
- continue
- pool_widget = pool_item.widget()
- if not isinstance(pool_widget, AffixPoolWidget):
- continue
- for j in range(pool_widget.affix_list.count()):
- list_item = pool_widget.affix_list.item(j)
- affix_widget = pool_widget.affix_list.itemWidget(list_item)
- if isinstance(affix_widget, AffixWidget):
- yield affix_widget
+ # NOTE: Since AffixWidgets are now inside dialogs, we can't yield UI widgets for bulk updates.
+ # Bulk operations in this view must be handled via direct model updates or a different pattern.
+ return []
+
+ def refresh_all_summaries(self):
+ for layouts in [self.affix_pool_layouts]:
+ for layout in layouts:
+ for i in range(layout.count()):
+ w = layout.itemAt(i).widget()
+ if isinstance(w, AffixSummaryWidget):
+ w.refresh_display()
+ for i in range(self.aspect_rows_layout.count()):
+ w = self.aspect_rows_layout.itemAt(i).widget()
+ if isinstance(w, UniqueAspectWidget):
+ w.refresh_display()
def count_want_greater_affixes(self):
want_greater_count = 0
- if not hasattr(self, "affix_pool_layout") or not hasattr(self, "inherent_pool_layout"):
- return 0
-
- for affix_widget in self.iter_affix_widgets():
- if affix_widget.greater_checkbox.isChecked():
- want_greater_count += 1
+ for pool in self.config.affix_pool:
+ for affix in pool.count:
+ if affix.want_greater:
+ want_greater_count += 1
return want_greater_count
@@ -522,60 +1143,102 @@ def update_greater_count_label(self):
else:
self.greater_count_label.setText(f"({count} greater affixes marked)")
+ # Update affix pool footers
+ for footer, model in zip(self.affix_footers, self.config.affix_pool, strict=False):
+ self._update_footer_constraints(footer, model)
+
+ def _update_footer_constraints(self, footer, model):
+ if footer and model:
+ min_spin = footer.property("min_spin")
+ if min_spin:
+ min_allowed = sum(1 for a in model.count if getattr(a, "required", False))
+ min_spin.set_minimum(min_allowed)
+ if model.min_count < min_allowed:
+ model.min_count = min_allowed
+ min_spin.set_value(min_allowed)
+
def convert_all_to_min_percent_of_affix(self, percent: int):
for affix_widget in self.iter_affix_widgets():
affix_widget.set_min_percent(percent, convert_mode=True)
class UniqueAspectWidget(QWidget):
+ delete_requested = pyqtSignal()
+ config_changed = pyqtSignal()
+
def __init__(self, unique_aspect: AspectUniqueFilterModel, parent=None):
super().__init__(parent)
self.unique_aspect = unique_aspect
+ self.setObjectName("SummaryCard")
+ self.setStyleSheet(_create_summary_card_style())
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setup_ui()
def setup_ui(self):
- layout = QHBoxLayout()
- layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
- layout.setSpacing(50)
-
- self.create_aspect_name_combobox()
- self.create_mode_combobox()
- self.create_value_input()
- self.mode_combo.currentTextChanged.connect(self.update_mode)
- self.update_mode(self.mode_combo.currentText())
-
- layout.addWidget(self.name_combo)
- layout.addWidget(self.mode_combo)
- layout.addWidget(self.value_edit)
-
- self.setMinimumWidth(850)
- self.setLayout(layout)
+ self.main_layout = QHBoxLayout(self)
+ self.main_layout.setContentsMargins(22, 8, 10, 8)
+
+ self.summary_label = QLabel()
+ self.summary_label.setStyleSheet("font-weight: bold; color: #e2e8f0;")
+ self.main_layout.addWidget(self.summary_label, 1)
+
+ self.threshold_label = QLabel()
+ self.threshold_label.setMinimumWidth(60)
+ self.threshold_label.setStyleSheet("color: #94a3b8; font-size: 13px; font-weight: bold;")
+ self.main_layout.addWidget(self.threshold_label)
+
+ self.delete_btn = _create_delete_btn()
+ self.delete_btn.clicked.connect(self.delete_requested.emit)
+ self.main_layout.addWidget(self.delete_btn)
+
+ self.refresh_display()
+
+ @override
+ def paintEvent(self, event):
+ opt = QStyleOption()
+ opt.initFrom(self)
+ p = QPainter(self)
+ self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, p, self)
+ p.end()
+
+ @override
+ def mousePressEvent(self, event):
+ if event is None or event.button() == Qt.MouseButton.LeftButton:
+ self.open_config_dialog()
+
+ def open_config_dialog(self) -> QDialog.DialogCode:
+ # Gather context by crawling up the widget tree
+ char_class = "all"
+ allowed_types = []
+ curr = self.parent()
+ while curr:
+ if hasattr(curr, "profile_model"): # ProfileEditor
+ char_class = curr.profile_model.class_name.lower()
+ # Check for Item Types in AffixGroupEditor (Affixes Tab)
+ if hasattr(curr, "config") and hasattr(curr.config, "item_type"):
+ allowed_types = curr.config.item_type
+ # Check for Item Types in UniqueWidget (Global Uniques Tab)
+ if hasattr(curr, "unique_model") and hasattr(curr.unique_model, "item_type"):
+ allowed_types = curr.unique_model.item_type
+ curr = curr.parent()
+
+ dialog = UniqueAspectDialog(self, self.unique_aspect, char_class, allowed_types)
+ result = dialog.exec()
+ if result == QDialog.DialogCode.Accepted:
+ self.refresh_display()
+ self.config_changed.emit()
+ return result
+
+ def refresh_display(self):
+ name = Dataloader().aspect_unique_dict.get(self.unique_aspect.name, {}).get("name", self.unique_aspect.name)
+ self.summary_label.setText(name.replace("_", " ").title())
- def create_aspect_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().aspect_unique_dict.keys()))
- self.name_combo.setMaximumWidth(600)
- if self.unique_aspect.name in Dataloader().aspect_unique_dict:
- self.name_combo.setCurrentText(self.unique_aspect.name)
- self.name_combo.currentTextChanged.connect(self.update_name)
-
- def create_mode_combobox(self):
- self.mode_combo = IgnoreScrollWheelComboBox()
- self.mode_combo.setFixedSize(100, self.mode_combo.sizeHint().height())
- self.mode_combo.addItems([AFFIX_VALUE_MODE, AFFIX_PERCENT_MODE])
if self.unique_aspect.min_percent_of_aspect:
- self.mode_combo.setCurrentText(AFFIX_PERCENT_MODE)
+ self.threshold_label.setText(f"{self.unique_aspect.min_percent_of_aspect}%")
+ elif self.unique_aspect.value is not None:
+ self.threshold_label.setText(str(self.unique_aspect.value))
else:
- self.mode_combo.setCurrentText(AFFIX_VALUE_MODE)
-
- def create_value_input(self):
- self.value_edit = QLineEdit()
- self.value_edit.setFixedSize(100, self.value_edit.sizeHint().height())
- self.value_edit.textChanged.connect(self.update_value)
+ self.threshold_label.setText("Any")
def update_name(self, current_text=None):
aspect_name = current_text or self.name_combo.currentText()
@@ -637,164 +1300,233 @@ def update_value(self, value):
self.unique_aspect.min_percent_of_aspect = 0
-class AffixPoolWidget(QWidget):
- def __init__(self, pool: AffixFilterCountModel, parent=None):
+class AffixSummaryWidget(QWidget):
+ delete_requested = pyqtSignal()
+ config_changed = pyqtSignal()
+
+ def __init__(self, model: AffixFilterModel, parent=None):
super().__init__(parent)
- self.pool = pool
+ self.model = model
+ self.setObjectName("SummaryCard")
+ self.setStyleSheet(_create_summary_card_style())
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setup_ui()
def setup_ui(self):
- layout = QVBoxLayout()
- layout.setAlignment(Qt.AlignmentFlag.AlignTop)
-
- config_layout = QHBoxLayout()
- config_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
-
- min_count_label = QLabel("Min Count:")
- min_count_label.setMaximumWidth(100)
- min_count_label.setProperty("affixHeaderLabel", True) # noqa: FBT003
- self._refresh_widget_style(min_count_label)
- config_layout.addWidget(min_count_label)
-
- self.min_count = IgnoreScrollWheelSpinBox()
- self.min_count.setValue(self.pool.min_count)
- self.min_count.setMaximumWidth(100)
- self.min_count.valueChanged.connect(self.update_min_count)
- config_layout.addWidget(self.min_count)
- config_layout.addSpacing(150)
-
- max_count_label = QLabel("Max Count:")
- max_count_label.setMaximumWidth(100)
- max_count_label.setProperty("affixHeaderLabel", True) # noqa: FBT003
- self._refresh_widget_style(max_count_label)
- config_layout.addWidget(max_count_label)
-
- self.max_count = IgnoreScrollWheelSpinBox()
- self.max_count.setValue(min(self.pool.max_count, 2147483647))
- self.max_count.setMaximumWidth(100)
- self.max_count.valueChanged.connect(self.update_max_count)
- config_layout.addWidget(self.max_count)
-
- layout.addLayout(config_layout)
-
- title_layout = QHBoxLayout()
- title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
-
- affix_label = QLabel("Affixes")
- affix_label.setProperty("affixHeaderLabel", True) # noqa: FBT003
- self._refresh_widget_style(affix_label)
-
- greater_label = QLabel("Greater")
- greater_label.setProperty("affixHeaderLabel", True) # noqa: FBT003
- self._refresh_widget_style(greater_label)
-
- mode_label = QLabel("Mode")
- mode_label.setProperty("affixHeaderLabel", True) # noqa: FBT003
- self._refresh_widget_style(mode_label)
-
- value_label = QLabel("Threshold")
- value_label.setProperty("affixHeaderLabel", True) # noqa: FBT003
- self._refresh_widget_style(value_label)
-
- title_layout.addSpacing(250)
- title_layout.addWidget(affix_label)
- title_layout.addSpacing(400)
- title_layout.addWidget(greater_label)
- title_layout.addSpacing(70)
- title_layout.addWidget(mode_label)
- title_layout.addSpacing(85)
- title_layout.addWidget(value_label)
+ self.main_layout = QHBoxLayout(self)
+ self.main_layout.setContentsMargins(22, 8, 10, 8)
+
+ self.summary_label = QLabel()
+ self.summary_label.setStyleSheet("font-weight: bold; color: #e2e8f0;")
+ self.main_layout.addWidget(self.summary_label, 1)
+
+ self.threshold_label = QLabel()
+ self.threshold_label.setMinimumWidth(60)
+ self.threshold_label.setStyleSheet("color: #94a3b8; font-size: 13px; font-weight: bold;")
+ self.main_layout.addWidget(self.threshold_label)
+
+ self.delete_btn = _create_delete_btn()
+ self.delete_btn.clicked.connect(self.delete_requested.emit)
+ self.main_layout.addWidget(self.delete_btn)
+
+ self.refresh_display()
+
+ @override
+ def paintEvent(self, event):
+ opt = QStyleOption()
+ opt.initFrom(self)
+ p = QPainter(self)
+ self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, p, self)
+ p.end()
+
+ @override
+ def mousePressEvent(self, event):
+ if event is None or event.button() == Qt.MouseButton.LeftButton:
+ self.open_config_dialog()
+
+ def open_config_dialog(self) -> QDialog.DialogCode:
+ # Gather context by crawling up the widget tree
+ allowed_types = []
+ is_global = False
+ curr = self.parent()
+ while curr:
+ # Check for Item Types in AffixGroupEditor (Affixes Tab)
+ if hasattr(curr, "config") and hasattr(curr.config, "item_type"):
+ allowed_types = curr.config.item_type
+ is_global = False
+ break
+ # If we hit UniqueWidget, we are in a Global Rule
+ if hasattr(curr, "unique_model"):
+ is_global = True
+ break
+ curr = curr.parent()
+
+ dialog = AffixEditDialog(self, self.model, None if is_global else allowed_types)
+ result = dialog.exec()
+ if result == QDialog.DialogCode.Accepted:
+ self.refresh_display()
+ self.config_changed.emit()
+ return result
+
+ def refresh_display(self):
+ name = Dataloader().affix_dict.get(self.model.name, self.model.name)
+ if self.model.want_greater:
+ name += " (GA)"
+
+ if getattr(self.model, "required", False):
+ self.summary_label.setText(f'[REQ] {name}')
+ else:
+ self.summary_label.setText(name)
- self.affix_list = QListWidget()
- self.affix_list.setMinimumHeight(200)
- self.affix_list.setAlternatingRowColors(True)
- for affix in self.pool.count:
- self.add_affix_item(affix)
+ if self.model.min_percent_of_affix:
+ self.threshold_label.setText(f"{self.model.min_percent_of_affix}%")
+ elif self.model.value is not None:
+ self.threshold_label.setText(str(self.model.value))
+ else:
+ self.threshold_label.setText("Any")
- affix_btn_layout = QHBoxLayout()
- add_affix_btn = QPushButton("Add Affix")
- add_affix_btn.clicked.connect(self.add_affix)
- affix_btn_layout.addWidget(add_affix_btn)
- remove_affix_btn = QPushButton("Remove Affix")
- remove_affix_btn.clicked.connect(lambda: self.remove_selected(self.affix_list))
- affix_btn_layout.addWidget(remove_affix_btn)
+class AffixPoolWidget(QWidget):
+ pool_delete_requested = pyqtSignal()
+ config_changed = pyqtSignal()
- layout.addLayout(affix_btn_layout)
- layout.addLayout(title_layout)
- layout.addWidget(self.affix_list)
+ def __init__(self, pool: AffixFilterCountModel, parent=None):
+ super().__init__(parent)
+ self.pool = pool
+ self.setObjectName("SummaryCard")
+ self.setStyleSheet(_create_summary_card_style())
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.setup_ui()
- self.setLayout(layout)
+ def setup_ui(self):
+ self.main_layout = QHBoxLayout(self)
+ self.main_layout.setContentsMargins(22, 8, 10, 8)
+ self.main_layout.setSpacing(10)
+
+ # Container for labels on the left
+ text_layout = QVBoxLayout()
+ text_layout.setContentsMargins(0, 0, 0, 0)
+ text_layout.setSpacing(2)
+
+ self.affix_summary = QLabel()
+ self.affix_summary.setWordWrap(True)
+ self.affix_summary.setStyleSheet("color: #cbd5e1; font-size: 11px;")
+ text_layout.addWidget(self.affix_summary)
+
+ self.count_label = QLabel()
+ self.count_label.setStyleSheet("color: #94a3b8; font-size: 13px; font-weight: bold;")
+ text_layout.addWidget(self.count_label)
+
+ self.main_layout.addLayout(text_layout, 1)
+
+ # Hidden label used for internal state and dialog titles
+ self.pool_name_label = QLabel()
+ self.pool_name_label.setVisible(False)
+
+ self.del_pool_btn = _create_delete_btn()
+ self.del_pool_btn.setToolTip("Delete entire pool")
+ self.del_pool_btn.clicked.connect(self.pool_delete_requested.emit)
+ self.main_layout.addWidget(self.del_pool_btn, 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+
+ self.refresh_display()
+
+ @override
+ def mousePressEvent(self, event):
+ if event is None or event.button() == Qt.MouseButton.LeftButton:
+ self.open_config_dialog()
+
+ def open_config_dialog(self):
+ # Find allowed types
+ allowed_types = []
+ is_global = False
+ curr = self.parent()
+ while curr:
+ if hasattr(curr, "config") and hasattr(curr.config, "item_type"):
+ allowed_types = curr.config.item_type
+ is_global = False
+ break
+ if hasattr(curr, "unique_model"):
+ is_global = True
+ break
+ curr = curr.parent()
- def _refresh_widget_style(self, widget):
- widget.style().unpolish(widget)
- widget.style().polish(widget)
+ dialog = AffixPoolDialog(self, self.pool, self.pool_name_label.text(), None if is_global else allowed_types)
+ if dialog.exec() == QDialog.DialogCode.Accepted:
+ self.refresh_display()
+ self.config_changed.emit()
- def add_affix_item(self, affix: AffixFilterModel):
- item = QListWidgetItem()
- widget = AffixWidget(affix)
- item.setSizeHint(widget.sizeHint())
- self.affix_list.addItem(item)
- self.affix_list.setItemWidget(item, widget)
-
- def add_affix(self):
- new_affix = AffixFilterModel(name=next(iter(Dataloader().affix_dict.keys())), value=None)
- self.pool.count.append(new_affix)
- self.add_affix_item(new_affix)
-
- def remove_selected(self, list_widget: QListWidget):
- for item in list_widget.selectedItems():
- row = list_widget.row(item)
- list_widget.takeItem(row)
- del self.pool.count[row]
-
- def update_min_count(self):
- self.pool.min_count = self.min_count.value()
+ def set_pool_name(self, name: str):
+ self.pool_name_label.setText(name.upper())
- def update_max_count(self):
- self.pool.max_count = self.max_count.value()
+ def refresh_display(self):
+ max_val = "∞" if self.pool.max_count > 1000 else str(self.pool.max_count)
+ self.count_label.setText(f"Min: {self.pool.min_count} / Max: {max_val}")
+ self.affix_summary.setText(_affix_summary(self.pool))
class AffixWidget(QWidget):
- def __init__(self, affix: AffixFilterModel, parent=None):
+ delete_requested = pyqtSignal()
+
+ def __init__(self, affix: AffixFilterModel, parent=None, allowed_item_types: list[ItemType] | None = None):
super().__init__(parent)
self.affix = affix
+ self.allowed_item_types = allowed_item_types
+ self.setStyleSheet("background: transparent; border: none;")
self.setup_ui()
def setup_ui(self):
- layout = QHBoxLayout()
- layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
- layout.setSpacing(50)
+ main_vbox = QVBoxLayout(self)
+ main_vbox.setContentsMargins(0, 5, 0, 5)
+ main_vbox.setSpacing(8)
self.create_affix_name_combobox()
self.create_greater_checkbox()
+ self.create_required_checkbox()
self.create_mode_combobox()
self.create_value_input()
+
self.mode_combo.currentTextChanged.connect(self.update_mode)
self.update_mode(self.mode_combo.currentText())
- layout.addWidget(self.name_combo)
- layout.addWidget(self.greater_checkbox)
- layout.addWidget(self.mode_combo)
- layout.addWidget(self.value_edit)
+ # Top row: Affix selection
+ main_vbox.addWidget(self.name_combo)
- self.setLayout(layout)
+ # Bottom row: Options and Values
+ bottom_hbox = QHBoxLayout()
+ bottom_hbox.setSpacing(10)
+ bottom_hbox.addWidget(self.required_checkbox)
+ bottom_hbox.addWidget(self.greater_checkbox)
+ bottom_hbox.addStretch()
+ bottom_hbox.addWidget(self.mode_combo)
+ bottom_hbox.addWidget(self.value_edit)
+ main_vbox.addLayout(bottom_hbox)
def create_affix_name_combobox(self):
- self.name_combo = IgnoreScrollWheelComboBox()
+ affix_dict = Dataloader().affix_dict
+ filtered_affixes = sorted(affix_dict.values())
+
+ self.name_combo = TruncatingComboBox(parent=self)
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()))
- self.name_combo.setMaximumWidth(600)
- if self.affix.name in Dataloader().affix_dict:
- self.name_combo.setCurrentText(Dataloader().affix_dict[self.affix.name])
- # currentIndexChanged misses some editable-combobox keyboard flows.
+ self.name_combo.addItems(filtered_affixes)
+ self.name_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ if self.affix.name in affix_dict:
+ self.name_combo.setCurrentText(affix_dict[self.affix.name])
self.name_combo.currentTextChanged.connect(self.update_name)
+ def create_required_checkbox(self):
+ self.required_checkbox = CheckmarkCheckBox("Required")
+ self.required_checkbox.setChecked(getattr(self.affix, "required", False))
+ self.required_checkbox.setFixedWidth(85)
+ self.required_checkbox.stateChanged.connect(self.update_required)
+
+ def update_required(self):
+ self.affix.required = self.required_checkbox.isChecked()
+
def create_greater_checkbox(self):
- self.greater_checkbox = QCheckBox("Greater")
+ self.greater_checkbox = CheckmarkCheckBox("GA")
self.greater_checkbox.setChecked(getattr(self.affix, "want_greater", False))
self.greater_checkbox.setFixedWidth(80)
self.greater_checkbox.setProperty("greaterCheckbox", True) # noqa: FBT003
@@ -817,7 +1549,7 @@ def update_parent_count_label(self):
def create_mode_combobox(self):
self.mode_combo = IgnoreScrollWheelComboBox()
- self.mode_combo.setFixedSize(100, self.mode_combo.sizeHint().height())
+ self.mode_combo.setFixedWidth(80)
self.mode_combo.addItems([AFFIX_VALUE_MODE, AFFIX_PERCENT_MODE])
if self.affix.min_percent_of_affix:
self.mode_combo.setCurrentText(AFFIX_PERCENT_MODE)
@@ -826,7 +1558,7 @@ def create_mode_combobox(self):
def create_value_input(self):
self.value_edit = QLineEdit()
- self.value_edit.setFixedSize(100, self.value_edit.sizeHint().height())
+ self.value_edit.setFixedWidth(80)
self.value_edit.textChanged.connect(self.update_value)
def update_name(self, current_text=None):
@@ -893,81 +1625,215 @@ class AffixesTab(QWidget):
def __init__(self, affixes_model: list[DynamicItemFilterModel], parent=None):
super().__init__(parent)
self.affixes_model = affixes_model
+ self._current_slot_name = ""
+ self._current_slot_item_types = []
self.loaded = False
+ self.settings = QSettings("d4lf", "profile_editor")
+ self.item_names = []
+ self.item_data_map: dict[str, DynamicItemFilterModel] = {}
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def load(self):
- if not self.loaded:
- self.setup_ui()
- self.loaded = True
+ with contextlib.suppress(RuntimeError):
+ if not self.loaded:
+ self.setup_ui()
+ self.loaded = True
def setup_ui(self):
"""Populate the grid layout with existing groups."""
+ self.setStyleSheet("background: transparent; border: none;")
self.main_layout = QVBoxLayout(self)
- self.main_layout.setContentsMargins(0, 20, 0, 20)
+ self.main_layout.setContentsMargins(0, 0, 0, 0)
self.tab_widget = QTabWidget(self)
- self.tab_widget.setTabsClosable(True)
- self.tab_widget.tabCloseRequested.connect(self.close_tab)
-
- self.toolbar = QToolBar("MyToolBar", self)
- self.toolbar.setMinimumHeight(50)
- self.toolbar.setContentsMargins(10, 10, 10, 10)
- self.toolbar.setMovable(False)
-
- self.item_names = []
- for affix_group in self.affixes_model:
- for item_name in affix_group.root:
- if item_name in self.item_names:
- QMessageBox.warning(
- self, "Warning", f"Item name already exist please rename {item_name} in the profile file."
- )
- continue
- group = AffixGroupEditor(affix_group)
- self.item_names.append(item_name)
- self.tab_widget.addTab(group, item_name)
-
- add_item_button = QPushButton()
- add_item_button.setText("Create Item")
- add_item_button.clicked.connect(self.add_item_type)
-
- remove_item_button = QPushButton()
- remove_item_button.setText("Remove Item")
- remove_item_button.clicked.connect(self.remove_item_type)
-
- set_all_min_greater_affix_button = QPushButton("Set All Min GAs (Excludes Auto Synced Items)")
- convert_all_to_min_percent_button = QPushButton("Convert All To Min %")
- set_all_min_power_button = QPushButton("Set all minPower")
- set_all_min_greater_affix_button.clicked.connect(self.set_all_min_greater_affix)
- convert_all_to_min_percent_button.clicked.connect(self.convert_all_to_min_percent_of_affix)
- set_all_min_power_button.clicked.connect(self.set_all_min_power)
-
- self.toolbar.addWidget(add_item_button)
- self.toolbar.addWidget(remove_item_button)
- self.toolbar.addWidget(set_all_min_greater_affix_button)
- self.toolbar.addWidget(convert_all_to_min_percent_button)
- self.toolbar.addWidget(set_all_min_power_button)
-
- self.main_layout.addWidget(self.toolbar)
+ self.tab_widget.setStyleSheet("""
+ QTabWidget { background: transparent; border: none; }
+ QTabWidget::pane { border: none; }
+ QTabBar::tab {
+ background: #1a1a1a;
+ color: #94a3b8;
+ padding: 8px 30px 8px 12px;
+ border: 1px solid #334155;
+ border-bottom: none;
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ margin-right: 2px;
+ }
+ QTabBar::close-button:hover { background-color: rgba(255, 255, 255, 0.1); }
+ QTabBar::tab:selected {
+ background: #1e3a5f;
+ color: #e2e8f0;
+ border: 1px solid #3b82f6;
+ border-bottom: 2px solid #3b82f6;
+ }
+ QTabBar::tab:last, QTabBar::tab:selected:last, QTabBar::tab:only-one, QTabBar::tab:selected:only-one {
+ background: #06201b;
+ color: #22c55e;
+ border: 1px solid #064e3b;
+ border-bottom: 1px solid #064e3b;
+ }
+ """)
+ with QSignalBlocker(self.tab_widget):
+ self.tab_widget.setTabsClosable(True)
+ self.tab_widget.tabCloseRequested.connect(self.close_tab)
+ self.tab_widget.currentChanged.connect(self._on_tab_changed)
+ self.tab_widget.tabBar().tabBarClicked.connect(self._on_tab_bar_clicked)
+
+ # Add a persistent "+" tab at the end
+ self.tab_widget.addTab(QWidget(), "+")
+
+ self.item_names = []
+ self.item_data_map.clear()
+ for affix_group in self.affixes_model:
+ for item_name in affix_group.root:
+ if item_name in self.item_names:
+ QMessageBox.warning(
+ self, "Warning", f"Item name already exist please rename {item_name} in the profile file."
+ )
+ continue
+ self.item_names.append(item_name)
+ self.item_data_map[item_name] = affix_group
+ # Insert before the "+" tab
+ self.tab_widget.insertTab(self.tab_widget.count() - 1, QWidget(), item_name)
+
+ self._update_plus_tab_button()
self.main_layout.addWidget(self.tab_widget)
def show_message(self, text):
QMessageBox.information(self, "Info", text)
+ def _on_tab_changed(self, index):
+ if index >= 0 and self.tab_widget.tabText(index) == "+":
+ self.add_item_type()
+
+ def _on_tab_bar_clicked(self, index):
+ # This handles clicking the "+" tab when it's already selected
+ if index >= 0 and self.tab_widget.tabText(index) == "+" and self.tab_widget.currentIndex() == index:
+ self.add_item_type()
+
+ def _update_plus_tab_button(self):
+ for i in range(self.tab_widget.count()):
+ if self.tab_widget.tabText(i) == "+":
+ self.tab_widget.tabBar().setTabButton(i, QTabBar.ButtonPosition.RightSide, None)
+ self.tab_widget.setTabToolTip(i, "Create Item")
+
+ def _ensure_tab_instantiated(self, index: int):
+ if index < 0 or index >= self.tab_widget.count():
+ return
+ if not isinstance(self.tab_widget.widget(index), AffixGroupEditor):
+ item_name = self.item_names[index]
+ affix_group = self.item_data_map[item_name]
+ is_current = self.tab_widget.currentIndex() == index
+ with QSignalBlocker(self.tab_widget):
+ editor = AffixGroupEditor(affix_group)
+ editor.duplicate_requested.connect(self.duplicate_item_tab)
+ self.tab_widget.removeTab(index)
+ self.tab_widget.insertTab(index, editor, item_name)
+ if is_current:
+ self.tab_widget.setCurrentIndex(index)
+
def add_item_type(self):
+ plus_idx = -1
+ for i in range(self.tab_widget.count()):
+ if self.tab_widget.tabText(i) == "+":
+ plus_idx = i
+ break
+
+ # Switch to previous tab if we were triggered by clicking the "+" tab
+ if self.tab_widget.currentIndex() == plus_idx and plus_idx > 0:
+ self.tab_widget.setCurrentIndex(plus_idx - 1)
+
+ if self._current_slot_name:
+ base_name = self._current_slot_name.replace(" ", "")
+ new_name = base_name
+ if new_name in self.item_names:
+ i = 2
+ while f"{base_name}{i}" in self.item_names:
+ i += 1
+ new_name = f"{base_name}{i}"
+
+ item_model = ItemFilterModel(item_type=self._current_slot_item_types or [])
+ dynamic_filter = DynamicItemFilterModel({new_name: item_model})
+
+ self.item_names.append(new_name)
+ self.item_data_map[new_name] = dynamic_filter
+ group = AffixGroupEditor(dynamic_filter)
+ self.tab_widget.insertTab(plus_idx, group, new_name)
+ self.affixes_model.append(dynamic_filter)
+ self.tab_widget.setCurrentIndex(plus_idx)
+ self._update_plus_tab_button()
+ return
+
+ # Fallback for manual creation outside of doll context
dialog = CreateItem(self.item_names, self)
if dialog.exec() == QDialog.DialogCode.Accepted:
item = dialog.get_value()
for item_name in item.root:
group = AffixGroupEditor(item)
self.item_names.append(item_name)
- self.tab_widget.addTab(group, item_name)
+ self.item_data_map[item_name] = item
+ self.tab_widget.insertTab(plus_idx, group, item_name)
self.affixes_model.append(item)
- return
+ self.tab_widget.setCurrentIndex(plus_idx)
+ self._update_plus_tab_button()
def close_tab(self, index):
- self.item_names.pop(index)
- self.tab_widget.removeTab(index)
- self.affixes_model.pop(index)
+ if self.tab_widget.tabText(index) == "+":
+ return
+
+ item_name = self.item_names[index]
+ reply = QMessageBox.question(
+ self,
+ "Confirm Deletion",
+ f"Are you sure you want to delete the item filter '{item_name}'?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No,
+ )
+ if reply != QMessageBox.StandardButton.Yes:
+ return
+
+ with QSignalBlocker(self.tab_widget):
+ name = self.item_names.pop(index)
+ self.item_data_map.pop(name, None)
+ self.tab_widget.removeTab(index)
+ self.affixes_model.pop(index)
+ self._update_plus_tab_button()
+
+ def duplicate_item_tab(self, original_filter: DynamicItemFilterModel):
+ # Find a unique name for the duplicated item
+ original_name = next(iter(original_filter.root.keys()))
+ new_name_base = f"{original_name} (Copy)"
+ new_name = new_name_base
+ i = 1
+ while new_name in self.item_names:
+ i += 1
+ new_name = f"{new_name_base} {i}"
+
+ # Create a deep copy of the filter model
+ new_filter_model = copy.deepcopy(original_filter)
+ # Update the key in the root dictionary to the new name
+ new_filter_model.root = {new_name: new_filter_model.root.pop(original_name)}
+
+ # Add to our internal lists and create a new tab
+ self.item_names.append(new_name)
+ self.item_data_map[new_name] = new_filter_model
+ self.affixes_model.append(new_filter_model)
+
+ # Find the "+" tab index to insert before it
+ plus_idx = -1
+ for i in range(self.tab_widget.count()):
+ if self.tab_widget.tabText(i) == "+":
+ plus_idx = i
+ break
+
+ # Create the actual editor widget and insert the tab
+ editor = AffixGroupEditor(new_filter_model)
+ editor.duplicate_requested.connect(self.duplicate_item_tab)
+
+ if plus_idx != -1:
+ self.tab_widget.insertTab(plus_idx, editor, new_name)
+ self.tab_widget.setCurrentIndex(plus_idx)
+ self._update_plus_tab_button()
def remove_item_type(self):
dialog = DeleteItem(self.item_names, self)
@@ -976,8 +1842,10 @@ def remove_item_type(self):
for item_name in item_names_to_delete:
index = self.item_names.index(item_name)
self.item_names.remove(item_name)
+ self.item_data_map.pop(item_name, None)
self.tab_widget.removeTab(index)
self.affixes_model.pop(index)
+ self._update_plus_tab_button()
return
def set_all_min_greater_affix(self):
@@ -985,24 +1853,149 @@ def set_all_min_greater_affix(self):
if dialog.exec() == QDialog.DialogCode.Accepted:
min_greater_affix = dialog.get_value()
for i in range(self.tab_widget.count()):
- tab: AffixGroupEditor = self.tab_widget.widget(i)
- if tab.auto_sync_checkbox.isChecked():
+ if self.tab_widget.tabText(i) == "+":
continue
- tab.min_greater.setValue(min_greater_affix)
- tab.update_min_greater_affix()
+
+ tab = self.tab_widget.widget(i)
+ item_name = self.item_names[i]
+
+ if isinstance(tab, AffixGroupEditor):
+ if tab.auto_sync_checkbox.isChecked():
+ continue
+ tab.min_greater.setValue(min_greater_affix)
+ tab.update_min_greater_affix()
+ else:
+ # Placeholder: check settings for auto-sync status
+ if self.settings.value(f"auto_sync_ga_{item_name}", defaultValue=False, type=bool):
+ continue
+ self.item_data_map[item_name].root[item_name].min_greater_affix_count = min_greater_affix
def convert_all_to_min_percent_of_affix(self):
- current_tab = self.tab_widget.currentWidget()
- if isinstance(current_tab, AffixGroupEditor):
- dialog = MinPercentDialog(self)
- if dialog.exec() == QDialog.DialogCode.Accepted:
- current_tab.convert_all_to_min_percent_of_affix(dialog.get_value())
+ dialog = MinPercentDialog(self)
+ if dialog.exec() == QDialog.DialogCode.Accepted:
+ percent = dialog.get_value()
+ for i in range(self.tab_widget.count()):
+ if self.tab_widget.tabText(i) == "+":
+ continue
+
+ tab = self.tab_widget.widget(i)
+ item_name = self.item_names[i]
+
+ if isinstance(tab, AffixGroupEditor):
+ tab.convert_all_to_min_percent_of_affix(percent)
+ else:
+ # Placeholder: update the data model directly
+ config = self.item_data_map[item_name].root[item_name]
+ for pool in config.affix_pool:
+ for affix in pool.count:
+ affix.min_percent_of_affix = percent
+ affix.value = None
def set_all_min_power(self):
dialog = MinPowerDialog(self)
if dialog.exec() == QDialog.DialogCode.Accepted:
min_power = dialog.get_value()
for i in range(self.tab_widget.count()):
- tab: AffixGroupEditor = self.tab_widget.widget(i)
- tab.min_power.setValue(min_power)
- tab.update_min_power()
+ if self.tab_widget.tabText(i) == "+":
+ continue
+
+ tab = self.tab_widget.widget(i)
+ item_name = self.item_names[i]
+
+ if isinstance(tab, AffixGroupEditor):
+ tab.min_power.setValue(min_power)
+ tab.update_min_power()
+ else:
+ # Placeholder: Update the model directly
+ self.item_data_map[item_name].root[item_name].min_power = min_power
+
+ def filter_by_item_types(self, item_types: list[ItemType] | None, slot_name: str | None = None):
+ """Show only tabs that match the provided item types."""
+ if not hasattr(self, "tab_widget"):
+ return
+ self._current_slot_name = slot_name
+ self._current_slot_item_types = item_types
+
+ # Normalize slot name for comparison (e.g., "Dual-Wield 1" -> "dualwield1")
+ slot_match_name = slot_name.lower().replace(" ", "").replace("-", "") if slot_name else None
+ is_rings = slot_match_name == "rings"
+ is_dw_all = slot_match_name == "dualwields"
+ is_ring_2 = slot_match_name == "ring2"
+ is_ring_1 = slot_match_name == "ring1"
+ is_dw_1 = slot_match_name == "dualwield1"
+ is_dw_2 = slot_match_name == "dualwield2"
+ is_dw_ranged = slot_match_name == "rangedweapon"
+ is_bludgeoning = slot_match_name == "bludgeoning"
+ is_slashing = slot_match_name == "slashing"
+ is_main_hand = slot_match_name == "mainhand"
+ type_names = [t.value.lower().replace(" ", "").replace("-", "") for t in item_types] if item_types else []
+
+ # Determine if we have any tabs that specifically match this slot's name.
+ # This allows us to separate slots like "Ring 1" and "Ring 2" if the user has rules for both.
+ has_exact_match = False
+ if slot_match_name:
+ for i in range(self.tab_widget.count()):
+ tab_text = self.tab_widget.tabText(i).lower().replace(" ", "").replace("-", "")
+ if (
+ tab_text == slot_match_name
+ or (slot_match_name and slot_match_name in tab_text)
+ or (tab_text and tab_text in slot_match_name)
+ or (is_rings and "ring" in tab_text)
+ or (is_dw_all and "dualwield" in tab_text)
+ or (is_ring_1 and tab_text == "ring")
+ or (is_dw_1 and tab_text == "dualwield")
+ or (is_dw_2 and tab_text == "dualwield")
+ or (is_dw_ranged and tab_text == "ranged")
+ or (
+ tab_text in type_names
+ and not (is_ring_2 or is_dw_2 or is_dw_1 or is_bludgeoning or is_slashing or is_dw_ranged)
+ )
+ or (is_main_hand and tab_text == "weapon")
+ ):
+ has_exact_match = True
+ break
+
+ with QSignalBlocker(self.tab_widget):
+ for i in range(self.tab_widget.count()):
+ if self.tab_widget.tabText(i) == "+":
+ self.tab_widget.setTabVisible(i, True) # noqa: FBT003
+ continue
+
+ item_name = self.item_names[i]
+ affix_group = self.item_data_map[item_name]
+ config = affix_group.root[item_name]
+ tab_text = self.tab_widget.tabText(i).lower().replace(" ", "").replace("-", "")
+
+ type_match = not item_types or not config.item_type or any(t in config.item_type for t in item_types)
+
+ if has_exact_match:
+ visible = type_match and (
+ tab_text == slot_match_name
+ or (slot_match_name and slot_match_name in tab_text)
+ or (tab_text and tab_text in slot_match_name)
+ or (is_rings and "ring" in tab_text)
+ or (is_dw_all and "dualwield" in tab_text)
+ or (is_ring_1 and tab_text == "ring")
+ or (is_dw_1 and tab_text == "dualwield")
+ or (is_dw_2 and tab_text == "dualwield")
+ or (is_dw_ranged and tab_text == "ranged")
+ or (
+ tab_text in type_names
+ and not (is_ring_2 or is_dw_2 or is_dw_1 or is_bludgeoning or is_slashing or is_dw_ranged)
+ )
+ or (is_main_hand and tab_text == "weapon")
+ )
+ else:
+ visible = type_match
+
+ if visible and not isinstance(self.tab_widget.widget(i), AffixGroupEditor):
+ self._ensure_tab_instantiated(i)
+ self.tab_widget.setTabVisible(i, visible)
+
+ # Ensure a valid content tab is focused instead of the '+' tab
+ curr = self.tab_widget.currentIndex()
+ if curr == -1 or not self.tab_widget.isTabVisible(curr) or self.tab_widget.tabText(curr) == "+":
+ for i in range(self.tab_widget.count()):
+ if self.tab_widget.isTabVisible(i) and self.tab_widget.tabText(i) != "+":
+ self.tab_widget.setCurrentIndex(i)
+ break
diff --git a/src/gui/profile_editor/aspect_upgrades_tab.py b/src/gui/profile_editor/aspect_upgrades_tab.py
index 29d68db0..95f2dd23 100644
--- a/src/gui/profile_editor/aspect_upgrades_tab.py
+++ b/src/gui/profile_editor/aspect_upgrades_tab.py
@@ -1,59 +1,139 @@
-from PyQt6.QtCore import Qt
-from PyQt6.QtWidgets import QDialog, QHBoxLayout, QLabel, QListWidget, QPushButton, QVBoxLayout, QWidget
+"""Tab widget for managing the whitelist of aspects to track for Codex upgrades."""
+
+import contextlib
+from typing import override
+
+from PyQt6.QtCore import Qt, pyqtSignal
+from PyQt6.QtWidgets import (
+ QDialog,
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QScrollArea,
+ QSizePolicy,
+ QStyle,
+ QStyleOption,
+ QVBoxLayout,
+ QWidget,
+)
from src.gui.models.dialog import AddAspectUpgrade
+from src.gui.profile_editor.affixes_tab import (
+ QPainter,
+ _create_column_header,
+ _create_delete_btn,
+ _create_summary_card_style,
+)
ASPECT_UPGRADES_TABNAME = "Aspect Upgrades"
+class AspectUpgradeSummaryWidget(QWidget):
+ delete_requested = pyqtSignal()
+
+ def __init__(self, aspect_key: str, parent=None):
+ super().__init__(parent)
+ self.aspect_key = aspect_key
+ self.setObjectName("SummaryCard")
+ self.setStyleSheet(_create_summary_card_style())
+ self.setup_ui()
+
+ def setup_ui(self):
+ self.main_layout = QHBoxLayout(self)
+ self.main_layout.setContentsMargins(10, 8, 10, 8)
+
+ # Format the aspect key into a friendly title
+ display_name = self.aspect_key.replace("_", " ").title()
+ self.summary_label = QLabel(display_name)
+ self.summary_label.setStyleSheet("font-weight: bold; color: #e2e8f0;")
+ self.main_layout.addWidget(self.summary_label, 1)
+
+ self.delete_btn = _create_delete_btn()
+ self.delete_btn.clicked.connect(self.delete_requested.emit)
+ self.main_layout.addWidget(self.delete_btn)
+
+ @override
+ def paintEvent(self, event):
+ opt = QStyleOption()
+ opt.initFrom(self)
+ p = QPainter(self)
+ self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, p, self)
+ p.end()
+
+
class AspectUpgradesTab(QWidget):
def __init__(self, aspect_upgrades: list[str], parent=None):
super().__init__(parent)
self.aspect_upgrades = aspect_upgrades
- self.upgrade_list_widget = QListWidget()
self.loaded = False
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def load(self):
- if not self.loaded:
- self.setup_ui()
- self.loaded = True
+ with contextlib.suppress(RuntimeError):
+ if not self.loaded:
+ self.setup_ui()
+ self.loaded = True
def setup_ui(self):
- main_layout = QVBoxLayout(self)
- main_layout.setContentsMargins(0, 20, 0, 20)
- main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
- label = QLabel(
- "Add any legendary aspects you'd like to have favorited if an upgrade is found. See the readme on AspectUpgrades for more information."
+ self.main_layout = QVBoxLayout(self)
+ self.main_layout.setContentsMargins(0, 5, 0, 5)
+ self.main_layout.setSpacing(0)
+ self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+
+ self.header = QLabel("Aspect Upgrades")
+ self.header.setStyleSheet(
+ "font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 10px; background: transparent; border: none;"
+ )
+ self.main_layout.addWidget(self.header)
+
+ self.desc = QLabel(
+ "Whitelist specific aspects to track for Codex upgrades. Items matching these will be favorited and highlighted in orange when an upgrade is detected."
+ )
+ self.desc.setWordWrap(True)
+ self.desc.setStyleSheet(
+ "font-size: 13px; color: #94a3b8; font-style: italic; margin-bottom: 15px; background: transparent; border: none;"
)
- main_layout.addWidget(label)
- button_layout = self.create_button_layout()
- main_layout.addLayout(button_layout)
+ self.main_layout.addWidget(self.desc)
+
+ header = _create_column_header("Aspect Upgrades", self.add_aspect)
+ self.main_layout.addWidget(header)
+
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QFrame.Shape.Panel)
+ scroll.setStyleSheet("QScrollArea { border: 1px solid #2d2d2d; border-left: none; background-color: #121212; }")
- self.upgrade_list_widget.insertItems(0, self.aspect_upgrades)
- main_layout.addWidget(self.upgrade_list_widget)
- self.setLayout(main_layout)
+ self.scroll_widget = QWidget()
+ self.list_layout = QVBoxLayout(self.scroll_widget)
+ self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ self.list_layout.setContentsMargins(10, 10, 10, 10)
+ self.list_layout.setSpacing(4)
- def create_button_layout(self) -> QHBoxLayout:
- btn_layout = QHBoxLayout()
+ scroll.setWidget(self.scroll_widget)
+ self.main_layout.addWidget(scroll)
- add_tribute_btn = QPushButton("Add Aspect")
- add_tribute_btn.clicked.connect(self.add_aspect)
+ # Populate initial items
+ for aspect in self.aspect_upgrades:
+ self.add_aspect_item(aspect)
- remove_tribute_btn = QPushButton("Remove Aspect")
- remove_tribute_btn.clicked.connect(self.remove_aspect)
+ self.setLayout(self.main_layout)
- btn_layout.addWidget(add_tribute_btn)
- btn_layout.addWidget(remove_tribute_btn)
- return btn_layout
+ def add_aspect_item(self, aspect_key: str):
+ widget = AspectUpgradeSummaryWidget(aspect_key)
+ widget.delete_requested.connect(lambda: self.remove_aspect_item(widget))
+ self.list_layout.addWidget(widget)
def add_aspect(self):
dialog = AddAspectUpgrade(self.aspect_upgrades)
if dialog.exec() == QDialog.DialogCode.Accepted:
aspect_upgrade = dialog.get_value()
- self.aspect_upgrades.append(aspect_upgrade)
- self.upgrade_list_widget.addItem(aspect_upgrade)
+ if aspect_upgrade:
+ self.aspect_upgrades.append(aspect_upgrade)
+ self.add_aspect_item(aspect_upgrade)
- def remove_aspect(self):
- current_aspect = self.upgrade_list_widget.currentItem().text()
- self.aspect_upgrades.remove(current_aspect)
- self.upgrade_list_widget.takeItem(self.upgrade_list_widget.currentRow())
+ def remove_aspect_item(self, widget: AspectUpgradeSummaryWidget):
+ aspect_key = widget.aspect_key
+ if aspect_key in self.aspect_upgrades:
+ self.aspect_upgrades.remove(aspect_key)
+ widget.setParent(None)
+ widget.deleteLater()
diff --git a/src/gui/profile_editor/global_uniques_tab.py b/src/gui/profile_editor/global_uniques_tab.py
index 6fbb4cc6..9ca4cd87 100644
--- a/src/gui/profile_editor/global_uniques_tab.py
+++ b/src/gui/profile_editor/global_uniques_tab.py
@@ -1,87 +1,422 @@
-from PyQt6.QtCore import Qt
+import contextlib
+import copy
+
+from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, pyqtSignal
from PyQt6.QtWidgets import (
QDialog,
- QFormLayout,
QFrame,
QGroupBox,
+ QHBoxLayout,
+ QLabel,
QLineEdit,
+ QMessageBox,
QPushButton,
QScrollArea,
+ QSizePolicy,
+ QTabBar,
QTabWidget,
- QToolBar,
- QToolButton,
QVBoxLayout,
QWidget,
)
-from src.config.profile_models import GlobalUniqueModel
+from src.config.profile_models import (
+ AffixFilterCountModel,
+ AffixFilterModel,
+ AspectUniqueFilterModel,
+ GlobalUniqueModel,
+)
+from src.dataloader import Dataloader
from src.gui.importer.gui_common import MAX_POWER
+from src.gui.models.checkmark_checkbox import CheckmarkCheckBox
from src.gui.models.dialog import DeleteItem, IgnoreScrollWheelSpinBox
+from src.gui.profile_editor.affixes_tab import (
+ AffixSummaryWidget,
+ CharacterSpinBox,
+ ItemTypePicker,
+ UniqueAspectWidget,
+ _create_column_footer,
+ _create_column_header,
+ _item_type_summary,
+)
+from src.item.data.item_type import ItemType, is_armor, is_jewelry, is_weapon
-UNIQUES_TABNAME = "GlobalUniques"
+UNIQUES_TABNAME = "GlobalRules"
class UniqueWidget(QWidget):
+ duplicate_requested = pyqtSignal(GlobalUniqueModel)
+
def __init__(self, unique_model: GlobalUniqueModel, parent=None):
super().__init__(parent)
+ self.settings = QSettings("d4lf", "profile_editor")
self.unique_model = unique_model
-
+ self.affix_column_widgets = []
+ self.affix_pool_layouts = []
+ self.affix_footers = []
+ self.inherent_footer = None
+ self.item_types = [
+ item for item in ItemType.__members__.values() if is_armor(item) or is_jewelry(item) or is_weapon(item)
+ ]
self.setup_ui()
def setup_ui(self):
- scroll_area = QScrollArea(self)
- scroll_area.setWidgetResizable(True)
- scroll_area.setFrameShape(QFrame.Shape.NoFrame)
-
- content_widget = QWidget()
- self.content_layout = QVBoxLayout(content_widget)
- self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ self.content_layout = QVBoxLayout(self)
+ self.content_layout.setContentsMargins(0, 10, 0, 0)
self.create_general_groupbox()
- scroll_area.setWidget(content_widget)
- self.main_layout = QVBoxLayout()
- self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
- self.main_layout.addWidget(scroll_area)
- self.setLayout(self.main_layout)
+ # Rule Content
+ self.columns_layout = QHBoxLayout()
+ self.columns_layout.setContentsMargins(0, 0, 0, 0)
+ self.columns_layout.setSpacing(15)
+
+ # Column 1: Unique Aspects
+ self.aspect_col, self.aspect_rows_layout, _ = self._create_col_helper("Unique Aspects", self.add_unique_aspect)
+ self.columns_layout.addWidget(self.aspect_col)
+
+ # Column(s) 2: Affix Pool(s)
+ for pool in self.unique_model.affix_pool:
+ self._add_affix_pool_column_widget(pool)
+
+ self.content_layout.addLayout(self.columns_layout)
+
+ # Initialize content
+ self.init_aspects()
+ self.init_affix_pool()
+
+ def _create_col_helper(self, title, add_cb, pool_model=None, remove_cb=None):
+ col_widget = QWidget()
+ col_layout = QVBoxLayout(col_widget)
+ col_layout.setContentsMargins(0, 0, 0, 0)
+ col_layout.setSpacing(0)
+
+ header = _create_column_header(title, add_cb, remove_cb)
+ col_layout.addWidget(header)
+
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
+ scroll.viewport().setAutoFillBackground(False)
+ scroll.setStyleSheet(
+ "QScrollArea { border: 1px solid #2d2d2d; border-left: none; background-color: #121212; border-bottom: none; }"
+ )
+
+ inner = QWidget()
+ inner_layout = QVBoxLayout(inner)
+ inner_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ scroll.setWidget(inner)
+ col_layout.addWidget(scroll)
+
+ footer = None
+ if pool_model is not None:
+ footer = _create_column_footer(pool_model, self.update_greater_count_label)
+ footer.setStyleSheet(
+ "background-color: #1a1a1a; border: 1px solid #2d2d2d; border-left: none; border-top: none;"
+ )
+ col_layout.addWidget(footer)
+
+ return col_widget, inner_layout, footer
+
+ def _add_affix_pool_column_widget(self, pool_model: AffixFilterCountModel):
+ def add_cb():
+ self.add_affix_to_pool(pool_model)
+
+ # Only provide a remove callback for additional pools (index > 0)
+ is_additional = self.unique_model.affix_pool.index(pool_model) > 0
+ remove_cb = (lambda: self.remove_affix_pool_column(pool_model)) if is_additional else None
+
+ col_widget, inner_layout, footer = self._create_col_helper("Affix Pool", add_cb, pool_model, remove_cb)
+ self.columns_layout.addWidget(col_widget)
+
+ self.affix_column_widgets.append(col_widget)
+ self.affix_pool_layouts.append(inner_layout)
+ self.affix_footers.append(footer)
+
+ def add_additional_affix_pool_column(self):
+ new_pool = AffixFilterCountModel(count=[], min_count=1)
+ self.unique_model.affix_pool.append(new_pool)
+ self._add_affix_pool_column_widget(new_pool)
+
+ def remove_affix_pool_column(self, pool_model: AffixFilterCountModel):
+ reply = QMessageBox.question(
+ self,
+ "Confirm Deletion",
+ "Are you sure you want to delete this entire affix pool?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ )
+ if reply == QMessageBox.StandardButton.Yes:
+ idx = self.unique_model.affix_pool.index(pool_model)
+ self.unique_model.affix_pool.pop(idx)
+
+ widget = self.affix_column_widgets.pop(idx)
+ self.affix_pool_layouts.pop(idx)
+ self.affix_footers.pop(idx)
+
+ widget.setParent(None)
+ widget.deleteLater()
+ self.update_greater_count_label()
+
+ def init_aspects(self):
+ for aspect in self.unique_model.unique_aspect:
+ self.add_unique_aspect_item(aspect)
+
+ def init_affix_pool(self):
+ for i, pool in enumerate(self.unique_model.affix_pool):
+ for affix in pool.count:
+ self.add_affix_item(affix, pool_idx=i)
+
+ def add_affix_item(self, model: AffixFilterModel, pool_idx: int = 0):
+ layout = self.affix_pool_layouts[pool_idx]
+ widget = AffixSummaryWidget(model)
+ widget.delete_requested.connect(lambda: self.remove_affix_item_widget(widget, pool_idx))
+ widget.config_changed.connect(self.update_greater_count_label)
+ layout.addWidget(widget)
+ return widget
+
+ def remove_affix_item_widget(self, widget, pool_idx: int = 0):
+ layout = self.affix_pool_layouts[pool_idx]
+ pool = self.unique_model.affix_pool[pool_idx]
+ idx = layout.indexOf(widget)
+ if idx != -1:
+ pool.count.pop(idx)
+ widget.setParent(None)
+ widget.deleteLater()
+ self.update_greater_count_label()
def create_general_groupbox(self):
self.general_groupbox = QGroupBox()
- self.general_groupbox.setTitle("Global Unique Rule")
- self.general_form = QFormLayout()
+ self.general_groupbox.setTitle("Global Unique Rule Configuration")
+ self.general_groupbox.setStyleSheet("QGroupBox { border-left: none; border-right: none; }")
+ main_vbox = QVBoxLayout(self.general_groupbox)
+ main_vbox.setContentsMargins(10, 15, 10, 10)
+
+ # Profile Alias / Name
+ top_row = QHBoxLayout()
+ top_row.addWidget(QLabel("Rule Alias:"))
self.profile_alias = QLineEdit()
- self.profile_alias.setMaximumWidth(300)
+ self.profile_alias.setFixedWidth(200)
+ self.profile_alias.setStyleSheet("""
+ QLineEdit {
+ background-color: #09090b;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ color: #e2e8f0;
+ }
+ QLineEdit:focus { border-color: #3b82f6; }
+ """)
self.profile_alias.setText(self.unique_model.profile_alias)
self.profile_alias.textChanged.connect(self.update_profile_alias)
- self.general_form.addRow("Profile Alias:", self.profile_alias)
+ top_row.addWidget(self.profile_alias)
+
+ top_row.addSpacing(30)
+ top_row.addWidget(QLabel("Minimum Power:"))
self.min_power = IgnoreScrollWheelSpinBox()
self.min_power.setRange(0, MAX_POWER)
self.min_power.setValue(self.unique_model.min_power)
- self.min_power.setMaximumWidth(150)
+ self.min_power.setFixedWidth(80)
self.min_power.valueChanged.connect(self.update_min_power)
- self.general_form.addRow("Minimum Power:", self.min_power)
-
- self.min_greater = IgnoreScrollWheelSpinBox()
- self.min_greater.setRange(0, 4)
- self.min_greater.setValue(self.unique_model.min_greater_affix_count)
- self.min_greater.setMaximumWidth(150)
- self.min_greater.valueChanged.connect(self.update_min_greater_affix)
- self.general_form.addRow("Min Greater Affixes:", self.min_greater)
-
- self.min_percent = IgnoreScrollWheelSpinBox()
- self.min_percent.setRange(0, 100)
- self.min_percent.setValue(self.unique_model.min_percent_of_aspect)
- self.min_percent.setMaximumWidth(150)
- self.min_percent.valueChanged.connect(self.update_min_percent)
- self.general_form.addRow("Min Percent of Aspect:", self.min_percent)
-
- self.general_groupbox.setLayout(self.general_form)
+ top_row.addWidget(self.min_power)
+
+ top_row.addStretch()
+
+ duplicate_btn = QPushButton("Duplicate Rule")
+ duplicate_btn.setFixedWidth(120)
+ duplicate_btn.setStyleSheet("""
+ QPushButton {
+ background-color: #1e3a5f;
+ border: 1px solid #3b82f6;
+ color: #e2e8f0;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #2563eb; }
+ """)
+ duplicate_btn.clicked.connect(self._on_duplicate_clicked)
+ top_row.addWidget(duplicate_btn)
+ main_vbox.addLayout(top_row)
+
+ # Item Types (Slots)
+ slots_row = QHBoxLayout()
+ slots_row.addWidget(QLabel("Target Slots:"))
+ self.item_type_line_edit = QLineEdit()
+ self.item_type_line_edit.setReadOnly(True)
+ self.refresh_item_type_summary()
+ slots_row.addWidget(self.item_type_line_edit)
+ edit_item_types_btn = QPushButton("Select Slots")
+ edit_item_types_btn.setMaximumWidth(100)
+ edit_item_types_btn.clicked.connect(self.edit_item_types)
+ slots_row.addWidget(edit_item_types_btn)
+ main_vbox.addLayout(slots_row)
+
+ # Min Greater Affixes with Auto Sync
+ ga_row = QHBoxLayout()
+ ga_row.addWidget(QLabel("Min Greater Affixes:"))
+ self.min_greater = CharacterSpinBox()
+ self.min_greater.set_range(0, 4)
+ self.min_greater.set_value(self.unique_model.min_greater_affix_count)
+ self.min_greater.setFixedWidth(100)
+ self.min_greater.value_changed.connect(self.update_min_greater_affix_from_spin)
+
+ self.auto_sync_checkbox = CheckmarkCheckBox("Auto Sync")
+ self.auto_sync_checkbox.setStyleSheet("background: transparent;")
+ self.auto_sync_checkbox.setChecked(
+ self.settings.value(f"auto_sync_ga_global_{self.unique_model.profile_alias}", defaultValue=False, type=bool)
+ )
+ self.auto_sync_checkbox.stateChanged.connect(self.toggle_auto_sync)
+
+ self.greater_count_label = QLabel()
+ self.greater_count_label.setProperty("greaterCountLabel", True) # noqa: FBT003
+ self._refresh_widget_style(self.greater_count_label)
+
+ ga_row.addWidget(self.min_greater)
+ ga_row.addWidget(self.auto_sync_checkbox)
+ ga_row.addWidget(self.greater_count_label)
+ ga_row.addStretch()
+
+ add_pool_btn = QPushButton("Add Additional Affix Pool")
+ add_pool_btn.setFixedWidth(180)
+ add_pool_btn.setStyleSheet("""
+ QPushButton {
+ background-color: #06201b;
+ border: 1px solid #064e3b;
+ color: #22c55e;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #064e3b; color: white; }
+ """)
+ add_pool_btn.clicked.connect(self.add_additional_affix_pool_column)
+ ga_row.addWidget(add_pool_btn)
+
+ if self.auto_sync_checkbox.isChecked():
+ self.min_greater.setProperty("autoSyncSpin", True) # noqa: FBT003
+ self._refresh_widget_style(self.min_greater)
+
+ main_vbox.addLayout(ga_row)
+
+ self.min_greater.setEnabled(not self.auto_sync_checkbox.isChecked())
self.content_layout.addWidget(self.general_groupbox)
+ def _refresh_widget_style(self, widget):
+ widget.style().unpolish(widget)
+ widget.style().polish(widget)
+
+ def _on_duplicate_clicked(self):
+ self.duplicate_requested.emit(self.unique_model)
+
+ def add_unique_aspect_item(self, model: AspectUniqueFilterModel) -> UniqueAspectWidget:
+ widget = UniqueAspectWidget(model)
+ widget.delete_requested.connect(lambda: self.remove_unique_aspect_widget(widget))
+ self.aspect_rows_layout.addWidget(widget)
+ return widget
+
+ def add_unique_aspect(self):
+ aspect_name = next(iter(Dataloader().aspect_unique_dict.keys()))
+ new_aspect = AspectUniqueFilterModel(name=aspect_name)
+ self.unique_model.unique_aspect.append(new_aspect)
+ widget = self.add_unique_aspect_item(new_aspect)
+ if widget.open_config_dialog() == QDialog.DialogCode.Rejected:
+ self.remove_unique_aspect_widget(widget)
+
+ def remove_unique_aspect_widget(self, widget: UniqueAspectWidget):
+ if widget.unique_aspect in self.unique_model.unique_aspect:
+ self.unique_model.unique_aspect.remove(widget.unique_aspect)
+ widget.setParent(None)
+ widget.deleteLater()
+
+ def _get_default_affix_name(self) -> str:
+ common_affixes = ["Energy", "Strength", "Dexterity", "Vitality", "Intelligence"]
+ reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()}
+ for affix in common_affixes:
+ if affix in reverse_dict:
+ return reverse_dict[affix]
+ return next(iter(Dataloader().affix_dict.keys()))
+
+ def add_affix_to_pool(self, pool_model: AffixFilterCountModel):
+ idx = self.unique_model.affix_pool.index(pool_model)
+ new_affix = AffixFilterModel(name=self._get_default_affix_name())
+ pool_model.count.append(new_affix)
+ widget = self.add_affix_item(new_affix, pool_idx=idx)
+ if widget.open_config_dialog() == QDialog.DialogCode.Rejected:
+ self.remove_affix_item_widget(widget, pool_idx=idx)
+
+ def add_affix_pool(self):
+ if self.unique_model.affix_pool:
+ self.add_affix_to_pool(self.unique_model.affix_pool[0])
+
+ def add_inherent_pool(self):
+ new_affix = AffixFilterModel(name=self._get_default_affix_name())
+ self.unique_model.inherent_pool[0].count.append(new_affix)
+ widget = self.add_affix_item(new_affix)
+ if widget.open_config_dialog() == QDialog.DialogCode.Rejected:
+ self.remove_affix_item_widget(widget)
+
+ def toggle_auto_sync(self):
+ is_auto = self.auto_sync_checkbox.isChecked()
+ self.settings.setValue(f"auto_sync_ga_global_{self.unique_model.profile_alias}", is_auto)
+
+ self.min_greater.setEnabled(not is_auto)
+ if is_auto:
+ self.min_greater.setProperty("autoSyncSpin", True) # noqa: FBT003
+ else:
+ self.min_greater.setProperty("autoSyncSpin", False) # noqa: FBT003
+ self._refresh_widget_style(self.min_greater)
+
+ if is_auto:
+ self.update_greater_count_label()
+
+ def update_greater_count_label(self):
+ count = 0
+ # Count in affix pools
+ for pool in self.unique_model.affix_pool:
+ for affix in pool.count:
+ if getattr(affix, "want_greater", False):
+ count += 1
+
+ if count == 0:
+ self.greater_count_label.setText("(no greater affixes marked)")
+ elif count == 1:
+ self.greater_count_label.setText("(1 greater affix marked)")
+ else:
+ self.greater_count_label.setText(f"({count} greater affixes marked)")
+ if self.auto_sync_checkbox.isChecked():
+ with QSignalBlocker(self.min_greater):
+ self.min_greater.set_value(count)
+
+ # Update affix pool footers
+ for footer, model in zip(self.affix_footers, self.unique_model.affix_pool, strict=False):
+ self._update_footer_constraints(footer, model)
+
+ def _update_footer_constraints(self, footer, model):
+ if footer and model:
+ min_spin = footer.property("min_spin")
+ if min_spin:
+ min_allowed = sum(1 for a in model.count if getattr(a, "required", False))
+ min_spin.set_minimum(min_allowed)
+ if model.min_count < min_allowed:
+ model.min_count = min_allowed
+ min_spin.set_value(min_allowed)
+
+ def refresh_item_type_summary(self):
+ self.item_type_line_edit.setText(_item_type_summary(self.unique_model.item_type))
+
+ def edit_item_types(self):
+ picker = ItemTypePicker(self, self.item_types, self.unique_model.item_type)
+ if picker.exec() == QDialog.DialogCode.Accepted:
+ self.unique_model.item_type = picker.get_selected_item_types()
+ self.refresh_item_type_summary()
+
def update_profile_alias(self, value: str):
self.unique_model.profile_alias = value.strip()
+ self.update_parent_tab_text()
+
+ def update_parent_tab_text(self):
+ p = self.parent()
+ while p:
+ if type(p).__name__ == "UniquesTab":
+ p.rename_tabs()
+ break
+ p = p.parent()
def update_min_power(self):
self.unique_model.min_power = self.min_power.value()
@@ -89,54 +424,265 @@ def update_min_power(self):
def update_min_greater_affix(self):
self.unique_model.min_greater_affix_count = self.min_greater.value()
- def update_min_percent(self):
- self.unique_model.min_percent_of_aspect = self.min_percent.value()
+ def update_min_greater_affix_from_spin(self, value: int):
+ self.unique_model.min_greater_affix_count = value
class UniquesTab(QWidget):
def __init__(self, unique_model_list: list[GlobalUniqueModel], parent=None):
super().__init__(parent)
self.unique_model_list = unique_model_list
+ self._current_slot_name = ""
+ self._current_slot_item_types = []
self.loaded = False
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def load(self):
- if not self.loaded:
- self.setup_ui()
- self.loaded = True
+ with contextlib.suppress(RuntimeError):
+ if not self.loaded:
+ self.setup_ui()
+ self.loaded = True
def setup_ui(self):
+ self.setStyleSheet("background: transparent; border: none;")
self.main_layout = QVBoxLayout(self)
- self.main_layout.setContentsMargins(0, 20, 0, 20)
+ self.main_layout.setContentsMargins(0, 0, 0, 0)
self.tab_widget = QTabWidget(self)
- self.tab_widget.setTabsClosable(True)
- self.tab_widget.tabCloseRequested.connect(self.close_tab)
-
- self.add_button = QToolButton()
- self.add_button.setText("+")
- self.add_button.clicked.connect(self.add_item_type)
-
- self.tab_widget.setCornerWidget(self.add_button)
- self.toolbar = QToolBar("MyToolBar", self)
- self.toolbar.setMinimumHeight(50)
- self.toolbar.setContentsMargins(10, 10, 10, 10)
- self.toolbar.setMovable(False)
- for i, unique_model in enumerate(self.unique_model_list):
- group = UniqueWidget(unique_model)
- self.tab_widget.addTab(group, f"Unique Rule {i}")
-
- add_item_button = QPushButton("Create Rule")
- remove_item_button = QPushButton("Remove Rule")
- add_item_button.clicked.connect(self.add_item_type)
- remove_item_button.clicked.connect(self.remove_item_type)
- self.toolbar.addWidget(add_item_button)
- self.toolbar.addWidget(remove_item_button)
- self.main_layout.addWidget(self.toolbar)
+ self.tab_widget.setStyleSheet("""
+ QTabWidget { background: transparent; }
+ QTabWidget::pane { border: none; }
+ QTabBar::tab {
+ background: #1a1a1a;
+ color: #94a3b8;
+ padding: 8px 50px 8px 16px;
+ border: 1px solid #334155;
+ border-bottom: none;
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ margin-right: 2px;
+ }
+ QTabBar::close-button:hover { background-color: #f87171; }
+ QTabBar::tab:selected {
+ background: #1e3a5f;
+ color: #e2e8f0;
+ border: 1px solid #3b82f6;
+ border-bottom: 2px solid #3b82f6;
+ }
+ QTabBar::tab:last, QTabBar::tab:selected:last, QTabBar::tab:only-one, QTabBar::tab:selected:only-one {
+ background: #06201b;
+ color: #22c55e;
+ border: 1px solid #064e3b;
+ border-bottom: 1px solid #064e3b;
+ }
+ """)
+ with QSignalBlocker(self.tab_widget):
+ self.tab_widget.setTabsClosable(True)
+ self.tab_widget.tabCloseRequested.connect(self.close_tab)
+ self.tab_widget.currentChanged.connect(self._on_tab_changed)
+ self.tab_widget.tabBar().tabBarClicked.connect(self._on_tab_bar_clicked)
+
+ # Add a persistent "+" tab at the end
+ self.tab_widget.addTab(QWidget(), "+")
+
+ for i, unique_model in enumerate(self.unique_model_list):
+ self.tab_widget.insertTab(
+ self.tab_widget.count() - 1, QWidget(), unique_model.profile_alias or f"Rule {i}"
+ )
+
+ self._update_plus_tab_button()
+
self.main_layout.addWidget(self.tab_widget)
+ def _on_tab_changed(self, index):
+ if index >= 0 and self.tab_widget.tabText(index) == "+":
+ self.add_item_type()
+
+ def _on_tab_bar_clicked(self, index):
+ # This handles clicking the "+" tab when it's already selected
+ if index >= 0 and self.tab_widget.tabText(index) == "+" and self.tab_widget.currentIndex() == index:
+ self.add_item_type()
+
+ def _update_plus_tab_button(self):
+ for i in range(self.tab_widget.count()):
+ if self.tab_widget.tabText(i) == "+":
+ self.tab_widget.tabBar().setTabButton(i, QTabBar.ButtonPosition.RightSide, None)
+ self.tab_widget.setTabToolTip(i, "Create Rule")
+
def close_tab(self, index):
- self.tab_widget.removeTab(index)
- self.unique_model_list.pop(index)
+ if self.tab_widget.tabText(index) == "+":
+ return
+
+ rule_name = self.tab_widget.tabText(index)
+ reply = QMessageBox.question(
+ self,
+ "Confirm Deletion",
+ f"Are you sure you want to delete the global rule '{rule_name}'?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No,
+ )
+ if reply != QMessageBox.StandardButton.Yes:
+ return
+
+ with QSignalBlocker(self.tab_widget):
+ self.tab_widget.removeTab(index)
+ self.unique_model_list.pop(index)
self.rename_tabs()
+ self._update_plus_tab_button()
+
+ def filter_by_item_types(self, item_types: list[ItemType] | None, slot_name: str | None = None):
+ """Show only tabs that match the provided item types."""
+ if not hasattr(self, "tab_widget"):
+ return
+ self._current_slot_name = slot_name
+ self._current_slot_item_types = item_types
+
+ with QSignalBlocker(self.tab_widget):
+ if slot_name is None: # Global Rules view
+ for i in range(self.tab_widget.count()):
+ if self.tab_widget.tabText(i) == "+":
+ self.tab_widget.setTabVisible(i, True) # noqa: FBT003
+ continue
+ self._ensure_tab_instantiated(i)
+ self.tab_widget.setTabVisible(i, True) # noqa: FBT003
+ return
+
+ slot_match_name = slot_name.lower().replace(" ", "").replace("-", "") if slot_name else None
+ is_rings = slot_match_name == "rings"
+ is_dw_all = slot_match_name == "dualwields"
+ is_ring_2 = slot_match_name == "ring2"
+ is_ring_1 = slot_match_name == "ring1"
+ is_dw_1 = slot_match_name == "dualwield1"
+ is_dw_2 = slot_match_name == "dualwield2"
+ is_dw_ranged = slot_match_name == "rangedweapon"
+ is_bludgeoning = slot_match_name == "bludgeoning"
+ is_slashing = slot_match_name == "slashing"
+ is_main_hand = slot_match_name == "mainhand"
+ type_names = [t.value.lower().replace(" ", "").replace("-", "") for t in item_types] if item_types else []
+
+ # Check for exact matches in rule aliases/names
+ has_exact_match = False
+ if slot_match_name:
+ for i in range(self.tab_widget.count()):
+ if self.tab_widget.tabText(i) == "+":
+ continue
+ model = self.unique_model_list[i]
+ alias = model.profile_alias.lower().replace(" ", "").replace("-", "")
+ if (
+ alias == slot_match_name
+ or (slot_match_name and slot_match_name in alias)
+ or (alias and alias in slot_match_name)
+ or (is_rings and "ring" in alias)
+ or (is_dw_all and "dualwield" in alias)
+ or (is_ring_1 and alias == "ring")
+ or (is_dw_1 and alias == "dualwield")
+ or (is_dw_2 and alias == "dualwield")
+ or (is_dw_ranged and alias == "ranged")
+ or (
+ alias in type_names
+ and not (is_ring_2 or is_dw_2 or is_dw_1 or is_bludgeoning or is_slashing or is_dw_ranged)
+ )
+ or (is_main_hand and alias == "weapon")
+ ):
+ has_exact_match = True
+ break
+
+ for i in range(self.tab_widget.count()):
+ if self.tab_widget.tabText(i) == "+":
+ self.tab_widget.setTabVisible(i, True) # noqa: FBT003
+ continue
+
+ model = self.unique_model_list[i]
+ alias = model.profile_alias.lower().replace(" ", "").replace("-", "")
+ rule_types = getattr(model, "item_type", [])
+ type_match = not item_types or not rule_types or any(t in rule_types for t in item_types)
+
+ if has_exact_match:
+ visible = type_match and (
+ alias == slot_match_name
+ or (slot_match_name and slot_match_name in alias)
+ or (alias and alias in slot_match_name)
+ or (is_rings and "ring" in alias)
+ or (is_dw_all and "dualwield" in alias)
+ or (is_ring_1 and alias == "ring")
+ or (is_dw_1 and alias == "dualwield")
+ or (is_dw_2 and alias == "dualwield")
+ or (is_dw_ranged and alias == "ranged")
+ or (
+ alias in type_names
+ and not (is_ring_2 or is_dw_2 or is_dw_1 or is_bludgeoning or is_slashing or is_dw_ranged)
+ )
+ or (is_main_hand and alias == "weapon")
+ )
+ else:
+ visible = type_match
+
+ if visible:
+ self._ensure_tab_instantiated(i)
+ self.tab_widget.setTabVisible(i, visible)
+
+ # Ensure a valid content tab is focused instead of the '+' tab
+ curr = self.tab_widget.currentIndex()
+ if curr == -1 or not self.tab_widget.isTabVisible(curr) or self.tab_widget.tabText(curr) == "+":
+ for i in range(self.tab_widget.count()):
+ if self.tab_widget.isTabVisible(i) and self.tab_widget.tabText(i) != "+":
+ self.tab_widget.setCurrentIndex(i)
+ break
+
+ def _ensure_tab_instantiated(self, index: int):
+ if index < 0 or index >= self.tab_widget.count():
+ return
+ if not isinstance(self.tab_widget.widget(index), UniqueWidget):
+ # Find the correct model by counting non-plus tabs before this one
+ model_idx = 0
+ for i in range(index):
+ if self.tab_widget.tabText(i) != "+":
+ model_idx += 1
+
+ if model_idx >= len(self.unique_model_list):
+ return
+
+ model = self.unique_model_list[model_idx]
+ widget = UniqueWidget(model)
+ widget.duplicate_requested.connect(self.duplicate_rule_tab)
+ name = self.tab_widget.tabText(index)
+ is_current = self.tab_widget.currentIndex() == index
+ with QSignalBlocker(self.tab_widget):
+ self.tab_widget.removeTab(index)
+ self.tab_widget.insertTab(index, widget, name)
+ if is_current:
+ self.tab_widget.setCurrentIndex(index)
+
+ def duplicate_rule_tab(self, original_model: GlobalUniqueModel):
+ # Find a unique alias for the duplicated rule
+ original_alias = original_model.profile_alias or "New Rule"
+ new_alias_base = f"{original_alias} (Copy)"
+ new_alias = new_alias_base
+
+ existing_aliases = [m.profile_alias for m in self.unique_model_list]
+ i = 1
+ while new_alias in existing_aliases:
+ i += 1
+ new_alias = f"{new_alias_base} {i}"
+
+ # Create a deep copy of the unique rule model
+ new_model = copy.deepcopy(original_model)
+ new_model.profile_alias = new_alias
+ self.unique_model_list.append(new_model)
+
+ plus_idx = -1
+ for i in range(self.tab_widget.count()):
+ if self.tab_widget.tabText(i) == "+":
+ plus_idx = i
+ break
+
+ # Create the actual editor widget and insert the tab
+ editor = UniqueWidget(new_model)
+ editor.duplicate_requested.connect(self.duplicate_rule_tab)
+
+ if plus_idx != -1:
+ self.tab_widget.insertTab(plus_idx, editor, new_alias)
+ self.tab_widget.setCurrentIndex(plus_idx)
+ self._update_plus_tab_button()
def remove_item_type(self):
dialog = DeleteItem([self.tab_widget.tabText(i) for i in range(self.tab_widget.count())], self)
@@ -150,14 +696,33 @@ def remove_item_type(self):
self.tab_widget.removeTab(index)
self.unique_model_list.pop(index)
self.rename_tabs()
+ self._update_plus_tab_button()
return
def rename_tabs(self):
for i in range(self.tab_widget.count()):
- self.tab_widget.setTabText(i, f"Unique Rule {i}")
+ if self.tab_widget.tabText(i) == "+":
+ continue
+ model = self.unique_model_list[i]
+ self.tab_widget.setTabText(i, model.profile_alias or f"Rule {i}")
def add_item_type(self):
- unique_model = GlobalUniqueModel()
+ item_types = self._current_slot_item_types or []
+ plus_idx = -1
+ for i in range(self.tab_widget.count()):
+ if self.tab_widget.tabText(i) == "+":
+ plus_idx = i
+ break
+
+ # Switch to previous tab if we were triggered by clicking the "+" tab
+ if self.tab_widget.currentIndex() == plus_idx and plus_idx > 0:
+ self.tab_widget.setCurrentIndex(plus_idx - 1)
+
+ alias = f"New Rule {self.tab_widget.count()}"
+ unique_model = GlobalUniqueModel(item_type=item_types, profileAlias=alias)
group = UniqueWidget(unique_model)
- self.tab_widget.addTab(group, f"Unique Rule {self.tab_widget.count()}")
+ group.duplicate_requested.connect(self.duplicate_rule_tab)
+ self.tab_widget.insertTab(plus_idx, group, alias)
self.unique_model_list.append(unique_model)
+ self.tab_widget.setCurrentIndex(plus_idx)
+ self._update_plus_tab_button()
diff --git a/src/gui/profile_editor/paper_doll.py b/src/gui/profile_editor/paper_doll.py
new file mode 100644
index 00000000..7f5d136f
--- /dev/null
+++ b/src/gui/profile_editor/paper_doll.py
@@ -0,0 +1,454 @@
+"""Paper doll equipment layout for the profile editor."""
+
+from typing import override
+
+from PyQt6.QtCore import QRect, QSize, Qt, pyqtSignal
+from PyQt6.QtGui import QColor, QFont, QPainter, QPen
+from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
+
+from src.item.data.item_type import ItemType
+
+# Icon mapping for slots using common unicode symbols
+SLOT_ICONS = {
+ "Helm": "🪖",
+ "Chest Armor": "👕",
+ "Gloves": "🧤",
+ "Pants": "👖",
+ "Boots": "👢",
+ "Amulet": "📿",
+ "Rings": "💍",
+ "Main Hand": "⚔️",
+ "Off Hand": "🛡️",
+ "Bludgeoning": "🔨",
+ "Slashing": "🪓",
+ "Dual-Wield 1": "🗡️",
+ "Dual-Wield 2": "⚔️",
+ "Dual Wields": "⚔️",
+ "Ranged Weapon": "🏹",
+ "Aspect Upgrades": "✨",
+ "Sigils": "📜",
+ "Tributes": "🏆",
+ "Global Rules": "💎",
+}
+
+# Base gear slots common to all classes
+BASE_GEAR_SLOTS = [
+ # Left Column (Gear)
+ ("Helm", [ItemType.Helm], QRect(70, 10, 145, 60)),
+ ("Chest Armor", [ItemType.ChestArmor], QRect(70, 80, 145, 60)),
+ ("Gloves", [ItemType.Gloves], QRect(70, 150, 145, 60)),
+ ("Pants", [ItemType.Legs], QRect(70, 220, 145, 60)),
+ ("Boots", [ItemType.Boots], QRect(70, 290, 145, 60)),
+ # Right Column (Jewelry)
+ ("Amulet", [ItemType.Amulet], QRect(525, 10, 145, 60)),
+ ("Rings", [ItemType.Ring], QRect(525, 80, 145, 60)),
+]
+
+
+def get_weapon_slots(class_name: str | None = None) -> list[tuple[str, list[ItemType], QRect]]:
+ """Return weapon slot definitions based on character class."""
+ class_name = (class_name or "").lower()
+
+ # 1H Weapon types for dual wielding
+ one_hand_types = [ItemType.Axe, ItemType.Mace, ItemType.Sword, ItemType.Dagger, ItemType.Flail]
+
+ if "barbarian" in class_name:
+ return [
+ ("Bludgeoning", [ItemType.Mace2H], QRect(525, 150, 145, 60)),
+ ("Slashing", [ItemType.Axe2H, ItemType.Sword2H, ItemType.Polearm], QRect(525, 220, 145, 60)),
+ ("Dual Wields", one_hand_types, QRect(525, 290, 145, 60)),
+ ]
+
+ if "rogue" in class_name or "rog" in class_name:
+ return [
+ ("Dual Wields", [ItemType.Dagger, ItemType.Sword], QRect(525, 290, 145, 60)),
+ ("Ranged Weapon", [ItemType.Bow, ItemType.Crossbow2H], QRect(525, 220, 145, 60)),
+ ]
+
+ if "necromancer" in class_name or "necro" in class_name:
+ return [
+ (
+ "Main Hand",
+ [
+ ItemType.Scythe,
+ ItemType.Scythe2H,
+ ItemType.Sword,
+ ItemType.Sword2H,
+ ItemType.Dagger,
+ ItemType.Wand,
+ ItemType.Mace,
+ ],
+ QRect(525, 220, 145, 60),
+ ),
+ ("Off Hand", [ItemType.Shield, ItemType.Focus], QRect(525, 290, 145, 60)),
+ ]
+
+ if "druid" in class_name or "dru" in class_name:
+ return [
+ (
+ "Main Hand",
+ [ItemType.Mace, ItemType.Mace2H, ItemType.Axe, ItemType.Axe2H, ItemType.Staff, ItemType.Polearm],
+ QRect(525, 220, 145, 60),
+ ),
+ ("Off Hand", [ItemType.OffHandTotem], QRect(525, 290, 145, 60)),
+ ]
+
+ if any(c in class_name for c in ["sorcerer", "sorc", "warlock"]):
+ return [
+ ("Main Hand", [ItemType.Wand, ItemType.Dagger, ItemType.Staff], QRect(525, 220, 145, 60)),
+ ("Off Hand", [ItemType.Focus], QRect(525, 290, 145, 60)),
+ ]
+
+ if "spiritborn" in class_name or "spirit" in class_name:
+ return [
+ ("Main Hand", [ItemType.Glaive, ItemType.Quarterstaff, ItemType.Polearm], QRect(525, 220, 145, 60)),
+ # Spiritborn typically uses 2H or Dual 1H (handled in Main/Off logic if needed)
+ ("Off Hand", [], QRect(525, 290, 145, 60)),
+ ]
+
+ # Default Fallback
+ return [
+ (
+ "Main Hand",
+ [
+ ItemType.Axe,
+ ItemType.Axe2H,
+ ItemType.Bow,
+ ItemType.Crossbow2H,
+ ItemType.Dagger,
+ ItemType.Flail,
+ ItemType.Glaive,
+ ItemType.Mace,
+ ItemType.Mace2H,
+ ItemType.Polearm,
+ ItemType.Quarterstaff,
+ ItemType.Scythe,
+ ItemType.Scythe2H,
+ ItemType.Staff,
+ ItemType.Sword,
+ ItemType.Sword2H,
+ ItemType.Wand,
+ ],
+ QRect(525, 220, 145, 60),
+ ),
+ ("Off Hand", [ItemType.Shield, ItemType.Focus, ItemType.OffHandTotem, ItemType.Tome], QRect(525, 290, 145, 60)),
+ ]
+
+
+# Compatibility export for logic that expects a single list
+EQUIPMENT_SLOTS = BASE_GEAR_SLOTS + get_weapon_slots()
+
+SPECIAL_TABS = ["Aspect Upgrades", "Sigils", "Tributes", "Global Rules"]
+
+
+class EquipmentSlotButton(QFrame):
+ """A clickable equipment slot button for the paper doll."""
+
+ clicked = pyqtSignal()
+
+ def __init__(self, slot_name: str, item_types: list[ItemType], rect: QRect, parent: QWidget | None = None):
+ super().__init__(parent)
+ self.slot_name = slot_name
+ self.item_types = item_types
+ self._slot_rect = rect # Use _slot_rect to avoid shadowing QWidget.rect property
+ self._has_config = False
+ self._is_active = False
+ self._icon = SLOT_ICONS.get(slot_name, "")
+
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.setStyleSheet(
+ "EquipmentSlotButton {"
+ " border: 2px solid #4a5568;"
+ " border-radius: 8px;"
+ " background-color: #1e293b;"
+ " color: #94a3b8;"
+ " font-size: 11px;"
+ " font-weight: bold;"
+ " padding: 4px;"
+ " text-align: center;"
+ "}"
+ "EquipmentSlotButton:hover {"
+ " border-color: #3b82f6;"
+ " background-color: #2d3748;"
+ "}"
+ "EquipmentSlotButton.active {"
+ " border: 2px solid #3b82f6;"
+ " background-color: #1e3a5f;"
+ " color: #e2e8f0;"
+ "}"
+ )
+
+ def set_active(self, active: bool) -> None:
+ self._is_active = active
+ self.update()
+
+ def has_config(self, has: bool) -> None:
+ self._has_config = has
+ self.update()
+
+ @override
+ def paintEvent(self, event): # type: ignore[override]
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+
+ # Use local rect (0,0,W,H) instead of geometry (X,Y,W,H) relative to parent
+ r = self.rect()
+
+ # Background
+ bg_color = QColor(30, 58, 95) if self._is_active else QColor(30, 41, 59)
+
+ painter.fillRect(r, bg_color)
+
+ # Border
+ pen_width = max(1, int(2 * (self.width() / 85.0))) if self.width() < 120 else 2
+ pen = QPen(QColor(74, 85, 104) if not self._is_active else QColor(59, 130, 246), pen_width)
+ painter.setPen(pen)
+ painter.drawRoundedRect(r.adjusted(1, 1, -1, -1), 6, 6)
+
+ h = self.height()
+ w = self.width()
+ # Relative scaling for fonts (reference width is 145px)
+ s = w / 145.0
+ base_icon, base_text = 18, 10
+
+ # Slot Icon
+ painter.setPen(QColor(148, 163, 184) if not self._is_active else QColor(226, 232, 240))
+ icon_font = QFont("Segoe UI Emoji", max(6, int(base_icon * s)))
+ painter.setFont(icon_font)
+ painter.drawText(r.adjusted(0, int(h * 0.05), 0, -int(h * 0.50)), Qt.AlignmentFlag.AlignCenter, self._icon)
+
+ # Slot name
+ painter.setPen(QColor(148, 163, 184) if not self._is_active else QColor(226, 232, 240))
+ font = QFont("Segoe UI", max(6, int(base_text * s)), QFont.Weight.Medium)
+ painter.setFont(font)
+ painter.drawText(
+ r.adjusted(2, int(h * 0.50), -2, -int(h * 0.05)),
+ Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap,
+ self.slot_name,
+ )
+
+ # Config indicator (dot in corner if has config)
+ if self._has_config:
+ painter.setPen(QColor(34, 197, 94))
+ painter.setBrush(QColor(34, 197, 94))
+ painter.drawEllipse(self.width() - 12, 4, 8, 8)
+
+ painter.end()
+
+ @override
+ def mousePressEvent(self, event): # type: ignore[override]
+ if event.button() == Qt.MouseButton.LeftButton:
+ self.clicked.emit()
+ super().mousePressEvent(event)
+
+ @override
+ def sizeHint(self) -> QSize:
+ return QSize(self._slot_rect.width(), self._slot_rect.height())
+
+
+class CharacterCanvas(QFrame):
+ """Canvas for drawing the character silhouette and slot buttons."""
+
+ resized = pyqtSignal()
+ REF_WIDTH = 740
+ REF_HEIGHT = 510
+
+ def __init__(self, parent: QWidget | None = None):
+ super().__init__(parent)
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+ self.setStyleSheet("QFrame { background-color: #0f172a; border: none;}")
+
+ @override
+ def sizeHint(self) -> QSize:
+ return QSize(self.REF_WIDTH, self.REF_HEIGHT)
+
+ @override
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ self.resized.emit()
+
+ @override
+ def paintEvent(self, event): # type: ignore[override]
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+
+ # Draw a simple character silhouette outline
+ center_x = self.width() / 2
+ sx = self.width() / self.REF_WIDTH
+ sy = self.height() / self.REF_HEIGHT
+
+ # Use a slightly lighter, more muted color for the silhouette
+ # and thicker lines for better visibility without being too stark.
+ pen = QPen(QColor(100, 116, 139), max(1, int(2 * min(sx, sy))))
+ painter.setPen(pen)
+
+ # Head circle
+ painter.drawEllipse(int(center_x - 25 * sx), int(10 * sy), int(50 * sx), int(50 * sy))
+
+ # Torso (more of a rectangle now)
+ painter.drawRect(int(center_x - 20 * sx), int(60 * sy), int(40 * sx), int(100 * sy))
+
+ # Pelvis/Hips (a wider, shorter rectangle)
+ painter.drawRect(int(center_x - 30 * sx), int(160 * sy), int(60 * sx), int(20 * sy))
+
+ # Arms
+ painter.drawLine(int(center_x - 20 * sx), int(80 * sy), int(center_x - 80 * sx), int(150 * sy))
+ painter.drawLine(int(center_x + 20 * sx), int(80 * sy), int(center_x + 80 * sx), int(150 * sy))
+
+ # Legs
+ painter.drawLine(int(center_x - 20 * sx), int(180 * sy), int(center_x - 40 * sx), int(370 * sy))
+ painter.drawLine(int(center_x + 20 * sx), int(180 * sy), int(center_x + 40 * sx), int(370 * sy))
+
+ painter.end()
+
+
+class PaperDollWidget(QWidget):
+ """A paper doll character layout with clickable equipment slots."""
+
+ slot_clicked = pyqtSignal(str) # Emits slot_name when clicked
+
+ def __init__(self, parent: QWidget | None = None):
+ super().__init__(parent)
+ self._active_slot: str | None = None
+ self._slot_buttons: dict[str, EquipmentSlotButton] = {}
+ self._has_config_map: dict[str, bool] = {}
+
+ self.setup_ui()
+
+ def setup_ui(self):
+ main_layout = QHBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.setSpacing(0)
+ main_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
+
+ # Character silhouette panel (left side) - fill remaining space
+ self.character_panel = QFrame()
+ self.character_panel.setStyleSheet("QFrame { background-color: #0f172a; border-right: 1px solid #1e293b;}")
+ self.character_panel.setMaximumWidth(800)
+ char_layout = QVBoxLayout(self.character_panel)
+ char_layout.setContentsMargins(20, 10, 20, 20)
+ char_layout.setSpacing(5)
+ char_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
+
+ # Special Navigation Tabs at the Top
+ self.special_nav_layout = QHBoxLayout()
+ self.special_nav_layout.setSpacing(15)
+ self.special_nav_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ for name in SPECIAL_TABS:
+ btn = EquipmentSlotButton(name, [], QRect(0, 0, 145, 60))
+ btn.clicked.connect(lambda n=name: self._on_slot_clicked(n))
+ btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
+ self._slot_buttons[name] = btn
+ self.special_nav_layout.addWidget(btn)
+ char_layout.addLayout(self.special_nav_layout)
+
+ title_label = QLabel("Equipment")
+ title_label.setProperty("titleLabel", True) # noqa: FBT003
+ title_label.setStyleSheet(
+ "QLabel { color: #e2e8f0; font-size: 18px; font-weight: bold; padding: 10px; border-top: 1px solid #334155; border-bottom: 1px solid #334155; border-left: none; border-right: none; }"
+ )
+ title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ char_layout.addWidget(title_label)
+
+ # Character silhouette canvas
+ self.character_canvas = CharacterCanvas()
+ self.character_canvas.resized.connect(self.position_slots)
+ char_layout.addWidget(self.character_canvas)
+
+ main_layout.addWidget(self.character_panel, stretch=0)
+
+ # Side panel (right side) - initially shows placeholder
+ self.side_panel = QFrame()
+ self.side_panel.setStyleSheet("QFrame { background-color: #1e293b; border-left: 1px solid #334155;}")
+ self.side_panel.setMinimumWidth(700)
+ side_layout = QVBoxLayout(self.side_panel)
+ side_layout.setContentsMargins(20, 10, 20, 20)
+
+ self.show_message("Select an equipment slot to configure")
+
+ main_layout.addWidget(self.side_panel, stretch=1)
+ self.side_panel.hide()
+
+ def show_message(self, text: str) -> None:
+ """Clear side panel and show a message label."""
+ self._clear_layout()
+
+ placeholder = QLabel(text)
+ placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ placeholder.setWordWrap(True)
+ placeholder.setStyleSheet("QLabel { color: #64748b; font-size: 14px; padding: 40px;}")
+ self.side_panel.layout().addWidget(placeholder)
+ self.side_panel.layout().addStretch()
+
+ def set_active_slot(self, slot_name: str | None) -> None:
+ """Set the currently active equipment slot."""
+ self._active_slot = slot_name
+ for name, button in self._slot_buttons.items():
+ button.set_active(name == slot_name)
+ if slot_name:
+ self.slot_clicked.emit(slot_name)
+
+ def update_config_status(self, slot_name: str, has_config: bool) -> None:
+ """Update the config indicator for a slot."""
+ self._has_config_map[slot_name] = has_config
+ if slot_name in self._slot_buttons:
+ self._slot_buttons[slot_name].has_config(has_config)
+
+ def add_slot(self, slot_name: str, item_types: list[ItemType], rect: QRect) -> None:
+ """Add an equipment slot button."""
+ button = EquipmentSlotButton(slot_name, item_types, rect, self.character_canvas)
+ button.clicked.connect(lambda: self._on_slot_clicked(slot_name)) # type: ignore[misc]
+ self._slot_buttons[slot_name] = button
+
+ def position_slots(self) -> None:
+ """Position all slot buttons on the character canvas based on their defined rects and current canvas size."""
+ if not self.character_canvas.width() or not self.character_canvas.height():
+ return
+
+ sx = self.character_canvas.width() / 740.0
+ sy = self.character_canvas.height() / 510.0
+
+ # Scale the layout spacing for the top navigation
+ self.special_nav_layout.setSpacing(int(15 * sx))
+
+ for name, button in self._slot_buttons.items():
+ if button.parent() == self.character_canvas:
+ # Use the rect stored internally during add_slot
+ rect = button._slot_rect
+ button.setGeometry(
+ int(rect.x() * sx), int(rect.y() * sy), int(rect.width() * sx), int(rect.height() * sy)
+ )
+ elif name in SPECIAL_TABS:
+ # Scale the special buttons to match the canvas items
+ button.setFixedSize(int(145 * sx), int(60 * sy))
+ button.show()
+
+ def _on_slot_clicked(self, slot_name: str):
+ if self._active_slot == slot_name:
+ self.set_active_slot(None)
+ self.slot_clicked.emit(None)
+ else:
+ self.set_active_slot(slot_name)
+ self.slot_clicked.emit(slot_name)
+
+ def _clear_layout(self) -> None:
+ """Remove items from the side panel layout without necessarily deleting widgets."""
+ while self.side_panel.layout().count() > 0:
+ item = self.side_panel.layout().takeAt(0)
+ if item and item.widget():
+ item.widget().hide()
+
+ def clear_side_panel(self) -> None:
+ """Reset side panel to default placeholder."""
+ self.side_panel.hide()
+
+ def restore_side_panel(self, widget: QWidget) -> None:
+ """Restore a previously hidden widget to the side panel."""
+ self._clear_layout()
+ self.side_panel.layout().addWidget(widget)
+ widget.show()
+ self.side_panel.show()
+
+
+# Re-export for convenience
+__all__ = ["EQUIPMENT_SLOTS", "CharacterCanvas", "EquipmentSlotButton", "PaperDollWidget"]
diff --git a/src/gui/profile_editor/profile_editor.py b/src/gui/profile_editor/profile_editor.py
index 67d51fd8..e94ca7d7 100644
--- a/src/gui/profile_editor/profile_editor.py
+++ b/src/gui/profile_editor/profile_editor.py
@@ -1,100 +1,287 @@
+"""Profile editor with paper doll layout."""
+
+import contextlib
import logging
-from PyQt6.QtCore import Qt, pyqtSignal
-from PyQt6.QtWidgets import QMessageBox, QTabWidget
+from PyQt6.QtCore import QTimer, pyqtSignal
+from PyQt6.QtWidgets import (
+ QFrame,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QMessageBox,
+ QPushButton,
+ QScrollArea,
+ QVBoxLayout,
+ QWidget,
+)
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.sigils_tab import SIGILS_TABNAME, SigilsTab
-from src.gui.profile_editor.tributes_tab import TRIBUTES_TABNAME, TributesTab
+from src.gui.profile_editor.affixes_tab import AffixesTab
+from src.gui.profile_editor.aspect_upgrades_tab import AspectUpgradesTab
+from src.gui.profile_editor.global_uniques_tab import UniquesTab
+from src.gui.profile_editor.paper_doll import BASE_GEAR_SLOTS, PaperDollWidget, get_weapon_slots
+from src.gui.profile_editor.sigils_tab import SigilsTab
+from src.gui.profile_editor.tributes_tab import TributesTab
LOGGER = logging.getLogger(__name__)
-class ProfileEditor(QTabWidget):
+class ProfileEditor(QWidget):
+ """Profile editor with paper doll layout and side panel for editing."""
+
# Signal emitted when profile is saved (passes profile name)
profile_saved = pyqtSignal(str)
- def __init__(self, profile_model: ProfileModel, parent=None):
+ def __init__(self, profile_model: ProfileModel, parent: QWidget | None = None):
super().__init__(parent)
-
self.profile_model = profile_model
- # Create main tabs
- self.affixes_tab = AffixesTab(self.profile_model.affixes)
- self.aspect_upgrades_tab = AspectUpgradesTab(self.profile_model.aspect_upgrades)
- 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)
-
- self.currentChanged.connect(self.tab_changed)
- # Add tabs with icons
- self.addTab(self.affixes_tab, AFFIXES_TABNAME)
- self.addTab(self.aspect_upgrades_tab, ASPECT_UPGRADES_TABNAME)
- self.addTab(self.sigils_tab, SIGILS_TABNAME)
- self.addTab(self.tributes_tab, TRIBUTES_TABNAME)
- self.addTab(self.uniques_tab, UNIQUES_TABNAME)
-
- # Configure tab widget properties
- self.setDocumentMode(True)
- self.setMovable(False)
- self.setTabPosition(QTabWidget.TabPosition.North)
- self.setElideMode(Qt.TextElideMode.ElideRight)
-
- def tab_changed(self, index):
- if self.tabText(index) == AFFIXES_TABNAME:
+
+ # Create all tab widgets upfront (lazy-loaded internally)
+ self.affixes_tab = AffixesTab(self.profile_model.affixes, self)
+ self.aspect_upgrades_tab = AspectUpgradesTab(self.profile_model.aspect_upgrades, self)
+ self.sigils_tab = SigilsTab(self.profile_model.sigils, self)
+ self.tributes_tab = TributesTab(self.profile_model.tributes, self)
+ self.uniques_tab = UniquesTab(self.profile_model.global_uniques, self)
+
+ # Side panel content widget (swaps based on slot selection)
+ self.side_content_widget: QWidget | None = None
+
+ self.current_class = self._detect_class()
+
+ # Build the UI
+ self.setup_ui()
+
+ # Reset window expansion state on load to prevent accumulation when switching profiles
+ QTimer.singleShot(50, self._safe_initial_resize)
+
+ def _safe_initial_resize(self):
+ """Perform initial window adjustment safely to avoid RuntimeError if widget is deleted."""
+ with contextlib.suppress(RuntimeError):
+ self._adjust_window_size(expanding=False)
+
+ def _detect_class(self) -> str:
+ """Return the character class defined in the profile model."""
+ return self.profile_model.class_name.lower()
+
+ def setup_ui(self):
+ # Set a base minimum height to prevent the window from being "rolled up"
+ # into an unusable state.
+ self.setMinimumHeight(750)
+
+ main_layout = QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.setSpacing(0)
+
+ # Paper doll widget (left side with clickable slots)
+ self.paper_doll = PaperDollWidget()
+
+ # Add base gear slots
+ for slot_name, item_types, rect in BASE_GEAR_SLOTS:
+ self.paper_doll.add_slot(slot_name, item_types, rect)
+
+ # Add dynamic weapon slots based on class
+ self.weapon_slots = get_weapon_slots(self.current_class)
+ for slot_name, item_types, rect in self.weapon_slots:
+ self.paper_doll.add_slot(slot_name, item_types, rect)
+
+ # Position all slot buttons on the canvas
+ self.paper_doll.position_slots()
+
+ # Add Bulk Actions at bottom of armory
+ actions_group = QGroupBox("Profile-Wide Actions")
+ actions_layout = QHBoxLayout(actions_group)
+ actions_layout.setContentsMargins(10, 15, 10, 10)
+
+ btn_min_ga = QPushButton("Set Min GAs")
+ btn_min_ga.setToolTip("Set the Minimum Greater Affix requirement for every legendary filter in this profile.")
+ btn_min_ga.clicked.connect(self.affixes_tab.set_all_min_greater_affix)
+
+ btn_min_power = QPushButton("Set minPower")
+ btn_min_power.setToolTip(
+ "Set the Minimum Power threshold (e.g. 900) for every legendary filter in this profile."
+ )
+ btn_min_power.clicked.connect(self.affixes_tab.set_all_min_power)
+
+ btn_to_percent = QPushButton("Convert to Min %")
+ btn_to_percent.setToolTip(
+ "Convert every legendary filter in this profile to use 'Min %' mode instead of fixed values."
+ )
+ btn_to_percent.clicked.connect(self.affixes_tab.convert_all_to_min_percent_of_affix)
+
+ for btn in [btn_min_ga, btn_min_power, btn_to_percent]:
+ btn.setFixedHeight(32)
+ actions_layout.addWidget(btn)
+
+ # Insert into the paper doll panel's vertical layout (after the canvas)
+ self.paper_doll.character_panel.layout().addWidget(actions_group)
+
+ # Pre-create integrated gear view components to avoid heavy construction on every click
+ self.gear_view_scroll = QScrollArea()
+ self.gear_view_scroll.setWidgetResizable(True)
+ self.gear_view_scroll.setFrameShape(QFrame.Shape.NoFrame)
+ self.gear_view_scroll.setStyleSheet("background: transparent; border: none;")
+
+ self.gear_view_container = QWidget()
+ self.gear_view_container.setStyleSheet("background: transparent;")
+ self.gear_view_layout = QVBoxLayout(self.gear_view_container)
+ self.gear_view_layout.setContentsMargins(0, 0, 0, 0)
+
+ self.gear_view_header = QLabel()
+ self.gear_view_header.setStyleSheet(
+ "font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 10px; background: transparent; border: none;"
+ )
+ self.gear_view_layout.addWidget(self.gear_view_header)
+
+ self.gear_view_subheader = QLabel()
+ self.gear_view_subheader.setStyleSheet(
+ "font-size: 13px; color: #94a3b8; font-style: italic; margin-bottom: 15px; background: transparent; border: none;"
+ )
+ self.gear_view_layout.addWidget(self.gear_view_subheader)
+ self.gear_view_subheader.hide()
+
+ self.gear_view_layout.addWidget(self.affixes_tab)
+ self.gear_view_layout.addWidget(self.uniques_tab)
+ self.gear_view_scroll.setWidget(self.gear_view_container)
+
+ # Connect slot click signal
+ self.paper_doll.slot_clicked.connect(self.on_slot_clicked)
+
+ main_layout.addWidget(self.paper_doll)
+
+ def _update_equilibrium_config_status(self):
+ """Update the config status indicators on equipment slots."""
+ # TODO: Implement logic to check which items in self.profile_model.affixes match slot types
+ has_affix_config = len(self.profile_model.affixes) > 0
+ self.paper_doll.update_config_status("Equipment", has_affix_config)
+
+ def on_slot_clicked(self, slot_name: str):
+ """Handle equipment slot click - show relevant tab in side panel."""
+ self.side_content_widget = None
+ item_types = None
+
+ if not slot_name:
+ # Hide all content widgets and show placeholder
+ for widget in [
+ self.affixes_tab,
+ self.aspect_upgrades_tab,
+ self.sigils_tab,
+ self.tributes_tab,
+ self.uniques_tab,
+ ]:
+ with contextlib.suppress(RuntimeError):
+ widget.hide()
+ self.paper_doll.clear_side_panel()
+ self.gear_view_subheader.hide()
+ self._adjust_window_size(expanding=False)
+ return
+
+ # Find item types for the clicked slot
+ all_equipment = BASE_GEAR_SLOTS + self.weapon_slots
+ slot_info = next((s for s in all_equipment if s[0] == slot_name), None)
+ item_types = slot_info[1] if slot_info else None
+
+ # Determine if it's a gear/weapon slot (Affixes + Unique Rules)
+ is_gear_slot = any(s[0] == slot_name for s in all_equipment)
+
+ if is_gear_slot:
+ # Ensure children are loaded before filtering
self.affixes_tab.load()
- elif self.tabText(index) == ASPECT_UPGRADES_TABNAME:
- self.aspect_upgrades_tab.load()
- elif self.tabText(index) == SIGILS_TABNAME:
- self.sigils_tab.load()
- elif self.tabText(index) == TRIBUTES_TABNAME:
- self.tributes_tab.load()
- elif self.tabText(index) == UNIQUES_TABNAME:
self.uniques_tab.load()
+ # Ensure gear view components are visible (they might have been hidden by Global Rules)
+ self.affixes_tab.show()
+ self.uniques_tab.hide()
+ self.gear_view_header.show()
- @staticmethod
- def show_warning():
- msg = QMessageBox()
- msg.setIcon(QMessageBox.Icon.Warning)
- msg.setWindowTitle("Warning")
+ # Filter both tabs for this specific slot
+ self.affixes_tab.filter_by_item_types(item_types, slot_name)
- # Newline in message text
- msg.setText("The profile model might not be valid. Do you still want to save your changes ?")
+ self.gear_view_header.setText(f"Slot: {slot_name}")
+ self.gear_view_subheader.hide()
+ self.side_content_widget = self.gear_view_scroll
+ elif slot_name == "Aspect Upgrades":
+ with contextlib.suppress(RuntimeError):
+ self.aspect_upgrades_tab.load()
+ self.aspect_upgrades_tab.show()
+ self.side_content_widget = self.aspect_upgrades_tab
+ elif slot_name == "Sigils":
+ with contextlib.suppress(RuntimeError):
+ self.sigils_tab.load()
+ self.sigils_tab.show()
+ self.side_content_widget = self.sigils_tab
+ elif slot_name == "Tributes":
+ with contextlib.suppress(RuntimeError):
+ self.tributes_tab.load()
+ self.tributes_tab.show()
+ self.side_content_widget = self.tributes_tab
+ elif slot_name == "Global Rules":
+ with contextlib.suppress(RuntimeError):
+ self.uniques_tab.load()
+ # When clicking global tab, show all rules via the integrated view
+ # to avoid widget reparenting issues that break the layout.
+ self.uniques_tab.filter_by_item_types(None)
+ self.uniques_tab.show()
+ # Show header for Global Rules and hide the affixes tab
+ self.gear_view_header.setText("Global Rules")
+ self.gear_view_subheader.setText("These are rules that cover more than one item type.")
+ self.gear_view_subheader.show()
+ self.gear_view_header.show()
+ self.affixes_tab.hide()
+ self.side_content_widget = self.gear_view_scroll
+ else:
+ self.side_content_widget = None
- msg.setStandardButtons(QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard)
+ if self.side_content_widget is not None:
+ self.paper_doll.restore_side_panel(self.side_content_widget)
+ self._adjust_window_size(expanding=True)
+ else:
+ self.paper_doll.show_message(f"Configuration for '{slot_name}' coming soon")
- response = msg.exec()
- return response == QMessageBox.StandardButton.Save
+ def _adjust_window_size(self, expanding: bool):
+ """Resize the top-level window to accommodate the side panel."""
+ with contextlib.suppress(RuntimeError):
+ win = self.window()
+ if not win or win.isMaximized():
+ return
+
+ # Use dynamic properties on the main window to track expansion state globally across instances.
+ # This prevents the window from getting wider and wider when switching profiles.
+ is_already_expanded = win.property("profile_editor_expanded") is True
+
+ if expanding and not is_already_expanded:
+ # Use the actual sidebar minimum width to determine expansion delta
+ sidebar_w = self.paper_doll.side_panel.minimumWidth()
+ # Store the current width before expanding so we can return to it exactly
+ win.setProperty("profile_editor_pre_expansion_width", win.width())
+ win.resize(win.width() + sidebar_w, win.height())
+ win.setProperty("profile_editor_expanded", True) # noqa: FBT003
+ elif not expanding:
+ # Snap back to the base width (800) whenever the sidebar is closed.
+ # This eliminates empty space on the right and ensures the paper doll
+ # always opens at its "perfect" resolution.
+ win.resize(800, win.height())
+ win.setProperty("profile_editor_expanded", False) # noqa: FBT003
+
+ self.updateGeometry()
def save_all(self):
"""Save all tabs' configurations."""
try:
- # Validate
- model = ProfileModel.model_validate(self.profile_model)
- if model != self.profile_model:
- if self.show_warning():
- save_as_profile(
- self.profile_model.name, self.profile_model, "custom", exclude={"name"}, backup_file=True
- )
- # Emit signal for hot reload
- self.profile_saved.emit(self.profile_model.name)
- QMessageBox.information(
- self, "Info", f"Profile saved successfully to {self.profile_model.name + '.yaml'}"
- )
- else:
- QMessageBox.information(self, "Info", "Profile not saved.")
- else:
- save_as_profile(
- self.profile_model.name, self.profile_model, "custom", exclude={"name"}, backup_file=True
- )
- # Emit signal for hot reload
- self.profile_saved.emit(self.profile_model.name)
- QMessageBox.information(
- self, "Info", f"Profile saved successfully to {self.profile_model.name + '.yaml'}"
- )
+ # Re-validate to catch schema issues
+ ProfileModel.model_validate(self.profile_model)
+
+ save_as_profile(
+ file_name=self.profile_model.name,
+ profile=self.profile_model,
+ url="custom",
+ exclude={"name"},
+ backup_file=True,
+ )
+
+ # Emit signal for hot reload
+ self.profile_saved.emit(self.profile_model.name)
+ QMessageBox.information(self, "Info", f"Profile saved successfully to {self.profile_model.name + '.yaml'}")
except Exception as e:
LOGGER.exception("Failed to save profile")
QMessageBox.critical(self, "Error", f"Failed to save profile: {e}")
diff --git a/src/gui/profile_editor/sigils_tab.py b/src/gui/profile_editor/sigils_tab.py
index 7c09a456..2dacd673 100644
--- a/src/gui/profile_editor/sigils_tab.py
+++ b/src/gui/profile_editor/sigils_tab.py
@@ -1,151 +1,225 @@
+import contextlib
+from typing import override
+
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtWidgets import (
QComboBox,
QCompleter,
QDialog,
+ QDialogButtonBox,
QFormLayout,
+ QFrame,
+ QGroupBox,
QHBoxLayout,
QLabel,
QListWidget,
- QListWidgetItem,
QMessageBox,
QPushButton,
+ QScrollArea,
+ QSizePolicy,
+ QStyle,
+ QStyleOption,
QVBoxLayout,
QWidget,
)
from src.config.profile_models import SigilConditionModel, SigilFilterModel, SigilPriority
from src.dataloader import Dataloader
-from src.gui.models.collapsible_widget import Container
-from src.gui.models.dialog import CreateSigil, IgnoreScrollWheelComboBox, RemoveSigil
+from src.gui.models.dialog import IgnoreScrollWheelComboBox
+from src.gui.profile_editor.affixes_tab import (
+ QPainter,
+ SelectionDialog,
+ TruncatingComboBox,
+ _create_column_header,
+ _create_delete_btn,
+ _create_summary_card_style,
+)
SIGILS_TABNAME = "Sigils"
-class ConditionWidget(QWidget):
- condition_changed = pyqtSignal(str, str)
+class SigilSummaryWidget(QWidget):
+ delete_requested = pyqtSignal()
+ config_changed = pyqtSignal()
- def __init__(self, condition: str, parent=None):
+ def __init__(self, model: SigilConditionModel, whitelist: bool, parent=None):
+ super().__init__(parent)
+ self.model = model
+ self.whitelist = whitelist
+ self.setObjectName("SummaryCard")
+ self.setStyleSheet(_create_summary_card_style())
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.setup_ui()
+
+ def setup_ui(self):
+ self.main_layout = QHBoxLayout(self)
+ self.main_layout.setContentsMargins(10, 8, 10, 8)
+
+ text_layout = QVBoxLayout()
+ text_layout.setSpacing(2)
+
+ name = Dataloader().affix_sigil_dict_all["dungeons"].get(self.model.name, self.model.name)
+ self.name_label = QLabel(name)
+ self.name_label.setStyleSheet("font-weight: bold; color: #e2e8f0;")
+ text_layout.addWidget(self.name_label)
+
+ # Build condition summary
+ cond_text = "No conditions"
+ if self.model.condition:
+ names = [Dataloader().affix_sigil_dict.get(c, c) for c in self.model.condition if c]
+ cond_text = ", ".join(names)
+
+ self.cond_label = QLabel(cond_text)
+ self.cond_label.setStyleSheet("color: #94a3b8; font-size: 11px;")
+ self.cond_label.setWordWrap(True)
+ text_layout.addWidget(self.cond_label)
+
+ self.main_layout.addLayout(text_layout, 1)
+
+ self.delete_btn = _create_delete_btn()
+ self.delete_btn.clicked.connect(self.delete_requested.emit)
+ self.main_layout.addWidget(self.delete_btn)
+
+ @override
+ def paintEvent(self, event):
+ opt = QStyleOption()
+ opt.initFrom(self)
+ p = QPainter(self)
+ self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, p, self)
+ p.end()
+
+ @override
+ def mousePressEvent(self, event):
+ if event is None or event.button() == Qt.MouseButton.LeftButton:
+ self.open_config_dialog()
+
+ def open_config_dialog(self) -> QDialog.DialogCode:
+ name = Dataloader().affix_sigil_dict_all["dungeons"].get(self.model.name, self.model.name)
+ dialog = SigilEditDialog(self, self.model, name)
+ result = dialog.exec()
+ if result == QDialog.DialogCode.Accepted:
+ self.refresh_display()
+ self.config_changed.emit()
+ return result
+
+ def refresh_display(self):
+ name = Dataloader().affix_sigil_dict_all["dungeons"].get(self.model.name, self.model.name)
+ self.name_label.setText(name)
+
+ cond_text = "No conditions"
+ if self.model.condition:
+ names = [Dataloader().affix_sigil_dict.get(c, c) for c in self.model.condition if c]
+ cond_text = ", ".join(names)
+ self.cond_label.setText(cond_text)
+
+
+class SigilEditDialog(QDialog):
+ def __init__(self, parent: QWidget, model: SigilConditionModel, dungeon_name: str):
super().__init__(parent)
- self.condition = condition
- widget_layout = QHBoxLayout()
- widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
- self.name_combo = IgnoreScrollWheelComboBox()
+ self.setWindowTitle("Configure Sigil Rule")
+ self.setMinimumWidth(550)
+ self.model = model
+ self.setStyleSheet("""
+ QDialog { background-color: #1a1a1a; color: #e2e8f0; }
+ QLineEdit, QComboBox, QSpinBox, QListWidget {
+ background-color: #09090b;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ color: #e2e8f0;
+ padding: 4px;
+ }
+ QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; }
+ QListWidget::item:selected { background-color: #1e3a5f; color: #e2e8f0; }
+ QPushButton {
+ background-color: #262626;
+ border: 1px solid #3f3f46;
+ color: #e2e8f0;
+ padding: 6px 12px;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #323232; border-color: #52525b; }
+ """)
+
+ layout = QVBoxLayout(self)
+ header = QLabel("Sigil Rule Configuration")
+ header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;")
+ layout.addWidget(header)
+
+ desc = QLabel("Select a dungeon and define the specific affixes required for this rule.")
+ desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;")
+ desc.setWordWrap(True)
+ layout.addWidget(desc)
+
+ form = QFormLayout()
+
+ self.name_combo = TruncatingComboBox()
self.name_combo.setEditable(True)
self.name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
self.name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
+ self.name_combo.addItems(sorted(Dataloader().affix_sigil_dict_all["dungeons"].values()))
+ self.name_combo.setCurrentText(dungeon_name)
+ form.addRow("Dungeon:", self.name_combo)
+ layout.addLayout(form)
+
+ layout.addWidget(QLabel("Conditions (Must match ANY):"))
+ self.cond_list = QListWidget()
+ self.cond_list.setMinimumHeight(200)
+ for cond in self.model.condition:
+ if cond:
+ self.cond_list.addItem(Dataloader().affix_sigil_dict.get(cond, cond))
+ layout.addWidget(self.cond_list)
+
+ btn_layout = QHBoxLayout()
+ add_btn = QPushButton("+ Add Condition")
+ add_btn.clicked.connect(self._add_condition)
+ remove_btn = QPushButton("− Remove Selected")
+ remove_btn.clicked.connect(self._remove_condition)
+ btn_layout.addWidget(add_btn)
+ btn_layout.addWidget(remove_btn)
+ layout.addLayout(btn_layout)
+
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ buttons.accepted.connect(self.save_and_accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ def _add_condition(self):
affix_sigil_dict = {
**Dataloader().affix_sigil_dict_all["minor"],
**Dataloader().affix_sigil_dict_all["major"],
**Dataloader().affix_sigil_dict_all["positive"],
}
- self.name_combo.addItems(sorted(affix_sigil_dict.values()))
- self.name_combo.setMaximumWidth(600)
- self.name_combo.setCurrentText(condition)
- self.name_combo.currentIndexChanged.connect(self.update_condition)
- widget_layout.addWidget(self.name_combo)
- self.setLayout(widget_layout)
-
- def update_condition(self):
- old_condition = self.condition
- self.condition = self.name_combo.currentText()
- self.condition_changed.emit(old_condition, self.condition)
-
-
-class SigilWidget(Container):
- dungeon_changed = pyqtSignal()
-
- def __init__(self, sigil_name: str, sigil: SigilConditionModel, whitelist: bool):
- super().__init__(sigil_name, color_background=True)
- self.sigil = sigil
- self.sigil_name = sigil_name
- self.whitelist = whitelist
- self.setup_ui()
+ items = sorted(affix_sigil_dict.values())
+ dialog = SelectionDialog(self, "Select Condition", items)
+ if dialog.exec() == QDialog.DialogCode.Accepted:
+ val = dialog.get_value()
+ if val:
+ # Avoid adding same condition multiple times in UI
+ existing = [self.cond_list.item(i).text() for i in range(self.cond_list.count())]
+ if val not in existing:
+ self.cond_list.addItem(val)
+
+ def _remove_condition(self):
+ for item in self.cond_list.selectedItems():
+ self.cond_list.takeItem(self.cond_list.row(item))
+
+ def save_and_accept(self):
+ new_name = self.name_combo.currentText()
+ reverse_dungeon = {v: k for k, v in Dataloader().affix_sigil_dict_all["dungeons"].items()}
+ dungeon_id = reverse_dungeon.get(new_name)
+ if not dungeon_id:
+ QMessageBox.warning(self, "Warning", "Please select a valid dungeon from the list.")
+ return
- def setup_ui(self):
- container_layout = QVBoxLayout(self.content_widget)
- widget = QWidget()
- layout = QVBoxLayout()
- layout.setAlignment(Qt.AlignmentFlag.AlignTop)
- title_layout = QHBoxLayout()
- title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
-
- form_layout = QFormLayout()
- self.sigil_name_combo = IgnoreScrollWheelComboBox()
- self.sigil_name_combo.setEditable(True)
- self.sigil_name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
- self.sigil_name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
- self.sigil_name_combo.addItems(sorted(Dataloader().affix_sigil_dict_all["dungeons"].values()))
- self.sigil_name_combo.setCurrentText(self.sigil_name)
- self.sigil_name_combo.setMaximumWidth(150)
- self.sigil_name_combo.currentIndexChanged.connect(self.update_sigil_dungeon)
- form_layout.addRow("Dungeon:", self.sigil_name_combo)
-
- comparison_label = QLabel("Condition")
- title_layout.addSpacing(100)
- title_layout.addWidget(comparison_label)
- self.condition_list = QListWidget()
- self.condition_list.setMinimumHeight(50)
- self.condition_list.setAlternatingRowColors(True)
- for condition in self.sigil.condition:
- if not condition:
- continue
- self.add_condition_to_list(Dataloader().affix_sigil_dict[condition])
-
- condition_btn_layout = QHBoxLayout()
- add_condition_btn = QPushButton("Add Condition")
- add_condition_btn.clicked.connect(self.add_condition)
- condition_btn_layout.addWidget(add_condition_btn)
- remove_condition_btn = QPushButton("Remove Condition")
- remove_condition_btn.clicked.connect(self.remove_selected)
- condition_btn_layout.addWidget(remove_condition_btn)
- layout.addLayout(form_layout)
- layout.addLayout(condition_btn_layout)
- layout.addLayout(title_layout)
- layout.addWidget(self.condition_list)
- widget.setLayout(layout)
- container_layout.addWidget(widget)
-
- def add_condition_to_list(self, condition):
- widget_item = QListWidgetItem()
- widget = ConditionWidget(condition)
- widget.condition_changed.connect(self.on_condition_update)
- widget_item.setSizeHint(widget.sizeHint())
- self.condition_list.addItem(widget_item)
- self.condition_list.setItemWidget(widget_item, widget)
-
- def add_condition(self):
- self.add_condition_to_list(next(iter(Dataloader().affix_sigil_dict_all["minor"].values())))
- self.sigil.condition.append(next(iter(Dataloader().affix_sigil_dict_all["minor"].keys())))
-
- def remove_selected(self):
- for item in self.condition_list.selectedItems():
- row = self.condition_list.row(item)
- self.condition_list.takeItem(row)
- self.sigil.condition.pop(row)
-
- def revert_sigil_dungeon(self):
- self.sigil_name_combo.currentIndexChanged.disconnect()
- self.sigil_name_combo.currentTextChanged.connect(lambda: self.update_sigil_dungeon(classic=False))
- self.sigil_name_combo.setCurrentText(self.old_name)
- self.sigil_name_combo.currentTextChanged.disconnect()
- self.sigil_name_combo.currentIndexChanged.connect(self.update_sigil_dungeon)
-
- 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)
- 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:
- self.dungeon_changed.emit()
-
- def on_condition_update(self, old_condition, condition: str):
- reverse_dict = {v: k for k, v in Dataloader().affix_sigil_dict.items()}
- index = self.sigil.condition.index(reverse_dict.get(old_condition, ""))
- self.sigil.condition.pop(index)
- self.sigil.condition.insert(index, reverse_dict.get(condition))
+ self.model.name = dungeon_id
+
+ reverse_cond = {v: k for k, v in Dataloader().affix_sigil_dict.items()}
+ self.model.condition = []
+ for i in range(self.cond_list.count()):
+ text = self.cond_list.item(i).text()
+ if key := reverse_cond.get(text):
+ self.model.condition.append(key)
+ self.accept()
class SigilsTab(QWidget):
@@ -153,150 +227,129 @@ def __init__(self, sigil_model: SigilFilterModel, parent=None):
super().__init__(parent)
self.sigil_model = sigil_model
self.loaded = False
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def load(self):
- if not self.loaded:
- self.setup_ui()
- self.loaded = True
+ with contextlib.suppress(RuntimeError):
+ if not self.loaded:
+ self.setup_ui()
+ self.loaded = True
def setup_ui(self):
"""Populate the grid layout with existing groups."""
+ self.setStyleSheet("background: transparent; border: none;")
self.main_layout = QVBoxLayout(self)
- self.main_layout.setContentsMargins(0, 20, 0, 20)
+ self.main_layout.setContentsMargins(0, 5, 0, 5)
+ self.main_layout.setSpacing(0)
self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
- self.create_button_layout()
- self.create_form()
- self.create_containers()
-
- def create_button_layout(self):
- btn_layout = QHBoxLayout()
-
- add_sigil_btn = QPushButton("Add Sigil")
- add_sigil_btn.clicked.connect(self.create_sigil)
-
- remove_whitelist_sigil_btn = QPushButton("Remove Whitelist Sigil")
- remove_whitelist_sigil_btn.clicked.connect(lambda: self.remove_sigil())
-
- remove_blacklist_sigil_btn = QPushButton("Remove Blacklist Sigil")
- remove_blacklist_sigil_btn.clicked.connect(lambda: self.remove_sigil(blacklist=True))
-
- btn_layout.addWidget(add_sigil_btn)
- btn_layout.addWidget(remove_whitelist_sigil_btn)
- btn_layout.addWidget(remove_blacklist_sigil_btn)
- self.main_layout.addLayout(btn_layout)
- def create_form(self):
- self.general_form = QFormLayout()
+ self.header = QLabel("Sigil Filtering")
+ self.header.setStyleSheet(
+ "font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 10px; background: transparent; border: none;"
+ )
+ self.main_layout.addWidget(self.header)
+
+ self.desc = QLabel(
+ "Define which dungeons and affixes you want to whitelist or blacklist. Priority mode determines which list takes precedence."
+ )
+ self.desc.setWordWrap(True)
+ self.desc.setStyleSheet(
+ "font-size: 13px; color: #94a3b8; font-style: italic; margin-bottom: 15px; background: transparent; border: none;"
+ )
+ self.main_layout.addWidget(self.desc)
+
+ # 1. General Config
+ self.create_general_groupbox()
+
+ # 2. Columns Layout
+ columns_layout = QHBoxLayout()
+ columns_layout.setSpacing(15)
+
+ def create_col(title, add_cb):
+ col_widget = QWidget()
+ col_layout = QVBoxLayout(col_widget)
+ col_layout.setContentsMargins(0, 0, 0, 0)
+ col_layout.setSpacing(0)
+
+ header = _create_column_header(title, add_cb)
+ col_layout.addWidget(header)
+
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QFrame.Shape.Panel)
+ scroll.setStyleSheet(
+ "QScrollArea { border: 1px solid #2d2d2d; border-left: none; background-color: #121212; }"
+ )
+
+ inner = QWidget()
+ inner_layout = QVBoxLayout(inner)
+ inner_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ scroll.setWidget(inner)
+
+ col_layout.addWidget(scroll)
+ return col_widget, inner_layout
+
+ self.whitelist_col, self.whitelist_layout = create_col("Whitelist", self.add_whitelist_sigil)
+ self.blacklist_col, self.blacklist_layout = create_col("Blacklist", self.add_blacklist_sigil)
+
+ columns_layout.addWidget(self.whitelist_col)
+ columns_layout.addWidget(self.blacklist_col)
+ self.main_layout.addLayout(columns_layout)
+
+ # 3. Init content
+ self.init_sigils()
+
+ def create_general_groupbox(self):
+ group = QGroupBox("Configuration")
+ group.setStyleSheet("QGroupBox { margin-top: 10px; }")
+ form = QFormLayout(group)
self.priority_combobox = IgnoreScrollWheelComboBox()
- self.priority_combobox.setEditable(True)
- self.priority_combobox.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
- self.priority_combobox.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
self.priority_combobox.addItems(SigilPriority._member_names_)
self.priority_combobox.setCurrentText(self.sigil_model.priority)
self.priority_combobox.setMaximumWidth(150)
self.priority_combobox.currentIndexChanged.connect(self.update_priority)
- self.general_form.addRow("Priority:", self.priority_combobox)
- self.main_layout.addLayout(self.general_form)
+ form.addRow("Priority Mode:", self.priority_combobox)
+ self.main_layout.addWidget(group)
- def create_containers(self):
- # Blacklist
- self.blacklist_container = Container("Blacklist")
- self.blacklist_layout = QVBoxLayout(self.blacklist_container.content_widget)
- self.blacklist_sigils = []
+ def init_sigils(self):
+ for sigil in self.sigil_model.whitelist:
+ self.add_sigil_widget(sigil, whitelist=True)
+ for sigil in self.sigil_model.blacklist:
+ self.add_sigil_widget(sigil, whitelist=False)
- for sigil_condition in self.sigil_model.blacklist:
- self.add_sigil(sigil_condition)
- self.blacklist_sigils.append(Dataloader().affix_sigil_dict[sigil_condition.name])
+ def add_sigil_widget(self, model: SigilConditionModel, whitelist: bool):
+ layout = self.whitelist_layout if whitelist else self.blacklist_layout
+ widget = SigilSummaryWidget(model, whitelist)
+ widget.delete_requested.connect(lambda: self.remove_sigil_item(widget, whitelist))
+ layout.addWidget(widget)
+ return widget
- # Whitelist
- self.whitelist_container = Container("Whitelist")
- self.whitelist_layout = QVBoxLayout(self.whitelist_container.content_widget)
- self.whitelist_sigils = []
+ def add_whitelist_sigil(self):
+ self._create_new_sigil(whitelist=True)
- for sigil_condition in self.sigil_model.whitelist:
- self.add_sigil(sigil_condition, whitelist=True)
- self.whitelist_sigils.append(Dataloader().affix_sigil_dict[sigil_condition.name])
+ def add_blacklist_sigil(self):
+ self._create_new_sigil(whitelist=False)
- self.main_layout.addWidget(self.whitelist_container)
- self.main_layout.addWidget(self.blacklist_container)
+ def _create_new_sigil(self, whitelist: bool):
+ # Default to first dungeon key available
+ dungeon_key = next(iter(Dataloader().affix_sigil_dict_all["dungeons"].keys()))
+ new_sigil = SigilConditionModel(name=dungeon_key, condition=[])
- def add_sigil(self, sigil_condition: SigilConditionModel, whitelist: bool = False):
- name = Dataloader().affix_sigil_dict_all["dungeons"][sigil_condition.name]
if whitelist:
- widget = SigilWidget(name, sigil_condition, whitelist=True)
- widget.dungeon_changed.connect(lambda: self.on_dungeon_changed(widget))
- self.whitelist_layout.addWidget(widget)
+ self.sigil_model.whitelist.append(new_sigil)
else:
- widget = SigilWidget(name, sigil_condition, whitelist=False)
- widget.dungeon_changed.connect(lambda: self.on_dungeon_changed(widget))
- self.blacklist_layout.addWidget(widget)
+ self.sigil_model.blacklist.append(new_sigil)
- def create_sigil(self):
- dialog = CreateSigil(self.whitelist_sigils, self.blacklist_sigils)
- if dialog.exec() == QDialog.DialogCode.Accepted:
- sigil_name, type_name = dialog.get_value()
- reverse_dict = {v: k for k, v in Dataloader().affix_sigil_dict_all["dungeons"].items()}
- sigil_condition = SigilConditionModel(name=reverse_dict.get(sigil_name), condition=[])
- if type_name == "whitelist":
- widget = SigilWidget(sigil_name, sigil_condition, whitelist=True)
- widget.dungeon_changed.connect(lambda: self.on_dungeon_changed(widget))
- self.whitelist_layout.addWidget(widget)
- self.whitelist_sigils.append(sigil_name)
- self.sigil_model.whitelist.append(sigil_condition)
- elif type_name == "blacklist":
- widget = SigilWidget(sigil_name, sigil_condition, whitelist=False)
- widget.dungeon_changed.connect(lambda: self.on_dungeon_changed(widget))
- self.blacklist_layout.addWidget(widget)
- self.blacklist_sigils.append(sigil_name)
- self.sigil_model.blacklist.append(sigil_condition)
-
- def remove_sigil(self, blacklist: bool = False):
- dialog = RemoveSigil(self.blacklist_sigils, blacklist=True) if blacklist else RemoveSigil(self.whitelist_sigils)
- if dialog.exec() == QDialog.DialogCode.Accepted:
- to_delete = dialog.get_value()
- if blacklist:
- for sigil in to_delete:
- self.blacklist_sigils.remove(sigil)
- to_delete_list = []
- for i in range(self.blacklist_layout.count()):
- sigil_widget: SigilWidget = self.blacklist_layout.itemAt(i).widget()
- if sigil_widget.sigil_name in to_delete:
- to_delete_list.append(sigil_widget)
- for sig_widget in to_delete_list:
- sig_widget.setParent(None)
- self.sigil_model.blacklist.remove(sig_widget.sigil)
- else:
- for sigil in to_delete:
- self.whitelist_sigils.remove(sigil)
- to_delete_list = []
- for i in range(self.whitelist_layout.count()):
- sigil_widget: SigilWidget = self.whitelist_layout.itemAt(i).widget()
- if sigil_widget.sigil_name in to_delete:
- to_delete_list.append(sigil_widget)
- for sig_widget in to_delete_list:
- sig_widget.setParent(None)
- self.sigil_model.whitelist.remove(sig_widget.sigil)
+ widget = self.add_sigil_widget(new_sigil, whitelist)
+ if widget.open_config_dialog() == QDialog.DialogCode.Rejected:
+ self.remove_sigil_item(widget, whitelist)
+
+ def remove_sigil_item(self, widget: SigilSummaryWidget, whitelist: bool):
+ model_list = self.sigil_model.whitelist if whitelist else self.sigil_model.blacklist
+ if widget.model in model_list:
+ model_list.remove(widget.model)
+ widget.setParent(None)
+ widget.deleteLater()
def update_priority(self):
self.sigil_model.priority = SigilPriority(self.priority_combobox.currentText())
-
- def on_dungeon_changed(self, sigil_widget: SigilWidget):
- whitelist = sigil_widget.whitelist
- new_name = sigil_widget.sigil_name
- old_name = sigil_widget.old_name
- if whitelist and new_name in self.whitelist_sigils:
- QMessageBox.warning(self, "Warning", "Sigil already exist in whitelist. You can modify the existing one.")
- sigil_widget.revert_sigil_dungeon()
- return
- if not whitelist and new_name in self.blacklist_sigils:
- QMessageBox.warning(self, "Warning", "Sigil already exist in blacklist. You can modify the existing one.")
- sigil_widget.revert_sigil_dungeon()
- return
- if whitelist and old_name in self.whitelist_sigils:
- index = self.whitelist_sigils.index(old_name)
- self.whitelist_sigils.pop(index)
- self.whitelist_sigils.insert(index, new_name)
- if not whitelist and old_name in self.blacklist_sigils:
- index = self.blacklist_sigils.index(old_name)
- self.blacklist_sigils.pop(index)
- self.blacklist_sigils.insert(index, new_name)
diff --git a/src/gui/profile_editor/tributes_tab.py b/src/gui/profile_editor/tributes_tab.py
index 592ec81e..0e45aa7c 100644
--- a/src/gui/profile_editor/tributes_tab.py
+++ b/src/gui/profile_editor/tributes_tab.py
@@ -1,122 +1,264 @@
-from PyQt6.QtCore import Qt
+import contextlib
+from typing import override
+
+from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtWidgets import (
- QAbstractItemView,
QDialog,
+ QDialogButtonBox,
+ QFormLayout,
+ QFrame,
+ QGroupBox,
QHBoxLayout,
QLabel,
- QListWidget,
- QMessageBox,
- QPushButton,
+ QScrollArea,
+ QSizePolicy,
+ QStyle,
+ QStyleOption,
QVBoxLayout,
QWidget,
)
from src.config.profile_models import ItemRarity, TributeFilterModel
from src.dataloader import Dataloader
-from src.gui.models.dialog import AddTributeRarity, CreateTribute
+from src.gui.models.checkmark_checkbox import CheckmarkCheckBox
+from src.gui.profile_editor.affixes_tab import (
+ QPainter,
+ TruncatingComboBox,
+ _create_column_header,
+ _create_delete_btn,
+ _create_summary_card_style,
+)
TRIBUTES_TABNAME = "Tributes"
+class TributeSummaryWidget(QWidget):
+ delete_requested = pyqtSignal()
+ config_changed = pyqtSignal()
+
+ def __init__(self, model: TributeFilterModel, parent=None):
+ super().__init__(parent)
+ self.model = model
+ self.setObjectName("SummaryCard")
+ self.setStyleSheet(_create_summary_card_style())
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.setup_ui()
+
+ def setup_ui(self):
+ self.main_layout = QHBoxLayout(self)
+ self.main_layout.setContentsMargins(10, 8, 10, 8)
+
+ text_layout = QVBoxLayout()
+ text_layout.setSpacing(2)
+
+ self.name_label = QLabel()
+ self.name_label.setStyleSheet("font-weight: bold; color: #e2e8f0;")
+ text_layout.addWidget(self.name_label)
+
+ self.details_label = QLabel()
+ self.details_label.setStyleSheet("color: #94a3b8; font-size: 11px;")
+ self.details_label.setWordWrap(True)
+ text_layout.addWidget(self.details_label)
+
+ self.main_layout.addLayout(text_layout, 1)
+
+ self.delete_btn = _create_delete_btn()
+ self.delete_btn.clicked.connect(self.delete_requested.emit)
+ self.main_layout.addWidget(self.delete_btn)
+
+ self.refresh_display()
+
+ @override
+ def paintEvent(self, event):
+ opt = QStyleOption()
+ opt.initFrom(self)
+ p = QPainter(self)
+ self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, p, self)
+ p.end()
+
+ @override
+ def mousePressEvent(self, event):
+ if event is None or event.button() == Qt.MouseButton.LeftButton:
+ self.open_config_dialog()
+
+ def open_config_dialog(self) -> QDialog.DialogCode:
+ dialog = TributeEditDialog(self, self.model)
+ result = dialog.exec()
+ if result == QDialog.DialogCode.Accepted:
+ self.refresh_display()
+ self.config_changed.emit()
+ return result
+
+ def refresh_display(self):
+ if self.model.name:
+ name = Dataloader().tribute_dict.get(self.model.name, self.model.name)
+ self.name_label.setText(name.replace("Tribute of ", ""))
+ else:
+ self.name_label.setText("Broad Rarity Filter")
+
+ rarity_text = "All Rarities"
+ if self.model.rarities:
+ rarity_text = ", ".join(r.name.title() for r in self.model.rarities)
+ self.details_label.setText(rarity_text)
+
+
+class TributeEditDialog(QDialog):
+ def __init__(self, parent: QWidget, model: TributeFilterModel):
+ super().__init__(parent)
+ self.setWindowTitle("Configure Tribute Rule")
+ self.setMinimumWidth(500)
+ self.model = model
+ self.rarity_checkboxes: dict[ItemRarity, CheckmarkCheckBox] = {}
+ self.setStyleSheet("""
+ QDialog { background-color: #1a1a1a; color: #e2e8f0; }
+ QLineEdit, QComboBox, QSpinBox {
+ background-color: #09090b;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ color: #e2e8f0;
+ padding: 4px;
+ }
+ QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; }
+ QGroupBox {
+ font-weight: bold;
+ color: #3b82f6;
+ border: 1px solid #334155;
+ margin-top: 1.1em;
+ padding-top: 10px;
+ }
+ QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 3px; }
+ QPushButton {
+ background-color: #262626;
+ border: 1px solid #3f3f46;
+ color: #e2e8f0;
+ padding: 6px 12px;
+ border-radius: 4px;
+ }
+ QPushButton:hover { background-color: #323232; border-color: #52525b; }
+ """)
+
+ layout = QVBoxLayout(self)
+ header = QLabel("Tribute Rule Configuration")
+ header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;")
+ layout.addWidget(header)
+
+ desc = QLabel("Set a specific tribute or configure rarity-based filtering.")
+ desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;")
+ desc.setWordWrap(True)
+ layout.addWidget(desc)
+
+ form = QFormLayout()
+
+ self.name_combo = TruncatingComboBox()
+ self.name_combo.addItems(["[None / Rarity Only]"] + sorted(Dataloader().tribute_dict.values()))
+ if self.model.name:
+ self.name_combo.setCurrentText(Dataloader().tribute_dict.get(self.model.name, self.model.name))
+ else:
+ self.name_combo.setCurrentIndex(0)
+ form.addRow("Tribute:", self.name_combo)
+ layout.addLayout(form)
+
+ rarity_group = QGroupBox("Target Rarities")
+ rarity_layout = QVBoxLayout(rarity_group)
+ for rarity in ItemRarity:
+ cb = CheckmarkCheckBox(rarity.name.title())
+ cb.setChecked(rarity in self.model.rarities)
+ self.rarity_checkboxes[rarity] = cb
+ rarity_layout.addWidget(cb)
+ layout.addWidget(rarity_group)
+
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ buttons.accepted.connect(self.save_and_accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ def save_and_accept(self):
+ val = self.name_combo.currentText()
+ if val == "[None / Rarity Only]":
+ self.model.name = None
+ else:
+ reverse_dict = {v: k for k, v in Dataloader().tribute_dict.items()}
+ self.model.name = reverse_dict.get(val)
+
+ self.model.rarities = [r for r, cb in self.rarity_checkboxes.items() if cb.isChecked()]
+ self.accept()
+
+
class TributesTab(QWidget):
def __init__(self, tributes: list[TributeFilterModel] | None, parent=None):
super().__init__(parent)
self.tributes = tributes if tributes is not None else []
- self.tribute_list_widget = QListWidget()
self.loaded = False
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def load(self):
- if not self.loaded:
- self.setup_ui()
- self.loaded = True
+ with contextlib.suppress(RuntimeError):
+ if not self.loaded:
+ self.setup_ui()
+ self.loaded = True
def setup_ui(self):
+ self.setStyleSheet("background: transparent; border: none;")
main_layout = QVBoxLayout(self)
- main_layout.setContentsMargins(0, 20, 0, 20)
+ main_layout.setContentsMargins(0, 5, 0, 5)
+ main_layout.setSpacing(0)
main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
- label = QLabel(
- "Add tribute names and tribute rarities you want to keep. These rules are evaluated independently."
- )
- label.setWordWrap(True)
- main_layout.addWidget(label)
- button_layout = self.create_button_layout()
- main_layout.addLayout(button_layout)
-
- self.tribute_list_widget.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
- self._reload_tribute_list_widget()
- main_layout.addWidget(self.tribute_list_widget)
- self.setLayout(main_layout)
-
- def create_button_layout(self) -> QHBoxLayout:
- btn_layout = QHBoxLayout()
- add_tribute_btn = QPushButton("Add Tribute")
- add_tribute_btn.clicked.connect(self.add_tribute)
+ self.header = QLabel("Tributes")
+ self.header.setStyleSheet(
+ "font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 10px; background: transparent; border: none;"
+ )
+ main_layout.addWidget(self.header)
- add_rarity_btn = QPushButton("Add Rarity")
- add_rarity_btn.clicked.connect(self.add_rarity)
+ self.desc = QLabel(
+ "Add tributes or rarity-based rules you want to keep. These rules are evaluated independently."
+ )
+ self.desc.setWordWrap(True)
+ self.desc.setStyleSheet(
+ "font-size: 13px; color: #94a3b8; font-style: italic; margin-bottom: 15px; background: transparent; border: none;"
+ )
+ main_layout.addWidget(self.desc)
- remove_rule_btn = QPushButton("Remove Selected")
- remove_rule_btn.clicked.connect(self.remove_selected)
+ header = _create_column_header("Tributes", self.add_tribute)
+ main_layout.addWidget(header)
- btn_layout.addWidget(add_tribute_btn)
- btn_layout.addWidget(add_rarity_btn)
- btn_layout.addWidget(remove_rule_btn)
- return btn_layout
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QFrame.Shape.Panel)
+ scroll.setStyleSheet("QScrollArea { border: 1px solid #2d2d2d; border-left: none; background-color: #121212; }")
- def _reload_tribute_list_widget(self):
- self.tribute_list_widget.clear()
- for tribute in self.tributes:
- self.tribute_list_widget.addItem(self._display_text(tribute))
+ self.scroll_widget = QWidget()
+ self.list_layout = QVBoxLayout(self.scroll_widget)
+ self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ self.list_layout.setContentsMargins(10, 10, 10, 10)
+ self.list_layout.setSpacing(4)
- @staticmethod
- def _display_text(tribute: TributeFilterModel) -> str:
- if not tribute.name and not tribute.rarities:
- return "Empty tribute rule"
+ scroll.setWidget(self.scroll_widget)
+ main_layout.addWidget(scroll)
- parts = []
- if tribute.name:
- tribute_name = Dataloader().tribute_dict.get(tribute.name, tribute.name)
- parts.append(f"Tribute: {tribute_name}")
+ self.init_tributes()
+ self.setLayout(main_layout)
- if tribute.rarities:
- rarity_names = ", ".join(ItemRarity(rarity).name for rarity in tribute.rarities)
- parts.append(f"Rarities: {rarity_names}")
+ def init_tributes(self):
+ for tribute in self.tributes:
+ self.add_tribute_widget(tribute)
- return " | ".join(parts)
+ def add_tribute_widget(self, model: TributeFilterModel):
+ widget = TributeSummaryWidget(model)
+ widget.delete_requested.connect(lambda: self.remove_tribute_item(widget))
+ self.list_layout.addWidget(widget)
+ return widget
def add_tribute(self):
- dialog = CreateTribute(self._existing_tribute_names())
- if dialog.exec() == QDialog.DialogCode.Accepted:
- tribute_filter = dialog.get_value()
- self.tributes.append(tribute_filter)
- self.tribute_list_widget.addItem(self._display_text(tribute_filter))
-
- def add_rarity(self):
- dialog = AddTributeRarity(self._existing_rarities())
- if dialog.exec() == QDialog.DialogCode.Accepted:
- tribute_filter = dialog.get_value()
- self.tributes.append(tribute_filter)
- self.tribute_list_widget.addItem(self._display_text(tribute_filter))
-
- def remove_selected(self):
- rows = sorted(
- {self.tribute_list_widget.row(item) for item in self.tribute_list_widget.selectedItems()}, reverse=True
- )
- if not rows:
- QMessageBox.warning(self, "Warning", "Select at least one tribute rule to remove.")
- return
-
- for row in rows:
- self.tribute_list_widget.takeItem(row)
- self.tributes.pop(row)
-
- def _existing_tribute_names(self) -> list[str]:
- return [tribute.name for tribute in self.tributes if tribute.name and not tribute.rarities]
-
- def _existing_rarities(self) -> list[ItemRarity]:
- return [
- ItemRarity(tribute.rarities[0])
- for tribute in self.tributes
- if tribute.rarities and not tribute.name and len(tribute.rarities) == 1
- ]
+ tribute_id = next(iter(Dataloader().tribute_dict.keys()))
+ new_rule = TributeFilterModel(name=tribute_id, rarities=[])
+ self.tributes.append(new_rule)
+ widget = self.add_tribute_widget(new_rule)
+ if widget.open_config_dialog() == QDialog.DialogCode.Rejected:
+ self.remove_tribute_item(widget)
+
+ def remove_tribute_item(self, widget: TributeSummaryWidget):
+ if widget.model in self.tributes:
+ self.tributes.remove(widget.model)
+ widget.setParent(None)
+ widget.deleteLater()
diff --git a/src/gui/profile_editor_window.py b/src/gui/profile_editor_window.py
index a66476ff..760c6054 100644
--- a/src/gui/profile_editor_window.py
+++ b/src/gui/profile_editor_window.py
@@ -29,7 +29,7 @@ def __init__(self, parent=None, profile_name: str | None = None):
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, on=True)
self.setWindowTitle("Profile Editor")
- self.resize(self.settings.value("size", QSize(650, 800)))
+ self.resize(self.settings.value("size", QSize(800, 800)))
self.move(self.settings.value("pos", QPoint(0, 0)))
if self.settings.value("maximized", "true") == "true":
@@ -48,11 +48,22 @@ def _finish_construction(self):
def closeEvent(self, event): # noqa: N802
"""Save window size/position and check if profile needs saving."""
if not self.isMaximized():
- self.settings.setValue("size", self.size())
+ save_size = self.size()
+ # If we are currently expanded, we want to save the width we had BEFORE expansion
+ # so that next time the editor opens, it opens to just the paper doll view.
+ if self.property("profile_editor_expanded") is True:
+ pre_width = self.property("profile_editor_pre_expansion_width")
+ if pre_width is not None:
+ save_size.setWidth(int(pre_width))
+
+ self.settings.setValue("size", save_size)
self.settings.setValue("pos", self.pos())
self.settings.setValue("maximized", self.isMaximized())
- if self.profile_tab.check_close_save():
- event.accept()
+ if hasattr(self, "profile_tab"):
+ if self.profile_tab.check_close_save():
+ event.accept()
+ else:
+ event.ignore()
else:
- event.ignore()
+ event.accept()
diff --git a/src/gui/profile_tab.py b/src/gui/profile_tab.py
index a2815e25..07cf72e9 100644
--- a/src/gui/profile_tab.py
+++ b/src/gui/profile_tab.py
@@ -5,22 +5,11 @@
import yaml
from pydantic import ValidationError
from PyQt6.QtCore import QSettings, QSignalBlocker, Qt
-from PyQt6.QtWidgets import (
- QComboBox,
- QGroupBox,
- QHBoxLayout,
- QLabel,
- QMessageBox,
- QPushButton,
- QScrollArea,
- QTextBrowser,
- QVBoxLayout,
- QWidget,
-)
+from PyQt6.QtWidgets import QComboBox, QGroupBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QVBoxLayout, QWidget
from src.config.loader import IniConfigLoader
+from src.config.profile_models import ProfileModel
from src.dataloader import Dataloader
-from src.gui.importer.gui_common import ProfileModel
from src.gui.profile_editor.profile_editor import ProfileEditor
from src.item.filter import _UniqueKeyLoader
@@ -43,50 +32,64 @@ def __init__(self):
self.model_editor = None
self.first_show = True
self.main_layout = QVBoxLayout(self)
-
- scroll_area = QScrollArea(self)
- scroll_widget = QWidget(scroll_area)
- self.scrollable_layout = QVBoxLayout(scroll_widget)
- scroll_area.setWidgetResizable(True)
+ self.main_layout.setContentsMargins(10, 0, 10, 0)
+ self.main_layout.setSpacing(0)
info_layout = QHBoxLayout()
- info_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ info_layout.setContentsMargins(0, 0, 0, 0)
tools_groupbox = QGroupBox("Profile")
- tools_groupbox_layout = QHBoxLayout()
+ tools_groupbox.setStyleSheet("QGroupBox { margin-top: 8px; padding-top: 12px; }")
+ tools_groupbox_layout = QVBoxLayout()
+ tools_groupbox_layout.setContentsMargins(10, 5, 10, 10)
+ button_layout = QHBoxLayout()
+
self.profile_combo = QComboBox()
- self.save_button = QPushButton("Save")
- self.refresh_button = QPushButton("Undo Changes")
+ self.profile_combo.setMinimumWidth(250)
+ self.save_button = QPushButton("Save Profile")
+ self.save_button.setFixedWidth(130)
+ self.refresh_button = QPushButton("Revert to Saved")
+ self.refresh_button.setFixedWidth(130)
self.profile_combo.currentIndexChanged.connect(self.profile_selection_changed)
self.save_button.clicked.connect(self.save_yaml)
self.refresh_button.clicked.connect(self.refresh)
- tools_groupbox_layout.addWidget(self.profile_combo)
- tools_groupbox_layout.addWidget(self.save_button)
- tools_groupbox_layout.addWidget(self.refresh_button)
+
+ button_layout.addWidget(self.profile_combo)
+ button_layout.addWidget(self.save_button)
+ button_layout.addWidget(self.refresh_button)
+ button_layout.addStretch()
+ tools_groupbox_layout.addLayout(button_layout)
+
+ instructions_text = QLabel(
+ "Select a profile from the dropdown. Click 'Save Profile' to persist your changes. "
+ "Click 'Revert to Saved' to discard unsaved edits."
+ )
+ instructions_text.setStyleSheet("color: #94a3b8; font-size: 11px; font-style: italic;")
+ instructions_text.setWordWrap(True)
+ tools_groupbox_layout.addWidget(instructions_text)
+
tools_groupbox.setLayout(tools_groupbox_layout)
info_layout.addWidget(tools_groupbox)
+ info_layout.addStretch()
self.main_layout.addLayout(info_layout)
self.itemTypes = Dataloader().item_types_dict
self.affixesNames = Dataloader().affix_dict
self.profile_editor_created = False
- scroll_widget.setLayout(self.scrollable_layout)
- scroll_area.setWidget(scroll_widget)
- self.main_layout.addWidget(scroll_area)
- instructions_label = QLabel("Instructions")
- self.main_layout.addWidget(instructions_label)
-
- instructions_text = QTextBrowser()
- instructions_text.append(
- "Select a profile from the dropdown. Click 'Save' to save your changes. Click 'Undo Changes' to revert your changes."
- )
-
- instructions_text.setFixedHeight(50)
- self.main_layout.addWidget(instructions_text)
+ self.editor_container = QWidget()
+ self.editor_layout = QVBoxLayout(self.editor_container)
+ self.editor_layout.setContentsMargins(0, 0, 0, 0)
+ self.main_layout.addWidget(self.editor_container, stretch=1)
self.setLayout(self.main_layout)
self.populate_profile_dropdown()
+ def has_unsaved_changes(self) -> bool:
+ """Return True if the current profile has unsaved changes."""
+ if not self.root or not self.original_root:
+ return False
+ return self.root != self.original_root
+
def confirm_discard_changes(self):
reply = QMessageBox.warning(
self,
@@ -97,7 +100,26 @@ def confirm_discard_changes(self):
if reply == QMessageBox.StandardButton.Yes:
self.save_yaml()
return True
- return reply == QMessageBox.StandardButton.No
+ if reply == QMessageBox.StandardButton.No:
+ self._has_unsaved_changes = False
+ return True
+ return False
+
+ def confirm_discard_profile_switch(self) -> bool:
+ """Prompt user to save changes before switching profiles. Returns True if safe to proceed."""
+ reply = QMessageBox.warning(
+ self,
+ "Unsaved Changes",
+ "You have unsaved changes. What would you like to do before switching profiles?",
+ QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel,
+ )
+ if reply == QMessageBox.StandardButton.Save:
+ self.save_yaml()
+ return not self.has_unsaved_changes() # True if save cleared dirty state
+ if reply == QMessageBox.StandardButton.Discard:
+ self._has_unsaved_changes = False
+ return True
+ return False # Cancel
def create_alert(self, msg: str):
reply = QMessageBox.warning(self, "Alert", msg, QMessageBox.StandardButton.Ok)
@@ -111,6 +133,9 @@ def show_tab(self):
def profile_selection_changed(self, index):
selected_profile = self.profile_combo.itemData(index, Qt.ItemDataRole.UserRole)
if selected_profile and selected_profile != self.current_profile_name:
+ # Check for unsaved changes before switching
+ if self.has_unsaved_changes() and not self.confirm_discard_profile_switch():
+ return # User cancelled
self.load_selected_profile(selected_profile)
def load_selected_profile(self, profile_name):
@@ -118,9 +143,10 @@ def load_selected_profile(self, profile_name):
self.file_path = self.profile_paths[profile_name]
if self.load_yaml():
if self.model_editor:
- self.scrollable_layout.removeWidget(self.model_editor)
+ self.editor_layout.removeWidget(self.model_editor)
+ self.model_editor.deleteLater()
self.model_editor = ProfileEditor(self.root)
- self.scrollable_layout.addWidget(self.model_editor)
+ self.editor_layout.addWidget(self.model_editor)
self.current_profile_name = profile_name
self.set_current_profile_combo(profile_name)
LOGGER.info(f"Profile {self.root.name} loaded into profile editor.")
@@ -204,7 +230,7 @@ def select_initial_profile(self):
def create_profile_editor(self):
if not self.profile_editor_created and self.root:
self.model_editor = ProfileEditor(self.root)
- self.scrollable_layout.addWidget(self.model_editor)
+ self.editor_layout.addWidget(self.model_editor)
self.profile_editor_created = True
LOGGER.info(f"Profile {self.root.name} loaded into profile editor.")
@@ -266,8 +292,11 @@ def load_yaml(self):
return True
def save_yaml(self):
- self.original_root = copy.deepcopy(self.root)
+ if not self.root or not self.model_editor:
+ return
self.model_editor.save_all()
+ self.original_root = copy.deepcopy(self.root)
+ # Mark as saved by comparing after save
def check_close_save(self):
if self.root and self.original_root != self.root:
@@ -277,7 +306,12 @@ def check_close_save(self):
def refresh(self):
if not self.load_yaml():
return
- self.scrollable_layout.removeWidget(self.model_editor)
+ self.editor_layout.removeWidget(self.model_editor)
+ self.model_editor.deleteLater()
self.model_editor = ProfileEditor(self.root)
- self.scrollable_layout.addWidget(self.model_editor)
+ self.editor_layout.addWidget(self.model_editor)
LOGGER.info(f"Profile {self.root.name} refreshed.")
+
+ def set_unsaved_changes(self, has_changes: bool):
+ """Called by ProfileEditor when edits are made."""
+ self._has_unsaved_changes = has_changes
diff --git a/src/gui/settings_tab.py b/src/gui/settings_tab.py
index 522e277b..6bb92870 100644
--- a/src/gui/settings_tab.py
+++ b/src/gui/settings_tab.py
@@ -613,8 +613,8 @@ def setEnabled(self, enabled): # noqa: N802
class IgnoreScrollWheelComboBox(QComboBox):
- def __init__(self):
- super().__init__()
+ def __init__(self, parent=None):
+ super().__init__(parent)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
def wheelEvent(self, event): # noqa: N802
diff --git a/src/gui/themes.py b/src/gui/themes.py
index 8687d23f..7d4bd247 100644
--- a/src/gui/themes.py
+++ b/src/gui/themes.py
@@ -130,6 +130,46 @@
background-color: #444;
}
+/* SpinBox Styling - Fixes hard to read arrows and click issues */
+QSpinBox, QDoubleSpinBox {
+ background-color: #1e1e1e;
+ color: #e0e0e0;
+ border: 1px solid #3c3c3c;
+ border-radius: 4px;
+ padding-right: 24px;
+ min-height: 26px;
+}
+QSpinBox::up-button, QDoubleSpinBox::up-button {
+ subcontrol-origin: border;
+ subcontrol-position: top right;
+ width: 24px;
+ background-color: #252525;
+ border-left: 1px solid #3c3c3c;
+ border-bottom: 1px solid #3c3c3c;
+ border-top-right-radius: 4px;
+}
+QSpinBox::down-button, QDoubleSpinBox::down-button {
+ subcontrol-origin: border;
+ subcontrol-position: bottom right;
+ width: 24px;
+ background-color: #252525;
+ border-left: 1px solid #3c3c3c;
+ border-bottom-right-radius: 4px;
+}
+QSpinBox::up-button:hover, QSpinBox::down-button:hover {
+ background-color: #3c3c3c;
+}
+QSpinBox::up-arrow, QDoubleSpinBox::up-arrow {
+ image: none;
+ width: 8px;
+ height: 8px;
+}
+QSpinBox::down-arrow, QDoubleSpinBox::down-arrow {
+ image: none;
+ width: 8px;
+ height: 8px;
+}
+
/* Disabled checkbox styling */
QCheckBox:disabled {
color: gray;
@@ -428,6 +468,46 @@
background-color: #d3d3d3;
}
+/* SpinBox Styling */
+QSpinBox, QDoubleSpinBox {
+ background-color: #ffffff;
+ color: #1f1f1f;
+ border: 1px solid #c3c3c3;
+ border-radius: 4px;
+ padding-right: 24px;
+ min-height: 26px;
+}
+QSpinBox::up-button, QDoubleSpinBox::up-button {
+ subcontrol-origin: border;
+ subcontrol-position: top right;
+ width: 24px;
+ background-color: #e0e0e0;
+ border-left: 1px solid #c3c3c3;
+ border-bottom: 1px solid #c3c3c3;
+ border-top-right-radius: 4px;
+}
+QSpinBox::down-button, QDoubleSpinBox::down-button {
+ subcontrol-origin: border;
+ subcontrol-position: bottom right;
+ width: 24px;
+ background-color: #e0e0e0;
+ border-left: 1px solid #c3c3c3;
+ border-bottom-right-radius: 4px;
+}
+QSpinBox::up-button:hover, QSpinBox::down-button:hover {
+ background-color: #d3d3d3;
+}
+QSpinBox::up-arrow, QDoubleSpinBox::up-arrow {
+ image: none;
+ width: 8px;
+ height: 8px;
+}
+QSpinBox::down-arrow, QDoubleSpinBox::down-arrow {
+ image: none;
+ width: 8px;
+ height: 8px;
+}
+
/* Disabled checkbox styling */
QCheckBox:disabled {
color: gray;
diff --git a/src/item/filter.py b/src/item/filter.py
index f48e7c3f..81d6a55a 100644
--- a/src/item/filter.py
+++ b/src/item/filter.py
@@ -122,22 +122,22 @@ def _check_affixes(self, item: Item) -> FilterResult:
# check affixes
matched_affixes = []
if filter_spec.affix_pool:
- matched_affixes = self._match_affixes_count(
+ success, 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:
+ if not success:
continue
# check inherent
matched_inherents = []
if filter_spec.inherent_pool:
- matched_inherents = self._match_affixes_count(
+ success, matched_inherents = self._match_affixes_count(
expected_affixes=filter_spec.inherent_pool,
item_affixes=item.inherent,
min_greater_affix_count=filter_spec.min_greater_affix_count,
)
- if not matched_inherents:
+ if not success:
continue
all_matches = matched_affixes + matched_inherents
# Build a detailed string showing which affixes are GAs
@@ -263,6 +263,7 @@ def _check_tribute(self, item: Item) -> FilterResult:
def _check_global_unique_filter(self, item: Item) -> FilterResult:
res = FilterResult(keep=False, matched=[])
+ non_tempered_affixes = [affix for affix in item.affixes if affix.type != AffixType.tempered]
if not self.global_unique_filters:
keep = IniConfigLoader().general.handle_uniques != UnfilteredUniquesType.junk
@@ -282,12 +283,38 @@ def _check_global_unique_filter(self, item: Item) -> FilterResult:
expected_percent=filter_item.min_percent_of_aspect, item_aspect_or_affix=item.aspect
):
continue
- LOGGER.info(f"{item.original_name} -- Matched {profile_name}.GlobalUniques: {item.aspect.name}")
+
+ # Check affixes
+ matched_affixes = []
+ if filter_item.affix_pool:
+ success, matched_affixes = self._match_affixes_count(
+ expected_affixes=filter_item.affix_pool,
+ item_affixes=non_tempered_affixes,
+ min_greater_affix_count=filter_item.min_greater_affix_count,
+ )
+ if not success:
+ continue
+
+ # Check inherent
+ matched_inherents = []
+ if filter_item.inherent_pool:
+ success, matched_inherents = self._match_affixes_count(
+ expected_affixes=filter_item.inherent_pool,
+ item_affixes=item.inherent,
+ min_greater_affix_count=filter_item.min_greater_affix_count,
+ )
+ if not success:
+ continue
+
+ all_matches = matched_affixes + matched_inherents
+ LOGGER.info(
+ f"{item.original_name} -- Matched {profile_name}.GlobalUniques: {item.aspect.name if item.aspect else 'Rule'}"
+ )
res.keep = True
- matched_full_name = f"{profile_name}.{item.aspect.name}"
+ matched_full_name = f"{profile_name}.{item.aspect.name if item.aspect else 'Unique'}"
if filter_item.profile_alias:
- matched_full_name = f"{filter_item.profile_alias}.{item.aspect.name}"
- res.matched.append(MatchedFilter(matched_full_name, aspect_match=True))
+ matched_full_name = f"{filter_item.profile_alias}.{item.aspect.name if item.aspect else 'Unique'}"
+ res.matched.append(MatchedFilter(matched_full_name, all_matches, aspect_match=True))
return res
@@ -309,20 +336,30 @@ def _did_files_change(self) -> bool:
def _match_affixes_count(
self, expected_affixes: list[AffixFilterCountModel], item_affixes: list[Affix], min_greater_affix_count: int = 0
- ) -> list[Affix]:
+ ) -> tuple[bool, list[Affix]]:
result = []
for count_group in expected_affixes:
group_res = []
+ # Track required matches
+ required_names = [a.name for a in count_group.count if getattr(a, "required", False)]
+ matched_required_names = set()
+
# Do the normal affix matching first
for affix in count_group.count:
matched_item_affix = next((a for a in item_affixes if a.name == affix.name), None)
if matched_item_affix is not None and self._match_item_aspect_or_affix(affix, matched_item_affix):
group_res.append(matched_item_affix)
+ if getattr(affix, "required", False):
+ matched_required_names.add(affix.name)
+
+ # Check if all required affixes matched
+ if len(matched_required_names) < len(required_names):
+ return False, []
# Check minCount and maxCount
if not (count_group.min_count <= len(group_res) <= count_group.max_count):
- return [] # if one group fails, everything fails
+ return False, [] # if one group fails, everything fails
# Check want_greater requirements (2-mode system)
want_greater_affixes = [a for a in count_group.count if getattr(a, "want_greater", False)]
@@ -334,7 +371,7 @@ def _match_affixes_count(
for affix in want_greater_affixes:
matched_item_affix = next((a for a in item_affixes if a.name == affix.name), None)
if matched_item_affix is None or matched_item_affix.type != AffixType.greater:
- return [] # Flagged affix is missing or not GA, fail
+ return False, [] # Flagged affix is missing or not GA, fail
else:
# Mode 2: At least min_greater_affix_count of the flagged affixes must be GA (flexible)
flagged_ga_count = sum(
@@ -344,10 +381,10 @@ def _match_affixes_count(
and matched.type == AffixType.greater
)
if flagged_ga_count < min_greater_affix_count:
- return [] # Not enough flagged affixes are GA
+ return False, [] # Not enough flagged affixes are GA
result.extend(group_res)
- return result
+ return True, result
@staticmethod
def _match_affixes_sigils(
diff --git a/src/tools/gen_data.py b/src/tools/gen_data.py
index 21a078d3..1ac6dcd8 100644
--- a/src/tools/gen_data.py
+++ b/src/tools/gen_data.py
@@ -32,6 +32,15 @@
D4LF_BASE_DIR = Path(__file__).parent.parent.parent
+SNO_CLASS_MAP = {
+ 410764: "barbarian",
+ 410765: "druid",
+ 521360: "necromancer",
+ 410766: "rogue",
+ 410767: "sorcerer",
+ 550604: "spiritborn",
+}
+
class AffixGenerationContext(TypedDict):
attribute_descriptions: dict[str, str]
@@ -45,6 +54,119 @@ class AffixGenerationContext(TypedDict):
weapon_types_by_sno: dict[int, str]
+def _get_slot_lookup() -> dict[str, str]:
+ lookup = {g.lower(): g for g in GEAR_TYPES}
+ lookup.update({
+ "chest": "ChestArmor",
+ "body": "ChestArmor",
+ "pants": "Legs",
+ "axe_2h": "Axe2H",
+ "mace_2h": "Mace2H",
+ "scythe_2h": "Scythe2H",
+ "sword_2h": "Sword2H",
+ "crossbow_2h": "Crossbow2H",
+ "focusbookoffhand": "Focus",
+ "offhandtotem": "OffHandTotem",
+ "offhandshield": "Shield",
+ "offhandtome": "Focus",
+ })
+ return lookup
+
+
+def build_affix_slot_map(d4data_dir: Path) -> dict[int, list[str]]:
+ lookup = _get_slot_lookup()
+ pool_to_slots = {}
+ ity_files = (
+ list(d4data_dir.glob("**/*.itt.json"))
+ or list(d4data_dir.glob("**/*.ity.json"))
+ or list(d4data_dir.glob("**/ItemType*/*.json"))
+ or list(d4data_dir.glob("**/ItemType/*.json"))
+ )
+ matched_slots = set()
+
+ def find_pools(d, potential_pools, depth=0):
+ if isinstance(d, dict):
+ for k, v in d.items():
+ if k.startswith(("sno", "unk_")) or "Pool" in k or "Group" in k or "Affix" in k or k == "__raw__":
+ sno = v.get("__raw__") if isinstance(v, dict) else v
+ if isinstance(sno, int) and sno != -1:
+ potential_pools.append(sno % (2**32))
+ elif isinstance(v, int) and v != -1:
+ v_norm = v % (2**32)
+ if v_norm > 10000:
+ potential_pools.append(v_norm)
+ find_pools(v, potential_pools, depth + 1)
+ elif isinstance(d, list):
+ for item in d:
+ find_pools(item, potential_pools, depth + 1)
+
+ def scan_for_affixes(obj, found_affix_snos, pool_sno):
+ if isinstance(obj, dict):
+ for k, v in obj.items():
+ if k.startswith(("sno", "unk_")) or "Affix" in k or k == "__raw__":
+ val = v.get("__raw__") if isinstance(v, dict) else v
+ if isinstance(val, int):
+ val_norm = val % (2**32)
+ if val_norm > 10000 and val_norm != pool_sno:
+ found_affix_snos.append(val_norm)
+ scan_for_affixes(v, found_affix_snos, pool_sno)
+ elif isinstance(obj, list):
+ for item in obj:
+ scan_for_affixes(item, found_affix_snos, pool_sno)
+
+ for ity_file in ity_files:
+ ity_data = load_json_file(ity_file)
+ ity_type = str(ity_data.get("__type__", "")).lower()
+ is_metadata = (
+ "itemtype" in ity_type or "definition" in ity_type or ".itt" in ity_file.name or ".ity" in ity_file.name
+ )
+ if not is_metadata and ity_type:
+ continue
+ raw_stem = ity_file.name.split(".")[0].lower().replace("_", "").replace("-", "")
+ slot_name = lookup.get(raw_stem)
+ if not slot_name:
+ for key, internal_name in lookup.items():
+ if key in raw_stem:
+ slot_name = internal_name
+ break
+ if not slot_name:
+ continue
+ matched_slots.add(slot_name)
+
+ potential_pools = []
+ find_pools(ity_data, potential_pools)
+ for pool_sno in potential_pools:
+ if pool_sno not in pool_to_slots:
+ pool_to_slots[pool_sno] = set()
+ pool_to_slots[pool_sno].add(slot_name)
+
+ apf_files = (
+ list(d4data_dir.glob("**/*.apf.json"))
+ or list(d4data_dir.glob("**/AffixPool*/**/*.json"))
+ or list(d4data_dir.glob("**/Affix*/**/*.json"))
+ or list(d4data_dir.glob("**/AffixPool/*.json"))
+ )
+ affix_to_slots = {}
+ for apf_file in apf_files:
+ apf_data = load_json_file(apf_file)
+ pool_sno_raw = (
+ apf_data.get("__snoID__") or apf_data.get("snoID") or apf_data.get("snoId") or apf_data.get("snoID_")
+ )
+ if pool_sno_raw is None:
+ continue
+ pool_sno = int(pool_sno_raw) % (2**32)
+ if pool_sno not in pool_to_slots:
+ continue
+ slots = pool_to_slots[pool_sno]
+ found_affix_snos = []
+ scan_for_affixes(apf_data, found_affix_snos, pool_sno)
+ for affix_sno in set(found_affix_snos):
+ if affix_sno not in affix_to_slots:
+ affix_to_slots[affix_sno] = set()
+ affix_to_slots[affix_sno].update(slots)
+ return {k: sorted(v) for k, v in affix_to_slots.items()}
+
+
def remove_content_in_braces(input_string) -> str:
pattern = r"\{.*?\}"
result = re.sub(pattern, "", input_string)
@@ -302,7 +424,21 @@ def companion_style_affix_description(
description = ""
for attribute in attributes:
- localisation = context["attribute_descriptions"].get(attribute["id"], "")
+ attr_id = attribute["id"]
+ if any(
+ attr_id.startswith(p)
+ for p in [
+ "Affix_Value_",
+ "Affix_Flat_Value_",
+ "Item_Granted_Skill_Tree_Reward",
+ "Multiplicative_Damage_Percent_Bonus_Per_Skill_Tag",
+ "DOT_DPS_Reduction_Percent_Per_Damage_Type",
+ ]
+ ):
+ localisation = "#"
+ else:
+ localisation = context["attribute_descriptions"].get(attribute["id"]) or ""
+
if not localisation:
if (affix_name, attribute["id"]) not in EXPECTED_MISSING_AFFIX_LOCALISATIONS:
print(f"WARNING: ({affix_name}) Localisation id {attribute['id']} not found.")
@@ -360,12 +496,26 @@ def generate_affixes(d4data_dir: Path, language: str, output_file: Path | None =
if not context["skill_tags_by_sno"]:
context["skill_tags_by_sno"] = {int(key) % (2**32): value for key, value in gbid.get("56", {}).items()}
+ lookup = _get_slot_lookup()
+ affix_slot_map = build_affix_slot_map(d4data_dir)
affix_dict = {}
+ affix_metadata = {}
+
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
+ sno_raw = (
+ affix_data.get("__snoID__")
+ or affix_data.get("snoID")
+ or affix_data.get("snoId")
+ or affix_data.get("snoID_")
+ )
+ if sno_raw is None:
+ continue
+ affix_sno = int(sno_raw) % (2**32)
+
if affix_data.get("eMagicType") != 0:
continue
if affix_name.startswith("zz"):
@@ -382,7 +532,46 @@ def generate_affixes(d4data_dir: Path, language: str, output_file: Path | None =
if normalised is None:
continue
key, value = normalised
- affix_dict[key] = value
+ affix_dict[key] = value # All affixes go here
+
+ slots = affix_slot_map.get(affix_sno, [])
+ if not slots:
+ fn_lower = affix_name.lower().replace("_", "").replace("-", "")
+ for raw_stem, internal_name in lookup.items():
+ if raw_stem in fn_lower:
+ slots.append(internal_name)
+ if not slots:
+ # Generic stats (Life, Attributes, Resistance, Core Power) should be available on all armor/jewelry/shields
+ if any(
+ x in fn_lower
+ for x in [
+ "armor",
+ "life",
+ "stat",
+ "resist",
+ "dex",
+ "str",
+ "int",
+ "will",
+ "energy",
+ "essence",
+ "resource",
+ "fury",
+ "spirit",
+ "mana",
+ ]
+ ):
+ slots = ["Amulet", "Boots", "ChestArmor", "Gloves", "Helm", "Legs", "Ring", "Shield"]
+ # Offense-related stats should be on all weapons/jewelry
+ elif any(w in fn_lower for w in ["weapon", "attack", "damage", "crit", "speed"]):
+ slots = [t for t in GEAR_TYPES if t not in ["Boots", "ChestArmor", "Gloves", "Helm", "Legs"]]
+
+ if slots:
+ if key not in affix_metadata:
+ affix_metadata[key] = {"slots": []}
+ combined_slots = set(affix_metadata[key]["slots"])
+ combined_slots.update(slots)
+ affix_metadata[key]["slots"] = sorted(combined_slots)
merge_custom_affixes(affix_dict, language)
output_path = output_file or D4LF_BASE_DIR / f"assets/lang/{language}/affixes.json"
@@ -390,6 +579,11 @@ def generate_affixes(d4data_dir: Path, language: str, output_file: Path | None =
json.dump(affix_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True)
json_file.write("\n")
+ metadata_path = D4LF_BASE_DIR / f"assets/lang/{language}/affix_metadata.json"
+ with metadata_path.open("w", encoding="utf-8") as json_file:
+ json.dump(affix_metadata, 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"
@@ -632,14 +826,52 @@ def generate_uniques(d4data_dir, language):
if core_unique_file.name.startswith("S10_"):
# Chaos uniques really throw off our inherent counts
continue
+
# Get inherent count and item type from this file. Beyond that, we need the file name to find the enUS strings file.
+ item_type = ""
+ character_class = "all"
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"]:
continue
item_type = unique_item_data["snoItemType"]["name"]
inherent_affixes = unique_item_data["arInherentAffixes"]
+ class_info = unique_item_data.get("snoCharacterClass")
+ if class_info:
+ raw_id = int(class_info.get("__raw__", -1)) % (2**32)
+ if class_info.get("name"):
+ name_lower = class_info["name"].lower()
+ if "warlock" in name_lower or "sorcerer" in name_lower:
+ character_class = "sorcerer"
+ elif "barbarian" in name_lower:
+ character_class = "barbarian"
+ elif "druid" in name_lower:
+ character_class = "druid"
+ elif "necromancer" in name_lower:
+ character_class = "necromancer"
+ elif "rogue" in name_lower:
+ character_class = "rogue"
+ elif "spiritborn" in name_lower:
+ character_class = "spiritborn"
+ if character_class == "all" and raw_id in SNO_CLASS_MAP:
+ character_class = SNO_CLASS_MAP[raw_id]
+
+ if character_class == "all":
+ fn_lower = core_unique_file.name.lower()
+ class_patterns = {
+ "_barb": "barbarian",
+ "_dru": "druid",
+ "_necro": "necromancer",
+ "_rog": "rogue",
+ "_sorc": "sorcerer",
+ "_spirit": "spiritborn",
+ }
+ for pattern, c_name in class_patterns.items():
+ if pattern in fn_lower:
+ character_class = c_name
+ break
if item_type not in GEAR_TYPES and item_type != "FocusBookOffHand":
continue
@@ -669,7 +901,7 @@ def generate_uniques(d4data_dir, language):
if name_clean is None or name_clean in items_to_ignore or is_placeholder_or_test_name(name_clean):
continue
- unique_dict[name_clean] = {"num_inherents": num_inherents}
+ unique_dict[name_clean] = {"num_inherents": num_inherents, "item_type": item_type, "class": character_class}
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)