Skip to content

Instantly share code, notes, and snippets.

@Mijago
Last active July 9, 2024 15:34
Show Gist options
  • Save Mijago/7309b1e97ffc2f0f311c22df5d47428b to your computer and use it in GitHub Desktop.
Save Mijago/7309b1e97ffc2f0f311c22df5d47428b to your computer and use it in GitHub Desktop.
Theoretical Destiny 2 armor stat calculator
import configparser
import json
import os.path
import numpy as np
import pulp as pl
####################################################
# THEORETICAL Stat Optimizer by Mijago
# - You tell the script which stats you want (DESIRED_STATS)
# - Further fine-tune the configuration
# - Add d2ap_armor.json (from D2ArmorPicker -> Account)
# This script is able to tell you if you are able to reach a desired stat distribution, and which pieces you need.
# If you are not able to reach the distribution you want, it can tell you which armor pieces you can use
# and also the armor pieces you theoretically have to farm.
# Note that this script only returns one of potentially many possible results.
# Random thoughts and bits of information that I do not want to forget.
# - Feel free to play around with the grading weights. It impacts the results drastically and is unique to every problem.
# - The tool can select major and minor mods. We could give major mods a penalty so that the algorithm should prefer
# the usage of minor mods. BUT we already heavily penalize wasted stats. Due to this, the algorithm will always use
# a minor mod whenever possible. If we'd add the penalty, however, then it could also give us three minor mods in some
# rare cases, which could be interesting for some. You can add the following to the weights:
# - 1 * pl.lpSum(v_mods_major) - 0.5 * pl.lpSum(v_mods_minor)
# - Since the newest version it can also select the following:
# - class (some have more fragment slots, but you can specify this to your desired class)
# - subclass (required for the aspects and fragment selection)
# - aspects (only really required to get the amount of fragment slots)
# - fragments (you can fix the hashes of those you definitely need, can be disabled)
# - For very complex things, like "47 tiers", you have to disable some features that make the model very complex.
# For example, set stats.max_value to -1, and increase the timelimit_seconds.
######################################
# INPUT
# Set this to the name of the config file you want to read.
READ_CONFIG_FROM_FILE: None or str = None # "example.cfg"
config = dict(
main=dict(
# Set your desired stats. Be realistic, please
# The order is: [mob, res, rec, dis, int, str]
desired_stats=[0, 0, 0, 100, 0, 0],
# Set this to true if you want EXACT stats. Yes, that means you can ask the tool to get 6x69 stats..
desired_stats_exact=False,
# Set the amount of stat mods you want to use.
available_mods=5,
# static_stat_bonus represents your stat mods (Powerful Friends, Radiant Light) that apply a
# bonus or a penalty. You also add your desired subclass fragments in here, if they provide a bonus.
# This value is always added to the baseline of the stats. You can also add values to theorize.
static_stat_bonus=[0, 0, 0, 0, 0, 0],
# Set the class of the character. This is important for two things:
# First, the selection of the armor from d2ap_armor.json that is being used.
# Second, the selection of possible intrinsic stats for the generator of theoretical armor.
# You can also select "-1" if you want the tool to figure it out.
# -1 to let the script decide, or 0: Titan; 1: Hunter; 2: Warlock
character_class=-1,
),
stats=dict(
# Upper limit for stats. Use this to not waste too many points over 100
# Set it to -1 to disable this limit. This is required for some crazy things like 47 tiers.
max_value=109,
# The upper limit for wasted stats.
# Set it to -1 to disable this limit.
max_waste=-1,
# The minimum amount of stat tiers. A tier is a 10-step of a stat.
min_tiers=32,
# The minimum amount of stats at T10 or higher
min_100_stats=0
),
weights=dict(
# !!! Warning !!!
# If you have VERY intense boundaries, like "47 tiers", set all of these to 0.
# They drastically increase the complexity and runtime and are not required for extreme cases.
# Set this to 20 or a bit higher to enforce builds with lower or zero waste.W
waste_penalty=0,
# If you want to penalize mods (i.e. reduce the required amount), increase this
stat_mod_penalty=1,
# If you want to penalize fragment slots (i.e. reduce the required amount), increase this
fragment_slot_penalty=10,
# If you want to penalize fragments (i.e. reduce the required amount), increase this
fragment_penalty=10,
# Here you can set weights for each of the six stats.
# If you increase this, then the tool will try to put this stat in the foreground,
# AFTER all your requirements have been resolved.
# I'd recommend to set the one you really want to 1, the next one to 0.75, then 0.5 etc.
# Usually you can leave them all at 0. This is only for stat-preference.
# But in the end it comes down to your selected stats and what you want.
# You can also add a stat penalty here - simply give it a negative value around -5 to -10.
# Setting weights.intellect to -10 would basically tell the tool to never use intellect.
mobility=0,
resilience=0,
recovery=0,
discipline=0,
intellect=0,
strength=0
),
own_armor=dict(
# Set select_own_armor=True to see if you can reach your desired distribution with your own armor.
# Set select_own_armor=False to only generate theoretical armor.
# If you disable this, you do not need to present d2ap_armor.json to the tool.
# If the tool can not find d2ap_armor.json, then it'll automatically set USE_OWN_ARMOR=False.
select_own_armor=True,
# Set this to true if you want it to have an exotic
require_exotic=False,
# Add armor itemInstanceId you want to ignore. Must be a number
# You can find this in the "Armor Investigation" tab in D2ArmorPicker, or in the results of this tool.
# You can also find this in the d2ap_armor.json
ignored_armor_ids=[
# 6917529303135369161
],
# If you want a specific item in a specific slot, you can add the hash in here.
# You can specify as many as you want. Slot order: Helmet, Gauntlet, Chest, Legs
# If an entry is empty, it accepts every item.
# Example line to enforce Ashen Wake: (You get this ID from the light.gg URL)
# <https://www.light.gg/db/items/1734844650/ashen-wake/> turns into
# [1734844650], # gauntlet
# TODO: It currently does NOT take this into account in the generation process of theoretical armor.
# That means, it will not know when NOT to use exotic intrinsic stats when generating armor.
# You usually will not run into a situation where this occurs, but it is still important to know.
required_armor_hashes=[
[], # helmet
[], # gauntlet
[], # chest
[], # legs
]
),
theoretical_armor_pieces=dict(
# Settings for the generation of theoretical items
# If you set generate_theoretical_if_impossible=False then it will only use the armor from d2ap_armor.json
# It is NOT possible to set select_own_armor=False and emulate_theoretical_armor=False at the same time
emulate_theoretical_armor=True,
# Exotics that were introduced pre Shadowkeep have so-called Intrinsic stats.
# They are only in mob/res/rec, but may help sometimes.
# Setting emulate_exotics_w_intrinsic_stats=True allows the generator for theoretical
# armor to utilize these.
emulate_exotics_w_intrinsic_stats=True,
# Use this setting to disable intrinsic stats in specific slots.
# 1 means active, 0 means off. [Helmet, Gauntlet, Chest, Legs]
emulate_exotics_w_intrinsic_stats_slots=[1, 1, 1, 1],
# Setting this to True means that the script will always use the armor you set in REQUIRED_ARMOR_HASHES, even if this
# will lead to a failure (due to the stat distribution not being reachable).
# Setting this to False means that it will generate theoretical armor pieces in these slots that you need to grind,
# which can be useful if you want to reach a certain distribution with a specific exotic armor.
enforce_required_armor_pieces=True
),
combat_style_mod_selection=dict(
# This allows the tool to add Radiant Light and Powerful Friends
# Disable this if you want to use static_stat_bonus instead
enable_combatstyle_mod_selection=True,
),
fragment_selection=dict(
# If True, then the generator will pick class, subclass, aspects and fragments to fit your stats.
# You can set ARMOR_CLASS = -1 for this, and it will also select the best class.
enable_fragment_selection=True,
# If you want a predefined set of fragments, add them in here.
required_fragment_hashes=[],
# If you want to ignore a fragment, add it in here
ignored_fragment_hashes=[
# Negative ARC
# 1727069364, 1727069360, 1727069362,
# Negative SOLAR
# 362132294, 362132290, 362132292,
# Negative VOID
# 2661180603, 2661180601, 2272984668, 2272984671, 2272984664,
# Negative STASIS
# 2483898431, 3469412970, 537774542, 3469412974
],
# You can set the subclass that you want the generator to use.
# -1 to let the script decide, otherwise: 0: arc; 1: solar; 2: void; 3: stasis
selected_subclass=-1,
),
model=dict(
timelimit_seconds=20,
print_debug_messages=False
)
)
######################################
# reading config and applying it to the lookup variables
# DO NOT MODIFY ANYTHING IN THIS SECTION OR BELOW IT EXCEPT IF YOU KNOW WHAT YOU ARE DOING
parser = configparser.ConfigParser()
if READ_CONFIG_FROM_FILE is not None:
parser.read(READ_CONFIG_FROM_FILE)
else:
parser.read_dict(config)
# Writing our configuration file to 'example.cfg'
with open('recent.cfg', 'w') as configfile:
parser.write(configfile)
DESIRED_STATS = parser._get_conv("main", "desired_stats", lambda d: json.loads(d), fallback=[10, 10, 100, 10, 10, 10])
DESIRED_STATS_EXACT = parser.getboolean("main", "desired_stats_exact", fallback=False)
AVAILABLE_MODS = parser.getint("main", "available_mods", fallback=5)
MINIMUM_TIERS = parser.getint("stats", "min_tiers", fallback=28)
MIN_100_STATS = parser.getint("stats", "min_100_stats", fallback=0)
MAXIMUM_STAT_VALUE = parser.getint("stats", "max_value", fallback=109)
MAXIMUM_STAT_WASTE = parser.getint("stats", "max_waste", fallback=-1)
WASTE_PENALTY_WEIGHT = parser.getfloat("weights", "waste_penalty", fallback=0)
STAT_MOD_PENALTY_WEIGHT = parser.getfloat("weights", "stat_mod_penalty", fallback=0)
FRAGMENT_SLOT_PENALTY_WEIGHT = parser.getfloat("weights", "fragment_slot_penalty", fallback=0)
FRAGMENT_PENALTY_WEIGHT = parser.getfloat("weights", "fragment_penalty", fallback=0)
AVAILABLE_BONUS = parser._get_conv("main", "static_stat_bonus", lambda d: json.loads(d), fallback=[0, 0, 0, 0, 0, 0])
REQUIRE_EXOTIC = parser.getboolean("own_armor", "require_exotic", fallback=True)
USE_OWN_ARMOR = parser.getboolean("own_armor", "select_own_armor", fallback=True)
ARMOR_CLASS = parser.getint("main", "character_class", fallback=-1)
GENERATOR_ENABLED = parser.getboolean("theoretical_armor_pieces", "emulate_theoretical_armor", fallback=True)
GENERATOR_USE_EXOTIC_INTRINSICS = parser.getboolean("theoretical_armor_pieces",
"emulate_exotics_w_intrinsic_stats",
fallback=True)
GENERATOR_USE_EXOTIC_INTRINSICS_SLOTS = parser._get_conv("theoretical_armor_pieces", "emulate_exotics_w_intrinsic_stats_slots", lambda d: json.loads(d), fallback=[True, True, True, True])
GENERATOR_ENABLE_FRAGMENT_SELECTION = parser.getboolean("fragment_selection",
"enable_fragment_selection",
fallback=True)
GENERATOR_SELECTED_SUBCLASS = parser.getint("fragment_selection", "selected_subclass", fallback=-1)
GENERATOR_ENABLE_COMBATSTYLE_SELECTION = parser.getboolean("combat_style_mod_selection",
"enable_combatstyle_mod_selection",
fallback=True)
MODEL_TIMELIMIT_SECONDS = parser.getint("model", "timelimit_seconds", fallback=10)
MODEL_PRINT_DEBUG_MESSAGES = parser.getboolean("model", "print_debug_messages", fallback=False)
IGNORED_ARMOR = parser._get_conv("own_armor", "ignored_armor_ids", lambda d: json.loads(d), fallback=[])
STAT_WEIGHT_MOBILITY = parser.getfloat("weights", "mobility", fallback=0)
STAT_WEIGHT_RESILIENCE = parser.getfloat("weights", "resilience", fallback=0)
STAT_WEIGHT_RECOVERY = parser.getfloat("weights", "recovery", fallback=0)
STAT_WEIGHT_DISCIPLINE = parser.getfloat("weights", "discipline", fallback=0)
STAT_WEIGHT_INTELLECT = parser.getfloat("weights", "intellect", fallback=0)
STAT_WEIGHT_STRENGTH = parser.getfloat("weights", "strength", fallback=0)
REQUIRED_ARMOR_HASHES = parser._get_conv("own_armor", "required_armor_hashes", lambda d: json.loads(d),
fallback=[[], [], [], []])
REQUIRED_FRAGMENT_HASHES = parser._get_conv("fragment_selection", "required_fragment_hashes", lambda d: json.loads(d),
fallback=[])
IGNORED_FRAGMENT_HASHES = parser._get_conv("fragment_selection", "ignored_fragment_hashes", lambda d: json.loads(d),
fallback=[])
REQUIRED_ARMOR_ENFORCED = parser.getboolean("theoretical_armor_pieces", "enforce_required_armor_pieces", fallback=True)
######################################
# Fixed parameters for the algorithm
# DO NOT MODIFY ANYTHING IN THIS SECTION OR BELOW IT EXCEPT IF YOU KNOW WHAT YOU ARE DOING
# Contains the stat multipliers for each tier. This can be used to rate the score of stat tiers.
# In combination with the individual stat weights we can use this to force the tool to get higher
# tiers in certain stat tiers, lower in others, with the extra information of "how much is this tier
# helping?"
# Clipped to be in the range [0,10]
# TODO: This is not used *yet*.
WEIGHT_STAT_TIER_MULTIPLIERS = True
statTierMultipliers = [
[1.427, 1.256, 1.11, 1.0, 0.91461, 0.829, 0.7683, 0.72, 0.622, 0.561, 0.5],
[1.427, 1.256, 1.11, 1.0, 0.91461, 0.829, 0.7683, 0.72, 0.622, 0.561, 0.5],
[1.427, 1.256, 1.11, 1.0, 0.91461, 0.829, 0.7683, 0.72, 0.622, 0.561, 0.5],
[1.25, 1.14, 1.04, 1.0, 0.83, 0.72, 0.62, 0.55, 0.50, 0.455, 0.39],
[1.44, 1.276, 1.144, 1.0, 0.9488, 0.9024, 0.8608, 0.8224, 0.798, 0.776, 0.7664],
[1.25, 1.14, 1.04, 1.0, 0.83, 0.72, 0.62, 0.55, 0.50, 0.455, 0.39],
]
# Todo: add negative ones too
combatStyleMods = [
[(0, 20), "Powerful Friends", 1484685887],
[(5, 20), "Radiant Light", 2979815167],
]
subclassHashes = [
[2932390016, 2550323932, 2842471112, 613647804],
[2328211300, 2240888816, 2453351420, 873720784],
[3168997075, 3941205951, 2849050827, 3291545503],
]
subclassAspects = [
[ # Arc
# Titan
[(2, "Touch of Thunder", 1656549672), (2, "Knockout", 1656549674), (1, "Juggernaut", 1656549673)],
# Hunter
[(2, "Flow State", 4194622036), (2, "Tempest Strike", 4194622037), (2, "Lethal Current", 4194622038)],
# Warlock
[(2, "Arc Soul", 1293395731), (2, "Lightning Surge", 1293395730), (2, "Electrostatic Mind", 1293395729)],
],
[ # Solar
# Titan
[(2, "Sol Invictus", 2984351205), (2, "Roaring Flames", 2984351204), (2, "Consecration", 2984351206)],
# Hunter
[(3, "On Your Mark", 3066103999), (2, "Knock 'Em Down", 3066103998), (1, "Gunpowder Gamble", 3066103996)],
# Warlock
[(2, "Heat Rises", 83039194), (2, "Icarus Dash", 83039195), (2, "Touch Of Flame", 83039193)]
],
[ # Void
# Titan
[(2, "Offensive Bulwark", 1602994570), (2, "Controlled Demolition", 1602994568), (1, "Bastion", 1602994569)],
# Hunter
[(2, "Vanishing Step", 187655373), (2, "Stylish Executioner", 187655374), (1, "Trapper's Ambush", 187655372)],
# Warlock
[(2, "Child Of The Old Gods", 2321824287), (2, "Feed The Void", 2321824284),
(1, "Chaos Accelerant", 2321824285)]
],
[ # Stasis
# Titan
[(2, "Tectonic Harvest", 2031919264), (2, "Howl Of The Storm", 1563930741),
(1, "Cryoclasm", 2031919265), (3, "Diamond Lance", 3866705246)],
# Hunter
[(2, "Touch Of Winter", 4184589900), (2, "Grim Harvest", 1920417385),
(1, "Shatterdive", 2934767476), (1, "Winter's Shroud", 2934767477)],
# Warlock
[(2, "Iceflare Bolts", 668903196), (2, "Glacial Harvest", 2651551055),
(2, "Bleak Watcher", 2642597904), (2, "Frostpulse", 668903197)]
]
]
subclassFragments = [
[ # Arc
([(3, -10)], "Spark of Shock", 1727069364), ([(5, 10)], "Spark of Resistance", 1727069366),
([(2, 10)], "Spark of Volts", 3277705904), ([(6, -10)], "Spark of Focus", 1727069360),
([(5, -10)], "Spark of Discharge", 1727069362), ([(1, 10)], "Spark of Feedback", 3277705907),
([(4, 10)], "Spark of Brilliance", 3277705905),
],
[ # Solar
([(1, 10)], "Ember Of Wonder", 1051276350), ([(1, -10)], "Ember Of Empyrean", 362132294),
([(2, 10)], "Ember Of Searing", 1051276351), ([(2, -10)], "Ember Of Tempering", 362132290),
([(3, 10)], "Ember Of Char", 362132291), ([(3, -10)], "Ember Of Benelovence", 362132292),
([(4, 10)], "Ember Of Beams", 362132295), ([(5, 10)], "Ember Of Eruption", 1051276348),
([(5, 10)], "Ember Of Combustion", 362132289),
],
[ # void
([(2, -10)], "Echo Of Starvation", 2661180603), ([(4, -10)], "Echo Of Harvest", 2661180601),
([(2, 10)], "Echo Of Obscurity", 2661180602), ([(5, 10)], "Echo Of Instability", 2661180600),
([(3, -20)], "Echo Of Undermining", 2272984668), ([(3, 10)], "Echo Of Domineering", 2272984657),
([(1, 10)], "Echo Of Leeching", 2272984670), ([(4, 10)], "Echo Of Expulsion", 2272984665),
([(6, -10)], "Echo Of Persistence", 2272984671), ([(5, -10)], "Echo Of Provision", 2272984664),
([(0, 10), (4, 10)], "Echo Of Dilation", 2272984656),
],
[ # stasis
([(0, -10), (2, -10)], "Whisper Of Hunger", 2483898431), ([(3, -10)], "Whisper Of Fractures", 537774542),
([(5, -10)], "Whisper Of Hedrons", 3469412970), ([(2, -10), (3, -10)], "Whisper Of Bonds", 3469412974),
([(1, 10), (4, 10)], "Whisper Of Conduction", 2483898429), ([(1, 10)], "Whisper Of Shards", 3469412975),
([(2, 10)], "Whisper Of Chains", 537774540), ([(5, 10)], "Whisper Of Durance", 3469412969),
]
]
# Exotic intrinsic stats, per class (outer layer) and slot (inner layer)
possibleBonusStats = [
# Titan
[[[0, 1, 1], [0, 2, 0]],
[[0, 1, 1], [0, 2, 0], [0, 2, 1]],
[[2, 1, 0], [1, 1, 1], [0, 2, 1]],
[[1, 1, 0], [1, 0, 1], [0, 2, 0]]],
# Hunter
[[[2, 0, 0], [1, 1, 0], [1, 0, 1]],
[[1, 1, 1], [0, 1, 1], [1, 0, 1], [1, 1, 0], [2, 0, 0]],
[[2, 0, 1], [2, 1, 0], [1, 1, 1], [1, 2, 0]],
[[2, 1, 0], [2, 0, 0], [1, 1, 0]]],
# Warlock
[[[0, 1, 1], [0, 0, 2], [1, 0, 1]],
[[0, 0, 2], [0, 2, 1], [0, 1, 1], [1, 0, 1]],
[[2, 0, 1], [0, 2, 1], [0, 1, 2]],
[[0, 1, 2], [1, 0, 1], [0, 1, 1]]]
]
# If we do not want to use exotic intrinsics, this will simply set the array to be empty and thus disables the feature.
if not GENERATOR_USE_EXOTIC_INTRINSICS:
possibleBonusStats = [[[], [], [], []], [[], [], [], []], [[], [], [], []]]
for s, k in enumerate(GENERATOR_USE_EXOTIC_INTRINSICS_SLOTS):
if not k:
for clazz in range(0, 3):
possibleBonusStats[clazz][s] = []
# Define the usable plugs for armor generation
plugs = np.array([[1, 1, 10], [1, 1, 11], [1, 1, 12], [1, 1, 13], [1, 1, 14], [1, 1, 15],
[1, 5, 5], [1, 5, 6], [1, 5, 7], [1, 5, 8], [1, 5, 9], [1, 5, 10],
[1, 5, 11], [1, 6, 5], [1, 6, 6], [1, 6, 7], [1, 6, 8], [1, 6, 9],
[1, 7, 5], [1, 7, 6], [1, 7, 7], [1, 7, 8], [1, 8, 5], [1, 8, 6],
[1, 8, 7], [1, 9, 5], [1, 9, 6], [1, 10, 1], [1, 10, 5], [1, 11, 1],
[1, 11, 5], [1, 12, 1], [1, 13, 1], [1, 14, 1], [1, 15, 1], [5, 1, 5],
[5, 1, 6], [5, 1, 7], [5, 1, 8], [5, 1, 9], [5, 1, 10], [5, 1, 11],
[5, 5, 1], [5, 5, 5], [5, 6, 1], [5, 7, 1], [5, 8, 1], [5, 9, 1],
[5, 10, 1], [5, 11, 1], [6, 1, 5], [6, 1, 6], [6, 1, 7], [6, 1, 8],
[6, 1, 9], [6, 5, 1], [6, 6, 1], [6, 7, 1], [6, 8, 1], [6, 9, 1],
[7, 1, 5], [7, 1, 6], [7, 1, 7], [7, 1, 8], [7, 5, 1], [7, 6, 1],
[7, 7, 1], [7, 8, 1], [8, 1, 5], [8, 1, 6], [8, 1, 7], [8, 5, 1],
[8, 6, 1], [8, 7, 1], [9, 1, 5], [9, 1, 6], [9, 5, 1], [9, 6, 1],
[10, 1, 1], [10, 1, 5], [10, 5, 1], [11, 1, 1], [11, 1, 5], [11, 5, 1],
[12, 1, 1], [13, 1, 1], [14, 1, 1], [15, 1, 1]])
# Just some names required to print the results or to access specific fields of d2ap_armor.json entries.
stat_names = ["mobility", "resilience", "recovery", "discipline", "intellect", "strength", "class ability"]
slot_names = ["Helmet", "Gauntlet", "Chest", "Leg"]
##############################
# Validation Section
# This section validates your configuration
# DO NOT MODIFY ANYTHING IN THIS SECTION OR BELOW IT EXCEPT IF YOU KNOW WHAT YOU ARE DOING
if not os.path.exists("d2ap_armor.json"):
print("!!Warning!! d2ap_armor.json not found - setting USE_OWN_ARMOR to False")
USE_OWN_ARMOR = False
if USE_OWN_ARMOR:
with open("d2ap_armor.json", "r") as f:
armorData = json.load(f)
else:
armorData = []
if not USE_OWN_ARMOR and not GENERATOR_ENABLED:
raise Exception("Configuration error: USE_OWN_ARMOR and GENERATOR_ENABLED must not be disabled at the same time.")
if MIN_100_STATS < 0 or MIN_100_STATS > 5:
raise Exception("Configuration error: MIN_100_STATS must be in the range 0 <= x <= 4, but is %d." % MIN_100_STATS)
if AVAILABLE_MODS < 0 or AVAILABLE_MODS > 5:
raise Exception("Configuration error: AVAILABLE_MODS must be in the range 0 <= x <= 5, but is %d." % AVAILABLE_MODS)
if MODEL_TIMELIMIT_SECONDS <= 0:
raise Exception("Configuration error: "
"MODEL_TIMELIMIT_SECONDS must be larger than 0, but is %d." % MODEL_TIMELIMIT_SECONDS)
if ARMOR_CLASS != -1 and (ARMOR_CLASS < 0 or ARMOR_CLASS > 2):
raise Exception("Configuration error: "
"ARMOR_CLASS must be 0 (Titan), 1 (Hunter) or 2 (Warlock), but is %d." % ARMOR_CLASS)
if any([not isinstance(k, int) for k in IGNORED_ARMOR]):
raise Exception("Configuration error: "
"Every entry of IGNORED_ARMOR must be a number, but at least one isn't.")
if any([any(not isinstance(m, int) for m in k) for k in REQUIRED_ARMOR_HASHES]):
raise Exception("Configuration error: "
"Every entry of REQUIRED_ARMOR_HASHES must be a number, but at least one isn't.")
desired_stat_tier_sum = sum([k // 10 for k in DESIRED_STATS])
if desired_stat_tier_sum == 47:
print("!!WARNING!!\t\t"
"Note that the stat tier T47 can only be achieved in theory. "
"Your configuration of DESIRED_STATS requires at least %d tiers." % desired_stat_tier_sum)
elif desired_stat_tier_sum > 47:
print("!!WARNING!!\t\t"
"Stat tiers above T47 are impossible to reach. "
"Your configuration of DESIRED_STATS requires at least %d tiers." % desired_stat_tier_sum)
if sum(DESIRED_STATS) - sum(AVAILABLE_BONUS) - 10 * AVAILABLE_MODS > 335:
print("!!WARNING!!\t\t"
"Your configuration of DESIRED_STATS and AVAILABLE_BONUS is probably impossible to solve without fragments. "
"AVAILABLE_BONUS does not give enough stat points to generate DESIRED_STATS."
)
if MINIMUM_TIERS == 47:
print("!!WARNING!!\t\tNote that the stat tier T47 can only be achieved in theory.")
elif MINIMUM_TIERS > 47:
print("!!WARNING!!\t\tStat tiers above T47 are impossible to reach.")
if MAXIMUM_STAT_VALUE < 100:
print("!!WARNING!!\t\t"
"It is not recommended to set MAXIMUM_STAT_VALUE below 100. Current value is %d." % MAXIMUM_STAT_VALUE)
if WASTE_PENALTY_WEIGHT < 0:
print("!!WARNING!!\t\t"
"WASTE_PENALTY_WEIGHT should not be below 0, but is %d. "
"You are telling the script to prefer wasted stats." % WASTE_PENALTY_WEIGHT)
elif WASTE_PENALTY_WEIGHT == 0:
print("!!WARNING!!\t\t"
"WASTE_PENALTY_WEIGHT is 0. This is fine, but the tool will not weight in wasted stats in its result selection.")
def filterArmor(a, slot):
return ("slot" in a
and a["slot"] == slot
and (ARMOR_CLASS == -1 or a["clazz"] == ARMOR_CLASS)
and int(a["itemInstanceId"]) not in IGNORED_ARMOR
and (slot == 5 or len(REQUIRED_ARMOR_HASHES[slot - 1]) == 0 or int(a["hash"]) in REQUIRED_ARMOR_HASHES[slot - 1]))
armor = [
[a for a in armorData if filterArmor(a, 1)],
[a for a in armorData if filterArmor(a, 2)],
[a for a in armorData if filterArmor(a, 3)],
[a for a in armorData if filterArmor(a, 4)],
]
if any([len(i) == 0 and len(REQUIRED_ARMOR_HASHES[k]) > 0 for k, i in enumerate(armor)]):
print("!!WARNING!!\t\t"
"Your configuration of REQUIRED_ARMOR_HASHES resulted in an empty array of armor. "
"This can potentially lead to invalid results.")
########################################################################
########################################################################
# Code
# DO NOT MODIFY ANYTHING IN THIS SECTION OR BELOW IT EXCEPT IF YOU KNOW WHAT YOU ARE DOING
solver = pl.CPLEX_CMD()
model = pl.LpProblem("Stats", pl.LpMaximize)
# Step 1: Create variables for the stats themselves
# This is an array of 6 stats (stat0 to stat5), which represent out stats in the following order:
# 0: mobility 1: resilience 2: recovery
# 3: discipline 4: intellect 5: strength
v_total_stats = pl.LpVariable.dicts('stat', range(0, 6), lowBound=0, upBound=0, cat='Integer')
# Every armor gets a masterwork, so we just add a fixed 10 to the armor stat
# TODO: There is currently no setting for "assume masterwork" (which this is)
# The purpose of this script is to maximize things for theorycraftig,
# so I do not see the necessity right now.
for n in range(0, 6):
v_total_stats[n] += 10 # Masterworks :)
v_character_class = pl.LpVariable.dicts('class', range(0, 3), lowBound=0, upBound=1, cat='Integer')
model += pl.lpSum(v_character_class) == 1
# fix the selected class to 1
if ARMOR_CLASS > -1:
model += v_character_class[ARMOR_CLASS] == 1
v_aspect_slots = 0
if GENERATOR_ENABLE_FRAGMENT_SELECTION:
v_subcls = pl.LpVariable.dicts('subclass', range(0, 4), lowBound=0, upBound=1, cat='Integer')
model += pl.lpSum(v_subcls) == 1
if GENERATOR_SELECTED_SUBCLASS > -1:
# enforce the selected subcass
model += v_subcls[GENERATOR_SELECTED_SUBCLASS] == 1
# We also select aspects to get the slots we need for the fragments.
# this also means that we have pl.lpSum(v_aspect_slots) as the amount of free fragment slots
aspectCount = sum([sum(len(y) for y in x) for x in subclassAspects])
v_aspects = pl.LpVariable.dicts('subclass_aspects', range(0, aspectCount), lowBound=0, upBound=1, cat='Integer')
v_aspect_slots = pl.LpVariable("subclass_aspect_slots", lowBound=0, upBound=0, cat='Integer')
model += pl.lpSum(v_aspects) <= 2
n = 0
for subclsId, subcls in enumerate(subclassAspects):
for currentClass, classEntry in enumerate(subcls):
for aspect in classEntry:
if ARMOR_CLASS > -1 and ARMOR_CLASS != currentClass:
model += v_aspects[n] == 0
else:
model += v_aspects[n] <= v_subcls[subclsId]
model += v_aspects[n] <= v_character_class[currentClass]
v_aspect_slots += v_aspects[n] * aspect[0]
n += 1
# and now we select fragments to fulfill our stat requirements
fragmentCount = sum([len(k) for k in subclassFragments])
v_fragments = pl.LpVariable.dicts('subclass_fragments', range(0, aspectCount), lowBound=0, upBound=1, cat='Integer')
model += pl.lpSum(v_fragments) <= v_aspect_slots
n = 0
for subclsId, subcls in enumerate(subclassFragments):
for fragment in subcls:
# Disable this fragment if we chose a different subclass
model += v_fragments[n] <= v_subcls[subclsId]
# Enforce this fragment if it has been set in REQUIRED_FRAGMENT_HASHES
if fragment[2] in REQUIRED_FRAGMENT_HASHES:
model += v_fragments[n] == 1
# Disable this fragment if it has been set in IGNORED_FRAGMENT_HASHES
if fragment[2] in IGNORED_FRAGMENT_HASHES:
model += v_fragments[n] == 0
for stat_boost in fragment[0]:
stat, boost = stat_boost
if stat == 6:
# Class based stats
v_lookup = pl.LpVariable.dicts(fragment[1], range(0, 3), lowBound=0, upBound=1, cat='Integer')
model += pl.lpSum(v_lookup) <= 1
model += pl.lpSum(v_lookup) >= v_fragments[n]
v_total_stats[0] += boost * v_lookup[1]
v_total_stats[1] += boost * v_lookup[2]
v_total_stats[2] += boost * v_lookup[0]
else:
# all other stats
v_total_stats[stat] += boost * v_fragments[n]
n += 1
if GENERATOR_ENABLE_COMBATSTYLE_SELECTION:
v_combatstyle = pl.LpVariable.dicts('combatstyle', range(0, len(combatStyleMods)), lowBound=0, upBound=1, cat='Integer')
v_combatstyle_sum = pl.lpSum(v_combatstyle)
model += pl.lpSum(v_combatstyle) >= 0
model += pl.lpSum(v_combatstyle) <= 5
for n, modData in enumerate(combatStyleMods):
v_total_stats[modData[0][0]] += modData[0][1] * v_combatstyle[n]
# If we added static boosts in the configuration, we add them right here.
# Static boosts are just added to the baseline of the stat, before any armor calculation has been done.
if np.any(AVAILABLE_BONUS):
for n in range(0, 6):
v_total_stats[n] += AVAILABLE_BONUS[n]
if AVAILABLE_MODS > 0:
# Register the stat mod variables. We allow for major and minor stat mods.
# This is important in the case that we want to optimize for reduced wasted stats.
v_mods_major = pl.LpVariable.dicts('modMajor', range(0, 6), cat='Integer', lowBound=0, upBound=5)
v_mods_minor = pl.LpVariable.dicts('modMinor', range(0, 6), cat='Integer', lowBound=0, upBound=5)
model += pl.lpSum(v_mods_major) + pl.lpSum(v_mods_minor) <= AVAILABLE_MODS
if AVAILABLE_MODS > 0:
for mod in range(0, 6):
v_total_stats[mod] += 10 * v_mods_major[mod]
v_total_stats[mod] += 5 * v_mods_minor[mod]
# Apply items
# The basic idea is that we want it to use the items in our inventory.
# If it does not find a possible solution with our items, then it should propose which armor to grind.
# We do this by giving every item entry a "point score".
# The armor we have in our inventory gets a high score, and "generated" items get a low score.
# Later, we want to maximize the score.
# Due to the limits we applied, the MIP is then forced to fill impossible slots with fictional items.
v_armor_scores = pl.LpVariable.dicts('armor_score', range(0, 4), lowBound=0, upBound=0, cat='Integer')
# TODO: here is a small bug that happens when you use your own armor. If upBound is not 0, then it will possible generate builds without exotics.
# Not harmful at all, but noteworthy.
v_armor_exotic = pl.LpVariable.dicts('armor_exotic', range(0, 4), lowBound=0, upBound=1, # if not USE_OWN_ARMOR else 0,
cat='Integer')
armor_reference = []
custom_armor = []
for armorId in range(0, 4):
armorList = armor[armorId]
# Create a runtime variable with len(armorList) + 1; We use this to select our armor later.
# The +1 entry is the generated fallback armor piece
# The MIP will have to select ONE entry from here. We use this variable to grab stats
v_armort = pl.LpVariable.dicts('armor_%d_selection' % armorId, range(0, len(armorList) + 1), lowBound=0, upBound=1,
cat='Integer')
armor_reference.append(v_armort)
model += pl.lpSum(v_armort) == 1 # only select exactly one
if not GENERATOR_ENABLED:
model += v_armort[0] == 0
if GENERATOR_ENABLED and (not REQUIRED_ARMOR_ENFORCED or len(REQUIRED_ARMOR_HASHES[armorId]) == 0):
# register the generated fallback armor piece
v_item_stats = pl.LpVariable.dicts('armor_%d_stat' % armorId, range(0, 6), lowBound=0, upBound=0, cat='Integer')
v_item_plugs_g1 = pl.LpVariable.dicts('armor_%d_plug1' % armorId, range(0, len(plugs)), lowBound=0, upBound=2,
cat='Integer')
v_item_plugs_g2 = pl.LpVariable.dicts('armor_%d_plug2' % armorId, range(0, len(plugs)), lowBound=0, upBound=2,
cat='Integer')
# this forces 0 plugs if the fallback armor is not selected
model += pl.lpSum(v_item_plugs_g1) == v_armort[0] * 2
# this forces 0 plugs if the fallback armor is not selected
model += pl.lpSum(v_item_plugs_g2) == v_armort[0] * 2
# Apply exotic intrinsic stats
v_exo = pl.LpVariable("armor_gen_ex_%d" % armorId, lowBound=0, upBound=1, cat='Integer')
model += v_exo <= v_armort[0]
model += v_exo <= v_armor_exotic[armorId]
intrinsicCount = sum([sum(len(y) for y in x) for x in possibleBonusStats])
v_intrinsics = pl.LpVariable.dicts("armor_gen_intrinsics_%d" % armorId,
range(0, intrinsicCount), lowBound=0,
upBound=1, cat='Integer')
# only allow intrinsic stats if it is an exotic. we do this by limiting the whole intrinsic selector by 0
model += pl.lpSum(v_intrinsics) <= v_exo
n = 0
for clazzId, clazzEntries in enumerate(possibleBonusStats):
for entry in clazzEntries[armorId]:
# Limit the intrinsic stat(s) to the class it originates from
model += v_intrinsics[n] <= v_character_class[clazzId]
for r in [0, 1, 2]:
if entry[r] > 0:
v_item_stats[r] += entry[r] * v_intrinsics[n]
n += 1
# Add the plugs, which basically generates this armor set
for n in range(0, 3):
for k, v in enumerate(plugs):
v_item_stats[n] += v_item_plugs_g1[k] * plugs[k][n]
v_item_stats[3 + n] += v_item_plugs_g2[k] * plugs[k][n]
v_total_stats[n] += v_item_stats[n]
v_total_stats[3 + n] += v_item_stats[3 + n]
custom_armor.append((v_item_stats, v_intrinsics))
# now register our armor items
for kArmor, armorEntry in enumerate(armorList):
# Disable armor if it does not fit to the class the MIP selected.
model += v_armort[kArmor + 1] <= v_character_class[armorEntry["clazz"]]
# mark this armor as an exotic. This means that the result will only have up to one exotic
if armorEntry["isExotic"]:
v_armor_exotic[armorId] += v_armort[kArmor + 1]
# our armor is worth one point, while the generated one is not worth anything
v_armor_scores[armorId] += v_armort[kArmor + 1] * 1
# add another, higher score if we set entries in the REQUIRED_ARMOR_HASHES list.
# This means we really want these items in the result
if len(REQUIRED_ARMOR_HASHES[armorId]) > 0:
v_armor_scores[armorId] += v_armort[kArmor + 1] * 2
for kStat, stat in enumerate(stat_names[0:6]):
v_total_stats[kStat] += v_armort[kArmor + 1] * armorEntry[stat]
# Only allow one exotic
model += pl.lpSum(v_armor_exotic) <= 1
if REQUIRE_EXOTIC:
model += pl.lpSum(v_armor_exotic) == 1
# Apply a limit on each stat.
# The lower bound is the value in DESIRED_STATS, which means it HAS to have this value.
# The upper bound is just a fixed value to stop it from overflowing.
for stat in v_total_stats:
if DESIRED_STATS_EXACT:
model += v_total_stats[stat] == DESIRED_STATS[stat]
else:
model += v_total_stats[stat] >= DESIRED_STATS[stat]
if MAXIMUM_STAT_VALUE > -1:
model += v_total_stats[stat] <= MAXIMUM_STAT_VALUE
####################################
# Calculation variables
tiers = pl.LpVariable.dicts('tier', range(0, 6), lowBound=0, cat='Integer')
for stat in range(0, 6):
model += tiers[stat] >= 0
if MAXIMUM_STAT_VALUE > 0:
model += tiers[stat] <= MAXIMUM_STAT_VALUE // 10
model += tiers[stat] >= (v_total_stats[stat] / 10) - 0.9
model += tiers[stat] <= (v_total_stats[stat] / 10)
v_tier_sum = pl.lpSum(tiers)
model += v_tier_sum >= 0
model += v_tier_sum >= MINIMUM_TIERS
# Let's check how many 100 stats you have
if MIN_100_STATS > 0:
v_x100_stats = pl.LpVariable.dicts('x100stat', range(0, 6), lowBound=0, cat='Integer')
v_x100_stat_count = pl.lpSum(v_x100_stats)
for n in range(0, 6):
model += v_x100_stats[n] >= (v_total_stats[n] / 100) - 0.99
model += v_x100_stats[n] <= (v_total_stats[n] / 100)
model += v_x100_stat_count >= MIN_100_STATS
v_stat_sum = pl.lpSum(v_total_stats)
v_armor_scores_sum = pl.lpSum(v_armor_scores)
v_waste = pl.LpVariable('waste', cat="Integer")
model += v_waste == pl.lpSum([pl.lpSum(v_total_stats[stat] - tiers[stat] * 10) for stat in range(0, 6)])
if MAXIMUM_STAT_WASTE == 0:
model += pl.lpSum([pl.lpSum(v_total_stats[stat] - tiers[stat] * 10) for stat in range(0, 6)]) == 0
elif MAXIMUM_STAT_WASTE > -1:
model += v_waste <= MAXIMUM_STAT_WASTE
#########
# grading Weights
# Tell the model what we want to optimize.
# In summa:
# - Use as many items in our vault as we can. Only resort to armor generation as a last resort.
# - Then we want as many tiers as possible. Basically, any T34 is better than T33
# - Inside the same tier, we now optimize for stats.
# - We also give wasted stats a penalty, as we do not want them at all.
model += (
# we want to use as many of our own armor pieces as possible.
# Even one generated one is a HUUUUGE penalty!
+ 10000 * v_armor_scores_sum
+ 10 * v_tier_sum # We want as many tiers as possible
+ (0 if MIN_100_STATS == 0 else 10 * v_x100_stat_count) # We want as many tiers as possible
+ 1 * v_stat_sum # Then we want as many stat points as possible
- (0 if not GENERATOR_ENABLE_COMBATSTYLE_SELECTION or STAT_MOD_PENALTY_WEIGHT == 0 else STAT_MOD_PENALTY_WEIGHT * v_combatstyle_sum)
# we want to be able to give weights to each stat, basically telling the script to prefer
# or to dislike a certain stat
+ (0 if STAT_WEIGHT_MOBILITY == 0 else STAT_WEIGHT_MOBILITY * v_total_stats[0])
+ (0 if STAT_WEIGHT_RESILIENCE == 0 else STAT_WEIGHT_RESILIENCE * v_total_stats[1])
+ (0 if STAT_WEIGHT_RECOVERY == 0 else STAT_WEIGHT_RECOVERY * v_total_stats[2])
+ (0 if STAT_WEIGHT_DISCIPLINE == 0 else STAT_WEIGHT_DISCIPLINE * v_total_stats[3])
+ (0 if STAT_WEIGHT_INTELLECT == 0 else STAT_WEIGHT_INTELLECT * v_total_stats[4])
+ (0 if STAT_WEIGHT_STRENGTH == 0 else STAT_WEIGHT_STRENGTH * v_total_stats[5])
# and of course we do not like wasted points, so we simply remove them from the stat sum.
- (0 if WASTE_PENALTY_WEIGHT == 0 else WASTE_PENALTY_WEIGHT * v_waste)
# Now let's add weights for aspects and fragments.
# I want to get the best stats with the least amount of fragments
# That means that I will add a penalty for each used fragment
- (0 if (GENERATOR_ENABLE_FRAGMENT_SELECTION is False or FRAGMENT_SLOT_PENALTY_WEIGHT == 0) else
FRAGMENT_SLOT_PENALTY_WEIGHT * v_aspect_slots)
- (0 if GENERATOR_ENABLE_FRAGMENT_SELECTION is False or FRAGMENT_PENALTY_WEIGHT == 0 else
FRAGMENT_PENALTY_WEIGHT * pl.lpSum(v_fragments))
)
result = model.solve(pl.PULP_CBC_CMD(
timeLimit=MODEL_TIMELIMIT_SECONDS,
msg=MODEL_PRINT_DEBUG_MESSAGES,
keepFiles=False
))
print("~~~ Input ~~~")
print("Desired stats:", DESIRED_STATS)
print("Available bonus:", AVAILABLE_BONUS)
print("Available Mods", AVAILABLE_MODS)
if model.status == 1:
print('Solution is optimal: %s' % pl.LpStatus[model.status])
else:
print('!! Failed to find solution: %s !!' % pl.LpStatus[model.status])
print('This means that your configuration creates constraints that are not possible.')
print('Adapt the settings, and keep in mind that some distributions simply are impossible.')
print("v_armor_scores_sum", pl.value(v_armor_scores_sum))
print("v_tier_sum", pl.value(v_tier_sum))
print("v_character_class", [pl.value(v_character_class[k]) for k in v_character_class])
print("v_total_stats", [pl.value(v_total_stats[k]) for k in v_total_stats])
print("tiers", [pl.value(tiers[k]) for k in tiers])
if AVAILABLE_MODS > 0:
print("v_mods_major", [pl.value(v_mods_major[k]) for k in v_mods_major])
print("v_mods_minor", [pl.value(v_mods_minor[k]) for k in v_mods_minor])
if MIN_100_STATS > 0:
print("x100", pl.value(pl.lpSum(v_x100_stat_count)))
print("v_waste", pl.value(v_waste))
print("v_stat_sum", pl.value(v_stat_sum))
print("exotic", [pl.value(v_armor_exotic[k]) for k in range(0, 4)])
raise Exception('Failed to find solution: %s' % pl.LpStatus[model.status])
print()
chosen_clazz = np.where(np.array([pl.value(v_character_class[c]) for c in v_character_class]) == 1)[0][0]
print("~~~ OUTPUT ~~~")
print("Class: ", ["Titan", "Hunter", "Warlock"][chosen_clazz])
if GENERATOR_ENABLE_FRAGMENT_SELECTION:
subcls = np.where(np.array([pl.value(v_subcls[k]) for k in v_subcls]) == 1)[0][0]
print("Subclass: ", ["Arc", "Solar", "Void", "Stasis"][subcls])
aspects_flat = [x for g in subclassAspects for f in g for x in f]
aspects_indexes = np.where(np.array([pl.value(v_aspects[k]) for k in v_aspects]) == 1)[0]
if len(aspects_indexes) > 0:
print("Selected Aspects: ", end="")
for k in aspects_indexes:
print(aspects_flat[k][1], end=", ")
print()
print("%-18s %-5d" % ("Total Stat Points:", int(pl.value(v_stat_sum))))
print("%-18s %-4d" % ("Total Tiers:", int(pl.value(pl.lpSum(tiers)))))
print("")
print("Use the following armor pieces and mods:")
print("%-11s%-13s%-23s%-26s%-35s" % ("Slot", "Hash", "itemInstanceId", "Stats", "Name"))
formatStats = lambda st: "[%s]" % ",".join(["%3d" % s for s in st])
printPlaceholder = lambda: print("%-11s%-13s%-23s%-26s%-35s" % (
"-----------", "-------------", "-----------------------",
"--------------------------", "-----------------------------------"))
for k, armorRef in enumerate(armor_reference):
selected = np.where(np.array([pl.value(armorRef[p]) == 1 for p in armorRef]) == 1)[0]
if len(selected) == 0:
print("~~ NO ARMOR COULD BE FOUND FOR THIS SLOT ~~ CONFIGURATION NOT POSSIBLE ~~")
continue
selected = selected[0]
if selected > 0:
arm = armor[k][selected - 1]
print("%-11s%-13s%-23s%-26s%-35s" % (slot_names[k], arm["hash"], arm["itemInstanceId"],
formatStats([
arm["mobility"], arm["resilience"], arm["recovery"],
arm["discipline"], arm["intellect"], arm["strength"],
]), arm["name"]
))
else:
# print a message in case that we wanted to use our own armor, but couldn't because it is impossible.
# this only happens if REQUIRED_ARMOR_ENFORCED is False
if len(REQUIRED_ARMOR_HASHES[k]) > 0:
print(
"%-11sThe armor set in REQUIRED_ARMOR_HASHES could not satisfy the desired stats. Farm the following:"
% (slot_names[k]))
print("%-11s%-13s%-23s%-26s" % (slot_names[k], "theory", "Grind this %s" % slot_names[k],
formatStats(
[int(pl.value(custom_armor[k][0][p])) for p in custom_armor[k][0]])),
end="")
if pl.value(v_armor_exotic[k]) == 1:
intrinsicIndex = np.array([pl.value(custom_armor[k][1][r]) for r in custom_armor[k][1]])
if sum(intrinsicIndex) > 0:
plugIndex = np.where(intrinsicIndex == 1)[0][0]
bonus_flat = [y for g in possibleBonusStats for km, f in enumerate(g) for y in f if km == k]
stats = bonus_flat[plugIndex]
print("exotic with intrinsic stats %s" % str(stats), end="")
print("")
print("%-11s%-13s%-23s%-26s%-35s" % ("Masterwork", "--", "--", formatStats([10, 10, 10, 10, 10, 10]), ""))
printPlaceholder()
# print mods
if AVAILABLE_MODS > 0:
print("%-11s%-13s%-23s%-26s%-35s" % ("Stat Mods", "--", "--",
formatStats(
[pl.value(v_mods_major[k]) * 10 + pl.value(v_mods_minor[k]) * 5
for k in range(0, 6)]), ""))
# print mods
if sum(AVAILABLE_BONUS) > 0:
print("%-11s%-13s%-23s%-26s%-35s" % ("Fixed Bonus", "--", "--", formatStats(AVAILABLE_BONUS), ""))
if GENERATOR_ENABLE_COMBATSTYLE_SELECTION:
csMods = [m for k, m in enumerate(combatStyleMods) if pl.value(v_combatstyle[k]) == 1]
for mod in csMods:
stats = [0, 0, 0, 0, 0, 0]
stats[mod[0][0]] += mod[0][1]
print("%-11s%-13s%-23s%-26s%-35s" % ("CS Mod", "--", "--", formatStats(stats), mod[1]))
if GENERATOR_ENABLE_FRAGMENT_SELECTION:
fragments_flat = [f for g in subclassFragments for f in g]
fragment_indexes = np.where(np.array([pl.value(v_fragments[k]) for k in v_fragments]) == 1)[0]
if len(fragment_indexes) > 0:
for k in fragment_indexes:
strp = fragments_flat[k][1]
stats = [0, 0, 0, 0, 0, 0]
for s in fragments_flat[k][0]:
if s[0] == 6:
s[0] = [1, 0, 2][chosen_clazz]
stats[s[0]] += s[1]
print("%-11s%-13s%-23s%-26s%-35s" % ("Fragment", "--", "--", formatStats(stats), strp))
printPlaceholder()
# print stats
print("%-11s%-13s%-23s%-26s%-35s" % ("Summary", "--", "--",
formatStats([pl.value(v_total_stats[stat]) for stat in range(0, 6)]), ""))
# Generate DIM Link
def getModIds():
statModIds = [
# minor, major
[204137529, 3961599962], # mob
[3682186345, 2850583378], # res
[555005975, 2645858828], # rec
[2623485440, 4048838440], # dis
[1227870362, 3355995799], # int
[3699676109, 3253038666], # str
]
# Add stat mods
usedModIds = [
[statModIds[int(k)][1] for c in range(0, int(pl.value(v_mods_major[k])))]
+ [statModIds[k][0] for c in range(0, int(pl.value(v_mods_minor[k])))]
for k in range(0, 6)]
# Flatten the list
usedModIds = [int(j) for sub in usedModIds for j in sub]
# now add combat mods
if GENERATOR_ENABLE_COMBATSTYLE_SELECTION:
usedModIds += [m[2] for k, m in enumerate(combatStyleMods) if pl.value(v_combatstyle[k]) == 1]
return usedModIds
# Generate DIM link if all 4 items are my own
if USE_OWN_ARMOR and pl.value(v_armor_scores_sum) == 4:
import urllib.parse
print()
loadout = {
"id": "mijago",
"name": "Mijago Armor Calculator Loadout",
"classType": int(chosen_clazz),
"unequipped": [],
"clearSpace": False,
"parameters": {
"statConstraints": [],
"assumeArmorMasterwork": 3, # AssumeArmorMasterwork.All
"lockArmorEnergyType": 1, # LockArmorEnergyType.None
"mods": getModIds()
},
"equipped": []
}
# Equip armor
for k, armorRef in enumerate(armor_reference):
selected = np.where(np.array([pl.value(armorRef[p]) == 1 for p in armorRef]) == 1)[0][0]
arm = armor[k][selected - 1]
loadout["equipped"].append({
"id": arm["itemInstanceId"],
"hash": arm["hash"],
})
# add any class item
clazzItems = [a for a in armorData if filterArmor(a, 5) and a["clazz"] == chosen_clazz]
if len(clazzItems) > 0:
clazzItems.sort(key=lambda d: d["energyLevel"], reverse=True)
loadout["equipped"].append({
"id": clazzItems[0]["itemInstanceId"],
"hash": clazzItems[0]["hash"],
})
# equip fragments
if GENERATOR_ENABLE_FRAGMENT_SELECTION:
subcls = np.where(np.array([pl.value(v_subcls[k]) for k in v_subcls]) == 1)[0][0]
subclassItem = {
"id": "subclass",
"hash": subclassHashes[chosen_clazz][subcls]
}
fragments_flat = [f for g in subclassFragments for f in g]
fragment_indexes = np.where(np.array([pl.value(v_fragments[k]) for k in v_fragments]) == 1)[0]
socketOverrides = {}
if len(fragment_indexes) > 0:
for k, idx in enumerate(fragment_indexes):
socketOverrides[k + 7] = fragments_flat[idx][2]
aspects_flat = [x for g in subclassAspects for f in g for x in f]
aspects_indexes = np.where(np.array([pl.value(v_aspects[k]) for k in v_aspects]) == 1)[0]
if len(aspects_indexes) > 0:
for k, idx in enumerate(aspects_indexes):
socketOverrides[k + 5] = aspects_flat[idx][2]
subclassItem["socketOverrides"] = socketOverrides
loadout["equipped"].append(subclassItem)
if pl.value(pl.lpSum(v_armor_exotic)) == 1:
exotic_index = int(np.where(np.array([pl.value(v_armor_exotic[k]) for k in v_armor_exotic]) == 1)[0])
armorRef = armor_reference[exotic_index]
selected = np.where(np.array([pl.value(armorRef[p]) == 1 for p in armorRef]) == 1)[0][0]
arm = armor[exotic_index][selected - 1]
loadout["parameters"]["exoticArmorHash"] = arm["hash"]
url = "https://app.destinyitemmanager.com/loadouts?loadout=" + urllib.parse.quote(json.dumps(loadout).encode("utf-8"))
print(url)

Description is outdated, I will update it soon-ish.

Main changes:

  • Rewrote configuration
  • Can now select class, subclass, aspects and fragments

What it does

This script is directed at theoretical buildcrafters. The main question it answers is "Is this stat distribution possible?". This version has an extended functionality: It tells you, if YOU with YOUR armor can reach the distribution - and if you can't, then it can tell you which armor piece you have to farm. It tells you ONE possibility - not all available.

What is different to D2ArmorPicker?

For the general user, I suggest to keep using D2ArmorPicker. This tool has less configuration possibilities for builds. The main differences are:

  • D2AP has way more and easier configuration possibilities.
  • This script only gives one result.
  • This script can tell you "this is the item you need to grind to reach your desired stats". Note: This is for theorycrafters. The probability to get it is so small, if you have a life, don't go hunting for it.

Eli5 how to use it

  1. download d2ap_armor.json from D2ArmorPicker under the "Account" tab (v2.2.X).
  2. Edit the settings at the top of the script. Mainly, set DESIRED_STATS and ARMOR_CLASS, maybe comment out some AVAILABLE_BONUS lines (not the first line, though).
  3. python3 -m pip install numpy pulp
  4. python3 armorCalc.py
  5. Wait for it to finish. If you see results that have decimals after the comma, then the problem was not solvable.
  6. If it takes ages to load, I'd abort and increase the stat limits.

I don't want to install python, eww

  1. Open Google Colab
  2. Create a new notebook and copy the code in there.
  3. In Google Colab you have to install the modules, so add !pip install pulp numpy as the first line of the code.
  4. If you want, upload d2ap_armor.json.
  5. Edit the settings at the top of the script. Mainly, set DESIRED_STATS and ARMOR_CLASS, maybe comment out some AVAILABLE_BONUS lines (not the first line, though).
  6. Wait for it to finish. If you see results that have decimals after the comma, then the problem was not solvable. That means that the desired distribution is not reachable with your current settings.
  7. If it takes ages to load, I'd abort and increase the stat limits.

Example result

~~~ Input ~~~
Desired stats: [100, 50, 100, 100, 30, 60]
Available bonus: [20 20 10  0 10 30]
Available Mods 5
~~~ OUTPUT ~~~
Total Stat Points: 463.0
Total Tiers: 46.0
stat	value	mods
0 	 100.0 	 1.0
1 	 60.0 	 0.0
2 	 100.0 	 4.0
3 	 100.0 	 0.0
4 	 42.0 	 0.0
5 	 61.0 	 0.0
Use the following armor pieces:
6917529772790158022    Wormhusk Crown                     
6917529488361665725    Iron Forerunner Grips              
~~~~~~~~~~~~~~~~~~~    Grind these                         [2.0, 10.0, 22.0, 30.0, 2.0, 2.0]
6917529407619423071    Illuminus Strides (Magnificent)    

Example Use-Cases

1. Can I reach x/50/100/100/x/x with my gear?

DESIRED_STATS = [0, 50, 100, 100, 0, 0]
AVAILABLE_MODS = 5
MINIMUM_TIERS = 32
WASTE_PENALTY_WEIGHT = 2
AVAILABLE_BONUS = np.array([0, 0, 0, 0, 0, 0])
USE_OWN_ARMOR = True  # (actually can be True too, doesn't matter)
GENERATOR_ENABLED = False
MODEL_TIMELIMIT_SECONDS = 10

2. Are 33 base tiers possible, without fragments and mods?

DESIRED_STATS = [0, 0, 0, 0, 0, 0]
AVAILABLE_MODS = 0
MINIMUM_TIERS = 33
WASTE_PENALTY_WEIGHT = 0
AVAILABLE_BONUS = np.array([0, 1, 0, 0, 0, 0])
USE_OWN_ARMOR = False  # (actually can be True too, doesn't matter)
GENERATOR_ENABLED = True  
GENERATOR_USE_EXOTIC_INTRINSICS = True
MODEL_TIMELIMIT_SECONDS = 10

3. I want to use Loreley Splendor with 100 Resiliene and Recovery, a minimum of 50 discipline and the rest as high as possible. I also want to reduce wasted stats, because that's cool!

DESIRED_STATS = [0, 100, 100, 50, 0, 0]
AVAILABLE_MODS = 5
MINIMUM_TIERS = 32
WASTE_PENALTY_WEIGHT = 5
AVAILABLE_BONUS = np.array([0, 0, 0, 0, 0, 0])
AVAILABLE_BONUS += [0,10,10,0,0,0] # Solar
USE_OWN_ARMOR = True
ARMOR_CLASS = 0
REQUIRED_ARMOR_ENFORCED = True
GENERATOR_ENABLED = False

Configuration Variables

Name Type Description
ARMOR_CLASS int Specify the class you want to generate things on. 0=Titan; 1=Hunter; 2=Warlock
DESIRED_STATS int[6] Set the minimum of the stats you want. Do not exxagerate, be realistic. The order is: [Mobility, Resilience, Recovery, Discipline, Intellect, Strength]
AVAILABLE_BONUS int[6] Set any static boosts of stats. This includes: Powerful Friends, Radiant Light, Subclass Fragments and anything else that is not given by armor or the armor stat mods. The order is: [Mobility, Resilience, Recovery, Discipline, Intellect, Strength]
AVAILABLE_MODS int Specify how many armor stat mods shall be used. It always will use major mods.
MODEL_TIMELIMIT_SECONDS int The timespan in seconds that the model is allowed to use. Usually, 5s~10s is enough. Increase only if your query is too broad or too complex.
MINIMUM_TIERS int The minimum amount of stat tiers (10 steps). Use this if you want to search for builds that have a minimum of N tiers.
MAXIMUM_WASTE int Set the maximum amount of wasted stats. Default is 54 (so any amount of waste). Set it to 0 to only generate zero-waste builds.
WASTE_PENALTY_WEIGHT int Set the penalty of wasted stats. If it is 1 then it is just removed from the total stat sum. Set it to ~20 to enforce as-low-as-possible builds, or even zero-waste builds.
MAXIMUM_STAT_VALUE int Set the maximum value a stat can achieve. Default is 109, so you will not waste too many points above 100. Set this to a ridiculously high value (e.g. 1000) to disable the limit completely.
USE_OWN_ARMOR bool Set this to False to disable the usage of d2ap_armor.json. It will then only generate theoretical builds and not use your armor at all.
IGNORED_ARMOR int[] Add the itemInstanceId of armor you want to ignore to this list.
GENERATOR_ENABLED bool If you want to generate theoretical armor pieces. Set this to False to only use your armor. You can then check if you can reach the distribution. It's also way faster.
GENERATOR_USE_EXOTIC_INTRINSICS bool If this is true, then the generator can also generate theoretical armor pieces that are exotics with intrinsic stats.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment