Wechsel auf UNIT_COMBAT Engine (WoW Forever Beta Interface 16001)

- Entferne deaktiviertes COMBAT_LOG_EVENT_UNFILTERED
- Implementiere UNIT_COMBAT mit Anti-Triplet Filter (unit == 'target')
- Doppel-Auslösungs-Schutz bei Selbstheilung
- Mapping von school auf lokalisierte Schadensschulen (SCHOOL_NAMES)
- Parameter-Validierung mit issecretvalue
- AGENT_GUIDE.md aktualisiert
This commit is contained in:
2026-09-30 10:09:23 +02:00
parent edc52fdd86
commit bf81a9f75a
2 changed files with 129 additions and 131 deletions

View File

@@ -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)"]
```
---

View File

@@ -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 critical and critical ~= 0 then
local totalDamage = (amount or 0) + (absorbed or 0)
self:ProcessCrit(false, self.L["MELEE_ATTACK"], totalDamage, destName)
if modifier ~= "CRITICAL" or type(amount) ~= "number" or amount <= 0 then
return
end
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
local isDamage = false
local isHeal = false
if critical and critical ~= 0 then
local totalDamage = (amount or 0) + (absorbed or 0)
self:ProcessCrit(false, spellName, totalDamage, destName)
end
-- Schaden: Nur 'target' werten (verhindert Alias-Triplets durch nameplate1/softenemy)
if action == "WOUND" and unit == "target" then
isDamage = true
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"])