1047 lines
40 KiB
Lua
1047 lines
40 KiB
Lua
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",
|
|
"TrackerSync",
|
|
"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", "TrackerSync", "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", "TrackerSync", "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",
|
|
"TrackerSync",
|
|
"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",
|
|
"TrackerSync",
|
|
"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", "TrackerSync", "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", "TrackerSync", "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", "TrackerSync", "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
|
|
elseif msgType == HMGT.MSG_LURA_RUNES then
|
|
local payload = message:match("^%a+|(.+)$") or ""
|
|
if HMGT.EncounterAlerts and HMGT.EncounterAlerts.HandleLuraRunesComm then
|
|
HMGT.EncounterAlerts:HandleLuraRunesComm(senderName, payload)
|
|
end
|
|
end
|
|
end
|