Dateien nach "/" hochladen

This commit is contained in:
2026-10-29 00:37:00 +00:00
parent 8adf191f41
commit 8b9b8f702f
2 changed files with 529 additions and 11 deletions
+496
View File
@@ -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
+33 -11
View File
@@ -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