Skip to content

Commit

Permalink
Add json report (#9)
Browse files Browse the repository at this point in the history
* Fix run-on markdown parse

* Add an "ignored" armor attribute

* Show two permutation counts if ignored items exist

* Handle ignored armor in generate_class_outfits

* Too slow, breaks later variable use

* Discard ignored armor from eclipsed check

* Winnow ignored items from reports if zero-UPO

* Add initial support for json output

* Only unique pinnacle outfits should make ignored items appear in reports

* placate linter

I don't think "not foo" is more readable than "foo == False"

* Fix tests, adding include_ignored_armor flag

* Convert stat groups to more granular format

* Write class-specific json report
  • Loading branch information
irons authored Aug 4, 2024
1 parent 292568a commit f9a91a1
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 75 deletions.
85 changes: 28 additions & 57 deletions d2armor.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,10 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"### Filter ignored armor\n",
"### Flag ignored armor for exclusion from reports\n",
"\n",
"Ignore any armor in `data/ignored-armor.json` - if you have armor tagged as junk/infuse in DIM, you can export that as a CSV then identify the values to ignore.\n",
"\n",
"Ignore any armor in `data/ignored-armor.json` - if you have armor tagged as junk/infuse in DIM, you can export that as a CSV then identify the values to ignore\n",
"Expected format is a list of values that have an `instance_id` property, ex: \n",
"\n",
"```\n",
Expand Down Expand Up @@ -96,22 +97,22 @@
" ignored_ids = [armor[\"instance_id\"] for armor in ignored_armor]\n",
"\n",
" armor_dict = {}\n",
" filtered_armor_dict = {}\n",
" filtered_armor_list = []\n",
"\n",
" for armor in unfiltered_armor_dict.values():\n",
" if armor.instance_id not in ignored_ids:\n",
" armor_dict[armor.instance_id] = armor\n",
" else:\n",
" filtered_armor_dict[armor.instance_id] = armor\n",
" armor.ignored = (armor.instance_id in ignored_ids) \n",
" armor_dict[armor.instance_id] = armor\n",
" if armor.ignored:\n",
" filtered_armor_list.append(armor)\n",
"\n",
" print(\n",
" f\"data/ignored-armor.json contains {len(ignored_armor)} instance_id values. Filtered out {len(unfiltered_armor_dict) - len(armor_dict)} armor pieces.\"\n",
" f\"data/ignored-armor.json contains {len(ignored_armor)} instance_id values. Found {len(filtered_armor_list)} current armor pieces to ignore.\"\n",
" )\n",
"else:\n",
" print(\"No ignored armor found\")\n",
" armor_dict = unfiltered_armor_dict\n",
"\n",
"filtered_armor_dict"
"filtered_armor_list"
]
},
{
Expand Down Expand Up @@ -238,16 +239,18 @@
"outputs": [],
"source": [
"# generate outfit permutations for each class\n",
"from src.armor import ProfileOutfits\n",
"\n",
"d2_class = \"Hunter\"\n",
"hunter_outfits = profile_outfits.generate_class_outfits(d2_class)\n",
"hunter_outfits = profile_outfits.generate_class_outfits(d2_class, True)\n",
"print(f\"Generated {len(hunter_outfits)} outfit permutations for {d2_class}\")\n",
"\n",
"d2_class = \"Titan\"\n",
"titan_outfits = profile_outfits.generate_class_outfits(d2_class)\n",
"titan_outfits = profile_outfits.generate_class_outfits(d2_class, True)\n",
"print(f\"Generated {len(titan_outfits)} outfit permutations for {d2_class}\")\n",
"\n",
"d2_class = \"Warlock\"\n",
"warlock_outfits = profile_outfits.generate_class_outfits(d2_class)\n",
"warlock_outfits = profile_outfits.generate_class_outfits(d2_class, True)\n",
"print(f\"Generated {len(warlock_outfits)} outfit permutations for {d2_class}\")"
]
},
Expand Down Expand Up @@ -454,6 +457,19 @@
")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Generate a machine-readable report \n",
"importlib.reload(report)\n",
"report.armor_to_pinnacle_outfits_json(\n",
" d2_class, armor_dict, pinnacle_outfits_df\n",
")"
]
},
{
"cell_type": "code",
"execution_count": null,
Expand Down Expand Up @@ -656,51 +672,6 @@
"# print_row_weighted_vs_max(row)\n",
"outfit"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from collections import defaultdict\n",
"\n",
"exotic_class_items = [\n",
" armor\n",
" for armor in armor_dict.values()\n",
" if armor.rarity == \"Exotic\" and armor.slot == \"Class Item\"\n",
"]\n",
"\n",
"exotic_class_item_perks = defaultdict(int)\n",
"individual_perk_counts = defaultdict(int)\n",
"\n",
"for armor in exotic_class_items:\n",
" # create a tuple of the armor name and the armor.random_exotic_perks\n",
" exotic_class_item_perks[(armor.item_name, armor.random_exotic_perks)] += 1\n",
" for perk in armor.random_exotic_perks:\n",
" individual_perk_counts[perk] += 1\n",
"\n",
"\n",
"# sort it on count then key and print it out\n",
"exotic_class_item_perks = dict(\n",
" sorted(exotic_class_item_perks.items(), key=lambda x: (-x[1], x[0]))\n",
")\n",
"print(f\"Exotic Class Items: {len(exotic_class_items)}\")\n",
"print(f\"Unique Perk Combo Count: {len(exotic_class_item_perks)}\")\n",
"\n",
"# chance of new perk combo is unique perk combo count / 64\n",
"print(f\"Chance of new perk combo: {1 - len(exotic_class_item_perks) / 64:.2%}\")\n",
"for perks, count in exotic_class_item_perks.items():\n",
" print(f\"{perks}: {count}\")\n",
"\n",
"# sort individual perks by count then key and print it out\n",
"individual_perk_counts = dict(\n",
" sorted(individual_perk_counts.items(), key=lambda x: (-x[1], x[0]))\n",
")\n",
"print(f\"Unique Perk Count: {len(individual_perk_counts)}\")\n",
"for perk, count in individual_perk_counts.items():\n",
" print(f\"{perk}: {count}\")"
]
}
],
"metadata": {
Expand Down
13 changes: 8 additions & 5 deletions src/armor.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,15 +328,18 @@ def filter_and_group_armor(
self,
d2_class,
slots=["Helmet", "Gauntlets", "Chest Armor", "Leg Armor", "Class Item"],
include_ignored_armor = True
):
exotic_armor = defaultdict(list)
non_exotic_armor = defaultdict(list)
for armor in self.armor_dict.values():
if armor.d2_class == d2_class and armor.slot in slots:
if armor.is_exotic:
exotic_armor[armor.slot].append(armor)
if include_ignored_armor or not armor.ignored:
exotic_armor[armor.slot].append(armor)
else:
non_exotic_armor[armor.slot].append(armor)
if include_ignored_armor or not armor.ignored:
non_exotic_armor[armor.slot].append(armor)

if "Class Item" in non_exotic_armor:
# class items all have the same stats, the only option is if one is artifice. Pick one and remove the rest
Expand All @@ -352,12 +355,12 @@ def filter_and_group_armor(

# 4. generate all possible outfits using non-exotic armor
# 5. add in all possible outfits using a single piece of exotic armor
def generate_class_outfits(self, d2_class):
def generate_class_outfits(self, d2_class, include_ignored_armor):
outfits = []

# filter armor to only include armor for the given class and slots
exotic_armor, non_exotic_armor = self.filter_and_group_armor(
d2_class, ["Helmet", "Gauntlets", "Chest Armor", "Leg Armor", "Class Item"]
d2_class, ["Helmet", "Gauntlets", "Chest Armor", "Leg Armor", "Class Item"], include_ignored_armor
)

# append all possible non-exotic armor combinations
Expand Down Expand Up @@ -597,7 +600,7 @@ def append_outfit_permutation(
# identify all non-class item armor that has the same or worse stats than another piece of armor of the same rarity and type
def find_eclipsed_armor(self):
eclipsed_armor = []
armor_list = list(self.armor_dict.values())
armor_list = [armor for armor in list(self.armor_dict.values()) if not armor.ignored]

# sort by power level, low to high
armor_list.sort(key=lambda x: x.power)
Expand Down
78 changes: 77 additions & 1 deletion src/report.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from collections import defaultdict
from dataclasses import dataclass, field
from src.armor import Armor, ProfileOutfits
Expand Down Expand Up @@ -34,7 +35,7 @@ class ArmorPinnacleStats:

@property
def exotic_name_max_length(self):
if self._exotic_name_max_length == None:
if self._exotic_name_max_length is None:
self._exotic_name_max_length = max(len(s) for s in self.exotic_to_pinnacle_stats.keys() )
return self._exotic_name_max_length

Expand Down Expand Up @@ -78,6 +79,10 @@ def unique_pinnacle_outfits(self):
def is_exotic(self):
return self.armor.is_exotic

@property
def is_ignored(self):
return self.armor.ignored

def __hash__(self):
return hash(self.armor.instance_id)

Expand Down Expand Up @@ -243,6 +248,8 @@ def legendary_armor_to_pinnacle_outfits_report(
):
if armor_pinnacle_stats.is_exotic:
continue
if armor_pinnacle_stats.is_ignored and armor_pinnacle_stats.unique_pinnacle_outfits == 0:
continue
num += 1
print(armor_pinnacle_stats)
print(f"Total pieces: {num}")
Expand All @@ -266,6 +273,75 @@ def exotic_armor_to_pinnacle_outfits_report(d2_class, armor_dict, pinnacle_outfi
):
if not armor_pinnacle_stats.is_exotic:
continue
if armor_pinnacle_stats.is_ignored and armor_pinnacle_stats.unique_pinnacle_outfits == 0:
continue
num += 1
print(armor_pinnacle_stats)
print(f"Total pieces: {num}")

def convert_stat_group(*, stat_group, is_unique):
# converts a list of stat groups like 'dis/int/str' into their json representation.
groups = []
for sg in stat_group:
groups.append({"stats": sg.split(sep="/"), "unique": is_unique })
return groups

def armor_to_pinnacle_outfits_json(d2_class, armor_dict, pinnacle_outfits_df):
armor_pinnacle_stats_list = create_armor_pinnacle_stats_list(
d2_class, armor_dict, pinnacle_outfits_df
)
report = []
for armor_pinnacle_stats in sorted(
armor_pinnacle_stats_list,
key=lambda x: (
x.item_name,
-x.unique_pinnacle_outfits,
-x.total_pinnacle_outfits,
),
):
armor = {}
armor['name'] = armor_pinnacle_stats.armor.item_name
armor['type'] = armor_pinnacle_stats.armor.slot
armor['id'] = armor_pinnacle_stats.armor.instance_id
armor['hash'] = armor_pinnacle_stats.armor.item_hash
armor['is_exotic'] = armor_pinnacle_stats.armor.rarity == "Exotic"
armor['is_artifice'] = armor_pinnacle_stats.armor.is_artifice
armor['total_pinnacle_outfit_count'] = armor_pinnacle_stats.total_pinnacle_outfits
armor['unique_pinnacle_outfit_count'] = armor_pinnacle_stats.unique_pinnacle_outfits
armor['mobility'] = armor_pinnacle_stats.armor.mobility
armor['resilience'] = armor_pinnacle_stats.armor.resilience
armor['recovery'] = armor_pinnacle_stats.armor.recovery
armor['discipline'] = armor_pinnacle_stats.armor.discipline
armor['intellect'] = armor_pinnacle_stats.armor.intellect
armor['strength'] = armor_pinnacle_stats.armor.strength
armor['stat_total'] = armor_pinnacle_stats.armor.mobility + \
armor_pinnacle_stats.armor.resilience + \
armor_pinnacle_stats.armor.recovery + \
armor_pinnacle_stats.armor.discipline + \
armor_pinnacle_stats.armor.intellect + \
armor_pinnacle_stats.armor.strength
armor['d2_class'] = armor_pinnacle_stats.armor.d2_class

pinnacle_outfits = {}
for exotic, stat_combinations in sorted(
armor_pinnacle_stats.exotic_to_pinnacle_stats.items(), key=lambda x: -len(x[1])
):
stat_combination_list = sorted (
[str(stat_combination).strip('~') for stat_combination in stat_combinations if not stat_combination.is_unique]
)
unique_stat_combination_list = sorted (
[str(stat_combination) for stat_combination in stat_combinations if stat_combination.is_unique]
)
nonunique_stats = convert_stat_group(stat_group=stat_combination_list, is_unique=False)
unique_stats = convert_stat_group(stat_group=unique_stat_combination_list, is_unique=True)

# merge and return the two lists
pinnacle_outfits[exotic] = unique_stats + nonunique_stats

armor['pinnacle_outfits'] = pinnacle_outfits
report.append(armor)

with open(f"./data/armor-report-{d2_class.lower()}.json", 'w', encoding="utf-8") as f:
json.dump(report, f, indent = 2)

print(f"Wrote JSON file with {len(report)} {d2_class} items.")
24 changes: 12 additions & 12 deletions tests/test_d2armor.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ def test_generate_class_outfits(self):
]
)
profile_outfits = ProfileOutfits(armor_dict)
outfits = profile_outfits.generate_class_outfits("Warlock")
outfits = profile_outfits.generate_class_outfits("Warlock", True)
self.assertEqual(len(outfits), 4)

# point totals would be 44 for each stat, but we round down to the nearest 10 for the tier
Expand Down Expand Up @@ -303,7 +303,7 @@ def test_generate_many_class_outfits(self):

armor_dict = self.armor_list_to_dict(armor_list)
profile_outfits = ProfileOutfits(armor_dict)
outfits = profile_outfits.generate_class_outfits("Warlock")
outfits = profile_outfits.generate_class_outfits("Warlock", True)
# 4 slots with 20 pieces per slot, 10^4 = 10,000
self.assertEqual(len(outfits), 10**4)

Expand All @@ -315,7 +315,7 @@ def test_generate_many_class_outfits(self):

armor_dict = self.armor_list_to_dict(armor_list)
profile_outfits = ProfileOutfits(armor_dict)
outfits = profile_outfits.generate_class_outfits("Warlock")
outfits = profile_outfits.generate_class_outfits("Warlock", True)
# same 4 slots with 10 legendary pieces per slot, but now 2 exotic pieces per slot that each need to be combined with 3 slots of 10 legendary pieces
self.assertEqual(len(outfits), 10**4 + 8 * 10**3)

Expand All @@ -331,7 +331,7 @@ def test_outfit_permutations_zero_artifice(self):
)

