diff --git a/spec/System/TestBuildDisplayStats_spec.lua b/spec/System/TestBuildDisplayStats_spec.lua new file mode 100644 index 0000000000..4e85424f42 --- /dev/null +++ b/spec/System/TestBuildDisplayStats_spec.lua @@ -0,0 +1,106 @@ +describe("Build display stats", function() + local originalCompactValues + + before_each(function() + originalCompactValues = main.useCompactValues + newBuild() + end) + + after_each(function() + main.useCompactValues = originalCompactValues + end) + + local function getSidebarLine(label) + local suffix = label .. ":" + for _, stat in ipairs(build.controls.statBox.list) do + if stat[1] and stat[1]:sub(-#suffix) == suffix then + return stat + end + end + end + + it("only underlines sidebar stats with a visible breakdown", function() + build.skillsTab:PasteSocketGroup("Fireball 20/0 1") + runCallback("OnFrame") + + for _, line in ipairs(build.controls.statBox.list) do + if line.underline and line.underline[2] then + build:SetDisplayStat({ line = line, x = 0, y = 0, width = 300 }, false) + assert.is_true(build.controls.breakdown.shown, line[1]) + build:ClearDisplayStat() + end + end + end) + + it("uses aggregate breakdowns for dual-wield attacks", function() + build.skillsTab:PasteSocketGroup("skillId:MeleeMaceMacePlayer Mace Strike 20/0 1") + build.itemsTab:CreateDisplayItemFromRaw("New Item\nMarauding Mace\nQuality: 0\n20% increased Attack Speed") + build.itemsTab:AddDisplayItem() + build.itemsTab:CreateDisplayItemFromRaw("New Item\nMarauding Mace\nQuality: 0") + build.itemsTab:AddDisplayItem() + runCallback("OnFrame") + + local actor = build.calcsTab.mainEnv.player + assert.is_true(actor.mainSkill.activeEffect.statSet.skillFlags.bothWeaponAttack) + assert.matches("Simultaneous hits from each weapon", table.concat(actor.breakdown.Speed, "\n"), nil, true) + assert.matches("Both weapons", table.concat(actor.breakdown.PreEffectiveCritChance, "\n"), nil, true) + assert.matches("Both weapons", table.concat(actor.breakdown.CritChance, "\n"), nil, true) + assert.not_matches("Crit confirmation roll", table.concat(actor.breakdown.PreEffectiveCritChance, "\n"), nil, true) + assert.matches("Crit confirmation roll", table.concat(actor.breakdown.CritChance, "\n"), nil, true) + assert.not_matches("Effective Crit Chance:", table.concat(actor.breakdown.CritChance, "\n"), nil, true) + assert.matches("Both weapons", table.concat(actor.breakdown.HitChance, "\n"), nil, true) + + local critLine = getSidebarLine("Crit Chance") + local effectiveCritLine = getSidebarLine("Effective Crit Chance") + assert.are.equal("PreEffectiveCritChance", critLine.breakdown) + assert.are.equal("CritChance", effectiveCritLine.breakdown) + + local displayData = build:GetSidebarBreakdown(critLine.breakdown, critLine.modNames, critLine.ignoredSections, "player") + local breakdownCount = 0 + local hasMainHandModifiers = false + for _, section in ipairs(displayData) do + breakdownCount = breakdownCount + (section.breakdown and 1 or 0) + hasMainHandModifiers = hasMainHandModifiers or section.cfg == "weapon1" + end + assert.are.equal(1, breakdownCount) + assert.is_true(hasMainHandModifiers) + end) + + it("uses off-hand breakdowns for shield attacks", function() + build.itemsTab:CreateDisplayItemFromRaw("New Item\nShortsword\nQuality: 0") + build.itemsTab:AddDisplayItem() + build.itemsTab:CreateDisplayItemFromRaw("New Item\nSplintered Tower Shield\nQuality: 0") + build.itemsTab:AddDisplayItem() + build.skillsTab:PasteSocketGroup("Shield Wall 20/0 1") + build.configTab.input.enemyEvasion = 10000 + build.configTab:BuildModList() + runCallback("OnFrame") + + local actor = build.calcsTab.mainEnv.player + assert.is_falsy(actor.mainSkill.activeEffect.statSet.skillFlags.weapon1Attack) + assert.is_true(actor.mainSkill.activeEffect.statSet.skillFlags.weapon2Attack) + assert.are.equal(actor.breakdown.OffHand.Speed, actor.breakdown.Speed) + assert.are.equal(actor.breakdown.OffHand.AccuracyHitChance, actor.breakdown.HitChance) + assert.are.equal(actor.breakdown.OffHand.PreEffectiveCritChance, actor.breakdown.PreEffectiveCritChance) + assert.are.equal(actor.breakdown.OffHand.CritChance, actor.breakdown.CritChance) + + local critLine = getSidebarLine("Crit Chance") + local effectiveCritLine = getSidebarLine("Effective Crit Chance") + assert.are.equal("PreEffectiveCritChance", critLine.breakdown) + assert.are.equal("OffHand.CritChance", actor.breakdown.PreEffectiveCritChance.breakdownSource) + assert.are.equal("CritChance", effectiveCritLine.breakdown) + + local displayData = build:GetSidebarBreakdown(critLine.breakdown, critLine.modNames, critLine.ignoredSections, "player") + local hasModifierSection = false + for _, section in ipairs(displayData) do + hasModifierSection = hasModifierSection or section.modName ~= nil + end + assert.is_true(hasModifierSection) + + build.configTab.input.enemyBlockChance = 25 + build.configTab:BuildModList() + runCallback("OnFrame") + actor = build.calcsTab.mainEnv.player + assert.are.equal(actor.breakdown.OffHand.HitChance, actor.breakdown.HitChance) + end) +end) diff --git a/src/Classes/CalcBreakdownControl.lua b/src/Classes/CalcBreakdownControl.lua index 36d5bf57af..4d77ec8028 100644 --- a/src/Classes/CalcBreakdownControl.lua +++ b/src/Classes/CalcBreakdownControl.lua @@ -27,7 +27,13 @@ function CalcBreakdownClass:CalcBreakdownControl(calcsTab) self.rangeGuide:Load("Assets/range_guide.png") self.uiOverlay = NewImageHandle() self.uiOverlay:Load("Assets/game_ui_small.png") + self.borderThickness = 2 self.controls.scrollBar = new("ScrollBarControl"):ScrollBarControl({ "RIGHT", self, "RIGHT" }, { -2, 0, 18, 0 }, 80, "VERTICAL", true) + self.controls.scrollBar.x = function() + return -self.borderThickness + end + self.pinnedColour = { 0.25, 1, 0.25 } + self.borderColour = { 0.33, 0.66, 0.33 } return self end @@ -38,21 +44,35 @@ function CalcBreakdownClass:IsMouseOver() return self:IsMouseInBounds() or self:GetMouseOverControl() end -function CalcBreakdownClass:SetBreakdownData(displayData, pinned) +function CalcBreakdownClass:GetActor() + local env = self.calcsTab[self.envName or "calcsEnv"] + local actor = self.calcsTab.input.showMinion and env.minion or env.player + if self.forceActor then + actor = env[self.forceActor] + end + return actor, env +end + +---@param displayData any +---@param pinned any +---@param forceActor "player"|"minion"|nil +function CalcBreakdownClass:SetBreakdownData(displayData, pinned, forceActor) self.pinned = pinned if displayData == self.sourceData then return end self.sourceData = displayData + self.forceActor = forceActor self.shown = false if not displayData then return end -- Build list of sections + local actor, env = self:GetActor() self.sectionList = wipeTable(self.sectionList) for _, sectionData in ipairs(displayData) do - if self.calcsTab:CheckFlag(sectionData) then + if self.calcsTab:CheckFlag(sectionData, actor, env.player) then if sectionData.breakdown then self:AddBreakdownSection(sectionData) elseif sectionData.modName then @@ -61,7 +81,11 @@ function CalcBreakdownClass:SetBreakdownData(displayData, pinned) end end if #self.sectionList == 0 then - self.calcsTab:ClearDisplayStat() + if self.clearDisplayFunc then + self.clearDisplayFunc() + else + self.calcsTab:ClearDisplayStat() + end return end @@ -120,7 +144,7 @@ end -- Add sections based on the breakdown data generated by the Calcs module function CalcBreakdownClass:AddBreakdownSection(sectionData) - local actor = self.calcsTab.input.showMinion and self.calcsTab.calcsEnv.minion or self.calcsTab.calcsEnv.player + local actor = self:GetActor() local breakdown local ns, name = sectionData.breakdown:match("^(%a+)%.(%a+)$") if ns then @@ -273,7 +297,7 @@ end -- Add a table section showing a list of modifiers function CalcBreakdownClass:AddModSection(sectionData, modList) - local actor = self.calcsTab.input.showMinion and self.calcsTab.calcsEnv.minion or self.calcsTab.calcsEnv.player + local actor = self:GetActor() local build = self.calcsTab.build -- Build list of modifiers to display @@ -678,12 +702,13 @@ function CalcBreakdownClass:Draw(viewPort) local scrollBar = self.controls.scrollBar local width = self.contentWidth local height = self.contentHeight + local borderThickness = self.borderThickness if self.contentHeight > viewPort.height then -- Content won't fit the screen height, so set the scrollbar width = self.contentWidth + scrollBar.width height = viewPort.height - scrollBar.height = height - 4 - scrollBar:SetContentDimension(self.contentHeight - 4, viewPort.height - 4) + scrollBar.height = height - borderThickness * 2 + scrollBar:SetContentDimension(self.contentHeight - borderThickness * 2, viewPort.height - borderThickness * 2) else scrollBar:SetContentDimension(0, 0) end @@ -700,15 +725,14 @@ function CalcBreakdownClass:Draw(viewPort) -- Draw background SetDrawLayer(nil, 10) SetDrawColor(0, 0, 0, 0.9) - DrawImage(nil, x + 2, y + 2, width - 4, height - 4) + DrawImage(nil, x + borderThickness, y + borderThickness, width - borderThickness * 2, height - borderThickness * 2) -- Draw border (this is put in sub layer 11 so it draws over the contents, in case they don't fit the screen) SetDrawLayer(nil, 11) if self.pinned then - SetDrawColor(0.25, 1, 0.25) + SetDrawColor(unpack(self.pinnedColour)) else - SetDrawColor(0.33, 0.66, 0.33) + SetDrawColor(unpack(self.borderColour)) end - local borderThickness = 2 DrawImage(nil, x, y, width, borderThickness) DrawImage(nil, x, y + height - borderThickness, width, borderThickness) DrawImage(nil, x, y, borderThickness, height) @@ -750,7 +774,11 @@ function CalcBreakdownClass:OnKeyDown(key, doubleClick) if key:match("BUTTON") then if not mOver then -- Mouse click outside the control, hide the breakdown - self.calcsTab:ClearDisplayStat() + if self.clearDisplayFunc then + self.clearDisplayFunc() + else + self.calcsTab:ClearDisplayStat() + end self.shown = false return end diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index d43c69c93c..16aa139e03 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -415,9 +415,10 @@ function CalcsTabClass:SetDisplayStat(displayData, pin) self.controls.breakdown:SetBreakdownData(displayData, pin) end -function CalcsTabClass:CheckFlag(obj) - local actor = self.input.showMinion and self.calcsEnv.minion or self.calcsEnv.player - local skillFlags = actor.mainSkill.activeEffect.statSetCalcs.skillFlags +function CalcsTabClass:CheckFlag(obj, actor, player) + actor = actor or (self.input.showMinion and self.calcsEnv.minion or self.calcsEnv.player) + local activeEffect = actor.mainSkill.activeEffect + local skillFlags = (activeEffect.statSetCalcs or activeEffect.statSet).skillFlags or {} local skillData = actor.mainSkill.skillData if obj.flag and not skillFlags[obj.flag] then return @@ -432,7 +433,8 @@ function CalcsTabClass:CheckFlag(obj) end end end - if obj.playerFlag and not self.calcsEnv.player.mainSkill.activeEffect.statSetCalcs.skillFlags[obj.playerFlag] then + local playerActiveEffect = (player or self.calcsEnv.player).mainSkill.activeEffect + if obj.playerFlag and not (playerActiveEffect.statSetCalcs or playerActiveEffect.statSet).skillFlags[obj.playerFlag] then return end if obj.notFlag and skillFlags[obj.notFlag] then diff --git a/src/Classes/TextListControl.lua b/src/Classes/TextListControl.lua index f2b9fb016a..f6cda5a638 100644 --- a/src/Classes/TextListControl.lua +++ b/src/Classes/TextListControl.lua @@ -46,11 +46,37 @@ function TextListClass:Draw(viewPort) local lineY = -scrollBar.offset for _, lineInfo in ipairs(self.list) do if lineInfo[colIndex] then - DrawString(lineInfo.x or colInfo.x, lineY, lineInfo.align or colInfo.align, lineInfo.height, lineInfo.font or "VAR", lineInfo[colIndex]) + local textX = lineInfo.x or colInfo.x + local align = lineInfo.align or colInfo.align + DrawString(textX, lineY, align, lineInfo.height, lineInfo.font or "VAR", lineInfo[colIndex]) + if lineInfo.underline and lineInfo.underline[colIndex] then + local width = DrawStringWidth(lineInfo.height, "VAR", StripEscapes(lineInfo[colIndex])) + -- note: not fully handled. this is currently only used for + -- the side bar stats + if align == "RIGHT_X" then + textX = textX - width + end + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, textX, lineY + lineInfo.height, width, 1) + end end lineY = lineY + lineInfo.height end end + -- determine which line the user is hovering over + self.hoveredLine = nil + local cursorX, cursorY = GetCursorPos() + if cursorX >= x + 2 and cursorX < x + width - 18 and cursorY >= y + 2 and cursorY < y + height - 2 then + local rowY = y - scrollBar.offset + 2 + -- suboptimal. should do binary search if this causes performance problems + for _, lineInfo in ipairs(self.list) do + if cursorY >= rowY and cursorY < rowY + lineInfo.height then + self.hoveredLine = { line = lineInfo, x = x, y = rowY, width = width } + break + end + rowY = rowY + lineInfo.height + end + end SetViewport() end @@ -58,6 +84,9 @@ function TextListClass:OnKeyDown(key, doubleClick) if not self:IsShown() or not self:IsEnabled() then return end + if key == "LEFTBUTTON" and self.onClick then + self.onClick(self.hoveredLine) + end local mOverControl = self:GetMouseOverControl() if mOverControl and mOverControl.OnKeyDown then return mOverControl:OnKeyDown(key) diff --git a/src/Data/Global.lua b/src/Data/Global.lua index d877132bd6..7ec1c73e94 100644 --- a/src/Data/Global.lua +++ b/src/Data/Global.lua @@ -94,8 +94,9 @@ function updateColorCode(code, color) end function hexToRGB(hex) + hex = hex:gsub("%^x", "") -- Remove "^x" prefix hex = hex:gsub("0x", "") -- Remove "0x" prefix - hex = hex:gsub("#","") -- Remove '#' if present + hex = hex:gsub("#", "") -- Remove '#' if present if #hex ~= 6 then return nil end diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua index 9030fa8e69..f0c1bbe0ec 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -484,6 +484,9 @@ function buildMode:Init(dbFileName, buildName, buildXML, convertBuild, importLin local warnHeight = main.showWarnings and #self.controls.warnings.lines > 0 and 18 or 0 return main.screenH - main.mainBarHeight - 4 - y - warnHeight end + function self.controls.statBox.onClick(hoveredLine) + self:SetDisplayStat(hoveredLine, true) + end self.controls.warnings = new("Control"):Control({"TOPLEFT",self.controls.statBox,"BOTTOMLEFT",true}, {0, 0, 0, 18}) self.controls.warnings.lines = {} self.controls.warnings.width = function(control) @@ -521,6 +524,14 @@ function buildMode:Init(dbFileName, buildName, buildXML, convertBuild, importLin self.treeTab = new("TreeTab"):TreeTab(self) self.skillsTab = new("SkillsTab"):SkillsTab(self) self.calcsTab = new("CalcsTab"):CalcsTab(self) + self.controls.breakdown = new("CalcBreakdownControl"):CalcBreakdownControl(self.calcsTab) + self.controls.breakdown.pinnedColour = hexToRGB(colorCodes.CUSTOM) or error("failed to set breakdown pin colour") + self.controls.breakdown.envName = "mainEnv" + self.controls.breakdown.clearDisplayFunc = function() + self:ClearDisplayStat() + end + -- ensure that we don't refer to outdated calc tab sections + self.breakdownIndex = nil self.compareTab = new("CompareTab"):CompareTab(self) -- Load sections from the build file @@ -1331,6 +1342,10 @@ function buildMode:OnFrame(inputEvents) self.outputRevision = self.outputRevision + 1 self.buildFlag = false self.calcsTab:BuildOutput() + if self.controls.breakdown.pinned and self.breakdownInputs then + self.controls.breakdown:SetBreakdownData() + self.controls.breakdown:SetBreakdownData(unpack(self.breakdownInputs)) + end self:RefreshStatList() end if main.showThousandsSeparators ~= self.lastShowThousandsSeparators then @@ -1395,7 +1410,52 @@ function buildMode:OnFrame(inputEvents) DrawImage(nil, sideBarWidth - 4, 32, 4, main.screenH - 32) + local hovered = self.controls.statBox and self.controls.statBox.hoveredLine + self:SetDisplayStat(hovered, false) self:DrawControls(main.viewPort) + if self.controls.breakdown.pinned and self.breakdownInputs and not self.selControl then + self:SelectControl(self.controls.breakdown) + end +end + +function buildMode:SetDisplayStat(hovered, pin) + if not hovered or (not pin and self.controls.breakdown.pinned) then + -- if this is a hover popup, clear when the user moved mouse out of frame + if not self.controls.breakdown.pinned then + self:ClearDisplayStat() + end + return + end + local key = hovered.line.breakdown + -- user clicked a second time: clear + if pin and self.controls.breakdown.pinned then + self:ClearDisplayStat() + return + -- avoid being able to pin empty breakdowns + elseif pin and not self.controls.breakdown.pinned and not key then + return + end + + self.sidebarBreakdownData = self:GetSidebarBreakdown(key, hovered.line.modNames, hovered.line.ignoredSections, hovered.line.actorName) + self.sidebarBreakdownData.x = hovered.x - 1 + self.sidebarBreakdownData.y = hovered.y + self.sidebarBreakdownData.width = hovered.width + if hovered.line.breakdownColour then + self.controls.breakdown.borderColour = hovered.line.breakdownColour + end + local breakdownInputs = { self.sidebarBreakdownData, pin, hovered.line.actorName } + -- avoid rebuilding the sidebar every frame + if tableDeepEquals(breakdownInputs, self.breakdownInputs) then + return + end + self.breakdownInputs = breakdownInputs + self.controls.breakdown:SetBreakdownData(unpack(breakdownInputs)) +end + +function buildMode:ClearDisplayStat() + self.controls.breakdown:SetBreakdownData() + self.breakdownInputs = nil + self.sidebarBreakdownData = nil end function buildMode:UpdateClassDropdowns(treeVersion) @@ -2098,8 +2158,163 @@ function buildMode:FormatStat(statData, statVal, overCapStatVal, colorOverride) return valStr end +-- lazily index the calcs tab sections so that we can look up the matching +-- sections by breakdown name. this function works by iterating through calc +-- sections. it will link every breakdown key to the first found calc section +-- column (basically, a box). the result will contain references to this column +-- data and will also contain a set of mod names referred to by cells of said +-- box. if a cell shouldn't be picked, the field `skipSideBarIndex` can be set +-- on the cell. +function buildMode:BuildBreakdownIndex() + if self.breakdownIndex then + return self.breakdownIndex + end + local index = {} + local sectionList = self.calcsTab.sectionList + for _, section in ipairs(sectionList) do + if section.subSection then + for _, subSection in ipairs(section.subSection) do + -- cells for each mod name + local modSectionsByName = {} + local breakdownCells = {} + ---@type CalcSectionData + local subSectionData = subSection.data + for _, row in ipairs(subSectionData) do + for _, colData in ipairs(row) do + for _, cell in ipairs(colData) do + if not cell.skipSideBarIndex and cell.breakdown and not breakdownCells[cell.breakdown] then + breakdownCells[cell.breakdown] = colData + end + if cell.modName then + -- inherit flags from parent row + local copy = copyTable(row, true) + for k, v in pairs(cell) do + copy[k] = v + end + local names = type(cell.modName) == "table" and cell.modName or { cell.modName } + for _, name in ipairs(names) do + modSectionsByName[name] = modSectionsByName[name] or {} + t_insert(modSectionsByName[name], copy) + end + end + end + end + end + for key, colData in pairs(breakdownCells) do + -- ensure that we only set each breakdown once + if not index[key] then + index[key] = { colData = colData, modSectionsByName = modSectionsByName } + end + end + end + end + end + self.breakdownIndex = index + return index +end + +---@param key string A breakdown key +---@param modNames string[]? A modName key. Required if the mod name is different from the breakdown key. E.g. breakdown has Time while the mod has Speed +---@param ignoredSections table? +---@param actorName string? +function buildMode:GetSidebarBreakdown(key, modNames, ignoredSections, actorName) + local env = actorName and self.calcsTab.mainEnv[actorName] + local breakdownData = env and env.breakdown and env.breakdown[key] + local sourceKey = breakdownData and breakdownData.breakdownSource or key + local entry = self:BuildBreakdownIndex()[sourceKey] + if not entry then + local output = { { breakdown = key } } + -- it's possible for calc sections to not have a breakdown, while still + -- having a mod list. this handles e.g. spell suppression chance + if modNames then + table.insert(output, { modName = modNames }) + end + return output + end + local displayData = {} + -- this will include all of the hover information for the selected breakdown + for _, cell in ipairs(entry.colData) do + local displayCell = copyTable(cell) + if displayCell.breakdown == sourceKey then + displayCell.breakdown = key + end + t_insert(displayData, displayCell) + end + -- sometimes the breakdown doesn't include the mod names. for + -- example life has its mod tables separated to the inc and more breakdowns. + if modNames then + for _, modKey in ipairs(modNames) do + if entry.modSectionsByName[modKey] then + for _, statDisplay in ipairs(entry.modSectionsByName[modKey]) do + t_insert(displayData, statDisplay) + end + end + end + end + + local visitedSections = {} + -- remove sections that have been manually excluded, or ones that have + -- already been added. it's probably not guaranteed that the sections with + -- the same label always contain the exact same contents, but this gets rid + -- of pointless repeated sections + for i = #displayData, 1, -1 do + local label = displayData[i].label + if label then + if visitedSections[label] or (ignoredSections and ignoredSections[label]) then + table.remove(displayData, i) + end + visitedSections[label] = true + end + end + if #displayData == 0 then + return { { breakdown = key } } + end + return displayData +end + +---@param actorName string +function buildMode:GetStatBreakdownKey(statData, actorName) + local env = self.calcsTab.mainEnv[actorName] + local breakdown = env and env.breakdown + if not breakdown then + return nil + end + local key + if statData.breakdown ~= nil then + key = statData.breakdown + elseif statData.childStat then + local parent = breakdown[statData.stat] + if parent and parent[statData.childStat] then + key = statData.stat .. "." .. statData.childStat + end + elseif breakdown[statData.stat] then + key = statData.stat + end + if key == nil then + return nil + end + -- Modifier-only popups do not require a calculated breakdown. + if statData.modNames then + return key + end + local namespace, name = key:match("^(%a+)%.(%a+)$") + local breakdownData = namespace and breakdown[namespace] and breakdown[namespace][name] or breakdown[key] + if breakdownData and (#breakdownData > 0 + or breakdownData.radius + or breakdownData.rowList and #breakdownData.rowList > 0 + or breakdownData.reservations and #breakdownData.reservations > 0 + or breakdownData.damageTypes and #breakdownData.damageTypes > 0 + or breakdownData.slots and #breakdownData.slots > 0 + or breakdownData.modList and #breakdownData.modList > 0) then + return key + end + return nil +end -- Add stat list for given actor -function buildMode:AddDisplayStatList(statList, actor) +---@param statList DisplayStat[] +---@param actor any +---@param actorName string +function buildMode:AddDisplayStatList(statList, actor, actorName) local statBoxList = self.controls.statBox.list for index, statData in ipairs(statList) do if not statData.flag or actor.mainSkill.activeEffect.statSet.skillFlags[statData.flag] then @@ -2154,10 +2369,40 @@ function buildMode:AddDisplayStatList(statList, actor) if actor.output[statData.stat.."Warning"] or (statData.warnFunc and statData.warnFunc(statVal, actor.output) and statData.warnColor) then colorOverride = colorCodes.NEGATIVE end + local formattedStat = self:FormatStat(statData, statVal, overCapStatVal, colorOverride) + if statData.suffix and (not statData.suffixCondFunc or statData.suffixCondFunc(statVal, actor.output)) then + local suffix = statData.suffix + if type(suffix) == "function" then + suffix = suffix(statVal, actor.output) + end + if suffix then + formattedStat = formattedStat .. "^x808080 (" .. suffix .. ")" + end + end + local breakdown = self:GetStatBreakdownKey(statData, actorName) + local ignoredSections + if statData.ignoredSections then + ignoredSections = {} + for _, label in ipairs(statData.ignoredSections) do + ignoredSections[label] = true + end + end + -- SimpleGraphic also has these short codes which cannot be easily translated to hex + local colourCodeMap = { + ["^1"] = { 0.917, 0, 0 }, + ["^7"] = { 1, 1, 1 }, + } + local breakdownColour = labelColor ~= "^7" and hexToRGB(labelColor) or colourCodeMap[labelColor] t_insert(statBoxList, { height = 16, labelColor..statData.label..":", - self:FormatStat(statData, statVal, overCapStatVal, colorOverride), + formattedStat, + breakdown = breakdown, + modNames = statData.modNames, + underline = { false, not not breakdown }, + actorName = actorName, + ignoredSections = ignoredSections, + breakdownColour = breakdownColour, }) end end @@ -2261,7 +2506,7 @@ function buildMode:RefreshStatList() t_insert(statBoxList, { height = 14, align = "CENTER_X", x = 140, "^8" .. self.calcsTab.mainEnv.minion.mainSkill.infoMessage2}) end end - self:AddDisplayStatList(self.minionDisplayStats, self.calcsTab.mainEnv.minion) + self:AddDisplayStatList(self.minionDisplayStats, self.calcsTab.mainEnv.minion, "minion") t_insert(statBoxList, { height = 10 }) t_insert(statBoxList, { height = 18, "^7Player:" }) end @@ -2269,7 +2514,7 @@ function buildMode:RefreshStatList() t_insert(statBoxList, { height = 16, "^7Skill disabled:" }) t_insert(statBoxList, { height = 14, align = "CENTER_X", x = 140, self.calcsTab.mainEnv.player.mainSkill.disableReason }) end - self:AddDisplayStatList(self.displayStats, self.calcsTab.mainEnv.player) + self:AddDisplayStatList(self.displayStats, self.calcsTab.mainEnv.player, "player") self:InsertItemWarnings() self:EstimatePlayerProgress() end diff --git a/src/Modules/BuildDisplayStats.lua b/src/Modules/BuildDisplayStats.lua index c036e00397..448ff08ac5 100644 --- a/src/Modules/BuildDisplayStats.lua +++ b/src/Modules/BuildDisplayStats.lua @@ -6,7 +6,40 @@ -- This defines the stats in the side bar, and also which stats show in node/item comparisons -- This may be user-customisable in the future +local defenseIgnoredSections = { "Base from Gear", "Inc. from Tree" } +---@alias DisplayStatCondFunc fun(value: any, output: table):boolean? Show the row only when this returns truthy (usually receives the value and the actor output; the label-only variant receives output as its first arg) +---@alias DisplayStatWarnFunc fun(value: any, output: table):string|boolean|nil Returns a warning string to surface, or falsy for none +---@class DisplayStat One sidebar stat row (also drives node/item comparisons). An empty `{}` is a separator. +---@field stat? string Output value name, e.g. "Life", "TotalDPS", "MainHand" +---@field childStat? string Nested output field when `stat` is a table, e.g. "Accuracy" for MainHand.Accuracy +---@field label? string Display label shown before the value +---@field fmt? string C-style format spec applied to the value, e.g. "d", ".1f", ".2f%%", "+d%%" +---@field breakdown? string Override key for the sidebar breakdown. Should be set if the stat/childStat names don't match up with the breakdown names, or if the breakdown is conditional, but the cell isn't. +---@field modNames string[]? Modifier names to show in the breakdown popup. This should only be used if the breakdown itself doesn't list the mods. E.g. Life doesn't. +---@field ignoredSections string[]? A list of breakdown sections which shouldn't be shown. Useful to avoid duplicated info. +---@field color? string Colour escape code for the label (e.g. colorCodes.LIFE) +---@field flag? string Only show when the main skill has this skill flag +---@field notFlag? string Hide when the main skill has this skill flag +---@field condFunc? DisplayStatCondFunc Extra condition gating whether the row is shown +---@field warnFunc? DisplayStatWarnFunc Produces a warning message for the warnings panel +---@field warnColor? boolean Colour the value red when `warnFunc` returns truthy +---@field suffix? string|fun(value:any, output:table):string|nil Parenthesised suffix appended after the value +---@field suffixCondFunc? fun(value:any, output:table):boolean? Gates whether `suffix` is shown +---@field overCapStat? string Output name of the "over cap" counterpart shown alongside (e.g. resistances) +---@field overCapStatCondFunc? fun(value:any, output:table):boolean? Gates whether the over-cap value is shown +---@field compactValue? boolean Allow compact formatting (e.g. 712.3K) when the user setting is on +---@field compPercent? boolean Include a percentage delta in node/item comparisons +---@field lowerIsBetter? boolean Treat a decrease as an improvement in comparisons +---@field pc? boolean Multiply the value by 100 (fractional value shown as a percentage) +---@field mod? boolean Value is a modifier: scale by 100 and subtract 100 (shows the +/- portion) +---@field hideStat? boolean Compute/consider the stat but don't render a sidebar row +---@field chaosInoc? boolean Use the muted "immune" colour instead of red for a zero/negative value +---@field pool? string Associated resource pool name (e.g. "ManaUnreserved") for cost rows +---@field labelStat? string Output name rendered inline for a label-only row +---@field val? string Static parenthesised note for a label-only row + +---@type DisplayStat[] local displayStats = { { stat = "ActiveMinionLimit", label = "Active Minion Limit", fmt = "d" }, { stat = "AverageHit", label = "Average Hit", fmt = ".1f", compPercent = true }, @@ -15,8 +48,8 @@ local displayStats = { { stat = "AverageDamage", label = "Average Damage", fmt = ".1f", compPercent = true, flag = "monsterExplode", condFunc = function(v,o) return o.HitChance ~= 100 end }, { stat = "AverageBurstDamage", label = "Average Burst Damage", fmt = ".1f", compPercent = true, condFunc = function(v,o) return o.AverageBurstHits and o.AverageBurstHits > 1 and v > 0 end }, { stat = "PvpAverageDamage", label = "PvP Average Damage", fmt = ".1f", compPercent = true, flag = "attackPvP" }, - { stat = "Speed", label = "Attack Rate", fmt = ".2f", compPercent = true, flag = "attack", condFunc = function(v,o) return v > 0 and not o.ChannelTime and (o.TriggerTime or 0) == 0 end }, - { stat = "Speed", label = "Cast Rate", fmt = ".2f", compPercent = true, flag = "spell", condFunc = function(v,o) return v > 0 and not o.ChannelTime and (o.TriggerTime or 0) == 0 end }, + { stat = "Speed", label = "Attack Rate", fmt = ".2f", compPercent = true, flag = "attack", condFunc = function(v, o) return v > 0 and not o.ChannelTime and (o.TriggerTime or 0) == 0 end, breakdown = "Speed", modNames = { "Speed" }, ignoredSections = { "Inc. Cast Speed", "More Cast Speed", } }, + { stat = "Speed", label = "Cast Rate", fmt = ".2f", compPercent = true, flag = "spell", condFunc = function(v, o) return v > 0 and not o.ChannelTime and (o.TriggerTime or 0) == 0 end, modNames = { "Speed" } }, { stat = "Speed", label = "Effective Trigger Rate", fmt = ".2f", compPercent = true, condFunc = function(v,o) return (o.TriggerTime or 0) ~= 0 end }, { stat = "WarcryCastTime", label = "Cast Time", fmt = ".2fs", compPercent = true, lowerIsBetter = true, flag = "warcry" }, { stat = "ChannelTime", label = "Channel Time", fmt = ".2fs", compPercent = true, flag = "channelRelease", lowerIsBetter = true, condFunc = function(v,o) return not o.TriggerTime end }, @@ -30,11 +63,11 @@ local displayStats = { { stat = "TotemPlacementTime", label = "Totem Placement Time", fmt = ".2fs", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return not o.TriggerTime end }, { stat = "FiringRate", label = "Firing Rate", fmt = ".2f", compPercent = true, condFunc = function(v,o) return o.FiringRate end }, { stat = "ReloadTime", label = "Reload Time", fmt = ".2fs", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.ReloadTime end }, - { stat = "PreEffectiveCritChance", label = "Crit Chance", fmt = ".2f%%", flag = "hit" }, - { stat = "CritChance", label = "Effective Crit Chance", fmt = ".2f%%", flag = "hit", condFunc = function(v,o) return v ~= o.PreEffectiveCritChance end }, + { stat = "PreEffectiveCritChance", label = "Crit Chance", fmt = ".2f%%", flag = "hit", breakdown = "PreEffectiveCritChance" }, + { stat = "CritChance", label = "Effective Crit Chance", fmt = ".2f%%", flag = "hit", condFunc = function(v, o) return v ~= o.PreEffectiveCritChance end, breakdown = "CritChance" }, { stat = "CritBifurcates", label = "Crit Bifurcate Chance", fmt = ".2f%%", flag = "hit", condFunc = function(v,o) return (o.CritForks or 0) > 0 end }, - { stat = "CritMultiplier", label = "Crit Multiplier", fmt = "d%%", pc = true, condFunc = function(v,o) return (o.CritChance or 0) > 0 end }, - { stat = "HitChance", label = "Hit Chance", fmt = ".0f%%", flag = "attack" }, + { stat = "CritMultiplier", label = "Crit Multiplier", fmt = "d%%", pc = true, condFunc = function(v, o) return (o.CritChance or 0) > 0 end, breakdown = "CritMultiplier" }, + { stat = "HitChance", label = "Hit Chance", fmt = ".0f%%", flag = "attack", breakdown = "HitChance" }, { stat = "HitChance", label = "Hit Chance", fmt = ".0f%%", condFunc = function(v,o) return o.enemyHasSpellBlock end }, { stat = "AccuracyHitChanceUncapped", label = "Uncap. Hit Chance", fmt = ".0f%%", flag = "attack", condFunc = function(v,o) if o.AccuracyHitChanceUncapped then return o.AccuracyHitChanceUncapped > 100 end end }, { stat = "TotalDPS", label = "Hit DPS", fmt = ".1f", compPercent = true, flag = "notAverage" }, @@ -74,7 +107,7 @@ local displayStats = { { stat = "SealCooldown", label = "Seal Gain Frequency", fmt = ".2fs", lowerIsBetter = true }, { stat = "SealMax", label = "Max Number of Seals", fmt = "d" }, { stat = "TimeMaxSeals", label = "Time to Gain Max Seals", fmt = ".2fs", lowerIsBetter = true }, - { stat = "AreaOfEffectRadiusMetres", label = "AoE Radius", fmt = ".1fm" }, + { stat = "AreaOfEffectRadiusMetres", label = "AoE Radius", fmt = ".1fm", breakdown = "AreaOfEffectRadius", modNames = { "AreaOfEffect" } }, { stat = "BrandAttachmentRangeMetre", label = "Attachment Range", fmt = ".1fm", flag = "brand" }, { stat = "BrandTicks", label = "Activations per Brand", fmt = "d", flag = "brand" }, { stat = "ManaCost", label = "Mana Cost", fmt = "d", color = colorCodes.MANA, pool = "ManaUnreserved", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.ManaHasCost end }, @@ -92,11 +125,11 @@ local displayStats = { { stat = "RagePerSecondCost", label = "Rage Cost per second", fmt = ".2f", color = colorCodes.RAGE, pool = "Rage", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.RagePerSecondHasCost end }, { stat = "SoulCost", label = "Soul Cost", fmt = "d", color = colorCodes.RAGE, pool = "Soul", compPercent = true, lowerIsBetter = true, condFunc = function(v,o) return o.SoulHasCost end }, { }, - { stat = "Str", label = "Strength", color = colorCodes.STRENGTH, fmt = "d" }, + { stat = "Str", label = "Strength", color = colorCodes.STRENGTH, fmt = "d", breakdown = "Str" }, { stat = "ReqStr", label = "Strength Required", color = colorCodes.STRENGTH, fmt = "d", lowerIsBetter = true, condFunc = function(v,o) return v > o.Str end, warnFunc = function(v,o) return "You do not meet the Strength requirement of " .. (o.ReqStrItem.source == "Item" and o.ReqStrItem.sourceItem.name or o.ReqStrItem.source == "Gem" and o.ReqStrItem.sourceGem.nameSpec or o.ReqStrItem.source == "Support Gems" and "your total Strength support gems.") end }, - { stat = "Dex", label = "Dexterity", color = colorCodes.DEXTERITY, fmt = "d" }, + { stat = "Dex", label = "Dexterity", color = colorCodes.DEXTERITY, fmt = "d", breakdown = "Dex" }, { stat = "ReqDex", label = "Dexterity Required", color = colorCodes.DEXTERITY, fmt = "d", lowerIsBetter = true, condFunc = function(v,o) return v > o.Dex end, warnFunc = function(v,o) return "You do not meet the Dexterity requirement of " .. (o.ReqDexItem.source == "Item" and o.ReqDexItem.sourceItem.name or o.ReqDexItem.source == "Gem" and o.ReqDexItem.sourceGem.nameSpec or o.ReqDexItem.source == "Support Gems" and "your total Dexterity support gems.") end }, - { stat = "Int", label = "Intelligence", color = colorCodes.INTELLIGENCE, fmt = "d" }, + { stat = "Int", label = "Intelligence", color = colorCodes.INTELLIGENCE, fmt = "d", breakdown = "Int" }, { stat = "ReqInt", label = "Intelligence Required", color = colorCodes.INTELLIGENCE, fmt = "d", lowerIsBetter = true, condFunc = function(v,o) return v > o.Int end, warnFunc = function(v,o) return "You do not meet the Intelligence requirement of " .. (o.ReqIntItem.source == "Item" and o.ReqIntItem.sourceItem.name or o.ReqIntItem.source == "Gem" and o.ReqIntItem.sourceGem.nameSpec or o.ReqIntItem.source == "Support Gems" and "your total Intelligence support gems.") end }, { }, { stat = "Devotion", label = "Devotion", color = colorCodes.RARE, fmt = "d" }, @@ -117,20 +150,20 @@ local displayStats = { { stat = "Darkness", label = "Total Darkness", fmt = "d", color = colorCodes.CHAOS}, { stat = "ReservedDarkness", label = "Reserved Darkness", fmt ="d", color = colorCodes.CHAOS}, { }, - { stat = "Life", label = "Total Life", fmt = "d", color = colorCodes.LIFE, compPercent = true }, + { stat = "Life", label = "Total Life", fmt = "d", color = colorCodes.LIFE, compPercent = true, modNames = { "Life" }, ignoredSections = { "Base from Gear", "Inc. from Tree" } }, { stat = "Spec:LifeInc", label = "%Inc Life", fmt = "d%%", color = colorCodes.LIFE, condFunc = function(v,o) return v > 0 and o.Life > 1 end }, { stat = "LifeUnreserved", label = "Unreserved Life", fmt = "d", color = colorCodes.LIFE, condFunc = function(v,o) return v < o.Life end, compPercent = true, warnFunc = function(v) return v <= 0 and "Your unreserved Life is below 1" end }, { stat = "LifeRecoverable", label = "Life Recoverable", fmt = "d", color = colorCodes.LIFE, condFunc = function(v,o) return v < o.LifeUnreserved end, }, { stat = "LifeUnreservedPercent", label = "Unreserved Life", fmt = "d%%", color = colorCodes.LIFE, condFunc = function(v,o) return v < 100 end }, - { stat = "LifeRegenRecovery", label = "Life Regen", fmt = ".1f", color = colorCodes.LIFE, condFunc = function(v,o) return o.LifeRecovery <= 0 and o.LifeRegenRecovery ~= 0 end }, - { stat = "LifeRegenRecovery", label = "Life Recovery", fmt = ".1f", color = colorCodes.LIFE, condFunc = function(v,o) return o.LifeRecovery > 0 and o.LifeRegenRecovery ~= 0 end }, - { stat = "LifeLeechGainRate", label = "Life Leech/On Hit Rate", fmt = ".1f", color = colorCodes.LIFE, compPercent = true }, + { stat = "LifeRegenRecovery", label = "Life Regen", fmt = ".1f", color = colorCodes.LIFE, condFunc = function(v, o) return o.LifeRecovery <= 0 and o.LifeRegenRecovery ~= 0 end, breakdown = "LifeRegenRecovery" }, + { stat = "LifeRegenRecovery", label = "Life Recovery", fmt = ".1f", color = colorCodes.LIFE, condFunc = function(v, o) return o.LifeRecovery > 0 and o.LifeRegenRecovery ~= 0 end, breakdown = "LifeRegenRecovery" }, + { stat = "LifeLeechGainRate", label = "Life Leech/On Hit Rate", fmt = ".1f", color = colorCodes.LIFE, compPercent = true, breakdown = "LifeLeech" }, { stat = "LifeLeechGainPerHit", label = "Life Leech/Gain per Hit", fmt = ".1f", color = colorCodes.LIFE, compPercent = true }, { }, - { stat = "Mana", label = "Total Mana", fmt = "d", color = colorCodes.MANA, compPercent = true }, + { stat = "Mana", label = "Total Mana", fmt = "d", color = colorCodes.MANA, compPercent = true, modNames = { "Mana" }, ignoredSections = { "Base from Gear", "Inc. from Tree" } }, { stat = "Spec:ManaInc", label = "%Inc Mana from Tree", color = colorCodes.MANA, fmt = "d%%" }, { stat = "ManaUnreserved", label = "Unreserved Mana", fmt = "d", color = colorCodes.MANA, condFunc = function(v,o) return v < o.Mana end, compPercent = true, warnFunc = function(v) return v < 0 and "Your unreserved Mana is negative" end }, - { stat = "ManaUnreservedPercent", label = "Unreserved Mana", fmt = "d%%", color = colorCodes.MANA, condFunc = function(v,o) return v < 100 end }, + { stat = "ManaUnreservedPercent", label = "Unreserved Mana", fmt = "d%%", color = colorCodes.MANA, condFunc = function(v, o) return v < 100 end, breakdown = "ManaReserved" }, { stat = "ManaRegenRecovery", label = "Mana Regen", fmt = ".1f", color = colorCodes.MANA, condFunc = function(v,o) return o.ManaRecovery <= 0 and o.ManaRegenRecovery ~= 0 end }, { stat = "ManaRegenRecovery", label = "Mana Recovery", fmt = ".1f", color = colorCodes.MANA, condFunc = function(v,o) return o.ManaRecovery > 0 and o.ManaRegenRecovery ~= 0 end }, { stat = "ManaLeechGainRate", label = "Mana Leech/On Hit Rate", fmt = ".1f", color = colorCodes.MANA, compPercent = true }, @@ -140,7 +173,7 @@ local displayStats = { { stat = "SpiritUnreserved", label = "Unreserved Spirit", fmt = "d", color = colorCodes.SPIRIT, condFunc = function(v,o) return v < o.Spirit end, compPercent = true, warnFunc = function(v) return v < 0 and "Your unreserved Spirit is negative" end }, { stat = "SpiritUnreservedPercent", label = "Unreserved Spirit", fmt = "d%%", color = colorCodes.SPIRIT, condFunc = function(v,o) return v < 100 end }, { }, - { stat = "EnergyShield", label = "Energy Shield", fmt = "d", color = colorCodes.ES, compPercent = true }, + { stat = "EnergyShield", label = "Energy Shield", fmt = "d", color = colorCodes.ES, compPercent = true, modNames = { "EnergyShield", "Defences" }, ignoredSections = defenseIgnoredSections }, { stat = "EnergyShieldRecoveryCap", label = "Recoverable ES", color = colorCodes.ES, fmt = "d", condFunc = function(v,o) return o.CappingES end }, { stat = "Spec:EnergyShieldInc", label = "%Inc ES from Tree", color = colorCodes.ES, fmt = "d%%" }, { stat = "EnergyShieldRegenRecovery", label = "ES Regen", color = colorCodes.ES, fmt = ".1f", condFunc = function(v,o) return o.EnergyShieldRecovery <= 0 and o.EnergyShieldRegenRecovery ~= 0 end }, @@ -157,7 +190,7 @@ local displayStats = { { stat = "NetManaRegen", label = "Net Mana Recovery", fmt = "+.1f", color = colorCodes.MANA }, { stat = "NetEnergyShieldRegen", label = "Net ES Recovery", fmt = "+.1f", color = colorCodes.ES }, { }, - { stat = "Evasion", label = "Evasion Rating", fmt = "d", color = colorCodes.EVASION, compPercent = true }, + { stat = "Evasion", label = "Evasion rating", fmt = "d", color = colorCodes.EVASION, compPercent = true, modNames = { "Evasion", "ArmourAndEvasion", "Defences" }, ignoredSections = defenseIgnoredSections }, { stat = "Spec:EvasionInc", label = "%Inc Evasion from Tree", color = colorCodes.EVASION, fmt = "d%%" }, { stat = "EvadeChance", label = "Evade Chance", fmt = "d%%", color = colorCodes.EVASION, condFunc = function(v,o) return v > 0 and o.noSplitEvade end }, { stat = "MeleeEvadeChance", label = "Melee Evade Chance", fmt = "d%%", color = colorCodes.EVASION, condFunc = function(v,o) return v > 0 and o.splitEvade end }, @@ -167,15 +200,15 @@ local displayStats = { { stat = "DeflectionRating", label = "Deflection Rating", fmt = "d", color = colorCodes.EVASION, compPercent = true }, { stat = "DeflectChance", label = "Deflect Chance", fmt = "d%%", color = colorCodes.EVASION, compPercent = true }, { }, - { stat = "Armour", label = "Armour", fmt = "d", compPercent = true }, + { stat = "Armour", label = "Armour", fmt = "d", compPercent = true, modNames = { "Armour", "ArmourAndEvasion", "Defences", "ArmourDefense" }, ignoredSections = defenseIgnoredSections }, { stat = "Spec:ArmourInc", label = "%Inc Armour from Tree", fmt = "d%%" }, { stat = "PhysicalDamageReduction", label = "Phys. Damage Reduction", fmt = "d%%", condFunc = function() return true end }, { }, - { stat = "EffectiveBlockChance", label = "Block Chance", fmt = "d%%", overCapStat = "BlockChanceOverCap" }, - { stat = "EffectiveSpellBlockChance", label = "Spell Block Chance", fmt = "d%%", overCapStat = "SpellBlockChanceOverCap" }, + { stat = "EffectiveBlockChance", label = "Block Chance", fmt = ".3f%%", overCapStat = "BlockChanceOverCap", breakdown = "BlockChance" }, + { stat = "EffectiveSpellBlockChance", label = "Spell Block Chance", fmt = ".3f%%", overCapStat = "SpellBlockChanceOverCap", breakdown = "SpellBlockChance" }, { stat = "AttackDodgeChance", label = "Attack Dodge Chance", fmt = "d%%", overCapStat = "AttackDodgeChanceOverCap" }, { stat = "SpellDodgeChance", label = "Spell Dodge Chance", fmt = "d%%", overCapStat = "SpellDodgeChanceOverCap" }, - { stat = "EffectiveSpellSuppressionChance", label = "Spell Suppression Chance", fmt = "d%%", overCapStat = "SpellSuppressionChanceOverCap" }, + { stat = "EffectiveSpellSuppressionChance", label = "Spell Suppression Chance", fmt = ".2f%%", overCapStat = "SpellSuppressionChanceOverCap", breakdown = "", modNames = { "SpellSuppressionChance" } }, { }, { stat = "FireResist", label = "Fire Resistance", fmt = "d%%", color = colorCodes.FIRE, condFunc = function() return true end, overCapStat = "FireResistOverCap"}, { stat = "FireResistOverCap", label = "Fire Res. Over Max", fmt = "d%%", hideStat = true }, @@ -218,6 +251,7 @@ local displayStats = { { }, { stat = "SkillDPS", label = "Skill DPS", condFunc = function() return true end }, } +---@type DisplayStat[] local minionDisplayStats = { { stat = "AverageDamage", label = "Average Damage", fmt = ".1f", compPercent = true }, { stat = "Speed", label = "Attack/Cast Rate", fmt = ".2f", compPercent = true, condFunc = function(v,o) return v > 0 and (o.TriggerTime or 0) == 0 end }, @@ -241,14 +275,15 @@ local minionDisplayStats = { { stat = "ReservationDPS", label = "Reservation DPS", fmt = ".1f", compPercent = true, condFunc = function(v,o) return (o.ReservationDPS or 0) > 0 end }, { stat = "CombinedDPS", label = "Combined DPS", fmt = ".1f", compPercent = true, condFunc = function(v,o) return v ~= ((o.TotalDPS or 0) + (o.TotalDot or 0)) and v ~= o.WithImpaleDPS and v ~= o.WithPoisonDPS and v ~= o.WithIgniteDPS and v ~= o.WithBleedDPS end}, { stat = "Cooldown", label = "Skill Cooldown", fmt = ".3fs", lowerIsBetter = true }, - { stat = "Life", label = "Total Life", fmt = ".1f", color = colorCodes.LIFE, compPercent = true }, + { stat = "Life", label = "Total Life", fmt = ".1f", color = colorCodes.LIFE, compPercent = true, modNames = { "Life" }, ignoredSections = { "Base from Gear", "Inc. from Tree" } }, { stat = "LifeRegenRecovery", label = "Life Recovery", fmt = ".1f", color = colorCodes.LIFE }, { stat = "LifeLeechGainRate", label = "Life Leech/On Hit Rate", fmt = ".1f", color = colorCodes.LIFE, compPercent = true }, - { stat = "EnergyShield", label = "Energy Shield", fmt = "d", color = colorCodes.ES, compPercent = true }, + { stat = "EnergyShield", label = "Energy Shield", fmt = "d", color = colorCodes.ES, compPercent = true, modNames = { "EnergyShield", "Defences" }, ignoredSections = defenseIgnoredSections }, { stat = "EnergyShieldRegenRecovery", label = "ES Recovery", fmt = ".1f", color = colorCodes.ES }, { stat = "EnergyShieldLeechGainRate", label = "ES Leech/On Hit Rate", fmt = ".1f", color = colorCodes.ES, compPercent = true }, } -- Extra stats saved to the xml if not already saved there, mostly for 3rd party tools +---@type string[] local extraSaveStats = { "PowerCharges", "PowerChargesMax", diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 73b0edbc63..0c1f70b40e 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -3091,6 +3091,20 @@ function calcs.offence(env, actor, activeSkill) output.ChannelTime = m_max(skillData.channelTimeMultiplier / channelTime, minTime) output.ChannelSpeed = output.Speed or output.Time end + if breakdown then + if skillFlags.bothWeaponAttack then + breakdown.HitChance = { + "Both weapons:", + s_format("%.2f%% ^8(main hand)", output.MainHand.HitChance), + s_format("%.2f%% ^8(off hand)", output.OffHand.HitChance), + s_format("= %.2f%% ^8(average)", output.HitChance), + } + else + local handBreakdown = skillFlags.weapon1Attack and breakdown.MainHand or breakdown.OffHand + breakdown.HitChance = handBreakdown.HitChance or handBreakdown.AccuracyHitChance + breakdown.Speed = breakdown.Speed or handBreakdown.Speed + end + end if skillData.hitTimeOverride and not skillData.triggeredOnDeath then output.HitTime = skillData.hitTimeOverride output.HitSpeed = 1 / output.HitTime @@ -3783,9 +3797,10 @@ function calcs.offence(env, actor, activeSkill) local overCap = preCapCritChance - 100 t_insert(breakdown.CritChance, s_format("Crit is overcapped by %.2f%% (%d%% increased Critical Hit Chance)", overCap, overCap / more / (baseCrit + base) * 100)) end + breakdown.PreEffectiveCritChance = copyTable(breakdown.CritChance) if env.mode_effective and output.AccuracyHitChance < 100 then t_insert(breakdown.CritChance, "") - t_insert(breakdown.CritChance, "Effective Crit Chance:") + t_insert(breakdown.CritChance, "Crit confirmation roll:") t_insert(breakdown.CritChance, s_format("%.2f%%", preHitCheckCritChance)) t_insert(breakdown.CritChance, s_format("x %.2f ^8(chance to hit)", output.AccuracyHitChance / 100)) t_insert(breakdown.CritChance, s_format("= %.2f%%", preLuckyCritChance)) @@ -4665,6 +4680,36 @@ function calcs.offence(env, actor, activeSkill) skillFlags.showAverage = false skillFlags.notAverage = true end + if breakdown then + if skillFlags.bothWeaponAttack then + for _, critStat in ipairs({ "PreEffectiveCritChance", "CritChance" }) do + local combinedBreakdown = { "Both weapons:" } + for _, pass in ipairs(passList) do + t_insert(combinedBreakdown, pass.label .. ":") + if pass.breakdown[critStat] then + for _, line in ipairs(pass.breakdown[critStat]) do + t_insert(combinedBreakdown, line) + end + else + t_insert(combinedBreakdown, s_format("%.2f%%", pass.output[critStat])) + end + end + t_insert(combinedBreakdown, s_format("= %.2f%% ^8(average)", output[critStat])) + breakdown[critStat] = combinedBreakdown + end + else + local handBreakdown = skillFlags.weapon1Attack and breakdown.MainHand or breakdown.OffHand + breakdown.PreEffectiveCritChance = handBreakdown.PreEffectiveCritChance + breakdown.CritChance = handBreakdown.CritChance + end + local breakdownSource = skillFlags.weapon1Attack and "MainHand.CritChance" or "OffHand.CritChance" + if breakdown.PreEffectiveCritChance then + breakdown.PreEffectiveCritChance.breakdownSource = breakdownSource + end + if breakdown.CritChance then + breakdown.CritChance.breakdownSource = breakdownSource + end + end if skillFlags.bothWeaponAttack then if breakdown then breakdown.AverageDamage = { } diff --git a/src/Modules/CalcPerform.lua b/src/Modules/CalcPerform.lua index 6666bca25a..64f9382f24 100644 --- a/src/Modules/CalcPerform.lua +++ b/src/Modules/CalcPerform.lua @@ -1413,7 +1413,7 @@ function calcs.perform(env, skipEHP) output.LootRarityMagicEnemies = (lootRarityMagicEnemies > 0) and lootRarityMagicEnemies + modDB:Sum("INC", nil, "LootRarity") or 0 local breakdown = nil - if env.mode == "CALCS" then + if env.buildBreakdown then -- Initialise breakdown module breakdown = LoadModule(calcs.breakdownModule)(modDB, output, env.player) env.player.breakdown = breakdown diff --git a/src/Modules/CalcSections.lua b/src/Modules/CalcSections.lua index d885ad0c89..f4d9bf4bd4 100644 --- a/src/Modules/CalcSections.lua +++ b/src/Modules/CalcSections.lua @@ -46,7 +46,54 @@ local coldConvert = fillConvert("Cold") local fireConvert = fillConvert("Fire") local chaosConvert = fillConvert("Chaos") --- format {width, id, group, color, subsection:{default hidden, label, data:{}}} +---@class CalcSectionEntry A single description which is in the shown breakdown popup +---@field breakdown? string Key indicating which output breakdown should be displayed +---@field modName? string|string[] +---@field modType? "BASE"|"INC"|"MORE"|"OVERRIDE" +---@field cfg? string Config source +---@field enemy? boolean Whether the enemy modDB should be used +---@field actor? string Which actor should be used +---@field modSource? string Restrict mods to e.g. items +---@field label? string Heading shown above the resulting table +---@field control? table Injected by calcs for e.g. the skill details +---@field controlName? string +---@field skipSideBarIndex boolean? Set to true if this cell should be ignored while indexing the side bar breakdowns, and a later breakdown source should be preferred. + +---@class CalcSectionColumn : CalcSectionEntry One column cell: a format and descriptions for the popup +---@field [integer] CalcSectionEntry +---@field format? string Value format string (e.g. "{0:output:Life}") or literal header text + +---@class CalcSectionRow One row of a subsection +---@field [integer] CalcSectionColumn Column cells +---@field label? string Row label +---@field haveOutput? string Only show the row when this output value is non-zero +---@field flag? string Only show when the skill has this flag +---@field flagList? string[] Only show when the skill has all of these flags +---@field playerFlag? string Only show when the player skill has this flag +---@field notFlag? string Hide when the skill has this flag +---@field notFlagList? string[] Hide when the skill has any of these flags + +---@class CalcSectionData A subsection's rows plus subsection configuration values +---@field [integer] CalcSectionRow The rows +---@field extra? string Summary text shown beside the header (e.g. "{0:output:Life}") +---@field colWidth? integer Fixed width for value columns in this subsection +---@field flag? string Applied to the whole subsection (via subSection[1].data.flag) +---@field notFlag? string + +---@class CalcSectionSubsection +---@field defaultCollapsed boolean +---@field label string +---@field data CalcSectionData + +---@class CalcSection +---@field [1] integer Width in columns +---@field [2] string Section id +---@field [3] integer Group +---@field [4] string Colour escape code +---@field [5] CalcSectionSubsection[] Subsections +---@field [6] fun()? Optional update function + +---@type CalcSection[] return { { 3, "HitDamage", 1, colorCodes.OFFENCE, {{ defaultCollapsed = false, label = "Skill Hit Damage", data = { extra = "{output:DisplayDamage}", @@ -646,8 +693,8 @@ return { { label = "OH DMG Mod.", bgCol = colorCodes.OFFHANDBG, flag = "weapon2Attack", haveOutput = "OffHand.ImpaleModifier", { format = "{3:output:OffHand.ImpaleModifier}", modType = "MORE", { breakdown = "OffHand.ImpaleModifier" }, }, }, - { label = "Impale DPS", flag = "impale", flag = "notAverage", { format = "{1:output:ImpaleDPS}", { breakdown = "ImpaleDPS" }, }, }, - { label = "Impale Damage", flag = "impale", flag = "showAverage", { format = "{1:output:ImpaleDPS}", { breakdown = "ImpaleDPS" }, }, }, + { label = "Impale DPS", flag = "notAverage", { format = "{1:output:ImpaleDPS}", { breakdown = "ImpaleDPS" }, }, }, + { label = "Impale Damage", flag = "showAverage", { format = "{1:output:ImpaleDPS}", { breakdown = "ImpaleDPS" }, }, }, } } } }, { 1, "SkillTypeStats", 1, colorCodes.OFFENCE, {{ defaultCollapsed = false, label = "Skill type-specific Stats", data = { @@ -1645,7 +1692,7 @@ return { } }, { 1, "EnergyShield", 2, colorCodes.ES, {{ defaultCollapsed = false, label = "Energy Shield", data = { extra = "{0:output:EnergyShield}", - { label = "Base from Armours", { format = "{0:output:Gear:EnergyShield}", { breakdown = "EnergyShield", gearOnly = true }, }, }, + { label = "Base from Armours", { format = "{0:output:Gear:EnergyShield}", { breakdown = "EnergyShield", gearOnly = true, skipSideBarIndex = true }, }, }, { label = "Global Base", { format = "{0:mod:1}", { modName = { "EnergyShield", "EnergyShieldTotal" }, modType = "BASE" }, }, }, { label = "Inc. from Tree", { format = "{0:mod:1}%", { modName = "EnergyShield", modType = "INC", modSource = "Tree" }, }, }, { label = "Total Increased", { format = "{0:mod:1}%", { modName = { "EnergyShield", "Defences" }, modType = "INC" }, }, }, @@ -1732,7 +1779,7 @@ return { } }, { 1, "Armour", 3, colorCodes.ARMOUR, {{ defaultCollapsed = false, label = "Armour", data = { extra = "{0:output:Armour}", - { label = "Base from Armours", { format = "{0:output:Gear:Armour}", { breakdown = "Armour", gearOnly = true }, }, }, + { label = "Base from Armours", { format = "{0:output:Gear:Armour}", { breakdown = "Armour", gearOnly = true, skipSideBarIndex = true, }, }, }, { label = "Global Base", { format = "{0:mod:1}", { modName = { "Armour", "ArmourAndEvasion", "ArmourTotal" }, modType = "BASE" }, }, }, { label = "Inc. from Tree", { format = "{0:mod:1}%", { modName = { "Armour", "ArmourAndEvasion" }, modType = "INC", modSource = "Tree", }, }, }, { label = "Total Increased", { format = "{0:mod:1}%", { modName = { "Armour", "ArmourAndEvasion", "Defences" }, modType = "INC" }, }, }, @@ -1763,7 +1810,7 @@ return { } }, { 1, "Evasion", 3, colorCodes.EVASION, {{ defaultCollapsed = false, label = "Evasion", data = { extra = "{0:output:Evasion}", - { label = "Base from Armours", { format = "{0:output:Gear:Evasion}", { breakdown = "Evasion", gearOnly = true }, }, }, + { label = "Base from Armours", { format = "{0:output:Gear:Evasion}", { breakdown = "Evasion", gearOnly = true, skipSideBarIndex = true }, }, }, { label = "Global Base", { format = "{0:mod:1}", { modName = { "Evasion", "ArmourAndEvasion", "EvasionTotal" }, modType = "BASE" }, }, }, { label = "Inc. from Tree", { format = "{0:mod:1}%", { modName = { "Evasion", "ArmourAndEvasion" }, modType = "INC", modSource = "Tree" }, }, }, { label = "Total Increased", { format = "{0:mod:1}%", { modName = { "Evasion", "ArmourAndEvasion", "Defences" }, modType = "INC" }, }, }, diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index 153f437967..f4c2b257d5 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -574,6 +574,7 @@ function calcs.initEnv(build, mode, override, specEnv) env.configPlaceholder = build.configTab.placeholder env.calcsInput = build.calcsTab.input env.mode = mode + env.buildBreakdown = mode == "MAIN" or mode == "CALCS" env.spec = override.spec or build.spec env.classId = env.spec.curClassId diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index b2a4a0c791..d6142af6c8 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -482,6 +482,7 @@ end -- Process active skill function calcs.buildActiveSkill(env, mode, skill, targetUUID, limitedProcessingFlags) local fullEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override) + fullEnv.buildBreakdown = false -- env.limitedSkills contains a map of uuids that should be limited in calculation -- this is in order to prevent infinite recursion loops