-
Notifications
You must be signed in to change notification settings - Fork 389
Expand file tree
/
Copy pathTradeQueryGenerator.lua
More file actions
1039 lines (934 loc) · 39.3 KB
/
Copy pathTradeQueryGenerator.lua
File metadata and controls
1039 lines (934 loc) · 39.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- Path of Building
--
-- Module: Trade Query Generator
-- Generates weighted trade queries for item upgrades
--
local dkjson = require "dkjson"
local curl = require("lcurl.safe")
local m_max = math.max
local s_format = string.format
local t_insert = table.insert
-- string are an any type while tables require all fields to be matched with type and subType require both to be matched exactly. [1] type, [2] subType, subType is optional and must be nil if not present.
local tradeCategoryNames = {
["Ring"] = { "Ring" },
["Amulet"] = { "Amulet" },
["Belt"] = { "Belt" },
["Chest"] = { "Body Armour", "Body Armour: Armour", "Body Armour: Armour/Energy Shield", "Body Armour: Armour/Evasion", "Body Armour: Armour/Evasion/Energy Shield", "Body Armour: Energy Shield", "Body Armour: Evasion", "Body Armour: Evasion/Energy Shield" },
["Helmet"] = { "Helmet", "Helmet: Armour", "Helmet: Armour/Energy Shield", "Helmet: Armour/Evasion", "Helmet: Armour/Evasion/Energy Shield", "Helmet: Energy Shield", "Helmet: Evasion", "Helmet: Evasion/Energy Shield" },
["Gloves"] = { "Gloves: Armour", "Gloves: Armour/Energy Shield", "Gloves: Armour/Evasion", "Gloves: Armour/Evasion/Energy Shield", "Gloves: Energy Shield", "Gloves: Evasion", "Gloves: Evasion/Energy Shield" },
["Boots"] = { "Boots", "Boots: Armour", "Boots: Armour/Energy Shield", "Boots: Armour/Evasion", "Boots: Armour/Evasion/Energy Shield", "Boots: Energy Shield", "Boots: Evasion", "Boots: Evasion/Energy Shield" },
["Quiver"] = { "Quiver" },
["Shield"] = { "Shield", "Shield: Armour", "Shield: Armour/Energy Shield", "Shield: Armour/Evasion", "Shield: Evasion" },
["Focus"] = { "Focus" },
["1HWeapon"] = { "One Handed Mace", "Wand", "Sceptre", "Flail", "Spear" },
["2HWeapon"] = { "Staff", "Staff: Warstaff", "Two Handed Mace", "Crossbow", "Bow" },
-- ["1HAxe"] = { "One Handed Axe" },
-- ["1HSword"] = { "One Handed Sword", "Thrusting One Handed Sword" },
["1HMace"] = { "One Handed Mace" },
["Sceptre"] = { "Sceptre" },
-- ["Dagger"] = { "Dagger" },
["Wand"] = { "Wand" },
-- ["Claw"] = { "Claw" },
["Staff"] = { "Staff" },
["Quarterstaff"] = { "Staff: Warstaff" },
["Bow"] = { "Bow" },
["Crossbow"] = { "Crossbow"},
-- ["2HAxe"] = { "Two Handed Axe" },
-- ["2HSword"] = { "Two Handed Sword" },
["2HMace"] = { "Two Handed Mace" },
-- ["FishingRod"] = { "Fishing Rod" },
["BaseJewel"] = { "Jewel" },
["AnyJewel"] = { "Jewel" },
["LifeFlask"] = { "Flask: Life" },
["ManaFlask"] = { "Flask: Mana" },
["Charm"] = { "Charm" },
-- doesn't have trade mods
-- ["RadiusJewel"] = { "Jewel: Radius" },
-- not in the game yet.
-- ["TrapTool"] = { "TrapTool"}, Unsure if correct
["Flail"] = { "Flail" },
["Spear"] = { "Spear" }
}
-- Build lists of tags present on a given item category
local tradeCategoryTags = { }
for type, bases in pairs(data.itemBaseLists) do
for _, base in ipairs(bases) do
if not base.hidden then
if not tradeCategoryTags[type] then
tradeCategoryTags[type] = { }
end
local baseTags = { }
for tag, _ in pairs(base.base.tags) do
if tag ~= "default" and tag ~= "demigods" and not tag:match("_basetype") and tag ~= "not_for_sale" then -- filter fluff tags not used on mods.
baseTags[tag] = true
end
end
local present = false
for i, tags in ipairs(tradeCategoryTags[type]) do
if tableDeepEquals(baseTags, tags) then
present = true
end
end
if not present then
t_insert(tradeCategoryTags[type], baseTags)
end
end
end
end
local tradeStatCategoryIndices = {
["Explicit"] = 1,
["Implicit"] = 2,
["Corrupted"] = 3,
["AllocatesXEnchant"] = 3,
["Rune"] = 4,
}
local MAX_FILTERS = 35
local function logToFile(...)
ConPrintf(...)
end
local TradeQueryGeneratorClass = newClass("TradeQueryGenerator", function(self, queryTab)
self:InitMods()
self.queryTab = queryTab
self.itemsTab = queryTab.itemsTab
self.calcContext = { }
self.lastMaxPrice = nil
self.lastMaxPriceTypeIndex = nil
self.lastMaxLevel = nil
end)
local function fetchStats()
local tradeStats = ""
local easy = common.curl.easy()
easy:setopt_url("https://www.pathofexile.com/api/trade2/data/stats")
easy:setopt_useragent("Path of Building/" .. launch.versionNumber)
easy:setopt_writefunction(function(data)
tradeStats = tradeStats..data
return true
end)
easy:perform()
easy:close()
return tradeStats
end
local function canModSpawnForItemCategory(mod, names)
for _, name in pairs(tradeCategoryNames[names]) do
for _, tags in ipairs(tradeCategoryTags[name]) do
for i, key in ipairs(mod.weightKey) do
if tags[key] then
if mod.weightVal[i] > 0 then
return true
else
break
end
end
end
end
end
return false
end
-- Swaps mod word for its antonym
local function swapInverse(modLine)
if modLine:match("increased") then
modLine = modLine:gsub("([^ ]+) increased", "%1 reduced")
elseif modLine:match("reduced") then
modLine = modLine:gsub("([^ ]+) reduced", "%1 increased")
elseif modLine:match("more") then
modLine = modLine:gsub("([^ ]+) more", "%1 less")
elseif modLine:match("less") then
modLine = modLine:gsub("([^ ]+) less", "%1 more")
elseif modLine:match("expires ([^ ]+) slower") then
modLine = modLine:gsub("([^ ]+) slower", "%1 faster")
elseif modLine:match("expires ([^ ]+) faster") then
modLine = modLine:gsub("([^ ]+) faster", "%1 slower")
end
return modLine
end
function TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, newOutput, statWeights)
local meanStatDiff = 0
local function ratioModSums(...)
local baseModSum = 0
local newModSum = 0
for _, mod in ipairs({ ... }) do
baseModSum = baseModSum + (baseOutput[mod] or 0)
newModSum = newModSum + (newOutput[mod] or 0)
end
if baseModSum == math.huge then
return 0
else
if newModSum == math.huge then
return data.misc.maxStatIncrease
else
return math.min(newModSum / ((baseModSum ~= 0) and baseModSum or 1), data.misc.maxStatIncrease)
end
end
end
for _, statTable in ipairs(statWeights) do
if statTable.stat == "FullDPS" and not (baseOutput["FullDPS"] and newOutput["FullDPS"]) then
meanStatDiff = meanStatDiff + ratioModSums("TotalDPS", "TotalDotDPS", "CombinedDPS") * statTable.weightMult
end
meanStatDiff = meanStatDiff + ratioModSums(statTable.stat) * statTable.weightMult
end
return meanStatDiff
end
function TradeQueryGeneratorClass:ProcessMod(mod, tradeQueryStatsParsed, itemCategoriesMask, itemCategoriesOverride)
if mod.statOrder == nil then mod.statOrder = { } end
if mod.group == nil then mod.group = "" end
for index, modLine in ipairs(mod) do
if modLine:find("Grants Level") or modLine:find("inflict Decay") then -- skip mods that grant skills / decay, as they will often be overwhelmingly powerful but don't actually fit into the build
goto nextModLine
end
local modType = (mod.type == "Prefix" or mod.type == "Suffix") and "Explicit" or mod.type == "SpecialCorrupted" and "Corrupted" or mod.type
-- Special cases
local specialCaseData = { }
if modLine == "You can apply an additional Curse" then
specialCaseData.overrideModLineSingular = "You can apply an additional Curse"
modLine = "You can apply 1 additional Curses"
elseif modLine == "Bow Attacks fire an additional Arrow" then
specialCaseData.overrideModLineSingular = "Bow Attacks fire an additional Arrow"
modLine = "Bow Attacks fire 1 additional Arrows"
elseif modLine:find("Charm Slots") then
specialCaseData.overrideModLinePlural = "+# Charm Slots"
modLine = modLine:gsub("Slots", "Slot")
end
-- If this is the first tier for this mod, find matching trade mod and init the entry
if not self.modData[modType] then
logToFile("Unhandled Mod Type: %s", modType)
goto continue
end
-- iterate trade mod category to find mod with matching text.
local function getTradeMod()
-- try matching to global mods.
local matchStr = modLine:gsub("[#()0-9%-%+%.]","")
for _, entry in ipairs(tradeQueryStatsParsed.result[tradeStatCategoryIndices[modType]].entries) do
if entry.text:gsub("[#()0-9%-%+%.]","") == matchStr then
return entry
end
end
-- check reverse
matchStr = swapInverse(matchStr)
for _, entry in ipairs(tradeQueryStatsParsed.result[tradeStatCategoryIndices[modType]].entries) do
if entry.text:gsub("[#()0-9%-%+%.]","") == matchStr then
return entry, true
end
end
return nil
end
local tradeMod = nil
local invert
if mod.statOrder[index] == nil then -- if there isn't a mod order we have to use the trade id instead e.g. implicits.
tradeMod, invert = getTradeMod()
if tradeMod == nil then
logToFile("Unable to match %s mod: %s", modType, modLine)
goto nextModLine
end
mod.statOrder[index] = tradeMod.id
end
local statOrder = modLine:find("Nearby Enemies have %-") ~= nil and mod.statOrder[index + 1] or mod.statOrder[index] -- hack to get minus res mods associated with the correct statOrder
local uniqueIndex = mod.group ~= "" and tostring(statOrder).."_"..mod.group or tostring(statOrder)
if self.modData[modType][uniqueIndex] == nil then
if tradeMod == nil then
tradeMod, invert = getTradeMod()
end
if tradeMod == nil then
logToFile("Unable to match %s mod: %s", modType, modLine)
goto nextModLine
end
self.modData[modType][uniqueIndex] = { tradeMod = tradeMod, specialCaseData = { } }
elseif self.modData[modType][uniqueIndex].tradeMod.text:gsub("[#()0-9%-%+%.]","") == swapInverse(modLine):gsub("[#()0-9%-%+%.]","") and swapInverse(modLine) ~= modLine then -- if the swapped mod matches the inverse then consider it inverted, provide it changed.
invert = true
end
-- this is safe as we go to next line if the mod can't be found.
for key, value in pairs(specialCaseData) do
self.modData[modType][uniqueIndex].specialCaseData[key] = value
end
if invert then
self.modData[modType][uniqueIndex].invertOnNegative = true
modLine = swapInverse(modLine)
end
-- tokenize the numerical variables for this mod and store the sign if there is one
local tokens = { }
local poundStartPos, poundEndPos, tokenizeOffset = 0, 0, 0
while true do
poundStartPos, poundEndPos = self.modData[modType][uniqueIndex].tradeMod.text:find("[%+%-]?#", poundEndPos + 1)
if poundStartPos == nil then
break
end
local startPos, endPos, sign, min, max = modLine:find("([%+%-]?)%(?(%d+%.?%d*)%-?(%d*%.?%d*)%)?", poundStartPos + tokenizeOffset)
if endPos == nil then
logToFile("[GMD] Error extracting tokens from '%s' for tradeMod '%s'", modLine, self.modData[modType][uniqueIndex].tradeMod.text)
goto nextModLine
end
max = #max > 0 and tonumber(max) or tonumber(min)
tokenizeOffset = tokenizeOffset + (endPos - startPos)
-- the values are negative record its ranges as such.
if (invert or sign == "-") and not (invert and sign == "-") then
local temp = max
max = -min
min = -temp
end
if sign == "+" then self.modData[modType][uniqueIndex].usePositiveSign = true end
t_insert(tokens, min)
t_insert(tokens, max)
end
if #tokens ~= 0 and #tokens ~= 2 and #tokens ~= 4 then
logToFile("Unexpected # of tokens found for mod: %s", mod[index])
goto nextModLine
end
-- Update the min and max values available for each item category
for category, _ in pairs(itemCategoriesOverride or itemCategoriesMask or tradeCategoryNames) do
if itemCategoriesOverride or canModSpawnForItemCategory(mod, category) then
if self.modData[modType][uniqueIndex][category] == nil then
self.modData[modType][uniqueIndex][category] = { min = 999999, max = -999999 }
end
local modRange = self.modData[modType][uniqueIndex][category]
if #tokens == 0 then
modRange.min = 1
modRange.max = 1
elseif #tokens == 2 then
modRange.min = math.min(modRange.min, tokens[1])
modRange.max = math.max(modRange.max, tokens[2])
elseif #tokens == 4 then
modRange.min = math.min(modRange.min, (tokens[1] + tokens[3]) / 2)
modRange.max = math.max(modRange.max, (tokens[2] + tokens[4]) / 2)
end
end
end
::nextModLine::
end
::continue::
end
function TradeQueryGeneratorClass:GenerateModData(mods, tradeQueryStatsParsed, itemCategoriesMask, itemCategoriesOverride)
for _, mod in pairs(mods) do
self:ProcessMod( mod, tradeQueryStatsParsed, itemCategoriesMask, itemCategoriesOverride)
end
end
function TradeQueryGeneratorClass:InitMods()
local queryModFilePath = "Data/QueryMods.lua"
local file = io.open(queryModFilePath,"r")
if file then
file:close()
self.modData = LoadModule(queryModFilePath)
return
end
self.modData = {
["Explicit"] = { },
["Implicit"] = { },
["Enchant"] = { },
["AllocatesXEnchant"] = { },
["Corrupted"] = { },
["Rune"] = { },
}
-- originates from: https://www.pathofexile.com/api/trade2/data/stats
local tradeStats = fetchStats()
tradeStats:gsub("\n", " ")
local tradeQueryStatsParsed = dkjson.decode(tradeStats)
for _, modDomain in ipairs(tradeQueryStatsParsed.result) do
for _, mod in ipairs(modDomain.entries) do
mod.text = escapeGGGString(mod.text)
end
end
-- create mask for regular mods
local regularItemMask = { }
for category, _ in pairs(tradeCategoryNames) do
regularItemMask[category] = true
end
self:GenerateModData(data.itemMods.Item, tradeQueryStatsParsed, regularItemMask)
self:GenerateModData(data.itemMods.Corruption, tradeQueryStatsParsed, regularItemMask)
self:GenerateModData(data.itemMods.Jewel, tradeQueryStatsParsed, { ["BaseJewel"] = true, ["AnyJewel"] = true })
self:GenerateModData(data.itemMods.Flask, tradeQueryStatsParsed, { ["LifeFlask"] = true, ["ManaFlask"] = true })
self:GenerateModData(data.itemMods.Charm, tradeQueryStatsParsed, { ["Charm"] = true })
for _, entry in ipairs(tradeQueryStatsParsed.result[tradeStatCategoryIndices.AllocatesXEnchant].entries) do
if entry.text:sub(1, 10) == "Allocates " then
-- The trade id for allocatesX enchants end with "|[nodeID]" for the allocated node.
local nodeId = entry.id:sub(entry.id:find("|") + 1)
self.modData.AllocatesXEnchant[nodeId] = { tradeMod = entry, specialCaseData = { } }
end
end
-- implicit mods
for baseName, entry in pairs(data.itemBases) do
if entry.implicit ~= nil then
local mod = { type = "Implicit" }
for modLine in string.gmatch(entry.implicit, "([^".."\n".."]+)") do
t_insert(mod, modLine)
end
-- create trade type mask for base type
local maskOverride = {}
for tradeName, typeNames in pairs(tradeCategoryNames) do
for _, typeName in ipairs(typeNames) do
local entryName = entry.type
if entry.subType then
entryName = entryName..": "..entry.subType
end
if typeName == entryName then
maskOverride[tradeName] = true;
break
end
end
end
-- mask found process implicit mod this avoids processing unimplemented bases i.e. two handed axes.
if next(maskOverride) ~= nil then
self:ProcessMod(mod, tradeQueryStatsParsed, regularItemMask, maskOverride)
end
end
end
-- rune mods
for name, runeMods in pairs(data.itemMods.Runes) do
for slotType, mods in pairs(runeMods) do
if slotType == "weapon" then
self:ProcessMod(mods, tradeQueryStatsParsed, regularItemMask, { ["1HWeapon"] = true, ["2HWeapon"] = true, ["1HMace"] = true, ["Claw"] = true, ["Quarterstaff"] = true, ["Bow"] = true, ["2HMace"] = true, ["Crossbow"] = true, ["Spear"] = true, ["Flail"] = true })
elseif slotType == "armour" then
self:ProcessMod(mods, tradeQueryStatsParsed, regularItemMask, { ["Shield"] = true, ["Chest"] = true, ["Helmet"] = true, ["Gloves"] = true, ["Boots"] = true, ["Focus"] = true })
elseif slotType == "caster" then
self:ProcessMod(mods, tradeQueryStatsParsed, regularItemMask, { ["Wand"] = true, ["Staff"] = true })
else
-- Mod is slot specific, try to match against a value in tradeCategoryNames
local matchedCategory = nil
for category, categoryOptions in pairs(tradeCategoryNames) do
for i, opt in pairs(categoryOptions) do
if opt:lower():match(slotType) then
matchedCategory = category
break
end
end
if matchedCategory then
break
end
end
if matchedCategory then
self:ProcessMod(mods, tradeQueryStatsParsed, regularItemMask, { [matchedCategory] = true })
else
ConPrintf("TradeQuery: Unmatched category for modifier. Slot type: %s Modifier: %s", mods.slotType, mods.name)
end
end
end
end
local queryModsFile = io.open(queryModFilePath, 'w')
queryModsFile:write("-- This file is automatically generated, do not edit!\n-- Stat data (c) Grinding Gear Games\n\n")
queryModsFile:write("return " .. stringify(self.modData))
queryModsFile:close()
end
function TradeQueryGeneratorClass:GenerateModWeights(modsToTest)
local start = GetTime()
for _, entry in pairs(modsToTest) do
if entry[self.calcContext.itemCategory] ~= nil then
if self.alreadyWeightedMods[entry.tradeMod.id] ~= nil then -- Don't calculate the same thing twice (can happen with corrupted vs implicit)
goto continue
end
-- Test with a value halfway (or configured default Item Affix Quality) between the min and max available for this mod in this slot. Note that this can generate slightly different values for the same mod as implicit vs explicit.
local tradeModValue = math.ceil((entry[self.calcContext.itemCategory].max - entry[self.calcContext.itemCategory].min) * ( main.defaultItemAffixQuality or 0.5 ) + entry[self.calcContext.itemCategory].min)
local modValue = tradeModValue
-- Apply override text for special cases
local modLine
if (modValue == 1 or modValue == -1) and entry.specialCaseData.overrideModLineSingular ~= nil then
modLine = entry.specialCaseData.overrideModLineSingular
elseif (modValue ~= 1 and modValue ~= -1) and entry.specialCaseData.overrideModLinePlural ~= nil then
modLine = entry.specialCaseData.overrideModLinePlural
elseif entry.specialCaseData.overrideModLine ~= nil then
modLine = entry.specialCaseData.overrideModLine
else
modLine = entry.tradeMod.text
end
if entry.invertOnNegative and modValue < 0 then
modLine = swapInverse(modLine)
modValue = -1 * modValue
end
-- trade mod dictates a plus is used in front of positive values.
if modLine:find("+#") and modValue >= 0 then
modLine = modLine:gsub("#", modValue)
else
if entry.usePositiveSign and modValue >= 0 then
modLine = modLine:gsub("#", "+"..tostring(modValue))
else
modLine = modLine:gsub("+?#", modValue)
end
end
self.calcContext.testItem.explicitModLines[1] = { line = modLine, custom = true }
self.calcContext.testItem:BuildAndParseRaw()
if (self.calcContext.testItem.modList ~= nil and #self.calcContext.testItem.modList == 0) or (self.calcContext.testItem.slotModList ~= nil and #self.calcContext.testItem.slotModList[1] == 0 and #self.calcContext.testItem.slotModList[2] == 0) then
logToFile("Failed to test %s mod: %s", self.calcContext.itemCategory, modLine)
end
local output = self.calcContext.calcFunc({ repSlotName = self.calcContext.slot.slotName, repItem = self.calcContext.testItem })
local meanStatDiff = TradeQueryGeneratorClass.WeightedRatioOutputs(self.calcContext.baseOutput, output, self.calcContext.options.statWeights) * 1000 - (self.calcContext.baseStatValue or 0)
if meanStatDiff > 0.01 then
t_insert(self.modWeights, { tradeModId = entry.tradeMod.id, weight = meanStatDiff / tradeModValue, meanStatDiff = meanStatDiff })
end
self.alreadyWeightedMods[entry.tradeMod.id] = true
local now = GetTime()
if now - start > 50 then
-- Would be nice to update x/y progress on the popup here, but getting y ahead of time has a cost, and the visual seems to update on a significant delay anyways so it's not very useful
coroutine.yield()
start = now
end
end
::continue::
end
end
function TradeQueryGeneratorClass:GeneratePassiveNodeWeights(nodesToTest)
local start = GetTime()
for nodeId, entry in pairs(nodesToTest) do
if self.alreadyWeightedMods[entry.tradeMod.id] ~= nil then
ConPrintf("Node %s already evaluated", nodeId)
goto continue
end
local node = self.itemsTab.build.spec.nodes[tonumber(nodeId)]
if not node then
local nodeName = entry.tradeMod.text:match("1 Added Passive Skill is (.*)") or entry.tradeMod.text:match("Allocates (.*)")
node = nodeName and self.itemsTab.build.spec.tree.notableMap[nodeName:lower()]
if not node then
ConPrintf("Failed to find node %s", nodeId)
goto continue
end
end
local baseOutput = self.calcContext.baseOutput
local output = self.calcContext.calcFunc({ addNodes = { [node] = true } })
local meanStatDiff = TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, output, self.calcContext.options.statWeights) * 1000 - (self.calcContext.baseStatValue or 0)
if meanStatDiff > 0.01 then
t_insert(self.modWeights, { tradeModId = entry.tradeMod.id, weight = meanStatDiff, meanStatDiff = meanStatDiff, invert = false })
end
self.alreadyWeightedMods[entry.tradeMod.id] = true
local now = GetTime()
if now - start > 50 then
-- Would be nice to update x/y progress on the popup here, but getting y ahead of time has a cost, and the visual seems to update on a significant delay anyways so it's not very useful
coroutine.yield()
start = now
end
::continue::
end
end
function TradeQueryGeneratorClass:OnFrame()
if self.calcContext.co == nil then
return
end
local res, errMsg = coroutine.resume(self.calcContext.co, self)
if launch.devMode and not res then
error(errMsg)
end
if coroutine.status(self.calcContext.co) == "dead" then
self.calcContext.co = nil
self:FinishQuery()
end
end
local currencyTable = {
{ name = "Relative", id = nil },
{ name = "Exalted Orb", id = "exalted" },
{ name = "Chaos Orb", id = "chaos" },
{ name = "Divine Orb", id = "divine" },
{ name = "Orb of Augmentation", id = "aug" },
{ name = "Orb of Transmutation", id = "transmute" },
{ name = "Regal Orb", id = "regal" },
{ name = "Vaal Orb", id = "vaal" },
{ name = "Annulment Orb", id = "annul" },
{ name = "Orb of Alchemy", id = "alch" },
{ name = "Mirror of Kalandra", id = "mirror" }
}
function TradeQueryGeneratorClass:StartQuery(slot, options)
if self.lastMaxPrice then
options.maxPrice = self.lastMaxPrice
end
if self.lastMaxPriceTypeIndex then
options.maxPriceType = currencyTable[self.lastMaxPriceTypeIndex].id
end
if self.lastMaxLevel then
options.maxLevel = self.lastMaxLevel
end
-- Figure out what type of item we're searching for
local existingItem = slot and self.itemsTab.items[slot.selItemId]
local testItemType = existingItem and existingItem.baseName or "Diamond"
local itemCategoryQueryStr
local itemCategory
local special = { }
if options.special then
if options.special.itemName == "Megalomaniac" then
special = {
queryFilters = {},
queryExtra = {
name = "Megalomaniac",
type = "Diamond"
},
calcNodesInsteadOfMods = true,
}
end
elseif slot.slotName:find("^Weapon %d") then
if existingItem then
if existingItem.type == "Shield" then
itemCategoryQueryStr = "armour.shield"
itemCategory = "Shield"
elseif existingItem.type == "Focus" then
itemCategoryQueryStr = "armour.focus"
itemCategory = "Focus"
elseif existingItem.type == "Buckler" then
itemCategoryQueryStr = "armour.buckler"
itemCategory = "Buckler"
elseif existingItem.type == "Quiver" then
itemCategoryQueryStr = "armour.quiver"
itemCategory = "Quiver"
elseif existingItem.type == "Bow" then
itemCategoryQueryStr = "weapon.bow"
itemCategory = "Bow"
elseif existingItem.type == "Crossbow" then
itemCategoryQueryStr = "weapon.crossbow"
itemCategory = "Crossbow"
elseif existingItem.type == "Staff" and existingItem.base.subType == "Warstaff" then
itemCategoryQueryStr = "weapon.warstaff"
itemCategory = "Quarterstaff"
elseif existingItem.type == "Staff" then
itemCategoryQueryStr = "weapon.staff"
itemCategory = "Staff"
elseif existingItem.type == "Two Handed Sword" then
itemCategoryQueryStr = "weapon.twosword"
itemCategory = "2HSword"
elseif existingItem.type == "Two Handed Axe" then
itemCategoryQueryStr = "weapon.twoaxe"
itemCategory = "2HAxe"
elseif existingItem.type == "Two Handed Mace" then
itemCategoryQueryStr = "weapon.twomace"
itemCategory = "2HMace"
elseif existingItem.type == "Fishing Rod" then
itemCategoryQueryStr = "weapon.rod"
itemCategory = "FishingRod"
elseif existingItem.type == "One Handed Sword" then
itemCategoryQueryStr = "weapon.onesword"
itemCategory = "1HSword"
elseif existingItem.type == "Spear" then
itemCategoryQueryStr = "weapon.spear"
itemCategory = "Spear"
elseif existingItem.type == "Flail" then
itemCategoryQueryStr = "weapon.flail"
itemCategory = "weapon.flail"
elseif existingItem.type == "One Handed Axe" then
itemCategoryQueryStr = "weapon.oneaxe"
itemCategory = "1HAxe"
elseif existingItem.type == "One Handed Mace" then
itemCategoryQueryStr = "weapon.onemace"
itemCategory = "1HMace"
elseif existingItem.type == "Sceptre" then
itemCategoryQueryStr = "weapon.sceptre"
itemCategory = "Sceptre"
elseif existingItem.type == "Wand" then
itemCategoryQueryStr = "weapon.wand"
itemCategory = "Wand"
elseif existingItem.type == "Dagger" then
itemCategoryQueryStr = "weapon.dagger"
itemCategory = "Dagger"
elseif existingItem.type == "Claw" then
itemCategoryQueryStr = "weapon.claw"
itemCategory = "Claw"
elseif existingItem.type:find("Two Handed") ~= nil then
itemCategoryQueryStr = "weapon.twomelee"
itemCategory = "2HWeapon"
elseif existingItem.type:find("One Handed") ~= nil then
itemCategoryQueryStr = "weapon.one"
itemCategory = "1HWeapon"
else
logToFile("'%s' is not supported for weighted trade query generation", existingItem.type)
return
end
else
-- Item does not exist in this slot so assume 1H weapon
itemCategoryQueryStr = "weapon.one"
itemCategory = "1HWeapon"
end
elseif slot.slotName == "Body Armour" then
itemCategoryQueryStr = "armour.chest"
itemCategory = "Chest"
elseif slot.slotName == "Helmet" then
itemCategoryQueryStr = "armour.helmet"
itemCategory = "Helmet"
elseif slot.slotName == "Gloves" then
itemCategoryQueryStr = "armour.gloves"
itemCategory = "Gloves"
elseif slot.slotName == "Boots" then
itemCategoryQueryStr = "armour.boots"
itemCategory = "Boots"
elseif slot.slotName == "Amulet" then
itemCategoryQueryStr = "accessory.amulet"
itemCategory = "Amulet"
elseif slot.slotName == "Ring 1" or slot.slotName == "Ring 2" or slot.slotName == "Ring 3" then
itemCategoryQueryStr = "accessory.ring"
itemCategory = "Ring"
elseif slot.slotName == "Belt" then
itemCategoryQueryStr = "accessory.belt"
itemCategory = "Belt"
elseif slot.slotName:find("Time-Lost") ~= nil then
itemCategoryQueryStr = "jewel"
itemCategory = "RadiusJewel"
elseif slot.slotName:find("Jewel") ~= nil then
itemCategoryQueryStr = "jewel"
itemCategory = options.jewelType .. "Jewel"
-- not present on trade site
-- if itemCategory == "RadiusJewel" then
-- itemCategoryQueryStr = "jewel.radius"
-- elseif itemCategory == "BaseJewel" then
-- itemCategoryQueryStr = "jewel.base"
-- end
elseif slot.slotName:find("Flask 1") ~= nil then
itemCategoryQueryStr = "flask.life"
itemCategory = "Life Flask"
elseif slot.slotName:find("Flask 2") ~= nil then
itemCategoryQueryStr = "flask.mana"
itemCategory = "Mana Flask"
elseif slot.slotName:find("Charm") ~= nil then
itemCategoryQueryStr = "flask" -- these don't have a unique string so overlapping mods of the same benefit could interfere.
itemCategory = "Charm"
else
logToFile("'%s' is not supported for weighted trade query generation", existingItem and existingItem.type or "n/a")
return
end
-- Create a temp item for the slot with no mods
local itemRawStr = "Rarity: RARE\nStat Tester\n" .. testItemType
local testItem = new("Item", itemRawStr)
-- Calculate base output with a blank item
local calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator()
local baseItemOutput = slot and calcFunc({ repSlotName = slot.slotName, repItem = testItem }) or baseOutput
-- make weights more human readable
local compStatValue = TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, baseItemOutput, options.statWeights) * 1000
-- Test each mod one at a time and cache the normalized Stat (configured earlier) diff to use as weight
self.modWeights = { }
self.alreadyWeightedMods = { }
self.calcContext = {
itemCategoryQueryStr = itemCategoryQueryStr,
itemCategory = itemCategory,
special = special,
testItem = testItem,
baseOutput = baseOutput,
baseStatValue = compStatValue,
calcFunc = calcFunc,
options = options,
slot = slot,
}
-- OnFrame will pick this up and begin the work
self.calcContext.co = coroutine.create(self.ExecuteQuery)
-- Open progress tracking blocker popup
local controls = { }
controls.progressText = new("LabelControl", {"TOP",nil,"TOP"}, {0, 30, 0, 16}, string.format("Calculating Mod Weights..."))
self.calcContext.popup = main:OpenPopup(280, 65, "Please Wait", controls)
end
function TradeQueryGeneratorClass:ExecuteQuery()
if self.calcContext.special.calcNodesInsteadOfMods then
self:GeneratePassiveNodeWeights(self.modData.AllocatesXEnchant)
return
end
self:GenerateModWeights(self.modData["Explicit"])
self:GenerateModWeights(self.modData["Implicit"])
if self.calcContext.options.includeCorrupted then
self:GenerateModWeights(self.modData["Corrupted"])
end
if self.calcContext.options.includeRunes then
self:GenerateModWeights(self.modData["Rune"])
end
end
function TradeQueryGeneratorClass:FinishQuery()
-- Calc original item Stats without anoint or enchant, and use that diff as a basis for default min sum.
local originalItem = self.calcContext.slot and self.itemsTab.items[self.calcContext.slot.selItemId]
self.calcContext.testItem.explicitModLines = { }
if originalItem then
for _, modLine in ipairs(originalItem.explicitModLines) do
t_insert(self.calcContext.testItem.explicitModLines, modLine)
end
for _, modLine in ipairs(originalItem.implicitModLines) do
t_insert(self.calcContext.testItem.explicitModLines, modLine)
end
end
self.calcContext.testItem:BuildAndParseRaw()
local originalOutput = originalItem and self.calcContext.calcFunc({ repSlotName = self.calcContext.slot.slotName, repItem = self.calcContext.testItem }) or self.calcContext.baseOutput
local currentStatDiff = TradeQueryGeneratorClass.WeightedRatioOutputs(self.calcContext.baseOutput, originalOutput, self.calcContext.options.statWeights) * 1000 - (self.calcContext.baseStatValue or 0)
-- Sort by mean Stat diff rather than weight to more accurately prioritize stats that can contribute more
table.sort(self.modWeights, function(a, b)
return a.meanStatDiff > b.meanStatDiff
end)
-- A megalomaniac is not being compared to anything and the currentStatDiff will be 0, so just go for an arbitrary min weight - in this case triple the weight of the worst evaluated node.
local megalomaniacSpecialMinWeight = self.calcContext.special.itemName == "Megalomaniac" and self.modWeights[#self.modWeights] * 3
-- This Stat diff value will generally be higher than the weighted sum of the same item, because the stats are all applied at once and can thus multiply off each other.
-- So apply a modifier to get a reasonable min and hopefully approximate that the query will start out with small upgrades.
local minWeight = megalomaniacSpecialMinWeight or currentStatDiff * 0.5
-- Generate trade query str and open in browser
local filters = 0
local queryTable = {
query = {
filters = self.calcContext.special.queryFilters or {
type_filters = {
filters = {
category = { option = self.calcContext.itemCategoryQueryStr },
rarity = { option = "nonunique" }
}
}
},
status = { option = "online" },
stats = {
{
type = "weight",
value = { min = minWeight },
filters = { }
}
}
},
sort = { ["statgroup.0"] = "desc" },
engine = "new"
}
for k, v in pairs(self.calcContext.special.queryExtra or {}) do
queryTable.query[k] = v
end
local options = self.calcContext.options
for _, entry in pairs(self.modWeights) do
t_insert(queryTable.query.stats[1].filters, { id = entry.tradeModId, value = { weight = (entry.invert == true and entry.weight * -1 or entry.weight) } })
filters = filters + 1
if filters == MAX_FILTERS then
break
end
end
if not options.includeMirrored then
queryTable.query.filters.misc_filters = {
disabled = false,
filters = {
mirrored = false,
}
}
end
if options.maxPrice and options.maxPrice > 0 then
queryTable.query.filters.trade_filters = {
filters = {
price = {
option = options.maxPriceType,
max = options.maxPrice
}
}
}
end
if options.maxLevel and options.maxLevel > 0 then
queryTable.query.filters.req_filters = {
disabled = false,
filters = {
lvl = {
max = options.maxLevel
}
}
}
end
if options.sockets and options.sockets > 0 then
queryTable.query.filters.equipment_filters = {
disabled = false,
filters = {
rune_sockets = {
min = options.sockets
}
}
}
end
local errMsg = nil
if #queryTable.query.stats[1].filters == 0 then
-- No mods to filter
errMsg = "Could not generate search, found no mods to search for"
end
local queryJson = dkjson.encode(queryTable)
self.requesterCallback(self.requesterContext, queryJson, errMsg)
-- Close blocker popup
main:ClosePopup()
end
function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callback)
self.requesterCallback = callback
self.requesterContext = context
local controls = { }
local options = { }
local popupHeight = 110
local isJewelSlot = slot and slot.slotName:find("Jewel") ~= nil
controls.includeCorrupted = new("CheckBoxControl", {"TOP",nil,"TOP"}, {-40, 30, 18}, "Corrupted Mods:", function(state) end)
controls.includeCorrupted.state = not context.slotTbl.alreadyCorrupted and (self.lastIncludeCorrupted == nil or self.lastIncludeCorrupted == true)
controls.includeCorrupted.enabled = not context.slotTbl.alreadyCorrupted
local canHaveRunes = slot and (slot.slotName:find("Weapon 1") or slot.slotName:find("Weapon 2") or slot.slotName:find("Helmet") or slot.slotName:find("Body Armour") or slot.slotName:find("Gloves") or slot.slotName:find("Boots"))
controls.includeRunes = new("CheckBoxControl", {"TOPRIGHT",controls.includeCorrupted,"BOTTOMRIGHT"}, {0, 5, 18}, "Rune Mods:", function(state) end)
controls.includeRunes.state = canHaveRunes and (self.lastIncludeRunes == nil or self.lastIncludeRunes == true)
controls.includeRunes.enabled = canHaveRunes
local lastItemAnchor = controls.includeRunes
local function updateLastAnchor(anchor, height)
lastItemAnchor = anchor
popupHeight = popupHeight + (height or 23)
end
if context.slotTbl.unique then
options.special = { itemName = context.slotTbl.slotName }
end
controls.includeMirrored = new("CheckBoxControl", {"TOPRIGHT",lastItemAnchor,"BOTTOMRIGHT"}, {0, 5, 18}, "Mirrored items:", function(state) end)
controls.includeMirrored.state = (self.lastIncludeMirrored == nil or self.lastIncludeMirrored == true)
updateLastAnchor(controls.includeMirrored)
if isJewelSlot then
controls.jewelType = new("DropDownControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 100, 18}, { "Any", "Base", "Radius" }, function(index, value) end) -- this does nothing atm
controls.jewelType.selIndex = self.lastJewelType or 1
controls.jewelTypeLabel = new("LabelControl", {"RIGHT",controls.jewelType,"LEFT"}, {-5, 0, 0, 16}, "Jewel Type:")
updateLastAnchor(controls.jewelType)
end
-- Add max price limit selection dropbox
local currencyDropdownNames = { }
for _, currency in ipairs(currencyTable) do
t_insert(currencyDropdownNames, currency.name)
end
controls.maxPrice = new("EditControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 70, 18}, nil, nil, "%D")
controls.maxPrice.buf = self.lastMaxPrice and tostring(self.lastMaxPrice) or ""
controls.maxPriceType = new("DropDownControl", {"LEFT",controls.maxPrice,"RIGHT"}, {5, 0, 150, 18}, currencyDropdownNames, nil)
controls.maxPriceType.selIndex = self.lastMaxPriceTypeIndex or 1
controls.maxPriceLabel = new("LabelControl", {"RIGHT",controls.maxPrice,"LEFT"}, {-5, 0, 0, 16}, "^7Max Price:")
updateLastAnchor(controls.maxPrice)
controls.maxLevel = new("EditControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 100, 18}, nil, nil, "%D")
controls.maxLevel.buf = self.lastMaxLevel and tostring(self.lastMaxLevel) or ""
controls.maxLevelLabel = new("LabelControl", {"RIGHT",controls.maxLevel,"LEFT"}, {-5, 0, 0, 16}, "Max Level:")
updateLastAnchor(controls.maxLevel)
-- basic filtering by slot for sockets Megalomaniac does not have slot and Sockets use "Jewel nodeId"
if slot and not isJewelSlot and not slot.slotName:find("Flask") and not slot.slotName:find("Belt") and not slot.slotName:find("Ring") and not slot.slotName:find("Amulet") and not slot.slotName:find("Charm") then
controls.sockets = new("EditControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 70, 18}, nil, nil, "%D")
controls.socketsLabel = new("LabelControl", {"RIGHT",controls.sockets,"LEFT"}, {-5, 0, 0, 16}, "# of Empty Sockets:")
updateLastAnchor(controls.sockets)
end
for i, stat in ipairs(statWeights) do
controls["sortStatType"..tostring(i)] = new("LabelControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, i == 1 and 5 or 3, 70, 16}, i < (#statWeights < 6 and 10 or 5) and s_format("^7%.2f: %s", stat.weightMult, stat.label) or ("+ "..tostring(#statWeights - 4).." Additional Stats"))
lastItemAnchor = controls["sortStatType"..tostring(i)]
popupHeight = popupHeight + 19
if i == 1 then
controls.sortStatLabel = new("LabelControl", {"RIGHT",lastItemAnchor,"LEFT"}, {-5, 0, 0, 16}, "^7Stat to Sort By:")
elseif i == 5 then
-- tooltips do not actually work for labels
lastItemAnchor.tooltipFunc = function(tooltip)
tooltip:Clear()
tooltip:AddLine(16, "Sorts the weights by the stats selected multiplied by a value")
tooltip:AddLine(16, "Currently sorting by:")
for i, stat in ipairs(statWeights) do
if i > 4 then
tooltip:AddLine(16, s_format("%s: %.2f", stat.label, stat.weightMult))
end
end
end
break
end
end
popupHeight = popupHeight + 4