function BuffSystem.ApplyBuff(unit, buffData, source, level)
-- [Restored 2026-05-03] CC immunity hook (priest cleanse).
-- Reject Crowd Control buffs if target.runtimeFlags.ccImmune is true.
-- ApplyCC internally calls ApplyBuff, so this single check covers all CC paths.
-- NPCs are not subject to ccImmune (priest does not cleanse allied NPCs).
if buffData and buffData.type == "Crowd Control" then
local th = HeroSwap and HeroSwap.GetHeroByUnit
and HeroSwap.GetHeroByUnit(unit)
if th and th.runtimeFlags and th.runtimeFlags.ccImmune then
return false
end
end
-- visualPreset merge — shallow-copy required to prevent skill_data pollution.
-- Bug-fix design: prevents the first preset from becoming permanently locked
-- when the same skill is re-cast.
local bd = buffData
if buffData and buffData.visualPreset then
bd = {}
for k, v in pairs(buffData) do bd[k] = v end
local lib = (bd.category == "DEBUFF") and DEBUFF_VISUALS or BUFF_VISUALS
local preset = lib[bd.visualPreset]
if preset then
-- Use `== nil` comparison instead of `or`.
-- This structurally blocks a bug where `or` would pick the preset
-- value when the skill explicitly disables a field (e.g. effect=false).
-- Currently works by accident because stealth.effect=nil, but this
-- handles future false-value cases.
if bd.effect == nil then bd.effect = preset.effect end
if bd.icon == nil then bd.icon = preset.icon end
if bd.color == nil then bd.color = preset.color end
if bd.attachPoint == nil then bd.attachPoint = preset.attachPoint end
-- displayName: skill name takes priority, only use preset if nil.
if bd.displayName == nil then bd.displayName = preset.displayName end
end
end
local BB = BuffSystem.BuffBot
if BB then
BB.Apply(unit, bd, source, level)
-- [Workaround] BuffBot's CheckBuff periodic callback does not work
-- in this environment, so buffs do not auto-expire. The World Editor
-- uses an internal copy of the map, so BuffBot.lua modifications are
-- not reflected. ALICE_CallDelayed works correctly via src/, so we
-- bypass with an external timer that calls Dispel directly at the
-- duration's expiry point.
-- On re-apply (refresh), fallOffPoint is updated so remain > 0.1
-- and the Dispel is automatically skipped.
if bd and bd.duration and bd.duration > 0
and BB.Dispel and BB.GetRemainingDuration and ALICE_CallDelayed then
local capturedUnit = unit
local capturedName = bd.name
ALICE_CallDelayed(function()
if capturedUnit and GetUnitTypeId(capturedUnit) ~= 0 then
local remain = BB.GetRemainingDuration(capturedUnit, capturedName) or 0
if remain <= 0.1 then
BB.Dispel(capturedUnit, capturedName)
end
end
end, bd.duration + 0.05)
end
-- Remaining-time tracking timer (text countdown + expiry-imminent flicker).
-- BuffBot CheckBuff does not work in this environment, so we implement
-- it externally. TimerStart fires immediately after BB.Apply, no
-- ALICE_CallDelayed delay — runs every tick.
-- Desync caution: BlzFrameSet* is only called inside the
-- `GetLocalPlayer() == curOwner` branch. DestroyTimer must be called
-- on every termination path (unit death / expiry).
-- [Restored 2026-05-03 BISECT-C] LIGUI removal showed desync mitigation.
-- Visual tick restored.
if bd and bd.duration and bd.duration > 0
and BB.GetData and BB.GetRemainingDuration and bd.name then
local capturedUnit = unit
local capturedName = bd.name
-- owner is re-evaluated on every tick inside the timer callback.
-- If unit ownership changes at runtime (-give, LinkHeroUnit, etc.)
-- the captured owner becomes stale, causing a UX bug or potential
-- future desync risk. GetOwningPlayer is a WC3 global state query
-- that returns the same value on every machine — sync-safe.
local FLICKER_DURATION = 5.0 -- flicker start threshold (seconds remaining)
local FLICKER_FAST = 2.0 -- fast-flicker threshold (seconds)
local FLICKER_MIN = 80 -- minimum alpha (0~255)
local TICK = 0.05 -- timer period (seconds)
local TOOLTIP_WIDTH = 0.24 -- BuffBot constant TOOLTIP_WIDTH (L123) hardcoded
local counter = 0
local alphaRestored = false
-- [Re-apply leak fix] Prevent duplicate timers for the same
-- unit×buffName by destroying any existing timer first.
-- Re-apply (refresh) keeps only one timer.
local timerKey = tostring(GetHandleId(capturedUnit)) .. "_" .. capturedName
local existingTmr = _activeBuffTimers[timerKey]
if existingTmr then
PauseTimer(existingTmr)
DestroyTimer(existingTmr)
_activeBuffTimers[timerKey] = nil
end
local tmr = CreateTimer()
_activeBuffTimers[timerKey] = tmr
TimerStart(tmr, TICK, true, function()
-- Unit destroyed → release timer immediately.
if not capturedUnit or GetUnitTypeId(capturedUnit) == 0 then
DestroyTimer(tmr)
_activeBuffTimers[timerKey] = nil -- lookup cleanup
return
end
-- Re-query current owner every tick to track ownership changes.
local curOwner = GetOwningPlayer(capturedUnit)
local remain = BB.GetRemainingDuration(capturedUnit, capturedName) or 0
-- About to expire (Dispel workaround handles BB.Dispel) → release timer.
if remain <= TICK then
DestroyTimer(tmr)
_activeBuffTimers[timerKey] = nil -- lookup cleanup
return
end
local data = BB.GetData(capturedUnit, capturedName)
if not data then return end
-- Update text countdown (every tick, local machine only).
-- Ported from BuffBot original L764-770, L783-790.
if GetLocalPlayer() == curOwner then
-- "N s" text below the icon
if data.textFrame then
BlzFrameSetText(data.textFrame, GetDurationText(remain))
end
-- Tooltip "N seconds remaining" text + size adjustment
if data.tooltipText and data.tooltip then
BlzFrameSetText(data.tooltipText,
data.tooltip .. "|n" .. GetDurationTextExtended(remain))
if data.tooltipParent then
BlzFrameSetSize(data.tooltipText, TOOLTIP_WIDTH, 0.0)
BlzFrameSetSize(data.tooltipParent,
TOOLTIP_WIDTH + 0.01,
BlzFrameGetHeight(data.tooltipText) + 0.031)
end
end
end
-- Alpha flicker manipulation (only if parentFrame exists).
if data.parentFrame then
if remain <= FLICKER_DURATION then
-- Entering flicker zone.
alphaRestored = false
-- Below 2 seconds: 2x speed flicker.
if remain <= FLICKER_FAST then
counter = counter + 2
else
counter = counter + 1
end
-- BuffBot original formula port (see L723-731).
local alpha = FLICKER_MIN + ((255 - FLICKER_MIN)
* (1 + math.cos(2 * bj_PI * counter * TICK / 1.0)) / 2) // 1
-- Local UI manipulation — desync prevention by running
-- only on the owning player's client.
if GetLocalPlayer() == curOwner then
BlzFrameSetAlpha(data.parentFrame, alpha)
end
elseif not alphaRestored then
-- Re-apply pushed remain back above 5 seconds → restore alpha 255 (one-shot).
alphaRestored = true
counter = 0
if GetLocalPlayer() == curOwner then
BlzFrameSetAlpha(data.parentFrame, 255)
end
end
end
end)
end
return true
end
-- Fallback path (BuffBot not loaded — never used in practice).
local hero = getHeroFromUnit(unit)
if not hero then return false end
local buffId = bd.name or ""
local existing = hero.activeBuffs[buffId]
if existing then
existing.remaining = bd.duration or existing.duration
existing.sourceUnit = source
return true
end
hero.activeBuffs[buffId] = CoreData.CreateBuffEntry({
buffId = buffId,
sourceUnit = source,
category = bd.category or CoreData.BUFF_CATEGORY.BUFF,
duration = bd.duration or 0,
remaining = bd.duration or 0,
statMods = bd.statMods,
ratioMods = bd.ratioMods,
})
if BuffSystem.StatEngine then
BuffSystem.StatEngine.RecalculateStats(hero)
end
return true
end
function BuffSystem.RemoveBuff(unit, buffName)
local BB = BuffSystem.BuffBot
if BB then
BB.Dispel(unit, buffName)
return true
end
local hero = getHeroFromUnit(unit)
if not hero then return false end
if hero.activeBuffs[buffName] then
hero.activeBuffs[buffName] = nil
if BuffSystem.StatEngine then
BuffSystem.StatEngine.RecalculateStats(hero)
end
return true
end
return false
end
function BuffSystem.RemoveAllBuffs(unit, buffType)
local BB = BuffSystem.BuffBot
if BB then
BB.DispelAll(unit, buffType)
return
end
local hero = getHeroFromUnit(unit)
if not hero then return end
for buffId, buff in pairs(hero.activeBuffs) do
if not buffType or buff.category == buffType then
hero.activeBuffs[buffId] = nil
end
end
if BuffSystem.StatEngine then
BuffSystem.StatEngine.RecalculateStats(hero)
end
end