local NanamiDPS = NanamiDPS local DataStore = NanamiDPS.DataStore local Parser = CreateFrame("Frame") NanamiDPS.Parser = Parser local validUnits = { ["player"] = true } for i = 1, 4 do validUnits["party" .. i] = true end for i = 1, 40 do validUnits["raid" .. i] = true end local validPets = { ["pet"] = true } for i = 1, 4 do validPets["partypet" .. i] = true end for i = 1, 40 do validPets["raidpet" .. i] = true end Parser.validUnits = validUnits Parser.validPets = validPets ----------------------------------------------------------------------- -- Spell threat coefficients (vanilla / classic values, max rank) -- Reference: classic-warrior wiki, warcrafttavern.com -- bonus = fixed threat added ON TOP of damage -- mult = multiplier applied to the DAMAGE portion (default 1.0) ----------------------------------------------------------------------- local spellThreatData = { ["Heroic Strike"] = { bonus = 173 }, ["Shield Slam"] = { bonus = 254 }, ["Revenge"] = { bonus = 270, mult = 2.25 }, ["Shield Bash"] = { bonus = 156, mult = 1.5 }, ["Cleave"] = { bonus = 100 }, ["Execute"] = { mult = 1.25 }, ["Hamstring"] = { bonus = 135, mult = 1.25 }, ["Thunder Clap"] = { mult = 2.5 }, ["Overpower"] = { mult = 0.75 }, ["Disarm"] = { bonus = 99 }, ["Sunder Armor"] = { bonus = 261 }, ["Maul"] = { mult = 1.75 }, ["Swipe"] = { mult = 1.75 }, ["Faerie Fire (Feral)"] = { bonus = 108 }, ["Mind Blast"] = { mult = 2.0 }, ["Holy Nova"] = { mult = 0 }, ["Earth Shock"] = { mult = 2.0 }, ["Searing Pain"] = { mult = 2.0 }, ["Distracting Shot"] = { bonus = 600 }, ["Scorpid Poison"] = { bonus = 5 }, ["Intimidation"] = { bonus = 580 }, ["Life Tap"] = { mult = 0 }, ["Counterspell"] = { bonus = 300 }, ["Mocking Blow"] = { bonus = 250 }, } do local cnAliases = { ["\232\139\177\229\139\135\230\137\147\229\135\187"] = "Heroic Strike", ["\231\155\190\231\137\140\231\140\155\229\135\187"] = "Shield Slam", ["\229\164\141\228\187\135"] = "Revenge", ["\231\155\190\229\135\187"] = "Shield Bash", ["\233\161\186\229\138\136\230\150\169"] = "Cleave", ["\230\150\169\230\157\128"] = "Execute", ["\230\150\173\231\173\139"] = "Hamstring", ["\233\155\183\233\156\134\228\184\128\229\135\187"] = "Thunder Clap", ["\229\142\139\229\136\182"] = "Overpower", ["\231\188\180\230\162\176"] = "Disarm", ["\231\160\180\231\148\178\230\148\187\229\135\187"] = "Sunder Armor", ["\230\167\152\229\135\187"] = "Maul", ["\230\140\165\229\135\187"] = "Swipe", ["\231\178\190\231\129\181\228\185\139\231\129\171(\233\135\142\233\135\145)"] = "Faerie Fire (Feral)", ["\229\191\131\231\129\181\233\156\135\231\136\134"] = "Mind Blast", ["\231\165\158\229\156\163\230\150\176\230\152\159"] = "Holy Nova", ["\229\156\176\233\156\135\230\156\175"] = "Earth Shock", ["\231\129\188\231\131\173\228\185\139\231\151\155"] = "Searing Pain", ["\230\137\176\228\185\177\229\176\132\229\135\187"] = "Distracting Shot", ["\232\157\157\230\175\146"] = "Scorpid Poison", ["\232\131\129\232\191\171"] = "Intimidation", ["\231\148\159\229\145\189\229\136\134\230\181\129"] = "Life Tap", ["\229\143\141\229\136\182\233\173\148\230\179\149"] = "Counterspell", ["\229\152\178\229\188\132\230\137\147\229\135\187"] = "Mocking Blow", } for cn, en in pairs(cnAliases) do if spellThreatData[en] then spellThreatData[cn] = spellThreatData[en] end end end local classThreatMod = { ROGUE = 0.8, } ----------------------------------------------------------------------- -- Stance / form / buff threat modifier detection -- Scans UnitBuff textures to detect known threat-altering states. -- Cached per-unit for 2 seconds to avoid excessive buff scanning. ----------------------------------------------------------------------- local stanceScanPatterns = { { pat = "DefensiveStance", mod = 1.3 }, { pat = "OffensiveStance", mod = 0.8 }, { pat = "Racial_Avatar", mod = 0.8 }, { pat = "BerserkStance", mod = 0.8 }, { pat = "BearForm", mod = 1.3 }, { pat = "CatForm", mod = 0.8 }, } local buffScanPatterns = { { pat = "SealOfSalvation", mod = 0.7 }, } local function ScanUnitThreatMod(unit) if not unit or not UnitExists(unit) then return 1.0 end local stanceMod = 1.0 local buffMod = 1.0 for i = 1, 32 do local texture = UnitBuff(unit, i) if not texture then break end for _, s in ipairs(stanceScanPatterns) do if string.find(texture, s.pat) then stanceMod = s.mod end end for _, b in ipairs(buffScanPatterns) do if string.find(texture, b.pat) then buffMod = buffMod * b.mod end end end return stanceMod * buffMod end local stanceModCache = {} local STANCE_CACHE_TTL = 2 function Parser:GetUnitThreatMod(name) local now = GetTime() local cached = stanceModCache[name] if cached and (now - cached.time) < STANCE_CACHE_TTL then return cached.mod end local unit = self:UnitByName(name) local mod = ScanUnitThreatMod(unit) stanceModCache[name] = { mod = mod, time = now } return mod end function Parser:CalculateSpellThreat(source, spell, damage) if not damage or damage <= 0 then return 0 end local threat = damage local coeff = spell and spellThreatData[spell] if coeff then threat = damage * (coeff.mult or 1.0) + (coeff.bonus or 0) end local class = DataStore:GetClass(source) if class and classThreatMod[class] then threat = threat * classThreatMod[class] end threat = threat * self:GetUnitThreatMod(source) return math.max(threat, 0) end function Parser:CalculateHealThreat(source, amount) if not amount or amount <= 0 then return 0 end local threat = amount * 0.5 local class = DataStore:GetClass(source) if class and classThreatMod[class] then threat = threat * classThreatMod[class] end threat = threat * self:GetUnitThreatMod(source) return threat end local unit_cache = {} function Parser:UnitByName(name) if unit_cache[name] and UnitName(unit_cache[name]) == name then return unit_cache[name] end for unit in pairs(validUnits) do if UnitName(unit) == name then unit_cache[name] = unit return unit end end for unit in pairs(validPets) do if UnitName(unit) == name then unit_cache[name] = unit return unit end end return nil end function Parser:ScanName(name) if not name then return nil end for unit in pairs(validUnits) do if UnitExists(unit) and UnitName(unit) == name then if UnitIsPlayer(unit) then local _, class = UnitClass(unit) DataStore:SetClass(name, class) return "PLAYER" end end end -- SuperWoW pet format: "PetName (OwnerName)" local match, _, owner = string.find(name, "%((.*)%)", 1) if match and owner then if Parser:ScanName(owner) == "PLAYER" then DataStore:SetClass(name, owner) return "PET" end end for unit in pairs(validPets) do if UnitExists(unit) and UnitName(unit) == name then if strsub(unit, 0, 3) == "pet" then DataStore:SetClass(name, UnitName("player")) elseif strsub(unit, 0, 8) == "partypet" then DataStore:SetClass(name, UnitName("party" .. strsub(unit, 9))) elseif strsub(unit, 0, 7) == "raidpet" then DataStore:SetClass(name, UnitName("raid" .. strsub(unit, 8))) end return "PET" end end if NanamiDPS.config and NanamiDPS.config.trackAllUnits then DataStore:SetClass(name, DataStore:GetClass(name) or "__other__") return "OTHER" end return nil end function Parser:ResolveSource(source) source = NanamiDPS.trim(source) local sourceType = self:ScanName(source) if not sourceType then return nil, nil end if NanamiDPS.config and NanamiDPS.config.mergePets and sourceType == "PET" then local ownerClass = DataStore:GetClass(source) if ownerClass and ownerClass ~= "__other__" and NanamiDPS.validClasses[DataStore:GetClass(ownerClass)] then return ownerClass, "Pet: " .. source elseif ownerClass and ownerClass ~= "__other__" then return ownerClass, "Pet: " .. source end end return source, nil end function Parser:ProcessDamage(source, spell, target, amount, school) if type(source) ~= "string" or not tonumber(amount) then return end source = NanamiDPS.trim(source) amount = tonumber(amount) if source == target then return end local resolvedSource, petPrefix = self:ResolveSource(source) if not resolvedSource then return end local finalSpell = petPrefix and (petPrefix .. ": " .. (spell or "")) or spell if not DataStore.inCombat then DataStore:StartCombat() end DataStore:AddDamage(resolvedSource, finalSpell, target, amount, school) local targetType = self:ScanName(target) if targetType == "PLAYER" then DataStore:AddDamageTaken(target, finalSpell, resolvedSource, amount, school) end DataStore:AddThreat(source, self:CalculateSpellThreat(source, spell, amount)) DataStore:UpdateActivity(resolvedSource, GetTime()) if targetType == "PLAYER" then self:FeedDeathLog(target, { time = GetTime(), source = resolvedSource, spell = finalSpell, amount = -amount, type = "damage", hp = self:GetUnitHP(target), }) end self:ThrottledRefresh() end function Parser:ProcessHealing(source, spell, target, amount, school) if type(source) ~= "string" or not tonumber(amount) then return end source = NanamiDPS.trim(source) amount = tonumber(amount) local resolvedSource, petPrefix = self:ResolveSource(source) if not resolvedSource then return end local finalSpell = petPrefix and (petPrefix .. ": " .. (spell or "")) or spell local effective = amount local unitstr = self:UnitByName(target) if unitstr then effective = math.min(UnitHealthMax(unitstr) - UnitHealth(unitstr), amount) end if not DataStore.inCombat then DataStore:StartCombat() end DataStore:AddHealing(resolvedSource, finalSpell, target, amount, effective) DataStore:AddThreat(source, self:CalculateHealThreat(source, amount)) DataStore:UpdateActivity(resolvedSource, GetTime()) local targetType = self:ScanName(target) if targetType == "PLAYER" then self:FeedDeathLog(target, { time = GetTime(), source = resolvedSource, spell = finalSpell, amount = effective, type = "heal", hp = self:GetUnitHP(target), }) end self:ThrottledRefresh() end function Parser:ProcessDeath(name) if not name then return end local nameType = self:ScanName(name) if nameType ~= "PLAYER" then return end local log = self:FlushDeathLog(name) DataStore:AddDeath(name, { time = GetTime(), timeStr = date("%H:%M:%S"), events = log, }) self:ThrottledRefresh() end function Parser:ProcessDispel(source, spell, target, aura) if type(source) ~= "string" then return end source = NanamiDPS.trim(source) local resolvedSource = self:ResolveSource(source) if not resolvedSource then return end DataStore:AddDispel(resolvedSource, spell or NanamiDPS.L["Unknown"], target, aura) DataStore:UpdateActivity(resolvedSource, GetTime()) self:ThrottledRefresh() end function Parser:ProcessInterrupt(source, spell, target, interrupted) if type(source) ~= "string" then return end source = NanamiDPS.trim(source) local resolvedSource = self:ResolveSource(source) if not resolvedSource then return end DataStore:AddInterrupt(resolvedSource, spell or NanamiDPS.L["Unknown"], target, interrupted) DataStore:UpdateActivity(resolvedSource, GetTime()) self:ThrottledRefresh() end function Parser:GetUnitHP(name) local unit = self:UnitByName(name) if unit then return UnitHealth(unit), UnitHealthMax(unit) end return 0, 0 end -- Throttle refresh callbacks to avoid excessive UI updates Parser.lastRefreshTime = 0 local REFRESH_INTERVAL = 0.25 function Parser:ThrottledRefresh() local now = GetTime() if now - self.lastRefreshTime >= REFRESH_INTERVAL then self.lastRefreshTime = now NanamiDPS:FireCallback("refresh") end end -- Death log buffer: last 15 seconds of events per player Parser.deathLogBuffer = {} local DEATH_LOG_WINDOW = 15 function Parser:FeedDeathLog(name, entry) if not self.deathLogBuffer[name] then self.deathLogBuffer[name] = {} end table.insert(self.deathLogBuffer[name], entry) -- Trim old entries local now = GetTime() local buf = self.deathLogBuffer[name] while table.getn(buf) > 0 and (now - buf[1].time) > DEATH_LOG_WINDOW do table.remove(buf, 1) end end function Parser:FlushDeathLog(name) local log = self.deathLogBuffer[name] or {} self.deathLogBuffer[name] = {} return log end -- Combat state detection local combatFrame = CreateFrame("Frame", "NanamiDPSCombatState", UIParent) combatFrame:RegisterEvent("PLAYER_REGEN_DISABLED") combatFrame:RegisterEvent("PLAYER_REGEN_ENABLED") local pendingCombatEnd = false local combatEndTimer = 0 combatFrame:SetScript("OnEvent", function() if event == "PLAYER_REGEN_DISABLED" then pendingCombatEnd = false if not DataStore.inCombat then DataStore:StartCombat() end elseif event == "PLAYER_REGEN_ENABLED" then pendingCombatEnd = true combatEndTimer = GetTime() + 2 end end) combatFrame:SetScript("OnUpdate", function() if (this.tick or 1) > GetTime() then return end this.tick = GetTime() + 1 if pendingCombatEnd and GetTime() >= combatEndTimer then pendingCombatEnd = false DataStore:StopCombat() end end)