profile_outfits = ProfileOutfits(no_artifice_armor_dict)
outfits = profile_outfits.generate_class_outfits("Warlock")
outfits = profile_outfits.generate_class_outfits("Warlock", True)
self.assertEqual(len(outfits), 1)
# stats in outfit are 10 as we're assuming all armor pieces are masterworked, even though they have all stats at zero
self.assertEqual(
Expand Down Expand Up @@ -369,7 +369,7 @@ def test_outfit_permutations_one_artifice_stats_zero(self):
)

profile_outfits = ProfileOutfits(one_artifice_armor_dict)
outfits = profile_outfits.generate_class_outfits("Warlock")
outfits = profile_outfits.generate_class_outfits("Warlock", True)
self.assertEqual(len(outfits), 1)
self.assertEqual(
outfits[0],
Expand Down Expand Up @@ -413,7 +413,7 @@ def test_outfit_permutations_one_artifice_stats_seven(self):
)

profile_outfits = ProfileOutfits(one_artifice_armor_dict)
outfits = profile_outfits.generate_class_outfits("Warlock")
outfits = profile_outfits.generate_class_outfits("Warlock", True)
self.assertEqual(len(outfits), 6)
self.assertIn(
(
Expand Down Expand Up @@ -551,7 +551,7 @@ def test_outfit_permutations_two_artifice_stats_four(self):
)

profile_outfits = ProfileOutfits(two_artifice_armor_dict)
outfits = profile_outfits.generate_class_outfits("Warlock")
outfits = profile_outfits.generate_class_outfits("Warlock", True)
self.assertEqual(len(outfits), 7)
self.assertIn(
(
Expand Down Expand Up @@ -755,7 +755,7 @@ def test_outfit_permutations_five_artifice_stats(self):
]
)
)
outfits_0 = profile_outfits_0.generate_class_outfits("Warlock")
outfits_0 = profile_outfits_0.generate_class_outfits("Warlock", True)
self.assertEqual(len(outfits_0), 7)

