local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) if not HMGT then return end HMGT.TrackerDetection = HMGT.TrackerDetection or {} local internals = HMGT.TrackerInternals or {} local GetSpellChargesInfo = internals.GetSpellChargesInfo local GetSpellCooldownInfo = internals.GetSpellCooldownInfo local GetGlobalCooldownInfo = internals.GetGlobalCooldownInfo local GetSpellDebugLabel = internals.GetSpellDebugLabel local BuildCooldownStateFingerprint = internals.BuildCooldownStateFingerprint local ApplyOwnCooldownReducers = internals.ApplyOwnCooldownReducers local ApplyObservedCooldownReducers = internals.ApplyObservedCooldownReducers function HMGT:HandleOwnSpellCast(spellId) local isInterrupt = HMGT_SpellData.InterruptLookup[spellId] ~= nil local isCooldown = HMGT_SpellData.CooldownLookup[spellId] ~= nil if not isInterrupt and not isCooldown then return end local spellEntry = HMGT_SpellData.InterruptLookup[spellId] or HMGT_SpellData.CooldownLookup[spellId] spellId = tonumber(spellEntry and spellEntry.spellId) or spellId local name = self:NormalizePlayerName(UnitName("player")) local pData = self.playerData[name] local talents = pData and pData.talents or {} if self:IsAvailabilitySpell(spellEntry) then self:LogTrackedSpellCast(name, spellEntry, { stateKind = "availability", required = HMGT_SpellData.GetEffectiveAvailabilityRequired(spellEntry, talents), }) if self:RefreshOwnAvailabilitySpell(spellEntry) then self:PublishOwnSpellState(spellId, { sendLegacy = true }) end self:TriggerTrackerUpdate() return end local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) local now = GetTime() local inCombat = InCombatLockdown and InCombatLockdown() local cur, max, chargeStart, chargeDuration = nil, nil, nil, nil if not inCombat and GetSpellChargesInfo then cur, max, chargeStart, chargeDuration = GetSpellChargesInfo(spellId) end local cachedMaxCharges, cachedChargeDuration = self:GetKnownChargeInfo( spellEntry, talents, spellId, (not inCombat and tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration) or effectiveCd ) local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo( spellEntry, talents, (not inCombat and tonumber(max) and tonumber(max) > 0) and tonumber(max) or ((cachedMaxCharges > 0) and cachedMaxCharges or nil), (not inCombat and tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration) or ((cachedChargeDuration > 0) and cachedChargeDuration or effectiveCd) ) local hasCharges = ((tonumber(max) or 0) > 1) or (tonumber(inferredMaxCharges) or 0) > 1 local currentCharges = 0 local maxCharges = 0 local chargeDur = 0 local chargeStartTime = nil local startTime = now local duration = effectiveCd local expiresIn = effectiveCd local existingCd = self:GetActiveCooldown(name, spellId) if existingCd and (tonumber(existingCd.maxCharges) or 0) > 0 then self:ResolveChargeState(existingCd, now) end if hasCharges then maxCharges = math.max(1, tonumber(max) or cachedMaxCharges or tonumber(inferredMaxCharges) or 1) currentCharges = tonumber(cur) if currentCharges == nil then local prevCharges = existingCd and tonumber(existingCd.currentCharges) local prevMax = existingCd and tonumber(existingCd.maxCharges) if prevCharges and prevMax and prevMax == maxCharges then currentCharges = math.max(0, prevCharges - 1) else currentCharges = math.max(0, maxCharges - 1) end end currentCharges = math.max(0, math.min(maxCharges, currentCharges)) chargeDur = tonumber(chargeDuration) or cachedChargeDuration or tonumber(inferredChargeDuration) or tonumber(effectiveCd) or 0 chargeDur = math.max(0, chargeDur) self:StoreKnownChargeInfo(spellId, maxCharges, chargeDur) if currentCharges < maxCharges and chargeDur > 0 then chargeStartTime = tonumber(chargeStart) or now local missing = maxCharges - currentCharges startTime = chargeStartTime duration = missing * chargeDur expiresIn = math.max(0, duration - (now - startTime)) else startTime = now duration = 0 expiresIn = 0 end end self:Debug( "verbose", "HandleOwnSpellCast name=%s spellId=%s cd=%.2f charges=%s/%s", tostring(name), tostring(spellId), tonumber(effectiveCd) or 0, hasCharges and tostring(currentCharges) or "-", hasCharges and tostring(maxCharges) or "-" ) self._cdNonce = (self._cdNonce or 0) + 1 local nonce = self._cdNonce self:SetActiveCooldown(name, spellId, { startTime = startTime, duration = duration, spellEntry = spellEntry, currentCharges = hasCharges and currentCharges or nil, maxCharges = hasCharges and maxCharges or nil, chargeStart = hasCharges and chargeStartTime or nil, chargeDuration = hasCharges and chargeDur or nil, _nonce = nonce, }) self:LogTrackedSpellCast(name, spellEntry, { cooldown = effectiveCd, currentCharges = hasCharges and currentCharges or nil, maxCharges = hasCharges and maxCharges or nil, chargeCooldown = hasCharges and chargeDur or nil, }) if expiresIn > 0 then self:ScheduleTimer(function() local current = self:GetActiveCooldown(name, spellId) if current and current._nonce == nonce then self:ClearActiveCooldown(name, spellId) self:PublishOwnSpellState(spellId) self:TriggerTrackerUpdate() end end, expiresIn) end self:PublishOwnSpellState(spellId, { sendLegacy = true }) self:TriggerTrackerUpdate() end function HMGT:RefreshOwnCooldownStateFromGame(spellId) local sid = tonumber(spellId) if not sid then return false end if InCombatLockdown and InCombatLockdown() then return false end local ownName = self:NormalizePlayerName(UnitName("player")) if not ownName then return false end local spellEntry = HMGT_SpellData.InterruptLookup[sid] or HMGT_SpellData.CooldownLookup[sid] if not spellEntry or self:IsAvailabilitySpell(spellEntry) then return false end sid = tonumber(spellEntry.spellId) or sid local existing = self:GetActiveCooldown(ownName, sid) local before = BuildCooldownStateFingerprint and BuildCooldownStateFingerprint(existing) or "nil" local now = GetTime() local pData = self.playerData[ownName] local talents = pData and pData.talents or {} local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) local cur, max, chargeStart, chargeDuration = nil, nil, nil, nil if GetSpellChargesInfo then cur, max, chargeStart, chargeDuration = GetSpellChargesInfo(sid) end local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo( spellEntry, talents, (tonumber(max) and tonumber(max) > 0) and tonumber(max) or nil, (tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration) or effectiveCd ) local hasCharges = ((tonumber(max) or 0) > 1) or (tonumber(inferredMaxCharges) or 0) > 1 if hasCharges then local maxCharges = math.max(1, tonumber(max) or tonumber(inferredMaxCharges) or 1) local currentCharges = tonumber(cur) if currentCharges == nil then currentCharges = maxCharges end currentCharges = math.max(0, math.min(maxCharges, currentCharges)) local chargeDur = tonumber(chargeDuration) or tonumber(inferredChargeDuration) or tonumber(effectiveCd) or 0 chargeDur = math.max(0, chargeDur) if currentCharges < maxCharges and chargeDur > 0 then local chargeStartTime = tonumber(chargeStart) or now local missing = maxCharges - currentCharges local updatedEntry = self:SetActiveCooldown(ownName, sid, { startTime = chargeStartTime, duration = missing * chargeDur, spellEntry = spellEntry, currentCharges = currentCharges, maxCharges = maxCharges, chargeStart = chargeStartTime, chargeDuration = chargeDur, }) self:RefreshCooldownExpiryTimer(ownName, sid, updatedEntry) else self:ClearActiveCooldown(ownName, sid) end else local cooldownStart, cooldownDuration = 0, 0 if GetSpellCooldownInfo then cooldownStart, cooldownDuration = GetSpellCooldownInfo(sid) end cooldownStart = tonumber(cooldownStart) or 0 cooldownDuration = tonumber(cooldownDuration) or 0 local gcdStart, gcdDuration = 0, 0 if GetGlobalCooldownInfo then gcdStart, gcdDuration = GetGlobalCooldownInfo() end gcdStart = tonumber(gcdStart) or 0 gcdDuration = tonumber(gcdDuration) or 0 local existingDuration = tonumber(existing and existing.duration) or 0 local existingStart = tonumber(existing and existing.startTime) or now local existingRemaining = math.max(0, existingDuration - (now - existingStart)) local isLikelyGlobalCooldown = cooldownDuration > 0 and gcdDuration > 0 and math.abs(cooldownDuration - gcdDuration) <= 0.15 and (tonumber(effectiveCd) or 0) > (gcdDuration + 1.0) local isSuspiciousShortRefresh = cooldownDuration > 0 and existingRemaining > 2.0 and existingDuration > 2.0 and cooldownDuration < math.max(2.0, existingDuration * 0.35) and cooldownDuration < math.max(2.0, (tonumber(effectiveCd) or 0) * 0.35) if isLikelyGlobalCooldown or isSuspiciousShortRefresh then self:DebugScoped( "verbose", "TrackedSpells", "Ignore suspicious refresh for %s: spellCD=%.3f gcd=%.3f existing=%.3f remaining=%.3f effective=%.3f", GetSpellDebugLabel and GetSpellDebugLabel(sid) or tostring(sid), cooldownDuration, gcdDuration, existingDuration, existingRemaining, tonumber(effectiveCd) or 0 ) return false end if cooldownDuration > 0 then local updatedEntry = self:SetActiveCooldown(ownName, sid, { startTime = cooldownStart, duration = cooldownDuration, spellEntry = spellEntry, }) self:RefreshCooldownExpiryTimer(ownName, sid, updatedEntry) else self:ClearActiveCooldown(ownName, sid) end end local after = BuildCooldownStateFingerprint and BuildCooldownStateFingerprint(self:GetActiveCooldown(ownName, sid)) or "nil" return before ~= after end function HMGT:DidOwnInterruptSucceed(triggerSpellId, talents) local sid = tonumber(triggerSpellId) if not sid then return false end local spellEntry = HMGT_SpellData and HMGT_SpellData.InterruptLookup and HMGT_SpellData.InterruptLookup[sid] if not spellEntry then return false end sid = tonumber(spellEntry.spellId) or sid local observedDuration = 0 if GetSpellCooldownInfo then local _, duration = GetSpellCooldownInfo(sid) observedDuration = duration end observedDuration = tonumber(observedDuration) or 0 if observedDuration <= 0 then return false end local expectedDuration = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) expectedDuration = tonumber(expectedDuration) or 0 if expectedDuration <= 0 then return false end return observedDuration < (expectedDuration - 0.05) end function HMGT:HandleOwnCooldownReductionTrigger(triggerSpellId) local ownName = self:NormalizePlayerName(UnitName("player")) if not ownName then return end local pData = self.playerData[ownName] local classToken = pData and pData.class or select(2, UnitClass("player")) local specIndex = pData and pData.specIndex or GetSpecialization() local talents = pData and pData.talents or {} if not classToken or not specIndex then return end local reducers = HMGT_SpellData.GetCooldownReducersForCast(classToken, specIndex, triggerSpellId, talents) if not reducers or #reducers == 0 then return end local instantReducers = {} local observedInstantReducers = {} local successReducers = {} local observedSuccessReducers = {} for _, reducer in ipairs(reducers) do local observed = type(reducer.observe) == "table" if reducer.requireInterruptSuccess then if observed then observedSuccessReducers[#observedSuccessReducers + 1] = reducer else successReducers[#successReducers + 1] = reducer end else if observed then observedInstantReducers[#observedInstantReducers + 1] = reducer else instantReducers[#instantReducers + 1] = reducer end end end local castTs = GetServerTime() if #instantReducers > 0 and ApplyOwnCooldownReducers then ApplyOwnCooldownReducers(self, ownName, triggerSpellId, instantReducers, castTs) end if #observedInstantReducers > 0 and ApplyObservedCooldownReducers then ApplyObservedCooldownReducers(self, ownName, observedInstantReducers) end if #successReducers > 0 or #observedSuccessReducers > 0 then local function ApplySuccessReducers() if not self:DidOwnInterruptSucceed(triggerSpellId, talents) then return false end if #successReducers > 0 and ApplyOwnCooldownReducers then ApplyOwnCooldownReducers(self, ownName, triggerSpellId, successReducers, castTs) end if #observedSuccessReducers > 0 and ApplyObservedCooldownReducers then ApplyObservedCooldownReducers(self, ownName, observedSuccessReducers) end return true end if not ApplySuccessReducers() then C_Timer.After(0.12, function() if not self or not self.playerData or not self.playerData[ownName] then return end ApplySuccessReducers() end) end end end function HMGT:HandleRemoteSpellCast(playerName, spellId, castTimestamp, curCharges, maxCharges, chargeRemaining, chargeDuration) local spellEntry = HMGT_SpellData.InterruptLookup[spellId] or HMGT_SpellData.CooldownLookup[spellId] if not spellEntry then return end spellId = tonumber(spellEntry.spellId) or spellId if self:IsAvailabilitySpell(spellEntry) then return end local pData = self.playerData[playerName] local talents = pData and pData.talents or {} local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) castTimestamp = tonumber(castTimestamp) or GetServerTime() local existingEntry = self:GetActiveCooldown(playerName, spellId) if (tonumber(maxCharges) or 0) <= 0 and existingEntry and existingEntry.lastCastTimestamp then local prevTs = tonumber(existingEntry.lastCastTimestamp) or 0 if math.abs(prevTs - castTimestamp) <= 1 then return end end local now = GetTime() local elapsed = math.max(0, GetServerTime() - castTimestamp) local incomingCur = tonumber(curCharges) or 0 local incomingMax = tonumber(maxCharges) or 0 local incomingChargeRemaining = tonumber(chargeRemaining) or 0 local incomingChargeDuration = tonumber(chargeDuration) or 0 local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo( spellEntry, talents, (incomingMax > 0) and incomingMax or nil, (incomingChargeDuration > 0) and incomingChargeDuration or effectiveCd ) local hasCharges = (incomingMax > 1) or (tonumber(inferredMaxCharges) or 0) > 1 local currentCharges = 0 local maxChargeCount = 0 local chargeDur = 0 local nextChargeRemaining = 0 local chargeStartTime = nil local startTime, duration, expiresIn if hasCharges then maxChargeCount = math.max(1, (incomingMax > 0 and incomingMax) or tonumber(inferredMaxCharges) or 1) chargeDur = tonumber(incomingChargeDuration) or tonumber(inferredChargeDuration) or tonumber(effectiveCd) or 0 chargeDur = math.max(0, chargeDur) if chargeDur <= 0 then chargeDur = math.max(0, tonumber(effectiveCd) or 0) end if incomingMax > 0 then currentCharges = math.max(0, math.min(maxChargeCount, incomingCur)) nextChargeRemaining = math.max(0, math.min(chargeDur, incomingChargeRemaining - elapsed)) if currentCharges < maxChargeCount and chargeDur > 0 then chargeStartTime = now - math.max(0, chargeDur - nextChargeRemaining) end else local existing = self:GetActiveCooldown(playerName, spellId) if existing and (tonumber(existing.maxCharges) or 0) == maxChargeCount then self:ResolveChargeState(existing, now) local prevCharges = tonumber(existing.currentCharges) or maxChargeCount local prevStart = tonumber(existing.chargeStart) local prevDur = tonumber(existing.chargeDuration) or chargeDur if prevDur > 0 then chargeDur = prevDur end currentCharges = math.max(0, prevCharges - 1) if currentCharges < maxChargeCount and chargeDur > 0 then if prevCharges >= maxChargeCount then chargeStartTime = now else chargeStartTime = prevStart or now end nextChargeRemaining = math.max(0, chargeDur - (now - chargeStartTime)) end else currentCharges = math.max(0, maxChargeCount - 1) if currentCharges < maxChargeCount and chargeDur > 0 then chargeStartTime = now nextChargeRemaining = chargeDur end end end if currentCharges >= maxChargeCount and maxChargeCount > 0 then currentCharges = math.max(0, maxChargeCount - 1) if chargeDur > 0 then chargeStartTime = now nextChargeRemaining = chargeDur end end if currentCharges < maxChargeCount and chargeDur > 0 then chargeStartTime = chargeStartTime or now local missing = maxChargeCount - currentCharges startTime = chargeStartTime duration = missing * chargeDur expiresIn = math.max(0, duration - (now - startTime)) else startTime = now duration = 0 expiresIn = 0 end else local remaining = effectiveCd - elapsed if remaining <= 0 then return end startTime = now - elapsed duration = effectiveCd expiresIn = remaining end self:Debug( "verbose", "HandleRemoteSpellCast name=%s spellId=%s elapsed=%.2f expiresIn=%.2f charges=%s/%s", tostring(playerName), tostring(spellId), tonumber(elapsed) or 0, tonumber(expiresIn) or 0, hasCharges and tostring(currentCharges) or "-", hasCharges and tostring(maxChargeCount) or "-" ) self._cdNonce = (self._cdNonce or 0) + 1 local nonce = self._cdNonce self:SetActiveCooldown(playerName, spellId, { startTime = startTime, duration = duration, spellEntry = spellEntry, currentCharges = hasCharges and currentCharges or nil, maxCharges = hasCharges and maxChargeCount or nil, chargeStart = hasCharges and chargeStartTime or nil, chargeDuration = hasCharges and chargeDur or nil, lastCastTimestamp = castTimestamp, _nonce = nonce, }) self:LogTrackedSpellCast(playerName, spellEntry, { cooldown = effectiveCd, currentCharges = hasCharges and currentCharges or nil, maxCharges = hasCharges and maxChargeCount or nil, chargeCooldown = hasCharges and chargeDur or nil, }) if expiresIn > 0 then self:ScheduleTimer(function() local current = self:GetActiveCooldown(playerName, spellId) if current and current._nonce == nonce then self:ClearActiveCooldown(playerName, spellId) self:TriggerTrackerUpdate() end end, expiresIn) end self:TriggerTrackerUpdate() end