diff --git a/ShaguDPS.toc b/ShaguDPS.toc new file mode 100644 index 0000000..1c6e9de --- /dev/null +++ b/ShaguDPS.toc @@ -0,0 +1,24 @@ +## Interface: 11200 +## Title: |cffffcc00Shagu|cffffffffDPS +## Author: Shagu; Duciel (Nampower parser); OEVG2 (4.0) +## Notes: A simple and lightweight DPS and damage meter. Reads ClassicAPI or Nampower combat events when available, the chat combat log otherwise. +## Version: 4.6.0 +## OptionalDeps: pfUI +## SavedVariablesPerCharacter: ShaguDPS_Config, ShaguDPS_Fights + +# main +core.lua +skin.lua + +# parser +parser.lua +parser-vanilla.lua +parser-native.lua + +# threat and fight history +threat.lua +fights.lua + +# interface +window.lua +settings.lua diff --git a/core.lua b/core.lua new file mode 100644 index 0000000..55d3eba --- /dev/null +++ b/core.lua @@ -0,0 +1,146 @@ +ShaguDPS = {} + +-- initialize default question dialog +StaticPopupDialogs["SHAGUMETER_QUESTION"] = { + button1 = YES, + button2 = NO, + timeout = 0, + whileDead = 1, + hideOnEscape = 1, +} + +-- list of available statusbar textures +local textures = { + "Interface\\BUTTONS\\WHITE8X8", + "Interface\\TargetingFrame\\UI-StatusBar", + "Interface\\Tooltips\\UI-Tooltip-Background", + "Interface\\PaperDollInfoFrame\\UI-Character-Skills-Bar", + "Interface\\AddOns\\ShaguDPS\\img\\bars\\Puppeteer", + "Interface\\AddOns\\ShaguDPS\\img\\bars\\Puppeteer-Shineless", + "Interface\\AddOns\\ShaguDPS\\img\\bars\\Puppeteer-Flat", + "Interface\\AddOns\\ShaguDPS\\img\\bars\\BlizzardRetailBar", +} + +-- names of the textures above, as the settings show them +local texture_names = { + "Flat", "Blizzard", "Tooltip", "Skill Bar", + "Puppeteer", "Puppeteer Shineless", "Puppeteer Flat", "Blizzard Retail (Modern)", +} + +-- a basic rounding function +local function round(input, places) + if not places then places = 0 end + if type(input) == "number" and type(places) == "number" then + local pow = 1 + for i = 1, places do pow = pow * 10 end + return floor(input * pow + 0.5) / pow + end +end + +local function expansion() + local _, _, _, client = GetBuildInfo() + client = client or 11200 + + -- detect client expansion + if client >= 20000 and client <= 20400 then + return "tbc" + elseif client >= 30000 and client <= 30300 then + return "wotlk" + else + return "vanilla" + end +end + +-- shared variables +local data = { + damage = { + [0] = {}, -- overall + [1] = {}, -- current + }, + + heal = { + [0] = {}, -- overall + [1] = {}, -- current + }, + + dispels_done = { [0] = {}, [1] = {} }, + dispels_received = { [0] = {}, [1] = {} }, + classes = {}, +} + +local dmg_table = {} +local view_dmg_all = { } +local view_dps_all = { } +local playerClasses = {} + +-- default config +local config = { + -- size + height = 15, + spacing = 0, + + -- tracking + track_all_units = 0, + merge_pets = 1, + source = 1, -- 1: auto, 2: ClassicAPI, 3: Nampower, 4: chat combat log + time_mode = 0, -- 0: active time per unit, 1: fight duration for everyone + + -- appearance + visible = 1, + backdrop = 1, + texture = 2, + pastel = 0, + class_icons = 1, + lock = 0, + theme = 1, -- 1: classic Blizzard look, 0: original flat look +} + +local internals = { + ["_sum"] = true, + ["_ctime"] = true, + ["_tick"] = true, + ["_esum"] = true, + ["_effective"] = true, + -- threat view + ["_perc"] = true, + ["_tank"] = true, + ["_melee"] = true, + ["_tps"] = true, + ["_pull"] = true, + ["_minimum"] = true, + ["_auras"] = true, + ["_abilities"] = true, + ["_players"] = true, +} + +-- create core component frames +local settings = CreateFrame("Frame", nil, UIParent) +local parser = CreateFrame("Frame") +local window = {} + +-- make everything public +ShaguDPS.data = data +ShaguDPS.config = config +ShaguDPS.textures = textures +ShaguDPS.texture_names = texture_names +ShaguDPS.window = window +ShaguDPS.settings = settings +ShaguDPS.internals = internals +ShaguDPS.parser = parser +ShaguDPS.round = round +ShaguDPS.expansion = expansion + +-- the theme is read once when the frames are built, so switching it needs a reload +StaticPopupDialogs["SHAGUDPS_RELOAD"] = { + text = "ShaguDPS: the new theme needs a UI reload. Reload now?", + button1 = ACCEPT, + button2 = CANCEL, + OnAccept = function() ReloadUI() end, + timeout = 0, + whileDead = 1, + hideOnEscape = 1, +} + +-- the active theme: nil until the saved variables are loaded, then true for the +-- classic look. Frames built before that get their skin once it is known. +ShaguDPS.classic = nil diff --git a/docs/combat-log.md b/docs/combat-log.md new file mode 100644 index 0000000..3377908 --- /dev/null +++ b/docs/combat-log.md @@ -0,0 +1,198 @@ +# World of Warcraft: Combat Log - Events & Strings + +An overview of World of Warcraft 1.12.1 (Vanilla) combat log events and the related global strings they can produce. +The goal of this list is to identify which strings can be fired by which event as accurate as possible. Some events +might overlap with other strings, and some strings might be used across several events, which is why there are clusters. + +The list might be incomplete and faulty. If you came here and found mistakes, please let me know or send a PR. + +## Damage + +### Hit Damage + +#### Hit Damage (self vs. other) + +- Events: + - `CHAT_MSG_COMBAT_SELF_HITS` + +- Strings: + - `COMBATHITSELFOTHER` + - `COMBATHITSCHOOLSELFOTHER` + - `COMBATHITCRITSELFOTHER` + - `COMBATHITCRITSCHOOLSELFOTHER` + + +#### Hit Damage (other vs. self) + +- Events: + - `CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS` + +- Strings: + - `COMBATHITOTHERSELF` + - `COMBATHITCRITOTHERSELF` + - `COMBATHITSCHOOLOTHERSELF` + - `COMBATHITCRITSCHOOLOTHERSELF` + + +#### Hit Damage (other vs. other) + +- Events: + - `CHAT_MSG_COMBAT_PARTY_HITS` + - `CHAT_MSG_COMBAT_FRIENDLYPLAYER_HITS` + - `CHAT_MSG_COMBAT_HOSTILEPLAYER_HITS` + - `CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS` + - `CHAT_MSG_COMBAT_CREATURE_VS_PARTY_HITS` + - `CHAT_MSG_COMBAT_PET_HITS` + +- Strings: + - `COMBATHITOTHEROTHER` + - `COMBATHITCRITOTHEROTHER` + - `COMBATHITSCHOOLOTHEROTHER` + - `COMBATHITCRITSCHOOLOTHEROTHER` + + +### Spell Damage + +#### Spell Damage (self vs. self/other) + +- Events: + - `CHAT_MSG_SPELL_SELF_DAMAGE` + +- Strings: + - `SPELLLOGSCHOOLSELFSELF` + - `SPELLLOGCRITSCHOOLSELFSELF` + - `SPELLLOGSELFSELF` + - `SPELLLOGCRITSELFSELF` + - `SPELLLOGSCHOOLSELFOTHER` + - `SPELLLOGCRITSCHOOLSELFOTHER` + - `SPELLLOGSELFOTHER` + - `SPELLLOGCRITSELFOTHER` + + +#### Spell Damage (other vs. self) + +- Events: + - `CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE` + +- Strings: + - `SPELLLOGSCHOOLOTHERSELF` + - `SPELLLOGCRITSCHOOLOTHERSELF` + - `SPELLLOGOTHERSELF` + - `SPELLLOGCRITOTHERSELF` + + +#### Spell Damage (other vs. other) + +- Events: + - `CHAT_MSG_SPELL_PARTY_DAMAGE` + - `CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE` + - `CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE` + - `CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE` + - `CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE` + - `CHAT_MSG_SPELL_PET_DAMAGE` + +- Strings: + - `SPELLLOGSCHOOLOTHEROTHER` + - `SPELLLOGCRITSCHOOLOTHEROTHER` + - `SPELLLOGOTHEROTHER` + - `SPELLLOGCRITOTHEROTHER` + + +### Shield Damage + +#### Shield Damage (self vs. other) + +- Events: + - `CHAT_MSG_SPELL_DAMAGESHIELDS_ON_SELF` + +- Strings: + - `DAMAGESHIELDSELFOTHER` + + +#### Shield Damage (other vs. self/other) + +- Events: + - `CHAT_MSG_SPELL_DAMAGESHIELDS_ON_OTHERS` + +- Strings: + - `DAMAGESHIELDOTHERSELF` + - `DAMAGESHIELDOTHEROTHER` + + +### Periodic Damage + +#### Periodic Damage (self/other vs. other) + +- Events: + - `CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE` + - `CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE` + - `CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE` + - `CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE` + +- Strings: + - `PERIODICAURADAMAGEOTHEROTHER` + - `PERIODICAURADAMAGESELFOTHER` + + +#### Periodic Damage (self/other vs. self) + +- Events: + - `CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE` + +- Strings: + - `PERIODICAURADAMAGEOTHERSELF` + - `PERIODICAURADAMAGESELFSELF` + + +## Heal + +### Heal + +#### Heal (self vs. self/other) + +- Events: + - `CHAT_MSG_SPELL_SELF_BUFF` + +- Strings: + - `HEALEDCRITSELFSELF` + - `HEALEDSELFSELF` + - `HEALEDCRITSELFOTHER` + - `HEALEDSELFOTHER` + + +#### Heal (other vs. self/other) + +- Events: + - `CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF` + - `CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF` + - `CHAT_MSG_SPELL_PARTY_BUFF` + +- Strings: + - `HEALEDCRITOTHEROTHER` + - `HEALEDOTHEROTHER` + - `HEALEDCRITOTHERSELF` + - `HEALEDOTHERSELF` + + +### Periodic Heal + +#### Periodic Heal (self/other vs. other) + +- Events: + - `CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS` + - `CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS` + - `CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_BUFFS` + +- Strings: + - `PERIODICAURAHEALOTHEROTHER` + - `PERIODICAURAHEALSELFOTHER` + + +#### Periodic Heal (other vs. self/other) + +- Events: + - `CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS` + +- Strings: + - `PERIODICAURAHEALSELFSELF` + - `PERIODICAURAHEALOTHERSELF` diff --git a/docs/release-4.5.0.md b/docs/release-4.5.0.md new file mode 100644 index 0000000..71730f5 --- /dev/null +++ b/docs/release-4.5.0.md @@ -0,0 +1,54 @@ +# ShaguDPS 4.5.0 + +For WoW 1.18.1 clients, Interface 11200, Lua 5.0. + +## Meter display + +- Totals, rates and percentages align in separate columns in every mode. +- DPS, HPS and TPS display as whole numbers: `100K (1000 DPS) [100%]`. +- Compact totals use uppercase K/M; percentages have at most one decimal. +- Optional class icons in Damage, Heal and Threat (enabled by default). +- New Blizzard Retail (Modern) texture, with modern colors for all nine classes. + Select it under Bar Texture or use `/sdps texture 8`. +- Wider target names and a centered target/Threat title group. + +## Threat + +- Fixed missing players caused by decimal threat values and percentages. +- Request fresh data immediately on target changes and display replies immediately. +- Regular polling remains at 0.5 seconds; replies may be shared with TWThreat. +- TPS uses a rolling five-second average and resets on threat reductions. +- Clear old bars on target death, combat end, leaving the group, or three seconds + without a reply. Resume when fresh data arrives. +- Show the 110–130% pull threshold range when your row is absent from the reply. + The threshold bar marks its upper end; it has no TPS value. +- Refresh threat windows without marking Damage and Heal windows for refresh. + +## Fight history + +- Prevent duplicate wipe entries from post-fight healing and targeted corpses. +- Use ClassicAPI destination GUIDs to identify boss deaths, retaining chat + detection for older clients and unresolved GUIDs. +- Existing saved history is retained; old duplicate entries are not removed. + +## Diagnostics + +- `/sdps threatdebug`: measure requests, replies and changed replies for ten seconds. +- `/sdps perf`: sample addon memory and CPU for ten seconds where supported. + CPU profiling requires `/console scriptProfile 1` followed by `/reload`. + After testing, use `/console scriptProfile 0` and `/reload` to disable its overhead. + +## Install or update + +1. Exit WoW and back up your existing addon and character SavedVariables. +2. Extract the ZIP into `Interface/AddOns`, keeping the folder named `ShaguDPS`. +3. Restart WoW so the new texture file is available. +4. Set class icons and bar texture in `/sdps config` as desired. + +ClassicAPI, Nampower and SuperWoW remain optional. The chat combat parser is +available on a plain client. Live threat requires the supported server addon +message API and a grouped player targeting an engaged elite or boss. +New ClassicAPI functionality is detected by API availability. + +The retail texture's source attribution and rights notice are included in +`img/bars/BlizzardRetailBar-attribution.txt`. diff --git a/docs/release-4.6.0.md b/docs/release-4.6.0.md new file mode 100644 index 0000000..d18f159 --- /dev/null +++ b/docs/release-4.6.0.md @@ -0,0 +1,56 @@ +# ShaguDPS 4.6.0 + +For WoW 1.18.1 clients, Interface 11200, Lua 5.0. + +## Dispels + +- Added a single Dispels mode. Select it in the mode menu, then reopen the + menu to choose Done or Received for that window. +- Count successful curse, disease, poison and magic aura removals, including + offensive dispels. Failed attempts and casts alone do not count. +- Tooltips show removed auras, players involved and dispelling abilities when + ClassicAPI supplies them. Unknown abilities remain explicitly labelled; + no spell is guessed from the aura removed. +- Record aggregate counts in Current, Overall and saved boss fights. Old + fights without recorded data show a missing-data message. +- Recording requires ClassicAPI with combat-log support, independently of + the chosen damage parser. No full event timeline is stored. + +## Newer ClassicAPI support + +- Show named damage shields such as Thorns when supplied. Older or ambiguous + events retain Reflect (School) labels. Damage ownership is unchanged. +- Valid positive event overhealing overrides the health-based approximation. + A zero remains ambiguous on older builds: it is used directly only after + this session has observed positive overhealing and the target is visible + with an absolute maximum health above 100. Other cases use the health + fallback. ClassicAPI predicts overhealing client-side; it is not server- + authoritative. Older builds, Nampower and the chat parser remain supported. +- These changes enrich future data; earlier saved breakdowns are not rewritten. + +## Display and threat + +- Matching windows share sorted display data; unchanged rows skip repainting. + Rates using live fight duration still update, while reports, resizing, + scrolling and settings changes redraw as needed. Threat bypasses paint skips. +- Label server percentages as percentages of the pull threshold, confirmed + in game at range and in melee. Pull Aggro displays 100%; its raw threat + threshold remains 110% of tank threat in melee or 130% at range. +- Use target GUIDs and switch generations to reject replies associated with + older outstanding requests. The payload has no target/request ID, so reply + loss, reordering or other addons can still prevent definitive attribution. +- Regular polling remains at 0.5 seconds, with immediate target-switch requests. + +## Updating + +Exit WoW, back up the addon and SavedVariables, and replace the ShaguDPS folder +inside Interface/AddOns with the folder in this ZIP. Restart the client. +Existing settings and boss history remain supported. Newer ClassicAPI behavior +requires an installed DLL build that contains the relevant combat-log updates. + +## Validation + +Lua 5.0 compilation and mocked regression checks cover old/new heal suffixes, +named/unnamed shields and dispels, counter storage, saved fights, display cache +invalidation, column layout, target safeguards and diagnostics. The new named +spell and healing paths still need in-game verification with the newer DLL. diff --git a/fights.lua b/fights.lua new file mode 100644 index 0000000..f30df28 --- /dev/null +++ b/fights.lua @@ -0,0 +1,163 @@ +-- Fight history: a copy of the "current" segment of each boss fight, kept +-- in ShaguDPS_Fights (per character, so it survives a reload or logout). +-- +-- A fight counts as a boss fight when somebody in the group targeted a boss +-- (classification "worldboss" or a skull level) while it ran. It is named +-- after that boss, and marked as a kill when the boss died during it. + +local parser = ShaguDPS.parser +local data = ShaguDPS.data + +local MAX_FIGHTS = 10 + +local fights = CreateFrame("Frame") +ShaguDPS.fights = fights + +-- newest first: { id, name, kill, duration, ended, damage, heal, classes } +fights.list = {} +fights.next_id = 1 + +local boss, boss_dead, boss_guid +local dispels_recorded +local structured_deaths = CombatLogGetCurrentEventInfo and UnitGUID + +local function IsBoss(unit) + if not UnitExists(unit) or UnitIsPlayer(unit) or UnitIsFriend("player", unit) then return nil end + -- Post-fight heals can open another parser segment while someone still + -- targets the corpse. Only an engaged, living boss identifies a new attempt. + if UnitIsDead(unit) or not UnitAffectingCombat(unit) then return nil end + return UnitClassification(unit) == "worldboss" or UnitLevel(unit) == -1 +end + +-- the units whose targets are checked for a boss +local scan_units = { "target", "targettarget" } +for i = 1, 4 do table.insert(scan_units, "party" .. i .. "target") end +for i = 1, 40 do table.insert(scan_units, "raid" .. i .. "target") end + +local function ScanForBoss() + local raid = GetNumRaidMembers() + for _, unit in pairs(scan_units) do + -- skip raid units of a smaller raid + local _, _, n = string.find(unit, "^raid(%d+)") + if not n or tonumber(n) <= raid then + if IsBoss(unit) then + local guid = UnitGUID and UnitGUID(unit) + if type(guid) ~= "string" or guid == "" then guid = nil end + return UnitName(unit), guid + end + end + end +end + +local function Copy(t) + local c = {} + for k, v in pairs(t) do + c[k] = type(v) == "table" and Copy(v) or v + end + return c +end + +-- class and pet owner of everyone in the fight, so colors and pet names +-- still work after a reload +local function Classes(damage, heal, done, received) + local c = {} + for _, segment in pairs({ damage, heal, done or {}, received or {} }) do + for name in pairs(segment) do + c[name] = data.classes[name] + local owner = data.classes[name] + if owner and data.classes[owner] then c[owner] = data.classes[owner] end + end + end + return c +end + +fights.Get = function(self, id) + for _, fight in pairs(self.list) do + if fight.id == id then return fight end + end +end + +fights.Reset = function(self) + for k in pairs(self.list) do self.list[k] = nil end +end + +-- the fight shown by a window: segment 2 with config.fight = id +fights.ForWindow = function(self, wconfig) + local fight = self:Get(wconfig.fight) + if fight then + -- restore class colors of fights from an earlier session + for name, class in pairs(fight.classes) do + if not data.classes[name] then data.classes[name] = class end + end + end + return fight +end + +parser.OnFightStart = function() + dispels_recorded = ShaguDPS.dispels and ShaguDPS.dispels.available and true or false + boss, boss_dead, boss_guid = nil, nil, nil +end + +parser.OnFightEnd = function(duration) + if not boss or duration < 5 then return end + if not next(data.damage[1]) and not next(data.heal[1]) + and not next(data.dispels_done[1]) and not next(data.dispels_received[1]) then return end + + local fight = { + id = fights.next_id, + name = boss, + kill = boss_dead, + duration = duration, + ended = date and date("%H:%M") or "", + damage = Copy(data.damage[1]), + heal = Copy(data.heal[1]), + dispels_recorded = dispels_recorded, + dispels_done = Copy(data.dispels_done[1]), + dispels_received = Copy(data.dispels_received[1]), + } + fight.classes = Classes(fight.damage, fight.heal, fight.dispels_done, fight.dispels_received) + + fights.next_id = fights.next_id + 1 + table.insert(fights.list, 1, fight) + while table.getn(fights.list) > MAX_FIGHTS do + table.remove(fights.list) + end + + boss, boss_dead, boss_guid = nil, nil, nil +end + +-- look for a boss once a second while a fight runs +fights:SetScript("OnUpdate", function() + if (this.tick or 0) > GetTime() then return end + this.tick = GetTime() + 1 + if not parser.fight.active or boss then return end + boss, boss_guid = ScanForBoss() +end) + +-- "Onyxia dies." +local death_pattern +if UNITDIESOTHER then + -- escape the pattern characters of the text, then turn "%s" into a capture + death_pattern = string.gsub(UNITDIESOTHER, "([%.%-%+%*%?%[%]%^%$%(%)])", "%%%1") + death_pattern = "^" .. string.gsub(death_pattern, "%%s", "(.+)") .. "$" +end + +fights:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH") +fights:RegisterEvent("VARIABLES_LOADED") +if structured_deaths then fights:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED") end +fights:SetScript("OnEvent", function() + if event == "VARIABLES_LOADED" then + ShaguDPS_Fights = ShaguDPS_Fights or {} + fights.list = ShaguDPS_Fights + for _, fight in pairs(fights.list) do + if fight.id >= fights.next_id then fights.next_id = fight.id + 1 end + end + elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then + -- Death events often have no source; match the destination GUID directly. + if boss_guid and arg2 == "UNIT_DIED" and arg8 == boss_guid then boss_dead = true end + elseif event == "CHAT_MSG_COMBAT_HOSTILE_DEATH" and boss and death_pattern and arg1 + and not (structured_deaths and boss_guid) then + local _, _, name = string.find(arg1, death_pattern) + if name == boss then boss_dead = true end + end +end) diff --git a/img/announce.tga b/img/announce.tga new file mode 100644 index 0000000..148c65c Binary files /dev/null and b/img/announce.tga differ diff --git a/img/bars/BlizzardRetailBar-attribution.txt b/img/bars/BlizzardRetailBar-attribution.txt new file mode 100644 index 0000000..0e0e190 --- /dev/null +++ b/img/bars/BlizzardRetailBar-attribution.txt @@ -0,0 +1,5 @@ +BlizzardRetailBar.tga supplied by the user in Modern-Warlock-Color-and-Texture.zip. +Original source: https://github.com/Bodify/BetterBlizzFrames/blob/main/media/blizzTex/BlizzardRetailBar.tga +Attribution: Bodify / BetterBlizzFrames. +The supplied reference states: All Rights Reserved, Copyright (c) 2023-2026 Bodify. +The reference does not grant redistribution rights. diff --git a/img/bars/BlizzardRetailBar.tga b/img/bars/BlizzardRetailBar.tga new file mode 100644 index 0000000..18e973c Binary files /dev/null and b/img/bars/BlizzardRetailBar.tga differ diff --git a/img/bars/Puppeteer-Flat.tga b/img/bars/Puppeteer-Flat.tga new file mode 100644 index 0000000..f840717 Binary files /dev/null and b/img/bars/Puppeteer-Flat.tga differ diff --git a/img/bars/Puppeteer-Shineless.tga b/img/bars/Puppeteer-Shineless.tga new file mode 100644 index 0000000..1539787 Binary files /dev/null and b/img/bars/Puppeteer-Shineless.tga differ diff --git a/img/bars/Puppeteer.tga b/img/bars/Puppeteer.tga new file mode 100644 index 0000000..e8fc781 Binary files /dev/null and b/img/bars/Puppeteer.tga differ diff --git a/img/lock.tga b/img/lock.tga new file mode 100644 index 0000000..dfc35d6 Binary files /dev/null and b/img/lock.tga differ diff --git a/img/minus.tga b/img/minus.tga new file mode 100644 index 0000000..46d3116 Binary files /dev/null and b/img/minus.tga differ diff --git a/img/plus.tga b/img/plus.tga new file mode 100644 index 0000000..e8e500c Binary files /dev/null and b/img/plus.tga differ diff --git a/img/reset.tga b/img/reset.tga new file mode 100644 index 0000000..3dda81a Binary files /dev/null and b/img/reset.tga differ diff --git a/img/resize.tga b/img/resize.tga new file mode 100644 index 0000000..9243f65 Binary files /dev/null and b/img/resize.tga differ diff --git a/img/settings.tga b/img/settings.tga new file mode 100644 index 0000000..73e3668 Binary files /dev/null and b/img/settings.tga differ diff --git a/img/unlock.tga b/img/unlock.tga new file mode 100644 index 0000000..5d1dfc0 Binary files /dev/null and b/img/unlock.tga differ diff --git a/parser-native.lua b/parser-native.lua new file mode 100644 index 0000000..6355929 --- /dev/null +++ b/parser-native.lua @@ -0,0 +1,329 @@ +-- Native data sources: structured combat events from client mods instead of +-- matching localized text patterns against every combat log chat line. +-- +-- ClassicAPI COMBAT_LOG_EVENT_UNFILTERED with GUIDs and unit flags +-- Nampower SPELL_DAMAGE_EVENT_*, AUTO_ATTACK_*, SPELL_HEAL_BY_* events +-- (needs GUID unit tokens from SuperWoW or ClassicAPI) +-- +-- The chat parser in parser-vanilla.lua stays as the fallback. The source in +-- use is picked by parser:SelectSource() once the settings are loaded. + +local parser = ShaguDPS.parser +local config = ShaguDPS.config + +local autohit = "Auto Hit" + +-- school names as the chat combat log prints them, by Nampower's school index +local school_index = { + [0] = "Physical", [1] = "Holy", [2] = "Fire", [3] = "Nature", + [4] = "Frost", [5] = "Shadow", [6] = "Arcane", +} + +-- school names by the school bit mask the combat log events use +local school_mask = { + [1] = "Physical", [2] = "Holy", [4] = "Fire", [8] = "Nature", + [16] = "Frost", [32] = "Shadow", [64] = "Arcane", +} + +-- "Reflect (Fire)" etc, built once +local reflect = {} +for _, school in pairs(school_index) do reflect[school] = "Reflect (" .. school .. ")" end + +-- Lua 5.0 has no bit operators: test a single bit by arithmetic +local function hasbit(value, bit) + return math.mod(math.floor(value / bit), 2) == 1 +end + +------------------------------------------------------------------------------- +-- unit helpers +------------------------------------------------------------------------------- + +-- GUID of a unit token, from whichever client mod provides one +local function GUIDOf(unit) + if UnitGUID then return UnitGUID(unit) end + if GetUnitGUID then return GetUnitGUID(unit) end + local _, guid = UnitExists(unit) -- SuperWoW + return guid +end + +-- true when "0x..." GUID strings work as unit tokens (SuperWoW or ClassicAPI) +local guid_tokens +local function GUIDTokensWork() + if guid_tokens == nil then + local guid = GUIDOf("player") + local name = UnitName("player") + guid_tokens = (type(guid) == "string" and name and UnitName(guid) == name) and true or false + end + return guid_tokens +end + +-- name of the player owning a pet, guardian or totem +local function OwnerName(guid) + if UnitOwnerGUID then + local owner = UnitOwnerGUID(guid) + if owner and UnitIsPlayer(owner) then return UnitName(owner) end + return nil + end + + -- SuperWoW: "owner" is the owner's unit token + local owner = guid .. "owner" + if UnitExists(owner) and UnitIsPlayer(owner) then return UnitName(owner) end +end + +-- Names of group members and their pets by GUID. Pets use the "Pet (Owner)" +-- format of the SuperWoW combat log, so the core's pet detection and merging +-- work the same for every source. Units that aren't tracked are cached as +-- false, so the expensive name scans of the core run once per unit and not on +-- every hit. Entries expire so group changes are picked up. +local cache, cache_size, cache_trackall = {}, 0, nil + +local function CacheGet(guid) + -- toggling "track all units" changes what is tracked + if cache_trackall ~= config.track_all_units then + cache, cache_size, cache_trackall = {}, 0, config.track_all_units + end + + local entry = cache[guid] + if entry and entry.expire > GetTime() then return true, entry.name end +end + +local function CacheSet(guid, name, lifetime) + if cache_size > 2000 then cache, cache_size = {}, 0 end + if not cache[guid] then cache_size = cache_size + 1 end + cache[guid] = cache[guid] or {} + cache[guid].name = name + cache[guid].expire = GetTime() + lifetime + return name +end + +-- is this a player of the group (or anyone when tracking all units)? +local function Tracked(name) + if not name or name == UNKNOWNOBJECT or name == UNKNOWN then return nil end + if config.track_all_units == 1 then return true end + return parser:ScanName(name) == "PLAYER" +end + +-- label of a pet/guardian: "Pet (Owner)" or nil when the owner isn't tracked +local function PetLabel(guid, name, owner) + name = name or UnitName(guid) + owner = owner or OwnerName(guid) + if name and owner and Tracked(owner) then + return name .. " (" .. owner .. ")" + end +end + +------------------------------------------------------------------------------- +-- ClassicAPI: COMBAT_LOG_EVENT_UNFILTERED +------------------------------------------------------------------------------- + +-- COMBATLOG_OBJECT_* bits: affiliation 1 mine, 2 party, 4 raid; unit types below +local TYPE_PLAYER, TYPE_PET, TYPE_GUARDIAN = 1024, 4096, 8192 + +local function CLEUSource(guid, name, flags) + if not guid or not name or not flags or flags < 0 then return nil end + + local found, label = CacheGet(guid) + if found then return label end + + local grouped = hasbit(flags, 1) or hasbit(flags, 2) or hasbit(flags, 4) + + if hasbit(flags, TYPE_PLAYER) then + if grouped and Tracked(name) then + return CacheSet(guid, name, 60) + elseif config.track_all_units == 1 then + return CacheSet(guid, name, 60) + end + return CacheSet(guid, false, 10) + end + + if hasbit(flags, TYPE_PET) or hasbit(flags, TYPE_GUARDIAN) then + local owner = hasbit(flags, 1) and UnitName("player") or nil + local label = PetLabel(guid, name, owner) + if label then return CacheSet(guid, label, 30) end + end + + if config.track_all_units == 1 and name ~= UNKNOWNOBJECT then + return CacheSet(guid, name, 30) + end + + return CacheSet(guid, false, 10) +end + +-- arg12 onward depend on the subevent (see the ClassicAPI documentation) +local cleu = {} + +cleu.SWING_DAMAGE = function(source, target) + -- amount, overkill, school, ... + return parser:AddData(source, autohit, target, arg12, "Physical", "damage") +end + +cleu.SPELL_DAMAGE = function(source, target) + -- spellId, spellName, spellSchool, amount, ... + return parser:AddData(source, arg13 or UNKNOWN, target, arg15, school_mask[arg14] or "Physical", "damage") +end +cleu.SPELL_PERIODIC_DAMAGE = cleu.SPELL_DAMAGE +cleu.RANGE_DAMAGE = cleu.SPELL_DAMAGE + +cleu.DAMAGE_SHIELD = function(source, target) + local school = school_mask[arg14] or "Physical" + local spell = type(arg12) == "number" and arg12 > 0 and arg13 + return parser:AddData(source, spell or reflect[school], target, arg15, school, "damage") +end + +-- Old builds and unavailable target health both report zero overhealing. +-- A valid positive value proves this runtime implements the newer accounting. +local observed_overhealing = false +cleu.SPELL_HEAL = function(source, target) + local amount, over = arg15, arg16 + local effective + if type(amount) == "number" and type(over) == "number" and over >= 0 and over <= amount and amount - amount == 0 then + if over > 0 then + observed_overhealing = true + effective = amount - over + elseif observed_overhealing and arg8 and UnitExists(arg8) and UnitIsVisible and UnitIsVisible(arg8) and UnitHealthMax(arg8) > 100 then + effective = amount + end + end + return parser:AddData(source, arg13 or UNKNOWN, target, amount, "Physical", "heal", effective) +end +cleu.SPELL_PERIODIC_HEAL = cleu.SPELL_HEAL + +local function CLEUEvent() + local handler = cleu[arg2] + if not handler or arg3 then return end + + local source = CLEUSource(arg4, arg5, arg6) + if not source then return end + + handler(source, arg9 or UNKNOWN) +end + +parser:RegisterSource("classicapi", { + available = function() + return CombatLogGetCurrentEventInfo and UnitOwnerGUID and true or nil + end, + why = function() + -- ClassicAPI builds before the combat log was added have the rest of the API + if UnitOwnerGUID or C_PlayerInfo then return "this ClassicAPI build has no combat log yet" end + return "ClassicAPI isn't loaded" + end, + enable = function() + parser:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED") + parser:SetScript("OnEvent", CLEUEvent) + end, +}) + +------------------------------------------------------------------------------- +-- Nampower: damage, auto attack and heal events +------------------------------------------------------------------------------- + +local function NPSource(guid) + if not guid then return nil end + + local found, label = CacheGet(guid) + if found then return label end + + if UnitIsPlayer(guid) then + local name = UnitName(guid) + if Tracked(name) then return CacheSet(guid, name, 60) end + return CacheSet(guid, false, 10) + end + + local label = PetLabel(guid) + if label then return CacheSet(guid, label, 30) end + + if config.track_all_units == 1 then + local name = UnitName(guid) + if name then return CacheSet(guid, name, 30) end + end + + return CacheSet(guid, false, 10) +end + +-- target names: heals need them to find the target's health +local function NPTarget(guid) + return guid and UnitName(guid) or UNKNOWN +end + +-- spell id -> name +local spell_names = {} +local function SpellName(id) + local name = spell_names[id] + if not name then + if GetSpellInfo then name = GetSpellInfo(id) end + if not name and SpellInfo then name = SpellInfo(id) end + name = name or UNKNOWN + spell_names[id] = name + end + return name +end + +local nampower = {} + +nampower.SPELL_DAMAGE_EVENT_SELF = function() + -- targetGuid, casterGuid, spellId, amount, mitigationStr, hitInfo, spellSchool + if not arg4 or arg4 <= 0 then return end + local source = NPSource(arg2) + if not source then return end + if arg1 == arg2 then return end -- damage to self + + -- the target's name is only used to skip self damage: don't look it up + parser:AddData(source, SpellName(arg3), arg1, arg4, school_index[arg7] or "Physical", "damage") +end +nampower.SPELL_DAMAGE_EVENT_OTHER = nampower.SPELL_DAMAGE_EVENT_SELF + +nampower.AUTO_ATTACK_SELF = function() + -- attackerGuid, targetGuid, totalDamage, ... + if not arg3 or arg3 <= 0 then return end + local source = NPSource(arg1) + if not source then return end + if arg1 == arg2 then return end + parser:AddData(source, autohit, arg2, arg3, "Physical", "damage") +end +nampower.AUTO_ATTACK_OTHER = nampower.AUTO_ATTACK_SELF + +nampower.DAMAGE_SHIELD_SELF = function() + -- unitGuid, targetGuid, damage, school + if not arg3 or arg3 <= 0 then return end + local source = NPSource(arg1) + if not source then return end + local school = school_index[arg4] or "Physical" + parser:AddData(source, reflect[school], arg2, arg3, school, "damage") +end +nampower.DAMAGE_SHIELD_OTHER = nampower.DAMAGE_SHIELD_SELF + +nampower.SPELL_HEAL_BY_SELF = function() + -- targetGuid, casterGuid, spellId, amount, critical, periodic + if not arg4 or arg4 <= 0 then return end + local source = NPSource(arg2) + if not source then return end + parser:AddData(source, SpellName(arg3), NPTarget(arg1), arg4, "Physical", "heal") +end +nampower.SPELL_HEAL_BY_OTHER = nampower.SPELL_HEAL_BY_SELF + +local function NampowerEvent() + local handler = nampower[event] + if handler then handler() end +end + +parser:RegisterSource("nampower", { + available = function() + return GetNampowerVersion and GUIDTokensWork() and true or nil + end, + why = function() + if not GetNampowerVersion then return "Nampower isn't loaded" end + return "needs SuperWoW or ClassicAPI for GUID unit tokens" + end, + enable = function() + -- auto attack and heal events are off by default in Nampower + for _, cvar in pairs({ "NP_EnableAutoAttackEvents", "NP_EnableSpellHealEvents" }) do + local ok, value = pcall(GetCVar, cvar) + if ok and value == "0" then pcall(SetCVar, cvar, "1") end + end + + for event in pairs(nampower) do + parser:RegisterEvent(event) + end + parser:SetScript("OnEvent", NampowerEvent) + end, +}) diff --git a/parser-vanilla.lua b/parser-vanilla.lua new file mode 100644 index 0000000..7f76e77 --- /dev/null +++ b/parser-vanilla.lua @@ -0,0 +1,443 @@ +local parser = ShaguDPS.parser + +-- sanitize, cache and convert patterns into gfind compatible ones +local sanitize_cache = {} +function sanitize(pattern) + if not sanitize_cache[pattern] then + local ret = pattern + -- escape magic characters + ret = gsub(ret, "([%+%-%*%(%)%?%[%]%^])", "%%%1") + -- remove capture indexes + ret = gsub(ret, "%d%$","") + -- catch all characters + ret = gsub(ret, "(%%%a)","%(%1+%)") + -- convert all %s to .+ + ret = gsub(ret, "%%s%+",".+") + -- set priority to numbers over strings + ret = gsub(ret, "%(.%+%)%(%%d%+%)","%(.-%)%(%%d%+%)") + -- cache it + sanitize_cache[pattern] = ret + end + + return sanitize_cache[pattern] +end + +-- find, cache and return the indexes of a regex pattern +local capture_cache = {} +function captures(pat) + local r = capture_cache + if not r[pat] then + -- set default to nil + r[pat] = { nil, nil, nil, nil, nil } + + -- try to find custom capture indexes + for a, b, c, d, e in string.gfind(gsub(pat, "%((.+)%)", "%1"), gsub(pat, "%d%$", "%%(.-)$")) do + r[pat][1] = tonumber(a) + r[pat][2] = tonumber(b) + r[pat][3] = tonumber(c) + r[pat][4] = tonumber(d) + r[pat][5] = tonumber(e) + end + end + + return r[pat][1], r[pat][2], r[pat][3], r[pat][4], r[pat][5] +end + +-- same as string.find but aware of up to 5 capture indexes +local ra, rb, rc, rd, re, a, b, c, d, e, match, num, va, vb, vc, vd, ve +function cfind(str, pat) + -- read capture indexes + a, b, c, d, e = captures(pat) + match, num, va, vb, vc, vd, ve = string.find(str, sanitize(pat)) + + -- put entries into the proper return values + ra = e == 1 and ve or d == 1 and vd or c == 1 and vc or b == 1 and vb or va + rb = e == 2 and ve or d == 2 and vd or c == 2 and vc or a == 2 and va or vb + rc = e == 3 and ve or d == 3 and vd or a == 3 and va or b == 3 and vb or vc + rd = e == 4 and ve or a == 4 and va or c == 4 and vc or b == 4 and vb or vd + re = a == 5 and va or d == 5 and vd or c == 5 and vc or b == 5 and vb or ve + + return match, num, ra, rb, rc, rd, re +end + +-- list of possible combat log patterns that may appear on the same events +local combatlog_strings = { + -- Damage + ["Hit Damage (self vs. other)"] = { + COMBATHITSELFOTHER, COMBATHITSCHOOLSELFOTHER, COMBATHITCRITSELFOTHER, COMBATHITCRITSCHOOLSELFOTHER + }, + ["Hit Damage (other vs. self)"] = { + COMBATHITOTHERSELF, COMBATHITCRITOTHERSELF, COMBATHITSCHOOLOTHERSELF, COMBATHITCRITSCHOOLOTHERSELF + }, + ["Hit Damage (other vs. other)"] = { + COMBATHITOTHEROTHER, COMBATHITCRITOTHEROTHER, COMBATHITSCHOOLOTHEROTHER, COMBATHITCRITSCHOOLOTHEROTHER + }, + ["Spell Damage (self vs. self/other)"] = { + SPELLLOGSCHOOLSELFSELF, SPELLLOGCRITSCHOOLSELFSELF, SPELLLOGSELFSELF, SPELLLOGCRITSELFSELF, SPELLLOGSCHOOLSELFOTHER, SPELLLOGCRITSCHOOLSELFOTHER, SPELLLOGSELFOTHER, SPELLLOGCRITSELFOTHER + }, + ["Spell Damage (other vs. self)"] = { + SPELLLOGSCHOOLOTHERSELF, SPELLLOGCRITSCHOOLOTHERSELF, SPELLLOGOTHERSELF, SPELLLOGCRITOTHERSELF + }, + ["Spell Damage (other vs. other)"] = { + SPELLLOGSCHOOLOTHEROTHER, SPELLLOGCRITSCHOOLOTHEROTHER, SPELLLOGOTHEROTHER, SPELLLOGCRITOTHEROTHER + }, + ["Shield Damage (self vs. other)"] = { + DAMAGESHIELDSELFOTHER + }, + ["Shield Damage (other vs. self/other)"] = { + DAMAGESHIELDOTHERSELF, DAMAGESHIELDOTHEROTHER + }, + ["Periodic Damage (self/other vs. other)"] = { + PERIODICAURADAMAGESELFOTHER, PERIODICAURADAMAGEOTHEROTHER + }, + ["Periodic Damage (self/other vs. self)"] = { + PERIODICAURADAMAGESELFSELF, PERIODICAURADAMAGEOTHERSELF + }, + + -- Heal + ["Heal (self vs. self/other)"] = { + HEALEDCRITSELFSELF, HEALEDSELFSELF, HEALEDCRITSELFOTHER, HEALEDSELFOTHER + }, + ["Heal (other vs. self/other)"] = { + HEALEDCRITOTHERSELF, HEALEDOTHERSELF, HEALEDCRITOTHEROTHER, HEALEDOTHEROTHER + }, + ["Periodic Heal (self/other vs. other)"] = { + PERIODICAURAHEALSELFOTHER, PERIODICAURAHEALOTHEROTHER + }, + ["Periodic Heal (other vs. self/other)"] = { + PERIODICAURAHEALSELFSELF, PERIODICAURAHEALOTHERSELF + } +} + +-- list of combat log events with possible patterns assigned to them +local combatlog_events = { + -- Damage + ["CHAT_MSG_COMBAT_SELF_HITS"] = combatlog_strings["Hit Damage (self vs. other)"], + ["CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS"] = combatlog_strings["Hit Damage (other vs. self)"], + ["CHAT_MSG_COMBAT_PARTY_HITS"] = combatlog_strings["Hit Damage (other vs. other)"], + ["CHAT_MSG_COMBAT_FRIENDLYPLAYER_HITS"] = combatlog_strings["Hit Damage (other vs. other)"], + ["CHAT_MSG_COMBAT_HOSTILEPLAYER_HITS"] = combatlog_strings["Hit Damage (other vs. other)"], + ["CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS"] = combatlog_strings["Hit Damage (other vs. other)"], + ["CHAT_MSG_COMBAT_CREATURE_VS_PARTY_HITS"] = combatlog_strings["Hit Damage (other vs. other)"], + ["CHAT_MSG_COMBAT_PET_HITS"] = combatlog_strings["Hit Damage (other vs. other)"], + ["CHAT_MSG_SPELL_SELF_DAMAGE"] = combatlog_strings["Spell Damage (self vs. self/other)"], + ["CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE"] = combatlog_strings["Spell Damage (other vs. self)"], + ["CHAT_MSG_SPELL_PARTY_DAMAGE"] = combatlog_strings["Spell Damage (other vs. other)"], + ["CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE"] = combatlog_strings["Spell Damage (other vs. other)"], + ["CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE"] = combatlog_strings["Spell Damage (other vs. other)"], + ["CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE"] = combatlog_strings["Spell Damage (other vs. other)"], + ["CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE"] = combatlog_strings["Spell Damage (other vs. other)"], + ["CHAT_MSG_SPELL_PET_DAMAGE"] = combatlog_strings["Spell Damage (other vs. other)"], + ["CHAT_MSG_SPELL_DAMAGESHIELDS_ON_SELF"] = combatlog_strings["Shield Damage (self vs. other)"], + ["CHAT_MSG_SPELL_DAMAGESHIELDS_ON_OTHERS"] = combatlog_strings["Shield Damage (other vs. self/other)"], + ["CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE"] = combatlog_strings["Periodic Damage (self/other vs. other)"], + ["CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE"] = combatlog_strings["Periodic Damage (self/other vs. other)"], + ["CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE"] = combatlog_strings["Periodic Damage (self/other vs. other)"], + ["CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE"] = combatlog_strings["Periodic Damage (self/other vs. other)"], + ["CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE"] = combatlog_strings["Periodic Damage (self/other vs. self)"], + + -- Heal + ["CHAT_MSG_SPELL_SELF_BUFF"] = combatlog_strings["Heal (self vs. self/other)"], + ["CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF"] = combatlog_strings["Heal (other vs. self/other)"], + ["CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF"] = combatlog_strings["Heal (other vs. self/other)"], + ["CHAT_MSG_SPELL_PARTY_BUFF"] = combatlog_strings["Heal (other vs. self/other)"], + ["CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS"] = combatlog_strings["Periodic Heal (self/other vs. other)"], + ["CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS"] = combatlog_strings["Periodic Heal (self/other vs. other)"], + ["CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_BUFFS"] = combatlog_strings["Periodic Heal (self/other vs. other)"], + ["CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS"] = combatlog_strings["Periodic Heal (other vs. self/other)"] +} + +-- list of all possible patterns including the logic on how to parse them +local combatlog_parser = { + [SPELLLOGSCHOOLSELFSELF] = function(d, attack, value, school) + -- Your %s hits you for %d %s damage. + return d.source, attack, d.target, value, school, "damage" + end, + + [SPELLLOGCRITSCHOOLSELFSELF] = function(d, attack, value, school) + -- Your %s crits you for %d %s damage. + return d.source, attack, d.target, value, school, "damage" + end, + + [SPELLLOGSELFSELF] = function(d, attack, value) + -- Your %s hits you for %d. + return d.source, attack, d.target, value, d.school, "damage" + end, + + [SPELLLOGCRITSELFSELF] = function(d, attack, value) + -- Your %s crits you for %d. + return d.source, attack, d.target, value, d.school, "damage" + end, + + [PERIODICAURADAMAGESELFSELF] = function(d, value, school, attack) + -- You suffer %d %s damage from your %s. + return d.source, attack, d.target, value, school, "damage" + end, + + [SPELLLOGSCHOOLSELFOTHER] = function(d, attack, target, value, school) + -- Your %s hits %s for %d %s damage. + return d.source, attack, target, value, school, "damage" + end, + + [SPELLLOGCRITSCHOOLSELFOTHER] = function(d, attack, target, value, school) + -- Your %s crits %s for %d %s damage. + return d.source, attack, target, value, school, "damage" + end, + + [SPELLLOGSELFOTHER] = function(d, attack, target, value) + -- Your %s hits %s for %d. + return d.source, attack, target, value, d.school, "damage" + end, + + [SPELLLOGCRITSELFOTHER] = function(d, attack, target, value) + -- Your %s crits %s for %d. + return d.source, attack, target, value, d.school, "damage" + end, + + [PERIODICAURADAMAGESELFOTHER] = function(d, target, value, school, attack) + -- %s suffers %d %s damage from your %s. + return d.source, attack, target, value, school, "damage" + end, + + [COMBATHITSELFOTHER] = function(d, target, value) + -- You hit %s for %d. + return d.source, d.attack, target, value, d.school, "damage" + end, + + [COMBATHITCRITSELFOTHER] = function(d, target, value) + -- You crit %s for %d. + return d.source, d.attack, target, value, d.school, "damage" + end, + + [COMBATHITSCHOOLSELFOTHER] = function(d, target, value, school) + -- You hit %s for %d %s damage. + return d.source, d.attack, target, value, school, "damage" + end, + + [COMBATHITCRITSCHOOLSELFOTHER] = function(d, target, value, school) + -- You crit %s for %d %s damage. + return d.source, d.attack, target, value, school, "damage" + end, + + [DAMAGESHIELDSELFOTHER] = function(d, value, school, target) + -- You reflect %d %s damage to %s. + return d.source, "Reflect ("..school..")", target, value, school, "damage" + end, + + [SPELLLOGSCHOOLOTHERSELF] = function(d, source, attack, value, school) + -- %s's %s hits you for %d %s damage. + return source, attack, d.target, value, school, "damage" + end, + + [SPELLLOGCRITSCHOOLOTHERSELF] = function(d, source, attack, value, school) + -- %s's %s crits you for %d %s damage. + return source, attack, d.target, value, school, "damage" + end, + + [SPELLLOGOTHERSELF] = function(d, source, attack, value) + -- %s's %s hits you for %d. + return source, attack, d.target, value, d.school, "damage" + end, + + [SPELLLOGCRITOTHERSELF] = function(d, source, attack, value) + -- %s's %s crits you for %d. + return source, attack, d.target, value, d.school, "damage" + end, + + [PERIODICAURADAMAGEOTHERSELF] = function(d, value, school, source, attack) + -- You suffer %d %s damage from %s's %s. + return source, attack, d.target, value, school, "damage" + end, + + [COMBATHITOTHERSELF] = function(d, source, value) + -- %s hits you for %d. + return source, d.attack, d.target, value, d.school, "damage" + end, + + [COMBATHITCRITOTHERSELF] = function(d, source, value) + -- %s crits you for %d. + return source, d.attack, d.target, value, d.school, "damage" + end, + + [COMBATHITSCHOOLOTHERSELF] = function(d, source, value, school) + -- %s hits you for %d %s damage. + return source, d.attack, d.target, value, school, "damage" + end, + + [COMBATHITCRITSCHOOLOTHERSELF] = function(d, source, value, school) + -- %s crits you for %d %s damage. + return source, d.attack, d.target, value, school, "damage" + end, + + [SPELLLOGSCHOOLOTHEROTHER] = function(d, source, attack, target, value, school) + -- %s's %s hits %s for %d %s damage. + return source, attack, target, value, school, "damage" + end, + + [SPELLLOGCRITSCHOOLOTHEROTHER] = function(d, source, attack, target, value, school) + -- %s's %s crits %s for %d %s damage. + return source, attack, target, value, school, "damage" + end, + + [SPELLLOGOTHEROTHER] = function(d, source, attack, target, value) + -- %s's %s hits %s for %d. + return source, attack, target, value, d.school, "damage" + end, + + [SPELLLOGCRITOTHEROTHER] = function(d, source, attack, target, value, school) + -- %s's %s crits %s for %d. + return source, attack, target, value, school, "damage" + end, + + [PERIODICAURADAMAGEOTHEROTHER] = function(d, target, value, school, source, attack) + -- %s suffers %d %s damage from %s's %s. + return source, attack, target, value, school, "damage" + end, + + [COMBATHITOTHEROTHER] = function(d, source, target, value) + -- %s hits %s for %d. + return source, d.attack, target, value, d.school, "damage" + end, + + [COMBATHITCRITOTHEROTHER] = function(d, source, target, value) + -- %s crits %s for %d. + return source, d.attack, target, value, d.school, "damage" + end, + + [COMBATHITSCHOOLOTHEROTHER] = function(d, source, target, value, school) + -- %s hits %s for %d %s damage. + return source, d.attack, target, value, school, "damage" + end, + + [COMBATHITCRITSCHOOLOTHEROTHER] = function(d, source, target, value, school) + -- %s crits %s for %d %s damage. + return source, d.attack, target, value, school, "damage" + end, + + [DAMAGESHIELDOTHERSELF] = function(d, source, value, school) + -- %s reflects %d %s damage to you. + return source, "Reflect ("..school..")", d.target, value, school, "damage" + end, + + [DAMAGESHIELDOTHEROTHER] = function(d, source, value, school, target) + -- %s reflects %d %s damage to %s. + return source, "Reflect ("..school..")", target, value, school, "damage" + end, + + [HEALEDCRITOTHERSELF] = function(d, source, spell, value) + -- %s's %s critically heals you for %d. + return source, spell, d.target, value, d.school, "heal" + end, + + [HEALEDOTHERSELF] = function(d, source, spell, value) + -- %s's %s heals you for %d. + return source, spell, d.target, value, d.school, "heal" + end, + + [PERIODICAURAHEALOTHERSELF] = function(d, value, source, spell) + -- You gain %d health from %s's %s. + return source, spell, d.target, value, d.school, "heal" + end, + + [HEALEDCRITSELFSELF] = function(d, spell, value) + -- Your %s critically heals you for %d. + return d.source, spell, d.target, value, d.school, "heal" + end, + + [HEALEDSELFSELF] = function(d, spell, value) + -- Your %s heals you for %d. + return d.source, spell, d.target, value, d.school, "heal" + end, + + [PERIODICAURAHEALSELFSELF] = function(d, value, spell) + -- You gain %d health from %s. + return d.source, spell, d.target, value, d.school, "heal" + end, + + [HEALEDCRITSELFOTHER] = function(d, spell, target, value) + -- Your %s critically heals %s for %d. + return d.source, spell, target, value, d.school, "heal" + end, + + [HEALEDSELFOTHER] = function(d, spell, target, value) + -- Your %s heals %s for %d. + return d.source, spell, target, value, d.school, "heal" + end, + + [PERIODICAURAHEALSELFOTHER] = function(d, target, value, spell) + -- %s gains %d health from your %s. + return d.source, spell, target, value, d.school, "heal" + end, + + [HEALEDCRITOTHEROTHER] = function(d, source, spell, target, value) + -- %s's %s critically heals %s for %d. + return source, spell, target, value, d.school, "heal" + end, + + [HEALEDOTHEROTHER] = function(d, source, spell, target, value) + -- %s's %s heals %s for %d. + return source, spell, target, value, d.school, "heal" + end, + + [PERIODICAURAHEALOTHEROTHER] = function(d, target, value, source, spell) + -- %s gains %d health from %s's %s. + return source, spell, target, value, d.school, "heal" + end, +} + + +-- preload all combat log patterns +for pattern in pairs(combatlog_parser) do + sanitize(pattern) +end + +-- cache default table +local defaults = { } + +-- initialize absorb and resist pattern +local absorb = sanitize(ABSORB_TRAILER) +local resist = sanitize(RESIST_TRAILER) + +-- scope all required variables +local _, num, pattern, result, a1, a2, a3, a4, a5 + +-- use same strings each time +local empty, physical, autohit = "", "physical", "Auto Hit" +local player = UnitName("player") + +-- call all datasources on each event +local function OnEvent() + if not arg1 then return end + + -- remove absorb and resist suffixes + arg1 = string.gsub(arg1, absorb, empty) + arg1 = string.gsub(arg1, resist, empty) + + -- write default values + defaults.source = player + defaults.target = player + defaults.school = physical + defaults.attack = autohit + defaults.spell = UNKNOWN + defaults.value = 0 + + -- iterate over all patterns assigned to the current event + for _, pattern in pairs(combatlog_events[event]) do + result, num, a1, a2, a3, a4, a5 = cfind(arg1, pattern) + + if result then + return parser:AddData(combatlog_parser[pattern](defaults, a1, a2, a3, a4, a5)) + end + end +end + +-- the chat combat log works on every client and is the fallback source +parser:RegisterSource("combatlog", { + available = function() return true end, + enable = function() + for event in pairs(combatlog_events) do + parser:RegisterEvent(event) + end + parser:SetScript("OnEvent", OnEvent) + end, +}) diff --git a/parser.lua b/parser.lua new file mode 100644 index 0000000..0774e90 --- /dev/null +++ b/parser.lua @@ -0,0 +1,450 @@ +-- 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 + +-- "Wolf (Hunter)" from the GUID-based sources is shown as "Pet: Wolf" +local pet_actions = {} +local function PetAction(source) + local action = pet_actions[source] + if not action then + action = "Pet: " .. gsub(source, "%s%(.-%)$", "") + pet_actions[source] = action + end + return action +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) + 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 diff --git a/settings.lua b/settings.lua new file mode 100644 index 0000000..ae8a50f --- /dev/null +++ b/settings.lua @@ -0,0 +1,494 @@ +-- load public variables into local +local settings = ShaguDPS.settings +local window = ShaguDPS.window +local parser = ShaguDPS.parser +local skin = ShaguDPS.skin + +local config = ShaguDPS.config +local textures = ShaguDPS.textures + +-- default backdrops (flat theme) +local backdrop = { + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 8, + insets = { left = 2, right = 2, top = 2, bottom = 2 } +} + +local backdrop_window = { + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + tile = true, tileSize = 16, edgeSize = 16, + insets = { left = 3, right = 3, top = 3, bottom = 3 } +} + +local backdrop_border = { + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 16, + insets = { left = 3, right = 3, top = 3, bottom = 3 } +} + +-- labels of the selectors that aren't plain numbers or textures +local labels = { + source = { "Auto", "ClassicAPI", "Nampower", "Chat Log" }, + time_mode = { [0] = "Active Time", [1] = "Fight Duration" }, + theme = { [0] = "Flat", [1] = "Classic" }, +} + +local function p(msg) + DEFAULT_CHAT_FRAME:AddMessage("|cffffcc00Shagu|cffffffffDPS:|cffffddcc " .. msg) +end + +-- name of the data source in use +local function ActiveSource() + local name = parser.active_source + return name and parser.source_labels[name] or "none" +end + +-- why the selected source isn't the one in use, if it isn't +local function SourceProblem() + if not parser.unavailable then return end + return parser.source_labels[parser.unavailable] .. ": " .. (parser.unavailable_reason or "not available") +end + +-- things that need more than a window refresh when they change +local function OnConfigChanged(entry) + if entry == "source" then + parser:SelectSource() + if settings.status then settings.status:Update() end + local problem = SourceProblem() + if problem then + p("|cffff6060" .. problem .. "|cffffddcc, using " .. ActiveSource() .. " instead.") + else + p("Data source: " .. ActiveSource()) + end + elseif entry == "theme" then + StaticPopup_Show("SHAGUDPS_RELOAD") + end +end + +-- row layout +local ROW = 20 + +local function RowY(self) + return -self.top - (self.entries - 1) * ROW +end + +local function CreateSelector(self, values, entry) + local classic = ShaguDPS.classic + local input = CreateFrame("Frame", nil, self) + input.values = values + input.entry = entry + + input:Hide() + input:SetHeight(18) + input:SetWidth(values and 112 or 54) + input:SetPoint("TOPRIGHT", self, "TOPRIGHT", classic and -16 or -8, RowY(self) + 1) + input:SetScript("OnShow", function() input:change() end) + + if not classic then + input:SetBackdrop(backdrop) + input:SetBackdropColor(.2,.2,.2,1) + input:SetBackdropBorderColor(.4,.4,.4,1) + end + + input.texture = input:CreateTexture(nil, "ARTWORK") + input.texture:SetPoint("TOPLEFT", input, "TOPLEFT", classic and 18 or 13, -3) + input.texture:SetPoint("BOTTOMRIGHT", input, "BOTTOMRIGHT", classic and -18 or -13, 3) + input.texture:SetVertexColor(.8, .4, .2) + + input.caption = input:CreateFontString(nil, "OVERLAY", "GameFontWhite") + input.caption:SetAllPoints() + if classic then skin.Font(input.caption, 10) else input.caption:SetFont(STANDARD_TEXT_FONT, 10) end + input.caption:SetText("Select") + + local function Arrow(dir) + local button = CreateFrame("Button", nil, input) + button:SetPoint(dir < 0 and "LEFT" or "RIGHT", input, dir < 0 and "LEFT" or "RIGHT", dir < 0 and 1 or -1, 0) + + if classic then + -- the stock spellbook page arrows + local file = dir < 0 and "PrevPage" or "NextPage" + button:SetWidth(18) + button:SetHeight(18) + button:SetNormalTexture("Interface\\Buttons\\UI-SpellbookIcon-" .. file .. "-Up") + button:SetPushedTexture("Interface\\Buttons\\UI-SpellbookIcon-" .. file .. "-Down") + button:SetDisabledTexture("Interface\\Buttons\\UI-SpellbookIcon-" .. file .. "-Disabled") + button:SetHighlightTexture("Interface\\Buttons\\UI-Common-MouseHilight", "ADD") + else + button:SetWidth(12) + button:SetHeight(16) + button:SetBackdrop(backdrop) + button:SetBackdropColor(.2,.2,.2,1) + button:SetBackdropBorderColor(.4,.4,.4,1) + button:SetScript("OnEnter", function() this:SetBackdropBorderColor(1.0, 0.8, 0.0, 1) end) + button:SetScript("OnLeave", function() this:SetBackdropBorderColor(0.4, 0.4, 0.4, 1) end) + button.caption = button:CreateFontString(nil, "OVERLAY", "GameFontWhite") + button.caption:SetFont(STANDARD_TEXT_FONT, 10) + button.caption:SetText(dir < 0 and "<" or ">") + button.caption:SetAllPoints() + end + + button:SetScript("OnClick", function() input:change(dir) end) + return button + end + + input.left = Arrow(-1) + input.right = Arrow(1) + + local function SetEnabled(button, enabled) + if classic then + if enabled then button:Enable() else button:Disable() end + else + button:SetAlpha(enabled and 1 or .25) + end + end + + input.change = function(self, mod) + local value = config[self.entry] or 1 + + if mod and (not self.values or self.values[value + mod]) then + config[self.entry] = math.ceil(value + mod) + end + + local current = config[self.entry] + if self.values and self.values[current] then + local _, _, clean = string.find(self.values[current], ".+\\(.+)") + self.caption:SetText(clean or self.values[current]) + else + self.caption:SetText(current) + end + + SetEnabled(self.right, not self.values or self.values[current + 1]) + SetEnabled(self.left, not self.values or self.values[current - 1]) + + if self.entry == "texture" then + local texture = textures[current] + if texture then + self.texture:SetTexture(texture) + else + self.texture:SetTexture() + end + end + + window.Refresh(true) + if mod then OnConfigChanged(self.entry) end + end + + return input +end + +local function CreateConfig(self, caption, entry, check) + local classic = ShaguDPS.classic + self.entries = self.entries and self.entries + 1 or 1 + local y = RowY(self) + + local text = self:CreateFontString(nil, "OVERLAY", "GameFontWhite") + text:SetPoint("TOPLEFT", self, "TOPLEFT", classic and 20 or 10, y) + text:SetWidth(170) + text:SetHeight(ROW) + text:SetJustifyH("LEFT") + text:SetText(caption) + + if classic then + skin.Font(text, 10) + text:SetTextColor(1, 1, 1) + else + text:SetFont(STANDARD_TEXT_FONT, 10, "THINOUTLINE") + end + + if check == "header" then + text:SetPoint("TOPLEFT", self, "TOPLEFT", classic and 16 or 8, y - 3) + + if classic then + -- "Header ———" in gold, the rule trailing the text + skin.Font(text, 11) + text:SetTextColor(1, .82, 0) + text:SetWidth(text:GetStringWidth() + 2) + + local rule = skin.TrailingRule(self) + rule:SetPoint("LEFT", text, "RIGHT", 6, 0) + rule:SetPoint("RIGHT", self, "RIGHT", -18, 0) + else + text:SetFont(STANDARD_TEXT_FONT, 11, "OUTLINE") + text:SetTextColor(1, .8, 0) + end + elseif check == "status" then + -- read-only line below the setting it belongs to + text:SetPoint("TOPLEFT", self, "TOPLEFT", classic and 28 or 18, y + 4) + text:SetWidth(classic and 280 or 270) + text:SetTextColor(.6, .6, .6) + text.Update = function(self) + if parser.unavailable then + -- the selected source can't be used: say why + self:SetText("|cffff6060" .. parser.source_labels[parser.unavailable] .. " unavailable|r, using |cffffffff" .. ActiveSource()) + else + self:SetText("Active: |cffffffff" .. ActiveSource()) + end + end + text:Update() + return text + elseif check == "boolean" then + local input = CreateFrame("CheckButton", nil, self, "OptionsCheckButtonTemplate") + input:Hide() + input:SetHeight(classic and 22 or 18) + input:SetWidth(classic and 22 or 18) + input:SetPoint("TOPRIGHT", self, "TOPRIGHT", classic and -16 or -8, y) + input:SetScript("OnShow", function() + this:SetChecked(config[entry] == 1) + end) + + input:SetScript("OnClick", function() + config[entry] = this:GetChecked() and 1 or 0 + window.Refresh(true) + end) + + self.checks = self.checks or {} + self.checks[entry] = input + + input:Show() + elseif check == "number" then + self:CreateSelector(nil, entry):Show() + elseif type(check) == "table" then + self:CreateSelector(check, entry):Show() + end + + return text +end + +-- the settings window is built the first time it opens, when the theme is known +local function Build() + local classic = ShaguDPS.classic + settings.CreateConfig = CreateConfig + settings.CreateSelector = CreateSelector + settings.top = classic and 30 or 26 + + if classic then + settings:SetBackdrop(skin.backdrop_dialog) + skin.HeaderPlate(settings, "ShaguDPS") + + local close = CreateFrame("Button", nil, settings, "UIPanelCloseButton") + close:SetPoint("TOPRIGHT", settings, "TOPRIGHT", -5, -5) + close:SetScript("OnClick", function() settings:Hide() end) + else + -- window background + settings:SetBackdrop(backdrop_window) + settings:SetBackdropColor(.5,.5,.5,.9) + + -- window border + settings.border = CreateFrame("Frame", nil, settings) + settings.border:ClearAllPoints() + settings.border:SetPoint("TOPLEFT", settings, "TOPLEFT", -1,1) + settings.border:SetPoint("BOTTOMRIGHT", settings, "BOTTOMRIGHT", 1,-1) + settings.border:SetFrameLevel(100) + settings.border:SetBackdrop(backdrop_border) + settings.border:SetBackdropBorderColor(.7,.7,.7,1) + + settings.title = settings:CreateTexture(nil, "NORMAL") + settings.title:SetTexture(0,0,0,.6) + settings.title:SetHeight(20) + settings.title:SetPoint("TOPLEFT", 2, -2) + settings.title:SetPoint("TOPRIGHT", -2, -2) + + settings.caption = settings:CreateFontString(nil, "OVERLAY", "GameFontWhite") + settings.caption:SetFont(STANDARD_TEXT_FONT, 10, "OUTLINE") + settings.caption:SetText("|cffffcc00Shagu|cffffffffDPS") + settings.caption:SetAllPoints(settings.title) + + settings.btnClose = CreateFrame("Button", nil, settings) + settings.btnClose:SetPoint("RIGHT", settings.title, "RIGHT", -4, 0) + settings.btnClose:SetHeight(16) + settings.btnClose:SetWidth(16) + settings.btnClose:SetBackdrop(backdrop) + settings.btnClose:SetBackdropColor(.2,.2,.2,1) + settings.btnClose:SetBackdropBorderColor(.4,.4,.4,1) + + settings.btnClose.caption = settings.btnClose:CreateFontString(nil, "OVERLAY", "GameFontWhite") + settings.btnClose.caption:SetFont(STANDARD_TEXT_FONT, 14) + settings.btnClose.caption:SetText("x") + settings.btnClose.caption:SetAllPoints() + settings.btnClose:SetScript("OnEnter", function() this:SetBackdropBorderColor(1.0, 0.8, 0.0, 1) end) + settings.btnClose:SetScript("OnLeave", function() this:SetBackdropBorderColor(0.4, 0.4, 0.4, 1) end) + settings.btnClose:SetScript("OnClick", function() settings:Hide() end) + end + + settings:CreateConfig("Data", nil, "header") + settings:CreateConfig("Data Source", "source", labels.source) + settings.status = settings:CreateConfig(nil, nil, "status") + settings:CreateConfig("Per Second Time", "time_mode", labels.time_mode) + settings:CreateConfig("Track All Nearby Units", "track_all_units", "boolean") + settings:CreateConfig("Merge Pets With Owner", "merge_pets", "boolean") + + settings:CreateConfig("Window", nil, "header") + settings:CreateConfig("Theme", "theme", labels.theme) + settings:CreateConfig("Bar Texture", "texture", ShaguDPS.texture_names) + settings:CreateConfig("Bar Height", "height", "number") + settings:CreateConfig("Bar Spacing", "spacing", "number") + settings:CreateConfig("Show Class Icons", "class_icons", "boolean") + settings:CreateConfig("Pastel Colors", "pastel", "boolean") + settings:CreateConfig("Show Backdrops", "backdrop", "boolean") + settings:CreateConfig("Lock Windows", "lock", "boolean") + + settings:SetWidth(classic and 340 or 316) + settings:SetHeight(settings.top + settings.entries * ROW + (classic and 16 or 8)) +end + +-- Load settings on Login +settings:RegisterEvent("PLAYER_ENTERING_WORLD") +settings:SetScript("OnEvent", function() + if not this.loaded then + this.loaded = true + + if ShaguDPS_Config then + for k, v in pairs(ShaguDPS_Config) do + config[k] = v + end + end + + ShaguDPS_Config = config + + -- the theme is fixed for the session; skin the frames built so far + ShaguDPS.classic = config.theme == 1 + window.ApplyTheme() + end + + parser:SelectSource() + if settings.status then settings.status:Update() end + window.Refresh(true) +end) + +settings:Hide() +settings:SetPoint("CENTER", UIParent, "CENTER", 0, 32) +settings:SetWidth(252) +settings:SetHeight(300) +settings:SetMovable(true) +settings:EnableMouse(true) +settings:SetClampedToScreen(true) +settings:RegisterForDrag("LeftButton") +settings:SetScript("OnDragStart", function() this:StartMoving() end) +settings:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) +settings:SetFrameStrata("DIALOG") +settings:SetScript("OnShow", function() + if not this.built then + this.built = true + Build() + end +end) + +-- close with escape +setglobal("ShaguDPSSettings", settings) +table.insert(UISpecialFrames, "ShaguDPSSettings") + +-- Profiling is opt-in. Take only two snapshots; never reset global counters. +local perfFrame +local function MeasurePerformance() + local function out(text) DEFAULT_CHAT_FRAME:AddMessage("ShaguDPS performance: " .. text) end + local memory = UpdateAddOnMemoryUsage and GetAddOnMemoryUsage + local cpu = UpdateAddOnCPUUsage and GetAddOnCPUUsage + if not memory and not cpu then + out("This build has no addon profiling APIs. A newer ClassicAPI build is required.") + return + end + local enabled = false + if cpu and GetCVar then + local ok, value = pcall(GetCVar, "scriptProfile") + enabled = ok and tonumber(value) == 1 + end + if not enabled then + out("CPU profiling is off. For CPU results use /console scriptProfile 1, then /reload. Turn it off and reload after testing; profiling adds overhead.") + end + local initialMemory, initialCPU + if memory then UpdateAddOnMemoryUsage(); initialMemory = GetAddOnMemoryUsage("ShaguDPS") end + if enabled then UpdateAddOnCPUUsage(); initialCPU = GetAddOnCPUUsage("ShaguDPS") end + local start = GetTime() + perfFrame = perfFrame or CreateFrame("Frame") + perfFrame:SetScript("OnUpdate", function() + local elapsed = GetTime() - start + if elapsed < 10 then return end + perfFrame:SetScript("OnUpdate", nil) + if memory then + UpdateAddOnMemoryUsage() + local current = GetAddOnMemoryUsage("ShaguDPS") + out(string.format("Memory: %.1f KB (%+.1f KB over %.1fs; garbage collection affects this).", current, current - initialMemory, elapsed)) + end + if enabled then + UpdateAddOnCPUUsage() + local delta = GetAddOnCPUUsage("ShaguDPS") - initialCPU + if delta < 0 then + out("CPU counters were reset during measurement; run /sdps perf again.") + else + out(string.format("CPU: %.2f ms over %.1fs (%.2f ms/s).", delta, elapsed, delta / elapsed)) + end + end + end) + out("Measuring for 10 seconds. Play normally during the sample.") +end + +-- Provide Slash Commands +SLASH_SHAGUMETER1, SLASH_SHAGUMETER2, SLASH_SHAGUMETER3 = "/shagudps", "/sdps", "/sd" +SlashCmdList["SHAGUMETER"] = function(msg, editbox) + local function out(msg) + DEFAULT_CHAT_FRAME:AddMessage(msg) + end + + if (msg == "" or msg == nil) then + out("|cffffcc00Shagu|cffffffffDPS: |cffaaaaaa(data source: " .. ActiveSource() .. ")") + out(" /sdps perf |cffcccccc- Measure addon CPU/memory for 10 seconds") + out(" /sdps config |cffcccccc- Open the settings") + out(" /sdps visible " .. config.visible .. " |cffcccccc- Show main window") + out(" /sdps height " .. config.height .. " |cffcccccc- Bar height") + out(" /sdps spacing " .. config.spacing .. " |cffcccccc- Bar spacing") + out(" /sdps trackall " .. config.track_all_units .. " |cffcccccc- Track all nearby units") + out(" /sdps mergepet " .. config.merge_pets .. " |cffcccccc- Merge pets into owner data") + out(" /sdps source " .. config.source .. " |cffcccccc- Data source (1 auto, 2 ClassicAPI, 3 Nampower, 4 chat log)") + out(" /sdps time " .. config.time_mode .. " |cffcccccc- Per second time (0 active time, 1 fight duration)") + out(" /sdps texture " .. config.texture .. " |cffcccccc- Set the statusbar texture") + out(" /sdps pastel " .. config.pastel .. " |cffcccccc- Use pastel colors") + out(" /sdps backdrop " .. config.backdrop .. " |cffcccccc- Show window backdrop and border") + out(" /sdps theme " .. config.theme .. " |cffcccccc- Theme (0 flat, 1 classic, needs a reload)") + out(" /sdps lock " .. config.lock .. " |cffcccccc- Lock window") + out(" /sdps toggle |cffcccccc- Toggle window") + return + end + + local _, _, cmd, args = string.find(msg, "%s?(%w+)%s?(.*)") + cmd = strlower(cmd or "") + local num = tonumber(args) + + -- options: command -> config entry, min, max, caption + local options = { + visible = { "visible", 0, 1, "Visible" }, + lock = { "lock", 0, 1, "Lock" }, + trackall = { "track_all_units", 0, 1, "Track all units" }, + mergepet = { "merge_pets", 0, 1, "Merge pet" }, + pastel = { "pastel", 0, 1, "Use pastel colors" }, + backdrop = { "backdrop", 0, 1, "Show window backdrop" }, + time = { "time_mode", 0, 1, "Per second time" }, + theme = { "theme", 0, 1, "Theme" }, + source = { "source", 1, 4, "Data source" }, + texture = { "texture", 1, table.getn(textures), "Texture" }, + height = { "height", 1, 999, "Bar height" }, + spacing = { "spacing", 0, config.height, "Bar spacing" }, + } + + if cmd == "perf" then + MeasurePerformance() + elseif cmd == "threatdebug" then + ShaguDPS.threat:Measure() + elseif cmd == "toggle" then + config.visible = config.visible == 1 and 0 or 1 + window.Refresh(true) + p("Visible: " .. config.visible) + elseif cmd == "config" or cmd == "settings" then + if settings:IsShown() then settings:Hide() else settings:Show() end + elseif options[cmd] then + local o = options[cmd] + if num and num >= o[2] and num <= o[3] and math.floor(num) == num then + config[o[1]] = num + window.Refresh(true) + p(o[4] .. ": " .. num) + OnConfigChanged(o[1]) + else + DEFAULT_CHAT_FRAME:AddMessage("|cffffcc00Shagu|cffffffffDPS:|cffff5511 Valid Options are " .. o[2] .. "-" .. o[3]) + end + end +end diff --git a/skin.lua b/skin.lua new file mode 100644 index 0000000..784d8e2 --- /dev/null +++ b/skin.lua @@ -0,0 +1,185 @@ +-- Classic theme: the stock Blizzard look. Tooltip-style panels in a neutral +-- dark fill with the silver border, gold titles, drop-shadow text instead of +-- outlines, silver rules that fade out at the ends, quest-log hover glow and +-- the marbled dialog box with the gold header plate for the settings. + +local skin = {} +ShaguDPS.skin = skin + +skin.colors = { + bg = { 0.090, 0.085, 0.090 }, + border = { 0.900, 0.850, 0.900 }, + gold = { 1, 0.82, 0 }, + white = { 1, 1, 1 }, + rule = { 0.8, 0.8, 0.8, 0.6 }, +} + +skin.backdrop_window = { + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + tile = true, tileSize = 16, edgeSize = 16, + insets = { left = 3, right = 3, top = 3, bottom = 3 } +} + +skin.backdrop_border = { + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 16, + insets = { left = 3, right = 3, top = 3, bottom = 3 } +} + +skin.backdrop_panel = { + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 } +} + +skin.backdrop_dialog = { + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, tileSize = 32, edgeSize = 32, + insets = { left = 11, right = 12, top = 12, bottom = 11 } +} + +-- stock font with the 1px drop shadow and no outline +skin.Font = function(fs, size) + fs:SetFont(STANDARD_TEXT_FONT, size or 10, "") + fs:SetShadowColor(0, 0, 0, 1) + fs:SetShadowOffset(1, -1) +end + +-- tooltip-style panel in the neutral dark fill +skin.Panel = function(frame, alpha) + local c = skin.colors + frame:SetBackdrop(skin.backdrop_panel) + frame:SetBackdropColor(c.bg[1], c.bg[2], c.bg[3], alpha or 0.95) + frame:SetBackdropBorderColor(c.border[1], c.border[2], c.border[3], 1) +end + +-- a 1px silver line that fades out toward both ends +skin.Rule = function(parent) + local c = skin.colors.rule + local rule = CreateFrame("Frame", nil, parent) + rule:SetHeight(1) + + local left = rule:CreateTexture(nil, "ARTWORK") + left:SetTexture("Interface\\Buttons\\WHITE8X8") + left:SetPoint("TOPLEFT", rule, "TOPLEFT", 0, 0) + left:SetPoint("BOTTOMRIGHT", rule, "BOTTOM", 0, 0) + left:SetGradientAlpha("HORIZONTAL", c[1], c[2], c[3], 0, c[1], c[2], c[3], c[4]) + + local right = rule:CreateTexture(nil, "ARTWORK") + right:SetTexture("Interface\\Buttons\\WHITE8X8") + right:SetPoint("TOPLEFT", rule, "TOP", 0, 0) + right:SetPoint("BOTTOMRIGHT", rule, "BOTTOMRIGHT", 0, 0) + right:SetGradientAlpha("HORIZONTAL", c[1], c[2], c[3], c[4], c[1], c[2], c[3], 0) + + return rule +end + +-- same line, but only fading out to the right ("Label ———") +skin.TrailingRule = function(parent) + local c = skin.colors.rule + local rule = CreateFrame("Frame", nil, parent) + rule:SetHeight(1) + + local tex = rule:CreateTexture(nil, "ARTWORK") + tex:SetTexture("Interface\\Buttons\\WHITE8X8") + tex:SetAllPoints(rule) + tex:SetGradientAlpha("HORIZONTAL", c[1], c[2], c[3], c[4], c[1], c[2], c[3], 0) + + return rule +end + +-- quest-log style hover glow, hidden until shown +skin.Glow = function(parent, layer, alpha) + local glow = parent:CreateTexture(nil, layer or "HIGHLIGHT") + glow:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") + glow:SetBlendMode("ADD") + glow:SetAlpha(alpha or 0.5) + glow:SetAllPoints(parent) + return glow +end + +-- gold title plate across the top edge of a dialog, like the stock option +-- windows. Three slices of the stock header art (left cap, middle, right cap) +-- so the middle stretches to fit the title without squashing the caps. +skin.HeaderPlate = function(frame, text) + local file = "Interface\\DialogFrame\\UI-DialogBox-Header" + + local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + title:SetText(text) + + local mid = frame:CreateTexture(nil, "ARTWORK") + mid:SetTexture(file) + mid:SetTexCoord(0.31, 0.67, 0, 0.63) + mid:SetHeight(40) + mid:SetWidth(math.ceil(title:GetStringWidth() or 100) + 16) + mid:SetPoint("TOP", frame, "TOP", 0, 12) + + local left = frame:CreateTexture(nil, "ARTWORK") + left:SetTexture(file) + left:SetTexCoord(0.21, 0.31, 0, 0.63) + left:SetWidth(30) + left:SetHeight(40) + left:SetPoint("RIGHT", mid, "LEFT", 0, 0) + + local right = frame:CreateTexture(nil, "ARTWORK") + right:SetTexture(file) + right:SetTexCoord(0.67, 0.77, 0, 0.63) + right:SetWidth(30) + right:SetHeight(40) + right:SetPoint("LEFT", mid, "RIGHT", 0, 0) + + title:SetPoint("TOP", mid, "TOP", 0, -14) + return title +end + +-- small icon button: gold icon that lights up white on hover, no box +skin.IconButton = function(button) + local c = skin.colors.gold + button:SetBackdrop(nil) + if button.tex then + button.tex:SetWidth(11) + button.tex:SetHeight(11) + button.tex:SetVertexColor(c[1], c[2], c[3]) + end + + button.hoverOn = function(self) + if self.tex then self.tex:SetVertexColor(1, 1, 1) end + end + + button.hoverOff = function(self) + if self.tex then self.tex:SetVertexColor(c[1], c[2], c[3]) end + end +end + +-- text button: gold caption that turns white on hover, with the glow behind +skin.TextButton = function(button, size) + local c = skin.colors.gold + button:SetBackdrop(nil) + skin.Font(button.caption, size or 10) + button.caption:SetTextColor(c[1], c[2], c[3], 1) + button.glow = button.glow or skin.Glow(button, "BACKGROUND", 0.35) + button.glow:Hide() + + button.hoverOn = function(self) + self.caption:SetTextColor(1, 1, 1, 1) + self.glow:Show() + end + + button.hoverOff = function(self) + self.caption:SetTextColor(c[1], c[2], c[3], 1) + self.glow:Hide() + end +end + +-- entry of a dropdown list: white caption (gold when selected), glow on hover +skin.MenuEntry = function(button) + button:SetBackdrop(nil) + skin.Font(button.caption, 10) + button.glow = button.glow or skin.Glow(button, "BACKGROUND", 0.5) + button.glow:Hide() + + button.hoverOn = function(self) self.glow:Show() end + button.hoverOff = function(self) self.glow:Hide() end +end diff --git a/tests/class-icons.lua b/tests/class-icons.lua new file mode 100644 index 0000000..644c334 --- /dev/null +++ b/tests/class-icons.lua @@ -0,0 +1,97 @@ +-- Exercise the actual icon/layout helper without loading the full WoW UI. +ShaguDPS = { data={dispels_done={[0]={},[1]={}},dispels_received={[0]={},[1]={}}}, expansion=function() return 'vanilla' end, window={}, config={class_icons=1,height=15,spacing=0} } +local f = assert(io.open('window.lua')) +local source = f:read('*a') +f:close() +local boundary = assert(string.find(source, '-- default backdrops', 1, true)) +assert(loadstring(string.sub(source, 1, boundary - 1)))() +local icon = { + SetTexture=function(self,v) self.texture=v end, + SetTexCoord=function(self,a,b,c,d) self.coords={a,b,c,d} end, + SetWidth=function(self,v) self.width=v end, SetHeight=function(self,v) self.height=v end, + SetPoint=function() end, Show=function(self) self.shown=true end, Hide=function(self) self.shown=false end, +} +local bar = {CreateTexture=function() return icon end,textLeft={SetPoint=function(self,a,b,c,x) self.offset=x end}} +local update = ShaguDPS.window.UpdateClassIcon +update(bar, 'MAGE') +assert(icon.shown and icon.width==13 and bar.textLeft.offset==18) +assert(icon.coords[1]==.25 and icon.coords[2]==.5) +update(bar, 'PRIEST') +assert(icon.coords[1]==.5 and icon.coords[3]==.25, 'recycled bar updates class') +update(bar, nil) +assert(not icon.shown and bar.textLeft.offset==5, 'threshold/unknown rows have no icon gap') +ShaguDPS.config.class_icons=0 +update(bar, 'MAGE') +assert(not icon.shown and bar.textLeft.offset==5, 'toggle restores original layout') +ShaguDPS.config.class_icons=1 +ShaguDPS.config.height=24 +ShaguDPS.config.spacing=2 +update(bar,'WARRIOR') +assert(icon.shown and icon.width==20 and bar.textLeft.offset==25, 'icons follow bar sizing') +print('Class icon layout checks passed (Lua 5.0)') + +local percent = ShaguDPS.window.FormatThreatPercent +assert(percent(44.200001) == '44.2') +assert(percent(43.299999) == '43.3') +assert(percent(100) == '100') +assert(percent(99.99) == '100') +assert(percent('110-130') == '110-130') +assert(percent(nil) == '0') +print('Threat percentage formatting checks passed (Lua 5.0)') + +local rate, total = ShaguDPS.window.FormatRate, ShaguDPS.window.FormatTotal +assert(rate(1000.4) == '1000' and rate(1000.6) == '1001') +assert(rate(12345.6) == '12346' and rate(nil) == '0') +assert(total(100000) == '100K' and total(1000000) == '1M') +assert(total(12345) == '12.3K' and total(1234567) == '1.23M') +assert(total(1000) == '1000' and total(0) == '0') +print('Whole-number rates and compact totals passed (Lua 5.0)') + +local templateStart = assert(string.find(source, 'local view_templates = {', 1, true)) +local templateEnd = assert(string.find(source, '-- views of older versions:', templateStart, true)) +local templates = assert(loadstring(string.sub(source, templateStart, templateEnd - 1) .. '\nreturn view_templates'))() +for _, id in pairs({1, 3, 5}) do + local label = id == 1 and 'DPS' or (id == 3 and 'HPS' or 'TPS') + assert(string.format(templates[id].bar_string, total(100000), rate(1000.3), percent(100)) == '100K (1000 ' .. label .. ') [100%]') +end +print('Damage, healing and threat line formats passed (Lua 5.0)') + +local function text() + return {ClearAllPoints=function(self) self.points={} end, + SetPoint=function(self,point,bar,relative,x,y) self.points=self.points or {};self.points[point]=x end, + SetWidth=function(self,width) self.width=width end} +end +local function row(shown) + return {IsShown=function() return shown end,textRight=text(),textRate=text(),textPercent=text(),textLeft=text()} +end +local rows={row(true),row(true),row(false)} +ShaguDPS.window.AlignValueColumns(rows,3,{40,65,35}) +for i=1,2 do + assert(rows[i].textPercent.points.TOPRIGHT==-5) + assert(rows[i].textRate.points.TOPRIGHT==-46) + assert(rows[i].textRight.points.TOPRIGHT==-117) + assert(rows[i].textLeft.points.BOTTOMRIGHT==-163) + assert(rows[i].textRate.width==65) +end +assert(not rows[3].textRight.points, 'hidden rows do not affect layout') +ShaguDPS.window.AlignValueColumns(rows,2,{35,55,25}) +assert(rows[1].textRate.width==55 and rows[1].textRight.points.TOPRIGHT==-97) +print('Shared column alignment checks passed (Lua 5.0)') + +local dispelSegment = ShaguDPS.window.GetDispelSegment +assert(dispelSegment({segment=1}) == ShaguDPS.data.dispels_done[1]) +assert(dispelSegment({segment=0,dispels_received=1}) == ShaguDPS.data.dispels_received[0]) +assert(not next(dispelSegment({segment=2},{damage={},heal={}})), 'old history shows empty dispels') +local saved = {dispels_done={Priest={_sum=2}},dispels_received={Mage={_sum=2}}} +assert(dispelSegment({},saved).Priest._sum == 2) +assert(dispelSegment({dispels_received=1},saved).Mage._sum == 2) +assert(string.format(templates[6].bar_string,'4','25') == '4 [25%]') +print('Dispels view direction and saved-history selection passed (Lua 5.0)') + +local dispelStatus = ShaguDPS.window.DispelStatus +assert(string.find(dispelStatus({}, {damage={}}, true), 'not recorded', 1, true)) +assert(string.find(dispelStatus({}, {dispels_done={},dispels_recorded=false}, true), 'not recorded', 1, true)) +assert(dispelStatus({}, {dispels_done={},dispels_recorded=true}, false) == nil, 'saved data available without ClassicAPI') +assert(dispelStatus({}, {dispels_done={}}, true) == nil, 'earlier dispel snapshots remain readable') +assert(string.find(dispelStatus({}, nil, false), 'requires ClassicAPI', 1, true)) +print('Missing versus zero dispel-data checks passed (Lua 5.0)') diff --git a/tests/classicapi-updates.lua b/tests/classicapi-updates.lua new file mode 100644 index 0000000..c77d002 --- /dev/null +++ b/tests/classicapi-updates.lua @@ -0,0 +1,53 @@ +-- Exercise native event handlers and the real counter storage with Lua 5.0. +dofile('tests/dispels.lua') +CombatLogGetCurrentEventInfo=function() end +UnitOwnerGUID=function() end +local oldName, oldExists = UnitName, UnitExists +UnitName=function(unit) if unit=='0xTARGET' then return 'Mage' end return oldName(unit) end +UnitExists=function(unit) return unit=='0xTARGET' or oldExists(unit) end +local visible=true +UnitIsVisible=function() return visible end +UnitHealthMax=function() return 1000 end +UnitHealth=function() return 950 end +UNKNOWN,UNKNOWNOBJECT='Unknown','Unknown' +local p,d=ShaguDPS.parser,ShaguDPS.data +p.RegisterEvent=function() end +p.SetScript=function(self,key,fn) self[key]=fn end +dofile('parser-native.lua') +p.sources.classicapi.enable() +local function heal(over) + local previous=d.heal[0].Priest and d.heal[0].Priest._esum or 0 + arg2,arg3,arg4,arg5,arg6,arg8,arg9='SPELL_HEAL',false,'0xPRIEST','Priest',1025,'0xTARGET','Mage' + arg12,arg13,arg14,arg15,arg16=1,'Heal',2,100,over + p.OnEvent() + return d.heal[0].Priest._esum-previous +end +assert(heal(0)==50,'old zero-overheal builds use health fallback') +assert(heal(60)==40,'positive event overheal overrides health approximation') +assert(heal(0)==100,'known new runtime with visible absolute health can trust zero') +visible=false +assert(heal(0)==50,'unavailable health still requires fallback') +assert(heal(101)==50 and heal(-1)==50,'invalid suffixes do not corrupt totals') +visible=true +local function shield(id,name) + arg2,arg3,arg4,arg5,arg6,arg8,arg9='DAMAGE_SHIELD',false,'0xPRIEST','Priest',1025,'0xENEMY','Enemy' + arg12,arg13,arg14,arg15=id,name,8,25 + p.OnEvent() +end +shield(467,'Thorns') +assert(d.damage[0].Priest.Thorns==25) +shield(0,nil) +assert(d.damage[0].Priest['Reflect (Nature)']==25) +-- Independent listener: names enrich the breakdown without altering counts. +dofile('parser.lua') +local frame=ShaguDPS.dispels +local before=d.dispels_done[0].Priest and d.dispels_done[0].Priest._sum or 0 +event,arg2,arg3,arg5,arg9,arg12,arg13,arg15,arg16='COMBAT_LOG_EVENT_UNFILTERED','SPELL_DISPEL',false,'Priest','Mage',527,'Dispel Magic',123,'Magic debuff' +frame.scripts.OnEvent() +assert(d.dispels_done[0].Priest._sum==before+1) +assert(d.dispels_done[0].Priest._abilities['Dispel Magic']==1) +arg12,arg13=0,nil +frame.scripts.OnEvent() +assert(d.dispels_done[0].Priest._abilities['Unknown ability']>=1) +assert(d.dispels_received[0].Mage._abilities['Dispel Magic']==1) +print('ClassicAPI named spells and healing compatibility checks passed (Lua 5.0)') diff --git a/tests/dispels.lua b/tests/dispels.lua new file mode 100644 index 0000000..e5dd1ad --- /dev/null +++ b/tests/dispels.lua @@ -0,0 +1,71 @@ +-- Lua 5.0, run from the repository root. +local now, combat = 1, false +function GetTime() return now end +function GetNumRaidMembers() return 2 end +function GetNumPartyMembers() return 0 end +function UnitName(unit) + if unit=='player' or unit=='raid1' then return 'Priest' end + if unit=='raid2' then return 'Mage' end + if unit=='target' then return 'Boss' end +end +function UnitExists(unit) return UnitName(unit)~=nil end +function UnitIsPlayer(unit) return unit~='target' and UnitExists(unit) end +function UnitClass(unit) return 'Priest', unit=='raid2' and 'MAGE' or 'PRIEST' end +function UnitIsFriend(_,unit) return unit~='target' end +function UnitIsDead() return false end +function UnitClassification() return 'worldboss' end +function UnitLevel() return -1 end +function UnitAffectingCombat(unit) return combat and UnitExists(unit) end +function CreateFrame() + return {events={}, scripts={},RegisterEvent=function(self,k) self.events[k]=true end,SetScript=function(self,k,fn) self.scripts[k]=fn end} +end +CombatLogGetCurrentEventInfo=function() end +gsub,strsub=string.gsub,string.sub +ShaguDPS={parser={},config={merge_pets=1},data={damage={[0]={},[1]={}},heal={[0]={},[1]={}},classes={}}} +dofile('parser.lua') +dofile('fights.lua') +local p,d,f=ShaguDPS.parser,ShaguDPS.data,ShaguDPS.fights +local listener=ShaguDPS.dispels +assert(listener.available and listener.events.COMBAT_LOG_EVENT_UNFILTERED) +local function dispel(source,target,aura,kind) + event,arg2,arg3,arg5,arg9,arg15,arg16='COMBAT_LOG_EVENT_UNFILTERED',kind or 'SPELL_DISPEL',false,source,target,123,aura + listener.scripts.OnEvent() +end +p.combat:UpdateState() +combat=true +p.combat:UpdateState() +dispel('Priest','Mage','Curse') +assert(d.dispels_done[1].Priest._sum==1 and d.dispels_received[1].Mage._sum==1) +assert(d.dispels_done[1].Priest._auras.Curse==1 and d.dispels_done[1].Priest._players.Mage==1) +assert(d.dispels_received[1].Mage._players.Priest==1) +now=2 +this=f;f.scripts.OnUpdate() +dispel('Priest','Mage','Disease') +dispel('Mage','Priest','Poison') +dispel('Priest','Enemy','Magic buff') -- offensive dispel +assert(d.dispels_done[1].Priest._sum==3 and not d.dispels_received[1].Enemy) +dispel('Enemy','Priest','Friendly buff') -- received hostile dispel +assert(d.dispels_received[1].Priest._sum==2 and not d.dispels_done[1].Enemy) +dispel('Priest','Mage','Curse','SPELL_DISPEL_FAILED') +dispel('Priest','Mage','Curse','SPELL_CAST_SUCCESS') +assert(d.dispels_done[1].Priest._sum==3, 'failed dispels and casts do not count') +dispel('Enemy','Other enemy','Magic buff') +assert(not d.dispels_done[1].Enemy) +now,combat=12,false +p.combat:UpdateState() +assert(table.getn(f.list)==1, 'dispel-only boss fight is saved') +assert(f.list[1].dispels_done.Priest._sum==3 and f.list[1].classes.Priest=='PRIEST') +now,combat=20,true +p.combat:UpdateState() +dispel('Mage','Priest','Curse') +assert(not d.dispels_done[1].Priest and d.dispels_done[1].Mage._sum==1, 'new segment starts clean') +assert(d.dispels_done[0].Priest._sum==3 and d.dispels_done[0].Mage._sum==2) +assert(f.list[1].dispels_done.Priest._sum==3, 'saved data is an independent copy') +-- Older history entries have no dispel fields but remain selectable. +f.list[2]={id=99,classes={Priest='PRIEST'},damage={},heal={}} +assert(f:ForWindow({fight=99}).dispels_done==nil) +-- No ClassicAPI: no registration or inferred events. +CombatLogGetCurrentEventInfo=nil +dofile('parser.lua') +assert(not ShaguDPS.dispels.available and not ShaguDPS.dispels.events.COMBAT_LOG_EVENT_UNFILTERED) +print('Dispels event, segmentation, history and compatibility checks passed (Lua 5.0)') diff --git a/tests/display-cache.lua b/tests/display-cache.lua new file mode 100644 index 0000000..bbace4f --- /dev/null +++ b/tests/display-cache.lua @@ -0,0 +1,48 @@ +-- Real display helpers, isolated from WoW widget creation. +local f=assert(io.open('window.lua'));local source=f:read('*a');f:close() +local start=assert(string.find(source,'-- Shared, display-only caches.',1,true)) +local finish=assert(string.find(source,'local function barTooltipShow()',start,true)) +ShaguDPS={window={},data={classes={Alice='MAGE',Bob='PRIEST'}},config={height=15,spacing=0,texture=2,[1]={view=1,bars=8},[2]={view=1,bars=8}}} +RAID_CLASS_COLORS={MAGE={r=.25,g=.78,b=.92},PRIEST={r=1,g=1,b=1}} +local prefix='local window,data,config=ShaguDPS.window,ShaguDPS.data,ShaguDPS.config\n' +assert(loadstring(prefix..string.sub(source,start,finish-1)))() +local w=ShaguDPS.window +local function order(t,a,b) return t[a]._sum>t[b]._sum end +local segment={Alice={_sum=100,_ctime=1},Bob={_sum=50,_ctime=1}} +local c=w.DisplaySnapshot(segment,order) +assert(c.sorts==1 and c.keys[1]=='Alice') +local function frame(id) + return {GetID=function() return id end,GetWidth=function() return 400 end,GetHeight=function() return 160 end,scroll=0} +end +local first,second=frame(1),frame(2) +assert(not w.PaintUnchanged(first,c,nil,false,false)) +for i=1,1000 do + local shared=w.DisplaySnapshot(segment,order) + assert(shared==c and w.PaintUnchanged(first,shared,nil,false,false)) +end +assert(c.sorts==1,'unchanged windows do not repeat sorting') +assert(not w.PaintUnchanged(second,c,nil,false,false)) +assert(w.PaintUnchanged(second,c,nil,false,false)) +segment.Bob._sum=150 +w.DisplaySnapshot(segment,order) +assert(c.sorts==2 and c.keys[1]=='Bob') +assert(not w.PaintUnchanged(first,c,nil,false,false)) +assert(not w.PaintUnchanged(second,c,nil,false,false)) +assert(not w.PaintUnchanged(first,c,10,false,false)) +assert(not w.PaintUnchanged(first,c,11,false,false),'live fight-duration rates must redraw') +assert(not w.PaintUnchanged(first,c,11,true,false),'settings refresh must redraw') +assert(not w.PaintUnchanged(first,c,11,false,true),'reports must execute') +first.scroll=1 +assert(not w.PaintUnchanged(first,c,11,false,false)) +ShaguDPS.config.class_icons=1 +assert(not w.PaintUnchanged(first,c,11,false,false)) +ShaguDPS.data.classes.Alice='PRIEST' +w.DisplaySnapshot(segment,order) +assert(not w.PaintUnchanged(first,c,11,false,false)) +segment.Bob=nil +w.DisplaySnapshot(segment,order) +assert(c.count==1 and table.getn(c.keys)==1) +ShaguDPS.config[1].view=5 +assert(not w.PaintUnchanged(first,c,nil,false,false)) +assert(not w.PaintUnchanged(first,c,nil,false,false),'threat must bypass paint skip') +print('Shared sorting and repaint invalidation checks passed (Lua 5.0)') diff --git a/tests/fights.lua b/tests/fights.lua new file mode 100644 index 0000000..fa8a75c --- /dev/null +++ b/tests/fights.lua @@ -0,0 +1,107 @@ +-- Run from the repository root using Lua 5.0: lua tests/fights.lua +local now, in_combat, dead = 0, true, false +function GetTime() return now end +function GetNumRaidMembers() return 2 end +function GetNumPartyMembers() return 0 end +function UnitExists(unit) return unit == 'target' or unit == 'player' or unit == 'raid1' or unit == 'raid2' end +function UnitIsPlayer(unit) return UnitExists(unit) and unit ~= 'target' end +function UnitIsFriend(_, unit) return unit ~= 'target' end +function UnitIsDead(unit) return unit == 'target' and dead end +function UnitAffectingCombat(unit) return UnitExists(unit) and in_combat and not UnitIsDead(unit) end +function UnitClassification() return 'worldboss' end +function UnitLevel() return -1 end +function UnitName(unit) + if unit == 'target' then return 'Hakkar' end + if unit == 'player' or unit == 'raid1' then return 'Tester' end + if unit == 'raid2' then return 'Healer' end +end +function UnitClass() return 'Priest', 'PRIEST' end +function UnitHealth() return 50 end +function UnitHealthMax() return 100 end +function CreateFrame() + return {scripts={},RegisterEvent=function() end,SetScript=function(self,k,fn) self.scripts[k]=fn end} +end +gsub,strsub=string.gsub,string.sub +UNITDIESOTHER='%s dies.' +ShaguDPS={parser={},config={merge_pets=1},data={damage={[0]={},[1]={}},heal={[0]={},[1]={}},classes={Tester='PRIEST',Healer='PRIEST'}}} +dofile('parser.lua') +dofile('fights.lua') +local parser, fights = ShaguDPS.parser, ShaguDPS.fights +local function scan() + this=fights + fights.scripts.OnUpdate() +end +local function heal() + parser:AddData('Healer','Heal','Tester',100,2,'heal') +end +parser.combat:UpdateState() +parser:AddData('Tester','Smite','Hakkar',1000,2,'damage') +scan() +now=60 +event,arg1='CHAT_MSG_COMBAT_HOSTILE_DEATH','Hakkar dies.' +fights.scripts.OnEvent() +dead,in_combat=true,false +parser.combat:UpdateState() +assert(table.getn(fights.list)==1 and fights.list[1].kill, 'save real boss kill') +-- Healing after combat starts a small parser segment while the corpse remains targeted. +now=61 +heal() +scan() +now=67 +heal() +now=73 +parser.combat:UpdateState() +assert(table.getn(fights.list)==1, 'post-kill healing with a boss corpse must not create a duplicate wipe') +assert(fights.list[1].damage.Tester._sum==1000, 'preserve original kill data') +-- An alive but unengaged boss must not turn pre-pull healing into a wipe either. +now,dead=75,false +heal() +scan() +now=81 +heal() +now=87 +parser.combat:UpdateState() +assert(table.getn(fights.list)==1, 'pre-pull healing must not save a boss attempt') +-- A genuine subsequent pull and wipe should still be kept separately. +now,in_combat=90,true +parser.combat:UpdateState() +parser:AddData('Tester','Smite','Hakkar',200,2,'damage') +scan() +now,in_combat=100,false +parser.combat:UpdateState() +assert(table.getn(fights.list)==2 and not fights.list[1].kill, 'retain a genuine new wipe') +assert(fights.list[1].name=='Hakkar' and fights.list[2].kill) +-- Saved history must be a snapshot, independent of later segment edits. +ShaguDPS.data.damage[1].Tester._sum=9999 +assert(fights.list[2].damage.Tester._sum==1000) +-- Modern ClassicAPI: destination GUID, not localized text or source, decides kills. +CombatLogGetCurrentEventInfo = function() end +local guid = '0xBOSS1' +UnitGUID = function() return guid end +dofile('fights.lua') +fights = ShaguDPS.fights +now, in_combat, dead = 110, true, false +parser.fight.active = true +parser.OnFightStart() +scan() +event, arg1 = 'CHAT_MSG_COMBAT_HOSTILE_DEATH', 'Hakkar dies.' +fights.scripts.OnEvent() +event, arg2, arg8 = 'COMBAT_LOG_EVENT_UNFILTERED', 'UNIT_DIED', '0xOTHER' +fights.scripts.OnEvent() +parser.OnFightEnd(10) +assert(not fights.list[1].kill, 'ignore a same-name mob or unrelated death') +now = 120 +parser.OnFightStart() +scan() +event, arg2, arg3, arg8 = 'COMBAT_LOG_EVENT_UNFILTERED', 'UNIT_DIED', true, guid +fights.scripts.OnEvent() +parser.OnFightEnd(10) +assert(fights.list[1].kill, 'source-less GUID death marks the boss kill') +now, guid = 130, nil +parser.OnFightStart() +scan() +event, arg1 = 'CHAT_MSG_COMBAT_HOSTILE_DEATH', 'Hakkar dies.' +fights.scripts.OnEvent() +parser.OnFightEnd(10) +assert(fights.list[1].kill, 'chat fallback when boss GUID cannot be resolved') +print('Fight history regression checks passed (Lua 5.0)') diff --git a/tests/performance.lua b/tests/performance.lua new file mode 100644 index 0000000..70f84d0 --- /dev/null +++ b/tests/performance.lua @@ -0,0 +1,37 @@ +-- Exercise the real command through its slash handler with mocked profiling APIs. +ShaguDPS = {settings={}, window={}, parser={}, config={}, textures={}} +local now, snapshots, cpuSnapshots = 0, 0, 0 +local messages, frames = {}, {} +DEFAULT_CHAT_FRAME = {AddMessage=function(self,s) table.insert(messages,s) end} +function CreateFrame() + local f={SetScript=function(self,key,fn) self[key]=fn end} + table.insert(frames,f) + return f +end +function GetTime() return now end +strlower=string.lower +SlashCmdList={} +local file=assert(io.open('settings.lua')) +local source=file:read('*a');file:close() +local start=assert(string.find(source,'-- Profiling is opt-in.',1,true)) +assert(loadstring('local config, textures = ShaguDPS.config, ShaguDPS.textures\n' .. string.sub(source,start)))() +local function run() SlashCmdList.SHAGUMETER('perf') end +run() +assert(string.find(messages[1],'no addon profiling APIs',1,true)) +UpdateAddOnMemoryUsage=function() snapshots=snapshots+1 end +GetAddOnMemoryUsage=function(name) assert(name=='ShaguDPS');return snapshots==1 and 100 or 120 end +GetCVar=function() return '0' end +run() +assert(snapshots==1) +now=9;frames[1].OnUpdate();assert(snapshots==1) +now=10;frames[1].OnUpdate();assert(snapshots==2 and frames[1].OnUpdate==nil) +assert(string.find(messages[table.getn(messages)],'120.0 KB (+20.0 KB',1,true)) +UpdateAddOnCPUUsage=function() cpuSnapshots=cpuSnapshots+1 end +GetAddOnCPUUsage=function() return cpuSnapshots==1 and 50 or 75 end +GetCVar=function() return '1' end +run() +now=20;frames[1].OnUpdate() +assert(cpuSnapshots==2 and frames[1].OnUpdate==nil) +assert(string.find(messages[table.getn(messages)],'25.00 ms over 10.0s (2.50 ms/s)',1,true)) +assert(snapshots==4, 'memory snapshots only at measurement boundaries') +print('Performance diagnostic checks passed (Lua 5.0)') diff --git a/tests/threat-benchmark.lua b/tests/threat-benchmark.lua new file mode 100644 index 0000000..77d6eaf --- /dev/null +++ b/tests/threat-benchmark.lua @@ -0,0 +1,44 @@ +-- Synthetic Lua 5.0 benchmark, run from the repository root: +-- lua tests/threat-benchmark.lua 0.5 (then 0.25) +-- Simulates 10 minutes at 60 FPS with 40 entries per reply. +-- Includes packet generation and parsing; WoW rendering/networking are mocked. +local input = assert(io.open('threat.lua')) +local source = input:read('*a') +input:close() +local interval = tonumber(arg[1]) +source = string.gsub(source, 'local INTERVAL = [%d%.]+', 'local INTERVAL = '..interval) +local now, requests, draws = 0, 0, 0 +local frames = 600 * 60 +function GetTime() return now end +function GetNumPartyMembers() return 0 end +function GetNumRaidMembers() return 40 end +function UnitExists() return true end +function UnitIsPlayer() return false end +function UnitIsDead() return false end +function UnitClassification() return 'worldboss' end +function UnitAffectingCombat() return true end +function UnitName(unit) return unit == 'player' and 'Player1' or 'Boss' end +function CreateFrame() + return {scripts={}, RegisterEvent=function() end,SetScript=function(self,k,f) self.scripts[k]=f end} +end +ShaguDPS={config={[1]={view=5,bars=40}},data={classes={}},parser={ScanName=function() end,callbacks={refresh={}}},window={[1]={IsVisible=function() return true end,Refresh=function() draws=draws+1 end}}} +local rows={} +function SendAddonMessage() + requests=requests+1 + for i=1,40 do rows[i]='Player'..i..':'..(i==1 and '1' or '0')..':'..(requests*100+i)..'.5:100.0:1' end + event,arg2='CHAT_MSG_ADDON','TWTv4='..table.concat(rows,';') + ShaguDPS.threat.scripts.OnEvent() +end +assert(loadstring(source))() +collectgarbage() +local before=gcinfo() +local start=os.clock() +for frame=1,frames do + now=frame/60 + this=ShaguDPS.threat + this.scripts.OnUpdate() +end +local elapsed=os.clock()-start +local peak=gcinfo() +collectgarbage() +print(string.format('interval=%.2fs, simulated=600s, requests=%d, rows=%d, mock redraws=%d, CPU=%.4fs, CPU/reply=%.3fms, heap before/after GC=%.1f/%.1fKB, heap at end=%.1fKB',interval,requests,requests*40,draws,elapsed,elapsed/requests*1000,before,gcinfo(),peak)) diff --git a/tests/threat.lua b/tests/threat.lua new file mode 100644 index 0000000..f015d5a --- /dev/null +++ b/tests/threat.lua @@ -0,0 +1,229 @@ +-- Run from the repository root with Lua 5.0: lua tests/threat.lua +local now, party, raid, target, combat = 10, 1, 0, true, true +local sent, refreshes, draws = {}, 0, 0 +local visible = true +function GetTime() return now end +function GetNumPartyMembers() return party end +function GetNumRaidMembers() return raid end +function UnitExists() return target end +function UnitIsPlayer() return nil end +local dead = false +function UnitIsDead() return dead end +function UnitClassification() return 'elite' end +function UnitAffectingCombat() return combat end +function UnitName(unit) return unit == 'player' and 'Tester' or 'Boss' end +function SendAddonMessage(prefix, text, channel) + table.insert(sent, {prefix, text, channel}) +end +function CreateFrame() + return {scripts={}, RegisterEvent=function() end, + SetScript=function(self, name, fn) self.scripts[name] = fn end} +end +ShaguDPS = { + config = {[1]={view=5,bars=8}}, + data = {classes={}}, + parser = {ScanName=function() end, callbacks={refresh={function() refreshes=refreshes+1 end}}}, + window = {[1]={IsVisible=function() return visible end, Refresh=function() draws=draws+1 end}}, +} +dofile('threat.lua') +local threat, data = ShaguDPS.threat, ShaguDPS.data +local function update() + this = threat + threat.scripts.OnUpdate() +end +local function emit(kind, text) + this, event, arg1, arg2 = threat, kind, 'SERVER', text + threat.scripts.OnEvent() +end +update() +assert(table.getn(sent) == 1 and sent[1][1] == 'TWT_UDTSv4' and sent[1][2] == 'limit=8' and sent[1][3] == 'PARTY') +emit('CHAT_MSG_ADDON', 'TWTv4=Tank:1:1000.5:100.0:1;Tester:0:500.25:50.0:1;#TMTv1=ignored') +assert(data.threat.Tank and data.threat.Tank._sum == 1000.5, 'decimal threat must be parsed') +assert(data.threat.Tester._perc == 50 and data.threat.Tester._melee) +assert(data.threat['Pull Aggro']._sum == 1100) +now = 12 +emit('CHAT_MSG_ADDON', 'TWTv4=Tank:1:1200:100:1;Tester:0:700:58:0;Bad:0:oops:10:1;') +assert(data.threat.Tester._tps == 99.875) +assert(not data.threat.Bad) +assert(data.threat['Pull Aggro']._sum == 1560) +update() +assert(table.getn(sent) == 1, 'recent external answers suppress duplicate queries') +now = 13.5 +update() +assert(table.getn(sent) == 2, 'queries resume after external answers stop') +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:0:0:0:0;') +assert(not data.threat.Tank and not data.threat['Pull Aggro']) +emit('PLAYER_TARGET_CHANGED') +assert(not next(data.threat)) +assert(not threat.last_answer and threat.pending) +assert(table.getn(sent) == 3, 'target changes query immediately') +now, party = 15, 0 +update() +assert(table.getn(sent) == 3, 'no queries while solo') +now, party, combat = 16, 1, false +update() +assert(table.getn(sent) == 3, 'no queries out of combat') +now, combat, visible = 17, true, false +update() +assert(table.getn(sent) == 3, 'no queries with hidden threat windows') +now, visible, raid = 18, true, 10 +update() +assert(sent[4][3] == 'RAID') +assert(draws > 0 and refreshes == 0, 'threat does not refresh damage/heal callbacks') +now = 22 +update() +assert(string.find(threat:GetStatus(), 'No threat reply', 1, true)) +party, raid = 0, 0 +assert(string.find(threat:GetStatus(), 'Join a party', 1, true)) +party, target = 1, false +assert(string.find(threat:GetStatus(), 'Select a living', 1, true)) +target, combat = true, false +assert(string.find(threat:GetStatus(), 'enter combat', 1, true)) +combat = true +emit('PLAYER_TARGET_CHANGED') +assert(string.find(threat:GetStatus(), 'Waiting for threat data', 1, true)) +emit('CHAT_MSG_ADDON', 'TWTv4=') +assert(string.find(threat:GetStatus(), 'Waiting for threat data', 1, true), 'previous target reply discarded') +emit('CHAT_MSG_ADDON', 'TWTv4=') +assert(string.find(threat:GetStatus(), 'no threat entries', 1, true)) +-- Poll at 2 Hz, without waiting for the normal window refresh timer. +emit('PLAYER_TARGET_CHANGED') +now = 30 +update() +local requests, previous_draws = table.getn(sent), draws +now = 30.21 +update() +assert(table.getn(sent) == requests, 'polls must be rate limited') +now = 30.51 +update() +assert(table.getn(sent) == requests + 1, 'poll again after 0.5 seconds') +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:100:100:1;') +assert(draws > previous_draws, 'visible threat window refreshes immediately') +assert(data.threat.Tester._tps == 0, 'first sample has no elapsed time') +now = 30.71 +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:120:100:1;') +assert(math.abs(data.threat.Tester._tps - 100) < 0.001, 'TPS updates on the next sample') +now = 30.91 +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:110:100:1;') +assert(data.threat.Tester._tps == 0, 'threat reduction resets TPS baseline') +now = 31.11 +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:130:100:1;') +assert(math.abs(data.threat.Tester._tps - 100) < 0.001) +emit('CHAT_MSG_ADDON', 'TWTv4=') +now = 31.3 +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:200:100:1;') +assert(data.threat.Tester._tps == 0, 'returning players start a fresh sample') +visible = false +previous_draws = draws +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:210:100:1;') +assert(draws == previous_draws, 'hidden windows are not redrawn') +-- Recent replies from another addon must not suppress a new target query. +visible = true +now = 40 +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:300:100:1;') +local before_switch = table.getn(sent) +emit('PLAYER_TARGET_CHANGED') +assert(table.getn(sent) == before_switch + 1) +assert(not next(data.threat), 'clear previous target data immediately') +update() +assert(table.getn(sent) == before_switch + 1, 'no duplicate poll in the same frame') +visible = false +emit('PLAYER_TARGET_CHANGED') +assert(table.getn(sent) == before_switch + 1, 'hidden windows do not query on target change') +visible, target = true, false +emit('PLAYER_TARGET_CHANGED') +assert(table.getn(sent) == before_switch + 1, 'no query when target is lost') +local messages = {} +DEFAULT_CHAT_FRAME = { AddMessage=function(self, text) table.insert(messages, text) end } +now, visible, target = 50, true, true +threat.last_foreign = nil +threat:Measure() +for sample = 0, 19 do + now = 50 + sample * 0.5 + update() + emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:300:100:1;') +end +assert(table.getn(messages) == 1, 'diagnostic does not spam each packet') +now = 60 +update() +assert(table.getn(messages) == 2) +assert(string.find(messages[2], '20 requests (2.00/s)', 1, true)) +assert(string.find(messages[2], '20 replies (2.00/s)', 1, true)) +assert(string.find(messages[2], '0 changed replies', 1, true)) +now = 61 +update() +assert(table.getn(messages) == 2, 'measurement stops automatically') +-- A constant 100 TPS stream remains stable across rolling-window boundaries. +now = 70 +emit('PLAYER_TARGET_CHANGED') +for i = 0, 16 do + now = 70 + i * 0.5 + emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:' .. (100 + i * 50) .. ':100:1;') + if i > 0 then assert(math.abs(data.threat.Tester._tps - 100) < 0.001) end +end +-- Repeated identical totals age the old gains out of the five-second window. +for i = 1, 10 do + now = 78 + i * 0.5 + emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:900:100:1;') +end +assert(data.threat.Tester._tps == 0) +now = 83.5 +emit('CHAT_MSG_ADDON', 'TWTv4=Tank:1:1000:100:1;') +assert(data.threat['Pull Aggro']._minimum == 1100) +assert(data.threat['Pull Aggro']._sum == 1300) +assert(data.threat['Pull Aggro']._perc == 100) +now = 84 +emit('CHAT_MSG_ADDON', 'TWTv4=Tank:1:1000:100:1;Tester:0:100:10:0;') +assert(not data.threat['Pull Aggro']._minimum and data.threat['Pull Aggro']._perc == 100) +now = 87.1 +update() +assert(not next(data.threat), 'expire old bars after three seconds without replies') +assert(string.find(threat:GetStatus(), 'stopped arriving', 1, true)) +now = 87.2 +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:200:100:1;') +assert(data.threat.Tester._tps == 0 and not threat.stale, 'fresh reply recovers from stale state') +now, dead = 88, true +update() +assert(not next(data.threat), 'clear bars on target death') +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:300:100:1;') +assert(not next(data.threat), 'ignore late reply for a dead target') +dead = false +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:300:100:1;') +now, combat = 89, false +update() +assert(not next(data.threat), 'clear bars on combat end') +combat = true +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:300:100:1;') +now, party, raid = 90, 0, 0 +update() +assert(not next(data.threat), 'clear bars on leaving the group') +assert(refreshes == 0, 'damage/heal callbacks remain untouched') +-- Same-name mobs remain distinct when client GUIDs are available. +party, raid, target, combat, dead = 1, 0, true, true, false +local targetGuid = '0xA' +UnitGUID = function() return targetGuid end +now = 100 +emit('PLAYER_TARGET_CHANGED') +targetGuid = '0xB' +emit('PLAYER_TARGET_CHANGED') +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:111:100:1;') +assert(not next(data.threat), 'old same-name target reply must not populate new target') +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:222:100:1;') +assert(data.threat.Tester._sum == 222) +-- An expired pending request must not permanently block fresh replies. +now = 103 +update() +emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:333:100:1;') +assert(data.threat.Tester._sum == 333) +-- Captured in-game examples: keep server percentages, never normalize twice. +now = 105 +emit('CHAT_MSG_ADDON', 'TWTv4=Francin:1:2034:100:1;Tester:0:516:19.5:0;') +assert(data.threat.Tester._perc == 19.5 and data.threat.Francin._perc == 100) +assert(data.threat['Pull Aggro']._sum == math.floor(2034 * 1.3)) +assert(data.threat['Pull Aggro']._perc == 100) +now = 106 +emit('CHAT_MSG_ADDON', 'TWTv4=Francin:1:2614:100:1;Tester:0:486:16.9:1;') +assert(data.threat.Tester._perc == 16.9) +assert(data.threat['Pull Aggro']._sum == math.floor(2614 * 1.1)) +assert(data.threat['Pull Aggro']._perc == 100) +print('Threat regression checks passed (Lua 5.0)') diff --git a/threat.lua b/threat.lua new file mode 100644 index 0000000..273ab65 --- /dev/null +++ b/threat.lua @@ -0,0 +1,340 @@ +-- Threat view: the server's threat list for the current target. +-- +-- 1.18.1 servers answer a threat query sent as an addon message with the +-- prefix "TWT_UDTSv4" and the text "limit=" to the group channel. The +-- answer arrives as CHAT_MSG_ADDON whose text holds +-- "TWTv4=name:tank:threat:percent:melee;name:tank:threat:percent:melee;..." +-- for the target of the player who asked: tank is 1 for the unit's current +-- victim, percent is relative to the pull threshold (the victim shows 100), +-- melee is 1 for players in melee +-- range. Melee players pull aggro at 110% of the tank's threat, ranged ones +-- at 130%. Only works in a party or raid, on elite and boss targets. +-- +-- This is the same server API the TWThreat addon uses. When another addon is +-- already asking, ShaguDPS only listens to the answers and doesn't ask twice. + +local parser = ShaguDPS.parser +local config = ShaguDPS.config +local data = ShaguDPS.data +local window = ShaguDPS.window + +local QUERY_PREFIX = "TWT_UDTSv4" +local ANSWER_TAG = "TWTv4=" +local INTERVAL = 0.5 +local STALE_TIMEOUT = 3 +local TPS_WINDOW = 5 +local FOREIGN_TIMEOUT = 1 -- allow slower polling addons to keep supplying data + +-- Opt-in, short measurement; no per-packet chat output or retained log. +local measurement +local function DiagnosticMessage(text) + DEFAULT_CHAT_FRAME:AddMessage("ShaguDPS threat: " .. text) +end + +local function DiagnosticTick(now) + if not measurement or now - measurement.start < 10 then return end + local elapsed = now - measurement.start + DiagnosticMessage(string.format("%.1fs: %d requests (%.2f/s), %d replies (%.2f/s), %d changed replies, %d target switches. Configured interval: %.2fs.", + elapsed, measurement.sent, measurement.sent / elapsed, measurement.received, + measurement.received / elapsed, measurement.changed, measurement.switches, INTERVAL)) + measurement = nil +end + +local PULL = "Pull Aggro" +local generation = 0 +local requests = {} +local function TargetKey() + local guid = UnitGUID and UnitGUID("target") + if not guid and GetUnitGUID then guid = GetUnitGUID("target") end + if not guid then local _, value = UnitExists("target"); guid = value end + if type(guid) == "string" and guid ~= "" then return guid end + return UnitName("target") +end +local function ExpireRequests(now) + while requests[1] and now - requests[1].time > 1.5 do table.remove(requests, 1) end +end + +-- view 5 shows data.threat: one entry per player, rebuilt on every answer +data.threat = {} + +local threat = CreateFrame("Frame") +ShaguDPS.threat = threat + +threat.Measure = function() + measurement = { start = GetTime(), sent = 0, received = 0, changed = 0, switches = 0 } + DiagnosticMessage("Measuring for 10 seconds. Keep one engaged elite/boss targeted, stay grouped, and keep the threat window visible. Disable other threat addons for an isolated test.") +end + +threat.target = nil -- name of the unit the list belongs to +threat.pending = nil -- we asked and wait for our answer +threat.last_foreign = nil -- last answer to a question another addon asked + +-- Recent timestamped samples per player, bounded to the rolling TPS window. +local history = {} + +local function Wipe(t) + for k in pairs(t) do t[k] = nil end +end + +local function Clear() + Wipe(data.threat) + Wipe(history) +end + +local function TPS(name, value, now) + local h = history[name] + local last = h and h[table.getn(h)] + if not last or value < last.value or now - last.time >= STALE_TIMEOUT then + history[name] = { { time = now, value = value } } + return 0 + end + if now == last.time then + last.value = value + else + table.insert(h, { time = now, value = value }) + end + local cutoff = now - TPS_WINDOW + -- Retain one sample before the boundary so it can be interpolated. + while table.getn(h) > 1 and h[2].time <= cutoff do table.remove(h, 1) end + local first = h[1] + local start, baseline = first.time, first.value + if start < cutoff and h[2] then + local nextSample = h[2] + baseline = baseline + (nextSample.value - baseline) * (cutoff - start) / (nextSample.time - start) + start = cutoff + end + return now > start and math.max(0, (value - baseline) / (now - start)) or 0 +end + +local function Entry(name) + local entry = data.threat[name] + if not entry then + entry = { _ctime = 1 } + data.threat[name] = entry + end + return entry +end + +-- "name:tank:threat:percent:melee;..." +local function ParseAnswer(text) + local _, start = string.find(text, ANSWER_TAG, 1, true) + if not start then return end + + local list = string.sub(text, start + 1) + + -- tank mode answers append "#TMTv1=..." for other targets: ignore it + local cut = string.find(list, "#", 1, true) + if cut then list = string.sub(list, 1, cut - 1) end + + local now = GetTime() + local seen = {} + local tank_threat, my_melee = 0, nil + local me = UnitName("player") + + -- Accept decimal numeric fields, just like the reference addon. Match fields + -- first, then validate them as TWThreat does instead of requiring integers. + for name, tank, value, perc, melee in string.gfind(list, "([^:;]+):([^:;]+):([^:;]+):([^:;]+):([^:;]+)") do + value, perc = tonumber(value), tonumber(perc) + if value and perc and (tank == "0" or tank == "1") and (melee == "0" or melee == "1") then + local entry = Entry(name) + entry._sum = value + entry._perc = perc + entry._tank = tank == "1" or nil + entry._melee = melee == "1" or nil + entry._tps = TPS(name, value, now) + seen[name] = true + + if entry._tank then tank_threat = value end + if name == me then my_melee = entry._melee end + + -- class colors for group members + if not data.classes[name] then parser:ScanName(name) end + end + end + + -- drop players that left the list + for name in pairs(data.threat) do + if not seen[name] and name ~= PULL then + data.threat[name] = nil + history[name] = nil + end + end + + -- where you'd pull aggro: 110% of the tank in melee range, 130% at range + if tank_threat > 0 then + local pull = Entry(PULL) + pull._sum = math.floor(tank_threat * (my_melee and 1.1 or 1.3)) + pull._perc = 100 + pull._pull = true + pull._minimum = nil + if not seen[me] then + pull._minimum = math.floor(tank_threat * 1.1) + end + else + data.threat[PULL] = nil + end +end + +-- does any visible window show threat? returns the most bars they show +local function ThreatBars() + local bars + for i = 1, 10 do + local w = window[i] + if w and config[i] and config[i].view == 5 and w:IsVisible() then + bars = math.max(bars or 0, config[i].bars or 8) + end + end + return bars +end + +local function TargetUsable() + if not UnitExists("target") or UnitIsPlayer("target") or UnitIsDead("target") then return nil end + local class = UnitClassification("target") + return class == "elite" or class == "worldboss" or class == "rareelite" +end + +-- Explain why an empty view is empty, rather than implying every target works. +threat.GetStatus = function(self) + if GetNumRaidMembers() == 0 and GetNumPartyMembers() == 0 then + return "Join a party or raid to receive threat data." + elseif not TargetUsable() then + return "Select a living elite or boss target to view threat." + elseif not UnitAffectingCombat("target") then + return "Waiting for the target to enter combat." + elseif self.stale then + return "Threat data stopped arriving. Waiting for a fresh reply." + elseif self.last_answer then + return "The server returned no threat entries for this target." + elseif self.requested_at and GetTime() - self.requested_at >= 3 then + return "No threat reply received. This view requires the server threat API." + end + return "Waiting for threat data from the server." +end + +local function Refresh() + -- Threat packets already arrive at a bounded rate. Display them immediately + -- instead of waiting for the regular 0.2-second window refresh timer. + for i = 1, 10 do + local w = window[i] + if w and config[i] and config[i].view == 5 then + w.needs_refresh = true + if w:IsVisible() then + w.needs_refresh = nil + w:Refresh() + end + end + end +end + +local function Query(now) + if GetNumRaidMembers() == 0 and GetNumPartyMembers() == 0 then return end + if not TargetUsable() or not UnitAffectingCombat("target") then return end + + local bars = ThreatBars() + if not bars then return end + + -- another addon (TWThreat) is already asking: just listen to its answers + if threat.last_foreign and now - threat.last_foreign < FOREIGN_TIMEOUT then return end + + threat.tick = now + INTERVAL + threat.pending = true + threat.requested_at = threat.requested_at or now + local channel = GetNumRaidMembers() > 0 and "RAID" or "PARTY" + ExpireRequests(now) + if table.getn(requests) >= 8 then table.remove(requests, 1) end + table.insert(requests, { key = TargetKey(), generation = generation, time = now }) + if measurement then measurement.sent = measurement.sent + 1 end + SendAddonMessage(QUERY_PREFIX, "limit=" .. math.min(40, bars), channel) +end + +threat:RegisterEvent("CHAT_MSG_ADDON") +threat:RegisterEvent("PLAYER_TARGET_CHANGED") +threat:RegisterEvent("PLAYER_REGEN_ENABLED") + +threat:SetScript("OnEvent", function() + if event == "CHAT_MSG_ADDON" then + if not arg2 or not string.find(arg2, ANSWER_TAG, 1, true) then return end + if measurement then + measurement.received = measurement.received + 1 + local _, start = string.find(arg2, ANSWER_TAG, 1, true) + local payload = string.sub(arg2, start + 1) + local cut = string.find(payload, "#", 1, true) + if cut then payload = string.sub(payload, 1, cut - 1) end + if measurement.previous and payload ~= measurement.previous then + measurement.changed = measurement.changed + 1 + end + measurement.previous = payload + end + + -- Replies contain no target ID. Match outstanding requests in send order; + -- reject those made for a previous target/generation, even with the same name. + ExpireRequests(GetTime()) + local request = requests[1] + if request then + table.remove(requests, 1) + threat.pending = table.getn(requests) > 0 and true or nil + if request.generation ~= generation or request.key ~= TargetKey() then return end + else + threat.pending = nil + threat.last_foreign = GetTime() + end + + -- answers are for the target of whoever asked: only take them while + -- we look at a target that can have one + if not TargetUsable() or not UnitAffectingCombat("target") then return end + if GetNumRaidMembers() == 0 and GetNumPartyMembers() == 0 then return end + threat.stale = nil + threat.target = UnitName("target") + threat.last_answer = GetTime() + ParseAnswer(arg2) + Refresh() + elseif event == "PLAYER_TARGET_CHANGED" then + generation = generation + 1 + if measurement then + measurement.switches = measurement.switches + 1 + measurement.previous = nil + end + -- the list belongs to the old target + Clear() + threat.pending, threat.requested_at, threat.last_answer = nil, nil, nil + threat.target = TargetUsable() and UnitName("target") or nil + threat.stale = nil + threat.tick = 0 + -- Replies for the previous target must not delay the new target query. + threat.last_foreign = nil + Refresh() + Query(GetTime()) + elseif event == "PLAYER_REGEN_ENABLED" then + Wipe(history) + end +end) + +-- ask the server twice a second while a threat window is shown +threat:SetScript("OnUpdate", function() + local now = GetTime() + DiagnosticTick(now) + if (this.tick or 0) > now then return end + this.tick = now + INTERVAL + + local usable = TargetUsable() + local active = usable and UnitAffectingCombat("target") and + (GetNumRaidMembers() > 0 or GetNumPartyMembers() > 0) + local expired = threat.last_answer and now - threat.last_answer >= STALE_TIMEOUT + local cleared + if not active or expired then + cleared = next(data.threat) ~= nil + Clear() + threat.stale = active and expired or nil + if not active then + threat.last_answer, threat.requested_at, threat.pending, threat.last_foreign = nil, nil, nil, nil + end + end + threat.target = usable and UnitName("target") or nil + local status = threat:GetStatus() + if cleared or status ~= threat.status then + threat.status = status + Refresh() + end + + Query(now) +end) diff --git a/window.lua b/window.lua new file mode 100644 index 0000000..be820d1 --- /dev/null +++ b/window.lua @@ -0,0 +1,1691 @@ +-- check for expansion +local tbc = ShaguDPS.expansion() == "tbc" and true or nil + +-- load public variables into local +local window = ShaguDPS.window +local parser = ShaguDPS.parser + +local data = ShaguDPS.data +local config = ShaguDPS.config +local internals = ShaguDPS.internals + +local textures = ShaguDPS.textures +local spairs = ShaguDPS.spairs +local round = ShaguDPS.round +local skin = ShaguDPS.skin + +-- time used for "per second" values: each unit's own active time, or with the +-- "Fight Duration" time mode the time the whole segment lasted (set by Refresh) +local fight_time = nil +local function ctime(unitdata) + return fight_time or unitdata["_ctime"] +end + +-- title row: two icons left, three right, so the two text buttons sit a bit +-- left of the center and the window needs this much width +local MIN_WIDTH = 186 +local TITLE_SHIFT = -8.5 + +local function SetFightTime(segment, wconfig) + if config.time_mode ~= 1 then + fight_time = nil + elseif segment == 2 then + -- a fight of the history: the time it lasted + local fight = ShaguDPS.fights and wconfig and ShaguDPS.fights:Get(wconfig.fight) + fight_time = fight and math.max(1, fight.duration) or nil + else + fight_time = parser:GetFightTime(segment or 1) + end +end + +-- caption of a window's segment: Current, Overall or the fight's name +local function SegmentName(wconfig) + if wconfig.segment == 2 then + local fight = ShaguDPS.fights and ShaguDPS.fights:Get(wconfig.fight) + if fight then return fight.name end + end + return wconfig.segment == 0 and "Overall" or "Current" +end + +-- all known classes +local classes = { + WARRIOR = true, MAGE = true, ROGUE = true, DRUID = true, HUNTER = true, + SHAMAN = true, PRIEST = true, WARLOCK = true, PALADIN = true, +} + +-- Modern class palette for the retail bar preset, independent of other addons. +local modern_colors = { + WARRIOR = {.78, .61, .43}, MAGE = {.25, .78, .92}, + ROGUE = {1, .96, .41}, DRUID = {1, .49, .04}, + HUNTER = {.67, .83, .45}, SHAMAN = {0, .44, .87}, + PRIEST = {1, 1, 1}, WARLOCK = {.53, .53, .93}, + PALADIN = {.96, .55, .73}, +} + +-- Format display values explicitly to avoid Lua exposing float precision noise. +window.FormatThreatPercent = function(value) + if type(value) ~= "number" then return value or "0" end + return (string.gsub(string.format("%.1f", value), "%.0$", "")) +end + +window.FormatRate = function(value) + return string.format("%.0f", value or 0) +end + +window.FormatTotal = function(value) + value = value or 0 + local divisor, suffix, precision = 1, "", 0 + if value >= 1000000 then + divisor, suffix, precision = 1000000, "M", 2 + elseif value >= 10000 then + divisor, suffix, precision = 1000, "K", 1 + end + local text = string.format("%." .. precision .. "f", value / divisor) + if precision > 0 then + text = string.gsub(text, "0+$", "") + text = string.gsub(text, "%.$", "") + end + return text .. suffix +end + +-- The built-in class atlas is also used by GreedMeter; no extra assets needed. +local class_icons = { + WARRIOR = {0, .25, 0, .25}, MAGE = {.25, .5, 0, .25}, + ROGUE = {.5, .75, 0, .25}, DRUID = {.75, 1, 0, .25}, + HUNTER = {0, .25, .25, .5}, SHAMAN = {.25, .5, .25, .5}, + PRIEST = {.5, .75, .25, .5}, WARLOCK = {.75, 1, .25, .5}, + PALADIN = {0, .25, .5, .75}, +} + +window.UpdateClassIcon = function(bar, class) + local coords = config.class_icons == 1 and class_icons[class] + local offset = 5 + if coords then + bar.classIcon = bar.classIcon or bar:CreateTexture(nil, "OVERLAY") + local size = math.max(1, config.height - config.spacing - 2) + bar.classIcon:SetTexture("Interface\\Glues\\CharacterCreate\\UI-CharacterCreate-Classes") + bar.classIcon:SetTexCoord(unpack(coords)) + bar.classIcon:SetWidth(size) + bar.classIcon:SetHeight(size) + bar.classIcon:SetPoint("LEFT", bar, "LEFT", 2, 0) + bar.classIcon:Show() + offset = size + 5 + elseif bar.classIcon then + bar.classIcon:Hide() + end + bar.textLeft:SetPoint("TOPLEFT", bar, "TOPLEFT", offset, 1) +end + +-- Share measured column widths across the visible rows of one window. +window.AlignValueColumns = function(bars, count, widths) + for i = 1, count do + local bar = bars[i] + if bar and bar:IsShown() then + local offset = 5 + local columns = { bar.textRight, bar.textRate, bar.textPercent } + for column = 3, 1, -1 do + local text = columns[column] + text:ClearAllPoints() + text:SetPoint("TOPRIGHT", bar, "TOPRIGHT", -offset, 1) + text:SetPoint("BOTTOMRIGHT", bar, "BOTTOMRIGHT", -offset, 0) + text:SetWidth(widths[column]) + offset = offset + widths[column] + 6 + end + bar.textLeft:SetPoint("BOTTOMRIGHT", bar, "BOTTOMRIGHT", -offset, 0) + end + end +end + +local empty_dispels = {} +window.GetDispelSegment = function(wconfig, fight) + local key = wconfig.dispels_received == 1 and "dispels_received" or "dispels_done" + if fight then return fight[key] or empty_dispels end + return data[key][wconfig.segment or 0] +end + +window.DispelStatus = function(wconfig, fight, available) + local key = wconfig.dispels_received == 1 and "dispels_received" or "dispels_done" + if fight and (fight[key] == nil or fight.dispels_recorded == false) then + return "Dispel data was not recorded for this fight." + elseif not fight and not available then + return "Dispel tracking requires ClassicAPI with combat logging." + end +end + +-- default backdrops +local backdrop = { + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 8, + insets = { left = 2, right = 2, top = 2, bottom = 2 } +} + +local backdrop_window = { + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + tile = true, tileSize = 16, edgeSize = 16, + insets = { left = 3, right = 3, top = 3, bottom = 3 } +} + +local backdrop_border = { + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 16, + insets = { left = 3, right = 3, top = 3, bottom = 3 } +} + +-- templates describing the window contents +local view_templates = { + [6] = { + name = "Dispels", sort = "normal", bar_max = "best", bar_val = "value", + chat_string = "%s [%s%%]", bar_string = "%s [%s%%]", + bar_string_params = { "value_short", "percent_display", "percent_display" }, + chat_string_params = { "value_short", "percent_display", "percent_display" }, + }, + [1] = { -- damage: total, per second and share in one bar + name = "Damage", + sort = "normal", + bar_max = "best", + bar_val = "value", + bar_lower_max = nil, + bar_lower_val = nil, + chat_string = "%s (%s DPS) [%s%%]", + bar_string = "%s (%s DPS) [%s%%]", + bar_string_params = { "value_short", "persecond_short", "percent_display" }, + chat_string_params = { "value_short", "persecond_short", "percent_display" }, + }, + [3] = { -- heal: effective total, per second and share; overheal as the light bar + name = "Heal", + sort = "normal", + bar_max = "best", + bar_val = "effective_value", + bar_lower_max = "best", + bar_lower_val = "value", + chat_string = "%s (%s HPS) [%s%%] [+%s overheal]", + bar_string = "%s (%s HPS) [%s%%]", + bar_string_params = { "effective_short", "effective_persecond_short", "effective_percent_display" }, + chat_string_params = { "effective_short", "effective_persecond_short", "effective_percent_display", "uneffective_short" }, + }, + [5] = { -- threat on the current target + name = "Threat", + sort = "normal", + bar_max = "best", + bar_val = "value", + bar_lower_max = nil, + bar_lower_val = nil, + chat_string = "%s (%s TPS) [%s%%]", + bar_string = "%s (%s TPS) [%s%%]", + bar_string_params = { "value_short", "threat_tps_short", "threat_perc" }, + chat_string_params = { "value_short", "threat_tps_short", "threat_perc" }, + }, +} + +-- views of older versions: DPS (2) and HPS (4) are part of Damage and Heal now +local view_merged = { [2] = 1, [4] = 3 } + +local short = window.FormatTotal + +-- panel button templates +local menubuttons = { + ["Dispels"] = {3, 6, 25.5, "Dispels", "|cffffffffSuccessful removals; select Done or Received", "view"}, + ["Done"] = {4, 0, 25.5, "Dispels Done", "|cffffffffShow players removing auras", "dispels_received"}, + ["Received"] = {5, 1, 25.5, "Dispels Received", "|cffffffffShow players whose auras were removed", "dispels_received"}, + -- segments + ["Current"] = { 0, 1, -25.5, "Current Segment", "|cffffffffShow current fight", "segment" }, + ["Fights"] = { 1, 2, -25.5, "Boss Fights", "|cffffffffPick a past boss fight", "segment" }, + ["Overall"] = { 2, 0, -25.5, "Overall Segment", "|cffffffffShow all fights", "segment" }, + + -- modes + ["Damage"] = { 0, 1, 25.5, "Damage View", "|cffffffffDamage done and damage per second", "view" }, + ["Heal"] = { 1, 3, 25.5, "Heal View", "|cffffffffHealing done and heal per second", "view" }, + ["Threat"] = { 2, 5, 25.5, "Threat View", "|cffffffffThreat on your target", "view" }, +} + +-- default colors of chat types +local chatcolors = { + ["SAY"] = "|cffFFFFFF", + ["EMOTE"] = "|cffFF7E40", + ["YELL"] = "|cffFF3F40", + ["PARTY"] = "|cffAAABFE", + ["GUILD"] = "|cff3CE13F", + ["OFFICER"] = "|cff40BC40", + ["RAID"] = "|cffFF7D01", + ["RAID_WARNING"] = "|cffFF4700", + ["BATTLEGROUND"] = "|cffFF7D01", + ["WHISPER"] = "|cffFF7EFF", + ["CHANNEL"] = "|cffFEC1C0" +} + +local sort_algorithms = { + normal = function(t,a,b) + if t[a]["_esum"] and t[b]["_esum"] and t[a]["_esum"] ~= t[b]["_esum"] then + return t[b]["_esum"] < t[a]["_esum"] + else + return t[b]["_sum"] < t[a]["_sum"] + end + end, + per_second = function(t,a,b) + if t[a]["_esum"] and t[b]["_esum"] and t[a]["_esum"] ~= t[b]["_esum"] then + return t[b]["_esum"] / ctime(t[b]) < t[a]["_esum"] / ctime(t[a]) + else + return t[b]["_sum"] / ctime(t[b]) < t[a]["_sum"] / ctime(t[a]) + end + end, + single_spell = function(t,a,b) + if t["_effective"] and t["_effective"][a] and t["_effective"][b] and t["_effective"][a] ~= t["_effective"][b] then + return t["_effective"][b] < t["_effective"][a] + else + if tonumber(t[b]) and tonumber(t[a]) then return t[b] < t[a] end + end + end +} + +local rgbcache = {} +local function str2rgb(text) + if not text then return 1, 1, 1 end + if rgbcache[text] then return unpack(rgbcache[text]) end + local counter = 1 + local l = string.len(text) + for i = 1, l, 3 do + counter = mod(counter*8161, 4294967279) + + (string.byte(text,i)*16776193) + + ((string.byte(text,i+1) or (l-i+256))*8372226) + + ((string.byte(text,i+2) or (l-i+256))*3932164) + end + local hash = mod(mod(counter, 4294967291),16777216) + local r = (hash - (mod(hash,65536))) / 65536 + local g = ((hash - r*65536) - ( mod((hash - r*65536),256)) ) / 256 + local b = hash - r*65536 - g*256 + rgbcache[text] = { r / 255, g / 255, b / 255 } + return unpack(rgbcache[text]) +end + +local function spairs(t, order) + -- collect the keys + local keys = {} + for k in pairs(t) do keys[table.getn(keys)+1] = k end + + -- if order function given, sort by it by passing the table and keys a, b, + -- otherwise just sort the keys + if order then + table.sort(keys, function(a,b) return order(t, a, b) end) + else + table.sort(keys) + end + + -- return the iterator function + local i = 0 + return function() + i = i + 1 + if keys[i] then + return keys[i], t[keys[i]] + end + end +end + +-- Shared, display-only caches. Weak keys let discarded fight tables expire. +local displayCaches = setmetatable({}, {__mode = "k"}) +local paintOptions = {"height", "spacing", "texture", "class_icons", "pastel", "merge_pets", "time_mode", "backdrop", "theme"} +window.DisplaySnapshot = function(t, order) + local modes = displayCaches[t] + if not modes then modes = {}; displayCaches[t] = modes end + local c = modes[order] + if not c then + c = {rows = {}, keys = {}, revision = 0, sorts = 0} + c.compare = function(a, b) + if order(c.rows, a, b) then return true end + if order(c.rows, b, a) then return false end + return a < b + end + modes[order] = c + end + local changed, ranking = false, false + local count = 0 + for name, entry in pairs(t) do + count = count + 1 + local row = c.rows[name] + if not row then row = {}; c.rows[name] = row; changed, ranking = true, true end + if row._sum ~= entry._sum or row._esum ~= entry._esum or row._ctime ~= entry._ctime then + ranking = true; changed = true + end + row._sum, row._esum, row._ctime = entry._sum, entry._esum, entry._ctime + local class = data.classes[name] + local ownerClass = class and data.classes[class] + local color = RAID_CLASS_COLORS and RAID_CLASS_COLORS[ownerClass or class] + local r, g, b = color and color.r, color and color.g, color and color.b + if row.class ~= class or row.ownerClass ~= ownerClass or row.r ~= r or row.g ~= g or row.b ~= b then changed = true end + row.class, row.ownerClass, row.r, row.g, row.b = class, ownerClass, r, g, b + end + for name in pairs(c.rows) do + if not t[name] then c.rows[name] = nil; changed, ranking = true, true end + end + if ranking then + local n = 0 + for name in pairs(c.rows) do n = n + 1; c.keys[n] = name end + for i = n + 1, table.getn(c.keys) do c.keys[i] = nil end + table.sort(c.keys, c.compare) + c.sorts = c.sorts + 1 + end + if changed then c.revision = c.revision + 1 end + c.count = count + return c +end + +window.PaintUnchanged = function(self, cache, duration, force, report) + local state = self.paintState + local wc = config[self:GetID()] + local same = not force and not report and wc.view ~= 5 and state and + state.cache == cache and state.revision == cache.revision and + state.duration == duration and state.scroll == self.scroll and + state.view == wc.view and state.bars == wc.bars and + state.direction == wc.dispels_received and state.width == self:GetWidth() and state.height == self:GetHeight() + state = state or {}; self.paintState = state + for _, key in pairs(paintOptions) do + if state["option_" .. key] ~= config[key] then same = false end + state["option_" .. key] = config[key] + end + state.cache, state.revision, state.duration = cache, cache.revision, duration + state.scroll, state.view, state.bars = self.scroll, wc.view, wc.bars + state.direction, state.width, state.height = wc.dispels_received, self:GetWidth(), self:GetHeight() + return same and true or false +end + +local function barTooltipShow() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + + local segment = this.parent.segment + local wid = this.parent:GetID() + if not segment[this.unit] then return end + + if this.glow then this.glow:Show() end + + if config[wid].view == 6 then + local entry = segment[this.unit] + GameTooltip:AddLine(this.title .. ": " .. entry._sum .. " dispels") + local function Breakdown(title, counts) + GameTooltip:AddLine(title) + local count = 0 + for name, value in spairs(counts or {}, sort_algorithms.single_spell) do + count = count + 1 + if count > 10 then GameTooltip:AddLine("..."); break end + GameTooltip:AddDoubleLine(name, tostring(value)) + end + end + Breakdown("Removed auras", entry._auras) + if entry._abilities then Breakdown("Dispel abilities", entry._abilities) end + Breakdown(config[wid].dispels_received == 1 and "Dispelled by" or "Dispelled from", entry._players) + GameTooltip:Show() + return + end + + -- threat: the server's numbers for the current target + if config[wid].view == 5 then + local entry = segment[this.unit] + GameTooltip:AddLine(this.title .. ":") + if entry._pull then + local threshold = entry._minimum and (entry._minimum .. "-" .. entry._sum) or entry._sum + GameTooltip:AddDoubleLine("|cffffffffYou pull aggro at", "|cffff4040" .. threshold) + if entry._minimum then + GameTooltip:AddLine("|cffaaaaaaYour range is unknown: 110% melee, 130% ranged.") + GameTooltip:AddLine("|cffaaaaaaThe bar marks the upper end of this range.") + end + GameTooltip:AddDoubleLine("|cffaaaaaaOf pull threshold", "|cffaaaaaa" .. window.FormatThreatPercent(entry._perc) .. "%") + else + GameTooltip:AddDoubleLine("|cffffffffThreat", "|cffffffff" .. entry._sum) + GameTooltip:AddDoubleLine("|cffffffffOf pull threshold", "|cffffffff" .. window.FormatThreatPercent(entry._perc) .. "%") + GameTooltip:AddDoubleLine("|cffffffffThreat Per Second", "|cffffffff" .. window.FormatRate(entry._tps)) + if entry._tank then GameTooltip:AddLine("|cffffd100Has aggro (server displays 100%)") end + GameTooltip:AddLine(entry._melee and "|cffaaaaaaMelee range: pulls at 110%" or "|cffaaaaaaRanged: pulls at 130%") + end + GameTooltip:Show() + return + end + + SetFightTime(config[wid].segment, config[wid]) + local time = ctime(segment[this.unit]) + local value = segment[this.unit]["_sum"] + local persec = round(value / time, 0) + + if this.glow then this.glow:Show() end + + GameTooltip:AddLine(this.title .. ":") + + if config[wid].view == 1 then + GameTooltip:AddDoubleLine("|cffffffffDamage", "|cffffffff" .. value) + GameTooltip:AddDoubleLine("|cffffffffDamage Per Second", "|cffffffff" .. persec) + elseif config[wid].view == 3 then + local evalue = segment[this.unit]["_esum"] + local epersec = round(evalue / time, 0) + + GameTooltip:AddDoubleLine("|cffffffffHealing", "|cffffffff" .. evalue) + GameTooltip:AddDoubleLine("|cffaaaaaaOverheal", "|cffcc8888+" .. value - evalue) + GameTooltip:AddLine(" ") + GameTooltip:AddDoubleLine("|cffffffffHealing Per Second", "|cffffffff" .. epersec) + GameTooltip:AddDoubleLine("|cffaaaaaaOverheal Per Second", "|cffcc8888+" .. persec - epersec) + end + + -- active time vs. fight duration + local active = segment[this.unit]["_ctime"] or 1 + if config.time_mode == 1 then + GameTooltip:AddDoubleLine("|cffaaaaaaFight Duration", "|cffaaaaaa" .. SecondsToTime(math.floor(time))) + GameTooltip:AddDoubleLine("|cffaaaaaaActive Time", "|cffaaaaaa" .. SecondsToTime(math.floor(active)) .. + string.format(" (%d%%)", math.min(100, active / time * 100))) + else + GameTooltip:AddDoubleLine("|cffaaaaaaActive Time", "|cffaaaaaa" .. SecondsToTime(math.floor(active))) + end + + GameTooltip:AddLine(" ") + GameTooltip:AddLine("Details:") + + for attack, damage in spairs(segment[this.unit], sort_algorithms.single_spell) do + if attack and not internals[attack] then + local percent = damage == 0 and 0 or round(damage / segment[this.unit]["_sum"] * 100,1) + if segment[this.unit]["_effective"] and segment[this.unit]["_effective"][attack] then + -- heal / effective heal + local effective = segment[this.unit]["_effective"][attack] + local epercent = effective == 0 and 0 or round(effective / segment[this.unit]["_esum"] * 100,1) + + local str = string.format("|cffcc8888+%s|cffffffff %s (%.1f%%)", damage - effective, effective, epercent) + GameTooltip:AddDoubleLine("|cffffffff" .. attack, str) + else + -- damage + local str = string.format("|cffffffff %s (%.1f%%)", damage, percent) + GameTooltip:AddDoubleLine("|cffffffff" .. attack, str) + end + end + end + GameTooltip:Show() +end + +local function barTooltipHide() + if this.glow then this.glow:Hide() end + GameTooltip:Hide() +end + +local function barScrollWheel() + this.scroll = arg1 > 0 and this.scroll - 1 or this.scroll + this.scroll = arg1 < 0 and this.scroll + 1 or this.scroll + + local count = 0 + for k,v in pairs(this.segment) do + count = count + 1 + end + + this.scroll = math.min(this.scroll, count + 1 - config[this:GetID()].bars) + this.scroll = math.max(this.scroll, 0) + + this:Refresh() +end + +local function ResetData() + data.dispels_done[0], data.dispels_done[1] = {}, {} + data.dispels_received[0], data.dispels_received[1] = {}, {} + -- clear overall damage data + for k, v in pairs(data.damage[0]) do + data.damage[0][k] = nil + end + + -- clear current damage data + for k, v in pairs(data.damage[1]) do + data.damage[1][k] = nil + end + + -- clear overall heal data + for k, v in pairs(data.heal[0]) do + data.heal[0][k] = nil + end + + -- clear current heal data + for k, v in pairs(data.heal[1]) do + data.heal[1][k] = nil + end + + for i=1,10 do + if window[i] then window[i].scroll = 0 end + end + + -- reset the fight timers too + parser:ResetFight(true) + if ShaguDPS.fights then ShaguDPS.fights:Reset() end + + -- reload all windows + window.Refresh() +end + +local function CreateBar(parent, i, background) + local prefix = "ShaguDPS" .. (parent:GetID() == 1 and "" or parent:GetID()) + parent.bars[i] = parent.bars[i] or CreateFrame("StatusBar", prefix .. "Bar" .. i, parent) + parent.bars[i].parent = parent + + parent.bars[i]:SetStatusBarTexture(textures[config.texture] or textures[1]) + parent.bars[i]:SetPoint("TOPLEFT", parent, "TOPLEFT", 2, -config.height * (i-1) - 22) + parent.bars[i]:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -2, -config.height * (i-1) - 22) + parent.bars[i]:SetHeight(config.height - config.spacing) + parent.bars[i]:SetFrameLevel(4) + + parent.bars[i].lowerBar = parent.bars[i].lowerBar or CreateFrame("StatusBar", prefix .. "LowerBar" .. i, parent) + parent.bars[i].lowerBar:SetStatusBarTexture(textures[config.texture] or textures[1]) + parent.bars[i].lowerBar:SetPoint("TOPLEFT", parent, "TOPLEFT", 2, -config.height * (i-1) - 22) + parent.bars[i].lowerBar:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -2, -config.height * (i-1) - 22) + parent.bars[i].lowerBar:SetStatusBarColor(1, 1, 1, .4) + parent.bars[i].lowerBar:SetHeight(config.height - config.spacing) + parent.bars[i].lowerBar:SetFrameLevel(2) + + parent.bars[i].textLeft = parent.bars[i].textLeft or parent.bars[i]:CreateFontString("Status", "OVERLAY", "GameFontNormal") + parent.bars[i].textLeft:SetFont(STANDARD_TEXT_FONT, 10, "THINOUTLINE") + parent.bars[i].textLeft:SetJustifyH("LEFT") + parent.bars[i].textLeft:SetFontObject(GameFontWhite) + parent.bars[i].textLeft:SetParent(parent.bars[i]) + parent.bars[i].textLeft:ClearAllPoints() + parent.bars[i].textLeft:SetPoint("TOPLEFT", parent.bars[i], "TOPLEFT", 5, 1) + parent.bars[i].textLeft:SetPoint("BOTTOMRIGHT", parent.bars[i], "BOTTOMRIGHT", -5, 0) + + parent.bars[i].textRight = parent.bars[i].textRight or parent.bars[i]:CreateFontString("Status", "OVERLAY", "GameFontNormal") + parent.bars[i].textRight:SetFont(STANDARD_TEXT_FONT, 10, "THINOUTLINE") + parent.bars[i].textRight:SetJustifyH("RIGHT") + parent.bars[i].textRight:SetFontObject(GameFontWhite) + parent.bars[i].textRight:SetParent(parent.bars[i]) + parent.bars[i].textRight:ClearAllPoints() + parent.bars[i].textRight:SetPoint("TOPLEFT", parent.bars[i], "TOPLEFT", 5, 1) + parent.bars[i].textRight:SetPoint("BOTTOMRIGHT", parent.bars[i], "BOTTOMRIGHT", -5, 0) + + for _, field in pairs({"textRate", "textPercent"}) do + local bar = parent.bars[i] + bar[field] = bar[field] or bar:CreateFontString(nil, "OVERLAY", "GameFontNormal") + bar[field]:SetFont(STANDARD_TEXT_FONT, 10, "THINOUTLINE") + bar[field]:SetFontObject(GameFontWhite) + bar[field]:SetJustifyH("RIGHT") + if ShaguDPS.classic then + skin.Font(bar[field], math.max(8, math.min(13, config.height - 4))) + bar[field]:SetTextColor(1, 1, 1) + end + end + + -- classic: shadowed text, a dark track behind the bar and a hover glow + local bar = parent.bars[i] + if ShaguDPS.classic then + local size = math.max(8, math.min(13, config.height - 4)) + skin.Font(bar.textLeft, size) + skin.Font(bar.textRight, size) + bar.textLeft:SetTextColor(1, 1, 1) + bar.textRight:SetTextColor(1, 1, 1) + + -- the track sits below the overheal bar, and follows the bar's visibility + if not bar.track then + bar.track = CreateFrame("Frame", nil, parent) + bar.track:SetFrameLevel(1) + bar.track.tex = bar.track:CreateTexture(nil, "BACKGROUND") + bar.track.tex:SetAllPoints(bar.track) + bar:SetScript("OnShow", function() this.track:Show() end) + bar:SetScript("OnHide", function() this.track:Hide() end) + end + bar.track:SetAllPoints(bar) + bar.track.tex:SetTexture(textures[config.texture] or textures[1]) + bar.track.tex:SetVertexColor(0.2, 0.2, 0.2, 0.35) + if not bar:IsShown() then bar.track:Hide() end + + bar.glow = bar.glow or skin.Glow(bar, "OVERLAY", 0.35) + bar.glow:Hide() + end + + parent.bars[i]:EnableMouse(true) + parent.bars[i]:SetScript("OnEnter", barTooltipShow) + parent.bars[i]:SetScript("OnLeave", barTooltipHide) + + return parent.bars[i] +end + +local function btnEnter() + if this.tooltip then + GameTooltip_SetDefaultAnchor(GameTooltip, this) + for i, data in pairs(this.tooltip) do + if type(data) == "string" then + GameTooltip:AddLine(data) + elseif type(data) == "table" then + GameTooltip:AddDoubleLine(data[1], data[2]) + end + end + GameTooltip:Show() + end + + if this.hoverOn then return this:hoverOn() end + this:SetBackdropBorderColor(1,.8,0,1) +end + +local function btnLeave() + if this.tooltip then + GameTooltip:Hide() + end + + if this.hoverOff then return this:hoverOff() end + this:SetBackdropBorderColor(.4,.4,.4,1) +end + +local function announce(text) + local type = tbc and ChatFrameEditBox:GetAttribute("chatType") or ChatFrameEditBox.chatType + local language = tbc and ChatFrameEditBox:GetAttribute("language") or ChatFrameEditBox.language + local channel = tbc and ChatFrameEditBox:GetAttribute("channelTarget") or ChatFrameEditBox.channelTarget + local target = tbc and ChatFrameEditBox:GetAttribute("tellTarget") or ChatFrameEditBox.tellTarget + + if type == "WHISPER" then + SendChatMessage(text, type, language, target) + elseif type == "CHANNEL" then + SendChatMessage(text, type, language, channel); + else + SendChatMessage(text, type, language); + end +end + +local function GetCaps(view, values) + local values = values or {} + + -- reset/empty values + values.best = 0 + values.all = 0 + values.persecond_best = 0 + values.persecond_all = 0 + values.effective_best = 0 + values.effective_all = 0 + values.effective_persecond_best = 0 + values.effective_persecond_all = 0 + + for name, data in pairs(view) do + local val = 0 + + -- calculate normal values + if data["_sum"] and data["_ctime"] then + local time = ctime(data) + values.all = values.all + data["_sum"] + if data["_sum"] > values.best then + values.best = data["_sum"] + end + + values.persecond_all = values.persecond_all + data["_sum"] / time + if data["_sum"] / time > values.persecond_best then + values.persecond_best = data["_sum"] / time + end + end + + -- calculate effective values + if data["_esum"] and data["_ctime"] then + local time = ctime(data) + values.effective_all = values.effective_all + data["_esum"] + if data["_esum"] > values.effective_best then + values.effective_best = data["_esum"] + end + + values.effective_persecond_all = values.effective_persecond_all + data["_esum"] / time + if data["_esum"] / time > values.effective_persecond_best then + values.effective_persecond_best = data["_esum"] / time + end + end + end + + return values +end + +local function GetData(unitdata, values) + local values = values or {} + + -- read normal values + values.value = unitdata["_sum"] + values.value_persecond = round(values.value / ctime(unitdata), 1) + values.percent = values.value == 0 and 0 or round(values.value / values.all * 100,1) + values.percent_persecond = values.value_persecond == 0 and 0 or round(values.value_persecond / values.persecond_all * 100, 1) + + -- read effective values + if unitdata["_esum"] then + values.effective_value = unitdata["_esum"] + values.effective_value_persecond = round(values.effective_value / ctime(unitdata), 1) + values.effective_percent = values.effective_value == 0 and 0 or round(values.effective_value / values.effective_all * 100, 1) + values.effective_percent_persecond = values.effective_value_persecond == 0 and 0 or round(values.effective_value_persecond / values.effective_persecond_all * 100,1) + values.uneffective_value = values.value - values.effective_value + values.uneffective_value_persecond = values.value_persecond - values.effective_value_persecond + else + values.effective_value = 0 + values.effective_value_persecond = 0 + values.effective_percent = 0 + values.effective_percent_persecond = 0 + values.uneffective_value = 0 + values.uneffective_value_persecond = 0 + end + + -- threat percent of the tank + values.threat_perc = window.FormatThreatPercent(unitdata["_perc"]) + values.threat_tps_short = window.FormatRate(unitdata["_tps"]) + values.percent_display = window.FormatThreatPercent(values.percent) + values.effective_percent_display = window.FormatThreatPercent(values.effective_percent) + + -- short numbers for the bars + values.value_short = short(values.value) + if unitdata._minimum then + values.value_short = short(unitdata._minimum) .. "-" .. values.value_short + end + values.persecond_short = window.FormatRate(values.value / ctime(unitdata)) + values.effective_short = short(values.effective_value) + values.effective_persecond_short = window.FormatRate(values.effective_value / ctime(unitdata)) + values.uneffective_short = short(values.uneffective_value) + + -- check pet and detect owner/unit names + local pet = not classes[data["classes"][values.name]] and data["classes"][values.name] ~= "__other__" + local unit = pet and data["classes"][values.name] or values.name + + -- merge pet/owner strings if option is set + if config.merge_pets == 0 then + -- "Wolf (Hunter)" becomes "Hunter - Wolf" + local petname = pet and string.gsub(values.name, "%s%(.-%)$", "") or nil + values.name = pet and unit .. " - " .. petname or unit + else + values.name = unit + end + + values.class = not unitdata._pull and data.classes[unit] or nil + + -- write color into view + -- default to faded name colors + local r, g, b = str2rgb(values.name) + values.color = values.color or {} + values.color.r = r / 4 + .4 + values.color.g = g / 4 + .4 + values.color.b = b / 4 + .4 + + -- the pull aggro line is red + if unitdata["_pull"] then + values.color.r, values.color.g, values.color.b = .9, .15, .15 + end + + -- replace color by class colors if possible + if classes[data["classes"][unit]] then + -- set color to player class colors + values.color.r = RAID_CLASS_COLORS[data["classes"][unit]].r + values.color.g = RAID_CLASS_COLORS[data["classes"][unit]].g + values.color.b = RAID_CLASS_COLORS[data["classes"][unit]].b + + if config.texture == 8 then + local color = modern_colors[data.classes[unit]] + values.color.r, values.color.g, values.color.b = color[1], color[2], color[3] + elseif config.pastel == 1 then + values.color.r = (values.color.r + .5) * .5 + values.color.g = (values.color.g + .5) * .5 + values.color.b = (values.color.b + .5) * .5 + end + end + + return values +end + +local function Refresh(self, force, report) + -- skip refreshs on legacy calls + if not self or type(self) == "boolean" then return end + + -- assign shortcuts + local values, buttons = self.values, self.buttons + local wid = self:GetID() + + -- DPS and HPS windows of older versions become Damage and Heal + local view = config[wid].view + if view_merged[view] then config[wid].view = view_merged[view] end + if not view_templates[config[wid].view] then config[wid].view = 1 end + + -- config changes + if force then + if config.visible == 1 then + self:Show() + else + self:Hide() + end + + for _, button in pairs(buttons) do + button.caption:SetTextColor(1,1,1,1) + end + + if self.titleRule then + if config.backdrop == 1 then self.titleRule:Show() else self.titleRule:Hide() end + end + + -- update backdrop borders + if config.backdrop == 1 and ShaguDPS.classic then + local c = skin.colors + self:SetBackdrop(skin.backdrop_window) + self:SetBackdropColor(c.bg[1], c.bg[2], c.bg[3], .9) + + self.border:SetBackdrop(skin.backdrop_border) + self.border:SetBackdropBorderColor(c.border[1], c.border[2], c.border[3], 1) + elseif config.backdrop == 1 then + -- window background + self:SetBackdrop(backdrop_window) + self:SetBackdropColor(.5,.5,.5,.5) + + -- window border + self.border:SetBackdrop(backdrop_border) + self.border:SetBackdropBorderColor(.7,.7,.7,1) + else + self:SetBackdrop(nil) + self.border:SetBackdrop(nil) + end + + -- update panel button appearance + if config[wid].view == 1 then + self.btnDamage.caption:SetTextColor(1,.9,0,1) + self.btnMode.caption:SetText("Damage") + elseif config[wid].view == 3 then + self.btnHeal.caption:SetTextColor(1,.9,0,1) + self.btnMode.caption:SetText("Heal") + elseif config[wid].view == 6 then + self.btnDispels.caption:SetTextColor(1,.9,0,1) + self.btnMode.caption:SetText("Dispels") + self[config[wid].dispels_received == 1 and "btnReceived" or "btnDone"].caption:SetTextColor(1,.9,0,1) + elseif config[wid].view == 5 then + self.btnThreat.caption:SetTextColor(1,.9,0,1) + self.btnMode.caption:SetText("Threat") + end + + -- a history fight that was dropped from the list falls back to current + if config[wid].segment == 2 and not (ShaguDPS.fights and ShaguDPS.fights:Get(config[wid].fight)) then + config[wid].segment = 1 + end + + if config[wid].segment == 0 then + self.btnOverall.caption:SetTextColor(1,.9,0,1) + elseif config[wid].segment == 1 then + self.btnCurrent.caption:SetTextColor(1,.9,0,1) + elseif config[wid].segment == 2 then + self.btnFights.caption:SetTextColor(1,.9,0,1) + end + self.btnSegment.caption:SetText(SegmentName(config[wid])) + + -- lock icon of the title row + if self.btnLock then + local locked = config.lock == 1 + self.btnLock.tex:SetTexture("Interface\\AddOns\\ShaguDPS\\img\\" .. (locked and "lock" or "unlock")) + self.btnLock.tooltip = { + locked and "Unlock Windows" or "Lock Windows", + locked and "|cffffffffAllow moving and resizing" or "|cffffffffPrevent moving and resizing", + } + end + + self:SetWidth(math.max(MIN_WIDTH, config[wid].width or MIN_WIDTH)) + self:SetHeight(config.height * (config[wid].bars or 8) + 22 + (config[wid].bars == 0 and 2 or 3)) + end + + -- hidden windows catch up when they are shown again + if not force and not report and not self:IsVisible() then + self.needs_refresh = true + return + end + + -- set view to damage or heal + local fight = config[wid].segment == 2 and ShaguDPS.fights and ShaguDPS.fights:ForWindow(config[wid]) + if config[wid].segment == 2 and not fight then config[wid].segment = 1 end + + if config[wid].view == 1 then + self.segment = fight and fight.damage or data.damage[(config[wid].segment or 0)] + elseif config[wid].view == 3 then + self.segment = fight and fight.heal or data.heal[(config[wid].segment or 0)] + elseif config[wid].view == 6 then + self.segment = window.GetDispelSegment(config[wid], fight) + elseif config[wid].view == 5 then + self.segment = data.threat + end + + -- threat has no segments: the segment button names the target instead + if config[wid].view == 5 then + local target = ShaguDPS.threat and ShaguDPS.threat.target + self.btnSegment.caption:SetText(target or "No Target") + -- Size to the target text and center the two buttons as one title group. + -- Leave room for the icons at both ends of the title bar. + local width = math.min(math.max(50, self.btnSegment.caption:GetStringWidth() + 12), math.max(50, self:GetWidth() - 170)) + self.btnSegment:SetWidth(width) + self.btnSegment:ClearAllPoints() + self.btnSegment:SetPoint("RIGHT", self.title, "CENTER", (width - 51) / 2, 0) + else + self.btnSegment:SetWidth(50) + self.btnSegment:ClearAllPoints() + self.btnSegment:SetPoint("RIGHT", self.title, "CENTER", TITLE_SHIFT - .5, 0) + if not force and self.lastview == 5 then + self.btnSegment.caption:SetText(SegmentName(config[wid])) + end + end + if config[wid].view == 6 then + self.btnSegment:SetWidth(90) + self.btnSegment.caption:SetText((config[wid].dispels_received == 1 and "Received: " or "Done: ") .. SegmentName(config[wid])) + elseif self.lastview == 6 then + self.btnSegment.caption:SetText(SegmentName(config[wid])) + end + self.btnMode:ClearAllPoints() + self.btnMode:SetPoint("LEFT", self.btnSegment, "RIGHT", 1, 0) + self.lastview = config[wid].view + + -- hint while the threat list is empty + if (config[wid].view == 5 or config[wid].view == 6) and not next(self.segment) then + self.hint = self.hint or self:CreateFontString(nil, "OVERLAY", "GameFontDisable") + self.hint:SetPoint("TOPLEFT", self, "TOPLEFT", 8, -26) + self.hint:SetPoint("TOPRIGHT", self, "TOPRIGHT", -8, -26) + self.hint:SetJustifyH("LEFT") + self.hint:SetFont(STANDARD_TEXT_FONT, 10) + if config[wid].view == 6 then + self.hint:SetText(window.DispelStatus(config[wid], fight, ShaguDPS.dispels.available) or "No successful dispels recorded for this segment.") + else + self.hint:SetText(ShaguDPS.threat:GetStatus()) + end + self.hint:Show() + elseif self.hint then + self.hint:Hide() + end + + -- read view settings + + local template = view_templates[config[wid].view] + local sort = sort_algorithms[template.sort] + + -- report to chat if flag is set + if report then + local name = view_templates[config[wid].view].name + if config[wid].view == 6 then name = name .. (config[wid].dispels_received == 1 and " Received" or " Done") end + local seg = SegmentName(config[wid]) + if config[wid].segment == 2 then + local f = ShaguDPS.fights:Get(config[wid].fight) + seg = seg .. (f.kill and " (kill, " or " (wipe, ") .. SecondsToTime(math.floor(f.duration)) .. ")" + end + announce("ShaguDPS - " .. seg .. " " .. name .. ":") + if config[wid].view == 6 then + local status = window.DispelStatus(config[wid], fight, ShaguDPS.dispels.available) + if status then announce(status) end + end + end + + -- load caps of the current view + SetFightTime(config[wid].segment, config[wid]) + local cache + if config[wid].view ~= 5 then + cache = window.DisplaySnapshot(self.segment, sort) + if window.PaintUnchanged(self, cache, fight_time, force, report) then return end + else + self.paintState = nil + end + -- clear previous results + for id, bar in pairs(self.bars) do + bar.lowerBar:Hide() + bar:Hide() + end + + self.values = self.GetCaps(self.segment, self.values) + -- Threat bypasses paint caching; its percentages/TPS can change independently. + local keys, count + if cache then keys, count = cache.keys, cache.count else + cache = window.DisplaySnapshot(self.segment, sort) + keys, count = cache.keys, cache.count + end + local first = self.scroll + 1 + local last = math.min(count, self.scroll + (config[wid].bars or 8)) + if report then first, last = 1, math.min(count, math.max(last, 10)) end + + local columnWidths = {1, 1, 1} + + -- only the units that get a bar (or a chat line) need their values read + for i = first, last do + local name = keys[i] + local unitdata = self.segment[name] + + -- attach name to values + self.values.name = name + + -- load data values of the current unit + self.values = self.GetData(unitdata, self.values) + + local a = template.bar_string_params + local bar = i - self.scroll + if bar >= 1 and bar <= (config[wid].bars or 8) then + self.bars[bar] = not force and self.bars[bar] or CreateBar(self, bar) + + -- attach unit and titles to bar + self.bars[bar].title = self.values.name + self.bars[bar].unit = name + + self.bars[bar]:SetMinMaxValues(0, self.values[template.bar_max]) + self.bars[bar]:SetValue(self.values[template.bar_val]) + + -- enable lower bar if template requires it + if template.bar_lower_max and template.bar_lower_val then + self.bars[bar].lowerBar:SetMinMaxValues(0, self.values[template.bar_lower_max]) + self.bars[bar].lowerBar:SetValue(self.values[template.bar_lower_val]) + self.bars[bar].lowerBar:Show() + else + self.bars[bar].lowerBar:Hide() + end + + self.bars[bar]:SetStatusBarColor(self.values.color.r, self.values.color.g, self.values.color.b, 1) + self.bars[bar].textLeft:SetText(i .. ". " .. self.values.name) + window.UpdateClassIcon(self.bars[bar], self.values.class) + + local row = self.bars[bar] + row.textRight:SetText(self.values[a[1]]) + row.textRate:SetText((unitdata._pull or config[wid].view == 6) and "" or ("(" .. self.values[a[2]] .. " " .. (config[wid].view == 1 and "DPS" or config[wid].view == 3 and "HPS" or "TPS") .. ")")) + local percentage = unitdata._pull and self.values.threat_perc or self.values[a[3]] + row.textPercent:SetText("[" .. percentage .. "%]") + for column, field in pairs({"textRight", "textRate", "textPercent"}) do + columnWidths[column] = math.max(columnWidths[column], math.ceil(row[field]:GetStringWidth()) + 1) + end + + self.bars[bar]:Show() + end + + -- report to chat if flag is set + if report and i <= 10 then + local c = template.chat_string_params or a + local chat = string.format(template.chat_string, + self.values[c[1]], self.values[c[2]], self.values[c[3]], self.values[c[4]], self.values[c[5]]) + if config[wid].view == 5 and unitdata._pull then + chat = string.format("%s [%s%%]", self.values.value_short, self.values.threat_perc) + end + + announce(i .. ". " .. self.values.name .. " " .. chat) + end + end + window.AlignValueColumns(self.bars, config[wid].bars or 8, columnWidths) +end + +local function Resize(self) + local wid = self:GetID() + local width = self:GetWidth() + local height = self:GetTop() - self:GetBottom() + local bars = (height - 22) / config.height + bars = math.floor(bars) + + config[wid].width = width + if config[wid].bars ~= bars then + config[wid].bars = bars + self:Refresh() + end +end + +local function CreateWindow(wid) + -- create default config + config[wid] = config[wid] or {} + config[wid].bars = config[wid].bars or 8 + config[wid].width = math.max(MIN_WIDTH, config[wid].width or MIN_WIDTH) + config[wid].segment = config[wid].segment or 1 + config[wid].view = config[wid].view or 1 + + local frame = CreateFrame("Frame", "ShaguDPSWindow" .. (wid == 1 and "" or wid), UIParent) + frame.scroll = 0 + frame.GetCaps = GetCaps + frame.GetData = GetData + frame.Refresh = Refresh + frame.Resize = Resize + + frame.LoadPosition = function() + if ShaguDPS_Config and ShaguDPS_Config[frame:GetID()] and ShaguDPS_Config[frame:GetID()].pos then + -- load config position if existing + frame:ClearAllPoints() + frame:SetPoint("CENTER", UIParent, "BOTTOMLEFT", unpack(ShaguDPS_Config[frame:GetID()].pos)) + else + -- use default window position + frame:ClearAllPoints() + frame:SetPoint("RIGHT", UIParent, "RIGHT", -100, -100) + end + end + + frame:SetID(wid) + frame:EnableMouse(true) + frame:EnableMouseWheel(1) + frame:SetResizable(true) + frame:SetMinResize(MIN_WIDTH, 22) + frame:RegisterForDrag("LeftButton") + frame:SetMovable(true) + frame:SetScript("OnDragStart", function() + if config.lock == 0 then + frame:StartMoving() + end + end) + + frame:SetScript("OnDragStop", function() + frame:StopMovingOrSizing() + ShaguDPS_Config[frame:GetID()].pos = { frame:GetCenter() } + end) + + frame:SetScript("OnMouseWheel", barScrollWheel) + frame:SetClampedToScreen(true) + + frame:SetScript("OnUpdate", function() + -- update config on resize + if this.sizing then + this:Resize() + end + + -- only check for updates every .2 seconds + if ( this.tick or 1) > GetTime() then return else this.tick = GetTime() + .2 end + + -- check for resize button + if config.lock == 0 and MouseIsOver(this) then + this.btnResize:SetAlpha(.5) + else + this.btnResize:SetAlpha(0) + end + + -- refresh window if needed + if this.needs_refresh then + this.needs_refresh = nil + this:Refresh() + end + end) + + frame:RegisterEvent("PLAYER_LOGIN") + frame:SetScript("OnEvent", frame.LoadPosition) + frame.LoadPosition() + + frame.title = frame:CreateTexture(nil, "NORMAL") + frame.title:SetTexture(0,0,0,.6) + frame.title:SetHeight(20) + frame.title:SetPoint("TOPLEFT", 2, -2) + frame.title:SetPoint("TOPRIGHT", -2, -2) + + frame.btnSegment = CreateFrame("Button", "ShaguDPSDamage", frame) + frame.btnSegment:SetPoint("RIGHT", frame.title, "CENTER", TITLE_SHIFT - .5, 0) + frame.btnSegment:SetFrameStrata("MEDIUM") + frame.btnSegment:SetHeight(16) + frame.btnSegment:SetWidth(50) + frame.btnSegment:SetBackdrop(backdrop) + frame.btnSegment:SetBackdropColor(.2,.2,.2,1) + frame.btnSegment:SetBackdropBorderColor(.4,.4,.4,1) + + frame.btnSegment.caption = frame.btnSegment:CreateFontString("ShaguDPSTitle", "OVERLAY", "GameFontWhite") + frame.btnSegment.caption:SetFont(STANDARD_TEXT_FONT, 9) + frame.btnSegment.caption:SetText("Overall") + frame.btnSegment.caption:SetAllPoints() + frame.btnSegment.tooltip = { "Select Segment", "|cffffffffOverall, Current" } + frame.btnSegment:SetScript("OnEnter", btnEnter) + frame.btnSegment:SetScript("OnLeave", btnLeave) + frame.btnSegment:SetScript("OnClick", function() + frame.btnDamage:Hide() + frame.btnHeal:Hide() + frame.btnThreat:Hide() + frame.btnDispels:Hide() + frame.btnDone:Hide() + frame.btnReceived:Hide() + + -- the threat view always shows the current target + if frame.btnCurrent:IsShown() or config[frame:GetID()].view == 5 then + frame.btnOverall:Hide() + frame.btnFights:Hide() + frame.btnCurrent:Hide() + else + frame.btnOverall:Show() + frame.btnFights:Show() + frame.btnCurrent:Show() + end + end) + + frame.btnMode = CreateFrame("Button", "ShaguDPSDamage", frame) + frame.btnMode:SetPoint("LEFT", frame.title, "CENTER", TITLE_SHIFT + .5, 0) + frame.btnMode:SetFrameStrata("MEDIUM") + frame.btnMode:SetHeight(16) + frame.btnMode:SetWidth(50) + frame.btnMode:SetBackdrop(backdrop) + frame.btnMode:SetBackdropColor(.2,.2,.2,1) + frame.btnMode:SetBackdropBorderColor(.4,.4,.4,1) + + frame.btnMode.caption = frame.btnMode:CreateFontString("ShaguDPSTitle", "OVERLAY", "GameFontWhite") + frame.btnMode.caption:SetFont(STANDARD_TEXT_FONT, 9) + frame.btnMode.caption:SetText("Mode: Damage") + frame.btnMode.caption:SetAllPoints() + frame.btnMode.tooltip = { "Select Mode", "|cffffffffDamage, Heal, Threat, Dispels" } + frame.btnMode:SetScript("OnEnter", btnEnter) + frame.btnMode:SetScript("OnLeave", btnLeave) + frame.btnMode:SetScript("OnClick", function() + if frame.btnDamage:IsShown() then + frame.btnDamage:Hide() + frame.btnHeal:Hide() + frame.btnThreat:Hide() + frame.btnDispels:Hide() + frame.btnDone:Hide() + frame.btnReceived:Hide() + frame.btnOverall:Hide() + frame.btnFights:Hide() + frame.btnCurrent:Hide() + else + frame.btnDamage:Show() + frame.btnHeal:Show() + frame.btnThreat:Show() + frame.btnDispels:Show() + if config[frame:GetID()].view == 6 then + frame.btnDone:Show() + frame.btnReceived:Show() + end + if frame.menuView then + frame.menuView:SetPoint("BOTTOMRIGHT", config[frame:GetID()].view == 6 and frame.btnReceived or frame.btnDispels, "BOTTOMRIGHT", 6, -6) + end + frame.btnOverall:Hide() + frame.btnFights:Hide() + frame.btnCurrent:Hide() + end + end) + + for name, template in pairs(menubuttons) do + frame["btn"..name] = CreateFrame("Button", "ShaguDPS" .. name, frame) + + local button = frame["btn"..name] + local template = template + local name = name + + if template[6] == "view" or template[6] == "dispels_received" then + button:SetPoint("CENTER", frame.btnMode, "CENTER", 0, -17-template[1]*14) + else + button:SetPoint("CENTER", frame.title, "CENTER", template[3] + TITLE_SHIFT, -17-template[1]*14) + end + button:SetFrameStrata("HIGH") + button:SetHeight(16) + button:SetWidth(50) + button:SetBackdrop(backdrop) + button:SetBackdropColor(.2,.2,.2,1) + button:SetBackdropBorderColor(.4,.4,.4,1) + button:Hide() + + button.caption = button:CreateFontString("ShaguDPS"..name.."Title", "OVERLAY", "GameFontWhite") + button.caption:SetFont(STANDARD_TEXT_FONT, 9) + button.caption:SetText(name) + button.caption:SetAllPoints() + button.tooltip = { template[4], template[5] } + button:SetScript("OnEnter", btnEnter) + button:SetScript("OnLeave", btnLeave) + button:SetScript("OnClick", function() + if name == "Fights" then return frame:ToggleFightList() end + + config[frame:GetID()][template[6]] = template[2] + if template[6] == "dispels_received" then config[frame:GetID()].view = 6 end + + frame.scroll = 0 + frame:Refresh(true) + + for button in pairs(menubuttons) do + frame["btn"..button]:Hide() + end + end) + end + + frame.btnAnnounce = CreateFrame("Button", "ShaguDPSReset", frame) + frame.btnAnnounce:SetPoint("LEFT", frame.title, "LEFT", 2, 0) + frame.btnAnnounce:SetFrameStrata("MEDIUM") + frame.btnAnnounce:SetHeight(16) + frame.btnAnnounce:SetWidth(16) + frame.btnAnnounce:SetBackdrop(backdrop) + frame.btnAnnounce:SetBackdropColor(.2,.2,.2,1) + frame.btnAnnounce:SetBackdropBorderColor(.4,.4,.4,1) + frame.btnAnnounce.tooltip = { + "Send to Chat", + { "|cffffffffClick", "|cffaaaaaaAsk to anounce all data."}, + { "|cffffffffShift-Click", "|cffaaaaaaAnnounce all data."}, + } + + frame.btnAnnounce.tex = frame.btnAnnounce:CreateTexture() + frame.btnAnnounce.tex:SetWidth(10) + frame.btnAnnounce.tex:SetHeight(10) + frame.btnAnnounce.tex:SetPoint("CENTER", 0, 0) + frame.btnAnnounce.tex:SetTexture("Interface\\AddOns\\ShaguDPS" .. (tbc and "-tbc" or "") .. "\\img\\announce") + frame.btnAnnounce:SetScript("OnEnter", btnEnter) + frame.btnAnnounce:SetScript("OnLeave", btnLeave) + frame.btnAnnounce:SetScript("OnClick", function() + if IsShiftKeyDown() then + -- reload / anounce + frame:Refresh(nil, true) + else + local ctype = tbc and ChatFrameEditBox:GetAttribute("chatType") or ChatFrameEditBox.chatType + local color = chatcolors[ctype] + if not color then color = "|cff00FAF6" end + + local name = view_templates[config[frame:GetID()].view].name + local text = "Post |cffffdd00" .. name .. "|r data into /" .. color..string.lower(ctype) .. "|r?" + + local dialog = StaticPopupDialogs["SHAGUMETER_QUESTION"] + dialog.text = text + + dialog.OnAccept = function() frame:Refresh(nil, true) end + StaticPopup_Show("SHAGUMETER_QUESTION") + end + end) + + frame.btnSettings = CreateFrame("Button", "ShaguDPSReset", frame) + frame.btnSettings:SetPoint("LEFT", frame.btnAnnounce, "RIGHT", 1, 0) + frame.btnSettings:SetFrameStrata("MEDIUM") + frame.btnSettings:SetHeight(16) + frame.btnSettings:SetWidth(16) + frame.btnSettings:SetBackdrop(backdrop) + frame.btnSettings:SetBackdropColor(.2,.2,.2,1) + frame.btnSettings:SetBackdropBorderColor(.4,.4,.4,1) + frame.btnSettings.tooltip = { + "Settings", + "|cffffffffShow Configuration Window" + } + + frame.btnSettings.tex = frame.btnSettings:CreateTexture() + frame.btnSettings.tex:SetWidth(10) + frame.btnSettings.tex:SetHeight(10) + frame.btnSettings.tex:SetPoint("CENTER", 0, 0) + frame.btnSettings.tex:SetTexture("Interface\\AddOns\\ShaguDPS" .. (tbc and "-tbc" or "") .. "\\img\\settings") + frame.btnSettings:SetScript("OnEnter", btnEnter) + frame.btnSettings:SetScript("OnLeave", btnLeave) + frame.btnSettings:SetScript("OnClick", function() + if ShaguDPS.settings:IsShown() then + ShaguDPS.settings:Hide() + else + ShaguDPS.settings:Show() + end + end) + + frame.btnReset = CreateFrame("Button", "ShaguDPSReset", frame) + frame.btnReset:SetPoint("RIGHT", frame.title, "RIGHT", -2, 0) + frame.btnReset:SetFrameStrata("MEDIUM") + frame.btnReset:SetHeight(16) + frame.btnReset:SetWidth(16) + frame.btnReset:SetBackdrop(backdrop) + frame.btnReset:SetBackdropColor(.2,.2,.2,1) + frame.btnReset:SetBackdropBorderColor(.4,.4,.4,1) + frame.btnReset.tooltip = { + "Reset Data", + { "|cffffffffClick", "|cffaaaaaaAsk to reset all data."}, + { "|cffffffffShift-Click", "|cffaaaaaaReset all data."}, + } + + frame.btnReset.tex = frame.btnReset:CreateTexture() + frame.btnReset.tex:SetWidth(10) + frame.btnReset.tex:SetHeight(10) + frame.btnReset.tex:SetPoint("CENTER", 0, 0) + frame.btnReset.tex:SetTexture("Interface\\AddOns\\ShaguDPS" .. (tbc and "-tbc" or "") .. "\\img\\reset") + frame.btnReset:SetScript("OnEnter", btnEnter) + frame.btnReset:SetScript("OnLeave", btnLeave) + frame.btnReset:SetScript("OnClick", function() + if IsShiftKeyDown() then + ResetData() + else + local dialog = StaticPopupDialogs["SHAGUMETER_QUESTION"] + dialog.text = "Do you wish to reset the data?" + dialog.OnAccept = ResetData + StaticPopup_Show("SHAGUMETER_QUESTION") + end + end) + + frame.btnWindow = CreateFrame("Button", "ShaguDPSReset", frame) + frame.btnWindow:SetPoint("RIGHT", frame.btnReset, "LEFT", -1, 0) + frame.btnWindow:SetFrameStrata("MEDIUM") + frame.btnWindow:SetHeight(16) + frame.btnWindow:SetWidth(16) + frame.btnWindow:SetBackdrop(backdrop) + frame.btnWindow:SetBackdropColor(.2,.2,.2,1) + frame.btnWindow:SetBackdropBorderColor(.4,.4,.4,1) + + frame.btnWindow.tex = frame.btnWindow:CreateTexture() + frame.btnWindow.tex:SetWidth(10) + frame.btnWindow.tex:SetHeight(10) + frame.btnWindow.tex:SetPoint("CENTER", 0, 0) + + if frame:GetID() == 1 then + frame.btnWindow.tex:SetTexture("Interface\\AddOns\\ShaguDPS" .. (tbc and "-tbc" or "") .. "\\img\\plus") + frame.btnWindow.tooltip = { + "New Window", + "|cffffffffCreate a new window" + } + + frame.btnWindow:SetScript("OnClick", function() + for i=1,10 do + if not ShaguDPS.window[i] then + -- new windows start with the size and view of the first one + config[i] = { + width = config[1].width, bars = config[1].bars, + view = config[1].view, segment = config[1].segment, + dispels_received = config[1].dispels_received, + } + + ShaguDPS.window[i] = CreateWindow(i) + ShaguDPS.window.Refresh(true) + return + end + end + end) + else + frame.btnWindow.tex:SetTexture("Interface\\AddOns\\ShaguDPS" .. (tbc and "-tbc" or "") .. "\\img\\minus") + frame.btnWindow.tooltip = { + "Remove Window", + "|cffffffffDelete this window" + } + + frame.btnWindow:SetScript("OnClick", function() + local wid = frame:GetID() + window[wid]:Hide() + window[wid] = nil + config[wid] = nil + window.Refresh(true) + end) + end + + frame.btnWindow:SetScript("OnEnter", btnEnter) + frame.btnWindow:SetScript("OnLeave", btnLeave) + + -- lock / unlock all windows + frame.btnLock = CreateFrame("Button", nil, frame) + frame.btnLock:SetPoint("RIGHT", frame.btnWindow, "LEFT", -1, 0) + frame.btnLock:SetFrameStrata("MEDIUM") + frame.btnLock:SetHeight(16) + frame.btnLock:SetWidth(16) + frame.btnLock:SetBackdrop(backdrop) + frame.btnLock:SetBackdropColor(.2,.2,.2,1) + frame.btnLock:SetBackdropBorderColor(.4,.4,.4,1) + + frame.btnLock.tex = frame.btnLock:CreateTexture() + frame.btnLock.tex:SetWidth(10) + frame.btnLock.tex:SetHeight(10) + frame.btnLock.tex:SetPoint("CENTER", 0, 0) + frame.btnLock.tex:SetTexture("Interface\\AddOns\\ShaguDPS\\img\\unlock") + frame.btnLock:SetScript("OnEnter", btnEnter) + frame.btnLock:SetScript("OnLeave", btnLeave) + frame.btnLock:SetScript("OnClick", function() + config.lock = config.lock == 1 and 0 or 1 + window.Refresh(true) + + local check = ShaguDPS.settings.checks and ShaguDPS.settings.checks.lock + if check then check:SetChecked(config.lock == 1) end + + -- show the new state right away while the mouse is still on it + GameTooltip:Hide() + btnEnter() + end) + + -- list of past boss fights, opened from the "Fights" entry + frame.fightList = CreateFrame("Frame", nil, frame) + frame.fightList:SetFrameStrata("HIGH") + frame.fightList:SetPoint("TOPLEFT", frame.btnFights, "TOPRIGHT", 4, 2) + frame.fightList:SetWidth(150) + frame.fightList:Hide() + frame.fightList.entries = {} + frame.btnFights:SetScript("OnHide", function() frame.fightList:Hide() end) + + frame.ToggleFightList = function(self) + local list = self.fightList + if list:IsShown() then return list:Hide() end + + local fights = ShaguDPS.fights and ShaguDPS.fights.list or {} + local count = math.max(1, table.getn(fights)) + + for i = 1, math.max(count, table.getn(list.entries)) do + local entry = list.entries[i] + if not entry and i <= count then + entry = CreateFrame("Button", nil, list) + entry:SetHeight(14) + entry:SetPoint("TOPLEFT", list, "TOPLEFT", 6, -6 - (i - 1) * 14) + entry:SetPoint("TOPRIGHT", list, "TOPRIGHT", -6, -6 - (i - 1) * 14) + entry.caption = entry:CreateFontString(nil, "OVERLAY", "GameFontWhite") + entry.caption:SetFont(STANDARD_TEXT_FONT, 9) + entry.caption:SetPoint("LEFT", entry, "LEFT", 2, 0) + entry.caption:SetPoint("RIGHT", entry, "RIGHT", -34, 0) + entry.caption:SetJustifyH("LEFT") + entry.time = entry:CreateFontString(nil, "OVERLAY", "GameFontWhite") + entry.time:SetFont(STANDARD_TEXT_FONT, 9) + entry.time:SetPoint("RIGHT", entry, "RIGHT", -2, 0) + entry.time:SetJustifyH("RIGHT") + if ShaguDPS.classic then + skin.Font(entry.caption, 9) + skin.Font(entry.time, 9) + skin.MenuEntry(entry) + end + entry:SetScript("OnEnter", btnEnter) + entry:SetScript("OnLeave", btnLeave) + entry:SetScript("OnClick", function() + if not this.id then return end + config[self:GetID()].segment = 2 + config[self:GetID()].fight = this.id + self.scroll = 0 + for b in pairs(menubuttons) do self["btn" .. b]:Hide() end + self:Refresh(true) + end) + list.entries[i] = entry + end + + if entry then + local fight = fights[i] + if i > count then + entry:Hide() + elseif not fight then + entry.id = nil + entry.tooltip = nil + entry.caption:SetText("|cff888888No boss fights yet") + entry.time:SetText("") + entry:Show() + else + local selected = config[self:GetID()].segment == 2 and config[self:GetID()].fight == fight.id + local color = selected and "|cffffd100" or (fight.kill and "|cffffffff" or "|cffaaaaaa") + entry.id = fight.id + entry.caption:SetText(color .. fight.name .. (fight.kill and "" or " |cff888888(wipe)")) + entry.time:SetText("|cffaaaaaa" .. string.format("%d:%02d", math.floor(fight.duration / 60), math.mod(math.floor(fight.duration), 60))) + entry.tooltip = { fight.name, { "|cffffffff" .. (fight.kill and "Kill" or "Wipe"), "|cffaaaaaaended " .. (fight.ended or "") } } + entry:Show() + end + end + end + + list:SetHeight(count * 14 + 12) + if ShaguDPS.classic then + skin.Panel(list) + else + list:SetBackdrop(backdrop) + list:SetBackdropColor(.2,.2,.2,1) + list:SetBackdropBorderColor(.4,.4,.4,1) + end + list:Show() + end + + frame.btnResize = CreateFrame("Frame", nil, frame) + frame.btnResize:SetPoint("BOTTOMRIGHT", -3, 3) + frame.btnResize:SetWidth(12) + frame.btnResize:SetHeight(12) + frame.btnResize:EnableMouse(1) + frame.btnResize.tex = frame.btnResize:CreateTexture(nil, "BACKGROUND") + frame.btnResize.tex:SetAllPoints() + frame.btnResize.tex:SetTexture("Interface\\AddOns\\ShaguDPS" .. (tbc and "-tbc" or "") .. "\\img\\resize") + frame.btnResize:SetFrameLevel(50) + frame.btnResize:SetScript("OnMouseDown", function() + if not this:GetParent().sizing and config.lock == 0 then + this:GetParent().sizing = true + this:GetParent():StartSizing() + end + end) + + frame.btnResize:SetScript("OnMouseUp", function() + this:GetParent().sizing = nil + this:GetParent():StopMovingOrSizing() + this:GetParent():Refresh(true) + end) + + frame.border = CreateFrame("Frame", "ShaguDPSBorder", frame) + frame.border:ClearAllPoints() + frame.border:SetPoint("TOPLEFT", frame, "TOPLEFT", -1,1) + frame.border:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 1,-1) + frame.border:SetFrameLevel(100) + + frame.bars = {} + frame.values = {} + frame.buttons = { + frame.btnDamage, + frame.btnHeal, + frame.btnThreat, + frame.btnDispels, + frame.btnDone, + frame.btnReceived, + frame.btnOverall, + frame.btnFights, + frame.btnCurrent + } + + table.insert(parser.callbacks.refresh, function() + frame.needs_refresh = true + end) + + if ShaguDPS.classic then window.ApplyClassic(frame) end + + return frame +end + +-- classic theme: no boxes around the title buttons, gold text and icons, +-- a silver rule under the title and the dropdown lists as tooltip panels +window.ApplyClassic = function(frame) + if frame.classic then return end + frame.classic = true + + frame.title:SetTexture(0, 0, 0, 0) + + frame.titleRule = skin.Rule(frame) + frame.titleRule:SetPoint("TOPLEFT", frame, "TOPLEFT", 6, -21) + frame.titleRule:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -6, -21) + frame.titleRule:SetFrameLevel(frame:GetFrameLevel() + 3) + + skin.IconButton(frame.btnAnnounce) + skin.IconButton(frame.btnSettings) + skin.IconButton(frame.btnReset) + skin.IconButton(frame.btnWindow) + skin.IconButton(frame.btnLock) + skin.TextButton(frame.btnSegment, 10) + skin.TextButton(frame.btnMode, 10) + + -- one tooltip panel behind each dropdown list + local function MenuPanel(first, last) + local panel = CreateFrame("Frame", nil, frame) + panel:SetFrameStrata("HIGH") + panel:SetFrameLevel(first:GetFrameLevel() > 1 and first:GetFrameLevel() - 1 or 1) + panel:SetPoint("TOPLEFT", first, "TOPLEFT", -6, 6) + panel:SetPoint("BOTTOMRIGHT", last, "BOTTOMRIGHT", 6, -6) + skin.Panel(panel) + panel:Hide() + + first:SetScript("OnShow", function() panel:Show() end) + first:SetScript("OnHide", function() panel:Hide() end) + return panel + end + + frame.menuSegment = MenuPanel(frame.btnCurrent, frame.btnOverall) + frame.menuView = MenuPanel(frame.btnDamage, frame.btnDispels) + + for name in pairs(menubuttons) do + local button = frame["btn" .. name] + button:SetFrameLevel(frame.menuView:GetFrameLevel() + 2) + skin.MenuEntry(button) + end +end + +-- the theme is known once the saved variables are loaded +window.ApplyTheme = function() + if not ShaguDPS.classic then return end + for i=1,10 do + if window[i] then window.ApplyClassic(window[i]) end + end +end + +-- make sure to always create first window +window[1] = window[1] or CreateWindow(1) + +-- update all available windows +window.Refresh = function(force, report) + for i=1,10 do + if config[i] then + window[i] = window[i] or CreateWindow(i) + window[i]:Refresh(force, report) + end + end +end + +window.Refresh(true)