970 lines
33 KiB
Lua
970 lines
33 KiB
Lua
if not BNP or not BNP.libnameplate then return end
|
|
|
|
-- PvP immunity / anti-fear indicators (strict whitelist) -------------------
|
|
-- Keep this deliberately close to BNP's proven aura/nameplate path:
|
|
-- SuperWoW's projected nameplate token (plate:GetName(1)) is used directly
|
|
-- with UnitBuff, just like auras.lua uses it directly with UnitDebuff.
|
|
-- No GUID -> token reverse conversion and no C_UnitAuras dependency here.
|
|
|
|
BNP.guidImmunities = BNP.guidImmunities or {}
|
|
|
|
local UI = BNP.UI or {}
|
|
local MAX_ICONS = 4
|
|
local ICON_SPACING = UI.ICON_SPACING or 2
|
|
local SIDE_SPACING = 4
|
|
local BOTTOM_SPACING = 4
|
|
local BASE_TOP_OFFSET = UI.ICON_OFFSET_Y or 15
|
|
local COMBO_LAYOUT_OFFSET = 16
|
|
local MISSING_GRACE = 0.20
|
|
local NAME_HIDDEN_COMPACT_SHIFT = 10
|
|
|
|
local _, playerClass = UnitClass("player")
|
|
|
|
local IMM_ERR_LAST = {}
|
|
local function ReportImmunityError(stage, err)
|
|
local now = GetTime and GetTime() or 0
|
|
local last = IMM_ERR_LAST[stage] or -100
|
|
if now - last < 1.5 then return end
|
|
IMM_ERR_LAST[stage] = now
|
|
local msg = "|cffff4444BNP IMM ERROR [" .. tostring(stage) .. "]|r " .. tostring(err)
|
|
if DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
|
|
DEFAULT_CHAT_FRAME:AddMessage(msg)
|
|
end
|
|
end
|
|
|
|
local IMMUNITY_DEFS = {
|
|
{
|
|
key = "divine_shield",
|
|
names = { "Divine Shield", "Gottesschild" },
|
|
spellIDs = { 642, 1020 },
|
|
durations = { [642] = 10, [1020] = 12 },
|
|
defaultDuration = 12,
|
|
textureMatches = { "spell_holy_divineintervention" },
|
|
selfOnly = true,
|
|
},
|
|
{
|
|
key = "divine_protection",
|
|
names = { "Divine Protection", "Göttlicher Schutz" },
|
|
spellIDs = { 498, 5573 },
|
|
durations = { [498] = 6, [5573] = 8 },
|
|
defaultDuration = 8,
|
|
selfOnly = true,
|
|
},
|
|
{
|
|
key = "blessing_of_protection",
|
|
names = { "Blessing of Protection", "Segen des Schutzes" },
|
|
spellIDs = { 1022, 5599, 10278 },
|
|
durations = { [1022] = 6, [5599] = 8, [10278] = 10 },
|
|
defaultDuration = 10,
|
|
textureMatches = { "spell_holy_sealofprotection" },
|
|
},
|
|
{
|
|
key = "ice_block",
|
|
names = { "Ice Block", "Eisblock" },
|
|
spellIDs = { 11958 },
|
|
durations = { [11958] = 10 },
|
|
defaultDuration = 10,
|
|
textureMatches = { "spell_frost_frost" },
|
|
selfOnly = true,
|
|
},
|
|
{
|
|
key = "berserker_rage",
|
|
names = { "Berserker Rage", "Berserkerwut" },
|
|
spellIDs = { 18499 },
|
|
durations = { [18499] = 10 },
|
|
defaultDuration = 10,
|
|
selfOnly = true,
|
|
},
|
|
{
|
|
key = "death_wish",
|
|
names = { "Death Wish", "Todeswunsch" },
|
|
spellIDs = { 12328 },
|
|
durations = { [12328] = 30 },
|
|
defaultDuration = 30,
|
|
selfOnly = true,
|
|
},
|
|
{
|
|
key = "recklessness",
|
|
names = { "Recklessness", "Tollkühnheit" },
|
|
spellIDs = { 1719 },
|
|
durations = { [1719] = 15 },
|
|
defaultDuration = 15,
|
|
selfOnly = true,
|
|
},
|
|
{
|
|
key = "fear_ward",
|
|
names = { "Fear Ward", "Furchtzauberschutz" },
|
|
spellIDs = { 6346 },
|
|
durations = { [6346] = 600 },
|
|
defaultDuration = 600,
|
|
},
|
|
{
|
|
key = "will_of_the_forsaken",
|
|
names = { "Will of the Forsaken", "Wille der Verlassenen" },
|
|
spellIDs = { 7744 },
|
|
durations = { [7744] = 5 },
|
|
defaultDuration = 5,
|
|
selfOnly = true,
|
|
},
|
|
}
|
|
|
|
local DEF_BY_ID = {}
|
|
local DEF_BY_NAME = {}
|
|
|
|
local function Lower(value)
|
|
if type(value) ~= "string" or value == "" then return nil end
|
|
return string.lower(value)
|
|
end
|
|
|
|
local function DefDuration(def, spellID)
|
|
if def and def.durations and spellID and def.durations[spellID] then
|
|
return def.durations[spellID]
|
|
end
|
|
return def and def.defaultDuration or nil
|
|
end
|
|
|
|
local function BuildLookup()
|
|
local i, j, def, id, name
|
|
for i = 1, table.getn(IMMUNITY_DEFS) do
|
|
def = IMMUNITY_DEFS[i]
|
|
if def.names then
|
|
for j = 1, table.getn(def.names) do
|
|
name = Lower(def.names[j])
|
|
if name then DEF_BY_NAME[name] = def end
|
|
end
|
|
end
|
|
if def.spellIDs then
|
|
for j = 1, table.getn(def.spellIDs) do
|
|
id = def.spellIDs[j]
|
|
DEF_BY_ID[id] = def
|
|
if SpellInfo then
|
|
local spellName = SpellInfo(id)
|
|
spellName = Lower(spellName)
|
|
if spellName then DEF_BY_NAME[spellName] = def end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
BuildLookup()
|
|
|
|
local function TextureMatches(def, texture)
|
|
local t = Lower(texture)
|
|
if not def or not t or not def.textureMatches then return false end
|
|
local i
|
|
for i = 1, table.getn(def.textureMatches) do
|
|
local pattern = Lower(def.textureMatches[i])
|
|
if pattern and string.find(t, pattern, 1, true) then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function FindDef(spellID, name, texture)
|
|
-- STRICT WHITELIST: an aura is only an immunity when its spell ID or exact
|
|
-- spell name is configured above. Do not guess from a shared icon/texture.
|
|
-- Many unrelated server buffs reuse Blizzard icons (e.g. armor/world buffs),
|
|
-- which previously allowed false positives such as Forest Armor.
|
|
local sid = tonumber(spellID)
|
|
if sid then
|
|
return DEF_BY_ID[sid]
|
|
end
|
|
|
|
local n = Lower(name)
|
|
if n then
|
|
return DEF_BY_NAME[n]
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
local function GetIconSize()
|
|
if BNP.GetImmunityIconSize then return BNP:GetImmunityIconSize() end
|
|
if BNP.GetIconSize then return BNP:GetIconSize() end
|
|
return 18
|
|
end
|
|
|
|
local function GetPosition()
|
|
if BNP.GetImmunityPosition then return BNP:GetImmunityPosition() end
|
|
return "top"
|
|
end
|
|
|
|
local function GetVisualParent(plate)
|
|
if plate and plate.GetParent then
|
|
local p = plate:GetParent()
|
|
if p then return p end
|
|
end
|
|
return WorldFrame or UIParent
|
|
end
|
|
|
|
local function GetBaseTopOffset()
|
|
local y = BASE_TOP_OFFSET
|
|
if (playerClass == "ROGUE" or playerClass == "DRUID")
|
|
and BNP.AreComboPointsEnabled and BNP:AreComboPointsEnabled() then
|
|
y = y + COMBO_LAYOUT_OFFSET
|
|
end
|
|
return y
|
|
end
|
|
|
|
local function IsTopDebuffPosition(position)
|
|
return position == "top" or position == "top_left" or position == "top_right"
|
|
end
|
|
|
|
local function GetTopOffset()
|
|
local auraSize = BNP.GetIconSize and BNP:GetIconSize() or 18
|
|
local ccSize = BNP.GetCCIconSize and BNP:GetCCIconSize() or auraSize
|
|
local debuffPosition = BNP.GetDebuffPosition and BNP:GetDebuffPosition() or "top"
|
|
local ccPosition = BNP.GetCCPosition and BNP:GetCCPosition() or debuffPosition
|
|
local debuffs = not BNP.AreDebuffsEnabled or BNP:AreDebuffsEnabled()
|
|
local ccs = not BNP.AreCrowdControlEnabled or BNP:AreCrowdControlEnabled()
|
|
local separateCC = ccs and debuffs and ccPosition == "top" and IsTopDebuffPosition(debuffPosition)
|
|
and BNP.IsSeparateCCRowEnabled and BNP:IsSeparateCCRowEnabled()
|
|
local y = GetBaseTopOffset()
|
|
|
|
if separateCC then
|
|
y = y + auraSize + ICON_SPACING + ccSize + ICON_SPACING
|
|
else
|
|
local rowHeight = 0
|
|
if debuffs and IsTopDebuffPosition(debuffPosition) then rowHeight = math.max(rowHeight, auraSize) end
|
|
if ccs and ccPosition == "top" then rowHeight = math.max(rowHeight, ccSize) end
|
|
if rowHeight > 0 then y = y + rowHeight + ICON_SPACING end
|
|
end
|
|
return y
|
|
end
|
|
|
|
local function FrameShown(frame)
|
|
return frame and frame.IsShown and frame:IsShown() and true or false
|
|
end
|
|
|
|
local function GetBottomOffset(plate)
|
|
local auraSize = BNP.GetIconSize and BNP:GetIconSize() or 18
|
|
local ccSize = BNP.GetCCIconSize and BNP:GetCCIconSize() or auraSize
|
|
local debuffPosition = BNP.GetDebuffPosition and BNP:GetDebuffPosition() or "top"
|
|
local ccPosition = BNP.GetCCPosition and BNP:GetCCPosition() or debuffPosition
|
|
local debuffs = not BNP.AreDebuffsEnabled or BNP:AreDebuffsEnabled()
|
|
local ccs = not BNP.AreCrowdControlEnabled or BNP:AreCrowdControlEnabled()
|
|
local separateCC = ccs and debuffs and ccPosition == "bottom" and debuffPosition == "bottom"
|
|
and BNP.IsSeparateCCRowEnabled and BNP:IsSeparateCCRowEnabled()
|
|
|
|
local harmfulDepth = 0
|
|
if separateCC then
|
|
-- The CC row is physically the second row, so preserve the first-row
|
|
-- offset whenever that second row is visible.
|
|
if FrameShown(plate and plate.BNPCCContainer) then
|
|
harmfulDepth = auraSize + ICON_SPACING + ccSize
|
|
elseif FrameShown(plate and plate.BNPAuraContainer) then
|
|
harmfulDepth = auraSize
|
|
end
|
|
else
|
|
if debuffs and debuffPosition == "bottom" and FrameShown(plate and plate.BNPAuraContainer) then
|
|
harmfulDepth = math.max(harmfulDepth, auraSize)
|
|
end
|
|
if ccs and ccPosition == "bottom" then
|
|
if ccPosition == debuffPosition then
|
|
if FrameShown(plate and plate.BNPAuraContainer) then harmfulDepth = math.max(harmfulDepth, ccSize) end
|
|
elseif FrameShown(plate and plate.BNPCCContainer) then
|
|
harmfulDepth = math.max(harmfulDepth, ccSize)
|
|
end
|
|
end
|
|
end
|
|
|
|
local y = BOTTOM_SPACING
|
|
if harmfulDepth > 0 then y = y + harmfulDepth + ICON_SPACING end
|
|
return y
|
|
end
|
|
|
|
local function GetSideOffset(position)
|
|
local auraSize = BNP.GetIconSize and BNP:GetIconSize() or 18
|
|
local ccSize = BNP.GetCCIconSize and BNP:GetCCIconSize() or auraSize
|
|
local debuffPosition = BNP.GetDebuffPosition and BNP:GetDebuffPosition() or "top"
|
|
local ccPosition = BNP.GetCCPosition and BNP:GetCCPosition() or debuffPosition
|
|
local debuffs = not BNP.AreDebuffsEnabled or BNP:AreDebuffsEnabled()
|
|
local ccs = not BNP.AreCrowdControlEnabled or BNP:AreCrowdControlEnabled()
|
|
local rowHeight = 0
|
|
if debuffs and debuffPosition == position then rowHeight = math.max(rowHeight, auraSize) end
|
|
if ccs and ccPosition == position then rowHeight = math.max(rowHeight, ccSize) end
|
|
if rowHeight > 0 then return (rowHeight / 2) + ICON_SPACING + (GetIconSize() / 2) end
|
|
return 0
|
|
end
|
|
|
|
local function GetRegionWidth(region)
|
|
if not region then return 0 end
|
|
if region.IsShown and not region:IsShown() then return 0 end
|
|
if region.GetStringWidth then
|
|
local w = tonumber(region:GetStringWidth()) or 0
|
|
if w > 0 then return w end
|
|
end
|
|
if region.GetWidth then return math.max(0, tonumber(region:GetWidth()) or 0) end
|
|
return 0
|
|
end
|
|
|
|
local function GetRightSpacing(plate)
|
|
local extra = GetRegionWidth(plate and plate.level) + GetRegionWidth(plate and plate.levelicon)
|
|
if extra > 0 then extra = extra + 6 end
|
|
return SIDE_SPACING + extra
|
|
end
|
|
|
|
local function AnchorContainer(plate, container)
|
|
if not plate or not container then return end
|
|
container:ClearAllPoints()
|
|
local position = GetPosition()
|
|
local anchor = plate.healthbar or plate
|
|
|
|
if position == "left" then
|
|
container:SetPoint("RIGHT", anchor, "LEFT", -SIDE_SPACING, GetSideOffset("left"))
|
|
elseif position == "right" then
|
|
container:SetPoint("LEFT", anchor, "RIGHT", GetRightSpacing(plate), GetSideOffset("right"))
|
|
else
|
|
local nameHidden = (BNP.IsPlateNameHidden and BNP:IsPlateNameHidden(plate)) or plate.BNPNameHidden
|
|
local compactShift = nameHidden and -NAME_HIDDEN_COMPACT_SHIFT or 0
|
|
if plate.healthbar then
|
|
container:SetPoint("BOTTOM", plate.healthbar, "TOP", 0, GetTopOffset() + compactShift)
|
|
else
|
|
container:SetPoint("BOTTOM", plate, "BOTTOM", 0, 24 + GetTopOffset() + compactShift)
|
|
end
|
|
end
|
|
end
|
|
|
|
function BNP:RefreshImmunityLayoutForPlate(plate)
|
|
if not plate or not plate.BNPImmunityContainer then return end
|
|
AnchorContainer(plate, plate.BNPImmunityContainer)
|
|
end
|
|
|
|
local function ApplyFrameLevel(plate, container)
|
|
if not plate or not container then return end
|
|
if plate.GetFrameStrata and container.GetFrameStrata and container.SetFrameStrata then
|
|
local strata = plate:GetFrameStrata()
|
|
if strata and container:GetFrameStrata() ~= strata then
|
|
container:SetFrameStrata(strata)
|
|
end
|
|
end
|
|
if plate.GetFrameLevel and container.GetFrameLevel and container.SetFrameLevel then
|
|
local level = math.max(0, (tonumber(plate:GetFrameLevel()) or 0) + 1)
|
|
if container:GetFrameLevel() ~= level then container:SetFrameLevel(level) end
|
|
local i
|
|
for i = 1, table.getn(container.icons or {}) do
|
|
local icon = container.icons[i]
|
|
if icon and icon.GetFrameLevel and icon.SetFrameLevel and icon:GetFrameLevel() ~= level then
|
|
icon:SetFrameLevel(level)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function CreateIcon(parent, index)
|
|
local size = GetIconSize()
|
|
local icon = CreateFrame("Frame", nil, parent)
|
|
icon:SetWidth(size)
|
|
icon:SetHeight(size)
|
|
|
|
local texture = icon:CreateTexture(nil, "ARTWORK")
|
|
texture:SetAllPoints(icon)
|
|
icon.texture = texture
|
|
|
|
local timer = icon:CreateFontString(nil, "OVERLAY")
|
|
timer:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", UI.TIMER_SIZE or 8, "OUTLINE")
|
|
timer:SetPoint("CENTER", icon, "CENTER", UI.TIMER_OFFSET_X or 0, UI.TIMER_OFFSET_Y or 0)
|
|
timer:SetTextColor(1, 1, 1)
|
|
icon.timer = timer
|
|
|
|
icon.index = index
|
|
icon:Hide()
|
|
return icon
|
|
end
|
|
|
|
local function LayoutContainer(plate, container)
|
|
if not plate or not container then return end
|
|
local size = GetIconSize()
|
|
local position = GetPosition()
|
|
local i
|
|
|
|
if container.BNPLastSize ~= size or container.BNPLastIconPosition ~= position then
|
|
container.BNPLastSize = size
|
|
container.BNPLastIconPosition = position
|
|
container:SetWidth(size * MAX_ICONS + ICON_SPACING * (MAX_ICONS - 1))
|
|
container:SetHeight(size)
|
|
for i = 1, table.getn(container.icons or {}) do
|
|
local icon = container.icons[i]
|
|
icon:SetWidth(size)
|
|
icon:SetHeight(size)
|
|
icon:ClearAllPoints()
|
|
if position == "left" then
|
|
icon:SetPoint("RIGHT", container, "RIGHT", -((i - 1) * (size + ICON_SPACING)), 0)
|
|
else
|
|
icon:SetPoint("LEFT", container, "LEFT", (i - 1) * (size + ICON_SPACING), 0)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Cheap layout key; avoids touching anchors every scan.
|
|
local layoutKey = tostring(position) .. ":" ..
|
|
tostring(BNP.GetDebuffPosition and BNP:GetDebuffPosition() or "top") .. ":" ..
|
|
tostring(BNP.GetCCPosition and BNP:GetCCPosition() or "top") .. ":" ..
|
|
tostring(BNP.IsSeparateCCRowEnabled and BNP:IsSeparateCCRowEnabled()) .. ":" ..
|
|
tostring(BNP.GetIconSize and BNP:GetIconSize() or 18) .. ":" ..
|
|
tostring(BNP.GetCCIconSize and BNP:GetCCIconSize() or 18) .. ":" .. tostring(size) .. ":" ..
|
|
tostring(plate.BNPNameHidden and true or false) .. ":" ..
|
|
tostring(FrameShown(plate.BNPAuraContainer)) .. ":" ..
|
|
tostring(FrameShown(plate.BNPCCContainer))
|
|
|
|
if container.BNPLastLayoutKey ~= layoutKey then
|
|
container.BNPLastLayoutKey = layoutKey
|
|
AnchorContainer(plate, container)
|
|
end
|
|
ApplyFrameLevel(plate, container)
|
|
end
|
|
|
|
local function HideContainer(plate)
|
|
local container = plate and plate.BNPImmunityContainer
|
|
if not container then return end
|
|
local i
|
|
for i = 1, table.getn(container.icons or {}) do container.icons[i]:Hide() end
|
|
container:Hide()
|
|
end
|
|
|
|
local function InstallHideGuard(plate)
|
|
if not plate or plate.BNPImmunityHideGuard then return end
|
|
local oldOnHide = plate:GetScript("OnHide")
|
|
plate:SetScript("OnHide", function()
|
|
if oldOnHide then oldOnHide() end
|
|
HideContainer(this or plate)
|
|
end)
|
|
plate.BNPImmunityHideGuard = true
|
|
end
|
|
|
|
local function CreateContainer(plate)
|
|
if not plate or plate.BNPImmunityContainer then return end
|
|
if BNP.RegisterPlate then BNP:RegisterPlate(plate) end
|
|
|
|
local container = CreateFrame("Frame", nil, GetVisualParent(plate))
|
|
container.icons = {}
|
|
local i
|
|
for i = 1, MAX_ICONS do container.icons[i] = CreateIcon(container, i) end
|
|
plate.BNPImmunityContainer = container
|
|
LayoutContainer(plate, container)
|
|
container:Hide()
|
|
InstallHideGuard(plate)
|
|
end
|
|
|
|
local function GetCache(guid)
|
|
if not guid then return nil end
|
|
local cache = BNP.guidImmunities[guid]
|
|
if not cache then
|
|
cache = { _nextOrder = 0 }
|
|
BNP.guidImmunities[guid] = cache
|
|
end
|
|
return cache
|
|
end
|
|
|
|
local function CommitSeen(guid, def, spellID, texture, duration, expires, now, source)
|
|
if not guid or not def then return end
|
|
local cache = GetCache(guid)
|
|
local entry = cache[def.key]
|
|
if not entry then
|
|
cache._nextOrder = (cache._nextOrder or 0) + 1
|
|
entry = { order = cache._nextOrder }
|
|
cache[def.key] = entry
|
|
end
|
|
|
|
entry.spellID = tonumber(spellID) or entry.spellID
|
|
entry.texture = texture or entry.texture
|
|
entry.duration = tonumber(duration) or entry.duration or DefDuration(def, tonumber(spellID))
|
|
entry.lastSeen = now
|
|
entry.missingSince = nil
|
|
if source == "capi" then
|
|
entry.capiConfirmed = true
|
|
entry.capiMissingSince = nil
|
|
elseif source == "unitbuff" then
|
|
entry.unitBuffConfirmed = true
|
|
entry.unitBuffMissingSince = nil
|
|
end
|
|
|
|
expires = tonumber(expires)
|
|
if expires and expires > now then
|
|
entry.expires = expires
|
|
elseif not entry.expires or entry.expires <= now then
|
|
local d = entry.duration or DefDuration(def, entry.spellID)
|
|
if d then entry.expires = now + d end
|
|
end
|
|
end
|
|
|
|
-- These full immunities also cleanse harmful effects in Classic. Keep the
|
|
-- harmful-debuff module hard-suppressed while the immunity entry is active.
|
|
local CLEANSE_ALL = {
|
|
divine_shield = true,
|
|
ice_block = true,
|
|
}
|
|
|
|
local function ApplyCleanseSuppression(guid, def, entry, now)
|
|
if not guid or not def or not CLEANSE_ALL[def.key] then return end
|
|
if not entry then return end
|
|
local remaining = entry.expires and (entry.expires - now) or nil
|
|
if remaining and remaining <= 0 then return end
|
|
if BNP.SuppressHarmfulAurasForImmunity then
|
|
-- Renewable short hold: immediate cleanse now, but if Ice Block/Bubble is
|
|
-- clicked off early the suppression naturally expires within a fraction
|
|
-- of a second instead of hiding new debuffs until the guessed full timer.
|
|
BNP:SuppressHarmfulAurasForImmunity(guid, 0.45)
|
|
end
|
|
end
|
|
|
|
local function ParseDurationExpiry(now, a5, a6, a7, a8, a9, a10)
|
|
local vals = { a5, a6, a7, a8, a9, a10 }
|
|
local i
|
|
for i = 1, table.getn(vals) - 1 do
|
|
local d = tonumber(vals[i])
|
|
local e = tonumber(vals[i + 1])
|
|
if d and e and d > 0 and d <= 3600 and e > now then return d, e end
|
|
end
|
|
return nil, nil
|
|
end
|
|
|
|
local function ReconcileMissingSource(guid, source, seen, now)
|
|
local cache = guid and BNP.guidImmunities[guid] or nil
|
|
if not cache then return end
|
|
|
|
local d, def, entry, confirmedKey, missingKey
|
|
if source == "capi" then
|
|
confirmedKey = "capiConfirmed"
|
|
missingKey = "capiMissingSince"
|
|
else
|
|
confirmedKey = "unitBuffConfirmed"
|
|
missingKey = "unitBuffMissingSince"
|
|
end
|
|
|
|
for d = 1, table.getn(IMMUNITY_DEFS) do
|
|
def = IMMUNITY_DEFS[d]
|
|
entry = cache[def.key]
|
|
if entry and entry[confirmedKey] then
|
|
if seen and seen[def.key] then
|
|
entry[missingKey] = nil
|
|
else
|
|
if not entry[missingKey] then entry[missingKey] = now end
|
|
if now - entry[missingKey] >= MISSING_GRACE then
|
|
-- Once a live aura source has positively confirmed this exact
|
|
-- immunity, that same source becomes authoritative for early
|
|
-- removals (Ice Block clicked off, Bubble canceled/removed, dispel).
|
|
cache[def.key] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function ScanClassicAPIBuffs(unit, guid, now)
|
|
if not unit or not guid then return false end
|
|
if type(C_UnitAuras) ~= "table" then return false end
|
|
|
|
local seen = {}
|
|
|
|
-- Preferred path: ask ClassicAPI directly for the whitelisted spell IDs.
|
|
-- Its aura cache is evicted by the client's real OnAuraRemoved hook, so an
|
|
-- Ice Block that is clicked off disappears here immediately instead of
|
|
-- living until the guessed cast timer ends.
|
|
if type(C_UnitAuras.GetUnitAuraBySpellID) == "function" then
|
|
local anyCallWorked = false
|
|
local d, j, def, sid, ok, aura
|
|
for d = 1, table.getn(IMMUNITY_DEFS) do
|
|
def = IMMUNITY_DEFS[d]
|
|
if def.spellIDs then
|
|
for j = 1, table.getn(def.spellIDs) do
|
|
sid = def.spellIDs[j]
|
|
ok, aura = pcall(C_UnitAuras.GetUnitAuraBySpellID, unit, sid, "HELPFUL")
|
|
if ok then
|
|
anyCallWorked = true
|
|
if type(aura) == "table" then
|
|
local auraID = tonumber(aura.spellId or aura.spellID) or sid
|
|
local matched = FindDef(auraID, aura.name, aura.icon or aura.texture)
|
|
if matched then
|
|
seen[matched.key] = true
|
|
CommitSeen(guid, matched, auraID, aura.icon or aura.texture,
|
|
tonumber(aura.duration), tonumber(aura.expirationTime), now, "capi")
|
|
break
|
|
end
|
|
end
|
|
else
|
|
-- If this build rejects the exact getter, fall back to index scan
|
|
-- below and never use a failed call as negative evidence.
|
|
anyCallWorked = false
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if not anyCallWorked and d == 1 then break end
|
|
end
|
|
|
|
if anyCallWorked then
|
|
ReconcileMissingSource(guid, "capi", seen, now)
|
|
return true
|
|
end
|
|
end
|
|
|
|
local getter = nil
|
|
local useFilter = false
|
|
if type(C_UnitAuras.GetBuffDataByIndex) == "function" then
|
|
getter = C_UnitAuras.GetBuffDataByIndex
|
|
elseif type(C_UnitAuras.GetAuraDataByIndex) == "function" then
|
|
getter = C_UnitAuras.GetAuraDataByIndex
|
|
useFilter = true
|
|
end
|
|
if not getter then return false end
|
|
|
|
local completed = true
|
|
local i
|
|
for i = 1, 48 do
|
|
local ok, aura
|
|
if useFilter then
|
|
ok, aura = pcall(getter, unit, i, "HELPFUL")
|
|
else
|
|
ok, aura = pcall(getter, unit, i)
|
|
end
|
|
if not ok then completed = false break end
|
|
if not aura then break end
|
|
|
|
if type(aura) == "table" then
|
|
local spellID = tonumber(aura.spellId or aura.spellID)
|
|
local def = FindDef(spellID, aura.name, aura.icon or aura.texture)
|
|
if def then
|
|
seen[def.key] = true
|
|
CommitSeen(guid, def, spellID, aura.icon or aura.texture,
|
|
tonumber(aura.duration), tonumber(aura.expirationTime), now, "capi")
|
|
end
|
|
end
|
|
end
|
|
|
|
if completed then ReconcileMissingSource(guid, "capi", seen, now) end
|
|
return completed
|
|
end
|
|
|
|
local function ScanUnitBuffs(unit, guid, now)
|
|
if not unit or not guid or not UnitBuff then return false end
|
|
|
|
local seen = {}
|
|
local completed = true
|
|
local i
|
|
for i = 1, 64 do
|
|
local ok, texture, applications, buffID, a4, a5, a6, a7, a8, a9, a10 = pcall(UnitBuff, unit, i)
|
|
if not ok then completed = false break end
|
|
if not texture then break end
|
|
|
|
local spellID = tonumber(buffID)
|
|
if not spellID then
|
|
if type(a4) == "number" then spellID = a4
|
|
elseif type(a5) == "number" then spellID = a5 end
|
|
end
|
|
if spellID and spellID < -1 then spellID = spellID + 65536 end
|
|
|
|
local auraName = nil
|
|
if spellID and SpellInfo then auraName = SpellInfo(spellID) end
|
|
local def = FindDef(spellID, auraName, texture)
|
|
if def then
|
|
seen[def.key] = true
|
|
local duration, expires = ParseDurationExpiry(now, a4, a5, a6, a7, a8, a9, a10)
|
|
CommitSeen(guid, def, spellID, texture, duration, expires, now, "unitbuff")
|
|
end
|
|
end
|
|
|
|
-- UnitBuff is only allowed to remove an immunity after UnitBuff itself has
|
|
-- positively seen that exact whitelist entry at least once. This preserves
|
|
-- the cast-event fallback on clients where hostile helpful auras are hidden,
|
|
-- while still making clicked-off buffs disappear immediately when visible.
|
|
if completed then ReconcileMissingSource(guid, "unitbuff", seen, now) end
|
|
return completed
|
|
end
|
|
|
|
local function ResolveClassicAuraUnit(guid, targetGUID)
|
|
if not guid then return nil end
|
|
if targetGUID and guid == targetGUID then return "target" end
|
|
|
|
if type(UnitTokenFromGUID) == "function" then
|
|
local ok, token = pcall(UnitTokenFromGUID, guid)
|
|
if ok and token and token ~= "" then return token end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function FormatTimer(remaining)
|
|
if not remaining or remaining <= 0 then return "" end
|
|
if remaining >= 3600 then return math.ceil(remaining / 3600) .. "h" end
|
|
if remaining >= 60 then return math.ceil(remaining / 60) .. "m" end
|
|
return tostring(math.ceil(remaining))
|
|
end
|
|
|
|
local function Render(plate, guid, now)
|
|
local container = plate and plate.BNPImmunityContainer
|
|
if not container then return end
|
|
LayoutContainer(plate, container)
|
|
|
|
local cache = guid and BNP.guidImmunities[guid] or nil
|
|
local active = {}
|
|
local d, def, entry, remaining
|
|
for d = 1, table.getn(IMMUNITY_DEFS) do
|
|
def = IMMUNITY_DEFS[d]
|
|
entry = cache and cache[def.key] or nil
|
|
remaining = entry and entry.expires and (entry.expires - now) or nil
|
|
if entry and (not entry.expires or (remaining and remaining > 0)) then
|
|
ApplyCleanseSuppression(guid, def, entry, now)
|
|
if table.getn(active) < MAX_ICONS then
|
|
table.insert(active, { def = def, entry = entry, remaining = remaining })
|
|
end
|
|
elseif cache and entry and entry.expires and remaining and remaining <= 0 then
|
|
cache[def.key] = nil
|
|
end
|
|
end
|
|
|
|
if table.getn(active) > 1 then
|
|
table.sort(active, function(a, b) return (a.entry.order or 0) < (b.entry.order or 0) end)
|
|
end
|
|
|
|
local count = table.getn(active)
|
|
local size = GetIconSize()
|
|
local visibleWidth = size
|
|
if count > 1 then visibleWidth = size * count + ICON_SPACING * (count - 1) end
|
|
container:SetWidth(visibleWidth)
|
|
|
|
local i
|
|
for i = 1, MAX_ICONS do
|
|
local icon = container.icons[i]
|
|
local item = active[i]
|
|
if item then
|
|
local texture = item.entry.texture
|
|
if not texture and item.entry.spellID and SpellInfo then
|
|
local _, _, spellTexture = SpellInfo(item.entry.spellID)
|
|
texture = spellTexture
|
|
end
|
|
if texture then icon.texture:SetTexture(texture) end
|
|
icon.timer:SetText(FormatTimer(item.remaining))
|
|
icon:Show()
|
|
else
|
|
icon.timer:SetText("")
|
|
icon:Hide()
|
|
end
|
|
end
|
|
|
|
if count > 0 then container:Show() else container:Hide() end
|
|
end
|
|
|
|
local function GetTargetGUID()
|
|
if not UnitExists then return nil end
|
|
local exists, guid = UnitExists("target")
|
|
if exists and guid then return guid end
|
|
return nil
|
|
end
|
|
|
|
local function GetPlateGUID(plate)
|
|
if not plate or not plate.GetName then return nil end
|
|
local token = plate:GetName(1)
|
|
if token and token ~= "" then return token end
|
|
return nil
|
|
end
|
|
|
|
local function UpdatePlate(plate)
|
|
if not plate or not plate:IsShown() then
|
|
HideContainer(plate)
|
|
return
|
|
end
|
|
if BNP.ArePvPImmunitiesEnabled and not BNP:ArePvPImmunitiesEnabled() then
|
|
HideContainer(plate)
|
|
return
|
|
end
|
|
if not plate.BNPImmunityContainer then CreateContainer(plate) end
|
|
|
|
local now = GetTime()
|
|
local guid = BNP.GetStablePlateAuraGUID and BNP.GetStablePlateAuraGUID(plate, now) or GetPlateGUID(plate)
|
|
if not guid then HideContainer(plate) return end
|
|
|
|
local targetGUID = GetTargetGUID()
|
|
local unit = nil
|
|
if targetGUID and targetGUID == guid then
|
|
unit = "target"
|
|
else
|
|
unit = GetPlateGUID(plate)
|
|
end
|
|
|
|
-- Scan the two aura surfaces independently. ClassicAPI nameplateN tokens are
|
|
-- preferred when available; SuperWoW's raw GUID token remains the legacy
|
|
-- UnitBuff fallback. Do not let one resolver's player check block the other.
|
|
local classicUnit = ResolveClassicAuraUnit(guid, targetGUID)
|
|
local classicCanScan = classicUnit ~= nil
|
|
if classicCanScan and UnitIsPlayer then
|
|
classicCanScan = UnitIsPlayer(classicUnit) and true or false
|
|
end
|
|
if classicCanScan then ScanClassicAPIBuffs(classicUnit, guid, now) end
|
|
|
|
local legacyCanScan = unit ~= nil
|
|
if legacyCanScan and UnitIsPlayer then
|
|
legacyCanScan = UnitIsPlayer(unit) and true or false
|
|
end
|
|
if legacyCanScan then ScanUnitBuffs(unit, guid, now) end
|
|
|
|
Render(plate, guid, now)
|
|
end
|
|
|
|
-- UNIT_CASTEVENT is a fast timer seed. Once ClassicAPI/UnitBuff has
|
|
-- positively confirmed the aura, that live source also removes it early when
|
|
-- the buff is canceled, dispelled, or otherwise ends before its timer.
|
|
local castFrame = CreateFrame("Frame")
|
|
castFrame:RegisterEvent("UNIT_CASTEVENT")
|
|
local function HandleImmunityCastEvent()
|
|
local casterGUID = arg1
|
|
local targetGUID = arg2
|
|
local eventType = arg3
|
|
local spellID = tonumber(arg4)
|
|
if spellID and spellID < -1 then spellID = spellID + 65536 end
|
|
|
|
-- Keep the last raw event for /bnpimm diagnostics. This is intentionally
|
|
-- cheap and lets us verify instant enemy casts without another addon.
|
|
BNP._immLastCast = {
|
|
casterGUID = casterGUID,
|
|
targetGUID = targetGUID,
|
|
eventType = eventType,
|
|
spellID = spellID,
|
|
time = GetTime(),
|
|
}
|
|
|
|
if not casterGUID or not spellID then return end
|
|
if eventType ~= "CAST" and eventType ~= "CHANNEL" then return end
|
|
|
|
local texture = nil
|
|
local spellName = nil
|
|
if SpellInfo then
|
|
spellName, _, texture = SpellInfo(spellID)
|
|
end
|
|
local def = DEF_BY_ID[spellID] or FindDef(spellID, spellName, texture)
|
|
if not def then return end
|
|
|
|
local guid = def.selfOnly and casterGUID or (targetGUID or casterGUID)
|
|
local now = GetTime()
|
|
local duration = DefDuration(def, spellID)
|
|
if duration then
|
|
CommitSeen(guid, def, spellID, texture, duration, now + duration, now)
|
|
local cache = BNP.guidImmunities and BNP.guidImmunities[guid]
|
|
ApplyCleanseSuppression(guid, def, cache and cache[def.key], now)
|
|
end
|
|
end
|
|
|
|
castFrame:SetScript("OnEvent", function()
|
|
local ok, err = pcall(HandleImmunityCastEvent)
|
|
if not ok then ReportImmunityError("cast", err) end
|
|
end)
|
|
|
|
function BNP:RefreshImmunityVisibility()
|
|
local plate
|
|
for plate in pairs(BNP.plates or {}) do
|
|
if plate then
|
|
if self:ArePvPImmunitiesEnabled() then
|
|
if not plate.BNPImmunityContainer then CreateContainer(plate) end
|
|
else
|
|
HideContainer(plate)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function BNP:RefreshAllImmunityLayouts()
|
|
local plate
|
|
for plate in pairs(BNP.plates or {}) do
|
|
if plate and plate.BNPImmunityContainer then
|
|
plate.BNPImmunityContainer.BNPLastLayoutKey = nil
|
|
plate.BNPImmunityContainer.BNPLastSize = nil
|
|
plate.BNPImmunityContainer.BNPLastIconPosition = nil
|
|
LayoutContainer(plate, plate.BNPImmunityContainer)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Small temporary diagnostic for this test build. Target a bubbled player and
|
|
-- type /bnpimm. It prints the raw UnitBuff slots BNP sees, including spell ID
|
|
-- when SuperWoW exposes it. This makes server/client differences obvious without
|
|
-- relying on another addon's truncated error window.
|
|
SLASH_BNPIMMUNITYDEBUG1 = "/bnpimm"
|
|
SlashCmdList["BNPIMMUNITYDEBUG"] = function()
|
|
if not DEFAULT_CHAT_FRAME then return end
|
|
local exists, guid = UnitExists("target")
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99BNP IMM DEBUG|r targetGUID=" .. tostring(guid or "nil") .. " player=" .. tostring(UnitIsPlayer and UnitIsPlayer("target")))
|
|
|
|
local last = BNP._immLastCast
|
|
if last then
|
|
DEFAULT_CHAT_FRAME:AddMessage(" last CASTEVENT type=" .. tostring(last.eventType) .. " id=" .. tostring(last.spellID) .. " caster=" .. tostring(last.casterGUID) .. " target=" .. tostring(last.targetGUID))
|
|
else
|
|
DEFAULT_CHAT_FRAME:AddMessage(" last CASTEVENT: none seen")
|
|
end
|
|
|
|
if not exists then return end
|
|
|
|
local capiFound = 0
|
|
if type(C_UnitAuras) == "table" then
|
|
local getter = nil
|
|
local useFilter = false
|
|
if type(C_UnitAuras.GetBuffDataByIndex) == "function" then
|
|
getter = C_UnitAuras.GetBuffDataByIndex
|
|
elseif type(C_UnitAuras.GetAuraDataByIndex) == "function" then
|
|
getter = C_UnitAuras.GetAuraDataByIndex
|
|
useFilter = true
|
|
end
|
|
DEFAULT_CHAT_FRAME:AddMessage(" ClassicAPI aura getter=" .. tostring(getter ~= nil))
|
|
if getter then
|
|
local i
|
|
for i = 1, 16 do
|
|
local ok, aura
|
|
if useFilter then ok, aura = pcall(getter, "target", i, "HELPFUL")
|
|
else ok, aura = pcall(getter, "target", i) end
|
|
if not ok then
|
|
DEFAULT_CHAT_FRAME:AddMessage(" CAPI error slot " .. tostring(i) .. ": " .. tostring(aura))
|
|
break
|
|
end
|
|
if not aura then break end
|
|
if type(aura) == "table" then
|
|
local sid = tonumber(aura.spellId or aura.spellID)
|
|
local def = FindDef(sid, aura.name, aura.icon or aura.texture)
|
|
if def then capiFound = capiFound + 1 end
|
|
if def then
|
|
DEFAULT_CHAT_FRAME:AddMessage(" CAPI MATCH " .. tostring(i) .. " id=" .. tostring(sid) .. " name=" .. tostring(aura.name) .. " tex=" .. tostring(aura.icon or aura.texture))
|
|
end
|
|
else
|
|
DEFAULT_CHAT_FRAME:AddMessage(" CAPI slot " .. tostring(i) .. " returned " .. type(aura) .. ": " .. tostring(aura))
|
|
end
|
|
end
|
|
end
|
|
else
|
|
DEFAULT_CHAT_FRAME:AddMessage(" ClassicAPI C_UnitAuras: unavailable")
|
|
end
|
|
|
|
local ubFound = 0
|
|
local i
|
|
for i = 1, 32 do
|
|
local ok, texture, stacks, buffID, a4, a5, a6 = pcall(UnitBuff, "target", i)
|
|
if not ok then
|
|
DEFAULT_CHAT_FRAME:AddMessage(" UnitBuff error slot " .. tostring(i) .. ": " .. tostring(texture))
|
|
break
|
|
end
|
|
if not texture then break end
|
|
local spellID = tonumber(buffID)
|
|
if not spellID and type(a4) == "number" then spellID = a4 end
|
|
if spellID and spellID < -1 then spellID = spellID + 65536 end
|
|
local def = FindDef(spellID, spellID and SpellInfo and SpellInfo(spellID) or nil, texture)
|
|
if def then ubFound = ubFound + 1 end
|
|
if def then
|
|
DEFAULT_CHAT_FRAME:AddMessage(" UB MATCH " .. tostring(i) .. " id=" .. tostring(spellID) .. " tex=" .. tostring(texture))
|
|
end
|
|
end
|
|
if capiFound == 0 and ubFound == 0 then
|
|
DEFAULT_CHAT_FRAME:AddMessage(" no configured immunity matched live target buffs")
|
|
end
|
|
end
|
|
|
|
-- Sanity-check the whole module callback so one optional PvP feature can never
|
|
-- stop any other BNP nameplate module.
|
|
local function SafeCreateContainer(plate)
|
|
local ok, err = pcall(CreateContainer, plate)
|
|
if not ok then ReportImmunityError("create", err) end
|
|
end
|
|
|
|
local function SafeUpdatePlate(plate)
|
|
local ok, err = pcall(UpdatePlate, plate)
|
|
if not ok then ReportImmunityError("update", err) end
|
|
end
|
|
|
|
table.insert(BNP.libnameplate.OnInit, SafeCreateContainer)
|
|
table.insert(BNP.libnameplate.OnShow, SafeCreateContainer)
|
|
table.insert(BNP.libnameplate.OnUpdate, SafeUpdatePlate)
|