diff --git a/Codex.lua b/Codex.lua new file mode 100644 index 0000000..e20c3b9 --- /dev/null +++ b/Codex.lua @@ -0,0 +1,4474 @@ +-- Freewood-OctoWoW-Codex: lernt Quests, Kreaturen und Beute beim Spielen. +-- +-- Gespeichert (Account-weit) in Codex.db: +-- quests[questID] = { +-- t = Titel, lvl = Stufe, pl = niedrigste Spielerstufe bei Annahme, zone = Karte der Annahme, +-- give = Ref, turn = Ref, -- Ref = { k="npc"|"object"|"other", id, n, m, x, y } +-- xp, money, rep = { [Fraktion] = Wert }, +-- fixed = { { id, c } }, choice = { { id, c } }, chosen = { [itemID] = Anzahl }, +-- obj = { { ty, name, txt, req, ids = { [npcID] = Treffer } } }, +-- qi = { [itemID] = { ["u"|"o"] = Anzahl } }, -- Quest-Items und ihre Quellen +-- seen, done, first, last, partial } +-- units[npcID] = { n, l1, l2, c, cells = { [m*GRID^2+zelle] = n }, +-- k = gelootete Leichen, coin, coinN, d = { [itemID] = { n, q, qi } }, +-- alt = { k, d } } -- alt: zweites Beutefenster (Haeuten usw.) +-- objects[objID] = wie units (ohne Stufen) +-- names[itemID] = Itemname (fuer die Suche) +-- xp[spielerstufe][gegnerstufe][klasse] = { n, sum, min, max } Kill-Erfahrung ohne Zusaetze +-- xpb[...] gleiche Form, aber mit Ruhe-/Gruppen-/Raidzusatz +-- units[npcID].xp[spielerstufe] = { n, sum, min, max } +-- units[npcID].xm[spielerstufe][gegnerstufe] = { n, sum, min, max } +-- quests[id].obj[i].item = Item-ID, gelernt aus dem Beutefenster, wenn der Zaehler steigt +-- objEmpty = Ziele ohne lesbaren Namen (max. 5, fuer /fwcodex test) +-- objects[id].n = Name, vom Objekt-Tooltip gelernt +-- units[npcID].f = Tierfamilie (Text) +-- remote[absender] = { seen, n, cursor, hash, rec = { ["u123"] = bereinigter Datensatz } } empfangene Daten (CodexShare.lua) +-- sync = { stamps = { ["u123"] = { sig, upd } }, sent, received } Aenderungsstaende eigener Daten +-- settings.share = ein Schalter fuer das gesamte Teilen +-- +-- Geheime Werte (secret values) werden mit Util.Clean verworfen, jede Erfassung laeuft geschuetzt. +-- Kein Kampflog: Beute, Quest-Fenster und Ziele werden ausserhalb des Kampfes gelesen. + +local ADDON, NS = ... + +local FW = _G.Freewood +if not FW or not FW:RequireAPI(1, ADDON) then + return +end + +local Util = FW.Util +local L = FW.L + +local GRID = 200 +local MAX_CELLS = 600 +local MAX_DROPS = 80 +local MAX_OBJ = 12 +local MAX_REWARDS = 12 +local MAX_IDS = 12 +local MAX_NAMES = 6000 +local MAX_LOOTED_GUIDS = 300 +local RECENT_SECS = 60 -- so lange passt ein gelootetes Quest-Item zu einem steigenden Zaehler + +local function GetMeta(field) + local fn = (C_AddOns and C_AddOns.GetAddOnMetadata) or GetAddOnMetadata + if fn then + local ok, value = pcall(fn, ADDON, field) + if ok then + return value + end + end + return nil +end + +local Codex = FW:NewModule("Codex", { + version = GetMeta("Version") or "dev", + title = L.CODEX_TITLE, + description = L.CODEX_DESC, + defaults = { + quests = {}, + units = {}, + objects = {}, + names = {}, + ttk = {}, -- Kampfdauer { [Klasse] = { [Spielerstufe] = { [Stufenabstand] = { n, sum, min, max } } } }, Zehntelsekunden + qtimer = {}, -- laufende Quest-Zeitnehmer (siehe CodexTime.lua) + istats = {}, -- Itemwerte { [itemID] = { [Schluessel] = Zahl } }, vom Spiel gelesen (nur lokal) + disp = {}, -- Creature-Display-IDs fuer 2D-Bilder { [npcID] = displayID } (nur lokal) + cnames = {}, -- von Hand eingetragene Itemnamen { [itemID] = { de, en } } + xp = {}, + xpb = {}, + xr = {}, -- Erfahrung je Kill MIT Ruhebonus (Gesamtwert), gleiche Form wie xp (nur lokal) + xpUnparsed = {}, + objEmpty = {}, + remote = {}, + sync = { stamps = {}, sent = 0, received = 0 }, + settings = { enabled = true, share = true, shareDefaultOn = true }, + recipes = {}, -- Rezepte der Berufe { [Beruf] = { [Name] = { it, rg, ob } } } (siehe CodexRecipes.lua, nur lokal) + wrq = {}, -- Ruf-Anforderungen der Waren { [haendlerID] = { v = 1, [itemID] = { f = Fraktion, s = Stufe 1-8, ok = erfuellt } } } (nur lokal) + vfac = {}, -- Fraktion von Haendlern { [haendlerID] = "A" | "H" } (nur lokal) + ied = {}, -- Eigenschaften von Items { [itemID] = { ed = { z, dg } } }, wird geteilt + inames = {}, -- vom Spiel gelernte Instanznamen { [instanceID] = Name } (nur lokal) + path = {}, -- Wegenetz { [welt] = { [zellKey] = Maske } }, siehe CodexPath.lua (nur lokal) + fav = {}, -- Favoriten und Notizen { ["q123"] = { note, t } }, nur lokal + dnotes = {}, -- Dev-Notizen { ["q123"] = { n = Text, t } }, nur lokal, nur mit Freewood-OctoWoW-Dev und Bearbeitungsrecht + }, +}) +NS.Codex = Codex +FW.Codex = Codex +Codex.GRID = GRID + +local state = { + pool = nil, -- Ruhe-Vorrat (GetXPExhaustion) vor dem naechsten Kill + xpOpen = 0, -- Kills, deren Ruhebonus noch bestimmt wird + xpJobs = {}, + detail = nil, -- zuletzt geoeffnetes Annahme-Fenster + turn = nil, -- zuletzt geoeffnetes Abgabe-Fenster + objState = {}, -- [questID] = { [i] = Anzahl } + recentItems = {}, -- [itemID] = { t, quest, qid }: zuletzt gelootete Items + requested = {}, -- [itemID] = true: Itemdaten schon angefordert + pendingInfo = 0, -- noch nicht angekommene Itemdaten + objNames = {}, -- [objectID] = Name, vom Tooltip gelernt, bevor das Objekt gelootet wurde + repBuf = {}, -- { t, name, amount } + lootedGuids = {}, -- Leichen, die in dieser Sitzung schon ein Beutefenster hatten + lootedItems = {}, -- [guid] = { [itemID] = true }: dort schon gezaehlte Items + lootedCount = 0, + noteAt = {}, -- Drosselung fuer NoteUnit + stats = { quests = 0, loot = 0, errors = 0 }, +} +Codex.runtime = state + +------------------------------------------------------------------------ +-- Kleine Pruefer +------------------------------------------------------------------------ + +local function Num(v) + if Util.IsSecret(v) then + return nil + end + if type(v) == "number" and v == v and v ~= math.huge and v ~= -math.huge then + return v + end + return nil +end + +local function Str(v, max) + if Util.IsSecret(v) then + return nil + end + if type(v) == "string" and v ~= "" then + return v:sub(1, max or 80) + end + return nil +end + +local function Strip(text) + text = text:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""):gsub("|T.-|t", "") + return (text:gsub("^%s+", ""):gsub("%s+$", "")) +end + +local function Now() + return time() +end + +-- Fuehrt fn geschuetzt aus; Fehler (zum Beispiel durch geheime Werte) werden still gezaehlt +local function Safe(fn, ...) + local ok, a, b, c = pcall(fn, ...) + if not ok then + state.stats.errors = state.stats.errors + 1 + if FW.settings and FW.settings.debug then + FW:Debug("Codex: " .. tostring(a)) + end + return nil + end + return a, b, c +end + +------------------------------------------------------------------------ +-- Orte und Raster +------------------------------------------------------------------------ + +local function PlayerPoint() + local loc = Util.GetPlayerLocation() + if loc and loc.mapID and loc.x and loc.y then + return loc.mapID, loc.x, loc.y + end + return nil +end + +local function CellKey(m, x, y) + local cx = math.min(GRID - 1, math.max(0, math.floor(x * GRID))) + local cy = math.min(GRID - 1, math.max(0, math.floor(y * GRID))) + return m * GRID * GRID + cy * GRID + cx +end + +local function NoteInstance(ent) + local id = Codex.CurrentInstance and Codex.CurrentInstance() + if not id then return end + local iz = ent.iz + if not iz then + iz = {} + ent.iz = iz + end + if iz[id] then + iz[id] = iz[id] + 1 + return + end + local n = 0 + for _ in pairs(iz) do n = n + 1 end + if n < 3 then iz[id] = 1 end +end + +local function AddCell(ent) + NoteInstance(ent) + local m, x, y = PlayerPoint() + if not m then + return + end + ent.cells = ent.cells or {} + local key = CellKey(m, x, y) + if ent.cells[key] then + ent.cells[key] = ent.cells[key] + 1 + return + end + local n = 0 + for _ in pairs(ent.cells) do + n = n + 1 + if n >= MAX_CELLS then + return + end + end + ent.cells[key] = 1 +end + +-- Rueckgabe: Karten-ID und Zelle als Liste { { m, cx, cy, n } } +function Codex.DecodeCells(cells) + local list = {} + for key, n in pairs(cells or {}) do + local m = math.floor(key / (GRID * GRID)) + local idx = key % (GRID * GRID) + list[#list + 1] = { m = m, cx = idx % GRID, cy = math.floor(idx / GRID), n = n } + end + return list +end + +------------------------------------------------------------------------ +-- Instanzen: das Spiel nennt in Dungeons oft keinen Namen, aber die Instanz-ID (GetInstanceInfo, 8. Rueckgabe). +-- Die ID ist sprachunabhaengig. Als "Zone" wird sie negativ gespeichert (-389 = Flammenschlund). +------------------------------------------------------------------------ + +Codex.INSTANCES = { + [30] = { "Alteractal", "Alterac Valley" }, [33] = { "Burg Schattenfang", "Shadowfang Keep" }, + [34] = { "Das Verlies", "The Stockade" }, [36] = { "Die Todesminen", "The Deadmines" }, + [43] = { "Die Höhlen des Wehklagens", "Wailing Caverns" }, [47] = { "Kral der Klingenhauer", "Razorfen Kraul" }, + [48] = { "Die Tiefschwarze Grotte", "Blackfathom Deeps" }, [70] = { "Uldaman", "Uldaman" }, + [90] = { "Gnomeregan", "Gnomeregan" }, [109] = { "Der Tempel von Atal'Hakkar", "The Temple of Atal'Hakkar" }, + [129] = { "Hügel der Klingenhauer", "Razorfen Downs" }, [189] = { "Das Scharlachrote Kloster", "Scarlet Monastery" }, + [209] = { "Zul'Farrak", "Zul'Farrak" }, [229] = { "Schwarzfelsspitze", "Blackrock Spire" }, + [230] = { "Die Schwarzfels-Tiefen", "Blackrock Depths" }, [249] = { "Onyxias Hort", "Onyxia's Lair" }, + [289] = { "Scholomance", "Scholomance" }, [309] = { "Zul'Gurub", "Zul'Gurub" }, + [329] = { "Stratholme", "Stratholme" }, [349] = { "Maraudon", "Maraudon" }, + [369] = { "Die Tiefenbahn", "The Deeprun Tram" }, [389] = { "Flammenschlund", "Ragefire Chasm" }, + [409] = { "Geschmolzener Kern", "Molten Core" }, [429] = { "Düsterbruch", "Dire Maul" }, + [469] = { "Pechschwingenhort", "Blackwing Lair" }, [489] = { "Kriegshymnenschlucht", "Warsong Gulch" }, + [509] = { "Ruinen von Ahn'Qiraj", "Ruins of Ahn'Qiraj" }, [529] = { "Arathibecken", "Arathi Basin" }, + [531] = { "Tempel von Ahn'Qiraj", "Temple of Ahn'Qiraj" }, [533] = { "Naxxramas", "Naxxramas" }, +} + +-- Name einer Instanz: eingebaute Liste (Deutsch / Englisch), sonst vom Spiel gelernt, sonst "Instanz #ID" +function Codex.InstanceName(id) + local en = Codex.Lang() == "en" + local b = Codex.INSTANCES[id] + if b then return en and b[2] or b[1] end + local learned = Codex.db and Codex.db.inames and Codex.db.inames[id] + if learned then return learned end + return string.format(FW.L.CODEX_INSTANCE_UNKNOWN or "Instance #%d", id) +end + +-- Instanz-ID, in der wir gerade stehen (nil in der offenen Welt) +function Codex.CurrentInstance() + if not (IsInInstance and GetInstanceInfo) then return nil end + local ok, inside, itype = pcall(IsInInstance) + if not ok or Util.IsSecret(inside) or not inside or Util.IsSecret(itype) or itype == "none" then return nil end + local ok2, name, _, _, _, _, _, _, id = pcall(GetInstanceInfo) + if not ok2 or Util.IsSecret(id) or type(id) ~= "number" or id < 1 or id > 100000 then return nil end + -- lesbaren Namen fuer unbekannte Instanzen merken (nur lokal, in der aktuellen Sprache) + if type(name) == "string" and name ~= "" and not Util.IsSecret(name) and not Codex.INSTANCES[id] and Codex.db then + Codex.db.inames = Codex.db.inames or {} + Codex.db.inames[id] = name:sub(1, 60) + end + return id +end + +-- Kreaturtyp (UnitCreatureType, je nach Client englisch oder deutsch) -> fester Kurzname +Codex.CT_NAMES = { + ["beast"] = "beast", ["wildtier"] = "beast", ["bestie"] = "beast", + ["humanoid"] = "humanoid", ["humanoide"] = "humanoid", + ["undead"] = "undead", ["untoter"] = "undead", ["untote"] = "undead", + ["demon"] = "demon", ["d\195\164mon"] = "demon", ["daemon"] = "demon", ["dämon"] = "demon", + ["dragonkin"] = "dragonkin", ["drachkin"] = "dragonkin", + ["elemental"] = "elemental", ["elementar"] = "elemental", + ["giant"] = "giant", ["riese"] = "giant", + ["mechanical"] = "mechanical", ["mechanisch"] = "mechanical", + ["critter"] = "critter", ["kleintier"] = "critter", + ["totem"] = "totem", + ["gas cloud"] = "gascloud", ["gaswolke"] = "gascloud", +} +Codex.CT_CODES = { + beast = true, humanoid = true, undead = true, demon = true, dragonkin = true, elemental = true, + giant = true, mechanical = true, critter = true, totem = true, gascloud = true, other = true, pet = true, +} + +function Codex.MapName(mapID) + if type(mapID) == "number" and mapID < 0 then + return Codex.InstanceName(-mapID) + end + if C_Map and C_Map.GetMapInfo and mapID then + local ok, info = pcall(C_Map.GetMapInfo, mapID) + if ok and type(info) == "table" and type(info.name) == "string" then + return info.name + end + end + return mapID and ("#" .. mapID) or "?" +end + +-- Hauptkarte einer Zellenmenge: Karte mit den meisten Treffern +function Codex.MainMap(cells) + local per, best, bestN = {}, nil, 0 + for key, n in pairs(cells or {}) do + local m = math.floor(key / (GRID * GRID)) + per[m] = (per[m] or 0) + n + if per[m] > bestN then + best, bestN = m, per[m] + end + end + return best +end + +------------------------------------------------------------------------ +-- GUIDs, Einheiten, Objekte +------------------------------------------------------------------------ + +-- "Creature-0-4621-1-27730-4107-0000477262" -> "npc", 4107; GameObject -> "object", id +local function ParseGUID(guid) + if Util.IsSecret(guid) or type(guid) ~= "string" then + return nil + end + local kind, _, _, _, _, id = strsplit("-", guid) + id = tonumber(id) + if not id then + local hk, hid = Util.ParseHexGUID(guid) + if not hk then + return nil + end + kind, id = hk, hid + end + if kind == "Creature" or kind == "Vehicle" then + return "npc", id + end + if kind == "GameObject" then + return "object", id + end + return nil +end +Codex.ParseGUID = ParseGUID + +function Codex:Unit(id, create) + local u = self.db.units[id] + if not u and create then + u = {} + self.db.units[id] = u + end + return u +end + +function Codex:Object(id, create) + local o = self.db.objects[id] + if not o and create then + o = {} + self.db.objects[id] = o + end + return o +end + +function Codex:Quest(id, create) + local q = self.db.quests[id] + if not q and create then + q = { seen = 0, done = 0, first = Now() } + self.db.quests[id] = q + state.stats.quests = state.stats.quests + 1 + end + return q +end + +local function NoteName(itemID, name) + name = Str(name, 80) + if not itemID or not name then + return + end + local names = Codex.db.names + if names[itemID] == name then + return + end + if names[itemID] == nil then + local n = 0 + for _ in pairs(names) do + n = n + 1 + if n >= MAX_NAMES then + return + end + end + end + names[itemID] = name +end + +-- Qualitaet (Farbe) eines Items merken, damit Namen auch farbig sind, wenn das Spiel das Item gerade nicht kennt +function Codex.RememberQuality(itemID, quality) + if type(itemID) ~= "number" or type(quality) ~= "number" or quality < 0 or quality > 8 then + return + end + local iq = Codex.db.iq + if not iq then + iq = {} + Codex.db.iq = iq + end + if iq[itemID] == quality then + return + end + if iq[itemID] == nil then + local n = 0 + for _ in pairs(iq) do + n = n + 1 + if n >= 8000 then + return + end + end + end + iq[itemID] = quality +end + +function Codex.QualityOf(itemID) + local info = Util.GetItem(itemID) + if info and info.quality then + Codex.RememberQuality(itemID, info.quality) + return info.quality + end + return Codex.db.iq and Codex.db.iq[itemID] or nil +end + +Codex.AddCell = AddCell +Codex.NoteName = NoteName +Codex.Str = Str +Codex.Safe = Safe + +-- Einheit am Unit-Token beobachten (Name, Stufe, Fundort). Nur angreifbare Kreaturen. +function Codex:NoteUnit(unit) + if Util.IsSecret(unit) or not UnitExists(unit) then + return + end + local isPlayer, controlled = UnitIsPlayer(unit), UnitPlayerControlled(unit) + if Util.IsSecret(isPlayer) or Util.IsSecret(controlled) or isPlayer or controlled then + return + end + local canAttack = UnitCanAttack and UnitCanAttack("player", unit) + if Util.IsSecret(canAttack) or not canAttack then + return + end + local kind, id = ParseGUID(UnitGUID(unit)) + if kind ~= "npc" then + return + end + local now = GetTime and GetTime() or 0 + if state.noteAt[id] and now - state.noteAt[id] < 1.5 then + return + end + state.noteAt[id] = now + + local u = self:Unit(id, true) + local name = Str(UnitName(unit), 60) + if name then + u.n = name + end + local lvl = Num(UnitLevel and UnitLevel(unit)) + if lvl and lvl > 0 then + u.l1 = u.l1 and math.min(u.l1, lvl) or lvl + u.l2 = u.l2 and math.max(u.l2, lvl) or lvl + end + local class = UnitClassification and Str(UnitClassification(unit), 16) + if class and class ~= "normal" and class ~= "trivial" and class ~= "minus" then + u.c = class + end + local family = UnitCreatureFamily and Str(UnitCreatureFamily(unit), 40) + if family then + u.f = family + end + local ctype = UnitCreatureType and Str(UnitCreatureType(unit), 40) + local ctcode = ctype and Codex.CT_NAMES[ctype:lower()] + if ctcode then + u.ct = ctcode + end + AddCell(u) +end + +-- Objektnamen kommen vom Tooltip (Mouseover auf Truhe, Kraut usw.); data.id ist die Objekt-ID +function Codex:OnObjectTooltip(data) + Safe(function() + if type(data) ~= "table" or Util.IsSecret(data) or not self.db.settings.enabled then + return + end + if TooltipUtil and TooltipUtil.SurfaceArgs then + pcall(TooltipUtil.SurfaceArgs, data) + end + local id = Num(data.id) + local line = type(data.lines) == "table" and data.lines[1] or nil + local name = type(line) == "table" and Str(line.leftText, 60) or nil + if not id or not name then + return + end + Codex.lastObject = { id = id, name = name, t = (GetTime and GetTime()) or 0 } + local o = self.db.objects[id] + if o then + o.n = name + else + local count = 0 + for _ in pairs(state.objNames) do count = count + 1 end + if count >= 200 then + state.objNames = {} + end + state.objNames[id] = name + end + end) +end + +function Codex:OnTargetChanged() + Safe(self.NoteUnit, self, "target") + if self.Names then Safe(self.Names.Read, self.Names, "target", "target") end +end + +function Codex:OnMouseover() + Safe(self.NoteUnit, self, "mouseover") + if self.Names then Safe(self.Names.Read, self.Names, "mouseover", "mouse") end +end + +------------------------------------------------------------------------ +-- Quest-Fenster lesen +------------------------------------------------------------------------ + +-- NPC oder Objekt, mit dem gerade gesprochen wird. nil bei Item-Quests. +local function ReadInteraction() + local ref = { k = "other" } + local guid = UnitGUID and UnitGUID("npc") + local kind, id = ParseGUID(guid) + if kind then + ref.k = kind + ref.id = id + ref.n = Str(UnitName("npc"), 60) + if kind == "npc" then + local u = Codex:Unit(id, true) + if ref.n then + u.n = ref.n + end + else + local o = Codex:Object(id, true) + if ref.n then + o.n = ref.n + end + end + end + local m, x, y = PlayerPoint() + if m then + ref.m, ref.x, ref.y = m, Util.Round(x, 3), Util.Round(y, 3) + end + return ref +end + +local function ItemIDFromLink(link) + link = Util.Clean(link) + if type(link) ~= "string" then + return nil + end + return tonumber(link:match("item:(%d+)")) +end + +local function ReadRewardList(kind, count) + local list = {} + for i = 1, math.min(count or 0, MAX_REWARDS) do + local link = GetQuestItemLink and GetQuestItemLink(kind, i) + local id = ItemIDFromLink(link) + local name, _, c + if GetQuestItemInfo then + name, _, c = GetQuestItemInfo(kind, i) + end + if id then + list[#list + 1] = { id = id, c = Num(c) or 1 } + NoteName(id, name) + end + end + return list +end + +local function ReadRewards() + local r = {} + r.xp = Num(GetRewardXP and GetRewardXP()) + r.money = Num(GetRewardMoney and GetRewardMoney()) + r.fixed = ReadRewardList("reward", Num(GetNumQuestRewards and GetNumQuestRewards())) + r.choice = ReadRewardList("choice", Num(GetNumQuestChoices and GetNumQuestChoices())) + return r +end + +local function ApplyRewards(q, r, final) + if not r then + return + end + if r.xp and r.xp > 0 and (final or not q.xp) then + q.xp = r.xp + end + if r.money and (final or not q.money) then + q.money = r.money + end + if #r.fixed > 0 or final then + q.fixed = #r.fixed > 0 and r.fixed or nil + end + if #r.choice > 0 or final then + q.choice = #r.choice > 0 and r.choice or nil + end +end + +------------------------------------------------------------------------ +-- Quest-Ziele +------------------------------------------------------------------------ + +-- Nur das Verb ("getötet") ist kein Name: so entstanden Ziele ohne Namen, wenn der Server den Kreaturnamen noch nicht kannte +local VERBS = { "getötet", "besiegt", "erschlagen", "beseitigt", "vernichtet", "zerstört", "erledigt", "bezwungen", + "slain", "killed", "defeated", "destroyed", "vanquished", "eliminated" } +local VERB_ONLY = {} +for _, v in ipairs(VERBS) do VERB_ONLY[v] = true end +VERB_ONLY["zur strecke gebracht"] = true +function Codex.IsBadObjName(name) + return type(name) == "string" and VERB_ONLY[name:lower()] == true +end + +-- Verb am Ende entfernen ("Wolf getötet" -> "Wolf"); nur das Verb ergibt nil +function Codex.StripKillVerb(name) + if type(name) ~= "string" then return nil end + name = name:gsub("^[%s:]+", ""):gsub("[%s:]+$", "") + for _ = 1, 2 do + local low = name:lower() + local cut + for _, v in ipairs(VERBS) do + local suffix = " " .. v + if #low > #suffix and low:sub(-#suffix) == suffix then cut = #suffix break end + end + if not cut then + local sfx = " zur strecke gebracht" + if #low > #sfx and low:sub(-#sfx) == sfx then cut = #sfx end + end + if not cut then break end + name = name:sub(1, #name - cut) + end + name = name:gsub("^[%s:]+", ""):gsub("[%s:]+$", "") + if name == "" or Codex.IsBadObjName(name) then return nil end + return name +end + +local function ObjectiveName(text) + text = Strip(text) + local name = text:match("^(.-):%s*%d+%s*/%s*%d+") or text:match("^%d+%s*/%s*%d+%s+(.+)$") or text + -- Kill-Ziele heissen "Wolf getoetet" / "Wolf slain": das Verb gehoert nicht zum Namen + name = Codex.StripKillVerb(name) or "" + -- Nur Zaehler ohne Namen ("0/3"): kein Name + if name == "" or name:match("^%d+%s*/%s*%d+$") or Codex.IsBadObjName(name) then + return nil + end + return name:sub(1, 60) +end + +-- Ziele ohne lesbaren Namen merken (fuer /fwcodex test) +local function NoteEmptyObjective(ty, text) + local list = Codex.db and Codex.db.objEmpty + if not list or #list >= 5 then + return + end + local entry = tostring(ty) .. " | " .. tostring(text):sub(1, 100) + for _, e in ipairs(list) do + if e == entry then + return + end + end + list[#list + 1] = entry +end + +-- Liest die Ziele einer Quest: Liste { ty, text, name, have, req, done } +local function ReadObjectives(questID) + local list = {} + 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 + local text = Str(o.text, 120) + list[#list + 1] = { + ty = Str(o.type, 16) or "event", + text = text and Strip(text) or "", + have = Num(o.numFulfilled) or 0, + req = Num(o.numRequired) or 0, + done = o.finished and true or false, + } + end + end + end + return list + end + local index = GetQuestLogIndexByID and GetQuestLogIndexByID(questID) + if index and GetNumQuestLeaderBoards then + for i = 1, GetNumQuestLeaderBoards(index) do + local text, ty, done = GetQuestLogLeaderBoard(i, index) + text = Str(text, 120) + local have, req = (text or ""):match("(%d+)%s*/%s*(%d+)") + list[#list + 1] = { + ty = Str(ty, 16) or "event", + text = text and Strip(text) or "", + have = tonumber(have) or 0, + req = tonumber(req) or 0, + done = done and true or false, + } + end + end + return list +end + +local function StoreObjectives(q, list) + local old = q.obj or {} + local new = {} + for i, o in ipairs(list) do + if i > MAX_OBJ then + break + end + local prev = old[i] + local nm = o.text ~= "" and ObjectiveName(o.text) or nil + if not nm and prev and prev.name and not Codex.IsBadObjName(prev.name) then + nm = prev.name -- ein schon bekannter Name geht nicht wieder verloren + end + new[i] = { + ty = o.ty, + txt = o.text ~= "" and o.text:sub(1, 100) or nil, + name = nm, + req = o.req > 0 and o.req or nil, + ids = prev and prev.ids or nil, + item = prev and prev.item or nil, + } + if not new[i].name and not new[i].item then + NoteEmptyObjective(o.ty, o.text) + end + end + if #new > 0 then + q.obj = new + end +end + +local function QuestLogInfo(questID) + if C_QuestLog and C_QuestLog.GetLogIndexForQuestID then + local index = C_QuestLog.GetLogIndexForQuestID(questID) + if index and C_QuestLog.GetInfo then + local info = C_QuestLog.GetInfo(index) + if type(info) == "table" then + return Str(info.title, 100), Num(info.level) + end + end + end + if GetQuestLogIndexByID and GetQuestLogTitle then + local index = GetQuestLogIndexByID(questID) + if index and index > 0 then + local title, level = GetQuestLogTitle(index) + return Str(title, 100), Num(level) + end + end + return nil +end + +local function TrackObjectives(questID, list) + local counts = {} + for i, o in ipairs(list) do + counts[i] = o.have + end + state.objState[questID] = counts +end + +-- Ziele aller Quests im Log pruefen; steigt ein Kill-Zaehler, wird das anvisierte Ziel gelernt +function Codex:CheckObjectives() + for questID, counts in pairs(state.objState) do + local list = ReadObjectives(questID) + local q = self:Quest(questID, false) + if #list == 0 then + state.objState[questID] = nil + else + for i, o in ipairs(list) do + local before = counts[i] + local entry = q and q.obj and q.obj[i] + if entry then + -- fehlender Name: aus dem aktuellen Text nachtragen (Itemnamen sind oft erst spaeter geladen) + if (not entry.name or Codex.IsBadObjName(entry.name)) and o.text ~= "" then + local nm = ObjectiveName(o.text) + if nm then + entry.name = nm + entry.txt = o.text:sub(1, 100) + if self.LinkObjectives then self.LinkObjectives(q, questID) end + end + end + if before and o.have > before then + if o.ty == "monster" then + self:LearnKillTarget(entry, o) + elseif o.ty == "item" then + self:LearnObjectiveItem(questID, entry, o.have - before, i) + end + end + end + counts[i] = o.have + end + if #list ~= #(q and q.obj or {}) and q then + StoreObjectives(q, list) + end + end + end +end + +-- Steigt der Zaehler eines Sammel-Ziels, war es meist das Quest-Item, das gerade gelootet wurde. +-- Genau ein Kandidat (oder ein Namenstreffer) wird dem Ziel zugeordnet. +function Codex:LearnObjectiveItem(questID, entry, delta, idx) + if entry.item then + return + end + local now = Now() + local want = entry.name and entry.name:lower() + -- Kandidaten: kuerzlich gelootete Items dieser Quest (oder ohne Quest-Angabe); mit passender Menge zuerst + local strict, loose = {}, {} + local nStrict, nLoose, exact = 0, 0, nil + local key = tostring(questID) .. ":" .. tostring(idx or 0) + state.objRise = state.objRise or {} + local since = state.objRise[key] -- nur Beute nach dem letzten Anstieg dieses Zaehlers zaehlt + state.objRise[key] = now + for itemID, r in pairs(state.recentItems) do + if now - r.t <= RECENT_SECS and (not since or r.t > since) and (not r.qid or r.qid == 0 or r.qid == questID) then + loose[itemID] = true + nLoose = nLoose + 1 + if not delta or not r.qty or r.qty == delta then + strict[itemID] = true + nStrict = nStrict + 1 + end + local nm = self.db.names[itemID] + if want and nm and nm:lower() == want then + exact = itemID + end + end + end + local cands, n = strict, nStrict + if n == 0 then cands, n = loose, nLoose end + local found + if exact then + found = exact + else + -- ueber mehrere Zaehler-Anstiege schneiden sich die Kandidaten auf genau ein Item zusammen + state.objCand = state.objCand or {} + local prior = state.objCand[key] + if prior then + local cut, cn = {}, 0 + for itemID in pairs(cands) do + if prior[itemID] then cut[itemID] = true cn = cn + 1 end + end + if cn > 0 then cands, n = cut, cn end + end + state.objCand[key] = cands + if n == 1 then + found = next(cands) + end + if found then state.objCand[key] = nil end + end + if found then + entry.item = found + -- Quelle(n) des ersten Fundes gleich eintragen (spaetere Funde laufen ueber die Item-ID) + local r = state.recentItems[found] + local q = self:Quest(questID, false) + if r and q and r.srcs then + q.qi = q.qi or {} + q.qi[found] = q.qi[found] or {} + for _, sk in ipairs(r.srcs) do + q.qi[found][sk] = (q.qi[found][sk] or 0) + 1 + end + end + FW:Fire("CODEX_CHANGED", "quest", questID) + end +end + +-- Quest-Zuordnungen von Hand: Index { kill[questID] = { ["u38"]=true }, item[questID][itemID] = { ["u38"]=true } } +local qsrc = { rev = -1 } +local function QuestSrcIndex() + local V = Codex.V() + if qsrc.rev == Codex.chainRev and qsrc.v == V then return qsrc end + qsrc = { rev = Codex.chainRev, v = V, kill = {}, item = {}, rel = {}, relOf = {} } + for _, def in ipairs({ { "units", "u" }, { "objects", "o" } }) do + for id, e in pairs(V[def[1]] or {}) do + local ed = type(e) == "table" and e.ed or nil + if type(ed) == "table" then + for _, qid in ipairs(ed.qk or {}) do + local t = qsrc.kill[qid] + if not t then t = {} qsrc.kill[qid] = t end + t[def[2] .. id] = true + end + for _, s in ipairs(ed.qd or {}) do + local q, it = tostring(s):match("^(%d+):(%d+)$") + q, it = tonumber(q), tonumber(it) + if q and it then + local byItem = qsrc.item[q] + if not byItem then byItem = {} qsrc.item[q] = byItem end + local t = byItem[it] + if not t then t = {} byItem[it] = t end + t[def[2] .. id] = true + end + end + end + end + end + -- gehoert zur Quest: von der Kreatur/dem Objekt/dem Item aus (qb) und von der Quest aus (qr) + local function Rel(qid, letter, id) + local r = qsrc.rel[qid] + if not r then r = { i = {}, u = {}, o = {} } qsrc.rel[qid] = r end + r[letter][id] = true + local key = letter .. id + local of = qsrc.relOf[key] + if not of then of = {} qsrc.relOf[key] = of end + of[qid] = true + end + for _, def in ipairs({ { "units", "u" }, { "objects", "o" }, { "ied", "i" } }) do + for id, e in pairs(V[def[1]] or {}) do + local ed = type(e) == "table" and e.ed or nil + if type(ed) == "table" then + for _, qid in ipairs(ed.qb or {}) do Rel(qid, def[2], id) end + end + end + end + for qid, q in pairs(V.quests or {}) do + local ed = type(q) == "table" and q.ed or nil + if type(ed) == "table" then + for _, tok in ipairs(ed.qr or {}) do + local k, n = tostring(tok):match("^([iuo]):(%d+)$") + if k and n then Rel(qid, k, tonumber(n)) end + end + end + end + return qsrc +end +-- Zugeordnet zu einer Quest: { i = {Item-IDs}, u = {Kreatur-IDs}, o = {Objekt-IDs} } (sortiert) +function Codex.QuestRelated(qid) + local r = QuestSrcIndex().rel[qid] + local out = { i = {}, u = {}, o = {} } + if r then + for letter in pairs(out) do + for id in pairs(r[letter]) do out[letter][#out[letter] + 1] = id end + table.sort(out[letter]) + end + end + return out +end +-- Quests, zu denen ein Item (letter "i"), eine Kreatur ("u") oder ein Objekt ("o") gehoert (sortiert) +function Codex.EntityQuests(letter, id) + local set = QuestSrcIndex().relOf[letter .. id] + local out = {} + for qid in pairs(set or {}) do out[#out + 1] = qid end + table.sort(out) + return out +end +function Codex.QuestKillKeys(qid) return QuestSrcIndex().kill[qid] or {} end +function Codex.QuestItemKeys(qid, itemID) + local byItem = QuestSrcIndex().item[qid] + return byItem and byItem[itemID] or {} +end +function Codex.QuestManualItems(qid) + local out = {} + for itemID in pairs(QuestSrcIndex().item[qid] or {}) do out[#out + 1] = itemID end + table.sort(out) + return out +end +-- Was ein Eintrag von Hand zugeordnet hat: Ziel-Quests (Liste) und Quest-Items (Liste "Quest:Item") +function Codex.ManualQuestLinks(kind, id) + local V = Codex.V() + local e = (kind == "units" and V.units or V.objects)[id] + local ed = type(e) == "table" and e.ed or nil + return ed and ed.qk or {}, ed and ed.qd or {} +end + +-- Items eines Ziels: gelernte Item-ID, Namenstreffer oder (bei genau einem Item-Ziel) die Quest-Items der Quest +function Codex.ObjectiveItems(q, o, qid) + local ids = {} + if o.item then + ids[1] = o.item + return ids + end + local lname = o.name and o.name:lower() + if lname then + for itemID, name in pairs(Codex.db.names) do + if name:lower() == lname then + ids[#ids + 1] = itemID + end + end + end + if #ids == 0 and o.ty == "item" and q.qi then + local itemObjs = 0 + for _, ob in ipairs(q.obj or {}) do + if ob.ty == "item" then + itemObjs = itemObjs + 1 + end + end + if itemObjs == 1 then + for itemID in pairs(q.qi) do + ids[#ids + 1] = itemID + end + end + end + -- von Hand eingetragene Quest-Items (bei genau einem Item-Ziel) + if #ids == 0 and o.ty == "item" and qid then + local itemObjs = 0 + for _, ob in ipairs(q.obj or {}) do + if ob.ty == "item" then itemObjs = itemObjs + 1 end + end + if itemObjs == 1 then + for _, itemID in ipairs(Codex.QuestManualItems(qid)) do ids[#ids + 1] = itemID end + end + end + table.sort(ids) + return ids +end + +-- Kill-Ziele einer Quest mit den Kreaturen der Vorlage verknuepfen (IDs eintragen, fehlende Namen auffuellen). +-- Erst nach Namen; Ziele ohne Namen nur der Reihe nach und nur, wenn genau so viele Kreaturen uebrig sind wie Ziele. +function Codex.LinkObjectives(q, qid) + local Seed = Codex.Seed + if not (q and q.obj and Seed and Seed.Ready and Seed.Ready()) then return false end + local sq = Seed.Quest(qid) + if not sq then return false end + local units = {} + for _, r in ipairs(sq.obj or {}) do + if r.kind == "units" then units[#units + 1] = r.id end + end + if #units == 0 then return false end + local mons = {} + for _, o in ipairs(q.obj) do + if o.ty == "monster" then mons[#mons + 1] = o end + end + if #mons == 0 then return false end + local changed, used = false, {} + for _, o in ipairs(mons) do + if o.name and not Codex.IsBadObjName(o.name) then + local l = o.name:lower() + for _, uid in ipairs(units) do + local nm = Seed.Name("units", uid) + if nm and nm:lower() == l then + used[uid] = true + if not (o.ids and o.ids[uid]) then + o.ids = o.ids or {} + o.ids[uid] = 1 + changed = true + end + end + end + end + end + local open, free = {}, {} + for _, o in ipairs(mons) do + if (not o.name or Codex.IsBadObjName(o.name)) and not (o.ids and next(o.ids)) then open[#open + 1] = o end + end + for _, uid in ipairs(units) do + if not used[uid] then free[#free + 1] = uid end + end + if #open > 0 and #open == #free then + for i, o in ipairs(open) do + local nm = Seed.Name("units", free[i]) + if nm then + o.name = nm + o.ids = { [free[i]] = 1 } + changed = true + end + end + end + return changed +end + +-- einmal je Sitzung: alle bekannten Quests nachtragen (sobald die Vorlage geladen ist) +function Codex.RepairObjectives() + if Codex.objRepaired or not (Codex.db and Codex.db.quests) then return 0 end + local Seed = Codex.Seed + if not (Seed and Seed.Ready and Seed.Ready()) then return 0 end + Codex.objRepaired = true + local n = 0 + for qid, q in pairs(Codex.db.quests) do + if type(q) == "table" and q.obj and Codex.LinkObjectives(q, qid) then n = n + 1 end + end + return n +end + +function Codex:LearnKillTarget(objEntry, o) + local guid = UnitGUID and UnitGUID("target") + local kind, id = ParseGUID(guid) + if kind ~= "npc" then + return + end + local name = Str(UnitName("target"), 60) + if not name then + return + end + local want = (objEntry.name or o.text or ""):lower() + local have = name:lower() + if want == "" or not (want == have or want:find(have, 1, true) or have:find(want, 1, true)) then + return + end + objEntry.ids = objEntry.ids or {} + if objEntry.ids[id] == nil then + local n = 0 + for _ in pairs(objEntry.ids) do + n = n + 1 + end + if n >= MAX_IDS then + return + end + end + objEntry.ids[id] = (objEntry.ids[id] or 0) + 1 + local u = self:Unit(id, true) + u.n = u.n or name +end + +------------------------------------------------------------------------ +-- Ruf +------------------------------------------------------------------------ + +local function FormatToPattern(fmt) + if type(fmt) ~= "string" then + return nil + end + local order = {} + local marked = fmt:gsub("%%(%d?)%$?([sd])", function(_, kind) + order[#order + 1] = kind + return "\1" .. kind .. "\2" + end) + marked = marked:gsub("([%^%$%(%)%.%[%]%*%+%-%?%%])", "%%%1") + marked = marked:gsub("\1s\2", "(.-)"):gsub("\1d\2", "(%%d+)") + return "^" .. marked .. "$", order +end + +local repPatterns = {} +local function BuildRepPatterns() + repPatterns = {} + for _, def in ipairs({ { "FACTION_STANDING_INCREASED", 1 }, { "FACTION_STANDING_DECREASED", -1 } }) do + local fmt = _G[def[1]] + local pattern, order = FormatToPattern(fmt) + if pattern then + local nameIdx, amountIdx + for i, kind in ipairs(order) do + if kind == "s" and not nameIdx then nameIdx = i end + if kind == "d" and not amountIdx then amountIdx = i end + end + repPatterns[#repPatterns + 1] = { pattern = pattern, name = nameIdx or 1, amount = amountIdx or 2, sign = def[2] } + end + end +end + +function Codex:OnFactionMessage(text) + Safe(function() + text = Util.Clean(text) + if type(text) ~= "string" then + return + end + if #repPatterns == 0 then + BuildRepPatterns() + end + for _, p in ipairs(repPatterns) do + local caps = { text:match(p.pattern) } + if #caps >= 2 then + local amount = tonumber(caps[p.amount]) + local name = Str(caps[p.name], 60) + if amount and name then + state.repBuf[#state.repBuf + 1] = { t = GetTime and GetTime() or 0, name = name, amount = amount * p.sign } + while #state.repBuf > 20 do + table.remove(state.repBuf, 1) + end + end + return + end + end + end) +end + +------------------------------------------------------------------------ +-- Quest-Ereignisse +------------------------------------------------------------------------ + +-- Item aus der Tasche benutzt (Rechtsklick): merken, damit eine Quest ohne NPC dem Item zugeordnet werden kann +function Codex.NoteItemUse(bag, slot) + Safe(function() + local id + local ci = _G.C_Container + if ci and ci.GetContainerItemID then id = ci.GetContainerItemID(bag, slot) + elseif _G.GetContainerItemID then id = _G.GetContainerItemID(bag, slot) end + id = Num(id) + if id then state.itemUse = { id = id, t = GetTime and GetTime() or 0 } end + end) +end + +-- Quest-Texte: bereinigt (keine Farbcodes), Absatz als {br}, der eigene Charaktername als $N +local function ReplaceWord(s, word, repl) + local low, lw, out, pos = s:lower(), word:lower(), {}, 1 + local n = 0 + while true do + local a, b = low:find(lw, pos, true) + if not a then break end + local before = a > 1 and s:sub(a - 1, a - 1) or "" + local after = s:sub(b + 1, b + 1) + n = n + 1 + if not before:find("[%a\128-\255]") and not after:find("[%a\128-\255]") then + out[n] = s:sub(pos, a - 1) .. repl + else + out[n] = s:sub(pos, b) + end + pos = b + 1 + end + out[n + 1] = s:sub(pos) + return table.concat(out) +end + +local function CleanQText(s, cap) + if type(s) ~= "string" or s == "" then return nil end + s = s:gsub("|[cC]%x%x%x%x%x%x%x%x", ""):gsub("|[rR]", "") + s = s:gsub("\r", ""):gsub("\n%s*\n+", "{br}"):gsub("\n", " ") + s = s:gsub("|", ""):gsub("%c", " "):gsub("%s+", " ") + s = s:gsub("^%s+", ""):gsub("%s+$", "") + local name = UnitName and UnitName("player") + if type(name) == "string" and #name >= 2 then + s = ReplaceWord(s, name, "$N") + end + if #s < 3 then return nil end + return s:sub(1, cap) +end +Codex.CleanQText = CleanQText + +local QTEXT_CAP = { d = 900, o = 400, p = 400, r = 600 } + +-- Merkt Texte in der Quest (nur was noch fehlt). Rueckgabe: true, wenn etwas Neues dazukam +function Codex.StoreQText(q, tx) + if type(q) ~= "table" or type(tx) ~= "table" then return false end + local added = false + for f in pairs(QTEXT_CAP) do + local v = tx[f] + if type(v) == "string" and v ~= "" then + q.tx = q.tx or {} + if not q.tx[f] then + q.tx[f] = v + added = true + end + end + end + return added +end + +-- Quest zu einem offenen Quest-Fenster (Fortschritt/Abgabe): ueber die Quest-ID, sonst ueber den Titel +local function QuestForWindow() + local qid = Num(GetQuestID and GetQuestID()) + local quests = Codex.db and Codex.db.quests + if type(quests) ~= "table" then return nil end + if qid and quests[qid] then return qid, quests[qid] end + local title = Str(GetTitleText and GetTitleText(), 100) + if not title then return nil end + local found, foundQ + for id, q in pairs(quests) do + if type(q) == "table" and q.t == title then + if found then return nil end -- nicht eindeutig + found, foundQ = id, q + end + end + return found, foundQ +end + +function Codex:OnQuestProgress() + Safe(function() + local id, q = QuestForWindow() + if not q then return end + if Codex.StoreQText(q, { p = CleanQText(GetProgressText and GetProgressText(), QTEXT_CAP.p) }) then + FW:Fire("CODEX_CHANGED", "quest", id) + end + end) +end + +function Codex:OnQuestDetail(startItem) + Safe(function() + local si = Num(startItem) and Num(startItem) > 0 and Num(startItem) or nil + local nowT = GetTime and GetTime() or 0 + local giver0 = ReadInteraction() + local learned + if not si and giver0.k == "other" and state.itemUse and nowT - state.itemUse.t <= 20 then + si, learned = state.itemUse.id, true -- Quest ohne NPC kurz nach Item-Benutzung: das Item startet sie + end + Codex.lastStartItem = { id = si, learned = learned, giver = giver0.k, at = nowT, used = state.itemUse and state.itemUse.id } + state.detail = { + si = si, + t = GetTime and GetTime() or 0, + title = Str(GetTitleText and GetTitleText(), 100), + qid = Num(GetQuestID and GetQuestID()), + tx = { + d = CleanQText(GetQuestText and GetQuestText(), QTEXT_CAP.d), + o = CleanQText(GetObjectiveText and GetObjectiveText(), QTEXT_CAP.o), + }, + giver = giver0, + rew = ReadRewards(), + } + Codex.LearnChainOffer(state.detail) + end) +end + +-- Folgequest lernen: Das Spiel bietet die naechste Quest einer Kette gleich nach der Abgabe beim selben NPC an. +-- Erkannt wird sie am Angebot (QUEST_DETAIL) in den ersten Sekunden nach der Abgabe; eine Quest, die man spaeter +-- von Hand beim selben NPC waehlt, zaehlt nicht. Es zaehlt der Zeitpunkt des Angebots, nicht der der Annahme, und +-- die Reihenfolge von Abgabe und Angebot ist egal (das Spiel meldet beides fast gleichzeitig). +Codex.CHAIN_WINDOW = 4 +local function LinkChain(q, questID, t, giver) + local last = state.lastTurnIn + if not (last and q) or last.qid == questID then return false end + if math.abs((t or 0) - (last.t or 0)) > Codex.CHAIN_WINDOW then return false end + -- derselbe NPC (oder dasselbe Objekt) + local g, tn = giver, last.npc + if g and tn and g.id and tn.id and (g.id ~= tn.id or g.k ~= tn.k) then return false end + q.pre = q.pre or {} + for _, p in ipairs(q.pre) do + if p == last.qid then return false end + end + if #q.pre >= 8 then return false end + q.pre[#q.pre + 1] = last.qid + Codex.chainRev = (Codex.chainRev or 0) + 1 + return true +end + +function Codex.LearnChain(q, questID, detail) + if not detail then return false end + return LinkChain(q, questID, detail.t, detail.giver) +end + +-- Wird diese Quest gerade als unmittelbare Folgequest der letzten Abgabe angeboten (selber NPC, wenige Sekunden)? Rueckgabe: Quest-ID der Abgabe +local function FollowUpOf(detail) + local last = state.lastTurnIn + if not (detail and detail.qid and last) or last.qid == detail.qid then return nil end + if math.abs((detail.t or 0) - (last.t or 0)) > Codex.CHAIN_WINDOW then return nil end + local g, tn = detail.giver, last.npc + if g and tn and g.id and tn.id and (g.id ~= tn.id or g.k ~= tn.k) then return nil end + return last.qid +end + +-- Schon beim Angebot (ohne Annahme): die Quest wird nach der Abgabe vom selben NPC angeboten +local ChainOfferCore +function Codex.LearnChainOffer(detail) + local from = FollowUpOf(detail) + local learned = ChainOfferCore(detail) + -- der Levelplaner nimmt spontane Folgequests in seinen Guide auf + if from then FW:Fire("CODEX_FOLLOWUP_OFFER", detail.qid, from, detail) end + return learned +end + +ChainOfferCore = function(detail) + if not (detail and detail.qid and state.lastTurnIn) then return false end + local V = Codex.db.quests + local q = V[detail.qid] + if not q then + if not detail.title then return false end + -- Probelauf, ohne den Datensatz anzulegen, wenn es keine Kette waere + local last = state.lastTurnIn + if last.qid == detail.qid or math.abs((detail.t or 0) - (last.t or 0)) > Codex.CHAIN_WINDOW then return false end + local g, tn = detail.giver, last.npc + if g and tn and g.id and tn.id and (g.id ~= tn.id or g.k ~= tn.k) then return false end + q = Codex:Quest(detail.qid, true) + q.t = detail.title + if q.gif then q.give = detail.giver or q.give q.gif = nil else q.give = q.give or detail.giver end + end + if LinkChain(q, detail.qid, detail.t, detail.giver) then + FW:Fire("CODEX_CHANGED", "quest", detail.qid) + return true + end + return false +end + +-- Vorquests einer Quest: aus den Daten (gelernt oder mitgeliefert) und der Classic-Vorlage +-- Quest, die ein Gegenstand startet: Item-ID oder nil +function Codex.QuestStartItem(id) + local q = Codex.V().quests[id] + return q and q.si or nil +end + +-- Ketten-Index: Folgequests je Quest (aus den Vorquests der Daten und den "Folgequests" der Devs) und +-- Vorquests, die Devs ueber die Folgequest-Liste der Elternquest nachtragen +local nextCache = { view = nil, rev = -1, idx = nil, devPre = nil } +Codex.chainRev = 0 +local function ChainCache() + local V = Codex.V() + if nextCache.view ~= V or nextCache.rev ~= Codex.chainRev or not nextCache.idx then + local idx, devPre = {}, {} + local function Link(parent, child) + idx[parent] = idx[parent] or {} + for _, x in ipairs(idx[parent]) do if x == child then return end end + table.insert(idx[parent], child) + end + for qid, q in pairs(V.quests) do + for _, p in ipairs(q.pre or {}) do + if p ~= qid then Link(p, qid) end + end + for _, n in ipairs(q.ed and q.ed.nx or {}) do + if n ~= qid then + Link(qid, n) + devPre[n] = devPre[n] or {} + table.insert(devPre[n], qid) + end + end + end + -- Folgequests aus dem Client-Cache des Servers (CodexWdbChain.lua) + local wdbPre = {} + for parent, child in pairs(Codex.WdbNext or {}) do + if parent ~= child then + Link(parent, child) + wdbPre[child] = wdbPre[child] or {} + table.insert(wdbPre[child], parent) + end + end + for _, l in pairs(idx) do table.sort(l) end + for _, l in pairs(devPre) do table.sort(l) end + for _, l in pairs(wdbPre) do table.sort(l) end + nextCache = { view = V, rev = Codex.chainRev, idx = idx, devPre = devPre, wdbPre = wdbPre } + end + return nextCache +end + +-- Vorquests einer Quest: Dev-Eintrag ersetzt alles (Daten, Vorlage); sonst Daten, Vorlage und Beobachtungen +function Codex.QuestPre(id) + local out, have = {}, {} + local q = Codex.V().quests[id] + local dev = q and q.ed and q.ed.pre + if dev then + for _, p in ipairs(dev) do + if p ~= id and not have[p] then have[p] = true out[#out + 1] = p end + end + else + for _, p in ipairs(q and q.pre or {}) do + if p ~= id and not have[p] then have[p] = true out[#out + 1] = p end + end + local sq = Codex.Seed and Codex.Seed.Ready() and Codex.Seed.Quest(id) or nil + for _, p in ipairs(sq and sq.pre or {}) do + if p ~= id and not have[p] then have[p] = true out[#out + 1] = p end + end + -- Aus Beobachtungen abgeleitet (nur wenn nichts Sicheres bekannt ist) + if #out == 0 and Codex.Avail and q then + local own = Codex.db.quests[id] + for _, p in ipairs(Codex.Avail.InferredPre(q, own ~= nil and (own.po or 0) >= 1)) do + if p ~= id then out[#out + 1] = p end + end + end + end + -- Elternquests, die diese Quest als Folgequest eingetragen haben + for _, p in ipairs(ChainCache().devPre[id] or {}) do + if p ~= id and not have[p] then have[p] = true out[#out + 1] = p end + end + if not dev then + for _, p in ipairs(ChainCache().wdbPre[id] or {}) do + if p ~= id and not have[p] then have[p] = true out[#out + 1] = p end + end + end + return out +end + +-- Folgequests: Quests, die diese als Vorquest haben (Daten, Vorlage, Dev-Eintraege) +function Codex.QuestNext(id) + local V = Codex.V() + local out, have = {}, {} + for _, n in ipairs(ChainCache().idx[id] or {}) do have[n] = true out[#out + 1] = n end + if Codex.Seed and Codex.Seed.Ready() then + for _, n in ipairs(Codex.Seed.NextQuests(id)) do + local qn = V.quests[n] + local devPre = qn and qn.ed and qn.ed.pre + local overridden = false + if devPre then + overridden = true + for _, p in ipairs(devPre) do if p == id then overridden = false end end + end + if not have[n] and n ~= id and not overridden then have[n] = true out[#out + 1] = n end + end + end + table.sort(out) + return out +end + +-- Stellung in einer Kette: "start", "mid", "end" oder nil (keine Kette) +function Codex.ChainRole(id) + local hasPre, hasNext = #Codex.QuestPre(id) > 0, #Codex.QuestNext(id) > 0 + if hasPre and hasNext then return "mid" end + if hasNext then return "start" end + if hasPre then return "end" end + return nil +end + +function Codex:OnQuestAccepted(a, b) + Safe(function() + local questID = Num(b) or Num(a) + if not questID then + return + end + if Codex.InferDone then Codex.InferDone({ questID }) end + local q = self:Quest(questID, true) + q.seen = (q.seen or 0) + 1 + q.last = Now() + q.partial = nil + local d = state.detail + local now = GetTime and GetTime() or 0 + if d and now - d.t < 300 and (not d.qid or d.qid == questID) then + q.t = d.title or q.t + if d.si then + q.si = d.si + else + q.give = d.giver or q.give + end + ApplyRewards(q, d.rew, false) + Codex.StoreQText(q, d.tx) + if d.giver and d.giver.m then + q.zone = d.giver.m + end + end + state.detail = nil + Codex.LearnChain(q, questID, d) + + local title, level = QuestLogInfo(questID) + q.t = q.t or title + q.lvl = level or q.lvl + local pl = Num(UnitLevel and UnitLevel("player")) + if pl then + q.pl = q.pl and math.min(q.pl, pl) or pl + end + local list = ReadObjectives(questID) + StoreObjectives(q, list) + TrackObjectives(questID, list) + FW:Fire("CODEX_CHANGED", "quest", questID) + end) +end + +function Codex:OnQuestComplete() + Safe(function() + local qid0, q0 = QuestForWindow() + if q0 and Codex.StoreQText(q0, { r = CleanQText(GetRewardText and GetRewardText(), QTEXT_CAP.r) }) then + FW:Fire("CODEX_CHANGED", "quest", qid0) + end + state.turn = { + t = GetTime and GetTime() or 0, + qid = Num(GetQuestID and GetQuestID()), + npc = ReadInteraction(), + rew = ReadRewards(), + } + end) +end + +-- Merkt sich die gewaehlte Belohnung (Aufruf von GetQuestReward(index)) +function Codex:OnChoice(index) + Safe(function() + local turn = state.turn + index = Num(index) + if turn and index and turn.rew and turn.rew.choice[index] then + turn.chosen = turn.rew.choice[index].id + end + end) +end + +-- Luecken fuellen: Zone aus Geber/Abgabe-NPC, Geber aus der Abgabe der (einzigen) Vorquest. Echte Werte ersetzen die abgeleiteten (q.gif). +function Codex.FillGaps() + local quests = Codex.db and Codex.db.quests + if type(quests) ~= "table" then return 0 end + local n = 0 + for id, q in pairs(quests) do + if type(q) == "table" then + if not q.zone then + local sp = q.turn or q.give + if type(sp) == "table" and type(sp.m) == "number" then q.zone = sp.m n = n + 1 end + end + if not q.give and not q.si and type(q.pre) == "table" and #q.pre == 1 then + local p = quests[q.pre[1]] + local tn = type(p) == "table" and p.turn or nil + if type(tn) == "table" and tn.id then + q.give = { n = tn.n, m = tn.m, x = tn.x, y = tn.y, id = tn.id, k = tn.k } + q.gif = true + n = n + 1 + end + end + end + end + if n > 0 then Codex.chainRev = (Codex.chainRev or 0) + 1 end + return n +end + +function Codex:OnQuestTurnedIn(questID, xp, money) + Safe(function() + questID = Num(questID) + if not questID then + return + end + if Codex.MarkTurned then Codex.MarkTurned(questID, true) end + local q = self:Quest(questID, true) + q.done = (q.done or 0) + 1 + q.last = Now() + q.partial = nil + xp, money = Num(xp), Num(money) + if xp and xp > 0 then + q.xp = xp + end + if money then + q.money = money + end + local turn = state.turn + local tnow = GetTime and GetTime() or 0 + if turn and tnow - turn.t < 300 and (not turn.qid or turn.qid == questID) then + q.turn = turn.npc or q.turn + ApplyRewards(q, turn.rew, true) + if xp and xp > 0 then + q.xp = xp + end + if money then + q.money = money + end + if turn.chosen then + q.chosen = q.chosen or {} + q.chosen[turn.chosen] = (q.chosen[turn.chosen] or 0) + 1 + end + end + state.turn = nil + state.objState[questID] = nil + q.t = q.t or QuestLogInfo(questID) + -- fuer das Lernen von Folgequests: diese Abgabe samt NPC und Zeit merken + state.lastTurnIn = { qid = questID, t = tnow, npc = q.turn } + + -- Ruf: Meldungen kurz vor und nach der Abgabe einsammeln + local t0 = tnow + C_Timer.After(2, function() Safe(Codex.FillGaps) end) + C_Timer.After(1.5, function() + Safe(function() + local rep + for _, e in ipairs(state.repBuf) do + if e.t >= t0 - 1 and e.t <= t0 + 2 then + rep = rep or {} + rep[e.name] = (rep[e.name] or 0) + e.amount + end + end + if rep then + q.rep = rep + FW:Fire("CODEX_CHANGED", "quest", questID) + end + end) + end) + FW:Fire("CODEX_CHANGED", "quest", questID) + end) +end + +function Codex:OnQuestRemoved(questID) + questID = Num(questID) + if questID then + state.objState[questID] = nil + end +end + +function Codex:OnQuestLogUpdate() + if self.checkTimer then + return + end + self.checkTimer = true + C_Timer.After(0.4, function() + Codex.checkTimer = nil + Safe(Codex.CheckObjectives, Codex) + end) +end + +-- Bereits angenommene Quests im Log erfassen (ohne Geber) +function Codex:ScanLog() + Safe(function() + local count + if C_QuestLog and C_QuestLog.GetNumQuestLogEntries then + count = C_QuestLog.GetNumQuestLogEntries() + elseif GetNumQuestLogEntries then + count = GetNumQuestLogEntries() + end + local activeIDs = {} + for i = 1, Num(count) or 0 do + local title, level, questID, isHeader + if C_QuestLog and C_QuestLog.GetInfo then + local info = C_QuestLog.GetInfo(i) + if type(info) == "table" then + title, level, questID, isHeader = info.title, info.level, info.questID, info.isHeader + end + elseif GetQuestLogTitle then + local t, l, _, h, _, _, _, id = GetQuestLogTitle(i) + title, level, questID, isHeader = t, l, id, h + end + questID = Num(questID) + if questID and questID > 0 and not isHeader then + activeIDs[#activeIDs + 1] = questID + local q = self:Quest(questID, false) + if not q then + q = self:Quest(questID, true) + q.seen = 0 + q.partial = true + q.t = Str(title, 100) + q.lvl = Num(level) + local list = ReadObjectives(questID) + StoreObjectives(q, list) + end + TrackObjectives(questID, ReadObjectives(questID)) + end + end + if Codex.InferDone then Codex.InferDone(activeIDs) end + end) +end + +------------------------------------------------------------------------ +-- Beute +------------------------------------------------------------------------ + +local function EntityFor(kind, id) + if kind == "npc" then + return Codex:Unit(id, true) + elseif kind == "object" then + return Codex:Object(id, true) + end + return nil +end + +local function AddDrop(bucket, itemID, qty, isQuest) + bucket.d = bucket.d or {} + local d = bucket.d[itemID] + if not d then + local n = 0 + for _ in pairs(bucket.d) do + n = n + 1 + if n >= MAX_DROPS then + return + end + end + d = { n = 0, q = 0 } + bucket.d[itemID] = d + end + d.n = d.n + 1 + d.q = d.q + (qty or 1) + if isQuest then + d.qi = 1 + end +end + +-- Octo/1.12: GetLootSlotInfo meldet nicht, ob ein Item ein Quest-Item ist. Ersatz: Name oder ID passt zu einem Item-Ziel +-- einer Quest im Log. IDs der Quests im Log: einmal je Beutefenster gelesen. +local function LogQuestIDs() + local out = {} + local count + if C_QuestLog and C_QuestLog.GetNumQuestLogEntries then + count = C_QuestLog.GetNumQuestLogEntries() + elseif GetNumQuestLogEntries then + count = GetNumQuestLogEntries() + end + for i = 1, Num(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 + questID = Num(questID) + if questID and questID > 0 and not isHeader then out[#out + 1] = questID end + end + return out +end + +function Codex:QuestForLootItem(ids, itemID, lootName) + local want = type(lootName) == "string" and lootName:lower() or nil + for _, qid in ipairs(ids) do + local q = self:Quest(qid, false) + for _, ob in ipairs(q and q.obj or {}) do + if ob.ty == "item" and ((ob.item and ob.item == itemID) or (want and ob.name and ob.name:lower() == want)) then + return qid + end + end + end + return nil +end + +-- Wird dieses Item gerade fuer eine Quest im Log gebraucht? (Item-Ziel passt nach ID oder Name und ist noch nicht erfuellt) +local function NeedsItem(self, logIDs, live, itemID, lootName) + local want = type(lootName) == "string" and lootName:lower() or nil + for _, qid in ipairs(logIDs) do + local q = self:Quest(qid, false) + if q and q.obj then + for i, ob in ipairs(q.obj) do + if ob.ty == "item" and ((ob.item and ob.item == itemID) or (want and ob.name and ob.name:lower() == want)) then + local objs = live[qid] + if not objs then + objs = ReadObjectives(qid) + live[qid] = objs + end + local lv = objs[i] + if not (lv and lv.done) then return true end + end + end + end + end + return false +end + +function Codex:OnLootOpened() + Safe(function() + if not self.db.settings.enabled or not GetNumLootItems then + return + end + local windows, order = {}, {} + local logIDs + for slot = 1, GetNumLootItems() do + local sources = { GetLootSourceInfo(slot) } + local stype = GetLootSlotType and Num(GetLootSlotType(slot)) + local link = GetLootSlotLink and Util.Clean(GetLootSlotLink(slot)) + local itemID = ItemIDFromLink(link) + local _, lootName, lootQty, _, _, _, isQuest, questID + if GetLootSlotInfo then + _, lootName, lootQty, _, _, _, isQuest, questID = GetLootSlotInfo(slot) + end + logIDs = logIDs or LogQuestIDs() + if itemID and not isQuest then + local qid = self:QuestForLootItem(logIDs, itemID, lootName) + if qid then isQuest, questID = true, qid end + end + if itemID then + NoteName(itemID, lootName) + -- Alle gelooteten Items merken (nicht nur gemeldete Quest-Items): Der Client sagt auf Octo nicht, + -- ob ein Item zu einer Quest gehoert, und Item- und Zielnamen stehen in verschiedenen Sprachen. + -- Steigt spaeter ein Sammel-Zaehler, wird das Item aus diesen Kandidaten abgeleitet. + local recent = state.recentItems + local srcs = {} + for si = 1, #sources, 2 do + local g = sources[si] + if type(g) == "string" and not Util.IsSecret(g) then + local sk, sid = ParseGUID(g) + if sk and sid then srcs[#srcs + 1] = (sk == "npc" and "u" or "o") .. sid end + end + end + recent[itemID] = { t = Now(), quest = isQuest and true or false, qid = Num(questID), qty = Num(lootQty) or 1, srcs = srcs } + local n = 0 + for _ in pairs(recent) do n = n + 1 end + if n > 30 then + local cutoff = Now() - RECENT_SECS + for id, r in pairs(recent) do + if r.t < cutoff then recent[id] = nil end + end + end + end + for i = 1, #sources, 2 do + local guid, qty = sources[i], Num(sources[i + 1]) or 1 + if type(guid) == "string" and not Util.IsSecret(guid) then + local w = windows[guid] + if not w then + w = { items = {}, coin = 0, hasCoin = false } + windows[guid] = w + order[#order + 1] = guid + end + if itemID then + local it = w.items[itemID] + if not it then + it = { q = 0, quest = (isQuest and true or false), qid = Num(questID), name = lootName } + w.items[itemID] = it + end + it.q = it.q + qty + elseif stype == 2 then + local cq = Num(lootQty) + w.coin = w.coin + ((cq and cq > 0 and #sources == 2) and cq or qty) + w.hasCoin = true + end + end + end + end + + local targetGuid = UnitGUID and Util.Clean(UnitGUID("target")) + for _, guid in ipairs(order) do + local kind, id = ParseGUID(guid) + local ent = kind and EntityFor(kind, id) + if ent and kind == "object" and not ent.n then + ent.n = state.objNames[id] + if not ent.n and UnitGUID and UnitName and Util.Clean(UnitGUID("softinteract")) == guid then + ent.n = Str(UnitName("softinteract"), 60) + end + end + if ent then + local w = windows[guid] + local again = state.lootedGuids[guid] + if not again then + if state.lootedCount >= MAX_LOOTED_GUIDS then + state.lootedGuids, state.lootedItems, state.lootedCount = {}, {}, 0 + end + state.lootedGuids[guid] = true + state.lootedCount = state.lootedCount + 1 + end + -- zweites Beutefenster derselben Leiche: was schon gezaehlt ist (liegengelassen), nicht nochmal + local seen = state.lootedItems[guid] + if not seen then + seen = {} + state.lootedItems[guid] = seen + end + local fresh, freshCount = {}, 0 + for itemID, it in pairs(w.items) do + if not (again and seen[itemID]) then + fresh[itemID] = it + freshCount = freshCount + 1 + end + end + local skip = again and freshCount == 0 and not (w.hasCoin and w.coin > 0) + local bucket = ent + if again and not skip then + ent.alt = ent.alt or { k = 0 } + bucket = ent.alt + end + if not skip then + bucket.k = (bucket.k or 0) + 1 + end + if w.hasCoin and w.coin > 0 and not again then + ent.coin = (ent.coin or 0) + w.coin + ent.coinN = (ent.coinN or 0) + 1 + end + local srcKey = (kind == "npc" and "u" or "o") .. id + -- Quest-Items zaehlen nur, solange die Quest laeuft und das Item noch gebraucht wird + local live, neededNow = {}, {} + for itemID, it in pairs(fresh) do + seen[itemID] = true + local old = bucket.d and bucket.d[itemID] + local needed = true + if it.quest or (old and old.qi) then + needed = NeedsItem(self, logIDs or {}, live, itemID, it.name) + neededNow[itemID] = needed + end + if needed then + AddDrop(bucket, itemID, it.q, it.quest) + end + if it.quest and it.qid and it.qid > 0 then + local q = self:Quest(it.qid, false) + if q then + q.qi = q.qi or {} + q.qi[itemID] = q.qi[itemID] or {} + q.qi[itemID][srcKey] = (q.qi[itemID][srcKey] or 0) + 1 + end + end + end + if not skip and not again and bucket.d then + -- Nenner der Drop-Chance fuer Quest-Items: nur Leichen, bei denen die Quest lief + for itemID, d in pairs(bucket.d) do + local need = neededNow[itemID] + if need == nil then need = NeedsItem(self, logIDs or {}, live, itemID, nil) end + if d.qi and need then + d.qk = (d.qk or 0) + 1 + end + end + end + if not again then + ent.lt = Now() + AddCell(ent) + if kind == "npc" and targetGuid == guid then + state.noteAt[id] = nil + self:NoteUnit("target") + end + if kind == "npc" and self.Names then + if ent.n then + self.Names:Consume(ent.n) + else + self.Names:Resolve(id, ent, guid) + end + end + end + state.stats.loot = state.stats.loot + 1 + FW:Fire("CODEX_CHANGED", kind == "npc" and "unit" or "object", id) + end + end + end) +end + + +------------------------------------------------------------------------ +-- Erfahrung pro Kill +------------------------------------------------------------------------ + +-- Klassifizierung auf wenige Schluessel vereinfachen +local function ClassKey(class) + if class == "elite" or class == "rare" or class == "rareelite" or class == "worldboss" then + return class + end + return "normal" +end + +function Codex.GrayLevel(pl) + if pl <= 5 then + return 0 + elseif pl <= 39 then + return pl - 5 - math.floor(pl / 10) + elseif pl <= 59 then + return pl - 1 - math.floor(pl / 5) + end + return pl - 9 +end + +function Codex.ZeroDiff(pl) + if pl < 8 then return 5 end + if pl <= 9 then return 6 end + if pl <= 11 then return 7 end + if pl <= 15 then return 8 end + if pl <= 19 then return 9 end + if pl <= 29 then return 11 end + if pl <= 39 then return 12 end + if pl <= 44 then return 13 end + if pl <= 49 then return 14 end + if pl <= 54 then return 15 end + if pl <= 59 then return 16 end + return 17 +end + +-- Farbe des Gegners (red, orange, yellow, green, gray) +function Codex.XPColor(pl, ml) + if ml >= pl + 5 then return "red" end + if ml >= pl + 3 then return "orange" end + if ml >= pl - 2 then return "yellow" end + if ml > Codex.GrayLevel(pl) then return "green" end + return "gray" +end + +-- Sicht fuer die Anzeige: eigene und empfangene Daten zusammen (ohne Fremddaten einfach die Datenbank) +function Codex.V() + local share = Codex.Share + if share then + return share.View() + end + return Codex.db +end + +-- Kill-Erfahrung nach der klassischen Formel (Classic 1-60), nur als Ersatz fuer fehlende Messwerte +-- Gegner gleich oder hoeher: Basis aus der Spielerstufe. Gegner niedriger: Basis aus der Gegnerstufe +-- (45 + 5 x Gegnerstufe), so gemessen in Forever (Stufe 3 gegen 2 = 44, Stufe 2 gegen 1 = 40). +function Codex.FormulaXP(pl, ml, class) + local gain + if ml >= pl then + local base = pl * 5 + 45 + local d = math.min(ml - pl, 4) + gain = math.floor((base * (20 + d) / 10 + 1) / 2) + elseif ml > Codex.GrayLevel(pl) then + local zd = Codex.ZeroDiff(pl) + gain = math.floor((ml * 5 + 45) * (zd + ml - pl) / zd) + else + gain = 0 + end + if class == "elite" or class == "rareelite" then + gain = gain * 2 + end + return gain, Codex.XPColor(pl, ml) +end + +-- Erfahrung fuer einen Kill: gemessener Wert (eigene und empfangene Messreihen), sonst die Formel. +-- Rueckgabe: XP, Farbe, gemessen (true/false), Anzahl der Messwerte +function Codex.PredictXP(pl, ml, class) + local gain, color = Codex.FormulaXP(pl, ml, class) + local share = Codex.Share + if share and gain > 0 then + local measured, n = share.XPMeasured(pl, ml, ClassKey(class)) + if measured and measured > 0 then + return measured, color, true, n + end + end + return gain, color, false, 0 +end + +local function AddSample(s, xp) + s.n = (s.n or 0) + 1 + s.sum = (s.sum or 0) + xp + s.min = s.min and math.min(s.min, xp) or xp + s.max = s.max and math.max(s.max, xp) or xp +end + +local function Bucket(tbl, pl, ml, cls) + tbl[pl] = tbl[pl] or {} + tbl[pl][ml] = tbl[pl][ml] or {} + tbl[pl][ml][cls] = tbl[pl][ml][cls] or {} + return tbl[pl][ml][cls] +end + +-- Alle Messreihen als Liste { pl, ml, cls, n, avg, min, max, pred, diff, bonus } +function Codex:XPRows(includeBonus) + local rows = {} + for _, def in ipairs({ { Codex.V().xp, false }, { self.db.xpb, true }, { self.db.xr or {}, true, true } }) do + if includeBonus or not def[2] then + for pl, byMob in pairs(def[1]) do + for ml, byClass in pairs(byMob) do + for cls, s in pairs(byClass) do + if s.n and s.n > 0 then + local pred, color = Codex.FormulaXP(pl, ml, cls) + local avg = s.sum / s.n + rows[#rows + 1] = { + pl = pl, ml = ml, cls = cls, n = s.n, avg = avg, min = s.min, max = s.max, + pred = pred, color = color, diff = avg - pred, bonus = def[2], rested = def[3], + } + end + end + end + end + end + end + table.sort(rows, function(a, b) + if a.pl ~= b.pl then return a.pl > b.pl end + if a.ml ~= b.ml then return a.ml < b.ml end + return a.cls < b.cls + end) + return rows +end + +-- Muster aus den Spiel-Texten (COMBATLOG_XPGAIN_FIRSTPERSON...) bauen +local xpPatterns, xpIgnore + +local function BuildXPPatterns() + xpPatterns, xpIgnore = {}, {} + for key, fmt in pairs(_G) do + if type(key) == "string" and key:find("^COMBATLOG_XPGAIN_FIRSTPERSON") and type(fmt) == "string" then + local pattern, order = FormatToPattern(fmt) + if pattern then + if key:find("UNNAMED") then + xpIgnore[#xpIgnore + 1] = pattern + else + local nameIdx, xpIdx + for i, kind in ipairs(order) do + if kind == "s" and not nameIdx then nameIdx = i end + if kind == "d" and not xpIdx then xpIdx = i end + end + local bonusKind + if #order > 2 then + bonusKind = key:find("RAID") and "raid" or key:find("GROUP") and "group" or "bonus" + end + xpPatterns[#xpPatterns + 1] = { + pattern = pattern, name = nameIdx or 1, xp = xpIdx or 2, bonus = bonusKind, len = #fmt, + } + end + end + end + end + table.sort(xpPatterns, function(a, b) return a.len > b.len end) +end + +function Codex:OnXPMessage(text) + Safe(function() + if self.Names then + self.Names.xpSeen = self.Names.xpSeen + 1 + if Util.IsSecret(text) then + self.Names.fail.xpSecret = self.Names.fail.xpSecret + 1 + end + end + text = Util.Clean(text) + if type(text) ~= "string" or not self.db.settings.enabled then + return + end + if not xpPatterns then + BuildXPPatterns() + end + local matched + for _, p in ipairs(xpPatterns) do + local caps = { text:match(p.pattern) } + if #caps >= 2 then + matched = { name = Str(caps[p.name], 60), xp = tonumber(caps[p.xp]), bonus = p.bonus } + break + end + end + if not matched then + -- Octo: Meldung mit Ruhebonus ("X dies, you gain 38 experience. (+16 exp Rested bonus)"): die Spiel-Texte passen nicht. + -- Die genannten XP enthalten den Bonus, die Grund-XP sind die Differenz. + local nm, gx, rb = text:match("^(.+) dies, you gain (%d+) experience%.%s*%(%+(%d+) exp [^%)]*bonus%)") + if not nm then nm, gx = text:match("^(.+) dies, you gain (%d+) experience%.?$") end + if nm and gx then + local base = tonumber(gx) - (tonumber(rb) or 0) + if base > 0 then matched = { name = Str(nm, 60), xp = base } end + end + end + if not matched then + for _, pattern in ipairs(xpIgnore) do + if text:match(pattern) then + return + end + end + local list = self.db.xpUnparsed + if #list < 5 then + list[#list + 1] = text:sub(1, 200) + end + return + end + if not matched.name or not matched.xp or matched.xp <= 0 then + return + end + if matched.name and not matched.bonus then + local tk0, tid0 = ParseGUID(UnitGUID and UnitGUID("target")) + FW:Fire("CODEX_KILL", matched.name, tk0 == "npc" and tid0 or nil) + end + + -- Name der getoeteten Kreatur lernen (Ziel passt) oder fuer das naechste Beutefenster merken + if self.Names then + local tk, tid = ParseGUID(UnitGUID and UnitGUID("target")) + local tn = Str(UnitName and UnitName("target"), 60) + if tk == "npc" and tn == matched.name then + self.Names:Learn(tid, tn, "xp") + else + self.Names:NoteKill(matched.name) + end + end + + -- Gegner: anvisiert und gleicher Name, sonst eindeutiger Name in der Datenbank + local ml, cls, id + local tkind, tid = ParseGUID(UnitGUID and UnitGUID("target")) + if tkind == "npc" and Str(UnitName("target"), 60) == matched.name then + ml = Num(UnitLevel("target")) + cls = Str(UnitClassification and UnitClassification("target"), 16) + id = tid + else + for uid, u in pairs(self.db.units) do + if u.n == matched.name and u.l1 and u.l1 == u.l2 then + if id then + id = nil + break + end + id, ml, cls = uid, u.l1, u.c + end + end + end + local pl = state.pl or Num(UnitLevel and UnitLevel("player")) + -- Kill fuer die Kampfdauer melden, auch wenn die Gegnerstufe hier unbekannt ist + -- (die Zeitmessung kennt die Stufe des anvisierten Gegners aus dem Kampf) + if self.Time and not matched.bonus then + self.Time:OnKill(pl, ml, id, ml and ClassKey(cls) or nil) + end + if not (pl and ml and ml > 0 and pl > 0) then + return + end + cls = ClassKey(cls) + + if matched.bonus then + AddSample(Bucket(self.db.xpb, pl, ml, cls), matched.xp) + FW:Fire("CODEX_CHANGED", "xp", pl) + return + end + + -- Ruhebonus: steht in der Meldung nicht getrennt, also am Ruhe-Vorrat erkennen (er sinkt um den Bonus) + local base = state.pool + if base and base > 0 and C_Timer and C_Timer.After then + local job = { pl = pl, ml = ml, cls = cls, id = id, xp = matched.xp, base = base } + if state.xpOpen > 0 then + -- mehrere Kills dicht hintereinander: der Bonus laesst sich nicht zuordnen + job.unsure = true + for _, j in ipairs(state.xpJobs) do j.unsure = true end + end + state.xpOpen = state.xpOpen + 1 + state.xpJobs[#state.xpJobs + 1] = job + C_Timer.After(0.4, function() + Safe(function() Codex:FinishXP(job) end) + end) + return + end + self:RecordXP(pl, ml, cls, id, matched.xp, nil) + end) +end + +-- Ruhe-Vorrat lesen (0 ohne Ruhebonus, nil wenn die Abfrage fehlt) +local function ReadPool() + if type(_G.GetXPExhaustion) ~= "function" then + return nil + end + local ok, v = pcall(_G.GetXPExhaustion) + if not ok then + return nil + end + return tonumber(v) or 0 +end + +-- normal: Erfahrung ohne Ruhebonus; total: mit Ruhebonus (nil, wenn keiner dabei war) +function Codex:RecordXP(pl, ml, cls, id, normal, total) + self.db.xr = self.db.xr or {} + AddSample(Bucket(self.db.xp, pl, ml, cls), normal) + if total then + AddSample(Bucket(self.db.xr, pl, ml, cls), total) + end + if id then + local u = self:Unit(id, true) + u.xp = u.xp or {} + u.xp[pl] = u.xp[pl] or {} + AddSample(u.xp[pl], normal) + -- zusaetzlich nach Gegnerstufe getrennt (Kreaturen mit Stufe 1-2 usw.) + u.xm = u.xm or {} + u.xm[pl] = u.xm[pl] or {} + u.xm[pl][ml] = u.xm[pl][ml] or {} + AddSample(u.xm[pl][ml], normal) + if total then + u.xr = u.xr or {} + u.xr[pl] = u.xr[pl] or {} + u.xr[pl][ml] = u.xr[pl][ml] or {} + AddSample(u.xr[pl][ml], total) + end + end + FW:Fire("CODEX_CHANGED", "xp", pl) + if id then + FW:Fire("CODEX_CHANGED", "unit", id) + end +end + +function Codex:FinishXP(job) + state.xpOpen = math.max(0, state.xpOpen - 1) + for i, j in ipairs(state.xpJobs) do + if j == job then table.remove(state.xpJobs, i) break end + end + local after = ReadPool() + if after then + state.pool = after + end + local bonus = after and (job.base - after) or 0 + if bonus <= 0 then + self:RecordXP(job.pl, job.ml, job.cls, job.id, job.xp, nil) + elseif job.unsure or bonus >= job.xp then + self.xpDropped = (self.xpDropped or 0) + 1 + else + self:RecordXP(job.pl, job.ml, job.cls, job.id, job.xp - bonus, job.xp) + end +end + +-- Ruhe-Vorrat merken: beim Kampfbeginn und ausserhalb des Kampfes (Ausruhen fuellt ihn) +function Codex:OnPoolEvent(event) + if event == "PLAYER_REGEN_DISABLED" or event == "PLAYER_ENTERING_WORLD" + or not (UnitAffectingCombat and UnitAffectingCombat("player")) then + if state.xpOpen == 0 then + state.pool = ReadPool() + end + end +end + +function Codex:OnLevelUp(newLevel) + newLevel = Num(newLevel) + if newLevel then + state.pl = newLevel + end +end + +------------------------------------------------------------------------ +-- Abfragen fuer Oberflaeche und andere Addons +------------------------------------------------------------------------ + +-- Quest-Status des eingeloggten Charakters: "active" (im Questlog), "done" (abgegeben) oder nil. +-- Das Spiel kennt beides lokal, es wird nichts gespeichert. +-- Simulation (Guide-Berechnung im Levelplaner): Codex.sim = { done = {}, active = {}, memo = {}, level = n }. +-- Waehrend eine Teilstrecke gerechnet wird, gelten die simulierten Quests als erledigt oder angenommen; alles andere +-- liefert das Spiel (einmal je Quest gemerkt, damit die Rechnung keine Schnittstellenaufrufe wiederholt). +local realQuestStatus +function Codex.QuestStatus(id) + local sim = Codex.sim + if sim then + if sim.done[id] then return "done" end + if sim.active[id] then return "active" end + local m = sim.memo[id] + if m == nil then + m = realQuestStatus(id) or false + sim.memo[id] = m + end + return m or nil + end + return realQuestStatus(id) +end + +-- Eigene Quests (Freewood-OctoWoW-Creator): IDs ab LOCAL_BASE, Stand liefert das Creator-Modul (nie das Questlog des Spiels) +Codex.LOCAL_BASE = 9000000 +function Codex.IsLocalQuest(id) + return type(id) == "number" and id >= Codex.LOCAL_BASE and id < Codex.LOCAL_BASE + 1000000 +end + +-- Octo kennt keine Abfrage "Quest erledigt": Abgaben selbst merken, je Charakter (db.turned[Schluessel][questID]) +local function TurnedSet(create) + local db = Codex.db + local key = Codex.Trust and Codex.Trust.SelfKey and Codex.Trust.SelfKey() or nil + if not db or not key then return nil end + db.turned = db.turned or {} + local t = db.turned[key] + if not t and create ~= false then + t = {} + db.turned[key] = t + -- einmalig und nur fuer den allerersten Charakter (Umstieg auf Speicherung je Charakter): bisher aufgezeichnete Abgaben + -- diesem Charakter zuschreiben. Jeder weitere Charakter beginnt leer (sonst gaelten neue Charaktere als "alles erledigt"). + local others = false + for k, v in pairs(db.turned) do + if k ~= key and type(v) == "table" then others = true break end + end + if not others then + local V = Codex.V and Codex.V() + for qid, q in pairs(V and V.quests or {}) do + if type(q) == "table" and (q.done or 0) > 0 then t[qid] = true end + end + end + end + return t +end +function Codex.MarkTurned(id, on) + local t = TurnedSet(true) + if t and id then t[id] = on ~= false and true or nil end +end + +-- Wer eine Folgequest im Log hat, hat die Vorquest erledigt (nur bei eindeutiger, einzelner Vorquest) +function Codex.InferDone(ids) + local t = TurnedSet(true) + if not t then return end + local n = 0 + for _, id in ipairs(ids or {}) do + local cur, depth = id, 0 + while cur and depth < 15 do + local pre = Codex.QuestPre and Codex.QuestPre(cur) or {} + if #pre ~= 1 then break end + cur = pre[1] + if not t[cur] then t[cur] = true n = n + 1 end + depth = depth + 1 + end + end + if n > 0 then Codex.chainRev = (Codex.chainRev or 0) + 1 end + return n +end + +_G.SLASH_FWDONE1 = "/fwdone" +_G.SlashCmdList = _G.SlashCmdList or {} +_G.SlashCmdList["FWDONE"] = function(msg) + if string.match(msg or "", "^%s*reset") then + local key = Codex.Trust and Codex.Trust.SelfKey and Codex.Trust.SelfKey() or nil + local say0 = function(x) if _G.DEFAULT_CHAT_FRAME then _G.DEFAULT_CHAT_FRAME:AddMessage("|cffffd100Freewood|r " .. x) end end + if not (Codex.db and key) then say0("Charakter unbekannt.") return end + if not string.match(msg, "reset%s+ja") then + say0("/fwdone reset ja loescht die gemerkten Abgaben dieses Charakters (" .. tostring(key) .. "). Danach gilt er als ohne erledigte Quests; angenommene Quests bleiben. Dann im Planer NEU druecken.") + return + end + Codex.db.turned = Codex.db.turned or {} + Codex.db.turned[key] = {} + Codex.chainRev = (Codex.chainRev or 0) + 1 + say0("Abgaben dieses Charakters geloescht. Jetzt im Planer NEU druecken.") + if _G.FW and _G.FW.Fire then _G.FW:Fire("QUEST_TURNED_IN_LOCAL", 0) end + return + end + local id, undo = string.match(msg or "", "^(%d+)%s*(%a*)") + id = tonumber(id) + local say = function(x) if _G.DEFAULT_CHAT_FRAME then _G.DEFAULT_CHAT_FRAME:AddMessage("|cffffd100Freewood|r " .. x) end end + if not id then say("/fwdone [weg] markiert eine Quest als erledigt (oder wieder offen)") return end + Codex.MarkTurned(id, undo ~= "weg") + Codex.chainRev = (Codex.chainRev or 0) + 1 + say(string.format("Quest %d: %s", id, undo == "weg" and "wieder offen" or "erledigt")) + if _G.FW and _G.FW.Fire then _G.FW:Fire("QUEST_TURNED_IN_LOCAL", id) end +end + +realQuestStatus = function(id) + if Codex.LocalStatus and Codex.IsLocalQuest(id) then + return Codex.LocalStatus(id) + end + local QL = _G.C_QuestLog + if QL and QL.GetLogIndexForQuestID then + local ok, idx = pcall(QL.GetLogIndexForQuestID, id) + if ok and type(idx) == "number" and idx > 0 then + return "active" + end + end + do + local t = TurnedSet(true) + if t and t[id] then return "done" end + end + local fn = (QL and QL.IsQuestFlaggedCompleted) or _G.IsQuestFlaggedCompleted + if type(fn) == "function" then + local ok, done = pcall(fn, id) + if ok and done then + return "done" + end + end + -- Ersatz: Liste aller erledigten Quests + local list + if QL and QL.GetAllCompletedQuestIDs then + local ok, r = pcall(QL.GetAllCompletedQuestIDs) + if ok and type(r) == "table" then + for _, v in pairs(r) do if v == id then return "done" end end + end + elseif type(_G.GetQuestsCompleted) == "function" then + local ok, r = pcall(_G.GetQuestsCompleted) + if ok and type(r) == "table" and r[id] then return "done" end + end + return nil +end + +function Codex:GetQuest(id) + return Codex.V().quests[id] +end + +-- Display-ID einer Kreatur (fuer das 2D-Bild in der Liste) +Codex.dispStats = { read = 0, fail = 0, shown = 0 } +function Codex:GetDisplayID(npcID) + local d = self.db.disp and self.db.disp[npcID] + return type(d) == "number" and d > 0 and d or nil +end + +-- Octo: PlayerModel:SetCreature zeigt nur Kreaturen aus dem Client-Cache. Unbekannte +-- werden zuerst angefragt (CREATURE_DATA_LOAD_RESULT), dann laeuft fn. +local creatureWait = {} +local function RunWaiting(id) + local cbs = creatureWait[id] + creatureWait[id] = nil + if cbs then + for _, fn in ipairs(cbs) do + pcall(fn) + end + end +end +function Codex.EnsureCreature(id, fn) + local C = _G.C_CreatureInfo + if type(id) ~= "number" or not (C and C.RequestLoadCreatureByID) then + fn() + return + end + if creatureWait[id] then + table.insert(creatureWait[id], fn) + return + end + creatureWait[id] = { fn } + if not Codex.creatureWatch then + local w = CreateFrame("Frame") + w:RegisterEvent("CREATURE_DATA_LOAD_RESULT") + w:SetScript("OnEvent", function(_, _, a1) + local got = tonumber(a1) or tonumber(_G.arg1) + if got then RunWaiting(got) end + end) + Codex.creatureWatch = w + end + local ok = pcall(C.RequestLoadCreatureByID, id) + if not ok then + RunWaiting(id) + return + end + if creatureWait[id] and C_Timer and C_Timer.After then + C_Timer.After(4, function() RunWaiting(id) end) + end +end + +-- Display-ID direkt aus dem Kreatur-Cache (ClassicAPI), ohne Modell +function Codex.CacheDisplayID(id) + local C = _G.C_CreatureInfo + if not (C and C.GetCreatureInfoByID) then return nil end + local ok, info = pcall(C.GetCreatureInfoByID, id) + if ok and type(info) == "table" then + local d = tonumber(info.displayID) + if d and d > 0 then return d end + end + return nil +end + +function Codex:SetDisplayID(npcID, displayID) + if type(npcID) ~= "number" or type(displayID) ~= "number" or displayID <= 0 then + return false + end + self.db.disp = self.db.disp or {} + if self.db.disp[npcID] ~= displayID then + self.db.disp[npcID] = displayID + return true + end + return false +end + +------------------------------------------------------------------------ +-- Itemwerte (Staerke, Beweglichkeit, Ausdauer, Ruestung, Schaden pro Sekunde ...) +------------------------------------------------------------------------ + +-- Waffen: Schadensspanne und Tempo stehen nur im Tooltip (die Wertetabelle liefert nur den Schaden pro Sekunde). +-- Gespeichert als _dmin, _dmax, _spd und _s (= gelesen) neben den anderen Werten. +local scanTip +local scanTried = {} + +local function ScanTooltip() + if scanTip == nil then + scanTip = false + if CreateFrame and UIParent then + local ok, tip = pcall(CreateFrame, "GameTooltip", "FreewoodCodexScanTip", nil, "GameTooltipTemplate") + if ok and tip then + scanTip = tip + end + end + end + return scanTip or nil +end + +local function TemplatePattern(template) + if type(template) ~= "string" or template == "" then + return nil + end + local p = template:gsub("([%%%^%$%(%)%.%[%]%*%+%-%?])", "%%%1") + p = p:gsub("%%%%[sd]", "(.-)") + return "^" .. p .. "$" +end + +local function LineText(name, i) + local fs = _G[name .. i] + local ok, text = pcall(function() return fs and fs.GetText and fs:GetText() end) + if ok and type(text) == "string" and not Util.IsSecret(text) then + return text + end + return nil +end + +local function CleanLine(text) + if type(text) ~= "string" or Util.IsSecret(text) then + return nil + end + return (text:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")) +end + +-- Zeilen des Item-Tooltips { { links, rechts } }: erst die Tooltip-Daten (neue Clients), sonst ein versteckter Tooltip. +-- Zweiter Rueckgabewert: woher sie stammen (fuer /fwcodex weapon) +-- Tooltip noch nicht fertig? (nur "Frage Gegenstandsinformationen ab ..." oder gar keine Zeilen) +local function LinesReady(lines) + if #lines < 2 then + return false + end + local wait = _G.RETRIEVING_ITEM_INFO + if type(wait) == "string" and lines[1][1] == wait then + return false + end + return true +end + +-- Rueckgabe: Zeilen, Quelle, fertig (false = Itemdaten noch nicht geladen, spaeter nochmal) +local function TooltipLines(itemID) + local firstLines, firstSource + if C_TooltipInfo and C_TooltipInfo.GetItemByID then + local ok, data = pcall(C_TooltipInfo.GetItemByID, itemID) + if ok and type(data) == "table" and type(data.lines) == "table" then + local lines = {} + for _, ln in ipairs(data.lines) do + if type(ln) == "table" then + lines[#lines + 1] = { CleanLine(ln.leftText), CleanLine(ln.rightText) } + end + end + if LinesReady(lines) then + return lines, "C_TooltipInfo", true + end + if #lines > 0 then + firstLines, firstSource = lines, "C_TooltipInfo" + end + end + end + local tip = ScanTooltip() + if not tip then + return firstLines or {}, firstSource or "kein Tooltip", false + end + local ok = pcall(function() + tip:SetOwner(UIParent, "ANCHOR_NONE") + tip:ClearLines() + tip:SetHyperlink("item:" .. itemID) + end) + if not ok then + return firstLines or {}, firstSource or "Tooltip-Fehler", false + end + local lines = {} + for i = 1, (tip.NumLines and tip:NumLines() or 0) do + lines[#lines + 1] = { CleanLine(LineText("FreewoodCodexScanTipTextLeft", i)), CleanLine(LineText("FreewoodCodexScanTipTextRight", i)) } + end + pcall(tip.Hide, tip) + if not LinesReady(lines) and firstLines then + return firstLines, firstSource, false + end + return lines, "versteckter Tooltip", LinesReady(lines) +end + +local function Number(text) + return tonumber((tostring(text):gsub(",", "."))) +end + +-- Schaden und Tempo aus den Zeilen. Fehlt das Tempo im Text, wird es aus Schaden und DPS errechnet. +local function ParseWeapon(lines, dps) + local patterns = { + TemplatePattern(_G.DAMAGE_TEMPLATE_WITH_SCHOOL), TemplatePattern(_G.DAMAGE_TEMPLATE), + } + local speedWord = type(_G.WEAPON_SPEED) == "string" and _G.WEAPON_SPEED or "Speed" + speedWord = speedWord:gsub("([%%%^%$%(%)%.%[%]%*%+%-%?])", "%%%1") + local dmin, dmax, spd + for _, ln in ipairs(lines) do + local left, right = ln[1], ln[2] + if left and not dmin then + for _, pat in ipairs(patterns) do + local a, b = left:match(pat) + a, b = a and Number(a), b and Number(b) + if a and b then + dmin, dmax = a, b + break + end + end + if not dmin then + -- allgemein: Zeile beginnt mit "min - max" + local a, b = left:match("^(%d+[%.,]?%d*)%s*[%-\226\128\147\226\128\148]+%s*(%d+[%.,]?%d*)%s*%a") + a, b = a and Number(a), b and Number(b) + if a and b then + dmin, dmax = a, b + end + end + end + if not spd then + for _, text in ipairs({ left or "", right or "" }) do + local v = text:match(speedWord .. "%s+([%d%.,]+)") + v = v and Number(v) + if v and v > 0 then + spd = v + break + end + end + end + end + if dmin and dmax and not spd and dps and dps > 0 then + spd = math.floor(((dmin + dmax) / 2 / dps) * 100 + 0.5) / 100 + end + if dmin and dmax and spd then + return dmin, dmax, spd + end + return nil +end + +local function DPSOf(stats) + for key, value in pairs(stats) do + if key:find("DAMAGE_PER_SECOND", 1, true) and type(value) == "number" then + return value + end + end + return nil +end + +-- Rueckgabe: min, max, Tempo (oder nil, wenn der Tooltip noch leer ist oder keine Waffe zeigt) +local function ReadWeapon(itemID, dps) + local lines, _, ready = TooltipLines(itemID) + local dmin, dmax, spd = ParseWeapon(lines, dps) + return dmin, dmax, spd, ready +end + +-- Ausgabe fuer /fwcodex weapon : zeigt, was der Tooltip liefert +function Codex.WeaponDebug(itemID) + if Util.RequestItem then Util.RequestItem(itemID, function() end) end -- Itemdaten anfordern + local lines, source, ready = TooltipLines(itemID) + FW:Print(string.format("Waffe %d: Quelle %s, %d Zeilen%s", itemID, source, #lines, + ready and "" or " (Itemdaten noch nicht geladen, in ein paar Sekunden noch einmal eingeben)")) + for i, ln in ipairs(lines) do + if i <= 14 then + FW:Print(string.format(" %d: [%s] [%s]", i, tostring(ln[1]), tostring(ln[2]))) + end + end + local st = Codex.db.istats and Codex.db.istats[itemID] + local dmin, dmax, spd = ParseWeapon(lines, st and DPSOf(st)) + FW:Print(string.format("Ergebnis: Schaden %s - %s, Tempo %s, DPS laut Werten %s", tostring(dmin), tostring(dmax), tostring(spd), + tostring(st and DPSOf(st)))) +end + +-- Tooltip-Scanner (/fwcodex tipscan [stop]): liest die Tooltips aller bekannten Items und sammelt die Textzeilen +-- mit Effekten ("Equip:", "Chance on hit:", "Use:" und alles, was nicht zu den Standardzeilen gehoert). +-- Ergebnis: je Textmuster (Zahlen als # ersetzt) Anzahl und ein Beispiel-Item, im Log (Reiter "Log") und in db.tipscan. +local tipScan +local STD_PATTERNS = { + "^Binds when", "^Requires ", "^Durability", "^Classes:", "^Races:", "^Unique", "^Sell", "^Soulbound", "^Quest Item", + "^Two%-Hand", "^One%-Hand", "^Main Hand", "^Off Hand", "^Ranged", "^Held In Off", "^Thrown", "^Projectile", "^Relic", + "^[%d%.,]+ %- [%d%.,]+ ", "^%(?[%d%.,]+ damage per second", "^[%d%.,]+ Armor", "^[%d%.,]+ Block", "^%+[%d%.,]+ [%a ]+$", + "^%-[%d%.,]+ [%a ]+$", "^%+?[%d%.,]+ [%a ]+ Resistance", "^%d+ Slot ", "^Item Level", "^\"", "^Cloth$", "^Leather$", "^Mail$", "^Plate$", + "^Shield$", "^Sword$", "^Axe$", "^Mace$", "^Dagger$", "^Staff$", "^Bow$", "^Gun$", "^Crossbow$", "^Wand$", "^Fist Weapon$", "^Polearm$", + "^Idol$", "^Libram$", "^Totem$", "^Back$", "^Head$", "^Neck$", "^Shoulder$", "^Chest$", "^Waist$", "^Legs$", "^Feet$", "^Wrist$", "^Hands$", + "^Finger$", "^Trinket$", "^Shirt$", "^Held", "^Bag$", "^Quiver$", "^Ammo Pouch$", "^%d+ Slot", +} +local function IsStd(text) + for _, pat in ipairs(STD_PATTERNS) do + if text:find(pat) then return true end + end + return false +end + +local function Norm(text) + return (text:gsub("%d+[%.,]?%d*", "#")) +end + +function Codex.TipScan(ids) + if tipScan then + FW:Print("Tooltip-Scan laeuft schon (/fwcodex tipscan stop zum Abbrechen).") + return + end + local list, seen = {}, {} + for _, id in ipairs(ids or {}) do + if type(id) == "number" and not seen[id] then seen[id] = true list[#list + 1] = id end + end + for id in pairs(Codex.db.istats or {}) do + if type(id) == "number" and not seen[id] then seen[id] = true list[#list + 1] = id end + end + table.sort(list) + local st = { list = list, pos = 1, pat = {}, done = 0, wait = 0, skipped = 0, t0 = _G.GetTime and _G.GetTime() or 0 } + tipScan = st + FW:Print(string.format("Tooltip-Scan: %d Items, laeuft im Hintergrund ...", #list)) + local function Finish(aborted) + tipScan = nil + local rows = {} + for key, v in pairs(st.pat) do rows[#rows + 1] = { key = key, n = v.n, id = v.id, ex = v.ex, kind = v.kind } end + table.sort(rows, function(a, b) if a.n ~= b.n then return a.n > b.n end return a.key < b.key end) + Codex.db.tipscan = { when = _G.date and _G.date("%Y-%m-%d %H:%M:%S") or "", items = st.done, skipped = st.skipped, rows = rows } + local Log = FW.Dev and FW.Dev.Diag + local function Out(text) + if Log and Log.Add then Log.Add("scan", text) else FW:Print(text) end + end + Out(string.format("TIPSCAN %s: %d Items gelesen, %d uebersprungen (nicht geladen), %d Textmuster", aborted and "abgebrochen" or "fertig", st.done, st.skipped, #rows)) + for i, r in ipairs(rows) do + Out(string.format("TIPSCAN %d x %s | %s | Beispiel %d: %s", r.n, r.kind, r.key, r.id, r.ex)) + if i >= 700 then break end + end + FW:Print(string.format("Tooltip-Scan %s: %d Textmuster im Log (Reiter Log, Art \"scan\") und in den gespeicherten Daten.", aborted and "abgebrochen" or "fertig", #rows)) + end + local function Step() + if tipScan ~= st then return Finish(true) end + local n = 0 + while st.pos <= #list and n < 40 do + local id = list[st.pos] + if Util.RequestItem and not (Util.GetItem and Util.GetItem(id)) then pcall(Util.RequestItem, id, function() end) end + local lines, _, ready = TooltipLines(id) + if not ready and st.wait < 6 then + st.wait = st.wait + 1 + break -- spaeter nochmal versuchen + end + st.wait = 0 + if ready then + st.done = st.done + 1 + for i = 2, #lines do + local left = lines[i][1] + if type(left) == "string" and left ~= "" and not IsStd(left) then + local kind = "sonst" + if left:find("^Equip:") then kind = "equip" elseif left:find("^Chance on hit:") then kind = "proc" + elseif left:find("^Use:") then kind = "use" end + local key = Norm(left) + local v = st.pat[key] + if not v then st.pat[key] = { n = 1, id = id, ex = left, kind = kind } else v.n = v.n + 1 end + end + end + else + st.skipped = st.skipped + 1 + end + st.pos = st.pos + 1 + n = n + 1 + end + if st.pos > #list then return Finish(false) end + if C_Timer and C_Timer.After then C_Timer.After(0.05, Step) end + end + Step() +end + +function Codex.TipScanStop() tipScan = nil end + +local function HasDPS(stats) + for key in pairs(stats) do + if key:find("DAMAGE_PER_SECOND", 1, true) then + return true + end + end + return false +end + +function Codex.ResetWeaponScan(itemID) + scanTried[itemID] = nil +end + +local function AddWeapon(itemID, out) + if out._s or (scanTried[itemID] or 0) >= 6 or not HasDPS(out) then + return + end + local dmin, dmax, spd, ready = ReadWeapon(itemID, DPSOf(out)) + if dmin then + out._dmin, out._dmax, out._spd, out._s = dmin, dmax, spd, 1 + scanTried[itemID] = 6 + elseif ready then + scanTried[itemID] = 6 -- Tooltip war fertig und enthaelt keine Waffenwerte + else + scanTried[itemID] = (scanTried[itemID] or 0) + 1 -- noch nicht geladen: spaeter erneut versuchen + end +end + +-- Gemeinsamer Zaubermacht-Wert ("Schaden und Heilung um bis zu 18") gilt fuer beide: +-- Zauberschaden und Zauberheilung bekommen ihn, damit Anzeige und Filter einheitlich sind. +-- Rueckgabe: true, wenn etwas geaendert wurde. +local SP_DAMAGE, SP_HEALING = "ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", "ITEM_MOD_SPELL_HEALING_DONE_SHORT" +local function SplitSpellPower(t) + local changed = false + for _, key in ipairs({ "ITEM_MOD_SPELL_POWER_SHORT", "ITEM_MOD_SPELL_POWER" }) do + local v = t[key] + if type(v) == "number" and v ~= 0 then + t[SP_DAMAGE] = math.max(t[SP_DAMAGE] or 0, v) + t[SP_HEALING] = math.max(t[SP_HEALING] or 0, v) + end + if v ~= nil then + t[key] = nil + changed = true + end + end + -- Schluessel ohne "_SHORT" (ITEM_MOD_BLOCK_VALUE) und mit sind derselbe Wert: auf die Form mit "_SHORT" vereinheitlichen + local alias + for key, v in pairs(t) do + if type(key) == "string" and key:sub(1, 9) == "ITEM_MOD_" and key:sub(-6) ~= "_SHORT" then + alias = alias or {} + alias[#alias + 1] = key + end + end + if alias then + for _, key in ipairs(alias) do + local v, target = t[key], key .. "_SHORT" + if type(v) == "number" and (t[target] or 0) < v then t[target] = v end + t[key] = nil + changed = true + end + end + return changed +end +Codex.SplitSpellPower = SplitSpellPower + + +-- Effekte aus dem Tooltip ("Equip:", "Chance on hit:", "Use:", Set-Boni): bekannte Texte werden Zahlenwerte (FW_*), alles +-- andere nur Markierungen (Wert 1). Die Zeilen selbst stehen in _fx, _e = 1 heisst "Effekte gelesen" (Stand EFFECT_VER). +local EFFECT_VER = 3 +local SCHOOLS = { ARCANE = true, FIRE = true, FROST = true, HOLY = true, NATURE = true, SHADOW = true } +local EQUIP_RULES = { + { "^Improves your chance to get a critical strike by ([%d%.]+)%%", "FW_CRIT" }, + { "^Improves your chance to get a critical strike with spells by ([%d%.]+)%%", "FW_SPELLCRIT" }, + { "^Improves your chance to get a critical strike with missile weapons by ([%d%.]+)%%", "FW_RANGEDCRIT" }, + { "^Improves your chance to hit by ([%d%.]+)%%", "FW_HIT" }, + { "^Improves your chance to hit with spells by ([%d%.]+)%%", "FW_SPELLHIT" }, + { "^Increases your chance to dodge an attack by ([%d%.]+)%%", "FW_DODGE" }, + { "^Increases your chance to parry an attack by ([%d%.]+)%%", "FW_PARRY" }, + { "^Increases your chance to block attacks with %a+ shield by ([%d%.]+)%%", "FW_BLOCK" }, + { "^Increases the block value of your shield by ([%d%.]+)", "FW_BLOCKVAL" }, + { "^Restores ([%d%.]+) mana per 5 sec", "FW_MP5" }, + { "^Restores ([%d%.]+) health per 5 sec", "FW_HP5" }, + { "^Restores ([%d%.]+) health every 5 sec", "FW_HP5" }, + { "^Increases your attack and casting speed by ([%d%.]+)%%", "FW_HASTE" }, + { "^Increases ranged attack speed by ([%d%.]+)%%", "FW_RANGEDSPEED" }, + { "^Your attacks ignore ([%d%.]+) of the target's armor", "FW_ARMORPEN" }, + { "^Decreases the magical resistances of your spell targets by ([%d%.]+)", "FW_SPELLPEN" }, + { "^([%d%.]+)%% of damage dealt is returned as healing", "FW_LIFESTEAL" }, + { "^%+([%d%.]+) Attack Power%.?$", "ITEM_MOD_ATTACK_POWER_SHORT" }, + { "^Increases attack power by ([%d%.]+)%.?$", "ITEM_MOD_ATTACK_POWER_SHORT" }, + { "^%+([%d%.]+) Ranged Attack Power%.?$", "ITEM_MOD_RANGED_ATTACK_POWER_SHORT" }, + { "^Increases ranged attack power by ([%d%.]+)%.?$", "ITEM_MOD_RANGED_ATTACK_POWER_SHORT" }, +} +local function SetMax(out, key, v) + if type(v) == "number" and v ~= 0 and (out[key] or 0) < v then out[key] = v end +end + +local function ParseEffects(lines, out) + local fx = {} + local function Flag(key) out[key] = 1 end + for i = 2, #lines do + local left = lines[i][1] + if type(left) == "string" and left ~= "" and left:sub(1, 1) ~= "\n" then + local txt = left:gsub("%s+$", "") + if txt:find("^Equip:") then + local t = txt:gsub("^Equip:%s*", "") + local done = false + for _, r in ipairs(EQUIP_RULES) do + local v = t:match(r[1]) + if v then SetMax(out, r[2], tonumber(v)) done = true break end + end + if not done then + local v = t:match("^Increases damage and healing done by magical spells and effects by up to (%d+)") + if v then + SetMax(out, "ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", tonumber(v)) + SetMax(out, "ITEM_MOD_SPELL_HEALING_DONE_SHORT", tonumber(v)) + done = true + end + end + if not done then + local v = t:match("^Increases healing done by spells and effects by up to (%d+)") + if v then SetMax(out, "ITEM_MOD_SPELL_HEALING_DONE_SHORT", tonumber(v)) done = true end + end + if not done then + local sch, v = t:match("^Increases damage done by (%a+) spells and effects by up to (%d+)") + if sch and SCHOOLS[sch:upper()] then SetMax(out, "FW_SD_" .. sch:upper(), tonumber(v)) done = true end + end + if not done then + local name, v = t:match("^Increased ([%a ]-) %+(%d+)") + if name then + local k = name:upper():gsub(" ", "_") + SetMax(out, (k == "DEFENSE" or k == "FISHING") and ("FW_" .. k) or ("FW_SK_" .. k), tonumber(v)) + done = true + end + end + if not done then + -- Bewegungstempo: Schwimmen, Laufen, Reittier (ohne Prozentangabe zaehlt der Wert 1) + local low = t:lower() + local key = low:find("swim speed", 1, true) and "FW_SWIM" + or ((low:find("run speed", 1, true) or low:find("movement speed", 1, true)) and "FW_RUNSPEED") + or (low:find("mount speed", 1, true) and "FW_MOUNTSPEED") or nil + if key then + SetMax(out, key, tonumber(low:match("([%d%.]+)%%")) or 1) + done = true + end + end + if not done then Flag("FW_EQUIPFX") end + fx[#fx + 1] = txt + elseif txt:find("^Chance on hit:") then + Flag("FW_PROC") fx[#fx + 1] = txt + elseif txt:find("^Use:") then + Flag("FW_USE") fx[#fx + 1] = txt + elseif txt:find("^%(%d+%) Set:") then + Flag("FW_SET") fx[#fx + 1] = txt + elseif txt:find("^%+ ?[%d%.]+ %- [%d%.]+ %a+ Damage$") then + Flag("FW_EXTRADMG") fx[#fx + 1] = txt + else + local v = txt:match("^Adds ([%d%.]+) damage per second$") + if v then SetMax(out, "FW_AMMODPS", tonumber(v)) fx[#fx + 1] = txt end + end + end + end + while #fx > 8 do fx[#fx] = nil end + for k, t in ipairs(fx) do if #t > 200 then fx[k] = t:sub(1, 200) end end + out._fx = (#fx > 0) and fx or nil + out._e = EFFECT_VER +end + +Codex.FXFLAGS = { FW_EQUIPFX = true, FW_PROC = true, FW_USE = true, FW_SET = true, FW_EXTRADMG = true } +local effectTries = {} +-- Effekte eines gespeicherten Eintrags nachtragen (nur wenn der Tooltip fertig ist) +local function AddEffects(itemID, out) + if out._e == EFFECT_VER or (effectTries[itemID] or 0) >= 3 then return false end + effectTries[itemID] = (effectTries[itemID] or 0) + 1 + local lines, _, ready = TooltipLines(itemID) + if not ready then return false end + ParseEffects(lines, out) + return true +end +Codex.AddEffects = AddEffects + +local function ReadStats(itemID) + local link = "item:" .. itemID + local fn = (C_Item and C_Item.GetItemStats) or GetItemStats + if type(fn) ~= "function" then + return nil + end + local ok, t = pcall(fn, link) + if not ok or type(t) ~= "table" then + return nil + end + local out, any = {}, false + for key, value in pairs(t) do + if type(key) == "string" and type(value) == "number" and value ~= 0 + and not key:find("^EMPTY_SOCKET") then + out[key] = value + any = true + end + end + SplitSpellPower(out) + if any then + AddWeapon(itemID, out) + end + AddEffects(itemID, out) + if not any then + for k, v in pairs(out) do + if type(k) == "string" and k:sub(1, 1) ~= "_" and type(v) == "number" and v ~= 0 then any = true break end + end + end + return out, any +end + +local function HasAnyStat(st) + for k, v in pairs(st) do + if type(k) == "string" and k:sub(1, 1) ~= "_" and type(v) == "number" and v ~= 0 then return true end + end + return false +end + +-- Rüstung und Waffen haben immer Werte: eine leere Antwort heisst dort "noch nicht geladen" +local function ShouldHaveStats(itemID) + local info = Util.GetItem and Util.GetItem(itemID) + if type(info) ~= "table" then return false end + local loc = info.equipLoc + if type(loc) == "string" and loc ~= "" and loc ~= "INVTYPE_BAG" and loc ~= "INVTYPE_TABARD" and loc ~= "INVTYPE_BODY" then return true end + return info.classID == 2 or info.classID == 4 +end + +local emptyTries = {} -- itemID -> Versuche in dieser Sitzung (leer gelesene Ruestung/Waffen werden hoechstens dreimal neu gelesen) + +-- Werte eines Items (aus der Datenbank, sonst vom Spiel lesen und merken) +function Codex:GetStats(itemID) + if type(itemID) ~= "number" then + return nil + end + local store = self.db.istats + if not store then + store = {} + self.db.istats = store + end + local st = store[itemID] + if st and not HasAnyStat(st) and (emptyTries[itemID] or 0) < 3 and ShouldHaveStats(itemID) then + -- aelterer leerer Eintrag zu Ruestung/Waffe: neu lesen + emptyTries[itemID] = (emptyTries[itemID] or 0) + 1 + local again, any = ReadStats(itemID) + if any then + store[itemID] = again + st = again + end + end + if st then + SplitSpellPower(st) -- aeltere Eintraege mit gemeinsamem Zaubermacht-Wert + if st._e ~= EFFECT_VER and Util.GetItem and Util.GetItem(itemID) then + AddEffects(itemID, st) -- aeltere Eintraege: Effekte nachtragen + end + if not st._s and Util.GetItem and Util.GetItem(itemID) then + AddWeapon(itemID, st) -- aeltere Eintraege: Schaden und Tempo nachtragen + end + return st + end + if not (Util.GetItem and Util.GetItem(itemID)) then + return nil -- Item noch nicht geladen: spaeter noch einmal versuchen + end + local out, any = ReadStats(itemID) + if out then + if not any and ShouldHaveStats(itemID) and (emptyTries[itemID] or 0) < 3 then + -- Ruestung/Waffe ohne Werte gelesen: nicht merken, spaeter noch einmal versuchen + emptyTries[itemID] = (emptyTries[itemID] or 0) + 1 + return out + end + store[itemID] = out -- auch leer (Item ohne Werte), damit nicht erneut gelesen wird + return out + end + return nil +end + +-- Im Hintergrund: gespeicherte Eintraege ohne Effekte neu lesen (kleine Schritte, keine Ruckler) +local fxUpgrading +-- Entfernt Items, die der Server nicht kennt (Item-Lader: fehlgeschlagen), aus der eigenen Datenbank +function Codex.PurgeMissing() + local db = Codex.db + local fail = db and db.itemload and db.itemload.fail + if not fail then return 0 end + local n = 0 + local cat = db.cat and db.cat.i + for id in pairs(fail) do + if cat and cat[id] and not (Util and Util.GetItem and Util.GetItem(id)) then + cat[id] = nil + n = n + 1 + end + if db.istats and db.istats[id] and not (Util and Util.GetItem and Util.GetItem(id)) then + db.istats[id] = nil + n = n + 1 + end + end + if n > 0 and FW.Dev and FW.Dev.Diag then FW.Dev.Diag.Add("scan", "Aufgeraeumt: " .. n .. " unbekannte Items entfernt") end + return n +end + +function Codex.UpgradeEffects() + if fxUpgrading or not (C_Timer and C_Timer.After) then return end + local store = Codex.db and Codex.db.istats + if not store then return end + local list = {} + for id, st in pairs(store) do + if type(id) == "number" and type(st) == "table" and st._e ~= EFFECT_VER then list[#list + 1] = id end + end + if #list == 0 then return end + table.sort(list) + fxUpgrading = true + local pos = 1 + local function Step() + local n = 0 + while pos <= #list and n < 15 do + local id = list[pos] + local st = store[id] + if type(st) == "table" and st._e ~= EFFECT_VER and Util.GetItem and Util.GetItem(id) then + AddEffects(id, st) + end + pos = pos + 1 + n = n + 1 + end + if pos <= #list then C_Timer.After(0.1, Step) else fxUpgrading = false end + end + Step() +end + +-- Item-Lader (/fwcodex itemload [stop|status]): fordert die Itemdaten der Datenbank langsam an und liest dann Werte und Effekte. +-- Fortsetzen nach Neustart: erledigte Items haben Werte (_e), fehlgeschlagene stehen in db.itemload.fail. +local loader +local LOAD_RATE = 0.25 -- Sekunden zwischen zwei Schritten +local LOAD_MAX_OUT = 40 -- so viele Anfragen gleichzeitig offen +local LOAD_TIMEOUT = 5 -- Sekunden, danach gilt eine Anfrage als fehlgeschlagen + +local function LoaderState() + local db = Codex.db + db.itemload = db.itemload or {} + db.itemload.fail = db.itemload.fail or {} + return db.itemload +end + +function Codex.ItemLoadStatus() + local st = loader + if not st then + FW:Print("Item-Lader: aus. Start mit /fwcodex itemload") + return + end + FW:Print(string.format("Item-Lader: %d von %d bearbeitet, %d geladen, %d fehlgeschlagen, %d offen", st.pos - 1, #st.list, st.loaded, st.failed, st.out)) +end + +function Codex.ItemLoadStop(silent) + if loader then + loader.stop = true + loader = nil + if not silent then FW:Print("Item-Lader gestoppt (Fortsetzen mit /fwcodex itemload).") end + end + if Codex.db then LoaderState().on = nil end +end + +function Codex.ItemLoadStart(ids, resume) + if loader then + Codex.ItemLoadStatus() + return + end + if not (C_Timer and C_Timer.After and Util.RequestItem) then + FW:Print("Item-Lader: nicht verfuegbar (keine Timer/Item-Abfrage).") + return + end + local ls = LoaderState() + local store = Codex.db.istats or {} + local list, seen = {}, {} + for _, id in ipairs(ids or {}) do + if type(id) == "number" and not seen[id] and not ls.fail[id] then + seen[id] = true + local st = store[id] + if not (st and st._e == EFFECT_VER) then list[#list + 1] = id end + end + end + -- zuerst Items, die schon im Cache sind (kosten keine Abfrage) + local cached, rest = {}, {} + for _, id in ipairs(list) do + if Util.GetItem and Util.GetItem(id) then cached[#cached + 1] = id else rest[#rest + 1] = id end + end + table.sort(cached) table.sort(rest) + for _, id in ipairs(rest) do cached[#cached + 1] = id end + local state = { list = cached, pos = 1, loaded = 0, failed = 0, out = 0, pending = {}, t0 = _G.GetTime and _G.GetTime() or 0 } + loader = state + ls.on = true + if #cached == 0 then + FW:Print("Item-Lader: nichts zu tun, alle bekannten Items sind eingelesen.") + loader = nil + ls.on = nil + return + end + FW:Print(string.format("Item-Lader: %d Items, im Hintergrund (Stand mit /fwcodex itemload status, Stopp mit /fwcodex itemload stop).", #cached)) + local nextReport = 500 + local function Now() return _G.GetTime and _G.GetTime() or 0 end + local function Finish() + if loader == state then loader = nil end + LoaderState().on = nil + if Codex.MarkItemsDirty then Codex.MarkItemsDirty() end + FW:Print(string.format("Item-Lader fertig: %d geladen, %d fehlgeschlagen. Bitte /reload, damit alles gespeichert wird.", state.loaded, state.failed)) + end + local function Done(id, info) + if state.pending[id] then + state.pending[id] = nil + state.out = state.out - 1 + end + if info then + state.loaded = state.loaded + 1 + LoaderState().fail[id] = nil + Safe(function() Codex:GetStats(id) end) + else + state.failed = state.failed + 1 + LoaderState().fail[id] = 1 + end + end + local function Step() + if loader ~= state then return end + -- Zeitueberschreitungen + local now = Now() + for id, t in pairs(state.pending) do + if now - t > LOAD_TIMEOUT then Done(id, nil) end + end + local n = 0 + while state.pos <= #state.list and state.out < LOAD_MAX_OUT and n < 10 do + local id = state.list[state.pos] + state.pos = state.pos + 1 + n = n + 1 + if Util.GetItem and Util.GetItem(id) then + state.loaded = state.loaded + 1 + Safe(function() Codex:GetStats(id) end) + else + state.pending[id] = now + state.out = state.out + 1 + Util.RequestItem(id, function(info) Done(id, info) end) + end + end + if state.pos > nextReport then + nextReport = nextReport + 500 + if Codex.MarkItemsDirty then Codex.MarkItemsDirty() end + FW:Print(string.format("Item-Lader: %d von %d, %d geladen, %d fehlgeschlagen", state.pos - 1, #state.list, state.loaded, state.failed)) + end + if state.pos > #state.list and state.out <= 0 then return Finish() end + C_Timer.After(LOAD_RATE, Step) + end + Step() +end + +function Codex:CaptureStats(ids) + local n = 0 + for _, id in ipairs(ids or {}) do + if self.db.istats == nil or self.db.istats[id] == nil then + if self:GetStats(id) then n = n + 1 end + end + end + return n +end + +-- Anzeigename eines Wertes ("ITEM_MOD_AGILITY_SHORT" -> "Beweglichkeit") +function Codex.StatName(key) + if key:sub(1, 3) == "FW_" then + local txt = FW.L["CODEX_FX_" .. key] + -- FW.L liefert bei fehlendem Schluessel den Schluessel selbst zurueck + if type(txt) == "string" and txt ~= "CODEX_FX_" .. key then return txt end + if key:sub(1, 6) == "FW_SK_" then + local nm = key:sub(7):gsub("_", " "):lower():gsub("(%a)([%w]*)", function(a, b) return a:upper() .. b end) + return string.format(FW.L.CODEX_FX_SKILL, nm) + end + end + local g = _G[key] + if type(g) == "string" and g ~= "" then + return g + end + local nm = key:gsub("^ITEM_MOD_", ""):gsub("_SHORT$", ""):gsub("_", " "):lower() + return (nm:gsub("(%a)([%w]*)", function(a, b) return a:upper() .. b end)) +end + +function Codex.FormatStat(value) + if math.abs(value - math.floor(value)) < 0.05 then + return string.format("%d", math.floor(value + 0.5)) + end + return string.format("%.1f", value) +end + +-- Sortierte Liste der Werte-Schluessel, die in der Datenbank vorkommen +Codex.FXKEYS = { "FW_CRIT", "FW_SPELLCRIT", "FW_RANGEDCRIT", "FW_HIT", "FW_SPELLHIT", "FW_DODGE", "FW_PARRY", "FW_BLOCK", "FW_BLOCKVAL", + "FW_MP5", "FW_HP5", "FW_DEFENSE", "FW_FISHING", "FW_HASTE", "FW_RANGEDSPEED", "FW_ARMORPEN", "FW_SPELLPEN", "FW_LIFESTEAL", "FW_AMMODPS", + "FW_SD_ARCANE", "FW_SD_FIRE", "FW_SD_FROST", "FW_SD_HOLY", "FW_SD_NATURE", "FW_SD_SHADOW", + "FW_SWIM", "FW_RUNSPEED", "FW_MOUNTSPEED", "FW_PROC", "FW_USE", "FW_SET", "FW_EQUIPFX", "FW_EXTRADMG" } +function Codex:StatKeys(ids) + local seen, list = {}, {} + for _, key in ipairs(Codex.FXKEYS) do seen[key] = true list[#list + 1] = key end + for _, id in ipairs(ids or {}) do + local st = self.db.istats and self.db.istats[id] + if st then + for rawKey in pairs(st) do + local key = rawKey + if key:sub(1, 9) == "ITEM_MOD_" and key:sub(-6) ~= "_SHORT" then key = key .. "_SHORT" end + if not seen[key] and key:sub(1, 1) ~= "_" then + seen[key] = true + list[#list + 1] = key + end + end + end + end + table.sort(list, function(a, b) return Codex.StatName(a) < Codex.StatName(b) end) + return list +end + +function Codex:GetUnit(id) + return Codex.V().units[id] +end + +function Codex:GetObject(id) + return Codex.V().objects[id] +end + +function Codex:ItemName(itemID) + local c = self.db.cnames and self.db.cnames[itemID] + local lang = Codex.Lang() + if c and c[lang] then + return c[lang] + end + local info = Util.GetItem and Util.GetItem(itemID) + if info and info.name then + return info.name + end + local seen = self.db.names[itemID] or (c and (c.de or c.en)) + if seen then + return seen + end + -- zuletzt die Vorlage (Vanilla-Daten), nur fuer noch nie gesehene Items + local seed = Codex.Seed and Codex.Seed.Ready() and Codex.Seed.Name("items", itemID) + if seed then + return seed + end + return Codex.ExtraItemName and Codex.ExtraItemName(itemID) or nil +end + +------------------------------------------------------------------------ +-- Namen von Hand eintragen (Deutsch / Englisch) +-- Kreaturen, Objekte und Quests: Felder cde / cen am Datensatz (werden geteilt, eigener Eintrag geht vor). +-- Items: db.cnames[itemID] = { de, en } (nur lokal, Itemnamen liefert das Spiel). +------------------------------------------------------------------------ + +function Codex.Lang() + return (FW.activeLocale == "deDE") and "de" or "en" +end + +local CUSTOM_MAX = 60 + +local function CleanCustom(v, max) + if 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, max or CUSTOM_MAX) +end +Codex.CleanCustom = CleanCustom + +-- Anzeigename einer Kreatur / eines Objekts: gewaehlte Sprache, sonst vom Spiel gelernt, sonst die andere Sprache +function Codex.NameOf(ent) + if type(ent) ~= "table" then + return nil + end + local mine, other = ent.cde, ent.cen + if Codex.Lang() == "en" then + mine, other = ent.cen, ent.cde + end + return mine or ent.n or other or ent.ne +end + +function Codex.QuestTitle(q, id) + -- Vorlage (Vanilla) nur als letzter Ausweg + local function SeedTitle() + return Codex.Seed and Codex.Seed.Ready() and Codex.Seed.QuestTitle(id) or nil + end + if type(q) ~= "table" then + return SeedTitle() or ("Quest #" .. tostring(id)) + end + local mine, other = q.cde, q.cen + if Codex.Lang() == "en" then + mine, other = q.cen, q.cde + end + return mine or q.t or other or SeedTitle() or ("Quest #" .. tostring(id)) +end + +local CUSTOM_TABLE = { units = "units", objects = "objects", quests = "quests" } +local CUSTOM_EVENT = { units = "unit", objects = "object", quests = "quest", items = "item", zones = "item" } + +-- Eigene Namen (de, en) zu einer Kreatur, einem Objekt, einer Quest oder einem Item +function Codex:GetCustom(kind, id) + -- vorgemerkte (noch nicht gesendete) Aenderungen eines Bearbeiters zuerst + local hook = Codex.DevHook + local staged = hook and hook.Staged and hook.Staged(kind, id) + if staged then + return staged.de, staged.en + end + if kind == "items" then + local c = self.db.cnames and self.db.cnames[id] + return c and c.de, c and c.en + end + local t = CUSTOM_TABLE[kind] and self.db[CUSTOM_TABLE[kind]] + local e = t and t[id] + return e and e.cde, e and e.cen +end + +-- de / en setzen (leer oder nil loescht). Rueckgabe: true, wenn gespeichert +-- Namen aendern darf nur, wer berechtigt ist und das Modul Freewood-OctoWoW-Dev hat; die Aenderung wird dort erst +-- vorgemerkt (Vorschau) und mit "Senden" uebernommen (direct = true). +function Codex:SetCustom(kind, id, de, en, direct) + if type(id) ~= "number" or not CUSTOM_EVENT[kind] then + return false + end + local max = kind == "quests" and 100 or CUSTOM_MAX + de, en = CleanCustom(de, max), CleanCustom(en, max) + if not direct then + local hook = Codex.DevHook + if not (Codex.Trust and Codex.Trust.CanEdit() and hook and hook.Stage) then + return false, "denied" + end + return hook.Stage(kind, id, de, en) + end + if kind == "items" then + self.db.cnames = self.db.cnames or {} + if de or en then + self.db.cnames[id] = { de = de, en = en } + else + self.db.cnames[id] = nil + end + else + local t = self.db[CUSTOM_TABLE[kind]] + local e = t[id] + if not e then + if not (de or en) then + return true + end + if kind == "units" then + e = self:Unit(id, true) + elseif kind == "objects" then + e = self:Object(id, true) + else + e = self:Quest(id, true) + end + end + e.cde, e.cen = de, en + end + FW:Fire("CODEX_CHANGED", CUSTOM_EVENT[kind], id) + return true +end + +-- "fehlerhaft"-Markierung (true / false = aufgehoben / nil = keine Angabe); gleiche Regeln wie bei Namen +function Codex:SetBad(kind, id, value, direct) + if type(id) ~= "number" or not CUSTOM_TABLE[kind] then + return false + end + if not direct then + local hook = Codex.DevHook + local action = value == true and "bad" or "unbad" + if not (Codex.Trust and Codex.Trust.Can(action) and hook and hook.StageBad) then + return false, "denied" + end + return hook.StageBad(kind, id, value) + end + local t = self.db[CUSTOM_TABLE[kind]] + local e = t[id] + if not e then + if value == nil then return true end + if kind == "units" then e = self:Unit(id, true) elseif kind == "objects" then e = self:Object(id, true) else e = self:Quest(id, true) end + end + e.bad = value + FW:Fire("CODEX_CHANGED", CUSTOM_EVENT[kind], id) + return true +end + +function Codex:IsBad(kind, id) + local t = CUSTOM_TABLE[kind] and self.db[CUSTOM_TABLE[kind]] + local e = t and t[id] + local view = Codex.V()[CUSTOM_TABLE[kind] or ""] + local v = view and view[id] + return (v and v.bad == true) or (e and e.bad == true) or false +end + +------------------------------------------------------------------------ +-- Eigenschaften von Hand (Dev): Zone(n), Dungeon ja/nein, Klasse, Stufen +-- Kreaturen, Objekte, Quests: Feld ed am Datensatz (wird geteilt, eigener Eintrag geht vor). +-- Items: db.ied[itemID] = { ed } (eigener Datensatztyp "i"). Zonen: negative Zahl = Instanz-ID. +-- ed = { z = { mapID, ... }, dg = true/false, cl = "normal"|"elite"|"rare"|"rareelite"|"worldboss", l1, l2 } +------------------------------------------------------------------------ + +-- Gebiete: die von Hand gesetzte Stufenspanne liegt im Item-Datensatz "i" unter einer hohen ID +-- (50000000 + Karten-ID, Instanzen 50200000 + Instanz-ID), damit Teilen, Vormerken und Protokoll gleich laufen. +Codex.ZONE_BASE = 50000000 +function Codex.ZoneEdID(key) + if type(key) ~= "number" then return nil end + if key < 0 then return Codex.ZONE_BASE + 200000 + (-key) end + return Codex.ZONE_BASE + key +end +function Codex.ZoneFromEdID(id) + if type(id) ~= "number" or id < Codex.ZONE_BASE then return nil end + local r = id - Codex.ZONE_BASE + if r >= 200000 then return -(r - 200000) end + return r +end + +local ED_CLASS = { normal = true, elite = true, rare = true, rareelite = true, worldboss = true } +local MAX_ED_ZONES = 6 +Codex.ED_CLASSES = { "normal", "elite", "rare", "rareelite", "worldboss" } +Codex.MAX_ED_ZONES = MAX_ED_ZONES + +local function EdInt(v, lo, hi) + v = tonumber(v) + if v and v == math.floor(v) and v >= lo and v <= hi then return v end + return nil +end + +-- Bereinigt eine Eigenschaften-Tabelle; Rueckgabe nil, wenn nichts uebrig bleibt +-- Quest-Zuordnung von Hand (Kreaturen und Objekte): qk = Quests, deren Ziel dies ist; qd = "Quest:Item" (laesst das Quest-Item fallen) +local function EdIdList(list, max) + if type(list) ~= "table" then return nil end + local ids, seen = {}, {} + for i, p in ipairs(list) do + if i > 20 then break end + p = EdInt(p, 1, 100000000) + if p and not seen[p] and #ids < (max or 8) then ids[#ids + 1] = p seen[p] = true end + end + return #ids > 0 and ids or nil +end +local function EdPairList(list, max) + if type(list) ~= "table" then return nil end + local out, seen = {}, {} + for i, v in ipairs(list) do + if i > 20 then break end + local q, it + if type(v) == "string" then q, it = v:match("^(%d+):(%d+)$") end + q, it = EdInt(q, 1, 100000000), EdInt(it, 1, 100000000) + if q and it then + local key = q .. ":" .. it + if not seen[key] and #out < (max or 8) then out[#out + 1] = key seen[key] = true end + end + end + return #out > 0 and out or nil +end + +-- Zuordnung "gehoert zur Quest": qb (Kreatur, Objekt, Item: Quest-IDs), qr (Quest: Liste "i:Item", "u:Kreatur", "o:Objekt") +local function EdTokenList(list, max) + if type(list) ~= "table" then return nil end + local out, seen = {}, {} + for i, v in ipairs(list) do + if i > 40 then break end + local k, n + if type(v) == "string" then k, n = v:match("^([iuo]):(%d+)$") end + n = EdInt(n, 1, 100000000) + if k and n then + local key = k .. ":" .. n + if not seen[key] and #out < (max or 24) then out[#out + 1] = key seen[key] = true end + end + end + return #out > 0 and out or nil +end + +function Codex.CleanEd(ed, kind) + if type(ed) ~= "table" then return nil end + local out = {} + if kind == "zones" then + -- Gebiete: nur die Stufenspanne von Hand + local l1, l2 = EdInt(ed.l1, 1, 130), EdInt(ed.l2, 1, 130) + if l1 or l2 then + l1, l2 = l1 or l2, l2 or l1 + if l1 > l2 then l1, l2 = l2, l1 end + return { l1 = l1, l2 = l2 } + end + return nil + end + if type(ed.z) == "table" then + local z, seen = {}, {} + for i, m in ipairs(ed.z) do + if i > 20 then break end + m = EdInt(m, -100000, 100000) + if m and m ~= 0 and not seen[m] and #z < MAX_ED_ZONES then + z[#z + 1] = m + seen[m] = true + end + end + if #z > 0 then out.z = z end + end + if ed.dg == true or ed.dg == false then out.dg = ed.dg end + if kind == "units" then + if ED_CLASS[ed.cl] then out.cl = ed.cl end + local l1, l2 = EdInt(ed.l1, 1, 130), EdInt(ed.l2, 1, 130) + if l1 or l2 then + l1, l2 = l1 or l2, l2 or l1 + if l1 > l2 then l1, l2 = l2, l1 end + out.l1, out.l2 = l1, l2 + end + end + if kind == "units" or kind == "objects" then + out.qk = EdIdList(ed.qk) + out.qd = EdPairList(ed.qd) + end + if kind == "units" or kind == "objects" or kind == "items" then + out.qb = EdIdList(ed.qb, 12) + end + if kind == "quests" then + out.qr = EdTokenList(ed.qr) + end + if kind == "items" then + -- Schluessel von Hand: Item, das zum Oeffnen (Truhe) noetig ist, um dieses Item zu bekommen + local key = EdInt(ed.key, 1, 100000000) + if key then out.key = key end + end + if kind == "quests" then + -- Vorquests von Hand (jede genuegt), Folgequests, davor/danach (Reihenfolge im Plan), Stufe, Questtext + local function Ids(list, max) + if type(list) ~= "table" then return nil end + local ids, seen = {}, {} + for i, p in ipairs(list) do + if i > 20 then break end + p = EdInt(p, 1, 100000000) + if p and not seen[p] and #ids < (max or 8) then + ids[#ids + 1] = p + seen[p] = true + end + end + return #ids > 0 and ids or nil + end + out.pre = Ids(ed.pre) + out.nx = Ids(ed.nx) + out.bf = Ids(ed.bf) + out.af = Ids(ed.af) + local ql = EdInt(ed.ql, 1, 130) + if ql then out.ql = ql end + local si = EdInt(ed.si, 1, 100000000) -- Item, durch das die Quest startet + if si then out.si = si end + if type(ed.tx) == "string" then + local t = ed.tx:gsub("[%c]", " "):gsub("^%s+", ""):gsub("%s+$", "") + if t ~= "" then out.tx = t:sub(1, 600) end + end + end + if next(out) == nil then return nil end + return out +end + +local function CopyEd(ed) + if not ed then return nil end + local out = { dg = ed.dg, cl = ed.cl, l1 = ed.l1, l2 = ed.l2, key = ed.key, ql = ed.ql, tx = ed.tx, si = ed.si } + if ed.z then + out.z = {} + for i, m in ipairs(ed.z) do out.z[i] = m end + end + for _, f in ipairs({ "pre", "nx", "bf", "af", "qk", "qd", "qb", "qr" }) do + if ed[f] then + out[f] = {} + for i, p in ipairs(ed[f]) do out[f][i] = p end + end + end + return out +end +Codex.CopyEd = CopyEd + +-- Gespeicherter Stand (ohne Vormerkung) +function Codex:GetEdStored(kind, id) + local t = (kind == "items" or kind == "zones") and self.db.ied or (CUSTOM_TABLE[kind] and self.db[CUSTOM_TABLE[kind]]) + local e = t and t[id] + return e and e.ed or nil +end + +-- Eigenschaften zu einer Kreatur, einem Objekt, einer Quest oder einem Item (vorgemerkte Aenderungen zuerst) +function Codex:GetEd(kind, id) + local hook = Codex.DevHook + local st = hook and hook.StagedEd and hook.StagedEd(kind, id) + if st then return CopyEd(st.ed) end + return CopyEd(self:GetEdStored(kind, id)) +end + +-- ed = nil loescht. Wie bei Namen: erst vormerken (Freewood-OctoWoW-Dev), mit "Senden" uebernehmen (direct = true) +function Codex:SetEd(kind, id, ed, direct) + if type(id) ~= "number" or not CUSTOM_EVENT[kind] then + return false + end + ed = Codex.CleanEd(ed, kind) + if not direct then + local hook = Codex.DevHook + if not (Codex.Trust and Codex.Trust.CanEdit() and hook and hook.StageEd) then + return false, "denied" + end + return hook.StageEd(kind, id, ed) + end + if kind == "items" or kind == "zones" then + self.db.ied = self.db.ied or {} + self.db.ied[id] = ed and { ed = CopyEd(ed) } or nil + if Codex.Share and Codex.Share.NoteEd then Codex.Share.NoteEd(ed ~= nil) end + else + local t = self.db[CUSTOM_TABLE[kind]] + local e = t[id] + if not e then + if not ed then return true end + if kind == "units" then e = self:Unit(id, true) elseif kind == "objects" then e = self:Object(id, true) else e = self:Quest(id, true) end + end + e.ed = CopyEd(ed) + if Codex.Share and Codex.Share.NoteEd then Codex.Share.NoteEd(ed ~= nil) end + end + Codex.chainRev = (Codex.chainRev or 0) + 1 + FW:Fire("CODEX_CHANGED", CUSTOM_EVENT[kind], id) + return true +end + +-- Verschlossene Truhen: Item, das ein anderes Item (aus einer Truhe) erst zugaenglich macht { [Item] = Schluessel-Item }. +-- Eingebaut: Vergilbter Umschlag (Truhe auf dem Gebaeude bei Lieutenant Benedict) braucht Benedicts Schluessel. +Codex.KEY_DEFAULT = { [4881] = 4882 } + +function Codex.KeyOf(itemID) + if not itemID then return nil end + local ed = Codex:GetEd("items", itemID) + if ed and ed.key then return ed.key end + return Codex.KEY_DEFAULT[itemID] +end + +-- Datensatz mit Eigenschaften: ueberschreibt Klasse (c), Stufen (l1, l2), Dungeon (dg) und Zonen (ez, bei Quests zone). +-- inplace = true: direkt in der (frisch gebauten) Tabelle e, sonst eine Ueberlagerung ueber dem Original. +function Codex.ApplyEd(e, kind, inplace) + local ed = type(e) == "table" and e.ed + if not ed then return e end + local o = inplace and e or {} + if ed.cl then + if ed.cl == "normal" then o.c = false else o.c = ed.cl end + end + if ed.l1 then o.l1, o.l2 = ed.l1, ed.l2 or ed.l1 end + if ed.dg ~= nil then o.dg = ed.dg end + if ed.z then + o.ez = ed.z + if kind == "quests" then o.zone = ed.z[1] end + end + if ed.pre then o.pre = ed.pre end + if ed.key then o.key = ed.key end + if kind == "quests" then + if ed.ql then o.lvl = ed.ql end + if ed.si then o.si = ed.si end + if ed.tx then o.dtx = ed.tx end + end + if inplace then return e end + return setmetatable(o, { __index = e }) +end + +-- Karten eines Datensatzes: von Hand gesetzte zuerst, sonst die Hauptkarte der Fundorte +function Codex.ZonesOf(e) + if type(e) ~= "table" then return {} end + if e.ez and #e.ez > 0 then return e.ez end + if type(e.iz) == "table" then + local best, bestN + for id, n in pairs(e.iz) do + if not bestN or n > bestN then best, bestN = id, n end + end + if best then return { -best } end + end + local m = e.cells and Codex.MainMap(e.cells) + return m and { m } or {} +end + +function Codex:SetCustomLang(kind, id, lang, name) + local de, en = self:GetCustom(kind, id) + if lang == "de" then + de = name + else + en = name + end + return self:SetCustom(kind, id, de, en) +end + +-- Text mit Zeilen "ID Name" (auch "ID;Name", "ID=Name", "IDName") einlesen. +-- lang: "de" oder "en". Rueckgabe: gespeichert, uebersprungen +function Codex:ImportNames(kind, lang, text) + local done, skipped = 0, 0 + if type(text) ~= "string" or not CUSTOM_EVENT[kind] or (lang ~= "de" and lang ~= "en") then + return 0, 0 + end + local lines = 0 + for line in (text .. "\n"):gmatch("([^\r\n]*)[\r\n]") do + local id, name = line:match("^%s*#?(%d+)%s*[=;:,\t ]%s*(.-)%s*$") + if id or line:find("%S") then + lines = lines + 1 + if lines > 1000 then + skipped = skipped + 1 + elseif id and CleanCustom(name) and tonumber(id) <= 100000000 then + self:SetCustomLang(kind, tonumber(id), lang, name) + done = done + 1 + else + skipped = skipped + 1 + end + end + end + return done, skipped +end + +-- Drop-Chance: Anteil der gelooteten Leichen, in denen das Item lag. Rueckgabe: chance (0..1), n, k +function Codex.DropChance(ent, itemID, alt) + local bucket = alt and ent.alt or ent + if not bucket or not bucket.d or not bucket.d[itemID] or not bucket.k or bucket.k == 0 then + return nil, 0, bucket and bucket.k or 0 + end + local d = bucket.d[itemID] + local n = d.n + if d.qi and d.qk and d.qk >= n and d.qk > 0 then + return n / d.qk, n, d.qk -- Quest-Item: nur Leichen mit laufender Quest + end + return n / bucket.k, n, bucket.k +end + +-- Sortierte Beuteliste { { id, chance, n, k, avg, qi } } +function Codex.DropList(ent, alt) + local bucket = alt and ent.alt or ent + local list = {} + if bucket and bucket.d and bucket.k and bucket.k > 0 then + for id, d in pairs(bucket.d) do + local den = (d.qi and d.qk and d.qk >= d.n and d.qk > 0) and d.qk or bucket.k + list[#list + 1] = { + id = id, chance = d.n / den, n = d.n, k = den, + avg = d.n > 0 and d.q / d.n or 1, qi = d.qi and true or false, + } + end + end + table.sort(list, function(a, b) + if a.chance ~= b.chance then + return a.chance > b.chance + end + return a.id < b.id + end) + return list +end + +-- Durchschnittliches Gold pro Leiche mit Gold (Kupfer) +function Codex.AverageCoin(ent) + if ent.coin and ent.coinN and ent.coinN > 0 then + return ent.coin / ent.coinN + end + return nil +end + +-- Orte: Zellen zu Fundstellen zusammenfassen (je BIN x BIN Zellen eine Stelle). +-- Rueckgabe: { { m, x, y, n } } sortiert nach Treffern, x/y als Anteil 0..1 +local SPOT_BIN = 5 + +function Codex.TopSpots(cells, limit) + local bins = {} + for key, n in pairs(cells or {}) do + local m = math.floor(key / (GRID * GRID)) + local idx = key % (GRID * GRID) + local cx, cy = idx % GRID, math.floor(idx / GRID) + local bk = m * 1000000 + math.floor(cy / SPOT_BIN) * 1000 + math.floor(cx / SPOT_BIN) + local b = bins[bk] + if not b then + b = { m = m, sx = 0, sy = 0, n = 0 } + bins[bk] = b + end + b.sx, b.sy, b.n = b.sx + (cx + 0.5) * n, b.sy + (cy + 0.5) * n, b.n + n + end + local list = {} + for _, b in pairs(bins) do + list[#list + 1] = { m = b.m, x = b.sx / b.n / GRID, y = b.sy / b.n / GRID, n = b.n } + end + table.sort(list, function(a, b) + if a.n ~= b.n then return a.n > b.n end + if a.m ~= b.m then return a.m < b.m end + if a.y ~= b.y then return a.y < b.y end + return a.x < b.x + end) + if limit and #list > limit then + for i = #list, limit + 1, -1 do list[i] = nil end + end + return list +end + +function Codex.AddCells(dst, cells) + for key, n in pairs(cells or {}) do + dst[key] = (dst[key] or 0) + n + end + return dst +end + +local MAX_POINTS = 4000 + +local function EntityName(ent, id, kind) + return (ent and ent.n) or ((kind == "objects" and "#" or "#") .. tostring(id)) +end + +local function AddEntityPoints(points, ent, id, kind, label) + if not ent or not ent.cells then + return + end + for key, n in pairs(ent.cells) do + if #points >= MAX_POINTS then + return + end + local m = math.floor(key / (GRID * GRID)) + local idx = key % (GRID * GRID) + points[#points + 1] = { + m = m, x = ((idx % GRID) + 0.5) / GRID, y = (math.floor(idx / GRID) + 0.5) / GRID, + n = n, name = label or EntityName(ent, id, kind), k = kind == "objects" and "o" or "u", + } + end +end + +-- Fundstellen der Vorlage (Vanilla) als Punkte +local function AddSeedPoints(points, kind, id, label) + local Seed = Codex.Seed + if not (Seed and Seed.Ready()) then + return + end + local cells = Seed.Cells(kind, id) + if next(cells) then + AddEntityPoints(points, { cells = cells, n = Seed.Name(kind, id) }, id, kind, label) + end +end + +local function HasCells(ent) + return ent and ent.cells and next(ent.cells) ~= nil +end + +local function AddSourceKey(points, key, label) + local tk = key:sub(1, 1) == "u" and "units" or "objects" + local id = tonumber(key:sub(2)) + local ent = id and (tk == "units" and Codex.V().units[id] or Codex.V().objects[id]) + if HasCells(ent) then + AddEntityPoints(points, ent, id, tk, label) + elseif id then + AddSeedPoints(points, tk, id, label) + end +end + +-- Punkte fuer die Weltkarte. spec = { kind = "units"|"objects"|"items"|"quests", id = ... } +-- Punkt: { m, x, y, n, name, pin } (pin: feste Stelle wie Quest-Geber) +function Codex.CollectPoints(spec) + local points = {} + local kind, id = spec.kind, spec.id + if kind == "units" or kind == "objects" then + local ent = kind == "units" and Codex.V().units[id] or Codex.V().objects[id] + if HasCells(ent) then + AddEntityPoints(points, ent, id, kind) + else + AddSeedPoints(points, kind, id) + end + elseif kind == "items" then + for _, pair in ipairs({ { "units", Codex.V().units }, { "objects", Codex.V().objects } }) do + for eid, ent in pairs(pair[2]) do + local chance = Codex.DropChance(ent, id, false) + if chance then + AddEntityPoints(points, ent, eid, pair[1], + string.format("%s (%d%%)", EntityName(ent, eid, pair[1]), math.floor(chance * 100 + 0.5))) + end + end + end + -- Vorlage: nur wenn es noch keine eigenen Fundstellen gibt (die besten Quellen) + local Seed = Codex.Seed + if #points == 0 and Seed and Seed.Ready() then + local it = Seed.Item(id) + for _, def in ipairs({ { "units", it and it.u or {}, 8 }, { "objects", it and it.o or {}, 3 } }) do + for i, src in ipairs(def[2]) do + if i > def[3] then break end + AddSeedPoints(points, def[1], src.id, + string.format("%s (%d%%)", Seed.Name(def[1], src.id) or ("#" .. src.id), math.floor(src.chance + 0.5))) + end + end + end + elseif kind == "quests" then + local q = Codex.V().quests[id] + if q then + for _, def in ipairs({ { q.give, L.CODEX_TAG_GIVE, "give" }, { q.turn, L.CODEX_TAG_TURN, "turn" } }) do + local ref = def[1] + if ref and ref.m and ref.x and ref.y then + points[#points + 1] = { m = ref.m, x = ref.x, y = ref.y, n = 4, pin = true, tag = def[3], + name = string.format(def[2], ref.n or "?") } + end + end + for _, o in ipairs(q.obj or {}) do + if o.ty == "monster" then + for uid in pairs(o.ids or {}) do + AddEntityPoints(points, Codex.V().units[uid], uid, "units") + end + for key in pairs(Codex.QuestKillKeys(id)) do AddSourceKey(points, key) end + elseif o.ty == "item" then + for _, itemID in ipairs(Codex.ObjectiveItems(q, o, id)) do + local before = #points + for key in pairs(Codex.QuestItemKeys(id, itemID)) do AddSourceKey(points, key) end + for key in pairs(q.qi and q.qi[itemID] or {}) do + AddSourceKey(points, key) + end + -- eigene Quests (Creator) kennen keine Quest-Gegenstaende: dann die bekannten Quellen des Gegenstands zeigen + if q.loc and #points == before then + for _, p in ipairs(Codex.CollectPoints({ kind = "items", id = itemID })) do points[#points + 1] = p end + end + end + end + end + end + -- Vorlage: Geber, Abgabe und Ziele, soweit eigene Daten fehlen + local Seed = Codex.Seed + local sq = Seed and Seed.Ready() and Seed.Quest(id) + if sq then + local function Pin(refs, tag, have, tagName) + if have then return end + for _, r in ipairs(refs) do + if r.kind ~= "items" then + local m, x, y = Seed.FirstSpot(r.kind, r.id) + if m then + points[#points + 1] = { m = m, x = x, y = y, n = 4, pin = true, tag = tagName, + name = string.format(tag, Seed.Name(r.kind, r.id) or ("#" .. r.id)) } + end + end + end + end + Pin(sq.start, L.CODEX_TAG_GIVE, q and q.give and q.give.m, "give") + Pin(sq.stop, L.CODEX_TAG_TURN, q and q.turn and q.turn.m, "turn") + if not (q and q.obj and #q.obj > 0) then + for _, r in ipairs(sq.obj) do + if r.kind ~= "items" then + AddSeedPoints(points, r.kind, r.id) + end + end + end + end + end + return points +end + +-- Karte und uebergeordnete Karten (Stadt -> Gebiet -> Kontinent) +function Codex.MapChain(mapID) + local list, seen = { mapID }, { [mapID] = true } + local CM = _G.C_Map + local id = mapID + for _ = 1, 6 do + local ok, info = pcall(function() return CM and CM.GetMapInfo and CM.GetMapInfo(id) end) + local parent = ok and info and info.parentMapID or nil + if not parent or parent == 0 or seen[parent] then break end + list[#list + 1] = parent + seen[parent] = true + id = parent + end + return list +end + +-- Position des Spielers auf der Karte mapID (auch auf einer uebergeordneten Karte) +function Codex.PlayerPosOn(mapID, loc) + if not loc then return nil end + if loc.mapID == mapID and loc.x and loc.y then return loc.x, loc.y end + local CM = _G.C_Map + if CM and CM.GetPlayerMapPosition then + local ok, pos = pcall(CM.GetPlayerMapPosition, mapID, "player") + if ok and pos and pos.GetXY then + local x, y = pos:GetXY() + if type(x) == "number" and type(y) == "number" and (x ~= 0 or y ~= 0) then return x, y end + end + end + return nil +end + +-- Kann der Spieler die Quest jetzt annehmen? Nicht angenommen, nicht abgeschlossen, Mindeststufe erreicht, +-- Volk und Klasse passen, und (laut Vorlage) wurde eine Vorquest abgeschlossen. +local function HasBit(mask, index) + return math.floor(mask / 2 ^ (index - 1)) % 2 == 1 +end + +-- Klasse und Volk des Spielers als Nummer (Bit-Index fuer die Masken der Quest-Daten). +-- Der Client liefert UnitClass/UnitRace nur mit Name und englischem Kennwort (kein dritter Wert): daraus die Nummer ableiten. +local CLASS_ID = { WARRIOR = 1, PALADIN = 2, HUNTER = 3, ROGUE = 4, PRIEST = 5, SHAMAN = 7, MAGE = 8, WARLOCK = 9, DRUID = 11 } +local RACE_ID = { HUMAN = 1, ORC = 2, DWARF = 3, NIGHTELF = 4, SCOURGE = 5, UNDEAD = 5, TAUREN = 6, GNOME = 7, TROLL = 8, + GOBLIN = 9, BLOODELF = 10, HIGHELF = 10 } +local function PlayerClassID() + if not UnitClass then return nil end + local _, token, id = UnitClass("player") + if type(id) == "number" and id > 0 then return id end + return type(token) == "string" and CLASS_ID[token:upper()] or nil +end +local function PlayerRaceID() + if not UnitRace then return nil end + local name, token, id = UnitRace("player") + if type(id) == "number" and id > 0 then return id end + local key = type(token) == "string" and token:upper():gsub("[%s_]", "") or nil + if key and RACE_ID[key] then return RACE_ID[key] end + key = type(name) == "string" and name:upper():gsub("[%s_]", "") or nil + return key and RACE_ID[key] or nil +end +Codex.PlayerClassID, Codex.PlayerRaceID = PlayerClassID, PlayerRaceID +-- assume: Menge von Quest-IDs, die als abgegeben gelten (Plan: Folgequest nach der Abgabe) +function Codex.QuestAvailable(id, assume) + if Codex.QuestStatus(id) ~= nil then return false end + local sq = Codex.Seed and Codex.Seed.Ready() and Codex.Seed.Quest(id) or nil + local live = Codex.V().quests[id] + if not sq and not live then return true end + -- Voraussetzungen: Vorlage, sonst die Angaben in den eigenen Daten + local min = (sq and sq.min) or (live and live.mn) + if (not min or min == 0) and live and Codex.Avail then + local own = Codex.db.quests[id] + min = Codex.Avail.InferredMin(live, own ~= nil and own.mi ~= nil) or min + end + local race = (sq and sq.race) or (live and live.rc) + local class = (sq and sq.class) or (live and live.cl) + local pl = Codex.sim and Codex.sim.level or (UnitLevel and UnitLevel("player")) or nil + if min and min > 0 and pl and pl < min then return false end + if race and race > 0 then + local raceID = PlayerRaceID() + if raceID and not HasBit(race, raceID) then return false end + end + if class and class > 0 then + local classID = PlayerClassID() + if classID and not HasBit(class, classID) then return false end + end + local pre = Codex.QuestPre(id) + if #pre > 0 then + local any = false + for _, p in ipairs(pre) do + if Codex.QuestStatus(p) == "done" or (assume and assume[p]) then any = true break end + end + if not any then return false end + end + return true +end + +-- Quest-Route: eine Station (der Questgeber) je annehmbarer Quest. +-- Schritt 1 (aufwendig, einmal je Route): Kandidaten mit Position sammeln +-- opts (optional): { below = n, above = m } = nur Quests von Spielerstufe - n bis Spielerstufe + m +local function QuestLevel(id) + local q = Codex.V().quests[id] + local lv = q and tonumber(q.lvl) + if not lv or lv <= 0 then + local sq = Codex.Seed and Codex.Seed.Ready() and Codex.Seed.Quest(id) or nil + lv = sq and tonumber(sq.lvl) + end + return lv and lv > 0 and lv or nil +end + +function Codex.QuestRouteCandidates(ids, opts) + local cands = {} + local pl = UnitLevel and UnitLevel("player") or nil + local lo, hi + if opts and pl then + if opts.below then lo = pl - opts.below end + if opts.above then hi = pl + opts.above end + end + for _, id in ipairs(ids) do + local inRange = true + if lo or hi then + local lv = QuestLevel(id) + inRange = lv ~= nil and (not lo or lv >= lo) and (not hi or lv <= hi) + end + if inRange and Codex.QuestAvailable(id) then + local pick, any + for _, p in ipairs(Codex.CollectPoints({ kind = "quests", id = id })) do + if p.pin then + any = any or p + if p.tag == "give" and not pick then pick = p end + end + end + pick = pick or any + if pick then + local q = Codex.V().quests[id] + cands[#cands + 1] = { id = id, m = pick.m, x = pick.x, y = pick.y, title = Codex.QuestTitle(q, id) } + end + end + end + return cands +end + +-- Schritt 2 (billig, bei jeder Bewegung): Karte waehlen und nach naechstem Nachbarn ordnen. +-- Rueckgabe: Punkte (mit Nummer im Namen), Karten-ID, Weg (Spielerposition + Stationen) +local MAX_ROUTE = 40 +function Codex.QuestRouteOrder(cands, loc) + if #cands == 0 then return {}, nil, {} end + -- Karte: die des Spielers, sonst (Stadt, Hoehle ...) die naechste uebergeordnete Karte mit Stationen. + -- Ohne Spielerposition die Karte mit den meisten Stationen. Nie ein fernes Gebiet. + local perMap, bestMap, bestN = {}, nil, 0 + for _, c in ipairs(cands) do + perMap[c.m] = (perMap[c.m] or 0) + 1 + if perMap[c.m] > bestN or (perMap[c.m] == bestN and c.m < bestMap) then bestMap, bestN = c.m, perMap[c.m] end + end + local mapID + if loc and loc.mapID then + for _, m in ipairs(Codex.MapChain(loc.mapID)) do + if perMap[m] then mapID = m break end + end + if not mapID then return {}, nil, {} end + else + mapID = bestMap + end + local pool = {} + for _, c in ipairs(cands) do + if c.m == mapID then pool[#pool + 1] = c end + end + table.sort(pool, function(a, b) return a.id < b.id end) + local cx, cy = Codex.PlayerPosOn(mapID, loc) + local path = {} + if cx then path[1] = { m = mapID, x = cx, y = cy } end + if not cx then cx, cy = pool[1].x, pool[1].y end + local points = {} + while #pool > 0 and #points < MAX_ROUTE do + local bi, bd + for i, c in ipairs(pool) do + local d = (c.x - cx) ^ 2 + (c.y - cy) ^ 2 + if not bd or d < bd then bi, bd = i, d end + end + local c = table.remove(pool, bi) + points[#points + 1] = { m = c.m, x = c.x, y = c.y, n = 4, pin = true, id = c.id, + name = string.format("%d. %s", #points + 1, c.title) } + cx, cy = c.x, c.y + path[#path + 1] = { m = c.m, x = c.x, y = c.y } + end + return points, mapID, path +end + +function Codex.QuestRoute(ids, loc) + return Codex.QuestRouteOrder(Codex.QuestRouteCandidates(ids), loc) +end + +-- Itemdaten (Qualitaet, Typ, Preis) nachladen; feuert CODEX_ITEMS_LOADED, wenn etwas angekommen ist. +-- Rueckgabe: Zahl neu angeforderter Items +function Codex:LoadItemInfo(ids) + local started = 0 + for _, id in ipairs(ids) do + if not state.requested[id] and not Util.GetItem(id) then + state.requested[id] = true + state.pendingInfo = state.pendingInfo + 1 + started = started + 1 + Util.RequestItem(id, function() + state.pendingInfo = math.max(0, state.pendingInfo - 1) + if not state.infoTimer then + state.infoTimer = true + C_Timer.After(0.5, function() + state.infoTimer = nil + FW:Fire("CODEX_ITEMS_LOADED") + end) + end + end) + end + end + return started +end + +-- Durchschnittlicher Wert einer gelooteten Leiche in Kupfer: tatsaechliches Gold plus Haendlerwert +-- der Items nach Drop-Chance und Durchschnittsmenge. Rueckgabe: gesamt, gold, items, Items ohne Preisdaten +function Codex.KillValue(ent, useAH) + local k = ent.k or 0 + if k <= 0 then + return nil + end + local coin = (ent.coin or 0) / k + local items, missing = 0, 0 + for id, d in pairs(ent.d or {}) do + if not d.qi then + local info = Util.GetItem(id) + if info then + local v = info.sellPrice or 0 + if useAH then + local ah = Codex.AuctionPrice(id) + if ah and ah > v then v = ah end + end + items = items + (d.q / k) * v + else + local ah = useAH and Codex.AuctionPrice(id) + if ah then + items = items + (d.q / k) * ah + else + missing = missing + 1 + end + end + end + end + return coin + items, coin, items, missing +end + +-- Auktionshauspreis in Kupfer. Ein spaeteres Auktionsmodul setzt Codex.AuctionProvider = function(itemID) return kupfer end; +-- ohne Anbieter bleibt der Wert leer (und die Sortierung "Auktionspreis" wird nicht angeboten). +function Codex.AuctionPrice(itemID) + local fn = Codex.AuctionProvider + if type(fn) ~= "function" then + return nil + end + local ok, v = pcall(fn, itemID) + if ok and type(v) == "number" and v == v and v >= 0 then + return v + end + return nil +end + +function Codex:Counts() + local q, u, o, n = 0, 0, 0, 0 + for _ in pairs(Codex.V().quests) do q = q + 1 end + for _ in pairs(Codex.V().units) do u = u + 1 end + for _ in pairs(Codex.V().objects) do o = o + 1 end + for _ in pairs(self.db.names) do n = n + 1 end + return q, u, o, n +end + +------------------------------------------------------------------------ +-- Start +------------------------------------------------------------------------ + +function Codex:OnEnable() + -- Teilen ist von Anfang an an: einmalig auch bei vorhandenen Daten einschalten (danach gilt, was der Spieler waehlt) + if self.db and self.db.settings and not self.db.settings.shareDefaultOn then + self.db.settings.shareDefaultOn = true + self.db.settings.share = true + end + FW:RegisterEvent("QUEST_DETAIL", self.OnQuestDetail, self) + FW:RegisterEvent("QUEST_ACCEPTED", self.OnQuestAccepted, self) + FW:RegisterEvent("QUEST_PROGRESS", self.OnQuestProgress, self) + FW:RegisterEvent("QUEST_COMPLETE", self.OnQuestComplete, self) + FW:RegisterEvent("QUEST_TURNED_IN", self.OnQuestTurnedIn, self) + FW:RegisterEvent("QUEST_REMOVED", self.OnQuestRemoved, self) + FW:RegisterEvent("QUEST_LOG_UPDATE", self.OnQuestLogUpdate, self) + FW:RegisterEvent("UNIT_QUEST_LOG_CHANGED", self.OnQuestLogUpdate, self) + FW:RegisterEvent("LOOT_OPENED", self.OnLootOpened, self) + FW:RegisterEvent("CHAT_MSG_COMBAT_FACTION_CHANGE", self.OnFactionMessage, self) + FW:RegisterEvent("CHAT_MSG_COMBAT_XP_GAIN", self.OnXPMessage, self) + FW:RegisterEvent("PLAYER_LEVEL_UP", self.OnLevelUp, self) + for _, event in ipairs({ "PLAYER_REGEN_DISABLED", "PLAYER_ENTERING_WORLD", "UPDATE_EXHAUSTION" }) do local event = event + FW:RegisterEvent(event, function() Codex:OnPoolEvent(event) end, self) + end + C_Timer.After(2, function() Codex:OnPoolEvent("PLAYER_ENTERING_WORLD") end) + state.pl = Num(UnitLevel and UnitLevel("player")) + FW:RegisterEvent("PLAYER_TARGET_CHANGED", self.OnTargetChanged, self) + FW:RegisterEvent("UPDATE_MOUSEOVER_UNIT", self.OnMouseover, self) + + if not self.hooked and hooksecurefunc and type(_G.GetQuestReward) == "function" then + hooksecurefunc("GetQuestReward", function(index) + Codex:OnChoice(index) + end) + self.hooked = true + end + if not self.useHooked and hooksecurefunc then + local ci = _G.C_Container + if ci and type(ci.UseContainerItem) == "function" then + hooksecurefunc(ci, "UseContainerItem", function(bag, slot) Codex.NoteItemUse(bag, slot) end) + self.useHooked = true + elseif type(_G.UseContainerItem) == "function" then + hooksecurefunc("UseContainerItem", function(bag, slot) Codex.NoteItemUse(bag, slot) end) + self.useHooked = true + end + end + if not self.tipHooked and TooltipDataProcessor and TooltipDataProcessor.AddTooltipPostCall + and Enum and Enum.TooltipDataType and Enum.TooltipDataType.Object then + local ok = pcall(TooltipDataProcessor.AddTooltipPostCall, Enum.TooltipDataType.Object, function(_, data) + Codex:OnObjectTooltip(data) + end) + self.tipHooked = ok + end + C_Timer.After(3, function() + Codex:ScanLog() + end) + C_Timer.After(6, function() Safe(Codex.FillGaps) end) + C_Timer.After(40, function() Safe(Codex.UpgradeEffects) end) + C_Timer.After(10, function() Safe(Codex.PurgeMissing) end) + C_Timer.After(90, function() + local ls = Codex.db and Codex.db.itemload + if ls and ls.on and Codex.AllItemIds then Safe(function() Codex.ItemLoadStart(Codex.AllItemIds(), true) end) end + end) + C_Timer.After(4, function() + if Codex.Forever then Codex.Forever.Load() end + end) + if self.Avail then + self.Avail:Start() + end + if self.Names then + self.Names:Start() + end + if self.Time then + self.Time:Start() + end + if self.Roles then + self.Roles:Start() + end + if self.Recipes then + self.Recipes:Start() + end + if self.Sync then + self.Sync:Init() + end + if self.Lookup then + self.Lookup:Start() + end +end + +function Codex:OnDisable() + for _, event in ipairs({ "QUEST_DETAIL", "QUEST_ACCEPTED", "QUEST_PROGRESS", "QUEST_COMPLETE", "QUEST_TURNED_IN", "QUEST_REMOVED", + "QUEST_LOG_UPDATE", "UNIT_QUEST_LOG_CHANGED", "LOOT_OPENED", "CHAT_MSG_COMBAT_FACTION_CHANGE", "CHAT_MSG_COMBAT_XP_GAIN", "PLAYER_LEVEL_UP", + "PLAYER_TARGET_CHANGED", "UPDATE_MOUSEOVER_UNIT", + "PLAYER_REGEN_DISABLED", "PLAYER_ENTERING_WORLD", "UPDATE_EXHAUSTION" }) do + FW:UnregisterEvent(event, self) + end + if self.Names then + self.Names:Stop() + end + if self.Time then + self.Time:Stop() + end + if self.Roles then + self.Roles:Stop() + end + if self.Recipes then + self.Recipes:Stop() + end + if self.Sync then + self.Sync:Stop() + end + if self.Lookup then + self.Lookup:Stop() + end +end + +-- Test im Spiel: /fwcodex test +function Codex:RunTest() + local Lc = FW.L + local function yn(v) + return v and Lc.CODEX_YES or Lc.CODEX_NO + end + FW:Print(string.format(Lc.CODEX_TEST_API, + yn(GetLootSourceInfo), yn(C_QuestLog and C_QuestLog.GetQuestObjectives), yn(GetQuestReward), yn(self.hooked))) + local q, u, o, n = self:Counts() + FW:Print(string.format(Lc.CODEX_TEST_COUNTS, q, u, o, n)) + FW:Print(string.format(Lc.CODEX_TEST_STATS, state.stats.loot, state.stats.errors)) + local rows = self:XPRows(true) + FW:Print(string.format(Lc.CODEX_TEST_XP, #rows, #self.db.xpUnparsed)) + for _, text in ipairs(self.db.xpUnparsed) do + FW:Print(" ? " .. text) + end + if self.Share then + local own = self.Share.Summary() + local senders, records = self.Share.RemoteStats() + FW:Print(string.format(Lc.CODEX_TEST_SHARE, self.db.settings.share and Lc.CODEX_ON or Lc.CODEX_OFF, own or 0, senders, records)) + end + local empty = self.db.objEmpty or {} + FW:Print(string.format(Lc.CODEX_TEST_OBJ, #empty)) + for _, text in ipairs(empty) do + FW:Print(" ? " .. text) + end +end diff --git a/CodexAvail.lua b/CodexAvail.lua new file mode 100644 index 0000000..fd7abcb --- /dev/null +++ b/CodexAvail.lua @@ -0,0 +1,581 @@ +-- Freewood-OctoWoW-Codex: Verfuegbarkeit von Quests (noch nicht annehmbar) und Voraussetzungen lernen +-- +-- Der Server verraet nicht, welche Quests man jetzt annehmen koennte oder welche Vorquest fehlt. Das Spiel liefert nur: +-- * die Angebotsliste eines NPCs oder Objekts, waehrend man mit ihm spricht (GOSSIP_SHOW, QUEST_GREETING, QUEST_DETAIL) +-- * welche Quests der Charakter erledigt hat (IsQuestFlaggedCompleted / GetAllCompletedQuestIDs) +-- Daraus entsteht ein Vergleich zweier Zeitpunkte: +-- Negativ-Beobachtung: Eine Quest sollte hier angeboten werden, wird es aber nicht (oder der Spieler markiert sie als "noch nicht +-- moeglich"). Gemerkt werden Stufe und die Menge der erledigten Quests. +-- Positiv-Beobachtung: Spaeter wird sie angeboten oder angenommen. Die Vorquest muss unter den seither erledigten Quests liegen, +-- und hat sich nur die Stufe geaendert, ist das die Mindeststufe. +-- Die Markierung "noch nicht moeglich" gilt je Charakter (db.avail[Charakter]). Die gelernten Angaben stehen in den Quest-Daten +-- und werden geteilt: pi (Kandidat -> Anzahl), po (Beobachtungen), mi (Stufenbereich lo/hi/n). Als Vorquest gilt ein Kandidat, der in +-- allen Beobachtungen vorkam: fuer den eigenen Charakter sofort, fuer andere erst ab zwei Beobachtungen. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Avail = { probe = nil } +Codex.Avail = Avail +NS.Avail = Avail + +local MAX_DELTA = 6 -- mehr neu erledigte Quests sind keine brauchbare Beobachtung +local MAX_PI = 8 +local NEG_KEEP = 60 * 24 * 3600 +local function Now() return _G.time and _G.time() or 0 end + +local function SelfKey() + return Codex.Trust and Codex.Trust.SelfKey and Codex.Trust.SelfKey() or nil +end + +local function Char(create) + local db = Codex.db + if not db then return nil end + local key = SelfKey() + if not key then return nil end + db.avail = db.avail or {} + local c = db.avail[key] + if not c and create then + c = { blocked = {}, neg = {} } + db.avail[key] = c + end + return c +end + +local function PlayerLevel() + local lv = _G.UnitLevel and _G.UnitLevel("player") + return tonumber(lv) or 1 +end + +------------------------------------------------------------------------ +-- Erledigte Quests +------------------------------------------------------------------------ + +-- Menge der erledigten Quest-IDs des Charakters; Ersatz: bekannte IDs einzeln abfragen +function Avail.Completed() + local set = {} + local QL = _G.C_QuestLog + if QL and QL.GetAllCompletedQuestIDs then + local ok, list = pcall(QL.GetAllCompletedQuestIDs) + if ok and type(list) == "table" then + for _, id in pairs(list) do set[id] = true end + if next(set) ~= nil then return set, true end + end + end + for id in pairs(Codex.V().quests) do + if Codex.QuestStatus(id) == "done" then set[id] = true end + end + local c = Char(false) + for id in pairs(c and c.neg or {}) do + if Codex.QuestStatus(id) == "done" then set[id] = true end + end + return set, false +end + +local function ToList(set) + local out = {} + for id in pairs(set) do out[#out + 1] = id end + table.sort(out) + return out +end + +------------------------------------------------------------------------ +-- Markieren +------------------------------------------------------------------------ + +-- Eine Quest als "noch nicht annehmbar" merken (src: "manual" oder "scan"). Rueckgabe: true, wenn neu +function Avail.Block(id, src) + id = tonumber(id) + local c = Char(true) + if not id or not c then return false end + if Codex.QuestStatus(id) ~= nil then return false end + local fresh = c.blocked[id] == nil + local lv = PlayerLevel() + local done = Avail.Completed() + c.blocked[id] = { lv = lv, t = Now(), n = #ToList(done), src = src or "manual" } + -- Schnappschuss fuer die spaetere Auswertung; die juengste Negativ-Beobachtung gilt (sie grenzt am engsten ein) + c.neg[id] = { lv = lv, t = Now(), done = ToList(done) } + FW:Fire("CODEX_AVAIL_CHANGED", id) + return fresh +end + +function Avail.Unblock(id) + local c = Char(false) + if c and c.blocked[id] then + c.blocked[id] = nil + FW:Fire("CODEX_AVAIL_CHANGED", id) + return true + end + return false +end + +function Avail.IsBlocked(id) + local c = Char(false) + return c ~= nil and c.blocked[id] ~= nil +end + +function Avail.BlockedCount() + local c = Char(false) + local n = 0 + for _ in pairs(c and c.blocked or {}) do n = n + 1 end + return n +end + +-- Liste { id, lv, t, src, title }, sortiert nach Titel +function Avail.List() + local out = {} + local c = Char(false) + local V = Codex.V() + for id, b in pairs(c and c.blocked or {}) do + out[#out + 1] = { id = id, lv = b.lv, t = b.t, src = b.src, title = Codex.QuestTitle(V.quests[id], id) } + end + table.sort(out, function(a, b) + if a.title ~= b.title then return a.title < b.title end + return a.id < b.id + end) + return out +end + +function Avail.Count() + local c = Char(false) + local n = 0 + for _ in pairs(c and c.blocked or {}) do n = n + 1 end + return n +end + +function Avail.ClearAll() + local c = Char(false) + if c then c.blocked = {} FW:Fire("CODEX_AVAIL_CHANGED") end +end + +-- Aenderung am Charakter (Stufe, abgegebene Quest): markierte Quests koennten jetzt frei sein und kommen zurueck +function Avail.Release() + local c = Char(false) + if not c or next(c.blocked) == nil then return 0 end + local lv = PlayerLevel() + local count = #ToList(Avail.Completed()) + local freed = 0 + for id, b in pairs(c.blocked) do + if lv > (b.lv or 0) or count > (b.n or 0) or Codex.QuestStatus(id) ~= nil then + c.blocked[id] = nil + freed = freed + 1 + end + end + if freed > 0 then FW:Fire("CODEX_AVAIL_CHANGED") end + return freed +end + +------------------------------------------------------------------------ +-- Auswerten (Positiv-Beobachtung) +------------------------------------------------------------------------ + +local function Bracket(q, lo, hi) + local mi = q.mi + if type(mi) == "table" and mi.lo and mi.hi then + local nlo, nhi = math.max(mi.lo, lo), math.min(mi.hi, hi) + if nhi > nlo then + q.mi = { lo = nlo, hi = nhi, n = (mi.n or 1) + 1 } + return + end + end + q.mi = { lo = lo, hi = hi, n = 1 } +end + +-- Die Quest ist jetzt verfuegbar (angeboten oder angenommen): Unterschied zum Zeitpunkt der Negativ-Beobachtung auswerten +function Avail.Observed(id) + local c = Char(false) + local neg = c and c.neg[id] + if not neg then return false end + c.neg[id] = nil + local V = Codex.V() + local q = Codex.db.quests[id] + if not q then + local title = V.quests[id] and V.quests[id].t + if not title then return false end -- ohne bekannten Titel laesst sich der Datensatz nicht teilen + q = Codex:Quest(id, true) + q.t = title + end + local now = Avail.Completed() + local before = {} + for _, p in ipairs(neg.done or {}) do before[p] = true end + local delta = {} + for p in pairs(now) do + if not before[p] and p ~= id then delta[#delta + 1] = p end + end + table.sort(delta) + local lv = PlayerLevel() + local changed = false + if #delta == 0 then + if lv > (neg.lv or lv) then + Bracket(q, neg.lv, lv) + changed = true + end + elseif #delta <= MAX_DELTA then + q.pi = q.pi or {} + q.po = (q.po or 0) + 1 + local n = 0 + for _ in pairs(q.pi) do n = n + 1 end + for _, p in ipairs(delta) do + if q.pi[p] or n < MAX_PI then + if not q.pi[p] then n = n + 1 end + q.pi[p] = (q.pi[p] or 0) + 1 + end + end + changed = true + end + if changed then + Codex.chainRev = (Codex.chainRev or 0) + 1 + FW:Fire("CODEX_CHANGED", "quest", id) + end + Avail.Unblock(id) + return changed +end + +-- Vorquests, die sich aus den Beobachtungen ergeben: Kandidaten, die in allen Beobachtungen vorkamen. +-- Fuer die eigene Beobachtung gilt schon eine, sonst braucht es zwei (own = true, wenn der Datensatz eigene Beobachtungen traegt) +function Avail.InferredPre(q, own) + local out = {} + if type(q) ~= "table" or type(q.pi) ~= "table" then return out end + local po = q.po or 0 + if po < (own and 1 or 2) then return out end + for p, n in pairs(q.pi) do + if n >= po then out[#out + 1] = p end + end + table.sort(out) + return out +end + +-- Mindeststufe aus den Beobachtungen: nur wenn der Bereich genau eine Stufe breit ist (und zwei Beobachtungen oder eigene) +function Avail.InferredMin(q, own) + local mi = type(q) == "table" and q.mi or nil + if type(mi) ~= "table" or not mi.lo or not mi.hi then return nil end + if mi.hi - mi.lo ~= 1 then return nil end + if (mi.n or 1) < (own and 1 or 2) then return nil end + return mi.hi +end + +------------------------------------------------------------------------ +-- Scan beim Geber +------------------------------------------------------------------------ + +-- Angebotene Quests des gerade angesprochenen NPCs oder Objekts: Menge von IDs und Menge von Titeln +local function ReadOffers() + local ids, titles = {}, {} + local any = false + local G = _G.C_GossipInfo + if G and G.GetAvailableQuests then + local ok, list = pcall(G.GetAvailableQuests) + if ok and type(list) == "table" then + any = true + for _, e in ipairs(list) do + if type(e) == "table" then + if type(e.questID) == "number" then ids[e.questID] = true end + if type(e.title) == "string" then titles[e.title] = true end + end + end + end + end + -- Quest-Begruessung (alte Form): nur Titel + if _G.GetNumAvailableQuests and _G.GetAvailableTitle then + local ok, n = pcall(_G.GetNumAvailableQuests) + if ok and type(n) == "number" and n > 0 then + any = true + for i = 1, n do + local ok2, t = pcall(_G.GetAvailableTitle, i) + if ok2 and type(t) == "string" then titles[t] = true end + end + end + end + return ids, titles, any +end +Avail.ReadOffers = ReadOffers + +-- Wer wird gerade angesprochen: kind ("npc"/"object"), id oder nil +local function Interaction() + local guid = _G.UnitGUID and _G.UnitGUID("npc") + if guid == nil then return nil end + local ok, kind, id = pcall(Codex.ParseGUID, guid) + if ok and kind then return kind, id end + return nil +end + +local function Probe(text) + if Avail.probe and Now() < Avail.probe then FW:Print("Probe: " .. text) end +end + +-- Angebotsliste ausgewertet: ids/titles = was angeboten wird +function Avail.ScanOffers(kind, npcID, ids, titles) + local V = Codex.V() + local scanned, blocked, seen = 0, 0, 0 + -- Positiv: angebotene Quests mit Negativ-Beobachtung + for id in pairs(ids) do + seen = seen + 1 + Avail.Observed(id) + Avail.Unblock(id) + end + -- Negativ: Quests dieses Gebers, die nach den Daten annehmbar waeren, aber nicht angeboten werden + if kind and npcID then + for id, q in pairs(V.quests) do + local g = q.give + if type(g) == "table" and g.id == npcID and g.k == kind then + scanned = scanned + 1 + local title = Codex.QuestTitle(q, id) + if not ids[id] and not titles[title] and Codex.QuestStatus(id) == nil + and not Avail.IsBlocked(id) and Codex.QuestAvailable(id) then + if Avail.Block(id, "scan") then blocked = blocked + 1 end + end + end + end + end + return seen, scanned, blocked +end + +function Avail:OnGossip() + local ok, err = pcall(function() + local kind, id = Interaction() + local ids, titles, any = ReadOffers() + if not any then + Probe("Gossip: keine Angebotsliste lesbar") + return + end + local n = 0 + for _ in pairs(ids) do n = n + 1 end + local t = 0 + for _ in pairs(titles) do t = t + 1 end + local seen, scanned, blocked = Avail.ScanOffers(kind, id, ids, titles) + Probe(string.format("Gossip: %s %s, angeboten %d (IDs) / %d (Titel), Quests dieses Gebers in den Daten %d, neu als 'noch nicht moeglich' %d", + tostring(kind), tostring(id), n, t, scanned, blocked)) + end) + if not ok then Probe("Fehler: " .. tostring(err)) end +end + +function Avail:OnQuestDetail() + pcall(function() + local id = tonumber(_G.GetQuestID and _G.GetQuestID()) + if id then + Avail.Observed(id) + Avail.Unblock(id) + Probe("Quest-Angebot " .. id) + end + end) +end + +function Avail:OnQuestAccepted(a, b) + pcall(function() + local id = tonumber(b) or tonumber(a) + if id then + Avail.Observed(id) + Avail.Unblock(id) + end + end) +end + +function Avail:OnChange() + pcall(Avail.Release) +end + +function Avail.Prune() + local c = Char(false) + if not c then return end + local now = Now() + for id, neg in pairs(c.neg) do + if now - (neg.t or 0) > NEG_KEEP then c.neg[id] = nil end + end +end + +function Avail:Start() + FW:RegisterEvent("GOSSIP_SHOW", self.OnGossip, self) + FW:RegisterEvent("QUEST_GREETING", self.OnGossip, self) + FW:RegisterEvent("QUEST_DETAIL", self.OnQuestDetail, self) + FW:RegisterEvent("QUEST_ACCEPTED", self.OnQuestAccepted, self) + FW:RegisterEvent("PLAYER_LEVEL_UP", self.OnChange, self) + FW:RegisterEvent("QUEST_TURNED_IN", self.OnChange, self) + C_Timer.After(6, function() Avail.Prune() Avail.Release() end) + -- Antworten auf RequestLoadQuestByID zaehlen (nur fuer den Titel-Test; Ereignis kann fehlen) + pcall(FW.RegisterEvent, FW, "QUEST_DATA_LOAD_RESULT", function() Avail.loadEvents = (Avail.loadEvents or 0) + 1 end, Avail) +end + +------------------------------------------------------------------------ +-- Test-Befehl: /fwquestprobe [QuestID] zeigt, was Forever liefert +------------------------------------------------------------------------ + +function Avail.RunProbe(id) + local function Say(t) FW:Print("Probe: " .. t) end + id = tonumber(id) or 96652 + local G, QL = _G.C_GossipInfo, _G.C_QuestLog + Say("C_GossipInfo.GetAvailableQuests: " .. tostring(G and G.GetAvailableQuests ~= nil)) + Say("GetNumAvailableQuests / GetAvailableTitle (Begruessung): " .. tostring(_G.GetNumAvailableQuests ~= nil) .. " / " .. tostring(_G.GetAvailableTitle ~= nil)) + Say("C_QuestLog.GetAllCompletedQuestIDs: " .. tostring(QL and QL.GetAllCompletedQuestIDs ~= nil)) + if QL and QL.GetAllCompletedQuestIDs then + local ok, list = pcall(QL.GetAllCompletedQuestIDs) + Say(" erledigte Quests laut Spiel: " .. (ok and type(list) == "table" and #list or ("Fehler " .. tostring(list)))) + end + Say("C_QuestLog.RequestLoadQuestByID: " .. tostring(QL and QL.RequestLoadQuestByID ~= nil) .. ", GetTitleForQuestID: " .. tostring(QL and QL.GetTitleForQuestID ~= nil)) + if QL and QL.GetTitleForQuestID then + local ok, t = pcall(QL.GetTitleForQuestID, id) + Say(" Titel zu " .. id .. " sofort: " .. tostring(ok and t)) + end + if QL and QL.RequestLoadQuestByID then + local ok, err = pcall(QL.RequestLoadQuestByID, id) + Say(" RequestLoadQuestByID(" .. id .. "): " .. (ok and "aufgerufen" or tostring(err))) + C_Timer.After(2, function() + local ok2, t2 = pcall(QL.GetTitleForQuestID, id) + Say(" Titel zu " .. id .. " nach 2 s: " .. tostring(ok2 and t2)) + end) + end + -- Was verraet das Spiel ueber eine geladene Quest-ID ausser dem Titel? Bekannte Abfragen einzeln versuchen. + C_Timer.After(3, function() + local found = 0 + local calls = { + { "C_QuestLog", "GetQuestDifficultyLevel" }, { "C_QuestLog", "GetQuestTagInfo" }, { "C_QuestLog", "GetQuestType" }, + { "C_QuestLog", "GetSuggestedGroupSize" }, { "C_QuestLog", "IsQuestFlaggedCompleted" }, { "C_QuestLog", "GetQuestObjectives" }, + { "C_QuestLog", "GetLogIndexForQuestID" }, { "C_QuestLog", "IsOnQuest" }, { "C_QuestLog", "IsQuestReplayable" }, + { "C_QuestLog", "GetQuestInfo" }, { "C_QuestLog", "GetZoneStoryInfo" }, { "C_QuestLog", "GetQuestLogRewardMoney" }, + { "C_QuestLog", "GetRequiredMoney" }, { "C_QuestLog", "IsFailed" }, { "C_QuestLog", "ReadyForTurnIn" }, + { "C_QuestLog", "GetQuestDetailsTheme" }, { "C_QuestLog", "IsQuestCalling" }, { "C_QuestLog", "IsQuestTrivial" }, + { "C_QuestLog", "GetQuestLevel" }, { "C_QuestLog", "GetMinLevel" }, { "C_QuestLog", "GetMaxLevel" }, + } + for _, c in ipairs(calls) do + local tbl = _G[c[1]] + local fn = tbl and tbl[c[2]] + if type(fn) == "function" then + local ok, a, b = pcall(fn, id) + local function Str(v) + if type(v) == "table" then + local parts, n = {}, 0 + for k, x in pairs(v) do + n = n + 1 + if n > 6 then parts[#parts + 1] = "..." break end + parts[#parts + 1] = tostring(k) .. "=" .. tostring(x) + end + return "{" .. table.concat(parts, ", ") .. "}" + end + return tostring(v) + end + if ok and a ~= nil then + found = found + 1 + Say(" " .. c[2] .. "(" .. id .. ") = " .. Str(a) .. (b ~= nil and (", " .. Str(b)) or "")) + elseif not ok then + Say(" " .. c[2] .. " Fehler: " .. tostring(a)) + end + end + end + Say(" weitere Angaben zur Quest " .. id .. ": " .. found .. " Abfragen mit Ergebnis") + end) + Avail.probe = Now() + 300 + Say("Die naechsten 5 Minuten wird bei jedem Gespraech mit einem NPC oder Objekt die Angebotsliste gemeldet.") + local kind, nid = Interaction() + if kind then Avail:OnGossip() end +end + +------------------------------------------------------------------------ +-- Titel-Test: /fwquesttitles [von] [bis] fragt Quest-Titel beim Server ab (gedrosselt, nur Anzeige, nichts wird gespeichert) +------------------------------------------------------------------------ + +local SCAN_MAX = 500 -- IDs je Lauf +local SCAN_BATCH = 20 -- Anfragen je Schub +local SCAN_PAUSE = 0.5 -- Sekunden zwischen den Schueben + +-- Rueckgabe: Liste { id, title } der gefundenen Titel (nach dem Lauf an callback(list, requested, events)) +function Avail.TitleScan(lo, hi, callback) + local QL = _G.C_QuestLog + if not (QL and QL.RequestLoadQuestByID and QL.GetTitleForQuestID) then return false, "api" end + lo, hi = tonumber(lo), tonumber(hi) + if not (lo and hi) or lo < 1 then return false, "range" end + if hi < lo then lo, hi = hi, lo end + if hi - lo + 1 > SCAN_MAX then hi = lo + SCAN_MAX - 1 end + if Avail.scanning then return false, "busy" end + Avail.scanning = true + local events0 = Avail.loadEvents or 0 + local before = {} + for id = lo, hi do + local ok, t = pcall(QL.GetTitleForQuestID, id) + if ok and type(t) == "string" and t ~= "" then before[id] = true end + end + local nextID, requested = lo, 0 + local function Step() + local stop = math.min(nextID + SCAN_BATCH - 1, hi) + for id = nextID, stop do + if pcall(QL.RequestLoadQuestByID, id) then requested = requested + 1 end + end + nextID = stop + 1 + if nextID <= hi then + C_Timer.After(SCAN_PAUSE, Step) + else + C_Timer.After(3, function() + local list = {} + for id = lo, hi do + local ok, t = pcall(QL.GetTitleForQuestID, id) + if ok and type(t) == "string" and t ~= "" then list[#list + 1] = { id = id, title = t, cached = before[id] or nil } end + end + Avail.scanning = false + Avail.lastScan = { lo = lo, hi = hi, list = list } + if callback then callback(list, requested, (Avail.loadEvents or 0) - events0, lo, hi) end + end) + end + end + Step() + return true, lo, hi +end + +SLASH_FREEWOODQUESTTITLES1 = "/fwquesttitles" +SlashCmdList["FREEWOODQUESTTITLES"] = function(msg) + local a, b = (msg or ""):match("(%d+)%D+(%d+)") + a, b = tonumber(a), tonumber(b) + if not a then + a = tonumber((msg or ""):match("%d+")) or 96600 + b = a + 99 + end + local ok, lo, hi = Avail.TitleScan(a, b, function(list, requested, events, l, h) + local V = Codex.V() + local known, new = 0, 0 + for _, e in ipairs(list) do + if V.quests[e.id] then known = known + 1 else new = new + 1 end + end + FW:Print(string.format("Titel-Test %d-%d: %d Anfragen, %d Antworten (Ereignis %d), davon %d dem Codex schon bekannt, %d neu", + l, h, requested, #list, events, known, new)) + for i = 1, math.min(#list, 12) do + local e = list[i] + FW:Print(string.format(" #%d %s%s", e.id, e.title, V.quests[e.id] and "" or " (neu)")) + end + if #list > 12 then FW:Print(" ... und " .. (#list - 12) .. " weitere") end + end) + if not ok then + FW:Print("Titel-Test nicht moeglich: " .. tostring(lo)) + else + FW:Print(string.format("Titel-Test %d-%d laeuft (gedrosselt, dauert etwa %d s) ...", lo, hi, math.ceil((hi - lo + 1) / SCAN_BATCH * SCAN_PAUSE) + 3)) + end +end + +SLASH_FREEWOODQUESTPROBE1 = "/fwquestprobe" +SlashCmdList["FREEWOODQUESTPROBE"] = function(msg) + Avail.RunProbe((msg or ""):match("%d+")) +end + +-- Markierungen verwalten: /fwblock (Liste), /fwblock (aufheben), /fwblock alle (alle aufheben) +SLASH_FREEWOODBLOCK1 = "/fwblock" +SlashCmdList["FREEWOODBLOCK"] = function(msg) + msg = (msg or ""):lower():gsub("^%s+", ""):gsub("%s+$", "") + local L = FW.L + if msg == "alle" or msg == "all" then + Avail.ClearAll() + FW:Print(L.CODEX_AVAIL_CLEARED) + return + end + local id = tonumber(msg:match("%d+")) + if id then + Avail.Unblock(id) + FW:Print(string.format(L.CODEX_AVAIL_UNBLOCKED, id)) + return + end + local list = Avail.List() + FW:Print(string.format(L.CODEX_AVAIL_LIST, #list)) + for _, e in ipairs(list) do + FW:Print(string.format(" %s (#%d, Stufe %s)", e.title, e.id, tostring(e.lv))) + end +end diff --git a/CodexCatalog.lua b/CodexCatalog.lua new file mode 100644 index 0000000..5e3756f --- /dev/null +++ b/CodexCatalog.lua @@ -0,0 +1,799 @@ +-- Freewood-OctoWoW-Codex: Quest-Katalog (Titel und Stufe aller Quest-IDs, die der Server kennt) +-- +-- Der Server liefert zu jeder Quest-ID auf Anfrage (C_QuestLog.RequestLoadQuestByID) den Titel und die Stufe +-- (GetTitleForQuestID, GetQuestDifficultyLevel), auch fuer Quests, die man nie gesehen hat. Der Katalog fragt die IDs +-- gedrosselt ab und merkt sich nur "ID gibt es" mit Titel und Stufe. Geber, Ort, Ziele und Vorquests liefert er nicht; +-- die lernt der Codex weiter beim Spielen. +-- +-- /fwquestkatalog setzt den Lauf dort fort, wo er aufgehoert hat (Quest-IDs 1 bis 120000) +-- /fwquestkatalog nur diesen Bereich +-- /fwquestkatalog stop|status|neu +-- /fwkatalog [von bis | stop | status | neu | test ] dasselbe fuer Quests (q), Items (i) und NPCs (n) +-- +-- Items: C_Item.RequestLoadItemDataByID, danach GetItemInfo (Qualitaet, Stufe, Mindeststufe, Verkaufspreis, Name). +-- NPCs: es gibt keine Abfrage nur mit ID; der Name kommt aus dem Tooltip eines Kreatur-Links (C_TooltipInfo.GetHyperlink). +-- +-- Quest-Einzelheiten (0.1.57): C_QuestLog.GetQuestDetails liefert aus derselben Antwort Ziele (Kreatur/Objekt/Item mit ID +-- und Anzahl), feste und Wahl-Belohnungen, Geld, Startitem, gelehrten Zauber, Typ, Flags und die Texte. Ablage in +-- db.cat.qd[id]. Nicht dabei (schickt der Server nicht): Geber, Abgabe, Vorquests, Rasse, Klasse, Zeitlimit. +-- /fwquestkatalog details holt die Einzelheiten fuer alle bekannten Quest-IDs nach +-- +-- Ablage: db.cat = { loc = Sprache, pos/posi/posn = naechste ID, q = { [id] = "Stufe|Titel" }, i = { [id] = "Qualitaet|Stufe|Mindeststufe|Preis|Name" }, n = { [id] = "Name" } }. Die Startdaten (Freewood-OctoWoW-ForeverData) +-- bringen einen fertigen Katalog mit (extra.cat); der eigene geht vor. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Cat = { rev = 0 } +Codex.Catalog = Cat +NS.Catalog = Cat + +local MAX_ID = 250000 -- Quests (Standard, wenn kein Bereich angegeben ist) +local BATCH = 10 -- Anfragen je Schub +local PAUSE = 1.0 -- Sekunden zwischen den Schueben (ca. 10 Anfragen/s) +local HARVEST = 4 -- Sekunden bis zum Einsammeln der Antworten eines Schubs + +local function Now() return _G.GetTime and _G.GetTime() or 0 end + +local function Store() + local db = Codex.db + if not db then return nil end + db.cat = db.cat or {} + local c = db.cat + c.q = c.q or {} + c.i = c.i or {} + c.n = c.n or {} + c.qd = c.qd or {} + c.c = c.c or {} + c.o = c.o or {} + if not c.loc then c.loc = _G.GetLocale and _G.GetLocale() or nil end + return c +end + +local function Parse(s) + if type(s) ~= "string" then return nil end + local lv, t = s:match("^(%d+)|(.+)$") + if not t then return nil end + lv = tonumber(lv) + return t, (lv and lv > 0) and lv or nil +end + +-- Mitgelieferter Katalog (Startdaten) +local function Builtin() + local f = Codex.Forever + if f and f.Disabled and f.Disabled() then return nil end + local g = _G.FreewoodForeverData + local ex = g and g.ready and type(g.extra) == "table" and g.extra.cat + if type(ex) == "table" and type(ex.q) == "table" then return ex end + return nil +end + +-- Titel, Stufe der Quest aus dem Katalog (eigener zuerst, dann die Startdaten) +function Cat.Get(id) + if type(id) ~= "number" then return nil end + local db = Codex.db + local own = db and db.cat and db.cat.q and db.cat.q[id] + local t, lv = Parse(own) + if t then return t, lv or (Codex.WdbLevel and Codex.WdbLevel[id]) end + local b = Builtin() + if b then + t, lv = Parse(b.q[id]) + if t then return t, lv or (Codex.WdbLevel and Codex.WdbLevel[id]) end + end + return nil +end + +-- Einzelheiten einer Quest (entpackt) oder nil: +-- { req = { { kind = "monster"|"object"|"item", id, count } }, rew = { { id, count } }, choice = { ... }, +-- money, src, spell, flags, qtype, poi = { map, x, y }, desc, obj, done, objText = { [Nr] = Text } } +local detCache = setmetatable({}, { __mode = "k" }) +local function Unpack(list) + local out = {} + if type(list) ~= "string" then return out end + for id, n in list:gmatch("(%d+)x(%d+)") do out[#out + 1] = { id = tonumber(id), count = tonumber(n) } end + return out +end + +function Cat.Details(id) + local db = Codex.db + local r = db and db.cat and db.cat.qd and db.cat.qd[id] + if type(r) ~= "table" then return nil end + local hit = detCache[r] + if hit then return hit end + local d = { req = {}, rew = Unpack(r.w), choice = Unpack(r.c), money = r.m, src = r.s, spell = r.sp, flags = r.f, + qtype = r.t, desc = r.d, obj = r.o, done = r.e, objText = {} } + if type(r.r) == "string" then + for code, rid, n in r.r:gmatch("(%a)(%d+)x(%d+)") do + rid = tonumber(rid) + -- Objekte kommen als negative Zahl (32 Bit) an: 2147483648 - ID + if code == "o" and rid > 1073741824 then rid = 2147483648 - rid end + d.req[#d.req + 1] = { kind = code == "m" and "monster" or (code == "o" and "object" or "item"), id = rid, count = tonumber(n) } + end + end + if type(r.ot) == "string" then + for nr, t in r.ot:gmatch("(%d+)=([^;]*)") do d.objText[tonumber(nr)] = t end + end + if type(r.p) == "string" then + local m, x, y = r.p:match("^(%d+):([%d%.]+):([%d%.]+)$") + if m then d.poi = { map = tonumber(m), x = tonumber(x), y = tonumber(y) } end + end + detCache[r] = d + return d +end + +function Cat.DetailCount() + local n = 0 + local db = Codex.db + for _ in pairs(db and db.cat and db.cat.qd or {}) do n = n + 1 end + return n +end + +-- Rueckverweise aus den Einzelheiten: { monster = { [id] = { questID, ... } }, object = ..., item = ... (Ziel-Item), +-- reward = ... (feste Belohnung oder Wahl), src = ... (Startitem) } - gebaut je Stand des Katalogs +local revIdx, revIdxRev +function Cat.Reverse() + if revIdx and revIdxRev == Cat.rev then return revIdx end + local idx = { monster = {}, object = {}, item = {}, reward = {}, src = {} } + local db = Codex.db + local function Add(group, id, qid) + local t = idx[group][id] + if not t then t = {} idx[group][id] = t end + if t[#t] ~= qid then t[#t + 1] = qid end + end + local ids = {} + for qid in pairs(db and db.cat and db.cat.qd or {}) do + if type(qid) == "number" then ids[#ids + 1] = qid end + end + table.sort(ids) + for _, qid in ipairs(ids) do + local d = Cat.Details(qid) + if d then + for _, r in ipairs(d.req) do Add(r.kind, r.id, qid) end + for _, it in ipairs(d.rew) do Add("reward", it.id, qid) end + for _, it in ipairs(d.choice) do Add("reward", it.id, qid) end + if d.src then Add("src", d.src, qid) end + end + end + revIdx, revIdxRev = idx, Cat.rev + return idx +end + +-- Stand des Katalogs (aendert sich mit neuen eigenen Titeln und mit dem Laden oder Abschalten der Startdaten) +function Cat.Rev() + local b = Builtin() + return Cat.rev .. ":" .. (b and tostring(b) or "-") +end + +-- Alle IDs des Katalogs (eigene und mitgelieferte), sortiert +local idCache, idRev +function Cat.IDs() + local rev = Cat.Rev() + if idCache and idRev == rev then return idCache end + local seen, out = {}, {} + local db = Codex.db + for id in pairs(db and db.cat and db.cat.q or {}) do + if type(id) == "number" then seen[id] = true out[#out + 1] = id end + end + local b = Builtin() + if b then + for id in pairs(b.q) do + if type(id) == "number" and not seen[id] then seen[id] = true out[#out + 1] = id end + end + end + table.sort(out) + idCache, idRev = out, rev + return out +end + +function Cat.Count() + local db = Codex.db + local own = 0 + for _ in pairs(db and db.cat and db.cat.q or {}) do own = own + 1 end + return own, #Cat.IDs() +end + +------------------------------------------------------------------------ +-- Arten: q = Quests, i = Items, n = NPCs +------------------------------------------------------------------------ + +local function CleanName(t) + t = tostring(t):gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""):gsub("|", "/") + return t +end + +local function ReqQuest(id) + local QL = _G.C_QuestLog + pcall(QL.RequestLoadQuestByID, id) +end + +local function ReadQuest(id) + local QL = _G.C_QuestLog + local ok, t = pcall(QL.GetTitleForQuestID, id) + if not (ok and type(t) == "string" and t ~= "") then return nil end + local lv = 0 + if QL.GetQuestDifficultyLevel then + local ok2, v = pcall(QL.GetQuestDifficultyLevel, id) + if ok2 and type(v) == "number" and v > 0 and v < 200 then lv = math.floor(v) end + end + return lv .. "|" .. CleanName(t) +end + +-- Einzelheiten einer Quest aus der Server-Antwort (nil, wenn nicht geladen). Kurze Schluessel, Listen als Text: +-- r = Ziele "m6x10;o1617x1;i182x1" (m Kreatur, o Objekt, i Item), w = feste Belohnung "60x1", c = Wahl "6076x1;60x1" +-- m = Geld, s = Startitem, sp = gelehrter Zauber, f = Flags, t = Typ, p = Kartenpunkt "Karte:x:y" +-- d = Beschreibung, o = Zieltext, e = Abschlusstext, ot = Zieltexte je Ziel "1=Text;2=Text" (nur wenn vorhanden) +local function Txt(v) + if type(v) ~= "string" or v == "" then return nil end + return (v:gsub("|", "/")) +end + +local function PairsText(list) + if type(list) ~= "table" then return nil end + local out = {} + for i = 1, #list do + local it = list[i] + if type(it) == "table" and tonumber(it.id) and it.id > 0 then + out[#out + 1] = math.floor(it.id) .. "x" .. math.floor(tonumber(it.count) or 1) + end + end + if #out == 0 then return nil end + return table.concat(out, ";") +end + +local KIND_CODE = { monster = "m", object = "o", item = "i" } + +local function ReadDetails(id) + local QL = _G.C_QuestLog + if not (QL and QL.GetQuestDetails) then return nil end + local ok, d = pcall(QL.GetQuestDetails, id) + if not (ok and type(d) == "table") then return nil end + local rec = {} + local req, ot = {}, {} + for i = 1, #(d.requirements or {}) do + local r = d.requirements[i] + local code = r and KIND_CODE[r.kind] + if code and tonumber(r.id) then + req[#req + 1] = code .. math.floor(r.id) .. "x" .. math.floor(tonumber(r.count) or 1) + local t = Txt(r.text) + if t then ot[#ot + 1] = #req .. "=" .. t:gsub(";", ",") end + end + end + if #req > 0 then rec.r = table.concat(req, ";") end + if #ot > 0 then rec.ot = table.concat(ot, ";") end + rec.w = PairsText(d.rewardItems) + rec.c = PairsText(d.choiceItems) + local function N(v) v = tonumber(v) return (v and v > 0) and math.floor(v) or nil end + rec.m = N(d.rewardMoney) + rec.s = N(d.srcItemID) + rec.sp = N(d.rewardSpellID) + rec.f = N(d.questFlags) + rec.t = Txt(d.questType) + if type(d.poi) == "table" and N(d.poi.mapID) then + rec.p = math.floor(d.poi.mapID) .. ":" .. string.format("%.3f", tonumber(d.poi.x) or 0) .. ":" .. string.format("%.3f", tonumber(d.poi.y) or 0) + end + rec.d = Txt(d.description) + rec.o = Txt(d.objectives) + rec.e = Txt(d.completionText) + return rec +end + +local function ItemInfo(id) + local CI = _G.C_Item + local get = (CI and CI.GetItemInfo) or _G.GetItemInfo + if not get then return nil end + return get(id) +end + +local function ReqItem(id) + local CI = _G.C_Item + if CI and CI.RequestLoadItemDataByID then pcall(CI.RequestLoadItemDataByID, id) + else pcall(ItemInfo, id) end +end + +local function ReadItem(id) + local ok, name, _, quality, ilvl, minLvl, _, _, _, _, _, sell = pcall(ItemInfo, id) + if not (ok and type(name) == "string" and name ~= "") then return nil end + local function N(v) return math.floor(tonumber(v) or 0) end + return string.format("%d|%d|%d|%d|%s", N(quality), N(ilvl), N(minLvl), N(sell), CleanName(name)) +end + +local function UnitLink(id) + return "unit:Creature-0-0-0-0-" .. id .. "-0000000000" +end + +local function NpcData(id) + local TI = _G.C_TooltipInfo + if not (TI and TI.GetHyperlink) then return nil end + local ok, data = pcall(TI.GetHyperlink, UnitLink(id)) + if ok and type(data) == "table" then return data end + return nil +end + +local function ReqNpc(id) NpcData(id) end + +local function ReadNpc(id) + local data = NpcData(id) + local line = data and data.lines and data.lines[1] + local name = line and line.leftText + if type(name) ~= "string" or name == "" then return nil end + return CleanName(name) +end + +-- Kreaturen und Objekte ueber die Server-Abfrage (ClassicAPI: C_CreatureInfo, C_GameObjectInfo). +-- Ablage: "Feld=Wert;Feld=Wert|Name|Untertitel" - alle einfachen Felder der Antwort werden mitgenommen +local function Pack(info) + local keys = {} + for k, v in pairs(info) do + local tv = type(v) + if k ~= "name" and k ~= "subName" and type(k) == "string" and (tv == "number" or tv == "boolean" or tv == "table") then + keys[#keys + 1] = k + end + end + table.sort(keys) + local parts = {} + for _, k in ipairs(keys) do + local v = info[k] + if type(v) == "number" then + parts[#parts + 1] = k .. "=" .. (v == math.floor(v) and string.format("%d", v) or string.format("%.3f", v)) + elseif type(v) == "boolean" then + parts[#parts + 1] = k .. "=" .. (v and "1" or "0") + else + local list = {} + for i = 1, 24 do + if type(v[i]) == "number" then list[#list + 1] = string.format("%d", math.floor(v[i])) else break end + end + if #list > 0 then parts[#parts + 1] = k .. "=" .. table.concat(list, ",") end + end + end + local sub = type(info.subName) == "string" and info.subName ~= "" and info.subName or "" + return table.concat(parts, ";") .. "|" .. CleanName(info.name) .. "|" .. CleanName(sub) +end + +local function Entity(kind, id) + local C = kind == "c" and _G.C_CreatureInfo or _G.C_GameObjectInfo + local get = C and (kind == "c" and C.GetCreatureInfoByID or C.GetGameObjectInfoByID) + if not get then return nil end + local ok, info = pcall(get, id) + if ok and type(info) == "table" and type(info.name) == "string" and info.name ~= "" then return info end + return nil +end + +local function ReqEntity(kind, id) + local C = kind == "c" and _G.C_CreatureInfo or _G.C_GameObjectInfo + local req = C and (kind == "c" and C.RequestLoadCreatureByID or C.RequestLoadGameObjectByID) + if req then pcall(req, id) end +end + +local function ReadEntity(kind, id) + local info = Entity(kind, id) + return info and Pack(info) or nil +end + +local KINDS = { + q = { store = "q", pos = "pos", max = MAX_ID, req = ReqQuest, read = ReadQuest, label = "Quests", + ready = function() local QL = _G.C_QuestLog return QL and QL.RequestLoadQuestByID and QL.GetTitleForQuestID end }, + i = { store = "i", pos = "posi", max = 250000, req = ReqItem, read = ReadItem, label = "Items", + ready = function() return (_G.C_Item and (_G.C_Item.GetItemInfo or _G.C_Item.RequestLoadItemDataByID)) or _G.GetItemInfo end }, + n = { store = "n", pos = "posn", max = 250000, req = ReqNpc, read = ReadNpc, label = "NPCs", + ready = function() return _G.C_TooltipInfo and _G.C_TooltipInfo.GetHyperlink end }, + c = { store = "c", pos = "posc", max = 150000, req = function(id) ReqEntity("c", id) end, read = function(id) return ReadEntity("c", id) end, + label = "Kreaturen", ready = function() local C = _G.C_CreatureInfo return C and C.RequestLoadCreatureByID and C.GetCreatureInfoByID end }, + o = { store = "o", pos = "poso", max = 250000, req = function(id) ReqEntity("o", id) end, read = function(id) return ReadEntity("o", id) end, + label = "Objekte", ready = function() local C = _G.C_GameObjectInfo return C and C.RequestLoadGameObjectByID and C.GetGameObjectInfoByID end }, +} +Cat.KINDS = KINDS + +-- Item aus dem Katalog: Qualitaet, Stufe, Mindeststufe, Verkaufspreis, Name +function Cat.GetItem(id) + local function P(s) + if type(s) ~= "string" then return nil end + local q, il, ml, sp, nm = s:match("^(%d+)|(%d+)|(%d+)|(%d+)|(.+)$") + if not nm then return nil end + return nm, tonumber(q), tonumber(il), tonumber(ml), tonumber(sp) + end + local db = Codex.db + local r = { P(db and db.cat and db.cat.i and db.cat.i[id]) } + if r[1] then return unpack(r) end + local b = Builtin() + return P(b and b.i and b.i[id]) +end + +-- NPC-Name aus dem Katalog +function Cat.GetNpc(id) + local db = Codex.db + local own = db and db.cat and db.cat.n and db.cat.n[id] + if type(own) == "string" then return own end + local b = Builtin() + local v = b and b.n and b.n[id] + return type(v) == "string" and v or nil +end + +------------------------------------------------------------------------ +-- Lauf +------------------------------------------------------------------------ + +local function Say(text) FW:Print("Katalog: " .. text) end + +local function CountOf(kind) + local c = Codex.db and Codex.db.cat + local n = 0 + for _ in pairs(c and c[KINDS[kind].store] or {}) do n = n + 1 end + return n +end + +local function Harvest(job, a, b) + local K = KINDS[job.kind] + local c = Store() + if c then + local tbl = c[K.store] + for i = a, b do + local id = job.ids and job.ids[i] or i + local entry = K.read(id) + if entry and tbl[id] ~= entry then + if not tbl[id] then job.found = job.found + 1 end + tbl[id] = entry + Cat.rev = Cat.rev + 1 + end + if job.kind == "q" and entry and not (c.qd and c.qd[id]) then + local det = ReadDetails(id) + if det then + c.qd[id] = det + job.details = (job.details or 0) + 1 + Cat.rev = Cat.rev + 1 + end + end + end + end + job.pending = job.pending - 1 + if job.nextID > job.hi and job.pending <= 0 and Cat.job == job then + Cat.job = nil + Say(string.format("%s fertig mit %d-%d: %d neue Eintraege, insgesamt %d eigene%s (%d Sekunden).", K.label, job.lo, job.hi, job.found, CountOf(job.kind), + job.kind == "q" and string.format(", %d neue Einzelheiten", job.details or 0) or "", math.floor(Now() - job.t0))) + FW:Fire("CODEX_CHANGED", "catalog") + if job.onDone then job.onDone() end + end +end + +local function Step(job) + if Cat.job ~= job then return end + if job.stop then + if job.pending <= 0 then Cat.job = nil end + return + end + if _G.InCombatLockdown and _G.InCombatLockdown() then + C_Timer.After(2, function() Step(job) end) + return + end + -- Bremse: bei niedriger Framerate, hoher Latenz oder zu vielen offenen + -- Schueben nicht nachlegen, sondern warten (der Client bleibt bedienbar) + local fps = _G.GetFramerate and _G.GetFramerate() or 60 + local lat = 0 + if _G.GetNetStats then + local _, _, home, world = _G.GetNetStats() + lat = math.max(tonumber(home) or 0, tonumber(world) or 0) + end + if fps < 15 or lat > 600 or job.pending >= 5 then + job.waits = (job.waits or 0) + 1 + C_Timer.After(fps < 15 and 4 or 2, function() Step(job) end) + return + end + local K = KINDS[job.kind] + local a = job.nextID + local b = math.min(a + BATCH - 1, job.hi) + for i = a, b do K.req(job.ids and job.ids[i] or i) end + job.nextID = b + 1 + job.pending = job.pending + 1 + local c = Store() + if job.resume and c then c[K.pos] = job.nextID end + C_Timer.After(HARVEST, function() Harvest(job, a, b) end) + -- Fortschritt alle 10000 IDs + if math.floor(a / 10000) ~= math.floor(b / 10000) then + Say(string.format("%s: ID %d von %d ...", K.label, b, job.hi)) + end + if job.nextID <= job.hi then + C_Timer.After(PAUSE, function() Step(job) end) + end +end + +-- Rueckgabe: true oder false, Grund ("api", "busy", "range") +function Cat.Start(lo, hi, resume, kind, ids) + kind = kind or "q" + local K = KINDS[kind] + if not K then return false, "kind" end + if not K.ready() then return false, "api" end + if Cat.job then return false, "busy" end + if not Store() then return false, "db" end + lo = math.max(1, math.floor(tonumber(lo) or 1)) + hi = math.min(ids and #ids or K.max, math.floor(tonumber(hi) or K.max)) + if hi < lo then return false, "range" end + local job = { kind = kind, lo = lo, hi = hi, nextID = lo, found = 0, pending = 0, t0 = Now(), resume = resume, ids = ids } + Cat.job = job + Step(job) + return true, lo, hi +end + +function Cat.Stop() + if Cat.job then Cat.job.stop = true end +end + +-- Lauf dort fortsetzen, wo er aufgehoert hat (fuer Befehl und Dev-Knopf). Rueckgabe wie Start; "done", wenn alles durch ist +function Cat.Resume(restart, kind) + kind = kind or "q" + local K = KINDS[kind] + local c = Store() + if restart and c then c[K.pos] = 1 end + local pos = c and c[K.pos] or 1 + if pos > K.max then return false, "done" end + return Cat.Start(pos, K.max, true, kind) +end + +-- Kreatur / Objekt aus dem Katalog: Name, Untertitel, Felder (Tabelle) - oder nil +local entCache = setmetatable({}, { __mode = "v" }) +function Cat.GetEntity(kind, id) + local db = Codex.db + local s = db and db.cat and db.cat[kind] and db.cat[kind][id] + if type(s) ~= "string" then return nil end + local hit = entCache[s] + if hit then return hit.name, hit.sub, hit.f end + local f, name, sub = s:match("^([^|]*)|([^|]*)|?(.*)$") + if not name then return nil end + local fields = {} + for k, v in f:gmatch("(%w+)=([^;]+)") do fields[k] = tonumber(v) or v end + entCache[s] = { name = name, sub = sub ~= "" and sub or nil, f = fields } + return name, sub ~= "" and sub or nil, fields +end +function Cat.GetCreature(id) return Cat.GetEntity("c", id) end +function Cat.GetObject(id) return Cat.GetEntity("o", id) end + +-- Ziele aus den Quest-Einzelheiten, die noch nicht im Katalog stehen +function Cat.TargetIDs(kind) + local rev = Cat.Reverse()[kind == "c" and "monster" or "object"] + local have = Codex.db and Codex.db.cat and Codex.db.cat[kind] or {} + local ids = {} + for id in pairs(rev) do + if not have[id] then ids[#ids + 1] = id end + end + table.sort(ids) + return ids +end + +-- Holt Kreaturen, danach Objekte, die in Quests als Ziel vorkommen. Rueckgabe wie Start +function Cat.StartTargets() + local cs, os = Cat.TargetIDs("c"), Cat.TargetIDs("o") + local function Objects() + if #os > 0 then Cat.Start(1, #os, false, "o", os) end + end + if #cs > 0 then + local ok, a = Cat.Start(1, #cs, false, "c", cs) + if ok then Cat.job.onDone = Objects return true, #cs + #os end + return false, a + end + if #os > 0 then + local ok, a = Cat.Start(1, #os, false, "o", os) + if ok then return true, #os end + return false, a + end + return false, "none" +end + +------------------------------------------------------------------------ +-- Aus dem Quest-Cache des Clients (CodexWdbChain.lua): Zone, Ruf-Ziel, Art +------------------------------------------------------------------------ +local SORTS = { + [-1] = { "Epic", "Episch" }, [-22] = { "Seasonal", "Saisonal" }, [-24] = { "Herbalism", "Kräuterkunde" }, + [-61] = { "Warlock", "Hexenmeister" }, [-81] = { "Warrior", "Krieger" }, [-82] = { "Shaman", "Schamane" }, + [-101] = { "Fishing", "Angeln" }, [-121] = { "Blacksmithing", "Schmiedekunst" }, [-141] = { "Paladin", "Paladin" }, + [-161] = { "Mage", "Magier" }, [-162] = { "Rogue", "Schurke" }, [-181] = { "Alchemy", "Alchemie" }, + [-182] = { "Leatherworking", "Lederverarbeitung" }, [-201] = { "Engineering", "Ingenieurskunst" }, + [-221] = { "Treasure map", "Schatzkarte" }, [-241] = { "Tournament", "Turnier" }, [-261] = { "Hunter", "Jäger" }, + [-262] = { "Priest", "Priester" }, [-263] = { "Druid", "Druide" }, [-264] = { "Tailoring", "Schneiderei" }, + [-284] = { "Special", "Besonderes" }, [-304] = { "Cooking", "Kochkunst" }, [-324] = { "First Aid", "Erste Hilfe" }, + [-344] = { "Legendary", "Legendär" }, [-364] = { "Darkmoon Faire", "Dunkelmond-Jahrmarkt" }, + [-365] = { "Ahn'Qiraj War", "Ahn'Qiraj-Krieg" }, [-366] = { "Lunar Festival", "Mondfest" }, + [-367] = { "Reputation", "Ruf" }, [-368] = { "Invasion", "Invasion" }, [-369] = { "Midsummer", "Sonnenwendfest" }, +} +local FACTIONS = { + [21] = "Booty Bay", [47] = "Ironforge", [54] = "Gnomeregan Exiles", [59] = "Thorium Brotherhood", [67] = "Horde", + [68] = "Undercity", [69] = "Darnassus", [70] = "Syndicate", [72] = "Stormwind", [76] = "Orgrimmar", + [81] = "Thunder Bluff", [87] = "Bloodsail Buccaneers", [92] = "Gelkis Clan Centaur", [93] = "Magram Clan Centaur", + [270] = "Zandalar Tribe", [349] = "Ravenholdt", [369] = "Gadgetzan", [470] = "Ratchet", [509] = "League of Arathor", + [510] = "The Defilers", [529] = "Argent Dawn", [530] = "Darkspear Trolls", [576] = "Timbermaw Hold", + [577] = "Everlook", [609] = "Cenarion Circle", [729] = "Frostwolf Clan", [730] = "Stormpike Guard", + [749] = "Hydraxian Waterlords", [809] = "Shen'dralar", [889] = "Warsong Outriders", [890] = "Silverwing Sentinels", + [910] = "Brood of Nozdormu", +} +local STANDINGS = { { 42000, "Exalted", "Ehrfürchtig" }, { 21000, "Revered", "Respektvoll" }, { 9000, "Honored", "Wohlwollend" }, + { 3000, "Friendly", "Freundlich" } } +local INFOS = { [1] = { "Group", "Gruppe" }, [41] = { "PvP", "PvP" }, [62] = { "Raid", "Schlachtzug" }, + [81] = { "Dungeon", "Dungeon" }, [82] = { "World event", "Weltereignis" } } + +local function Pick(t) if not t then return nil end return Codex.Lang() == "en" and t[1] or t[2] end + +local zoneNames = {} +function Cat.ZoneName(z) + if type(z) ~= "number" or z == 0 then return nil end + if z < 0 then return Pick(SORTS[z]) end + local n = zoneNames[z] + if n == nil then + n = false + local own = Codex.db and Codex.db.cat and Codex.db.cat.a and Codex.db.cat.a[z] + if type(own) == "string" then + n = own:match("|(.+)$") or own + end + local CM = _G.C_Map + if not n and CM and CM.GetAreaInfo then + local ok, name = pcall(CM.GetAreaInfo, z) + if ok and type(name) == "string" and name ~= "" then n = name end + end + if not n and CM and CM.GetMapInfo then + local ok, info = pcall(CM.GetMapInfo, z) + if ok and type(info) == "table" and type(info.name) == "string" then n = info.name end + end + zoneNames[z] = n + end + return n or nil +end + +-- Fraktionsname: erst die Spielfunktion (Spielsprache), sonst die feste Liste +local factionNames = {} +function Cat.FactionName(id) + local n = factionNames[id] + if n == nil then + n = false + local f = _G.GetFactionInfoByID or (_G.C_Reputation and _G.C_Reputation.GetFactionInfoByID) + if f then + local ok, name = pcall(f, id) + if ok and type(name) == "string" and name ~= "" then n = name end + end + if not n then + local ok, d = pcall(function() return _G.C_Reputation.GetFactionDataByID(id) end) + if ok and type(d) == "table" and type(d.name) == "string" then n = d.name end + end + factionNames[id] = n + end + return n or FACTIONS[id] +end + +-- { zone = Zonenname/Kategorie, zoneID, rep = "Name: Stufe", info = "Gruppe"/"Dungeon"/... } (nur was bekannt ist) +function Cat.QuestMeta(id) + local z = Codex.WdbZone and Codex.WdbZone[id] + local r = Codex.WdbRep and Codex.WdbRep[id] + local i = Codex.WdbInfo and Codex.WdbInfo[id] + if not (z or r or i) then return nil end + local m = { zoneID = z, zone = z and Cat.ZoneName(z) or nil, info = i and Pick(INFOS[i]) or nil } + if r then + local st + for _, s in ipairs(STANDINGS) do if r[2] >= s[1] then st = Codex.Lang() == "en" and s[2] or s[3] break end end + m.rep = (Cat.FactionName(r[1]) or ("#" .. r[1])) .. ": " .. (st or tostring(r[2])) + end + return m +end + +-- Quest-IDs des Katalogs, zu denen noch keine Einzelheiten gespeichert sind +function Cat.MissingDetails() + local have = Codex.db and Codex.db.cat and Codex.db.cat.qd or {} + local ids = {} + for _, id in ipairs(Cat.IDs()) do + if not have[id] then ids[#ids + 1] = id end + end + return ids +end + +-- Holt die Einzelheiten (Ziele, Belohnungen, Texte) nach. Rueckgabe: true, Anzahl | false, Grund ("none", "busy", "api" ...) +function Cat.StartDetails() + local ids = Cat.MissingDetails() + if #ids == 0 then return false, "none" end + local ok, a = Cat.Start(1, #ids, false, "q", ids) + if ok then return true, #ids end + return false, a +end + +-- Stand fuer die Anzeige: laeuft, naechste ID, letzte ID, eigene Eintraege, gesamt (nur Quests mit Startdaten) +function Cat.Progress(kind) + kind = kind or "q" + local K = KINDS[kind] + local c = Codex.db and Codex.db.cat + local own, all = CountOf(kind), CountOf(kind) + if kind == "q" then own, all = Cat.Count() end + local running = Cat.job ~= nil and not Cat.job.stop and Cat.job.kind == kind + return running, (Cat.job and Cat.job.kind == kind and Cat.job.nextID) or (c and c[K.pos]) or 1, K.max, own, all +end + +local function Status(kind) + local K = KINDS[kind] + local c = Codex.db and Codex.db.cat + local pos = c and c[K.pos] or 1 + local own, all = CountOf(kind), nil + if kind == "q" then own, all = Cat.Count() end + local line = string.format("%s: %d eigene Eintraege%s. Lauf bis ID %d von %d.", K.label, own, all and (", " .. all .. " im Katalog (mit Startdaten)") or "", math.min(pos, K.max), K.max) + if kind == "q" then line = line .. string.format(" Einzelheiten (Ziele, Belohnungen, Texte): %d.", Cat.DetailCount()) end + if Cat.job then line = line .. " Laeuft: " .. KINDS[Cat.job.kind].label .. " (ID " .. Cat.job.nextID .. ")." end + Say(line) +end + +-- Einzelne ID pruefen (zeigt die Rohantwort, speichert nichts) +local function Test(kind, id) + local K = KINDS[kind] + if not K.ready() then Say(K.label .. ": diese Abfrage bietet das Spiel nicht an.") return end + K.req(id) + C_Timer.After(2, function() + local entry = K.read(id) + Say(string.format("%s-Test ID %d: %s", K.label, id, entry or "keine Antwort (unbekannt oder noch nicht geladen)")) + end) + Say(string.format("%s-Test ID %d: Antwort in 2 Sekunden ...", K.label, id)) +end + +local function Handle(kind, msg) + msg = (msg or ""):lower():gsub("^%s+", ""):gsub("%s+$", "") + local K = KINDS[kind] + if msg == "stop" then + if Cat.job then Cat.Stop() Say("wird angehalten (der Stand bleibt erhalten).") else Say("es laeuft nichts.") end + return + elseif msg == "status" then + Status(kind) + return + end + if kind == "q" and msg == "details" then + local ok, n = Cat.StartDetails() + if ok then + Say(string.format("Einzelheiten fuer %d Quests werden geholt (ca. %d Sekunden). Anhalten: stop.", n, math.ceil(n / (BATCH / PAUSE)))) + elseif n == "none" then + Say("alle bekannten Quests haben schon Einzelheiten (" .. Cat.DetailCount() .. ").") + elseif n == "busy" then + Say("es laeuft schon ein Lauf (stop zum Anhalten).") + else + Say("nicht moeglich: " .. tostring(n)) + end + return + end + if (kind == "c" or kind == "o") and msg == "ziele" then + local ok, n = Cat.StartTargets() + if ok then + Say(string.format("%d Ziele aus Quests (Kreaturen, dann Objekte) werden geholt. Anhalten: stop.", n)) + elseif n == "none" then + Say("alle Ziele aus den Quests sind schon im Katalog.") + else + Say("nicht moeglich: " .. tostring(n)) + end + return + end + local tid = msg:match("^test%s+(%d+)$") + if tid then Test(kind, tonumber(tid)) return end + local lo, hi = msg:match("^(%d+)%D+(%d+)$") + local ok, a, b + if lo then + ok, a, b = Cat.Start(tonumber(lo), tonumber(hi), false, kind) + else + ok, a, b = Cat.Resume(msg == "neu", kind) + if a == "done" then + Say(K.label .. ": bis ID " .. K.max .. " durchgelaufen. Mit neu beginnt ein neuer Lauf von vorn (vorhandene Eintraege bleiben).") + return + end + end + if ok then + Say(string.format("%s: Lauf %d-%d gestartet (gedrosselt, ca. %d Anfragen pro Sekunde). Anhalten: stop. Du kannst weiterspielen.", + K.label, a, b, math.floor(BATCH / PAUSE))) + elseif a == "busy" then + Say("es laeuft schon ein Lauf (stop zum Anhalten).") + elseif a == "api" then + Say(K.label .. ": diese Abfrage bietet diese Version von Forever nicht an.") + else + Say("Bereich nicht moeglich.") + end +end + +SLASH_FREEWOODQUESTCAT1 = "/fwquestkatalog" +SlashCmdList["FREEWOODQUESTCAT"] = function(msg) Handle("q", msg) end + +SLASH_FREEWOODCAT1 = "/fwkatalog" +SlashCmdList["FREEWOODCAT"] = function(msg) + msg = (msg or ""):lower():gsub("^%s+", "") + local k, rest = msg:match("^(%a)%s*(.*)$") + if k == "q" or k == "i" or k == "n" or k == "c" or k == "o" then Handle(k, rest) return end + Say("benutze /fwkatalog q|i|n [von bis | stop | status | neu | test ] (q = Quests, i = Items, n = NPC-Namen, c = Kreaturen, o = Objekte); q details holt Ziele, Belohnungen und Texte nach; c/o ziele holt nur die Ziele aus den Quests") +end diff --git a/CodexFavUI.lua b/CodexFavUI.lua new file mode 100644 index 0000000..c7a8eef --- /dev/null +++ b/CodexFavUI.lua @@ -0,0 +1,103 @@ +-- Freewood-OctoWoW-Codex: Favoriten und Notizen sichern / uebertragen (Export und Import als Text) + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS.Codex +if not FW or not Codex or not FW.UI then + return +end + +local UI = FW.UI +local FavUI = {} +NS.FavUI = FavUI +Codex.FavUI = FavUI + +local win + +local function Build() + local L = FW.L + local w = UI.CreatePanel(UIParent, "bg", "gold") + w:SetSize(520, 190) + w:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + w:SetFrameStrata("DIALOG") + w:EnableMouse(true) + w:Hide() + + local title = UI.CreateLabel(w, L.CODEX_FAVX_TITLE, { font = "heading", size = 14, color = "goldLight" }) + title:SetPoint("TOPLEFT", w, "TOPLEFT", 16, -14) + local note = UI.CreateLabel(w, L.CODEX_FAVX_NOTE, { size = 11, color = "textDim", justifyV = "TOP" }) + note:SetPoint("TOPLEFT", w, "TOPLEFT", 16, -40) + note:SetPoint("TOPRIGHT", w, "TOPRIGHT", -16, -40) + note:SetHeight(44) + note:SetWordWrap(true) + + local box = UI.CreatePanel(w, "bgDeep", "bronze") + box:SetHeight(26) + box:SetPoint("TOPLEFT", w, "TOPLEFT", 16, -92) + box:SetPoint("TOPRIGHT", w, "TOPRIGHT", -16, -92) + local edit = CreateFrame("EditBox", nil, box) + edit:SetAutoFocus(false) + edit:SetPoint("TOPLEFT", box, "TOPLEFT", 8, -2) + edit:SetPoint("BOTTOMRIGHT", box, "BOTTOMRIGHT", -8, 2) + edit:SetMaxLetters(0) + if not edit:SetFont(UI.GetFontPath("body"), 12, "") then + edit:SetFont("Fonts\\FRIZQT__.TTF", 12, "") + end + edit:SetTextColor(UI.Color("text")) + edit:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + w.edit = edit + + local status = UI.CreateLabel(w, "", { size = 11, color = "textDim" }) + status:SetPoint("TOPLEFT", w, "TOPLEFT", 16, -126) + status:SetPoint("TOPRIGHT", w, "TOPRIGHT", -16, -126) + w.status = status + + local export = UI.CreateButton(w, L.CODEX_FAVX_EXPORT, 150, 26, function() + local text, n = Codex.FavExport() + edit:SetText(text) + edit:SetFocus() + edit:HighlightText() + status:SetText(string.format(L.CODEX_FAVX_EXPORTED, n)) + end, "default") + export:SetPoint("BOTTOMLEFT", w, "BOTTOMLEFT", 16, 14) + local import = UI.CreateButton(w, L.CODEX_FAVX_IMPORT, 150, 26, function() + local added, notes, bad = Codex.FavImport(edit:GetText()) + if not added then + status:SetText(L.CODEX_FAVX_BAD) + return + end + status:SetText(string.format(L.CODEX_FAVX_IMPORTED, added, notes, bad)) + FW:Fire("CODEX_CHANGED", "import") + end, "primary") + import:SetPoint("LEFT", export, "RIGHT", 8, 0) + local close = UI.CreateButton(w, L.CODEX_FAVX_CLOSE, 100, 26, function() w:Hide() end, "default") + close:SetPoint("BOTTOMRIGHT", w, "BOTTOMRIGHT", -16, 14) + w.exportButton, w.importButton = export, import + return w +end + +function FavUI.Open() + win = win or Build() + win.status:SetText("") + win:Show() + return win +end + +if UI.RegisterSettingsSection then + UI:RegisterSettingsSection("codex-fav", { + order = 107, + height = 110, + build = function(parent) + local L = FW.L + local f, stack = UI.CreateSection(parent, L.CODEX_FAVX_HEADER) + local note = UI.CreateLabel(f, L.CODEX_FAVX_SECTION_NOTE, { size = 11, color = "textDim", justifyV = "TOP" }) + stack:Add(note, { fit = true, gap = 8 }) + local open = UI.CreateButton(f, L.CODEX_FAVX_OPEN, 240, 26, function() FavUI.Open() end, "default") + stack:Add(open, { height = 26, gap = 10 }) + stack:Layout() + return f + end, + refresh = function() end, + }) +end