Dateien nach "/" hochladen

This commit is contained in:
2026-10-27 14:08:44 +00:00
parent 5457dd095c
commit 05ba4e6191
4 changed files with 1860 additions and 0 deletions
+494
View File
@@ -0,0 +1,494 @@
-- Freewood-OctoWoW-Codex: Namen von Kreaturen lernen
--
-- Forever gibt Kreaturennamen im Kampf als "geheime Werte" zurueck. In Instanzen ist fast alles Kampf,
-- darum kannte der Codex dort nur die IDs. Hier werden Namen auf mehreren Wegen gesammelt:
-- target / mouse Ziel und Mouseover (auch Leichen und noch nicht bekannte Kreaturen)
-- scan ausserhalb des Kampfes alle paar Sekunden: Ziel, Fokus, Gruppenziele, Namensplaketten
-- plate Ereignis "Namensplakette erscheint" (falls das Spiel es erlaubt)
-- xp XP-Meldung "X stirbt ..." mit passendem Ziel
-- pend XP-Meldung ohne lesbares Ziel: Name wird dem naechsten frisch gelooteten Unbekannten zugeordnet,
-- aber nur, wenn eindeutig (genau ein Name in den letzten 90 Sekunden)
-- quest Kill-Ziele von Quests (eigene und fremde), die genau eine Kreatur-ID kennen
-- cache Name war schon gesehen, bevor die Kreatur einen Datensatz hatte
local ADDON, NS = ...
local FW = _G.Freewood
local Codex = NS and NS.Codex
if not FW or not Codex then
return
end
local Util = FW.Util
local Names = {
stats = { target = 0, mouse = 0, scan = 0, plate = 0, xp = 0, pend = 0, quest = 0, cache = 0, tip = 0 },
tipFail = { na = 0, err = 0, none = 0, secret = 0 },
xpSeen = 0,
kills = {}, evCount = {}, evSecret = 0, samples = {}, killEvents = {},
reads = 0, secret = 0, sweeps = 0, plateEvents = 0,
fail = { existSecret = 0, playerSecret = 0, guidSecret = 0, notNpc = 0, xpSecret = 0 },
idName = {}, idCount = 0, pending = {}, running = false, plateOk = nil,
}
NS.Names = Names
Codex.Names = Names
local MAX_CACHE = 800
local PENDING_SECS = 90
local SCAN_SECS = 2.5
local QUEST_EVERY = 24 -- Durchlaeufe zwischen zwei Quest-Auswertungen (ca. eine Minute)
local TOKENS = { "target", "focus", "mouseover", "targettarget", "focustarget", "softenemy" }
local function ParseGUID(guid)
local parse = Codex.ParseGUID
if parse then
return parse(guid)
end
end
local function CleanName(v)
if Util.IsSecret(v) or type(v) ~= "string" then
return nil
end
v = v:gsub("[%c|]", ""):gsub("^%s+", ""):gsub("%s+$", "")
if v == "" then
return nil
end
return v:sub(1, 60)
end
-- Kreatur kennt der Codex schon (eigene oder fremde Daten)?
local function Known(id)
if Codex.db.units[id] then
return true
end
local v = Codex.V and Codex.V()
return v and v.units and v.units[id] ~= nil
end
-- Name zu einer Kreatur-ID eintragen. Gibt true zurueck, wenn ein neuer Name gespeichert wurde.
function Names:Learn(id, name, src)
name = CleanName(name)
if name and Codex.StripKillVerb then name = Codex.StripKillVerb(name) end
if type(id) ~= "number" or not name then
return false
end
local u = Codex.db.units[id]
if u and u.n then
return false
end
if not u and self.idName[id] then
return false
end
if u or Known(id) then
u = u or Codex:Unit(id, true)
u.n = name
self.idName[id] = nil
if src and self.stats[src] then
self.stats[src] = self.stats[src] + 1
end
FW:Fire("CODEX_CHANGED", "unit", id)
return true
end
-- noch kein Datensatz: Name merken, bis die Kreatur gelootet wird
if not self.idName[id] and self.idCount < MAX_CACHE then
self.idName[id] = name
self.idCount = self.idCount + 1
end
return false
end
-- Eine Einheit lesen: nur Kreaturen, nur wenn der Name lesbar ist.
-- fail[...] zaehlt, woran es scheitert (nur zur Diagnose): exist, player, guid, npc, name
function Names:Read(unit, src)
local f = self.fail
local existsRaw = UnitExists and UnitExists(unit)
if Util.IsSecret(existsRaw) then
f.existSecret = f.existSecret + 1
return
end
if not existsRaw then
return
end
local playerRaw = UnitIsPlayer and UnitIsPlayer(unit)
if Util.IsSecret(playerRaw) then
f.playerSecret = f.playerSecret + 1
return
end
if playerRaw then
return
end
local guidRaw = UnitGUID and UnitGUID(unit)
if Util.IsSecret(guidRaw) then
f.guidSecret = f.guidSecret + 1
return
end
local kind, id = ParseGUID(guidRaw)
if kind ~= "npc" then
f.notNpc = f.notNpc + 1
return
end
self.reads = self.reads + 1
local raw = UnitName and UnitName(unit)
if Util.IsSecret(raw) then
self.secret = self.secret + 1
return
end
self:Learn(id, raw, src)
end
local function GroupTokens()
local list = {}
local n = GetNumGroupMembers and GetNumGroupMembers() or 0
if n > 1 then
local raid = IsInRaid and IsInRaid()
local prefix, count = raid and "raid" or "party", raid and math.min(n, 40) or math.min(n - 1, 4)
for i = 1, count do
list[#list + 1] = prefix .. i .. "target"
end
end
return list
end
-- Alle bekannten Quellen einmal lesen. Im Kampf sind die Namen geheim, dann lohnt es nicht.
function Names:Scan(src)
src = src or "scan"
if UnitAffectingCombat and Util.Clean(UnitAffectingCombat("player")) then
return
end
self.sweeps = self.sweeps + 1
for _, unit in ipairs(TOKENS) do
self:Read(unit, src)
end
for _, unit in ipairs(GroupTokens()) do
self:Read(unit, src)
end
for i = 1, 40 do
local unit = "nameplate" .. i
if UnitExists and Util.Clean(UnitExists(unit)) then
self:Read(unit, "plate")
end
end
end
-- Kill-Ziele von Quests mit genau einer bekannten Kreatur-ID
function Names:FromQuests()
local v = Codex.V and Codex.V()
if not v or not v.quests then
return 0
end
local learned = 0
for _, q in pairs(v.quests) do
for _, ob in ipairs(q.obj or {}) do
if ob.ty == "monster" and ob.name and type(ob.ids) == "table" then
local only, count
count = 0
for id in pairs(ob.ids) do
only = id
count = count + 1
if count > 1 then
break
end
end
if count == 1 and self:Learn(only, ob.name, "quest") then
learned = learned + 1
end
end
end
end
return learned
end
-- XP-Meldung: Name einer getoeteten Kreatur ohne lesbares Ziel merken
function Names:NoteKill(name)
name = CleanName(name)
if not name then
return
end
local list = self.pending
list[#list + 1] = { name = name, t = time() }
while #list > 20 do
table.remove(list, 1)
end
end
-- Versuch: Name ueber den Tooltip-Link der Beute-GUID ("unit:<GUID>"). Die GUID aus dem Beutefenster ist lesbar.
function Names:FromTooltip(guid)
local api = _G.C_TooltipInfo and _G.C_TooltipInfo.GetHyperlink
if type(api) ~= "function" or type(guid) ~= "string" then
self.tipFail.na = self.tipFail.na + 1
return nil
end
local ok, data = pcall(api, "unit:" .. guid)
if not ok then
self.tipFail.err = self.tipFail.err + 1
return nil
end
if Util.IsSecret(data) then
self.tipFail.secret = self.tipFail.secret + 1
return nil
end
local line = type(data) == "table" and type(data.lines) == "table" and data.lines[1]
local text = type(line) == "table" and line.leftText
if Util.IsSecret(text) then
self.tipFail.secret = self.tipFail.secret + 1
return nil
end
local name = CleanName(text)
if not name then
self.tipFail.none = self.tipFail.none + 1
end
return name
end
-- Eine gelootete Kreatur mit bekanntem Namen hat ihre XP-Meldung "verbraucht"
function Names:Consume(name)
local list = self.pending
for i = #list, 1, -1 do
if list[i].name == name then
table.remove(list, i)
return
end
end
end
-- Frisch gelootete Kreatur ohne Namen: aus Merkzettel oder XP-Meldungen auffuellen
function Names:Resolve(id, ent, guid)
if type(id) ~= "number" or type(ent) ~= "table" or ent.n then
return
end
local cached = self.idName[id]
if cached then
ent.n = cached
self.idName[id] = nil
self.stats.cache = self.stats.cache + 1
return
end
local tip = guid and self:FromTooltip(guid)
if tip then
ent.n = tip
self.stats.tip = self.stats.tip + 1
return
end
if (ent.k or 0) > 1 then
return
end
local now, list = time(), self.pending
for i = #list, 1, -1 do
if now - list[i].t > PENDING_SECS then
table.remove(list, i)
end
end
-- nur zuordnen, wenn in den letzten 90 Sekunden genau ein Name gemeldet wurde
local only
for _, p in ipairs(list) do
if only and only ~= p.name then
return
end
only = p.name
end
if not only then
return
end
for i = #list, 1, -1 do
if list[i].name == only then
table.remove(list, i)
break
end
end
ent.n = only
self.stats.pend = self.stats.pend + 1
end
-- Kampflog-Zeilen "Euer Charakter besiegt X." / "X stirbt." als Vorschlaege fuer Namen (nie automatisch zugeordnet)
local KILL_EVENTS = { "CHAT_MSG_COMBAT_HOSTILE_DEATH", "CHAT_MSG_COMBAT_SELF_HITS", "CHAT_MSG_COMBAT_PARTY_HITS",
"CHAT_MSG_COMBAT_MISC_INFO", "CHAT_MSG_COMBAT_PET_HITS" }
local KILL_PATTERNS = {
"^Euer Charakter besiegt (.+)%.$", "besiegt (.+)%.$", "^Ihr habt (.+) get\195\182tet%.$", "^(.+) stirbt%.$",
"^You have slain (.+)!$", "^You defeat (.+)%.$", "defeats (.+)%.$", "^(.+) dies%.$",
}
-- Zeile pruefen. Rueckgabe: Name oder nil
function Names.ParseKill(text)
if type(text) ~= "string" then
return nil
end
text = text:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""):gsub("|H.-|h(.-)|h", "%1")
for _, pattern in ipairs(KILL_PATTERNS) do
local name = text:match(pattern)
if name then
name = name:gsub("^%s+", ""):gsub("%s+$", "")
if name ~= "" and #name <= 80 then
return name
end
end
end
return nil
end
function Names:OnKillLine(event, text)
self.evCount[event] = (self.evCount[event] or 0) + 1
if Util.IsSecret(text) then
self.evSecret = self.evSecret + 1
return
end
if type(text) ~= "string" then
return
end
local sample = self.samples[event]
if not sample then
sample = {}
self.samples[event] = sample
end
if #sample < 2 then
sample[#sample + 1] = text:sub(1, 90)
end
local name = Names.ParseKill(text)
if not name then
return
end
local list, now = self.kills, time()
local last = list[#list]
if last and last.name == name and now - last.t < 2 then
return
end
list[#list + 1] = { name = name, t = now }
while #list > 40 do
table.remove(list, 1)
end
end
-- Zuletzt besiegte Gegner (neueste zuerst, ohne Doppelte). Pro Eintrag: name, alt (ohne letztes grossgeschriebenes Wort)
function Names:RecentKills(maxAge, limit)
maxAge, limit = maxAge or 900, limit or 8
local now, seen, out = time(), {}, {}
for i = #self.kills, 1, -1 do
local k = self.kills[i]
if now - k.t <= maxAge and not seen[k.name] then
seen[k.name] = true
local alt
local head, tail = k.name:match("^(.+)%s+(%S+)$")
if head and tail:match("^%u") and #head >= 3 then
alt = head
end
out[#out + 1] = { name = k.name, alt = alt, t = k.t }
if #out >= limit then
break
end
end
end
return out
end
local function Tick()
if not Names.running then
return
end
pcall(Names.Scan, Names, "scan")
Names.tickCount = (Names.tickCount or 0) + 1
if Names.tickCount % QUEST_EVERY == 0 then
pcall(Names.FromQuests, Names)
end
C_Timer.After(SCAN_SECS, Tick)
end
function Names:OnRegen()
C_Timer.After(0.5, function() pcall(Names.Scan, Names, "scan") end)
C_Timer.After(2.5, function() pcall(Names.Scan, Names, "scan") end)
end
function Names:OnPlate(unit)
self.plateEvents = self.plateEvents + 1
if type(unit) == "string" then
self:Read(unit, "plate")
end
end
-- Alte Eintraege mit anhaengendem Kill-Verb ("... beseitigt", "getötet") bereinigen (einmal beim Start)
function Names.Cleanup()
local strip = Codex.StripKillVerb
if not strip or not Codex.db then return end
local n = 0
for _, u in pairs(Codex.db.units or {}) do
if type(u) == "table" and type(u.n) == "string" then
local c = strip(u.n)
if c ~= u.n then u.n = c n = n + 1 end
end
end
for _, q in pairs(Codex.db.quests or {}) do
for _, ob in ipairs(type(q) == "table" and q.obj or {}) do
if type(ob.name) == "string" then
local c = strip(ob.name)
if c ~= ob.name then ob.name = c n = n + 1 end
end
end
end
Names.cleaned = n
end
function Names:Start()
if self.running then
return
end
pcall(Names.Cleanup)
self.running = true
FW:RegisterEvent("PLAYER_REGEN_ENABLED", self.OnRegen, self)
-- das Ereignis kann in gesperrten Situationen verboten sein: Fehler abfangen
self.plateOk = pcall(FW.RegisterEvent, FW, "NAME_PLATE_UNIT_ADDED", self.OnPlate, self)
for _, loopEvent in ipairs(KILL_EVENTS) do
local event = loopEvent -- eigene Variable je Durchlauf: Lua 5.0 teilt die Schleifenvariable zwischen Closures
self.killEvents[event] = pcall(FW.RegisterEvent, FW, event, function(owner, text)
Names:OnKillLine(event, text)
end, Names)
end
C_Timer.After(12, function()
pcall(Names.FromQuests, Names)
end)
C_Timer.After(SCAN_SECS, Tick)
end
function Names:Stop()
self.running = false
FW:UnregisterEvent("PLAYER_REGEN_ENABLED", self)
if self.plateOk then
FW:UnregisterEvent("NAME_PLATE_UNIT_ADDED", self)
end
for event, ok in pairs(self.killEvents) do
if ok then
FW:UnregisterEvent(event, self)
end
end
end
function Names:Debug()
local total, named = 0, 0
local v = Codex.V and Codex.V() or Codex.db
for _, u in pairs(v.units or {}) do
total = total + 1
if u.n then
named = named + 1
end
end
local st = self.stats
FW:Print(string.format("Codex-Namen: %d Kreaturen, %d mit Namen, %d ohne", total, named, total - named))
FW:Print(string.format("Neu gelernt ueber: Ziel %d, Mouseover %d, Suche %d, Namensplakette %d, XP-Meldung %d, XP-Zuordnung %d, Quest %d, Merkzettel %d, Tooltip %d",
st.target, st.mouse, st.scan, st.plate, st.xp, st.pend, st.quest, st.cache, st.tip))
local tf = self.tipFail
FW:Print(string.format("XP-Meldungen gesehen %d (davon geheim %d); Tooltip-Weg: nicht vorhanden %d, Fehler %d, geheim %d, ohne Namen %d",
self.xpSeen, self.fail.xpSecret, tf.na, tf.err, tf.secret, tf.none))
local evs = {}
for event, n in pairs(self.evCount) do
evs[#evs + 1] = event:gsub("^CHAT_MSG_COMBAT_", "") .. "=" .. n
end
table.sort(evs)
FW:Print(string.format("Kampflog-Zeilen: %s (geheim %d); gemerkte Kills %d", #evs > 0 and table.concat(evs, " ") or "keine", self.evSecret, #self.kills))
for event, list in pairs(self.samples) do
for _, text in ipairs(list) do
FW:Print(" Beispiel " .. event:gsub("^CHAT_MSG_COMBAT_", "") .. ": " .. text:gsub("|", "||"))
end
end
local f = self.fail
FW:Print(string.format("Abbrueche vor dem Namen: Einheit geheim %d, Spieler-Angabe geheim %d, GUID geheim %d, keine Kreatur %d; Instanz: %s",
f.existSecret, f.playerSecret, f.guidSecret, f.notNpc, tostring(IsInInstance and select(1, IsInInstance()))))
FW:Print(string.format("Gelesen %d, davon im Kampf geheim %d; Suchdurchlaeufe %d; Namensplaketten-Ereignis %s (%d); Merkzettel %d, offene XP-Namen %d",
self.reads, self.secret, self.sweeps, self.plateOk and "ok" or "nicht verfuegbar", self.plateEvents, self.idCount, #self.pending))
end