diff --git a/CodexTime.lua b/CodexTime.lua new file mode 100644 index 0000000..34d0ae6 --- /dev/null +++ b/CodexTime.lua @@ -0,0 +1,496 @@ +-- Freewood-OctoWoW-Codex: Zeitmessung +-- Kampfdauer (Time to kill): wie lange dauert ein Solo-Kampf gegen einen einzelnen Gegner, +-- je Klasse, Spielerstufe und Stufenabstand (Gegner minus Spieler, -5 bis +5). +-- Quest-Zeit: aktive Spielzeit vom ersten Fortschritt bis zur Abgabebereitschaft, bei mehreren +-- gleichzeitig laufenden Quests gleichmaessig aufgeteilt. +-- Zeiten werden als ganze Zehntelsekunden gespeichert. +-- +-- Gespeichert: +-- db.ttk[Klasse][Spielerstufe][Abstand] = { n, sum, min, max } (Klasse zum Beispiel "WARRIOR") +-- units[npcID].tt[Klasse][Spielerstufe] = { n, sum, min, max } (nur lokal) +-- quests[id].tm aufgeteilte aktive Zeit bis fertig, .tr aktive Zeit bis fertig ohne Aufteilen, +-- quests[id].ta aktive Zeit bis zur Abgabe +-- db.qtimer[questID] = laufende Zeitnehmer (ueberleben Neustart des Spiels) + +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 Time = {} +NS.Time = Time +Codex.Time = Time + +local MAX_DELTA = 5 +local MIN_KILL, MAX_KILL = 0.5, 120 -- Sekunden +local KILL_END_WINDOW = 8 -- Kampf muss kurz nach dem Kill enden +local MAX_QUEST_SECONDS = 4 * 3600 +local TICK = 1 + +Time.stats = { fights = 0, ok = 0, skip = {}, quests = 0 } + +local function Now() + return GetTime and GetTime() or time() +end + +local function Safe(fn, ...) + local ok, err = pcall(fn, ...) + if not ok and FW.Debug then + FW:Debug("Codex.Time: " .. tostring(err)) + end + return ok +end + +local function Skip(reason) + Time.stats.skip[reason] = (Time.stats.skip[reason] or 0) + 1 +end + +local function Sample(tbl, key, value) + local s = tbl[key] + if not s then + s = { n = 0, sum = 0 } + tbl[key] = s + end + s.n = s.n + 1 + s.sum = s.sum + value + s.min = s.min and math.min(s.min, value) or value + s.max = s.max and math.max(s.max, value) or value + return s +end + +function Time.PlayerToken() + local token + if UnitClass then + local _, t = UnitClass("player") + token = t + end + if type(token) == "string" and token:match("^%u+$") then + return token + end + return nil +end + +-- Klassenname des Spielers in der Spielsprache (fuer Anzeigen) +function Time.ClassName() + if UnitClass then + local name = UnitClass("player") + if type(name) == "string" and name ~= "" then + return name + end + end + return Time.PlayerToken() or "?" +end + +------------------------------------------------------------------------ +-- Kampfdauer +------------------------------------------------------------------------ + +local fight = nil + +local function InGroup() + return (IsInGroup and IsInGroup()) or (GetNumGroupMembers and GetNumGroupMembers() > 1) or false +end + +local function InInstance() + if IsInInstance then + local inside = IsInInstance() + return inside and true or false + end + return false +end + +function Time:NoteTarget() + if not fight or not fight.ok then + return + end + if not (UnitExists and UnitExists("target")) then + return + end + if UnitCanAttack and not UnitCanAttack("player", "target") then + return + end + local guid = UnitGUID and UnitGUID("target") + if guid == nil or Util.IsSecret(guid) then + fight.ok, fight.why = false, "secret" + return + end + if not fight.targets[guid] then + fight.targets[guid] = true + fight.nTargets = fight.nTargets + 1 + if fight.nTargets > 1 then + fight.ok, fight.why = false, "multi" + return + end + local classification = UnitClassification and UnitClassification("target") + if classification and classification ~= "normal" then + fight.ok, fight.why = false, "elite" + end + local level = UnitLevel and tonumber(UnitLevel("target")) + if level and level > 0 then + fight.tl = level -- Stufe des Gegners, falls der Erfahrungs-Hook sie nicht kennt + end + end +end + +function Time:OnRegenDisabled() + Safe(function() + fight = { start = Now(), targets = {}, nTargets = 0, kills = 0, ok = true } + if InGroup() then + fight.ok, fight.why = false, "group" + elseif InInstance() then + fight.ok, fight.why = false, "instance" + end + Time:NoteTarget() + end) +end + +function Time:OnTargetChanged() + Safe(Time.NoteTarget, Time) +end + +-- vom Erfahrungs-Hook aufgerufen: ein Gegner ist gestorben (pl, ml: Stufen, id: NPC-ID oder nil) +function Time:OnKill(pl, ml, id, classification) + if not fight then + return + end + fight.kills = fight.kills + 1 + fight.killAt = Now() + fight.pl, fight.ml, fight.id = pl, ml or fight.ml, id or fight.id + if classification and classification ~= "normal" and fight.ok then + fight.ok, fight.why = false, "elite" + end +end + +function Time:Record(token, pl, delta, seconds, id) + local ds = math.floor(seconds * 10 + 0.5) + local db = Codex.db + db.ttk = db.ttk or {} + db.ttk[token] = db.ttk[token] or {} + db.ttk[token][pl] = db.ttk[token][pl] or {} + Sample(db.ttk[token][pl], delta, ds) + if id then + local u = Codex:Unit(id, true) + u.tt = u.tt or {} + u.tt[token] = u.tt[token] or {} + Sample(u.tt[token], pl, ds) + end + FW:Fire("CODEX_CHANGED", "ttk", pl) +end + +function Time:OnRegenEnabled() + Safe(function() + local f = fight + fight = nil + if not f then + return + end + local st = Time.stats + st.fights = st.fights + 1 + if not f.ok then + return Skip(f.why or "other") + end + if f.kills ~= 1 then + return Skip(f.kills == 0 and "nokill" or "kills") + end + local seconds = f.killAt - f.start + if seconds < MIN_KILL then + return Skip("short") + end + if seconds > MAX_KILL then + return Skip("long") + end + if Now() - f.killAt > KILL_END_WINDOW then + return Skip("late") + end + local pl = f.pl or (UnitLevel and tonumber(UnitLevel("player"))) + local ml = f.ml or f.tl + local delta = ml and pl and (ml - pl) + if not delta or math.abs(delta) > MAX_DELTA then + return Skip("range") + end + local token = Time.PlayerToken() + if not token then + return Skip("class") + end + st.ok = st.ok + 1 + Time:Record(token, pl, delta, seconds, f.id) + end) +end + +-- Klassenname zu einem Klassenschluessel ("WARRIOR" -> "Krieger"), sonst der Schluessel selbst +function Time.ClassLabel(token) + local names = _G.LOCALIZED_CLASS_NAMES_MALE + if type(names) == "table" and type(names[token]) == "string" then + return names[token] + end + return tostring(token) +end + +-- Kampfdauer einer Kreatur je Klasse (eigene und geteilte Messungen): +-- Liste { cls, secs, n, own } nach Dauer sortiert, Durchschnitt ueber alle Spielerstufen +function Time.UnitClasses(u) + local list = {} + local mine = Time.PlayerToken() + for cls, byPl in pairs(u and u.tt or {}) do + local n, sum = 0, 0 + for _, s in pairs(byPl) do + n = n + (s.n or 0) + sum = sum + (s.sum or 0) + end + if n > 0 then + list[#list + 1] = { cls = cls, secs = sum / n / 10, n = n, own = cls == mine } + end + end + table.sort(list, function(a, b) + if a.secs ~= b.secs then return a.secs < b.secs end + return a.cls < b.cls + end) + return list +end + +-- Durchschnittliche Kampfdauer (Sekunden, Anzahl) einer Kreatur fuer die eigene Klasse +function Time.UnitAverage(u) + local token = Time.PlayerToken() + local byPl = u and u.tt and token and u.tt[token] + if not byPl then + return nil + end + local n, sum = 0, 0 + for _, s in pairs(byPl) do + n = n + (s.n or 0) + sum = sum + (s.sum or 0) + end + if n < 1 then + return nil + end + return sum / n / 10, n +end + +------------------------------------------------------------------------ +-- Quest-Zeit +------------------------------------------------------------------------ + +local lastTick = nil +local ticker = nil + +local function QTimers() + Codex.db.qtimer = Codex.db.qtimer or {} + return Codex.db.qtimer +end + +local function LogEntries() + local list = {} + local count + if C_QuestLog and C_QuestLog.GetNumQuestLogEntries then + count = C_QuestLog.GetNumQuestLogEntries() + elseif GetNumQuestLogEntries then + count = GetNumQuestLogEntries() + end + for i = 1, tonumber(count) or 0 do + local questID, isHeader + if C_QuestLog and C_QuestLog.GetInfo then + local info = C_QuestLog.GetInfo(i) + if type(info) == "table" then + questID, isHeader = info.questID, info.isHeader + end + elseif GetQuestLogTitle then + local _, _, _, h, _, _, _, id = GetQuestLogTitle(i) + questID, isHeader = id, h + end + if type(questID) == "number" and questID > 0 and not isHeader then + list[#list + 1] = questID + end + end + return list +end + +-- Fortschritt (Summe der erfuellten Ziele) und ob alles erledigt ist +local function Progress(questID) + local have, finished, count = 0, true, 0 + if C_QuestLog and C_QuestLog.GetQuestObjectives then + local objs = C_QuestLog.GetQuestObjectives(questID) + if type(objs) == "table" then + for _, o in ipairs(objs) do + if type(o) == "table" then + count = count + 1 + have = have + (tonumber(o.numFulfilled) or 0) + if not o.finished then finished = false end + end + end + end + end + if count == 0 then + finished = false + end + if not finished and C_QuestLog and C_QuestLog.IsComplete then + local ok, done = pcall(C_QuestLog.IsComplete, questID) + if ok and done and count > 0 then finished = true end + end + return have, finished +end + +function Time:ScanQuests() + Safe(function() + local timers = QTimers() + local seen = {} + for _, questID in ipairs(LogEntries()) do + seen[questID] = true + local have, finished = Progress(questID) + local t = timers[questID] + if not t then + -- neu im Log: nur ohne vorhandenen Fortschritt kennen wir den Beginn + t = { eff = 0, raw = 0, all = 0, have = have, started = false, skip = have > 0 or finished } + timers[questID] = t + end + if not t.skip then + if have > (t.have or 0) and not t.started then + t.started = true + end + if finished and t.started and not t.done then + t.done = true + end + end + t.have = have + end + for questID, t in pairs(timers) do + if not seen[questID] then + if t.done then + -- fertige Quests kurz behalten: die Abgabe kann nach dem Verlassen des Logs eintreffen + t.goneAt = t.goneAt or Now() + if Now() - t.goneAt > 60 then timers[questID] = nil end + else + timers[questID] = nil + end + end + end + end) +end + +function Time:Tick() + Safe(function() + local now = Now() + local dt = lastTick and (now - lastTick) or 0 + lastTick = now + if dt <= 0 or dt > 3 then + return + end + if UnitIsAFK and UnitIsAFK("player") then + return + end + local timers = QTimers() + local active = 0 + for _, t in pairs(timers) do + if t.started and not t.done and not t.skip then + active = active + 1 + end + end + for _, t in pairs(timers) do + if t.started and not t.skip then + t.all = (t.all or 0) + dt + if not t.done then + t.eff = t.eff + dt / math.max(1, active) + t.raw = t.raw + dt + end + end + end + end) +end + +-- Quest abgegeben: Zeiten in die Quest schreiben +function Time:OnTurnedIn(questID) + Safe(function() + questID = tonumber(questID) + local t = questID and QTimers()[questID] + if not t or t.skip or not t.started then + if questID then QTimers()[questID] = nil end + return + end + QTimers()[questID] = nil + if not t.done or t.raw < 1 or t.all > MAX_QUEST_SECONDS then + return + end + local q = Codex:Quest(questID, true) + local function Add(field, seconds) + local ds = math.floor(seconds * 10 + 0.5) + q[field] = q[field] or { n = 0, sum = 0 } + local s = q[field] + s.n = s.n + 1 + s.sum = s.sum + ds + s.min = s.min and math.min(s.min, ds) or ds + s.max = s.max and math.max(s.max, ds) or ds + end + Add("tm", t.eff) + Add("tr", t.raw) + Add("ta", t.all) + Time.stats.quests = Time.stats.quests + 1 + FW:Fire("CODEX_CHANGED", "quest", questID) + end) +end + +function Time:OnQuestLog() + if self.scanPending then + return + end + self.scanPending = true + C_Timer.After(0.5, function() + Time.scanPending = nil + Time:ScanQuests() + end) +end + +------------------------------------------------------------------------ +-- Start / Stop +------------------------------------------------------------------------ + +function Time:Start() + FW:RegisterEvent("PLAYER_REGEN_DISABLED", self.OnRegenDisabled, self) + FW:RegisterEvent("PLAYER_REGEN_ENABLED", self.OnRegenEnabled, self) + FW:RegisterEvent("PLAYER_TARGET_CHANGED", self.OnTargetChanged, self) + FW:RegisterEvent("QUEST_LOG_UPDATE", self.OnQuestLog, self) + FW:RegisterEvent("UNIT_QUEST_LOG_CHANGED", self.OnQuestLog, self) + FW:RegisterEvent("QUEST_ACCEPTED", self.OnQuestLog, self) + FW:RegisterEvent("QUEST_TURNED_IN", self.OnTurnedIn, self) + if not ticker and C_Timer and C_Timer.NewTicker then + lastTick = Now() + ticker = C_Timer.NewTicker(TICK, function() Time:Tick() end) + end + C_Timer.After(4, function() Time:ScanQuests() end) +end + +function Time:Stop() + for _, event in ipairs({ "PLAYER_REGEN_DISABLED", "PLAYER_REGEN_ENABLED", "PLAYER_TARGET_CHANGED", + "QUEST_LOG_UPDATE", "UNIT_QUEST_LOG_CHANGED", "QUEST_ACCEPTED", "QUEST_TURNED_IN" }) do + FW:UnregisterEvent(event, self) + end + if ticker and ticker.Cancel then + ticker:Cancel() + end + ticker = nil + fight = nil +end + +function Time:Debug() + local st = self.stats + local n = 0 + for _, byPl in pairs(Codex.db.ttk or {}) do + for _, byDelta in pairs(byPl) do + for _ in pairs(byDelta) do n = n + 1 end + end + end + local reasons = {} + for k, v in pairs(st.skip) do reasons[#reasons + 1] = k .. "=" .. v end + table.sort(reasons) + local active, running = 0, 0 + for _, t in pairs(QTimers()) do + running = running + 1 + if t.started and not t.done and not t.skip then active = active + 1 end + end + FW:Print(string.format("Zeit: Kaempfe %d, gewertet %d, Messreihen %d, uebersprungen: %s", st.fights, st.ok, n, + #reasons > 0 and table.concat(reasons, " ") or "-")) + FW:Print(string.format("Quest-Zeit: Zeitnehmer %d, davon aktiv %d, gespeicherte Abgaben %d", running, active, st.quests)) +end diff --git a/CodexUI.lua b/CodexUI.lua index 2c40995..ce1c2a5 100644 --- a/CodexUI.lua +++ b/CodexUI.lua @@ -353,10 +353,12 @@ local function ItemIndex() end end end - -- Items aus den Startdaten, deren Itemdaten schon geladen sind (sonst nur Zahlen ohne Namen) + -- Items aus den Startdaten, deren Name bekannt ist: aus dem Client oder aus dem mitgelieferten Katalog + -- (Name, Stufe, Preis ohne Serverabfrage). Unbekannte erscheinen, sobald der Client sie geladen hat. + local CatGet = Codex.Catalog and Codex.Catalog.GetItem if Codex.Forever and Codex.Forever.ItemIds then for id in pairs(Codex.Forever.ItemIds()) do - if not index[id] and Util.GetItem(id) then + if not index[id] and ((CatGet and CatGet(id)) or Util.GetItem(id)) then local it = Codex.Forever.Item(id) local f = { zones = {}, best = 0, fe = true } if it then @@ -1910,11 +1912,11 @@ local function Build(content) nameCtl = Codex.NameUI.Attach(f, function() return kind, selected end, function() dataVersion = dataVersion + 1 Refresh() - end, { x = LIST_W + 22, y = 34, relayout = function() if UpdateFavUIRef then UpdateFavUIRef() end end }) + end, { x = LIST_W + 22, y = 0, relayout = function() if UpdateFavUIRef then UpdateFavUIRef() end end }) end ---------------- Favorit und Notiz ---------------- - local favButton = UI.CreateButton(f, L.CODEX_FAV_ADD, 140, 26, function() + local favButton = UI.CreateButton(f, L.CODEX_FAV_ADD, 150, 26, function() if selected ~= nil and Codex.FavSupported(kind) then Codex.SetFav(kind, selected, not Codex.IsFav(kind, selected)) dataVersion = dataVersion + 1 @@ -1923,6 +1925,7 @@ local function Build(content) end, "default") favButton:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W + 22 + 108, 0) favButton:Hide() + if nameCtl then nameCtl.lead = { backButton, favButton } end local noteBox = UI.CreatePanel(f, "bgDeep", "bronze") noteBox:SetHeight(24) noteBox:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W + 22, -34) @@ -1985,8 +1988,15 @@ local function Build(content) noteEdit:ClearFocus() end noteTarget = nil - local devH = nameCtl and nameCtl.height or 0 -- Reihe mit den Bearbeiten-Knoepfen (nur fuer Berechtigte) - local top = 34 + devH + -- Favorit-Knopf zuerst ein-/ausblenden, dann die gemeinsame Knopfreihe (Zurueck, Favorit, Bearbeiten ...) legen + if ok then + favButton:Show() + else + favButton:Hide() + end + if nameCtl then nameCtl.Layout(true) end + local devH = nameCtl and nameCtl.height or 32 -- eine gemeinsame Reihe von links nach rechts + local top = devH + 2 noteBox:ClearAllPoints() noteBox:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W + 22, -top) noteBox:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, -top) @@ -2142,6 +2152,11 @@ local function Build(content) GameTooltip:SetOwner(self, "ANCHOR_RIGHT") pcall(GameTooltip.SetItemByID, GameTooltip, self.itemID) GameTooltip:Show() + elseif self.tip and GameTooltip then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(self.tip[1] or "", 1, 1, 1) + for ti = 2, #self.tip do GameTooltip:AddLine(self.tip[ti], 0.8, 0.8, 0.8) end + GameTooltip:Show() elseif self.spellID and GameTooltip then GameTooltip:SetOwner(self, "ANCHOR_RIGHT") local ok = pcall(GameTooltip.SetSpellByID, GameTooltip, self.spellID) @@ -2283,7 +2298,7 @@ local function Build(content) elseif style == "heading" then h = 28; top = 12 elseif style == "item" or style == "link" then h = 26 elseif style == "text" then h = TextHeight(opts.text) end - if opts.spellID then h = 26 end + if opts.spellID or opts.tip then h = 26 end row:ClearAllPoints() row:SetPoint("TOPLEFT", detail.child, "TOPLEFT", 0, -(self.y + top)) row:SetPoint("TOPRIGHT", detail.child, "TOPRIGHT", 0, -(self.y + top)) @@ -2299,7 +2314,8 @@ local function Build(content) row.onClick = opts.onClick row.itemID = opts.itemID row.spellID = opts.spellID - row:EnableMouse(row.clickable or opts.itemID ~= nil or opts.spellID ~= nil) + row.tip = opts.tip + row:EnableMouse(row.clickable or opts.itemID ~= nil or opts.spellID ~= nil or opts.tip ~= nil) local leftX = 4 + indent if opts.icon then @@ -3287,10 +3303,16 @@ local function Build(content) D:Add({ style = "heading", text = string.format(L.CODEX_SEC_LEARN_SPELLS, #g.spells) }) for _, sp in ipairs(g.spells) do local rank = sp.rk or (sp.sp and Codex.SpellRank(sp.sp)) or nil - local icon = sp.sp and Codex.SpellIcon(sp.sp) or nil - D:Add({ text = (sp.nm or "?") .. (rank and (" |cff9a9aa3(" .. rank .. ")|r") or ""), + local icon = sp.sp and Codex.SpellIcon(sp.sp) or sp.ic or nil + local tip + if not sp.sp then + tip = { (sp.nm or "?") .. (rank and (" (" .. rank .. ")") or "") } + if lv > 0 then tip[#tip + 1] = string.format(L.CODEX_TRAIN_LEVEL, lv) end + if sp.cost and sp.cost > 0 then tip[#tip + 1] = FormatMoney(sp.cost) end + end + D:Add({ tip = tip, text = (sp.nm or "?") .. (rank and (" |cff9a9aa3(" .. rank .. ")|r") or ""), right = (sp.cost and sp.cost > 0) and FormatMoney(sp.cost) or "-", rightWidth = 150, - spellID = sp.sp, icon = icon or (sp.sp and "Interface\\Icons\\INV_Misc_QuestionMark") or nil }) + spellID = sp.sp, icon = icon or "Interface\\Icons\\INV_Misc_QuestionMark" }) end D:Add({ style = "heading", text = string.format(L.CODEX_SEC_LEARN_TRAINERS, #g.trainers) }) for i, id in ipairs(g.trainers) do