Files
ShaguDPS/parser.lua
T

457 lines
14 KiB
Lua

-- load public variables into local
local parser = ShaguDPS.parser
local data = ShaguDPS.data
local config = ShaguDPS.config
local round = ShaguDPS.round
-- populate all valid player units
local validUnits = { ["player"] = true }
for i=1,4 do validUnits["party" .. i] = true end
for i=1,40 do validUnits["raid" .. i] = true end
-- populate all valid player pets
local validPets = { ["pet"] = true }
for i=1,4 do validPets["partypet" .. i] = true end
for i=1,40 do validPets["raidpet" .. i] = true end
-- find unitstr by name
local unit_cache = {}
local function UnitByName(name)
-- skip all scans if cache is still valid
if unit_cache[name] and UnitName(unit_cache[name]) == name then
return unit_cache[name]
end
-- scan players for current name
for unit in pairs(validUnits) do
if UnitName(unit) == name then
unit_cache[name] = unit
return unit
end
end
-- scan pets for current name
for unit in pairs(validPets) do
if UnitName(unit) == name then
unit_cache[name] = unit
return unit
end
end
end
-- trim leading and trailing spaces; names repeat on every hit, so remember
-- the results instead of building new strings each time
local trim_cache, trim_count = {}, 0
local function trim(str)
local clean = trim_cache[str]
if not clean then
clean = gsub(str, "^%s*(.-)%s*$", "%1")
if trim_count > 1000 then trim_cache, trim_count = {}, 0 end
trim_cache[str] = clean
trim_count = trim_count + 1
end
return clean
end
-- Cache labels per pet and spell; repeated hits only perform table lookups.
local pet_actions = {}
local function PetAction(source, action)
local pet = pet_actions[source]
if not pet then
pet = { prefix = "Pet: " .. gsub(source, "%s%(.-%)$", "") .. " - ", spells = {} }
pet_actions[source] = pet
end
action = action or UNKNOWN or "Unknown"
local label = pet.spells[action]
if not label then
label = pet.prefix .. action
pet.spells[action] = label
end
return label
end
local function combat()
-- check if in combat
if UnitAffectingCombat("player") or UnitAffectingCombat("pet") then
return true
end
local raid = GetNumRaidMembers()
local group = GetNumPartyMembers()
if raid >= 1 then
for i = 1, raid do
-- check if any raid member is infight
if UnitAffectingCombat("raid"..i) or UnitAffectingCombat("raidpet"..i) then return true end
end
else
for i = 1, group do
-- check if any group member is infight
if UnitAffectingCombat("party"..i) or UnitAffectingCombat("partypet"..i) then return true end
end
end
return nil
end
local start_next_segment = nil
-- fight timer: the time a fight lasted, shared by everyone in it. Used for the
-- "Fight Duration" time mode instead of each unit's own active time.
local fight = { active = nil, start = 0, last = 0, current = 0, total = 0 }
parser.fight = fight
local function FightStart(now)
fight.active = true
fight.start = now
fight.last = now
fight.current = 0
if parser.OnFightStart then parser.OnFightStart() end
end
local function FightStop(now)
if not fight.active then return end
local duration = math.max(0, now - fight.start)
fight.active = nil
fight.current = duration
fight.total = fight.total + duration
if parser.OnFightEnd then parser.OnFightEnd(duration) end
end
-- duration of a segment (0: overall, 1: current) in seconds, never below 1
parser.GetFightTime = function(self, segment)
local running = fight.active and (GetTime() - fight.start) or 0
local time
if segment == 0 then
time = fight.total + running
else
time = fight.active and running or fight.current
end
return math.max(1, time)
end
parser.ResetFight = function(self, overall)
fight.active = nil
fight.current = 0
if overall then fight.total = 0 end
end
parser.combat = CreateFrame("Frame", "ShaguDPSCombatState", UIParent)
parser.combat:RegisterEvent("PLAYER_REGEN_DISABLED")
parser.combat:RegisterEvent("PLAYER_REGEN_ENABLED")
-- scan and trigger combat state changes
parser.combat.UpdateState = function(self)
local state = combat() == true and "COMBAT" or "NO_COMBAT"
if not self.oldstate or self.oldstate ~= state then
self.oldstate = state
if state == "NO_COMBAT" then
start_next_segment = true
FightStop(GetTime())
end
elseif state == "NO_COMBAT" and fight.active and fight.last + 5 < GetTime() then
-- data that came in while nobody was in combat (a heal or a dot after the
-- fight): close that short segment at its last event
FightStop(fight.last)
end
end
-- check when player leaves/enters combat
parser.combat:SetScript("OnEvent", function()
this:UpdateState()
end)
-- check each second
parser.combat:SetScript("OnUpdate", function()
if ( this.tick or 1) > GetTime() then return else this.tick = GetTime() + 1 end
this:UpdateState()
-- keep "fight duration" windows ticking while a fight runs
if fight.active and config.time_mode == 1 then
for id, callback in pairs(parser.callbacks.refresh) do
callback()
end
end
end)
parser.ScanName = function(self, name)
-- ignore invalid messages
if not name then return end
-- check if name matches a real player
for unit, _ in pairs(validUnits) do
if UnitExists(unit) and UnitName(unit) == name then
if UnitIsPlayer(unit) then
local _, class = UnitClass(unit)
data["classes"][name] = class
return "PLAYER"
end
end
end
-- detect SuperWoW pet owner
local match, _, owner = string.find(name, "%((.*)%)", 1)
if match and owner then
if parser:ScanName(owner) == "PLAYER" then
data["classes"][name] = owner
return "PET"
end
end
-- check if name matches a player pet
for unit, _ in pairs(validPets) do
if UnitExists(unit) and UnitName(unit) == name then
-- parse and set pet owners
if strsub(unit,0,3) == "pet" then
data["classes"][name] = UnitName("player")
elseif strsub(unit,0,8) == "partypet" then
data["classes"][name] = UnitName("party" .. strsub(unit,9))
elseif strsub(unit,0,7) == "raidpet" then
data["classes"][name] = UnitName("raid" .. strsub(unit,8))
end
return "PET"
end
end
-- assign class other if tracking of all units is set
if config.track_all_units == 1 then
data["classes"][name] = data["classes"][name] or "__other__"
return "OTHER"
else
return nil
end
end
-- Dispel counters share segment boundaries with damage/healing, without timelines.
data.dispels_done = data.dispels_done or { [0] = {}, [1] = {} }
data.dispels_received = data.dispels_received or { [0] = {}, [1] = {} }
local function PrepareSegment(source)
if start_next_segment and data.classes[source] and data.classes[source] ~= "__other__" then
data.damage[1], data.heal[1] = {}, {}
data.dispels_done[1], data.dispels_received[1] = {}, {}
start_next_segment = nil
FightStart(GetTime())
end
end
local function CountDispel(view, name, other, aura, ability)
for segment = 0, 1 do
local entry = view[segment][name]
if not entry then
entry = { _sum = 0, _ctime = 1, _auras = {}, _players = {}, _abilities = {} }
view[segment][name] = entry
end
entry._abilities = entry._abilities or {}
local spell = ability or "Unknown ability"
entry._abilities[spell] = (entry._abilities[spell] or 0) + 1
entry._sum = entry._sum + 1
entry._auras[aura] = (entry._auras[aura] or 0) + 1
entry._players[other] = (entry._players[other] or 0) + 1
end
end
parser.AddDispel = function(self, source, target, aura, ability)
if type(source) ~= "string" or type(target) ~= "string" or type(aura) ~= "string" then return end
local done = self:ScanName(source) == "PLAYER"
local received = self:ScanName(target) == "PLAYER"
if not done and not received then return end
PrepareSegment(done and source or target)
if done then CountDispel(data.dispels_done, source, target, aura, ability) end
if received then CountDispel(data.dispels_received, target, source, aura, ability) end
if not fight.active then FightStart(GetTime()) end
fight.last = GetTime()
for _, callback in pairs(self.callbacks.refresh) do callback() end
end
-- Listen independently of the selected damage parser; never count failed casts.
local dispels = CreateFrame("Frame")
ShaguDPS.dispels = dispels
dispels.available = CombatLogGetCurrentEventInfo and true or nil
if dispels.available then dispels:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED") end
dispels:SetScript("OnEvent", function()
if event == "COMBAT_LOG_EVENT_UNFILTERED" and arg2 == "SPELL_DISPEL" and not arg3 then
local ability = type(arg12) == "number" and arg12 > 0 and type(arg13) == "string" and arg13 or nil
parser:AddDispel(arg5, arg9, arg16 or (arg15 and ("Aura #" .. arg15)), ability)
end
end)
parser.AddData = function(self, source, action, target, value, school, datatype, effective_override)
-- abort on invalid input
if type(source) ~= "string" then return end
if not tonumber(value) then return end
-- trim leading and trailing spaces
source = trim(source)
-- prevent self-damage from being tracked
if datatype == "damage" and source == target then
return
end
PrepareSegment(source)
-- calculate effective value (heal)
local effective = 0
if datatype == "heal" then
local unitstr
local override = type(effective_override) == "number" and effective_override >= 0 and effective_override <= tonumber(value)
if override then
effective = effective_override
else
unitstr = UnitByName(target)
end
if unitstr then
-- calculate the effective healing of the current data
effective = math.max(0, math.min(UnitHealthMax(unitstr) - UnitHealth(unitstr), value))
end
end
-- write both (overall and current segment)
for segment = 0, 1 do
local entry = data[datatype][segment]
-- detect source and write initial table
if not entry[source] then
local type = parser:ScanName(source)
if type == "PET" then
-- create owner table if not yet existing
local owner = data["classes"][source]
if not entry[owner] and parser:ScanName(owner) then
entry[owner] = { ["_sum"] = 0, ["_ctime"] = 1 }
end
elseif not type then
-- invalid or disabled unit type
break
end
-- create base damage table
entry[source] = { ["_sum"] = 0, ["_ctime"] = 1 }
end
-- write pet damage into owners data if enabled
local action, source = action, source
if config.merge_pets == 1 and -- merge pets?
data["classes"][source] ~= "__other__" and -- valid unit?
entry[data["classes"][source]] -- has owner?
then
-- remove pet data
entry[source] = nil
action = PetAction(source, action)
source = data["classes"][source]
-- write data into owner
if not entry[source] then
entry[source] = { ["_sum"] = 0, ["_ctime"] = 1 }
end
end
if entry[source] then
-- write overall value and per spell
entry[source][action] = (entry[source][action] or 0) + tonumber(value)
entry[source]["_sum"] = (entry[source]["_sum"] or 0) + tonumber(value)
if datatype == "heal" then
-- write effective healing sumary
entry[source]["_esum"] = (entry[source]["_esum"] or 0) + tonumber(effective)
-- write effective healing per spell
entry[source]["_effective"] = entry[source]["_effective"] or {}
entry[source]["_effective"][action] = (entry[source]["_effective"][action] or 0) + tonumber(effective)
end
-- the first data of a session starts the fight timer too
if segment == 1 then
if not fight.active then FightStart(GetTime()) end
fight.last = GetTime()
end
entry[source]["_ctime"] = entry[source]["_ctime"] or 1
entry[source]["_tick"] = entry[source]["_tick"] or GetTime()
if entry[source]["_tick"] + 5 < GetTime() then
entry[source]["_tick"] = GetTime()
entry[source]["_ctime"] = entry[source]["_ctime"] + 5
else
entry[source]["_ctime"] = entry[source]["_ctime"] + (GetTime() - entry[source]["_tick"])
entry[source]["_tick"] = GetTime()
end
end
end
for id, callback in pairs(parser.callbacks.refresh) do
callback()
end
end
parser.callbacks = {
["refresh"] = {}
}
-- data sources: each parser file registers one. Only one is active at a time,
-- picked by the "source" setting (1: best available, then a forced one).
-- available() returns true when the client can feed it
-- enable() registers its events and sets parser's OnEvent handler
local source_order = { "classicapi", "nampower", "combatlog" }
local source_ids = { classicapi = 2, nampower = 3, combatlog = 4 }
parser.sources = {}
parser.source_labels = {
classicapi = "ClassicAPI",
nampower = "Nampower",
combatlog = "Chat Combat Log",
}
parser.RegisterSource = function(self, name, source)
self.sources[name] = source
end
parser.SelectSource = function(self)
local wanted = config.source or 1
local pick
for _, name in pairs(source_order) do
local src = self.sources[name]
if src and (wanted == 1 or source_ids[name] == wanted) and src.available() then
pick = name
break
end
end
-- a forced source that is missing falls back to the best available one,
-- and says why it couldn't be used
self.unavailable = nil
if not pick and wanted ~= 1 then
for name, id in pairs(source_ids) do
if id == wanted then
local src = self.sources[name]
self.unavailable = name
self.unavailable_reason = src and src.why and src.why() or "not loaded"
end
end
end
if not pick then
for _, name in pairs(source_order) do
local src = self.sources[name]
if src and src.available() then pick = name break end
end
end
if pick == self.active_source then return pick end
self:UnregisterAllEvents()
self:SetScript("OnEvent", nil)
if self.active_source and self.sources[self.active_source].disable then
self.sources[self.active_source].disable()
end
self.active_source = pick
if pick then self.sources[pick].enable() end
return pick
end