Files

985 lines
38 KiB
Lua

--[[
CombatLedger - core: shared namespace, settings and saved layout,
appearance helpers, number formatting, diagnostics and the debug log.
CombatLedger reads Nampower's structured combat events (GUIDs, spell
ids, raw amounts, hit flags) rather than parsing localized combat-log
text. Load order (.toc): Core, GuidCache, Aggregator, Threat, Events,
History, then the UI files.
]]
-- Shared namespace; every file starts with `local CL = CombatLedger` and
-- attaches its module (CL.GuidCache, CL.Aggregator, ...).
CombatLedger = CombatLedger or {}
local CL = CombatLedger
-- Key Bindings panel labels for Bindings.xml's toggle action.
BINDING_HEADER_COMBATLEDGER_TITLE = "CombatLedger"
BINDING_NAME_COMBATLEDGER_TOGGLE = "Toggle CombatLedger windows"
CombatLedgerDB = CombatLedgerDB or { encounters = {} }
CombatLedgerDB.encounters = CombatLedgerDB.encounters or {}
CombatLedgerDB.settings = CombatLedgerDB.settings or {}
CL.db = CombatLedgerDB
-- Idle fallback for ending an encounter: this long without a relevant
-- combat event ends it even if combat state never changed (training
-- dummies don't toggle regen). Normal ends are combat-state driven - see
-- Events.lua's encounter lifecycle.
CL.IDLE_SECONDS = 12
-- User settings with their defaults (edited in UI_Options.lua). Only
-- values that differ from these are stored in CombatLedgerDB.settings.
CL.defaultSettings = {
matchPfui = true, -- while true (and pfUI is loaded), bar texture + font mirror pfUI's own instead of barTexture/fontKey/fontSize below
barTexture = "flat", -- pfUI's own flat bar look, bundled in img/bar.tga (see CL.GetBarTexture) - the default even without pfUI installed
hideBorder = false, -- ShaguDPS-style borderless window, independent of matchPfui
fontKey = "friz",
fontSize = 10,
barHeight = nil, -- nil = each window's own built-in default
smoothBars = true,
barSpeed = 8, -- 1 (slow drift) - 10 (near-instant), only used while smoothBars is on
numberFormat = "abbreviated", -- "abbreviated" (1.2k) / "full" (1,234) / "raw" (1234)
lockWindow = false,
showMinimapButton = true,
showBarTooltips = true, -- summary tooltip when hovering a meter bar (UI_MainWindow.lua's ShowBarTooltip)
announceChannel = "auto", -- "auto" (raid > party > say) / "say" / "party" / "raid" / "guild"
announceCount = 5,
windowOpacityPct = 81, -- background alpha, as a percent - ignored while matchPfui is on
-- Auto-show/auto-hide/grouped-only are per window: CL.GetWindowOption.
pfuiDock = false, -- dock the main window into pfUI's right chat panel (see UI_PfuiDock.lua) - opt-in, since it moves/resizes the window
showClassIcon = false, -- class icon before the name on each bar - opt-in, redundant with the existing class-colored bar fill for some tastes
classColorMenus = false, -- header/dropdown buttons take the player's class color instead of the flat near-black default - see CL.ApplyButtonSkin
highlightSelf = false, -- border around whichever bar is the player's own, in highlightSelfColor below - opt-in, some people find a border on every meter distracting
highlightSelfColor = { 1, 0.82, 0 }, -- Options color picker; gold by default
barBorderEnabled = false, -- border around EVERY bar, in barBorderColor below - independent of highlightSelf, which always wins on your own row regardless of this
barBorderColor = { 1, 1, 1 }, -- user-customizable via Options' color picker
-- Automatic resets of Current Fight + Overall (see Events.lua): "off" / "ask" / "always".
clearOnJoinPartyMode = "off", -- going from solo to grouped
clearOnLeavePartyMode = "off", -- going from grouped to solo
clearOnEnterInstanceMode = "off", -- entering an instance (not a re-entry after a short absence)
announcePulls = true, -- "Pull: X (spell)" chat print at the start of a boss encounter (not regular elite trash) - see Bosses.lua
maxEncounters = 50, -- Options' "Saved fights": history kept per character, oldest dropped first (Skada's "Saved fights")
historyBossOnly = false, -- Options' "Remember boss fights only": only boss pulls are saved to History (Skada's option of the same name)
mergePets = true, -- pets roll up into their owner's bar in every meter mode (Skada's "Merge pets into owners"); off = pets get their own rows. Threat always keeps pets separate - see Threat.lua
}
-- SavedVariables are loaded after this file runs and replace
-- CombatLedgerDB wholesale, so every accessor re-ensures its sub-table
-- instead of trusting the load-time defaults above.
local function EnsureSettingsTable()
if not CombatLedgerDB.settings then
CombatLedgerDB.settings = {}
end
end
function CL.GetSetting(key)
EnsureSettingsTable()
local v = CombatLedgerDB.settings[key]
if v == nil then return CL.defaultSettings[key] end
return v
end
function CL.SetSetting(key, value)
EnsureSettingsTable()
CombatLedgerDB.settings[key] = value
end
-- Per-window size/position. `key` is a window id ("main", "breakdown",
-- "deathRecap", "history", or an extra meter window's id).
local function EnsureLayoutTable()
if not CombatLedgerDB.layout then
CombatLedgerDB.layout = {}
end
end
function CL.GetLayout(key)
EnsureLayoutTable()
return CombatLedgerDB.layout[key]
end
function CL.SaveLayout(key, frame)
EnsureLayoutTable()
local point, _, relPoint, x, y = frame:GetPoint(1)
CombatLedgerDB.layout[key] = {
width = frame:GetWidth(),
height = frame:GetHeight(),
point = point or "CENTER",
relPoint = relPoint or "CENTER",
x = x or 0,
y = y or 0,
}
end
-- Applies a saved layout if one exists (returns true); otherwise leaves
-- the frame's defaults alone (false). Optional min/max bounds clamp a
-- saved size into what the window's current layout can fit.
function CL.ApplyLayout(key, frame, minW, minH, maxW, maxH)
local saved = CL.GetLayout(key)
if not saved then return false end
if saved.width then
local w = saved.width
if minW and w < minW then w = minW end
if maxW and w > maxW then w = maxW end
frame:SetWidth(w)
end
if saved.height then
local h = saved.height
if minH and h < minH then h = minH end
if maxH and h > maxH then h = maxH end
frame:SetHeight(h)
end
frame:ClearAllPoints()
frame:SetPoint(saved.point or "CENTER", UIParent, saved.relPoint or "CENTER", saved.x or 0, saved.y or 0)
return true
end
-- Meter windows and their mode/segment/threat filter. A non-"main" key
-- here is what makes UI.RestoreAllWindows recreate that extra window on
-- login; closing a window forgets its key.
local function EnsureWindowsTable()
if not CombatLedgerDB.windows then
CombatLedgerDB.windows = {}
end
end
function CL.GetWindowState(id)
EnsureWindowsTable()
return CombatLedgerDB.windows[id]
end
function CL.SaveWindowState(id, mode, segment, threatFilter)
EnsureWindowsTable()
CombatLedgerDB.windows[id] = { mode = mode, segment = segment, threatFilter = threatFilter }
end
function CL.ForgetWindowState(id)
EnsureWindowsTable()
CombatLedgerDB.windows[id] = nil
if CombatLedgerDB.windowOptions then
CombatLedgerDB.windowOptions[id] = nil
end
end
-- Per-window behavior toggles (auto-show, auto-hide, grouped only).
-- Stored apart from SaveWindowState's record, which is replaced whole on
-- every mode/segment change.
local function EnsureWindowOptionsTable()
if not CombatLedgerDB.windowOptions then
CombatLedgerDB.windowOptions = {}
end
end
function CL.GetWindowOption(id, key, default)
EnsureWindowOptionsTable()
local opts = CombatLedgerDB.windowOptions[id]
if not opts or opts[key] == nil then return default end
return opts[key]
end
function CL.SetWindowOption(id, key, value)
EnsureWindowOptionsTable()
if not CombatLedgerDB.windowOptions[id] then
CombatLedgerDB.windowOptions[id] = {}
end
CombatLedgerDB.windowOptions[id][key] = value
end
-- Every remembered window id other than "main" (which is handled
-- separately since it always exists and is never closable).
function CL.GetExtraWindowIds()
EnsureWindowsTable()
local ids = {}
local id, _
for id, _ in pairs(CombatLedgerDB.windows) do
if id ~= "main" then
table.insert(ids, id)
end
end
return ids
end
-- pfUI presence comes from the addon manager, not the `pfUI` global:
-- other addons on this client can create a pfUI-shaped table even when
-- pfUI itself isn't loaded.
function CL.HasPfui()
local ok, loaded = pcall(IsAddOnLoaded, "pfUI")
return ok and loaded and true or false
end
-- "Match pfUI" (default on, only with pfUI loaded) uses pfUI's own bar
-- texture, font and window skin; off, the manual barTexture/fontKey/
-- fontSize settings apply (Options greys them out while matching).
function CL.IsMatchPfui()
return CL.HasPfui() and (CL.GetSetting("matchPfui") ~= false)
end
-- Manual bar textures. All but "blizzard" are bundled in img/ (the
-- pfUI-derived ones under pfUI's MIT license - see README), so they work
-- without pfUI installed. pfUI itself only exposes its one selected bar
-- texture (media["img:bar"]), which is what "Match pfUI" uses instead.
CL.BAR_TEXTURES = {
{ key = "flat", label = "Flat (default)" },
{ key = "blizzard", label = "Blizzard Default" },
{ key = "raid", label = "Smooth Gradient", file = "bar_smooth" },
{ key = "elvui", label = "ElvUI Style", file = "bar_elvui" },
{ key = "gradient", label = "pfUI Gradient", file = "bar_gradient" },
{ key = "striped", label = "Striped", file = "bar_striped" },
{ key = "tukui", label = "TukUI Style", file = "bar_tukui" },
}
function CL.GetAvailableBarTextures()
return CL.BAR_TEXTURES
end
function CL.GetBarTexture()
if CL.IsMatchPfui() and pfUI.media and pfUI.media["img:bar"] then
return pfUI.media["img:bar"]
end
local key = CL.GetSetting("barTexture") or "flat"
if key == "flat" then
return "Interface\\AddOns\\CombatLedger\\img\\bar"
end
if key ~= "blizzard" then
local i
for i = 1, table.getn(CL.BAR_TEXTURES) do
local t = CL.BAR_TEXTURES[i]
if t.key == key and t.file then
return "Interface\\AddOns\\CombatLedger\\img\\" .. t.file
end
end
end
return "Interface\\TargetingFrame\\UI-StatusBar"
end
-- The client's own built-in font files - no LibSharedMedia dependency,
-- so this works standalone. Covers the handful of fonts every 1.12
-- client ships with.
CL.FONTS = {
{ key = "friz", label = "Friz Quadrata (default)", path = "Fonts\\FRIZQT__.TTF" },
{ key = "arial", label = "Arial Narrow", path = "Fonts\\ARIALN.TTF" },
{ key = "skurri", label = "Skurri", path = "Fonts\\SKURRI.TTF" },
{ key = "morpheus", label = "Morpheus", path = "Fonts\\MORPHEUS.ttf" },
-- Bundled (fonts/Expressway.ttf), not a stock client font - see README.
{ key = "expressway", label = "Expressway", path = "Interface\\AddOns\\CombatLedger\\fonts\\Expressway.ttf" },
}
function CL.GetFontPath()
if CL.IsMatchPfui() and pfUI.font_default then
return pfUI.font_default
end
local key = CL.GetSetting("fontKey") or "friz"
local i
for i = 1, table.getn(CL.FONTS) do
if CL.FONTS[i].key == key then return CL.FONTS[i].path end
end
return "Fonts\\FRIZQT__.TTF"
end
function CL.GetFontSize()
if CL.IsMatchPfui() and pfUI_config and pfUI_config.global and pfUI_config.global.font_size then
return pfUI_config.global.font_size
end
return CL.GetSetting("fontSize") or 10
end
-- Applies the chosen font family to a FontString (at creation, and again
-- from appearance-changed listeners). `size` is for bar text, which the
-- "Font size" option controls; omit it elsewhere to keep the FontString's
-- own template size.
function CL.ApplyFont(fontString, size)
if not fontString then return end
if not size then
local _, curSize = fontString:GetFont()
size = curSize or CL.GetFontSize()
end
fontString:SetFont(CL.GetFontPath(), size, "OUTLINE")
end
-- Applies the font family (native sizes kept) to every FontString under
-- a frame. Callers that want bar text at CL.GetFontSize() apply that
-- afterwards.
function CL.ApplyFontToTree(frame)
if not frame then return end
local regions = { frame:GetRegions() }
local i
for i = 1, table.getn(regions) do
local r = regions[i]
if r.SetFont and r.GetFont then
CL.ApplyFont(r)
end
end
local children = { frame:GetChildren() }
for i = 1, table.getn(children) do
CL.ApplyFontToTree(children[i])
end
end
-- The Options bar-height override, or the window's own `default`.
function CL.GetBarHeight(default)
return CL.GetSetting("barHeight") or default
end
-- Frame strata, lowest to highest. Shadows sit one tier below their
-- window and dropdowns one tier above their anchor, so nothing from other
-- addons can land in between.
CL.STRATA_ORDER = { "BACKGROUND", "LOW", "MEDIUM", "HIGH", "DIALOG", "FULLSCREEN", "FULLSCREEN_DIALOG", "TOOLTIP" }
function CL.NextLowerStrata(strata)
local i
for i = 1, table.getn(CL.STRATA_ORDER) do
if CL.STRATA_ORDER[i] == strata then
return CL.STRATA_ORDER[math.max(1, i - 1)]
end
end
return "BACKGROUND" -- unrecognized input - safest fallback
end
-- One tier above `strata` (used by CL.ShowDropdown).
function CL.NextHigherStrata(strata)
local i
for i = 1, table.getn(CL.STRATA_ORDER) do
if CL.STRATA_ORDER[i] == strata then
return CL.STRATA_ORDER[math.min(table.getn(CL.STRATA_ORDER), i + 1)]
end
end
return "TOOLTIP" -- unrecognized input - safest fallback (definitely on top)
end
-- Window background alpha; only used while "Match pfUI" is off.
function CL.GetWindowOpacity()
return (CL.GetSetting("windowOpacityPct") or 81) / 100
end
-- `fallback` is the window's own alpha, used while matching pfUI.
function CL.GetBackdropAlpha(fallback)
if CL.IsMatchPfui() then return fallback end
return CL.GetWindowOpacity()
end
-- Flat 1px-edged panel matching pfUI's default window backdrop; colors
-- are set separately with SetBackdropColor/SetBackdropBorderColor.
CL.WINDOW_BACKDROP = {
bgFile = "Interface\\BUTTONS\\WHITE8X8", tile = false, tileSize = 0,
edgeFile = "Interface\\BUTTONS\\WHITE8X8", edgeSize = 1,
insets = { left = -1, right = -1, top = -1, bottom = -1 },
}
-- Soft drop shadow (bundled img/glow2.tga, pfUI's), drawn 5px outside
-- the window on every side.
CL.WINDOW_SHADOW = {
edgeFile = "Interface\\AddOns\\CombatLedger\\img\\glow2", edgeSize = 8,
insets = { left = 0, right = 0, top = 0, bottom = 0 },
}
-- Near-black border used by the manual skin for windows and buttons.
-- Chrome stays neutral; only text is class-colored.
CL.FLAT_BORDER_R, CL.FLAT_BORDER_G, CL.FLAT_BORDER_B = 0.059, 0.059, 0.059
-- Skins a window with pfUI's backdrop while "Match pfUI" is on, otherwise
-- with the manual flat backdrop. Called at creation and on every
-- appearance change, so it must switch cleanly both ways: pfUI's
-- CreateBackdrop puts its skin on child frames (f.backdrop,
-- f.backdrop_shadow) and clears f's own backdrop only the first time, so
-- manual mode hides those children and pfUI mode clears f's backdrop
-- itself every time.
function CL.ApplyWindowSkin(f, borderR, borderG, borderB, opacityFallback)
if CL.IsMatchPfui() and pfUI.api then
local ok = pcall(function()
pfUI.api.CreateBackdrop(f)
pfUI.api.CreateBackdropShadow(f)
end)
if ok and f.backdrop then
f:SetBackdrop(nil)
f.backdrop:Show()
-- Border left as pfUI draws it; only text is class-colored.
if f.backdrop_shadow then f.backdrop_shadow:Show() end
return
end
end
if f.backdrop then f.backdrop:Hide() end
if f.backdrop_shadow then f.backdrop_shadow:Hide() end
f:SetBackdrop(CL.WINDOW_BACKDROP)
f:SetBackdropColor(0, 0, 0, CL.GetBackdropAlpha(opacityFallback))
-- "Hide border" (manual skin only): the edge goes transparent.
if CL.GetSetting("hideBorder") then
f:SetBackdropBorderColor(0, 0, 0, 0)
else
f:SetBackdropBorderColor(CL.FLAT_BORDER_R, CL.FLAT_BORDER_G, CL.FLAT_BORDER_B, 1)
end
-- Manual-skin shadow: a child frame created once, then shown/hidden.
if not CL.GetSetting("hideBorder") then
if not f.flatShadow then
f.flatShadow = CreateFrame("Frame", nil, f)
-- One tier below the window's own strata (see STRATA_ORDER).
f.flatShadow:SetFrameStrata(CL.NextLowerStrata(f:GetFrameStrata()))
f.flatShadow:SetFrameLevel(1)
f.flatShadow:SetPoint("TOPLEFT", f, "TOPLEFT", -5, 5)
f.flatShadow:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 5, -5)
f.flatShadow:SetBackdrop(CL.WINDOW_SHADOW)
end
f.flatShadow:SetBackdropBorderColor(0, 0, 0, 0.35)
f.flatShadow:Show()
elseif f.flatShadow then
f.flatShadow:Hide()
end
end
-- A header button's resting fill color: pfUI's configured background
-- while matching pfUI, otherwise the flat default. UI_MainWindow's
-- SetButtonTooltip re-asserts it every frame, so it must match whatever
-- skin is active.
function CL.GetButtonNormalFill()
if CL.IsMatchPfui() and pfUI.api and pfUI.api.GetStringColor and pfUI_config then
local ok, r, g, b, a = pcall(pfUI.api.GetStringColor, pfUI_config.appearance.border.background)
if ok and r then return r, g, b, a end
end
return 0.12, 0.12, 0.14, 0.9
end
function CL.ApplyButtonSkin(btn, borderR, borderG, borderB)
local matchedPfui = false
if CL.IsMatchPfui() and pfUI.api then
local ok = pcall(pfUI.api.SkinButton, btn, nil, nil, nil, nil, true)
matchedPfui = ok
end
-- "Class colored menus" overrides the border in either skin.
if CL.GetSetting("classColorMenus") then
btn:SetBackdropBorderColor(borderR, borderG, borderB, 1)
elseif not matchedPfui then
btn:SetBackdropBorderColor(CL.FLAT_BORDER_R, CL.FLAT_BORDER_G, CL.FLAT_BORDER_B, 1)
end
end
-- Shared click-menu (the meter's Mode/Segment buttons, Options pickers):
-- a plain frame with pooled rows instead of UIDropDownMenu. One can be
-- open at a time; an invisible full-screen catcher closes it on an
-- outside click.
local dropdownFrame = nil
local dropdownCatcher = nil -- full-screen invisible button that closes the menu on an outside click
function CL.CloseDropdown()
if dropdownFrame then dropdownFrame:Hide() end
if dropdownCatcher then dropdownCatcher:Hide() end
end
-- `options` is an array of { label, onClick, color (optional {r,g,b}) }.
function CL.ShowDropdown(anchor, options)
if not dropdownCatcher then
dropdownCatcher = CreateFrame("Button", nil, UIParent)
dropdownCatcher:SetAllPoints(UIParent)
dropdownCatcher:SetFrameLevel(1)
dropdownCatcher:EnableMouse(true)
dropdownCatcher:RegisterForClicks("LeftButtonUp", "RightButtonUp")
dropdownCatcher:SetScript("OnClick", CL.CloseDropdown)
end
if not dropdownFrame then
dropdownFrame = CreateFrame("Frame", nil, UIParent)
-- Same strata as the catcher, one level above it.
dropdownFrame:SetFrameLevel(2)
dropdownFrame:SetBackdrop(CL.WINDOW_BACKDROP)
dropdownFrame:SetBackdropColor(0.05, 0.05, 0.05, 0.97)
dropdownFrame:SetBackdropBorderColor(CL.FLAT_BORDER_R, CL.FLAT_BORDER_G, CL.FLAT_BORDER_B, 1)
dropdownFrame.rows = {}
end
-- One tier above the anchor's current strata, recomputed per open.
local dropStrata = CL.NextHigherStrata(anchor:GetFrameStrata())
dropdownCatcher:SetFrameStrata(dropStrata)
dropdownFrame:SetFrameStrata(dropStrata)
dropdownCatcher:Show()
local ROW_H = 16
local width = 150
local count = table.getn(options)
local height = count * ROW_H + 6
dropdownFrame:SetWidth(width)
dropdownFrame:SetHeight(height)
dropdownFrame:ClearAllPoints()
-- Opens upward when there's no room below the anchor.
local anchorBottom = anchor:GetBottom() or 0
if anchorBottom - height < 10 then
dropdownFrame:SetPoint("BOTTOM", anchor, "TOP", 0, 2)
else
dropdownFrame:SetPoint("TOP", anchor, "BOTTOM", 0, -2)
end
local i
for i = 1, count do
local row = dropdownFrame.rows[i]
if not row then
row = CreateFrame("Button", nil, dropdownFrame)
row:SetHeight(ROW_H)
row:EnableMouse(true)
row:RegisterForClicks("LeftButtonUp")
local hl = row:CreateTexture(nil, "HIGHLIGHT")
hl:SetAllPoints(row)
hl:SetTexture("Interface\\Buttons\\WHITE8X8")
hl:SetVertexColor(1, 1, 1, 0.15)
local text = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
text:SetPoint("LEFT", row, "LEFT", 4, 0)
text:SetJustifyH("LEFT")
CL.ApplyFont(text)
row.text = text
dropdownFrame.rows[i] = row
end
row:ClearAllPoints()
row:SetPoint("TOPLEFT", dropdownFrame, "TOPLEFT", 3, -3 - (i - 1) * ROW_H)
row:SetPoint("TOPRIGHT", dropdownFrame, "TOPRIGHT", -3, -3 - (i - 1) * ROW_H)
row.text:SetText(options[i].label)
-- Rows are pooled, so every row sets its color (white by default).
local color = options[i].color
if color then
row.text:SetTextColor(color[1], color[2], color[3])
else
row.text:SetTextColor(1, 1, 1)
end
local onClick = options[i].onClick
row:SetScript("OnClick", function()
CL.CloseDropdown()
if onClick then onClick() end
end)
row:Show()
end
local j
for j = count + 1, table.getn(dropdownFrame.rows) do
dropdownFrame.rows[j]:Hide()
end
dropdownFrame:Show()
end
function CL.IsSmoothBars()
local v = CL.GetSetting("smoothBars")
if v == nil then return true end
return v
end
function CL.GetBarSpeed()
return CL.GetSetting("barSpeed") or 8
end
-- Re-stacks a pooled bar list after a bar-height change (each bar's Y
-- offset depends on the height).
function CL.RepositionBarPool(pool, height, gap)
local i
for i = 1, table.getn(pool) do
local bar = pool[i]
bar:SetHeight(height)
bar:ClearAllPoints()
bar:SetPoint("TOPLEFT", bar:GetParent(), "TOPLEFT", 0, -((i - 1) * (height + gap)))
bar:SetPoint("TOPRIGHT", bar:GetParent(), "TOPRIGHT", 0, -((i - 1) * (height + gap)))
end
end
-- Number formatting, shared by every window (Options: Number format).
CL.NUMBER_FORMATS = {
{ key = "abbreviated", label = "Abbreviated (1.2k)" },
{ key = "full", label = "Full (1,234)" },
{ key = "raw", label = "Raw (1234)" },
}
local function AddCommas(numStr)
local formatted = numStr
local k
while true do
formatted, k = string.gsub(formatted, "^(-?%d+)(%d%d%d)", "%1,%2")
if k == 0 then break end
end
return formatted
end
-- Rate unit label per mode ("DPS", "HPS", "DTPS"; "/s" otherwise).
CL.RATE_SUFFIXES = { damage = "DPS", healing = "HPS", taken = "DTPS" }
function CL.RateSuffix(mode)
return CL.RATE_SUFFIXES[mode] or "/s"
end
function CL.FormatNumber(n)
n = n or 0
if n < 0 then n = 0 end
local mode = CL.GetSetting("numberFormat") or "abbreviated"
if mode == "raw" then
return tostring(math.floor(n))
elseif mode == "full" then
return AddCommas(tostring(math.floor(n)))
end
if n >= 1000000 then
return string.format("%.1fm", n / 1000000)
elseif n >= 1000 then
return string.format("%.1fk", n / 1000)
end
return tostring(math.floor(n))
end
-- Overheal/unverified lines for any healing bucket or spell entry (see
-- Aggregator.lua's AddHeal), shared by the main tooltip and the breakdown
-- panel. addLine(label, value, dim). Entries without `raw` (saved before
-- overheal tracking) show nothing.
function CL.AddOverhealLines(t, addLine)
local raw = t and t.raw
if not raw or raw <= 0 then return end
local over = t.overheal or 0
addLine("Overheal (est.)", string.format("%s (%.0f%%)", CL.FormatNumber(over), over / raw * 100))
local unverified = t.unverified or 0
if unverified > 0 then
addLine("Unverified", string.format("%s (%.0f%%)", CL.FormatNumber(unverified), unverified / raw * 100), true)
end
end
-- Display order for avoidance/miss outcomes. Melee entries always carry
-- the first set (Aggregator.lua's NewAvoided); spell misses
-- (SPELL_MISS_*) can add resist/absorb/reflect, so every key is optional.
CL.AVOID_KEYS = { "dodge", "parry", "miss", "resist", "block", "absorb", "reflect", "evade", "immune", "deflect", "other" }
-- Returns the total count and a "3 dodge, 1 parry" summary (nil if empty).
function CL.SummarizeAvoided(av)
if not av then return 0, nil end
local total, parts = 0, {}
local i
for i = 1, table.getn(CL.AVOID_KEYS) do
local key = CL.AVOID_KEYS[i]
local n = av[key] or 0
if n > 0 then
total = total + n
table.insert(parts, n .. " " .. key)
end
end
if total == 0 then return 0, nil end
return total, table.concat(parts, ", ")
end
-- Landed attempts for one spell, to compare its misses against: its
-- hits - but for a DoT, its casts or direct hits (a tick isn't an
-- attempt) - or, for a spell that never dealt damage (Sunder Armor,
-- Taunt), its recorded casts from `casts` (a unit's pendingCasts). nil
-- when nothing is known.
local function LandedAttempts(entry, castCount)
if entry then
if entry.tickHits and entry.tickHits.hits > 0 then
if entry.directHits and entry.directHits.hits > 0 then return entry.directHits.hits end
return entry.casts or entry.hits
end
return entry.hits
end
return castCount
end
-- Miss/dodge/parry/... lines for a damage bucket (unit-wide or one
-- target), shared by the bar tooltip and the breakdown summary:
-- swings: white melee swings avoided out of all swings, with the split
-- abilities: everything that isn't a white swing - spells AND melee
-- specials (Stormstrike, Sunder Armor...), which is why dodge
-- and parry show up here - missed out of all attempts (see
-- LandedAttempts), with the split
-- addLine(label, value) for a value row, addNote(text) for the grey split.
-- `casts` (optional) is the unit's pendingCasts, which supplies attempts
-- for spells that never dealt damage. `mode` "taken" words it as avoided
-- rather than missed. Returns the number of landed melee swings (for
-- glancing/crushing percentages).
function CL.AddAvoidanceLines(bucket, mode, addLine, addNote, casts)
if not bucket then return 0 end
local function AddSums(sum, av)
local k, v
for k, v in pairs(av) do sum[k] = (sum[k] or 0) + v end
end
local swingSum, swingHits = {}, 0
local _, entry
for _, entry in ipairs({ bucket.melee, bucket.offhand, bucket.petMelee, bucket.petOffhand }) do
if entry then
swingHits = swingHits + (entry.hits or 0)
if entry.avoided then AddSums(swingSum, entry.avoided) end
end
end
local avoided, summary = CL.SummarizeAvoided(swingSum)
if avoided > 0 then
local swings = swingHits + avoided
addLine((mode == "taken") and "Swings avoided" or "Swings missed",
string.format("%d/%d (%.0f%%)", avoided, swings, avoided / swings * 100))
addNote(summary)
end
if bucket.spellMisses then
local spellSum = {}
local knownMisses, knownAttempts = 0, 0
local spellId, av
for spellId, av in pairs(bucket.spellMisses) do
AddSums(spellSum, av)
local missed = CL.SummarizeAvoided(av)
local landed = LandedAttempts(bucket.spells and bucket.spells[spellId], casts and casts[spellId])
if landed then
knownMisses = knownMisses + missed
knownAttempts = knownAttempts + missed + landed
end
end
local spellAvoided, spellSummary = CL.SummarizeAvoided(spellSum)
if spellAvoided > 0 then
local label = (mode == "taken") and "Abilities avoided" or "Abilities missed"
if knownAttempts > 0 and knownMisses == spellAvoided then
addLine(label, string.format("%d/%d (%.0f%%)", spellAvoided, knownAttempts,
spellAvoided / knownAttempts * 100))
else
-- Some missed spells have no recorded attempts to compare
-- against (e.g. a filtered target), so no percentage.
addLine(label, tostring(spellAvoided))
end
addNote(spellSummary)
end
end
return swingHits
end
-- Mitigation lines for anything carrying a `mit` table (Aggregator.lua's
-- ApplyMitigation), shared by the main tooltip and the breakdown panel.
-- meleeHits = the hit count glancing/crushing percentages are taken of
-- (they only ever happen on white melee swings). addLine(label, value).
function CL.AddMitigationLines(t, meleeHits, addLine)
local m = t and t.mit
if not m then return end
local function Amount(label, key)
local amount = m[key] or 0
if amount > 0 then
local n = m[key .. "Hits"] or 0
addLine(label, string.format("%s (%d %s)", CL.FormatNumber(amount), n, (n == 1) and "hit" or "hits"))
end
end
local function Count(label, key)
local n = m[key] or 0
if n > 0 then
if meleeHits and meleeHits > 0 then
addLine(label, string.format("%d (%.0f%%)", n, n / meleeHits * 100))
else
addLine(label, tostring(n))
end
end
end
Amount("Absorbed", "absorbed")
Amount("Blocked", "blocked")
Amount("Resisted", "resisted")
Count("Glancing", "glancing")
Count("Crushing", "crushing")
end
-- Appearance-changed notifications: each UI file registers a listener
-- that re-applies fonts/textures/sizes; Options fires it after a change.
CL.appearanceListeners = {}
function CL.OnAppearanceChanged(fn)
table.insert(CL.appearanceListeners, fn)
end
function CL.FireAppearanceChanged()
local i
for i = 1, table.getn(CL.appearanceListeners) do
local ok, err = pcall(CL.appearanceListeners[i])
if not ok then
CL.Print("Appearance listener error: " .. tostring(err))
end
end
end
-- Icon for melee rows, which have no spellId to look an icon up from.
CL.MELEE_ICON = "Interface\\Icons\\Ability_MeleeDamage"
-- Class icon atlas coordinates (img/classicons.tga, 4x3 grid). Kept
-- locally since CLASS_ICON_TCOORDS isn't guaranteed on this client.
CL.CLASS_ICON_TCOORDS = {
WARRIOR = { 0, 0.25, 0, 0.25 },
MAGE = { 0.25, 0.49609375, 0, 0.25 },
ROGUE = { 0.49609375, 0.7421875, 0, 0.25 },
DRUID = { 0.7421875, 0.98828125, 0, 0.25 },
HUNTER = { 0, 0.25, 0.25, 0.5 },
SHAMAN = { 0.25, 0.49609375, 0.25, 0.5 },
PRIEST = { 0.49609375, 0.7421875, 0.25, 0.5 },
WARLOCK = { 0.7421875, 0.98828125, 0.25, 0.5 },
PALADIN = { 0, 0.25, 0.5, 0.75 },
}
-- spellId -> icon texture via the spell DBC, so it works for any spell
-- seen in combat, not only the player's own spellbook.
function CL.GetSpellIcon(spellId)
if not spellId then return nil end
if type(GetSpellRecField) ~= "function" or type(GetSpellIconTexture) ~= "function" then return nil end
local okId, iconId = pcall(GetSpellRecField, spellId, "spellIconID")
if not okId or not iconId or iconId <= 0 then return nil end
local okTex, tex = pcall(GetSpellIconTexture, iconId)
if okTex and type(tex) == "string" and tex ~= "" then
return tex
end
return nil
end
-- Addon accent color (epic purple).
CL.ACCENT_HEX = "a335ee"
CL.ACCENT_R, CL.ACCENT_G, CL.ACCENT_B = 0.64, 0.21, 0.93
-- Theme color for window chrome and titles: the player's class color,
-- or the accent if that can't be read. Returns r, g, b, hex.
function CL.GetThemeColor()
local ok, _, classToken = pcall(UnitClass, "player")
if ok and classToken and RAID_CLASS_COLORS and RAID_CLASS_COLORS[classToken] then
local c = RAID_CLASS_COLORS[classToken]
return c.r, c.g, c.b, string.format("%02x%02x%02x", c.r * 255, c.g * 255, c.b * 255)
end
return CL.ACCENT_R, CL.ACCENT_G, CL.ACCENT_B, CL.ACCENT_HEX
end
-- Announce channels. "auto" resolves at announce time: raid, else
-- party, else say.
-- Choices for the automatic Overall reset rules.
CL.RESET_MODES = {
{ key = "off", label = "Off" },
{ key = "ask", label = "Ask" },
{ key = "always", label = "Always" },
}
CL.ANNOUNCE_CHANNELS = {
{ key = "auto", label = "Auto (raid/party)" },
{ key = "say", label = "Say" },
{ key = "party", label = "Party" },
{ key = "raid", label = "Raid" },
{ key = "guild", label = "Guild" },
}
function CL.ResolveAnnounceChannel()
local key = CL.GetSetting("announceChannel") or "auto"
if key == "auto" then
if GetNumRaidMembers and GetNumRaidMembers() > 0 then return "RAID" end
if GetNumPartyMembers and GetNumPartyMembers() > 0 then return "PARTY" end
return "SAY"
end
return string.upper(key)
end
-- Bounds for Options' "Saved fights" (setting maxEncounters) - see
-- History.lua's trim-oldest cap.
CL.MIN_SAVED_FIGHTS, CL.MAX_SAVED_FIGHTS, CL.SAVED_FIGHTS_STEP = 5, 50, 5
function CL.Print(msg)
DEFAULT_CHAT_FRAME:AddMessage("|cff" .. CL.ACCENT_HEX .. "CombatLedger:|r " .. msg)
end
--------------------------------------------------------------------------
-- Diagnostics (shown by /cl status)
--
-- Event handlers and OnUpdate ticks run under pcall and report failures
-- here instead of letting one bad event break a handler for the session.
-- Each error tag is announced in chat once; repeats are only counted.
--------------------------------------------------------------------------
CL.Diagnostics = {
eventCounts = {}, -- [eventName] = times received this session
errorCounts = {}, -- [tag] = errors caught this session
lastErrors = {}, -- [tag] = most recent error message
}
function CL.CountEvent(name)
local counts = CL.Diagnostics.eventCounts
counts[name] = (counts[name] or 0) + 1
end
function CL.RecordError(tag, err)
tag = tostring(tag)
local d = CL.Diagnostics
local first = d.errorCounts[tag] == nil
d.errorCounts[tag] = (d.errorCounts[tag] or 0) + 1
d.lastErrors[tag] = tostring(err)
if first then
CL.Print("|cffff4040error|r in " .. tag .. ": " .. tostring(err) .. " (further ones are counted in /cl status)")
end
if CL.debug then CL.LogLine("[ERROR] " .. tag .. ": " .. tostring(err)) end
end
-- Debug logging to CL.LOG_FILENAME (/cl debug). Off by default.
CL.debug = false
-- The persisted debug flag is restored on PLAYER_ENTERING_WORLD
-- (SavedVariables aren't loaded yet when this file runs). Core loads
-- first, so this handler runs before any other file's handler for the
-- same event and debug output covers login too.
local debugRestoreFrame = CreateFrame("Frame")
debugRestoreFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
debugRestoreFrame:SetScript("OnEvent", function()
CL.debug = (CombatLedgerDB.settings.debug == true)
end)
-- Test mode (session only): windows show fabricated data for previewing
-- appearance (Aggregator.GetTestEncounter).
CL.testMode = false
-- Number of keys in any table (table.getn only counts the array part).
function CL.TableCount(t)
local n = 0
local _
for _ in pairs(t) do n = n + 1 end
return n
end
-- Is the single power-of-two `bit` set in `value`? Lua 5.0 has no
-- bitwise operators, so this is floor(value / bit) mod 2 == 1.
function CL.HasBit(value, bit)
if not value then return false end
return math.mod(math.floor(value / bit), 2) == 1
end
-- hitInfo bits. AUTO_ATTACK_* and SPELL_DAMAGE_EVENT_* use different
-- bitfields: on auto-attacks (the MaNGOS HITINFO_* enum) 0x02 is set on
-- every landed swing, on spell damage 0x02 means crit.
CL.AUTO_ATTACK_HITFLAG_CRIT = 128
CL.AUTO_ATTACK_HITFLAG_GLANCING = 16384
CL.AUTO_ATTACK_HITFLAG_CRUSHING = 32768
CL.AUTO_ATTACK_HITFLAG_OFFHAND = 4
CL.SPELL_DAMAGE_HITFLAG_CRIT = 2
-- AUTO_ATTACK victimState values (MaNGOS VICTIMSTATE_*). An avoided
-- swing arrives as a 0-damage AUTO_ATTACK with one of these.
CL.VICTIMSTATE_MISS = 0
CL.VICTIMSTATE_NORMAL = 1
CL.VICTIMSTATE_DODGE = 2
CL.VICTIMSTATE_PARRY = 3
CL.VICTIMSTATE_INTERRUPT = 4
CL.VICTIMSTATE_BLOCK = 5
CL.VICTIMSTATE_EVADE = 6
CL.VICTIMSTATE_IMMUNE = 7
CL.VICTIMSTATE_DEFLECT = 8
--------------------------------------------------------------------------
-- File-based debug log (WriteCustomFile, Nampower v3.2+)
--------------------------------------------------------------------------
CL.LOG_FILENAME = "CombatLedger_debug.log"
local logBuffer = {}
local loggedThisSession = false -- first flush of a session overwrites (fresh file per test), later ones append
-- LogLine only buffers; FlushLog (once a second from Events.lua, and at
-- encounter end) writes the batch, so combat doesn't cost a file write
-- per event. The first flush of a session overwrites the file.
function CL.LogLine(line)
table.insert(logBuffer, line)
end
function CL.FlushLog()
if table.getn(logBuffer) == 0 then return end
if not WriteCustomFile then
-- Nothing can ever write it out - don't let it grow forever.
logBuffer = {}
return
end
local content = table.concat(logBuffer, "\n") .. "\n"
local mode = loggedThisSession and "a" or "w"
local ok = pcall(WriteCustomFile, CL.LOG_FILENAME, content, mode)
if ok then
loggedThisSession = true
logBuffer = {}
end
-- On failure the buffer is kept and retried on the next flush.
end