TL;DR: Made a website for optimizing Paragon cards using the GNU Linear Programming Kit and Haskell. Code available here.

I love MOBA’s (Dota, LoL, Paragon), and I love Haskell. Since Paragon is my current go-to game, I wanted to determine the cards to buy to maximize my Damage Per Second (DPS).

First things first, I found a spreadsheet of all the cards, colors, costs, and stats. Using some Vim magic, I made them into a list of tuples:

("Wellspring Staff",3,Universal,"6 Power|30 Mana|Fully Upgraded Bonus:30 Mana|0.3 Mana Regen"), ("Whirling Wand",3,Universal,"6 Power|5.5 Attack Speed|Fully Upgraded Bonus:11 Attack Speed"),

Which I parse into a data structure:

data Card = Card { _cost :: Integer , _power :: Integer , _speed :: Integer , _crit :: Integer , _pen :: Integer , _lifesteal :: Integer , _crit_bonus :: Integer , _ward :: Integer , _blink :: Integer , _name :: String , _firstType :: String , _secondType :: String , _afinity :: Afinity } deriving (Show) makeLenses ''Card

The equation to maximize DPS combines the stats of: power, speed, crit chance, armor pen, crit bonus, and the enemy’s armor.

dmgReduction :: Double -> Double -> Double dmgReduction enemyArmor penetrationPoints = let effectiveArmor = enemyArmor - (penetrationPoints * 4.0) realArmor = if (effectiveArmor < 0) then 0 else effectiveArmor reduction = (100/(100 + effectiveArmor)) in if reduction > 1 then 1 else reduction dps :: Hero -> Double -> Double -> Double -> Double -> Double -> Double -> Double dps hero powerPoints attackSpeedPoints critPoints penetrationPoints critDamage enemyArmor = do let reduction = dmgReduction enemyArmor penetrationPoints baseDmg = ((hero^.base_damage)+(6*powerPoints*(hero^.scaling))) hitsSecond = 1/((hero^.base_attack_time)/(((5.5*attackSpeedPoints) + (hero^.attack_speed))/100)) critBonus = (1+((0.04*critPoints)*(critDamage-1))) baseDmg * hitsSecond * critBonus * reduction

To speed up the optimization problem, I broke it down into two calculations. First I run the DPS algorithm against all the possible combinations of values with a max total cost of sixty points and six total cards. Since each card gets a bonus when completed with all three upgrades, those counted for extra:

maxDps :: Bool -> Bool -> Bool -> Integer -> String -> Integer -> Double -> Build maxDps w b cheapCrit lifeSteal hero_name reduce_by en_armor = let totalPoints = 66 -- counts the bonus +1 of the 6 cards when completed ward = if w then 1 else 0 blink = if b then 1 else 0 maxPen = if hero_name == "sparrow" then 0 else 30 points = totalPoints - lifeSteal - (3 * ward) - (6 * blink) - reduce_by totals = [ (calcIfUnder hero_name dmg speed crit pen critbonus points ward blink lifeSteal en_armor) | dmg <- [0..30], speed <- [0..30], crit <- [0..30], pen <- [0..maxPen], critbonus <- [0..1]] build = head $ sortBy (flip compare `on` _bdps) totals in bcheapCrit .~ cheapCrit $ build

The function calcIfUnder returns a completed Build if the total card point equaled 60, otherwise an empty Build .

From this, we can quickly calculate the highest possible DPS for any given character, as a Build of the exact power, speed, crit chance, armor pen, enemy armor, and crit bonus points needed.

Now that we know the best possible Build , the hard part is figuring out what cards and upgrades to buy. Using glpk-hs, I make a tuple of each card with the possible upgrades for a given stat:

-- For cost (e.g. base cost is 3) [("Whirling Wand - speed:1,power:5",9), ("Whirling Wand - speed:2,power:4",9), ("Whirling Wand - speed:3,power:3",9), ...] -- For power (e.g. base power is 1) [("Whirling Wand - speed:1,power:5",6), ("Whirling Wand - speed:2,power:4",5), ("Whirling Wand - speed:3,power:3",4), ...] -- For speed (e.g. base speed is 3) [("Whirling Wand - speed:1,power:5",4), ("Whirling Wand - speed:2,power:4",5), ("Whirling Wand - speed:3,power:3",6), ...]

This turns out to be roughly a few thousand cards+upgrades per stat. Since we only care about matching a stat exactly, we can use equalTo from glpk-hs:

lpCards :: Build -> LP String Integer lpCards build = execLPM $ do let hero = heroFromName $ build^.bhero let useCheapCrit = (build^.bcheapCrit) equalTo (linCombination (collectCostAndNameTuples hero _cost useCheapCrit)) totalCXP equalTo (linCombination (collectCostAndNameTuples hero _power useCheapCrit)) (build^.bpower) equalTo (linCombination (collectCostAndNameTuples hero _speed useCheapCrit)) (build^.bspeed) equalTo (linCombination (collectCostAndNameTuples hero _crit useCheapCrit)) (build^.bcrit) equalTo (linCombination (collectCostAndNameTuples hero _pen useCheapCrit)) (build^.bpen) equalTo (linCombination (collectCostAndNameTuples hero _lifesteal useCheapCrit)) (build^.blifesteal) equalTo (linCombination (collectCostAndNameTuples hero _crit_bonus useCheapCrit)) (build^.bcrit_bonus) equalTo (linCombination (collectCostAndNameTuples hero _ward useCheapCrit)) (build^.bward) equalTo (linCombination (collectCostAndNameTuples hero _blink useCheapCrit)) (build^.bblink) equalTo (linCombination (map (\(_,name) -> (1, name)) $ collectCostAndNameTuples hero _power useCheapCrit)) totalCards mapM (\(_,name) -> varBds name 0 1) $ collectCostAndNameTuples hero _power useCheapCrit mapM (\(_,name) -> setVarKind name IntVar) $ collectCostAndNameTuples hero _power useCheapCrit optimize :: Build -> IO [HandCard] optimize b = do x <- glpSolveVars mipDefaults (lpCards b) putStrLn $ "Build" ++ (show b) case x of (Success, Just (obj, vars)) -> let cards = (map toHandCard) $ filter (\(name, count) -> count /= 0) $ Map.toList vars in if null cards then solverFailed else return cards (failure, result) -> solverFailed

Running optimize from a scotty site gathers a solution for six card+upgrade tuples that match the desired ratio, and it is fast enough to run in under a second!

main :: IO () main = do scotty 3000 $ do middleware $ staticPolicy (noDots >-> addBase "static/html") middleware $ staticPolicy (noDots >-> addBase "static/dist") post "/dps" $ do s <- jsonData :: ActionM UISetting json $ DP.maxDps (has_ward s) (has_blink s) (cheap_crit s) (desired_lifesteal s) (hero_name s) 0 (enemy_armor s) post "/optimize" $ do build <- jsonData :: ActionM OP.Build r <- liftIO $ OP.optimize build json r

Sample output:

And there you have it, a solver for the best DPS cards to build for Paragon for any hero! Code available here.