diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 544aa8b..121c724 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -74,45 +74,71 @@ CritTrackerDB = { --- +--- + ## 4. Logik- & Entscheidungsfluss (Core Engine) -### 4.1. Combat Log Filterung (`COMBAT_LOG_EVENT_UNFILTERED`) +### 4.1. Warum `UNIT_COMBAT` statt `COMBAT_LOG_EVENT_UNFILTERED`? (Root Cause) +Im "WoW Forever"-Client (Interface 16001 / 12.x Engine) ist die sogenannte Addon-Apokalypse aktiv: +- `COMBAT_LOG_EVENT_UNFILTERED` wurde von Blizzard für Addons **vollständig deaktiviert** und feuert niemals. +- Kritische Treffer und Heilungen müssen zwingend über das Event `UNIT_COMBAT` abgefangen werden. -Die Engine verwendet `CombatLogGetCurrentEventInfo()` mit folgenden Regeln: +### 4.2. Filterregeln & Mehrfachauslösungs-Schutz +`UNIT_COMBAT` feuert mit den Argumenten `(unit, action, modifier, amount, school)`. -1. **Source-Check:** Nur Events mit `sourceGUID == UnitGUID("player")` werden verarbeitet (Spieler-Treffer). -2. **Sub-Events:** - - **Schaden:** `SWING_DAMAGE`, `SPELL_DAMAGE`, `RANGE_DAMAGE`, `SPELL_PERIODIC_DAMAGE`, `SPELL_BUILDING_DAMAGE`. - - Effektiver Schaden: `amount + absorbed` (Absorbe werden dem Treffer zugerechnet). - - Bedingung: `critical == true`. - - **Heilung:** `SPELL_HEAL`, `SPELL_PERIODIC_HEAL`, `SPELL_BUILDING_HEAL`. - - Effektive Heilung: `amount + overhealing` (Overheal wird berücksichtigt, damit große Heiler-Crits bei voller HP nicht fälschlich als "Low Crit" eingestuft werden). - - Bedingung: `critical == true`. +1. **Forever Secret Values Schutz:** + Parameter werden mit `issecretvalue(val)` geprüft. Handelt es sich um ein geschütztes Geheimnis oder `nil`, wird das Event verworfen. +2. **Krit-Filter:** + Nur Events mit `modifier == "CRITICAL"` und numerischem `amount > 0` werden akzeptiert. +3. **Schaden-Filter (Crucial Anti-Triplet):** + - Es wird ausschließlich `action == "WOUND"` und `unit == "target"` ausgewertet. + - Alle Pseudo-Einheiten wie `nameplate*`, `softenemy` etc. werden verworfen, da der Client denselben Treffer sonst 3-fach meldet. +4. **Heilung-Filter (Anti-Selfheal-Duplicate):** + - Akzeptiert `action == "HEAL"` für `unit == "target"` oder `unit == "player"`. + - Bei Selbstheilung mit sich selbst im Ziel (`UnitIsUnit("player", "target")`) wird die Auswertung auf `unit == "target"` beschränkt, um doppelte Auslösungen zu verhindern. +5. **Schadensschulen-Mapping (`SCHOOL_NAMES`):** + Da `UNIT_COMBAT` keine konkreten Zaubernamen liefert, wird das Argument `school` auf die jeweilige Schadensart gemappt: + - `0` / `1` = Physisch / Physical + - `2` = Heilig / Holy + - `4` = Feuer / Fire + - `8` = Natur / Nature + - `16` = Frost / Frost + - `32` = Schatten / Shadow + - `64` = Arkan / Arcane -### 4.2. Ablaufdiagramm der Crit-Verarbeitung +### 4.3. Ablaufdiagramm der Crit-Verarbeitung ```mermaid flowchart TD - A["Combat Log Crit Event (Player)"] --> B{"Addon aktiviert?"} + A["Event: UNIT_COMBAT(unit, action, modifier, amount, school)"] --> B{"IsPublic? modifier == 'CRITICAL'?"} B -- Nein --> End[Abbruch] - B -- Ja --> C["Hole bisherigen Rekord (Schaden/Heilung)"] + B -- Ja --> C{"action & unit Check"} - C --> D{"amount > currentRecord?"} - D -- Ja --> E["Neuer Rekord! Aktualisiere charData (maxRecord = amount)"] - D -- Nein --> F{"enableLowCrit UND currentRecord > 0 UND amount <= (currentRecord * threshold)?"} + C -- "WOUND & unit == 'target'" --> D["isDamage = true"] + C -- "HEAL & (target oder player ohne Self-Duplicate)" --> E["isHeal = true"] + C -- Andere Units/Actions --> End - F -- Ja --> G["Als Low-Crit markieren (Sound: low.mp3)"] - F -- Nein --> H["Als normaler Crit markieren (Sound: critX / healX)"] + D --> F["Löse school via SCHOOL_NAMES auf"] + E --> F - E --> I{"GetTime() - lastOutputTime < cooldown?"} - G --> I - H --> I + F --> G["Hole bisherigen Rekord (charData.maxDamage / maxHeal)"] + G --> H{"amount > currentRecord?"} - I -- Ja (im Cooldown) --> J["Ausgabe unterdrückt (Anti-Spam)"] - I -- Nein (Cooldown frei) --> K["lastOutputTime = GetTime()"] + H -- Ja --> I["Neuer Rekord! Aktualisiere charData (maxRecord = amount)"] + H -- Nein --> J{"enableLowCrit UND currentRecord > 0 UND amount <= threshold?"} - K --> L["PlaySoundFile(soundPath, soundChannel)"] - K --> M["DEFAULT_CHAT_FRAME:AddMessage(formatierte Nachricht)"] + J -- Ja --> K["Als Low-Crit markieren (Sound: low.mp3)"] + J -- Nein --> L["Als normaler Crit markieren (Sound: critX / healX)"] + + I --> M{"GetTime() - lastOutputTime < cooldown?"} + K --> M + L --> M + + M -- Ja (im Cooldown) --> N["Ausgabe unterdrückt (Anti-Spam)"] + M -- Nein (Cooldown frei) --> O["lastOutputTime = GetTime()"] + + O --> P["PlaySoundFile(soundPath, soundChannel)"] + O --> Q["DEFAULT_CHAT_FRAME:AddMessage(formatierte Nachricht)"] ``` --- diff --git a/CritTracker.lua b/CritTracker.lua index 85d4788..0d5a7ef 100644 --- a/CritTracker.lua +++ b/CritTracker.lua @@ -1,6 +1,6 @@ --[[ - CritTracker - Core Engine - World of Warcraft - Forever (12.x Beta / Interface 16001) + CritTracker - Core Engine (WoW Forever Edition) + Optimiert für Interface 16001 / UNIT_COMBAT Engine ]] local addonName, addonTable = ... @@ -12,23 +12,45 @@ _G["CritTracker"] = CritTracker -- Timing & State CritTracker.lastOutputTime = 0 --- Defaults +-- Standardeinstellungen local defaultSettings = { enabled = true, enableChat = true, enableSound = true, enableLowCrit = true, - lowThreshold = 30, -- 30% of highest recorded crit - cooldown = 5, -- 5 seconds output cooldown (1-30s) + lowThreshold = 30, -- 30% des Rekords + cooldown = 5, -- Cooldown in Sekunden damageSound = "crit1", -- crit1, crit2, crit3, crit4, none healSound = "heal1", -- heal1, heal2, heal3, heal4, none lowSound = "low", -- low, none soundChannel = "Master", -- Master, Dialog, SFX language = "auto", -- "auto", "German", "English" - announceMode = "all", -- "all" (subject to cooldown) or "records_only" + announceMode = "all", -- "all" oder "records_only" } --- Dynamic Localization Proxy +-- Schadensschulen-Übersetzung für UNIT_COMBAT +local SCHOOL_NAMES = { + [0] = { deDE = "Physisch", enUS = "Physical" }, + [1] = { deDE = "Physisch", enUS = "Physical" }, + [2] = { deDE = "Heilig", enUS = "Holy" }, + [4] = { deDE = "Feuer", enUS = "Fire" }, + [8] = { deDE = "Natur", enUS = "Nature" }, + [16] = { deDE = "Frost", enUS = "Frost" }, + [32] = { deDE = "Schatten", enUS = "Shadow" }, + [64] = { deDE = "Arkan", enUS = "Arcane" }, +} + +-- Schutz vor Forever Secret Values +local function IsPublic(val) + if type(val) == "nil" then return false end + if type(issecretvalue) == "function" then + local ok, secret = pcall(issecretvalue, val) + if ok and secret == true then return false end + end + return true +end + +-- Lokalisierungs-Proxy CritTracker.L = setmetatable({}, { __index = function(_, key) local lang = CritTracker:GetActiveLanguage() @@ -44,7 +66,6 @@ CritTracker.L = setmetatable({}, { end }) --- Language Resolution function CritTracker:GetActiveLanguage() local setting = (self.db and self.db.language) or "auto" if setting == "German" then @@ -52,25 +73,18 @@ function CritTracker:GetActiveLanguage() elseif setting == "English" then return "English" else - -- Auto: German client -> German, all others -> English local locale = GetLocale() - if locale == "deDE" then - return "German" - else - return "English" - end + return (locale == "deDE") and "German" or "English" end end --- Sound File Path function CritTracker:GetSoundFilePath(soundKey) local lang = self:GetActiveLanguage() return "Interface\\AddOns\\CritTracker\\sounds\\" .. lang .. "\\" .. soundKey .. ".mp3" end --- Play Sound function CritTracker:PlaySound(soundKey) - if not self.db.enableSound or not soundKey or soundKey == "none" then + if not self.db or not self.db.enableSound or not soundKey or soundKey == "none" then return end local path = self:GetSoundFilePath(soundKey) @@ -78,7 +92,6 @@ function CritTracker:PlaySound(soundKey) PlaySoundFile(path, channel) end --- Print Message (Visible strictly to the local player) function CritTracker:Print(msg) if not msg then return end if DEFAULT_CHAT_FRAME then @@ -88,7 +101,6 @@ function CritTracker:Print(msg) end end --- Format Numbers with commas / dots function CritTracker:FormatNumber(n) if not n then return "0" end if BreakUpLargeNumbers then @@ -99,7 +111,6 @@ function CritTracker:FormatNumber(n) return left .. (num:reverse():gsub('(%d%d%d)', '%1.'):reverse()) .. right end --- Character Data Accessor (strictly per character & realm) function CritTracker:GetCharKey() local name = UnitName("player") or "Unknown" local realm = GetRealmName() or "UnknownRealm" @@ -107,12 +118,8 @@ function CritTracker:GetCharKey() end function CritTracker:GetCharData() - if not CritTrackerDB then - CritTrackerDB = {} - end - if not CritTrackerDB.chars then - CritTrackerDB.chars = {} - end + if not CritTrackerDB then CritTrackerDB = {} end + if not CritTrackerDB.chars then CritTrackerDB.chars = {} end local key = self:GetCharKey() if not CritTrackerDB.chars[key] then CritTrackerDB.chars[key] = { @@ -126,7 +133,6 @@ function CritTracker:GetCharData() return CritTrackerDB.chars[key] end --- Reset Records for Current Character function CritTracker:ResetRecords() local charData = self:GetCharData() charData.maxDamage = 0 @@ -143,7 +149,6 @@ function CritTracker:ResetRecords() end end --- Level Up Handler function CritTracker:OnLevelUp(newLevel) local charData = self:GetCharData() charData.maxDamage = 0 @@ -159,14 +164,9 @@ function CritTracker:OnLevelUp(newLevel) end end --- Initialization function CritTracker:OnInitialize() - if not CritTrackerDB then - CritTrackerDB = {} - end - if not CritTrackerDB.settings then - CritTrackerDB.settings = {} - end + if not CritTrackerDB then CritTrackerDB = {} end + if not CritTrackerDB.settings then CritTrackerDB.settings = {} end for k, v in pairs(defaultSettings) do if CritTrackerDB.settings[k] == nil then CritTrackerDB.settings[k] = v @@ -174,11 +174,9 @@ function CritTracker:OnInitialize() end self.db = CritTrackerDB.settings - -- Initialize Character local charData = self:GetCharData() local curLevel = UnitLevel("player") if charData.level and charData.level > 0 and curLevel > charData.level then - -- Leveled up while offline / addon disabled self:OnLevelUp(curLevel) else charData.level = curLevel @@ -189,8 +187,7 @@ function CritTracker:OnInitialize() end end --- Main Crit Processing -function CritTracker:ProcessCrit(isHeal, spellName, amount, destName) +function CritTracker:ProcessCrit(isHeal, spellOrSchool, amount, destName) if not self.db or not self.db.enabled or not amount or amount <= 0 then return end @@ -203,19 +200,17 @@ function CritTracker:ProcessCrit(isHeal, spellName, amount, destName) local thresholdPercent = self.db.lowThreshold or 30 local thresholdValue = math.floor(currentRecord * (thresholdPercent / 100)) - -- Check for Low Crit if self.db.enableLowCrit and currentRecord > 0 and not isNewRecord and amount <= thresholdValue then isLowCrit = true end - -- Update Record if higher if isNewRecord then if isHeal then charData.maxHeal = amount - charData.maxHealSpell = spellName or "" + charData.maxHealSpell = spellOrSchool or "" else charData.maxDamage = amount - charData.maxDamageSpell = spellName or "" + charData.maxDamageSpell = spellOrSchool or "" end if self.UpdateOptionsDisplay then @@ -223,19 +218,16 @@ function CritTracker:ProcessCrit(isHeal, spellName, amount, destName) end end - -- Cooldown Check for Audio and Text Output local now = GetTime() local cooldown = self.db.cooldown or 5 if (now - self.lastOutputTime) < cooldown then - -- Suppressed by Cooldown (Records are already preserved) return end - -- Determine Sound and Text local soundKey = nil local chatMsg = nil local destStr = (destName and destName ~= "") and string.format(self.L["TARGET_ON"], destName) or "" - local spellStr = (spellName and spellName ~= "") and spellName or self.L["UNKNOWN"] + local spellStr = (spellOrSchool and spellOrSchool ~= "") and spellOrSchool or (isHeal and "Heilung" or "Treffer") local amountStr = self:FormatNumber(amount) local recordStr = self:FormatNumber(currentRecord) @@ -248,7 +240,6 @@ function CritTracker:ProcessCrit(isHeal, spellName, amount, destName) local formatStr = isHeal and self.L["NEW_RECORD_HEAL"] or self.L["NEW_RECORD_DAMAGE"] chatMsg = string.format(formatStr, destStr, spellStr, amountStr) else - -- Normal Crit (between threshold and current record) if self.db.announceMode == "all" then soundKey = isHeal and (self.db.healSound or "heal1") or (self.db.damageSound or "crit1") local formatStr = isHeal and self.L["NORMAL_CRIT_HEAL"] or self.L["NORMAL_CRIT_DAMAGE"] @@ -256,7 +247,6 @@ function CritTracker:ProcessCrit(isHeal, spellName, amount, destName) end end - -- If something triggered, apply cooldown and output if soundKey or chatMsg then self.lastOutputTime = now @@ -270,74 +260,56 @@ function CritTracker:ProcessCrit(isHeal, spellName, amount, destName) end end --- Combat Log Parser -function CritTracker:OnCombatLogEvent() - local timestamp, subevent, hideCaster, sourceGUID, sourceName, sourceFlags, sourceRaidFlags, - destGUID, destName, destFlags, destRaidFlags, - arg12, arg13, arg14, arg15, arg16, arg17, arg18, arg19, arg20, - arg21, arg22, arg23, arg24 = CombatLogGetCurrentEventInfo() - - if not sourceGUID or sourceGUID ~= UnitGUID("player") then +-- UNIT_COMBAT Parser +function CritTracker:OnUnitCombat(unit, action, modifier, amount, school) + if not IsPublic(unit) or not IsPublic(action) or not IsPublic(modifier) or not IsPublic(amount) then return end - if subevent == "SWING_DAMAGE" then - local amount = arg12 - local overkill = arg13 - local school = arg14 - local resisted = arg15 - local blocked = arg16 - local absorbed = arg17 - local critical = arg18 + if modifier ~= "CRITICAL" or type(amount) ~= "number" or amount <= 0 then + return + end - if critical and critical ~= 0 then - local totalDamage = (amount or 0) + (absorbed or 0) - self:ProcessCrit(false, self.L["MELEE_ATTACK"], totalDamage, destName) - end + local isDamage = false + local isHeal = false - elseif subevent == "SPELL_DAMAGE" or subevent == "RANGE_DAMAGE" - or subevent == "SPELL_PERIODIC_DAMAGE" or subevent == "SPELL_BUILDING_DAMAGE" then - local spellId = arg12 - local spellName = arg13 - local spellSchool = arg14 - local amount = arg15 - local overkill = arg16 - local school = arg17 - local resisted = arg18 - local blocked = arg19 - local absorbed = arg20 - local critical = arg21 + -- Schaden: Nur 'target' werten (verhindert Alias-Triplets durch nameplate1/softenemy) + if action == "WOUND" and unit == "target" then + isDamage = true - if critical and critical ~= 0 then - local totalDamage = (amount or 0) + (absorbed or 0) - self:ProcessCrit(false, spellName, totalDamage, destName) - end - - elseif subevent == "SPELL_HEAL" or subevent == "SPELL_PERIODIC_HEAL" or subevent == "SPELL_BUILDING_HEAL" then - local spellId = arg12 - local spellName = arg13 - local spellSchool = arg14 - local amount = arg15 - local overhealing = arg16 - local absorbed = arg17 - local critical = arg18 - - if critical and critical ~= 0 then - local totalHeal = (amount or 0) + (overhealing or 0) - self:ProcessCrit(true, spellName, totalHeal, destName) + -- Heilung: 'target' oder 'player' + elseif action == "HEAL" then + if unit == "target" then + isHeal = true + elseif unit == "player" then + -- Verhindert doppelte Auslösung bei Ziel auf sich selbst + if not UnitIsUnit("player", "target") then + isHeal = true + end end end + + if not isDamage and not isHeal then return end + + local targetName = UnitName(unit) or (isDamage and "Ziel" or "Spieler") + + -- Schadensart / Schule auflösen + local langKey = (self:GetActiveLanguage() == "German") and "deDE" or "enUS" + local schoolName = (school and SCHOOL_NAMES[school] and SCHOOL_NAMES[school][langKey]) + or (isHeal and (langKey == "deDE" and "Heilung" or "Heal") or (langKey == "deDE" and "Angriff" or "Attack")) + + self:ProcessCrit(isHeal, schoolName, amount, targetName) end --- Event Registration +-- Event Registrierung CritTracker:RegisterEvent("ADDON_LOADED") CritTracker:RegisterEvent("PLAYER_LOGIN") CritTracker:RegisterEvent("PLAYER_LEVEL_UP") -CritTracker:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED") +CritTracker:RegisterEvent("UNIT_COMBAT") CritTracker:SetScript("OnEvent", function(self, event, ...) - if event == "COMBAT_LOG_EVENT_UNFILTERED" then - self:OnCombatLogEvent() + if event == "UNIT_COMBAT" then + self:OnUnitCombat(...) elseif event == "PLAYER_LEVEL_UP" then local newLevel = ... self:OnLevelUp(newLevel) @@ -357,7 +329,7 @@ CritTracker:SetScript("OnEvent", function(self, event, ...) end end) --- Slash Commands +-- Slash Commands (/ctr & /crittracker) SLASH_CRITTRACKER1 = "/crittracker" SLASH_CRITTRACKER2 = "/ctr" SlashCmdList["CRITTRACKER"] = function(msg) @@ -380,13 +352,13 @@ SlashCmdList["CRITTRACKER"] = function(msg) elseif cmd == "test" then if arg == "heal" then CritTracker:PlaySound(CritTracker.db.healSound or "heal1") - CritTracker:Print(string.format(CritTracker.L["NORMAL_CRIT_HEAL"], "", "Test Heal", CritTracker:FormatNumber(50000))) + CritTracker:Print(string.format(CritTracker.L["NORMAL_CRIT_HEAL"], "", "Heilig", CritTracker:FormatNumber(50000))) elseif arg == "low" then CritTracker:PlaySound("low") - CritTracker:Print(string.format(CritTracker.L["LOW_CRIT_DAMAGE"], "", "Test Low Hit", CritTracker:FormatNumber(150), CritTracker.db.lowThreshold, CritTracker:FormatNumber(1000))) + CritTracker:Print(string.format(CritTracker.L["LOW_CRIT_DAMAGE"], "", "Physisch", CritTracker:FormatNumber(150), CritTracker.db.lowThreshold, CritTracker:FormatNumber(1000))) else CritTracker:PlaySound(CritTracker.db.damageSound or "crit1") - CritTracker:Print(string.format(CritTracker.L["NORMAL_CRIT_DAMAGE"], "", "Test Strike", CritTracker:FormatNumber(125000))) + CritTracker:Print(string.format(CritTracker.L["NORMAL_CRIT_DAMAGE"], "", "Feuer", CritTracker:FormatNumber(125000))) end else CritTracker:Print(CritTracker.L["SLASH_HELP_HEADER"])