profile_outfits_1 = ProfileOutfits(
Expand All @@ -769,7 +769,7 @@ def test_outfit_permutations_five_artifice_stats(self):
]
)
)
outfits_1 = profile_outfits_1.generate_class_outfits("Warlock")
outfits_1 = profile_outfits_1.generate_class_outfits("Warlock", True)
self.assertEqual(len(outfits_1), 7)

profile_outfits_2 = ProfileOutfits(
Expand All @@ -783,7 +783,7 @@ def test_outfit_permutations_five_artifice_stats(self):
]
)
)
outfits_2 = profile_outfits_2.generate_class_outfits("Warlock")
outfits_2 = profile_outfits_2.generate_class_outfits("Warlock", True)
self.assertEqual(len(outfits_2), 7)

profile_outfits_3 = ProfileOutfits(
Expand All @@ -797,7 +797,7 @@ def test_outfit_permutations_five_artifice_stats(self):
]
)
)
outfits_3 = profile_outfits_3.generate_class_outfits("Warlock")
outfits_3 = profile_outfits_3.generate_class_outfits("Warlock", True)
self.assertEqual(len(outfits_3), 7)

profile_outfits_4 = ProfileOutfits(
Expand All @@ -811,7 +811,7 @@ def test_outfit_permutations_five_artifice_stats(self):
]
)
)
outfits_4 = profile_outfits_4.generate_class_outfits("Warlock")
outfits_4 = profile_outfits_4.generate_class_outfits("Warlock", True)
self.assertEqual(len(outfits_4), 22)

# print outfits_4 to a file in data as json
Expand Down

0 comments on commit f9a91a1

Please sign in to comment.