local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) if not HMGT then return end HMGT.TrackerSync = HMGT.TrackerSync or {} local internals = HMGT.TrackerInternals or {} local GetSpellChargesInfo = internals.GetSpellChargesInfo local GetSpellDebugLabel = internals.GetSpellDebugLabel local MSG_SPELL_CAST = HMGT.MSG_SPELL_CAST local MSG_CD_REDUCE = HMGT.MSG_CD_REDUCE local MSG_SPELL_STATE = HMGT.MSG_SPELL_STATE local MSG_HELLO = HMGT.MSG_HELLO local MSG_PLAYER_INFO = HMGT.MSG_PLAYER_INFO local MSG_SYNC_REQUEST = HMGT.MSG_SYNC_REQUEST local MSG_SYNC_RESPONSE = HMGT.MSG_SYNC_RESPONSE local MSG_RELIABLE = HMGT.MSG_RELIABLE local MSG_ACK = HMGT.MSG_ACK local COMM_PREFIX = HMGT.COMM_PREFIX local ADDON_VERSION = HMGT.ADDON_VERSION or "dev" local PROTOCOL_VERSION = HMGT.PROTOCOL_VERSION or 0 function HMGT:SuppressRemoteTrackedSpellLogs(playerName, duration) local normalizedName = self:NormalizePlayerName(playerName) if not normalizedName then return end self._suppressTrackedSpellLogUntil = self._suppressTrackedSpellLogUntil or {} self._suppressTrackedSpellLogUntil[normalizedName] = GetTime() + math.max(0, tonumber(duration) or 0) end function HMGT:IsRemoteTrackedSpellLogSuppressed(playerName) local normalizedName = self:NormalizePlayerName(playerName) local suppression = self._suppressTrackedSpellLogUntil local untilTime = suppression and suppression[normalizedName] if not untilTime then return false end if untilTime <= GetTime() then suppression[normalizedName] = nil return false end return true end function HMGT:BuildClearSpellStateSnapshot(spellId, spellEntry) return { spellId = tonumber(spellId), spellEntry = spellEntry, kind = "clear", a = 0, b = 0, c = 0, d = 0, } end function HMGT:GetOwnSpellStateSnapshot(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return nil end local spellEntry = HMGT_SpellData.InterruptLookup[sid] or HMGT_SpellData.CooldownLookup[sid] if not spellEntry then return nil end if self:IsAvailabilitySpell(spellEntry) then local current, max = self:GetOwnAvailabilityProgress(spellEntry) if (tonumber(max) or 0) > 0 then self:StoreAvailabilityState(self:NormalizePlayerName(UnitName("player")), sid, current, max, spellEntry) return { spellId = sid, spellEntry = spellEntry, kind = "availability", a = tonumber(current) or 0, b = tonumber(max) or 0, c = 0, d = 0, } end return self:BuildClearSpellStateSnapshot(sid, spellEntry) end local ownName = self:NormalizePlayerName(UnitName("player")) local pData = ownName and self.playerData and self.playerData[ownName] local talents = pData and pData.talents or {} local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) local knownMaxCharges, knownChargeDuration = self:GetKnownChargeInfo(spellEntry, talents, sid, effectiveCd) local cdData = ownName and self:GetActiveCooldown(ownName, sid) if cdData then if (tonumber(cdData.maxCharges) or 0) > 0 then local nextRemaining, chargeDuration, charges, maxCharges = self:ResolveChargeState(cdData) self:StoreKnownChargeInfo(sid, maxCharges, chargeDuration) if (tonumber(maxCharges) or 0) > 0 and (tonumber(charges) or 0) < (tonumber(maxCharges) or 0) then return { spellId = sid, spellEntry = spellEntry, kind = "charges", a = tonumber(charges) or 0, b = tonumber(maxCharges) or 0, c = tonumber(nextRemaining) or 0, d = tonumber(chargeDuration) or 0, } end elseif knownMaxCharges > 1 then local duration = tonumber(cdData.duration) or 0 local startTime = tonumber(cdData.startTime) or GetTime() local remaining = math.max(0, duration - (GetTime() - startTime)) local currentCharges = knownMaxCharges if remaining > 0 then currentCharges = math.max(0, knownMaxCharges - 1) return { spellId = sid, spellEntry = spellEntry, kind = "charges", a = tonumber(currentCharges) or 0, b = tonumber(knownMaxCharges) or 0, c = tonumber(remaining) or 0, d = tonumber(knownChargeDuration) or tonumber(effectiveCd) or duration, } end else local duration = tonumber(cdData.duration) or 0 local startTime = tonumber(cdData.startTime) or GetTime() local remaining = math.max(0, duration - (GetTime() - startTime)) if duration > 0 and remaining > 0 then return { spellId = sid, spellEntry = spellEntry, kind = "cooldown", a = remaining, b = duration, c = 0, d = 0, } end end end if InCombatLockdown and InCombatLockdown() then if knownMaxCharges > 1 then return { spellId = sid, spellEntry = spellEntry, kind = "charges", a = tonumber(knownMaxCharges) or 0, b = tonumber(knownMaxCharges) or 0, c = 0, d = tonumber(knownChargeDuration) or tonumber(effectiveCd) or 0, } end return self:BuildClearSpellStateSnapshot(sid, spellEntry) end local remaining, total, currentCharges, maxCharges = self:GetCooldownInfo(ownName, sid) if (tonumber(maxCharges) or 0) > 0 then local cur = math.max(0, math.floor((tonumber(currentCharges) or 0) + 0.5)) local max = math.max(0, math.floor((tonumber(maxCharges) or 0) + 0.5)) local nextRemaining = math.max(0, tonumber(remaining) or 0) local chargeDuration = math.max(0, tonumber(total) or 0) if max <= 0 or cur >= max then return self:BuildClearSpellStateSnapshot(sid, spellEntry) end return { spellId = sid, spellEntry = spellEntry, kind = "charges", a = cur, b = max, c = nextRemaining, d = chargeDuration, } end local duration = math.max(0, tonumber(total) or 0) local cooldownRemaining = math.max(0, tonumber(remaining) or 0) if duration <= 0 or cooldownRemaining <= 0 then return self:BuildClearSpellStateSnapshot(sid, spellEntry) end return { spellId = sid, spellEntry = spellEntry, kind = "cooldown", a = cooldownRemaining, b = duration, c = 0, d = 0, } end function HMGT:SendSpellStateSnapshot(snapshot, target, revision) if type(snapshot) ~= "table" then return false end local sid = tonumber(snapshot.spellId) local kind = tostring(snapshot.kind or "") local rev = tonumber(revision) or 0 if not sid or sid <= 0 or kind == "" or rev <= 0 then return false end self:DebugScoped( "verbose", "TrackedSpells", "SendSpellStateSnapshot target=%s spell=%s kind=%s rev=%d a=%.3f b=%.3f c=%.3f d=%.3f", tostring(target and target ~= "" and target or "GROUP"), GetSpellDebugLabel and GetSpellDebugLabel(sid) or tostring(sid), tostring(kind), rev, tonumber(snapshot.a) or 0, tonumber(snapshot.b) or 0, tonumber(snapshot.c) or 0, tonumber(snapshot.d) or 0 ) local payload = string.format( "%s|%d|%s|%d|%.3f|%.3f|%.3f|%.3f|%s|%d", MSG_SPELL_STATE, sid, kind, rev, tonumber(snapshot.a) or 0, tonumber(snapshot.b) or 0, tonumber(snapshot.c) or 0, tonumber(snapshot.d) or 0, ADDON_VERSION, PROTOCOL_VERSION ) if target and target ~= "" then self:SendDirectMessage(payload, target, "ALERT") else self:SendGroupMessage(payload, "ALERT") end return true end function HMGT:PublishOwnSpellState(spellId, opts) opts = opts or {} local sid = tonumber(spellId) if not sid or sid <= 0 then return false end local snapshot = opts.snapshot or self:GetOwnSpellStateSnapshot(sid) if not snapshot then return false end local revision = tonumber(opts.revision) or self:NextLocalSpellStateRevision(sid) local sent = self:SendSpellStateSnapshot(snapshot, opts.target, revision) if not sent then return false end if opts.sendLegacy then if snapshot.kind == "availability" then self:BroadcastAvailabilityState(sid, snapshot.a, snapshot.b, opts.target) elseif snapshot.kind ~= "clear" then self:BroadcastSpellCast(sid, snapshot) end end return true end function HMGT:SendOwnTrackedSpellStates(target) local ownName = self:NormalizePlayerName(UnitName("player")) if not ownName then return 0 end self:RefreshOwnAvailabilityStates() local sent = 0 local sentBySpell = {} local activeStates = self:GetPlayerCooldownMap(ownName, false) if type(activeStates) == "table" then for sid in pairs(activeStates) do sid = tonumber(sid) if sid and sid > 0 and not sentBySpell[sid] then local revision = self:EnsureLocalSpellStateRevision(sid) if revision > 0 and self:SendSpellStateSnapshot(self:GetOwnSpellStateSnapshot(sid), target, revision) then sent = sent + 1 sentBySpell[sid] = true end end end end local availabilityStates = self:GetAvailabilityStateMap(ownName, false) if type(availabilityStates) == "table" then for sid in pairs(availabilityStates) do sid = tonumber(sid) if sid and sid > 0 and not sentBySpell[sid] then local revision = self:EnsureLocalSpellStateRevision(sid) if revision > 0 and self:SendSpellStateSnapshot(self:GetOwnSpellStateSnapshot(sid), target, revision) then sent = sent + 1 sentBySpell[sid] = true end end end end return sent end function HMGT:BroadcastRepairSpellStates() if not self:IsEnabled() then return end local sent = self:SendOwnTrackedSpellStates() if sent > 0 then self:DebugScoped("verbose", "TrackedSpells", "RepairSpellStates sent=%d", sent) end end function HMGT:ReconcileOwnTrackedSpellStatesFromGame(publishChanges) if InCombatLockdown and InCombatLockdown() then return 0 end local ownName = self:NormalizePlayerName(UnitName("player")) local pData = ownName and self.playerData and self.playerData[ownName] if not ownName or not pData or not pData.class or not pData.specIndex then return 0 end pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex) local changed = 0 for sid in pairs(pData.knownSpells or {}) do local spellEntry = HMGT_SpellData.InterruptLookup[sid] or HMGT_SpellData.CooldownLookup[sid] if spellEntry and not self:IsAvailabilitySpell(spellEntry) then if self:RefreshOwnCooldownStateFromGame(sid) then changed = changed + 1 if publishChanges then self:PublishOwnSpellState(sid, { sendLegacy = true }) end end end end if changed > 0 then self:TriggerTrackerUpdate() end return changed end function HMGT:SendHello(target) local name = self:NormalizePlayerName(UnitName("player")) local pData = self.playerData[name] if not pData or not pData.class or not pData.specIndex then return end pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex) self:RefreshOwnAvailabilityStates() local knownSpellList = self:SerializeKnownSpellList(pData.knownSpells) local knownCount = 0 for _ in pairs(pData.knownSpells or {}) do knownCount = knownCount + 1 end local payload = string.format("%s|%s|%d|%s|%d|%s|%s", MSG_HELLO, ADDON_VERSION, PROTOCOL_VERSION, pData.class, pData.specIndex, pData.talentHash or "", knownSpellList ) if target and target ~= "" then self:DebugScoped("verbose", "Comm", "SendHello whisper target=%s class=%s spec=%s spells=%d", tostring(target), tostring(pData.class), tostring(pData.specIndex), knownCount) self:SendDirectMessage(payload, target) self:SendOwnTrackedSpellStates(target) self:SendOwnAvailabilityStates(target) return end self:DebugScoped("verbose", "Comm", "SendHello group class=%s spec=%s spells=%d", tostring(pData.class), tostring(pData.specIndex), knownCount) self:SendGroupMessage(payload) self:SendOwnTrackedSpellStates() self:SendOwnAvailabilityStates() end function HMGT:BroadcastSpellCast(spellId, snapshot) local cur, max, chargeRemaining, chargeDuration = 0, 0, 0, 0 if type(snapshot) == "table" and tostring(snapshot.kind) == "charges" then cur = math.max(0, math.floor((tonumber(snapshot.a) or 0) + 0.5)) max = math.max(0, math.floor((tonumber(snapshot.b) or 0) + 0.5)) chargeRemaining = math.max(0, tonumber(snapshot.c) or 0) chargeDuration = math.max(0, tonumber(snapshot.d) or 0) elseif not (InCombatLockdown and InCombatLockdown()) and GetSpellChargesInfo then local c, m, cs, cd = GetSpellChargesInfo(spellId) cur = tonumber(c) or 0 max = tonumber(m) or 0 chargeDuration = tonumber(cd) or 0 if max > 0 and cur < max and cs and chargeDuration > 0 then chargeRemaining = math.max(0, chargeDuration - (GetTime() - cs)) end else local ownName = self:NormalizePlayerName(UnitName("player")) local remaining, total, currentCharges, maxCharges = self:GetCooldownInfo(ownName, spellId, { deferChargeCooldownUntilEmpty = false, }) cur = math.max(0, math.floor((tonumber(currentCharges) or 0) + 0.5)) max = math.max(0, math.floor((tonumber(maxCharges) or 0) + 0.5)) chargeRemaining = math.max(0, tonumber(remaining) or 0) chargeDuration = math.max(0, tonumber(total) or 0) end self:DebugScoped("verbose", "TrackedSpells", "BroadcastSpellCast spell=%s serverTime=%s charges=%d/%d", GetSpellDebugLabel and GetSpellDebugLabel(spellId) or tostring(spellId), tostring(GetServerTime()), cur, max) self:SendGroupMessage(string.format("%s|%d|%d|%d|%d|%.3f|%.3f|%s|%d", MSG_SPELL_CAST, spellId, GetServerTime(), cur, max, chargeRemaining, chargeDuration, ADDON_VERSION, PROTOCOL_VERSION)) end function HMGT:BroadcastCooldownReduce(targetSpellId, amount, castTimestamp, triggerSpellId) local sid = tonumber(targetSpellId) local value = tonumber(amount) or 0 if not sid or sid <= 0 or value <= 0 then return end local ts = tonumber(castTimestamp) or GetServerTime() local triggerId = tonumber(triggerSpellId) or 0 self:Debug( "verbose", "BroadcastCooldownReduce target=%s amount=%.2f ts=%s trigger=%s", tostring(sid), value, tostring(ts), tostring(triggerId) ) self:SendGroupMessage(string.format( "%s|%d|%.3f|%d|%d|%s|%d", MSG_CD_REDUCE, sid, value, ts, triggerId, ADDON_VERSION, PROTOCOL_VERSION )) end function HMGT:RequestSync(reason) self:DebugScoped("info", "Comm", "RequestSync(%s)", tostring(reason or "Hello")) self:SendHello() end function HMGT:QueueSyncRequest(delay, reason) local wait = tonumber(delay) or 0.2 if wait < 0 then wait = 0 end if self._syncRequestTimer then return end self._syncRequestTimer = self:ScheduleTimer(function() self._syncRequestTimer = nil self:RequestSync(reason or "Hello") end, wait) end function HMGT:QueueDeltaSyncBurst(reason, delays) if not (IsInGroup() or IsInRaid()) then return end local now = GetTime() local normalizedReason = tostring(reason or "delta") self._deltaSyncBurstAt = self._deltaSyncBurstAt or {} if (tonumber(self._deltaSyncBurstAt[normalizedReason]) or 0) > now - 2.5 then return end self._deltaSyncBurstAt[normalizedReason] = now delays = type(delays) == "table" and delays or { 0.35, 1.25, 2.75 } self._syncBurstTimers = self._syncBurstTimers or {} for _, wait in ipairs(delays) do local delay = math.max(0, tonumber(wait) or 0) local timerHandle timerHandle = self:ScheduleTimer(function() if self._syncBurstTimers then for index, handle in ipairs(self._syncBurstTimers) do if handle == timerHandle then table.remove(self._syncBurstTimers, index) break end end end self:RequestSync(normalizedReason) end, delay) self._syncBurstTimers[#self._syncBurstTimers + 1] = timerHandle end self:DebugScoped("info", "Comm", "QueueDeltaSyncBurst reason=%s count=%d", normalizedReason, #delays) end function HMGT:SendSyncResponse(target) local name = self:NormalizePlayerName(UnitName("player")) local pData = self.playerData[name] if not pData then return end pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex) self:RefreshOwnAvailabilityStates() local knownSpellList = self:SerializeKnownSpellList(pData.knownSpells) local cdList = {} local now = GetTime() local ownCooldowns = self:GetPlayerCooldownMap(name, false) if ownCooldowns then for spellId, cdInfo in pairs(ownCooldowns) do if (tonumber(cdInfo.maxCharges) or 0) > 0 then self:ResolveChargeState(cdInfo, now) end local remaining = cdInfo.duration - (now - cdInfo.startTime) remaining = math.max(0, math.min(cdInfo.duration, remaining)) if remaining > 0 then table.insert(cdList, string.format("%d:%.3f:%.3f:%d:%d", spellId, remaining, cdInfo.duration, cdInfo.currentCharges or 0, cdInfo.maxCharges or 0)) end end end self:SendDirectMessage( string.format("%s|%s|%d|%s|%d|%s|%s|%s", MSG_SYNC_RESPONSE, ADDON_VERSION, PROTOCOL_VERSION, pData.class, pData.specIndex, pData.talentHash or "", knownSpellList, table.concat(cdList, ";")), target) local stateCount = self:SendOwnTrackedSpellStates(target) local availabilityCount = self:SendOwnAvailabilityStates(target) self:DebugScoped("verbose", "Comm", "SendSyncResponse target=%s entries=%d state=%d availability=%d", tostring(target), #cdList, stateCount, availabilityCount) end function HMGT:StoreRemotePlayerInfo(playerName, class, specIndex, talentHash, knownSpellList) if not playerName or not class then return end local previous = self.playerData[playerName] local knownSpells = previous and previous.knownSpells if knownSpellList ~= nil then knownSpells = self:ParseKnownSpellList(knownSpellList) end self.playerData[playerName] = { class = class, specIndex = tonumber(specIndex), talentHash = talentHash, talents = self:ParseTalentHash(talentHash), knownSpells = knownSpells, } if type(knownSpells) == "table" then self:PruneAvailabilityStates(playerName, knownSpells) end local knownCount = 0 if type(knownSpells) == "table" then for _ in pairs(knownSpells) do knownCount = knownCount + 1 end end self:DebugScoped( "info", "TrackedSpells", "Spielerinfo von %s: class=%s spec=%s bekannteSpells=%d", tostring(playerName), tostring(class), tostring(specIndex), knownCount ) end function HMGT:GetClassTokenForSpecId(specId) local sid = tonumber(specId) if not sid or sid <= 0 then return nil end if type(GetSpecializationInfoByID) == "function" then local returns = { pcall(GetSpecializationInfoByID, sid) } local ok = returns[1] local classToken = returns[7] if ok and type(classToken) == "string" and classToken ~= "" then return classToken end end if type(GetSpecializationInfoForClassID) ~= "function" then return nil end for classID = 1, 20 do local _, token = GetClassInfo(classID) if token then local count = 4 if type(GetNumSpecializationsForClassID) == "function" then count = tonumber(GetNumSpecializationsForClassID(classID)) or 4 end for index = 1, math.max(1, count) do local foundSpecId = GetSpecializationInfoForClassID(classID, index) if tonumber(foundSpecId) == sid then return token end end end end return nil end function HMGT:ClearRemoteSpellState(playerName, spellId) local normalizedName = self:NormalizePlayerName(playerName) local sid = tonumber(spellId) if not normalizedName or not sid or sid <= 0 then return false end local changed = false if self:ClearActiveCooldown(normalizedName, sid) then changed = true end if self:ClearAvailabilityState(normalizedName, sid) then changed = true end return changed end function HMGT:ApplyRemoteSpellState(playerName, spellId, kind, revision, a, b, c, d) local normalizedName = self:NormalizePlayerName(playerName) local sid = tonumber(spellId) local rev = tonumber(revision) or 0 if not normalizedName or not sid or sid <= 0 or rev <= 0 then return false end if not self:IsPlayerInCurrentGroup(normalizedName) then return false end local currentRevision = self:GetRemoteSpellStateRevision(normalizedName, sid) if currentRevision >= rev then return false end local spellEntry = HMGT_SpellData.CooldownLookup[sid] or HMGT_SpellData.InterruptLookup[sid] if not spellEntry then return false end sid = tonumber(spellEntry.spellId) or sid local now = GetTime() local stateKind = tostring(kind or "") local changed = false local shouldLogCast = false local logDetails = nil local previousEntry = self:GetActiveCooldown(normalizedName, sid) local isSuppressed = self:IsRemoteTrackedSpellLogSuppressed(normalizedName) if stateKind == "clear" then changed = self:ClearRemoteSpellState(normalizedName, sid) elseif stateKind == "availability" then changed = self:StoreAvailabilityState(normalizedName, sid, tonumber(a) or 0, tonumber(b) or 0, spellEntry) if self:ClearActiveCooldown(normalizedName, sid) then changed = true end elseif stateKind == "cooldown" then local duration = math.max(0, tonumber(b) or 0) local remaining = math.max(0, math.min(duration, tonumber(a) or 0)) if duration <= 0 or remaining <= 0 then changed = self:ClearRemoteSpellState(normalizedName, sid) else local previousRemaining = 0 if previousEntry then previousRemaining = math.max( 0, (tonumber(previousEntry.duration) or 0) - (now - (tonumber(previousEntry.startTime) or now)) ) end self:SetActiveCooldown(normalizedName, sid, { startTime = now - (duration - remaining), duration = duration, spellEntry = spellEntry, _stateRevision = rev, _stateKind = stateKind, }) changed = true shouldLogCast = (not isSuppressed) and previousRemaining <= 0.05 if shouldLogCast then logDetails = { cooldown = duration, } end end elseif stateKind == "charges" then local maxCharges = math.max(0, math.floor((tonumber(b) or 0) + 0.5)) local currentCharges = math.max(0, math.min(maxCharges, math.floor((tonumber(a) or 0) + 0.5))) local nextRemaining = math.max(0, tonumber(c) or 0) local chargeDuration = math.max(0, tonumber(d) or 0) if maxCharges <= 0 or currentCharges >= maxCharges then changed = self:ClearRemoteSpellState(normalizedName, sid) else local previousCharges = nil if previousEntry and (tonumber(previousEntry.maxCharges) or 0) > 0 then self:ResolveChargeState(previousEntry, now) previousCharges = tonumber(previousEntry.currentCharges) end local chargeStart = nil local duration = 0 local startTime = now if chargeDuration > 0 then nextRemaining = math.min(chargeDuration, nextRemaining) chargeStart = now - math.max(0, chargeDuration - nextRemaining) duration = (maxCharges - currentCharges) * chargeDuration startTime = chargeStart end self:SetActiveCooldown(normalizedName, sid, { startTime = startTime, duration = duration, spellEntry = spellEntry, currentCharges = currentCharges, maxCharges = maxCharges, chargeStart = chargeStart, chargeDuration = chargeDuration, _stateRevision = rev, _stateKind = stateKind, }) changed = true shouldLogCast = (not isSuppressed) and ( (previousCharges ~= nil and currentCharges < previousCharges) or (previousCharges == nil) ) if shouldLogCast then logDetails = { cooldown = chargeDuration, currentCharges = currentCharges, maxCharges = maxCharges, chargeCooldown = chargeDuration, } end end else return false end self:SetRemoteSpellStateRevision(normalizedName, sid, rev) if changed then self:DebugScoped( "info", "TrackedSpells", "Sync von %s: %s -> %s (rev=%d)", tostring(normalizedName), GetSpellDebugLabel and GetSpellDebugLabel(sid) or tostring(sid), tostring(stateKind), rev ) end if changed and shouldLogCast and logDetails then self:LogTrackedSpellCast(normalizedName, spellEntry, logDetails) end return changed end function HMGT:OnCommReceived(prefix, message, distribution, sender) if prefix ~= COMM_PREFIX then return end local senderName = self:NormalizePlayerName(sender) if senderName == self:NormalizePlayerName(UnitName("player")) then return end local msgType = message:match("^(%a+)") self:DebugScoped("verbose", "Comm", "OnCommReceived type=%s from=%s dist=%s", tostring(msgType), tostring(senderName), tostring(distribution)) if msgType == MSG_ACK then local messageId = message:match("^%a+|(.+)$") if messageId then self:HandleReliableAck(senderName, messageId) end return elseif msgType == MSG_RELIABLE then local messageId, innerPayload = message:match("^%a+|([^|]+)|(.+)$") if not messageId or not innerPayload then return end local dedupeKey = string.format("%s|%s", tostring(senderName or ""), tostring(messageId)) self.receivedReliableMessages = self.receivedReliableMessages or {} self:SendReliableAck(sender, messageId) if self.receivedReliableMessages[dedupeKey] then self:DebugScoped("verbose", "Comm", "Reliable duplicate sender=%s id=%s", tostring(senderName), tostring(messageId)) return end self.receivedReliableMessages[dedupeKey] = GetTime() + 30 message = innerPayload msgType = message:match("^(%a+)") self:DebugScoped("verbose", "Comm", "Reliable recv sender=%s id=%s inner=%s", tostring(senderName), tostring(messageId), tostring(msgType)) end if msgType == MSG_SPELL_CAST then local spellId, timestamp, cur, max, chargeRemaining, chargeDuration, version, protocol = message:match("^%a+|(%d+)|([%d%.]+)|(%d+)|(%d+)|([%d%.]+)|([%d%.]+)|([^|]+)|(%d+)$") if not spellId then spellId, timestamp, version = message:match("^%a+|(%d+)|([%d%.]+)|(.+)$") if not spellId then spellId, timestamp = message:match("^%a+|(%d+)|([%d%.]+)$") end end if spellId then self:RegisterPeerVersion(senderName, version, protocol, "SC") self:RememberPeerProtocolVersion(senderName, protocol) if (tonumber(protocol) or 0) >= 5 then return end self:DebugScoped("verbose", "TrackedSpells", "Legacy cast von %s: %s ts=%s", tostring(senderName), GetSpellDebugLabel and GetSpellDebugLabel(spellId) or tostring(spellId), tostring(timestamp)) self:HandleRemoteSpellCast( senderName, tonumber(spellId), tonumber(timestamp), tonumber(cur) or 0, tonumber(max) or 0, tonumber(chargeRemaining) or 0, tonumber(chargeDuration) or 0 ) end elseif msgType == MSG_CD_REDUCE then local targetSpellId, amount, timestamp, triggerSpellId, version, protocol = message:match("^%a+|(%d+)|([%d%.]+)|([%d%.]+)|(%d+)|([^|]+)|(%d+)$") if not targetSpellId then targetSpellId, amount, timestamp, triggerSpellId = message:match("^%a+|(%d+)|([%d%.]+)|([%d%.]+)|(%d+)$") end if targetSpellId then self:RegisterPeerVersion(senderName, version, protocol, "CR") self:RememberPeerProtocolVersion(senderName, protocol) if (tonumber(protocol) or 0) >= 5 then return end self:HandleRemoteCooldownReduce( senderName, tonumber(targetSpellId), tonumber(amount) or 0, tonumber(timestamp), tonumber(triggerSpellId) or 0 ) end elseif msgType == MSG_SPELL_STATE then local spellId, stateKind, revision, a, b, c, d, version, protocol = message:match("^%a+|(%d+)|(%a+)|(%d+)|([%d%.%-]+)|([%d%.%-]+)|([%d%.%-]+)|([%d%.%-]+)|([^|]+)|(%d+)$") if spellId then self:RegisterPeerVersion(senderName, version, protocol, "STA") self:RememberPeerProtocolVersion(senderName, protocol) if self:ApplyRemoteSpellState(senderName, spellId, stateKind, revision, a, b, c, d) then self:TriggerTrackerUpdate() end else local current, max spellId, current, max, version, protocol = message:match("^%a+|(%d+)|(%d+)|(%d+)|([^|]+)|(%d+)$") if not spellId then spellId, current, max = message:match("^%a+|(%d+)|(%d+)|(%d+)$") end if spellId then self:RegisterPeerVersion(senderName, version, protocol, "STA") self:RememberPeerProtocolVersion(senderName, protocol) if (tonumber(protocol) or 0) >= 5 then return end local sid = tonumber(spellId) local spellEntry = HMGT_SpellData.CooldownLookup[sid] or HMGT_SpellData.InterruptLookup[sid] if self:IsAvailabilitySpell(spellEntry) then if self:StoreAvailabilityState(senderName, sid, tonumber(current) or 0, tonumber(max) or 0, spellEntry) then self:TriggerTrackerUpdate() end end end end elseif msgType == MSG_HELLO then local version, protocol, class, specIndex, talentHash, knownSpellList = message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") if class then self:RegisterPeerVersion(senderName, version, protocol, "HEL") self:RememberPeerProtocolVersion(senderName, protocol) self:ClearRemoteSpellStateRevisions(senderName) self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) self:DebugScoped("info", "TrackedSpells", "Hello von %s: class=%s spec=%s spells=%s", tostring(senderName), tostring(class), tostring(specIndex), tostring(knownSpellList or "")) self:SendSyncResponse(sender) self:TriggerTrackerUpdate() end elseif msgType == MSG_PLAYER_INFO then local class, specIndex, talentHash, version, protocol = message:match("^%a+|(%u+)|(%d+)|(.-)|([^|]+)|(%d+)$") if not class then class, specIndex, talentHash = message:match("^%a+|(%u+)|(%d+)|(.*)") end if class then self:RegisterPeerVersion(senderName, version, protocol, "PI") self:RememberPeerProtocolVersion(senderName, protocol) self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, nil) self:TriggerTrackerUpdate() end elseif msgType == MSG_SYNC_REQUEST then local version, protocol, class, specIndex, talentHash, knownSpellList = message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") if class then self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) end if not version then version, protocol = message:match("^%a+|([^|]+)|(%d+)$") end if not version then version = message:match("^%a+|(.+)$") end self:RegisterPeerVersion(senderName, version, protocol, "SRQ") self:RememberPeerProtocolVersion(senderName, protocol) self:DebugScoped("info", "Comm", "SyncRequest von %s", tostring(senderName)) self:SendSyncResponse(sender) self:TriggerTrackerUpdate() elseif msgType == MSG_SYNC_RESPONSE then local version, protocol, class, specIndex, talentHash, knownSpellList, cdListStr = message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)|(.-)$") if not class then version, protocol, class, specIndex, talentHash, cdListStr = message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") end if not class then class, specIndex, talentHash, cdListStr = message:match("^%a+|(%u+)|(%d+)|(.-)|(.-)$") end if class then self:RegisterPeerVersion(senderName, version, protocol, "SRS") self:RememberPeerProtocolVersion(senderName, protocol) self:SuppressRemoteTrackedSpellLogs(senderName, 1.5) self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) if cdListStr and cdListStr ~= "" then local knownTalents = self.playerData[senderName] and self.playerData[senderName].talents or {} local applied = 0 for entry in cdListStr:gmatch("([^;]+)") do local sid, rem, dur, cur, max = entry:match("(%d+):([%d%.]+):([%d%.]+):(%d+):(%d+)") if not sid then sid, rem, dur = entry:match("(%d+):([%d%.]+):([%d%.]+)") end if sid then sid, rem, dur = tonumber(sid), tonumber(rem), tonumber(dur) rem = math.max(0, math.min(dur, rem)) local remaining = rem if remaining > 0 then local spellEntry = HMGT_SpellData.CooldownLookup[sid] or HMGT_SpellData.InterruptLookup[sid] if spellEntry then local localStartTime = GetTime() - (dur - remaining) local curCharges = tonumber(cur) or 0 local maxChargeCount = tonumber(max) or 0 local chargeStart = nil local chargeDur = nil if maxChargeCount > 0 then curCharges = math.max(0, math.min(maxChargeCount, curCharges)) local missing = maxChargeCount - curCharges if missing > 0 and dur > 0 then chargeDur = dur / missing chargeStart = localStartTime end else local inferredMax, inferredDur = HMGT_SpellData.GetEffectiveChargeInfo( spellEntry, knownTalents, nil, HMGT_SpellData.GetEffectiveCooldown(spellEntry, knownTalents) ) if (tonumber(inferredMax) or 0) > 1 then maxChargeCount = inferredMax curCharges = math.max(0, inferredMax - 1) chargeDur = inferredDur chargeStart = localStartTime end end self:SetActiveCooldown(senderName, sid, { startTime = localStartTime, duration = dur, spellEntry = spellEntry, currentCharges = (maxChargeCount > 0) and curCharges or nil, maxCharges = (maxChargeCount > 0) and maxChargeCount or nil, chargeStart = chargeStart, chargeDuration = chargeDur, }) applied = applied + 1 end end end end self:DebugScoped("info", "TrackedSpells", "SyncResponse von %s: cdsApplied=%d", tostring(senderName), applied) end self:TriggerTrackerUpdate() end elseif msgType == HMGT.MSG_RAID_TIMELINE then local encounterId, timeSec, spellId, leadTime, alertText = message:match("^%a+|(%d+)|(%d+)|([%-]?%d+)|(%d+)|(.*)$") if not encounterId then encounterId, timeSec, spellId, leadTime = message:match("^%a+|(%d+)|(%d+)|([%-]?%d+)|(%d+)$") alertText = "" end if encounterId and HMGT.RaidTimeline and HMGT.RaidTimeline.HandleAssignmentComm then HMGT.RaidTimeline:HandleAssignmentComm( senderName, tonumber(encounterId), tonumber(timeSec), tonumber(spellId), tonumber(leadTime), alertText ) end elseif msgType == HMGT.MSG_RAID_TIMELINE_TEST then local encounterId, difficultyId, serverStartTime, duration = message:match("^%a+|(%d+)|(%d+)|(%d+)|(%d+)$") if encounterId and HMGT.RaidTimeline and HMGT.RaidTimeline.HandleTestStartComm then HMGT.RaidTimeline:HandleTestStartComm( senderName, tonumber(encounterId), tonumber(difficultyId), tonumber(serverStartTime), tonumber(duration) ) end end end