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.