495 lines
16 KiB
Lua
495 lines
16 KiB
Lua
-- 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
|