|
| 1 | +-- Path of Building |
| 2 | +-- |
| 3 | +-- Module: Compare Entry |
| 4 | +-- Lightweight Build wrapper for comparison. Loads XML, creates tabs, and runs calculations |
| 5 | +-- without setting up the full UI chrome of the primary build. |
| 6 | +-- |
| 7 | +local t_insert = table.insert |
| 8 | +local m_min = math.min |
| 9 | +local m_max = math.max |
| 10 | + |
| 11 | +local CompareEntryClass = newClass("CompareEntry", "ControlHost", function(self, xmlText, label) |
| 12 | + self.ControlHost() |
| 13 | + |
| 14 | + self.label = label or "Comparison Build" |
| 15 | + self.buildName = label or "Comparison Build" |
| 16 | + self.xmlText = xmlText |
| 17 | + |
| 18 | + -- Default build properties (mirrors Build.lua:Init lines 72-82) |
| 19 | + self.viewMode = "TREE" |
| 20 | + self.characterLevel = m_min(m_max(main.defaultCharLevel or 1, 1), 100) |
| 21 | + self.targetVersion = liveTargetVersion |
| 22 | + self.bandit = "None" |
| 23 | + self.pantheonMajorGod = "None" |
| 24 | + self.pantheonMinorGod = "None" |
| 25 | + self.characterLevelAutoMode = main.defaultCharLevel == 1 or main.defaultCharLevel == nil |
| 26 | + self.mainSocketGroup = 1 |
| 27 | + |
| 28 | + self.spectreList = {} |
| 29 | + self.timelessData = { |
| 30 | + jewelType = {}, conquerorType = {}, |
| 31 | + devotionVariant1 = 1, devotionVariant2 = 1, |
| 32 | + jewelSocket = {}, fallbackWeightMode = {}, |
| 33 | + searchList = "", searchListFallback = "", |
| 34 | + searchResults = {}, sharedResults = {} |
| 35 | + } |
| 36 | + |
| 37 | + -- Shared data (read-only references) |
| 38 | + self.latestTree = main.tree[latestTreeVersion] |
| 39 | + self.data = data |
| 40 | + |
| 41 | + -- Flags |
| 42 | + self.modFlag = false |
| 43 | + self.buildFlag = false |
| 44 | + self.outputRevision = 1 |
| 45 | + |
| 46 | + -- Display stats (same as primary build uses) |
| 47 | + self.displayStats, self.minionDisplayStats, self.extraSaveStats = LoadModule("Modules/BuildDisplayStats") |
| 48 | + |
| 49 | + -- Load from XML |
| 50 | + if xmlText then |
| 51 | + self:LoadFromXML(xmlText) |
| 52 | + end |
| 53 | +end) |
| 54 | + |
| 55 | +function CompareEntryClass:LoadFromXML(xmlText) |
| 56 | + -- Parse the XML (same pattern as Build.lua:LoadDB, line 1834) |
| 57 | + local dbXML, errMsg = common.xml.ParseXML(xmlText) |
| 58 | + if errMsg then |
| 59 | + ConPrintf("CompareEntry: Error parsing XML: %s", errMsg) |
| 60 | + return true |
| 61 | + end |
| 62 | + if not dbXML or not dbXML[1] or dbXML[1].elem ~= "PathOfBuilding" then |
| 63 | + ConPrintf("CompareEntry: 'PathOfBuilding' root element missing") |
| 64 | + return true |
| 65 | + end |
| 66 | + |
| 67 | + -- Load Build section first (same pattern as Build.lua:LoadDB, line 1848) |
| 68 | + for _, node in ipairs(dbXML[1]) do |
| 69 | + if type(node) == "table" and node.elem == "Build" then |
| 70 | + self:LoadBuildSection(node) |
| 71 | + break |
| 72 | + end |
| 73 | + end |
| 74 | + |
| 75 | + -- Check for import link |
| 76 | + for _, node in ipairs(dbXML[1]) do |
| 77 | + if type(node) == "table" and node.elem == "Import" then |
| 78 | + if node.attrib.importLink then |
| 79 | + self.importLink = node.attrib.importLink |
| 80 | + end |
| 81 | + break |
| 82 | + end |
| 83 | + end |
| 84 | + |
| 85 | + -- Store XML sections for tab loading |
| 86 | + self.xmlSectionList = {} |
| 87 | + for _, node in ipairs(dbXML[1]) do |
| 88 | + if type(node) == "table" then |
| 89 | + t_insert(self.xmlSectionList, node) |
| 90 | + end |
| 91 | + end |
| 92 | + |
| 93 | + -- Version check |
| 94 | + if self.targetVersion ~= liveTargetVersion then |
| 95 | + self.targetVersion = liveTargetVersion |
| 96 | + end |
| 97 | + |
| 98 | + -- Create tabs (same pattern as Build.lua lines 579-590) |
| 99 | + -- PartyTab is replaced with a stub providing an empty enemyModList and actor |
| 100 | + -- (CalcPerform.lua:1088 accesses build.partyTab.actor for party member buffs) |
| 101 | + local partyActor = { Aura = {}, Curse = {}, Warcry = {}, Link = {}, modDB = new("ModDB"), output = {} } |
| 102 | + partyActor.modDB.actor = partyActor |
| 103 | + self.partyTab = { enemyModList = new("ModList"), actor = partyActor } |
| 104 | + self.configTab = new("ConfigTab", self) |
| 105 | + self.itemsTab = new("ItemsTab", self) |
| 106 | + self.treeTab = new("TreeTab", self) |
| 107 | + self.skillsTab = new("SkillsTab", self) |
| 108 | + self.calcsTab = new("CalcsTab", self) |
| 109 | + |
| 110 | + -- Set up savers table (same pattern as Build.lua lines 593-606) |
| 111 | + self.savers = { |
| 112 | + ["Config"] = self.configTab, |
| 113 | + ["Tree"] = self.treeTab, |
| 114 | + ["TreeView"] = self.treeTab.viewer, |
| 115 | + ["Items"] = self.itemsTab, |
| 116 | + ["Skills"] = self.skillsTab, |
| 117 | + ["Calcs"] = self.calcsTab, |
| 118 | + } |
| 119 | + self.legacyLoaders = { |
| 120 | + ["Spec"] = self.treeTab, |
| 121 | + } |
| 122 | + |
| 123 | + -- Special rebuild to properly initialise boss placeholders |
| 124 | + self.configTab:BuildModList() |
| 125 | + |
| 126 | + -- Load legacy bandit and pantheon choices from build section |
| 127 | + for _, control in ipairs({ "bandit", "pantheonMajorGod", "pantheonMinorGod" }) do |
| 128 | + self.configTab.input[control] = self[control] |
| 129 | + end |
| 130 | + |
| 131 | + -- Load XML sections into tabs (same pattern as Build.lua lines 620-647) |
| 132 | + -- Defer passive trees until after items are loaded (jewel socket issue) |
| 133 | + local deferredPassiveTrees = {} |
| 134 | + for _, node in ipairs(self.xmlSectionList) do |
| 135 | + local saver = self.savers[node.elem] or self.legacyLoaders[node.elem] |
| 136 | + if saver then |
| 137 | + if saver == self.treeTab then |
| 138 | + t_insert(deferredPassiveTrees, node) |
| 139 | + else |
| 140 | + saver:Load(node, "CompareEntry") |
| 141 | + end |
| 142 | + end |
| 143 | + end |
| 144 | + for _, node in ipairs(deferredPassiveTrees) do |
| 145 | + self.treeTab:Load(node, "CompareEntry") |
| 146 | + end |
| 147 | + for _, saver in pairs(self.savers) do |
| 148 | + if saver.PostLoad then |
| 149 | + saver:PostLoad() |
| 150 | + end |
| 151 | + end |
| 152 | + |
| 153 | + if next(self.configTab.input) == nil then |
| 154 | + if self.configTab.ImportCalcSettings then |
| 155 | + self.configTab:ImportCalcSettings() |
| 156 | + end |
| 157 | + end |
| 158 | + |
| 159 | + -- Build calculation output tables (same pattern as Build.lua lines 654-657) |
| 160 | + self.calcsTab:BuildOutput() |
| 161 | + self.buildFlag = false |
| 162 | +end |
| 163 | + |
| 164 | +-- Load build section attributes (same pattern as Build.lua:Load, line 927) |
| 165 | +function CompareEntryClass:LoadBuildSection(xml) |
| 166 | + self.targetVersion = xml.attrib.targetVersion or legacyTargetVersion |
| 167 | + if xml.attrib.viewMode then |
| 168 | + self.viewMode = xml.attrib.viewMode |
| 169 | + end |
| 170 | + self.characterLevel = tonumber(xml.attrib.level) or 1 |
| 171 | + self.characterLevelAutoMode = xml.attrib.characterLevelAutoMode == "true" |
| 172 | + for _, diff in pairs({ "bandit", "pantheonMajorGod", "pantheonMinorGod" }) do |
| 173 | + self[diff] = xml.attrib[diff] or "None" |
| 174 | + end |
| 175 | + self.mainSocketGroup = tonumber(xml.attrib.mainSkillIndex) or tonumber(xml.attrib.mainSocketGroup) or 1 |
| 176 | + wipeTable(self.spectreList) |
| 177 | + for _, child in ipairs(xml) do |
| 178 | + if child.elem == "Spectre" then |
| 179 | + if child.attrib.id and data.minions[child.attrib.id] then |
| 180 | + t_insert(self.spectreList, child.attrib.id) |
| 181 | + end |
| 182 | + elseif child.elem == "TimelessData" then |
| 183 | + self.timelessData.jewelType = { id = tonumber(child.attrib.jewelTypeId) } |
| 184 | + self.timelessData.conquerorType = { id = tonumber(child.attrib.conquerorTypeId) } |
| 185 | + self.timelessData.devotionVariant1 = tonumber(child.attrib.devotionVariant1) or 1 |
| 186 | + self.timelessData.devotionVariant2 = tonumber(child.attrib.devotionVariant2) or 1 |
| 187 | + self.timelessData.jewelSocket = { id = tonumber(child.attrib.jewelSocketId) } |
| 188 | + self.timelessData.fallbackWeightMode = { idx = tonumber(child.attrib.fallbackWeightModeIdx) } |
| 189 | + self.timelessData.socketFilter = child.attrib.socketFilter == "true" |
| 190 | + self.timelessData.socketFilterDistance = tonumber(child.attrib.socketFilterDistance) or 0 |
| 191 | + self.timelessData.searchList = child.attrib.searchList |
| 192 | + self.timelessData.searchListFallback = child.attrib.searchListFallback |
| 193 | + end |
| 194 | + end |
| 195 | +end |
| 196 | + |
| 197 | +function CompareEntryClass:GetOutput() |
| 198 | + return self.calcsTab.mainOutput |
| 199 | +end |
| 200 | + |
| 201 | +function CompareEntryClass:GetSpec() |
| 202 | + return self.spec |
| 203 | +end |
| 204 | + |
| 205 | +function CompareEntryClass:Rebuild() |
| 206 | + wipeGlobalCache() |
| 207 | + self.outputRevision = self.outputRevision + 1 |
| 208 | + self.calcsTab:BuildOutput() |
| 209 | + self.buildFlag = false |
| 210 | +end |
| 211 | + |
| 212 | +function CompareEntryClass:SetActiveSpec(index) |
| 213 | + if self.treeTab and self.treeTab.SetActiveSpec then |
| 214 | + self.treeTab:SetActiveSpec(index) |
| 215 | + self:Rebuild() |
| 216 | + end |
| 217 | +end |
| 218 | + |
| 219 | +function CompareEntryClass:SetActiveItemSet(id) |
| 220 | + if self.itemsTab and self.itemsTab.SetActiveItemSet then |
| 221 | + self.itemsTab:SetActiveItemSet(id) |
| 222 | + self:Rebuild() |
| 223 | + end |
| 224 | +end |
| 225 | + |
| 226 | +function CompareEntryClass:SetActiveSkillSet(id) |
| 227 | + if self.skillsTab and self.skillsTab.SetActiveSkillSet then |
| 228 | + self.skillsTab:SetActiveSkillSet(id) |
| 229 | + self:Rebuild() |
| 230 | + end |
| 231 | +end |
| 232 | + |
| 233 | +-- Stub methods that the build interface may call |
| 234 | +function CompareEntryClass:RefreshStatList() |
| 235 | + -- No sidebar to refresh in comparison entry |
| 236 | +end |
| 237 | + |
| 238 | +function CompareEntryClass:RefreshSkillSelectControls() |
| 239 | + -- No skill select controls in comparison entry |
| 240 | +end |
| 241 | + |
| 242 | +function CompareEntryClass:UpdateClassDropdowns() |
| 243 | + -- No class dropdowns in comparison entry |
| 244 | +end |
| 245 | + |
| 246 | +function CompareEntryClass:SyncLoadouts() |
| 247 | + -- No loadout syncing in comparison entry |
| 248 | +end |
| 249 | + |
| 250 | +function CompareEntryClass:OpenSpectreLibrary() |
| 251 | + -- No spectre library in comparison entry |
| 252 | +end |
| 253 | + |
| 254 | +function CompareEntryClass:AddStatComparesToTooltip(tooltip, baseOutput, compareOutput, header, nodeCount) |
| 255 | + -- Reuse the stat comparison logic |
| 256 | + local count = 0 |
| 257 | + if self.calcsTab and self.calcsTab.mainEnv and self.calcsTab.mainEnv.player and self.calcsTab.mainEnv.player.mainSkill then |
| 258 | + if self.calcsTab.mainEnv.player.mainSkill.minion and baseOutput.Minion and compareOutput.Minion then |
| 259 | + count = count + self:CompareStatList(tooltip, self.minionDisplayStats, self.calcsTab.mainEnv.minion, baseOutput.Minion, compareOutput.Minion, header.."\n^7Minion:", nodeCount) |
| 260 | + if count > 0 then |
| 261 | + header = "^7Player:" |
| 262 | + else |
| 263 | + header = header.."\n^7Player:" |
| 264 | + end |
| 265 | + end |
| 266 | + count = count + self:CompareStatList(tooltip, self.displayStats, self.calcsTab.mainEnv.player, baseOutput, compareOutput, header, nodeCount) |
| 267 | + end |
| 268 | + return count |
| 269 | +end |
| 270 | + |
| 271 | +-- Stat comparison (mirrors Build.lua:CompareStatList, line 1733) |
| 272 | +function CompareEntryClass:CompareStatList(tooltip, statList, actor, baseOutput, compareOutput, header, nodeCount) |
| 273 | + local s_format = string.format |
| 274 | + local count = 0 |
| 275 | + if not actor or not actor.mainSkill then |
| 276 | + return 0 |
| 277 | + end |
| 278 | + for _, statData in ipairs(statList) do |
| 279 | + if statData.stat and not statData.childStat and statData.stat ~= "SkillDPS" then |
| 280 | + local flagMatch = true |
| 281 | + if statData.flag then |
| 282 | + if type(statData.flag) == "string" then |
| 283 | + flagMatch = actor.mainSkill.skillFlags[statData.flag] |
| 284 | + elseif type(statData.flag) == "table" then |
| 285 | + for _, flag in ipairs(statData.flag) do |
| 286 | + if not actor.mainSkill.skillFlags[flag] then |
| 287 | + flagMatch = false |
| 288 | + break |
| 289 | + end |
| 290 | + end |
| 291 | + end |
| 292 | + end |
| 293 | + if statData.notFlag then |
| 294 | + if type(statData.notFlag) == "string" then |
| 295 | + if actor.mainSkill.skillFlags[statData.notFlag] then |
| 296 | + flagMatch = false |
| 297 | + end |
| 298 | + elseif type(statData.notFlag) == "table" then |
| 299 | + for _, flag in ipairs(statData.notFlag) do |
| 300 | + if actor.mainSkill.skillFlags[flag] then |
| 301 | + flagMatch = false |
| 302 | + break |
| 303 | + end |
| 304 | + end |
| 305 | + end |
| 306 | + end |
| 307 | + if flagMatch then |
| 308 | + local statVal1 = compareOutput[statData.stat] or 0 |
| 309 | + local statVal2 = baseOutput[statData.stat] or 0 |
| 310 | + local diff = statVal1 - statVal2 |
| 311 | + if statData.stat == "FullDPS" and not compareOutput[statData.stat] then |
| 312 | + diff = 0 |
| 313 | + end |
| 314 | + if (diff > 0.001 or diff < -0.001) and (not statData.condFunc or statData.condFunc(statVal1, compareOutput) or statData.condFunc(statVal2, baseOutput)) then |
| 315 | + if count == 0 then |
| 316 | + tooltip:AddLine(14, header) |
| 317 | + end |
| 318 | + local color = ((statData.lowerIsBetter and diff < 0) or (not statData.lowerIsBetter and diff > 0)) and colorCodes.POSITIVE or colorCodes.NEGATIVE |
| 319 | + local val = diff * ((statData.pc or statData.mod) and 100 or 1) |
| 320 | + local valStr = s_format("%+"..statData.fmt, val) |
| 321 | + local number, suffix = valStr:match("^([%+%-]?%d+%.%d+)(%D*)$") |
| 322 | + if number then |
| 323 | + valStr = number:gsub("0+$", ""):gsub("%.$", "") .. suffix |
| 324 | + end |
| 325 | + valStr = formatNumSep(valStr) |
| 326 | + local line = s_format("%s%s %s", color, valStr, statData.label) |
| 327 | + if statData.compPercent and statVal1 ~= 0 and statVal2 ~= 0 then |
| 328 | + local pc = statVal1 / statVal2 * 100 - 100 |
| 329 | + line = line .. s_format(" (%+.1f%%)", pc) |
| 330 | + end |
| 331 | + tooltip:AddLine(14, line) |
| 332 | + count = count + 1 |
| 333 | + end |
| 334 | + end |
| 335 | + end |
| 336 | + end |
| 337 | + return count |
| 338 | +end |
| 339 | + |
| 340 | +return CompareEntryClass |
0 commit comments